@the-open-engine/zeroshot 5.4.0

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