lark-remote 0.4.1 → 0.4.3
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/CHANGELOG.md +39 -0
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/bridge/approval-coordinator.d.ts +2 -0
- package/dist/bridge/approval-coordinator.js +16 -7
- package/dist/bridge/index.d.ts +22 -0
- package/dist/bridge/index.js +138 -56
- package/dist/card/approval-render.js +9 -8
- package/dist/card/card-budget.js +39 -26
- package/dist/card/run-renderer.js +9 -2
- package/dist/card/text-truncate.d.ts +9 -10
- package/dist/card/text-truncate.js +48 -43
- package/dist/cli.js +17 -0
- package/dist/config/codex-config.d.ts +3 -0
- package/dist/config/codex-config.js +4 -1
- package/dist/config/dir.js +6 -6
- package/dist/config/index.d.ts +7 -5
- package/dist/config/index.js +52 -13
- package/dist/config/kimi-config.d.ts +4 -1
- package/dist/config/kimi-config.js +5 -2
- package/dist/config/pi-config.d.ts +2 -7
- package/dist/config/pi-config.js +6 -6
- package/dist/config/wizard.js +8 -4
- package/dist/connector/index.d.ts +19 -0
- package/dist/connector/index.js +134 -23
- package/dist/inbound/placeholder.d.ts +3 -2
- package/dist/inbound/placeholder.js +17 -7
- package/dist/index.js +28 -6
- package/dist/instance-lock.d.ts +31 -15
- package/dist/instance-lock.js +64 -62
- package/dist/platform/agent-stopper.d.ts +29 -7
- package/dist/platform/agent-stopper.js +40 -8
- package/dist/platform/identity.d.ts +59 -3
- package/dist/platform/identity.js +105 -30
- package/dist/platform/path.d.ts +0 -7
- package/dist/platform/path.js +1 -51
- package/dist/platform/shell.d.ts +14 -0
- package/dist/platform/shell.js +35 -2
- package/dist/platform/sleep-blocker.d.ts +6 -0
- package/dist/platform/sleep-blocker.js +31 -5
- package/dist/platform/spawn.d.ts +1 -1
- package/dist/platform/spawn.js +1 -1
- package/dist/platform/terminator-posix.d.ts +1 -29
- package/dist/platform/terminator-posix.js +32 -30
- package/dist/platform/terminator-win32.js +4 -2
- package/dist/platform/terminator.d.ts +2 -2
- package/dist/platform/terminator.js +17 -2
- package/dist/platform/types.d.ts +3 -0
- package/dist/router/card-action-feedback.d.ts +17 -0
- package/dist/router/card-action-feedback.js +31 -0
- package/dist/router/card-helpers.d.ts +38 -2
- package/dist/router/card-helpers.js +79 -2
- package/dist/router/config/claude.js +8 -5
- package/dist/router/index.d.ts +41 -6
- package/dist/router/index.js +254 -84
- package/dist/runner/claude/runner.js +1 -1
- package/dist/runner/claude/session.d.ts +2 -6
- package/dist/runner/claude/session.js +7 -8
- package/dist/runner/codex/app-server/protocol-types.d.ts +4 -0
- package/dist/runner/codex/app-server/protocol-types.js +4 -0
- package/dist/runner/codex/app-server/runner.js +3 -0
- package/dist/runner/common/acp/base-acp-runner.js +7 -0
- package/dist/runner/common/connection-based-runner.d.ts +18 -0
- package/dist/runner/common/connection-based-runner.js +31 -0
- package/dist/runner/common/jsonrpc/client.js +6 -1
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +20 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +27 -1
- package/dist/runner/common/jsonrpc/transport.d.ts +29 -1
- package/dist/runner/common/jsonrpc/transport.js +79 -10
- package/dist/runner/common/spawning-runner.d.ts +49 -19
- package/dist/runner/common/spawning-runner.js +94 -54
- package/dist/runner/dsh/client.d.ts +1 -3
- package/dist/runner/dsh/client.js +0 -4
- package/dist/runner/dsh/types.d.ts +0 -36
- package/dist/runner/kimi/acp/runner.d.ts +18 -1
- package/dist/runner/kimi/acp/runner.js +100 -42
- package/dist/runner/kimi/acp/translator.d.ts +0 -1
- package/dist/runner/opencode/acp/translator.d.ts +0 -1
- package/dist/runner/pi/rpc/protocol-types.d.ts +4 -0
- package/dist/runner/pi/rpc/runner.js +3 -0
- package/dist/runner/resolve-agent-choices.js +0 -5
- package/package.json +2 -2
- package/dist/runner/common/process-stopper.d.ts +0 -7
- package/dist/runner/common/process-stopper.js +0 -8
- package/dist/runner/dsh/fake-dsh-server.d.ts +0 -60
- package/dist/runner/dsh/fake-dsh-server.js +0 -156
package/dist/instance-lock.js
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 单实例锁(configDir 粒度)。
|
|
3
|
+
*
|
|
4
|
+
* 锁文件固定两行:`pid\n<进程名>`。进程名用于 pid 复用防护——锁定进程崩溃后
|
|
5
|
+
* pid 可能被系统回收给无关进程,纯 `kill(pid, 0)` 存活探测分不出来。
|
|
6
|
+
*
|
|
7
|
+
* 校验单源 = `platform/identity`(与 killOrphan 同一套判定、同一套名字口径):
|
|
8
|
+
* - posix `ps -o command=`(**不用 comm**:bash/垫片包装的进程 comm 只会给
|
|
9
|
+
* 解释器名,拿它比对会假阴性);
|
|
10
|
+
* - win32 CIM `Get-CimInstance Win32_Process`(Windows 没有 ps)。
|
|
11
|
+
* 名字两侧都必须过 `binaryName`:记录值与比较口径各写一份就会永远判「名字
|
|
12
|
+
* 不符 → 陈旧」,等于自造一次半迁移。
|
|
13
|
+
*
|
|
14
|
+
* 与 killOrphan 的差异:killOrphan 是 fail-closed(身份不明就不杀进程),
|
|
15
|
+
* 这里是 fail-open(身份不明就认定锁还活着,绝不 clobber 无法证伪的锁)。
|
|
16
|
+
* 两者方向相反,因此身份判定取三态而不是布尔。
|
|
17
|
+
*/
|
|
1
18
|
import fs from 'node:fs';
|
|
2
19
|
import path from 'node:path';
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
4
20
|
import { silentlyUnlink } from './common/fs.js';
|
|
21
|
+
import { binaryName, verifyPidIdentityVerdict } from './platform/identity.js';
|
|
5
22
|
export class InstanceAlreadyRunningError extends Error {
|
|
6
23
|
pid;
|
|
7
24
|
lockPath;
|
|
@@ -39,22 +56,22 @@ function isPidAlive(pid) {
|
|
|
39
56
|
}
|
|
40
57
|
export class InstanceLock {
|
|
41
58
|
lockPath;
|
|
42
|
-
|
|
43
|
-
* P2-20: injectable process-running probe for testing, and a process-name
|
|
44
|
-
* verifier to defend against PID reuse (a stale pid recycled by an unrelated
|
|
45
|
-
* process would otherwise be misjudged as "our instance still running").
|
|
46
|
-
* Default implementation checks the process name via `ps` when available.
|
|
47
|
-
*/
|
|
48
|
-
isProcessRunning;
|
|
59
|
+
verifyIdentity;
|
|
49
60
|
constructor(lockPath, opts) {
|
|
50
61
|
this.lockPath = lockPath;
|
|
51
|
-
this.
|
|
52
|
-
opts?.
|
|
62
|
+
this.verifyIdentity =
|
|
63
|
+
opts?.verifyIdentity ??
|
|
64
|
+
((pid, expectedBinary) => verifyPidIdentityVerdict(pid, { expectedBinary }));
|
|
53
65
|
}
|
|
54
|
-
|
|
66
|
+
/**
|
|
67
|
+
* 取锁。**异步**是接口的一部分:身份校验走 platform/identity(win32 侧是
|
|
68
|
+
* 异步 CIM 查询),调用方必须 await——漏 await 会在校验完成前就放行第二条
|
|
69
|
+
* 实例(这正是锁要防的事)。
|
|
70
|
+
*/
|
|
71
|
+
async acquire() {
|
|
55
72
|
fs.mkdirSync(path.dirname(this.lockPath), { recursive: true });
|
|
56
73
|
// Atomic create-with-exclusive: 'wx' fails if file already exists.
|
|
57
|
-
// This eliminates the TOCTOU race between readPid+
|
|
74
|
+
// This eliminates the TOCTOU race between readPid+verification
|
|
58
75
|
// and writeFileSync in the previous implementation.
|
|
59
76
|
try {
|
|
60
77
|
const fd = fs.openSync(this.lockPath, 'wx');
|
|
@@ -64,8 +81,8 @@ export class InstanceLock {
|
|
|
64
81
|
catch (err) {
|
|
65
82
|
if (err.code === 'EEXIST') {
|
|
66
83
|
// Lock file exists — check if the owner is still alive
|
|
67
|
-
const { pid,
|
|
68
|
-
if (pid && this.
|
|
84
|
+
const { pid, name } = this.readPidAndName();
|
|
85
|
+
if (pid && (await this.isHeldByLiveInstance(pid, name))) {
|
|
69
86
|
throw new InstanceAlreadyRunningError(pid, this.lockPath);
|
|
70
87
|
}
|
|
71
88
|
// Stale lock: remove and retry once
|
|
@@ -79,8 +96,8 @@ export class InstanceLock {
|
|
|
79
96
|
if (retryErr.code === 'EEXIST') {
|
|
80
97
|
// Another process created the lock between unlink and retry.
|
|
81
98
|
// Bounded retry (max 1): give up rather than recurse.
|
|
82
|
-
const { pid,
|
|
83
|
-
if (pid && this.
|
|
99
|
+
const { pid, name } = this.readPidAndName();
|
|
100
|
+
if (pid && (await this.isHeldByLiveInstance(pid, name))) {
|
|
84
101
|
throw new InstanceAlreadyRunningError(pid, this.lockPath);
|
|
85
102
|
}
|
|
86
103
|
// New lock is also stale — give up rather than recurse.
|
|
@@ -95,7 +112,7 @@ export class InstanceLock {
|
|
|
95
112
|
}
|
|
96
113
|
}
|
|
97
114
|
release() {
|
|
98
|
-
const { pid } = this.
|
|
115
|
+
const { pid } = this.readPidAndName();
|
|
99
116
|
if (pid === process.pid) {
|
|
100
117
|
silentlyUnlink(this.lockPath);
|
|
101
118
|
}
|
|
@@ -125,59 +142,44 @@ export class InstanceLock {
|
|
|
125
142
|
}
|
|
126
143
|
}
|
|
127
144
|
/**
|
|
128
|
-
* Lock file stores `pid\
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* the stale pid.
|
|
145
|
+
* Lock file stores `pid\nbinaryName`, the owning process's executable name
|
|
146
|
+
* captured at acquire time. `binaryName(process.execPath)` 是唯一在
|
|
147
|
+
* posix/win32 都稳定可比的口径:win32 没有 ps,而 execPath(`C:\...\node.exe`)
|
|
148
|
+
* 与 CIM CommandLine 首 token 归一后必然一致(bun/node 垫片场景亦然)。
|
|
133
149
|
*/
|
|
134
150
|
lockContents(pid) {
|
|
135
|
-
return `${pid}\n${
|
|
151
|
+
return `${pid}\n${binaryName(process.execPath)}`;
|
|
136
152
|
}
|
|
137
|
-
|
|
153
|
+
readPidAndName() {
|
|
138
154
|
if (!fs.existsSync(this.lockPath))
|
|
139
|
-
return { pid: undefined,
|
|
155
|
+
return { pid: undefined, name: undefined };
|
|
140
156
|
const raw = fs.readFileSync(this.lockPath, 'utf-8').trim();
|
|
141
|
-
const [pidStr, ...
|
|
157
|
+
const [pidStr, ...nameParts] = raw.split('\n');
|
|
142
158
|
const pid = Number(pidStr);
|
|
143
|
-
const
|
|
144
|
-
if (Number.isInteger(pid) && pid > 0 &&
|
|
145
|
-
return { pid,
|
|
146
|
-
// Malformed or missing
|
|
159
|
+
const name = nameParts.join('\n') || undefined;
|
|
160
|
+
if (Number.isInteger(pid) && pid > 0 && name)
|
|
161
|
+
return { pid, name };
|
|
162
|
+
// Malformed or missing name — treat as corrupt, unlink and return undefined
|
|
147
163
|
silentlyUnlink(this.lockPath);
|
|
148
|
-
return { pid: undefined,
|
|
164
|
+
return { pid: undefined, name: undefined };
|
|
149
165
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
function currentComm() {
|
|
169
|
-
return readComm(process.pid) ?? 'lark-remote';
|
|
170
|
-
}
|
|
171
|
-
function readComm(pid) {
|
|
172
|
-
try {
|
|
173
|
-
const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
|
|
174
|
-
encoding: 'utf-8',
|
|
175
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
176
|
-
}).trim();
|
|
177
|
-
return out || null;
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
return null;
|
|
166
|
+
/**
|
|
167
|
+
* 锁是否仍由「我们的」实例持有(fail-open 三态):
|
|
168
|
+
* - 进程不存在 → false(陈旧)
|
|
169
|
+
* - 无记录名(老/损坏文件)→ false(无法验证,与迁移前同口径按陈旧处理)
|
|
170
|
+
* - 身份 match → true(锁定实例还在跑,拒绝取锁)
|
|
171
|
+
* - 身份 mismatch → false(pid 已复用给无关进程,陈旧可接管)
|
|
172
|
+
* - 身份 unknown → true(查不到身份,宁可拒绝也不 clobber)
|
|
173
|
+
*/
|
|
174
|
+
async isHeldByLiveInstance(pid, name) {
|
|
175
|
+
if (!isPidAlive(pid))
|
|
176
|
+
return false;
|
|
177
|
+
if (!name)
|
|
178
|
+
return false;
|
|
179
|
+
// 记录名过 binaryName 归一:老锁文件里存的是 ps comm 的全路径形态,
|
|
180
|
+
// 不归一的话升级后第一次启动会把它误判成「名字不符 → 陈旧」。
|
|
181
|
+
const verdict = await this.verifyIdentity(pid, binaryName(name));
|
|
182
|
+
return verdict !== 'mismatch';
|
|
181
183
|
}
|
|
182
184
|
}
|
|
183
185
|
//# sourceMappingURL=instance-lock.js.map
|
|
@@ -2,21 +2,43 @@
|
|
|
2
2
|
* 每 agent 的协议停止通道注册表(design.md §3.3)。
|
|
3
3
|
*
|
|
4
4
|
* Windows 没有可拦截的跨进程 SIGTERM,优雅停止只能靠协议通道(claude 的 stdin
|
|
5
|
-
* 控制通道、codex 的 turn interrupt、ACP 的
|
|
6
|
-
*
|
|
5
|
+
* 控制通道、codex 的 turn interrupt、ACP 的 session/cancel…)。「无通道」是
|
|
6
|
+
* **合法状态**:由 Terminator 显式跳过优雅段并打日志,绝不允许悄悄等一个永远
|
|
7
|
+
* 不会来的退出事件。
|
|
8
|
+
*
|
|
9
|
+
* **键 = agent + pid,不是 agent 单维**。两条理由:
|
|
10
|
+
* 1. 同一 agent 可以同时有多个存活子进程(每个 workspace 一条长驻连接,外加
|
|
11
|
+
* claude 的进程代际更替)。按 agent 单键索引时后注册的连接会**覆盖**前一个
|
|
12
|
+
* 的通道,Terminator 便拿着 A 工作区的通道去停 B 工作区的进程;
|
|
13
|
+
* 2. Terminator 手上只有 `proc`,只有 pid 能把「这条通道」与「正要停的那个
|
|
14
|
+
* 进程」对上——agent 键圈族,pid 键定归属。
|
|
15
|
+
* 反例(pid 键必要性的现成证据):kimi 的 `terminal/kill` 复用同一个 agent 键
|
|
16
|
+
* ('kimi')去停 bash 终端子进程,pid 不同 → 查表落空 → 走树杀,语义正确,
|
|
17
|
+
* 而不是误用 agent 的 turn-cancel 通道。
|
|
7
18
|
*
|
|
8
19
|
* 独立成文件是为了避免 `terminator.ts`(接口 + 分发)与 `terminator-win32.ts`
|
|
9
20
|
* 互相 import 形成循环依赖。
|
|
10
21
|
*/
|
|
11
22
|
import type { ChildProcess } from 'node:child_process';
|
|
12
23
|
export type AgentStopper = (proc: ChildProcess) => void | Promise<void>;
|
|
13
|
-
/** agent key → 协议停止通道。测试请自建实例以隔离。 */
|
|
24
|
+
/** agent key → pid → 协议停止通道。测试请自建实例以隔离。 */
|
|
14
25
|
export declare class AgentStopperRegistry {
|
|
15
26
|
private readonly stoppers;
|
|
16
|
-
register(agent: string, stopper: AgentStopper): void;
|
|
17
|
-
get(agent: string): AgentStopper | undefined;
|
|
18
|
-
has(agent: string): boolean;
|
|
27
|
+
register(agent: string, pid: number, stopper: AgentStopper): void;
|
|
28
|
+
get(agent: string, pid: number): AgentStopper | undefined;
|
|
29
|
+
has(agent: string, pid: number): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* 注销通道。
|
|
32
|
+
*
|
|
33
|
+
* 传 `stopper` 时只在该 pid 上仍是**同一条**通道时才删:重连/进程换代可能让
|
|
34
|
+
* 同一 pid 上已经换了新通道,无脑删等于让新连接裸奔(win32 上退化成直接树杀)。
|
|
35
|
+
*
|
|
36
|
+
* @returns 是否真的删掉了
|
|
37
|
+
*/
|
|
38
|
+
unregister(agent: string, pid: number, stopper?: AgentStopper): boolean;
|
|
19
39
|
clear(): void;
|
|
40
|
+
/** 当前登记的通道条数(诊断 / 测试断言不泄漏用)。 */
|
|
41
|
+
get size(): number;
|
|
20
42
|
}
|
|
21
|
-
/** 接线用的进程级单例;runner
|
|
43
|
+
/** 接线用的进程级单例;runner 在子进程起来时注册自己的停止通道。 */
|
|
22
44
|
export declare const agentStopperRegistry: AgentStopperRegistry;
|
|
@@ -1,19 +1,51 @@
|
|
|
1
|
-
/** agent key → 协议停止通道。测试请自建实例以隔离。 */
|
|
1
|
+
/** agent key → pid → 协议停止通道。测试请自建实例以隔离。 */
|
|
2
2
|
export class AgentStopperRegistry {
|
|
3
3
|
stoppers = new Map();
|
|
4
|
-
register(agent, stopper) {
|
|
5
|
-
this.stoppers.
|
|
4
|
+
register(agent, pid, stopper) {
|
|
5
|
+
let byPid = this.stoppers.get(agent);
|
|
6
|
+
if (!byPid) {
|
|
7
|
+
byPid = new Map();
|
|
8
|
+
this.stoppers.set(agent, byPid);
|
|
9
|
+
}
|
|
10
|
+
byPid.set(pid, stopper);
|
|
6
11
|
}
|
|
7
|
-
get(agent) {
|
|
8
|
-
return this.stoppers.get(agent);
|
|
12
|
+
get(agent, pid) {
|
|
13
|
+
return this.stoppers.get(agent)?.get(pid);
|
|
9
14
|
}
|
|
10
|
-
has(agent) {
|
|
11
|
-
return this.stoppers.
|
|
15
|
+
has(agent, pid) {
|
|
16
|
+
return this.stoppers.get(agent)?.has(pid) === true;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 注销通道。
|
|
20
|
+
*
|
|
21
|
+
* 传 `stopper` 时只在该 pid 上仍是**同一条**通道时才删:重连/进程换代可能让
|
|
22
|
+
* 同一 pid 上已经换了新通道,无脑删等于让新连接裸奔(win32 上退化成直接树杀)。
|
|
23
|
+
*
|
|
24
|
+
* @returns 是否真的删掉了
|
|
25
|
+
*/
|
|
26
|
+
unregister(agent, pid, stopper) {
|
|
27
|
+
const byPid = this.stoppers.get(agent);
|
|
28
|
+
const current = byPid?.get(pid);
|
|
29
|
+
if (!byPid || current === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
if (stopper !== undefined && current !== stopper)
|
|
32
|
+
return false;
|
|
33
|
+
byPid.delete(pid);
|
|
34
|
+
if (byPid.size === 0)
|
|
35
|
+
this.stoppers.delete(agent);
|
|
36
|
+
return true;
|
|
12
37
|
}
|
|
13
38
|
clear() {
|
|
14
39
|
this.stoppers.clear();
|
|
15
40
|
}
|
|
41
|
+
/** 当前登记的通道条数(诊断 / 测试断言不泄漏用)。 */
|
|
42
|
+
get size() {
|
|
43
|
+
let total = 0;
|
|
44
|
+
for (const byPid of this.stoppers.values())
|
|
45
|
+
total += byPid.size;
|
|
46
|
+
return total;
|
|
47
|
+
}
|
|
16
48
|
}
|
|
17
|
-
/** 接线用的进程级单例;runner
|
|
49
|
+
/** 接线用的进程级单例;runner 在子进程起来时注册自己的停止通道。 */
|
|
18
50
|
export const agentStopperRegistry = new AgentStopperRegistry();
|
|
19
51
|
//# sourceMappingURL=agent-stopper.js.map
|
|
@@ -14,7 +14,36 @@ export interface VerifyPidIdentityOptions extends QueryIdentityOptions {
|
|
|
14
14
|
expectedBinary: string;
|
|
15
15
|
/** pid 文件记录的 CreationDate;旧文件缺省该字段则退化为仅命令行匹配 */
|
|
16
16
|
expectedCreationDate?: string;
|
|
17
|
+
/** 匹配强度;默认 'executable'(见 {@link IdentityMatchMode}) */
|
|
18
|
+
matchMode?: IdentityMatchMode;
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* 匹配强度。两个调用方观测到的进程形态**不同**,因此不能共用一档强度:
|
|
22
|
+
*
|
|
23
|
+
* - `'executable'`(默认):命令行的可执行位就是该名字——首 token 的 basename,
|
|
24
|
+
* 或带路径分隔符的 token 的 basename 去扩展名后相等。用于「记录值本身就是
|
|
25
|
+
* 可执行名」的调用方(`InstanceLock` 记 `binaryName(process.execPath)`)。
|
|
26
|
+
* 这里**不能放松**:把它放松成路径段匹配,一个 `node-18` 形态的无关进程就会被
|
|
27
|
+
* 判成「锁还在」,新实例再也起不来。
|
|
28
|
+
*
|
|
29
|
+
* - `'agent-invocation'`:agent CLI 普遍以 **解释器调用脚本** 的形态存在
|
|
30
|
+
* (npm/pnpm/bun 全局装的 `bin` 是 `#!/usr/bin/env node` 脚本),ps 看到的是
|
|
31
|
+
* `node <安装路径>/.../cli.js`,agent 名只出现在**路径段**里:
|
|
32
|
+
* - `pi` → `node ~/.nvm/…/lib/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js`
|
|
33
|
+
* - `dsh` → `node ~/.nvm/…/lib/node_modules/@deepseek-ai/dsh/lib/bin.js`
|
|
34
|
+
* - `codex`→ `node ~/.nvm/…/lib/node_modules/@openai/codex/bin/codex.js`(basename 恰好命中)
|
|
35
|
+
* 在 `'executable'` 之上,再接受「带路径分隔符的 token 的某个路径段等于
|
|
36
|
+
* expectedBinary,或以 expectedBinary + 非字母数字字符开头」。用于 killOrphan
|
|
37
|
+
* (记录的是二进制名,观测到的是解释器调用)。
|
|
38
|
+
*
|
|
39
|
+
* 两条纪律仍保留:裸参数不参与匹配(`bash claude`、`grep claude foo.log` 仍然
|
|
40
|
+
* 不匹配),非首 token 必须含路径分隔符。代价是「段前缀」比精确相等松一点
|
|
41
|
+
* (`node /x/pi-data/y.js` 会被判成 pi)——比旧的 `out.includes(needle)` 子串匹配
|
|
42
|
+
* 严格,且换取的是 pi/dsh 这类 agent 的孤儿真的能被回收。
|
|
43
|
+
*
|
|
44
|
+
* 未被使用的一档强度等于死代码:加档必须同时有生产调用方与用例。
|
|
45
|
+
*/
|
|
46
|
+
export type IdentityMatchMode = 'executable' | 'agent-invocation';
|
|
18
47
|
/**
|
|
19
48
|
* 查询进程身份;进程不存在或查询失败返回 null。
|
|
20
49
|
*/
|
|
@@ -26,8 +55,35 @@ export declare function queryProcessIdentity(pid: number, opts?: QueryIdentityOp
|
|
|
26
55
|
*/
|
|
27
56
|
export declare function tokenizeCommandLine(commandLine: string): string[];
|
|
28
57
|
/**
|
|
29
|
-
*
|
|
58
|
+
* 取命令行 token 的「去掉扩展名的 basename」,用于与期望二进制名比较。
|
|
59
|
+
*
|
|
60
|
+
* 导出是给「自己记录进程名」的调用方(InstanceLock 写锁文件)复用同一口径:
|
|
61
|
+
* 记录值与比较口径若各写一份,锁会永远判「名字不符 → 陈旧」,自造一次新的
|
|
62
|
+
* half-migration。win32 上 `.cmd` 垫片(`node_modules/.bin/claude.cmd`)也靠
|
|
63
|
+
* 这里的去扩展名把 `claude.cmd` 归一成 `claude`。
|
|
64
|
+
*/
|
|
65
|
+
export declare function binaryName(token: string, platform?: NodeJS.Platform): string;
|
|
66
|
+
/**
|
|
67
|
+
* 身份判定三态。`unknown`(查询失败/进程已消失)与 `mismatch`(身份不符)必须
|
|
68
|
+
* 分开:两个调用方对它们的处置**方向相反**——
|
|
69
|
+
* - killOrphan:两者都不杀(fail-closed,宁可留孤儿也不误杀无辜进程);
|
|
70
|
+
* - InstanceLock:`unknown` 退回纯存活探测(不 clobber 无法证伪的锁),
|
|
71
|
+
* 只有 `mismatch` 才判陈旧并接管。
|
|
72
|
+
* 布尔接口无法同时表达这两种语义,因此这里(async + sync 两个 verdict 入口)是
|
|
73
|
+
* 唯一判定源。
|
|
74
|
+
*/
|
|
75
|
+
export type IdentityVerdict = 'match' | 'mismatch' | 'unknown';
|
|
76
|
+
export declare function verifyPidIdentityVerdict(pid: number, opts: VerifyPidIdentityOptions): Promise<IdentityVerdict>;
|
|
77
|
+
/**
|
|
78
|
+
* 同步版判定,给无法 await 的调用方(`SpawningRunner.killOrphan`——Bridge.getRunner
|
|
79
|
+
* 是同步方法,异步化会级联整个 runner 接口)。
|
|
80
|
+
*
|
|
81
|
+
* posix 用 `execFileSync('ps', ...)`,与 `queryProcessIdentity` 的 posix 分支是
|
|
82
|
+
* 同一条命令、同一个超时;win32 **恒返回 `unknown`**——CIM 必须经 PowerShell
|
|
83
|
+
* 收 stdout,没有同步形态。`unknown` 在 killOrphan 的 fail-closed 语义下就是
|
|
84
|
+
* 「不杀 + 清陈旧 pid 文件」,与异步路径一致,因此不是能力缺失而是正确的降级。
|
|
30
85
|
*
|
|
31
|
-
*
|
|
86
|
+
* 严禁在这里退回「跳过身份校验直接 kill(pid, 0)」的旧行为:那正是本次迁移
|
|
87
|
+
* 要堵的洞(pid 被系统回收给无关进程时会误杀)。
|
|
32
88
|
*/
|
|
33
|
-
export declare function
|
|
89
|
+
export declare function verifyPidIdentityVerdictSync(pid: number, opts: VerifyPidIdentityOptions): IdentityVerdict;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* (killOrphan)应按需调用,真机矩阵(M0)需实测整体耗时;
|
|
13
13
|
* - platform 显式注入,win32 语义可在任意宿主上单测。
|
|
14
14
|
*/
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
15
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { currentPlatform, isWin32 } from './select.js';
|
|
18
18
|
const DEFAULT_TIMEOUT_MS = 3000;
|
|
@@ -134,8 +134,15 @@ export function tokenizeCommandLine(commandLine) {
|
|
|
134
134
|
tokens.push(current);
|
|
135
135
|
return tokens;
|
|
136
136
|
}
|
|
137
|
-
/**
|
|
138
|
-
|
|
137
|
+
/**
|
|
138
|
+
* 取命令行 token 的「去掉扩展名的 basename」,用于与期望二进制名比较。
|
|
139
|
+
*
|
|
140
|
+
* 导出是给「自己记录进程名」的调用方(InstanceLock 写锁文件)复用同一口径:
|
|
141
|
+
* 记录值与比较口径若各写一份,锁会永远判「名字不符 → 陈旧」,自造一次新的
|
|
142
|
+
* half-migration。win32 上 `.cmd` 垫片(`node_modules/.bin/claude.cmd`)也靠
|
|
143
|
+
* 这里的去扩展名把 `claude.cmd` 归一成 `claude`。
|
|
144
|
+
*/
|
|
145
|
+
export function binaryName(token, platform = currentPlatform) {
|
|
139
146
|
let text = token.trim();
|
|
140
147
|
if (text.length >= 2 &&
|
|
141
148
|
((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))) {
|
|
@@ -143,45 +150,113 @@ function tokenBinaryName(token, platform) {
|
|
|
143
150
|
}
|
|
144
151
|
// CIM 的 CommandLine 用 Windows 分隔符;posix 用 unix 分隔符
|
|
145
152
|
const p = isWin32(platform) ? path.win32 : path.posix;
|
|
146
|
-
const
|
|
147
|
-
const ext = p.extname(base);
|
|
148
|
-
const name = ext ? base.slice(0, -ext.length) : base;
|
|
153
|
+
const name = stripExtension(p.basename(text));
|
|
149
154
|
return caseInsensitive(platform) ? name.toLowerCase() : name;
|
|
150
155
|
}
|
|
151
156
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
157
|
+
* 命令行与期望二进制是否属于同一程序(review 2026-09-05 收紧 + 2026-09-20 分档)。
|
|
158
|
+
*
|
|
159
|
+
* 1. 可执行位匹配:首 token 的 basename 相等——`/usr/local/bin/claude --verbose`
|
|
160
|
+
* 匹配 claude;native 单文件 CLI(claude 的 `bin/claude.exe`)也走这条;
|
|
161
|
+
* 2. 或后续 token 是**带路径分隔符的脚本路径**且 basename(去扩展名)相等——
|
|
162
|
+
* `node /home/user/.local/bin/claude`、`…/@openai/codex/bin/codex.js` 匹配;
|
|
163
|
+
* 3. 仅 `'agent-invocation'` 档再放开到**路径段**:某个路径段等于 expectedBinary,
|
|
164
|
+
* 或以 expectedBinary + 非字母数字字符开头。这是为了 `node …/pi-coding-agent/…/cli.js`
|
|
165
|
+
* 这类「名字只在目录里」的安装形态(见 IdentityMatchMode)。
|
|
166
|
+
*
|
|
167
|
+
* 任意位置裸参数不参与匹配:`bash claude`(脚本恰好叫 claude)、`grep claude foo.log`
|
|
168
|
+
* 这类不再假阳性(killOrphan 场景会误杀无辜进程)。
|
|
158
169
|
*/
|
|
159
|
-
function commandLineMatches(commandLine, want, platform) {
|
|
170
|
+
function commandLineMatches(commandLine, want, platform, mode) {
|
|
160
171
|
const tokens = tokenizeCommandLine(commandLine);
|
|
161
172
|
const first = tokens[0];
|
|
162
|
-
if (first !== undefined &&
|
|
173
|
+
if (first !== undefined && binaryName(first, platform) === want)
|
|
163
174
|
return true;
|
|
164
|
-
return tokens
|
|
165
|
-
|
|
166
|
-
|
|
175
|
+
return tokens.slice(1).some((token) => {
|
|
176
|
+
// 非首 token 必须像「路径」才参与:把裸参数(`bash claude`)排除在外
|
|
177
|
+
if (!/[/\\]/.test(token))
|
|
178
|
+
return false;
|
|
179
|
+
if (binaryName(token, platform) === want)
|
|
180
|
+
return true;
|
|
181
|
+
if (mode !== 'agent-invocation')
|
|
182
|
+
return false;
|
|
183
|
+
return token.split(/[/\\]/).some((segment, index, all) => {
|
|
184
|
+
// 末段是文件名:与 binaryName 同口径去扩展名;中间段是目录名,保留原样
|
|
185
|
+
const name = index === all.length - 1 ? stripExtension(segment) : segment;
|
|
186
|
+
const normalized = caseInsensitive(platform) ? name.toLowerCase() : name;
|
|
187
|
+
if (normalized === want)
|
|
188
|
+
return true;
|
|
189
|
+
if (!normalized.startsWith(want))
|
|
190
|
+
return false;
|
|
191
|
+
// `pi-coding-agent` / `claude.mock.js` 命中;`claudette` / `claudeAgent` 不命中。
|
|
192
|
+
// 判定分隔符必须同时覆盖大小写字母(linux 档不归一大小写,`claudeX` 的 X
|
|
193
|
+
// 是字母而不是分隔符)。
|
|
194
|
+
const next = normalized[want.length];
|
|
195
|
+
return next !== undefined && !/[a-zA-Z0-9]/.test(next);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** 去掉最后一个扩展名(`claude.mock.js` → `claude.mock`)。与 binaryName 的口径一致。 */
|
|
200
|
+
function stripExtension(base) {
|
|
201
|
+
const dot = base.lastIndexOf('.');
|
|
202
|
+
return dot > 0 ? base.slice(0, dot) : base;
|
|
167
203
|
}
|
|
168
204
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* 用于 killOrphan 与实例锁陈旧判定——只有这里返回 true 才允许动手。
|
|
205
|
+
* 身份快照 → 判定。async/sync 两条取快照的路径共用这一处比较逻辑:口径若各写
|
|
206
|
+
* 一份,迟早会因为「只在一个路径上收紧规则」而分裂成两种身份语义。
|
|
172
207
|
*/
|
|
173
|
-
|
|
174
|
-
const platform = opts.platform ?? currentPlatform;
|
|
175
|
-
const identity = await queryProcessIdentity(pid, opts);
|
|
176
|
-
if (!identity)
|
|
177
|
-
return false;
|
|
208
|
+
function decideIdentity(identity, opts, platform) {
|
|
178
209
|
const want = caseInsensitive(platform) ? opts.expectedBinary.toLowerCase() : opts.expectedBinary;
|
|
179
|
-
|
|
180
|
-
|
|
210
|
+
const mode = opts.matchMode ?? 'executable';
|
|
211
|
+
if (!commandLineMatches(identity.commandLine, want, platform, mode))
|
|
212
|
+
return 'mismatch';
|
|
181
213
|
// CreationDate 仅在 pid 文件带了第三行时才比对(旧文件缺字段 → 退化为命令行匹配)
|
|
182
214
|
const expected = opts.expectedCreationDate?.trim();
|
|
183
|
-
if (expected)
|
|
184
|
-
return
|
|
185
|
-
return
|
|
215
|
+
if (expected && identity.creationDate !== expected)
|
|
216
|
+
return 'mismatch';
|
|
217
|
+
return 'match';
|
|
218
|
+
}
|
|
219
|
+
export async function verifyPidIdentityVerdict(pid, opts) {
|
|
220
|
+
const platform = opts.platform ?? currentPlatform;
|
|
221
|
+
const identity = await queryProcessIdentity(pid, opts);
|
|
222
|
+
if (!identity)
|
|
223
|
+
return 'unknown';
|
|
224
|
+
return decideIdentity(identity, opts, platform);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 同步版判定,给无法 await 的调用方(`SpawningRunner.killOrphan`——Bridge.getRunner
|
|
228
|
+
* 是同步方法,异步化会级联整个 runner 接口)。
|
|
229
|
+
*
|
|
230
|
+
* posix 用 `execFileSync('ps', ...)`,与 `queryProcessIdentity` 的 posix 分支是
|
|
231
|
+
* 同一条命令、同一个超时;win32 **恒返回 `unknown`**——CIM 必须经 PowerShell
|
|
232
|
+
* 收 stdout,没有同步形态。`unknown` 在 killOrphan 的 fail-closed 语义下就是
|
|
233
|
+
* 「不杀 + 清陈旧 pid 文件」,与异步路径一致,因此不是能力缺失而是正确的降级。
|
|
234
|
+
*
|
|
235
|
+
* 严禁在这里退回「跳过身份校验直接 kill(pid, 0)」的旧行为:那正是本次迁移
|
|
236
|
+
* 要堵的洞(pid 被系统回收给无关进程时会误杀)。
|
|
237
|
+
*/
|
|
238
|
+
export function verifyPidIdentityVerdictSync(pid, opts) {
|
|
239
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
240
|
+
return 'unknown';
|
|
241
|
+
const platform = opts.platform ?? currentPlatform;
|
|
242
|
+
if (isWin32(platform))
|
|
243
|
+
return 'unknown';
|
|
244
|
+
let stdout;
|
|
245
|
+
try {
|
|
246
|
+
stdout = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
247
|
+
encoding: 'utf-8',
|
|
248
|
+
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
249
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
250
|
+
windowsHide: true,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// 进程不存在 / ps 不可用 / 超时:查不到身份 → unknown(不杀)
|
|
255
|
+
return 'unknown';
|
|
256
|
+
}
|
|
257
|
+
const commandLine = stdout.split('\n')[0]?.trim() ?? '';
|
|
258
|
+
if (!commandLine)
|
|
259
|
+
return 'unknown';
|
|
260
|
+
return decideIdentity({ commandLine }, opts, platform);
|
|
186
261
|
}
|
|
187
262
|
//# sourceMappingURL=identity.js.map
|
package/dist/platform/path.d.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
export interface PathOptions {
|
|
2
2
|
platform?: NodeJS.Platform;
|
|
3
|
-
/** 环境变量注入(测试用);默认 process.env */
|
|
4
|
-
env?: Record<string, string | undefined>;
|
|
5
|
-
/** 家目录注入(测试用);默认 os.homedir() */
|
|
6
|
-
home?: string;
|
|
7
|
-
/** 是否执行 realpath(默认 true);不存在的路径自动跳过 */
|
|
8
|
-
realpath?: boolean;
|
|
9
3
|
}
|
|
10
|
-
export declare function canonicalPath(p: string, opts?: PathOptions): string;
|
|
11
4
|
export declare function samePath(a: string, b: string, opts?: PathOptions): boolean;
|
|
12
5
|
/**
|
|
13
6
|
* cwd → 会话项目目录名(Claude/pi 的 on-disk 编码,lossy N-to-N)。
|
package/dist/platform/path.js
CHANGED
|
@@ -3,66 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 所有 cwd 相等性判断、会话目录编码、pid 文件名后缀、spawn cwd 前统一走本模块。
|
|
5
5
|
* 语义映射:
|
|
6
|
-
* canonicalPath(p): realpath(expandEnv(expandTilde(normalizeSeparators(p))))
|
|
7
6
|
* samePath(a, b): win32/darwin 默认大小写不敏感 + 分隔符不敏感;linux 严格相等
|
|
8
7
|
* displayName(p): path.basename(win32 原生认识 \ 与 /)
|
|
9
8
|
*
|
|
10
9
|
* 全部函数接受显式 `platform` 参数(默认 currentPlatform),使 win32 语义
|
|
11
10
|
* 可在任意宿主的单元测试中验证。
|
|
12
11
|
*/
|
|
13
|
-
import fs from 'node:fs';
|
|
14
|
-
import os from 'node:os';
|
|
15
12
|
import path from 'node:path';
|
|
16
13
|
import { currentPlatform, isWin32 } from './select.js';
|
|
17
|
-
/** 展开开头的 ~ 或 ~/(win32 含 ~\)为家目录;其余原样返回。 */
|
|
18
|
-
function expandTilde(p, home, platform) {
|
|
19
|
-
if (p === '~')
|
|
20
|
-
return home;
|
|
21
|
-
if (p.startsWith('~/') || (isWin32(platform) && p.startsWith('~\\'))) {
|
|
22
|
-
return path.join(home, p.slice(2));
|
|
23
|
-
}
|
|
24
|
-
return p;
|
|
25
|
-
}
|
|
26
|
-
/** 展开 %VAR%(win32)或 $VAR / ${VAR}(posix);未定义的变量保留字面量。 */
|
|
27
|
-
function expandEnv(p, env, platform) {
|
|
28
|
-
if (isWin32(platform)) {
|
|
29
|
-
return p.replace(/%([A-Za-z0-9_]+)%/g, (match, name) => env[name] ?? match);
|
|
30
|
-
}
|
|
31
|
-
return p.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (match, braced, plain) => {
|
|
32
|
-
const name = braced ?? plain;
|
|
33
|
-
if (name === undefined)
|
|
34
|
-
return match;
|
|
35
|
-
const value = env[name];
|
|
36
|
-
return value === undefined ? match : value;
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
/** win32 把反斜杠归一为正斜杠(Node win32 API 两者皆收);posix 反斜杠是合法文件名字符,不动。 */
|
|
40
|
-
function normalizeSeparators(p, platform) {
|
|
41
|
-
return isWin32(platform) ? p.replaceAll('\\', '/') : p;
|
|
42
|
-
}
|
|
43
14
|
function resolveOptions(opts) {
|
|
44
|
-
|
|
45
|
-
return {
|
|
46
|
-
platform,
|
|
47
|
-
env: opts?.env ?? process.env,
|
|
48
|
-
home: opts?.home ?? os.homedir(),
|
|
49
|
-
realpath: opts?.realpath ?? true,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
export function canonicalPath(p, opts) {
|
|
53
|
-
const { platform, env, home, realpath } = resolveOptions(opts);
|
|
54
|
-
let out = expandTilde(p, home, platform);
|
|
55
|
-
out = expandEnv(out, env, platform);
|
|
56
|
-
out = normalizeSeparators(out, platform);
|
|
57
|
-
if (!realpath)
|
|
58
|
-
return out;
|
|
59
|
-
try {
|
|
60
|
-
return fs.realpathSync(out);
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// 路径尚不存在(如待创建的目录):返回展开+归一化形态,不抛
|
|
64
|
-
return out;
|
|
65
|
-
}
|
|
15
|
+
return { platform: opts?.platform ?? currentPlatform };
|
|
66
16
|
}
|
|
67
17
|
export function samePath(a, b, opts) {
|
|
68
18
|
const { platform } = resolveOptions(opts);
|
package/dist/platform/shell.d.ts
CHANGED
|
@@ -24,6 +24,20 @@ export interface ShellBackend {
|
|
|
24
24
|
cwd: string;
|
|
25
25
|
options?: SpawnOptions;
|
|
26
26
|
}): ChildProcess;
|
|
27
|
+
/**
|
|
28
|
+
* 执行一段 **argv**(不是 shell 字符串)。ACP 的 terminal/create 这类协议
|
|
29
|
+
* 直接给 command + args,逐 token 语义必须保留(拼成字符串会改写引号/转义),
|
|
30
|
+
* 所以这里**只把「可执行文件在哪」收进 seam**,不改写参数也不加 `-c`:
|
|
31
|
+
*
|
|
32
|
+
* - posix:原样透传(与迁移前 `spawnProcess(command, args, ...)` 逐字一致);
|
|
33
|
+
* - win32:首 token 是 shell(bash/sh/zsh/dash)时按 Git Bash 解析,缺失则抛
|
|
34
|
+
* {@link ShellUnavailableError}——kimi agent 在 Windows 上可用的前置条件
|
|
35
|
+
* (§4.4)。其余命令交给 cross-spawn 做 PATH/PATHEXT 解析,不做命令翻译。
|
|
36
|
+
*/
|
|
37
|
+
spawnArgv(file: string, args: readonly string[], opts?: {
|
|
38
|
+
cwd?: string;
|
|
39
|
+
options?: SpawnOptions;
|
|
40
|
+
}): ChildProcess;
|
|
27
41
|
}
|
|
28
42
|
/** shell 不可用(如 win32 上没装 Git Bash):调用方应转成明确错误卡。 */
|
|
29
43
|
export declare class ShellUnavailableError extends Error {
|