swarmroom 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.
- package/bin/swarmroom.js +2 -0
- package/dist/commands/agents.d.ts +2 -0
- package/dist/commands/agents.js +71 -0
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +302 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +165 -0
- package/dist/commands/start.d.ts +2 -0
- package/dist/commands/start.js +134 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +58 -0
- package/dist/configurators/__tests__/configurators.test.d.ts +1 -0
- package/dist/configurators/__tests__/configurators.test.js +102 -0
- package/dist/configurators/claude-code.d.ts +5 -0
- package/dist/configurators/claude-code.js +60 -0
- package/dist/configurators/gemini-cli.d.ts +5 -0
- package/dist/configurators/gemini-cli.js +61 -0
- package/dist/configurators/index.d.ts +18 -0
- package/dist/configurators/index.js +21 -0
- package/dist/configurators/opencode.d.ts +5 -0
- package/dist/configurators/opencode.js +60 -0
- package/dist/daemon/__tests__/config.test.d.ts +1 -0
- package/dist/daemon/__tests__/config.test.js +125 -0
- package/dist/daemon/__tests__/process-detector.test.d.ts +1 -0
- package/dist/daemon/__tests__/process-detector.test.js +77 -0
- package/dist/daemon/__tests__/watcher.test.d.ts +1 -0
- package/dist/daemon/__tests__/watcher.test.js +305 -0
- package/dist/daemon/config.d.ts +26 -0
- package/dist/daemon/config.js +89 -0
- package/dist/daemon/process-detector.d.ts +21 -0
- package/dist/daemon/process-detector.js +59 -0
- package/dist/daemon/watcher.d.ts +38 -0
- package/dist/daemon/watcher.js +225 -0
- package/dist/detectors/__tests__/detectors.test.d.ts +1 -0
- package/dist/detectors/__tests__/detectors.test.js +105 -0
- package/dist/detectors/claude-code.d.ts +7 -0
- package/dist/detectors/claude-code.js +39 -0
- package/dist/detectors/gemini-cli.d.ts +6 -0
- package/dist/detectors/gemini-cli.js +27 -0
- package/dist/detectors/index.d.ts +15 -0
- package/dist/detectors/index.js +12 -0
- package/dist/detectors/opencode.d.ts +6 -0
- package/dist/detectors/opencode.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +17 -0
- package/dist/utils/display.d.ts +10 -0
- package/dist/utils/display.js +78 -0
- package/package.json +38 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('node:child_process', () => ({
|
|
3
|
+
execSync: vi.fn(),
|
|
4
|
+
}));
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { isProcessRunning, isClaudeCodeRunning, isOpenCodeRunning, isGeminiCliRunning, isAgentProcessRunning, AGENT_PROCESS_DETECTORS, } from '../process-detector.js';
|
|
7
|
+
const mockedExecSync = vi.mocked(execSync);
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.clearAllMocks();
|
|
10
|
+
});
|
|
11
|
+
describe('isProcessRunning', () => {
|
|
12
|
+
it('returns true when pgrep succeeds (process found)', () => {
|
|
13
|
+
mockedExecSync.mockReturnValue(Buffer.from('12345'));
|
|
14
|
+
expect(isProcessRunning('claude')).toBe(true);
|
|
15
|
+
expect(mockedExecSync).toHaveBeenCalledWith('pgrep -f "claude"', {
|
|
16
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
it('returns false when pgrep throws (process not found)', () => {
|
|
20
|
+
mockedExecSync.mockImplementation(() => {
|
|
21
|
+
throw new Error('exit code 1');
|
|
22
|
+
});
|
|
23
|
+
expect(isProcessRunning('claude')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('isClaudeCodeRunning', () => {
|
|
27
|
+
it('checks for "claude" process', () => {
|
|
28
|
+
mockedExecSync.mockReturnValue(Buffer.from('12345'));
|
|
29
|
+
const result = isClaudeCodeRunning();
|
|
30
|
+
expect(result).toBe(true);
|
|
31
|
+
expect(mockedExecSync).toHaveBeenCalledWith('pgrep -f "claude"', expect.any(Object));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('isOpenCodeRunning', () => {
|
|
35
|
+
it('checks for "opencode" process', () => {
|
|
36
|
+
mockedExecSync.mockReturnValue(Buffer.from('12345'));
|
|
37
|
+
const result = isOpenCodeRunning();
|
|
38
|
+
expect(result).toBe(true);
|
|
39
|
+
expect(mockedExecSync).toHaveBeenCalledWith('pgrep -f "opencode"', expect.any(Object));
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('isGeminiCliRunning', () => {
|
|
43
|
+
it('checks for "gemini" process', () => {
|
|
44
|
+
mockedExecSync.mockReturnValue(Buffer.from('12345'));
|
|
45
|
+
const result = isGeminiCliRunning();
|
|
46
|
+
expect(result).toBe(true);
|
|
47
|
+
expect(mockedExecSync).toHaveBeenCalledWith('pgrep -f "gemini"', expect.any(Object));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('AGENT_PROCESS_DETECTORS', () => {
|
|
51
|
+
it('has entries for claude-code, opencode, and gemini-cli', () => {
|
|
52
|
+
expect(Object.keys(AGENT_PROCESS_DETECTORS)).toEqual(expect.arrayContaining(['claude-code', 'opencode', 'gemini-cli']));
|
|
53
|
+
expect(Object.keys(AGENT_PROCESS_DETECTORS)).toHaveLength(3);
|
|
54
|
+
});
|
|
55
|
+
it('maps to the correct detector functions', () => {
|
|
56
|
+
expect(AGENT_PROCESS_DETECTORS['claude-code']).toBe(isClaudeCodeRunning);
|
|
57
|
+
expect(AGENT_PROCESS_DETECTORS['opencode']).toBe(isOpenCodeRunning);
|
|
58
|
+
expect(AGENT_PROCESS_DETECTORS['gemini-cli']).toBe(isGeminiCliRunning);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('isAgentProcessRunning', () => {
|
|
62
|
+
it('returns false for unknown agent type', () => {
|
|
63
|
+
expect(isAgentProcessRunning('unknown-agent')).toBe(false);
|
|
64
|
+
expect(mockedExecSync).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
it('delegates to the correct detector for known agent type', () => {
|
|
67
|
+
mockedExecSync.mockReturnValue(Buffer.from('12345'));
|
|
68
|
+
expect(isAgentProcessRunning('claude-code')).toBe(true);
|
|
69
|
+
expect(mockedExecSync).toHaveBeenCalledWith('pgrep -f "claude"', expect.any(Object));
|
|
70
|
+
});
|
|
71
|
+
it('returns false when known agent process is not running', () => {
|
|
72
|
+
mockedExecSync.mockImplementation(() => {
|
|
73
|
+
throw new Error('exit code 1');
|
|
74
|
+
});
|
|
75
|
+
expect(isAgentProcessRunning('opencode')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
const mockOn = vi.fn();
|
|
3
|
+
const mockSend = vi.fn();
|
|
4
|
+
const mockClose = vi.fn();
|
|
5
|
+
vi.mock('ws', () => {
|
|
6
|
+
const MockWebSocket = vi.fn().mockImplementation(function () {
|
|
7
|
+
this.on = mockOn;
|
|
8
|
+
this.send = mockSend;
|
|
9
|
+
this.close = mockClose;
|
|
10
|
+
this.readyState = 1;
|
|
11
|
+
});
|
|
12
|
+
MockWebSocket.OPEN = 1;
|
|
13
|
+
return { default: MockWebSocket };
|
|
14
|
+
});
|
|
15
|
+
const mockLoadDaemonConfig = vi.fn();
|
|
16
|
+
vi.mock('../config.js', () => ({
|
|
17
|
+
loadDaemonConfig: (...args) => mockLoadDaemonConfig(...args),
|
|
18
|
+
}));
|
|
19
|
+
const mockIsAgentProcessRunning = vi.fn();
|
|
20
|
+
vi.mock('../process-detector.js', () => ({
|
|
21
|
+
isAgentProcessRunning: (...args) => mockIsAgentProcessRunning(...args),
|
|
22
|
+
}));
|
|
23
|
+
const mockSpawn = vi.fn();
|
|
24
|
+
vi.mock('node:child_process', () => ({
|
|
25
|
+
spawn: (...args) => mockSpawn(...args),
|
|
26
|
+
}));
|
|
27
|
+
import WebSocket from 'ws';
|
|
28
|
+
import { DaemonWatcher } from '../watcher.js';
|
|
29
|
+
function makeDefaultConfig(overrides = {}) {
|
|
30
|
+
return {
|
|
31
|
+
hubUrl: 'http://localhost:3000',
|
|
32
|
+
agents: {
|
|
33
|
+
'claude-code': {
|
|
34
|
+
headlessWakeup: false,
|
|
35
|
+
command: 'claude',
|
|
36
|
+
args: ['-p', '{message}', '--dangerously-skip-permissions'],
|
|
37
|
+
},
|
|
38
|
+
'opencode': {
|
|
39
|
+
headlessWakeup: false,
|
|
40
|
+
command: 'opencode',
|
|
41
|
+
args: ['run', '{message}'],
|
|
42
|
+
},
|
|
43
|
+
'gemini-cli': {
|
|
44
|
+
headlessWakeup: false,
|
|
45
|
+
command: 'gemini',
|
|
46
|
+
args: ['-p', '{message}'],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function getEventHandler(eventName) {
|
|
53
|
+
for (const call of mockOn.mock.calls) {
|
|
54
|
+
if (call[0] === eventName) {
|
|
55
|
+
return call[1];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
vi.useFakeTimers();
|
|
63
|
+
mockLoadDaemonConfig.mockReturnValue(makeDefaultConfig());
|
|
64
|
+
mockSpawn.mockReturnValue({
|
|
65
|
+
stdout: { on: vi.fn() },
|
|
66
|
+
stderr: { on: vi.fn() },
|
|
67
|
+
on: vi.fn(),
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('DaemonWatcher constructor', () => {
|
|
71
|
+
it('uses config hubUrl when no override provided', () => {
|
|
72
|
+
mockLoadDaemonConfig.mockReturnValue(makeDefaultConfig({ hubUrl: 'http://myhost:4000' }));
|
|
73
|
+
const watcher = new DaemonWatcher();
|
|
74
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
75
|
+
watcher.start();
|
|
76
|
+
expect(WebSocket).toHaveBeenCalledWith('ws://myhost:4000/ws');
|
|
77
|
+
});
|
|
78
|
+
it('uses provided hubUrl over config hubUrl', () => {
|
|
79
|
+
const watcher = new DaemonWatcher({ hubUrl: 'http://custom:5000' });
|
|
80
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
81
|
+
watcher.start();
|
|
82
|
+
expect(WebSocket).toHaveBeenCalledWith('ws://custom:5000/ws');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('DaemonWatcher.start', () => {
|
|
86
|
+
it('creates WebSocket with ws:// URL derived from http:// hubUrl', () => {
|
|
87
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
88
|
+
const watcher = new DaemonWatcher();
|
|
89
|
+
watcher.start();
|
|
90
|
+
expect(WebSocket).toHaveBeenCalledWith('ws://localhost:3000/ws');
|
|
91
|
+
});
|
|
92
|
+
it('registers event handlers on the WebSocket', () => {
|
|
93
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
94
|
+
const watcher = new DaemonWatcher();
|
|
95
|
+
watcher.start();
|
|
96
|
+
const registeredEvents = mockOn.mock.calls.map((c) => c[0]);
|
|
97
|
+
expect(registeredEvents).toContain('open');
|
|
98
|
+
expect(registeredEvents).toContain('message');
|
|
99
|
+
expect(registeredEvents).toContain('close');
|
|
100
|
+
expect(registeredEvents).toContain('error');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('DaemonWatcher message handling', () => {
|
|
104
|
+
function startWatcher(configOverrides = {}) {
|
|
105
|
+
mockLoadDaemonConfig.mockReturnValue(makeDefaultConfig(configOverrides));
|
|
106
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
107
|
+
const watcher = new DaemonWatcher();
|
|
108
|
+
watcher.start();
|
|
109
|
+
return watcher;
|
|
110
|
+
}
|
|
111
|
+
function sendMessage(msg) {
|
|
112
|
+
const handler = getEventHandler('message');
|
|
113
|
+
expect(handler).toBeDefined();
|
|
114
|
+
handler(JSON.stringify(msg));
|
|
115
|
+
}
|
|
116
|
+
function makeUndeliveredMsg(agentName, content = 'hello') {
|
|
117
|
+
return {
|
|
118
|
+
type: 'message_undelivered',
|
|
119
|
+
payload: {
|
|
120
|
+
recipientAgentId: 'agent-123',
|
|
121
|
+
recipientAgentName: agentName,
|
|
122
|
+
message: { content },
|
|
123
|
+
},
|
|
124
|
+
timestamp: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
it('does NOT spawn when headless wakeup is disabled', () => {
|
|
128
|
+
startWatcher();
|
|
129
|
+
sendMessage(makeUndeliveredMsg('claude-code'));
|
|
130
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
it('does NOT spawn when agent process is already running', () => {
|
|
133
|
+
startWatcher({
|
|
134
|
+
agents: {
|
|
135
|
+
'claude-code': {
|
|
136
|
+
headlessWakeup: true,
|
|
137
|
+
command: 'claude',
|
|
138
|
+
args: ['-p', '{message}'],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
mockIsAgentProcessRunning.mockReturnValue(true);
|
|
143
|
+
sendMessage(makeUndeliveredMsg('claude-code'));
|
|
144
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
it('does NOT spawn when on cooldown', () => {
|
|
147
|
+
startWatcher({
|
|
148
|
+
agents: {
|
|
149
|
+
'claude-code': {
|
|
150
|
+
headlessWakeup: true,
|
|
151
|
+
command: 'claude',
|
|
152
|
+
args: ['-p', '{message}'],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
mockIsAgentProcessRunning.mockReturnValue(false);
|
|
157
|
+
sendMessage(makeUndeliveredMsg('claude-code'));
|
|
158
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
159
|
+
mockSpawn.mockClear();
|
|
160
|
+
sendMessage(makeUndeliveredMsg('claude-code'));
|
|
161
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
it('DOES spawn when headless enabled, process not running, no cooldown', () => {
|
|
164
|
+
startWatcher({
|
|
165
|
+
agents: {
|
|
166
|
+
'opencode': {
|
|
167
|
+
headlessWakeup: true,
|
|
168
|
+
command: 'opencode',
|
|
169
|
+
args: ['run', '{message}'],
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
mockIsAgentProcessRunning.mockReturnValue(false);
|
|
174
|
+
sendMessage(makeUndeliveredMsg('opencode'));
|
|
175
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
176
|
+
expect(mockSpawn).toHaveBeenCalledWith('opencode', ['run', 'hello'], expect.objectContaining({
|
|
177
|
+
cwd: expect.any(String),
|
|
178
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
179
|
+
}));
|
|
180
|
+
});
|
|
181
|
+
it('spawns with correct workdir from agent config', () => {
|
|
182
|
+
startWatcher({
|
|
183
|
+
agents: {
|
|
184
|
+
'gemini-cli': {
|
|
185
|
+
headlessWakeup: true,
|
|
186
|
+
command: 'gemini',
|
|
187
|
+
args: ['-p', '{message}'],
|
|
188
|
+
workdir: '/my/project',
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
mockIsAgentProcessRunning.mockReturnValue(false);
|
|
193
|
+
sendMessage(makeUndeliveredMsg('gemini-cli'));
|
|
194
|
+
expect(mockSpawn).toHaveBeenCalledWith('gemini', ['-p', 'hello'], expect.objectContaining({ cwd: '/my/project' }));
|
|
195
|
+
});
|
|
196
|
+
it('replaces {message} template in args with message content', () => {
|
|
197
|
+
startWatcher({
|
|
198
|
+
agents: {
|
|
199
|
+
'claude-code': {
|
|
200
|
+
headlessWakeup: true,
|
|
201
|
+
command: 'claude',
|
|
202
|
+
args: ['-p', '{message}', '--flag'],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
mockIsAgentProcessRunning.mockReturnValue(false);
|
|
207
|
+
sendMessage(makeUndeliveredMsg('claude-code', 'fix the bug'));
|
|
208
|
+
expect(mockSpawn).toHaveBeenCalledWith('claude', ['-p', 'fix the bug', '--flag'], expect.any(Object));
|
|
209
|
+
});
|
|
210
|
+
it('ignores unknown agent names', () => {
|
|
211
|
+
startWatcher({
|
|
212
|
+
agents: {
|
|
213
|
+
'claude-code': {
|
|
214
|
+
headlessWakeup: true,
|
|
215
|
+
command: 'claude',
|
|
216
|
+
args: ['-p', '{message}'],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
sendMessage(makeUndeliveredMsg('totally-unknown'));
|
|
221
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
describe('DaemonWatcher heartbeat', () => {
|
|
225
|
+
it('responds with pong when receiving heartbeat', () => {
|
|
226
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
227
|
+
const watcher = new DaemonWatcher();
|
|
228
|
+
watcher.start();
|
|
229
|
+
const handler = getEventHandler('message');
|
|
230
|
+
expect(handler).toBeDefined();
|
|
231
|
+
handler(JSON.stringify({
|
|
232
|
+
type: 'heartbeat',
|
|
233
|
+
payload: {},
|
|
234
|
+
timestamp: new Date().toISOString(),
|
|
235
|
+
}));
|
|
236
|
+
expect(mockSend).toHaveBeenCalledWith(expect.stringContaining('"pong":true'));
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
describe('DaemonWatcher.stop', () => {
|
|
240
|
+
it('closes the WebSocket', () => {
|
|
241
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
242
|
+
const watcher = new DaemonWatcher();
|
|
243
|
+
watcher.start();
|
|
244
|
+
watcher.stop();
|
|
245
|
+
expect(mockClose).toHaveBeenCalledWith(1000, 'daemon stopping');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
describe('DaemonWatcher.findAgentType (via message handling)', () => {
|
|
249
|
+
function startWithAllHeadless() {
|
|
250
|
+
mockLoadDaemonConfig.mockReturnValue(makeDefaultConfig({
|
|
251
|
+
agents: {
|
|
252
|
+
'claude-code': { headlessWakeup: true, command: 'claude', args: ['-p', '{message}'] },
|
|
253
|
+
'opencode': { headlessWakeup: true, command: 'opencode', args: ['run', '{message}'] },
|
|
254
|
+
'gemini-cli': { headlessWakeup: true, command: 'gemini', args: ['-p', '{message}'] },
|
|
255
|
+
},
|
|
256
|
+
}));
|
|
257
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
258
|
+
mockIsAgentProcessRunning.mockReturnValue(false);
|
|
259
|
+
const watcher = new DaemonWatcher();
|
|
260
|
+
watcher.start();
|
|
261
|
+
return watcher;
|
|
262
|
+
}
|
|
263
|
+
it('matches exact agent name', () => {
|
|
264
|
+
startWithAllHeadless();
|
|
265
|
+
const handler = getEventHandler('message');
|
|
266
|
+
handler(JSON.stringify({
|
|
267
|
+
type: 'message_undelivered',
|
|
268
|
+
payload: {
|
|
269
|
+
recipientAgentId: 'id-1',
|
|
270
|
+
recipientAgentName: 'claude-code',
|
|
271
|
+
message: { content: 'test' },
|
|
272
|
+
},
|
|
273
|
+
timestamp: new Date().toISOString(),
|
|
274
|
+
}));
|
|
275
|
+
expect(mockSpawn).toHaveBeenCalledWith('claude', expect.any(Array), expect.any(Object));
|
|
276
|
+
});
|
|
277
|
+
it('matches partial agent name containing config key', () => {
|
|
278
|
+
startWithAllHeadless();
|
|
279
|
+
const handler = getEventHandler('message');
|
|
280
|
+
handler(JSON.stringify({
|
|
281
|
+
type: 'message_undelivered',
|
|
282
|
+
payload: {
|
|
283
|
+
recipientAgentId: 'id-2',
|
|
284
|
+
recipientAgentName: 'my-claude-code-agent',
|
|
285
|
+
message: { content: 'test' },
|
|
286
|
+
},
|
|
287
|
+
timestamp: new Date().toISOString(),
|
|
288
|
+
}));
|
|
289
|
+
expect(mockSpawn).toHaveBeenCalledWith('claude', expect.any(Array), expect.any(Object));
|
|
290
|
+
});
|
|
291
|
+
it('returns null for completely unmatched agent name', () => {
|
|
292
|
+
startWithAllHeadless();
|
|
293
|
+
const handler = getEventHandler('message');
|
|
294
|
+
handler(JSON.stringify({
|
|
295
|
+
type: 'message_undelivered',
|
|
296
|
+
payload: {
|
|
297
|
+
recipientAgentId: 'id-3',
|
|
298
|
+
recipientAgentName: 'cursor-agent',
|
|
299
|
+
message: { content: 'test' },
|
|
300
|
+
},
|
|
301
|
+
timestamp: new Date().toISOString(),
|
|
302
|
+
}));
|
|
303
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type DaemonConfig } from '@swarmroom/shared';
|
|
2
|
+
/**
|
|
3
|
+
* Returns the path to the daemon config file.
|
|
4
|
+
*/
|
|
5
|
+
export declare function getConfigPath(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Returns the default daemon configuration.
|
|
8
|
+
* All agents have headlessWakeup disabled by default.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getDefaultConfig(): DaemonConfig;
|
|
11
|
+
/**
|
|
12
|
+
* Load daemon config from ~/.swarmroom/daemon.json.
|
|
13
|
+
* Returns default config if file doesn't exist.
|
|
14
|
+
* Validates with Zod schema and merges with defaults for missing fields.
|
|
15
|
+
*/
|
|
16
|
+
export declare function loadDaemonConfig(): DaemonConfig;
|
|
17
|
+
/**
|
|
18
|
+
* Save daemon config to ~/.swarmroom/daemon.json.
|
|
19
|
+
* Creates the ~/.swarmroom/ directory if it doesn't exist.
|
|
20
|
+
*/
|
|
21
|
+
export declare function saveDaemonConfig(config: DaemonConfig): void;
|
|
22
|
+
/**
|
|
23
|
+
* Ensure the config file exists. If not, write defaults.
|
|
24
|
+
* Returns the loaded (or newly created) config.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ensureConfig(): DaemonConfig;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { DaemonConfigSchema } from '@swarmroom/shared';
|
|
5
|
+
const CONFIG_DIR = join(homedir(), '.swarmroom');
|
|
6
|
+
const CONFIG_PATH = join(CONFIG_DIR, 'daemon.json');
|
|
7
|
+
/**
|
|
8
|
+
* Returns the path to the daemon config file.
|
|
9
|
+
*/
|
|
10
|
+
export function getConfigPath() {
|
|
11
|
+
return CONFIG_PATH;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Returns the default daemon configuration.
|
|
15
|
+
* All agents have headlessWakeup disabled by default.
|
|
16
|
+
*/
|
|
17
|
+
export function getDefaultConfig() {
|
|
18
|
+
return {
|
|
19
|
+
hubUrl: 'http://localhost:3000',
|
|
20
|
+
agents: {
|
|
21
|
+
'claude-code': {
|
|
22
|
+
headlessWakeup: false,
|
|
23
|
+
command: 'claude',
|
|
24
|
+
args: ['-p', '{message}', '--dangerously-skip-permissions'],
|
|
25
|
+
},
|
|
26
|
+
'opencode': {
|
|
27
|
+
headlessWakeup: false,
|
|
28
|
+
command: 'opencode',
|
|
29
|
+
args: ['run', '{message}'],
|
|
30
|
+
},
|
|
31
|
+
'gemini-cli': {
|
|
32
|
+
headlessWakeup: false,
|
|
33
|
+
command: 'gemini',
|
|
34
|
+
args: ['-p', '{message}'],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Load daemon config from ~/.swarmroom/daemon.json.
|
|
41
|
+
* Returns default config if file doesn't exist.
|
|
42
|
+
* Validates with Zod schema and merges with defaults for missing fields.
|
|
43
|
+
*/
|
|
44
|
+
export function loadDaemonConfig() {
|
|
45
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
46
|
+
return getDefaultConfig();
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const raw = readFileSync(CONFIG_PATH, 'utf-8');
|
|
50
|
+
const parsed = JSON.parse(raw);
|
|
51
|
+
// Merge with defaults: user config overrides defaults
|
|
52
|
+
const defaults = getDefaultConfig();
|
|
53
|
+
const merged = {
|
|
54
|
+
...defaults,
|
|
55
|
+
...parsed,
|
|
56
|
+
agents: {
|
|
57
|
+
...defaults.agents,
|
|
58
|
+
...(parsed.agents ?? {}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return DaemonConfigSchema.parse(merged);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.warn(`[daemon] Failed to load config from ${CONFIG_PATH}, using defaults:`, error);
|
|
65
|
+
return getDefaultConfig();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Save daemon config to ~/.swarmroom/daemon.json.
|
|
70
|
+
* Creates the ~/.swarmroom/ directory if it doesn't exist.
|
|
71
|
+
*/
|
|
72
|
+
export function saveDaemonConfig(config) {
|
|
73
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
74
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Ensure the config file exists. If not, write defaults.
|
|
80
|
+
* Returns the loaded (or newly created) config.
|
|
81
|
+
*/
|
|
82
|
+
export function ensureConfig() {
|
|
83
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
84
|
+
const config = getDefaultConfig();
|
|
85
|
+
saveDaemonConfig(config);
|
|
86
|
+
return config;
|
|
87
|
+
}
|
|
88
|
+
return loadDaemonConfig();
|
|
89
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a process matching the given name/pattern is currently running.
|
|
3
|
+
* Uses `pgrep -f` on Linux/macOS or `tasklist` on Windows.
|
|
4
|
+
*/
|
|
5
|
+
export declare function isProcessRunning(processPattern: string): boolean;
|
|
6
|
+
/** Check if any Claude Code process is running */
|
|
7
|
+
export declare function isClaudeCodeRunning(): boolean;
|
|
8
|
+
/** Check if any OpenCode process is running */
|
|
9
|
+
export declare function isOpenCodeRunning(): boolean;
|
|
10
|
+
/** Check if any Gemini CLI process is running */
|
|
11
|
+
export declare function isGeminiCliRunning(): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Map of agent type names to their process detection functions.
|
|
14
|
+
* Agent type names match the keys used in DaemonConfig.agents.
|
|
15
|
+
*/
|
|
16
|
+
export declare const AGENT_PROCESS_DETECTORS: Record<string, () => boolean>;
|
|
17
|
+
/**
|
|
18
|
+
* Check if the agent of the given type has a running process.
|
|
19
|
+
* Returns false for unknown agent types.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isAgentProcessRunning(agentType: string): boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { platform } from 'node:os';
|
|
3
|
+
/**
|
|
4
|
+
* Check if a process matching the given name/pattern is currently running.
|
|
5
|
+
* Uses `pgrep -f` on Linux/macOS or `tasklist` on Windows.
|
|
6
|
+
*/
|
|
7
|
+
export function isProcessRunning(processPattern) {
|
|
8
|
+
const os = platform();
|
|
9
|
+
try {
|
|
10
|
+
if (os === 'win32') {
|
|
11
|
+
const output = execSync(`tasklist /FI "IMAGENAME eq ${processPattern}*"`, {
|
|
12
|
+
encoding: 'utf-8',
|
|
13
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
14
|
+
});
|
|
15
|
+
return !output.includes('No tasks are running');
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
// Linux / macOS — pgrep -f matches full command line
|
|
19
|
+
execSync(`pgrep -f "${processPattern}"`, {
|
|
20
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
21
|
+
});
|
|
22
|
+
return true; // pgrep exits 0 = found
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false; // pgrep exits 1 = not found, or command failed
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Check if any Claude Code process is running */
|
|
30
|
+
export function isClaudeCodeRunning() {
|
|
31
|
+
return isProcessRunning('claude');
|
|
32
|
+
}
|
|
33
|
+
/** Check if any OpenCode process is running */
|
|
34
|
+
export function isOpenCodeRunning() {
|
|
35
|
+
return isProcessRunning('opencode');
|
|
36
|
+
}
|
|
37
|
+
/** Check if any Gemini CLI process is running */
|
|
38
|
+
export function isGeminiCliRunning() {
|
|
39
|
+
return isProcessRunning('gemini');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Map of agent type names to their process detection functions.
|
|
43
|
+
* Agent type names match the keys used in DaemonConfig.agents.
|
|
44
|
+
*/
|
|
45
|
+
export const AGENT_PROCESS_DETECTORS = {
|
|
46
|
+
'claude-code': isClaudeCodeRunning,
|
|
47
|
+
'opencode': isOpenCodeRunning,
|
|
48
|
+
'gemini-cli': isGeminiCliRunning,
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Check if the agent of the given type has a running process.
|
|
52
|
+
* Returns false for unknown agent types.
|
|
53
|
+
*/
|
|
54
|
+
export function isAgentProcessRunning(agentType) {
|
|
55
|
+
const detector = AGENT_PROCESS_DETECTORS[agentType];
|
|
56
|
+
if (!detector)
|
|
57
|
+
return false;
|
|
58
|
+
return detector();
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface DaemonWatcherOptions {
|
|
2
|
+
hubUrl?: string;
|
|
3
|
+
workdir?: string;
|
|
4
|
+
verbose?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare class DaemonWatcher {
|
|
7
|
+
private readonly hubUrl;
|
|
8
|
+
private readonly workdir;
|
|
9
|
+
private readonly verbose;
|
|
10
|
+
private config;
|
|
11
|
+
private ws;
|
|
12
|
+
private reconnectTimer;
|
|
13
|
+
private reconnectAttempts;
|
|
14
|
+
private intentionalDisconnect;
|
|
15
|
+
private spawnCooldowns;
|
|
16
|
+
constructor(options?: DaemonWatcherOptions);
|
|
17
|
+
/** Start the daemon — connect to Hub WebSocket */
|
|
18
|
+
start(): void;
|
|
19
|
+
/** Stop the daemon — disconnect and clean up */
|
|
20
|
+
stop(): void;
|
|
21
|
+
/** Reload config from disk */
|
|
22
|
+
reloadConfig(): void;
|
|
23
|
+
private connectWebSocket;
|
|
24
|
+
private sendRegister;
|
|
25
|
+
private handleMessage;
|
|
26
|
+
private handleUndeliveredMessage;
|
|
27
|
+
/**
|
|
28
|
+
* Find which agent type (config key) matches the given agent name.
|
|
29
|
+
* The agent name from the hub may be "claude-code", "opencode", "gemini-cli",
|
|
30
|
+
* or a custom name. We try exact match first, then partial matching.
|
|
31
|
+
*/
|
|
32
|
+
private findAgentType;
|
|
33
|
+
private isOnCooldown;
|
|
34
|
+
private spawnHeadless;
|
|
35
|
+
private scheduleReconnect;
|
|
36
|
+
private clearReconnectTimer;
|
|
37
|
+
private log;
|
|
38
|
+
}
|