fleet-commander-ai 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +159 -0
  3. package/bin/fleet-commander-mcp.js +27 -0
  4. package/bin/fleet-commander.js +22 -0
  5. package/dist/client/assets/index-CHukC8Hq.js +188 -0
  6. package/dist/client/assets/index-CHukC8Hq.js.map +1 -0
  7. package/dist/client/assets/index-DvMjcYbg.css +1 -0
  8. package/dist/client/index.html +13 -0
  9. package/dist/server/config.d.ts +51 -0
  10. package/dist/server/config.d.ts.map +1 -0
  11. package/dist/server/config.js +104 -0
  12. package/dist/server/config.js.map +1 -0
  13. package/dist/server/db.d.ts +388 -0
  14. package/dist/server/db.d.ts.map +1 -0
  15. package/dist/server/db.js +1524 -0
  16. package/dist/server/db.js.map +1 -0
  17. package/dist/server/index.d.ts +2 -0
  18. package/dist/server/index.d.ts.map +1 -0
  19. package/dist/server/index.js +162 -0
  20. package/dist/server/index.js.map +1 -0
  21. package/dist/server/mcp/index.d.ts +2 -0
  22. package/dist/server/mcp/index.d.ts.map +1 -0
  23. package/dist/server/mcp/index.js +112 -0
  24. package/dist/server/mcp/index.js.map +1 -0
  25. package/dist/server/mcp/tools/add-project.d.ts +9 -0
  26. package/dist/server/mcp/tools/add-project.d.ts.map +1 -0
  27. package/dist/server/mcp/tools/add-project.js +58 -0
  28. package/dist/server/mcp/tools/add-project.js.map +1 -0
  29. package/dist/server/mcp/tools/get-team-timeline.d.ts +9 -0
  30. package/dist/server/mcp/tools/get-team-timeline.d.ts.map +1 -0
  31. package/dist/server/mcp/tools/get-team-timeline.js +48 -0
  32. package/dist/server/mcp/tools/get-team-timeline.js.map +1 -0
  33. package/dist/server/mcp/tools/get-team.d.ts +9 -0
  34. package/dist/server/mcp/tools/get-team.d.ts.map +1 -0
  35. package/dist/server/mcp/tools/get-team.js +48 -0
  36. package/dist/server/mcp/tools/get-team.js.map +1 -0
  37. package/dist/server/mcp/tools/get-usage.d.ts +9 -0
  38. package/dist/server/mcp/tools/get-usage.d.ts.map +1 -0
  39. package/dist/server/mcp/tools/get-usage.js +33 -0
  40. package/dist/server/mcp/tools/get-usage.js.map +1 -0
  41. package/dist/server/mcp/tools/launch-team.d.ts +8 -0
  42. package/dist/server/mcp/tools/launch-team.d.ts.map +1 -0
  43. package/dist/server/mcp/tools/launch-team.js +49 -0
  44. package/dist/server/mcp/tools/launch-team.js.map +1 -0
  45. package/dist/server/mcp/tools/list-issues.d.ts +9 -0
  46. package/dist/server/mcp/tools/list-issues.d.ts.map +1 -0
  47. package/dist/server/mcp/tools/list-issues.js +47 -0
  48. package/dist/server/mcp/tools/list-issues.js.map +1 -0
  49. package/dist/server/mcp/tools/list-projects.d.ts +9 -0
  50. package/dist/server/mcp/tools/list-projects.d.ts.map +1 -0
  51. package/dist/server/mcp/tools/list-projects.js +32 -0
  52. package/dist/server/mcp/tools/list-projects.js.map +1 -0
  53. package/dist/server/mcp/tools/list-teams.d.ts +9 -0
  54. package/dist/server/mcp/tools/list-teams.d.ts.map +1 -0
  55. package/dist/server/mcp/tools/list-teams.js +43 -0
  56. package/dist/server/mcp/tools/list-teams.js.map +1 -0
  57. package/dist/server/mcp/tools/restart-team.d.ts +8 -0
  58. package/dist/server/mcp/tools/restart-team.d.ts.map +1 -0
  59. package/dist/server/mcp/tools/restart-team.js +46 -0
  60. package/dist/server/mcp/tools/restart-team.js.map +1 -0
  61. package/dist/server/mcp/tools/send-message.d.ts +9 -0
  62. package/dist/server/mcp/tools/send-message.d.ts.map +1 -0
  63. package/dist/server/mcp/tools/send-message.js +48 -0
  64. package/dist/server/mcp/tools/send-message.js.map +1 -0
  65. package/dist/server/mcp/tools/stop-team.d.ts +8 -0
  66. package/dist/server/mcp/tools/stop-team.d.ts.map +1 -0
  67. package/dist/server/mcp/tools/stop-team.js +46 -0
  68. package/dist/server/mcp/tools/stop-team.js.map +1 -0
  69. package/dist/server/mcp/tools/system-health.d.ts +9 -0
  70. package/dist/server/mcp/tools/system-health.d.ts.map +1 -0
  71. package/dist/server/mcp/tools/system-health.js +32 -0
  72. package/dist/server/mcp/tools/system-health.js.map +1 -0
  73. package/dist/server/middleware/error-handler.d.ts +32 -0
  74. package/dist/server/middleware/error-handler.d.ts.map +1 -0
  75. package/dist/server/middleware/error-handler.js +65 -0
  76. package/dist/server/middleware/error-handler.js.map +1 -0
  77. package/dist/server/routes/events.d.ts +16 -0
  78. package/dist/server/routes/events.d.ts.map +1 -0
  79. package/dist/server/routes/events.js +164 -0
  80. package/dist/server/routes/events.js.map +1 -0
  81. package/dist/server/routes/issues.d.ts +4 -0
  82. package/dist/server/routes/issues.d.ts.map +1 -0
  83. package/dist/server/routes/issues.js +198 -0
  84. package/dist/server/routes/issues.js.map +1 -0
  85. package/dist/server/routes/project-groups.d.ts +4 -0
  86. package/dist/server/routes/project-groups.d.ts.map +1 -0
  87. package/dist/server/routes/project-groups.js +124 -0
  88. package/dist/server/routes/project-groups.js.map +1 -0
  89. package/dist/server/routes/projects.d.ts +4 -0
  90. package/dist/server/routes/projects.d.ts.map +1 -0
  91. package/dist/server/routes/projects.js +319 -0
  92. package/dist/server/routes/projects.js.map +1 -0
  93. package/dist/server/routes/prs.d.ts +4 -0
  94. package/dist/server/routes/prs.d.ts.map +1 -0
  95. package/dist/server/routes/prs.js +186 -0
  96. package/dist/server/routes/prs.js.map +1 -0
  97. package/dist/server/routes/query.d.ts +4 -0
  98. package/dist/server/routes/query.d.ts.map +1 -0
  99. package/dist/server/routes/query.js +82 -0
  100. package/dist/server/routes/query.js.map +1 -0
  101. package/dist/server/routes/state-machine.d.ts +4 -0
  102. package/dist/server/routes/state-machine.d.ts.map +1 -0
  103. package/dist/server/routes/state-machine.js +86 -0
  104. package/dist/server/routes/state-machine.js.map +1 -0
  105. package/dist/server/routes/stream.d.ts +18 -0
  106. package/dist/server/routes/stream.d.ts.map +1 -0
  107. package/dist/server/routes/stream.js +62 -0
  108. package/dist/server/routes/stream.js.map +1 -0
  109. package/dist/server/routes/system.d.ts +4 -0
  110. package/dist/server/routes/system.d.ts.map +1 -0
  111. package/dist/server/routes/system.js +225 -0
  112. package/dist/server/routes/system.js.map +1 -0
  113. package/dist/server/routes/teams.d.ts +4 -0
  114. package/dist/server/routes/teams.d.ts.map +1 -0
  115. package/dist/server/routes/teams.js +570 -0
  116. package/dist/server/routes/teams.js.map +1 -0
  117. package/dist/server/routes/usage.d.ts +4 -0
  118. package/dist/server/routes/usage.d.ts.map +1 -0
  119. package/dist/server/routes/usage.js +80 -0
  120. package/dist/server/routes/usage.js.map +1 -0
  121. package/dist/server/schema.sql +267 -0
  122. package/dist/server/services/cc-query.d.ts +20 -0
  123. package/dist/server/services/cc-query.d.ts.map +1 -0
  124. package/dist/server/services/cc-query.js +352 -0
  125. package/dist/server/services/cc-query.js.map +1 -0
  126. package/dist/server/services/cleanup.d.ts +15 -0
  127. package/dist/server/services/cleanup.d.ts.map +1 -0
  128. package/dist/server/services/cleanup.js +232 -0
  129. package/dist/server/services/cleanup.js.map +1 -0
  130. package/dist/server/services/diagnostics-service.d.ts +85 -0
  131. package/dist/server/services/diagnostics-service.d.ts.map +1 -0
  132. package/dist/server/services/diagnostics-service.js +242 -0
  133. package/dist/server/services/diagnostics-service.js.map +1 -0
  134. package/dist/server/services/event-collector.d.ts +125 -0
  135. package/dist/server/services/event-collector.d.ts.map +1 -0
  136. package/dist/server/services/event-collector.js +299 -0
  137. package/dist/server/services/event-collector.js.map +1 -0
  138. package/dist/server/services/event-service.d.ts +22 -0
  139. package/dist/server/services/event-service.d.ts.map +1 -0
  140. package/dist/server/services/event-service.js +53 -0
  141. package/dist/server/services/event-service.js.map +1 -0
  142. package/dist/server/services/github-poller.d.ts +68 -0
  143. package/dist/server/services/github-poller.d.ts.map +1 -0
  144. package/dist/server/services/github-poller.js +563 -0
  145. package/dist/server/services/github-poller.js.map +1 -0
  146. package/dist/server/services/issue-fetcher.d.ts +231 -0
  147. package/dist/server/services/issue-fetcher.d.ts.map +1 -0
  148. package/dist/server/services/issue-fetcher.js +1053 -0
  149. package/dist/server/services/issue-fetcher.js.map +1 -0
  150. package/dist/server/services/issue-service.d.ts +102 -0
  151. package/dist/server/services/issue-service.d.ts.map +1 -0
  152. package/dist/server/services/issue-service.js +279 -0
  153. package/dist/server/services/issue-service.js.map +1 -0
  154. package/dist/server/services/message-template-service.d.ts +39 -0
  155. package/dist/server/services/message-template-service.d.ts.map +1 -0
  156. package/dist/server/services/message-template-service.js +87 -0
  157. package/dist/server/services/message-template-service.js.map +1 -0
  158. package/dist/server/services/pr-service.d.ts +73 -0
  159. package/dist/server/services/pr-service.d.ts.map +1 -0
  160. package/dist/server/services/pr-service.js +231 -0
  161. package/dist/server/services/pr-service.js.map +1 -0
  162. package/dist/server/services/project-group-service.d.ts +64 -0
  163. package/dist/server/services/project-group-service.d.ts.map +1 -0
  164. package/dist/server/services/project-group-service.js +149 -0
  165. package/dist/server/services/project-group-service.js.map +1 -0
  166. package/dist/server/services/project-service.d.ts +161 -0
  167. package/dist/server/services/project-service.d.ts.map +1 -0
  168. package/dist/server/services/project-service.js +623 -0
  169. package/dist/server/services/project-service.js.map +1 -0
  170. package/dist/server/services/service-error.d.ts +25 -0
  171. package/dist/server/services/service-error.d.ts.map +1 -0
  172. package/dist/server/services/service-error.js +49 -0
  173. package/dist/server/services/service-error.js.map +1 -0
  174. package/dist/server/services/sse-broker.d.ts +144 -0
  175. package/dist/server/services/sse-broker.d.ts.map +1 -0
  176. package/dist/server/services/sse-broker.js +111 -0
  177. package/dist/server/services/sse-broker.js.map +1 -0
  178. package/dist/server/services/startup-recovery.d.ts +10 -0
  179. package/dist/server/services/startup-recovery.d.ts.map +1 -0
  180. package/dist/server/services/startup-recovery.js +122 -0
  181. package/dist/server/services/startup-recovery.js.map +1 -0
  182. package/dist/server/services/stuck-detector.d.ts +20 -0
  183. package/dist/server/services/stuck-detector.d.ts.map +1 -0
  184. package/dist/server/services/stuck-detector.js +167 -0
  185. package/dist/server/services/stuck-detector.js.map +1 -0
  186. package/dist/server/services/stuck-detector.test.d.ts +2 -0
  187. package/dist/server/services/stuck-detector.test.d.ts.map +1 -0
  188. package/dist/server/services/stuck-detector.test.js +363 -0
  189. package/dist/server/services/stuck-detector.test.js.map +1 -0
  190. package/dist/server/services/team-manager.d.ts +188 -0
  191. package/dist/server/services/team-manager.d.ts.map +1 -0
  192. package/dist/server/services/team-manager.js +1869 -0
  193. package/dist/server/services/team-manager.js.map +1 -0
  194. package/dist/server/services/team-service.d.ts +251 -0
  195. package/dist/server/services/team-service.d.ts.map +1 -0
  196. package/dist/server/services/team-service.js +707 -0
  197. package/dist/server/services/team-service.js.map +1 -0
  198. package/dist/server/services/usage-service.d.ts +42 -0
  199. package/dist/server/services/usage-service.d.ts.map +1 -0
  200. package/dist/server/services/usage-service.js +101 -0
  201. package/dist/server/services/usage-service.js.map +1 -0
  202. package/dist/server/services/usage-tracker.d.ts +68 -0
  203. package/dist/server/services/usage-tracker.d.ts.map +1 -0
  204. package/dist/server/services/usage-tracker.js +220 -0
  205. package/dist/server/services/usage-tracker.js.map +1 -0
  206. package/dist/server/utils/build-timeline.d.ts +32 -0
  207. package/dist/server/utils/build-timeline.d.ts.map +1 -0
  208. package/dist/server/utils/build-timeline.js +142 -0
  209. package/dist/server/utils/build-timeline.js.map +1 -0
  210. package/dist/server/utils/find-git-bash.d.ts +10 -0
  211. package/dist/server/utils/find-git-bash.d.ts.map +1 -0
  212. package/dist/server/utils/find-git-bash.js +46 -0
  213. package/dist/server/utils/find-git-bash.js.map +1 -0
  214. package/dist/server/utils/hook-installer.d.ts +20 -0
  215. package/dist/server/utils/hook-installer.d.ts.map +1 -0
  216. package/dist/server/utils/hook-installer.js +90 -0
  217. package/dist/server/utils/hook-installer.js.map +1 -0
  218. package/dist/server/utils/process-utils.d.ts +10 -0
  219. package/dist/server/utils/process-utils.d.ts.map +1 -0
  220. package/dist/server/utils/process-utils.js +33 -0
  221. package/dist/server/utils/process-utils.js.map +1 -0
  222. package/dist/server/utils/resolve-claude-path.d.ts +4 -0
  223. package/dist/server/utils/resolve-claude-path.d.ts.map +1 -0
  224. package/dist/server/utils/resolve-claude-path.js +66 -0
  225. package/dist/server/utils/resolve-claude-path.js.map +1 -0
  226. package/dist/server/utils/resolve-message.d.ts +10 -0
  227. package/dist/server/utils/resolve-message.d.ts.map +1 -0
  228. package/dist/server/utils/resolve-message.js +27 -0
  229. package/dist/server/utils/resolve-message.js.map +1 -0
  230. package/dist/shared/message-templates.d.ts +9 -0
  231. package/dist/shared/message-templates.d.ts.map +1 -0
  232. package/dist/shared/message-templates.js +88 -0
  233. package/dist/shared/message-templates.js.map +1 -0
  234. package/dist/shared/state-machine.d.ts +28 -0
  235. package/dist/shared/state-machine.d.ts.map +1 -0
  236. package/dist/shared/state-machine.js +282 -0
  237. package/dist/shared/state-machine.js.map +1 -0
  238. package/dist/shared/types.d.ts +404 -0
  239. package/dist/shared/types.d.ts.map +1 -0
  240. package/dist/shared/types.js +5 -0
  241. package/dist/shared/types.js.map +1 -0
  242. package/hooks/DESIGN.md +562 -0
  243. package/hooks/on_notification.sh +9 -0
  244. package/hooks/on_post_tool_use.sh +9 -0
  245. package/hooks/on_pre_compact.sh +10 -0
  246. package/hooks/on_session_end.sh +8 -0
  247. package/hooks/on_session_start.sh +8 -0
  248. package/hooks/on_stop.sh +8 -0
  249. package/hooks/on_stop_failure.sh +8 -0
  250. package/hooks/on_subagent_start.sh +8 -0
  251. package/hooks/on_subagent_stop.sh +8 -0
  252. package/hooks/on_teammate_idle.sh +8 -0
  253. package/hooks/on_tool_error.sh +8 -0
  254. package/hooks/send_event.sh +101 -0
  255. package/hooks/settings.json.example +120 -0
  256. package/package.json +93 -0
  257. package/prompts/default-prompt.md +16 -0
  258. package/scripts/install.ps1 +22 -0
  259. package/scripts/install.sh +229 -0
  260. package/scripts/launch.js +64 -0
  261. package/scripts/uninstall.ps1 +22 -0
  262. package/scripts/uninstall.sh +123 -0
  263. package/templates/agents/fleet-dev.md +162 -0
  264. package/templates/agents/fleet-planner.md +263 -0
  265. package/templates/agents/fleet-reviewer.md +309 -0
  266. package/templates/archive/fleet-coordinator.md +128 -0
  267. package/templates/archive/fleet-dev-csharp.md +74 -0
  268. package/templates/archive/fleet-dev-devops.md +76 -0
  269. package/templates/archive/fleet-dev-fsharp.md +83 -0
  270. package/templates/archive/fleet-dev-generic.md +64 -0
  271. package/templates/archive/fleet-dev-python.md +75 -0
  272. package/templates/archive/fleet-dev-typescript.md +76 -0
  273. package/templates/guides/api-design.md +159 -0
  274. package/templates/guides/csharp-conventions.md +182 -0
  275. package/templates/guides/devops-conventions.md +192 -0
  276. package/templates/guides/fsharp-conventions.md +201 -0
  277. package/templates/guides/python-conventions.md +146 -0
  278. package/templates/guides/sql-database.md +125 -0
  279. package/templates/guides/testing-strategies.md +123 -0
  280. package/templates/guides/typescript-conventions.md +130 -0
  281. package/templates/workflow.md +513 -0
@@ -0,0 +1,1869 @@
1
+ // =============================================================================
2
+ // Fleet Commander — Team Manager Service (Spawn / Stop / Resume)
3
+ // =============================================================================
4
+ // Manages Claude Code agent processes: creates git worktrees, copies hooks,
5
+ // spawns child processes, captures output, and handles lifecycle transitions.
6
+ //
7
+ // Per-project: launch() accepts a projectId and resolves repo path, github
8
+ // repo, and worktree naming from the project record in the database.
9
+ // =============================================================================
10
+ import { spawn, execSync, exec as execCallback } from 'child_process';
11
+ import { promisify } from 'util';
12
+ import path from 'path';
13
+ import fs from 'fs';
14
+ import config from '../config.js';
15
+ import { getDatabase } from '../db.js';
16
+ import { sseBroker } from './sse-broker.js';
17
+ import { findGitBash } from '../utils/find-git-bash.js';
18
+ import { resolveClaudePath } from '../utils/resolve-claude-path.js';
19
+ import { getUsageZone } from './usage-tracker.js';
20
+ import { resolveMessage } from '../utils/resolve-message.js';
21
+ const execAsync = promisify(execCallback);
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+ const MAX_OUTPUT_LINES = config.outputBufferLines;
26
+ const MAX_PARSED_EVENTS = 1000;
27
+ // ---------------------------------------------------------------------------
28
+ // summarizeEvent — short text summary for console logging
29
+ // ---------------------------------------------------------------------------
30
+ function summarizeEvent(event) {
31
+ switch (event.type) {
32
+ case 'assistant': {
33
+ const content = event.message?.content;
34
+ if (Array.isArray(content)) {
35
+ const text = content.find((c) => c.type === 'text')?.text ?? '';
36
+ return text.substring(0, 100) + (text.length > 100 ? '...' : '');
37
+ }
38
+ return '';
39
+ }
40
+ case 'tool_use': {
41
+ const tool = event.tool;
42
+ return `${tool?.name ?? 'unknown'}`;
43
+ }
44
+ case 'tool_result':
45
+ return 'completed';
46
+ case 'result':
47
+ return 'session complete';
48
+ default:
49
+ return event.type;
50
+ }
51
+ }
52
+ export class TeamManager {
53
+ outputBuffers = new Map();
54
+ childProcesses = new Map();
55
+ stdinPipes = new Map();
56
+ parsedEvents = new Map();
57
+ tokenCounters = new Map();
58
+ _processingQueue = new Set();
59
+ shutdownTimers = new Map();
60
+ /**
61
+ * Per-team map of tool_use_id -> agent name.
62
+ * When the TL spawns a subagent via the "Agent" or "Task" tool, the tool_use
63
+ * content block's `id` becomes the `parent_tool_use_id` on subsequent
64
+ * assistant events from that subagent. We extract the agent name from the
65
+ * tool input (e.g. `input.name` = "dev") and store it here so every stream
66
+ * event can be tagged with the originating agent name.
67
+ */
68
+ agentMaps = new Map();
69
+ /** Teams currently in extended thinking — tracked in-memory only */
70
+ thinkingTeams = new Set();
71
+ /** Timestamp when thinking started for each team (for duration tracking) */
72
+ thinkingStartTimes = new Map();
73
+ /** Content block index of the active thinking block per team */
74
+ thinkingBlockIndex = new Map();
75
+ // -------------------------------------------------------------------------
76
+ // syncWithOrigin — fetch + pull before creating a worktree
77
+ // -------------------------------------------------------------------------
78
+ /**
79
+ * Sync local repo with origin before creating a worktree.
80
+ * Returns the number of commits the local default branch was behind origin.
81
+ */
82
+ async syncWithOrigin(repoPath, teamId) {
83
+ let commitsBehind = 0;
84
+ try {
85
+ // Fetch latest from origin
86
+ await execAsync('git fetch origin', {
87
+ cwd: repoPath,
88
+ timeout: 30000,
89
+ });
90
+ // Detect default branch
91
+ let defaultBranch = 'main';
92
+ try {
93
+ const { stdout } = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
94
+ cwd: repoPath,
95
+ timeout: 5000,
96
+ });
97
+ defaultBranch = stdout.trim().replace(/^refs\/remotes\/origin\//, '');
98
+ }
99
+ catch {
100
+ // Fallback to 'main'
101
+ }
102
+ // Count commits behind
103
+ try {
104
+ const { stdout } = await execAsync(`git rev-list --count HEAD..origin/${defaultBranch}`, {
105
+ cwd: repoPath,
106
+ timeout: 5000,
107
+ });
108
+ commitsBehind = parseInt(stdout.trim(), 10) || 0;
109
+ }
110
+ catch {
111
+ // Non-fatal
112
+ }
113
+ // Pull to sync local default branch
114
+ if (commitsBehind > 0) {
115
+ console.log(`[TeamManager] Local is ${commitsBehind} commits behind origin/${defaultBranch}, pulling...`);
116
+ try {
117
+ await execAsync(`git pull origin ${defaultBranch} --ff-only`, {
118
+ cwd: repoPath,
119
+ timeout: 30000,
120
+ });
121
+ console.log(`[TeamManager] Pulled ${commitsBehind} commits from origin/${defaultBranch}`);
122
+ }
123
+ catch (pullErr) {
124
+ console.warn(`[TeamManager] Fast-forward pull failed, trying merge:`, pullErr instanceof Error ? pullErr.message : String(pullErr));
125
+ // If ff-only fails, don't force — just warn
126
+ }
127
+ }
128
+ else {
129
+ console.log(`[TeamManager] Repo is up to date with origin/${defaultBranch}`);
130
+ }
131
+ // Inject a log event into the team's session log
132
+ const syncEvent = {
133
+ type: 'fc',
134
+ subtype: 'origin_sync',
135
+ agentName: '__fc__',
136
+ timestamp: new Date().toISOString(),
137
+ message: {
138
+ content: [{
139
+ type: 'text',
140
+ text: commitsBehind > 0
141
+ ? `Synced with origin/${defaultBranch}: pulled ${commitsBehind} commit(s)`
142
+ : `Up to date with origin/${defaultBranch}`,
143
+ }],
144
+ },
145
+ };
146
+ const events = this.parsedEvents.get(teamId);
147
+ if (events)
148
+ events.push(syncEvent);
149
+ sseBroker.broadcast('team_output', { team_id: teamId, event: syncEvent }, teamId);
150
+ }
151
+ catch (err) {
152
+ console.error(`[TeamManager] Failed to sync with origin:`, err instanceof Error ? err.message : String(err));
153
+ }
154
+ return commitsBehind;
155
+ }
156
+ // -------------------------------------------------------------------------
157
+ // launch — create worktree, copy hooks, spawn Claude Code
158
+ // -------------------------------------------------------------------------
159
+ async launch(projectId, issueNumber, issueTitle, prompt, headless, force) {
160
+ const db = getDatabase();
161
+ // Look up project to get repo_path, github_repo, name
162
+ const project = db.getProject(projectId);
163
+ if (!project) {
164
+ throw new Error(`Project ${projectId} not found`);
165
+ }
166
+ console.log(`[TeamManager] Launch started: project=${project.name} issue=#${issueNumber}`);
167
+ // Usage gate: if in red zone and not forced, queue instead of launching
168
+ if (!force && getUsageZone() === 'red') {
169
+ console.log(`[TeamManager] Usage zone is RED — queueing team for issue #${issueNumber}`);
170
+ return this.queueTeam(db, project, projectId, issueNumber, issueTitle, headless, prompt);
171
+ }
172
+ // Check active team limit before proceeding
173
+ const activeCount = db.getActiveTeamCountByProject(projectId);
174
+ if (activeCount >= project.maxActiveTeams) {
175
+ // Queue this team instead of launching
176
+ return this.queueTeam(db, project, projectId, issueNumber, issueTitle, headless, prompt);
177
+ }
178
+ // If no title provided, fetch from GitHub
179
+ if (!issueTitle && project.githubRepo) {
180
+ try {
181
+ const { stdout } = await execAsync(`gh issue view ${issueNumber} --repo ${project.githubRepo} --json title --jq .title`, { timeout: 10000 });
182
+ const result = stdout.trim();
183
+ if (result) {
184
+ issueTitle = result;
185
+ console.log(`[TeamManager] Fetched issue title from GitHub: "${issueTitle}"`);
186
+ }
187
+ }
188
+ catch {
189
+ // GitHub fetch failed, use fallback
190
+ issueTitle = `Issue #${issueNumber}`;
191
+ console.log(`[TeamManager] GitHub title fetch failed, using fallback: "${issueTitle}"`);
192
+ }
193
+ }
194
+ else if (!issueTitle) {
195
+ issueTitle = `Issue #${issueNumber}`;
196
+ }
197
+ // Derive worktree naming from project
198
+ const { worktreeName, branchName, worktreeRelPath, worktreeAbsPath } = this.deriveWorktreeNames(project, issueNumber);
199
+ // Check if a team already exists for this worktree name
200
+ const existing = db.getTeamByWorktree(worktreeName);
201
+ let relaunchTeamId = null;
202
+ if (existing) {
203
+ if (['running', 'launching', 'idle', 'stuck', 'queued'].includes(existing.status)) {
204
+ throw new Error(`Team already active for issue ${issueNumber} (status: ${existing.status})`);
205
+ }
206
+ if (existing.status === 'done') {
207
+ throw new Error(`Team already completed for issue ${issueNumber} — completed teams cannot be relaunched`);
208
+ }
209
+ // Terminal state (failed) — reuse the existing team record
210
+ relaunchTeamId = existing.id;
211
+ }
212
+ // ── Step 1: Insert or reuse team record in DB (status: queued) ──
213
+ // Team appears in FleetGrid immediately at this point.
214
+ const now = new Date().toISOString();
215
+ let team;
216
+ if (relaunchTeamId !== null) {
217
+ // Relaunch: reset the existing terminal team record
218
+ console.log(`[TeamManager] Relaunching existing team record: id=${relaunchTeamId}, worktree=${worktreeName}`);
219
+ const prevTeam = db.getTeam(relaunchTeamId);
220
+ db.updateTeam(relaunchTeamId, {
221
+ status: 'queued',
222
+ phase: 'init',
223
+ pid: null,
224
+ sessionId: null,
225
+ issueTitle: issueTitle ?? null,
226
+ headless: headless !== false,
227
+ launchedAt: now,
228
+ stoppedAt: null,
229
+ lastEventAt: null,
230
+ });
231
+ db.insertTransition({
232
+ teamId: relaunchTeamId,
233
+ fromStatus: prevTeam?.status ?? 'failed',
234
+ toStatus: 'queued',
235
+ trigger: 'pm_action',
236
+ reason: 'Relaunched by PM',
237
+ });
238
+ team = db.getTeam(relaunchTeamId);
239
+ }
240
+ else {
241
+ // Fresh launch: insert new team record
242
+ console.log(`[TeamManager] Inserting team record: worktree=${worktreeName}, branch=${branchName}`);
243
+ team = db.insertTeam({
244
+ projectId,
245
+ issueNumber,
246
+ issueTitle: issueTitle ?? null,
247
+ worktreeName,
248
+ branchName,
249
+ status: 'queued',
250
+ phase: 'init',
251
+ headless: headless !== false,
252
+ launchedAt: now,
253
+ });
254
+ }
255
+ console.log(`[TeamManager] Team queued: id=${team.id}, status=queued, relaunch=${relaunchTeamId !== null}`);
256
+ // Broadcast immediately so the team appears in the grid right away
257
+ this.broadcastSnapshot();
258
+ // Sync with origin before creating worktree
259
+ await this.syncWithOrigin(project.repoPath, team.id);
260
+ // ── Step 2: Create git worktree in the PROJECT's repo ──
261
+ const worktreeOk = await this.createWorktree(project.repoPath, worktreeRelPath, worktreeAbsPath, branchName, team.id, 'queued');
262
+ if (!worktreeOk) {
263
+ throw new Error(`Failed to create worktree for team ${team.id}`);
264
+ }
265
+ console.log(`[TeamManager] Worktree created: ${worktreeName} at ${worktreeAbsPath}`);
266
+ // Update team status to launching now that worktree exists
267
+ db.insertTransition({
268
+ teamId: team.id,
269
+ fromStatus: 'queued',
270
+ toStatus: 'launching',
271
+ trigger: 'system',
272
+ reason: 'Worktree created, spawning Claude Code process',
273
+ });
274
+ db.updateTeam(team.id, { status: 'launching' });
275
+ this.broadcastSnapshot();
276
+ // ── Step 3: Copy hook scripts and settings into worktree ──
277
+ this.copyHooksAndSettings(worktreeAbsPath);
278
+ // ── Step 4: Spawn Claude Code process ──
279
+ const resolvedPrompt = prompt || this.resolvePromptFromFile(project, issueNumber);
280
+ const isHeadless = headless !== false;
281
+ if (!isHeadless && process.platform === 'win32') {
282
+ // ── Interactive mode (Windows): open Claude Code in a new terminal ──
283
+ await this.launchInteractive(team, project, worktreeAbsPath, resolvedPrompt);
284
+ return db.getTeam(team.id);
285
+ }
286
+ // ── Headless mode (default): spawn in background, capture output ──
287
+ const args = this.buildHeadlessClaudeArgs(worktreeName, {
288
+ model: project.model,
289
+ });
290
+ const spawnEnv = this.buildSpawnEnv(project, worktreeName, projectId);
291
+ const claudePath = resolveClaudePath();
292
+ console.log(`[TeamManager] Spawning: ${claudePath} ${args.join(' ')} (headless=${isHeadless})`);
293
+ const child = this.spawnAndValidate(claudePath, args, project.repoPath, spawnEnv, team.id);
294
+ if (!child) {
295
+ throw new Error('Failed to spawn Claude Code process — no PID returned');
296
+ }
297
+ this.setupStdinAndOutput(team.id, child, resolvedPrompt);
298
+ this.attachProcessHandlers(team.id, child);
299
+ sseBroker.broadcast('team_launched', { team_id: team.id, issue_number: issueNumber, project_id: projectId }, team.id);
300
+ return db.getTeam(team.id);
301
+ }
302
+ // -------------------------------------------------------------------------
303
+ // stop — kill process tree
304
+ // -------------------------------------------------------------------------
305
+ async stop(teamId) {
306
+ const db = getDatabase();
307
+ const team = db.getTeam(teamId);
308
+ if (!team) {
309
+ throw new Error(`Team ${teamId} not found`);
310
+ }
311
+ // Cancel any pending merge-shutdown timer for this team
312
+ const pendingTimer = this.shutdownTimers.get(teamId);
313
+ if (pendingTimer) {
314
+ clearTimeout(pendingTimer);
315
+ this.shutdownTimers.delete(teamId);
316
+ }
317
+ // Queued teams have no process — just cancel them directly
318
+ if (team.status === 'queued') {
319
+ db.insertTransition({
320
+ teamId,
321
+ fromStatus: 'queued',
322
+ toStatus: 'failed',
323
+ trigger: 'pm_action',
324
+ reason: 'PM stopped queued team',
325
+ });
326
+ db.updateTeam(teamId, { status: 'failed', stoppedAt: new Date().toISOString() });
327
+ this.broadcastSnapshot();
328
+ return db.getTeam(teamId);
329
+ }
330
+ // Try graceful shutdown via stdin.end() first — closing stdin signals
331
+ // Claude Code to finish its current work and exit cleanly.
332
+ const stdin = this.stdinPipes.get(teamId);
333
+ if (stdin && !stdin.destroyed) {
334
+ try {
335
+ stdin.end();
336
+ console.log(`[TeamManager] Sent stdin EOF to team ${teamId} for graceful shutdown`);
337
+ // Give the process 5 seconds to finish gracefully before force-killing
338
+ await new Promise(resolve => setTimeout(resolve, 5000));
339
+ }
340
+ catch {
341
+ // stdin.end() failed — fall through to force kill
342
+ }
343
+ }
344
+ this.stdinPipes.delete(teamId);
345
+ // Force kill if still running — re-read from DB to get fresh PID
346
+ // (the process may have exited during the 5s grace period)
347
+ const freshTeam = db.getTeam(teamId);
348
+ if (freshTeam?.pid) {
349
+ this.killProcess(freshTeam.pid);
350
+ }
351
+ // Clean up child process reference
352
+ this.clearThinking(teamId);
353
+ this.flushTokenCounters(teamId);
354
+ this.persistParsedEvents(teamId);
355
+ this.childProcesses.delete(teamId);
356
+ this.outputBuffers.delete(teamId);
357
+ this.parsedEvents.delete(teamId);
358
+ this.agentMaps.delete(teamId);
359
+ this.tokenCounters.delete(teamId);
360
+ // Re-read to get the latest status (process exit handler may have already updated it)
361
+ const stopTeam = db.getTeam(teamId);
362
+ if (stopTeam && !['done', 'failed'].includes(stopTeam.status)) {
363
+ db.insertTransition({
364
+ teamId,
365
+ fromStatus: stopTeam.status,
366
+ toStatus: 'failed',
367
+ trigger: 'pm_action',
368
+ reason: 'PM stopped team',
369
+ });
370
+ }
371
+ const updated = db.updateTeam(teamId, {
372
+ status: 'failed',
373
+ pid: null,
374
+ stoppedAt: new Date().toISOString(),
375
+ });
376
+ sseBroker.broadcast('team_stopped', { team_id: teamId }, teamId);
377
+ this.broadcastSnapshot();
378
+ // Process queue when a slot frees up
379
+ if (team.projectId) {
380
+ this.processQueue(team.projectId).catch((err) => {
381
+ console.error(`[TeamManager] processQueue error after stop:`, err);
382
+ });
383
+ }
384
+ return updated;
385
+ }
386
+ // -------------------------------------------------------------------------
387
+ // resume — re-spawn with --resume flag in existing worktree
388
+ // -------------------------------------------------------------------------
389
+ async resume(teamId) {
390
+ const db = getDatabase();
391
+ const team = db.getTeam(teamId);
392
+ if (!team) {
393
+ throw new Error(`Team ${teamId} not found`);
394
+ }
395
+ if (team.status === 'done') {
396
+ throw new Error('Cannot resume a completed team');
397
+ }
398
+ // Resolve project for repo path and queue limit check
399
+ if (!team.projectId) {
400
+ throw new Error(`Team ${teamId} has no project`);
401
+ }
402
+ const project = db.getProject(team.projectId);
403
+ if (!project) {
404
+ throw new Error(`Project for team ${teamId} not found (projectId: ${team.projectId})`);
405
+ }
406
+ // Check queue limit — if too many teams are active, queue the resume instead
407
+ const activeCount = db.getActiveTeamCountByProject(team.projectId);
408
+ if (activeCount >= project.maxActiveTeams) {
409
+ db.insertTransition({
410
+ teamId,
411
+ fromStatus: team.status,
412
+ toStatus: 'queued',
413
+ trigger: 'pm_action',
414
+ reason: `Resume queued (${activeCount}/${project.maxActiveTeams} active)`,
415
+ });
416
+ db.updateTeam(teamId, { status: 'queued' });
417
+ console.log(`[TeamManager] Resume queued for team ${teamId} (${activeCount}/${project.maxActiveTeams} active)`);
418
+ this.broadcastSnapshot();
419
+ return db.getTeam(teamId);
420
+ }
421
+ // Verify worktree still exists
422
+ const worktreeAbsPath = path.join(project.repoPath, config.worktreeDir, team.worktreeName);
423
+ if (!fs.existsSync(worktreeAbsPath)) {
424
+ throw new Error(`Worktree ${team.worktreeName} no longer exists at ${worktreeAbsPath}`);
425
+ }
426
+ // Build args with --resume (headless mode — resume always uses stream-json)
427
+ const args = this.buildHeadlessClaudeArgs(team.worktreeName, {
428
+ resume: true,
429
+ model: project.model,
430
+ });
431
+ // Update status to launching
432
+ db.insertTransition({
433
+ teamId,
434
+ fromStatus: team.status,
435
+ toStatus: 'launching',
436
+ trigger: 'pm_action',
437
+ reason: 'PM resumed team',
438
+ });
439
+ db.updateTeam(teamId, {
440
+ status: 'launching',
441
+ launchedAt: new Date().toISOString(),
442
+ stoppedAt: null,
443
+ });
444
+ const spawnEnv = this.buildSpawnEnv(project, team.worktreeName, project.id);
445
+ const claudePath = resolveClaudePath();
446
+ console.log(`[TeamManager] Resume spawning: ${claudePath} ${args.join(' ')}`);
447
+ const child = this.spawnAndValidate(claudePath, args, project.repoPath, spawnEnv, teamId);
448
+ if (!child) {
449
+ throw new Error('Failed to spawn Claude Code process — no PID returned');
450
+ }
451
+ // Resume: no initial prompt — just set up stdin and output capture
452
+ this.setupStdinAndOutput(teamId, child);
453
+ this.attachProcessHandlers(teamId, child);
454
+ sseBroker.broadcast('team_launched', { team_id: teamId, issue_number: team.issueNumber }, teamId);
455
+ this.broadcastSnapshot();
456
+ return db.getTeam(teamId);
457
+ }
458
+ // -------------------------------------------------------------------------
459
+ // restart — stop then launch
460
+ // -------------------------------------------------------------------------
461
+ async restart(teamId, prompt) {
462
+ const db = getDatabase();
463
+ const team = db.getTeam(teamId);
464
+ if (!team) {
465
+ throw new Error(`Team ${teamId} not found`);
466
+ }
467
+ if (team.status === 'done') {
468
+ throw new Error('Cannot restart a completed team');
469
+ }
470
+ // Stop if running or queued
471
+ if (['launching', 'running', 'idle', 'stuck', 'queued'].includes(team.status)) {
472
+ await this.stop(teamId);
473
+ }
474
+ // Re-launch with the team's project
475
+ const projectId = team.projectId;
476
+ if (!projectId) {
477
+ throw new Error(`Team ${teamId} has no projectId — cannot restart`);
478
+ }
479
+ return this.launch(projectId, team.issueNumber, team.issueTitle ?? undefined, prompt);
480
+ }
481
+ // -------------------------------------------------------------------------
482
+ // stopAll — stop all running teams
483
+ // -------------------------------------------------------------------------
484
+ async stopAll() {
485
+ // Clear any pending merge-shutdown timers before force-stopping
486
+ this.clearShutdownTimers();
487
+ const db = getDatabase();
488
+ const activeTeams = db.getActiveTeams();
489
+ const results = [];
490
+ for (const team of activeTeams) {
491
+ try {
492
+ const stopped = await this.stop(team.id);
493
+ results.push(stopped);
494
+ }
495
+ catch {
496
+ // Log but continue stopping other teams
497
+ results.push(team);
498
+ }
499
+ }
500
+ return results;
501
+ }
502
+ // -------------------------------------------------------------------------
503
+ // queueTeam — insert a team with 'queued' status without spawning
504
+ // -------------------------------------------------------------------------
505
+ async queueTeam(db, project, projectId, issueNumber, issueTitle, headless, prompt) {
506
+ // Fetch title from GitHub if needed
507
+ if (!issueTitle && project.githubRepo) {
508
+ try {
509
+ const { stdout } = await execAsync(`gh issue view ${issueNumber} --repo ${project.githubRepo} --json title --jq .title`, { timeout: 10000 });
510
+ const result = stdout.trim();
511
+ if (result)
512
+ issueTitle = result;
513
+ }
514
+ catch {
515
+ issueTitle = `Issue #${issueNumber}`;
516
+ }
517
+ }
518
+ else if (!issueTitle) {
519
+ issueTitle = `Issue #${issueNumber}`;
520
+ }
521
+ const { worktreeName, branchName } = this.deriveWorktreeNames(project, issueNumber);
522
+ // Check for existing team
523
+ const existing = db.getTeamByWorktree(worktreeName);
524
+ if (existing) {
525
+ if (['running', 'launching', 'idle', 'stuck', 'queued'].includes(existing.status)) {
526
+ throw new Error(`Team already active for issue ${issueNumber} (status: ${existing.status})`);
527
+ }
528
+ if (existing.status === 'done') {
529
+ throw new Error(`Team already completed for issue ${issueNumber} — completed teams cannot be relaunched`);
530
+ }
531
+ // Terminal state (failed) — reuse the existing team record as queued
532
+ const now = new Date().toISOString();
533
+ db.updateTeam(existing.id, {
534
+ status: 'queued',
535
+ phase: 'init',
536
+ pid: null,
537
+ sessionId: null,
538
+ issueTitle: issueTitle ?? null,
539
+ customPrompt: prompt ?? null,
540
+ headless: headless !== false,
541
+ launchedAt: now,
542
+ stoppedAt: null,
543
+ lastEventAt: null,
544
+ });
545
+ db.insertTransition({
546
+ teamId: existing.id,
547
+ fromStatus: existing.status,
548
+ toStatus: 'queued',
549
+ trigger: 'pm_action',
550
+ reason: 'Re-queued by PM (queue path)',
551
+ });
552
+ const team = db.getTeam(existing.id);
553
+ const activeCount = db.getActiveTeamCountByProject(projectId);
554
+ console.log(`[TeamManager] Team ${team.id} queued (${activeCount}/${project.maxActiveTeams} active, headless=${team.headless})`);
555
+ this.broadcastSnapshot();
556
+ return team;
557
+ }
558
+ // Fresh insert with queued status
559
+ const now = new Date().toISOString();
560
+ const team = db.insertTeam({
561
+ projectId,
562
+ issueNumber,
563
+ issueTitle: issueTitle ?? null,
564
+ worktreeName,
565
+ branchName,
566
+ status: 'queued',
567
+ phase: 'init',
568
+ customPrompt: prompt ?? null,
569
+ headless: headless !== false,
570
+ launchedAt: now,
571
+ });
572
+ db.insertTransition({
573
+ teamId: team.id,
574
+ fromStatus: 'queued',
575
+ toStatus: 'queued',
576
+ trigger: 'pm_action',
577
+ reason: 'Team created and queued',
578
+ });
579
+ const activeCount = db.getActiveTeamCountByProject(projectId);
580
+ console.log(`[TeamManager] Team ${team.id} queued (${activeCount}/${project.maxActiveTeams} active)`);
581
+ this.broadcastSnapshot();
582
+ return team;
583
+ }
584
+ // -------------------------------------------------------------------------
585
+ // forceLaunch — bypass usage gate and slot limits to launch a queued team
586
+ // -------------------------------------------------------------------------
587
+ async forceLaunch(teamId) {
588
+ const db = getDatabase();
589
+ const team = db.getTeam(teamId);
590
+ if (!team) {
591
+ throw new Error(`Team ${teamId} not found`);
592
+ }
593
+ if (team.status !== 'queued') {
594
+ throw new Error(`Team ${teamId} is not queued (current status: ${team.status})`);
595
+ }
596
+ const projectId = team.projectId;
597
+ if (!projectId) {
598
+ throw new Error(`Team ${teamId} has no project ID`);
599
+ }
600
+ const project = db.getProject(projectId);
601
+ if (!project) {
602
+ throw new Error(`Project ${projectId} not found`);
603
+ }
604
+ // Log a warning if this exceeds maxActiveTeams
605
+ const activeCount = db.getActiveTeamCountByProject(projectId);
606
+ if (activeCount >= project.maxActiveTeams) {
607
+ console.warn(`[TeamManager] Force-launching team ${teamId} exceeds maxActiveTeams (${activeCount}/${project.maxActiveTeams})`);
608
+ }
609
+ // Insert transition and update status BEFORE calling launchQueued,
610
+ // since launchQueued's guard at the top accepts both 'queued' and 'launching'.
611
+ db.insertTransition({
612
+ teamId,
613
+ fromStatus: 'queued',
614
+ toStatus: 'launching',
615
+ trigger: 'pm_action',
616
+ reason: 'PM force-launched team',
617
+ });
618
+ db.updateTeam(teamId, { status: 'launching' });
619
+ this.broadcastSnapshot();
620
+ // Delegate to the existing private launch method
621
+ await this.launchQueued(team);
622
+ return db.getTeam(teamId);
623
+ }
624
+ // -------------------------------------------------------------------------
625
+ // processQueue — dequeue and launch teams when slots free up
626
+ // -------------------------------------------------------------------------
627
+ async processQueue(projectId) {
628
+ // Guard against concurrent processQueue calls for the same project
629
+ if (this._processingQueue.has(projectId))
630
+ return;
631
+ this._processingQueue.add(projectId);
632
+ // Track whether any teams were actually launched — used by re-drain to
633
+ // avoid an infinite loop when all queued teams are blocked by dependencies.
634
+ let launchedCount = 0;
635
+ try {
636
+ const db = getDatabase();
637
+ const project = db.getProject(projectId);
638
+ if (!project)
639
+ return;
640
+ // Usage gate: do not dequeue if in red zone
641
+ if (getUsageZone() === 'red') {
642
+ console.log(`[TeamManager] processQueue blocked — usage zone is RED`);
643
+ return;
644
+ }
645
+ const activeCount = db.getActiveTeamCountByProject(projectId);
646
+ const available = project.maxActiveTeams - activeCount;
647
+ if (available <= 0)
648
+ return;
649
+ const queued = db.getQueuedTeamsByProject(projectId);
650
+ // Filter queued teams by dependency status — only launch unblocked teams
651
+ const toDequeue = await this.filterUnblockedTeams(queued, available, projectId);
652
+ launchedCount = toDequeue.length;
653
+ for (const team of toDequeue) {
654
+ console.log(`[TeamManager] Dequeuing team ${team.id} (${team.worktreeName})`);
655
+ // Mark as launching BEFORE releasing the guard, so concurrent calls
656
+ // see this team as active (counted towards the active limit).
657
+ db.insertTransition({
658
+ teamId: team.id,
659
+ fromStatus: 'queued',
660
+ toStatus: 'launching',
661
+ trigger: 'system',
662
+ reason: 'Slot available, dequeuing team',
663
+ });
664
+ db.updateTeam(team.id, { status: 'launching' });
665
+ try {
666
+ await this.launchQueued(team);
667
+ }
668
+ catch (err) {
669
+ const msg = err instanceof Error ? err.message : String(err);
670
+ console.error(`[TeamManager] Failed to dequeue team ${team.id}: ${msg}`);
671
+ }
672
+ }
673
+ }
674
+ finally {
675
+ this._processingQueue.delete(projectId);
676
+ // Re-drain: if a concurrent processQueue call was dropped by the guard
677
+ // while we were awaiting launchQueued, there may still be queued teams
678
+ // with available slots. Schedule a re-check via setImmediate to break
679
+ // the call stack and let the new call acquire the guard cleanly.
680
+ // Only re-drain if we actually launched at least one team — otherwise
681
+ // all remaining queued teams are blocked by dependencies and re-draining
682
+ // would cause an infinite loop.
683
+ if (launchedCount > 0) {
684
+ const db = getDatabase();
685
+ const project = db.getProject(projectId);
686
+ if (project) {
687
+ const activeCount = db.getActiveTeamCountByProject(projectId);
688
+ const queued = db.getQueuedTeamsByProject(projectId);
689
+ if (queued.length > 0 && activeCount < project.maxActiveTeams) {
690
+ setImmediate(() => {
691
+ this.processQueue(projectId).catch((err) => {
692
+ console.error(`[TeamManager] processQueue re-drain error:`, err);
693
+ });
694
+ });
695
+ }
696
+ }
697
+ }
698
+ }
699
+ }
700
+ // -------------------------------------------------------------------------
701
+ // filterUnblockedTeams — check dependencies for queued teams
702
+ // -------------------------------------------------------------------------
703
+ /**
704
+ * Filter queued teams to only include those with no open dependencies.
705
+ * Teams with open dependencies are logged and tracked in the GitHubPoller
706
+ * for auto-launch when dependencies resolve.
707
+ *
708
+ * If a circular dependency is detected, the team is treated as unblocked
709
+ * to avoid deadlocking the queue.
710
+ *
711
+ * Uses the IssueFetcher's cached data when available, falling back to
712
+ * fresh API calls. If the dependency check fails entirely, the team is
713
+ * treated as unblocked (permissive fallback).
714
+ */
715
+ async filterUnblockedTeams(queued, available, projectId) {
716
+ const unblocked = [];
717
+ // Dynamic imports to avoid circular dependencies
718
+ let getIssueFetcher = null;
719
+ let detectCircularDeps = null;
720
+ let githubPollerModule = null;
721
+ try {
722
+ const issueFetcherMod = await import('./issue-fetcher.js');
723
+ getIssueFetcher = issueFetcherMod.getIssueFetcher;
724
+ detectCircularDeps = issueFetcherMod.detectCircularDependencies;
725
+ }
726
+ catch (err) {
727
+ console.error('[TeamManager] Failed to import issue-fetcher for dependency check:', err);
728
+ // Permissive fallback: if we can't check deps, allow all queued teams
729
+ return queued.slice(0, available);
730
+ }
731
+ try {
732
+ githubPollerModule = await import('./github-poller.js');
733
+ }
734
+ catch (err) {
735
+ console.error('[TeamManager] Failed to import github-poller for blocked tracking:', err);
736
+ }
737
+ const fetcher = getIssueFetcher();
738
+ for (const team of queued) {
739
+ if (unblocked.length >= available)
740
+ break;
741
+ try {
742
+ const deps = await fetcher.fetchDependenciesForIssue(projectId, team.issueNumber);
743
+ // Permissive fallback: if fetch returns null, treat as unblocked
744
+ if (!deps || deps.resolved) {
745
+ unblocked.push(team);
746
+ continue;
747
+ }
748
+ // Has open dependencies — check for circular dependencies
749
+ const openDeps = deps.blockedBy.filter((d) => d.state === 'open');
750
+ if (detectCircularDeps && openDeps.length > 0) {
751
+ // Build a local dependency graph for cycle detection
752
+ const depGraph = new Map();
753
+ depGraph.set(team.issueNumber, openDeps.map((d) => d.number));
754
+ // Add the open deps' own dependencies (if we can fetch them)
755
+ for (const dep of openDeps) {
756
+ try {
757
+ const subDeps = await fetcher.fetchDependenciesForIssue(projectId, dep.number);
758
+ if (subDeps && subDeps.blockedBy.length > 0) {
759
+ depGraph.set(dep.number, subDeps.blockedBy.filter((d) => d.state === 'open').map((d) => d.number));
760
+ }
761
+ }
762
+ catch {
763
+ // Can't fetch sub-deps — skip (cycle detection will still work for direct cycles)
764
+ }
765
+ }
766
+ const cycle = detectCircularDeps(team.issueNumber, depGraph);
767
+ if (cycle) {
768
+ console.warn(`[TeamManager] Circular dependency detected for team ${team.id} (issue #${team.issueNumber}): ` +
769
+ `${cycle.map((n) => '#' + n).join(' -> ')} — treating as unblocked to avoid deadlock`);
770
+ unblocked.push(team);
771
+ continue;
772
+ }
773
+ }
774
+ // Genuinely blocked — log and track for auto-launch on resolution
775
+ console.log(`[TeamManager] Skipping team ${team.id} (issue #${team.issueNumber}) — ` +
776
+ `blocked by open deps: ${openDeps.map((d) => '#' + d.number).join(', ')}`);
777
+ if (githubPollerModule) {
778
+ githubPollerModule.githubPoller.trackBlockedIssue(projectId, team.issueNumber, openDeps.map((d) => d.number));
779
+ }
780
+ }
781
+ catch (err) {
782
+ // Permissive fallback: if dependency check fails, allow the team to launch
783
+ console.error(`[TeamManager] Dependency check failed for team ${team.id} (issue #${team.issueNumber}), ` +
784
+ `allowing launch: ${err instanceof Error ? err.message : String(err)}`);
785
+ unblocked.push(team);
786
+ }
787
+ }
788
+ return unblocked;
789
+ }
790
+ // -------------------------------------------------------------------------
791
+ // launchQueued — spawn a team that was previously queued
792
+ // -------------------------------------------------------------------------
793
+ async launchQueued(team) {
794
+ const db = getDatabase();
795
+ // Re-check status to avoid racing with other dequeue calls
796
+ // processQueue pre-sets status to 'launching' before calling us,
797
+ // so we must accept both 'queued' and 'launching'.
798
+ const fresh = db.getTeam(team.id);
799
+ if (!fresh || (fresh.status !== 'queued' && fresh.status !== 'launching'))
800
+ return;
801
+ const projectId = team.projectId;
802
+ if (!projectId) {
803
+ console.error(`[TeamManager] Queued team ${team.id} has no projectId`);
804
+ return;
805
+ }
806
+ const project = db.getProject(projectId);
807
+ if (!project) {
808
+ console.error(`[TeamManager] Project ${projectId} not found for queued team ${team.id}`);
809
+ return;
810
+ }
811
+ const worktreeAbsPath = path.join(project.repoPath, config.worktreeDir, team.worktreeName);
812
+ const worktreeRelPath = path.posix.join(config.worktreeDir, team.worktreeName);
813
+ const branchName = team.branchName ?? `worktree-${team.worktreeName}`;
814
+ // Sync with origin before creating worktree
815
+ await this.syncWithOrigin(project.repoPath, team.id);
816
+ // ── Step 1: Create git worktree ──
817
+ const worktreeOk = await this.createWorktree(project.repoPath, worktreeRelPath, worktreeAbsPath, branchName, team.id, 'launching');
818
+ if (!worktreeOk)
819
+ return;
820
+ console.log(`[TeamManager] Worktree created for dequeued team: ${team.worktreeName}`);
821
+ db.updateTeam(team.id, { status: 'launching' });
822
+ this.broadcastSnapshot();
823
+ // ── Step 2: Copy hooks and settings ──
824
+ this.copyHooksAndSettings(worktreeAbsPath);
825
+ // ── Step 3: Spawn Claude Code ──
826
+ const resolvedPrompt = team.customPrompt || this.resolvePromptFromFile(project, team.issueNumber);
827
+ const isHeadless = team.headless;
828
+ if (!isHeadless && process.platform === 'win32') {
829
+ // ── Interactive mode (Windows): open Claude Code in a new terminal ──
830
+ await this.launchInteractive(team, project, worktreeAbsPath, resolvedPrompt);
831
+ return;
832
+ }
833
+ // ── Headless mode (default): spawn in background, capture output ──
834
+ const args = this.buildHeadlessClaudeArgs(team.worktreeName, {
835
+ model: project.model,
836
+ });
837
+ const spawnEnv = this.buildSpawnEnv(project, team.worktreeName, projectId);
838
+ const claudePath = resolveClaudePath();
839
+ console.log(`[TeamManager] Spawning dequeued team ${team.id}: ${claudePath} ${args.join(' ')} (headless=${isHeadless})`);
840
+ const child = this.spawnAndValidate(claudePath, args, project.repoPath, spawnEnv, team.id);
841
+ if (!child)
842
+ return;
843
+ this.setupStdinAndOutput(team.id, child, resolvedPrompt);
844
+ this.attachProcessHandlers(team.id, child);
845
+ sseBroker.broadcast('team_launched', { team_id: team.id, issue_number: team.issueNumber, project_id: projectId }, team.id);
846
+ }
847
+ // -------------------------------------------------------------------------
848
+ // getOutput — return rolling buffer content
849
+ // -------------------------------------------------------------------------
850
+ getOutput(teamId, lines) {
851
+ const buffer = this.outputBuffers.get(teamId);
852
+ if (!buffer) {
853
+ return [];
854
+ }
855
+ if (lines && lines > 0 && lines < buffer.lines.length) {
856
+ return buffer.lines.slice(-lines);
857
+ }
858
+ return [...buffer.lines];
859
+ }
860
+ // -------------------------------------------------------------------------
861
+ // getParsedEvents — return parsed NDJSON stream events
862
+ // -------------------------------------------------------------------------
863
+ getParsedEvents(teamId) {
864
+ // Check in-memory buffer first (for running teams)
865
+ const inMemory = this.parsedEvents.get(teamId);
866
+ if (inMemory && inMemory.length > 0) {
867
+ return inMemory;
868
+ }
869
+ // Fall back to persisted events in DB (for done/failed/restarted teams)
870
+ try {
871
+ const db = getDatabase();
872
+ const json = db.getStreamEvents(teamId);
873
+ if (json) {
874
+ return JSON.parse(json);
875
+ }
876
+ }
877
+ catch (err) {
878
+ console.error(`[TeamManager] Failed to load persisted stream events for team ${teamId}:`, err);
879
+ }
880
+ return [];
881
+ }
882
+ /**
883
+ * Persist the in-memory parsed events for a team to the database.
884
+ * Called before clearing the in-memory buffer on process exit/stop.
885
+ */
886
+ persistParsedEvents(teamId) {
887
+ const events = this.parsedEvents.get(teamId);
888
+ if (!events || events.length === 0)
889
+ return;
890
+ try {
891
+ const db = getDatabase();
892
+ db.upsertStreamEvents(teamId, JSON.stringify(events));
893
+ }
894
+ catch (err) {
895
+ console.error(`[TeamManager] Failed to persist stream events for team ${teamId}:`, err);
896
+ }
897
+ }
898
+ // -------------------------------------------------------------------------
899
+ // sendMessage — deliver a PM message to a running team via stdin
900
+ // -------------------------------------------------------------------------
901
+ sendMessage(teamId, message, source = 'fc', subtype) {
902
+ const stdin = this.stdinPipes.get(teamId);
903
+ if (!stdin || stdin.destroyed)
904
+ return false;
905
+ try {
906
+ this.writeStdinMessage(stdin, message);
907
+ console.log(`[TeamManager] Message sent to team ${teamId}: ${message.substring(0, 100)}`);
908
+ // Inject a synthetic event into parsedEvents so it appears in the
909
+ // Session Log alongside assistant responses (issue #5).
910
+ // 'user' = manual PM message, 'fc' = automated Fleet Commander message.
911
+ // 'subtype' distinguishes FC message categories for visual differentiation.
912
+ const syntheticEvent = {
913
+ type: source,
914
+ agentName: source === 'user' ? '__pm__' : '__fc__',
915
+ timestamp: new Date().toISOString(),
916
+ message: { content: [{ type: 'text', text: message }] },
917
+ ...(subtype ? { subtype } : {}),
918
+ };
919
+ const events = this.parsedEvents.get(teamId);
920
+ if (events) {
921
+ events.push(syntheticEvent);
922
+ while (events.length > MAX_PARSED_EVENTS) {
923
+ events.shift();
924
+ }
925
+ }
926
+ sseBroker.broadcast('team_output', { team_id: teamId, event: syntheticEvent }, teamId);
927
+ return true;
928
+ }
929
+ catch (err) {
930
+ console.error(`[TeamManager] Failed to send message to team ${teamId}:`, err);
931
+ return false;
932
+ }
933
+ }
934
+ // -------------------------------------------------------------------------
935
+ // getStdinPipe — expose stdin pipe for external graceful shutdown
936
+ // -------------------------------------------------------------------------
937
+ getStdinPipe(teamId) {
938
+ return this.stdinPipes.get(teamId);
939
+ }
940
+ // -------------------------------------------------------------------------
941
+ // gracefulShutdown — notify TL of merge, wait grace period, then kill
942
+ // -------------------------------------------------------------------------
943
+ /**
944
+ * Gracefully shut down a team after its PR is merged.
945
+ * 1. Send pr_merged_shutdown message to TL via stdin
946
+ * 2. Wait graceMs for the process to exit on its own
947
+ * 3. If still alive: close stdin, wait 10s, then force kill
948
+ *
949
+ * Race-condition safe: the process exit handler already does cleanup,
950
+ * so all timer callbacks re-check childProcesses.has() before acting.
951
+ */
952
+ gracefulShutdown(teamId, prNumber, graceMs) {
953
+ // Clear any existing shutdown timer for this team
954
+ const existing = this.shutdownTimers.get(teamId);
955
+ if (existing) {
956
+ clearTimeout(existing);
957
+ this.shutdownTimers.delete(teamId);
958
+ }
959
+ // Step 1: Send the shutdown message via stdin
960
+ const msg = resolveMessage('pr_merged_shutdown', {
961
+ PR_NUMBER: String(prNumber),
962
+ });
963
+ if (msg) {
964
+ this.sendMessage(teamId, msg, 'fc', 'pr_merged_shutdown');
965
+ }
966
+ console.log(`[TeamManager] Graceful shutdown initiated for team ${teamId} (PR #${prNumber}, grace=${graceMs}ms)`);
967
+ // Step 2: Set grace period timer
968
+ const graceTimer = setTimeout(() => {
969
+ this.shutdownTimers.delete(teamId);
970
+ // Re-check: process may have exited during grace period
971
+ if (!this.childProcesses.has(teamId)) {
972
+ console.log(`[TeamManager] Team ${teamId} already exited during grace period`);
973
+ return;
974
+ }
975
+ console.log(`[TeamManager] Grace period expired for team ${teamId} — closing stdin`);
976
+ // Step 3: Close stdin to signal CC to finish
977
+ const stdin = this.stdinPipes.get(teamId);
978
+ if (stdin && !stdin.destroyed) {
979
+ try {
980
+ stdin.end();
981
+ }
982
+ catch {
983
+ // stdin.end() failed — proceed to force kill
984
+ }
985
+ }
986
+ this.stdinPipes.delete(teamId);
987
+ // Step 4: Wait 10s then force kill if still alive
988
+ const killTimer = setTimeout(() => {
989
+ if (!this.childProcesses.has(teamId)) {
990
+ console.log(`[TeamManager] Team ${teamId} exited after stdin close`);
991
+ return;
992
+ }
993
+ console.log(`[TeamManager] Force-killing team ${teamId} after merge shutdown`);
994
+ const db = getDatabase();
995
+ const team = db.getTeam(teamId);
996
+ if (team?.pid) {
997
+ this.killProcess(team.pid);
998
+ }
999
+ // Clean up maps — the exit handler may also fire, but
1000
+ // childProcesses.delete is idempotent
1001
+ this.flushTokenCounters(teamId);
1002
+ this.persistParsedEvents(teamId);
1003
+ this.childProcesses.delete(teamId);
1004
+ this.outputBuffers.delete(teamId);
1005
+ this.parsedEvents.delete(teamId);
1006
+ this.tokenCounters.delete(teamId);
1007
+ // Set stoppedAt and broadcast
1008
+ if (team && !team.stoppedAt) {
1009
+ db.updateTeam(teamId, {
1010
+ pid: null,
1011
+ stoppedAt: new Date().toISOString(),
1012
+ });
1013
+ }
1014
+ sseBroker.broadcast('team_stopped', { team_id: teamId }, teamId);
1015
+ }, 10_000);
1016
+ if (killTimer.unref)
1017
+ killTimer.unref();
1018
+ }, graceMs);
1019
+ if (graceTimer.unref)
1020
+ graceTimer.unref();
1021
+ this.shutdownTimers.set(teamId, graceTimer);
1022
+ }
1023
+ /**
1024
+ * Clear all active shutdown timers. Called during server shutdown
1025
+ * to prevent dangling timers.
1026
+ */
1027
+ clearShutdownTimers() {
1028
+ for (const [teamId, timer] of this.shutdownTimers) {
1029
+ clearTimeout(timer);
1030
+ }
1031
+ this.shutdownTimers.clear();
1032
+ }
1033
+ // -------------------------------------------------------------------------
1034
+ // writeStdinMessage — low-level: write an SDKUserMessage JSON line to stdin
1035
+ // -------------------------------------------------------------------------
1036
+ writeStdinMessage(stdin, content) {
1037
+ const msg = {
1038
+ type: 'user',
1039
+ session_id: '',
1040
+ message: { role: 'user', content },
1041
+ parent_tool_use_id: null,
1042
+ };
1043
+ stdin.write(JSON.stringify(msg) + '\n');
1044
+ }
1045
+ // -------------------------------------------------------------------------
1046
+ // resolvePromptFromFile — read prompt from project's prompt file on disk
1047
+ // -------------------------------------------------------------------------
1048
+ /**
1049
+ * Read the project's prompt file and replace {{ISSUE_NUMBER}} placeholder.
1050
+ * Fallback chain: project prompt file > prompts/default-prompt.md > hardcoded default.
1051
+ */
1052
+ resolvePromptFromFile(project, issueNumber) {
1053
+ if (project.promptFile) {
1054
+ const absPath = path.join(config.fleetCommanderRoot, project.promptFile);
1055
+ if (fs.existsSync(absPath)) {
1056
+ try {
1057
+ const template = fs.readFileSync(absPath, 'utf-8');
1058
+ const resolved = template.replace(/\{\{ISSUE_NUMBER\}\}/g, String(issueNumber));
1059
+ console.log(`[TeamManager] Resolved prompt from file: ${project.promptFile}`);
1060
+ return resolved;
1061
+ }
1062
+ catch (err) {
1063
+ console.warn(`[TeamManager] Failed to read prompt file ${absPath}: ${err instanceof Error ? err.message : String(err)}`);
1064
+ }
1065
+ }
1066
+ else {
1067
+ console.warn(`[TeamManager] Prompt file not found: ${absPath}`);
1068
+ }
1069
+ }
1070
+ // Fall back to default-prompt.md
1071
+ const defaultPath = path.join(config.fleetCommanderRoot, 'prompts', 'default-prompt.md');
1072
+ if (fs.existsSync(defaultPath)) {
1073
+ try {
1074
+ const template = fs.readFileSync(defaultPath, 'utf-8');
1075
+ const resolved = template.replace(/\{\{ISSUE_NUMBER\}\}/g, String(issueNumber));
1076
+ console.log(`[TeamManager] Resolved prompt from default: prompts/default-prompt.md`);
1077
+ return resolved;
1078
+ }
1079
+ catch {
1080
+ // Fall through to hardcoded fallback
1081
+ }
1082
+ }
1083
+ // Hardcoded fallback (should not normally be reached)
1084
+ const fallback = `Read the ENTIRE file .claude/prompts/fleet-workflow.md before taking any actions.\nYou are the TL. There is NO coordinator — you orchestrate the Diamond team directly.\nPhase 0: Spawn fleet-planner. Wait for plan. Phase 1: Spawn fleet-dev WITH the planner's plan. Wait for ready. Phase 2: Spawn fleet-reviewer. Dev and reviewer communicate p2p. Planner stays alive for p2p questions.\nIssue: #${issueNumber}`;
1085
+ console.log(`[TeamManager] Using hardcoded fallback prompt`);
1086
+ return fallback;
1087
+ }
1088
+ // -------------------------------------------------------------------------
1089
+ // launchBatch — launch multiple teams with optional stagger delay
1090
+ // -------------------------------------------------------------------------
1091
+ async launchBatch(projectId, issues, prompt, delayMs, headless) {
1092
+ const results = [];
1093
+ const delay = delayMs ?? 2000; // default 2-second stagger
1094
+ for (let i = 0; i < issues.length; i++) {
1095
+ const issue = issues[i];
1096
+ try {
1097
+ const team = await this.launch(projectId, issue.number, issue.title, prompt, headless);
1098
+ results.push(team);
1099
+ }
1100
+ catch (err) {
1101
+ // Log error and continue — don't stop the batch
1102
+ const msg = err instanceof Error ? err.message : String(err);
1103
+ console.error(`[TeamManager] Batch launch failed at issue #${issue.number} (${i + 1}/${issues.length}): ${msg}`);
1104
+ }
1105
+ // Stagger delay between launches (skip after last)
1106
+ if (i < issues.length - 1 && delay > 0) {
1107
+ await new Promise((resolve) => setTimeout(resolve, delay));
1108
+ }
1109
+ }
1110
+ return results;
1111
+ }
1112
+ // -------------------------------------------------------------------------
1113
+ // Shared spawn/lifecycle helpers (extracted from launch/resume/launchQueued)
1114
+ // -------------------------------------------------------------------------
1115
+ /**
1116
+ * Build the spawn environment for a Claude Code process.
1117
+ * Inherits the server's env and adds fleet/git-bash vars.
1118
+ * This is the SINGLE source of CC env vars for BOTH headless and interactive modes.
1119
+ */
1120
+ buildSpawnEnv(project, worktreeName, projectId) {
1121
+ const env = {
1122
+ ...process.env,
1123
+ FLEET_TEAM_ID: worktreeName,
1124
+ FLEET_PROJECT_ID: String(projectId),
1125
+ FLEET_GITHUB_REPO: project.githubRepo ?? '',
1126
+ };
1127
+ // Only set agent teams env var when enabled; explicitly clear it otherwise
1128
+ // so it's not inherited from process.env
1129
+ if (config.enableAgentTeams) {
1130
+ env['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] = '1';
1131
+ }
1132
+ else {
1133
+ env['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] = undefined;
1134
+ }
1135
+ const gitBash = findGitBash();
1136
+ if (gitBash) {
1137
+ env['CLAUDE_CODE_GIT_BASH_PATH'] = gitBash;
1138
+ console.log(`[TeamManager] CLAUDE_CODE_GIT_BASH_PATH=${gitBash}`);
1139
+ }
1140
+ console.log(`[TeamManager] Spawn env: FLEET_TEAM_ID=${worktreeName}, FLEET_PROJECT_ID=${projectId}, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=${config.enableAgentTeams ? '1' : 'disabled'}`);
1141
+ return env;
1142
+ }
1143
+ /**
1144
+ * Build the base Claude CLI args shared by BOTH headless and interactive modes.
1145
+ * @param worktreeName — worktree name for --worktree flag
1146
+ * @param options.resume — add --resume flag (for resume flow)
1147
+ * @param options.model — optional model override from project
1148
+ * @param options.prompt — positional prompt arg (interactive mode only)
1149
+ */
1150
+ buildBaseClaudeArgs(worktreeName, options) {
1151
+ const args = [];
1152
+ if (options?.resume) {
1153
+ args.push('--resume');
1154
+ }
1155
+ args.push('--worktree', worktreeName);
1156
+ if (config.skipPermissions) {
1157
+ args.push('--dangerously-skip-permissions');
1158
+ }
1159
+ if (options?.model) {
1160
+ args.push('--model', options.model);
1161
+ }
1162
+ if (options?.prompt) {
1163
+ args.push(options.prompt);
1164
+ }
1165
+ return args;
1166
+ }
1167
+ /**
1168
+ * Build Claude CLI args for headless (stream-json) mode.
1169
+ * Calls buildBaseClaudeArgs() and appends stream-json + verbose flags.
1170
+ */
1171
+ buildHeadlessClaudeArgs(worktreeName, options) {
1172
+ const args = this.buildBaseClaudeArgs(worktreeName, options);
1173
+ args.push('--input-format', 'stream-json');
1174
+ args.push('--output-format', 'stream-json');
1175
+ args.push('--verbose');
1176
+ args.push('--include-partial-messages');
1177
+ return args;
1178
+ }
1179
+ /**
1180
+ * Build the `set VAR=value` portion of the innerCommand for interactive
1181
+ * mode on Windows. Iterates over buildSpawnEnv() output to generate
1182
+ * set commands dynamically, ensuring all env vars are passed.
1183
+ *
1184
+ * Only includes fleet/CC-specific vars (prefixed FLEET_, CLAUDE_CODE_)
1185
+ * to keep the command string manageable and avoid leaking unrelated env.
1186
+ */
1187
+ buildInteractiveEnvSetCommands(spawnEnv) {
1188
+ const prefixes = ['FLEET_', 'CLAUDE_CODE_'];
1189
+ const setCmds = [];
1190
+ for (const [key, value] of Object.entries(spawnEnv)) {
1191
+ if (value !== undefined && prefixes.some(p => key.startsWith(p))) {
1192
+ setCmds.push(`set ${key}=${value}`);
1193
+ }
1194
+ }
1195
+ return setCmds.join(' && ');
1196
+ }
1197
+ /**
1198
+ * Launch a Claude Code process in interactive mode (Windows terminal).
1199
+ * Extracted from the duplicated logic in launch() and launchQueued().
1200
+ *
1201
+ * Opens a new terminal window (wt.exe or cmd.exe) with the Claude Code
1202
+ * CLI and all required env vars from buildSpawnEnv().
1203
+ */
1204
+ async launchInteractive(team, project, worktreeAbsPath, prompt) {
1205
+ const db = getDatabase();
1206
+ const args = this.buildBaseClaudeArgs(team.worktreeName, {
1207
+ model: project.model,
1208
+ prompt,
1209
+ });
1210
+ const spawnEnv = this.buildSpawnEnv(project, team.worktreeName, team.projectId);
1211
+ const claudePath = resolveClaudePath();
1212
+ const fullCmd = `${claudePath} ${args.join(' ')}`;
1213
+ const windowTitle = `Team ${team.worktreeName}`;
1214
+ // Build env set commands dynamically from buildSpawnEnv()
1215
+ const envSetCmds = this.buildInteractiveEnvSetCommands(spawnEnv);
1216
+ const innerCommand = `cd /d "${worktreeAbsPath}" && ${envSetCmds} && ${fullCmd}`;
1217
+ const termPref = config.terminalCmd;
1218
+ let useWindowsTerminal = false;
1219
+ if (termPref === 'wt') {
1220
+ useWindowsTerminal = true;
1221
+ }
1222
+ else if (termPref === 'auto') {
1223
+ try {
1224
+ await execAsync('where wt.exe', { timeout: 3000 });
1225
+ useWindowsTerminal = true;
1226
+ }
1227
+ catch {
1228
+ useWindowsTerminal = false;
1229
+ }
1230
+ }
1231
+ let startCommand;
1232
+ if (useWindowsTerminal) {
1233
+ startCommand = `wt.exe new-tab --title "${windowTitle}" cmd.exe /k "${innerCommand}"`;
1234
+ }
1235
+ else {
1236
+ startCommand = `start "${windowTitle}" cmd.exe /k "${innerCommand}"`;
1237
+ }
1238
+ console.log(`[TeamManager] Interactive spawn (terminal=${useWindowsTerminal ? 'wt' : 'cmd'}): ${startCommand}`);
1239
+ const interactiveChild = spawn(startCommand, [], {
1240
+ env: spawnEnv,
1241
+ shell: true,
1242
+ detached: true,
1243
+ stdio: 'ignore',
1244
+ });
1245
+ interactiveChild.unref();
1246
+ console.log(`[TeamManager] Interactive window opened for team ${team.id} (worktree: ${team.worktreeName})`);
1247
+ db.insertTransition({
1248
+ teamId: team.id,
1249
+ fromStatus: 'launching',
1250
+ toStatus: 'running',
1251
+ trigger: 'system',
1252
+ reason: 'Interactive terminal window opened',
1253
+ });
1254
+ db.updateTeam(team.id, { status: 'running' });
1255
+ this.broadcastSnapshot();
1256
+ sseBroker.broadcast('team_launched', { team_id: team.id, issue_number: team.issueNumber, project_id: team.projectId }, team.id);
1257
+ }
1258
+ /**
1259
+ * Spawn a Claude Code process and validate it got a PID.
1260
+ * On failure, transitions team to 'failed' and broadcasts snapshot.
1261
+ * Returns the child process, or null if spawn failed.
1262
+ */
1263
+ spawnAndValidate(claudePath, args, cwd, env, teamId) {
1264
+ const db = getDatabase();
1265
+ const child = spawn(claudePath, args, {
1266
+ cwd,
1267
+ env,
1268
+ stdio: ['pipe', 'pipe', 'pipe'],
1269
+ detached: false,
1270
+ });
1271
+ const pid = child.pid;
1272
+ if (pid === undefined) {
1273
+ console.error(`[TeamManager] ERROR: spawn failed for team ${teamId}: no PID returned`);
1274
+ db.insertTransition({
1275
+ teamId,
1276
+ fromStatus: 'launching',
1277
+ toStatus: 'failed',
1278
+ trigger: 'system',
1279
+ reason: 'Spawn failed: no PID returned',
1280
+ });
1281
+ db.updateTeam(teamId, { status: 'failed', stoppedAt: new Date().toISOString() });
1282
+ this.broadcastSnapshot();
1283
+ return null;
1284
+ }
1285
+ console.log(`[TeamManager] Process spawned: PID ${pid}`);
1286
+ db.updateTeam(teamId, { pid });
1287
+ this.broadcastSnapshot();
1288
+ this.childProcesses.set(teamId, child);
1289
+ return child;
1290
+ }
1291
+ /**
1292
+ * Attach exit and error handlers to a child process.
1293
+ * These handlers clean up maps, transition the team to done/failed,
1294
+ * broadcast SSE events, and trigger queue processing.
1295
+ */
1296
+ attachProcessHandlers(teamId, child) {
1297
+ const db = getDatabase();
1298
+ child.on('exit', (code, signal) => {
1299
+ console.log(`[TeamManager] Process exited for team ${teamId} (code=${code}, signal=${signal})`);
1300
+ this.clearThinking(teamId);
1301
+ this.flushTokenCounters(teamId);
1302
+ this.persistParsedEvents(teamId);
1303
+ this.childProcesses.delete(teamId);
1304
+ this.stdinPipes.delete(teamId);
1305
+ this.outputBuffers.delete(teamId);
1306
+ this.parsedEvents.delete(teamId);
1307
+ this.tokenCounters.delete(teamId);
1308
+ const currentTeam = db.getTeam(teamId);
1309
+ if (!currentTeam)
1310
+ return;
1311
+ if (['launching', 'running', 'idle', 'stuck'].includes(currentTeam.status)) {
1312
+ const exitStatus = (code === 0) ? 'done' : 'failed';
1313
+ db.insertTransition({
1314
+ teamId,
1315
+ fromStatus: currentTeam.status,
1316
+ toStatus: exitStatus,
1317
+ trigger: 'system',
1318
+ reason: code === 0
1319
+ ? 'Process exited normally (code 0)'
1320
+ : `Process exited with code ${code}${signal ? `, signal ${signal}` : ''}`,
1321
+ });
1322
+ db.updateTeam(teamId, {
1323
+ status: exitStatus,
1324
+ pid: null,
1325
+ stoppedAt: new Date().toISOString(),
1326
+ });
1327
+ sseBroker.broadcast('team_stopped', { team_id: teamId }, teamId);
1328
+ this.broadcastSnapshot();
1329
+ }
1330
+ if (currentTeam.projectId) {
1331
+ this.processQueue(currentTeam.projectId).catch((err) => {
1332
+ console.error(`[TeamManager] processQueue error after team exit:`, err);
1333
+ });
1334
+ }
1335
+ });
1336
+ child.on('error', (err) => {
1337
+ console.error(`[TeamManager] ERROR: process error for team ${teamId}:`, err.message);
1338
+ this.clearThinking(teamId);
1339
+ this.flushTokenCounters(teamId);
1340
+ this.persistParsedEvents(teamId);
1341
+ this.childProcesses.delete(teamId);
1342
+ this.stdinPipes.delete(teamId);
1343
+ this.outputBuffers.delete(teamId);
1344
+ this.parsedEvents.delete(teamId);
1345
+ this.tokenCounters.delete(teamId);
1346
+ const currentTeam = db.getTeam(teamId);
1347
+ if (!currentTeam)
1348
+ return;
1349
+ if (['launching', 'running', 'idle', 'stuck'].includes(currentTeam.status)) {
1350
+ db.insertTransition({
1351
+ teamId,
1352
+ fromStatus: currentTeam.status,
1353
+ toStatus: 'failed',
1354
+ trigger: 'system',
1355
+ reason: `Process error: ${err.message.slice(0, 200)}`,
1356
+ });
1357
+ db.updateTeam(teamId, {
1358
+ status: 'failed',
1359
+ pid: null,
1360
+ stoppedAt: new Date().toISOString(),
1361
+ });
1362
+ sseBroker.broadcast('team_stopped', { team_id: teamId }, teamId);
1363
+ this.broadcastSnapshot();
1364
+ }
1365
+ if (currentTeam.projectId) {
1366
+ this.processQueue(currentTeam.projectId).catch((queueErr) => {
1367
+ console.error(`[TeamManager] processQueue error after team error:`, queueErr);
1368
+ });
1369
+ }
1370
+ });
1371
+ }
1372
+ /**
1373
+ * Store the stdin pipe, send the initial prompt (if provided), set up
1374
+ * output capture, and broadcast the initial FC event in the session log.
1375
+ */
1376
+ setupStdinAndOutput(teamId, child, prompt) {
1377
+ if (child.stdin) {
1378
+ this.stdinPipes.set(teamId, child.stdin);
1379
+ if (prompt) {
1380
+ this.writeStdinMessage(child.stdin, prompt);
1381
+ console.log(`[TeamManager] Initial prompt sent via stdin for team ${teamId}`);
1382
+ }
1383
+ }
1384
+ this.initOutputBuffer(teamId);
1385
+ this.captureOutput(teamId, child);
1386
+ if (prompt && child.stdin) {
1387
+ const initEvent = {
1388
+ type: 'fc',
1389
+ subtype: 'initial_prompt',
1390
+ agentName: '__fc__',
1391
+ timestamp: new Date().toISOString(),
1392
+ message: { content: [{ type: 'text', text: prompt }] },
1393
+ };
1394
+ const evts = this.parsedEvents.get(teamId);
1395
+ if (evts)
1396
+ evts.push(initEvent);
1397
+ sseBroker.broadcast('team_output', { team_id: teamId, event: initEvent }, teamId);
1398
+ }
1399
+ }
1400
+ /**
1401
+ * Create a git worktree with -b fallback. On failure, transitions team
1402
+ * to 'failed' and broadcasts snapshot.
1403
+ * Returns true on success, false on failure.
1404
+ */
1405
+ async createWorktree(repoPath, worktreeRelPath, worktreeAbsPath, branchName, teamId, fromStatus) {
1406
+ if (fs.existsSync(worktreeAbsPath))
1407
+ return true;
1408
+ try {
1409
+ await execAsync(`git -C "${repoPath}" worktree add "${worktreeRelPath}" -b "${branchName}"`);
1410
+ return true;
1411
+ }
1412
+ catch {
1413
+ // Branch may already exist — try without -b
1414
+ try {
1415
+ await execAsync(`git -C "${repoPath}" worktree add "${worktreeRelPath}" "${branchName}"`);
1416
+ return true;
1417
+ }
1418
+ catch (err2) {
1419
+ const msg = err2 instanceof Error ? err2.message : String(err2);
1420
+ console.error(`[TeamManager] ERROR: Worktree creation failed for team ${teamId}: ${msg}`);
1421
+ const db = getDatabase();
1422
+ db.insertTransition({
1423
+ teamId,
1424
+ fromStatus,
1425
+ toStatus: 'failed',
1426
+ trigger: 'system',
1427
+ reason: `Worktree creation failed: ${msg.slice(0, 200)}`,
1428
+ });
1429
+ db.updateTeam(teamId, { status: 'failed', stoppedAt: new Date().toISOString() });
1430
+ this.broadcastSnapshot();
1431
+ return false;
1432
+ }
1433
+ }
1434
+ }
1435
+ /**
1436
+ * Copy hook scripts and generate settings.json into a worktree directory.
1437
+ */
1438
+ copyHooksAndSettings(worktreeAbsPath) {
1439
+ const hookSrcDir = config.fcHooksDir;
1440
+ const hookDestDir = path.join(worktreeAbsPath, config.hookDir);
1441
+ fs.mkdirSync(hookDestDir, { recursive: true });
1442
+ if (fs.existsSync(hookSrcDir)) {
1443
+ const hookFiles = fs.readdirSync(hookSrcDir).filter((f) => f.endsWith('.sh'));
1444
+ for (const file of hookFiles) {
1445
+ const src = path.join(hookSrcDir, file);
1446
+ const dest = path.join(hookDestDir, file);
1447
+ fs.copyFileSync(src, dest);
1448
+ if (process.platform !== 'win32') {
1449
+ fs.chmodSync(dest, 0o755);
1450
+ }
1451
+ }
1452
+ }
1453
+ console.log(`[TeamManager] Hooks copied to worktree`);
1454
+ // Generate settings.json from example
1455
+ const settingsExamplePath = path.join(hookSrcDir, 'settings.json.example');
1456
+ const settingsDestDir = path.join(worktreeAbsPath, '.claude');
1457
+ const settingsDestPath = path.join(settingsDestDir, 'settings.json');
1458
+ fs.mkdirSync(settingsDestDir, { recursive: true });
1459
+ if (fs.existsSync(settingsExamplePath)) {
1460
+ fs.copyFileSync(settingsExamplePath, settingsDestPath);
1461
+ }
1462
+ }
1463
+ /**
1464
+ * Derive worktree naming from a project and issue number.
1465
+ */
1466
+ deriveWorktreeNames(project, issueNumber) {
1467
+ const slug = project.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
1468
+ const worktreeName = `${slug}-${issueNumber}`;
1469
+ const branchName = `worktree-${slug}-${issueNumber}`;
1470
+ const worktreeRelPath = path.posix.join(config.worktreeDir, worktreeName);
1471
+ const worktreeAbsPath = path.join(project.repoPath, config.worktreeDir, worktreeName);
1472
+ return { slug, worktreeName, branchName, worktreeRelPath, worktreeAbsPath };
1473
+ }
1474
+ // -------------------------------------------------------------------------
1475
+ // Internal helpers
1476
+ // -------------------------------------------------------------------------
1477
+ /**
1478
+ * Broadcast a full team dashboard snapshot to all SSE clients.
1479
+ * Called after any team state change so the Fleet Grid refreshes.
1480
+ */
1481
+ broadcastSnapshot() {
1482
+ try {
1483
+ const db = getDatabase();
1484
+ const dashboard = db.getTeamDashboard();
1485
+ sseBroker.broadcast('snapshot', { teams: dashboard });
1486
+ }
1487
+ catch (err) {
1488
+ console.error('[TeamManager] Failed to broadcast snapshot:', err);
1489
+ }
1490
+ }
1491
+ initOutputBuffer(teamId) {
1492
+ this.outputBuffers.set(teamId, { lines: [] });
1493
+ }
1494
+ captureOutput(teamId, child) {
1495
+ const buffer = this.outputBuffers.get(teamId);
1496
+ if (!buffer)
1497
+ return;
1498
+ // Resolve the worktree name for log prefixes
1499
+ const db = getDatabase();
1500
+ const team = db.getTeam(teamId);
1501
+ const logPrefix = team ? team.worktreeName : `team-${teamId}`;
1502
+ // Initialize parsed events buffer for this team
1503
+ if (!this.parsedEvents.has(teamId)) {
1504
+ this.parsedEvents.set(teamId, []);
1505
+ }
1506
+ const events = this.parsedEvents.get(teamId);
1507
+ // Initialize agent map for tracking tool_use_id -> agent name
1508
+ if (!this.agentMaps.has(teamId)) {
1509
+ this.agentMaps.set(teamId, new Map());
1510
+ }
1511
+ const agentMap = this.agentMaps.get(teamId);
1512
+ // Initialize token counter (seed from DB for restarts)
1513
+ if (!this.tokenCounters.has(teamId)) {
1514
+ const existingTeam = db.getTeam(teamId);
1515
+ this.tokenCounters.set(teamId, {
1516
+ inputTokens: existingTeam?.totalInputTokens ?? 0,
1517
+ outputTokens: existingTeam?.totalOutputTokens ?? 0,
1518
+ cacheCreationTokens: existingTeam?.totalCacheCreationTokens ?? 0,
1519
+ cacheReadTokens: existingTeam?.totalCacheReadTokens ?? 0,
1520
+ costUsd: existingTeam?.totalCostUsd ?? 0,
1521
+ });
1522
+ }
1523
+ // Partial line buffer for handling chunks that split across data events
1524
+ let stdoutPartial = '';
1525
+ // stdout: NDJSON from --output-format stream-json
1526
+ if (child.stdout) {
1527
+ child.stdout.on('data', (data) => {
1528
+ const text = stdoutPartial + data.toString('utf-8');
1529
+ const lines = text.split('\n');
1530
+ // Last element may be incomplete — save it for next chunk
1531
+ stdoutPartial = lines.pop() ?? '';
1532
+ for (const line of lines) {
1533
+ const trimmed = line.trim();
1534
+ if (!trimmed)
1535
+ continue;
1536
+ // Store raw line in output buffer
1537
+ buffer.lines.push(line);
1538
+ while (buffer.lines.length > MAX_OUTPUT_LINES) {
1539
+ buffer.lines.shift();
1540
+ }
1541
+ // Try to parse as JSON (NDJSON from stream-json output)
1542
+ try {
1543
+ const event = JSON.parse(trimmed);
1544
+ console.log(`[CC:${logPrefix}] ${event.type}: ${summarizeEvent(event)}`);
1545
+ // Skip CC-echoed "user" events — sendMessage() and
1546
+ // setupStdinAndOutput() already inject properly-labeled
1547
+ // synthetic events (type 'user' or 'fc') into parsedEvents.
1548
+ // The CC echo is redundant and would misattribute automated
1549
+ // FC messages as PM ("You") messages in the Session Log.
1550
+ if (event.type === 'user')
1551
+ continue;
1552
+ // Detect thinking start/stop from content_block_start/stop events
1553
+ // (must run before the buffer-skip below so thinking state is tracked)
1554
+ this.detectThinking(teamId, event);
1555
+ // content_block_start/delta/stop events are high-frequency partial
1556
+ // message fragments emitted by --include-partial-messages. They are
1557
+ // only needed for thinking detection (above) and must NOT be stored
1558
+ // in the parsedEvents buffer — they would flood the event cap
1559
+ // and evict meaningful session log entries.
1560
+ if (event.type === 'content_block_start' || event.type === 'content_block_delta' || event.type === 'content_block_stop' || event.type === 'stream_event') {
1561
+ continue;
1562
+ }
1563
+ // ----- Agent name resolution -----
1564
+ // 1. Learn agent names: when the TL's assistant event contains
1565
+ // a tool_use content block for "Agent" or "Task", record the
1566
+ // mapping from tool_use_id -> agent name.
1567
+ const ev = event;
1568
+ if (event.type === 'assistant') {
1569
+ const msg = ev.message;
1570
+ const content = msg?.content;
1571
+ if (Array.isArray(content)) {
1572
+ for (const block of content) {
1573
+ if (block &&
1574
+ typeof block === 'object' &&
1575
+ block.type === 'tool_use') {
1576
+ const toolBlock = block;
1577
+ const toolName = toolBlock.name;
1578
+ const toolId = toolBlock.id;
1579
+ if (toolId && (toolName === 'Agent' || toolName === 'Task' || toolName === 'dispatch_agent')) {
1580
+ const input = toolBlock.input;
1581
+ const agentName = (input?.agent_name ?? input?.name ?? 'subagent');
1582
+ agentMap.set(toolId, agentName.toLowerCase());
1583
+ }
1584
+ }
1585
+ }
1586
+ }
1587
+ }
1588
+ // 2. Resolve the agent name for this event
1589
+ const parentToolUseId = ev.parent_tool_use_id ?? null;
1590
+ let resolvedAgentName;
1591
+ if (event.type === 'user' || event.type === 'fc') {
1592
+ resolvedAgentName = 'team-lead';
1593
+ }
1594
+ else if (parentToolUseId) {
1595
+ resolvedAgentName = agentMap.get(parentToolUseId) ?? 'subagent';
1596
+ }
1597
+ else {
1598
+ resolvedAgentName = 'team-lead';
1599
+ }
1600
+ // 3. Extract description and lastToolName from system/task_progress
1601
+ let description;
1602
+ let lastToolName;
1603
+ if (event.type === 'system') {
1604
+ const subtype = ev.subtype;
1605
+ if (subtype === 'task_progress' || subtype === 'task_notification') {
1606
+ description = ev.description ?? undefined;
1607
+ lastToolName = ev.last_tool_name ??
1608
+ ev.tool_name ?? undefined;
1609
+ // system task events may carry a tool_use_id referencing the parent agent
1610
+ const sysToolUseId = ev.tool_use_id;
1611
+ if (sysToolUseId && agentMap.has(sysToolUseId)) {
1612
+ resolvedAgentName = agentMap.get(sysToolUseId);
1613
+ }
1614
+ }
1615
+ }
1616
+ // Store parsed event with timestamp + agent attribution
1617
+ const timestampedEvent = {
1618
+ ...event,
1619
+ timestamp: new Date().toISOString(),
1620
+ agentName: resolvedAgentName,
1621
+ ...(description ? { description } : {}),
1622
+ ...(lastToolName ? { lastToolName } : {}),
1623
+ };
1624
+ events.push(timestampedEvent);
1625
+ if (events.length > MAX_PARSED_EVENTS) {
1626
+ events.shift();
1627
+ }
1628
+ // Accumulate token counts from assistant events
1629
+ this.accumulateTokens(teamId, event);
1630
+ // Broadcast interesting events via SSE
1631
+ // Include 'system' for task_progress/task_notification visibility
1632
+ if (['assistant', 'tool_use', 'tool_result', 'result', 'system'].includes(event.type)) {
1633
+ sseBroker.broadcast('team_output', {
1634
+ team_id: teamId,
1635
+ event: timestampedEvent,
1636
+ }, teamId);
1637
+ }
1638
+ }
1639
+ catch {
1640
+ // Not valid JSON — raw text output (e.g. startup messages)
1641
+ console.log(`[CC:${logPrefix}:raw] ${trimmed.substring(0, 200)}`);
1642
+ }
1643
+ }
1644
+ });
1645
+ child.stdout.on('end', () => {
1646
+ if (stdoutPartial.trim()) {
1647
+ const trimmed = stdoutPartial.trim();
1648
+ buffer.lines.push(stdoutPartial);
1649
+ while (buffer.lines.length > MAX_OUTPUT_LINES) {
1650
+ buffer.lines.shift();
1651
+ }
1652
+ try {
1653
+ const event = JSON.parse(trimmed);
1654
+ console.log(`[CC:${logPrefix}] ${event.type}: ${summarizeEvent(event)}`);
1655
+ // Skip CC-echoed "user" events (same rationale as in 'data' handler)
1656
+ if (event.type !== 'user') {
1657
+ // Detect thinking state before filtering
1658
+ this.detectThinking(teamId, event);
1659
+ // Filter out content_block events (same rationale as in 'data' handler)
1660
+ if (event.type !== 'content_block_start' && event.type !== 'content_block_delta' && event.type !== 'content_block_stop' && event.type !== 'stream_event') {
1661
+ // Resolve agent name (same logic as 'data' handler)
1662
+ const endEv = event;
1663
+ const endParentId = endEv.parent_tool_use_id ?? null;
1664
+ const endAgentName = endParentId
1665
+ ? (agentMap.get(endParentId) ?? 'subagent')
1666
+ : 'team-lead';
1667
+ const timestampedEvent = {
1668
+ ...event,
1669
+ timestamp: new Date().toISOString(),
1670
+ agentName: endAgentName,
1671
+ };
1672
+ events.push(timestampedEvent);
1673
+ if (events.length > MAX_PARSED_EVENTS) {
1674
+ events.shift();
1675
+ }
1676
+ // Accumulate token counts from assistant events
1677
+ this.accumulateTokens(teamId, event);
1678
+ }
1679
+ }
1680
+ }
1681
+ catch {
1682
+ console.log(`[CC:${logPrefix}:raw] ${trimmed.substring(0, 200)}`);
1683
+ }
1684
+ stdoutPartial = '';
1685
+ }
1686
+ });
1687
+ }
1688
+ // stderr: always raw text (errors, warnings)
1689
+ if (child.stderr) {
1690
+ child.stderr.on('data', (data) => {
1691
+ const text = data.toString('utf-8');
1692
+ for (const line of text.split('\n')) {
1693
+ if (line.trim()) {
1694
+ console.log(`[CC:${logPrefix}:stderr] ${line}`);
1695
+ }
1696
+ }
1697
+ const newLines = text.split('\n');
1698
+ for (let idx = 0; idx < newLines.length; idx++) {
1699
+ const line = newLines[idx];
1700
+ if (line === '' && idx === newLines.length - 1)
1701
+ continue;
1702
+ buffer.lines.push(line);
1703
+ while (buffer.lines.length > MAX_OUTPUT_LINES) {
1704
+ buffer.lines.shift();
1705
+ }
1706
+ }
1707
+ });
1708
+ }
1709
+ }
1710
+ /**
1711
+ * Extract and accumulate token counts from stream events.
1712
+ * - `assistant` events: increment token counters from usage.input_tokens, etc.
1713
+ * - `result` events: replace totalCostUsd (it's cumulative) and flush to DB.
1714
+ */
1715
+ accumulateTokens(teamId, event) {
1716
+ const counter = this.tokenCounters.get(teamId);
1717
+ if (!counter)
1718
+ return;
1719
+ const ev = event;
1720
+ if (event.type === 'assistant') {
1721
+ // Extract usage from message.usage (Claude API response format)
1722
+ const message = ev.message;
1723
+ const usage = message?.usage;
1724
+ if (usage) {
1725
+ if (typeof usage.input_tokens === 'number') {
1726
+ counter.inputTokens += usage.input_tokens;
1727
+ }
1728
+ if (typeof usage.output_tokens === 'number') {
1729
+ counter.outputTokens += usage.output_tokens;
1730
+ }
1731
+ if (typeof usage.cache_creation_input_tokens === 'number') {
1732
+ counter.cacheCreationTokens += usage.cache_creation_input_tokens;
1733
+ }
1734
+ if (typeof usage.cache_read_input_tokens === 'number') {
1735
+ counter.cacheReadTokens += usage.cache_read_input_tokens;
1736
+ }
1737
+ }
1738
+ }
1739
+ if (event.type === 'result') {
1740
+ // total_cost_usd on result is cumulative for the session — replace, don't add
1741
+ if (typeof ev.total_cost_usd === 'number') {
1742
+ counter.costUsd = ev.total_cost_usd;
1743
+ console.log(`[TeamManager] Team ${teamId} cost: $${counter.costUsd.toFixed(4)}`);
1744
+ }
1745
+ // Flush to DB on every result event
1746
+ this.flushTokenCounters(teamId);
1747
+ }
1748
+ }
1749
+ /**
1750
+ * Persist in-memory token counters to the database and broadcast SSE update.
1751
+ */
1752
+ flushTokenCounters(teamId) {
1753
+ const counter = this.tokenCounters.get(teamId);
1754
+ if (!counter)
1755
+ return;
1756
+ try {
1757
+ const db = getDatabase();
1758
+ db.updateTeam(teamId, {
1759
+ totalInputTokens: counter.inputTokens,
1760
+ totalOutputTokens: counter.outputTokens,
1761
+ totalCacheCreationTokens: counter.cacheCreationTokens,
1762
+ totalCacheReadTokens: counter.cacheReadTokens,
1763
+ totalCostUsd: counter.costUsd,
1764
+ });
1765
+ const currentTeam = db.getTeam(teamId);
1766
+ const status = currentTeam?.status ?? 'running';
1767
+ sseBroker.broadcast('team_status_changed', {
1768
+ team_id: teamId,
1769
+ status,
1770
+ previous_status: status,
1771
+ tokens: {
1772
+ input: counter.inputTokens,
1773
+ output: counter.outputTokens,
1774
+ cacheCreation: counter.cacheCreationTokens,
1775
+ cacheRead: counter.cacheReadTokens,
1776
+ costUsd: counter.costUsd,
1777
+ },
1778
+ }, teamId);
1779
+ }
1780
+ catch (err) {
1781
+ console.error(`[TeamManager] Failed to flush token counters for team ${teamId}:`, err);
1782
+ }
1783
+ }
1784
+ /**
1785
+ * Detect thinking start/stop from Claude Code stream-json events.
1786
+ *
1787
+ * content_block_start with content_block.type === "thinking" signals the
1788
+ * start of an extended thinking block. content_block_stop (matched by
1789
+ * index) signals the end.
1790
+ */
1791
+ detectThinking(teamId, event) {
1792
+ let ev = event;
1793
+ let effectiveType = event.type;
1794
+ // Unwrap stream_event envelopes: thinking signals from
1795
+ // --include-partial-messages arrive wrapped as
1796
+ // { type: "stream_event", event: { type: "content_block_start", ... } }
1797
+ if (event.type === 'stream_event') {
1798
+ const inner = ev.event;
1799
+ if (inner && typeof inner.type === 'string') {
1800
+ ev = inner;
1801
+ effectiveType = inner.type;
1802
+ }
1803
+ }
1804
+ if (effectiveType === 'content_block_start') {
1805
+ const block = ev.content_block;
1806
+ if (block && block.type === 'thinking') {
1807
+ this.thinkingTeams.add(teamId);
1808
+ this.thinkingStartTimes.set(teamId, Date.now());
1809
+ const index = typeof ev.index === 'number' ? ev.index : -1;
1810
+ this.thinkingBlockIndex.set(teamId, index);
1811
+ sseBroker.broadcast('team_thinking_start', { team_id: teamId }, teamId);
1812
+ console.log(`[TeamManager] Team ${teamId} thinking started (block index ${index})`);
1813
+ }
1814
+ }
1815
+ else if (effectiveType === 'content_block_stop') {
1816
+ const index = typeof ev.index === 'number' ? ev.index : -1;
1817
+ const trackedIndex = this.thinkingBlockIndex.get(teamId);
1818
+ if (this.thinkingTeams.has(teamId) && (trackedIndex === undefined || trackedIndex === index)) {
1819
+ const startTime = this.thinkingStartTimes.get(teamId) ?? Date.now();
1820
+ const durationMs = Date.now() - startTime;
1821
+ this.thinkingTeams.delete(teamId);
1822
+ this.thinkingStartTimes.delete(teamId);
1823
+ this.thinkingBlockIndex.delete(teamId);
1824
+ sseBroker.broadcast('team_thinking_stop', { team_id: teamId, duration_ms: durationMs }, teamId);
1825
+ console.log(`[TeamManager] Team ${teamId} thinking stopped (${durationMs}ms)`);
1826
+ }
1827
+ }
1828
+ }
1829
+ /**
1830
+ * Clear thinking state for a team (e.g. on stop/cleanup).
1831
+ */
1832
+ clearThinking(teamId) {
1833
+ if (this.thinkingTeams.has(teamId)) {
1834
+ const startTime = this.thinkingStartTimes.get(teamId) ?? Date.now();
1835
+ const durationMs = Date.now() - startTime;
1836
+ this.thinkingTeams.delete(teamId);
1837
+ this.thinkingStartTimes.delete(teamId);
1838
+ this.thinkingBlockIndex.delete(teamId);
1839
+ sseBroker.broadcast('team_thinking_stop', { team_id: teamId, duration_ms: durationMs }, teamId);
1840
+ }
1841
+ }
1842
+ killProcess(pid) {
1843
+ try {
1844
+ if (process.platform === 'win32') {
1845
+ // On Windows, use taskkill to kill the entire process tree
1846
+ execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'pipe' });
1847
+ }
1848
+ else {
1849
+ // On Unix, send SIGTERM
1850
+ process.kill(pid, 'SIGTERM');
1851
+ }
1852
+ }
1853
+ catch {
1854
+ // Process may have already exited — ignore
1855
+ }
1856
+ }
1857
+ }
1858
+ // ---------------------------------------------------------------------------
1859
+ // Singleton
1860
+ // ---------------------------------------------------------------------------
1861
+ let _instance = null;
1862
+ export function getTeamManager() {
1863
+ if (!_instance) {
1864
+ _instance = new TeamManager();
1865
+ }
1866
+ return _instance;
1867
+ }
1868
+ export default TeamManager;
1869
+ //# sourceMappingURL=team-manager.js.map