@covibes/zeroshot 5.4.0 → 5.4.1

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 (136) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +8 -465
  3. package/cli/index.js +101 -5231
  4. package/package.json +16 -130
  5. package/CHANGELOG.md +0 -537
  6. package/cli/commands/providers.js +0 -149
  7. package/cli/lib/first-run.js +0 -211
  8. package/cli/lib/update-checker.js +0 -281
  9. package/cli/message-formatter-utils.js +0 -75
  10. package/cli/message-formatters-normal.js +0 -275
  11. package/cli/message-formatters-watch.js +0 -181
  12. package/cluster-templates/base-templates/debug-workflow.json +0 -375
  13. package/cluster-templates/base-templates/full-workflow.json +0 -581
  14. package/cluster-templates/base-templates/single-worker.json +0 -63
  15. package/cluster-templates/base-templates/worker-validator.json +0 -208
  16. package/cluster-templates/conductor-bootstrap.json +0 -124
  17. package/docker/zeroshot-cluster/Dockerfile +0 -152
  18. package/docker/zeroshot-cluster/pre-baked-deps.json +0 -28
  19. package/lib/completion.js +0 -174
  20. package/lib/docker-config.js +0 -220
  21. package/lib/git-remote-utils.js +0 -165
  22. package/lib/id-detector.js +0 -56
  23. package/lib/provider-defaults.js +0 -62
  24. package/lib/provider-detection.js +0 -59
  25. package/lib/provider-names.js +0 -57
  26. package/lib/settings/claude-auth.js +0 -78
  27. package/lib/settings.js +0 -503
  28. package/lib/stream-json-parser.js +0 -10
  29. package/scripts/fix-node-pty-permissions.js +0 -75
  30. package/scripts/record-demo.sh +0 -279
  31. package/scripts/setup-merge-queue.sh +0 -170
  32. package/scripts/test-install.sh +0 -40
  33. package/scripts/validate-templates.js +0 -100
  34. package/src/agent/agent-config.js +0 -246
  35. package/src/agent/agent-context-builder.js +0 -444
  36. package/src/agent/agent-hook-executor.js +0 -599
  37. package/src/agent/agent-lifecycle.js +0 -743
  38. package/src/agent/agent-stuck-detector.js +0 -256
  39. package/src/agent/agent-task-executor.js +0 -1690
  40. package/src/agent/agent-trigger-evaluator.js +0 -67
  41. package/src/agent/output-extraction.js +0 -217
  42. package/src/agent/output-reformatter.js +0 -175
  43. package/src/agent/schema-utils.js +0 -146
  44. package/src/agent-wrapper.js +0 -579
  45. package/src/agents/git-pusher-template.js +0 -285
  46. package/src/attach/attach-client.js +0 -438
  47. package/src/attach/attach-server.js +0 -543
  48. package/src/attach/index.js +0 -35
  49. package/src/attach/protocol.js +0 -220
  50. package/src/attach/ring-buffer.js +0 -121
  51. package/src/attach/socket-discovery.js +0 -267
  52. package/src/claude-task-runner.js +0 -574
  53. package/src/config-router.js +0 -80
  54. package/src/config-validator.js +0 -1925
  55. package/src/copy-worker.js +0 -43
  56. package/src/input-helpers.js +0 -65
  57. package/src/isolation-manager.js +0 -1471
  58. package/src/issue-providers/README.md +0 -305
  59. package/src/issue-providers/azure-devops-provider.js +0 -273
  60. package/src/issue-providers/base-provider.js +0 -232
  61. package/src/issue-providers/github-provider.js +0 -179
  62. package/src/issue-providers/gitlab-provider.js +0 -241
  63. package/src/issue-providers/index.js +0 -196
  64. package/src/issue-providers/jira-provider.js +0 -239
  65. package/src/ledger.js +0 -631
  66. package/src/lib/safe-exec.js +0 -88
  67. package/src/logic-engine.js +0 -223
  68. package/src/message-bus-bridge.js +0 -139
  69. package/src/message-bus.js +0 -249
  70. package/src/name-generator.js +0 -232
  71. package/src/orchestrator.js +0 -2815
  72. package/src/preflight.js +0 -639
  73. package/src/process-metrics.js +0 -608
  74. package/src/providers/anthropic/cli-builder.js +0 -73
  75. package/src/providers/anthropic/index.js +0 -204
  76. package/src/providers/anthropic/models.js +0 -23
  77. package/src/providers/anthropic/output-parser.js +0 -177
  78. package/src/providers/base-provider.js +0 -251
  79. package/src/providers/capabilities.js +0 -60
  80. package/src/providers/google/cli-builder.js +0 -55
  81. package/src/providers/google/index.js +0 -116
  82. package/src/providers/google/models.js +0 -24
  83. package/src/providers/google/output-parser.js +0 -101
  84. package/src/providers/index.js +0 -91
  85. package/src/providers/openai/cli-builder.js +0 -133
  86. package/src/providers/openai/index.js +0 -136
  87. package/src/providers/openai/models.js +0 -21
  88. package/src/providers/openai/output-parser.js +0 -143
  89. package/src/providers/opencode/cli-builder.js +0 -42
  90. package/src/providers/opencode/index.js +0 -103
  91. package/src/providers/opencode/models.js +0 -55
  92. package/src/providers/opencode/output-parser.js +0 -122
  93. package/src/schemas/sub-cluster.js +0 -185
  94. package/src/status-footer.js +0 -1026
  95. package/src/sub-cluster-wrapper.js +0 -601
  96. package/src/task-runner.js +0 -30
  97. package/src/template-resolver.js +0 -372
  98. package/src/tui/CHANGES.txt +0 -133
  99. package/src/tui/LAYOUT.md +0 -261
  100. package/src/tui/README.txt +0 -192
  101. package/src/tui/TWO-LEVEL-NAVIGATION.md +0 -186
  102. package/src/tui/data-poller.js +0 -349
  103. package/src/tui/demo.js +0 -208
  104. package/src/tui/formatters.js +0 -129
  105. package/src/tui/index.js +0 -193
  106. package/src/tui/keybindings.js +0 -324
  107. package/src/tui/layout.js +0 -334
  108. package/src/tui/renderer.js +0 -184
  109. package/task-lib/attachable-watcher.js +0 -309
  110. package/task-lib/claude-recovery.js +0 -156
  111. package/task-lib/commands/clean.js +0 -50
  112. package/task-lib/commands/episodes.js +0 -105
  113. package/task-lib/commands/get-log-path.js +0 -23
  114. package/task-lib/commands/kill.js +0 -32
  115. package/task-lib/commands/list.js +0 -105
  116. package/task-lib/commands/logs.js +0 -453
  117. package/task-lib/commands/resume.js +0 -42
  118. package/task-lib/commands/run.js +0 -57
  119. package/task-lib/commands/schedule.js +0 -105
  120. package/task-lib/commands/scheduler-cmd.js +0 -96
  121. package/task-lib/commands/schedules.js +0 -148
  122. package/task-lib/commands/status.js +0 -44
  123. package/task-lib/commands/unschedule.js +0 -16
  124. package/task-lib/completion.js +0 -9
  125. package/task-lib/config.js +0 -8
  126. package/task-lib/name-generator.js +0 -230
  127. package/task-lib/package.json +0 -3
  128. package/task-lib/runner.js +0 -201
  129. package/task-lib/scheduler.js +0 -252
  130. package/task-lib/store.js +0 -529
  131. package/task-lib/tui/formatters.js +0 -215
  132. package/task-lib/tui/index.js +0 -197
  133. package/task-lib/tui/layout.js +0 -111
  134. package/task-lib/tui/renderer.js +0 -129
  135. package/task-lib/tui.js +0 -425
  136. package/task-lib/watcher.js +0 -226
@@ -1,1690 +0,0 @@
1
- // @ts-nocheck
2
- /**
3
- * AgentTaskExecutor - Claude CLI spawning and monitoring
4
- *
5
- * Provides:
6
- * - Claude CLI task spawning (normal and isolated modes)
7
- * - Log streaming and real-time output broadcasting
8
- * - Task lifecycle management (wait, kill)
9
- * - Output parsing and validation
10
- * - Vibe-specific Claude config with AskUserQuestion blocked
11
- */
12
-
13
- const { spawn } = require('child_process');
14
- const path = require('path');
15
- const fs = require('fs');
16
- const os = require('os');
17
- const { exec, execSync } = require('../lib/safe-exec'); // Enforces timeouts - prevents infinite hangs
18
- const { getProvider, parseChunkWithProvider } = require('../providers');
19
- const { getTask } = require('../../task-lib/store.js');
20
- const { loadSettings } = require('../../lib/settings.js');
21
- const { resolveClaudeAuth } = require('../../lib/settings/claude-auth.js');
22
-
23
- // Schema utilities for normalizing LLM output
24
- const { normalizeEnumValues } = require('./schema-utils');
25
-
26
- /**
27
- * Build Claude-specific environment variables for task spawning
28
- * Consolidates auth resolution and model mapping logic used by both isolated and non-isolated modes
29
- * @param {Object} modelSpec - Model specification from agent
30
- * @param {Object} [options] - Options
31
- * @param {boolean} [options.includeAuth=true] - Include auth env vars (false for isolated mode where IsolationManager handles auth)
32
- * @returns {Object} Environment variables to merge into spawn env
33
- */
34
- function buildClaudeEnv(modelSpec, options = {}) {
35
- const { includeAuth = true } = options;
36
- const env = {};
37
-
38
- if (includeAuth) {
39
- const settings = loadSettings();
40
- const authEnv = resolveClaudeAuth(settings);
41
- Object.assign(env, authEnv);
42
- }
43
-
44
- if (modelSpec?.model) {
45
- env.ANTHROPIC_MODEL = modelSpec.model;
46
- }
47
-
48
- // Activate AskUserQuestion blocking hook (see hooks/block-ask-user-question.py)
49
- env.ZEROSHOT_BLOCK_ASK_USER = '1';
50
-
51
- return env;
52
- }
53
-
54
- /**
55
- * Validate and sanitize error messages.
56
- * Detects TypeScript type annotations that may have leaked into error storage.
57
- *
58
- * @param {string|null} error - Error message to validate
59
- * @returns {string|null} Sanitized error or original if valid
60
- */
61
- function sanitizeErrorMessage(error) {
62
- if (!error) return null;
63
-
64
- // Patterns that look like TypeScript type annotations (not real error messages)
65
- const typeAnnotationPatterns = [
66
- /^string\s*\|\s*null$/i,
67
- /^number\s*\|\s*undefined$/i,
68
- /^boolean\s*\|\s*null$/i,
69
- /^any$/i,
70
- /^unknown$/i,
71
- /^void$/i,
72
- /^never$/i,
73
- /^[A-Z][a-zA-Z]*\s*\|\s*(?:null|undefined)$/, // e.g., "Error | null"
74
- ];
75
-
76
- const trimmedError = error.trim();
77
-
78
- // Check if it's a union type like "string | number | boolean" (ReDoS-safe approach)
79
- const unionParts = trimmedError.split(/\s*\|\s*/);
80
- const isUnionType = unionParts.length > 1 && unionParts.every((p) => /^[a-z]+$/i.test(p));
81
-
82
- for (const pattern of typeAnnotationPatterns) {
83
- if (pattern.test(trimmedError) || isUnionType) {
84
- console.warn(
85
- `[agent-task-executor] WARNING: Error message looks like a TypeScript type annotation: "${error}". ` +
86
- `This indicates corrupted data. Replacing with generic error.`
87
- );
88
- return `Task failed with corrupted error data (original: "${error}")`;
89
- }
90
- }
91
-
92
- return error;
93
- }
94
-
95
- /**
96
- * Extract error context from task output.
97
- * Shared by both isolated and non-isolated modes.
98
- *
99
- * @param {Object} params - Extraction parameters
100
- * @param {string} params.output - Full task output
101
- * @param {string} [params.statusOutput] - Status command output (non-isolated only)
102
- * @param {string} params.taskId - Task ID for error messages
103
- * @param {boolean} [params.isNotFound=false] - True if task was not found
104
- * @returns {string|null} Sanitized error context or null if extraction failed
105
- */
106
- function extractErrorContext({ output, statusOutput, taskId, isNotFound = false }) {
107
- // Task not found - explicit error
108
- if (isNotFound) {
109
- return sanitizeErrorMessage(`Task ${taskId} not found (may have crashed or been killed)`);
110
- }
111
-
112
- // Try status output first (only available in non-isolated mode)
113
- if (statusOutput) {
114
- const statusErrorMatch = statusOutput.match(/Error:\s*(.+)/);
115
- if (statusErrorMatch) {
116
- return sanitizeErrorMessage(statusErrorMatch[1].trim());
117
- }
118
- }
119
-
120
- // KNOWN CLAUDE CODE LIMITATIONS - detect and provide actionable guidance
121
- const fullOutput = output || '';
122
-
123
- // 256KB file limit error
124
- if (fullOutput.includes('exceeds maximum allowed size') || fullOutput.includes('256KB')) {
125
- return sanitizeErrorMessage(
126
- `FILE TOO LARGE (Claude Code 256KB limit). ` +
127
- `Use offset and limit parameters when reading large files. ` +
128
- `Example: Read tool with offset=0, limit=1000 to read first 1000 lines.`
129
- );
130
- }
131
-
132
- // Streaming mode error (interactive tools in non-interactive mode)
133
- if (fullOutput.includes('only prompt commands are supported in streaming mode')) {
134
- return sanitizeErrorMessage(
135
- `STREAMING MODE ERROR: Agent tried to use interactive tools in streaming mode. ` +
136
- `This usually happens with AskUserQuestion or interactive prompts. ` +
137
- `Zeroshot agents must run non-interactively.`
138
- );
139
- }
140
-
141
- // NEVER TRUNCATE OUTPUT - truncation corrupts structured JSON and causes false "crash" status
142
- // If output is too verbose, that's a prompt problem - fix the prompts, not the data
143
- const trimmedOutput = (output || '').trim();
144
- if (!trimmedOutput) {
145
- return sanitizeErrorMessage(
146
- 'Task failed with no output (check if task was interrupted or timed out)'
147
- );
148
- }
149
-
150
- // Try to extract structured JSON from output first - it may contain the actual result
151
- // even if the task was marked as "failed" due to timeout/stale status
152
- try {
153
- const { extractJsonFromOutput } = require('./output-extraction');
154
- const extracted = extractJsonFromOutput(trimmedOutput);
155
- if (extracted && typeof extracted === 'object') {
156
- // If we found valid JSON, return it as the error context
157
- // This preserves the actual agent output for downstream processing
158
- return JSON.stringify(extracted);
159
- }
160
- } catch {
161
- // Extraction failed, fall through to error pattern matching
162
- }
163
-
164
- // Extract non-JSON lines only (JSON lines contain "is_error": true which falsely matches)
165
- const nonJsonLines = trimmedOutput
166
- .split('\n')
167
- .filter((line) => {
168
- const trimmed = line.trim();
169
- // Skip JSON objects and JSON-like content
170
- return trimmed && !trimmed.startsWith('{') && !trimmed.startsWith('"');
171
- })
172
- .join('\n');
173
-
174
- // Common error patterns - match against non-JSON content
175
- const textToSearch = nonJsonLines || trimmedOutput;
176
- const errorPatterns = [
177
- /Error:\s*(.+)/i,
178
- /error:\s*(.+)/i,
179
- /failed:\s*(.+)/i,
180
- /Exception:\s*(.+)/i,
181
- /panic:\s*(.+)/i,
182
- ];
183
-
184
- for (const pattern of errorPatterns) {
185
- const match = textToSearch.match(pattern);
186
- if (match) {
187
- // Don't truncate - let the full error message through
188
- return sanitizeErrorMessage(match[1]);
189
- }
190
- }
191
-
192
- // No pattern matched - return full output (no truncation)
193
- // If this is too long, the solution is to make agents output less, not to corrupt data
194
- return sanitizeErrorMessage(`Task failed. Output: ${trimmedOutput}`);
195
- }
196
-
197
- // Track if we've already ensured the AskUserQuestion hook is installed
198
- let askUserQuestionHookInstalled = false;
199
-
200
- // Track if we've already ensured the dangerous git hook is installed
201
- let dangerousGitHookInstalled = false;
202
-
203
- /**
204
- * Extract token usage from NDJSON output.
205
- * Looks for the 'result' event line which contains usage data.
206
- *
207
- * @param {string} output - Full NDJSON output from Claude CLI
208
- * @returns {Object|null} Token usage data or null if not found
209
- */
210
- function extractTokenUsage(output, providerName = 'claude') {
211
- if (!output) return null;
212
-
213
- const provider = getProvider(providerName);
214
- const events = parseChunkWithProvider(provider, output);
215
- const resultEvent = events.find((event) => event.type === 'result');
216
-
217
- if (!resultEvent) {
218
- return null;
219
- }
220
-
221
- return {
222
- inputTokens: resultEvent.inputTokens || 0,
223
- outputTokens: resultEvent.outputTokens || 0,
224
- cacheReadInputTokens: resultEvent.cacheReadInputTokens || 0,
225
- cacheCreationInputTokens: resultEvent.cacheCreationInputTokens || 0,
226
- totalCostUsd: resultEvent.cost || null,
227
- durationMs: resultEvent.duration || null,
228
- modelUsage: resultEvent.modelUsage || null,
229
- };
230
- }
231
-
232
- /**
233
- * Ensure the AskUserQuestion blocking hook is installed in user's Claude config.
234
- * This adds defense-in-depth by blocking the tool at the Claude CLI level.
235
- * Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
236
- *
237
- * Safe to call multiple times - only modifies config once per process.
238
- */
239
- function ensureAskUserQuestionHook() {
240
- if (askUserQuestionHookInstalled) {
241
- return; // Already installed this session
242
- }
243
-
244
- const userClaudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
245
- const hooksDir = path.join(userClaudeDir, 'hooks');
246
- const settingsPath = path.join(userClaudeDir, 'settings.json');
247
- const hookScriptName = 'block-ask-user-question.py';
248
- const hookScriptDst = path.join(hooksDir, hookScriptName);
249
-
250
- // Ensure hooks directory exists
251
- if (!fs.existsSync(hooksDir)) {
252
- fs.mkdirSync(hooksDir, { recursive: true });
253
- }
254
-
255
- // Copy hook script if not present or outdated
256
- const hookScriptSrc = path.join(__dirname, '..', '..', 'hooks', hookScriptName);
257
- if (fs.existsSync(hookScriptSrc)) {
258
- // Always copy to ensure latest version
259
- fs.copyFileSync(hookScriptSrc, hookScriptDst);
260
- fs.chmodSync(hookScriptDst, 0o755);
261
- }
262
-
263
- // Read existing settings or create new
264
- let settings = {};
265
- if (fs.existsSync(settingsPath)) {
266
- try {
267
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
268
- } catch (e) {
269
- console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
270
- settings = {};
271
- }
272
- }
273
-
274
- // Ensure hooks structure exists
275
- if (!settings.hooks) {
276
- settings.hooks = {};
277
- }
278
- if (!settings.hooks.PreToolUse) {
279
- settings.hooks.PreToolUse = [];
280
- }
281
-
282
- // Check if AskUserQuestion hook already exists
283
- const hasHook = settings.hooks.PreToolUse.some(
284
- (entry) =>
285
- entry.matcher === 'AskUserQuestion' ||
286
- (entry.hooks && entry.hooks.some((h) => h.command && h.command.includes(hookScriptName)))
287
- );
288
-
289
- if (!hasHook) {
290
- // Add the hook
291
- settings.hooks.PreToolUse.push({
292
- matcher: 'AskUserQuestion',
293
- hooks: [
294
- {
295
- type: 'command',
296
- command: hookScriptDst,
297
- },
298
- ],
299
- });
300
-
301
- // Write updated settings
302
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
303
- console.log(`[AgentTaskExecutor] Installed AskUserQuestion blocking hook in ${settingsPath}`);
304
- }
305
-
306
- askUserQuestionHookInstalled = true;
307
- }
308
-
309
- /**
310
- * Ensure the dangerous git blocking hook is installed in user's Claude config.
311
- * This blocks dangerous git commands like stash, checkout --, reset --hard, etc.
312
- * Modifies ~/.claude/settings.json and copies hook script to ~/.claude/hooks/
313
- *
314
- * Only used in worktree mode - Docker isolation mode has its own git-safe.sh wrapper.
315
- * Safe to call multiple times - only modifies config once per process.
316
- */
317
- function ensureDangerousGitHook() {
318
- if (dangerousGitHookInstalled) {
319
- return; // Already installed this session
320
- }
321
-
322
- const userClaudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
323
- const hooksDir = path.join(userClaudeDir, 'hooks');
324
- const settingsPath = path.join(userClaudeDir, 'settings.json');
325
- const hookScriptName = 'block-dangerous-git.py';
326
- const hookScriptDst = path.join(hooksDir, hookScriptName);
327
-
328
- // Ensure hooks directory exists
329
- if (!fs.existsSync(hooksDir)) {
330
- fs.mkdirSync(hooksDir, { recursive: true });
331
- }
332
-
333
- // Copy hook script if not present or outdated
334
- const hookScriptSrc = path.join(__dirname, '..', '..', 'hooks', hookScriptName);
335
- if (fs.existsSync(hookScriptSrc)) {
336
- // Always copy to ensure latest version
337
- fs.copyFileSync(hookScriptSrc, hookScriptDst);
338
- fs.chmodSync(hookScriptDst, 0o755);
339
- }
340
-
341
- // Read existing settings or create new
342
- let settings = {};
343
- if (fs.existsSync(settingsPath)) {
344
- try {
345
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
346
- } catch (e) {
347
- console.warn(`[AgentTaskExecutor] Could not parse settings.json, creating new: ${e.message}`);
348
- settings = {};
349
- }
350
- }
351
-
352
- // Ensure hooks structure exists
353
- if (!settings.hooks) {
354
- settings.hooks = {};
355
- }
356
- if (!settings.hooks.PreToolUse) {
357
- settings.hooks.PreToolUse = [];
358
- }
359
-
360
- // Check if dangerous git hook already exists
361
- const hasHook = settings.hooks.PreToolUse.some(
362
- (entry) =>
363
- entry.matcher === 'Bash' &&
364
- entry.hooks &&
365
- entry.hooks.some((h) => h.command && h.command.includes(hookScriptName))
366
- );
367
-
368
- if (!hasHook) {
369
- // Add the hook - matches Bash tool to check for dangerous git commands
370
- settings.hooks.PreToolUse.push({
371
- matcher: 'Bash',
372
- hooks: [
373
- {
374
- type: 'command',
375
- command: hookScriptDst,
376
- },
377
- ],
378
- });
379
-
380
- // Write updated settings
381
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
382
- console.log(`[AgentTaskExecutor] Installed dangerous git blocking hook in ${settingsPath}`);
383
- }
384
-
385
- dangerousGitHookInstalled = true;
386
- }
387
-
388
- /**
389
- * Spawn claude-zeroshots process and stream output via message bus
390
- * @param {Object} agent - Agent instance
391
- * @param {String} context - Context to pass to Claude
392
- * @returns {Promise<Object>} Result object { success, output, error }
393
- */
394
- async function spawnClaudeTask(agent, context) {
395
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
396
- const modelSpec = resolveAgentModelSpec(agent);
397
-
398
- const ctPath = getClaudeTasksPath();
399
- const cwd = agent.config.cwd || process.cwd();
400
-
401
- // Build zeroshot task run args.
402
- // CRITICAL: Default to strict schema validation to prevent cluster crashes from parse failures
403
- // strictSchema=true uses Claude CLI's native --json-schema enforcement (no streaming but guaranteed structure)
404
- // strictSchema=false uses stream-json with post-run validation (live logs but fragile)
405
- const { desiredOutputFormat, runOutputFormat } = resolveOutputFormatConfig(agent);
406
- const args = buildTaskRunArgs({
407
- agent,
408
- providerName,
409
- modelSpec,
410
- runOutputFormat,
411
- });
412
-
413
- // NOTE: maxRetries is handled by the agent wrapper's internal retry loop,
414
- // not passed to the CLI. See _handleTrigger() for retry logic.
415
-
416
- maybeLogStreamJsonNotice(agent, runOutputFormat);
417
-
418
- // If schema enforcement is desired but we had to run stream-json for live logs,
419
- // add explicit output instructions so the model still knows the required shape.
420
- const finalContext = buildFinalContext({
421
- agent,
422
- context,
423
- desiredOutputFormat,
424
- runOutputFormat,
425
- });
426
-
427
- args.push(finalContext);
428
-
429
- // MOCK SUPPORT: Use injected mock function if provided
430
- if (agent.mockSpawnFn) {
431
- return agent.mockSpawnFn(args, { context });
432
- }
433
-
434
- // SAFETY: Fail hard if testMode=true but no mock (should be caught in constructor)
435
- if (agent.testMode) {
436
- throw new Error(
437
- `AgentWrapper: testMode=true but attempting real Claude API call for agent '${agent.id}'. ` +
438
- `This is a bug - mock should be set in constructor.`
439
- );
440
- }
441
-
442
- // ISOLATION MODE: Run inside Docker container
443
- if (agent.isolation?.enabled) {
444
- return spawnClaudeTaskIsolated(agent, context);
445
- }
446
-
447
- // NON-ISOLATION MODE: For Claude, use user's existing Claude config
448
- // AskUserQuestion blocking handled via:
449
- // 1. Prompt injection (see agent-context-builder)
450
- // 2. PreToolUse hook (defense-in-depth) - activated by ZEROSHOT_BLOCK_ASK_USER env var
451
- ensureProviderHooks(agent, providerName);
452
- const spawnEnv = buildSpawnEnv(agent, providerName, modelSpec);
453
- const taskId = await spawnTaskProcess({
454
- agent,
455
- ctPath,
456
- args,
457
- cwd,
458
- spawnEnv,
459
- });
460
-
461
- agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId}`);
462
-
463
- // Wait for task to be registered in zeroshot storage (race condition fix)
464
- await waitForTaskReady(agent, taskId);
465
-
466
- // CRITICAL: Poll for REAL process PID from task store
467
- // The watcher spawns the actual CLI and writes PID to SQLite asynchronously.
468
- // We must poll because the watcher runs in a forked process.
469
- const MAX_PID_POLLS = 30; // 3 seconds max
470
- const PID_POLL_DELAY = 100;
471
- let realPid = null;
472
-
473
- for (let i = 0; i < MAX_PID_POLLS; i++) {
474
- const taskInfo = getTask(taskId);
475
- if (taskInfo?.pid) {
476
- realPid = taskInfo.pid;
477
- break;
478
- }
479
- await new Promise((r) => setTimeout(r, PID_POLL_DELAY));
480
- }
481
-
482
- if (realPid) {
483
- agent.processPid = realPid;
484
- agent._publishLifecycle('PROCESS_SPAWNED', { pid: realPid });
485
- agent._log(`📋 Agent ${agent.id}: Process PID: ${realPid}`);
486
- } else {
487
- agent._log(`⚠️ Agent ${agent.id}: PID not available (task may use non-standard watcher)`);
488
- }
489
-
490
- // Now follow the logs and stream output
491
- return followClaudeTaskLogs(agent, taskId);
492
- }
493
-
494
- function resolveAgentModelSpec(agent) {
495
- return agent._resolveModelSpec ? agent._resolveModelSpec() : { model: agent._selectModel() };
496
- }
497
-
498
- function resolveOutputFormatConfig(agent) {
499
- // CRITICAL: Default to strict schema validation to prevent cluster crashes from parse failures
500
- // strictSchema=true uses Claude CLI's native --json-schema enforcement (no streaming but guaranteed structure)
501
- // strictSchema=false uses stream-json with post-run validation (live logs but fragile)
502
- const desiredOutputFormat = agent.config.outputFormat || 'json';
503
- const strictSchema = agent.config.strictSchema !== false; // DEFAULT TO TRUE
504
- const runOutputFormat =
505
- agent.config.jsonSchema && desiredOutputFormat === 'json' && !strictSchema
506
- ? 'stream-json'
507
- : desiredOutputFormat;
508
-
509
- return { desiredOutputFormat, strictSchema, runOutputFormat };
510
- }
511
-
512
- function buildTaskRunArgs({ agent, providerName, modelSpec, runOutputFormat }) {
513
- const args = ['task', 'run', '--output-format', runOutputFormat, '--provider', providerName];
514
-
515
- if (modelSpec?.model) {
516
- args.push('--model', modelSpec.model);
517
- }
518
-
519
- if (modelSpec?.reasoningEffort) {
520
- args.push('--reasoning-effort', modelSpec.reasoningEffort);
521
- }
522
-
523
- // Add verification mode flag if configured
524
- if (agent.config.verificationMode) {
525
- args.push('-v');
526
- }
527
-
528
- // Add JSON schema if specified in agent config.
529
- // If we are running stream-json for live logs (strictSchema=false), do NOT pass schema to CLI.
530
- if (agent.config.jsonSchema && runOutputFormat === 'json') {
531
- const schema = JSON.stringify(agent.config.jsonSchema);
532
- args.push('--json-schema', schema);
533
- }
534
-
535
- return args;
536
- }
537
-
538
- function maybeLogStreamJsonNotice(agent, runOutputFormat) {
539
- if (agent.config.jsonSchema && runOutputFormat !== 'json' && !agent.quiet) {
540
- agent._log(
541
- `[Agent ${agent.id}] jsonSchema configured; running stream-json for live logs (strictSchema=false). Schema will be validated after completion.`
542
- );
543
- }
544
- }
545
-
546
- function buildFinalContext({ agent, context, desiredOutputFormat, runOutputFormat }) {
547
- if (
548
- agent.config.jsonSchema &&
549
- desiredOutputFormat === 'json' &&
550
- runOutputFormat === 'stream-json'
551
- ) {
552
- return (
553
- context +
554
- `\n\n## Output Format (REQUIRED)\n\nReturn a JSON object that matches this schema exactly.\n\nSchema:\n\`\`\`json\n${JSON.stringify(
555
- agent.config.jsonSchema,
556
- null,
557
- 2
558
- )}\n\`\`\`\n`
559
- );
560
- }
561
-
562
- return context;
563
- }
564
-
565
- function ensureProviderHooks(agent, providerName) {
566
- if (providerName !== 'claude') {
567
- return;
568
- }
569
-
570
- ensureAskUserQuestionHook();
571
-
572
- // WORKTREE MODE: Install git safety hook (blocks dangerous git commands)
573
- if (agent.worktree?.enabled) {
574
- ensureDangerousGitHook();
575
- }
576
- }
577
-
578
- function buildSpawnEnv(agent, providerName, modelSpec) {
579
- const spawnEnv = { ...process.env };
580
-
581
- if (providerName === 'claude') {
582
- Object.assign(spawnEnv, buildClaudeEnv(modelSpec));
583
-
584
- // WORKTREE MODE: Activate git safety hook via environment variable
585
- if (agent.worktree?.enabled) {
586
- spawnEnv.ZEROSHOT_WORKTREE = '1';
587
- }
588
- }
589
-
590
- return spawnEnv;
591
- }
592
-
593
- function parseTaskIdFromOutput(stdout) {
594
- const match = stdout.match(/Task spawned: ((?:task-)?[a-z]+-[a-z]+-[a-z0-9]+)/);
595
- return match ? match[1] : null;
596
- }
597
-
598
- function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
599
- // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it
600
- const SPAWN_TIMEOUT_MS = 30000; // 30 seconds to spawn task
601
-
602
- return new Promise((resolve, reject) => {
603
- const proc = spawn(ctPath, args, {
604
- cwd,
605
- stdio: ['ignore', 'pipe', 'pipe'],
606
- env: spawnEnv,
607
- });
608
-
609
- // NOTE: Don't emit PROCESS_SPAWNED here - proc.pid is a wrapper that exits immediately.
610
- // Real PID comes from task store after watcher spawns the actual CLI process.
611
- // PROCESS_SPAWNED is emitted in spawnClaudeTask after waitForTaskReady + PID polling.
612
-
613
- let stdout = '';
614
- let stderr = '';
615
- let resolved = false;
616
-
617
- // CRITICAL: Timeout to prevent infinite hang if provider CLI hangs
618
- const spawnTimeout = setTimeout(() => {
619
- if (resolved) return;
620
- resolved = true;
621
- proc.kill('SIGKILL');
622
- reject(
623
- new Error(
624
- `Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
625
- `stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
626
- )
627
- );
628
- }, SPAWN_TIMEOUT_MS);
629
-
630
- proc.stdout.on('data', (data) => {
631
- stdout += data.toString();
632
- });
633
-
634
- proc.stderr.on('data', (data) => {
635
- stderr += data.toString();
636
- });
637
-
638
- proc.on('close', (code, signal) => {
639
- clearTimeout(spawnTimeout);
640
- if (resolved) return;
641
- resolved = true;
642
- // Handle process killed by signal (e.g., SIGTERM, SIGKILL, SIGSTOP)
643
- if (signal) {
644
- reject(new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`));
645
- return;
646
- }
647
-
648
- if (code === 0) {
649
- // Parse task ID from output: "✓ Task spawned: xxx-yyy-nn"
650
- // Format: <adjective>-<noun>-<digits> (may or may not have task- prefix)
651
- const spawnedTaskId = parseTaskIdFromOutput(stdout);
652
- if (spawnedTaskId) {
653
- agent.currentTaskId = spawnedTaskId; // Track for resume capability
654
- agent._publishLifecycle('TASK_ID_ASSIGNED', {
655
- pid: agent.processPid,
656
- taskId: spawnedTaskId,
657
- });
658
-
659
- // Start liveness monitoring
660
- if (agent.enableLivenessCheck) {
661
- agent.lastOutputTime = Date.now(); // Initialize to spawn time
662
- agent._startLivenessCheck();
663
- }
664
-
665
- resolve(spawnedTaskId);
666
- } else {
667
- reject(new Error(`Could not parse task ID from output: ${stdout}`));
668
- }
669
- } else {
670
- reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
671
- }
672
- });
673
-
674
- proc.on('error', (error) => {
675
- clearTimeout(spawnTimeout);
676
- if (resolved) return;
677
- resolved = true;
678
- reject(error);
679
- });
680
- });
681
- }
682
-
683
- /**
684
- * Wait for task to be registered in ct storage
685
- * @param {Object} agent - Agent instance
686
- * @param {String} taskId - Task ID to wait for
687
- * @param {Number} maxRetries - Max retries (default 10)
688
- * @param {Number} delayMs - Delay between retries (default 200)
689
- * @returns {Promise<void>}
690
- */
691
- async function waitForTaskReady(agent, taskId, maxRetries = 10, delayMs = 200) {
692
- const ctPath = getClaudeTasksPath();
693
-
694
- for (let i = 0; i < maxRetries; i++) {
695
- let exists = false;
696
- try {
697
- const { stdout } = await exec(`${ctPath} status ${taskId}`, { timeout: 5000 });
698
- exists = !stdout.includes('Task not found');
699
- } catch {
700
- // Timeout or error - task not ready yet
701
- }
702
-
703
- if (exists) return;
704
-
705
- // Wait before retry
706
- await new Promise((r) => setTimeout(r, delayMs));
707
- }
708
-
709
- // FAIL FAST: Task not found after retries = unrecoverable error
710
- // Continuing with a non-existent task causes 30s of pointless polling then crash
711
- throw new Error(
712
- `Task ${taskId} not found after ${maxRetries} retries (${maxRetries * delayMs}ms). ` +
713
- `Task spawn may have failed silently. Check zeroshot task run output.`
714
- );
715
- }
716
-
717
- const MAX_STATUS_FAILURES = 30;
718
-
719
- function createLogFollowState() {
720
- return {
721
- output: '',
722
- logFilePath: null,
723
- lastSize: 0,
724
- pollInterval: null,
725
- statusCheckInterval: null,
726
- resolved: false,
727
- lineBuffer: '',
728
- consecutiveExecFailures: 0,
729
- };
730
- }
731
-
732
- function lookupLogFilePath(ctPath, taskId) {
733
- try {
734
- return execSync(`${ctPath} get-log-path ${taskId}`, {
735
- encoding: 'utf-8',
736
- timeout: 5000,
737
- }).trim();
738
- } catch {
739
- return null;
740
- }
741
- }
742
-
743
- function parseTimestampedLine(line) {
744
- let timestamp = Date.now();
745
- let content = line.replace(/\r$/, '');
746
-
747
- const timestampMatch = content.match(/^\[(\d{13})\](.*)$/);
748
- if (timestampMatch) {
749
- timestamp = parseInt(timestampMatch[1], 10);
750
- content = timestampMatch[2];
751
- }
752
-
753
- return { timestamp, content };
754
- }
755
-
756
- function shouldSkipLogLine(content) {
757
- return (
758
- content.startsWith('===') ||
759
- content.startsWith('Finished:') ||
760
- content.startsWith('Exit code:') ||
761
- (content.includes('"type":"system"') && content.includes('"subtype":"init"'))
762
- );
763
- }
764
-
765
- function isValidJsonLine(content) {
766
- if (!content.trim().startsWith('{')) {
767
- return false;
768
- }
769
-
770
- try {
771
- JSON.parse(content);
772
- return true;
773
- } catch {
774
- return false;
775
- }
776
- }
777
-
778
- function broadcastAgentLine({ agent, providerName, state, line }) {
779
- if (!line.trim()) return;
780
-
781
- const { timestamp, content } = parseTimestampedLine(line);
782
- if (shouldSkipLogLine(content)) {
783
- return;
784
- }
785
-
786
- const isValidJson = isValidJsonLine(content);
787
- state.output += content + '\n';
788
-
789
- agent.lastOutputTime = Date.now();
790
-
791
- agent._publish({
792
- topic: 'AGENT_OUTPUT',
793
- receiver: 'broadcast',
794
- timestamp,
795
- content: {
796
- text: content,
797
- data: {
798
- type: isValidJson ? 'json' : 'text',
799
- line: content,
800
- agent: agent.id,
801
- role: agent.role,
802
- iteration: agent.iteration,
803
- provider: providerName,
804
- },
805
- },
806
- });
807
- }
808
-
809
- function appendContentToBuffer(state, content, onLine) {
810
- state.lineBuffer += content;
811
- const lines = state.lineBuffer.split('\n');
812
-
813
- for (let i = 0; i < lines.length - 1; i++) {
814
- onLine(lines[i]);
815
- }
816
-
817
- state.lineBuffer = lines[lines.length - 1];
818
- }
819
-
820
- function pollLogFileForUpdates({ agent, fsModule, ctPath, taskId, state, onNewContent }) {
821
- if (!state.logFilePath) {
822
- const logFilePath = lookupLogFilePath(ctPath, taskId);
823
- if (!logFilePath) {
824
- return;
825
- }
826
- state.logFilePath = logFilePath;
827
- agent._log(`📋 Agent ${agent.id}: Found log file: ${logFilePath}`);
828
- }
829
-
830
- if (!fsModule.existsSync(state.logFilePath)) {
831
- return;
832
- }
833
-
834
- try {
835
- const stats = fsModule.statSync(state.logFilePath);
836
- const currentSize = stats.size;
837
-
838
- if (currentSize > state.lastSize) {
839
- const fd = fsModule.openSync(state.logFilePath, 'r');
840
- const buffer = Buffer.alloc(currentSize - state.lastSize);
841
- fsModule.readSync(fd, buffer, 0, buffer.length, state.lastSize);
842
- fsModule.closeSync(fd);
843
-
844
- onNewContent(buffer.toString('utf-8'));
845
- state.lastSize = currentSize;
846
- }
847
- } catch (err) {
848
- const error = /** @type {Error} */ (err);
849
- console.warn(`⚠️ Agent ${agent.id}: Error reading log: ${error.message}`);
850
- }
851
- }
852
-
853
- function stripAnsiCodes(value) {
854
- const ansiPattern = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
855
- return value.replace(ansiPattern, '');
856
- }
857
-
858
- function parseStatusFlags(cleanStdout) {
859
- return {
860
- isCompleted: /Status:\s+completed/i.test(cleanStdout),
861
- isFailed: /Status:\s+failed/i.test(cleanStdout),
862
- isStale: /Status:\s+stale/i.test(cleanStdout),
863
- };
864
- }
865
-
866
- function determineStaleSuccess({ agent, output, providerName, taskId }) {
867
- if (!output) {
868
- return false;
869
- }
870
-
871
- const hasStructuredOutput = /"structured_output"\s*:/.test(output);
872
- const hasSuccessResult = /"subtype"\s*:\s*"success"/.test(output);
873
- let hasParsedOutput = false;
874
-
875
- try {
876
- const { extractJsonFromOutput } = require('./output-extraction');
877
- hasParsedOutput = !!extractJsonFromOutput(output, providerName);
878
- } catch {
879
- // Ignore extraction errors - fallback to other signals
880
- }
881
-
882
- const success = hasStructuredOutput || hasSuccessResult || hasParsedOutput;
883
- if (!agent.quiet) {
884
- agent._log(
885
- `[Agent ${agent.id}] Task ${taskId} is stale - recovered as ${success ? 'SUCCESS' : 'FAILURE'} based on output analysis`
886
- );
887
- }
888
-
889
- return success;
890
- }
891
-
892
- function finalizeLogFollow(agent, state) {
893
- if (state.pollInterval) {
894
- clearInterval(state.pollInterval);
895
- }
896
- if (state.statusCheckInterval) {
897
- clearInterval(state.statusCheckInterval);
898
- }
899
- agent.currentTask = null;
900
- }
901
-
902
- function handleStatusExecError({ agent, state, ctPath, taskId, error, stderr, resolve }) {
903
- if (!error) {
904
- return false;
905
- }
906
-
907
- state.consecutiveExecFailures++;
908
- if (state.consecutiveExecFailures < MAX_STATUS_FAILURES) {
909
- return true;
910
- }
911
-
912
- console.error(
913
- `[Agent ${agent.id}] ⚠️ Status polling failed ${MAX_STATUS_FAILURES} times consecutively! STOPPING.`
914
- );
915
- console.error(` Command: ${ctPath} status ${taskId}`);
916
- console.error(` Error: ${error.message}`);
917
- console.error(` Stderr: ${stderr || 'none'}`);
918
- console.error(` This may indicate zeroshot is not in PATH or task storage is corrupted.`);
919
-
920
- if (!state.resolved) {
921
- state.resolved = true;
922
- finalizeLogFollow(agent, state);
923
-
924
- agent._publish({
925
- topic: 'AGENT_ERROR',
926
- receiver: 'broadcast',
927
- content: {
928
- text: `Task ${taskId} polling failed after ${MAX_STATUS_FAILURES} consecutive failures`,
929
- data: {
930
- taskId,
931
- error: 'polling_timeout',
932
- attempts: state.consecutiveExecFailures,
933
- role: agent.role,
934
- iteration: agent.iteration,
935
- },
936
- },
937
- });
938
-
939
- resolve({
940
- success: false,
941
- output: state.output,
942
- error: `Status polling failed ${MAX_STATUS_FAILURES} times - task may not exist`,
943
- });
944
- }
945
-
946
- return true;
947
- }
948
-
949
- function handleStatusCompletion({
950
- agent,
951
- taskId,
952
- providerName,
953
- state,
954
- stdout,
955
- pollLogFile,
956
- resolve,
957
- }) {
958
- const cleanStdout = stripAnsiCodes(stdout);
959
- const { isCompleted, isFailed, isStale } = parseStatusFlags(cleanStdout);
960
-
961
- if (!isCompleted && !isFailed && !isStale) {
962
- return false;
963
- }
964
-
965
- pollLogFile();
966
-
967
- let success = isCompleted;
968
- if (isStale) {
969
- success = determineStaleSuccess({ agent, output: state.output, providerName, taskId });
970
- }
971
-
972
- setTimeout(() => {
973
- if (state.resolved) return;
974
- state.resolved = true;
975
-
976
- finalizeLogFollow(agent, state);
977
-
978
- const errorContext = !success
979
- ? extractErrorContext({ output: state.output, statusOutput: stdout, taskId })
980
- : null;
981
-
982
- resolve({
983
- success,
984
- output: state.output,
985
- error: errorContext,
986
- tokenUsage: extractTokenUsage(state.output, providerName),
987
- });
988
- }, 500);
989
-
990
- return true;
991
- }
992
-
993
- function buildKillHandler({ agent, state, providerName, resolve }) {
994
- return {
995
- kill: (reason = 'Task killed') => {
996
- if (state.resolved) return;
997
- state.resolved = true;
998
- finalizeLogFollow(agent, state);
999
- agent._stopLivenessCheck();
1000
- resolve({
1001
- success: false,
1002
- output: state.output,
1003
- error: reason,
1004
- tokenUsage: extractTokenUsage(state.output, providerName),
1005
- });
1006
- },
1007
- };
1008
- }
1009
-
1010
- function createLogFollower({ agent, taskId, fsModule, ctPath, providerName }) {
1011
- return new Promise((resolve) => {
1012
- const state = createLogFollowState();
1013
-
1014
- state.logFilePath = lookupLogFilePath(ctPath, taskId);
1015
- if (state.logFilePath) {
1016
- agent._log(`📋 Agent ${agent.id}: Following ct logs for ${taskId}`);
1017
- } else {
1018
- agent._log(`⏳ Agent ${agent.id}: Waiting for log file...`);
1019
- }
1020
-
1021
- const broadcastLine = (line) => broadcastAgentLine({ agent, providerName, state, line });
1022
- const processNewContent = (content) => appendContentToBuffer(state, content, broadcastLine);
1023
- const pollLogFile = () =>
1024
- pollLogFileForUpdates({
1025
- agent,
1026
- fsModule,
1027
- ctPath,
1028
- taskId,
1029
- state,
1030
- onNewContent: processNewContent,
1031
- });
1032
-
1033
- state.pollInterval = setInterval(pollLogFile, 300);
1034
-
1035
- state.statusCheckInterval = setInterval(() => {
1036
- exec(`${ctPath} status ${taskId}`, { timeout: 5000 }, (error, stdout, stderr) => {
1037
- if (state.resolved) return;
1038
-
1039
- if (handleStatusExecError({ agent, state, ctPath, taskId, error, stderr, resolve })) {
1040
- return;
1041
- }
1042
-
1043
- state.consecutiveExecFailures = 0;
1044
- handleStatusCompletion({
1045
- agent,
1046
- taskId,
1047
- providerName,
1048
- state,
1049
- stdout,
1050
- pollLogFile,
1051
- resolve,
1052
- });
1053
- });
1054
- }, 1000);
1055
-
1056
- agent.currentTask = buildKillHandler({ agent, state, providerName, resolve });
1057
- });
1058
- }
1059
-
1060
- /**
1061
- * Follow claude-zeroshots logs until completion, streaming to message bus
1062
- * Reads log file directly for reliable streaming
1063
- * @param {Object} agent - Agent instance
1064
- * @param {String} taskId - Task ID to follow
1065
- * @returns {Promise<Object>} Result object { success, output, error }
1066
- */
1067
- function followClaudeTaskLogs(agent, taskId) {
1068
- const fsModule = require('fs');
1069
- const ctPath = getClaudeTasksPath();
1070
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
1071
-
1072
- return createLogFollower({ agent, taskId, fsModule, ctPath, providerName });
1073
- }
1074
-
1075
- /**
1076
- * Get path to claude-zeroshots executable
1077
- * @returns {String} Path to zeroshot command
1078
- */
1079
- function getClaudeTasksPath() {
1080
- // Use zeroshot command (unified CLI)
1081
- return 'zeroshot'; // Assumes zeroshot is installed globally
1082
- }
1083
-
1084
- /**
1085
- * Spawn claude-zeroshots inside Docker container (isolation mode)
1086
- * Runs Claude CLI inside the container for full isolation
1087
- * @param {Object} agent - Agent instance
1088
- * @param {String} context - Context to pass to Claude
1089
- * @returns {Promise<Object>} Result object { success, output, error }
1090
- */
1091
- async function spawnClaudeTaskIsolated(agent, context) {
1092
- const { manager, clusterId } = agent.isolation;
1093
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
1094
- const modelSpec = agent._resolveModelSpec
1095
- ? agent._resolveModelSpec()
1096
- : { model: agent._selectModel() };
1097
-
1098
- agent._log(`📦 Agent ${agent.id}: Running task in isolated container using zeroshot task run...`);
1099
-
1100
- // Build zeroshot task run command (same infrastructure as non-isolation mode)
1101
- // CRITICAL: Default to strict schema validation to prevent cluster crashes from parse failures
1102
- const desiredOutputFormat = agent.config.outputFormat || 'json';
1103
- const strictSchema = agent.config.strictSchema !== false; // DEFAULT TO TRUE
1104
- const runOutputFormat =
1105
- agent.config.jsonSchema && desiredOutputFormat === 'json' && !strictSchema
1106
- ? 'stream-json'
1107
- : desiredOutputFormat;
1108
-
1109
- const command = [
1110
- 'zeroshot',
1111
- 'task',
1112
- 'run',
1113
- '--output-format',
1114
- runOutputFormat,
1115
- '--provider',
1116
- providerName,
1117
- ];
1118
-
1119
- if (modelSpec?.model) {
1120
- command.push('--model', modelSpec.model);
1121
- }
1122
-
1123
- if (modelSpec?.reasoningEffort) {
1124
- command.push('--reasoning-effort', modelSpec.reasoningEffort);
1125
- }
1126
-
1127
- // Add verification mode flag if configured
1128
- if (agent.config.verificationMode) {
1129
- command.push('-v');
1130
- }
1131
-
1132
- // Add JSON schema if specified in agent config
1133
- // If we are running stream-json for live logs (strictSchema=false), do NOT pass schema to CLI
1134
- if (agent.config.jsonSchema) {
1135
- if (runOutputFormat === 'json') {
1136
- // strictSchema=true OR no schema conflict: pass schema to CLI for native enforcement
1137
- const schema = JSON.stringify(agent.config.jsonSchema);
1138
- command.push('--json-schema', schema);
1139
- } else if (!agent.quiet) {
1140
- agent._log(
1141
- `[Agent ${agent.id}] jsonSchema configured; running stream-json for live logs (strictSchema=false). Schema will be validated after completion.`
1142
- );
1143
- }
1144
- }
1145
-
1146
- // Add explicit output instructions when we run stream-json for a jsonSchema agent
1147
- let finalContext = context;
1148
- if (
1149
- agent.config.jsonSchema &&
1150
- desiredOutputFormat === 'json' &&
1151
- runOutputFormat === 'stream-json'
1152
- ) {
1153
- finalContext += `\n\n## Output Format (REQUIRED)\n\nReturn a JSON object that matches this schema exactly.\n\nSchema:\n\`\`\`json\n${JSON.stringify(
1154
- agent.config.jsonSchema,
1155
- null,
1156
- 2
1157
- )}\n\`\`\`\n`;
1158
- }
1159
-
1160
- command.push(finalContext);
1161
-
1162
- // STEP 1: Spawn task and extract task ID (same as non-isolated mode)
1163
- // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it
1164
- const SPAWN_TIMEOUT_MS = 30000; // 30 seconds to spawn task
1165
- // Note: Auth env vars are injected by IsolationManager, we only need model mapping here
1166
- const isolatedEnv =
1167
- providerName === 'claude' ? buildClaudeEnv(modelSpec, { includeAuth: false }) : {};
1168
-
1169
- const taskId = await new Promise((resolve, reject) => {
1170
- const proc = manager.spawnInContainer(clusterId, command, {
1171
- env: isolatedEnv,
1172
- });
1173
-
1174
- // Track PID for resource monitoring
1175
- agent.processPid = proc.pid;
1176
- agent._publishLifecycle('PROCESS_SPAWNED', { pid: proc.pid });
1177
-
1178
- let stdout = '';
1179
- let stderr = '';
1180
- let resolved = false;
1181
-
1182
- // CRITICAL: Timeout to prevent infinite hang if provider CLI hangs
1183
- const spawnTimeout = setTimeout(() => {
1184
- if (resolved) return;
1185
- resolved = true;
1186
- proc.kill('SIGKILL');
1187
- reject(
1188
- new Error(
1189
- `Spawn timeout after ${SPAWN_TIMEOUT_MS / 1000}s - provider CLI hung. ` +
1190
- `stdout: ${stdout.slice(-500)}, stderr: ${stderr.slice(-500)}`
1191
- )
1192
- );
1193
- }, SPAWN_TIMEOUT_MS);
1194
-
1195
- proc.stdout.on('data', (data) => {
1196
- stdout += data.toString();
1197
- });
1198
-
1199
- proc.stderr.on('data', (data) => {
1200
- stderr += data.toString();
1201
- });
1202
-
1203
- proc.on('close', (code, signal) => {
1204
- clearTimeout(spawnTimeout);
1205
- if (resolved) return;
1206
- resolved = true;
1207
- // Handle process killed by signal
1208
- if (signal) {
1209
- reject(new Error(`Process killed by signal ${signal}${stderr ? `: ${stderr}` : ''}`));
1210
- return;
1211
- }
1212
-
1213
- if (code === 0) {
1214
- // Parse task ID from output: "✓ Task spawned: xxx-yyy-nn"
1215
- const match = stdout.match(/Task spawned: ((?:task-)?[a-z]+-[a-z]+-[a-z0-9]+)/);
1216
- if (match) {
1217
- const spawnedTaskId = match[1];
1218
- agent.currentTaskId = spawnedTaskId; // Track for resume capability
1219
- agent._publishLifecycle('TASK_ID_ASSIGNED', {
1220
- pid: agent.processPid,
1221
- taskId: spawnedTaskId,
1222
- });
1223
-
1224
- // Start liveness monitoring
1225
- if (agent.enableLivenessCheck) {
1226
- agent.lastOutputTime = Date.now(); // Initialize to spawn time
1227
- agent._startLivenessCheck();
1228
- }
1229
-
1230
- resolve(spawnedTaskId);
1231
- } else {
1232
- reject(new Error(`Could not parse task ID from output: ${stdout}`));
1233
- }
1234
- } else {
1235
- reject(new Error(`zeroshot task run failed with code ${code}: ${stderr}`));
1236
- }
1237
- });
1238
-
1239
- proc.on('error', (error) => {
1240
- clearTimeout(spawnTimeout);
1241
- if (resolved) return;
1242
- resolved = true;
1243
- reject(error);
1244
- });
1245
- });
1246
-
1247
- agent._log(`📋 Agent ${agent.id}: Following zeroshot logs for ${taskId} in container...`);
1248
-
1249
- // STEP 2: Follow the task's log file inside container (NOT the spawn stdout!)
1250
- return followClaudeTaskLogsIsolated(agent, taskId);
1251
- }
1252
-
1253
- /**
1254
- * Follow task logs inside Docker container (isolated mode)
1255
- * Reads task log file inside container and streams JSON lines to message bus
1256
- * @param {Object} agent - Agent instance with isolation context
1257
- * @param {String} taskId - Task ID to follow
1258
- * @returns {Promise<Object>} Result object
1259
- * @private
1260
- */
1261
- /**
1262
- * Follow Claude task logs in isolated container using persistent tail -f stream
1263
- * Issue #23: Persistent log streaming instead of polling (10-20% latency reduction)
1264
- *
1265
- * OLD APPROACH (removed):
1266
- * - Polled every 500ms with 2-3 docker exec calls per poll
1267
- * - Each docker exec = ~100-200ms overhead
1268
- * - Total: 300-400ms latency per poll cycle
1269
- *
1270
- * NEW APPROACH:
1271
- * - Single persistent `tail -f` stream via spawnInContainer()
1272
- * - Lines arrive in real-time as they're written
1273
- * - Status checks reduced to every 2 seconds (not every poll)
1274
- * - Result: 10-20% overall latency reduction
1275
- */
1276
- function createIsolatedLogState() {
1277
- return {
1278
- taskExited: false,
1279
- fullOutput: '',
1280
- tailProcess: null,
1281
- statusCheckInterval: null,
1282
- lineBuffer: '',
1283
- };
1284
- }
1285
-
1286
- function buildIsolatedCleanup(state) {
1287
- return () => {
1288
- if (state.tailProcess) {
1289
- try {
1290
- state.tailProcess.kill('SIGTERM');
1291
- } catch {
1292
- // Ignore - process may already be dead
1293
- }
1294
- state.tailProcess = null;
1295
- }
1296
- if (state.statusCheckInterval) {
1297
- clearInterval(state.statusCheckInterval);
1298
- state.statusCheckInterval = null;
1299
- }
1300
- };
1301
- }
1302
-
1303
- function broadcastIsolatedLine({ agent, providerName, taskId, line }) {
1304
- const timestampMatch = line.match(/^\[(\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*(.*)$/);
1305
- const timestamp = timestampMatch ? new Date(timestampMatch[1]).getTime() : Date.now();
1306
- const content = timestampMatch ? timestampMatch[2] : line;
1307
-
1308
- agent.messageBus.publish({
1309
- cluster_id: agent.cluster.id,
1310
- topic: 'AGENT_OUTPUT',
1311
- sender: agent.id,
1312
- content: {
1313
- data: {
1314
- line: content,
1315
- taskId,
1316
- iteration: agent.iteration,
1317
- provider: providerName,
1318
- },
1319
- },
1320
- timestamp,
1321
- });
1322
-
1323
- agent.lastOutputTime = Date.now();
1324
- }
1325
-
1326
- function appendIsolatedContent(state, content, onLine) {
1327
- state.lineBuffer += content;
1328
- const lines = state.lineBuffer.split('\n');
1329
-
1330
- for (let i = 0; i < lines.length - 1; i++) {
1331
- if (lines[i].trim()) {
1332
- onLine(lines[i]);
1333
- }
1334
- }
1335
-
1336
- state.lineBuffer = lines[lines.length - 1];
1337
- }
1338
-
1339
- function startIsolatedTail({ agent, manager, clusterId, logFilePath, state, onLine }) {
1340
- state.tailProcess = manager.spawnInContainer(clusterId, [
1341
- 'sh',
1342
- '-c',
1343
- `while [ ! -f "${logFilePath}" ]; do sleep 0.1; done; tail -F -n +1 "${logFilePath}"`,
1344
- ]);
1345
-
1346
- state.tailProcess.stdout.on('data', (data) => {
1347
- const chunk = data.toString();
1348
- state.fullOutput += chunk;
1349
- appendIsolatedContent(state, chunk, onLine);
1350
- });
1351
-
1352
- state.tailProcess.stderr.on('data', (data) => {
1353
- const msg = data.toString().trim();
1354
- if (msg && !msg.includes('file truncated')) {
1355
- agent._log(`[${agent.id}] tail stderr: ${msg}`);
1356
- }
1357
- });
1358
-
1359
- state.tailProcess.on('close', (exitCode) => {
1360
- if (!state.taskExited) {
1361
- agent._log(`[${agent.id}] tail process exited with code ${exitCode}`);
1362
- }
1363
- });
1364
-
1365
- state.tailProcess.on('error', (err) => {
1366
- agent._log(`[${agent.id}] tail process error: ${err.message}`);
1367
- });
1368
- }
1369
-
1370
- async function checkIsolatedStatus({
1371
- agent,
1372
- manager,
1373
- clusterId,
1374
- logFilePath,
1375
- taskId,
1376
- providerName,
1377
- state,
1378
- cleanup,
1379
- resolve,
1380
- onLine,
1381
- }) {
1382
- if (state.taskExited) return;
1383
-
1384
- const statusResult = await manager.execInContainer(clusterId, [
1385
- 'sh',
1386
- '-c',
1387
- `zeroshot status ${taskId} 2>/dev/null || echo "not_found"`,
1388
- ]);
1389
-
1390
- const statusOutput = statusResult.stdout;
1391
- const isSuccess = /Status:\s+completed/i.test(statusOutput);
1392
- const isError = /Status:\s+failed/i.test(statusOutput);
1393
- const isNotFound = statusOutput.includes('not_found');
1394
-
1395
- if (!isSuccess && !isError && !isNotFound) {
1396
- return;
1397
- }
1398
-
1399
- state.taskExited = true;
1400
- await new Promise((r) => setTimeout(r, 200));
1401
-
1402
- const finalReadResult = await manager.execInContainer(clusterId, [
1403
- 'sh',
1404
- '-c',
1405
- `cat "${logFilePath}" 2>/dev/null || echo ""`,
1406
- ]);
1407
-
1408
- if (finalReadResult.code === 0 && finalReadResult.stdout) {
1409
- state.fullOutput = finalReadResult.stdout;
1410
- const remainingLines = state.fullOutput.split('\n');
1411
- for (const line of remainingLines) {
1412
- if (line.trim()) {
1413
- onLine(line);
1414
- }
1415
- }
1416
- }
1417
-
1418
- cleanup();
1419
-
1420
- const success = isSuccess && !isError;
1421
- const errorContext = !success
1422
- ? extractErrorContext({ output: state.fullOutput, taskId, isNotFound })
1423
- : null;
1424
- const parsedResult = await agent._parseResultOutput(state.fullOutput);
1425
-
1426
- resolve({
1427
- success,
1428
- output: state.fullOutput,
1429
- taskId,
1430
- result: parsedResult,
1431
- error: errorContext,
1432
- tokenUsage: extractTokenUsage(state.fullOutput, providerName),
1433
- });
1434
- }
1435
-
1436
- function startIsolatedStatusChecks({
1437
- agent,
1438
- manager,
1439
- clusterId,
1440
- logFilePath,
1441
- taskId,
1442
- providerName,
1443
- state,
1444
- cleanup,
1445
- resolve,
1446
- onLine,
1447
- }) {
1448
- state.statusCheckInterval = setInterval(() => {
1449
- checkIsolatedStatus({
1450
- agent,
1451
- manager,
1452
- clusterId,
1453
- logFilePath,
1454
- taskId,
1455
- providerName,
1456
- state,
1457
- cleanup,
1458
- resolve,
1459
- onLine,
1460
- }).catch((statusErr) => {
1461
- agent._log(`[${agent.id}] Status check error (will retry): ${statusErr.message}`);
1462
- });
1463
- }, 2000);
1464
- }
1465
-
1466
- function followClaudeTaskLogsIsolated(agent, taskId) {
1467
- const { isolation } = agent;
1468
- if (!isolation?.manager) {
1469
- throw new Error('followClaudeTaskLogsIsolated: isolation manager not found');
1470
- }
1471
-
1472
- const manager = isolation.manager;
1473
- const clusterId = isolation.clusterId;
1474
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
1475
-
1476
- return new Promise((resolve, reject) => {
1477
- const state = createIsolatedLogState();
1478
- const cleanup = buildIsolatedCleanup(state);
1479
- const onLine = (line) => broadcastIsolatedLine({ agent, providerName, taskId, line });
1480
-
1481
- manager
1482
- .execInContainer(clusterId, ['sh', '-c', `zeroshot get-log-path ${taskId}`])
1483
- .then(({ stdout, stderr, code }) => {
1484
- if (code !== 0) {
1485
- cleanup();
1486
- return reject(
1487
- new Error(`Failed to get log path for ${taskId} inside container: ${stderr || stdout}`)
1488
- );
1489
- }
1490
-
1491
- const logFilePath = stdout.trim();
1492
- if (!logFilePath) {
1493
- cleanup();
1494
- return reject(new Error(`Empty log path returned for ${taskId}`));
1495
- }
1496
-
1497
- agent._log(`[${agent.id}] Following isolated task logs (streaming): ${logFilePath}`);
1498
-
1499
- startIsolatedTail({
1500
- agent,
1501
- manager,
1502
- clusterId,
1503
- logFilePath,
1504
- state,
1505
- onLine,
1506
- });
1507
-
1508
- startIsolatedStatusChecks({
1509
- agent,
1510
- manager,
1511
- clusterId,
1512
- logFilePath,
1513
- taskId,
1514
- providerName,
1515
- state,
1516
- cleanup,
1517
- resolve,
1518
- onLine,
1519
- });
1520
-
1521
- if (agent.timeout > 0) {
1522
- setTimeout(() => {
1523
- if (!state.taskExited) {
1524
- cleanup();
1525
- reject(new Error(`Task ${taskId} timeout after ${agent.timeout}ms (isolated mode)`));
1526
- }
1527
- }, agent.timeout);
1528
- }
1529
- })
1530
- .catch((err) => {
1531
- cleanup();
1532
- reject(err);
1533
- });
1534
- });
1535
- }
1536
-
1537
- /**
1538
- * Parse agent output to extract structured result data
1539
- * GENERIC - returns whatever structured output the agent provides
1540
- * Works with any agent schema (planner, validator, worker, etc.)
1541
- *
1542
- * Uses clean extraction pipeline from output-extraction.js
1543
- * Falls back to reformatting if extraction fails and schema is available
1544
- *
1545
- * @param {Object} agent - Agent instance
1546
- * @param {String} output - Raw output from agent
1547
- * @returns {Promise<Object>} Parsed result data
1548
- */
1549
- async function parseResultOutput(agent, output) {
1550
- // Empty or error outputs = FAIL
1551
- if (!output || output.includes('Task not found') || output.includes('Process terminated')) {
1552
- throw new Error('Task execution failed - no output');
1553
- }
1554
-
1555
- const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
1556
- const { extractJsonFromOutput } = require('./output-extraction');
1557
-
1558
- // Use clean extraction pipeline
1559
- let parsed = extractJsonFromOutput(output, providerName);
1560
-
1561
- // If extraction failed but we have a schema, attempt reformatting
1562
- if (!parsed && agent.config.jsonSchema) {
1563
- const { reformatOutput } = require('./output-reformatter');
1564
-
1565
- try {
1566
- parsed = await reformatOutput({
1567
- rawOutput: output,
1568
- schema: agent.config.jsonSchema,
1569
- providerName,
1570
- onAttempt: (attempt, lastError) => {
1571
- if (lastError) {
1572
- console.warn(`[Agent ${agent.id}] Reformat attempt ${attempt}: ${lastError}`);
1573
- } else {
1574
- console.warn(
1575
- `[Agent ${agent.id}] JSON extraction failed, reformatting (attempt ${attempt})...`
1576
- );
1577
- }
1578
- },
1579
- });
1580
- } catch (reformatError) {
1581
- // Reformatting failed - fall through to error below
1582
- console.error(`[Agent ${agent.id}] Reformatting failed: ${reformatError.message}`);
1583
- }
1584
- }
1585
-
1586
- if (!parsed) {
1587
- const trimmedOutput = output.trim();
1588
- console.error(`\n${'='.repeat(80)}`);
1589
- console.error(`🔴 AGENT OUTPUT MISSING REQUIRED JSON BLOCK`);
1590
- console.error(`${'='.repeat(80)}`);
1591
- console.error(`Agent: ${agent.id}, Role: ${agent.role}, Provider: ${providerName}`);
1592
- console.error(`Output (last 500 chars): ${trimmedOutput.slice(-500)}`);
1593
- console.error(`${'='.repeat(80)}\n`);
1594
- throw new Error(`Agent ${agent.id} output missing required JSON block`);
1595
- }
1596
-
1597
- // If a JSON schema is configured, validate parsed output locally.
1598
- // This preserves schema enforcement even when we run stream-json for live logs.
1599
- // IMPORTANT: For non-validator agents we warn but do not fail the cluster.
1600
- if (agent.config.jsonSchema) {
1601
- // Normalize enum values BEFORE validation (handles case mismatches, common variations)
1602
- // This is provider-agnostic - works for Claude CLI, Gemini, Codex, etc.
1603
- normalizeEnumValues(parsed, agent.config.jsonSchema);
1604
-
1605
- const Ajv = require('ajv');
1606
- const ajv = new Ajv({
1607
- allErrors: true,
1608
- strict: false,
1609
- coerceTypes: false, // STRICT: Reject type mismatches (e.g., null instead of array)
1610
- useDefaults: true,
1611
- removeAdditional: true,
1612
- });
1613
- const validate = ajv.compile(agent.config.jsonSchema);
1614
- const valid = validate(parsed);
1615
- if (!valid) {
1616
- const errorList = (validate.errors || [])
1617
- .slice(0, 5)
1618
- .map((e) => `${e.instancePath || e.schemaPath} ${e.message}`)
1619
- .join('; ');
1620
- const msg =
1621
- `Agent ${agent.id} output failed JSON schema validation: ` +
1622
- (errorList || 'unknown schema error');
1623
-
1624
- // Validators stay strict (they already have auto-approval fallback on crash).
1625
- if (agent.role === 'validator') {
1626
- throw new Error(msg);
1627
- }
1628
-
1629
- // Non-validators: emit warning and continue with best-effort parsed data.
1630
- console.warn(`⚠️ ${msg}`);
1631
- agent._publish({
1632
- topic: 'AGENT_SCHEMA_WARNING',
1633
- receiver: 'broadcast',
1634
- content: {
1635
- text: msg,
1636
- data: {
1637
- agent: agent.id,
1638
- role: agent.role,
1639
- iteration: agent.iteration,
1640
- errors: validate.errors || [],
1641
- },
1642
- },
1643
- });
1644
- }
1645
- }
1646
-
1647
- // Return whatever the agent produced - no hardcoded field requirements
1648
- // Template substitution will validate that required fields exist
1649
- return parsed;
1650
- }
1651
-
1652
- /**
1653
- * Kill current task
1654
- * @param {Object} agent - Agent instance
1655
- */
1656
- function killTask(agent) {
1657
- if (agent.currentTask) {
1658
- // currentTask may be either a ChildProcess or our custom { kill } object
1659
- if (typeof agent.currentTask.kill === 'function') {
1660
- agent.currentTask.kill('SIGTERM');
1661
- }
1662
- agent.currentTask = null;
1663
- }
1664
-
1665
- // Also kill the underlying zeroshot task if we have a task ID
1666
- // This ensures the task process is stopped, not just our polling intervals
1667
- if (agent.currentTaskId) {
1668
- const ctPath = getClaudeTasksPath();
1669
- exec(`${ctPath} task kill ${agent.currentTaskId}`, { timeout: 10000 }, (error) => {
1670
- if (error) {
1671
- // Task may have already completed or been killed, ignore errors
1672
- agent._log(`Note: Could not kill task ${agent.currentTaskId}: ${error.message}`);
1673
- } else {
1674
- agent._log(`Killed task ${agent.currentTaskId}`);
1675
- }
1676
- });
1677
- agent.currentTaskId = null;
1678
- }
1679
- }
1680
-
1681
- module.exports = {
1682
- ensureAskUserQuestionHook,
1683
- spawnClaudeTask,
1684
- followClaudeTaskLogs,
1685
- waitForTaskReady,
1686
- spawnClaudeTaskIsolated,
1687
- getClaudeTasksPath,
1688
- parseResultOutput,
1689
- killTask,
1690
- };