agent-gear 1.1.9 → 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
package/src/fs-handler.ts CHANGED
@@ -1,5 +1,38 @@
1
- import { mkdirSync } from 'fs';
2
- import { join } from 'path';
1
+ import { mkdirSync, rmSync, statSync } from 'fs';
2
+ import { join, parse, resolve, sep } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ // Security model for destructive operations (removeDir): whitelist.
6
+ // Only paths the controller itself manages may be deleted — worktree/session
7
+ // directories registered via registerAllowedPath (acp:start/acp:resume cwd,
8
+ // git:worktree:add worktreePath, fs:mkdir results, ProcessRegistry entries).
9
+ // Explicit denies (filesystem root, home dir, cwd and their ancestors,
10
+ // Windows system paths) act as defense-in-depth on top of the whitelist.
11
+ //
12
+ // mkdir is non-destructive and stays on the legacy blacklist: worktree
13
+ // creation needs to mkdir parents that are not registered yet.
14
+ const BLOCKED_PATHS = ['/etc', '/usr', '/bin', '/sbin', '/var', '/proc', '/sys'];
15
+
16
+ const WINDOWS_DENIED_PATTERNS = [
17
+ /^[a-zA-Z]:[\\/]*$/, // drive root, e.g. C:\
18
+ /^[a-zA-Z]:[\\/]+windows[\\/]*$/i, // C:\Windows
19
+ /^[a-zA-Z]:[\\/]+users[\\/]*$/i, // C:\Users root
20
+ ];
21
+
22
+ function isSameOrAncestor(candidate: string, target: string): boolean {
23
+ return target === candidate || target.startsWith(candidate + sep);
24
+ }
25
+
26
+ function isExplicitlyDenied(rawPath: string, resolved: string): boolean {
27
+ if (WINDOWS_DENIED_PATTERNS.some((re) => re.test(rawPath.trim()))) return true;
28
+ if (resolved === parse(resolved).root) return true;
29
+ return isSameOrAncestor(resolved, homedir()) || isSameOrAncestor(resolved, process.cwd());
30
+ }
31
+
32
+ function isBlacklisted(targetPath: string): boolean {
33
+ const resolved = resolve(targetPath);
34
+ return BLOCKED_PATHS.some((blocked) => resolved === blocked || resolved.startsWith(blocked + '/'));
35
+ }
3
36
 
4
37
  export interface FsMkdirResult {
5
38
  success: boolean;
@@ -8,16 +41,68 @@ export interface FsMkdirResult {
8
41
  error?: string;
9
42
  }
10
43
 
44
+ export interface FsRemoveDirResult {
45
+ success: boolean;
46
+ error?: string;
47
+ }
48
+
11
49
  export class FsHandler {
50
+ private allowedRoots = new Set<string>();
51
+
52
+ /** Register a controller-managed directory; it and its subpaths become removable. */
53
+ registerAllowedPath(path: string): void {
54
+ const resolved = resolve(path);
55
+ if (isExplicitlyDenied(path, resolved)) return;
56
+ this.allowedRoots.add(resolved);
57
+ }
58
+
59
+ /** Drop a previously registered directory (e.g. after it was removed). */
60
+ unregisterAllowedPath(path: string): void {
61
+ this.allowedRoots.delete(resolve(path));
62
+ }
63
+
64
+ private isPathSafe(targetPath: string): boolean {
65
+ const resolved = resolve(targetPath);
66
+ if (isExplicitlyDenied(targetPath, resolved)) return false;
67
+ for (const root of this.allowedRoots) {
68
+ if (resolved === root || resolved.startsWith(root + sep)) return true;
69
+ }
70
+ return false;
71
+ }
72
+
12
73
  async mkdir(parentPath: string, folderName: string): Promise<FsMkdirResult> {
13
74
  try {
14
75
  const fullPath = join(parentPath, folderName);
76
+ if (isBlacklisted(fullPath)) {
77
+ return { success: false, error: `Path not allowed: ${fullPath}` };
78
+ }
15
79
  mkdirSync(fullPath, { recursive: true });
16
80
  return { success: true, name: folderName, path: fullPath };
17
81
  } catch (err) {
18
82
  return { success: false, error: (err as Error).message };
19
83
  }
20
84
  }
85
+
86
+ async removeDir(path: string): Promise<FsRemoveDirResult> {
87
+ try {
88
+ if (!this.isPathSafe(path)) {
89
+ return { success: false, error: `Path not allowed: ${path}` };
90
+ }
91
+ // 主工作区保护(最后一道防线):git 主工作区的 .git 是目录,
92
+ // linked worktree 的 .git 是指向主仓库 gitdir 的普通文件。
93
+ // 白名单可能被会话 cwd(= 仓库根)污染,凭路径无法区分,
94
+ // 凭 .git 形态可以——含 .git 目录的路径一律拒绝删除。
95
+ try {
96
+ if (statSync(join(resolve(path), '.git')).isDirectory()) {
97
+ return { success: false, error: `Refusing to remove a git main working tree: ${path}` };
98
+ }
99
+ } catch { /* no .git entry — not a repo root, proceed */ }
100
+ rmSync(path, { recursive: true, force: true });
101
+ return { success: true };
102
+ } catch (err) {
103
+ return { success: false, error: (err as Error).message };
104
+ }
105
+ }
21
106
  }
22
107
 
23
- export const fsHandler = new FsHandler();
108
+ export const fsHandler = new FsHandler();
package/src/index.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { hostname } from 'os';
3
+ import { hostname, platform } from 'os';
4
4
  import { resolveKey, resolveEndpoint, saveKeyToConfig, saveEndpointToConfig } from './config.js';
5
5
  import { connectToServer } from './socketio-client.js';
6
6
  import { writePid, removePid, isRunning } from './daemon.js';
7
7
  import { startMcpRegistration } from './commands/mcp-proxy.js';
8
8
  import { startMcpProxy } from './commands/mcp.js';
9
+ import { processRegistry, isProcessAlive } from './acp/process-registry.js';
9
10
 
10
11
  const program = new Command();
11
12
 
@@ -43,8 +44,32 @@ program
43
44
  serverName: options.name || hostname(),
44
45
  });
45
46
 
46
- const shutdown = () => {
47
+ const shutdown = async () => {
47
48
  console.log('Shutting down...');
49
+ // Kill all registered ACP processes before exiting, using the same
50
+ // TERM → short wait → KILL semantics as the acp:stop handler
51
+ const registered = processRegistry.listEntries();
52
+ if (registered.length > 0) {
53
+ console.log(`Killing ${registered.length} registered ACP process(es)...`);
54
+ for (const entry of registered) {
55
+ try {
56
+ if (platform() === 'win32') {
57
+ const { execFileSync } = await import('child_process');
58
+ execFileSync('taskkill', ['/PID', String(entry.pid), '/T', '/F'], { timeout: 5000 });
59
+ } else {
60
+ process.kill(-entry.pid, 'SIGTERM');
61
+ }
62
+ } catch { /* process may already be dead */ }
63
+ }
64
+ // Give processes a moment to exit
65
+ await new Promise((r) => setTimeout(r, 1000));
66
+ for (const entry of registered) {
67
+ if (platform() !== 'win32' && isProcessAlive(entry.pid)) {
68
+ try { process.kill(-entry.pid, 'SIGKILL'); } catch { /* already dead */ }
69
+ }
70
+ processRegistry.deregister(entry.sessionId);
71
+ }
72
+ }
48
73
  socket.disconnect();
49
74
  removePid();
50
75
  process.exit(0);
@@ -15,12 +15,17 @@ import { gitHandler } from './git-handler.js';
15
15
  import { worktreeHandler } from './worktree-handler.js';
16
16
  import { fsHandler } from './fs-handler.js';
17
17
  import { gatewayManager } from './gateway.js';
18
+ import { processRegistry, isProcessAlive } from './acp/process-registry.js';
18
19
 
19
20
  const CONTROLLER_VERSION = '1.1.1';
20
21
  const acpBridges = new Map<string, AcpSessionBridge>();
21
22
  const permissionRequestBridge = new Map<string, AcpSessionBridge>();
22
23
  const elicitationRequestBridge = new Map<string, AcpSessionBridge>();
23
24
 
25
+ function isStringArray(value: unknown): value is string[] {
26
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
27
+ }
28
+
24
29
  function createSpawner(serverType: string, sessionId: string, cwd: string): AcpSpawner {
25
30
  const config = acpDetector.getSpawnerConfig(serverType);
26
31
  return new AcpSpawner({ sessionId, cwd, command: config.command, args: config.args });
@@ -85,6 +90,7 @@ interface AcpCancelPayload {
85
90
 
86
91
  interface AcpStopPayload {
87
92
  sessionId: string;
93
+ ackRequired?: boolean;
88
94
  }
89
95
 
90
96
  interface AcpSetConfigOptionPayload {
@@ -128,6 +134,8 @@ interface AcpResumePayload {
128
134
  cwd: string;
129
135
  mcpEndpoint?: { url: string; token: string; serverNames: string[] };
130
136
  additionalDirectories?: string[];
137
+ modeId?: string;
138
+ modelId?: string;
131
139
  }
132
140
 
133
141
  interface AcpListSessionsPayload {
@@ -208,6 +216,12 @@ export function connectToServer(
208
216
  const heartbeat = new Heartbeat();
209
217
  const controllerName = options?.serverName || hostname();
210
218
 
219
+ // Seed the fs:remove-dir whitelist with cwds of ACP processes that survived
220
+ // a controller restart (their worktrees remain controller-managed).
221
+ for (const entry of processRegistry.listEntries()) {
222
+ fsHandler.registerAllowedPath(entry.cwd);
223
+ }
224
+
211
225
  socket.on('connect', () => {
212
226
  console.log(`Connected to ${endpoint}`);
213
227
  const resumeToken = getResumeTokenFromConfig();
@@ -294,6 +308,14 @@ export function connectToServer(
294
308
  console.log(`[ACP ctrl] acp:start received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} cwd=${data.cwd} ${describeMcpConfig(data)}`);
295
309
 
296
310
  try {
311
+ // 同 sessionId 重复 start(如 start 超时后的兜底 resume/重试与迟到初始化交叠):
312
+ // 先停掉旧 bridge,避免同会话双进程、旧 bridge 泄漏
313
+ const existingBridge = acpBridges.get(data.sessionId);
314
+ if (existingBridge) {
315
+ console.warn(`[ACP ctrl] acp:start for session ${data.sessionId} already has a bridge, stopping the previous one`);
316
+ acpBridges.delete(data.sessionId);
317
+ await existingBridge.stop().catch(() => {});
318
+ }
297
319
  const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
298
320
  const bridge = new AcpSessionBridge(socket, {
299
321
  sessionId: data.sessionId,
@@ -307,6 +329,41 @@ export function connectToServer(
307
329
 
308
330
  acpBridges.set(data.sessionId, bridge);
309
331
 
332
+ // The session cwd is a controller-managed worktree: allow fs:remove-dir on it
333
+ fsHandler.registerAllowedPath(data.cwd);
334
+ // Registry lifecycle is bound to the bridge: any stop exit (user close,
335
+ // agent exit, error) deregisters the PID so stale entries cannot pile up
336
+ bridge.on('acp:stopped', (payload: { sessionId: string }) => {
337
+ if (payload.sessionId === data.sessionId) {
338
+ processRegistry.deregister(data.sessionId);
339
+ }
340
+ });
341
+
342
+ // Register PID in process registry after spawner starts
343
+ const registerPid = () => {
344
+ const pid = spawner.pid;
345
+ if (pid) {
346
+ processRegistry.register(data.sessionId, {
347
+ pid,
348
+ cwd: data.cwd,
349
+ acpServerType: data.serverType,
350
+ });
351
+ }
352
+ };
353
+ // Register immediately if pid already available, otherwise wait for spawn
354
+ registerPid();
355
+ if (!spawner.pid) {
356
+ spawner.closed.then(() => {}).catch(() => {});
357
+ // Poll briefly for pid to appear after spawn
358
+ const pollId = setInterval(() => {
359
+ if (spawner.pid) {
360
+ clearInterval(pollId);
361
+ registerPid();
362
+ }
363
+ }, 50);
364
+ setTimeout(() => clearInterval(pollId), 5000);
365
+ }
366
+
310
367
  bridge.on('permission:sent', (requestId: string) => {
311
368
  permissionRequestBridge.set(requestId, bridge);
312
369
  });
@@ -346,6 +403,7 @@ export function connectToServer(
346
403
  bridge.off('acp:initialized', onInitialized);
347
404
  bridge.off('acp:stopped', onStopped);
348
405
  acpBridges.delete(data.sessionId);
406
+ processRegistry.deregister(data.sessionId);
349
407
  console.log(`[ACP ctrl] emitting acp:start:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
350
408
  socket.emit('acp:start:result', {
351
409
  requestId: data.requestId,
@@ -375,16 +433,33 @@ export function connectToServer(
375
433
  console.log(`[ACP ctrl] acp:resume received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} externalSessionId=${data.externalSessionId} ${describeMcpConfig(data)}`);
376
434
 
377
435
  try {
436
+ // 同 sessionId 重复 resume/start:先停掉旧 bridge(同 acp:start 的防双进程处理)
437
+ const existingBridge = acpBridges.get(data.sessionId);
438
+ if (existingBridge) {
439
+ console.warn(`[ACP ctrl] acp:resume for session ${data.sessionId} already has a bridge, stopping the previous one`);
440
+ acpBridges.delete(data.sessionId);
441
+ await existingBridge.stop().catch(() => {});
442
+ }
378
443
  const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
379
444
  const bridge = new AcpSessionBridge(socket, {
380
445
  sessionId: data.sessionId,
381
446
  cwd: data.cwd,
382
447
  mcpEndpoint: data.mcpEndpoint,
383
448
  additionalDirectories: data.additionalDirectories,
449
+ // 初始 mode/model:resume 完成后、acp:initialized 之前应用(时序同 acp:start)
450
+ initialModeId: data.modeId,
451
+ initialModelId: data.modelId,
384
452
  }, spawner);
385
453
 
386
454
  acpBridges.set(data.sessionId, bridge);
387
455
 
456
+ fsHandler.registerAllowedPath(data.cwd);
457
+ bridge.on('acp:stopped', (payload: { sessionId: string }) => {
458
+ if (payload.sessionId === data.sessionId) {
459
+ processRegistry.deregister(data.sessionId);
460
+ }
461
+ });
462
+
388
463
  bridge.on('permission:sent', (requestId: string) => {
389
464
  permissionRequestBridge.set(requestId, bridge);
390
465
  });
@@ -419,6 +494,7 @@ export function connectToServer(
419
494
  bridge.off('acp:initialized', onInitialized);
420
495
  bridge.off('acp:stopped', onStopped);
421
496
  acpBridges.delete(data.sessionId);
497
+ processRegistry.deregister(data.sessionId);
422
498
  console.log(`[ACP ctrl] emitting acp:resume:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
423
499
  socket.emit('acp:resume:result', {
424
500
  requestId: data.requestId,
@@ -580,12 +656,47 @@ export function connectToServer(
580
656
  }
581
657
  });
582
658
 
583
- socket.on('acp:stop', (data: AcpStopPayload) => {
584
- console.log(`ACP stop requested for session ${data.sessionId}`);
659
+ socket.on('acp:stop', async (data: AcpStopPayload) => {
660
+ if (!data || typeof data.sessionId !== 'string') {
661
+ console.warn(`[ACP ctrl] acp:stop ignored: invalid payload ${JSON.stringify(data)}`);
662
+ return;
663
+ }
664
+ console.log(`ACP stop requested for session ${data.sessionId} ackRequired=${data.ackRequired ?? false}`);
585
665
  const bridge = acpBridges.get(data.sessionId);
666
+ let killedByRegistry = false;
586
667
  if (bridge) {
587
- bridge.stop();
668
+ await bridge.stop();
588
669
  acpBridges.delete(data.sessionId);
670
+ } else {
671
+ // Bridge not found: fallback to registry PID kill
672
+ const entry = processRegistry.get(data.sessionId);
673
+ if (entry) {
674
+ console.log(`[ACP ctrl] bridge not found for session ${data.sessionId}, killing by PID ${entry.pid}`);
675
+ killedByRegistry = true;
676
+ try {
677
+ if (platform() === 'win32') {
678
+ const { execFile } = await import('child_process');
679
+ await new Promise<void>((resolve) => {
680
+ execFile('taskkill', ['/PID', String(entry.pid), '/T', '/F'], () => resolve());
681
+ });
682
+ } else {
683
+ process.kill(-entry.pid, 'SIGTERM');
684
+ setTimeout(() => {
685
+ // Probe before SIGKILL: the PID may have been reused after exit
686
+ if (!isProcessAlive(entry.pid)) return;
687
+ try { process.kill(-entry.pid, 'SIGKILL'); } catch { /* already dead */ }
688
+ }, 5000);
689
+ }
690
+ } catch { /* process may already be dead */ }
691
+ processRegistry.deregister(data.sessionId);
692
+ }
693
+ }
694
+ if (data.ackRequired) {
695
+ socket.emit('acp:stopped', {
696
+ sessionId: data.sessionId,
697
+ reason: 'user_close',
698
+ killed: killedByRegistry,
699
+ });
589
700
  }
590
701
  });
591
702
 
@@ -780,16 +891,22 @@ export function connectToServer(
780
891
  // Worktree / Git extension events
781
892
  socket.on('fs:mkdir', async (data: FsMkdirPayload) => {
782
893
  const result = await fsHandler.mkdir(data.parentPath, data.folderName);
894
+ if (result.success && result.path) {
895
+ fsHandler.registerAllowedPath(result.path);
896
+ }
783
897
  socket.emit('fs:mkdir:result', { requestId: data.requestId, ...result });
784
898
  });
785
899
 
786
900
  socket.on('git:worktree:add', async (data: GitWorktreeAddPayload) => {
787
901
  const result = await worktreeHandler.add(data.repoPath, data.worktreePath, data.branch);
902
+ if (result.success) {
903
+ fsHandler.registerAllowedPath(data.worktreePath);
904
+ }
788
905
  socket.emit('git:worktree:add:result', { requestId: data.requestId, ...result });
789
906
  });
790
907
 
791
- socket.on('git:worktree:remove', async (data: GitWorktreeRemovePayload) => {
792
- const result = await worktreeHandler.remove(data.repoPath, data.worktreePath);
908
+ socket.on('git:worktree:remove', async (data: GitWorktreeRemovePayload & { force?: boolean }) => {
909
+ const result = await worktreeHandler.remove(data.repoPath, data.worktreePath, data.force);
793
910
  socket.emit('git:worktree:remove:result', { requestId: data.requestId, ...result });
794
911
  });
795
912
 
@@ -803,6 +920,60 @@ export function connectToServer(
803
920
  socket.emit('git:commit:result', { requestId: data.requestId, ...result });
804
921
  });
805
922
 
923
+ // Worktree list event
924
+ socket.on('git:worktree:list', async (data: { requestId: string; workspacePath: string }) => {
925
+ console.log(`[ACP ctrl] git:worktree:list received: requestId=${data.requestId} workspacePath=${data.workspacePath}`);
926
+ const result = await worktreeHandler.list(data.workspacePath);
927
+ socket.emit('git:worktree:list:result', { requestId: data.requestId, ...result });
928
+ });
929
+
930
+ // FS remove-dir event
931
+ socket.on('fs:remove-dir', async (data: { requestId: string; path: string }) => {
932
+ console.log(`[ACP ctrl] fs:remove-dir received: requestId=${data.requestId} path=${data.path}`);
933
+ const result = await fsHandler.removeDir(data.path);
934
+ if (result.success) {
935
+ fsHandler.unregisterAllowedPath(data.path);
936
+ }
937
+ socket.emit('fs:remove-dir:result', { requestId: data.requestId, ...result });
938
+ });
939
+
940
+ // Reconnection reconciliation
941
+ socket.on('acp:reconcile', async (data: { activeSessionIds: string[]; pendingKillSessionIds: string[] }) => {
942
+ if (!data || !isStringArray(data.activeSessionIds) || !isStringArray(data.pendingKillSessionIds)) {
943
+ console.warn(`[ACP ctrl] acp:reconcile ignored: invalid payload ${JSON.stringify(data)}`);
944
+ return;
945
+ }
946
+ console.log(`[ACP ctrl] acp:reconcile received: active=${data.activeSessionIds.length} pendingKill=${data.pendingKillSessionIds.length}`);
947
+ const registered = processRegistry.listEntries();
948
+ for (const entry of registered) {
949
+ const shouldKill = data.pendingKillSessionIds.includes(entry.sessionId) || !data.activeSessionIds.includes(entry.sessionId);
950
+ if (shouldKill) {
951
+ console.log(`[ACP ctrl] reconcile: killing session=${entry.sessionId} pid=${entry.pid} (pendingKill=${data.pendingKillSessionIds.includes(entry.sessionId)})`);
952
+ try {
953
+ if (platform() === 'win32') {
954
+ const { execFile } = await import('child_process');
955
+ await new Promise<void>((resolve) => {
956
+ execFile('taskkill', ['/PID', String(entry.pid), '/T', '/F'], () => resolve());
957
+ });
958
+ } else {
959
+ process.kill(-entry.pid, 'SIGTERM');
960
+ setTimeout(() => {
961
+ // Probe before SIGKILL: the PID may have been reused after exit
962
+ if (!isProcessAlive(entry.pid)) return;
963
+ try { process.kill(-entry.pid, 'SIGKILL'); } catch { /* already dead */ }
964
+ }, 5000);
965
+ }
966
+ } catch { /* process may already be dead */ }
967
+ processRegistry.deregister(entry.sessionId);
968
+ // Also clean up bridge if present
969
+ const bridge = acpBridges.get(entry.sessionId);
970
+ if (bridge) {
971
+ acpBridges.delete(entry.sessionId);
972
+ }
973
+ }
974
+ }
975
+ });
976
+
806
977
  // Port Forward events (event names kept unchanged for backward compatibility)
807
978
  socket.on('port-forward:create', async (data: { tunnelId: string; port: number }) => {
808
979
  console.log(`Gateway tunnel create requested: tunnelId=${data.tunnelId} port=${data.port}`);
@@ -31,6 +31,18 @@ export interface WorktreeRemoveResult {
31
31
  error?: string;
32
32
  }
33
33
 
34
+ export interface WorktreeInfo {
35
+ path: string;
36
+ branch: string | null;
37
+ head: string;
38
+ }
39
+
40
+ export interface WorktreeListResult {
41
+ success: boolean;
42
+ worktrees?: WorktreeInfo[];
43
+ error?: string;
44
+ }
45
+
34
46
  export interface GitStatusResult {
35
47
  isClean: boolean;
36
48
  error?: string;
@@ -62,10 +74,37 @@ export class WorktreeHandler {
62
74
  }
63
75
  }
64
76
 
65
- async remove(repoPath: string, worktreePath: string): Promise<WorktreeRemoveResult> {
77
+ async list(workspacePath: string): Promise<WorktreeListResult> {
78
+ try {
79
+ const resolvedPath = expandTilde(workspacePath);
80
+ const { stdout } = await execAsync(`git -C "${resolvedPath}" worktree list --porcelain`);
81
+ const worktrees: WorktreeInfo[] = [];
82
+ const blocks = stdout.trim().split('\n\n');
83
+ for (const block of blocks) {
84
+ if (!block.trim()) continue;
85
+ const lines = Object.fromEntries(
86
+ block.split('\n').map(line => {
87
+ const idx = line.indexOf(' ');
88
+ return [line.slice(0, idx), line.slice(idx + 1)];
89
+ }),
90
+ );
91
+ worktrees.push({
92
+ path: lines['worktree'] || '',
93
+ branch: lines['branch']?.replace('refs/heads/', '') ?? null,
94
+ head: lines['HEAD'] || '',
95
+ });
96
+ }
97
+ return { success: true, worktrees };
98
+ } catch (err) {
99
+ return { success: false, error: (err as Error).message };
100
+ }
101
+ }
102
+
103
+ async remove(repoPath: string, worktreePath: string, force = false): Promise<WorktreeRemoveResult> {
66
104
  try {
67
105
  const resolvedPath = expandTilde(worktreePath);
68
- const { stderr } = await execAsync(`git -C "${repoPath}" worktree remove "${resolvedPath}"`);
106
+ const forceFlag = force ? ' --force' : '';
107
+ const { stderr } = await execAsync(`git -C "${repoPath}" worktree remove${forceFlag} "${resolvedPath}"`);
69
108
  if (stderr) return { success: false, error: stderr.trim() };
70
109
  try {
71
110
  if (existsSync(resolvedPath)) {