@inixiative/hivemind 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -0
  3. package/dist/agents/agents.test.d.ts +1 -0
  4. package/dist/agents/agents.test.js +167 -0
  5. package/dist/agents/getActiveAgents.d.ts +6 -0
  6. package/dist/agents/getActiveAgents.js +11 -0
  7. package/dist/agents/getAgent.d.ts +6 -0
  8. package/dist/agents/getAgent.js +7 -0
  9. package/dist/agents/getAgentBySessionId.d.ts +10 -0
  10. package/dist/agents/getAgentBySessionId.js +17 -0
  11. package/dist/agents/index.d.ts +10 -0
  12. package/dist/agents/index.js +12 -0
  13. package/dist/agents/markAgentDead.d.ts +6 -0
  14. package/dist/agents/markAgentDead.js +26 -0
  15. package/dist/agents/markAgentIdle.d.ts +5 -0
  16. package/dist/agents/markAgentIdle.js +12 -0
  17. package/dist/agents/registerAgent.d.ts +6 -0
  18. package/dist/agents/registerAgent.js +29 -0
  19. package/dist/agents/types.d.ts +30 -0
  20. package/dist/agents/types.js +1 -0
  21. package/dist/agents/unregisterAgent.d.ts +5 -0
  22. package/dist/agents/unregisterAgent.js +8 -0
  23. package/dist/agents/updateAgentContext.d.ts +5 -0
  24. package/dist/agents/updateAgentContext.js +12 -0
  25. package/dist/agents/updateAgentTask.d.ts +5 -0
  26. package/dist/agents/updateAgentTask.js +12 -0
  27. package/dist/agents/updateAgentWorktree.d.ts +5 -0
  28. package/dist/agents/updateAgentWorktree.js +12 -0
  29. package/dist/cli/index.d.ts +8 -0
  30. package/dist/cli/index.js +8 -0
  31. package/dist/cli/init.d.ts +14 -0
  32. package/dist/cli/init.js +71 -0
  33. package/dist/cli/install.d.ts +8 -0
  34. package/dist/cli/install.js +47 -0
  35. package/dist/cli/join.d.ts +9 -0
  36. package/dist/cli/join.js +38 -0
  37. package/dist/cli/registerMcp.d.ts +28 -0
  38. package/dist/cli/registerMcp.js +138 -0
  39. package/dist/cli/status.d.ts +8 -0
  40. package/dist/cli/status.js +82 -0
  41. package/dist/cli/watch.d.ts +6 -0
  42. package/dist/cli/watch.js +68 -0
  43. package/dist/cli.d.ts +12 -0
  44. package/dist/cli.js +49 -0
  45. package/dist/coordinator/coordinator.test.d.ts +1 -0
  46. package/dist/coordinator/coordinator.test.js +171 -0
  47. package/dist/coordinator/index.d.ts +16 -0
  48. package/dist/coordinator/index.js +166 -0
  49. package/dist/coordinator/spawn.d.ts +22 -0
  50. package/dist/coordinator/spawn.js +66 -0
  51. package/dist/datetime/datetime.test.d.ts +1 -0
  52. package/dist/datetime/datetime.test.js +63 -0
  53. package/dist/datetime/formatDate.d.ts +6 -0
  54. package/dist/datetime/formatDate.js +11 -0
  55. package/dist/datetime/formatDatetime.d.ts +6 -0
  56. package/dist/datetime/formatDatetime.js +12 -0
  57. package/dist/datetime/formatTime.d.ts +6 -0
  58. package/dist/datetime/formatTime.js +11 -0
  59. package/dist/datetime/index.d.ts +4 -0
  60. package/dist/datetime/index.js +7 -0
  61. package/dist/datetime/isStale.d.ts +10 -0
  62. package/dist/datetime/isStale.js +18 -0
  63. package/dist/datetime/now.d.ts +6 -0
  64. package/dist/datetime/now.js +9 -0
  65. package/dist/datetime/parseDatetime.d.ts +7 -0
  66. package/dist/datetime/parseDatetime.js +28 -0
  67. package/dist/db/constants.d.ts +4 -0
  68. package/dist/db/constants.js +6 -0
  69. package/dist/db/db.test.d.ts +1 -0
  70. package/dist/db/db.test.js +141 -0
  71. package/dist/db/ensureProjectDirs.d.ts +4 -0
  72. package/dist/db/ensureProjectDirs.js +12 -0
  73. package/dist/db/getConnection.d.ts +19 -0
  74. package/dist/db/getConnection.js +51 -0
  75. package/dist/db/getCurrentProject.d.ts +8 -0
  76. package/dist/db/getCurrentProject.js +14 -0
  77. package/dist/db/getProjectPaths.d.ts +21 -0
  78. package/dist/db/getProjectPaths.js +26 -0
  79. package/dist/db/index.d.ts +10 -0
  80. package/dist/db/index.js +13 -0
  81. package/dist/db/initializeDb.d.ts +7 -0
  82. package/dist/db/initializeDb.js +23 -0
  83. package/dist/db/nextEventSeq.d.ts +5 -0
  84. package/dist/db/nextEventSeq.js +13 -0
  85. package/dist/db/nextSubtaskSeq.d.ts +5 -0
  86. package/dist/db/nextSubtaskSeq.js +12 -0
  87. package/dist/db/nextTaskSeq.d.ts +5 -0
  88. package/dist/db/nextTaskSeq.js +13 -0
  89. package/dist/db/resetDb.d.ts +10 -0
  90. package/dist/db/resetDb.js +36 -0
  91. package/dist/events/emit.d.ts +6 -0
  92. package/dist/events/emit.js +31 -0
  93. package/dist/events/events.test.d.ts +1 -0
  94. package/dist/events/events.test.js +145 -0
  95. package/dist/events/getEventsByAgent.d.ts +6 -0
  96. package/dist/events/getEventsByAgent.js +14 -0
  97. package/dist/events/getEventsByBranch.d.ts +6 -0
  98. package/dist/events/getEventsByBranch.js +12 -0
  99. package/dist/events/getEventsByPlan.d.ts +6 -0
  100. package/dist/events/getEventsByPlan.js +14 -0
  101. package/dist/events/getEventsByWorktree.d.ts +6 -0
  102. package/dist/events/getEventsByWorktree.js +12 -0
  103. package/dist/events/getEventsSince.d.ts +12 -0
  104. package/dist/events/getEventsSince.js +47 -0
  105. package/dist/events/getRecentEvents.d.ts +6 -0
  106. package/dist/events/getRecentEvents.js +12 -0
  107. package/dist/events/index.d.ts +8 -0
  108. package/dist/events/index.js +9 -0
  109. package/dist/events/types.d.ts +34 -0
  110. package/dist/events/types.js +1 -0
  111. package/dist/git/getBranch.d.ts +4 -0
  112. package/dist/git/getBranch.js +14 -0
  113. package/dist/git/getCurrentWorktree.d.ts +5 -0
  114. package/dist/git/getCurrentWorktree.js +15 -0
  115. package/dist/git/getGitInfo.d.ts +10 -0
  116. package/dist/git/getGitInfo.js +23 -0
  117. package/dist/git/getRepoName.d.ts +4 -0
  118. package/dist/git/getRepoName.js +32 -0
  119. package/dist/git/getRepoRoot.d.ts +4 -0
  120. package/dist/git/getRepoRoot.js +14 -0
  121. package/dist/git/getWorktrees.d.ts +10 -0
  122. package/dist/git/getWorktrees.js +39 -0
  123. package/dist/git/index.d.ts +9 -0
  124. package/dist/git/index.js +7 -0
  125. package/dist/git/isGitRepo.d.ts +4 -0
  126. package/dist/git/isGitRepo.js +13 -0
  127. package/dist/hooks/index.d.ts +1 -0
  128. package/dist/hooks/index.js +1 -0
  129. package/dist/hooks/sessionStart.d.ts +21 -0
  130. package/dist/hooks/sessionStart.js +93 -0
  131. package/dist/ids/generateHex.d.ts +4 -0
  132. package/dist/ids/generateHex.js +7 -0
  133. package/dist/ids/getParentTaskId.d.ts +7 -0
  134. package/dist/ids/getParentTaskId.js +15 -0
  135. package/dist/ids/getPlanHexFromTaskId.d.ts +6 -0
  136. package/dist/ids/getPlanHexFromTaskId.js +9 -0
  137. package/dist/ids/ids.test.d.ts +1 -0
  138. package/dist/ids/ids.test.js +215 -0
  139. package/dist/ids/index.d.ts +16 -0
  140. package/dist/ids/index.js +17 -0
  141. package/dist/ids/isSubtask.d.ts +7 -0
  142. package/dist/ids/isSubtask.js +11 -0
  143. package/dist/ids/isValidId.d.ts +9 -0
  144. package/dist/ids/isValidId.js +22 -0
  145. package/dist/ids/makeAgentId.d.ts +8 -0
  146. package/dist/ids/makeAgentId.js +15 -0
  147. package/dist/ids/makeEventId.d.ts +11 -0
  148. package/dist/ids/makeEventId.js +12 -0
  149. package/dist/ids/makePlanId.d.ts +11 -0
  150. package/dist/ids/makePlanId.js +15 -0
  151. package/dist/ids/makeSubtaskId.d.ts +8 -0
  152. package/dist/ids/makeSubtaskId.js +15 -0
  153. package/dist/ids/makeTaskId.d.ts +8 -0
  154. package/dist/ids/makeTaskId.js +14 -0
  155. package/dist/ids/makeWorktreeId.d.ts +5 -0
  156. package/dist/ids/makeWorktreeId.js +12 -0
  157. package/dist/ids/parseId.d.ts +11 -0
  158. package/dist/ids/parseId.js +26 -0
  159. package/dist/ids/sanitizeLabel.d.ts +7 -0
  160. package/dist/ids/sanitizeLabel.js +12 -0
  161. package/dist/ids/typedIds.d.ts +34 -0
  162. package/dist/ids/typedIds.js +22 -0
  163. package/dist/ids/types.d.ts +14 -0
  164. package/dist/ids/types.js +1 -0
  165. package/dist/init/claudeConfig.d.ts +39 -0
  166. package/dist/init/claudeConfig.js +161 -0
  167. package/dist/llm/extractTasks.d.ts +28 -0
  168. package/dist/llm/extractTasks.js +108 -0
  169. package/dist/llm/index.d.ts +2 -0
  170. package/dist/llm/index.js +2 -0
  171. package/dist/llm/reconcileTasks.d.ts +21 -0
  172. package/dist/llm/reconcileTasks.js +82 -0
  173. package/dist/mcp/server.d.ts +2 -0
  174. package/dist/mcp/server.js +100 -0
  175. package/dist/mcp/tools/emitEvent.d.ts +62 -0
  176. package/dist/mcp/tools/emitEvent.js +84 -0
  177. package/dist/mcp/tools/events.d.ts +55 -0
  178. package/dist/mcp/tools/events.js +56 -0
  179. package/dist/mcp/tools/index.d.ts +18 -0
  180. package/dist/mcp/tools/index.js +13 -0
  181. package/dist/mcp/tools/query.d.ts +54 -0
  182. package/dist/mcp/tools/query.js +70 -0
  183. package/dist/mcp/tools/register.d.ts +47 -0
  184. package/dist/mcp/tools/register.js +79 -0
  185. package/dist/mcp/tools/reset.d.ts +38 -0
  186. package/dist/mcp/tools/reset.js +56 -0
  187. package/dist/mcp/tools/setup.d.ts +42 -0
  188. package/dist/mcp/tools/setup.js +75 -0
  189. package/dist/mcp/tools/status.d.ts +44 -0
  190. package/dist/mcp/tools/status.js +74 -0
  191. package/dist/mcp/tools/tasks.d.ts +116 -0
  192. package/dist/mcp/tools/tasks.js +143 -0
  193. package/dist/mcp/tools/worktreeCleanup.d.ts +38 -0
  194. package/dist/mcp/tools/worktreeCleanup.js +67 -0
  195. package/dist/plans/createPlan.d.ts +6 -0
  196. package/dist/plans/createPlan.js +29 -0
  197. package/dist/plans/getActivePlans.d.ts +6 -0
  198. package/dist/plans/getActivePlans.js +11 -0
  199. package/dist/plans/getPlan.d.ts +6 -0
  200. package/dist/plans/getPlan.js +7 -0
  201. package/dist/plans/index.d.ts +5 -0
  202. package/dist/plans/index.js +5 -0
  203. package/dist/plans/plans.test.d.ts +1 -0
  204. package/dist/plans/plans.test.js +107 -0
  205. package/dist/plans/types.d.ts +32 -0
  206. package/dist/plans/types.js +1 -0
  207. package/dist/plans/updatePlanStatus.d.ts +6 -0
  208. package/dist/plans/updatePlanStatus.js +8 -0
  209. package/dist/tasks/assignTask.d.ts +7 -0
  210. package/dist/tasks/assignTask.js +20 -0
  211. package/dist/tasks/blockTask.d.ts +5 -0
  212. package/dist/tasks/blockTask.js +12 -0
  213. package/dist/tasks/claimTask.d.ts +7 -0
  214. package/dist/tasks/claimTask.js +21 -0
  215. package/dist/tasks/completeTask.d.ts +6 -0
  216. package/dist/tasks/completeTask.js +18 -0
  217. package/dist/tasks/createTask.d.ts +6 -0
  218. package/dist/tasks/createTask.js +36 -0
  219. package/dist/tasks/getPendingTasks.d.ts +6 -0
  220. package/dist/tasks/getPendingTasks.js +19 -0
  221. package/dist/tasks/getTask.d.ts +6 -0
  222. package/dist/tasks/getTask.js +7 -0
  223. package/dist/tasks/getTasksByPlan.d.ts +6 -0
  224. package/dist/tasks/getTasksByPlan.js +11 -0
  225. package/dist/tasks/index.d.ts +11 -0
  226. package/dist/tasks/index.js +12 -0
  227. package/dist/tasks/startTask.d.ts +5 -0
  228. package/dist/tasks/startTask.js +12 -0
  229. package/dist/tasks/tasks.test.d.ts +1 -0
  230. package/dist/tasks/tasks.test.js +209 -0
  231. package/dist/tasks/types.d.ts +36 -0
  232. package/dist/tasks/types.js +1 -0
  233. package/dist/tasks/unclaimTask.d.ts +5 -0
  234. package/dist/tasks/unclaimTask.js +12 -0
  235. package/dist/test/factories/agentFactory.d.ts +13 -0
  236. package/dist/test/factories/agentFactory.js +5 -0
  237. package/dist/test/factories/eventFactory.d.ts +20 -0
  238. package/dist/test/factories/eventFactory.js +16 -0
  239. package/dist/test/factories/factories.test.d.ts +1 -0
  240. package/dist/test/factories/factories.test.js +101 -0
  241. package/dist/test/factories/index.d.ts +4 -0
  242. package/dist/test/factories/index.js +4 -0
  243. package/dist/test/factories/planFactory.d.ts +15 -0
  244. package/dist/test/factories/planFactory.js +14 -0
  245. package/dist/test/factories/taskFactory.d.ts +22 -0
  246. package/dist/test/factories/taskFactory.js +44 -0
  247. package/dist/test/multiAgentDemo.d.ts +16 -0
  248. package/dist/test/multiAgentDemo.js +20 -0
  249. package/dist/test/multiAgentDemo.test.d.ts +1 -0
  250. package/dist/test/multiAgentDemo.test.js +14 -0
  251. package/dist/test/setup.d.ts +9 -0
  252. package/dist/test/setup.js +50 -0
  253. package/dist/utils/index.d.ts +5 -0
  254. package/dist/utils/index.js +5 -0
  255. package/dist/utils/math.d.ts +6 -0
  256. package/dist/utils/math.js +10 -0
  257. package/dist/utils/math.test.d.ts +1 -0
  258. package/dist/utils/math.test.js +26 -0
  259. package/dist/utils/string.d.ts +11 -0
  260. package/dist/utils/string.js +17 -0
  261. package/dist/utils/string.test.d.ts +1 -0
  262. package/dist/utils/string.test.js +30 -0
  263. package/dist/watcher/index.d.ts +1 -0
  264. package/dist/watcher/index.js +1 -0
  265. package/dist/watcher/planWatcher.d.ts +31 -0
  266. package/dist/watcher/planWatcher.js +171 -0
  267. package/dist/worktrees/getAllWorktrees.d.ts +6 -0
  268. package/dist/worktrees/getAllWorktrees.js +10 -0
  269. package/dist/worktrees/getWorktreeById.d.ts +6 -0
  270. package/dist/worktrees/getWorktreeById.js +7 -0
  271. package/dist/worktrees/getWorktreeByPath.d.ts +6 -0
  272. package/dist/worktrees/getWorktreeByPath.js +7 -0
  273. package/dist/worktrees/index.d.ts +9 -0
  274. package/dist/worktrees/index.js +13 -0
  275. package/dist/worktrees/registerWorktree.d.ts +6 -0
  276. package/dist/worktrees/registerWorktree.js +28 -0
  277. package/dist/worktrees/removeWorktree.d.ts +6 -0
  278. package/dist/worktrees/removeWorktree.js +9 -0
  279. package/dist/worktrees/syncWorktreesFromGit.d.ts +7 -0
  280. package/dist/worktrees/syncWorktreesFromGit.js +51 -0
  281. package/dist/worktrees/types.d.ts +29 -0
  282. package/dist/worktrees/types.js +1 -0
  283. package/dist/worktrees/updateWorktreeCommit.d.ts +5 -0
  284. package/dist/worktrees/updateWorktreeCommit.js +13 -0
  285. package/dist/worktrees/updateWorktreeSeen.d.ts +5 -0
  286. package/dist/worktrees/updateWorktreeSeen.js +13 -0
  287. package/package.json +58 -0
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, beforeEach, afterEach, afterAll } from 'bun:test';
2
+ import { createTestDb, cleanupAllTestDbs } from '../test/setup';
3
+ import { buildAgent } from '../test/factories';
4
+ import { getAgent } from '../agents/getAgent';
5
+ import { getActiveAgents } from '../agents/getActiveAgents';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import { mkdirSync, rmSync, existsSync } from 'fs';
9
+ // Test directory for coordinator files
10
+ const TEST_COORD_DIR = path.join(__dirname, '../../.test-coordinator');
11
+ function createTestCoordinatorDir() {
12
+ const dataDir = path.join(TEST_COORD_DIR, `coord-${Date.now()}`);
13
+ mkdirSync(dataDir, { recursive: true });
14
+ return {
15
+ dataDir,
16
+ cleanup: () => {
17
+ if (existsSync(dataDir)) {
18
+ rmSync(dataDir, { recursive: true, force: true });
19
+ }
20
+ },
21
+ };
22
+ }
23
+ function cleanupAllCoordinatorDirs() {
24
+ if (existsSync(TEST_COORD_DIR)) {
25
+ rmSync(TEST_COORD_DIR, { recursive: true, force: true });
26
+ }
27
+ }
28
+ describe('Coordinator', () => {
29
+ let testDb;
30
+ let testCoord;
31
+ beforeEach(() => {
32
+ testDb = createTestDb();
33
+ testCoord = createTestCoordinatorDir();
34
+ });
35
+ afterEach(() => {
36
+ testDb.cleanup();
37
+ testCoord.cleanup();
38
+ });
39
+ afterAll(() => {
40
+ cleanupAllTestDbs();
41
+ cleanupAllCoordinatorDirs();
42
+ });
43
+ describe('Lock acquisition', () => {
44
+ it('creates pid file when acquiring lock', async () => {
45
+ const pidPath = path.join(testCoord.dataDir, 'coordinator.pid');
46
+ // Simulate acquiring lock
47
+ fs.writeFileSync(pidPath, process.pid.toString());
48
+ expect(existsSync(pidPath)).toBe(true);
49
+ const content = fs.readFileSync(pidPath, 'utf-8');
50
+ expect(parseInt(content, 10)).toBe(process.pid);
51
+ });
52
+ it('detects stale pid file for dead process', () => {
53
+ const pidPath = path.join(testCoord.dataDir, 'coordinator.pid');
54
+ // Write a PID that doesn't exist (use a very high number)
55
+ fs.writeFileSync(pidPath, '999999999');
56
+ // Check if process exists
57
+ let processExists = true;
58
+ try {
59
+ process.kill(999999999, 0);
60
+ }
61
+ catch {
62
+ processExists = false;
63
+ }
64
+ expect(processExists).toBe(false);
65
+ });
66
+ });
67
+ describe('PID-based agent detection', () => {
68
+ it('can detect if a PID is alive', () => {
69
+ // Current process is alive
70
+ let alive = true;
71
+ try {
72
+ process.kill(process.pid, 0);
73
+ }
74
+ catch {
75
+ alive = false;
76
+ }
77
+ expect(alive).toBe(true);
78
+ // Non-existent PID is dead
79
+ let deadAlive = true;
80
+ try {
81
+ process.kill(999999999, 0);
82
+ }
83
+ catch {
84
+ deadAlive = false;
85
+ }
86
+ expect(deadAlive).toBe(false);
87
+ });
88
+ it('agents store PID for lifecycle tracking', () => {
89
+ const { agent } = buildAgent(testDb.db, { pid: process.pid });
90
+ expect(agent.pid).toBe(process.pid);
91
+ });
92
+ });
93
+ describe('Agent cleanup', () => {
94
+ it('marks stale agent as dead and releases tasks', () => {
95
+ const { agent } = buildAgent(testDb.db);
96
+ // Create a claimed task
97
+ testDb.db
98
+ .prepare(`
99
+ INSERT INTO plans (id, hex, title, status, created_at)
100
+ VALUES ('pln_test', 'abcdef', 'Test Plan', 'active', datetime('now'))
101
+ `)
102
+ .run();
103
+ testDb.db
104
+ .prepare(`
105
+ INSERT INTO tasks (id, plan_hex, seq, plan_id, title, status, claimed_by)
106
+ VALUES ('tsk_test', 'abcdef', '001', 'pln_test', 'Test task', 'claimed', ?)
107
+ `)
108
+ .run(agent.id);
109
+ // Mark agent dead (simulating coordinator action)
110
+ testDb.db.prepare(`UPDATE agents SET status = 'dead' WHERE id = ?`).run(agent.id);
111
+ testDb.db
112
+ .prepare(`UPDATE tasks SET status = 'pending', claimed_by = NULL WHERE claimed_by = ?`)
113
+ .run(agent.id);
114
+ // Verify
115
+ const updatedAgent = getAgent(testDb.db, agent.id);
116
+ expect(updatedAgent.status).toBe('dead');
117
+ const task = testDb.db.prepare(`SELECT * FROM tasks WHERE id = 'tsk_test'`).get();
118
+ expect(task.status).toBe('pending');
119
+ expect(task.claimed_by).toBeNull();
120
+ });
121
+ });
122
+ describe('Singleton behavior', () => {
123
+ it('prevents multiple coordinators via pid file', () => {
124
+ const pidPath = path.join(testCoord.dataDir, 'coordinator.pid');
125
+ // First "coordinator" writes its PID
126
+ fs.writeFileSync(pidPath, process.pid.toString());
127
+ // Second coordinator should detect existing process
128
+ const existingPid = parseInt(fs.readFileSync(pidPath, 'utf-8'), 10);
129
+ let canAcquire = false;
130
+ try {
131
+ process.kill(existingPid, 0); // Check if alive
132
+ canAcquire = false; // Process alive, can't acquire
133
+ }
134
+ catch {
135
+ canAcquire = true; // Process dead, can acquire
136
+ }
137
+ expect(canAcquire).toBe(false);
138
+ });
139
+ it('allows new coordinator when previous is dead', () => {
140
+ const pidPath = path.join(testCoord.dataDir, 'coordinator.pid');
141
+ // Write a dead PID
142
+ fs.writeFileSync(pidPath, '999999999');
143
+ // Check if can acquire
144
+ const existingPid = parseInt(fs.readFileSync(pidPath, 'utf-8'), 10);
145
+ let canAcquire = false;
146
+ try {
147
+ process.kill(existingPid, 0);
148
+ canAcquire = false;
149
+ }
150
+ catch {
151
+ canAcquire = true;
152
+ }
153
+ expect(canAcquire).toBe(true);
154
+ });
155
+ });
156
+ describe('Coordinator lifecycle', () => {
157
+ it('coordinator runs indefinitely (no exit on empty)', () => {
158
+ // No agents registered
159
+ const active = getActiveAgents(testDb.db);
160
+ expect(active.length).toBe(0);
161
+ // Coordinator should NOT exit - it runs forever per project
162
+ const shouldExit = false; // Changed from heartbeat-based logic
163
+ expect(shouldExit).toBe(false);
164
+ });
165
+ it('coordinator monitors all active agents', () => {
166
+ buildAgent(testDb.db);
167
+ const active = getActiveAgents(testDb.db);
168
+ expect(active.length).toBe(1);
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Hivemind Coordinator - singleton background process (one per project)
3
+ *
4
+ * Responsibilities:
5
+ * - Periodically check if agent PIDs are still alive
6
+ * - Mark dead agents
7
+ * - Clean up resources
8
+ *
9
+ * Runs indefinitely until killed. Started by first agent join.
10
+ */
11
+ type CoordinatorConfig = {
12
+ project: string;
13
+ dataDir: string;
14
+ };
15
+ export declare function runCoordinator(config: CoordinatorConfig): void;
16
+ export {};
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Hivemind Coordinator - singleton background process (one per project)
3
+ *
4
+ * Responsibilities:
5
+ * - Periodically check if agent PIDs are still alive
6
+ * - Mark dead agents
7
+ * - Clean up resources
8
+ *
9
+ * Runs indefinitely until killed. Started by first agent join.
10
+ */
11
+ import { getConnection } from '../db/getConnection';
12
+ import { getActiveAgents } from '../agents/getActiveAgents';
13
+ import { markAgentDead } from '../agents/markAgentDead';
14
+ import { startPlanWatcher } from '../watcher/planWatcher';
15
+ import { emit } from '../events/emit';
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { parseDatetime } from '../datetime/parseDatetime';
19
+ const SWEEP_INTERVAL_MS = 30_000; // 30 seconds
20
+ const MIN_AGE_MS = 60_000; // Only kill agents older than 1 minute (grace period)
21
+ /**
22
+ * Check if a process is alive
23
+ */
24
+ function isProcessAlive(pid) {
25
+ try {
26
+ process.kill(pid, 0); // Signal 0 = check existence
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * Check if an agent is old enough to be considered for cleanup
35
+ */
36
+ function isOldEnough(createdAt) {
37
+ const created = parseDatetime(createdAt);
38
+ if (!created)
39
+ return false;
40
+ return Date.now() - created.getTime() > MIN_AGE_MS;
41
+ }
42
+ function getLockPath(config) {
43
+ return path.join(config.dataDir, 'coordinator.lock');
44
+ }
45
+ function getPidPath(config) {
46
+ return path.join(config.dataDir, 'coordinator.pid');
47
+ }
48
+ function acquireLock(config) {
49
+ const lockPath = getLockPath(config);
50
+ const pidPath = getPidPath(config);
51
+ try {
52
+ // Check if another coordinator is running
53
+ if (fs.existsSync(pidPath)) {
54
+ const existingPid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
55
+ // Check if process is still alive
56
+ try {
57
+ process.kill(existingPid, 0); // Signal 0 = check if process exists
58
+ // Process exists, can't acquire lock
59
+ return false;
60
+ }
61
+ catch {
62
+ // Process doesn't exist, stale PID file
63
+ fs.unlinkSync(pidPath);
64
+ }
65
+ }
66
+ // Write our PID
67
+ fs.writeFileSync(pidPath, process.pid.toString());
68
+ // Create lock file
69
+ fs.writeFileSync(lockPath, new Date().toISOString());
70
+ return true;
71
+ }
72
+ catch (error) {
73
+ console.error('Failed to acquire lock:', error);
74
+ return false;
75
+ }
76
+ }
77
+ function releaseLock(config) {
78
+ try {
79
+ const lockPath = getLockPath(config);
80
+ const pidPath = getPidPath(config);
81
+ if (fs.existsSync(lockPath))
82
+ fs.unlinkSync(lockPath);
83
+ if (fs.existsSync(pidPath))
84
+ fs.unlinkSync(pidPath);
85
+ }
86
+ catch {
87
+ // Ignore cleanup errors
88
+ }
89
+ }
90
+ function sweep(config) {
91
+ const db = getConnection(config.project);
92
+ // Get all active agents and check if their PIDs are alive
93
+ const activeAgents = getActiveAgents(db);
94
+ let marked = 0;
95
+ for (const agent of activeAgents) {
96
+ // Skip agents that are too new (grace period)
97
+ if (!isOldEnough(agent.created_at)) {
98
+ continue;
99
+ }
100
+ // If agent has a PID, check if it's still alive
101
+ if (agent.pid && !isProcessAlive(agent.pid)) {
102
+ markAgentDead(db, agent.id);
103
+ // Emit agent:dead system event
104
+ emit(db, {
105
+ type: 'agent:dead',
106
+ agent_id: agent.id,
107
+ worktree_id: agent.worktree_id ?? undefined,
108
+ content: `Agent ${agent.id} died (PID ${agent.pid} no longer running)`,
109
+ metadata: {
110
+ pid: agent.pid,
111
+ label: agent.label,
112
+ current_task_id: agent.current_task_id,
113
+ current_plan_id: agent.current_plan_id,
114
+ },
115
+ });
116
+ marked++;
117
+ }
118
+ // Agents without PIDs are left alone (legacy or manual registration)
119
+ }
120
+ return marked;
121
+ }
122
+ export function runCoordinator(config) {
123
+ // Try to acquire lock
124
+ if (!acquireLock(config)) {
125
+ // Another coordinator is running
126
+ process.exit(0);
127
+ }
128
+ console.log(`[coordinator] Started for ${config.project} (pid: ${process.pid})`);
129
+ // Start the plans watcher (syncs Claude's plan mode files to hivemind)
130
+ const watcher = startPlanWatcher({ project: config.project });
131
+ // Handle shutdown gracefully
132
+ const shutdown = () => {
133
+ console.log('[coordinator] Shutting down...');
134
+ watcher.stop();
135
+ releaseLock(config);
136
+ process.exit(0);
137
+ };
138
+ process.on('SIGINT', shutdown);
139
+ process.on('SIGTERM', shutdown);
140
+ // Main loop
141
+ const tick = () => {
142
+ try {
143
+ const marked = sweep(config);
144
+ if (marked > 0) {
145
+ console.log(`[coordinator] Marked ${marked} dead agent(s) (PID gone)`);
146
+ }
147
+ }
148
+ catch (error) {
149
+ console.error('[coordinator] Sweep error:', error);
150
+ }
151
+ // Schedule next tick
152
+ setTimeout(tick, SWEEP_INTERVAL_MS);
153
+ };
154
+ // Start the loop
155
+ tick();
156
+ }
157
+ // CLI entry point
158
+ if (import.meta.main) {
159
+ const project = process.argv[2];
160
+ const dataDir = process.argv[3];
161
+ if (!project || !dataDir) {
162
+ console.error('Usage: coordinator <project> <dataDir>');
163
+ process.exit(1);
164
+ }
165
+ runCoordinator({ project, dataDir });
166
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Spawn coordinator as a singleton - only starts if not already running
3
+ */
4
+ type SpawnConfig = {
5
+ project: string;
6
+ dataDir: string;
7
+ };
8
+ /**
9
+ * Upsert coordinator singleton - spawns if not running, no-op if already running
10
+ */
11
+ export declare function ensureCoordinator(config: SpawnConfig): {
12
+ spawned: boolean;
13
+ pid?: number;
14
+ };
15
+ /**
16
+ * Get coordinator status
17
+ */
18
+ export declare function getCoordinatorStatus(config: SpawnConfig): {
19
+ running: boolean;
20
+ pid?: number;
21
+ };
22
+ export {};
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Spawn coordinator as a singleton - only starts if not already running
3
+ */
4
+ import { spawn } from 'child_process';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ function isCoordinatorRunning(config) {
8
+ const pidPath = path.join(config.dataDir, 'coordinator.pid');
9
+ if (!fs.existsSync(pidPath)) {
10
+ return false;
11
+ }
12
+ try {
13
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
14
+ process.kill(pid, 0); // Signal 0 = check existence
15
+ return true;
16
+ }
17
+ catch {
18
+ // Process doesn't exist or can't be signaled
19
+ // Clean up stale PID file
20
+ try {
21
+ fs.unlinkSync(pidPath);
22
+ }
23
+ catch {
24
+ // Ignore
25
+ }
26
+ return false;
27
+ }
28
+ }
29
+ /**
30
+ * Upsert coordinator singleton - spawns if not running, no-op if already running
31
+ */
32
+ export function ensureCoordinator(config) {
33
+ // Check if already running
34
+ if (isCoordinatorRunning(config)) {
35
+ const pidPath = path.join(config.dataDir, 'coordinator.pid');
36
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
37
+ return { spawned: false, pid };
38
+ }
39
+ // Spawn coordinator as detached background process
40
+ const coordinatorPath = path.join(__dirname, 'index.ts');
41
+ const child = spawn('bun', ['run', coordinatorPath, config.project, config.dataDir], {
42
+ detached: true,
43
+ stdio: 'ignore',
44
+ env: { ...process.env },
45
+ });
46
+ // Detach from parent
47
+ child.unref();
48
+ return { spawned: true, pid: child.pid };
49
+ }
50
+ /**
51
+ * Get coordinator status
52
+ */
53
+ export function getCoordinatorStatus(config) {
54
+ const pidPath = path.join(config.dataDir, 'coordinator.pid');
55
+ if (!fs.existsSync(pidPath)) {
56
+ return { running: false };
57
+ }
58
+ try {
59
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
60
+ process.kill(pid, 0);
61
+ return { running: true, pid };
62
+ }
63
+ catch {
64
+ return { running: false };
65
+ }
66
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { formatDate } from './formatDate';
3
+ import { formatTime } from './formatTime';
4
+ import { formatDatetime } from './formatDatetime';
5
+ import { now } from './now';
6
+ import { parseDatetime } from './parseDatetime';
7
+ import { isStale } from './isStale';
8
+ describe('formatDate', () => {
9
+ it('formats date as yyyy/mm/dd', () => {
10
+ const date = new Date('2024-03-15T10:30:00Z');
11
+ const formatted = formatDate(date);
12
+ expect(formatted).toMatch(/^\d{4}\/\d{2}\/\d{2}$/);
13
+ });
14
+ });
15
+ describe('formatTime', () => {
16
+ it('formats time as hh:mm:ss', () => {
17
+ const date = new Date('2024-03-15T10:30:45Z');
18
+ const formatted = formatTime(date);
19
+ expect(formatted).toMatch(/^\d{2}:\d{2}:\d{2}$/);
20
+ });
21
+ });
22
+ describe('formatDatetime', () => {
23
+ it('formats datetime with timezone', () => {
24
+ const date = new Date('2024-03-15T10:30:45Z');
25
+ const formatted = formatDatetime(date);
26
+ expect(formatted).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} [A-Z]{2,4}$/);
27
+ });
28
+ });
29
+ describe('now', () => {
30
+ it('returns current datetime string', () => {
31
+ const current = now();
32
+ expect(current).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} [A-Z]{2,4}$/);
33
+ });
34
+ });
35
+ describe('parseDatetime', () => {
36
+ it('parses formatted datetime string', () => {
37
+ const original = new Date();
38
+ const formatted = formatDatetime(original);
39
+ const parsed = parseDatetime(formatted);
40
+ expect(parsed).toBeInstanceOf(Date);
41
+ // Allow 1 second tolerance due to milliseconds being lost
42
+ expect(Math.abs(parsed.getTime() - original.getTime())).toBeLessThan(1000);
43
+ });
44
+ it('returns null for invalid string', () => {
45
+ expect(parseDatetime('invalid')).toBeNull();
46
+ expect(parseDatetime('')).toBeNull();
47
+ });
48
+ });
49
+ describe('isStale', () => {
50
+ it('returns true for old timestamps', () => {
51
+ const oldDate = new Date(Date.now() - 120000); // 2 minutes ago
52
+ const formatted = formatDatetime(oldDate);
53
+ expect(isStale(formatted, 60000)).toBe(true); // 1 minute threshold
54
+ });
55
+ it('returns false for recent timestamps', () => {
56
+ const recent = now();
57
+ expect(isStale(recent, 60000)).toBe(false);
58
+ });
59
+ it('returns true for null/undefined', () => {
60
+ expect(isStale(null, 60000)).toBe(true);
61
+ expect(isStale(undefined, 60000)).toBe(true);
62
+ });
63
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Format a Date as yyyy/mm/dd
3
+ *
4
+ * Example: 2025/01/15
5
+ */
6
+ export declare function formatDate(date?: Date): string;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Format a Date as yyyy/mm/dd
3
+ *
4
+ * Example: 2025/01/15
5
+ */
6
+ export function formatDate(date = new Date()) {
7
+ const year = date.getFullYear();
8
+ const month = String(date.getMonth() + 1).padStart(2, '0');
9
+ const day = String(date.getDate()).padStart(2, '0');
10
+ return `${year}/${month}/${day}`;
11
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Format a Date as yyyy/mm/dd hh:mm:ss TZ
3
+ *
4
+ * Example: 2025/01/15 09:14:32 PST
5
+ */
6
+ export declare function formatDatetime(date?: Date): string;
@@ -0,0 +1,12 @@
1
+ import { formatDate } from './formatDate';
2
+ import { formatTime } from './formatTime';
3
+ /**
4
+ * Format a Date as yyyy/mm/dd hh:mm:ss TZ
5
+ *
6
+ * Example: 2025/01/15 09:14:32 PST
7
+ */
8
+ export function formatDatetime(date = new Date()) {
9
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
10
+ const tzAbbrev = date.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
11
+ return `${formatDate(date)} ${formatTime(date)} ${tzAbbrev}`;
12
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Format a Date as hh:mm:ss
3
+ *
4
+ * Example: 09:14:32
5
+ */
6
+ export declare function formatTime(date?: Date): string;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Format a Date as hh:mm:ss
3
+ *
4
+ * Example: 09:14:32
5
+ */
6
+ export function formatTime(date = new Date()) {
7
+ const hours = String(date.getHours()).padStart(2, '0');
8
+ const minutes = String(date.getMinutes()).padStart(2, '0');
9
+ const seconds = String(date.getSeconds()).padStart(2, '0');
10
+ return `${hours}:${minutes}:${seconds}`;
11
+ }
@@ -0,0 +1,4 @@
1
+ export { formatDatetime } from './formatDatetime';
2
+ export { parseDatetime } from './parseDatetime';
3
+ export { now } from './now';
4
+ export { isStale } from './isStale';
@@ -0,0 +1,7 @@
1
+ // Formatting
2
+ export { formatDatetime } from './formatDatetime';
3
+ // Parsing
4
+ export { parseDatetime } from './parseDatetime';
5
+ // Helpers
6
+ export { now } from './now';
7
+ export { isStale } from './isStale';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Check if a timestamp is older than threshold
3
+ *
4
+ * Used for agent liveness checks
5
+ *
6
+ * @param timestamp - Datetime string (2025/01/15 09:14:32 PST) or null
7
+ * @param thresholdMs - Stale threshold in milliseconds (default 30 seconds)
8
+ * @returns true if stale, including when timestamp is null/undefined
9
+ */
10
+ export declare function isStale(timestamp: string | null | undefined, thresholdMs?: number): boolean;
@@ -0,0 +1,18 @@
1
+ import { parseDatetime } from './parseDatetime';
2
+ /**
3
+ * Check if a timestamp is older than threshold
4
+ *
5
+ * Used for agent liveness checks
6
+ *
7
+ * @param timestamp - Datetime string (2025/01/15 09:14:32 PST) or null
8
+ * @param thresholdMs - Stale threshold in milliseconds (default 30 seconds)
9
+ * @returns true if stale, including when timestamp is null/undefined
10
+ */
11
+ export function isStale(timestamp, thresholdMs = 30000) {
12
+ const then = parseDatetime(timestamp);
13
+ if (!then) {
14
+ return true;
15
+ }
16
+ const now = new Date();
17
+ return now.getTime() - then.getTime() > thresholdMs;
18
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Get current timestamp
3
+ *
4
+ * Example: 2025/01/15 09:14:32 PST
5
+ */
6
+ export declare function now(): string;
@@ -0,0 +1,9 @@
1
+ import { formatDatetime } from './formatDatetime';
2
+ /**
3
+ * Get current timestamp
4
+ *
5
+ * Example: 2025/01/15 09:14:32 PST
6
+ */
7
+ export function now() {
8
+ return formatDatetime(new Date());
9
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Parse a datetime string to Date
3
+ *
4
+ * Input: 2025/01/15 09:14:32 PST
5
+ * Returns: Date object or null if invalid
6
+ */
7
+ export declare function parseDatetime(datetimeStr: string | null | undefined): Date | null;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Parse a datetime string to Date
3
+ *
4
+ * Input: 2025/01/15 09:14:32 PST
5
+ * Returns: Date object or null if invalid
6
+ */
7
+ export function parseDatetime(datetimeStr) {
8
+ if (!datetimeStr) {
9
+ return null;
10
+ }
11
+ try {
12
+ // Remove timezone abbreviation for parsing
13
+ const withoutTz = datetimeStr.replace(/\s+[A-Z]{3,4}$/, '');
14
+ const [datePart, timePart] = withoutTz.split(' ');
15
+ if (!datePart || !timePart) {
16
+ return null;
17
+ }
18
+ const [year, month, day] = datePart.split('/').map(Number);
19
+ const [hours, minutes, seconds] = timePart.split(':').map(Number);
20
+ if (isNaN(year) || isNaN(month) || isNaN(day)) {
21
+ return null;
22
+ }
23
+ return new Date(year, month - 1, day, hours, minutes, seconds);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Base hivemind directory: ~/.hivemind
3
+ */
4
+ export declare const HIVEMIND_BASE: string;