@desplega.ai/agent-swarm 1.20.0 → 1.51.2

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 (561) hide show
  1. package/README.md +271 -169
  2. package/openapi.json +5015 -0
  3. package/package.json +40 -7
  4. package/plugin/commands/close-issue.md +7 -3
  5. package/plugin/commands/create-pr.md +18 -12
  6. package/plugin/commands/implement-issue.md +7 -3
  7. package/plugin/commands/respond-github.md +8 -4
  8. package/plugin/commands/review-pr.md +44 -10
  9. package/plugin/commands/start-leader.md +1 -3
  10. package/plugin/commands/start-worker.md +1 -3
  11. package/plugin/commands/work-on-task.md +22 -3
  12. package/plugin/pi-skills/close-issue/SKILL.md +90 -0
  13. package/plugin/pi-skills/create-pr/SKILL.md +99 -0
  14. package/plugin/pi-skills/implement-issue/SKILL.md +135 -0
  15. package/plugin/pi-skills/investigate-sentry-issue/SKILL.md +138 -0
  16. package/plugin/pi-skills/respond-github/SKILL.md +98 -0
  17. package/plugin/pi-skills/review-offered-task/SKILL.md +45 -0
  18. package/plugin/pi-skills/review-pr/SKILL.md +261 -0
  19. package/plugin/pi-skills/start-leader/SKILL.md +121 -0
  20. package/plugin/pi-skills/start-worker/SKILL.md +60 -0
  21. package/plugin/pi-skills/swarm-chat/SKILL.md +82 -0
  22. package/plugin/pi-skills/todos/SKILL.md +66 -0
  23. package/plugin/pi-skills/work-on-task/SKILL.md +65 -0
  24. package/plugin/skills/artifacts/examples/approval-flow.ts +34 -0
  25. package/plugin/skills/artifacts/examples/hono-dashboard.ts +31 -0
  26. package/plugin/skills/artifacts/examples/multi-artifact.ts +20 -0
  27. package/plugin/skills/artifacts/examples/static-report.sh +17 -0
  28. package/plugin/skills/artifacts/skill.md +71 -0
  29. package/src/agentmail/app.ts +65 -0
  30. package/src/agentmail/handlers.ts +262 -0
  31. package/src/agentmail/index.ts +9 -0
  32. package/src/agentmail/templates.ts +111 -0
  33. package/src/agentmail/types.ts +51 -0
  34. package/src/artifact-sdk/browser-sdk.ts +30 -0
  35. package/src/artifact-sdk/index.ts +2 -0
  36. package/src/artifact-sdk/localtunnel.d.ts +20 -0
  37. package/src/artifact-sdk/port.ts +12 -0
  38. package/src/artifact-sdk/server.ts +156 -0
  39. package/src/artifact-sdk/tunnel.ts +19 -0
  40. package/src/be/chunking.ts +193 -0
  41. package/src/be/db-queries/oauth.ts +90 -0
  42. package/src/be/db-queries/tracker.ts +182 -0
  43. package/src/be/db.ts +3327 -784
  44. package/src/be/embedding.ts +80 -0
  45. package/src/be/migrations/001_initial.sql +409 -0
  46. package/src/be/migrations/002_one_time_schedules.sql +59 -0
  47. package/src/be/migrations/003_workflows.sql +51 -0
  48. package/src/be/migrations/004_workflow_source.sql +81 -0
  49. package/src/be/migrations/005_epic_next_steps.sql +2 -0
  50. package/src/be/migrations/006_vcs_provider.sql +94 -0
  51. package/src/be/migrations/007_task_dir.sql +2 -0
  52. package/src/be/migrations/008_workflow_redesign.sql +85 -0
  53. package/src/be/migrations/009_tracker_integration.sql +144 -0
  54. package/src/be/migrations/010_step_diagnostics.sql +1 -0
  55. package/src/be/migrations/011_step_next_port.sql +1 -0
  56. package/src/be/migrations/012_trigger_schema.sql +1 -0
  57. package/src/be/migrations/013_task_output_schema.sql +2 -0
  58. package/src/be/migrations/014_prompt_templates.sql +33 -0
  59. package/src/be/migrations/015_workflow_workspace.sql +3 -0
  60. package/src/be/migrations/016_active_session_runner_session.sql +4 -0
  61. package/src/be/migrations/017_channel_activity_cursors.sql +6 -0
  62. package/src/be/migrations/018_fix_seed_double_version.sql +30 -0
  63. package/src/be/migrations/runner.ts +188 -0
  64. package/src/be/seed.ts +62 -0
  65. package/src/cli.tsx +231 -299
  66. package/src/commands/artifact.ts +241 -0
  67. package/src/commands/onboard/compose-generator.ts +169 -0
  68. package/src/commands/onboard/env-generator.ts +79 -0
  69. package/src/commands/onboard/manifest.ts +37 -0
  70. package/src/commands/onboard/presets.ts +85 -0
  71. package/src/commands/onboard/service-names.ts +47 -0
  72. package/src/commands/onboard/steps/core-credentials.tsx +111 -0
  73. package/src/commands/onboard/steps/custom-templates.tsx +168 -0
  74. package/src/commands/onboard/steps/generate.tsx +154 -0
  75. package/src/commands/onboard/steps/harness-credentials.tsx +195 -0
  76. package/src/commands/onboard/steps/harness.tsx +21 -0
  77. package/src/commands/onboard/steps/health-check.tsx +171 -0
  78. package/src/commands/onboard/steps/integration-github.tsx +105 -0
  79. package/src/commands/onboard/steps/integration-gitlab.tsx +79 -0
  80. package/src/commands/onboard/steps/integration-menu.tsx +58 -0
  81. package/src/commands/onboard/steps/integration-sentry.tsx +79 -0
  82. package/src/commands/onboard/steps/integration-slack.tsx +165 -0
  83. package/src/commands/onboard/steps/post-connect.tsx +145 -0
  84. package/src/commands/onboard/steps/post-dashboard.tsx +34 -0
  85. package/src/commands/onboard/steps/post-task.tsx +103 -0
  86. package/src/commands/onboard/steps/prereq-check.tsx +178 -0
  87. package/src/commands/onboard/steps/review.tsx +82 -0
  88. package/src/commands/onboard/steps/start.tsx +97 -0
  89. package/src/commands/onboard/templates.ts +34 -0
  90. package/src/commands/onboard/types.ts +259 -0
  91. package/src/commands/onboard.tsx +425 -0
  92. package/src/commands/runner.ts +1540 -630
  93. package/src/commands/setup.tsx +23 -38
  94. package/src/commands/shared/client-config.ts +41 -0
  95. package/src/commands/templates.ts +172 -0
  96. package/src/github/app.ts +8 -0
  97. package/src/github/handlers.ts +384 -151
  98. package/src/github/index.ts +1 -0
  99. package/src/github/mentions-aliases.test.ts +73 -0
  100. package/src/github/mentions.test.ts +3 -3
  101. package/src/github/mentions.ts +32 -6
  102. package/src/github/templates.ts +398 -0
  103. package/src/github/types.ts +1 -0
  104. package/src/gitlab/auth.ts +63 -0
  105. package/src/gitlab/handlers.ts +368 -0
  106. package/src/gitlab/index.ts +19 -0
  107. package/src/gitlab/reactions.ts +104 -0
  108. package/src/gitlab/templates.ts +140 -0
  109. package/src/gitlab/types.ts +130 -0
  110. package/src/heartbeat/heartbeat.ts +434 -0
  111. package/src/heartbeat/index.ts +1 -0
  112. package/src/heartbeat/templates.ts +30 -0
  113. package/src/hooks/hook.ts +555 -4
  114. package/src/hooks/tool-loop-detection.test.ts +158 -0
  115. package/src/hooks/tool-loop-detection.ts +167 -0
  116. package/src/http/active-sessions.ts +199 -0
  117. package/src/http/agents.ts +328 -0
  118. package/src/http/config.ts +191 -0
  119. package/src/http/core.ts +309 -0
  120. package/src/http/db-query.ts +91 -0
  121. package/src/http/ecosystem.ts +63 -0
  122. package/src/http/epics.ts +460 -0
  123. package/src/http/index.ts +216 -0
  124. package/src/http/mcp.ts +77 -0
  125. package/src/http/memory.ts +168 -0
  126. package/src/http/openapi.ts +109 -0
  127. package/src/http/poll.ts +299 -0
  128. package/src/http/prompt-templates.ts +412 -0
  129. package/src/http/repos.ts +195 -0
  130. package/src/http/route-def.ts +123 -0
  131. package/src/http/schedules.ts +426 -0
  132. package/src/http/session-data.ts +241 -0
  133. package/src/http/stats.ts +174 -0
  134. package/src/http/tasks.ts +468 -0
  135. package/src/http/trackers/index.ts +10 -0
  136. package/src/http/trackers/linear.ts +187 -0
  137. package/src/http/types.ts +12 -0
  138. package/src/http/utils.ts +87 -0
  139. package/src/http/webhooks.ts +432 -0
  140. package/src/http/workflows.ts +530 -0
  141. package/src/http.ts +1 -1890
  142. package/src/linear/README.md +65 -0
  143. package/src/linear/app.ts +48 -0
  144. package/src/linear/client.ts +18 -0
  145. package/src/linear/index.ts +1 -0
  146. package/src/linear/oauth.ts +35 -0
  147. package/src/linear/outbound.ts +212 -0
  148. package/src/linear/sync.ts +567 -0
  149. package/src/linear/templates.ts +47 -0
  150. package/src/linear/types.ts +7 -0
  151. package/src/linear/webhook.ts +104 -0
  152. package/src/oauth/README.md +66 -0
  153. package/src/oauth/index.ts +6 -0
  154. package/src/oauth/wrapper.ts +204 -0
  155. package/src/prompts/base-prompt.ts +150 -265
  156. package/src/prompts/defaults.ts +196 -0
  157. package/src/prompts/registry.ts +57 -0
  158. package/src/prompts/resolver.ts +296 -0
  159. package/src/prompts/session-templates.ts +604 -0
  160. package/src/providers/claude-adapter.ts +442 -0
  161. package/src/providers/index.ts +24 -0
  162. package/src/providers/pi-mono-adapter.ts +442 -0
  163. package/src/providers/pi-mono-extension.ts +624 -0
  164. package/src/providers/pi-mono-mcp-client.ts +124 -0
  165. package/src/providers/types.ts +75 -0
  166. package/src/scheduler/scheduler.test.ts +2 -0
  167. package/src/scheduler/scheduler.ts +231 -40
  168. package/src/server.ts +97 -6
  169. package/src/slack/HEURISTICS.md +105 -0
  170. package/src/slack/actions.ts +133 -0
  171. package/src/slack/app.ts +7 -0
  172. package/src/slack/assistant.ts +118 -0
  173. package/src/slack/blocks.ts +233 -0
  174. package/src/slack/channel-activity.ts +177 -0
  175. package/src/slack/commands.ts +31 -17
  176. package/src/slack/files.ts +1 -1
  177. package/src/slack/handlers.test.ts +114 -1
  178. package/src/slack/handlers.ts +230 -55
  179. package/src/slack/responses.ts +120 -67
  180. package/src/slack/router.ts +17 -99
  181. package/src/slack/templates.ts +55 -0
  182. package/src/slack/thread-buffer.ts +213 -0
  183. package/src/slack/watcher.ts +119 -4
  184. package/src/tests/agent-activity.test.ts +247 -0
  185. package/src/tests/agentmail-filters.test.ts +97 -0
  186. package/src/tests/artifact-sdk.test.ts +800 -0
  187. package/src/tests/base-prompt.test.ts +264 -0
  188. package/src/tests/build-pi-skills.test.ts +127 -0
  189. package/src/tests/channel-activity.test.ts +363 -0
  190. package/src/tests/claude-adapter.test.ts +126 -0
  191. package/src/tests/context-versioning.test.ts +425 -0
  192. package/src/tests/db-queries-oauth.test.ts +197 -0
  193. package/src/tests/db-queries-tracker.test.ts +230 -0
  194. package/src/tests/epics.test.ts +3 -3
  195. package/src/tests/error-tracker.test.ts +368 -0
  196. package/src/tests/fetch-resolved-env.test.ts +167 -0
  197. package/src/tests/generate-default-claude-md.test.ts +9 -1
  198. package/src/tests/generate-identity-templates.test.ts +124 -0
  199. package/src/tests/gitlab-auth.test.ts +109 -0
  200. package/src/tests/gitlab-handlers.test.ts +691 -0
  201. package/src/tests/gitlab-vcs-db.test.ts +177 -0
  202. package/src/tests/heartbeat.test.ts +364 -0
  203. package/src/tests/http-api-integration.test.ts +1698 -0
  204. package/src/tests/linear-outbound-sync.test.ts +200 -0
  205. package/src/tests/linear-webhook.test.ts +406 -0
  206. package/src/tests/match-route.test.ts +187 -0
  207. package/src/tests/memory.test.ts +737 -0
  208. package/src/tests/migration-runner-regressions.test.ts +86 -0
  209. package/src/tests/model-control.test.ts +338 -0
  210. package/src/tests/oauth-wrapper.test.ts +147 -0
  211. package/src/tests/onboard-compose.test.ts +138 -0
  212. package/src/tests/onboard-env.test.ts +174 -0
  213. package/src/tests/onboard-manifest.test.ts +137 -0
  214. package/src/tests/pi-mono-adapter.test.ts +234 -0
  215. package/src/tests/pool-session-logs.test.ts +199 -0
  216. package/src/tests/progress-dedup.test.ts +98 -0
  217. package/src/tests/prompt-template-github.test.ts +682 -0
  218. package/src/tests/prompt-template-remaining.test.ts +504 -0
  219. package/src/tests/prompt-template-resolver.test.ts +621 -0
  220. package/src/tests/prompt-template-session.test.ts +363 -0
  221. package/src/tests/prompt-templates-db.test.ts +616 -0
  222. package/src/tests/provider-adapter.test.ts +122 -0
  223. package/src/tests/provider-command-format.test.ts +98 -0
  224. package/src/tests/reload-config.test.ts +170 -0
  225. package/src/tests/runner-polling-api.test.ts +25 -20
  226. package/src/tests/scheduled-tasks.test.ts +104 -0
  227. package/src/tests/scheduler-backoff.test.ts +166 -0
  228. package/src/tests/self-improvement.test.ts +541 -0
  229. package/src/tests/session-attach.test.ts +536 -0
  230. package/src/tests/session-costs.test.ts +267 -1
  231. package/src/tests/slack-actions.test.ts +133 -0
  232. package/src/tests/slack-assistant.test.ts +136 -0
  233. package/src/tests/slack-blocks.test.ts +246 -0
  234. package/src/tests/slack-metadata-inheritance.test.ts +243 -0
  235. package/src/tests/slack-queue-offline.test.ts +174 -0
  236. package/src/tests/slack-router.test.ts +181 -0
  237. package/src/tests/slack-thread-buffer.test.ts +305 -0
  238. package/src/tests/slack-thread-followups.test.ts +298 -0
  239. package/src/tests/slack-watcher.test.ts +101 -0
  240. package/src/tests/structured-output.test.ts +307 -0
  241. package/src/tests/swarm-repos.test.ts +198 -0
  242. package/src/tests/task-cancellation.test.ts +6 -4
  243. package/src/tests/task-working-dir.test.ts +176 -0
  244. package/src/tests/template-fetch.test.ts +490 -0
  245. package/src/tests/tool-annotations.test.ts +371 -0
  246. package/src/tests/tracker-tools.test.ts +184 -0
  247. package/src/tests/update-profile-agentid.test.ts +248 -0
  248. package/src/tests/update-profile-api.test.ts +143 -3
  249. package/src/tests/update-profile-auth.test.ts +195 -0
  250. package/src/tests/validation-adapters.test.ts +86 -0
  251. package/src/tests/vcs-provider.test.ts +27 -0
  252. package/src/tests/workflow-agent-task.test.ts +196 -0
  253. package/src/tests/workflow-async-v2.test.ts +508 -0
  254. package/src/tests/workflow-convergence.test.ts +541 -0
  255. package/src/tests/workflow-definition-validation.test.ts +366 -0
  256. package/src/tests/workflow-engine-v2.test.ts +691 -0
  257. package/src/tests/workflow-executors.test.ts +736 -0
  258. package/src/tests/workflow-http-v2.test.ts +599 -0
  259. package/src/tests/workflow-integration-io.test.ts +902 -0
  260. package/src/tests/workflow-io-schemas.test.ts +624 -0
  261. package/src/tests/workflow-registry.test.ts +592 -0
  262. package/src/tests/workflow-retry-v2.test.ts +401 -0
  263. package/src/tests/workflow-retry-validation.test.ts +282 -0
  264. package/src/tests/workflow-schedule-trigger.test.ts +104 -0
  265. package/src/tests/workflow-template.test.ts +288 -0
  266. package/src/tests/workflow-trigger-schema.test.ts +359 -0
  267. package/src/tests/workflow-triggers-v2.test.ts +264 -0
  268. package/src/tests/workflow-versions.test.ts +208 -0
  269. package/src/tests/workflow-workspace.test.ts +272 -0
  270. package/src/tests/x402-client.test.ts +117 -0
  271. package/src/tests/x402-config.test.ts +182 -0
  272. package/src/tests/x402-spending-tracker.test.ts +185 -0
  273. package/src/tools/cancel-task.ts +2 -0
  274. package/src/tools/context-diff.ts +171 -0
  275. package/src/tools/context-history.ts +138 -0
  276. package/src/tools/create-channel.ts +1 -0
  277. package/src/tools/db-query.ts +78 -0
  278. package/src/tools/delete-channel.ts +132 -0
  279. package/src/tools/epics/assign-task-to-epic.ts +1 -0
  280. package/src/tools/epics/create-epic.ts +3 -2
  281. package/src/tools/epics/delete-epic.ts +2 -0
  282. package/src/tools/epics/get-epic-details.ts +2 -0
  283. package/src/tools/epics/list-epics.ts +2 -0
  284. package/src/tools/epics/unassign-task-from-epic.ts +1 -0
  285. package/src/tools/epics/update-epic.ts +7 -4
  286. package/src/tools/get-swarm.ts +2 -0
  287. package/src/tools/get-task-details.ts +2 -0
  288. package/src/tools/get-tasks.ts +27 -1
  289. package/src/tools/inject-learning.ts +106 -0
  290. package/src/tools/join-swarm.ts +17 -7
  291. package/src/tools/list-channels.ts +2 -0
  292. package/src/tools/list-services.ts +2 -0
  293. package/src/tools/memory-get.ts +56 -0
  294. package/src/tools/memory-search.ts +131 -0
  295. package/src/tools/my-agent-info.ts +2 -0
  296. package/src/tools/poll-task.ts +2 -20
  297. package/src/tools/post-message.ts +1 -0
  298. package/src/tools/prompt-templates/delete.ts +86 -0
  299. package/src/tools/prompt-templates/get.ts +89 -0
  300. package/src/tools/prompt-templates/index.ts +5 -0
  301. package/src/tools/prompt-templates/list.ts +95 -0
  302. package/src/tools/prompt-templates/preview.ts +84 -0
  303. package/src/tools/prompt-templates/set.ts +117 -0
  304. package/src/tools/read-messages.ts +2 -0
  305. package/src/tools/register-agentmail-inbox.ts +166 -0
  306. package/src/tools/register-service.ts +2 -0
  307. package/src/tools/schedules/create-schedule.ts +134 -24
  308. package/src/tools/schedules/delete-schedule.ts +2 -0
  309. package/src/tools/schedules/list-schedules.ts +20 -4
  310. package/src/tools/schedules/run-schedule-now.ts +1 -0
  311. package/src/tools/schedules/update-schedule.ts +49 -17
  312. package/src/tools/send-task.ts +132 -10
  313. package/src/tools/slack-download-file.ts +4 -2
  314. package/src/tools/slack-list-channels.ts +2 -0
  315. package/src/tools/slack-post.ts +2 -0
  316. package/src/tools/slack-read.ts +2 -0
  317. package/src/tools/slack-reply.ts +2 -0
  318. package/src/tools/slack-upload-file.ts +2 -0
  319. package/src/tools/store-progress.ts +205 -4
  320. package/src/tools/swarm-config/delete-config.ts +87 -0
  321. package/src/tools/swarm-config/get-config.ts +108 -0
  322. package/src/tools/swarm-config/index.ts +4 -0
  323. package/src/tools/swarm-config/list-config.ts +99 -0
  324. package/src/tools/swarm-config/set-config.ts +118 -0
  325. package/src/tools/task-action.ts +50 -5
  326. package/src/tools/task-dedup.ts +97 -0
  327. package/src/tools/templates.ts +53 -0
  328. package/src/tools/tool-config.ts +124 -0
  329. package/src/tools/tracker/index.ts +6 -0
  330. package/src/tools/tracker/tracker-link-epic.ts +64 -0
  331. package/src/tools/tracker/tracker-link-task.ts +64 -0
  332. package/src/tools/tracker/tracker-map-agent.ts +57 -0
  333. package/src/tools/tracker/tracker-status.ts +56 -0
  334. package/src/tools/tracker/tracker-sync-status.ts +42 -0
  335. package/src/tools/tracker/tracker-unlink.ts +41 -0
  336. package/src/tools/unregister-service.ts +2 -0
  337. package/src/tools/update-profile.ts +172 -17
  338. package/src/tools/update-service-status.ts +2 -0
  339. package/src/tools/utils.ts +10 -1
  340. package/src/tools/workflows/create-workflow.ts +129 -0
  341. package/src/tools/workflows/delete-workflow.ts +42 -0
  342. package/src/tools/workflows/get-workflow-run.ts +59 -0
  343. package/src/tools/workflows/get-workflow.ts +53 -0
  344. package/src/tools/workflows/index.ts +9 -0
  345. package/src/tools/workflows/list-workflow-runs.ts +48 -0
  346. package/src/tools/workflows/list-workflows.ts +42 -0
  347. package/src/tools/workflows/retry-workflow-run.ts +40 -0
  348. package/src/tools/workflows/trigger-workflow.ts +96 -0
  349. package/src/tools/workflows/update-workflow.ts +133 -0
  350. package/src/tracker/types.ts +51 -0
  351. package/src/types.ts +530 -14
  352. package/src/utils/credentials.test.ts +156 -0
  353. package/src/utils/credentials.ts +50 -0
  354. package/src/utils/error-tracker.ts +190 -0
  355. package/src/vcs/index.ts +15 -0
  356. package/src/vcs/types.ts +5 -0
  357. package/src/workflows/checkpoint.ts +121 -0
  358. package/src/workflows/cooldown.ts +28 -0
  359. package/src/workflows/definition.ts +235 -0
  360. package/src/workflows/engine.ts +580 -0
  361. package/src/workflows/event-bus.ts +29 -0
  362. package/src/workflows/executors/agent-task.ts +103 -0
  363. package/src/workflows/executors/base.ts +86 -0
  364. package/src/workflows/executors/code-match.ts +88 -0
  365. package/src/workflows/executors/index.ts +16 -0
  366. package/src/workflows/executors/notify.ts +93 -0
  367. package/src/workflows/executors/property-match.ts +104 -0
  368. package/src/workflows/executors/raw-llm.ts +83 -0
  369. package/src/workflows/executors/registry.ts +76 -0
  370. package/src/workflows/executors/script.ts +103 -0
  371. package/src/workflows/executors/validate.ts +215 -0
  372. package/src/workflows/executors/vcs.ts +58 -0
  373. package/src/workflows/index.ts +61 -0
  374. package/src/workflows/input.ts +46 -0
  375. package/src/workflows/json-schema-validator.ts +118 -0
  376. package/src/workflows/recovery.ts +139 -0
  377. package/src/workflows/resume.ts +229 -0
  378. package/src/workflows/retry-poller.ts +216 -0
  379. package/src/workflows/template.ts +74 -0
  380. package/src/workflows/templates.ts +86 -0
  381. package/src/workflows/triggers.ts +124 -0
  382. package/src/workflows/validation.ts +104 -0
  383. package/src/workflows/version.ts +44 -0
  384. package/src/x402/cli.ts +140 -0
  385. package/src/x402/client.ts +192 -0
  386. package/src/x402/config.ts +131 -0
  387. package/src/x402/index.ts +37 -0
  388. package/src/x402/openfort-signer.ts +83 -0
  389. package/src/x402/spending-tracker.ts +109 -0
  390. package/templates/official/coder/CLAUDE.md +49 -0
  391. package/templates/official/coder/IDENTITY.md +28 -0
  392. package/templates/official/coder/SOUL.md +43 -0
  393. package/templates/official/coder/TOOLS.md +40 -0
  394. package/templates/official/coder/config.json +23 -0
  395. package/templates/official/coder/start-up.sh +23 -0
  396. package/templates/official/content-reviewer/CLAUDE.md +68 -0
  397. package/templates/official/content-reviewer/IDENTITY.md +28 -0
  398. package/templates/official/content-reviewer/SOUL.md +44 -0
  399. package/templates/official/content-reviewer/TOOLS.md +37 -0
  400. package/templates/official/content-reviewer/config.json +23 -0
  401. package/templates/official/content-reviewer/start-up.sh +23 -0
  402. package/templates/official/content-strategist/CLAUDE.md +63 -0
  403. package/templates/official/content-strategist/IDENTITY.md +33 -0
  404. package/templates/official/content-strategist/SOUL.md +48 -0
  405. package/templates/official/content-strategist/TOOLS.md +47 -0
  406. package/templates/official/content-strategist/config.json +23 -0
  407. package/templates/official/content-strategist/start-up.sh +23 -0
  408. package/templates/official/content-writer/CLAUDE.md +72 -0
  409. package/templates/official/content-writer/IDENTITY.md +30 -0
  410. package/templates/official/content-writer/SOUL.md +46 -0
  411. package/templates/official/content-writer/TOOLS.md +44 -0
  412. package/templates/official/content-writer/config.json +23 -0
  413. package/templates/official/content-writer/start-up.sh +23 -0
  414. package/templates/official/forward-deployed-engineer/CLAUDE.md +54 -0
  415. package/templates/official/forward-deployed-engineer/IDENTITY.md +37 -0
  416. package/templates/official/forward-deployed-engineer/SOUL.md +55 -0
  417. package/templates/official/forward-deployed-engineer/config.json +21 -0
  418. package/templates/official/lead/CLAUDE.md +33 -0
  419. package/templates/official/lead/IDENTITY.md +36 -0
  420. package/templates/official/lead/SOUL.md +51 -0
  421. package/templates/official/lead/config.json +22 -0
  422. package/templates/official/researcher/CLAUDE.md +46 -0
  423. package/templates/official/researcher/IDENTITY.md +28 -0
  424. package/templates/official/researcher/SOUL.md +43 -0
  425. package/templates/official/researcher/config.json +21 -0
  426. package/templates/official/reviewer/CLAUDE.md +63 -0
  427. package/templates/official/reviewer/IDENTITY.md +28 -0
  428. package/templates/official/reviewer/SOUL.md +45 -0
  429. package/templates/official/reviewer/config.json +21 -0
  430. package/templates/official/tester/CLAUDE.md +53 -0
  431. package/templates/official/tester/IDENTITY.md +28 -0
  432. package/templates/official/tester/SOUL.md +55 -0
  433. package/templates/official/tester/config.json +21 -0
  434. package/templates/schema.ts +35 -0
  435. package/.claude/settings.local.json +0 -115
  436. package/.dockerignore +0 -61
  437. package/.editorconfig +0 -15
  438. package/.env.docker.example +0 -39
  439. package/.env.example +0 -40
  440. package/.github/workflows/ci.yml +0 -76
  441. package/.github/workflows/docker-and-deploy.yml +0 -117
  442. package/.wts-config.json +0 -4
  443. package/.wts-setup.ts +0 -102
  444. package/CLAUDE.md +0 -104
  445. package/CONTRIBUTING.md +0 -270
  446. package/DEPLOYMENT.md +0 -605
  447. package/Dockerfile +0 -57
  448. package/Dockerfile.worker +0 -157
  449. package/FAQ.md +0 -19
  450. package/MCP.md +0 -406
  451. package/UI.md +0 -40
  452. package/assets/agent-swarm-logo-orange.png +0 -0
  453. package/assets/agent-swarm-logo.png +0 -0
  454. package/assets/agent-swarm.mp4 +0 -0
  455. package/assets/agent-swarm.png +0 -0
  456. package/biome.json +0 -39
  457. package/deploy/DEPLOY.md +0 -60
  458. package/deploy/agent-swarm.service +0 -17
  459. package/deploy/docker-push.ts +0 -30
  460. package/deploy/install.ts +0 -85
  461. package/deploy/prod-db.ts +0 -42
  462. package/deploy/uninstall.ts +0 -12
  463. package/deploy/update.ts +0 -21
  464. package/docker-compose.example.yml +0 -159
  465. package/docker-entrypoint.sh +0 -352
  466. package/ecosystem.config.cjs +0 -66
  467. package/plugin/README.md +0 -1
  468. package/plugin/hooks/hooks.json +0 -71
  469. package/pyproject.toml +0 -9
  470. package/scripts/generate-mcp-docs.ts +0 -415
  471. package/slack-manifest.json +0 -71
  472. package/src/tests/get-inbox-message.test.ts +0 -145
  473. package/src/tools/get-inbox-message.ts +0 -89
  474. package/src/tools/inbox-delegate.ts +0 -113
  475. package/thoughts/shared/plans/2025-12-18-slack-integration.md +0 -1195
  476. package/thoughts/shared/plans/2025-12-19-agent-log-streaming.md +0 -732
  477. package/thoughts/shared/plans/2025-12-19-role-based-swarm-plugin.md +0 -361
  478. package/thoughts/shared/plans/2025-12-20-mobile-responsive-ui.md +0 -501
  479. package/thoughts/shared/plans/2025-12-20-startup-team-swarm.md +0 -560
  480. package/thoughts/shared/plans/2025-12-23-runner-level-polling.md +0 -934
  481. package/thoughts/shared/plans/2025-12-23-runner-session-logs.md +0 -1000
  482. package/thoughts/shared/plans/2025-12-23-worker-lead-spawn-triggers.md +0 -568
  483. package/thoughts/shared/plans/2026-01-09-inverse-teleport.md +0 -1516
  484. package/thoughts/shared/plans/2026-01-12-agent-rename-pm2-control.md +0 -1133
  485. package/thoughts/shared/plans/2026-01-12-github-app-integration.md +0 -380
  486. package/thoughts/shared/plans/2026-01-12-lead-inbox-model.md +0 -876
  487. package/thoughts/shared/plans/2026-01-12-ralph-wiggum-integration.md +0 -463
  488. package/thoughts/shared/plans/2026-01-13-agent-concurrency.md +0 -691
  489. package/thoughts/shared/plans/2026-01-13-github-assignment-handling.md +0 -690
  490. package/thoughts/shared/plans/2026-01-13-prevent-duplicate-trigger-processing.md +0 -1071
  491. package/thoughts/shared/plans/2026-01-14-fix-slack-thread-context.md +0 -507
  492. package/thoughts/shared/plans/2026-01-15-scheduled-tasks-implementation.md +0 -565
  493. package/thoughts/shared/plans/2026-01-15-usage-cost-tracking-ui.md +0 -1479
  494. package/thoughts/shared/plans/2026-01-16-epics-feature-implementation.md +0 -1230
  495. package/thoughts/shared/research/.gitkeep +0 -0
  496. package/thoughts/shared/research/2025-01-09-inverse-teleport-plan-review.md +0 -420
  497. package/thoughts/shared/research/2025-12-18-slack-integration.md +0 -442
  498. package/thoughts/shared/research/2025-12-19-agent-log-streaming.md +0 -339
  499. package/thoughts/shared/research/2025-12-19-agent-secrets-cli-research.md +0 -390
  500. package/thoughts/shared/research/2025-12-21-gemini-cli-integration.md +0 -376
  501. package/thoughts/shared/research/2025-12-22-runner-loop-architecture.md +0 -582
  502. package/thoughts/shared/research/2025-12-22-setup-experience-improvements.md +0 -264
  503. package/thoughts/shared/research/2026-01-13-lead-duplicate-trigger-processing.md +0 -223
  504. package/thoughts/shared/research/2026-01-14-lead-slack-thread-context.md +0 -277
  505. package/thoughts/shared/research/2026-01-15-ai-tracker-agent-swarm-integration.md +0 -376
  506. package/thoughts/shared/research/2026-01-15-auto-starting-processes-in-worker-containers.md +0 -787
  507. package/thoughts/shared/research/2026-01-15-scheduled-tasks.md +0 -390
  508. package/thoughts/shared/research/2026-01-16-epics-feature-research.md +0 -437
  509. package/thoughts/taras/plans/2026-01-22-agent-swarm-schemas.md +0 -98
  510. package/thoughts/taras/plans/2026-01-28-per-worker-claude-md.md +0 -617
  511. package/thoughts/taras/plans/2026-01-28-sentry-cli-integration.md +0 -214
  512. package/thoughts/taras/research/2026-01-22-vercel-cli-integration.md +0 -287
  513. package/thoughts/taras/research/2026-01-27-excessive-polling-issue.md +0 -311
  514. package/thoughts/taras/research/2026-01-28-per-worker-claude-md.md +0 -383
  515. package/thoughts/taras/research/2026-01-28-sentry-cli-integration.md +0 -240
  516. package/tsconfig.json +0 -37
  517. package/ui/CLAUDE.md +0 -49
  518. package/ui/bun.lock +0 -771
  519. package/ui/index.html +0 -22
  520. package/ui/package-lock.json +0 -5290
  521. package/ui/package.json +0 -33
  522. package/ui/pnpm-lock.yaml +0 -3341
  523. package/ui/postcss.config.js +0 -6
  524. package/ui/public/logo.png +0 -0
  525. package/ui/src/App.tsx +0 -63
  526. package/ui/src/components/ActivityFeed.tsx +0 -440
  527. package/ui/src/components/AgentDetailPanel.tsx +0 -733
  528. package/ui/src/components/AgentsPanel.tsx +0 -815
  529. package/ui/src/components/ChatPanel.tsx +0 -1920
  530. package/ui/src/components/ConfigModal.tsx +0 -253
  531. package/ui/src/components/Dashboard.tsx +0 -832
  532. package/ui/src/components/EditAgentProfileModal.tsx +0 -433
  533. package/ui/src/components/EpicDetailPage.tsx +0 -741
  534. package/ui/src/components/EpicsPanel.tsx +0 -566
  535. package/ui/src/components/Header.tsx +0 -160
  536. package/ui/src/components/JsonViewer.tsx +0 -171
  537. package/ui/src/components/ScheduledTaskDetailPanel.tsx +0 -517
  538. package/ui/src/components/ScheduledTasksPanel.tsx +0 -639
  539. package/ui/src/components/ServicesPanel.tsx +0 -622
  540. package/ui/src/components/SessionLogPanel.tsx +0 -1219
  541. package/ui/src/components/StatsBar.tsx +0 -321
  542. package/ui/src/components/StatusBadge.tsx +0 -168
  543. package/ui/src/components/TaskDetailPanel.tsx +0 -903
  544. package/ui/src/components/TasksPanel.tsx +0 -614
  545. package/ui/src/components/UsageCharts.tsx +0 -216
  546. package/ui/src/components/UsageTab.tsx +0 -394
  547. package/ui/src/hooks/queries.ts +0 -353
  548. package/ui/src/hooks/useAutoScroll.ts +0 -83
  549. package/ui/src/index.css +0 -257
  550. package/ui/src/lib/api.ts +0 -268
  551. package/ui/src/lib/config.ts +0 -35
  552. package/ui/src/lib/contentPreview.ts +0 -208
  553. package/ui/src/lib/theme.ts +0 -214
  554. package/ui/src/lib/utils.ts +0 -88
  555. package/ui/src/main.tsx +0 -28
  556. package/ui/src/types/api.ts +0 -323
  557. package/ui/src/vite-env.d.ts +0 -1
  558. package/ui/tailwind.config.js +0 -37
  559. package/ui/tsconfig.json +0 -31
  560. package/ui/vite.config.ts +0 -35
  561. /package/{thoughts/shared/plans → templates/community}/.gitkeep +0 -0
@@ -1,34 +1,27 @@
1
- import { mkdir, unlink, writeFile } from "node:fs/promises";
2
- import { getBasePrompt } from "../prompts/base-prompt.ts";
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import type { TemplateResponse } from "../../templates/schema.ts";
4
+ import { type BasePromptArgs, getBasePrompt } from "../prompts/base-prompt.ts";
5
+ import {
6
+ generateDefaultClaudeMd,
7
+ generateDefaultIdentityMd,
8
+ generateDefaultSoulMd,
9
+ generateDefaultToolsMd,
10
+ } from "../prompts/defaults.ts";
11
+ import { configureHttpResolver, resolveTemplateAsync } from "../prompts/resolver.ts";
12
+ import {
13
+ type CostData,
14
+ createProviderAdapter,
15
+ type ProviderResult,
16
+ type ProviderSession,
17
+ type ProviderSessionConfig,
18
+ } from "../providers/index.ts";
19
+ import { resolveCredentialPools } from "../utils/credentials.ts";
3
20
  import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
4
-
5
- /** Task file data written to /tmp for hook to read */
6
- interface TaskFileData {
7
- taskId: string;
8
- agentId: string;
9
- startedAt: string;
10
- }
11
-
12
- /** Get the task file path for a given PID */
13
- function getTaskFilePath(pid: number): string {
14
- return `/tmp/agent-swarm-task-${pid}.json`;
15
- }
16
-
17
- /** Write task file before spawning Claude process */
18
- async function writeTaskFile(pid: number, data: TaskFileData): Promise<string> {
19
- const filePath = getTaskFilePath(pid);
20
- await writeFile(filePath, JSON.stringify(data, null, 2));
21
- return filePath;
22
- }
23
-
24
- /** Clean up task file after process exits */
25
- async function cleanupTaskFile(pid: number): Promise<void> {
26
- try {
27
- await unlink(getTaskFilePath(pid));
28
- } catch {
29
- // File might already be deleted or never created - ignore
30
- }
31
- }
21
+ import { detectVcsProvider } from "../vcs/index.ts";
22
+ import { interpolate } from "../workflows/template.ts";
23
+ // Side-effect import: registers runner trigger/resumption templates
24
+ import "./templates.ts";
32
25
 
33
26
  /** Save PM2 process list for persistence across container restarts */
34
27
  async function savePm2State(role: string): Promise<void> {
@@ -41,6 +34,90 @@ async function savePm2State(role: string): Promise<void> {
41
34
  }
42
35
  }
43
36
 
37
+ /** Fetch repo config for a task's vcsRepo (e.g., "desplega-ai/agent-swarm") */
38
+ async function fetchRepoConfig(
39
+ apiUrl: string,
40
+ apiKey: string,
41
+ vcsRepo: string,
42
+ ): Promise<{ url: string; name: string; clonePath: string; defaultBranch: string } | null> {
43
+ try {
44
+ const repoName = vcsRepo.split("/").pop() || vcsRepo;
45
+ const resp = await fetch(`${apiUrl}/api/repos?name=${encodeURIComponent(repoName)}`, {
46
+ headers: { Authorization: `Bearer ${apiKey}` },
47
+ });
48
+ if (!resp.ok) return null;
49
+ const data = (await resp.json()) as {
50
+ repos: Array<{ url: string; name: string; clonePath: string; defaultBranch: string }>;
51
+ };
52
+ return data.repos.find((r) => r.url.includes(vcsRepo)) ?? data.repos[0] ?? null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Read CLAUDE.md from a repo directory, returning null if not found */
59
+ async function readClaudeMd(clonePath: string, role: string): Promise<string | null> {
60
+ const claudeMdFile = Bun.file(`${clonePath}/CLAUDE.md`);
61
+ if (await claudeMdFile.exists()) {
62
+ const content = await claudeMdFile.text();
63
+ console.log(`[${role}] Read CLAUDE.md from ${clonePath}/CLAUDE.md (${content.length} chars)`);
64
+ return content;
65
+ }
66
+ console.log(`[${role}] No CLAUDE.md found at ${clonePath}/CLAUDE.md`);
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Ensure a repo is cloned and up-to-date for a task.
72
+ * Returns { clonePath, claudeMd, warning }.
73
+ */
74
+ async function ensureRepoForTask(
75
+ repoConfig: { url: string; name: string; clonePath: string; defaultBranch: string },
76
+ role: string,
77
+ ): Promise<{ clonePath: string; claudeMd: string | null; warning: string | null }> {
78
+ const { url, name, clonePath, defaultBranch } = repoConfig;
79
+
80
+ try {
81
+ const gitHeadExists = await Bun.file(`${clonePath}/.git/HEAD`).exists();
82
+
83
+ let warning: string | null = null;
84
+
85
+ if (!gitHeadExists) {
86
+ console.log(`[${role}] Cloning ${name} to ${clonePath}...`);
87
+ const provider = detectVcsProvider(url);
88
+ if (provider === "github") {
89
+ await Bun.$`gh repo clone ${url} ${clonePath} -- --branch ${defaultBranch} --single-branch`.quiet();
90
+ } else if (provider === "gitlab") {
91
+ await Bun.$`glab repo clone ${url} ${clonePath} -- --branch ${defaultBranch} --single-branch`.quiet();
92
+ } else {
93
+ await Bun.$`git clone --branch ${defaultBranch} --single-branch ${url} ${clonePath}`.quiet();
94
+ }
95
+ console.log(`[${role}] Cloned ${name}`);
96
+ } else {
97
+ console.log(`[${role}] Repo ${name} already cloned at ${clonePath}`);
98
+ const statusResult = await Bun.$`cd ${clonePath} && git status --porcelain`.quiet();
99
+ const statusOutput = statusResult.text().trim();
100
+
101
+ if (statusOutput === "") {
102
+ console.log(`[${role}] Pulling ${name} (${defaultBranch})...`);
103
+ await Bun.$`cd ${clonePath} && git pull origin ${defaultBranch} --ff-only`.quiet();
104
+ console.log(`[${role}] Pulled ${name}`);
105
+ } else {
106
+ console.warn(`[${role}] Repo ${name} has uncommitted changes, skipping pull`);
107
+ warning = `The repo "${name}" at ${clonePath} has uncommitted changes. A git pull was skipped to avoid losing work. You may need to commit or stash changes before pulling updates.`;
108
+ }
109
+ }
110
+
111
+ const claudeMd = await readClaudeMd(clonePath, role);
112
+ return { clonePath, claudeMd, warning };
113
+ } catch (err) {
114
+ const errorMsg = (err as Error).message;
115
+ console.warn(`[${role}] Error setting up repo ${name}: ${errorMsg}`);
116
+ const warning = `Failed to clone/setup repo "${name}" at ${clonePath}: ${errorMsg}. The repo may not be available. You may need to clone it manually.`;
117
+ return { clonePath, claudeMd: null, warning };
118
+ }
119
+ }
120
+
44
121
  /** API configuration for ping/close */
45
122
  interface ApiConfig {
46
123
  apiUrl: string;
@@ -88,6 +165,115 @@ async function closeAgent(config: ApiConfig, role: string): Promise<void> {
88
165
  }
89
166
  }
90
167
 
168
+ /**
169
+ * Fetch resolved config from the API and merge into a base env object.
170
+ * Falls back to baseEnv on any error (network, parse, etc).
171
+ * Credential env vars with comma-separated values get one randomly selected.
172
+ */
173
+ async function fetchResolvedEnv(
174
+ apiUrl: string,
175
+ apiKey: string,
176
+ agentId: string,
177
+ baseEnv: Record<string, string | undefined> = process.env,
178
+ ): Promise<Record<string, string | undefined>> {
179
+ const env: Record<string, string | undefined> = { ...baseEnv };
180
+
181
+ if (apiUrl && agentId) {
182
+ try {
183
+ const headers: Record<string, string> = { "X-Agent-ID": agentId };
184
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
185
+
186
+ const url = `${apiUrl}/api/config/resolved?agentId=${encodeURIComponent(agentId)}&includeSecrets=true`;
187
+ const response = await fetch(url, { headers });
188
+
189
+ if (!response.ok) {
190
+ console.warn(`[env-reload] Failed to fetch config: ${response.status}`);
191
+ } else {
192
+ const data = (await response.json()) as {
193
+ configs: Array<{ key: string; value: string }>;
194
+ };
195
+
196
+ if (data.configs?.length) {
197
+ for (const config of data.configs) {
198
+ env[config.key] = config.value;
199
+ }
200
+ console.log(`[env-reload] Loaded ${data.configs.length} config entries from API`);
201
+ }
202
+ }
203
+ } catch (error) {
204
+ console.warn(`[env-reload] Could not fetch config, using current env: ${error}`);
205
+ }
206
+ }
207
+
208
+ resolveCredentialPools(env);
209
+ return env;
210
+ }
211
+
212
+ /**
213
+ * Convert a tool call into a human-readable progress description.
214
+ */
215
+ function toolCallToProgress(toolName: string, args: unknown): string {
216
+ const a = args as Record<string, unknown>;
217
+ const shortPath = (p: unknown) => {
218
+ if (typeof p !== "string") return "";
219
+ // Show last 2 path segments for readability
220
+ const parts = p.split("/");
221
+ return parts.length > 2 ? parts.slice(-2).join("/") : p;
222
+ };
223
+
224
+ switch (toolName) {
225
+ case "Read":
226
+ return `Reading ${shortPath(a.file_path)}`;
227
+ case "Edit":
228
+ case "MultiEdit":
229
+ return `Editing ${shortPath(a.file_path)}`;
230
+ case "Write":
231
+ return `Writing ${shortPath(a.file_path)}`;
232
+ case "Bash":
233
+ return a.description ? `${a.description}` : "Running shell command";
234
+ case "Grep":
235
+ return `Searching for "${a.pattern}"`;
236
+ case "Glob":
237
+ return `Finding files matching ${a.pattern}`;
238
+ case "Agent":
239
+ case "Task":
240
+ return a.description ? `${a.description}` : "Delegating sub-task";
241
+ case "Skill":
242
+ return `Running /${a.skill}`;
243
+ default: {
244
+ // MCP tools: mcp__server__tool → "server:tool"
245
+ if (toolName.startsWith("mcp__")) {
246
+ const parts = toolName.split("__");
247
+ return parts.length >= 3 ? `Using ${parts[1]}:${parts[2]}` : `Using ${toolName}`;
248
+ }
249
+ return `Using ${toolName}`;
250
+ }
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Report task progress via the API (fire-and-forget).
256
+ */
257
+ async function updateProgressViaAPI(
258
+ apiUrl: string,
259
+ apiKey: string,
260
+ taskId: string,
261
+ progress: string,
262
+ ): Promise<void> {
263
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
264
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
265
+
266
+ try {
267
+ await fetch(`${apiUrl}/api/tasks/${taskId}/progress`, {
268
+ method: "POST",
269
+ headers,
270
+ body: JSON.stringify({ progress }),
271
+ });
272
+ } catch {
273
+ // Non-blocking — progress update failure is not critical
274
+ }
275
+ }
276
+
91
277
  /**
92
278
  * Ensure task is marked as completed or failed via the API.
93
279
  * This is called when a Claude process exits to ensure task status is updated,
@@ -96,11 +282,97 @@ async function closeAgent(config: ApiConfig, role: string): Promise<void> {
96
282
  * The API is idempotent - if the agent already marked the task as completed/failed,
97
283
  * this call will succeed without changing anything.
98
284
  */
285
+ /**
286
+ * Attempt to extract structured output from a task's progress history
287
+ * when the agent session ends without calling store-progress with valid output.
288
+ *
289
+ * - Claude adapter: runs a fallback extraction via `claude -p --json-schema`
290
+ * - Pi-mono adapter: returns an error (no fallback available)
291
+ */
292
+ async function handleStructuredOutputFallback(
293
+ config: ApiConfig,
294
+ taskId: string,
295
+ adapterType: string,
296
+ ): Promise<{ output?: string; failReason?: string } | null> {
297
+ const headers: Record<string, string> = {
298
+ "Content-Type": "application/json",
299
+ };
300
+ if (config.apiKey) {
301
+ headers.Authorization = `Bearer ${config.apiKey}`;
302
+ }
303
+
304
+ try {
305
+ // Fetch the task to check for outputSchema
306
+ const taskRes = await fetch(`${config.apiUrl}/api/tasks/${taskId}`, { headers });
307
+ if (!taskRes.ok) return null;
308
+
309
+ const taskData = (await taskRes.json()) as {
310
+ task?: {
311
+ task?: string;
312
+ output?: string;
313
+ outputSchema?: Record<string, unknown>;
314
+ };
315
+ logs?: Array<{ eventType: string; newValue?: string; createdAt?: string }>;
316
+ };
317
+
318
+ const task = taskData.task;
319
+ if (!task?.outputSchema) return null; // No schema — no fallback needed
320
+ if (task.output) return null; // Agent already stored valid output
321
+
322
+ if (adapterType !== "claude") {
323
+ return {
324
+ failReason:
325
+ "Structured output required by outputSchema but not provided via store-progress",
326
+ };
327
+ }
328
+
329
+ // Claude adapter fallback: extract structured data from progress history
330
+ const progressLogs = (taskData.logs ?? [])
331
+ .filter((l) => l.eventType === "task_progress")
332
+ .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
333
+
334
+ const progressEntries = progressLogs
335
+ .map((log, i) => `${i + 1}. [${log.createdAt}] ${log.newValue}`)
336
+ .join("\n");
337
+
338
+ const extractionPrompt = `Extract structured data from this task's execution history.
339
+
340
+ ## Task Description
341
+ ${task.task || "(no description)"}
342
+
343
+ ## Progress Updates (chronological)
344
+ ${progressEntries || "(no progress recorded)"}
345
+
346
+ ## Required Output Schema
347
+ ${JSON.stringify(task.outputSchema, null, 2)}
348
+
349
+ Extract the structured data from the progress updates above. Return ONLY valid JSON matching the schema.`;
350
+
351
+ const schemaJson = JSON.stringify(task.outputSchema);
352
+ const result =
353
+ await Bun.$`claude -p ${extractionPrompt} --json-schema ${schemaJson} --output-format json --model sonnet`
354
+ .json()
355
+ .catch(() => null);
356
+
357
+ if (result && typeof result === "object") {
358
+ return { output: JSON.stringify(result) };
359
+ }
360
+
361
+ return {
362
+ failReason: "Structured output extraction fallback failed — could not produce valid JSON",
363
+ };
364
+ } catch (err) {
365
+ console.warn(`[runner] Structured output fallback failed for task ${taskId}: ${err}`);
366
+ return null;
367
+ }
368
+ }
369
+
99
370
  async function ensureTaskFinished(
100
371
  config: ApiConfig,
101
372
  role: string,
102
373
  taskId: string,
103
374
  exitCode: number,
375
+ failureReason?: string,
104
376
  ): Promise<void> {
105
377
  const headers: Record<string, string> = {
106
378
  "X-Agent-ID": config.agentId,
@@ -112,14 +384,26 @@ async function ensureTaskFinished(
112
384
 
113
385
  // Determine status and reason based on exit code
114
386
  // Exit code 0 = success, non-zero = failure
115
- const status = exitCode === 0 ? "completed" : "failed";
387
+ let status = exitCode === 0 ? "completed" : "failed";
116
388
  const body: Record<string, string> = { status };
117
389
 
118
390
  if (status === "failed") {
119
- body.failureReason = `Claude process exited with code ${exitCode}`;
391
+ body.failureReason = failureReason || `Claude process exited with code ${exitCode}`;
120
392
  } else {
121
- body.output =
122
- "Process completed (runner wrapper fallback - agent may have provided explicit output)";
393
+ // Try structured output fallback if the task has an outputSchema
394
+ const adapterType = process.env.HARNESS_PROVIDER || "claude";
395
+ const fallback = await handleStructuredOutputFallback(config, taskId, adapterType);
396
+
397
+ if (fallback?.output) {
398
+ body.output = fallback.output;
399
+ } else if (fallback?.failReason) {
400
+ status = "failed";
401
+ body.status = "failed";
402
+ body.failureReason = fallback.failReason;
403
+ } else {
404
+ body.output =
405
+ "Process completed (runner wrapper fallback - agent may have provided explicit output)";
406
+ }
123
407
  }
124
408
 
125
409
  try {
@@ -143,6 +427,8 @@ async function ensureTaskFinished(
143
427
  `[${role}] Runner marked task ${taskId.slice(0, 8)} as ${status} (exit code: ${exitCode})`,
144
428
  );
145
429
  }
430
+ } else if (response.status === 404) {
431
+ console.log(`[${role}] Task ${taskId.slice(0, 8)} already finalized (not found), skipping`);
146
432
  } else {
147
433
  const error = await response.text();
148
434
  console.warn(
@@ -190,9 +476,20 @@ async function pauseTaskViaAPI(config: ApiConfig, role: string, taskId: string):
190
476
  }
191
477
 
192
478
  /** Fetch paused tasks from API for this agent */
193
- async function getPausedTasksFromAPI(
194
- config: ApiConfig,
195
- ): Promise<Array<{ id: string; task: string; progress?: string }>> {
479
+ async function getPausedTasksFromAPI(config: ApiConfig): Promise<
480
+ Array<{
481
+ id: string;
482
+ task: string;
483
+ progress?: string;
484
+ claudeSessionId?: string;
485
+ parentTaskId?: string;
486
+ dir?: string;
487
+ vcsRepo?: string;
488
+ finishedAt?: string;
489
+ output?: string;
490
+ status?: string;
491
+ }>
492
+ > {
196
493
  const headers: Record<string, string> = {
197
494
  "X-Agent-ID": config.agentId,
198
495
  };
@@ -212,7 +509,18 @@ async function getPausedTasksFromAPI(
212
509
  }
213
510
 
214
511
  const data = (await response.json()) as {
215
- tasks: Array<{ id: string; task: string; progress?: string }>;
512
+ tasks: Array<{
513
+ id: string;
514
+ task: string;
515
+ progress?: string;
516
+ claudeSessionId?: string;
517
+ parentTaskId?: string;
518
+ dir?: string;
519
+ vcsRepo?: string;
520
+ finishedAt?: string;
521
+ output?: string;
522
+ status?: string;
523
+ }>;
216
524
  };
217
525
  return data.tasks || [];
218
526
  } catch (error) {
@@ -244,31 +552,26 @@ async function resumeTaskViaAPI(config: ApiConfig, taskId: string): Promise<bool
244
552
  }
245
553
 
246
554
  /** Build prompt for a resumed task */
247
- function buildResumePrompt(task: { id: string; task: string; progress?: string }): string {
248
- let prompt = `/work-on-task ${task.id}
249
-
250
- **RESUMED TASK** - This task was interrupted during a deployment and is being resumed.
251
-
252
- Task: "${task.task}"`;
253
-
555
+ async function buildResumePrompt(
556
+ task: { id: string; task: string; progress?: string },
557
+ fmt: (cmd: string) => string = (cmd) => `/${cmd}`,
558
+ ): Promise<string> {
254
559
  if (task.progress) {
255
- prompt += `
256
-
257
- Previous Progress:
258
- ${task.progress}
259
-
260
- Continue from where you left off. Review the progress above and complete the remaining work.`;
261
- } else {
262
- prompt += `
263
-
264
- No progress was saved before the interruption. Start the task fresh but be aware files may have been partially modified.`;
560
+ const result = await resolveTemplateAsync("task.resumption.with_progress", {
561
+ work_on_task_cmd: fmt("work-on-task"),
562
+ task_id: task.id,
563
+ task_description: task.task,
564
+ progress: task.progress,
565
+ });
566
+ return result.text;
265
567
  }
266
568
 
267
- prompt += `
268
-
269
- When done, use \`store-progress\` with status: "completed" and include your output.`;
270
-
271
- return prompt;
569
+ const result = await resolveTemplateAsync("task.resumption.no_progress", {
570
+ work_on_task_cmd: fmt("work-on-task"),
571
+ task_id: task.id,
572
+ task_description: task.task,
573
+ });
574
+ return result.text;
272
575
  }
273
576
 
274
577
  /** Setup signal handlers for graceful shutdown */
@@ -303,7 +606,7 @@ function setupShutdownHandlers(
303
606
  );
304
607
  for (const [taskId, task] of state.activeTasks) {
305
608
  console.log(`[${role}] Pausing task ${taskId.slice(0, 8)}`);
306
- task.process.kill("SIGTERM");
609
+ task.session.abort().catch(() => {});
307
610
  // Mark as paused for graceful resume (instead of failed)
308
611
  if (apiConfig) {
309
612
  const paused = await pauseTaskViaAPI(apiConfig, role, taskId);
@@ -352,28 +655,19 @@ export interface RunnerOptions {
352
655
  aiLoop?: boolean; // Use AI-based loop (old behavior)
353
656
  }
354
657
 
355
- interface RunClaudeIterationOptions {
356
- prompt: string;
357
- logFile: string;
358
- systemPrompt?: string;
359
- additionalArgs?: string[];
360
- role: string;
361
- // New fields for log streaming
362
- apiUrl?: string;
363
- apiKey?: string;
364
- agentId?: string;
365
- sessionId?: string;
366
- iteration?: number;
367
- taskId?: string;
368
- }
369
-
370
658
  /** Running task state for parallel execution */
371
659
  interface RunningTask {
372
660
  taskId: string;
373
- process: ReturnType<typeof Bun.spawn>;
661
+ session: ProviderSession;
374
662
  logFile: string;
375
663
  startTime: Date;
376
- promise: Promise<number>;
664
+ promise: Promise<ProviderResult>;
665
+ /** The trigger type that caused this task to be spawned */
666
+ triggerType?: string;
667
+ /** Set when the promise resolves, enabling non-blocking completion checks */
668
+ result: ProviderResult | null;
669
+ /** Deferred cursor updates for channel_activity triggers — committed after success */
670
+ cursorUpdates?: Array<{ channelId: string; ts: string }>;
377
671
  }
378
672
 
379
673
  /** Runner state for tracking concurrent tasks */
@@ -408,6 +702,12 @@ async function flushLogBuffer(
408
702
  ): Promise<void> {
409
703
  if (buffer.lines.length === 0) return;
410
704
 
705
+ // Snapshot and clear buffer immediately to prevent duplicate flushes
706
+ // (fire-and-forget callers would otherwise race on the same buffer)
707
+ const lines = buffer.lines;
708
+ buffer.lines = [];
709
+ buffer.lastFlush = Date.now();
710
+
411
711
  const headers: Record<string, string> = {
412
712
  "Content-Type": "application/json",
413
713
  "X-Agent-ID": opts.agentId,
@@ -425,7 +725,7 @@ async function flushLogBuffer(
425
725
  iteration: opts.iteration,
426
726
  taskId: opts.taskId,
427
727
  cli: opts.cli || "claude",
428
- lines: buffer.lines,
728
+ lines,
429
729
  }),
430
730
  });
431
731
 
@@ -435,26 +735,6 @@ async function flushLogBuffer(
435
735
  } catch (error) {
436
736
  console.warn(`[runner] Error pushing logs: ${error}`);
437
737
  }
438
-
439
- // Clear buffer after flush
440
- buffer.lines = [];
441
- buffer.lastFlush = Date.now();
442
- }
443
-
444
- /** Data for session cost tracking */
445
- interface CostData {
446
- sessionId: string;
447
- taskId?: string;
448
- agentId: string;
449
- totalCostUsd: number;
450
- inputTokens?: number;
451
- outputTokens?: number;
452
- cacheReadTokens?: number;
453
- cacheWriteTokens?: number;
454
- durationMs: number;
455
- numTurns: number;
456
- model: string;
457
- isError: boolean;
458
738
  }
459
739
 
460
740
  /** Save session cost data to the API */
@@ -482,6 +762,120 @@ async function saveCostData(cost: CostData, apiUrl: string, apiKey: string): Pro
482
762
  }
483
763
  }
484
764
 
765
+ /** Save Claude session ID for a task (fire-and-forget) */
766
+ async function saveProviderSessionId(
767
+ apiUrl: string,
768
+ apiKey: string,
769
+ taskId: string,
770
+ claudeSessionId: string,
771
+ ): Promise<void> {
772
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
773
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
774
+ await fetch(`${apiUrl}/api/tasks/${taskId}/claude-session`, {
775
+ method: "PUT",
776
+ headers,
777
+ body: JSON.stringify({ claudeSessionId }),
778
+ });
779
+ }
780
+
781
+ /** Save provider session ID on the active session (for pool tasks where realTaskId is unknown) */
782
+ async function saveProviderSessionIdOnActiveSession(
783
+ apiUrl: string,
784
+ apiKey: string,
785
+ effectiveTaskId: string,
786
+ providerSessionId: string,
787
+ ): Promise<void> {
788
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
789
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
790
+ await fetch(`${apiUrl}/api/active-sessions/provider-session/${effectiveTaskId}`, {
791
+ method: "PUT",
792
+ headers,
793
+ body: JSON.stringify({ providerSessionId }),
794
+ });
795
+ }
796
+
797
+ /** Fetch Claude session ID for a task (for --resume) */
798
+ async function fetchProviderSessionId(
799
+ apiUrl: string,
800
+ apiKey: string,
801
+ taskId: string,
802
+ ): Promise<string | null> {
803
+ const headers: Record<string, string> = {};
804
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
805
+ try {
806
+ const response = await fetch(`${apiUrl}/api/tasks/${taskId}`, { headers });
807
+ if (!response.ok) return null;
808
+ const data = (await response.json()) as { claudeSessionId?: string };
809
+ return data.claudeSessionId || null;
810
+ } catch {
811
+ return null;
812
+ }
813
+ }
814
+
815
+ /** Register an active session with the API (fire-and-forget) */
816
+ async function registerActiveSession(
817
+ config: ApiConfig,
818
+ session: {
819
+ taskId: string;
820
+ triggerType: string;
821
+ taskDescription?: string;
822
+ runnerSessionId?: string;
823
+ },
824
+ ): Promise<void> {
825
+ const headers: Record<string, string> = {
826
+ "Content-Type": "application/json",
827
+ "X-Agent-ID": config.agentId,
828
+ };
829
+ if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
830
+ try {
831
+ await fetch(`${config.apiUrl}/api/active-sessions`, {
832
+ method: "POST",
833
+ headers,
834
+ body: JSON.stringify({
835
+ agentId: config.agentId,
836
+ taskId: session.taskId,
837
+ triggerType: session.triggerType,
838
+ taskDescription: session.taskDescription,
839
+ runnerSessionId: session.runnerSessionId,
840
+ }),
841
+ });
842
+ } catch {
843
+ // Non-blocking — session tracking is best-effort
844
+ }
845
+ }
846
+
847
+ /** Remove an active session by taskId (fire-and-forget) */
848
+ async function removeActiveSession(config: ApiConfig, taskId: string): Promise<void> {
849
+ const headers: Record<string, string> = { "X-Agent-ID": config.agentId };
850
+ if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
851
+ try {
852
+ await fetch(`${config.apiUrl}/api/active-sessions/by-task/${taskId}`, {
853
+ method: "DELETE",
854
+ headers,
855
+ });
856
+ } catch {
857
+ // Non-blocking
858
+ }
859
+ }
860
+
861
+ /** Clean up all active sessions for this agent (on startup) */
862
+ async function cleanupActiveSessions(config: ApiConfig): Promise<void> {
863
+ const headers: Record<string, string> = {
864
+ "Content-Type": "application/json",
865
+ "X-Agent-ID": config.agentId,
866
+ };
867
+ if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
868
+ try {
869
+ await fetch(`${config.apiUrl}/api/active-sessions/cleanup`, {
870
+ method: "POST",
871
+ headers,
872
+ body: JSON.stringify({ agentId: config.agentId }),
873
+ });
874
+ } catch {
875
+ // Non-blocking
876
+ }
877
+ }
878
+
485
879
  /** Trigger types returned by the poll API */
486
880
  interface Trigger {
487
881
  type:
@@ -489,9 +883,8 @@ interface Trigger {
489
883
  | "task_offered"
490
884
  | "unread_mentions"
491
885
  | "pool_tasks_available"
492
- | "tasks_finished"
493
- | "slack_inbox_message"
494
- | "epic_progress_changed";
886
+ | "epic_progress_changed"
887
+ | "channel_activity";
495
888
  taskId?: string;
496
889
  task?: unknown;
497
890
  mentionsCount?: number;
@@ -508,8 +901,14 @@ interface Trigger {
508
901
  messages?: Array<{
509
902
  id: string;
510
903
  content: string;
904
+ channelId?: string;
905
+ channelName?: string;
906
+ ts?: string;
907
+ user?: string;
908
+ text?: string;
511
909
  }>;
512
910
  epics?: unknown; // Epic progress updates for lead
911
+ cursorUpdates?: Array<{ channelId: string; ts: string }>; // Deferred cursor commits for channel_activity
513
912
  }
514
913
 
515
914
  /** Options for polling */
@@ -529,6 +928,7 @@ async function registerAgent(opts: {
529
928
  agentId: string;
530
929
  name: string;
531
930
  isLead: boolean;
931
+ role?: string;
532
932
  capabilities?: string[];
533
933
  maxTasks?: number;
534
934
  }): Promise<void> {
@@ -546,6 +946,7 @@ async function registerAgent(opts: {
546
946
  body: JSON.stringify({
547
947
  name: opts.name,
548
948
  isLead: opts.isLead,
949
+ role: opts.role,
549
950
  capabilities: opts.capabilities,
550
951
  maxTasks: opts.maxTasks,
551
952
  }),
@@ -601,7 +1002,11 @@ async function pollForTrigger(opts: PollOptions): Promise<Trigger | null> {
601
1002
  }
602
1003
 
603
1004
  /** Build prompt based on trigger type */
604
- function buildPromptForTrigger(trigger: Trigger, defaultPrompt: string): string {
1005
+ async function buildPromptForTrigger(
1006
+ trigger: Trigger,
1007
+ defaultPrompt: string,
1008
+ fmt: (cmd: string) => string = (cmd) => `/${cmd}`,
1009
+ ): Promise<string> {
605
1010
  switch (trigger.type) {
606
1011
  case "task_assigned": {
607
1012
  // Use the work-on-task command with task ID and description
@@ -609,12 +1014,25 @@ function buildPromptForTrigger(trigger: Trigger, defaultPrompt: string): string
609
1014
  trigger.task && typeof trigger.task === "object" && "task" in trigger.task
610
1015
  ? (trigger.task as { task: string }).task
611
1016
  : null;
612
- let prompt = `/work-on-task ${trigger.taskId}`;
613
- if (taskDesc) {
614
- prompt += `\n\nTask: "${taskDesc}"`;
1017
+ const taskDescSection = taskDesc ? `\n\nTask: "${taskDesc}"` : "";
1018
+
1019
+ // Build output instructions — use outputSchema if present, otherwise generic
1020
+ const taskObj = trigger.task as Record<string, unknown> | undefined;
1021
+ let outputInstructions: string;
1022
+ if (taskObj?.outputSchema && typeof taskObj.outputSchema === "object") {
1023
+ outputInstructions = `\n\n**Required Output Format**: When completing this task, you MUST call store-progress with output that is valid JSON conforming to this schema:\n\`\`\`json\n${JSON.stringify(taskObj.outputSchema, null, 2)}\n\`\`\`\nCall store-progress with status "completed" and your JSON output. If your output doesn't match the schema, the tool call will fail and you should fix and retry.`;
1024
+ } else {
1025
+ outputInstructions =
1026
+ '\n\nWhen done, use `store-progress` with status: "completed" and include your output.';
615
1027
  }
616
- prompt += `\n\nWhen done, use \`store-progress\` with status: "completed" and include your output.`;
617
- return prompt;
1028
+
1029
+ const result = await resolveTemplateAsync("task.trigger.assigned", {
1030
+ work_on_task_cmd: fmt("work-on-task"),
1031
+ task_id: trigger.taskId,
1032
+ task_desc_section: taskDescSection,
1033
+ output_instructions: outputInstructions,
1034
+ });
1035
+ return result.text;
618
1036
  }
619
1037
 
620
1038
  case "task_offered": {
@@ -623,73 +1041,27 @@ function buildPromptForTrigger(trigger: Trigger, defaultPrompt: string): string
623
1041
  trigger.task && typeof trigger.task === "object" && "task" in trigger.task
624
1042
  ? (trigger.task as { task: string }).task
625
1043
  : null;
626
- let prompt = `/review-offered-task ${trigger.taskId}`;
627
- if (taskDesc) {
628
- prompt += `\n\nA task has been offered to you:\n"${taskDesc}"`;
629
- }
630
- prompt += `\n\nAccept if you have capacity and skills. Reject with a reason if you cannot handle it.`;
631
- return prompt;
1044
+ const taskDescSection = taskDesc ? `\n\nA task has been offered to you:\n"${taskDesc}"` : "";
1045
+ const result = await resolveTemplateAsync("task.trigger.offered", {
1046
+ review_offered_task_cmd: fmt("review-offered-task"),
1047
+ task_id: trigger.taskId,
1048
+ task_desc_section: taskDescSection,
1049
+ });
1050
+ return result.text;
632
1051
  }
633
1052
 
634
- case "unread_mentions":
635
- // Check messages - numbered steps for clarity
636
- return `You have ${trigger.count || "unread"} mention(s) in chat channels.
637
-
638
- 1. Use \`read-messages\` with unreadOnly: true to see them
639
- 2. Respond to questions or requests directed at you
640
- 3. If a message requires work, create a task using \`send-task\``;
641
-
642
- case "pool_tasks_available":
643
- // Worker: claim a task from the pool - numbered steps for clarity
644
- return `${trigger.count} task(s) available in the pool.
645
-
646
- 1. Run \`get-tasks\` with unassigned: true to browse
647
- 2. Pick one matching your skills
648
- 3. Run \`task-action\` with action: "claim" and taskId: "<id>"
649
-
650
- Note: Claims are first-come-first-serve. If claim fails, pick another.`;
651
-
652
- case "tasks_finished": {
653
- // Lead: notification about finished tasks with inline details
654
- if (trigger.tasks && Array.isArray(trigger.tasks) && trigger.tasks.length > 0) {
655
- const completed = trigger.tasks.filter((t) => t.status === "completed");
656
- const failed = trigger.tasks.filter((t) => t.status === "failed");
657
-
658
- let prompt = `${trigger.count} task(s) finished:\n`;
659
-
660
- if (completed.length > 0) {
661
- prompt += "\n### Completed:\n";
662
- for (const t of completed) {
663
- const agentName = t.agentId ? `Agent ${t.agentId.slice(0, 8)}` : "Unknown";
664
- const output = t.output ? t.output.slice(0, 200) : "(no output)";
665
- const hasSlack = t.slackChannelId ? " [Slack - user expects reply]" : "";
666
- prompt += `- **Task ${t.id.slice(0, 8)}** by ${agentName}${hasSlack}\n`;
667
- prompt += ` Description: "${t.task?.slice(0, 100)}"\n`;
668
- prompt += ` Output: ${output}${t.output && t.output.length > 200 ? "..." : ""}\n`;
669
- }
670
- }
671
-
672
- if (failed.length > 0) {
673
- prompt += "\n### Failed:\n";
674
- for (const t of failed) {
675
- const agentName = t.agentId ? `Agent ${t.agentId.slice(0, 8)}` : "Unknown";
676
- const reason = t.failureReason || "(no reason given)";
677
- const hasSlack = t.slackChannelId ? " [Slack - user expects reply]" : "";
678
- prompt += `- **Task ${t.id.slice(0, 8)}** by ${agentName}${hasSlack}\n`;
679
- prompt += ` Description: "${t.task?.slice(0, 100)}"\n`;
680
- prompt += ` Reason: ${reason}\n`;
681
- }
682
- }
683
-
684
- prompt += `\nFor each task:
685
- 1. Completed: Verify output meets requirements
686
- 2. Failed: Reassign to another worker, or handle the issue
687
- 3. If Slack context: Use \`slack-reply\` with taskId to update the user`;
688
-
689
- return prompt;
690
- }
1053
+ case "unread_mentions": {
1054
+ const result = await resolveTemplateAsync("task.trigger.unread_mentions", {
1055
+ mention_count: trigger.count || "unread",
1056
+ });
1057
+ return result.text;
1058
+ }
691
1059
 
692
- return `Workers have finished ${trigger.count} task(s). Use \`get-tasks\` with status: "completed" or "failed" to review them.`;
1060
+ case "pool_tasks_available": {
1061
+ const result = await resolveTemplateAsync("task.trigger.pool_available", {
1062
+ task_count: trigger.count,
1063
+ });
1064
+ return result.text;
693
1065
  }
694
1066
 
695
1067
  case "epic_progress_changed": {
@@ -700,6 +1072,9 @@ Note: Claims are first-come-first-serve. If claim fails, pick another.`;
700
1072
  id: string;
701
1073
  name: string;
702
1074
  goal: string;
1075
+ plan?: string;
1076
+ prd?: string;
1077
+ nextSteps?: string;
703
1078
  status: string;
704
1079
  progress: number;
705
1080
  taskStats: {
@@ -724,92 +1099,83 @@ Note: Claims are first-come-first-serve. If claim fails, pick another.`;
724
1099
  return "Epic progress was updated but no details available. Use `list-epics` to check status.";
725
1100
  }
726
1101
 
727
- let prompt = `## Epic Progress Update\n\n${trigger.count} epic(s) have progress updates:\n\n`;
728
-
1102
+ // Build epics detail section as a pre-computed variable
1103
+ let epicsDetail = "";
729
1104
  for (const { epic, finishedTasks } of epics) {
730
- prompt += `### Epic: "${epic.name}" (${epic.id.slice(0, 8)})\n`;
731
- prompt += `**Goal:** ${epic.goal}\n`;
732
- prompt += `**Progress:** ${epic.progress}% complete (${epic.taskStats.completed}/${epic.taskStats.total} tasks)\n`;
733
- prompt += `**Status:** ${epic.status}\n\n`;
1105
+ epicsDetail += `### Epic: "${epic.name}" (${epic.id.slice(0, 8)})\n`;
1106
+ epicsDetail += `**Goal:** ${epic.goal}\n`;
1107
+ epicsDetail += `**Progress:** ${epic.progress}% complete (${epic.taskStats.completed}/${epic.taskStats.total} tasks)\n`;
1108
+ epicsDetail += `**Status:** ${epic.status}\n\n`;
1109
+
1110
+ if (epic.plan) {
1111
+ epicsDetail += `**Plan:**\n${epic.plan.slice(0, 2000)}\n\n`;
1112
+ }
1113
+ if (epic.prd) {
1114
+ epicsDetail += `**PRD:**\n${epic.prd.slice(0, 1000)}\n\n`;
1115
+ }
734
1116
 
735
1117
  // Show finished tasks
736
1118
  const completed = finishedTasks.filter((t) => t.status === "completed");
737
1119
  const failed = finishedTasks.filter((t) => t.status === "failed");
738
1120
 
739
1121
  if (completed.length > 0) {
740
- prompt += "**Recently Completed:**\n";
1122
+ epicsDetail += "**Recently Completed:**\n";
741
1123
  for (const t of completed) {
742
1124
  const agentName = t.agentId ? `Agent ${t.agentId.slice(0, 8)}` : "Unknown";
743
1125
  const output = t.output ? t.output.slice(0, 150) : "(no output)";
744
- prompt += `- Task ${t.id.slice(0, 8)} by ${agentName}: "${t.task.slice(0, 80)}"\n`;
745
- prompt += ` Output: ${output}${t.output && t.output.length > 150 ? "..." : ""}\n`;
1126
+ epicsDetail += `- Task ${t.id.slice(0, 8)} by ${agentName}: "${t.task.slice(0, 80)}"\n`;
1127
+ epicsDetail += ` Output: ${output}${t.output && t.output.length > 150 ? "..." : ""}\n`;
746
1128
  }
747
1129
  }
748
1130
 
749
1131
  if (failed.length > 0) {
750
- prompt += "\n**Recently Failed:**\n";
1132
+ epicsDetail += "\n**Recently Failed:**\n";
751
1133
  for (const t of failed) {
752
1134
  const agentName = t.agentId ? `Agent ${t.agentId.slice(0, 8)}` : "Unknown";
753
- prompt += `- Task ${t.id.slice(0, 8)} by ${agentName}: "${t.task.slice(0, 80)}"\n`;
754
- prompt += ` Reason: ${t.failureReason || "(no reason)"}\n`;
1135
+ epicsDetail += `- Task ${t.id.slice(0, 8)} by ${agentName}: "${t.task.slice(0, 80)}"\n`;
1136
+ epicsDetail += ` Reason: ${t.failureReason || "(no reason)"}\n`;
755
1137
  }
756
1138
  }
757
1139
 
758
1140
  // Show remaining work
759
1141
  const { inProgress, pending } = epic.taskStats;
760
1142
  if (inProgress > 0 || pending > 0) {
761
- prompt += `\n**Remaining:** ${inProgress} in progress, ${pending} pending\n`;
1143
+ epicsDetail += `\n**Remaining:** ${inProgress} in progress, ${pending} pending\n`;
762
1144
  }
763
1145
 
764
- prompt += "\n---\n\n";
1146
+ epicsDetail += "\n---\n\n";
765
1147
  }
766
1148
 
767
- prompt += `## Your Task: Plan Next Steps
768
-
769
- For each epic:
770
- 1. **Review** the completed work and any failures
771
- 2. **Determine** if the epic goal is met (progress = 100% and all tasks succeeded)
772
- 3. **If complete:** Use \`update-epic\` to mark status as "completed"
773
- 4. **If not complete:**
774
- - Retry failed tasks with \`send-task\` (reassign or modify)
775
- - Create new tasks for remaining work with \`send-task\` (include epicId)
776
- - Keep the epic progressing until the goal is achieved
777
-
778
- This is an iterative process - you'll be notified again when more tasks finish.
779
- The epic should keep progressing until 100% complete and the goal is achieved.`;
780
-
781
- return prompt;
1149
+ const result = await resolveTemplateAsync("task.trigger.epic_progress", {
1150
+ epic_count: trigger.count,
1151
+ epics_detail: epicsDetail,
1152
+ });
1153
+ return result.text;
782
1154
  }
783
1155
 
784
- case "slack_inbox_message": {
785
- // Lead: Slack inbox messages from users
786
- const inboxDetails = (trigger.messages || [])
787
- .map((m: { id: string; content: string }, index: number) => {
788
- // Parse structured content if present
789
- const newMessageMatch = m.content.match(/<new_message>\n([\s\S]*?)\n<\/new_message>/);
790
- const threadHistoryMatch = m.content.match(
791
- /<thread_history>\n([\s\S]*?)\n<\/thread_history>/,
792
- );
793
-
794
- const newMessage = newMessageMatch ? newMessageMatch[1] : m.content;
795
- const threadHistory = threadHistoryMatch ? threadHistoryMatch[1] : null;
796
-
797
- let formatted = `### Message ${index + 1} (inboxMessageId: ${m.id})\n`;
798
- formatted += `**New Message:**\n${newMessage}\n`;
799
-
800
- if (threadHistory) {
801
- formatted += `\n**Thread History:**\n${threadHistory}\n`;
802
- }
803
-
804
- return formatted;
805
- })
806
- .join("\n---\n\n");
1156
+ case "channel_activity": {
1157
+ const msgs = (trigger.messages || []) as Array<{
1158
+ channelId?: string;
1159
+ channelName?: string;
1160
+ ts?: string;
1161
+ user?: string;
1162
+ text?: string;
1163
+ }>;
1164
+ if (msgs.length === 0) {
1165
+ return "New Slack channel activity detected but no message details available. Use `slack-read` to check recent messages.";
1166
+ }
807
1167
 
808
- return `${trigger.count} Slack inbox message(s):\n\n${inboxDetails}\n\nFor each message, choose one:
809
- - **Reply directly**: Use \`slack-reply\` with inboxMessageId if you can answer immediately
810
- - **Delegate to worker**: Use \`inbox-delegate\` with inboxMessageId and agentId if it requires work
1168
+ let messagesDetail = "";
1169
+ for (const msg of msgs) {
1170
+ const channel = msg.channelName ? `#${msg.channelName}` : msg.channelId || "unknown";
1171
+ messagesDetail += `- **${channel}** (user: ${msg.user || "unknown"}): ${msg.text?.slice(0, 200) || "(no text)"}\n`;
1172
+ }
811
1173
 
812
- Do not leave messages unanswered.`;
1174
+ const result = await resolveTemplateAsync("task.trigger.channel_activity", {
1175
+ message_count: trigger.count || msgs.length,
1176
+ messages_detail: messagesDetail,
1177
+ });
1178
+ return result.text;
813
1179
  }
814
1180
 
815
1181
  default:
@@ -817,388 +1183,244 @@ Do not leave messages unanswered.`;
817
1183
  }
818
1184
  }
819
1185
 
820
- async function runClaudeIteration(opts: RunClaudeIterationOptions): Promise<number> {
821
- const { role } = opts;
822
- const Cmd = [
823
- "claude",
824
- "--model",
825
- "opus",
826
- "--verbose",
827
- "--output-format",
828
- "stream-json",
829
- "--dangerously-skip-permissions",
830
- "--allow-dangerously-skip-permissions",
831
- "--permission-mode",
832
- "bypassPermissions",
833
- "-p",
834
- opts.prompt,
835
- ];
836
-
837
- if (opts.additionalArgs && opts.additionalArgs.length > 0) {
838
- Cmd.push(...opts.additionalArgs);
839
- }
1186
+ /** Search agent memories relevant to a task description via the API */
1187
+ async function fetchRelevantMemories(
1188
+ apiUrl: string,
1189
+ apiKey: string,
1190
+ agentId: string,
1191
+ taskDescription: string,
1192
+ ): Promise<string | null> {
1193
+ try {
1194
+ const headers: Record<string, string> = {
1195
+ "Content-Type": "application/json",
1196
+ "X-Agent-ID": agentId,
1197
+ };
1198
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
840
1199
 
841
- if (opts.systemPrompt) {
842
- Cmd.push("--append-system-prompt", opts.systemPrompt);
843
- }
1200
+ const response = await fetch(`${apiUrl}/api/memory/search`, {
1201
+ method: "POST",
1202
+ headers,
1203
+ body: JSON.stringify({ query: taskDescription, limit: 5 }),
1204
+ });
844
1205
 
845
- console.log(`\x1b[2m[${role}]\x1b[0m \x1b[36m▸\x1b[0m Starting Claude (PID will follow)`);
1206
+ if (!response.ok) return null;
846
1207
 
847
- const logFileHandle = Bun.file(opts.logFile).writer();
848
- let stderrOutput = "";
1208
+ const data = (await response.json()) as {
1209
+ results: Array<{ id: string; name: string; content: string; similarity: number }>;
1210
+ };
849
1211
 
850
- const proc = Bun.spawn(Cmd, {
851
- env: process.env,
852
- stdout: "pipe",
853
- stderr: "pipe",
854
- });
1212
+ const useful = (data.results || []).filter((m) => m.similarity > 0.4);
1213
+ if (useful.length === 0) return null;
855
1214
 
856
- let stdoutChunks = 0;
857
- let stderrChunks = 0;
858
-
859
- const stdoutPromise = (async () => {
860
- if (proc.stdout) {
861
- // Initialize log buffer for API streaming
862
- const logBuffer: LogBuffer = { lines: [], lastFlush: Date.now(), partialLine: "" };
863
- const shouldStream = opts.apiUrl && opts.sessionId && opts.iteration;
864
-
865
- for await (const chunk of proc.stdout) {
866
- stdoutChunks++;
867
- const text = new TextDecoder().decode(chunk);
868
- logFileHandle.write(text);
869
-
870
- // Prepend any partial line from previous chunk
871
- const combined = logBuffer.partialLine + text;
872
- const parts = combined.split("\n");
873
-
874
- // Last element may be incomplete - save for next chunk
875
- logBuffer.partialLine = parts.pop() || "";
876
-
877
- // Process only complete lines (those that ended with \n)
878
- for (const line of parts) {
879
- prettyPrintLine(line, role);
880
-
881
- // Buffer non-empty lines for API streaming
882
- if (shouldStream && line.trim()) {
883
- logBuffer.lines.push(line.trim());
884
-
885
- // Check if we should flush (buffer full or time elapsed)
886
- const shouldFlush =
887
- logBuffer.lines.length >= LOG_BUFFER_SIZE ||
888
- Date.now() - logBuffer.lastFlush >= LOG_FLUSH_INTERVAL_MS;
889
-
890
- if (shouldFlush) {
891
- await flushLogBuffer(logBuffer, {
892
- apiUrl: opts.apiUrl!,
893
- apiKey: opts.apiKey || "",
894
- agentId: opts.agentId || "",
895
- sessionId: opts.sessionId!,
896
- iteration: opts.iteration!,
897
- taskId: opts.taskId,
898
- cli: "claude",
899
- });
900
- }
901
- }
902
- }
903
- }
1215
+ const memoryContext = useful
1216
+ .map((m) => `- **${m.name}** (id: ${m.id}): ${m.content.substring(0, 300)}`)
1217
+ .join("\n");
904
1218
 
905
- // Handle any remaining partial line at stream end
906
- if (logBuffer.partialLine.trim()) {
907
- prettyPrintLine(logBuffer.partialLine, role);
908
- if (shouldStream) {
909
- logBuffer.lines.push(logBuffer.partialLine.trim());
910
- }
911
- logBuffer.partialLine = "";
912
- }
1219
+ return `\n\n### Relevant Past Knowledge\n\nThese memories from your previous sessions may be useful. Use \`memory-get\` with the memory ID to retrieve full details.\n\n${memoryContext}\n`;
1220
+ } catch {
1221
+ // Non-blocking — don't fail task start because of memory search
1222
+ return null;
1223
+ }
1224
+ }
913
1225
 
914
- // Final flush for remaining buffered logs
915
- if (shouldStream && logBuffer.lines.length > 0) {
916
- await flushLogBuffer(logBuffer, {
917
- apiUrl: opts.apiUrl!,
918
- apiKey: opts.apiKey || "",
919
- agentId: opts.agentId || "",
920
- sessionId: opts.sessionId!,
921
- iteration: opts.iteration!,
922
- taskId: opts.taskId,
923
- cli: "claude",
924
- });
925
- }
926
- }
927
- })();
928
-
929
- const stderrPromise = (async () => {
930
- if (proc.stderr) {
931
- for await (const chunk of proc.stderr) {
932
- stderrChunks++;
933
- const text = new TextDecoder().decode(chunk);
934
- stderrOutput += text;
935
- prettyPrintStderr(text, role);
936
- logFileHandle.write(
937
- `${JSON.stringify({ type: "stderr", content: text, timestamp: new Date().toISOString() })}\n`,
938
- );
939
- }
940
- }
941
- })();
1226
+ async function fetchEpicNameAndGoal(
1227
+ apiUrl: string,
1228
+ apiKey: string,
1229
+ epicId: string,
1230
+ ): Promise<{ name: string; goal: string } | null> {
1231
+ try {
1232
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
1233
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
942
1234
 
943
- await Promise.all([stdoutPromise, stderrPromise]);
944
- await logFileHandle.end();
945
- const exitCode = await proc.exited;
1235
+ const response = await fetch(`${apiUrl}/api/epics/${epicId}`, { headers });
1236
+ if (!response.ok) return null;
946
1237
 
947
- if (exitCode !== 0 && stderrOutput) {
948
- console.error(`\x1b[31m[${role}] Full stderr:\x1b[0m\n${stderrOutput}`);
1238
+ const data = (await response.json()) as { name: string; goal: string };
1239
+ return { name: data.name, goal: data.goal };
1240
+ } catch {
1241
+ return null;
949
1242
  }
1243
+ }
950
1244
 
951
- if (stdoutChunks === 0 && stderrChunks === 0) {
952
- console.warn(`\x1b[33m[${role}] WARNING: No output from Claude - check auth/startup\x1b[0m`);
953
- }
1245
+ async function fetchEpicTaskContext(
1246
+ apiUrl: string,
1247
+ apiKey: string,
1248
+ epicId: string,
1249
+ currentTaskId: string,
1250
+ ): Promise<string | null> {
1251
+ try {
1252
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
1253
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
954
1254
 
955
- return exitCode ?? 1;
1255
+ const response = await fetch(`${apiUrl}/api/tasks?epicId=${epicId}&status=completed&limit=5`, {
1256
+ headers,
1257
+ });
1258
+ if (!response.ok) return null;
1259
+
1260
+ const data = (await response.json()) as {
1261
+ tasks: Array<{ id: string; task: string; output?: string }>;
1262
+ };
1263
+ const tasks = data.tasks || [];
1264
+
1265
+ const relevant = tasks.filter((t) => t.id !== currentTaskId);
1266
+ if (relevant.length === 0) return null;
1267
+
1268
+ let context = "\n\n### Recent Epic Task Completions\n\n";
1269
+ context += "These tasks were recently completed in the same epic:\n\n";
1270
+ for (const t of relevant.slice(0, 5)) {
1271
+ context += `- **${t.task.slice(0, 100)}**: ${(t.output || "no output").slice(0, 200)}\n`;
1272
+ }
1273
+ return context;
1274
+ } catch {
1275
+ return null;
1276
+ }
956
1277
  }
957
1278
 
958
- /** Spawn a Claude process without blocking - returns immediately with tracking info */
959
- async function spawnClaudeProcess(
960
- opts: RunClaudeIterationOptions,
1279
+ /** Spawn a provider session without blocking - returns immediately with tracking info */
1280
+ async function spawnProviderProcess(
1281
+ adapter: ReturnType<typeof createProviderAdapter>,
1282
+ opts: {
1283
+ prompt: string;
1284
+ logFile: string;
1285
+ systemPrompt?: string;
1286
+ additionalArgs?: string[];
1287
+ role: string;
1288
+ apiUrl: string;
1289
+ apiKey: string;
1290
+ agentId: string;
1291
+ runnerSessionId: string;
1292
+ iteration: number;
1293
+ taskId?: string;
1294
+ model?: string;
1295
+ cwd?: string;
1296
+ },
961
1297
  logDir: string,
962
- _metadataType: string,
963
- _sessionId: string,
964
1298
  isYolo: boolean,
965
1299
  ): Promise<RunningTask> {
966
- const { role, taskId } = opts;
967
- const Cmd = [
968
- "claude",
969
- "--model",
970
- "opus",
971
- "--verbose",
972
- "--output-format",
973
- "stream-json",
974
- "--dangerously-skip-permissions",
975
- "--allow-dangerously-skip-permissions",
976
- "--permission-mode",
977
- "bypassPermissions",
978
- "-p",
979
- opts.prompt,
980
- ];
981
-
982
- if (opts.additionalArgs && opts.additionalArgs.length > 0) {
983
- Cmd.push(...opts.additionalArgs);
1300
+ // Real task ID from DB (may be undefined for pool_tasks_available triggers)
1301
+ const realTaskId = opts.taskId;
1302
+ // Correlation ID for logs/display — always defined
1303
+ const effectiveTaskId = realTaskId || crypto.randomUUID();
1304
+
1305
+ // Resolve env first so we can use MODEL_OVERRIDE from config
1306
+ const freshEnv = await fetchResolvedEnv(opts.apiUrl, opts.apiKey, opts.agentId);
1307
+
1308
+ // Propagate agent-fs config to process.env so getBasePrompt() can read them
1309
+ // (fetchResolvedEnv returns a new object, doesn't update process.env)
1310
+ if (freshEnv.AGENT_FS_SHARED_ORG_ID) {
1311
+ process.env.AGENT_FS_SHARED_ORG_ID = freshEnv.AGENT_FS_SHARED_ORG_ID as string;
984
1312
  }
985
1313
 
986
- if (opts.systemPrompt) {
987
- Cmd.push("--append-system-prompt", opts.systemPrompt);
988
- }
989
-
990
- const effectiveTaskId = taskId || crypto.randomUUID();
991
-
992
- console.log(
993
- `\x1b[2m[${role}]\x1b[0m \x1b[36m▸\x1b[0m Spawning Claude for task ${effectiveTaskId.slice(0, 8)}`,
994
- );
995
-
996
- const logFileHandle = Bun.file(opts.logFile).writer();
1314
+ const model = opts.model || (freshEnv.MODEL_OVERRIDE as string) || "";
997
1315
 
998
- // Write task file before spawning so hook can read the current taskId
999
- // We use the parent process PID since we need to write before spawn
1000
- const taskFilePid = process.pid;
1001
- const taskFilePath = await writeTaskFile(taskFilePid, {
1316
+ const config: ProviderSessionConfig = {
1317
+ prompt: opts.prompt,
1318
+ systemPrompt: opts.systemPrompt || "",
1319
+ model,
1320
+ role: opts.role,
1321
+ agentId: opts.agentId,
1002
1322
  taskId: effectiveTaskId,
1003
- agentId: opts.agentId || "",
1004
- startedAt: new Date().toISOString(),
1005
- });
1323
+ apiUrl: opts.apiUrl,
1324
+ apiKey: opts.apiKey,
1325
+ cwd: opts.cwd || process.cwd(),
1326
+ logFile: opts.logFile,
1327
+ additionalArgs: opts.additionalArgs,
1328
+ iteration: opts.iteration,
1329
+ env: freshEnv as Record<string, string>,
1330
+ };
1006
1331
 
1007
- console.log(`\x1b[2m[${role}]\x1b[0m Task file written: ${taskFilePath}`);
1332
+ const session = await adapter.createSession(config);
1008
1333
 
1009
- const proc = Bun.spawn(Cmd, {
1010
- env: {
1011
- ...process.env,
1012
- TASK_FILE: taskFilePath,
1013
- },
1014
- stdout: "pipe",
1015
- stderr: "pipe",
1016
- });
1334
+ // Set up log streaming
1335
+ const logBuffer: LogBuffer = { lines: [], lastFlush: Date.now(), partialLine: "" };
1336
+ const shouldStream = opts.apiUrl && opts.runnerSessionId && opts.iteration;
1017
1337
 
1018
- // Create promise that resolves when process completes
1019
- const promise = (async () => {
1020
- let stderrOutput = "";
1021
- let stdoutChunks = 0;
1022
- let stderrChunks = 0;
1023
-
1024
- // Initialize log buffer for API streaming
1025
- const logBuffer: LogBuffer = { lines: [], lastFlush: Date.now(), partialLine: "" };
1026
- const shouldStream = opts.apiUrl && opts.sessionId && opts.iteration;
1027
-
1028
- const stdoutPromise = (async () => {
1029
- if (proc.stdout) {
1030
- for await (const chunk of proc.stdout) {
1031
- stdoutChunks++;
1032
- const text = new TextDecoder().decode(chunk);
1033
- logFileHandle.write(text);
1034
-
1035
- // Prepend any partial line from previous chunk
1036
- const combined = logBuffer.partialLine + text;
1037
- const parts = combined.split("\n");
1038
-
1039
- // Last element may be incomplete - save for next chunk
1040
- logBuffer.partialLine = parts.pop() || "";
1041
-
1042
- // Process only complete lines (those that ended with \n)
1043
- for (const line of parts) {
1044
- prettyPrintLine(line, role);
1045
-
1046
- // Extract cost data from result messages
1047
- if (shouldStream && line.trim()) {
1048
- try {
1049
- const json = JSON.parse(line.trim());
1050
- if (json.type === "result" && json.total_cost_usd !== undefined) {
1051
- // Extract token data from the usage object
1052
- // Claude's result JSON has: usage.input_tokens, usage.output_tokens, usage.cache_read_input_tokens, usage.cache_creation_input_tokens
1053
- const usage = json.usage as
1054
- | {
1055
- input_tokens?: number;
1056
- output_tokens?: number;
1057
- cache_read_input_tokens?: number;
1058
- cache_creation_input_tokens?: number;
1059
- }
1060
- | undefined;
1061
-
1062
- // Fire and forget - don't block the stream
1063
- saveCostData(
1064
- {
1065
- sessionId: opts.sessionId!,
1066
- taskId: opts.taskId,
1067
- agentId: opts.agentId || "",
1068
- totalCostUsd: json.total_cost_usd || 0,
1069
- inputTokens: usage?.input_tokens ?? 0,
1070
- outputTokens: usage?.output_tokens ?? 0,
1071
- cacheReadTokens: usage?.cache_read_input_tokens ?? 0,
1072
- cacheWriteTokens: usage?.cache_creation_input_tokens ?? 0,
1073
- durationMs: json.duration_ms || 0,
1074
- numTurns: json.num_turns || 1,
1075
- model: "opus",
1076
- isError: json.is_error || false,
1077
- },
1078
- opts.apiUrl!,
1079
- opts.apiKey || "",
1080
- ).catch((err) => console.warn(`[runner] Failed to save cost: ${err}`));
1081
- }
1082
- } catch {
1083
- // Ignore parse errors - not all lines are JSON
1084
- }
1338
+ // Auto-progress throttle: don't update more than once per 3 seconds
1339
+ let lastProgressTime = 0;
1340
+ const PROGRESS_THROTTLE_MS = 3000;
1085
1341
 
1086
- // Buffer for log streaming
1087
- logBuffer.lines.push(line.trim());
1088
-
1089
- const shouldFlush =
1090
- logBuffer.lines.length >= LOG_BUFFER_SIZE ||
1091
- Date.now() - logBuffer.lastFlush >= LOG_FLUSH_INTERVAL_MS;
1092
-
1093
- if (shouldFlush) {
1094
- await flushLogBuffer(logBuffer, {
1095
- apiUrl: opts.apiUrl!,
1096
- apiKey: opts.apiKey || "",
1097
- agentId: opts.agentId || "",
1098
- sessionId: opts.sessionId!,
1099
- iteration: opts.iteration!,
1100
- taskId: opts.taskId,
1101
- cli: "claude",
1102
- });
1103
- }
1104
- }
1105
- }
1342
+ session.onEvent((event) => {
1343
+ switch (event.type) {
1344
+ case "session_init":
1345
+ if (realTaskId) {
1346
+ saveProviderSessionId(opts.apiUrl, opts.apiKey, realTaskId, event.sessionId).catch(
1347
+ (err) => console.warn(`[runner] Failed to save session ID: ${err}`),
1348
+ );
1349
+ } else {
1350
+ // Pool task: save provider session ID on active session so it can be
1351
+ // propagated to the real task when the agent claims one
1352
+ saveProviderSessionIdOnActiveSession(
1353
+ opts.apiUrl,
1354
+ opts.apiKey,
1355
+ effectiveTaskId,
1356
+ event.sessionId,
1357
+ ).catch((err) =>
1358
+ console.warn(`[runner] Failed to save provider session on active session: ${err}`),
1359
+ );
1106
1360
  }
1107
-
1108
- // Handle any remaining partial line at stream end
1109
- if (logBuffer.partialLine.trim()) {
1110
- prettyPrintLine(logBuffer.partialLine, role);
1111
- if (shouldStream) {
1112
- // Try to extract cost data from final partial line
1113
- try {
1114
- const json = JSON.parse(logBuffer.partialLine.trim());
1115
- if (json.type === "result" && json.total_cost_usd !== undefined) {
1116
- const usage = json.usage as
1117
- | {
1118
- input_tokens?: number;
1119
- output_tokens?: number;
1120
- cache_read_input_tokens?: number;
1121
- cache_creation_input_tokens?: number;
1122
- }
1123
- | undefined;
1124
- saveCostData(
1125
- {
1126
- sessionId: opts.sessionId!,
1127
- taskId: opts.taskId,
1128
- agentId: opts.agentId || "",
1129
- totalCostUsd: json.total_cost_usd || 0,
1130
- inputTokens: usage?.input_tokens ?? 0,
1131
- outputTokens: usage?.output_tokens ?? 0,
1132
- cacheReadTokens: usage?.cache_read_input_tokens ?? 0,
1133
- cacheWriteTokens: usage?.cache_creation_input_tokens ?? 0,
1134
- durationMs: json.duration_ms || 0,
1135
- numTurns: json.num_turns || 1,
1136
- model: "opus",
1137
- isError: json.is_error || false,
1138
- },
1139
- opts.apiUrl!,
1140
- opts.apiKey || "",
1141
- ).catch((err) => console.warn(`[runner] Failed to save cost: ${err}`));
1142
- }
1143
- } catch {
1144
- // Ignore parse errors
1145
- }
1146
- logBuffer.lines.push(logBuffer.partialLine.trim());
1147
- }
1148
- logBuffer.partialLine = "";
1149
- }
1150
-
1151
- // Final flush for remaining buffered logs
1152
- if (shouldStream && logBuffer.lines.length > 0) {
1153
- await flushLogBuffer(logBuffer, {
1154
- apiUrl: opts.apiUrl!,
1155
- apiKey: opts.apiKey || "",
1156
- agentId: opts.agentId || "",
1157
- sessionId: opts.sessionId!,
1158
- iteration: opts.iteration!,
1159
- taskId: opts.taskId,
1160
- cli: "claude",
1161
- });
1361
+ break;
1362
+ case "tool_start": {
1363
+ // Auto-progress: report tool activity as task progress (throttled)
1364
+ const now = Date.now();
1365
+ if (effectiveTaskId && opts.apiUrl && now - lastProgressTime >= PROGRESS_THROTTLE_MS) {
1366
+ lastProgressTime = now;
1367
+ const progress = toolCallToProgress(event.toolName, event.args);
1368
+ updateProgressViaAPI(opts.apiUrl, opts.apiKey, effectiveTaskId, progress).catch(() => {});
1162
1369
  }
1370
+ break;
1163
1371
  }
1164
- })();
1165
-
1166
- const stderrPromise = (async () => {
1167
- if (proc.stderr) {
1168
- for await (const chunk of proc.stderr) {
1169
- stderrChunks++;
1170
- const text = new TextDecoder().decode(chunk);
1171
- stderrOutput += text;
1172
- prettyPrintStderr(text, role);
1173
- logFileHandle.write(
1174
- `${JSON.stringify({ type: "stderr", content: text, timestamp: new Date().toISOString() })}\n`,
1175
- );
1372
+ case "result":
1373
+ // Cost save is handled in waitForCompletion().then() to ensure
1374
+ // it completes before the process exits (fire-and-forget here
1375
+ // races with container shutdown).
1376
+ break;
1377
+ case "raw_log":
1378
+ prettyPrintLine(event.content, opts.role);
1379
+ if (shouldStream) {
1380
+ logBuffer.lines.push(event.content);
1381
+ const shouldFlush =
1382
+ logBuffer.lines.length >= LOG_BUFFER_SIZE ||
1383
+ Date.now() - logBuffer.lastFlush >= LOG_FLUSH_INTERVAL_MS;
1384
+ if (shouldFlush) {
1385
+ flushLogBuffer(logBuffer, {
1386
+ apiUrl: opts.apiUrl,
1387
+ apiKey: opts.apiKey,
1388
+ agentId: opts.agentId,
1389
+ sessionId: opts.runnerSessionId,
1390
+ iteration: opts.iteration,
1391
+ taskId: effectiveTaskId,
1392
+ cli: adapter.name,
1393
+ }).catch(() => {});
1394
+ }
1176
1395
  }
1177
- }
1178
- })();
1179
-
1180
- await Promise.all([stdoutPromise, stderrPromise]);
1181
- await logFileHandle.end();
1182
- const exitCode = await proc.exited;
1183
-
1184
- if (exitCode !== 0 && stderrOutput) {
1185
- console.error(
1186
- `\x1b[31m[${role}] Full stderr for task ${effectiveTaskId.slice(0, 8)}:\x1b[0m\n${stderrOutput}`,
1187
- );
1396
+ break;
1397
+ case "raw_stderr":
1398
+ prettyPrintStderr(event.content, opts.role);
1399
+ break;
1188
1400
  }
1401
+ });
1189
1402
 
1190
- if (stdoutChunks === 0 && stderrChunks === 0) {
1191
- console.warn(
1192
- `\x1b[33m[${role}] WARNING: No output from Claude for task ${effectiveTaskId.slice(0, 8)} - check auth/startup\x1b[0m`,
1193
- );
1403
+ // Create promise that handles completion
1404
+ const promise: Promise<ProviderResult> = session.waitForCompletion().then(async (result) => {
1405
+ // Final log flush
1406
+ if (shouldStream && logBuffer.lines.length > 0) {
1407
+ await flushLogBuffer(logBuffer, {
1408
+ apiUrl: opts.apiUrl,
1409
+ apiKey: opts.apiKey,
1410
+ agentId: opts.agentId,
1411
+ sessionId: opts.runnerSessionId,
1412
+ iteration: opts.iteration,
1413
+ taskId: effectiveTaskId,
1414
+ cli: adapter.name,
1415
+ });
1194
1416
  }
1195
1417
 
1196
- // Log errors if non-zero exit code
1197
- if (exitCode !== 0) {
1418
+ // Error logging for non-zero exit
1419
+ if (result.exitCode !== 0) {
1198
1420
  const errorLog = {
1199
1421
  timestamp: new Date().toISOString(),
1200
1422
  iteration: opts.iteration,
1201
- exitCode,
1423
+ exitCode: result.exitCode,
1202
1424
  taskId: effectiveTaskId,
1203
1425
  error: true,
1204
1426
  };
@@ -1210,29 +1432,99 @@ async function spawnClaudeProcess(
1210
1432
 
1211
1433
  if (!isYolo) {
1212
1434
  console.error(
1213
- `[${role}] Task ${effectiveTaskId.slice(0, 8)} exited with code ${exitCode}.`,
1435
+ `[${opts.role}] Task ${effectiveTaskId.slice(0, 8)} exited with code ${result.exitCode}.`,
1214
1436
  );
1215
1437
  } else {
1216
1438
  console.warn(
1217
- `[${role}] Task ${effectiveTaskId.slice(0, 8)} exited with code ${exitCode}. YOLO mode - continuing...`,
1439
+ `[${opts.role}] Task ${effectiveTaskId.slice(0, 8)} exited with code ${result.exitCode}. YOLO mode - continuing...`,
1218
1440
  );
1219
1441
  }
1220
1442
  }
1221
1443
 
1222
- // Clean up task file after process exits
1223
- await cleanupTaskFile(taskFilePid);
1224
- console.log(`\x1b[2m[${role}]\x1b[0m Task file cleaned up: ${taskFilePath}`);
1444
+ // Save cost data (awaited to ensure it completes before container exits)
1445
+ if (result.cost) {
1446
+ try {
1447
+ await saveCostData(
1448
+ { ...result.cost, taskId: realTaskId, sessionId: opts.runnerSessionId },
1449
+ opts.apiUrl,
1450
+ opts.apiKey,
1451
+ );
1452
+ } catch (err) {
1453
+ console.warn(`[runner] Failed to save cost: ${err}`);
1454
+ }
1455
+ }
1225
1456
 
1226
- return exitCode ?? 1;
1227
- })();
1457
+ return result;
1458
+ });
1228
1459
 
1229
- return {
1460
+ const runningTask: RunningTask = {
1230
1461
  taskId: effectiveTaskId,
1231
- process: proc,
1462
+ session,
1232
1463
  logFile: opts.logFile,
1233
1464
  startTime: new Date(),
1234
1465
  promise,
1466
+ result: null,
1235
1467
  };
1468
+
1469
+ // Non-blocking completion tracking
1470
+ promise
1471
+ .then((r) => {
1472
+ runningTask.result = r;
1473
+ })
1474
+ .catch(() => {
1475
+ runningTask.result = { exitCode: 1, isError: true };
1476
+ });
1477
+
1478
+ return runningTask;
1479
+ }
1480
+
1481
+ /** Run a single provider iteration (blocking) - used for AI-loop mode */
1482
+ async function runProviderIteration(
1483
+ adapter: ReturnType<typeof createProviderAdapter>,
1484
+ opts: {
1485
+ prompt: string;
1486
+ logFile: string;
1487
+ systemPrompt?: string;
1488
+ additionalArgs?: string[];
1489
+ role: string;
1490
+ apiUrl: string;
1491
+ apiKey: string;
1492
+ agentId: string;
1493
+ taskId?: string;
1494
+ cwd?: string;
1495
+ },
1496
+ ): Promise<ProviderResult> {
1497
+ const freshEnv = await fetchResolvedEnv(opts.apiUrl, opts.apiKey, opts.agentId);
1498
+ const model = (freshEnv.MODEL_OVERRIDE as string) || "";
1499
+
1500
+ const config: ProviderSessionConfig = {
1501
+ prompt: opts.prompt,
1502
+ systemPrompt: opts.systemPrompt || "",
1503
+ model,
1504
+ role: opts.role,
1505
+ agentId: opts.agentId,
1506
+ taskId: opts.taskId || crypto.randomUUID(),
1507
+ apiUrl: opts.apiUrl,
1508
+ apiKey: opts.apiKey,
1509
+ cwd: opts.cwd || process.cwd(),
1510
+ logFile: opts.logFile,
1511
+ additionalArgs: opts.additionalArgs,
1512
+ env: freshEnv as Record<string, string>,
1513
+ };
1514
+
1515
+ const session = await adapter.createSession(config);
1516
+
1517
+ session.onEvent((event) => {
1518
+ if (event.type === "raw_log") prettyPrintLine(event.content, opts.role);
1519
+ if (event.type === "raw_stderr") prettyPrintStderr(event.content, opts.role);
1520
+ if (event.type === "session_init" && opts.taskId) {
1521
+ saveProviderSessionId(opts.apiUrl, opts.apiKey, opts.taskId, event.sessionId).catch((err) =>
1522
+ console.warn(`[runner] Failed to save session ID: ${err}`),
1523
+ );
1524
+ }
1525
+ });
1526
+
1527
+ return session.waitForCompletion();
1236
1528
  }
1237
1529
 
1238
1530
  /** Check for completed processes and remove them from active tasks */
@@ -1241,32 +1533,134 @@ async function checkCompletedProcesses(
1241
1533
  role: string,
1242
1534
  apiConfig?: ApiConfig,
1243
1535
  ): Promise<void> {
1244
- const completedTasks: Array<{ taskId: string; exitCode: number }> = [];
1536
+ const completedTasks: Array<{
1537
+ taskId: string;
1538
+ result: ProviderResult;
1539
+ triggerType?: string;
1540
+ cursorUpdates?: Array<{ channelId: string; ts: string }>;
1541
+ }> = [];
1245
1542
 
1246
1543
  for (const [taskId, task] of state.activeTasks) {
1247
- // Check if the Bun subprocess has exited (non-blocking)
1248
- if (task.process.exitCode !== null) {
1544
+ // Non-blocking check: result is set by a .then() callback when the promise resolves
1545
+ if (task.result !== null) {
1249
1546
  console.log(
1250
- `[${role}] Task ${taskId.slice(0, 8)} completed with exit code ${task.process.exitCode}`,
1547
+ `[${role}] Task ${taskId.slice(0, 8)} completed with exit code ${task.result.exitCode} (trigger: ${task.triggerType || "unknown"})`,
1251
1548
  );
1252
- completedTasks.push({ taskId, exitCode: task.process.exitCode });
1549
+ completedTasks.push({
1550
+ taskId,
1551
+ result: task.result,
1552
+ triggerType: task.triggerType,
1553
+ cursorUpdates: task.cursorUpdates,
1554
+ });
1253
1555
  }
1254
1556
  }
1255
1557
 
1256
1558
  // Remove completed tasks from the map and ensure they're marked as finished
1257
- for (const { taskId, exitCode } of completedTasks) {
1559
+ for (const { taskId, result, cursorUpdates } of completedTasks) {
1258
1560
  state.activeTasks.delete(taskId);
1259
1561
 
1562
+ if (apiConfig) {
1563
+ removeActiveSession(apiConfig, taskId);
1564
+ }
1565
+
1260
1566
  // Call the finish API to ensure task status is updated
1261
1567
  // This is idempotent - if the agent already marked it, this is a no-op
1262
1568
  if (apiConfig) {
1263
- await ensureTaskFinished(apiConfig, role, taskId, exitCode);
1569
+ let failureReason: string | undefined;
1570
+ if (result.exitCode !== 0 && result.failureReason) {
1571
+ failureReason = result.failureReason;
1572
+ console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
1573
+ }
1574
+ await ensureTaskFinished(apiConfig, role, taskId, result.exitCode, failureReason);
1575
+
1576
+ // Commit channel activity cursors after successful processing
1577
+ // If the task failed, cursors stay uncommitted so messages are re-seen on next poll
1578
+ if (cursorUpdates && cursorUpdates.length > 0 && result.exitCode === 0) {
1579
+ try {
1580
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
1581
+ if (apiConfig.apiKey) headers.Authorization = `Bearer ${apiConfig.apiKey}`;
1582
+ await fetch(`${apiConfig.apiUrl}/api/channel-activity/commit-cursors`, {
1583
+ method: "POST",
1584
+ headers,
1585
+ body: JSON.stringify({ cursorUpdates }),
1586
+ });
1587
+ console.log(
1588
+ `[${role}] Committed ${cursorUpdates.length} channel activity cursor(s) for task ${taskId.slice(0, 8)}`,
1589
+ );
1590
+ } catch (err) {
1591
+ console.warn(`[${role}] Failed to commit channel activity cursors: ${err}`);
1592
+ }
1593
+ }
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ const TEMPLATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
1599
+
1600
+ async function fetchTemplate(
1601
+ templateId: string,
1602
+ registryUrl: string,
1603
+ cacheDir: string,
1604
+ ): Promise<TemplateResponse | null> {
1605
+ const safeId = templateId.replace(/[^a-zA-Z0-9_-]/g, "_");
1606
+ const cachePath = `${cacheDir}/${safeId}.json`;
1607
+
1608
+ // Check local cache
1609
+ try {
1610
+ const info = await stat(cachePath);
1611
+ if (Date.now() - info.mtimeMs < TEMPLATE_CACHE_TTL_MS) {
1612
+ const cached = await readFile(cachePath, "utf-8");
1613
+ return JSON.parse(cached) as TemplateResponse;
1614
+ }
1615
+ } catch {
1616
+ // No cache or expired, continue to fetch
1617
+ }
1618
+
1619
+ // Fetch from registry
1620
+ try {
1621
+ const resp = await fetch(`${registryUrl}/api/templates/${templateId}`);
1622
+ if (!resp.ok) {
1623
+ console.warn(`[template] Registry returned ${resp.status} for ${templateId}`);
1624
+ // Fall back to expired cache if available
1625
+ try {
1626
+ const cached = await readFile(cachePath, "utf-8");
1627
+ console.log(`[template] Using expired cache for ${templateId}`);
1628
+ return JSON.parse(cached) as TemplateResponse;
1629
+ } catch {
1630
+ return null;
1631
+ }
1632
+ }
1633
+
1634
+ const template = (await resp.json()) as TemplateResponse;
1635
+
1636
+ // Cache the response
1637
+ try {
1638
+ await mkdir(cacheDir, { recursive: true });
1639
+ await writeFile(cachePath, JSON.stringify(template), "utf-8");
1640
+ } catch {
1641
+ console.warn(`[template] Could not cache template to ${cachePath}`);
1642
+ }
1643
+
1644
+ return template;
1645
+ } catch (err) {
1646
+ console.warn(`[template] Failed to fetch from registry: ${err}`);
1647
+ // Fall back to expired cache
1648
+ try {
1649
+ const cached = await readFile(cachePath, "utf-8");
1650
+ console.log(`[template] Using expired cache for ${templateId}`);
1651
+ return JSON.parse(cached) as TemplateResponse;
1652
+ } catch {
1653
+ return null;
1264
1654
  }
1265
1655
  }
1266
1656
  }
1267
1657
 
1268
1658
  export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1269
- const { role, defaultPrompt, metadataType } = config;
1659
+ const { defaultPrompt, metadataType } = config;
1660
+ let role = config.role;
1661
+
1662
+ // Create provider adapter based on HARNESS_PROVIDER env var (default: claude)
1663
+ const adapter = createProviderAdapter(process.env.HARNESS_PROVIDER || "claude");
1270
1664
 
1271
1665
  const sessionId = process.env.SESSION_ID || crypto.randomUUID().slice(0, 8);
1272
1666
  const baseLogDir = opts.logsDir || process.env.LOG_DIR || "/logs";
@@ -1280,13 +1674,46 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1280
1674
  // Get agent identity and swarm URL for base prompt
1281
1675
  const agentId = process.env.AGENT_ID || "unknown";
1282
1676
 
1283
- const apiUrl = process.env.MCP_BASE_URL || "http://localhost:3013";
1677
+ const apiUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
1284
1678
  const swarmUrl = process.env.SWARM_URL || "localhost";
1285
1679
 
1286
- const capabilities = config.capabilities;
1680
+ // Configure HTTP-based template resolution (workers resolve via API, not local DB)
1681
+ if (process.env.API_KEY) {
1682
+ configureHttpResolver(apiUrl, process.env.API_KEY);
1683
+ }
1684
+
1685
+ let capabilities = config.capabilities;
1686
+
1687
+ // Agent identity fields — populated after registration by fetching full profile
1688
+ let agentSoulMd: string | undefined;
1689
+ let agentIdentityMd: string | undefined;
1690
+ let agentSetupScript: string | undefined;
1691
+ let agentToolsMd: string | undefined;
1692
+ let agentClaudeMd: string | undefined;
1693
+ let agentProfileName: string | undefined;
1694
+ let agentDescription: string | undefined;
1695
+
1696
+ // Per-task repo context — set when processing a task with githubRepo
1697
+ let currentRepoContext: BasePromptArgs["repoContext"] | undefined;
1698
+
1699
+ // Generate base prompt (identity fields injected after profile fetch below)
1700
+ const buildSystemPrompt = async () => {
1701
+ return getBasePrompt({
1702
+ role,
1703
+ agentId,
1704
+ swarmUrl,
1705
+ capabilities,
1706
+ name: agentProfileName,
1707
+ description: agentDescription,
1708
+ soulMd: agentSoulMd,
1709
+ identityMd: agentIdentityMd,
1710
+ toolsMd: agentToolsMd,
1711
+ claudeMd: agentClaudeMd,
1712
+ repoContext: currentRepoContext,
1713
+ });
1714
+ };
1287
1715
 
1288
- // Generate base prompt that's always included
1289
- const basePrompt = getBasePrompt({ role, agentId, swarmUrl, capabilities });
1716
+ let basePrompt = await buildSystemPrompt();
1290
1717
 
1291
1718
  // Resolve additional system prompt: CLI flag > env var
1292
1719
  let additionalSystemPrompt: string | undefined;
@@ -1318,7 +1745,8 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1318
1745
  }
1319
1746
 
1320
1747
  // Combine base prompt with any additional system prompt
1321
- const resolvedSystemPrompt = additionalSystemPrompt
1748
+ // Note: resolvedSystemPrompt is rebuilt after profile fetch when identity is available
1749
+ let resolvedSystemPrompt = additionalSystemPrompt
1322
1750
  ? `${basePrompt}\n\n${additionalSystemPrompt}`
1323
1751
  : basePrompt;
1324
1752
 
@@ -1346,8 +1774,38 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1346
1774
  let iteration = 0;
1347
1775
 
1348
1776
  if (!isAiLoop) {
1777
+ // Fetch template early (before registration) so defaults can be applied
1778
+ const templateId = process.env.TEMPLATE_ID;
1779
+ const registryUrl = process.env.TEMPLATE_REGISTRY_URL || "https://templates.agent-swarm.dev";
1780
+ let cachedTemplate: TemplateResponse | null = null;
1781
+
1782
+ if (templateId) {
1783
+ try {
1784
+ cachedTemplate = await fetchTemplate(templateId, registryUrl, "/workspace/.template-cache");
1785
+ if (cachedTemplate) {
1786
+ console.log(`[${role}] Fetched template: ${templateId}`);
1787
+
1788
+ // Apply agentDefaults as fallbacks (env/config takes precedence)
1789
+ const defaults = cachedTemplate.config.agentDefaults;
1790
+ if (config.role === "worker" && defaults.role) {
1791
+ role = defaults.role;
1792
+ }
1793
+ if (!capabilities?.length && defaults.capabilities?.length) {
1794
+ capabilities = defaults.capabilities;
1795
+ }
1796
+ }
1797
+ } catch (err) {
1798
+ console.warn(`[${role}] Failed to fetch template ${templateId}: ${err}`);
1799
+ }
1800
+ }
1801
+
1349
1802
  // Runner-level polling mode with parallel execution support
1350
- const maxConcurrent = parseInt(process.env.MAX_CONCURRENT_TASKS || "1", 10);
1803
+ const isLeadFromConfig = config.role === "lead";
1804
+ const isLead = isLeadFromConfig || (cachedTemplate?.config.agentDefaults?.isLead ?? false);
1805
+ const defaultMaxTasks = isLead ? 2 : 1;
1806
+ const maxConcurrent = process.env.MAX_CONCURRENT_TASKS
1807
+ ? parseInt(process.env.MAX_CONCURRENT_TASKS, 10)
1808
+ : (cachedTemplate?.config.agentDefaults?.maxTasks ?? defaultMaxTasks);
1351
1809
  console.log(`[${role}] Mode: runner-level polling (use --ai-loop for AI-based polling)`);
1352
1810
  console.log(`[${role}] Max concurrent tasks: ${maxConcurrent}`);
1353
1811
 
@@ -1357,6 +1815,9 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1357
1815
  maxConcurrent,
1358
1816
  };
1359
1817
 
1818
+ // Track tasks already signaled for cancellation to avoid repeated SIGTERM
1819
+ const cancelledSignaled = new Set<string>();
1820
+
1360
1821
  // Create API config for ping/close
1361
1822
  const apiConfig: ApiConfig = { apiUrl, apiKey, agentId };
1362
1823
 
@@ -1364,15 +1825,19 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1364
1825
  setupShutdownHandlers(role, apiConfig, () => state);
1365
1826
 
1366
1827
  // Register agent before starting
1367
- const agentName = process.env.AGENT_NAME || `${role}-${agentId.slice(0, 8)}`;
1828
+ const agentName =
1829
+ process.env.AGENT_NAME ||
1830
+ cachedTemplate?.config.displayName ||
1831
+ `${role}-${agentId.slice(0, 8)}`;
1368
1832
  try {
1369
1833
  await registerAgent({
1370
1834
  apiUrl,
1371
1835
  apiKey,
1372
1836
  agentId,
1373
1837
  name: agentName,
1374
- isLead: role === "lead",
1375
- capabilities: config.capabilities,
1838
+ role,
1839
+ isLead,
1840
+ capabilities,
1376
1841
  maxTasks: maxConcurrent,
1377
1842
  });
1378
1843
  console.log(`[${role}] Registered as "${agentName}" (ID: ${agentId})`);
@@ -1381,6 +1846,165 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1381
1846
  process.exit(1);
1382
1847
  }
1383
1848
 
1849
+ // Clean up any stale active sessions from previous runs (crash recovery)
1850
+ await cleanupActiveSessions(apiConfig);
1851
+ console.log(`[${role}] Cleaned up stale active sessions`);
1852
+
1853
+ // Fetch full agent profile to get soul/identity content
1854
+ try {
1855
+ const resp = await fetch(`${apiUrl}/me`, {
1856
+ headers: {
1857
+ Authorization: `Bearer ${apiKey}`,
1858
+ "X-Agent-ID": agentId,
1859
+ },
1860
+ });
1861
+ if (resp.ok) {
1862
+ const profile = (await resp.json()) as {
1863
+ soulMd?: string;
1864
+ identityMd?: string;
1865
+ claudeMd?: string;
1866
+ setupScript?: string;
1867
+ toolsMd?: string;
1868
+ name?: string;
1869
+ description?: string;
1870
+ };
1871
+ agentSoulMd = profile.soulMd;
1872
+ agentIdentityMd = profile.identityMd;
1873
+ agentSetupScript = profile.setupScript;
1874
+ agentToolsMd = profile.toolsMd;
1875
+ agentClaudeMd = profile.claudeMd;
1876
+ agentProfileName = profile.name;
1877
+ agentDescription = profile.description;
1878
+
1879
+ // Generate default templates if missing (runner registers via POST /api/agents
1880
+ // which doesn't generate templates like join-swarm does)
1881
+ if (!agentSoulMd || !agentIdentityMd || !agentToolsMd || !agentClaudeMd) {
1882
+ // Use already-fetched template (from pre-registration step)
1883
+ if (cachedTemplate) {
1884
+ const ctx = {
1885
+ agent: {
1886
+ name: agentProfileName || agentName,
1887
+ role: role,
1888
+ description: agentDescription || "",
1889
+ capabilities: (capabilities || []).join(", "),
1890
+ },
1891
+ };
1892
+ if (!agentSoulMd) agentSoulMd = interpolate(cachedTemplate.files.soulMd, ctx).result;
1893
+ if (!agentIdentityMd)
1894
+ agentIdentityMd = interpolate(cachedTemplate.files.identityMd, ctx).result;
1895
+ if (!agentToolsMd) agentToolsMd = interpolate(cachedTemplate.files.toolsMd, ctx).result;
1896
+ if (!agentClaudeMd)
1897
+ agentClaudeMd = interpolate(cachedTemplate.files.claudeMd, ctx).result;
1898
+ if (!agentSetupScript)
1899
+ agentSetupScript = interpolate(cachedTemplate.files.setupScript, ctx).result;
1900
+ console.log(`[${role}] Applied template: ${templateId}`);
1901
+ }
1902
+
1903
+ // Fallback to generic defaults for any still-missing fields
1904
+ const agentInfo = {
1905
+ name: agentProfileName || agentName,
1906
+ role: role,
1907
+ description: agentDescription,
1908
+ capabilities: config.capabilities,
1909
+ };
1910
+ if (!agentSoulMd) agentSoulMd = generateDefaultSoulMd(agentInfo);
1911
+ if (!agentIdentityMd) agentIdentityMd = generateDefaultIdentityMd(agentInfo);
1912
+ if (!agentToolsMd) agentToolsMd = generateDefaultToolsMd(agentInfo);
1913
+ if (!agentClaudeMd) agentClaudeMd = generateDefaultClaudeMd(agentInfo);
1914
+
1915
+ // Push generated templates to server
1916
+ try {
1917
+ const profileUpdate: Record<string, string> = {};
1918
+ if (!profile.soulMd) profileUpdate.soulMd = agentSoulMd;
1919
+ if (!profile.identityMd) profileUpdate.identityMd = agentIdentityMd;
1920
+ if (!profile.toolsMd) profileUpdate.toolsMd = agentToolsMd;
1921
+ if (!profile.claudeMd && agentClaudeMd) profileUpdate.claudeMd = agentClaudeMd;
1922
+ if (!profile.setupScript && agentSetupScript)
1923
+ profileUpdate.setupScript = agentSetupScript;
1924
+
1925
+ await fetch(`${apiUrl}/api/agents/${agentId}/profile`, {
1926
+ method: "PUT",
1927
+ headers: {
1928
+ Authorization: `Bearer ${apiKey}`,
1929
+ "X-Agent-ID": agentId,
1930
+ "Content-Type": "application/json",
1931
+ },
1932
+ body: JSON.stringify(profileUpdate),
1933
+ });
1934
+ console.log(`[${role}] Generated and saved default identity templates`);
1935
+ } catch {
1936
+ console.warn(`[${role}] Could not save generated templates to server`);
1937
+ }
1938
+ }
1939
+
1940
+ // Rebuild system prompt with identity
1941
+ basePrompt = await buildSystemPrompt();
1942
+ resolvedSystemPrompt = additionalSystemPrompt
1943
+ ? `${basePrompt}\n\n${additionalSystemPrompt}`
1944
+ : basePrompt;
1945
+ console.log(
1946
+ `[${role}] Loaded agent identity (soul: ${agentSoulMd ? "yes" : "no"}, identity: ${agentIdentityMd ? "yes" : "no"}, tools: ${agentToolsMd ? "yes" : "no"}, claude: ${agentClaudeMd ? "yes" : "no"})`,
1947
+ );
1948
+ console.log(`[${role}] Updated system prompt length: ${resolvedSystemPrompt.length} chars`);
1949
+ }
1950
+ } catch {
1951
+ console.warn(`[${role}] Could not fetch agent profile for identity — proceeding without`);
1952
+ }
1953
+
1954
+ // Write SOUL.md and IDENTITY.md to workspace before spawning Claude
1955
+ const SOUL_MD_PATH = "/workspace/SOUL.md";
1956
+ const IDENTITY_MD_PATH = "/workspace/IDENTITY.md";
1957
+
1958
+ if (agentSoulMd) {
1959
+ try {
1960
+ await Bun.write(SOUL_MD_PATH, agentSoulMd);
1961
+ console.log(`[${role}] Wrote SOUL.md to workspace`);
1962
+ } catch (err) {
1963
+ console.warn(`[${role}] Could not write SOUL.md: ${(err as Error).message}`);
1964
+ }
1965
+ }
1966
+ if (agentIdentityMd) {
1967
+ try {
1968
+ await Bun.write(IDENTITY_MD_PATH, agentIdentityMd);
1969
+ console.log(`[${role}] Wrote IDENTITY.md to workspace`);
1970
+ } catch (err) {
1971
+ console.warn(`[${role}] Could not write IDENTITY.md: ${(err as Error).message}`);
1972
+ }
1973
+ }
1974
+
1975
+ // Write setup script to workspace (agent can edit during session)
1976
+ // Only create if it doesn't exist — the entrypoint already composed/prepended it at container start
1977
+ if (agentSetupScript) {
1978
+ try {
1979
+ if (!(await Bun.file("/workspace/start-up.sh").exists())) {
1980
+ await Bun.write("/workspace/start-up.sh", `#!/bin/bash\n${agentSetupScript}\n`);
1981
+ console.log(`[${role}] Wrote start-up.sh to workspace`);
1982
+ }
1983
+ } catch (err) {
1984
+ console.warn(`[${role}] Could not write start-up.sh: ${(err as Error).message}`);
1985
+ }
1986
+ }
1987
+
1988
+ // Write TOOLS.md to workspace (agent can edit during session)
1989
+ if (agentToolsMd) {
1990
+ try {
1991
+ await Bun.write("/workspace/TOOLS.md", agentToolsMd);
1992
+ console.log(`[${role}] Wrote TOOLS.md to workspace`);
1993
+ } catch (err) {
1994
+ console.warn(`[${role}] Could not write TOOLS.md: ${(err as Error).message}`);
1995
+ }
1996
+ }
1997
+
1998
+ // Write CLAUDE.md to workspace (agent-level instructions)
1999
+ if (agentClaudeMd) {
2000
+ try {
2001
+ await Bun.write("/workspace/CLAUDE.md", agentClaudeMd);
2002
+ console.log(`[${role}] Wrote CLAUDE.md to workspace`);
2003
+ } catch (err) {
2004
+ console.warn(`[${role}] Could not write CLAUDE.md: ${(err as Error).message}`);
2005
+ }
2006
+ }
2007
+
1384
2008
  // ========== Resume paused tasks with PRIORITY ==========
1385
2009
  // Check for paused tasks from previous shutdown and resume them before normal polling
1386
2010
  try {
@@ -1391,6 +2015,14 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1391
2015
  console.log(`[${role}] Found ${pausedTasks.length} paused task(s) to resume`);
1392
2016
 
1393
2017
  for (const task of pausedTasks) {
2018
+ // Defensive: skip tasks that already have completion data (zombie prevention)
2019
+ if (task.finishedAt || task.output) {
2020
+ console.warn(
2021
+ `[${role}] Skipping zombie task ${task.id.slice(0, 8)} — already has completion data (finishedAt: ${!!task.finishedAt}, output: ${!!task.output})`,
2022
+ );
2023
+ continue;
2024
+ }
2025
+
1394
2026
  // Wait if at capacity (though unlikely on fresh startup)
1395
2027
  while (state.activeTasks.size >= state.maxConcurrent) {
1396
2028
  await checkCompletedProcesses(state, role, apiConfig);
@@ -1410,8 +2042,35 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1410
2042
  continue;
1411
2043
  }
1412
2044
 
1413
- // Build prompt with resume context
1414
- const resumePrompt = buildResumePrompt(task);
2045
+ // Build prompt with resume context + memory injection
2046
+ let resumePrompt = await buildResumePrompt(task, adapter.formatCommand.bind(adapter));
2047
+
2048
+ // Inject relevant memories for resumed tasks
2049
+ const resumeMemoryContext = await fetchRelevantMemories(
2050
+ apiUrl,
2051
+ apiKey,
2052
+ agentId,
2053
+ task.task,
2054
+ );
2055
+ if (resumeMemoryContext) {
2056
+ resumePrompt += resumeMemoryContext;
2057
+ console.log(`[${role}] Injected relevant memories into resumed task prompt`);
2058
+ }
2059
+
2060
+ // Resolve --resume: prefer own session ID, then parent's
2061
+ let resumeAdditionalArgs = opts.additionalArgs || [];
2062
+ if (task.claudeSessionId) {
2063
+ resumeAdditionalArgs = [...resumeAdditionalArgs, "--resume", task.claudeSessionId];
2064
+ console.log(
2065
+ `[${role}] Resuming task's own session ${task.claudeSessionId.slice(0, 8)}`,
2066
+ );
2067
+ } else if (task.parentTaskId) {
2068
+ const parentSessionId = await fetchProviderSessionId(apiUrl, apiKey, task.parentTaskId);
2069
+ if (parentSessionId) {
2070
+ resumeAdditionalArgs = [...resumeAdditionalArgs, "--resume", parentSessionId];
2071
+ console.log(`[${role}] Resuming parent session ${parentSessionId.slice(0, 8)}`);
2072
+ }
2073
+ }
1415
2074
 
1416
2075
  // Spawn Claude process for resumed task
1417
2076
  iteration++;
@@ -1434,27 +2093,69 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1434
2093
  };
1435
2094
  await Bun.write(logFile, `${JSON.stringify(metadata)}\n`);
1436
2095
 
1437
- const runningTask = await spawnClaudeProcess(
2096
+ // Resolve cwd for resumed task (mirrors normal task path: task.dir > vcsRepo clonePath)
2097
+ let resumeCwd: string | undefined;
2098
+ if (task.dir) {
2099
+ try {
2100
+ if (existsSync(task.dir) && statSync(task.dir).isDirectory()) {
2101
+ resumeCwd = task.dir;
2102
+ } else {
2103
+ console.warn(
2104
+ `[${role}] Resume task dir "${task.dir}" does not exist or is not a directory, falling back to default cwd`,
2105
+ );
2106
+ }
2107
+ } catch {
2108
+ console.warn(
2109
+ `[${role}] Failed to check resume task dir "${task.dir}", falling back to default cwd`,
2110
+ );
2111
+ }
2112
+ }
2113
+
2114
+ if (!resumeCwd && task.vcsRepo && apiUrl) {
2115
+ const repoConfig = await fetchRepoConfig(apiUrl, apiKey, task.vcsRepo);
2116
+ const effectiveConfig = repoConfig ?? {
2117
+ url: task.vcsRepo,
2118
+ name: task.vcsRepo.split("/").pop() || task.vcsRepo,
2119
+ clonePath: `/workspace/repos/${task.vcsRepo.split("/").pop() || task.vcsRepo}`,
2120
+ defaultBranch: "main",
2121
+ };
2122
+ const repoContext = await ensureRepoForTask(effectiveConfig, role);
2123
+ if (repoContext?.clonePath) {
2124
+ resumeCwd = repoContext.clonePath;
2125
+ }
2126
+ }
2127
+
2128
+ // Per-task runner session ID so session logs are scoped to this task
2129
+ const resumeRunnerSessionId = crypto.randomUUID();
2130
+
2131
+ const runningTask = await spawnProviderProcess(
2132
+ adapter,
1438
2133
  {
1439
2134
  prompt: resumePrompt,
1440
2135
  logFile,
1441
2136
  systemPrompt: resolvedSystemPrompt,
1442
- additionalArgs: opts.additionalArgs,
2137
+ additionalArgs: resumeAdditionalArgs,
1443
2138
  role,
1444
2139
  apiUrl,
1445
2140
  apiKey,
1446
2141
  agentId,
1447
- sessionId,
2142
+ runnerSessionId: resumeRunnerSessionId,
1448
2143
  iteration,
1449
2144
  taskId: task.id,
2145
+ model: (task as { model?: string }).model,
2146
+ cwd: resumeCwd,
1450
2147
  },
1451
2148
  logDir,
1452
- metadataType,
1453
- sessionId,
1454
2149
  isYolo,
1455
2150
  );
1456
2151
 
1457
2152
  state.activeTasks.set(task.id, runningTask);
2153
+ registerActiveSession(apiConfig, {
2154
+ taskId: task.id,
2155
+ triggerType: "task_resumed",
2156
+ taskDescription: task.task?.slice(0, 200),
2157
+ runnerSessionId: resumeRunnerSessionId,
2158
+ });
1458
2159
  console.log(
1459
2160
  `[${role}] Resumed task ${task.id.slice(0, 8)} (${state.activeTasks.size}/${state.maxConcurrent} active)`,
1460
2161
  );
@@ -1471,8 +2172,6 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1471
2172
  // ========== END: Resume paused tasks ==========
1472
2173
 
1473
2174
  // Track last finished task check for leads (to avoid re-processing)
1474
- let lastFinishedTaskCheck: string | undefined;
1475
-
1476
2175
  while (true) {
1477
2176
  // Ping server on each iteration to keep status updated
1478
2177
  await pingServer(apiConfig, role);
@@ -1480,6 +2179,38 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1480
2179
  // Check for completed processes first and ensure tasks are marked as finished
1481
2180
  await checkCompletedProcesses(state, role, apiConfig);
1482
2181
 
2182
+ // Check for cancelled tasks and signal their subprocesses
2183
+ if (state.activeTasks.size > 0) {
2184
+ for (const [taskId, task] of state.activeTasks) {
2185
+ if (cancelledSignaled.has(taskId)) continue; // Already sent SIGTERM
2186
+ try {
2187
+ const cancelResp = await fetch(
2188
+ `${apiUrl}/cancelled-tasks?taskId=${encodeURIComponent(taskId)}`,
2189
+ {
2190
+ headers: {
2191
+ Authorization: `Bearer ${apiKey}`,
2192
+ "X-Agent-ID": agentId,
2193
+ },
2194
+ },
2195
+ );
2196
+ if (cancelResp.ok) {
2197
+ const cancelData = (await cancelResp.json()) as {
2198
+ cancelled: Array<{ id: string }>;
2199
+ };
2200
+ if (cancelData.cancelled?.some((t) => t.id === taskId)) {
2201
+ console.log(
2202
+ `[${role}] Task ${taskId.slice(0, 8)} was cancelled — sending SIGTERM to subprocess`,
2203
+ );
2204
+ task.session.abort().catch(() => {});
2205
+ cancelledSignaled.add(taskId);
2206
+ }
2207
+ }
2208
+ } catch {
2209
+ // Non-blocking — cancellation check is best-effort
2210
+ }
2211
+ }
2212
+ }
2213
+
1483
2214
  // Only poll if we have capacity
1484
2215
  if (state.activeTasks.size < state.maxConcurrent) {
1485
2216
  console.log(
@@ -1495,19 +2226,160 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1495
2226
  agentId,
1496
2227
  pollInterval: PollIntervalMs,
1497
2228
  pollTimeout: effectiveTimeout,
1498
- since: lastFinishedTaskCheck,
1499
2229
  });
1500
2230
 
1501
2231
  if (trigger) {
1502
- // After getting a tasks_finished trigger, update the timestamp
1503
- if (trigger.type === "tasks_finished") {
1504
- lastFinishedTaskCheck = new Date().toISOString();
1505
- }
1506
-
1507
2232
  console.log(`[${role}] Trigger received: ${trigger.type}`);
1508
2233
 
1509
2234
  // Build prompt based on trigger
1510
- const triggerPrompt = buildPromptForTrigger(trigger, prompt);
2235
+ let triggerPrompt = await buildPromptForTrigger(
2236
+ trigger,
2237
+ prompt,
2238
+ adapter.formatCommand.bind(adapter),
2239
+ );
2240
+
2241
+ // Enrich prompt with relevant memories from past sessions
2242
+ if (trigger.type === "task_assigned" || trigger.type === "task_offered") {
2243
+ const task =
2244
+ trigger.task && typeof trigger.task === "object" && "task" in trigger.task
2245
+ ? (trigger.task as { task: string; epicId?: string; id?: string })
2246
+ : null;
2247
+ if (task?.task) {
2248
+ // Enrich search query with epic context for better memory retrieval
2249
+ let searchQuery = task.task;
2250
+ if (task.epicId) {
2251
+ const epicContext = await fetchEpicNameAndGoal(apiUrl, apiKey, task.epicId);
2252
+ if (epicContext) {
2253
+ searchQuery = `[Epic: ${epicContext.name}] ${epicContext.goal}\n\n${task.task}`;
2254
+ }
2255
+ }
2256
+
2257
+ const memoryContext = await fetchRelevantMemories(
2258
+ apiUrl,
2259
+ apiKey,
2260
+ agentId,
2261
+ searchQuery,
2262
+ );
2263
+ if (memoryContext) {
2264
+ triggerPrompt += memoryContext;
2265
+ console.log(`[${role}] Injected relevant memories into task prompt`);
2266
+ }
2267
+
2268
+ // Inject recent completed task summaries from the same epic
2269
+ if (task.epicId && task.id) {
2270
+ const epicTaskContext = await fetchEpicTaskContext(
2271
+ apiUrl,
2272
+ apiKey,
2273
+ task.epicId,
2274
+ task.id,
2275
+ );
2276
+ if (epicTaskContext) {
2277
+ triggerPrompt += epicTaskContext;
2278
+ console.log(`[${role}] Injected epic task context into prompt`);
2279
+ }
2280
+ }
2281
+ }
2282
+ }
2283
+
2284
+ // For epic progress triggers, search memories related to the epic goals
2285
+ if (trigger.type === "epic_progress_changed" && trigger.epics) {
2286
+ const epics = trigger.epics as Array<{
2287
+ epic: { name: string; goal: string };
2288
+ }>;
2289
+ const epicQueries = epics.map((e) => `${e.epic.name}: ${e.epic.goal}`).join("\n");
2290
+ const memoryContext = await fetchRelevantMemories(apiUrl, apiKey, agentId, epicQueries);
2291
+ if (memoryContext) {
2292
+ triggerPrompt += memoryContext;
2293
+ console.log(`[${role}] Injected memories into epic progress prompt`);
2294
+ }
2295
+ }
2296
+
2297
+ // Resolve --resume for child tasks with parentTaskId
2298
+ let effectiveAdditionalArgs = opts.additionalArgs || [];
2299
+ const taskObj = trigger.task as { parentTaskId?: string } | undefined;
2300
+ if (taskObj?.parentTaskId) {
2301
+ const parentSessionId = await fetchProviderSessionId(
2302
+ apiUrl,
2303
+ apiKey,
2304
+ taskObj.parentTaskId,
2305
+ );
2306
+ if (parentSessionId) {
2307
+ effectiveAdditionalArgs = [...effectiveAdditionalArgs, "--resume", parentSessionId];
2308
+ console.log(
2309
+ `[${role}] Child task — resuming parent session ${parentSessionId.slice(0, 8)}`,
2310
+ );
2311
+ } else {
2312
+ console.log(`[${role}] Child task — parent session ID not found, starting fresh`);
2313
+ }
2314
+ }
2315
+
2316
+ // Extract model from task data for per-task model selection
2317
+ const taskModel = (trigger.task as { model?: string } | undefined)?.model;
2318
+
2319
+ // Handle repo context for tasks with vcsRepo (GitHub/GitLab)
2320
+ const taskVcsRepo = (trigger.task as { vcsRepo?: string } | undefined)?.vcsRepo;
2321
+ if (taskVcsRepo && apiUrl) {
2322
+ const repoConfig = await fetchRepoConfig(apiUrl, apiKey, taskVcsRepo);
2323
+ // Fall back to convention-based config if repo is not registered
2324
+ const effectiveConfig = repoConfig ?? {
2325
+ url: taskVcsRepo,
2326
+ name: taskVcsRepo.split("/").pop() || taskVcsRepo,
2327
+ clonePath: `/workspace/repos/${taskVcsRepo.split("/").pop() || taskVcsRepo}`,
2328
+ defaultBranch: "main",
2329
+ };
2330
+ currentRepoContext = await ensureRepoForTask(effectiveConfig, role);
2331
+ } else {
2332
+ currentRepoContext = undefined;
2333
+ }
2334
+
2335
+ // Resolve effective working directory (priority: task.dir > repoContext.clonePath > process.cwd())
2336
+ const taskDir = (trigger.task as { dir?: string } | undefined)?.dir;
2337
+ let effectiveCwd: string | undefined;
2338
+
2339
+ if (taskDir) {
2340
+ try {
2341
+ if (existsSync(taskDir) && statSync(taskDir).isDirectory()) {
2342
+ effectiveCwd = taskDir;
2343
+ } else {
2344
+ console.warn(
2345
+ `[${role}] Task dir "${taskDir}" does not exist or is not a directory, falling back to default cwd`,
2346
+ );
2347
+ }
2348
+ } catch {
2349
+ console.warn(
2350
+ `[${role}] Failed to check task dir "${taskDir}", falling back to default cwd`,
2351
+ );
2352
+ }
2353
+ }
2354
+
2355
+ if (!effectiveCwd && currentRepoContext?.clonePath) {
2356
+ effectiveCwd = currentRepoContext.clonePath;
2357
+ }
2358
+
2359
+ // Annotate prompt with working directory context
2360
+ if (effectiveCwd && effectiveCwd !== process.cwd()) {
2361
+ triggerPrompt += `\n\n---\n**Working Directory**: You are starting in \`${effectiveCwd}\`. `;
2362
+ if (taskDir) {
2363
+ triggerPrompt += "This was explicitly set on the task.";
2364
+ } else if (currentRepoContext?.clonePath) {
2365
+ triggerPrompt += "This is the repository clone path for this task's VCS repo.";
2366
+ }
2367
+ triggerPrompt +=
2368
+ " You can still access any path on the filesystem — this is just your starting directory.";
2369
+ }
2370
+
2371
+ // Warn in system prompt when task dir was specified but doesn't exist
2372
+ let cwdWarning = "";
2373
+ if (taskDir && !effectiveCwd) {
2374
+ cwdWarning = `\n\nNote: The task requested working directory "${taskDir}" but it does not exist. Falling back to default directory.`;
2375
+ }
2376
+
2377
+ // Rebuild system prompt with per-task repo context
2378
+ const taskBasePrompt = await buildSystemPrompt();
2379
+ const taskSystemPrompt =
2380
+ (additionalSystemPrompt
2381
+ ? `${taskBasePrompt}\n\n${additionalSystemPrompt}`
2382
+ : taskBasePrompt) + cwdWarning;
1511
2383
 
1512
2384
  iteration++;
1513
2385
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
@@ -1517,6 +2389,9 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1517
2389
  console.log(`\n[${role}] === Iteration ${iteration} ===`);
1518
2390
  console.log(`[${role}] Logging to: ${logFile}`);
1519
2391
  console.log(`[${role}] Prompt: ${triggerPrompt.slice(0, 100)}...`);
2392
+ if (effectiveCwd) {
2393
+ console.log(`[${role}] Working directory: ${effectiveCwd}`);
2394
+ }
1520
2395
 
1521
2396
  const metadata = {
1522
2397
  type: metadataType,
@@ -1529,30 +2404,58 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1529
2404
  };
1530
2405
  await Bun.write(logFile, `${JSON.stringify(metadata)}\n`);
1531
2406
 
1532
- // Spawn without blocking (await to write task file, but process runs async)
1533
- const runningTask = await spawnClaudeProcess(
2407
+ // Per-task runner session ID so session logs are scoped to this task
2408
+ const taskRunnerSessionId = crypto.randomUUID();
2409
+
2410
+ // Spawn without blocking (await to set up session, but process runs async)
2411
+ const runningTask = await spawnProviderProcess(
2412
+ adapter,
1534
2413
  {
1535
2414
  prompt: triggerPrompt,
1536
2415
  logFile,
1537
- systemPrompt: resolvedSystemPrompt,
1538
- additionalArgs: opts.additionalArgs,
2416
+ systemPrompt: taskSystemPrompt,
2417
+ additionalArgs: effectiveAdditionalArgs,
1539
2418
  role,
1540
2419
  apiUrl,
1541
2420
  apiKey,
1542
2421
  agentId,
1543
- sessionId,
2422
+ runnerSessionId: taskRunnerSessionId,
1544
2423
  iteration,
1545
2424
  taskId: trigger.taskId,
2425
+ model: taskModel,
2426
+ cwd: effectiveCwd,
1546
2427
  },
1547
2428
  logDir,
1548
- metadataType,
1549
- sessionId,
1550
2429
  isYolo,
1551
2430
  );
1552
2431
 
2432
+ // Attach trigger metadata for logging
2433
+ runningTask.triggerType = trigger.type;
2434
+
2435
+ // Attach deferred cursor updates for channel_activity triggers
2436
+ if (trigger.type === "channel_activity" && trigger.cursorUpdates) {
2437
+ runningTask.cursorUpdates = trigger.cursorUpdates as Array<{
2438
+ channelId: string;
2439
+ ts: string;
2440
+ }>;
2441
+ }
2442
+
1553
2443
  state.activeTasks.set(runningTask.taskId, runningTask);
2444
+
2445
+ // Register active session for concurrency awareness
2446
+ const taskDesc =
2447
+ trigger.task && typeof trigger.task === "object" && "task" in trigger.task
2448
+ ? String((trigger.task as { task: string }).task).slice(0, 200)
2449
+ : undefined;
2450
+ registerActiveSession(apiConfig, {
2451
+ taskId: runningTask.taskId,
2452
+ triggerType: trigger.type,
2453
+ taskDescription: taskDesc,
2454
+ runnerSessionId: taskRunnerSessionId,
2455
+ });
2456
+
1554
2457
  console.log(
1555
- `[${role}] Started task ${runningTask.taskId.slice(0, 8)} (${state.activeTasks.size}/${state.maxConcurrent} active)`,
2458
+ `[${role}] Started task ${runningTask.taskId.slice(0, 8)} (${state.activeTasks.size}/${state.maxConcurrent} active, trigger: ${trigger.type})`,
1556
2459
  );
1557
2460
  }
1558
2461
  } else {
@@ -1593,19 +2496,26 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1593
2496
  };
1594
2497
  await Bun.write(logFile, `${JSON.stringify(metadata)}\n`);
1595
2498
 
1596
- const exitCode = await runClaudeIteration({
2499
+ const iterationResult = await runProviderIteration(adapter, {
1597
2500
  prompt,
1598
2501
  logFile,
1599
2502
  systemPrompt: resolvedSystemPrompt,
1600
2503
  additionalArgs: opts.additionalArgs,
1601
2504
  role,
2505
+ apiUrl,
2506
+ apiKey,
2507
+ agentId,
1602
2508
  });
1603
2509
 
1604
- if (exitCode !== 0) {
2510
+ if (iterationResult.exitCode !== 0) {
2511
+ const failureReason =
2512
+ iterationResult.failureReason || `Process exited with code ${iterationResult.exitCode}`;
2513
+
1605
2514
  const errorLog = {
1606
2515
  timestamp: new Date().toISOString(),
1607
2516
  iteration,
1608
- exitCode,
2517
+ exitCode: iterationResult.exitCode,
2518
+ failureReason,
1609
2519
  error: true,
1610
2520
  };
1611
2521
 
@@ -1615,12 +2525,12 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1615
2525
  await Bun.write(errorsFile, `${existingErrors}${JSON.stringify(errorLog)}\n`);
1616
2526
 
1617
2527
  if (!isYolo) {
1618
- console.error(`[${role}] Claude exited with code ${exitCode}. Stopping.`);
2528
+ console.error(`[${role}] ${failureReason}. Stopping.`);
1619
2529
  console.error(`[${role}] Error logged to: ${errorsFile}`);
1620
- process.exit(exitCode);
2530
+ process.exit(iterationResult.exitCode);
1621
2531
  }
1622
2532
 
1623
- console.warn(`[${role}] Claude exited with code ${exitCode}. YOLO mode - continuing...`);
2533
+ console.warn(`[${role}] ${failureReason}. YOLO mode - continuing...`);
1624
2534
  }
1625
2535
 
1626
2536
  console.log(`[${role}] Iteration ${iteration} complete. Starting next iteration...`);