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.
- package/dist/acp/bridge.d.ts.map +1 -1
- package/dist/acp/bridge.js +11 -2
- package/dist/acp/bridge.js.map +1 -1
- package/dist/acp/bridge.test.js +29 -0
- package/dist/acp/bridge.test.js.map +1 -1
- package/dist/acp/process-registry.d.ts +30 -0
- package/dist/acp/process-registry.d.ts.map +1 -0
- package/dist/acp/process-registry.integration.test.d.ts +2 -0
- package/dist/acp/process-registry.integration.test.d.ts.map +1 -0
- package/dist/acp/process-registry.integration.test.js +52 -0
- package/dist/acp/process-registry.integration.test.js.map +1 -0
- package/dist/acp/process-registry.js +110 -0
- package/dist/acp/process-registry.js.map +1 -0
- package/dist/acp/process-registry.test.d.ts +2 -0
- package/dist/acp/process-registry.test.d.ts.map +1 -0
- package/dist/acp/process-registry.test.js +209 -0
- package/dist/acp/process-registry.test.js.map +1 -0
- package/dist/acp/spawner.d.ts +1 -0
- package/dist/acp/spawner.d.ts.map +1 -1
- package/dist/acp/spawner.js +32 -10
- package/dist/acp/spawner.js.map +1 -1
- package/dist/fs-handler.d.ts +11 -0
- package/dist/fs-handler.d.ts.map +1 -1
- package/dist/fs-handler.js +79 -2
- package/dist/fs-handler.js.map +1 -1
- package/dist/fs-handler.test.js +78 -1
- package/dist/fs-handler.test.js.map +1 -1
- package/dist/index.js +32 -2
- package/dist/index.js.map +1 -1
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +177 -4
- package/dist/socketio-client.js.map +1 -1
- package/dist/worktree-handler.d.ts +12 -1
- package/dist/worktree-handler.d.ts.map +1 -1
- package/dist/worktree-handler.js +28 -2
- package/dist/worktree-handler.js.map +1 -1
- package/package.json +1 -1
- package/src/acp/bridge.test.ts +37 -0
- package/src/acp/bridge.ts +11 -2
- package/src/acp/process-registry.integration.test.ts +65 -0
- package/src/acp/process-registry.test.ts +239 -0
- package/src/acp/process-registry.ts +139 -0
- package/src/acp/spawner.ts +26 -10
- package/src/fs-handler.test.ts +106 -2
- package/src/fs-handler.ts +88 -3
- package/src/index.ts +27 -2
- package/src/socketio-client.ts +176 -5
- package/src/worktree-handler.ts +41 -2
- package/tests/acp-spawner.test.ts +58 -26
- package/tests/fs-handler.test.ts +63 -0
- 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);
|
package/src/socketio-client.ts
CHANGED
|
@@ -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
|
-
|
|
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}`);
|
package/src/worktree-handler.ts
CHANGED
|
@@ -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
|
|
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
|
|
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)) {
|