agent-gear 1.1.10 → 1.2.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 (51) hide show
  1. package/dist/acp/bridge.d.ts.map +1 -1
  2. package/dist/acp/bridge.js +11 -2
  3. package/dist/acp/bridge.js.map +1 -1
  4. package/dist/acp/bridge.test.js +29 -0
  5. package/dist/acp/bridge.test.js.map +1 -1
  6. package/dist/acp/process-registry.d.ts +30 -0
  7. package/dist/acp/process-registry.d.ts.map +1 -0
  8. package/dist/acp/process-registry.integration.test.d.ts +2 -0
  9. package/dist/acp/process-registry.integration.test.d.ts.map +1 -0
  10. package/dist/acp/process-registry.integration.test.js +52 -0
  11. package/dist/acp/process-registry.integration.test.js.map +1 -0
  12. package/dist/acp/process-registry.js +110 -0
  13. package/dist/acp/process-registry.js.map +1 -0
  14. package/dist/acp/process-registry.test.d.ts +2 -0
  15. package/dist/acp/process-registry.test.d.ts.map +1 -0
  16. package/dist/acp/process-registry.test.js +209 -0
  17. package/dist/acp/process-registry.test.js.map +1 -0
  18. package/dist/acp/spawner.d.ts +1 -0
  19. package/dist/acp/spawner.d.ts.map +1 -1
  20. package/dist/acp/spawner.js +32 -10
  21. package/dist/acp/spawner.js.map +1 -1
  22. package/dist/fs-handler.d.ts +11 -0
  23. package/dist/fs-handler.d.ts.map +1 -1
  24. package/dist/fs-handler.js +79 -2
  25. package/dist/fs-handler.js.map +1 -1
  26. package/dist/fs-handler.test.js +78 -1
  27. package/dist/fs-handler.test.js.map +1 -1
  28. package/dist/index.js +32 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/socketio-client.d.ts.map +1 -1
  31. package/dist/socketio-client.js +177 -4
  32. package/dist/socketio-client.js.map +1 -1
  33. package/dist/worktree-handler.d.ts +12 -1
  34. package/dist/worktree-handler.d.ts.map +1 -1
  35. package/dist/worktree-handler.js +28 -2
  36. package/dist/worktree-handler.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/acp/bridge.test.ts +37 -0
  39. package/src/acp/bridge.ts +11 -2
  40. package/src/acp/process-registry.integration.test.ts +65 -0
  41. package/src/acp/process-registry.test.ts +239 -0
  42. package/src/acp/process-registry.ts +139 -0
  43. package/src/acp/spawner.ts +26 -10
  44. package/src/fs-handler.test.ts +106 -2
  45. package/src/fs-handler.ts +88 -3
  46. package/src/index.ts +27 -2
  47. package/src/socketio-client.ts +176 -5
  48. package/src/worktree-handler.ts +41 -2
  49. package/tests/acp-spawner.test.ts +58 -26
  50. package/tests/fs-handler.test.ts +63 -0
  51. package/tests/socketio-client.test.ts +288 -0
@@ -11,6 +11,7 @@ vi.mock('os', () => ({
11
11
  vi.mock('child_process', () => ({
12
12
  spawn: vi.fn(),
13
13
  execFileSync: vi.fn(() => '/usr/bin/opencode'),
14
+ execFile: vi.fn(),
14
15
  }));
15
16
 
16
17
  vi.mock('fs', async (importOriginal) => {
@@ -19,7 +20,7 @@ vi.mock('fs', async (importOriginal) => {
19
20
  });
20
21
 
21
22
  import { AcpSpawner } from '../../src/acp/spawner.js';
22
- import { spawn, execFileSync } from 'child_process';
23
+ import { spawn, execFileSync, execFile } from 'child_process';
23
24
  import { existsSync } from 'fs';
24
25
 
25
26
  function createMockChildProcess(): ChildProcess & EventEmitter {
@@ -31,6 +32,7 @@ function createMockChildProcess(): ChildProcess & EventEmitter {
31
32
  Object.defineProperty(proc, 'stdout', { value: stdoutMock, writable: true });
32
33
  Object.defineProperty(proc, 'stderr', { value: new Readable({ read() {} }), writable: true });
33
34
  Object.defineProperty(proc, 'kill', { value: vi.fn().mockReturnValue(true), writable: true });
35
+ Object.defineProperty(proc, 'pid', { value: 12345, writable: true });
34
36
  return proc;
35
37
  }
36
38
 
@@ -60,6 +62,7 @@ describe('AcpSpawner', () => {
60
62
  spawner.start();
61
63
  expect(spawn).toHaveBeenCalledWith('/usr/bin/opencode', ['acp'], {
62
64
  cwd: '/tmp/project',
65
+ detached: true,
63
66
  stdio: ['pipe', 'pipe', 'pipe'],
64
67
  });
65
68
  });
@@ -130,26 +133,39 @@ describe('AcpSpawner', () => {
130
133
  });
131
134
 
132
135
  describe('stop', () => {
133
- it('should send SIGTERM to process', () => {
136
+ let processKillSpy: ReturnType<typeof vi.spyOn>;
137
+
138
+ beforeEach(() => {
139
+ processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true as any);
140
+ });
141
+
142
+ afterEach(() => {
143
+ processKillSpy.mockRestore();
144
+ });
145
+
146
+ it('should send SIGTERM to process group', () => {
134
147
  spawner.start();
148
+ const pid = mockProcess.pid;
135
149
  spawner.stop();
136
- expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
150
+ expect(processKillSpy).toHaveBeenCalledWith(-pid, 'SIGTERM');
137
151
  });
138
152
 
139
153
  it('should send SIGKILL after 5s if process has not exited', () => {
140
154
  spawner.start();
155
+ const pid = mockProcess.pid;
141
156
  spawner.stop();
142
- expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
157
+ expect(processKillSpy).toHaveBeenCalledWith(-pid, 'SIGTERM');
143
158
  vi.advanceTimersByTime(5000);
144
- expect(mockProcess.kill).toHaveBeenCalledWith('SIGKILL');
159
+ expect(processKillSpy).toHaveBeenCalledWith(-pid, 'SIGKILL');
145
160
  });
146
161
 
147
162
  it('should not send SIGKILL if process already exited', () => {
148
163
  spawner.start();
164
+ const pid = mockProcess.pid;
149
165
  spawner.stop();
150
166
  mockProcess.emit('exit', 0, null);
151
167
  vi.advanceTimersByTime(5000);
152
- expect(mockProcess.kill).toHaveBeenCalledTimes(1);
168
+ expect(processKillSpy).not.toHaveBeenCalledWith(-pid, 'SIGKILL');
153
169
  });
154
170
 
155
171
  it('should handle stop when no process', () => {
@@ -205,14 +221,14 @@ describe('AcpSpawner', () => {
205
221
  expect(execFileSync).toHaveBeenCalledWith('where', ['copilot'], { encoding: 'utf8', timeout: 1000 });
206
222
  });
207
223
 
208
- it('should return full path from `where`', () => {
224
+ it('should return full path from `where` and use shell for .cmd', () => {
209
225
  vi.mocked(execFileSync).mockReturnValue('C:\\Users\\ash\\AppData\\Roaming\\npm\\copilot.cmd\r\n');
210
226
  const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot', args: ['--acp', '--stdio'] });
211
227
  sp.start();
212
228
  expect(spawn).toHaveBeenCalledWith(
213
229
  'C:\\Users\\ash\\AppData\\Roaming\\npm\\copilot.cmd',
214
230
  ['--acp', '--stdio'],
215
- expect.objectContaining({ shell: true }),
231
+ expect.objectContaining({ cwd: 'C:\\project', stdio: ['pipe', 'pipe', 'pipe'], shell: true }),
216
232
  );
217
233
  });
218
234
 
@@ -221,7 +237,7 @@ describe('AcpSpawner', () => {
221
237
  vi.mocked(existsSync).mockImplementation((p: string) => p === 'copilot.cmd');
222
238
  const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot' });
223
239
  sp.start();
224
- expect(spawn).toHaveBeenCalledWith('copilot.cmd', expect.any(Array), expect.objectContaining({ shell: true }));
240
+ expect(spawn).toHaveBeenCalledWith('copilot.cmd', expect.any(Array), expect.objectContaining({ cwd: 'C:\\project' }));
225
241
  });
226
242
 
227
243
  it('should return bare command as last resort', () => {
@@ -229,42 +245,58 @@ describe('AcpSpawner', () => {
229
245
  vi.mocked(existsSync).mockReturnValue(false);
230
246
  const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot' });
231
247
  sp.start();
232
- expect(spawn).toHaveBeenCalledWith('copilot', expect.any(Array), expect.objectContaining({ shell: true }));
248
+ expect(spawn).toHaveBeenCalledWith('copilot', expect.any(Array), expect.objectContaining({ cwd: 'C:\\project' }));
233
249
  });
234
250
  });
235
251
 
236
252
  describe('start', () => {
237
- it('should spawn with shell: true', () => {
253
+ it('should spawn .cmd with shell: true on Windows (Node EINVAL on shell-less .cmd)', () => {
238
254
  vi.mocked(execFileSync).mockReturnValue('C:\\npm\\copilot.cmd\r\n');
239
255
  const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot' });
240
256
  sp.start();
241
257
  expect(spawn).toHaveBeenCalledWith(
242
- expect.any(String),
258
+ 'C:\\npm\\copilot.cmd',
243
259
  expect.any(Array),
244
- expect.objectContaining({ shell: true }),
260
+ expect.objectContaining({ cwd: 'C:\\project', shell: true }),
245
261
  );
246
262
  });
247
- });
248
263
 
249
- describe('stop', () => {
250
- it('should call kill() without signal on Windows', () => {
251
- const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot' });
252
- vi.mocked(execFileSync).mockReturnValue('C:\\npm\\copilot.cmd\r\n');
264
+ it('should spawn .bat with shell: true on Windows', () => {
265
+ vi.mocked(execFileSync).mockReturnValue('C:\\npm\\agent.bat\r\n');
266
+ const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'agent' });
253
267
  sp.start();
254
- sp.stop();
255
- expect(mockProcess.kill).toHaveBeenCalledWith();
256
- expect(mockProcess.kill).not.toHaveBeenCalledWith('SIGTERM');
268
+ expect(spawn).toHaveBeenCalledWith(
269
+ 'C:\\npm\\agent.bat',
270
+ expect.any(Array),
271
+ expect.objectContaining({ cwd: 'C:\\project', shell: true }),
272
+ );
257
273
  });
258
274
 
259
- it('should call kill() again after 5s on Windows', () => {
275
+ it('should spawn a real .exe directly without shell on Windows', () => {
276
+ vi.mocked(execFileSync).mockReturnValue('C:\\tools\\opencode.exe\r\n');
277
+ const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'opencode' });
278
+ sp.start();
279
+ const spawnOptions = vi.mocked(spawn).mock.calls[0][2];
280
+ expect(spawn).toHaveBeenCalledWith('C:\\tools\\opencode.exe', expect.any(Array), expect.objectContaining({ cwd: 'C:\\project' }));
281
+ expect(spawnOptions).not.toHaveProperty('shell');
282
+ });
283
+ });
284
+
285
+ describe('stop', () => {
286
+ it('should use taskkill on Windows instead of signal kill', () => {
287
+ vi.mocked(execFile).mockImplementation((_cmd: any, _args: any, cb: any) => {
288
+ if (typeof cb === 'function') cb(null, '', '');
289
+ return {} as any;
290
+ });
291
+ const processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true as any);
260
292
  const sp = new AcpSpawner({ sessionId: 's', cwd: 'C:\\project', command: 'copilot' });
261
293
  vi.mocked(execFileSync).mockReturnValue('C:\\npm\\copilot.cmd\r\n');
262
294
  sp.start();
263
295
  sp.stop();
264
- expect(mockProcess.kill).toHaveBeenCalledTimes(1);
265
- vi.advanceTimersByTime(5000);
266
- expect(mockProcess.kill).toHaveBeenCalledTimes(2);
267
- expect(mockProcess.kill).not.toHaveBeenCalledWith('SIGKILL');
296
+ // On Windows, stop() should call taskkill, not POSIX signals
297
+ expect(processKillSpy).not.toHaveBeenCalledWith(expect.anything(), 'SIGTERM');
298
+ expect(processKillSpy).not.toHaveBeenCalledWith(expect.anything(), 'SIGKILL');
299
+ processKillSpy.mockRestore();
268
300
  });
269
301
  });
270
302
  });
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
5
+ import { FsHandler } from '../src/fs-handler.js';
6
+
7
+ // 真实 fs 集成测试(不 mock):removeDir 的安全语义只有真实文件系统能覆盖
8
+ // (验收报告建议项 #31:isPathSafe/removeDir 零用例)
9
+
10
+ describe('FsHandler.removeDir(真实 fs)', () => {
11
+ const created: string[] = [];
12
+
13
+ function makeDir(): string {
14
+ const dir = mkdtempSync(join(tmpdir(), 'fs-handler-test-'));
15
+ created.push(dir);
16
+ return dir;
17
+ }
18
+
19
+ afterEach(() => {
20
+ for (const dir of created.splice(0)) {
21
+ rmSync(dir, { recursive: true, force: true });
22
+ }
23
+ });
24
+
25
+ it('拒绝删除 .git 为目录的路径(git 主工作区),目录保持完整', async () => {
26
+ const handler = new FsHandler();
27
+ const repoRoot = makeDir();
28
+ mkdirSync(join(repoRoot, '.git'));
29
+ writeFileSync(join(repoRoot, 'index.ts'), 'export {}');
30
+ handler.registerAllowedPath(repoRoot);
31
+
32
+ const result = await handler.removeDir(repoRoot);
33
+
34
+ expect(result.success).toBe(false);
35
+ expect(result.error).toContain('main working tree');
36
+ expect(existsSync(join(repoRoot, '.git'))).toBe(true);
37
+ expect(existsSync(join(repoRoot, 'index.ts'))).toBe(true);
38
+ });
39
+
40
+ it('允许删除 .git 为文件的已注册路径(linked worktree)', async () => {
41
+ const handler = new FsHandler();
42
+ const worktreeDir = makeDir();
43
+ // linked worktree 的 .git 是指向主仓库 gitdir 的普通文件
44
+ writeFileSync(join(worktreeDir, '.git'), 'gitdir: /repo/.git/worktrees/feat-1');
45
+ writeFileSync(join(worktreeDir, 'index.ts'), 'export {}');
46
+ handler.registerAllowedPath(worktreeDir);
47
+
48
+ const result = await handler.removeDir(worktreeDir);
49
+
50
+ expect(result.success).toBe(true);
51
+ expect(existsSync(worktreeDir)).toBe(false);
52
+ });
53
+
54
+ it('拒绝删除未注册的路径', async () => {
55
+ const handler = new FsHandler();
56
+ const dir = makeDir();
57
+
58
+ const result = await handler.removeDir(dir);
59
+
60
+ expect(result.success).toBe(false);
61
+ expect(existsSync(dir)).toBe(true);
62
+ });
63
+ });
@@ -28,6 +28,76 @@ vi.mock('../src/acp/sync-bridge.js', () => ({
28
28
  AcpSyncBridge: MockAcpSyncBridge,
29
29
  }));
30
30
 
31
+ const { bridgeInstances } = vi.hoisted(() => ({
32
+ bridgeInstances: [] as Array<{ sessionId: string; emit: (event: string, payload: unknown) => boolean }>,
33
+ }));
34
+
35
+ vi.mock('../src/acp/bridge.js', async () => {
36
+ const { EventEmitter } = await import('events');
37
+ class FakeAcpSessionBridge extends EventEmitter {
38
+ sessionId: string;
39
+ constructor(_socket: unknown, options: { sessionId: string }, _spawner: unknown) {
40
+ super();
41
+ this.sessionId = options.sessionId;
42
+ bridgeInstances.push(this as never);
43
+ }
44
+ async start() {
45
+ this.emit('acp:initialized', {
46
+ sessionId: this.sessionId,
47
+ acpSessionId: 'acp-ext-1',
48
+ modes: [],
49
+ availableModels: [],
50
+ configOptions: [],
51
+ authMethods: [],
52
+ });
53
+ }
54
+ async resume() {
55
+ this.emit('acp:initialized', {
56
+ sessionId: this.sessionId,
57
+ acpSessionId: 'acp-ext-1',
58
+ modes: [],
59
+ availableModels: [],
60
+ configOptions: [],
61
+ authMethods: [],
62
+ });
63
+ }
64
+ async stop() {
65
+ this.emit('acp:stopped', { sessionId: this.sessionId, reason: 'user_close' });
66
+ }
67
+ sendInput() {}
68
+ cancel() {}
69
+ }
70
+ return { AcpSessionBridge: FakeAcpSessionBridge };
71
+ });
72
+
73
+ vi.mock('../src/acp/spawner.js', () => ({
74
+ AcpSpawner: class {
75
+ pid = 4321;
76
+ constructor(_options: unknown) {}
77
+ start() {}
78
+ stop() {}
79
+ get closed() { return Promise.resolve(); }
80
+ },
81
+ }));
82
+
83
+ vi.mock('../src/acp/detector.js', () => ({
84
+ acpDetector: {
85
+ detect: vi.fn(async () => []),
86
+ getSpawnerConfig: vi.fn(() => ({ command: 'opencode', args: ['acp'] })),
87
+ },
88
+ }));
89
+
90
+ vi.mock('../src/acp/process-registry.js', () => ({
91
+ processRegistry: {
92
+ register: vi.fn(),
93
+ deregister: vi.fn(),
94
+ get: vi.fn(),
95
+ list: vi.fn(() => []),
96
+ listEntries: vi.fn(() => []),
97
+ },
98
+ isProcessAlive: vi.fn(() => true),
99
+ }));
100
+
31
101
  vi.mock('socket.io-client', () => ({
32
102
  io: vi.fn(),
33
103
  }));
@@ -66,6 +136,8 @@ import { io } from 'socket.io-client';
66
136
  import { clearConfigKey } from '../src/config.js';
67
137
  import * as os from 'os';
68
138
  import { readdir, access } from 'fs/promises';
139
+ import { fsHandler } from '../src/fs-handler.js';
140
+ import { processRegistry, isProcessAlive } from '../src/acp/process-registry.js';
69
141
 
70
142
  function createMockSocket() {
71
143
  const handlers: Record<string, (...args: unknown[]) => void> = {};
@@ -527,4 +599,220 @@ describe('SocketIO Client', () => {
527
599
  });
528
600
  });
529
601
  });
602
+
603
+ describe('acp:start registry lifecycle', () => {
604
+ function setup() {
605
+ bridgeInstances.length = 0;
606
+ const mockSocket = createMockSocket();
607
+ vi.mocked(io).mockReturnValue(mockSocket as never);
608
+ connectToServer('http://localhost:4001', 'zag_k_1_abc123');
609
+ return mockSocket;
610
+ }
611
+
612
+ async function startSession(mockSocket: ReturnType<typeof createMockSocket>, sessionId: string, cwd: string) {
613
+ mockSocket._trigger('acp:start', {
614
+ requestId: 'req-start',
615
+ sessionId,
616
+ serverType: 'opencode',
617
+ cwd,
618
+ });
619
+ await vi.waitFor(() => {
620
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({ sessionId, success: true }));
621
+ });
622
+ }
623
+
624
+ it('registers the session cwd as an allowed fs path', async () => {
625
+ const registerSpy = vi.spyOn(fsHandler, 'registerAllowedPath');
626
+ const mockSocket = setup();
627
+
628
+ await startSession(mockSocket, 'sess-1', '/home/user/wt-1');
629
+
630
+ expect(registerSpy).toHaveBeenCalledWith('/home/user/wt-1');
631
+ });
632
+
633
+ it('stops the previous bridge when acp:start arrives for the same sessionId again', async () => {
634
+ const mockSocket = setup();
635
+ await startSession(mockSocket, 'sess-1', '/home/user/wt-1');
636
+ const oldBridge = bridgeInstances[0] as unknown as { stop: () => Promise<void> };
637
+ const stopSpy = vi.spyOn(oldBridge, 'stop');
638
+
639
+ // 同 sessionId 再次 acp:start(如超时重试/兜底 resume 后迟到):旧 bridge 必须先停,
640
+ // 否则同会话双进程、旧 bridge 泄漏
641
+ mockSocket._trigger('acp:start', {
642
+ requestId: 'req-start-2',
643
+ sessionId: 'sess-1',
644
+ serverType: 'opencode',
645
+ cwd: '/home/user/wt-1',
646
+ });
647
+
648
+ await vi.waitFor(() => {
649
+ expect(stopSpy).toHaveBeenCalled();
650
+ });
651
+ await vi.waitFor(() => {
652
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:start:result', expect.objectContaining({
653
+ requestId: 'req-start-2',
654
+ sessionId: 'sess-1',
655
+ success: true,
656
+ }));
657
+ });
658
+ expect(bridgeInstances).toHaveLength(2);
659
+ });
660
+
661
+ it('deregisters the PID when the bridge emits acp:stopped', async () => {
662
+ const mockSocket = setup();
663
+ await startSession(mockSocket, 'sess-1', '/home/user/wt-1');
664
+ vi.mocked(processRegistry.deregister).mockClear();
665
+
666
+ bridgeInstances[0].emit('acp:stopped', { sessionId: 'sess-1', reason: 'user_close' });
667
+
668
+ expect(processRegistry.deregister).toHaveBeenCalledWith('sess-1');
669
+ });
670
+
671
+ it('deregisters the PID on the acp:stop bridge path', async () => {
672
+ const mockSocket = setup();
673
+ await startSession(mockSocket, 'sess-1', '/home/user/wt-1');
674
+ vi.mocked(processRegistry.deregister).mockClear();
675
+
676
+ mockSocket._trigger('acp:stop', { sessionId: 'sess-1' });
677
+
678
+ await vi.waitFor(() => {
679
+ expect(processRegistry.deregister).toHaveBeenCalledWith('sess-1');
680
+ });
681
+ });
682
+ });
683
+
684
+ describe('acp:stop fallback kill', () => {
685
+ beforeEach(() => {
686
+ vi.useFakeTimers();
687
+ vi.mocked(os.platform).mockReturnValue('linux');
688
+ });
689
+
690
+ afterEach(() => {
691
+ vi.useRealTimers();
692
+ });
693
+
694
+ function setup() {
695
+ bridgeInstances.length = 0;
696
+ const mockSocket = createMockSocket();
697
+ vi.mocked(io).mockReturnValue(mockSocket as never);
698
+ connectToServer('http://localhost:4001', 'zag_k_1_abc123');
699
+ return mockSocket;
700
+ }
701
+
702
+ it('kills by registry PID and probes liveness before SIGKILL', async () => {
703
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
704
+ vi.mocked(processRegistry.get).mockReturnValue({ pid: 2222, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' });
705
+ vi.mocked(isProcessAlive).mockReturnValue(true);
706
+ const mockSocket = setup();
707
+
708
+ mockSocket._trigger('acp:stop', { sessionId: 'sess-x', ackRequired: true });
709
+ await vi.advanceTimersByTimeAsync(0);
710
+
711
+ expect(killSpy).toHaveBeenCalledWith(-2222, 'SIGTERM');
712
+ expect(processRegistry.deregister).toHaveBeenCalledWith('sess-x');
713
+ expect(mockSocket.emit).toHaveBeenCalledWith('acp:stopped', {
714
+ sessionId: 'sess-x',
715
+ reason: 'user_close',
716
+ killed: true,
717
+ });
718
+
719
+ await vi.advanceTimersByTimeAsync(5000);
720
+ expect(isProcessAlive).toHaveBeenCalledWith(2222);
721
+ expect(killSpy).toHaveBeenCalledWith(-2222, 'SIGKILL');
722
+ });
723
+
724
+ it('skips SIGKILL when the process is already dead (PID reuse guard)', async () => {
725
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
726
+ vi.mocked(processRegistry.get).mockReturnValue({ pid: 2222, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' });
727
+ vi.mocked(isProcessAlive).mockReturnValue(false);
728
+ const mockSocket = setup();
729
+
730
+ mockSocket._trigger('acp:stop', { sessionId: 'sess-x' });
731
+ await vi.advanceTimersByTimeAsync(0);
732
+ expect(killSpy).toHaveBeenCalledWith(-2222, 'SIGTERM');
733
+
734
+ await vi.advanceTimersByTimeAsync(5000);
735
+ expect(killSpy).not.toHaveBeenCalledWith(-2222, 'SIGKILL');
736
+ });
737
+
738
+ it('ignores malformed payloads with a warning', async () => {
739
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
740
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
741
+ const mockSocket = setup();
742
+
743
+ mockSocket._trigger('acp:stop', { ackRequired: true });
744
+ await vi.advanceTimersByTimeAsync(0);
745
+
746
+ expect(warnSpy).toHaveBeenCalled();
747
+ expect(killSpy).not.toHaveBeenCalled();
748
+ expect(mockSocket.emit).not.toHaveBeenCalledWith('acp:stopped', expect.anything());
749
+ });
750
+ });
751
+
752
+ describe('acp:reconcile', () => {
753
+ beforeEach(() => {
754
+ vi.useFakeTimers();
755
+ vi.mocked(os.platform).mockReturnValue('linux');
756
+ });
757
+
758
+ afterEach(() => {
759
+ vi.useRealTimers();
760
+ });
761
+
762
+ function setup() {
763
+ bridgeInstances.length = 0;
764
+ const mockSocket = createMockSocket();
765
+ vi.mocked(io).mockReturnValue(mockSocket as never);
766
+ connectToServer('http://localhost:4001', 'zag_k_1_abc123');
767
+ return mockSocket;
768
+ }
769
+
770
+ it('kills orphan registry entries and probes liveness before SIGKILL', async () => {
771
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
772
+ vi.mocked(processRegistry.listEntries).mockReturnValue([
773
+ { sessionId: 's1', pid: 3333, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' },
774
+ ]);
775
+ vi.mocked(isProcessAlive).mockReturnValue(false);
776
+ const mockSocket = setup();
777
+
778
+ mockSocket._trigger('acp:reconcile', { activeSessionIds: [], pendingKillSessionIds: [] });
779
+ await vi.advanceTimersByTimeAsync(0);
780
+
781
+ expect(killSpy).toHaveBeenCalledWith(-3333, 'SIGTERM');
782
+ expect(processRegistry.deregister).toHaveBeenCalledWith('s1');
783
+
784
+ await vi.advanceTimersByTimeAsync(5000);
785
+ expect(isProcessAlive).toHaveBeenCalledWith(3333);
786
+ expect(killSpy).not.toHaveBeenCalledWith(-3333, 'SIGKILL');
787
+ });
788
+
789
+ it('keeps sessions listed in activeSessionIds', async () => {
790
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
791
+ vi.mocked(processRegistry.listEntries).mockReturnValue([
792
+ { sessionId: 's1', pid: 3333, cwd: '/x', acpServerType: 'opencode', startedAt: '2026-01-01' },
793
+ ]);
794
+ const mockSocket = setup();
795
+
796
+ mockSocket._trigger('acp:reconcile', { activeSessionIds: ['s1'], pendingKillSessionIds: [] });
797
+ await vi.advanceTimersByTimeAsync(5000);
798
+
799
+ expect(killSpy).not.toHaveBeenCalled();
800
+ expect(processRegistry.deregister).not.toHaveBeenCalled();
801
+ });
802
+
803
+ it('ignores malformed payloads with a warning', async () => {
804
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
805
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true as never);
806
+ const mockSocket = setup();
807
+ // connectToServer seeds the fs whitelist via listEntries once; clear it
808
+ vi.mocked(processRegistry.listEntries).mockClear();
809
+
810
+ mockSocket._trigger('acp:reconcile', { activeSessionIds: 'oops', pendingKillSessionIds: null });
811
+ await vi.advanceTimersByTimeAsync(0);
812
+
813
+ expect(warnSpy).toHaveBeenCalled();
814
+ expect(processRegistry.listEntries).not.toHaveBeenCalled();
815
+ expect(killSpy).not.toHaveBeenCalled();
816
+ });
817
+ });
530
818
  });