@inixiative/hivemind 0.1.7 → 0.1.8

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 (73) hide show
  1. package/README.md +67 -6
  2. package/dist/agents/agents.test.js +79 -0
  3. package/dist/agents/getAgentByPid.d.ts +7 -0
  4. package/dist/agents/getAgentByPid.js +13 -0
  5. package/dist/agents/index.d.ts +4 -0
  6. package/dist/agents/index.js +4 -0
  7. package/dist/agents/livenessMode.d.ts +2 -0
  8. package/dist/agents/livenessMode.js +13 -0
  9. package/dist/agents/registerAgent.js +4 -3
  10. package/dist/agents/touchAgent.d.ts +5 -0
  11. package/dist/agents/touchAgent.js +13 -0
  12. package/dist/agents/types.d.ts +1 -0
  13. package/dist/agents/updateAgentSession.d.ts +6 -0
  14. package/dist/agents/updateAgentSession.js +12 -0
  15. package/dist/cli/init.js +13 -5
  16. package/dist/cli/install.d.ts +1 -0
  17. package/dist/cli/install.js +9 -5
  18. package/dist/cli/registerMcp.js +4 -3
  19. package/dist/coordinator/index.js +32 -0
  20. package/dist/db/db.test.js +4 -3
  21. package/dist/db/getConnection.js +2 -0
  22. package/dist/db/initializeDb.js +2 -0
  23. package/dist/db/migrateDb.d.ts +6 -0
  24. package/dist/db/migrateDb.js +14 -0
  25. package/dist/db/schema.sql +134 -0
  26. package/dist/git/getBranch.d.ts +1 -1
  27. package/dist/git/getBranch.js +2 -2
  28. package/dist/git/getCurrentWorktree.d.ts +2 -2
  29. package/dist/git/getCurrentWorktree.js +6 -6
  30. package/dist/git/getGitInfo.d.ts +2 -2
  31. package/dist/git/getGitInfo.js +6 -6
  32. package/dist/git/getRepoName.d.ts +1 -1
  33. package/dist/git/getRepoName.js +3 -3
  34. package/dist/git/getRepoRoot.d.ts +1 -1
  35. package/dist/git/getRepoRoot.js +2 -2
  36. package/dist/git/getWorktrees.d.ts +1 -1
  37. package/dist/git/getWorktrees.js +2 -2
  38. package/dist/git/isGitRepo.d.ts +2 -2
  39. package/dist/git/isGitRepo.js +3 -3
  40. package/dist/hooks/sessionStart.d.ts +1 -1
  41. package/dist/hooks/sessionStart.js +77 -13
  42. package/dist/init/claudeConfig.d.ts +13 -3
  43. package/dist/init/claudeConfig.js +71 -29
  44. package/dist/mcp/core.d.ts +157 -0
  45. package/dist/mcp/core.js +78 -0
  46. package/dist/mcp/httpServer.d.ts +5 -0
  47. package/dist/mcp/httpServer.js +142 -0
  48. package/dist/mcp/httpServer.test.d.ts +1 -0
  49. package/dist/mcp/httpServer.test.js +164 -0
  50. package/dist/mcp/server.js +2 -93
  51. package/dist/mcp/tools/emitEvent.d.ts +1 -0
  52. package/dist/mcp/tools/emitEvent.js +20 -5
  53. package/dist/mcp/tools/events.d.ts +10 -0
  54. package/dist/mcp/tools/events.js +13 -0
  55. package/dist/mcp/tools/query.d.ts +5 -0
  56. package/dist/mcp/tools/query.js +15 -0
  57. package/dist/mcp/tools/register.d.ts +1 -0
  58. package/dist/mcp/tools/register.js +46 -24
  59. package/dist/mcp/tools/status.d.ts +35 -4
  60. package/dist/mcp/tools/status.js +103 -13
  61. package/dist/mcp/tools/status.test.d.ts +1 -0
  62. package/dist/mcp/tools/status.test.js +93 -0
  63. package/dist/mcp/tools/tasks.js +4 -0
  64. package/dist/test/factories/index.d.ts +1 -0
  65. package/dist/test/factories/index.js +1 -0
  66. package/dist/test/factories/worktreeFactory.d.ts +11 -0
  67. package/dist/test/factories/worktreeFactory.js +15 -0
  68. package/dist/test/setup.d.ts +1 -1
  69. package/dist/test/setup.js +30 -28
  70. package/dist/watcher/planWatcher.js +18 -0
  71. package/dist/worktrees/syncWorktreesFromGit.d.ts +1 -1
  72. package/dist/worktrees/syncWorktreesFromGit.js +2 -2
  73. package/package.json +2 -1
package/README.md CHANGED
@@ -49,11 +49,11 @@ This info goes into Claude's system context (not printed to terminal). Claude kn
49
49
 
50
50
  ## How It Works
51
51
 
52
- Agents are tracked by **process ID (PID)**, not heartbeats:
53
- - SessionStart hook registers agent with Claude's PID
54
- - Coordinator monitors PIDs every 30 seconds
55
- - When Claude exits, coordinator detects dead PID and marks agent dead
56
- - No polling or heartbeats required
52
+ Hivemind supports two lifecycle modes:
53
+ - Local mode (stdio): agents are tracked by **PID** and the coordinator marks agents dead when their process exits.
54
+ - Network mode (HTTP): agents are tracked by **lease heartbeat** (`last_seen_at`) and the coordinator marks agents dead when lease TTL expires.
55
+
56
+ Session start registers the agent, then status/events/query/task/emit calls refresh liveness in network mode.
57
57
 
58
58
  See [ARCHITECTURE.md](ARCHITECTURE.md) for details.
59
59
 
@@ -70,11 +70,72 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for details.
70
70
  | `hivemind_worktree_cleanup` | Clean up stale worktrees | ✓ |
71
71
  | `hivemind_setup` | Initialize project | ✓ |
72
72
  | `hivemind_register` | Register this agent | ✓ |
73
- | `hivemind_emit` | Emit event to log | |
73
+ | `hivemind_emit` | Emit event to log | |
74
74
  | `hivemind_reset` | Reset database | ✗ |
75
75
 
76
76
  Tools marked ✓ are auto-approved after `./setup.sh`. Tools marked ✗ require confirmation.
77
77
 
78
+ ## Server Modes
79
+
80
+ ### Local mode (default, stdio)
81
+
82
+ Use this for single-machine/local Claude sessions:
83
+
84
+ ```bash
85
+ bun run mcp
86
+ ```
87
+
88
+ This preserves the existing MCP stdio behavior and local PID-based lifecycle.
89
+
90
+ ### Network mode (shared MCP over HTTP)
91
+
92
+ Use this when multiple containers/processes need to share one hivemind:
93
+
94
+ ```bash
95
+ HIVEMIND_API_TOKEN=replace-me \
96
+ PORT=8787 \
97
+ bun run mcp:http
98
+ ```
99
+
100
+ The HTTP endpoint is:
101
+
102
+ ```text
103
+ http://0.0.0.0:$PORT/mcp
104
+ ```
105
+
106
+ Network mode requires:
107
+ - `Authorization: Bearer <HIVEMIND_API_TOKEN>` on MCP HTTP requests
108
+ - Lease/heartbeat lifecycle (`last_seen_at`) with TTL sweeping (no PID liveness reliance)
109
+
110
+ ## Environment Variables
111
+
112
+ - `HIVEMIND_API_TOKEN`: required for `bun run mcp:http`
113
+ - `PORT`: HTTP port for network mode (default `8787`)
114
+ - `HIVEMIND_REMOTE_URL`: when set in hook/agent environments, session-start register/status calls go to remote MCP instead of local DB logic
115
+ - `HIVEMIND_AGENT_LEASE_TTL_SECONDS`: lease TTL for stale-agent sweep in network mode (default `180`)
116
+ - `HIVEMIND_NETWORK_MODE`: liveness mode toggle (`1/true` enables lease-based mode). `mcp:http` enables this automatically.
117
+ - `HIVEMIND_BASE`: base directory for project DB/state (defaults to `~/.hivemind`)
118
+
119
+ ## Container-to-Container Example
120
+
121
+ Shared hivemind service container:
122
+
123
+ ```bash
124
+ docker run --rm -p 8787:8787 \
125
+ -e HIVEMIND_API_TOKEN=supersecret \
126
+ ghcr.io/inixiative/hivemind:latest \
127
+ bun run mcp:http
128
+ ```
129
+
130
+ Implementer/subject/oracle containers (each agent):
131
+
132
+ ```bash
133
+ export HIVEMIND_REMOTE_URL=http://hivemind:8787/mcp
134
+ export HIVEMIND_API_TOKEN=supersecret
135
+ ```
136
+
137
+ With `HIVEMIND_REMOTE_URL` set, `src/hooks/sessionStart.ts` registers and fetches status through the remote service, enabling shared coordination across isolated containers.
138
+
78
139
  ## CLI
79
140
 
80
141
  ```bash
@@ -4,10 +4,13 @@ import { buildAgent, buildTask } from '../test/factories';
4
4
  import { registerAgent } from './registerAgent';
5
5
  import { getAgent } from './getAgent';
6
6
  import { getActiveAgents } from './getActiveAgents';
7
+ import { getAgentByPid } from './getAgentByPid';
8
+ import { getAgentBySessionId } from './getAgentBySessionId';
7
9
  import { markAgentDead } from './markAgentDead';
8
10
  import { markAgentIdle } from './markAgentIdle';
9
11
  import { updateAgentContext } from './updateAgentContext';
10
12
  import { updateAgentTask } from './updateAgentTask';
13
+ import { updateAgentSession } from './updateAgentSession';
11
14
  import { unregisterAgent } from './unregisterAgent';
12
15
  describe('Agents Module', () => {
13
16
  let testDb;
@@ -164,4 +167,80 @@ describe('Agents Module', () => {
164
167
  expect(retrieved).toBeNull();
165
168
  });
166
169
  });
170
+ describe('getAgentByPid', () => {
171
+ it('finds active agent by PID', () => {
172
+ const { agent } = buildAgent(testDb.db, { pid: 12345 });
173
+ const found = getAgentByPid(testDb.db, 12345);
174
+ expect(found).toBeDefined();
175
+ expect(found.id).toBe(agent.id);
176
+ expect(found.pid).toBe(12345);
177
+ });
178
+ it('returns null when PID not found', () => {
179
+ buildAgent(testDb.db, { pid: 12345 });
180
+ const found = getAgentByPid(testDb.db, 99999);
181
+ expect(found).toBeNull();
182
+ });
183
+ it('only returns active agents', () => {
184
+ const { agent } = buildAgent(testDb.db, { pid: 12345 });
185
+ markAgentDead(testDb.db, agent.id);
186
+ const found = getAgentByPid(testDb.db, 12345);
187
+ expect(found).toBeNull();
188
+ });
189
+ it('returns most recent agent when multiple have same PID', () => {
190
+ buildAgent(testDb.db, { pid: 12345, label: 'old' });
191
+ const { agent: newer } = buildAgent(testDb.db, { pid: 12345, label: 'new' });
192
+ const found = getAgentByPid(testDb.db, 12345);
193
+ expect(found.id).toBe(newer.id);
194
+ });
195
+ });
196
+ describe('getAgentBySessionId', () => {
197
+ it('finds agent by session ID', () => {
198
+ const { agent } = buildAgent(testDb.db, { session_id: 'session-abc-123' });
199
+ const found = getAgentBySessionId(testDb.db, 'session-abc-123');
200
+ expect(found).toBeDefined();
201
+ expect(found.id).toBe(agent.id);
202
+ });
203
+ it('returns null when session not found', () => {
204
+ buildAgent(testDb.db, { session_id: 'session-abc-123' });
205
+ const found = getAgentBySessionId(testDb.db, 'different-session');
206
+ expect(found).toBeNull();
207
+ });
208
+ });
209
+ describe('updateAgentSession', () => {
210
+ it('updates session ID for existing agent', () => {
211
+ const { agent } = buildAgent(testDb.db, { session_id: 'old-session' });
212
+ updateAgentSession(testDb.db, agent.id, 'new-session');
213
+ const retrieved = getAgent(testDb.db, agent.id);
214
+ expect(retrieved.session_id).toBe('new-session');
215
+ });
216
+ it('allows lookup by new session ID after update', () => {
217
+ const { agent } = buildAgent(testDb.db, { session_id: 'old-session' });
218
+ updateAgentSession(testDb.db, agent.id, 'new-session');
219
+ // Old session no longer works
220
+ expect(getAgentBySessionId(testDb.db, 'old-session')).toBeNull();
221
+ // New session works
222
+ const found = getAgentBySessionId(testDb.db, 'new-session');
223
+ expect(found).toBeDefined();
224
+ expect(found.id).toBe(agent.id);
225
+ });
226
+ });
227
+ describe('compaction resilience', () => {
228
+ it('supports reconnecting agent with new session ID via PID', () => {
229
+ // Agent registers with original session
230
+ const { agent } = buildAgent(testDb.db, {
231
+ pid: 54321,
232
+ session_id: 'original-session',
233
+ });
234
+ // Simulate compaction: lookup by PID, update session
235
+ const existingAgent = getAgentByPid(testDb.db, 54321);
236
+ expect(existingAgent).toBeDefined();
237
+ expect(existingAgent.id).toBe(agent.id);
238
+ // Update to new session ID
239
+ updateAgentSession(testDb.db, agent.id, 'compacted-session');
240
+ // Now agent can be found by new session
241
+ const found = getAgentBySessionId(testDb.db, 'compacted-session');
242
+ expect(found).toBeDefined();
243
+ expect(found.id).toBe(agent.id);
244
+ });
245
+ });
167
246
  });
@@ -0,0 +1,7 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { Agent } from './types';
3
+ /**
4
+ * Find an active agent by PID
5
+ * Used to reconnect agents after session changes (e.g., compaction)
6
+ */
7
+ export declare function getAgentByPid(db: Database, pid: number): Agent | null;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Find an active agent by PID
3
+ * Used to reconnect agents after session changes (e.g., compaction)
4
+ */
5
+ export function getAgentByPid(db, pid) {
6
+ const stmt = db.prepare(`
7
+ SELECT * FROM agents
8
+ WHERE pid = ? AND status = 'active'
9
+ ORDER BY rowid DESC
10
+ LIMIT 1
11
+ `);
12
+ return stmt.get(pid);
13
+ }
@@ -3,8 +3,12 @@ export { registerAgent } from './registerAgent';
3
3
  export { unregisterAgent } from './unregisterAgent';
4
4
  export { getAgent } from './getAgent';
5
5
  export { getActiveAgents } from './getActiveAgents';
6
+ export { getAgentByPid } from './getAgentByPid';
7
+ export { getAgentBySessionId } from './getAgentBySessionId';
6
8
  export { updateAgentContext } from './updateAgentContext';
7
9
  export { updateAgentTask } from './updateAgentTask';
8
10
  export { updateAgentWorktree } from './updateAgentWorktree';
11
+ export { updateAgentSession } from './updateAgentSession';
12
+ export { touchAgent } from './touchAgent';
9
13
  export { markAgentDead } from './markAgentDead';
10
14
  export { markAgentIdle } from './markAgentIdle';
@@ -4,9 +4,13 @@ export { unregisterAgent } from './unregisterAgent';
4
4
  // Query
5
5
  export { getAgent } from './getAgent';
6
6
  export { getActiveAgents } from './getActiveAgents';
7
+ export { getAgentByPid } from './getAgentByPid';
8
+ export { getAgentBySessionId } from './getAgentBySessionId';
7
9
  // Updates
8
10
  export { updateAgentContext } from './updateAgentContext';
9
11
  export { updateAgentTask } from './updateAgentTask';
10
12
  export { updateAgentWorktree } from './updateAgentWorktree';
13
+ export { updateAgentSession } from './updateAgentSession';
14
+ export { touchAgent } from './touchAgent';
11
15
  export { markAgentDead } from './markAgentDead';
12
16
  export { markAgentIdle } from './markAgentIdle';
@@ -0,0 +1,2 @@
1
+ export declare function isNetworkLivenessMode(): boolean;
2
+ export declare function getAgentLeaseTtlMs(): number;
@@ -0,0 +1,13 @@
1
+ const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
2
+ export function isNetworkLivenessMode() {
3
+ const value = process.env.HIVEMIND_NETWORK_MODE?.trim().toLowerCase();
4
+ return value ? TRUE_VALUES.has(value) : false;
5
+ }
6
+ export function getAgentLeaseTtlMs() {
7
+ const raw = process.env.HIVEMIND_AGENT_LEASE_TTL_SECONDS;
8
+ const seconds = raw ? Number(raw) : 180;
9
+ if (!Number.isFinite(seconds) || seconds <= 0) {
10
+ return 180_000;
11
+ }
12
+ return Math.floor(seconds * 1000);
13
+ }
@@ -9,10 +9,10 @@ export function registerAgent(db, input = {}) {
9
9
  const parsed = parseId(id);
10
10
  const timestamp = now();
11
11
  const stmt = db.prepare(`
12
- INSERT INTO agents (id, hex, label, status, pid, session_id, worktree_id, context_summary, created_at)
13
- VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?)
12
+ INSERT INTO agents (id, hex, label, status, pid, session_id, last_seen_at, worktree_id, context_summary, created_at)
13
+ VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
14
14
  `);
15
- stmt.run(id, parsed.hex, parsed.label ?? null, input.pid ?? null, input.session_id ?? null, input.worktree_id ?? null, input.context_summary ?? null, timestamp);
15
+ stmt.run(id, parsed.hex, parsed.label ?? null, input.pid ?? null, input.session_id ?? null, timestamp, input.worktree_id ?? null, input.context_summary ?? null, timestamp);
16
16
  return {
17
17
  id,
18
18
  hex: parsed.hex,
@@ -20,6 +20,7 @@ export function registerAgent(db, input = {}) {
20
20
  status: 'active',
21
21
  pid: input.pid ?? null,
22
22
  session_id: input.session_id ?? null,
23
+ last_seen_at: timestamp,
23
24
  current_plan_id: null,
24
25
  current_task_id: null,
25
26
  worktree_id: input.worktree_id ?? null,
@@ -0,0 +1,5 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ /**
3
+ * Heartbeat/lease update for an active agent.
4
+ */
5
+ export declare function touchAgent(db: Database, agentId: string): boolean;
@@ -0,0 +1,13 @@
1
+ import { now } from '../datetime/now';
2
+ /**
3
+ * Heartbeat/lease update for an active agent.
4
+ */
5
+ export function touchAgent(db, agentId) {
6
+ const stmt = db.prepare(`
7
+ UPDATE agents
8
+ SET last_seen_at = ?, status = 'active'
9
+ WHERE id = ? AND status != 'dead'
10
+ `);
11
+ const result = stmt.run(now(), agentId);
12
+ return result.changes > 0;
13
+ }
@@ -12,6 +12,7 @@ export type Agent = {
12
12
  status: AgentStatus;
13
13
  pid: number | null;
14
14
  session_id: string | null;
15
+ last_seen_at: string | null;
15
16
  current_plan_id: string | null;
16
17
  current_task_id: string | null;
17
18
  worktree_id: string | null;
@@ -0,0 +1,6 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ /**
3
+ * Update an agent's session ID
4
+ * Called when conversation compacts and gets a new session ID
5
+ */
6
+ export declare function updateAgentSession(db: Database, agentId: string, sessionId: string): void;
@@ -0,0 +1,12 @@
1
+ import { now } from '../datetime/now';
2
+ /**
3
+ * Update an agent's session ID
4
+ * Called when conversation compacts and gets a new session ID
5
+ */
6
+ export function updateAgentSession(db, agentId, sessionId) {
7
+ const timestamp = now();
8
+ const stmt = db.prepare(`
9
+ UPDATE agents SET session_id = ?, last_seen_at = ? WHERE id = ?
10
+ `);
11
+ stmt.run(sessionId, timestamp, agentId);
12
+ }
package/dist/cli/init.js CHANGED
@@ -12,6 +12,7 @@ import { getGitInfo } from '../git/getGitInfo';
12
12
  import { initializeDb } from '../db/initializeDb';
13
13
  import { getProjectPaths } from '../db/getProjectPaths';
14
14
  import { initClaudeConfig } from '../init/claudeConfig';
15
+ import { isMcpRegistered } from './registerMcp';
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const HIVEMIND_ROOT = join(__dirname, '../..');
17
18
  export async function initCommand(options) {
@@ -61,11 +62,18 @@ export async function initCommand(options) {
61
62
  else {
62
63
  console.log('\n[2/2] Claude Config (skipped)');
63
64
  }
65
+ // Check if MCP is set up globally
66
+ const mcpReady = await isMcpRegistered();
64
67
  // Success
65
68
  console.log('\n---');
66
- console.log('Project registered! Restart Claude Code to activate.');
67
- console.log('\nNext session will:');
68
- console.log(' - Auto-register as a hivemind agent');
69
- console.log(' - Have hivemind MCP tools available');
70
- console.log(' - Follow 001-style task numbering');
69
+ if (mcpReady) {
70
+ console.log('Project registered! Restart Claude Code to activate.');
71
+ console.log('\nNext session will:');
72
+ console.log(' - Auto-register as a hivemind agent');
73
+ console.log(' - Have hivemind MCP tools available');
74
+ }
75
+ else {
76
+ console.log('Project registered, but MCP not set up globally.');
77
+ console.log('\nRun `hivemind install` first to set up MCP tools.');
78
+ }
71
79
  }
@@ -4,5 +4,6 @@
4
4
  * Run from hivemind directory to:
5
5
  * 1. Install dependencies
6
6
  * 2. Register MCP server globally
7
+ * 3. Configure permissions
7
8
  */
8
9
  export declare function installCommand(): Promise<void>;
@@ -4,6 +4,7 @@
4
4
  * Run from hivemind directory to:
5
5
  * 1. Install dependencies
6
6
  * 2. Register MCP server globally
7
+ * 3. Configure permissions
7
8
  */
8
9
  import { dirname, join } from 'path';
9
10
  import { fileURLToPath } from 'url';
@@ -11,6 +12,7 @@ import { $ } from 'bun';
11
12
  import { registerMcpServer, configurePermissions } from './registerMcp';
12
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
14
  const HIVEMIND_ROOT = join(__dirname, '../..');
15
+ const CLI_ENTRY = join(HIVEMIND_ROOT, 'src/cli.ts');
14
16
  export async function installCommand() {
15
17
  console.log('hivemind install\n');
16
18
  // 1. Install dependencies
@@ -35,13 +37,15 @@ export async function installCommand() {
35
37
  const permResult = configurePermissions();
36
38
  console.log(` ${permResult.message}`);
37
39
  if (permResult.added.length > 0) {
38
- console.log(' auto-approved: status, events, query, claim/start/complete task');
39
- console.log(' requires approval: emit (ad-hoc messages)');
40
+ console.log(' auto-approved: status, events, query, emit, claim/start/complete task');
41
+ console.log(' requires approval: reset (destructive)');
40
42
  }
41
43
  // Success
42
44
  console.log('\n---');
43
- console.log('Hivemind installed globally!');
44
- console.log('\nNext steps:');
45
+ console.log('Hivemind installed!');
46
+ console.log('\nAdd this to your shell profile (~/.zshrc or ~/.bashrc):');
47
+ console.log(`\n alias hivemind='bun run ${CLI_ENTRY}'`);
48
+ console.log('\nThen:');
45
49
  console.log(' cd <your-project>');
46
- console.log(' bun run /path/to/hivemind init');
50
+ console.log(' hivemind init');
47
51
  }
@@ -17,6 +17,8 @@ const AUTO_APPROVED_TOOLS = [
17
17
  'mcp__hivemind__hivemind_status',
18
18
  'mcp__hivemind__hivemind_events',
19
19
  'mcp__hivemind__hivemind_query',
20
+ // Event logging
21
+ 'mcp__hivemind__hivemind_emit',
20
22
  // Task management (structured operations)
21
23
  'mcp__hivemind__hivemind_claim_task',
22
24
  'mcp__hivemind__hivemind_start_task',
@@ -25,7 +27,6 @@ const AUTO_APPROVED_TOOLS = [
25
27
  'mcp__hivemind__hivemind_worktree_cleanup',
26
28
  ];
27
29
  // NOT auto-approved (require human oversight):
28
- // - hivemind_emit: allows arbitrary messages to other agents
29
30
  // - hivemind_reset: destructive operation that wipes the database
30
31
  /**
31
32
  * Register the hivemind MCP server using `claude mcp add`
@@ -43,8 +44,8 @@ export async function registerMcpServer(hivemindRoot) {
43
44
  alreadyRegistered: true,
44
45
  };
45
46
  }
46
- // Register the MCP server
47
- await $ `claude mcp add --transport stdio hivemind -- ${command}`.quiet();
47
+ // Register the MCP server globally (user scope = available in all projects)
48
+ await $ `claude mcp add --transport stdio --scope user hivemind -- bun run ${serverPath}`.quiet();
48
49
  return {
49
50
  success: true,
50
51
  message: `registered: hivemind -> ${command}`,
@@ -13,6 +13,7 @@ import { getActiveAgents } from '../agents/getActiveAgents';
13
13
  import { markAgentDead } from '../agents/markAgentDead';
14
14
  import { startPlanWatcher } from '../watcher/planWatcher';
15
15
  import { emit } from '../events/emit';
16
+ import { getAgentLeaseTtlMs, isNetworkLivenessMode } from '../agents/livenessMode';
16
17
  import * as fs from 'fs';
17
18
  import * as path from 'path';
18
19
  import { parseDatetime } from '../datetime/parseDatetime';
@@ -39,6 +40,13 @@ function isOldEnough(createdAt) {
39
40
  return false;
40
41
  return Date.now() - created.getTime() > MIN_AGE_MS;
41
42
  }
43
+ function isLeaseExpired(lastSeenAt, createdAt, ttlMs) {
44
+ const source = lastSeenAt ?? createdAt;
45
+ const seen = parseDatetime(source);
46
+ if (!seen)
47
+ return false;
48
+ return Date.now() - seen.getTime() > ttlMs;
49
+ }
42
50
  function getLockPath(config) {
43
51
  return path.join(config.dataDir, 'coordinator.lock');
44
52
  }
@@ -89,10 +97,34 @@ function releaseLock(config) {
89
97
  }
90
98
  function sweep(config) {
91
99
  const db = getConnection(config.project);
100
+ const networkMode = isNetworkLivenessMode();
101
+ const leaseTtlMs = getAgentLeaseTtlMs();
92
102
  // Get all active agents and check if their PIDs are alive
93
103
  const activeAgents = getActiveAgents(db);
94
104
  let marked = 0;
95
105
  for (const agent of activeAgents) {
106
+ if (networkMode) {
107
+ if (!isLeaseExpired(agent.last_seen_at, agent.created_at, leaseTtlMs)) {
108
+ continue;
109
+ }
110
+ markAgentDead(db, agent.id);
111
+ emit(db, {
112
+ type: 'agent:dead',
113
+ agent_id: agent.id,
114
+ worktree_id: agent.worktree_id ?? undefined,
115
+ content: `Agent ${agent.id} lease expired`,
116
+ metadata: {
117
+ reason: 'lease_expired',
118
+ last_seen_at: agent.last_seen_at,
119
+ lease_ttl_ms: leaseTtlMs,
120
+ label: agent.label,
121
+ current_task_id: agent.current_task_id,
122
+ current_plan_id: agent.current_plan_id,
123
+ },
124
+ });
125
+ marked++;
126
+ continue;
127
+ }
96
128
  // Skip agents that are too new (grace period)
97
129
  if (!isOldEnough(agent.created_at)) {
98
130
  continue;
@@ -33,10 +33,11 @@ describe('SQLite Database', () => {
33
33
  expect(indexes.length).toBeGreaterThan(0);
34
34
  });
35
35
  });
36
- describe('WAL mode', () => {
37
- it('uses WAL journal mode', () => {
36
+ describe('journal mode', () => {
37
+ it('uses memory journal mode for in-memory test db', () => {
38
38
  const result = testDb.db.query('PRAGMA journal_mode').get();
39
- expect(result.journal_mode).toBe('wal');
39
+ // In-memory databases use 'memory' journal mode
40
+ expect(result.journal_mode).toBe('memory');
40
41
  });
41
42
  });
42
43
  describe('basic CRUD operations', () => {
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
2
2
  import { existsSync } from 'fs';
3
3
  import { getProjectPaths } from './getProjectPaths';
4
4
  import { initializeDb } from './initializeDb';
5
+ import { migrateDb } from './migrateDb';
5
6
  const connections = new Map();
6
7
  /**
7
8
  * Get database connection for a project
@@ -17,6 +18,7 @@ export function getConnection(projectName) {
17
18
  if (existsSync(paths.dbPath)) {
18
19
  db = new Database(paths.dbPath);
19
20
  db.exec('PRAGMA journal_mode = WAL');
21
+ migrateDb(db);
20
22
  }
21
23
  else {
22
24
  db = initializeDb(projectName);
@@ -4,6 +4,7 @@ import { join, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { getProjectPaths } from './getProjectPaths';
6
6
  import { ensureProjectDirs } from './ensureProjectDirs';
7
+ import { migrateDb } from './migrateDb';
7
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
9
  /**
9
10
  * Initialize the database for a project
@@ -19,5 +20,6 @@ export function initializeDb(projectName) {
19
20
  // Run schema
20
21
  const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
21
22
  db.exec(schema);
23
+ migrateDb(db);
22
24
  return db;
23
25
  }
@@ -0,0 +1,6 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ /**
3
+ * Lightweight compatibility migrations for existing project databases.
4
+ * Keep this idempotent and safe to run on every connection open.
5
+ */
6
+ export declare function migrateDb(db: Database): void;
@@ -0,0 +1,14 @@
1
+ function hasColumn(db, table, column) {
2
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all();
3
+ return rows.some((row) => row.name === column);
4
+ }
5
+ /**
6
+ * Lightweight compatibility migrations for existing project databases.
7
+ * Keep this idempotent and safe to run on every connection open.
8
+ */
9
+ export function migrateDb(db) {
10
+ if (!hasColumn(db, 'agents', 'last_seen_at')) {
11
+ db.exec('ALTER TABLE agents ADD COLUMN last_seen_at TEXT');
12
+ }
13
+ db.exec('CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen_at)');
14
+ }