lark-remote 0.1.13 → 0.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 (132) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.en.md +2 -6
  3. package/README.md +3 -7
  4. package/dist/bridge/final-usage.d.ts +24 -0
  5. package/dist/bridge/final-usage.js +55 -0
  6. package/dist/bridge/index.d.ts +0 -7
  7. package/dist/bridge/index.js +21 -63
  8. package/dist/bridge/queue-manager.js +4 -3
  9. package/dist/card/approval-render.js +39 -94
  10. package/dist/card/bash-renderer.js +12 -21
  11. package/dist/card/card-shared.d.ts +13 -1
  12. package/dist/card/card-shared.js +21 -30
  13. package/dist/card/run-renderer.d.ts +0 -22
  14. package/dist/card/run-renderer.js +206 -153
  15. package/dist/card/run-state.d.ts +29 -1
  16. package/dist/card/run-state.js +172 -84
  17. package/dist/card/text-truncate.d.ts +0 -9
  18. package/dist/card/text-truncate.js +0 -9
  19. package/dist/card/tool-render.d.ts +4 -1
  20. package/dist/card/tool-render.js +76 -32
  21. package/dist/cli.js +2 -2
  22. package/dist/common/fs.d.ts +14 -0
  23. package/dist/common/fs.js +57 -3
  24. package/dist/config/codex-config.js +2 -0
  25. package/dist/config/index.d.ts +0 -5
  26. package/dist/config/index.js +0 -11
  27. package/dist/config/kimi-config.js +1 -0
  28. package/dist/config/opencode-config.js +13 -2
  29. package/dist/config/qr.d.ts +36 -0
  30. package/dist/config/qr.js +124 -0
  31. package/dist/config/wizard.js +69 -27
  32. package/dist/connector/index.d.ts +3 -2
  33. package/dist/connector/index.js +5 -3
  34. package/dist/index.js +14 -41
  35. package/dist/instance-lock.js +7 -0
  36. package/dist/persistence/atomic-write.d.ts +2 -0
  37. package/dist/persistence/atomic-write.js +20 -5
  38. package/dist/persistence/load-json-file.d.ts +1 -2
  39. package/dist/persistence/load-json-file.js +2 -3
  40. package/dist/platform/agent-stopper.d.ts +22 -0
  41. package/dist/platform/agent-stopper.js +19 -0
  42. package/dist/platform/command.d.ts +11 -0
  43. package/dist/platform/command.js +107 -0
  44. package/dist/platform/identity.d.ts +33 -0
  45. package/dist/platform/identity.js +187 -0
  46. package/dist/platform/path.d.ts +24 -0
  47. package/dist/platform/path.js +110 -0
  48. package/dist/platform/select.d.ts +9 -0
  49. package/dist/platform/select.js +12 -0
  50. package/dist/platform/shell.d.ts +51 -0
  51. package/dist/platform/shell.js +75 -0
  52. package/dist/platform/spawn.d.ts +41 -0
  53. package/dist/platform/spawn.js +77 -0
  54. package/dist/platform/terminator-posix.d.ts +38 -0
  55. package/dist/platform/terminator-posix.js +103 -0
  56. package/dist/platform/terminator-win32.d.ts +21 -0
  57. package/dist/platform/terminator-win32.js +110 -0
  58. package/dist/platform/terminator.d.ts +44 -0
  59. package/dist/platform/terminator.js +24 -0
  60. package/dist/platform/types.d.ts +23 -0
  61. package/dist/platform/types.js +5 -0
  62. package/dist/restart.js +4 -2
  63. package/dist/router/card-helpers.d.ts +33 -6
  64. package/dist/router/card-helpers.js +67 -11
  65. package/dist/router/config/common/render.d.ts +1 -1
  66. package/dist/router/config/common/render.js +1 -1
  67. package/dist/router/config-diff.d.ts +4 -0
  68. package/dist/router/config-diff.js +70 -0
  69. package/dist/router/index.d.ts +66 -6
  70. package/dist/router/index.js +450 -315
  71. package/dist/router/utils.d.ts +6 -0
  72. package/dist/router/utils.js +12 -0
  73. package/dist/runner/bash/runner.d.ts +9 -2
  74. package/dist/runner/bash/runner.js +45 -20
  75. package/dist/runner/claude/session.d.ts +4 -1
  76. package/dist/runner/claude/session.js +18 -4
  77. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
  78. package/dist/runner/codex/app-server/translator.d.ts +18 -0
  79. package/dist/runner/codex/app-server/translator.js +146 -0
  80. package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
  81. package/dist/runner/common/acp/base-acp-runner.js +248 -0
  82. package/dist/runner/common/acp/base-translator.d.ts +141 -0
  83. package/dist/runner/common/acp/base-translator.js +271 -0
  84. package/dist/runner/common/acp/protocol-helpers.d.ts +39 -0
  85. package/dist/runner/common/acp/protocol-helpers.js +34 -0
  86. package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
  87. package/dist/runner/common/jsonrpc/transport.js +25 -5
  88. package/dist/runner/common/process-stopper.d.ts +4 -26
  89. package/dist/runner/common/process-stopper.js +4 -76
  90. package/dist/runner/common/runner-utils.d.ts +0 -5
  91. package/dist/runner/common/runner-utils.js +0 -7
  92. package/dist/runner/common/spawning-runner.d.ts +42 -132
  93. package/dist/runner/common/spawning-runner.js +52 -271
  94. package/dist/runner/common/usage.d.ts +2 -0
  95. package/dist/runner/common/usage.js +2 -0
  96. package/dist/runner/index.d.ts +1 -1
  97. package/dist/runner/kimi/acp/runner.d.ts +45 -42
  98. package/dist/runner/kimi/acp/runner.js +140 -293
  99. package/dist/runner/kimi/acp/translator.d.ts +18 -100
  100. package/dist/runner/kimi/acp/translator.js +56 -242
  101. package/dist/runner/opencode/acp/runner.d.ts +22 -46
  102. package/dist/runner/opencode/acp/runner.js +73 -256
  103. package/dist/runner/opencode/acp/translator.d.ts +28 -103
  104. package/dist/runner/opencode/acp/translator.js +51 -236
  105. package/dist/runner/pi/rpc/translator.d.ts +3 -0
  106. package/dist/runner/pi/rpc/translator.js +47 -9
  107. package/dist/runner/probe.d.ts +8 -11
  108. package/dist/runner/probe.js +17 -21
  109. package/dist/runner/registry.d.ts +4 -4
  110. package/dist/runner/registry.js +4 -4
  111. package/dist/runner/types.d.ts +38 -1
  112. package/dist/session/claude/sessions.js +34 -57
  113. package/dist/session/codex/rollout-reader.d.ts +10 -0
  114. package/dist/session/codex/rollout-reader.js +123 -41
  115. package/dist/session/common/constants.d.ts +7 -0
  116. package/dist/session/common/constants.js +9 -0
  117. package/dist/session/common/two-pass.d.ts +22 -0
  118. package/dist/session/common/two-pass.js +70 -0
  119. package/dist/session/dsh/sessions.js +6 -5
  120. package/dist/session/kimi/sessions.d.ts +6 -0
  121. package/dist/session/kimi/sessions.js +50 -73
  122. package/dist/session/opencode/sessions.js +26 -8
  123. package/dist/session/pi/sessions.js +54 -73
  124. package/dist/update/index.d.ts +1 -2
  125. package/dist/update/index.js +1 -2
  126. package/dist/update/install.d.ts +2 -1
  127. package/dist/update/install.js +58 -9
  128. package/dist/update/version-check.d.ts +5 -0
  129. package/dist/update/version-check.js +9 -0
  130. package/package.json +9 -6
  131. package/dist/update/startup-hint.d.ts +0 -5
  132. package/dist/update/startup-hint.js +0 -11
@@ -116,6 +116,13 @@ export class InstanceLock {
116
116
  process.exit(129);
117
117
  });
118
118
  }
119
+ else {
120
+ // win32 无 SIGHUP;CTRL_BREAK 退出前也要释放锁(§3.5,对照 cli.ts 信号转发)
121
+ process.on('SIGBREAK', () => {
122
+ this.release();
123
+ process.exit(149);
124
+ });
125
+ }
119
126
  }
120
127
  /**
121
128
  * Lock file stores `pid\ncomm` where comm is the owning process's command
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Atomically write content (string or binary Buffer) to a file via tmp + rename.
3
3
  * Falls back to copy+unlink when rename fails with EXDEV (cross-device).
4
+ * rename 目标被占用(win32 EPERM/EBUSY)时短暂退避重试(§6);重试耗尽后
5
+ * 保留 tmp 现场并告警,再抛出原错误。
4
6
  */
5
7
  export declare function atomicWrite(filePath: string, content: string | Buffer, encoding?: BufferEncoding): void;
6
8
  /**
@@ -1,9 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { silentlyUnlink } from '../common/fs.js';
3
+ import { silentlyUnlink, withBusyRetry } from '../common/fs.js';
4
+ import { getLogger } from '../logger/index.js';
4
5
  /**
5
6
  * Atomically write content (string or binary Buffer) to a file via tmp + rename.
6
7
  * Falls back to copy+unlink when rename fails with EXDEV (cross-device).
8
+ * rename 目标被占用(win32 EPERM/EBUSY)时短暂退避重试(§6);重试耗尽后
9
+ * 保留 tmp 现场并告警,再抛出原错误。
7
10
  */
8
11
  export function atomicWrite(filePath, content, encoding = 'utf-8') {
9
12
  const dir = path.dirname(filePath);
@@ -33,8 +36,17 @@ export function atomicWrite(filePath, content, encoding = 'utf-8') {
33
36
  }
34
37
  }
35
38
  }
39
+ let preserveTmpScene = false;
36
40
  try {
37
- fs.renameSync(tmpPath, filePath);
41
+ withBusyRetry(() => fs.renameSync(tmpPath, filePath), {
42
+ onGiveUp: (err) => {
43
+ // 保留现场:tmp 里是已写好的完整内容,重试耗尽(win32)或 posix 首次
44
+ // 占用即失败都说明目标不可达;留着 tmp 供人工恢复,只告警不静默
45
+ preserveTmpScene = true;
46
+ getLogger().warn(`[atomic-write] rename to ${filePath} still busy after retries; ` +
47
+ `tmp preserved at ${tmpPath}: ${String(err)}`);
48
+ },
49
+ });
38
50
  }
39
51
  catch (err) {
40
52
  if (err.code === 'EXDEV') {
@@ -48,8 +60,11 @@ export function atomicWrite(filePath, content, encoding = 'utf-8') {
48
60
  finally {
49
61
  // Best-effort cleanup: on success the tmp was renamed away (ENOENT here,
50
62
  // silently ignored); on EXDEV it was copied and now needs removal; on other
51
- // errors we must not leak the tmp file.
52
- silentlyUnlink(tmpPath);
63
+ // errors we must not leak the tmp file — except when the scene is
64
+ // deliberately preserved after busy-retry exhaustion.
65
+ if (!preserveTmpScene) {
66
+ silentlyUnlink(tmpPath);
67
+ }
53
68
  }
54
69
  }
55
70
  /**
@@ -66,7 +81,7 @@ export function atomicWriteJson(filePath, data) {
66
81
  */
67
82
  export function atomicMoveFile(srcPath, destPath) {
68
83
  try {
69
- fs.renameSync(srcPath, destPath);
84
+ withBusyRetry(() => fs.renameSync(srcPath, destPath));
70
85
  }
71
86
  catch (err) {
72
87
  if (err.code === 'EXDEV') {
@@ -4,6 +4,5 @@
4
4
  *
5
5
  * @param filePath - Path to the JSON file
6
6
  * @param fallback - Value to return if file doesn't exist or is corrupted
7
- * @param parseFn - Optional custom parser (defaults to JSON.parse)
8
7
  */
9
- export declare function loadJsonFile<T>(filePath: string, fallback: T, parseFn?: (raw: string) => T): T;
8
+ export declare function loadJsonFile<T>(filePath: string, fallback: T): T;
@@ -6,14 +6,13 @@ import { getLogger } from '../logger/index.js';
6
6
  *
7
7
  * @param filePath - Path to the JSON file
8
8
  * @param fallback - Value to return if file doesn't exist or is corrupted
9
- * @param parseFn - Optional custom parser (defaults to JSON.parse)
10
9
  */
11
- export function loadJsonFile(filePath, fallback, parseFn = JSON.parse) {
10
+ export function loadJsonFile(filePath, fallback) {
12
11
  try {
13
12
  if (!fs.existsSync(filePath))
14
13
  return fallback;
15
14
  const raw = fs.readFileSync(filePath, 'utf-8');
16
- return parseFn(raw);
15
+ return JSON.parse(raw);
17
16
  }
18
17
  catch (err) {
19
18
  getLogger().warn(`[persistence] failed to load ${filePath}, using fallback:`, err);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 每 agent 的协议停止通道注册表(design.md §3.3)。
3
+ *
4
+ * Windows 没有可拦截的跨进程 SIGTERM,优雅停止只能靠协议通道(claude 的 stdin
5
+ * 控制通道、codex 的 turn interrupt、ACP 的 abort…)。「无通道」是**合法状态**:
6
+ * 由 Terminator 显式跳过优雅段并打日志,绝不允许悄悄等一个永远不会来的退出事件。
7
+ *
8
+ * 独立成文件是为了避免 `terminator.ts`(接口 + 分发)与 `terminator-win32.ts`
9
+ * 互相 import 形成循环依赖。
10
+ */
11
+ import type { ChildProcess } from 'node:child_process';
12
+ export type AgentStopper = (proc: ChildProcess) => void | Promise<void>;
13
+ /** agent key → 协议停止通道。测试请自建实例以隔离。 */
14
+ export declare class AgentStopperRegistry {
15
+ private readonly stoppers;
16
+ register(agent: string, stopper: AgentStopper): void;
17
+ get(agent: string): AgentStopper | undefined;
18
+ has(agent: string): boolean;
19
+ clear(): void;
20
+ }
21
+ /** 接线用的进程级单例;runner 在 run 启动时注册自己的停止通道。 */
22
+ export declare const agentStopperRegistry: AgentStopperRegistry;
@@ -0,0 +1,19 @@
1
+ /** agent key → 协议停止通道。测试请自建实例以隔离。 */
2
+ export class AgentStopperRegistry {
3
+ stoppers = new Map();
4
+ register(agent, stopper) {
5
+ this.stoppers.set(agent, stopper);
6
+ }
7
+ get(agent) {
8
+ return this.stoppers.get(agent);
9
+ }
10
+ has(agent) {
11
+ return this.stoppers.has(agent);
12
+ }
13
+ clear() {
14
+ this.stoppers.clear();
15
+ }
16
+ }
17
+ /** 接线用的进程级单例;runner 在 run 启动时注册自己的停止通道。 */
18
+ export const agentStopperRegistry = new AgentStopperRegistry();
19
+ //# sourceMappingURL=agent-stopper.js.map
@@ -0,0 +1,11 @@
1
+ import type { LaunchSpec } from './types.js';
2
+ export type { LaunchSpec } from './types.js';
3
+ export interface ResolveOptions {
4
+ platform?: NodeJS.Platform;
5
+ /** PATH 环境变量注入(测试用);默认 process.env.PATH */
6
+ pathEnv?: string;
7
+ /** PATHEXT 注入(测试用);win32 默认 .exe/.bat/.cmd(Windows 默认相对顺序) */
8
+ pathExt?: readonly string[];
9
+ }
10
+ export declare function clearResolveCache(): void;
11
+ export declare function resolveExecutable(name: string, opts?: ResolveOptions): LaunchSpec | null;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * resolveExecutable —— 可执行文件发现。
3
+ *
4
+ * 职责收窄为「探测/诊断」:报告 agent 装在哪、装没装(可用性探测 probe.ts、
5
+ * 自更新安装源检测 update/install.ts)。**执行**统一走 platform/spawn.ts 的
6
+ * cross-spawn 包装(PATHEXT 解析 + .cmd 经 cmd.exe 受控执行),本模块不再
7
+ * 解析 .cmd/.bat 垫片内容——垫片格式是 npm/pnpm 的上游实现细节(历史变过、
8
+ * pnpm 生成形态不同),手写解析器等于耦合该细节(v2 §4.2)。
9
+ *
10
+ * posix:PATH 目录顺序 + X_OK(现状语义迁移)。
11
+ * win32:PATH 目录顺序 × PATHEXT(.exe/.cmd/.bat) 解析,.cmd 命中即报告垫片
12
+ * 路径本身(执行时由 cross-spawn 处理),结果按 (platform, pathEnv, name) 缓存。
13
+ *
14
+ * 分隔符/join 全部跟随注入 platform(win32 为 `;` + path.win32.join),
15
+ * 不读宿主 path——posix 宿主模拟 win32 的测试才保真。
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { currentPlatform, isWin32 } from './select.js';
20
+ const DEFAULT_PATHEXT = ['.exe', '.bat', '.cmd'];
21
+ /**
22
+ * 缓存 TTL:probe 层(runner/probe.ts)5 分钟,本层更短——bridge 运行期间
23
+ * 用户新装/卸载 agent(典型场景:按 /config 不可用提示去安装)后,最迟
24
+ * TTL 过期即可被重新探测到,不必重启。null(未找到)结果同样过期重查。
25
+ */
26
+ const RESOLVE_CACHE_TTL_MS = 60_000;
27
+ const resolveCache = new Map();
28
+ export function clearResolveCache() {
29
+ resolveCache.clear();
30
+ }
31
+ /** 过期条目惰性清除;命中返回 spec,未命中/过期返回 undefined。 */
32
+ function cacheGet(key) {
33
+ const entry = resolveCache.get(key);
34
+ if (!entry)
35
+ return undefined;
36
+ if (Date.now() - entry.ts >= RESOLVE_CACHE_TTL_MS) {
37
+ resolveCache.delete(key);
38
+ return undefined;
39
+ }
40
+ return entry.spec;
41
+ }
42
+ function listPathDirs(pathEnv, platform) {
43
+ const delimiter = isWin32(platform) ? ';' : ':';
44
+ return pathEnv.split(delimiter).filter((dir) => dir.length > 0);
45
+ }
46
+ /**
47
+ * win32 join:真实 win32 宿主产出原生反斜杠路径;posix 宿主模拟 win32 的
48
+ * 测试里 dir 是 posix 风格路径,win32 join 会把分隔符归一成 `\` 而宿主 fs
49
+ * 读不到——回退正斜杠形态再探测一次(Windows fs 本身两种分隔符都认)。
50
+ */
51
+ function existsWin32(file) {
52
+ if (fs.existsSync(file))
53
+ return file;
54
+ const posixForm = file.replaceAll('\\', '/');
55
+ if (posixForm !== file && fs.existsSync(posixForm))
56
+ return posixForm;
57
+ return null;
58
+ }
59
+ function resolveWin32(name, pathEnv, pathExt) {
60
+ const hasExtension = /\.[A-Za-z0-9]+$/.test(name);
61
+ for (const dir of listPathDirs(pathEnv, 'win32')) {
62
+ const candidates = hasExtension ? [name] : pathExt.map((ext) => name + ext);
63
+ for (const candidate of candidates) {
64
+ const found = existsWin32(path.win32.join(dir, candidate));
65
+ // .cmd/.bat 垫片命中即报告垫片路径本身;执行语义由 cross-spawn 负责
66
+ if (found)
67
+ return { kind: 'direct', file: found };
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+ function resolvePosix(name, pathEnv) {
73
+ for (const dir of listPathDirs(pathEnv, 'linux')) {
74
+ // 与 win32 分支对称:join 必须跟随注入 platform,而非宿主 path。
75
+ // 宿主是 win32 时用 path.join 会把 `/tmp/a` + `agent` 拼成 `\tmp\a\agent`,
76
+ // posix 语义测试在 Windows 宿主上必然落空。
77
+ const file = path.posix.join(dir, name);
78
+ try {
79
+ fs.accessSync(file, fs.constants.X_OK);
80
+ return { kind: 'direct', file };
81
+ }
82
+ catch {
83
+ // 不是可执行文件(或不存在)→ 尝试下一个 PATH 目录
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ export function resolveExecutable(name, opts) {
89
+ // 空名守卫:posix 的 path.join(dir, '') = 目录本身,而目录有 X_OK——
90
+ // 不挡住会把第一个 PATH 目录误报为「可执行」(dsh 空 binary 之类的假阳性)
91
+ const trimmed = name.trim();
92
+ if (!trimmed)
93
+ return null;
94
+ const platform = opts?.platform ?? currentPlatform;
95
+ const pathEnv = opts?.pathEnv ?? process.env.PATH ?? '';
96
+ const pathExt = opts?.pathExt ?? DEFAULT_PATHEXT;
97
+ const key = JSON.stringify([platform, pathEnv, [...pathExt].join(';'), trimmed]);
98
+ const cached = cacheGet(key);
99
+ if (cached !== undefined)
100
+ return cached;
101
+ const spec = isWin32(platform)
102
+ ? resolveWin32(trimmed, pathEnv, pathExt)
103
+ : resolvePosix(trimmed, pathEnv);
104
+ resolveCache.set(key, { spec, ts: Date.now() });
105
+ return spec;
106
+ }
107
+ //# sourceMappingURL=command.js.map
@@ -0,0 +1,33 @@
1
+ /** 进程身份快照。creationDate 仅 win32(CIM CreationDate,DMTF 时间串)。 */
2
+ export interface ProcessIdentity {
3
+ commandLine: string;
4
+ creationDate?: string;
5
+ }
6
+ export interface QueryIdentityOptions {
7
+ /** 平台注入(测试用);默认当前宿主平台 */
8
+ platform?: NodeJS.Platform;
9
+ /** 查询超时(毫秒),超时杀掉查询进程并返回 null */
10
+ timeoutMs?: number;
11
+ }
12
+ export interface VerifyPidIdentityOptions extends QueryIdentityOptions {
13
+ /** 期望的二进制名(basename,如 'claude'/'node');win32/darwin 大小写不敏感 */
14
+ expectedBinary: string;
15
+ /** pid 文件记录的 CreationDate;旧文件缺省该字段则退化为仅命令行匹配 */
16
+ expectedCreationDate?: string;
17
+ }
18
+ /**
19
+ * 查询进程身份;进程不存在或查询失败返回 null。
20
+ */
21
+ export declare function queryProcessIdentity(pid: number, opts?: QueryIdentityOptions): Promise<ProcessIdentity | null>;
22
+ /**
23
+ * 引号感知的命令行 tokenizer:双引号段不按空白拆分。CIM 的 CommandLine 里
24
+ * "C:\Program Files\nodejs\node.exe" 这类含空格路径必须整段保留,朴素
25
+ * split(/\s+/) 会切碎 token 导致身份假阴性。
26
+ */
27
+ export declare function tokenizeCommandLine(commandLine: string): string[];
28
+ /**
29
+ * pid 身份验证:进程存活且命令行确实属于期望二进制(+ CreationDate 一致)才 true。
30
+ *
31
+ * 用于 killOrphan 与实例锁陈旧判定——只有这里返回 true 才允许动手。
32
+ */
33
+ export declare function verifyPidIdentity(pid: number, opts: VerifyPidIdentityOptions): Promise<boolean>;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * pid 身份验证(design.md §3.4 / §3.5)。
3
+ *
4
+ * POSIX 现状用 `ps -o command=` 读命令行,Windows 没有 ps,等价物是 CIM:
5
+ * PowerShell `Get-CimInstance Win32_Process` 取 CommandLine,并额外取
6
+ * CreationDate 做 pid 复用加固(Windows pid 复用快)。
7
+ *
8
+ * 纪律:
9
+ * - 只在查询失败/身份不匹配时返回 null / false,绝不抛给调用方——调用方
10
+ * (killOrphan、实例锁陈旧判定)一律「只有身份匹配才动手」;
11
+ * - win32 每次查询要拉起 PowerShell(冷启动约 1s),批量扫 pid 文件的场景
12
+ * (killOrphan)应按需调用,真机矩阵(M0)需实测整体耗时;
13
+ * - platform 显式注入,win32 语义可在任意宿主上单测。
14
+ */
15
+ import { spawn } from 'node:child_process';
16
+ import path from 'node:path';
17
+ import { currentPlatform, isWin32 } from './select.js';
18
+ const DEFAULT_TIMEOUT_MS = 3000;
19
+ /** 拉起一次性查询进程并收全 stdout;超时/拉起失败/进程报错一律 null。 */
20
+ function collect(file, args, timeoutMs) {
21
+ return new Promise((resolve) => {
22
+ let proc;
23
+ try {
24
+ // stderr 用 ignore:查询进程的错误输出我们不需要,pipe 而不消费在极端
25
+ // 情况下(管道缓冲满)会阻塞子进程,把一次性查询拖成挂起。
26
+ // windowsHide:避免查询进程在桌面闪控制台窗口
27
+ proc = spawn(file, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
28
+ }
29
+ catch {
30
+ resolve(null);
31
+ return;
32
+ }
33
+ let stdout = '';
34
+ let settled = false;
35
+ const finish = (result) => {
36
+ if (settled)
37
+ return;
38
+ settled = true;
39
+ clearTimeout(timer);
40
+ resolve(result);
41
+ };
42
+ proc.stdout?.setEncoding('utf8');
43
+ proc.stdout?.on('data', (chunk) => {
44
+ stdout += chunk;
45
+ });
46
+ proc.on('error', () => finish(null));
47
+ proc.on('exit', (code) => finish({ code, stdout }));
48
+ const timer = setTimeout(() => {
49
+ proc.kill();
50
+ finish(null);
51
+ }, timeoutMs);
52
+ });
53
+ }
54
+ /** 解析 CIM 输出(ConvertTo-Json 形态):单对象或数组,取首条。 */
55
+ function parseCimIdentity(stdout) {
56
+ const text = stdout.trim();
57
+ if (!text)
58
+ return null;
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(text);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
67
+ if (!first || typeof first !== 'object')
68
+ return null;
69
+ const record = first;
70
+ // 权限不足时 CIM 会给出 null 字段:命令行拿不到就没有身份可言
71
+ const commandLine = typeof record.CommandLine === 'string' ? record.CommandLine.trim() : '';
72
+ if (!commandLine)
73
+ return null;
74
+ const rawDate = typeof record.CreationDate === 'string' ? record.CreationDate.trim() : '';
75
+ return rawDate ? { commandLine, creationDate: rawDate } : { commandLine };
76
+ }
77
+ /**
78
+ * 查询进程身份;进程不存在或查询失败返回 null。
79
+ */
80
+ export async function queryProcessIdentity(pid, opts) {
81
+ if (!Number.isInteger(pid) || pid <= 0)
82
+ return null;
83
+ const platform = opts?.platform ?? currentPlatform;
84
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
85
+ if (isWin32(platform)) {
86
+ const result = await collect('powershell.exe', [
87
+ '-NoProfile',
88
+ '-NonInteractive',
89
+ '-Command',
90
+ `Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" ` +
91
+ // -Compress:单行输出,避免 PowerShell 按控制台宽度换行给解析添变数
92
+ '| Select-Object -Property CommandLine,CreationDate | ConvertTo-Json -Compress',
93
+ ], timeoutMs);
94
+ return result ? parseCimIdentity(result.stdout) : null;
95
+ }
96
+ const result = await collect('ps', ['-o', 'command=', '-p', String(pid)], timeoutMs);
97
+ if (!result || result.code !== 0)
98
+ return null;
99
+ const commandLine = result.stdout.split('\n')[0]?.trim() ?? '';
100
+ return commandLine ? { commandLine } : null;
101
+ }
102
+ function caseInsensitive(platform) {
103
+ // 与 PathKit.samePath 同口径:win32/darwin 大小写不敏感,linux 严格
104
+ return isWin32(platform) || platform === 'darwin';
105
+ }
106
+ /**
107
+ * 引号感知的命令行 tokenizer:双引号段不按空白拆分。CIM 的 CommandLine 里
108
+ * "C:\Program Files\nodejs\node.exe" 这类含空格路径必须整段保留,朴素
109
+ * split(/\s+/) 会切碎 token 导致身份假阴性。
110
+ */
111
+ export function tokenizeCommandLine(commandLine) {
112
+ const tokens = [];
113
+ let current = '';
114
+ let hasContent = false;
115
+ let inQuotes = false;
116
+ for (const ch of commandLine) {
117
+ if (ch === '"') {
118
+ inQuotes = !inQuotes;
119
+ // 空引号段也是 token 内容("" x 与 x 不同),与 cmd 的近似语义一致
120
+ hasContent = true;
121
+ }
122
+ else if (!inQuotes && /\s/.test(ch)) {
123
+ if (hasContent)
124
+ tokens.push(current);
125
+ current = '';
126
+ hasContent = false;
127
+ }
128
+ else {
129
+ current += ch;
130
+ hasContent = true;
131
+ }
132
+ }
133
+ if (hasContent)
134
+ tokens.push(current);
135
+ return tokens;
136
+ }
137
+ /** 取命令行 token 的「去掉扩展名的 basename」,用于与期望二进制名比较。 */
138
+ function tokenBinaryName(token, platform) {
139
+ let text = token.trim();
140
+ if (text.length >= 2 &&
141
+ ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))) {
142
+ text = text.slice(1, -1);
143
+ }
144
+ // CIM 的 CommandLine 用 Windows 分隔符;posix 用 unix 分隔符
145
+ const p = isWin32(platform) ? path.win32 : path.posix;
146
+ const base = p.basename(text);
147
+ const ext = p.extname(base);
148
+ const name = ext ? base.slice(0, -ext.length) : base;
149
+ return caseInsensitive(platform) ? name.toLowerCase() : name;
150
+ }
151
+ /**
152
+ * 命令行与期望二进制是否属于同一程序。收紧规则(review 2026-09-05):
153
+ * 1. 首 token(可执行位)匹配——`/usr/local/bin/claude --verbose` 匹配 claude;
154
+ * 2. 或后续 token 是**带路径分隔符的脚本路径**且 basename 匹配——
155
+ * `node /home/user/.local/bin/claude` 匹配 claude。
156
+ * 任意位置裸参数不再参与匹配:`bash claude`(脚本恰好叫 claude)、
157
+ * `grep claude foo.log` 这类不再假阳性(killOrphan 场景会误杀无辜进程)。
158
+ */
159
+ function commandLineMatches(commandLine, want, platform) {
160
+ const tokens = tokenizeCommandLine(commandLine);
161
+ const first = tokens[0];
162
+ if (first !== undefined && tokenBinaryName(first, platform) === want)
163
+ return true;
164
+ return tokens
165
+ .slice(1)
166
+ .some((token) => /[/\\]/.test(token) && tokenBinaryName(token, platform) === want);
167
+ }
168
+ /**
169
+ * pid 身份验证:进程存活且命令行确实属于期望二进制(+ CreationDate 一致)才 true。
170
+ *
171
+ * 用于 killOrphan 与实例锁陈旧判定——只有这里返回 true 才允许动手。
172
+ */
173
+ export async function verifyPidIdentity(pid, opts) {
174
+ const platform = opts.platform ?? currentPlatform;
175
+ const identity = await queryProcessIdentity(pid, opts);
176
+ if (!identity)
177
+ return false;
178
+ const want = caseInsensitive(platform) ? opts.expectedBinary.toLowerCase() : opts.expectedBinary;
179
+ if (!commandLineMatches(identity.commandLine, want, platform))
180
+ return false;
181
+ // CreationDate 仅在 pid 文件带了第三行时才比对(旧文件缺字段 → 退化为命令行匹配)
182
+ const expected = opts.expectedCreationDate?.trim();
183
+ if (expected)
184
+ return identity.creationDate === expected;
185
+ return true;
186
+ }
187
+ //# sourceMappingURL=identity.js.map
@@ -0,0 +1,24 @@
1
+ export interface PathOptions {
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
+ }
10
+ export declare function canonicalPath(p: string, opts?: PathOptions): string;
11
+ export declare function samePath(a: string, b: string, opts?: PathOptions): boolean;
12
+ /**
13
+ * cwd → 会话项目目录名(Claude/pi 的 on-disk 编码,lossy N-to-N)。
14
+ *
15
+ * 只可用于**定位**目录,绝不可反解回 cwd——cwd 必须从 JSONL 内容读取
16
+ * (回归 2026-06-21 /resume & /cd 路径错乱)。
17
+ *
18
+ * posix:`/` → `-`(`_` 同样归一,与 Claude Code 一致)。
19
+ * win32:`\` 与 `/` **都是**分隔符、`:` 是非法文件名字符,三者都必须归一,
20
+ * 否则 `C:\Users\x\proj` 会原样拼进目录名,mkdir/stat 必然失败。
21
+ * 反过来,posix 上 `:` 是**合法**文件名字符,不能动——否则既有目录定位不到。
22
+ */
23
+ export declare function encodeProjectDirName(cwd: string, opts?: PathOptions): string;
24
+ export declare function displayName(p: string, opts?: PathOptions): string;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * PathKit — 跨平台路径语义(design.md §5.1)。
3
+ *
4
+ * 所有 cwd 相等性判断、会话目录编码、pid 文件名后缀、spawn cwd 前统一走本模块。
5
+ * 语义映射:
6
+ * canonicalPath(p): realpath(expandEnv(expandTilde(normalizeSeparators(p))))
7
+ * samePath(a, b): win32/darwin 默认大小写不敏感 + 分隔符不敏感;linux 严格相等
8
+ * displayName(p): path.basename(win32 原生认识 \ 与 /)
9
+ *
10
+ * 全部函数接受显式 `platform` 参数(默认 currentPlatform),使 win32 语义
11
+ * 可在任意宿主的单元测试中验证。
12
+ */
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ 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
+ function resolveOptions(opts) {
44
+ const platform = opts?.platform ?? currentPlatform;
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
+ }
66
+ }
67
+ export function samePath(a, b, opts) {
68
+ const { platform } = resolveOptions(opts);
69
+ if (platform === 'linux')
70
+ return a === b;
71
+ if (isWin32(platform)) {
72
+ // win32:\ 与 / 都是分隔符,且路径大小写不敏感
73
+ const norm = (p) => p.replaceAll('\\', '/').replace(/\/+$/, '');
74
+ return norm(a).toLowerCase() === norm(b).toLowerCase();
75
+ }
76
+ // darwin:默认按大小写不敏感处理(macOS 默认文件系统 APFS 不敏感);
77
+ // 反斜杠是合法文件名字符,不动。分隔符只有 /。
78
+ // 假设标注:大小写敏感 APFS/HFS+ 卷上 /Users/A 与 /Users/a 实为两个目录,
79
+ // 此处会判等(cwd 守卫假阳性)——影响面小(默认卷不敏感),如需精确需
80
+ // 逐目录 stat 探测卷大小写属性,暂不做。
81
+ // 尾部分隔符必须与 win32 同口径忽略,否则 `/cd ~/repo/` 与 `/cd ~/repo`
82
+ // 会被判成两个目录,M2 迁移后直接表现为会话目录错配。
83
+ const norm = (p) => p.replace(/\/+$/, '');
84
+ return norm(a).toLowerCase() === norm(b).toLowerCase();
85
+ }
86
+ /**
87
+ * cwd → 会话项目目录名(Claude/pi 的 on-disk 编码,lossy N-to-N)。
88
+ *
89
+ * 只可用于**定位**目录,绝不可反解回 cwd——cwd 必须从 JSONL 内容读取
90
+ * (回归 2026-06-21 /resume & /cd 路径错乱)。
91
+ *
92
+ * posix:`/` → `-`(`_` 同样归一,与 Claude Code 一致)。
93
+ * win32:`\` 与 `/` **都是**分隔符、`:` 是非法文件名字符,三者都必须归一,
94
+ * 否则 `C:\Users\x\proj` 会原样拼进目录名,mkdir/stat 必然失败。
95
+ * 反过来,posix 上 `:` 是**合法**文件名字符,不能动——否则既有目录定位不到。
96
+ */
97
+ export function encodeProjectDirName(cwd, opts) {
98
+ const { platform } = resolveOptions(opts);
99
+ return isWin32(platform)
100
+ ? cwd.replace(/[/\\:]/g, '-').replace(/_/g, '-')
101
+ : cwd.replace(/\//g, '-').replace(/_/g, '-');
102
+ }
103
+ export function displayName(p, opts) {
104
+ const { platform } = resolveOptions(opts);
105
+ // 必须用与注入 platform 对应的 path 实现,而非宿主 `path`:宿主为 win32 时
106
+ // `path.basename` 会把 `\` 当分隔符,posix 语义(反斜杠是合法文件名字符)
107
+ // 就被宿主机悄悄改写。win32/posix 各用各的实现后,结果与宿主无关。
108
+ return isWin32(platform) ? path.win32.basename(p) : path.posix.basename(p);
109
+ }
110
+ //# sourceMappingURL=path.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 平台选择的唯一入口(design.md §2.2 纪律)。
3
+ *
4
+ * `process.platform` 的读取只允许发生在本文件;其余模块一律导入
5
+ * `currentPlatform` / `isWin32`,或接受显式 platform 参数(便于在任意
6
+ * 宿主上单测 win32 语义)。
7
+ */
8
+ export declare const currentPlatform: NodeJS.Platform;
9
+ export declare function isWin32(platform?: NodeJS.Platform): boolean;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 平台选择的唯一入口(design.md §2.2 纪律)。
3
+ *
4
+ * `process.platform` 的读取只允许发生在本文件;其余模块一律导入
5
+ * `currentPlatform` / `isWin32`,或接受显式 platform 参数(便于在任意
6
+ * 宿主上单测 win32 语义)。
7
+ */
8
+ export const currentPlatform = process.platform;
9
+ export function isWin32(platform = currentPlatform) {
10
+ return platform === 'win32';
11
+ }
12
+ //# sourceMappingURL=select.js.map