all-hands-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/.allhands/README.md +75 -0
  2. package/.allhands/agents/compounder.yaml +15 -0
  3. package/.allhands/agents/coordinator.yaml +17 -0
  4. package/.allhands/agents/documentor.yaml +15 -0
  5. package/.allhands/agents/e2e-test-planner.yaml +17 -0
  6. package/.allhands/agents/emergent.yaml +22 -0
  7. package/.allhands/agents/executor.yaml +14 -0
  8. package/.allhands/agents/ideation.yaml +11 -0
  9. package/.allhands/agents/initiative-steering.yaml +19 -0
  10. package/.allhands/agents/judge.yaml +13 -0
  11. package/.allhands/agents/planner.yaml +19 -0
  12. package/.allhands/agents/pr-reviewer.yaml +15 -0
  13. package/.allhands/docs.json +5 -0
  14. package/.allhands/docs.local.json +26 -0
  15. package/.allhands/flows/COMPOUNDING.md +203 -0
  16. package/.allhands/flows/COORDINATION.md +89 -0
  17. package/.allhands/flows/CORE.md +87 -0
  18. package/.allhands/flows/DOCUMENTATION.md +218 -0
  19. package/.allhands/flows/E2E_TEST_PLAN_BUILDING.md +140 -0
  20. package/.allhands/flows/EMERGENT_PLANNING.md +57 -0
  21. package/.allhands/flows/IDEATION_SCOPING.md +154 -0
  22. package/.allhands/flows/INITIATIVE_STEERING.md +110 -0
  23. package/.allhands/flows/JUDGE_REVIEWING.md +79 -0
  24. package/.allhands/flows/PROMPT_TASK_EXECUTION.md +68 -0
  25. package/.allhands/flows/PR_REVIEWING.md +43 -0
  26. package/.allhands/flows/SPEC_PLANNING.md +216 -0
  27. package/.allhands/flows/harness/WRITING_HARNESS_FLOWS.md +27 -0
  28. package/.allhands/flows/harness/WRITING_HARNESS_KNOWLEDGE.md +27 -0
  29. package/.allhands/flows/harness/WRITING_HARNESS_ORCHESTRATION.md +27 -0
  30. package/.allhands/flows/harness/WRITING_HARNESS_SKILLS.md +27 -0
  31. package/.allhands/flows/harness/WRITING_HARNESS_TOOLS.md +27 -0
  32. package/.allhands/flows/harness/WRITING_HARNESS_VALIDATION_TOOLING.md +27 -0
  33. package/.allhands/flows/shared/CODEBASE_UNDERSTANDING.md +72 -0
  34. package/.allhands/flows/shared/CREATE_HARNESS_SPEC.md +48 -0
  35. package/.allhands/flows/shared/CREATE_SPEC.md +41 -0
  36. package/.allhands/flows/shared/CREATE_VALIDATION_TOOLING_SPEC.md +70 -0
  37. package/.allhands/flows/shared/DOCUMENTATION_DISCOVERY.md +123 -0
  38. package/.allhands/flows/shared/DOCUMENTATION_WRITER.md +101 -0
  39. package/.allhands/flows/shared/EMERGENT_REFINEMENT_ANALYSIS.md +76 -0
  40. package/.allhands/flows/shared/EXTERNAL_TECH_GUIDANCE.md +97 -0
  41. package/.allhands/flows/shared/IDEATION_CODEBASE_GROUNDING.md +49 -0
  42. package/.allhands/flows/shared/PLAN_DEEPENING.md +152 -0
  43. package/.allhands/flows/shared/PROMPT_TASKS_CURATION.md +113 -0
  44. package/.allhands/flows/shared/PROMPT_VALIDATION_REVIEW.MD +99 -0
  45. package/.allhands/flows/shared/QUICK_PREMORTEM.md +70 -0
  46. package/.allhands/flows/shared/RESEARCH_GUIDANCE.md +38 -0
  47. package/.allhands/flows/shared/REVIEW_OPTIONS_BREAKDOWN.md +68 -0
  48. package/.allhands/flows/shared/SKILL_EXTRACTION.md +84 -0
  49. package/.allhands/flows/shared/SPEC_FLOW_ANALYSIS.md +119 -0
  50. package/.allhands/flows/shared/TDD_WORKFLOW.md +109 -0
  51. package/.allhands/flows/shared/UTILIZE_VALIDATION_TOOLING.md +84 -0
  52. package/.allhands/flows/shared/WRITING_HARNESS_FLOWS.md +11 -0
  53. package/.allhands/flows/shared/WRITING_HARNESS_MCP_TOOLS.md +84 -0
  54. package/.allhands/flows/shared/jury/ARCHITECTURE_REVIEW.md +91 -0
  55. package/.allhands/flows/shared/jury/BEST_PRACTICES_REVIEW.md +80 -0
  56. package/.allhands/flows/shared/jury/CLAIM_VERIFICATION_REVIEW.md +101 -0
  57. package/.allhands/flows/shared/jury/EXPECTATIONS_FIT_REVIEW.md +78 -0
  58. package/.allhands/flows/shared/jury/MAINTAINABILITY_REVIEW.md +110 -0
  59. package/.allhands/flows/shared/jury/PROMPTS_EXPECTATIONS_FIT.md +74 -0
  60. package/.allhands/flows/shared/jury/PROMPTS_FLOW_ANALYSIS.md +92 -0
  61. package/.allhands/flows/shared/jury/PROMPTS_YAGNI.md +78 -0
  62. package/.allhands/flows/shared/jury/PROMPT_PREMORTEM.md +125 -0
  63. package/.allhands/flows/shared/jury/SECURITY_REVIEW.md +86 -0
  64. package/.allhands/flows/shared/jury/YAGNI_REVIEW.md +82 -0
  65. package/.allhands/flows/wip/DEBUG_INVESTIGATION.md +162 -0
  66. package/.allhands/flows/wip/MEMORY_RECALL.md +62 -0
  67. package/.allhands/harness/ah +131 -0
  68. package/.allhands/harness/package-lock.json +5292 -0
  69. package/.allhands/harness/package.json +52 -0
  70. package/.allhands/harness/src/__tests__/e2e/commands.test.ts +307 -0
  71. package/.allhands/harness/src/__tests__/e2e/event-loop.test.ts +539 -0
  72. package/.allhands/harness/src/__tests__/e2e/hooks.test.ts +427 -0
  73. package/.allhands/harness/src/__tests__/e2e/new-initiative-routing.test.ts +137 -0
  74. package/.allhands/harness/src/__tests__/e2e/run-e2e.ts +109 -0
  75. package/.allhands/harness/src/__tests__/e2e/specs-type.test.ts +210 -0
  76. package/.allhands/harness/src/__tests__/e2e/validation-hooks.test.ts +669 -0
  77. package/.allhands/harness/src/__tests__/e2e/validation-path-consistency.test.ts +354 -0
  78. package/.allhands/harness/src/__tests__/e2e/validation.test.ts +528 -0
  79. package/.allhands/harness/src/__tests__/harness/assertions.ts +318 -0
  80. package/.allhands/harness/src/__tests__/harness/cli-runner.ts +359 -0
  81. package/.allhands/harness/src/__tests__/harness/fixture.ts +384 -0
  82. package/.allhands/harness/src/__tests__/harness/hook-runner.ts +411 -0
  83. package/.allhands/harness/src/__tests__/harness/index.ts +122 -0
  84. package/.allhands/harness/src/cli.ts +36 -0
  85. package/.allhands/harness/src/commands/complexity.ts +177 -0
  86. package/.allhands/harness/src/commands/context7.ts +202 -0
  87. package/.allhands/harness/src/commands/docs.ts +557 -0
  88. package/.allhands/harness/src/commands/hooks.ts +24 -0
  89. package/.allhands/harness/src/commands/index.ts +51 -0
  90. package/.allhands/harness/src/commands/knowledge.ts +382 -0
  91. package/.allhands/harness/src/commands/memories.ts +302 -0
  92. package/.allhands/harness/src/commands/notify.ts +61 -0
  93. package/.allhands/harness/src/commands/oracle.ts +158 -0
  94. package/.allhands/harness/src/commands/perplexity.ts +220 -0
  95. package/.allhands/harness/src/commands/planning.ts +245 -0
  96. package/.allhands/harness/src/commands/schema.ts +73 -0
  97. package/.allhands/harness/src/commands/skills.ts +128 -0
  98. package/.allhands/harness/src/commands/solutions.ts +353 -0
  99. package/.allhands/harness/src/commands/spawn.ts +158 -0
  100. package/.allhands/harness/src/commands/specs.ts +532 -0
  101. package/.allhands/harness/src/commands/tavily.ts +226 -0
  102. package/.allhands/harness/src/commands/tools.ts +579 -0
  103. package/.allhands/harness/src/commands/trace.ts +327 -0
  104. package/.allhands/harness/src/commands/tui.ts +960 -0
  105. package/.allhands/harness/src/commands/validate.ts +143 -0
  106. package/.allhands/harness/src/commands/validation-tools.ts +108 -0
  107. package/.allhands/harness/src/hooks/context.ts +1442 -0
  108. package/.allhands/harness/src/hooks/enforcement.ts +170 -0
  109. package/.allhands/harness/src/hooks/index.ts +54 -0
  110. package/.allhands/harness/src/hooks/lifecycle.ts +229 -0
  111. package/.allhands/harness/src/hooks/notification.ts +104 -0
  112. package/.allhands/harness/src/hooks/observability.ts +551 -0
  113. package/.allhands/harness/src/hooks/session.ts +88 -0
  114. package/.allhands/harness/src/hooks/shared.ts +815 -0
  115. package/.allhands/harness/src/hooks/transcript-parser.ts +208 -0
  116. package/.allhands/harness/src/hooks/validation.ts +617 -0
  117. package/.allhands/harness/src/lib/__tests__/ctags.test.ts +244 -0
  118. package/.allhands/harness/src/lib/__tests__/docs-validation.test.ts +344 -0
  119. package/.allhands/harness/src/lib/__tests__/mcp-runtime.test.ts +190 -0
  120. package/.allhands/harness/src/lib/__tests__/schema.test.ts +861 -0
  121. package/.allhands/harness/src/lib/base-command.ts +198 -0
  122. package/.allhands/harness/src/lib/cli-daemon.ts +343 -0
  123. package/.allhands/harness/src/lib/compaction.ts +313 -0
  124. package/.allhands/harness/src/lib/ctags.ts +497 -0
  125. package/.allhands/harness/src/lib/docs-validation.ts +907 -0
  126. package/.allhands/harness/src/lib/event-loop.ts +662 -0
  127. package/.allhands/harness/src/lib/flows.ts +155 -0
  128. package/.allhands/harness/src/lib/git.ts +276 -0
  129. package/.allhands/harness/src/lib/knowledge-worker.ts +72 -0
  130. package/.allhands/harness/src/lib/knowledge.ts +810 -0
  131. package/.allhands/harness/src/lib/llm.ts +255 -0
  132. package/.allhands/harness/src/lib/mcp-client.ts +432 -0
  133. package/.allhands/harness/src/lib/mcp-daemon.ts +486 -0
  134. package/.allhands/harness/src/lib/mcp-runtime.ts +418 -0
  135. package/.allhands/harness/src/lib/notification.ts +115 -0
  136. package/.allhands/harness/src/lib/opencode/index.ts +70 -0
  137. package/.allhands/harness/src/lib/opencode/profiles.ts +300 -0
  138. package/.allhands/harness/src/lib/opencode/prompts/codesearch.md +98 -0
  139. package/.allhands/harness/src/lib/opencode/prompts/knowledge-aggregator.md +67 -0
  140. package/.allhands/harness/src/lib/opencode/runner.ts +281 -0
  141. package/.allhands/harness/src/lib/oracle.ts +926 -0
  142. package/.allhands/harness/src/lib/planning-utils.ts +150 -0
  143. package/.allhands/harness/src/lib/planning.ts +605 -0
  144. package/.allhands/harness/src/lib/pr-review.ts +225 -0
  145. package/.allhands/harness/src/lib/prompts.ts +522 -0
  146. package/.allhands/harness/src/lib/schema.ts +418 -0
  147. package/.allhands/harness/src/lib/schemas/agent-profile.ts +141 -0
  148. package/.allhands/harness/src/lib/schemas/template-vars.ts +138 -0
  149. package/.allhands/harness/src/lib/session.ts +164 -0
  150. package/.allhands/harness/src/lib/specs.ts +348 -0
  151. package/.allhands/harness/src/lib/tldr.ts +829 -0
  152. package/.allhands/harness/src/lib/tmux.ts +1051 -0
  153. package/.allhands/harness/src/lib/trace-store.ts +714 -0
  154. package/.allhands/harness/src/mcp/__tests__/index.test.ts +46 -0
  155. package/.allhands/harness/src/mcp/_template.ts +47 -0
  156. package/.allhands/harness/src/mcp/filesystem.ts +33 -0
  157. package/.allhands/harness/src/mcp/index.ts +69 -0
  158. package/.allhands/harness/src/mcp/playwright.ts +34 -0
  159. package/.allhands/harness/src/mcp/xcodebuild.ts +29 -0
  160. package/.allhands/harness/src/schemas/docs.schema.json +44 -0
  161. package/.allhands/harness/src/schemas/settings.schema.json +214 -0
  162. package/.allhands/harness/src/tui/actions.ts +227 -0
  163. package/.allhands/harness/src/tui/file-viewer-modal.ts +270 -0
  164. package/.allhands/harness/src/tui/index.ts +1574 -0
  165. package/.allhands/harness/src/tui/modal.ts +232 -0
  166. package/.allhands/harness/src/tui/prompts-pane.ts +186 -0
  167. package/.allhands/harness/src/tui/status-pane.ts +434 -0
  168. package/.allhands/harness/tsconfig.json +22 -0
  169. package/.allhands/harness/vitest.config.ts +13 -0
  170. package/.allhands/pillars.md +33 -0
  171. package/.allhands/principles.md +88 -0
  172. package/.allhands/schemas/alignment.yaml +51 -0
  173. package/.allhands/schemas/documentation.yaml +10 -0
  174. package/.allhands/schemas/prompt.yaml +92 -0
  175. package/.allhands/schemas/skill.yaml +34 -0
  176. package/.allhands/schemas/solution.yaml +131 -0
  177. package/.allhands/schemas/spec.yaml +67 -0
  178. package/.allhands/schemas/validation-suite.yaml +49 -0
  179. package/.allhands/schemas/workflow.yaml +51 -0
  180. package/.allhands/settings.json +57 -0
  181. package/.allhands/skills/claude-code-patterns/SKILL.md +60 -0
  182. package/.allhands/skills/claude-code-patterns/docs/context-hygiene.md +19 -0
  183. package/.allhands/skills/harness-maintenance/SKILL.md +449 -0
  184. package/.allhands/skills/harness-maintenance/references/core-architecture.md +187 -0
  185. package/.allhands/skills/harness-maintenance/references/harness-skills.md +87 -0
  186. package/.allhands/skills/harness-maintenance/references/knowledge-compounding.md +78 -0
  187. package/.allhands/skills/harness-maintenance/references/tools-commands-mcp-hooks.md +115 -0
  188. package/.allhands/skills/harness-maintenance/references/validation-tooling.md +77 -0
  189. package/.allhands/skills/harness-maintenance/references/writing-flows.md +84 -0
  190. package/.allhands/validation/browser-automation.md +109 -0
  191. package/.allhands/validation/xcode-automation.md +195 -0
  192. package/.allhands/workflows/documentation.md +86 -0
  193. package/.allhands/workflows/investigation.md +81 -0
  194. package/.allhands/workflows/milestone.md +91 -0
  195. package/.allhands/workflows/optimization.md +85 -0
  196. package/.allhands/workflows/refactor.md +99 -0
  197. package/.allhands/workflows/triage.md +81 -0
  198. package/.claude/README.md +1 -0
  199. package/.claude/agents/explorer.md +10 -0
  200. package/.claude/agents/researcher.md +11 -0
  201. package/.claude/agents/task-runner.md +8 -0
  202. package/.claude/settings.json +231 -0
  203. package/.env.ai.example +7 -0
  204. package/.github/workflows/npm-publish.yml +69 -0
  205. package/.internal.json +45 -0
  206. package/.tldr/config.json +11 -0
  207. package/.tldrignore +90 -0
  208. package/CLAUDE.md +6 -0
  209. package/README.md +98 -0
  210. package/bin/sync-cli.js +7552 -0
  211. package/concerns.md +7 -0
  212. package/docs/README.md +41 -0
  213. package/docs/agents/README.md +24 -0
  214. package/docs/agents/agent-configuration-system.md +86 -0
  215. package/docs/agents/execution-agents.md +50 -0
  216. package/docs/agents/knowledge-agents.md +61 -0
  217. package/docs/agents/orchestration-agent.md +57 -0
  218. package/docs/agents/planning-agents.md +84 -0
  219. package/docs/agents/quality-review-agents.md +67 -0
  220. package/docs/agents/workflow-agent-orchestration.md +69 -0
  221. package/docs/flows/README.md +44 -0
  222. package/docs/flows/compounding.md +126 -0
  223. package/docs/flows/coordination.md +72 -0
  224. package/docs/flows/core-harness-integration.md +63 -0
  225. package/docs/flows/documentation-orchestration.md +98 -0
  226. package/docs/flows/e2e-test-plan-building.md +83 -0
  227. package/docs/flows/emergent-refinement.md +104 -0
  228. package/docs/flows/flow-authoring-and-mcp-tools.md +89 -0
  229. package/docs/flows/judge-reviewing.md +112 -0
  230. package/docs/flows/plan-deepening-and-research.md +107 -0
  231. package/docs/flows/plan-review-jury.md +114 -0
  232. package/docs/flows/pr-reviewing.md +54 -0
  233. package/docs/flows/prompt-task-execution.md +119 -0
  234. package/docs/flows/spec-planning.md +162 -0
  235. package/docs/flows/type-specific-scoping-flows.md +49 -0
  236. package/docs/flows/validation-and-skills-integration.md +145 -0
  237. package/docs/flows/wip/wip-flows.md +102 -0
  238. package/docs/harness/README.md +23 -0
  239. package/docs/harness/agent-profiles.md +84 -0
  240. package/docs/harness/cli/README.md +24 -0
  241. package/docs/harness/cli/cli-entry-and-command-discovery.md +91 -0
  242. package/docs/harness/cli/docs-command.md +87 -0
  243. package/docs/harness/cli/knowledge-command.md +91 -0
  244. package/docs/harness/cli/minor-cli-commands.md +65 -0
  245. package/docs/harness/cli/oracle-command.md +113 -0
  246. package/docs/harness/cli/planning-command.md +95 -0
  247. package/docs/harness/cli/schema-and-validation-commands.md +154 -0
  248. package/docs/harness/cli/search-commands.md +97 -0
  249. package/docs/harness/cli/spawn-command.md +136 -0
  250. package/docs/harness/cli/specs-command.md +102 -0
  251. package/docs/harness/cli/tools-command.md +122 -0
  252. package/docs/harness/cli/trace-command.md +122 -0
  253. package/docs/harness/cli-daemon.md +92 -0
  254. package/docs/harness/event-loop.md +184 -0
  255. package/docs/harness/hooks/README.md +15 -0
  256. package/docs/harness/hooks/context-hooks.md +96 -0
  257. package/docs/harness/hooks/lifecycle-and-observability-hooks.md +135 -0
  258. package/docs/harness/hooks/validation-hooks.md +97 -0
  259. package/docs/harness/test-harness.md +149 -0
  260. package/docs/harness/tui.md +176 -0
  261. package/docs/memories.md +20 -0
  262. package/docs/solutions/agentic-issues/premature-agent-deletion-tui-action-dependency-20260130.md +49 -0
  263. package/docs/solutions/agentic-issues/ref-anchor-scope-mismatch-skill-references-20260131.md +55 -0
  264. package/docs/solutions/agentic-issues/tautological-tests-routing-20260131.md +52 -0
  265. package/docs/solutions/integration_issue/blocktool-output-format-mismatch-hook-runner-20260130.md +52 -0
  266. package/docs/solutions/integration_issue/dual-validation-path-divergence-schema-20260130.md +66 -0
  267. package/docs/solutions/security-issues/unsanitized-domain-path-join-20260131.md +52 -0
  268. package/docs/solutions/test-failures/event-loop-mock-ordering-checkAgentWindows-20260130.md +63 -0
  269. package/docs/sync-cli/README.md +19 -0
  270. package/docs/sync-cli/cli-entrypoint-and-commands.md +39 -0
  271. package/docs/sync-cli/commands/README.md +11 -0
  272. package/docs/sync-cli/commands/pull-manifest-command.md +36 -0
  273. package/docs/sync-cli/commands/push-command.md +84 -0
  274. package/docs/sync-cli/commands/sync-command.md +71 -0
  275. package/docs/sync-cli/systems/README.md +14 -0
  276. package/docs/sync-cli/systems/git-and-github-integration.md +49 -0
  277. package/docs/sync-cli/systems/interactive-ui.md +43 -0
  278. package/docs/sync-cli/systems/manifest-and-distribution.md +51 -0
  279. package/docs/sync-cli/systems/path-resolution.md +42 -0
  280. package/package.json +46 -0
  281. package/scripts/install-shim.sh +40 -0
  282. package/scripts/pre-pack.sh +25 -0
  283. package/specs/harness-maintenance-skill.spec.md +138 -0
  284. package/specs/roadmap/git-spec-lifecycle-management.spec.md +113 -0
  285. package/specs/sync-init-flag.spec.md +117 -0
  286. package/specs/unified-workflow-orchestration.spec.md +250 -0
  287. package/specs/validation-tooling-practice.spec.md +98 -0
  288. package/specs/workflow-domain-configuration.spec.md +265 -0
  289. package/src/commands/pull-manifest.ts +31 -0
  290. package/src/commands/push.ts +344 -0
  291. package/src/commands/sync.ts +289 -0
  292. package/src/lib/constants.ts +10 -0
  293. package/src/lib/dotfiles.ts +36 -0
  294. package/src/lib/fs-utils.ts +18 -0
  295. package/src/lib/gh.ts +40 -0
  296. package/src/lib/git.ts +63 -0
  297. package/src/lib/gitignore.ts +167 -0
  298. package/src/lib/manifest.ts +121 -0
  299. package/src/lib/marker-sync.ts +39 -0
  300. package/src/lib/paths.ts +38 -0
  301. package/src/lib/target-lines.ts +66 -0
  302. package/src/lib/ui.ts +78 -0
  303. package/src/sync-cli.ts +120 -0
  304. package/target-lines.json +23 -0
  305. package/tsconfig.json +20 -0
@@ -0,0 +1,1051 @@
1
+ /**
2
+ * Tmux Integration
3
+ *
4
+ * Manages tmux sessions and windows for agent spawning.
5
+ *
6
+ * Session Structure:
7
+ * - Session: ah-hub (standardized name)
8
+ * - Window 0: TUI (main control)
9
+ * - Window 1+: Agent windows (coordinator, planner, executor, etc.)
10
+ *
11
+ * Startup Logic:
12
+ * 1. Check if tmux is available (fail with error if not)
13
+ * 2. If NOT in tmux: Create new session with user-provided name
14
+ * 3. If in tmux with multiple windows: Ask to create new session or use current
15
+ * 4. If in tmux with single window: Use current session
16
+ * 5. Rename active session to "ah-hub"
17
+ *
18
+ * Environment Variables passed to agents:
19
+ * - AGENT_ID: Unique agent identifier (= window name, used for MCP daemon isolation)
20
+ * - AGENT_TYPE: executor, coordinator, planner, judge, ideation, pr-reviewer
21
+ * - PROMPT_NUMBER: Current prompt number (when applicable)
22
+ * - SPEC_NAME: Current spec name
23
+ * - BRANCH: Current git branch
24
+ */
25
+
26
+ import { execSync, spawn } from 'child_process';
27
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, unlinkSync, statSync } from 'fs';
28
+ import { join } from 'path';
29
+ import {
30
+ buildAgentInvocation,
31
+ listAgentProfiles,
32
+ loadAgentProfile,
33
+ type TemplateContext
34
+ } from './opencode/index.js';
35
+ import { getCurrentBranch, getPlanningPaths } from './planning.js';
36
+ import { getBaseBranch } from './git.js';
37
+ import { addSpawnedWindow, removeSpawnedWindow, getSpawnedWindows } from './session.js';
38
+ import { loadProjectSettings } from '../hooks/shared.js';
39
+ import { getSpecForBranch, getWorkflowDomain } from './specs.js';
40
+
41
+ /**
42
+ * Agent type = agent profile name.
43
+ * Derived from .allhands/agents/*.yaml profile files.
44
+ */
45
+ export type AgentType = string;
46
+
47
+ export interface AgentEnv {
48
+ AGENT_ID: string;
49
+ AGENT_TYPE: AgentType;
50
+ PROMPT_NUMBER?: string;
51
+ SPEC_NAME?: string;
52
+ BRANCH: string;
53
+ }
54
+
55
+ export interface SpawnConfig {
56
+ name: string;
57
+ agentType: AgentType;
58
+ flowPath: string;
59
+ preamble?: string;
60
+ promptNumber?: number;
61
+ specName?: string;
62
+ nonCoding?: boolean;
63
+ /** If true, switch focus to the new window after spawning (default: true for TUI actions) */
64
+ focusWindow?: boolean;
65
+ /**
66
+ * If true, this agent is scoped to a specific prompt and can have multiple
67
+ * instances running concurrently (one per prompt).
68
+ * Prompt-scoped agents include the prompt number in their ID (e.g., "executor-01").
69
+ * Non-prompt-scoped agents use their name as AGENT_ID and only one can run at a time.
70
+ */
71
+ promptScoped?: boolean;
72
+ }
73
+
74
+ export interface SessionContext {
75
+ inTmux: boolean;
76
+ currentSession: string | null;
77
+ windowCount: number;
78
+ }
79
+
80
+ export interface SessionSetupResult {
81
+ sessionName: string;
82
+ isNew: boolean;
83
+ }
84
+
85
+ export const SESSION_NAME = 'ah-hub';
86
+
87
+ /**
88
+ * In-memory cache of agents spawned by ALL HANDS.
89
+ * Also persisted to session.json for cross-process visibility.
90
+ */
91
+ const spawnedAgentRegistry = new Set<string>();
92
+
93
+ /**
94
+ * Clean up old launcher scripts (older than 24 hours)
95
+ */
96
+ function cleanupOldLaunchers(launcherDir: string): void {
97
+ if (!existsSync(launcherDir)) return;
98
+
99
+ const maxAge = 24 * 60 * 60 * 1000; // 24 hours
100
+ const now = Date.now();
101
+
102
+ try {
103
+ const files = readdirSync(launcherDir);
104
+ for (const file of files) {
105
+ if (!file.endsWith('-launcher.sh') && !file.endsWith('-prompt.txt')) continue;
106
+
107
+ const filePath = join(launcherDir, file);
108
+ try {
109
+ const stat = statSync(filePath);
110
+ if (now - stat.mtimeMs > maxAge) {
111
+ unlinkSync(filePath);
112
+ }
113
+ } catch {
114
+ // Ignore errors for individual files
115
+ }
116
+ }
117
+ } catch {
118
+ // Ignore errors during cleanup
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Register an agent as spawned by ALL HANDS (persisted to disk)
124
+ */
125
+ export function registerSpawnedAgent(windowName: string, cwd?: string): void {
126
+ spawnedAgentRegistry.add(windowName);
127
+ addSpawnedWindow(windowName, cwd);
128
+ }
129
+
130
+ /**
131
+ * Unregister an agent (persisted to disk)
132
+ */
133
+ export function unregisterSpawnedAgent(windowName: string, cwd?: string): void {
134
+ spawnedAgentRegistry.delete(windowName);
135
+ removeSpawnedWindow(windowName, cwd);
136
+ }
137
+
138
+ /**
139
+ * Check if an agent was spawned by ALL HANDS
140
+ */
141
+ export function isSpawnedAgent(windowName: string, cwd?: string): boolean {
142
+ // Check both in-memory cache and persisted state
143
+ if (spawnedAgentRegistry.has(windowName)) return true;
144
+ return getSpawnedWindows(cwd).includes(windowName);
145
+ }
146
+
147
+ /**
148
+ * Get all registered spawned agents (from persisted state)
149
+ */
150
+ export function getSpawnedAgentRegistry(cwd?: string): Set<string> {
151
+ // Merge in-memory and persisted for complete view
152
+ const persisted = getSpawnedWindows(cwd);
153
+ return new Set([...spawnedAgentRegistry, ...persisted]);
154
+ }
155
+
156
+ /**
157
+ * Get current tmux window ID (stable identifier like @0)
158
+ */
159
+ export function getCurrentWindowId(): string | null {
160
+ if (!process.env.TMUX) return null;
161
+ try {
162
+ return execSync('tmux display-message -p "#{window_id}"', {
163
+ encoding: 'utf-8',
164
+ stdio: ['pipe', 'pipe', 'pipe'],
165
+ }).trim();
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Get current tmux context (are we in tmux, which session, how many windows)
173
+ */
174
+ export function getTmuxContext(): SessionContext {
175
+ const inTmux = !!process.env.TMUX;
176
+
177
+ if (!inTmux) {
178
+ return { inTmux: false, currentSession: null, windowCount: 0 };
179
+ }
180
+
181
+ try {
182
+ const currentSession = execSync('tmux display-message -p "#S"', {
183
+ encoding: 'utf-8',
184
+ stdio: ['pipe', 'pipe', 'pipe'],
185
+ }).trim();
186
+
187
+ const windowList = execSync(
188
+ `tmux list-windows -t "${currentSession}" -F "#{window_index}"`,
189
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
190
+ );
191
+ const windowCount = windowList.trim().split('\n').filter((l) => l).length;
192
+
193
+ return { inTmux: true, currentSession, windowCount };
194
+ } catch {
195
+ return { inTmux: true, currentSession: null, windowCount: 0 };
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Check if tmux needs to prompt user for session decision
201
+ * Returns: 'create-new' | 'use-current' | 'no-prompt-needed'
202
+ */
203
+ export function getSessionDecision(context: SessionContext): 'create-new' | 'use-current' | 'no-prompt-needed' {
204
+ if (!context.inTmux) {
205
+ // Not in tmux - will need to create new
206
+ return 'create-new';
207
+ }
208
+
209
+ if (context.windowCount > 1) {
210
+ // Multiple windows - should ask user
211
+ // This will be handled by TUI prompting
212
+ return 'create-new'; // Default to create-new, TUI can override
213
+ }
214
+
215
+ // Single window in tmux - use current
216
+ return 'use-current';
217
+ }
218
+
219
+ /**
220
+ * Rename current session to ah-hub
221
+ */
222
+ export function renameCurrentSession(): void {
223
+ try {
224
+ execSync(`tmux rename-session "${SESSION_NAME}"`, { stdio: 'pipe' });
225
+ } catch {
226
+ // Session might already be named this
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Create a new tmux session and attach to it
232
+ */
233
+ export function createNewSession(sessionName: string, cwd?: string): void {
234
+ const cwdArg = cwd ? `-c "${cwd}"` : '';
235
+ execSync(`tmux new-session -d -s "${sessionName}" ${cwdArg}`, { stdio: 'pipe' });
236
+ }
237
+
238
+ /**
239
+ * Setup TUI session with the new logic
240
+ *
241
+ * @param promptForNewSession - Callback to ask user if they want a new session
242
+ * @param promptForSessionName - Callback to get session name from user
243
+ * @param cwd - Working directory
244
+ * @returns Session setup result
245
+ */
246
+ export async function setupTUISession(
247
+ promptForNewSession: () => Promise<boolean>,
248
+ promptForSessionName: () => Promise<string>,
249
+ cwd?: string
250
+ ): Promise<SessionSetupResult> {
251
+ // Step 1: Check tmux availability
252
+ if (!isTmuxInstalled()) {
253
+ throw new Error('tmux is required but not found. Please install tmux and try again.');
254
+ }
255
+
256
+ const context = getTmuxContext();
257
+
258
+ // Step 2: Determine session strategy
259
+ if (!context.inTmux) {
260
+ // Not in tmux - create new session
261
+ const name = await promptForSessionName();
262
+ createNewSession(name, cwd);
263
+ // Attach and rename
264
+ execSync(`tmux rename-session -t "${name}" "${SESSION_NAME}"`, { stdio: 'pipe' });
265
+ return { sessionName: SESSION_NAME, isNew: true };
266
+ }
267
+
268
+ if (context.windowCount > 1) {
269
+ // Multiple windows - ask user
270
+ const wantNew = await promptForNewSession();
271
+ if (wantNew) {
272
+ const name = await promptForSessionName();
273
+ createNewSession(name, cwd);
274
+ execSync(`tmux rename-session -t "${name}" "${SESSION_NAME}"`, { stdio: 'pipe' });
275
+ // Switch to new session
276
+ execSync(`tmux switch-client -t "${SESSION_NAME}"`, { stdio: 'pipe' });
277
+ return { sessionName: SESSION_NAME, isNew: true };
278
+ }
279
+ }
280
+
281
+ // Use current session - just rename it
282
+ renameCurrentSession();
283
+ return { sessionName: SESSION_NAME, isNew: false };
284
+ }
285
+
286
+ /**
287
+ * Get the session name for the current branch (legacy - kept for compatibility)
288
+ */
289
+ export function getSessionName(branch?: string): string {
290
+ // Now always returns ah-hub for active session
291
+ return SESSION_NAME;
292
+ }
293
+
294
+ /**
295
+ * Check if tmux is installed
296
+ */
297
+ export function isTmuxInstalled(): boolean {
298
+ try {
299
+ execSync('which tmux', { stdio: 'pipe' });
300
+ return true;
301
+ } catch {
302
+ return false;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Check if a tmux session exists
308
+ */
309
+ export function sessionExists(sessionName: string): boolean {
310
+ try {
311
+ execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: 'pipe' });
312
+ return true;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Create a new tmux session
320
+ */
321
+ export function createSession(sessionName: string, cwd?: string): void {
322
+ const cwdArg = cwd ? `-c "${cwd}"` : '';
323
+ execSync(`tmux new-session -d -s "${sessionName}" ${cwdArg}`, { stdio: 'pipe' });
324
+ }
325
+
326
+ /**
327
+ * Get the current tmux session name (if inside tmux)
328
+ */
329
+ export function getCurrentSession(): string | null {
330
+ if (!process.env.TMUX) {
331
+ return null;
332
+ }
333
+
334
+ try {
335
+ return execSync('tmux display-message -p "#S"', {
336
+ encoding: 'utf-8',
337
+ stdio: ['pipe', 'pipe', 'pipe'],
338
+ }).trim();
339
+ } catch {
340
+ return null;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Ensure session exists, creating if necessary
346
+ *
347
+ * IMPORTANT: If already inside tmux, uses the CURRENT session.
348
+ * Only creates a new session if not inside tmux.
349
+ */
350
+ export function ensureSession(branch?: string, cwd?: string): string {
351
+ // If we're already in tmux, use the current session
352
+ const currentSession = getCurrentSession();
353
+ if (currentSession) {
354
+ return currentSession;
355
+ }
356
+
357
+ // Not in tmux - check if our target session exists, or create it
358
+ const sessionName = getSessionName(branch);
359
+
360
+ if (!sessionExists(sessionName)) {
361
+ createSession(sessionName, cwd);
362
+ }
363
+
364
+ return sessionName;
365
+ }
366
+
367
+ /**
368
+ * List windows in a session
369
+ */
370
+ export function listWindows(sessionName: string): Array<{ index: number; name: string; id: string }> {
371
+ try {
372
+ const output = execSync(
373
+ `tmux list-windows -t "${sessionName}" -F "#{window_index}:#{window_name}:#{window_id}"`,
374
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
375
+ );
376
+
377
+ return output
378
+ .trim()
379
+ .split('\n')
380
+ .filter((line) => line.length > 0)
381
+ .map((line) => {
382
+ const parts = line.split(':');
383
+ const index = parseInt(parts[0], 10);
384
+ const name = parts[1];
385
+ const id = parts[2] || '';
386
+ return { index, name, id };
387
+ });
388
+ } catch {
389
+ return [];
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Check if a window with given name exists
395
+ */
396
+ export function windowExists(sessionName: string, windowName: string): boolean {
397
+ const windows = listWindows(sessionName);
398
+ return windows.some((w) => w.name === windowName);
399
+ }
400
+
401
+ /**
402
+ * Create a new window in the session
403
+ *
404
+ * @param sessionName - Target session
405
+ * @param windowName - Name for the new window
406
+ * @param cwd - Working directory
407
+ * @param detached - If true, don't switch focus to new window (default: true)
408
+ */
409
+ export function createWindow(
410
+ sessionName: string,
411
+ windowName: string,
412
+ cwd?: string,
413
+ detached: boolean = true
414
+ ): number {
415
+ const cwdArg = cwd ? `-c "${cwd}"` : '';
416
+ const detachArg = detached ? '-d' : '';
417
+ execSync(`tmux new-window ${detachArg} -t "${sessionName}" -n "${windowName}" ${cwdArg}`, {
418
+ stdio: 'pipe',
419
+ });
420
+
421
+ // Prevent tmux from overriding the window name via automatic-rename
422
+ try {
423
+ execSync(`tmux set-option -t "${sessionName}:${windowName}" allow-rename off`, {
424
+ stdio: 'pipe',
425
+ });
426
+ } catch {
427
+ // Ignore — non-critical
428
+ }
429
+
430
+ // Get the new window's index
431
+ const windows = listWindows(sessionName);
432
+ const window = windows.find((w) => w.name === windowName);
433
+ return window?.index ?? -1;
434
+ }
435
+
436
+ /**
437
+ * Kill a window by name
438
+ */
439
+ export function killWindow(sessionName: string, windowName: string): boolean {
440
+ try {
441
+ execSync(`tmux kill-window -t "${sessionName}:${windowName}"`, { stdio: 'pipe' });
442
+ // Unregister from ALL HANDS tracking
443
+ unregisterSpawnedAgent(windowName);
444
+ return true;
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Send keys to a window
452
+ */
453
+ export function sendKeys(sessionName: string, windowName: string, keys: string): void {
454
+ execSync(`tmux send-keys -t "${sessionName}:${windowName}" "${keys}" Enter`, {
455
+ stdio: 'pipe',
456
+ });
457
+ }
458
+
459
+ /**
460
+ * Capture window output
461
+ */
462
+ export function capturePane(
463
+ sessionName: string,
464
+ windowName: string,
465
+ lines: number = 100
466
+ ): string {
467
+ try {
468
+ return execSync(
469
+ `tmux capture-pane -t "${sessionName}:${windowName}" -p -S -${lines}`,
470
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
471
+ );
472
+ } catch {
473
+ return '';
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Select/focus a window
479
+ */
480
+ export function selectWindow(sessionName: string, windowName: string): void {
481
+ execSync(`tmux select-window -t "${sessionName}:${windowName}"`, { stdio: 'pipe' });
482
+ }
483
+
484
+ /**
485
+ * Rename the current window
486
+ */
487
+ export function renameCurrentWindow(newName: string): void {
488
+ if (!process.env.TMUX) return;
489
+ try {
490
+ execSync(`tmux rename-window "${newName}"`, { stdio: 'pipe' });
491
+ } catch {
492
+ // Ignore errors
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Rename a specific window by ID (stable even if focus changes)
498
+ * Falls back to renaming current window if no target specified
499
+ */
500
+ export function renameWindow(targetWindowId: string | null, newName: string): void {
501
+ if (!process.env.TMUX) return;
502
+ try {
503
+ if (targetWindowId) {
504
+ // Use -t to target the specific window by ID
505
+ execSync(`tmux rename-window -t "${targetWindowId}" "${newName}"`, { stdio: 'pipe' });
506
+ } else {
507
+ // Fallback to current window
508
+ execSync(`tmux rename-window "${newName}"`, { stdio: 'pipe' });
509
+ }
510
+ } catch {
511
+ // Ignore errors
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Build the window name for an agent.
517
+ *
518
+ * Non-prompt-scoped agents use their name directly (e.g., "planner").
519
+ * Prompt-scoped agents include the prompt number (e.g., "executor-01").
520
+ */
521
+ export function buildWindowName(config: SpawnConfig): string {
522
+ if (!config.promptScoped) {
523
+ return config.name;
524
+ }
525
+
526
+ // Prompt-scoped agents include prompt number
527
+ if (config.promptNumber !== undefined) {
528
+ return `${config.name}-${String(config.promptNumber).padStart(2, '0')}`;
529
+ }
530
+
531
+ // Fallback: use name as-is
532
+ return config.name;
533
+ }
534
+
535
+ /**
536
+ * Build environment variables for agent
537
+ */
538
+ export function buildAgentEnv(config: SpawnConfig, branch: string, windowName: string): Record<string, string> {
539
+ // Note: BASE_BRANCH is communicated via the initial prompt, not env vars
540
+ const env: Record<string, string> = {
541
+ AGENT_ID: windowName, // Window name = AGENT_ID (used for MCP daemon isolation)
542
+ AGENT_TYPE: config.agentType,
543
+ BRANCH: branch,
544
+ };
545
+
546
+ if (config.promptScoped) {
547
+ env.PROMPT_SCOPED = 'true';
548
+
549
+ // Set autocompact threshold for prompt-scoped agents only
550
+ const settings = loadProjectSettings();
551
+ const autocompactAt = settings?.spawn?.promptScopedAutocompactAt ?? 65;
552
+ env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(autocompactAt);
553
+ }
554
+
555
+ if (config.promptNumber !== undefined) {
556
+ env.PROMPT_NUMBER = String(config.promptNumber).padStart(2, '0');
557
+ }
558
+
559
+ if (config.specName) {
560
+ env.SPEC_NAME = config.specName;
561
+ }
562
+
563
+ return env;
564
+ }
565
+
566
+ /**
567
+ * Spawn a Claude Code agent in a tmux window
568
+ *
569
+ * This creates a new window and runs `claude` with the appropriate
570
+ * flow and configuration.
571
+ *
572
+ * @param config - Agent spawn configuration
573
+ * @param branch - Git branch (defaults to current)
574
+ * @param cwd - Working directory
575
+ * @returns Session and window names
576
+ * @throws Error if non-prompt-scoped agent already exists
577
+ *
578
+ * Window naming:
579
+ * - Non-prompt-scoped agents use name directly (e.g., "planner")
580
+ * - Prompt-scoped agents include prompt number (e.g., "executor-01")
581
+ *
582
+ * The window name becomes the AGENT_ID for MCP daemon isolation.
583
+ *
584
+ * Window focus behavior:
585
+ * - config.focusWindow = true (default): Switch to the new window after spawning
586
+ * - config.focusWindow = false: Create window in background (for loop-spawned executors)
587
+ */
588
+ export function spawnAgent(
589
+ config: SpawnConfig,
590
+ branch?: string,
591
+ cwd?: string
592
+ ): { sessionName: string; windowName: string } {
593
+ const currentBranch = branch || getCurrentBranch();
594
+ const sessionName = ensureSession(currentBranch, cwd);
595
+ const windowName = buildWindowName(config);
596
+ const shouldFocus = config.focusWindow !== false; // Default to true
597
+
598
+ // Non-prompt-scoped agent enforcement: fail if already running
599
+ if (!config.promptScoped && windowExists(sessionName, windowName)) {
600
+ throw new Error(
601
+ `Agent "${windowName}" is already running. Only one instance of non-prompt-scoped agents is allowed.`
602
+ );
603
+ }
604
+
605
+ // Kill existing window if present (for prompt-scoped agents being restarted)
606
+ if (windowExists(sessionName, windowName)) {
607
+ killWindow(sessionName, windowName);
608
+ }
609
+
610
+ // Create new window (detached - don't switch focus yet)
611
+ createWindow(sessionName, windowName, cwd, true);
612
+
613
+ // Register this agent as spawned by ALL HANDS
614
+ registerSpawnedAgent(windowName);
615
+
616
+ // Build environment variables for the agent
617
+ const env = buildAgentEnv(config, currentBranch, windowName);
618
+
619
+ // Read the flow file content directly instead of referencing it
620
+ let flowContent = '';
621
+ if (existsSync(config.flowPath)) {
622
+ flowContent = readFileSync(config.flowPath, 'utf-8');
623
+ }
624
+
625
+ // Write a launcher script to avoid all shell escaping issues
626
+ const tempDir = join(cwd || process.cwd(), '.allhands', 'harness', '.cache', 'launchers');
627
+ mkdirSync(tempDir, { recursive: true });
628
+
629
+ // Clean up old launcher files (older than 24h)
630
+ cleanupOldLaunchers(tempDir);
631
+
632
+ const launcherScript = join(tempDir, `${windowName}-launcher.sh`);
633
+ const promptFile = join(tempDir, `${windowName}-prompt.txt`);
634
+
635
+ // Build combined prompt: flow content + preamble + base branch info
636
+ // NO system prompt - everything goes into the initial user prompt
637
+ const baseBranch = getBaseBranch();
638
+ const promptParts: string[] = [];
639
+
640
+ if (flowContent) {
641
+ promptParts.push(flowContent);
642
+ }
643
+
644
+ if (config.preamble && config.preamble.trim()) {
645
+ promptParts.push(config.preamble);
646
+ }
647
+
648
+ // Always append base branch info
649
+ promptParts.push(`The base branch name is "${baseBranch}".`);
650
+
651
+ const combinedPrompt = promptParts.join('\n\n');
652
+ writeFileSync(promptFile, combinedPrompt, 'utf-8');
653
+
654
+ // Build the launcher script
655
+ const scriptLines: string[] = ['#!/bin/bash', ''];
656
+
657
+ // Skip pyenv rehash to avoid lock contention when spawning multiple agents
658
+ scriptLines.push('export PYENV_REHASH_SKIP=1');
659
+
660
+ // Export environment variables
661
+ for (const [key, value] of Object.entries(env)) {
662
+ scriptLines.push(`export ${key}="${value}"`);
663
+ }
664
+ scriptLines.push('');
665
+
666
+ // Build claude command - NO system prompt, everything in initial prompt
667
+ const cmdParts: string[] = ['claude'];
668
+ cmdParts.push('--settings .claude/settings.json');
669
+ cmdParts.push('--dangerously-skip-permissions');
670
+ cmdParts.push(`"$(cat '${promptFile}')"`)
671
+
672
+ scriptLines.push(cmdParts.join(' \\\n '));
673
+
674
+ writeFileSync(launcherScript, scriptLines.join('\n'), { mode: 0o755 });
675
+
676
+ // Execute the launcher script with exec so it replaces the shell.
677
+ // This ensures the window closes when claude exits (no orphan shell).
678
+ sendKeys(sessionName, windowName, `exec bash '${launcherScript}'`);
679
+
680
+ // Switch focus to the new window if requested (default for TUI actions)
681
+ if (shouldFocus) {
682
+ selectWindow(sessionName, windowName);
683
+ }
684
+
685
+ return { sessionName, windowName };
686
+ }
687
+
688
+ /**
689
+ * Configuration for profile-based agent spawning
690
+ */
691
+ export interface ProfileSpawnConfig {
692
+ /** Agent profile name (must exist in .allhands/agents/) */
693
+ agentName: string;
694
+ /** Template context for variable resolution */
695
+ context: TemplateContext;
696
+ /** Optional prompt number for prompt-scoped agents */
697
+ promptNumber?: number;
698
+ /** If true, switch focus to the new window (default: true) */
699
+ focusWindow?: boolean;
700
+ /** Optional flow path override — when provided, use this instead of the profile's default flow */
701
+ flowOverride?: string;
702
+ }
703
+
704
+ /**
705
+ * Spawn an agent using its profile definition
706
+ *
707
+ * This is the preferred way to spawn agents. It:
708
+ * 1. Loads the agent profile
709
+ * 2. Validates required template variables
710
+ * 3. Resolves the message template
711
+ * 4. Spawns the agent with proper configuration
712
+ *
713
+ * @param config - Profile spawn configuration
714
+ * @param branch - Git branch (defaults to current)
715
+ * @param cwd - Working directory
716
+ * @returns Session and window names
717
+ * @throws Error if profile not found, validation fails, or non-prompt-scoped agent already exists
718
+ */
719
+ export function spawnAgentFromProfile(
720
+ config: ProfileSpawnConfig,
721
+ branch?: string,
722
+ cwd?: string
723
+ ): { sessionName: string; windowName: string } {
724
+ const profile = loadAgentProfile(config.agentName);
725
+
726
+ if (!profile) {
727
+ const available = listAgentProfiles();
728
+ throw new Error(
729
+ `Agent profile not found: ${config.agentName}. Available profiles: ${available.join(', ')}`
730
+ );
731
+ }
732
+
733
+ // Build the invocation (validates template vars)
734
+ const invocation = buildAgentInvocation(profile, config.context);
735
+
736
+ // Convert to SpawnConfig (flowOverride takes precedence over profile's default flow)
737
+ const spawnConfig: SpawnConfig = {
738
+ name: profile.name,
739
+ agentType: profile.name,
740
+ flowPath: config.flowOverride || invocation.flowPath,
741
+ preamble: invocation.preamble,
742
+ promptNumber: config.promptNumber,
743
+ specName: config.context.SPEC_NAME ?? undefined,
744
+ nonCoding: profile.nonCoding,
745
+ focusWindow: config.focusWindow,
746
+ promptScoped: profile.promptScoped,
747
+ };
748
+
749
+ return spawnAgent(spawnConfig, branch, cwd);
750
+ }
751
+
752
+ /**
753
+ * Configuration for custom flow spawning
754
+ */
755
+ export interface CustomFlowConfig {
756
+ /** Absolute path to the flow file */
757
+ flowPath: string;
758
+ /** Custom message to use as system prompt/preamble */
759
+ customMessage: string;
760
+ /** Unique window name (e.g., "custom-flow-1") */
761
+ windowName: string;
762
+ /** If true, switch focus to the new window (default: true) */
763
+ focusWindow?: boolean;
764
+ /** Current spec name (optional, for context) */
765
+ specName?: string;
766
+ }
767
+
768
+ /**
769
+ * Spawn a custom flow agent
770
+ *
771
+ * This allows running any flow file with a custom message as the preamble.
772
+ * The agent is tracked like profiled agents but without profile restrictions.
773
+ *
774
+ * @param config - Custom flow configuration
775
+ * @param branch - Git branch (defaults to current)
776
+ * @param cwd - Working directory
777
+ * @returns Session and window names
778
+ */
779
+ export function spawnCustomFlow(
780
+ config: CustomFlowConfig,
781
+ branch?: string,
782
+ cwd?: string
783
+ ): { sessionName: string; windowName: string } {
784
+ const currentBranch = branch || getCurrentBranch();
785
+ const sessionName = ensureSession(currentBranch, cwd);
786
+ const windowName = config.windowName;
787
+ const shouldFocus = config.focusWindow !== false;
788
+
789
+ // Kill existing window if present (allow respawning)
790
+ if (windowExists(sessionName, windowName)) {
791
+ killWindow(sessionName, windowName);
792
+ }
793
+
794
+ // Create new window (detached - don't switch focus yet)
795
+ createWindow(sessionName, windowName, cwd, true);
796
+
797
+ // Register this agent as spawned by ALL HANDS
798
+ registerSpawnedAgent(windowName);
799
+
800
+ // Build environment variables for the custom flow agent
801
+ // Note: BASE_BRANCH is communicated via the initial prompt, not env vars
802
+ const env: Record<string, string> = {
803
+ AGENT_ID: windowName,
804
+ AGENT_TYPE: 'custom-flow',
805
+ BRANCH: currentBranch,
806
+ };
807
+
808
+ if (config.specName) {
809
+ env.SPEC_NAME = config.specName;
810
+ }
811
+
812
+ // Read the flow file content
813
+ let flowContent = '';
814
+ if (existsSync(config.flowPath)) {
815
+ flowContent = readFileSync(config.flowPath, 'utf-8');
816
+ }
817
+
818
+ // Write a launcher script to avoid all shell escaping issues
819
+ const tempDir = join(cwd || process.cwd(), '.allhands', 'harness', '.cache', 'launchers');
820
+ mkdirSync(tempDir, { recursive: true });
821
+
822
+ // Clean up old launcher files (older than 24h)
823
+ cleanupOldLaunchers(tempDir);
824
+
825
+ const launcherScript = join(tempDir, `${windowName}-launcher.sh`);
826
+ const promptFile = join(tempDir, `${windowName}-prompt.txt`);
827
+
828
+ // Build combined prompt: flow content + custom message + base branch info
829
+ // NO system prompt - everything goes into the initial user prompt
830
+ const baseBranch = getBaseBranch();
831
+ const promptParts: string[] = [];
832
+
833
+ if (flowContent) {
834
+ promptParts.push(flowContent);
835
+ }
836
+
837
+ if (config.customMessage && config.customMessage.trim()) {
838
+ promptParts.push(config.customMessage);
839
+ }
840
+
841
+ // Always append base branch info
842
+ promptParts.push(`The base branch name is "${baseBranch}".`);
843
+
844
+ const combinedPrompt = promptParts.join('\n\n');
845
+ writeFileSync(promptFile, combinedPrompt, 'utf-8');
846
+
847
+ // Build the launcher script
848
+ const scriptLines: string[] = ['#!/bin/bash', ''];
849
+
850
+ // Skip pyenv rehash to avoid lock contention when spawning multiple agents
851
+ scriptLines.push('export PYENV_REHASH_SKIP=1');
852
+
853
+ // Export environment variables
854
+ for (const [key, value] of Object.entries(env)) {
855
+ scriptLines.push(`export ${key}="${value}"`);
856
+ }
857
+ scriptLines.push('');
858
+
859
+ // Build claude command - NO system prompt, everything in initial prompt
860
+ const cmdParts: string[] = ['claude'];
861
+ cmdParts.push('--settings .claude/settings.json');
862
+ cmdParts.push('--dangerously-skip-permissions');
863
+ cmdParts.push(`"$(cat '${promptFile}')"`)
864
+
865
+ scriptLines.push(cmdParts.join(' \\\n '));
866
+
867
+ writeFileSync(launcherScript, scriptLines.join('\n'), { mode: 0o755 });
868
+
869
+ // Execute the launcher script with exec so it replaces the shell
870
+ sendKeys(sessionName, windowName, `exec bash '${launcherScript}'`);
871
+
872
+ // Switch focus to the new window if requested
873
+ if (shouldFocus) {
874
+ selectWindow(sessionName, windowName);
875
+ }
876
+
877
+ return { sessionName, windowName };
878
+ }
879
+
880
+ /**
881
+ * Build standard template context from planning state
882
+ *
883
+ * This constructs the context object needed for agent spawning
884
+ * by reading the current planning state.
885
+ *
886
+ * @param spec - The spec name (used for planning paths)
887
+ * @param specName - Optional display name for spec
888
+ * @param promptNumber - Optional prompt number
889
+ * @param promptPath - Optional prompt file path
890
+ * @param cwd - Working directory
891
+ */
892
+ export function buildTemplateContext(
893
+ spec: string,
894
+ specName?: string,
895
+ promptNumber?: number,
896
+ promptPath?: string,
897
+ cwd?: string
898
+ ): TemplateContext {
899
+ // Use spec for planning paths (directory key)
900
+ const paths = getPlanningPaths(spec, cwd);
901
+ const branch = getCurrentBranch(cwd);
902
+
903
+ // Resolve spec type for SPEC_TYPE template variable
904
+ const branchSpec = getSpecForBranch(branch, cwd);
905
+
906
+ const context: TemplateContext = {
907
+ BRANCH: branch,
908
+ PLANNING_FOLDER: paths.root,
909
+ PROMPTS_FOLDER: paths.prompts,
910
+ ALIGNMENT_PATH: paths.alignment,
911
+ OUTPUT_PATH: join(paths.root, 'e2e-test-plan.md'),
912
+ SPEC_TYPE: branchSpec?.type ?? 'milestone',
913
+ };
914
+
915
+ // Set spec name (use the display name if provided, else the directory name)
916
+ context.SPEC_NAME = specName || spec;
917
+
918
+ if (promptNumber !== undefined) {
919
+ context.PROMPT_NUMBER = String(promptNumber).padStart(2, '0');
920
+ }
921
+
922
+ if (promptPath) {
923
+ context.PROMPT_PATH = promptPath;
924
+ }
925
+
926
+ // Add hypothesis domains from settings.json
927
+ const settings = loadProjectSettings();
928
+ const defaultDomains = ['testing', 'stability', 'performance', 'feature', 'ux', 'integration'];
929
+ const domains = settings?.emergent?.hypothesisDomains ?? defaultDomains;
930
+ context.HYPOTHESIS_DOMAINS = domains.join(', ');
931
+
932
+ // Try to read spec path from status (YAML format)
933
+ if (existsSync(paths.status)) {
934
+ try {
935
+ const content = readFileSync(paths.status, 'utf-8');
936
+ const specMatch = content.match(/^spec:\s*(.+)/m);
937
+ if (specMatch) {
938
+ context.SPEC_PATH = specMatch[1].trim();
939
+ }
940
+ } catch {
941
+ // Ignore parse errors
942
+ }
943
+ }
944
+
945
+ // Resolve WORKFLOW_DOMAIN_PATH from spec's initial_workflow_domain frontmatter
946
+ const basePath = cwd || process.cwd();
947
+ const workflowDomain = context.SPEC_PATH
948
+ ? getWorkflowDomain(join(basePath, context.SPEC_PATH))
949
+ : 'milestone';
950
+ const workflowDomainPath = join(basePath, '.allhands', 'workflows', `${workflowDomain}.md`);
951
+ if (existsSync(workflowDomainPath)) {
952
+ context.WORKFLOW_DOMAIN_PATH = workflowDomainPath;
953
+ } else {
954
+ console.warn(`Workflow domain config not found: ${workflowDomainPath}`);
955
+ context.WORKFLOW_DOMAIN_PATH = '';
956
+ }
957
+
958
+ return context;
959
+ }
960
+
961
+ /**
962
+ * Attach to the tmux session
963
+ */
964
+ export function attachSession(sessionName: string): void {
965
+ // This will take over the terminal
966
+ const child = spawn('tmux', ['attach-session', '-t', sessionName], {
967
+ stdio: 'inherit',
968
+ });
969
+
970
+ child.on('exit', () => {
971
+ process.exit(0);
972
+ });
973
+ }
974
+
975
+ /**
976
+ * Get info about all running agents
977
+ *
978
+ * Only returns agents that were spawned by ALL HANDS (tracked in registry)
979
+ * AND still exist in tmux.
980
+ */
981
+ export function getRunningAgents(branch?: string): Array<{
982
+ windowName: string;
983
+ agentType?: string;
984
+ }> {
985
+ // Use current session if we're in tmux, otherwise fall back to named session
986
+ const currentSession = getCurrentSession();
987
+ const sessionName = currentSession || getSessionName(branch);
988
+
989
+ if (!sessionExists(sessionName)) {
990
+ return [];
991
+ }
992
+
993
+ const windows = listWindows(sessionName);
994
+ const registry = getSpawnedAgentRegistry();
995
+
996
+ // Filter to agent windows that:
997
+ // 1. Are in our spawned registry (were created by ALL HANDS)
998
+ // 2. Still exist in tmux
999
+ // 3. Are not the TUI/hub window
1000
+ return windows
1001
+ .filter((w) => w.index > 0 && w.name !== 'hub' && registry.has(w.name))
1002
+ .map((w) => ({
1003
+ windowName: w.name,
1004
+ agentType: inferAgentType(w.name),
1005
+ }));
1006
+ }
1007
+
1008
+ /**
1009
+ * Get all valid agent types from profiles.
1010
+ */
1011
+ export function getAgentTypes(): string[] {
1012
+ return listAgentProfiles().map((name) => {
1013
+ const profile = loadAgentProfile(name);
1014
+ return profile?.name ?? name;
1015
+ });
1016
+ }
1017
+
1018
+ /**
1019
+ * Infer agent type from window name using agent profiles.
1020
+ *
1021
+ * Window names follow patterns:
1022
+ * - Non-prompt-scoped: exact profile name (e.g., "planner")
1023
+ * - Prompt-scoped: "{name}-{NN}" (e.g., "executor-01")
1024
+ */
1025
+ function inferAgentType(windowName: string): AgentType | undefined {
1026
+ const lowerName = windowName.toLowerCase();
1027
+
1028
+ // Load all profiles and match against window name
1029
+ const profileNames = listAgentProfiles();
1030
+
1031
+ for (const profileName of profileNames) {
1032
+ const profile = loadAgentProfile(profileName);
1033
+ if (!profile) continue;
1034
+
1035
+ const name = profile.name.toLowerCase();
1036
+
1037
+ if (!profile.promptScoped) {
1038
+ // Non-prompt-scoped: exact match
1039
+ if (lowerName === name) {
1040
+ return name;
1041
+ }
1042
+ } else {
1043
+ // Prompt-scoped: match "{name}" or "{name}-{NN}"
1044
+ if (lowerName === name || lowerName.match(new RegExp(`^${name}-\\d+$`))) {
1045
+ return name;
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ return undefined;
1051
+ }