lark-remote 0.4.2 → 0.4.4

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 (86) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/bridge/approval-coordinator.d.ts +2 -0
  3. package/dist/bridge/approval-coordinator.js +16 -7
  4. package/dist/bridge/index.d.ts +22 -0
  5. package/dist/bridge/index.js +138 -56
  6. package/dist/card/card-budget.js +39 -26
  7. package/dist/card/run-renderer.js +9 -2
  8. package/dist/card/run-state.js +81 -31
  9. package/dist/card/text-truncate.d.ts +9 -10
  10. package/dist/card/text-truncate.js +48 -43
  11. package/dist/card/tool-render.js +6 -2
  12. package/dist/cli.js +17 -0
  13. package/dist/config/codex-config.d.ts +3 -0
  14. package/dist/config/codex-config.js +4 -1
  15. package/dist/config/dir.js +6 -6
  16. package/dist/config/index.d.ts +7 -5
  17. package/dist/config/index.js +52 -13
  18. package/dist/config/kimi-config.d.ts +4 -1
  19. package/dist/config/kimi-config.js +5 -2
  20. package/dist/config/pi-config.d.ts +2 -7
  21. package/dist/config/pi-config.js +6 -6
  22. package/dist/config/wizard.js +8 -4
  23. package/dist/connector/index.d.ts +19 -0
  24. package/dist/connector/index.js +134 -23
  25. package/dist/inbound/placeholder.d.ts +3 -2
  26. package/dist/inbound/placeholder.js +17 -7
  27. package/dist/index.js +28 -6
  28. package/dist/instance-lock.d.ts +31 -15
  29. package/dist/instance-lock.js +64 -62
  30. package/dist/platform/agent-stopper.d.ts +29 -7
  31. package/dist/platform/agent-stopper.js +40 -8
  32. package/dist/platform/identity.d.ts +59 -3
  33. package/dist/platform/identity.js +105 -30
  34. package/dist/platform/path.d.ts +0 -7
  35. package/dist/platform/path.js +1 -51
  36. package/dist/platform/shell.d.ts +14 -0
  37. package/dist/platform/shell.js +35 -2
  38. package/dist/platform/sleep-blocker.d.ts +6 -0
  39. package/dist/platform/sleep-blocker.js +31 -5
  40. package/dist/platform/spawn.d.ts +1 -1
  41. package/dist/platform/spawn.js +1 -1
  42. package/dist/platform/terminator-posix.d.ts +1 -29
  43. package/dist/platform/terminator-posix.js +32 -30
  44. package/dist/platform/terminator-win32.js +4 -2
  45. package/dist/platform/terminator.d.ts +2 -2
  46. package/dist/platform/terminator.js +17 -2
  47. package/dist/platform/types.d.ts +3 -0
  48. package/dist/router/card-action-feedback.d.ts +17 -0
  49. package/dist/router/card-action-feedback.js +31 -0
  50. package/dist/router/config/claude.js +8 -5
  51. package/dist/router/index.d.ts +2 -0
  52. package/dist/router/index.js +35 -15
  53. package/dist/runner/claude/runner.js +1 -1
  54. package/dist/runner/claude/session.d.ts +2 -6
  55. package/dist/runner/claude/session.js +7 -8
  56. package/dist/runner/codex/app-server/protocol-types.d.ts +4 -0
  57. package/dist/runner/codex/app-server/protocol-types.js +4 -0
  58. package/dist/runner/codex/app-server/runner.js +3 -0
  59. package/dist/runner/common/acp/base-acp-runner.js +7 -0
  60. package/dist/runner/common/acp/base-translator.d.ts +43 -0
  61. package/dist/runner/common/acp/base-translator.js +83 -18
  62. package/dist/runner/common/connection-based-runner.d.ts +18 -0
  63. package/dist/runner/common/connection-based-runner.js +31 -0
  64. package/dist/runner/common/jsonrpc/client.js +6 -1
  65. package/dist/runner/common/jsonrpc/connection-manager.d.ts +20 -0
  66. package/dist/runner/common/jsonrpc/connection-manager.js +27 -1
  67. package/dist/runner/common/jsonrpc/transport.d.ts +29 -1
  68. package/dist/runner/common/jsonrpc/transport.js +79 -10
  69. package/dist/runner/common/spawning-runner.d.ts +49 -19
  70. package/dist/runner/common/spawning-runner.js +94 -54
  71. package/dist/runner/dsh/client.d.ts +1 -3
  72. package/dist/runner/dsh/client.js +0 -4
  73. package/dist/runner/dsh/types.d.ts +0 -36
  74. package/dist/runner/kimi/acp/runner.d.ts +18 -1
  75. package/dist/runner/kimi/acp/runner.js +100 -42
  76. package/dist/runner/kimi/acp/translator.d.ts +0 -1
  77. package/dist/runner/opencode/acp/translator.d.ts +22 -2
  78. package/dist/runner/opencode/acp/translator.js +60 -0
  79. package/dist/runner/pi/rpc/protocol-types.d.ts +4 -0
  80. package/dist/runner/pi/rpc/runner.js +3 -0
  81. package/dist/runner/resolve-agent-choices.js +0 -5
  82. package/package.json +2 -2
  83. package/dist/runner/common/process-stopper.d.ts +0 -7
  84. package/dist/runner/common/process-stopper.js +0 -8
  85. package/dist/runner/dsh/fake-dsh-server.d.ts +0 -66
  86. package/dist/runner/dsh/fake-dsh-server.js +0 -162
@@ -6,8 +6,11 @@
6
6
  * each line on stdout is a JSON message from the server; each `write()`
7
7
  * sends a JSON line to stdin.
8
8
  */
9
+ import { StringDecoder } from 'node:string_decoder';
9
10
  import { spawnProcess, mergeProcessEnv, isWindowsCommandNotFoundLine, useDetachedProcessGroup, } from '../../../platform/spawn.js';
10
- import { ProcessStopper } from '../process-stopper.js';
11
+ import { binaryName } from '../../../platform/identity.js';
12
+ import { createTerminator } from '../../../platform/terminator.js';
13
+ import { agentStopperRegistry, } from '../../../platform/agent-stopper.js';
11
14
  import { getLogger } from '../../../logger/index.js';
12
15
  /** Max bytes for a single line before we disconnect (10MB). */
13
16
  const MAX_LINE_BYTES = 10 * 1024 * 1024;
@@ -17,11 +20,17 @@ export class JsonlRpcTransport {
17
20
  proc = null;
18
21
  _closed = false;
19
22
  events = null;
20
- stopper;
23
+ terminator;
21
24
  binary;
22
25
  args;
23
26
  cwd;
24
27
  env;
28
+ /** 协议停止通道查找键(与 Terminator 用的 agent key 同一来源,见构造函数)。 */
29
+ agent;
30
+ stoppers;
31
+ stopperFactory;
32
+ /** 已登记的通道(agent+pid 键);null = 本连接没有可用通道或已注销。 */
33
+ registration = null;
25
34
  /** JSON lines buffered while stdin is under backpressure (bounded by callers). */
26
35
  writeQueue = [];
27
36
  flushing = false;
@@ -32,7 +41,21 @@ export class JsonlRpcTransport {
32
41
  this.args = opts.args;
33
42
  this.cwd = opts.cwd;
34
43
  this.env = opts.env ?? {};
35
- this.stopper = new ProcessStopper({ graceMs: 5000 });
44
+ // 协议停止通道按 agent key 索引:调用方(connection-manager)只给 binary,
45
+ // 这里用 binaryName 归一(含 win32 的 `.cmd` 垫片与全路径两种形态),
46
+ // 免得每个协议 runner 都要重复传一遍 agent 名。
47
+ this.agent = opts.agent ?? binaryName(opts.binary);
48
+ this.stoppers = opts.stoppers ?? agentStopperRegistry;
49
+ this.stopperFactory = opts.stopper;
50
+ // Terminator 与注册表共用同一个 agent key 和同一张表:两边任一取错来源,
51
+ // 查表就静默失配(优雅段退化回树杀还不报错),所以都从这里下传。
52
+ this.terminator =
53
+ opts.terminator ??
54
+ createTerminator({ graceMs: 5000, agent: this.agent, stoppers: this.stoppers });
55
+ }
56
+ /** 子进程 pid(spawn 前/退出后为 undefined);供测试与连接层按 pid 定位。 */
57
+ get pid() {
58
+ return this.proc?.pid;
36
59
  }
37
60
  get closed() {
38
61
  return this._closed;
@@ -70,6 +93,9 @@ export class JsonlRpcTransport {
70
93
  return;
71
94
  }
72
95
  getLogger().info(`[jsonrpc-transport] spawned pid=${proc.pid} binary=${this.binary}`);
96
+ // 子进程起来后立刻登记协议停止通道:win32 上 graceful stop 没有信号可拦,
97
+ // 只有这条通道能让 agent server 自己收摊(关 stdin / 取消在途 turn)。
98
+ this.registerStopper(proc.pid);
73
99
  proc.stdin?.on('error', (err) => {
74
100
  getLogger().warn(`[jsonrpc-transport] stdin error: ${err.message}`);
75
101
  this.handleClose('epipe');
@@ -90,34 +116,50 @@ export class JsonlRpcTransport {
90
116
  }
91
117
  });
92
118
  // Stdout: line-split with 10MB line limit
119
+ // decoder 跨 chunk 缓冲半个多字节字符:逐 chunk toString 会把中文/emoji 打成
120
+ // 不可逆的 U+FFFD,且 remainder 拼接后无法复原
121
+ const decoder = new StringDecoder('utf8');
93
122
  let remainder = '';
94
123
  proc.stdout?.on('data', (chunk) => {
95
- remainder += chunk.toString('utf-8');
124
+ remainder += decoder.write(chunk);
96
125
  const lines = remainder.split('\n');
97
126
  // Lines except the last (incomplete) one
98
127
  remainder = lines.pop() ?? '';
99
128
  for (const line of lines) {
100
129
  if (line.length === 0)
101
130
  continue;
102
- if (line.length > MAX_LINE_BYTES) {
131
+ // 上限量的是 UTF-8 字节:中文 1 单元 = 3 字节,用 line.length 会低估 2-3×
132
+ if (Buffer.byteLength(line, 'utf8') > MAX_LINE_BYTES) {
103
133
  getLogger().error(`[jsonrpc-transport] line exceeds ${MAX_LINE_BYTES} bytes, disconnecting`);
104
134
  this.handleClose('parse_error');
105
135
  return;
106
136
  }
137
+ let msg;
107
138
  try {
108
- const msg = JSON.parse(line);
109
- events.onMessage(msg);
139
+ msg = JSON.parse(line);
110
140
  }
111
141
  catch (err) {
112
142
  getLogger().warn(`[jsonrpc-transport] failed to parse JSON: ${err.message} line=${line.slice(0, 200)}`);
143
+ continue;
144
+ }
145
+ // handler 抛错是消费方故障,与协议解码无关;单独捕获以免掩盖真实故障点,
146
+ // 且不能打断同一 chunk 里后续的帧
147
+ try {
148
+ events.onMessage(msg);
149
+ }
150
+ catch (err) {
151
+ getLogger().error(`[jsonrpc-transport] onMessage handler threw: ${err instanceof Error ? err.message : String(err)}`);
113
152
  }
114
153
  }
115
154
  });
116
155
  // Handle process exit
117
156
  const onExit = (code, signal) => {
118
157
  this._closed = true;
158
+ // 进程已经没了:留着旧 pid 的通道只会在 pid 复用后指向无关进程。
159
+ this.unregisterStopper();
119
160
  this.proc = null;
120
- // Flush remaining buffer
161
+ // Flush remaining buffer(decoder.end() 吐出最后半个多字节字符)
162
+ remainder += decoder.end();
121
163
  if (remainder.length > 0) {
122
164
  try {
123
165
  const msg = JSON.parse(remainder);
@@ -209,7 +251,7 @@ export class JsonlRpcTransport {
209
251
  const proc = this.proc;
210
252
  if (proc) {
211
253
  try {
212
- await this.stopper.stop(proc);
254
+ await this.terminator.stop(proc, { immediate: false });
213
255
  }
214
256
  catch {
215
257
  // Ignore stop errors — process may already be dead
@@ -218,6 +260,27 @@ export class JsonlRpcTransport {
218
260
  proc.stdin.end();
219
261
  }
220
262
  }
263
+ // 注销必须在 stop() 之后:Terminator 就是在上面那次调用里查表取通道的。
264
+ this.unregisterStopper();
265
+ }
266
+ /**
267
+ * 按 pid 登记协议停止通道(工厂返回 undefined = 该 agent 无通道)。
268
+ * pid 是归属键:同一 agent 的多条长驻连接各有自己的通道,不能互相顶替。
269
+ */
270
+ registerStopper(pid) {
271
+ const stopper = this.stopperFactory?.(pid);
272
+ if (!stopper)
273
+ return;
274
+ this.stoppers.register(this.agent, pid, stopper);
275
+ this.registration = { pid, stopper };
276
+ }
277
+ /** 注销自己登记的那条通道(幂等;带身份校验,防误删同 pid 上的新通道)。 */
278
+ unregisterStopper() {
279
+ const reg = this.registration;
280
+ if (!reg)
281
+ return;
282
+ this.registration = null;
283
+ this.stoppers.unregister(this.agent, reg.pid, reg.stopper);
221
284
  }
222
285
  handleClose(reason) {
223
286
  if (this._closed)
@@ -237,8 +300,14 @@ export class JsonlRpcTransport {
237
300
  }
238
301
  // 异常路径(EPIPE/超长行/进程 error)也要收掉子进程,避免孤儿进程
239
302
  // 继续运行(如超长行场景子进程还在往 stdout 灌数据)。
240
- void this.stopper.stop(proc).catch((err) => {
303
+ void this.terminator
304
+ .stop(proc, { immediate: false })
305
+ .catch((err) => {
241
306
  getLogger().warn(`[jsonrpc-transport] failed to stop process during close: ${err instanceof Error ? err.message : String(err)}`);
307
+ })
308
+ .finally(() => {
309
+ // 通道查表发生在上面这次 stop() 里,注销必须等它落定。
310
+ this.unregisterStopper();
242
311
  });
243
312
  }
244
313
  // 统一关闭出口:EPIPE/超长行/进程 error 都必须通知上层(connection-manager
@@ -1,6 +1,6 @@
1
- import { type ChildProcess } from 'node:child_process';
1
+ import type { ChildProcess } from 'node:child_process';
2
2
  import type { Readable } from 'node:stream';
3
- import { ProcessStopper } from './process-stopper.js';
3
+ import { type Terminator } from '../../platform/terminator.js';
4
4
  import { SpawnHeartbeat } from './spawn-heartbeat.js';
5
5
  import type { SpawnOptions } from '../types.js';
6
6
  /**
@@ -50,7 +50,7 @@ export declare function unregisterExitCleanup(handler: ExitCleanupHandler): void
50
50
  export declare abstract class SpawningRunner {
51
51
  protected currentProcess: ChildProcess | null;
52
52
  protected readonly pidFilePath: string;
53
- protected readonly stopper: ProcessStopper;
53
+ protected readonly terminator: Terminator;
54
54
  protected readonly spawnHeartbeat: SpawnHeartbeat;
55
55
  protected binary: string;
56
56
  protected stopGraceMs: number;
@@ -77,6 +77,13 @@ export declare abstract class SpawningRunner {
77
77
  * 非零退出」才判 command-not-found;真挂起由既有 grace/超时兜底。
78
78
  */
79
79
  protected commandNotFoundSeen: boolean;
80
+ /**
81
+ * 本 runner 的 agent key('claude'/…):win32 上既是 Terminator 查协议停止
82
+ * 通道的键,也是这里登记通道的键。undefined = 无通道(如 `!` bash)。
83
+ */
84
+ protected readonly agent?: string;
85
+ /** 已登记的协议停止通道(agent+pid 键);null = 未登记。 */
86
+ private stopperRegistration;
80
87
  constructor(opts: {
81
88
  pidDir?: string;
82
89
  workspace: string;
@@ -96,6 +103,13 @@ export declare abstract class SpawningRunner {
96
103
  * spawn-stage-stalled WARN identifies the right agent.
97
104
  */
98
105
  logTag?: string;
106
+ /**
107
+ * 当前 run 所属 agent('claude'/'kimi'/…):win32 上按这个 key 查协议停止
108
+ * 通道。缺省 undefined = 无通道(Terminator 会显式记录后走树杀,不空等)。
109
+ */
110
+ agent?: string;
111
+ /** 终止器注入(测试用);默认按平台建 POSIX/win32 实现 */
112
+ terminator?: Terminator;
99
113
  });
100
114
  get isRunning(): boolean;
101
115
  /**
@@ -114,7 +128,7 @@ export declare abstract class SpawningRunner {
114
128
  * 'error' for ENOENT/EACCES, but some binaries fail silently without ever
115
129
  * emitting it. Without the race, the spawn lead-in hangs forever → the
116
130
  * workspace serial queue never settles → permanent deadlock, and /stop
117
- * can't recover (ProcessStopper returns early when pid === undefined).
131
+ * can't recover (Terminator.stop returns early when pid === undefined).
118
132
  * The timeout keeps the deadlock bounded.
119
133
  */
120
134
  protected awaitSpawnError(proc: ChildProcess): Promise<Error | undefined>;
@@ -128,30 +142,46 @@ export declare abstract class SpawningRunner {
128
142
  * On success returns the spawned child.
129
143
  */
130
144
  protected spawnChild(opts: SpawnOptions): Promise<ChildProcess>;
145
+ /**
146
+ * 登记本进程的协议停止通道(design §3.3 表):win32 上没有可拦截的跨进程
147
+ * SIGTERM,「优雅停止」只能经 agent 自有通道请求对方自行退出。stdio 型 agent
148
+ * CLI 的通道就是关 stdin(cc-connect 已验证 claude:关 stdin → 干净退出并跑
149
+ * Stop hooks)。
150
+ *
151
+ * 只在子进程真的挂了 stdin 管道时登记:stdin 为 'ignore' 的子类若照登一条空
152
+ * 通道,优雅段会从「无通道 → 显式跳过 + 直接树杀」退化成「空转满 grace 再
153
+ * 树杀」——照样能停,但白等一个不会被消费的请求。
154
+ */
155
+ protected registerStopper(proc: ChildProcess): void;
156
+ /** 注销本 runner 登记的通道(幂等;未登记时无操作)。 */
157
+ protected unregisterStopper(): void;
131
158
  /**
132
159
  * Stop the current process if one is running. Delegates the actual
133
- * SIGTERM → grace → SIGKILL sequence to `this.stopper.stop(proc, opts)`
134
- * (see `src/runner/common/process-stopper.ts`), forwarding the `immediate`
135
- * flag verbatim so all subclasses inherit identical stop semantics.
160
+ * posix SIGTERM → grace → SIGKILL / win32 协议通道 → grace → taskkill 序列
161
+ * 给 `this.terminator.stop(proc, { immediate })`(见
162
+ * `src/platform/terminator.ts`),`immediate` 在这里收敛成显式 boolean,
163
+ * 所有子类继承同一套停止语义。
136
164
  */
137
165
  stop(opts?: {
138
166
  immediate?: boolean;
139
167
  }): Promise<void>;
140
- killOrphan(): void;
141
168
  /**
142
- * P1-10: verify the process behind a stale pid file is actually ours before
143
- * sending any signal. `ps -o command=` (not `comm=`) is used because agent
144
- * binaries may be bash-wrapped scripts whose comm is the interpreter name
145
- * (`/bin/bash`); the full command line contains the binary path/basename.
169
+ * P1-10: 杀掉上一次崩溃留下的孤儿 agent 进程。**身份校验是动手的前置条件**:
170
+ * pid 文件里的 pid 可能已被系统回收给无关进程,`kill(pid, 0)` 存活探测分不出来。
146
171
  *
147
- * Returns:
148
- * 'match' — ps succeeded and the command line contains this.binary
149
- * (absolute path) or its basename (PATH lookup).
150
- * 'mismatch' — ps succeeded but the command line is clearly a different
151
- * process (pid was recycled to an unrelated process).
152
- * 'unknown' — ps failed / process gone / output unreadable: cannot tell.
172
+ * 判定单源 = `platform/identity`(posix `ps -o command=` / win32 CIM),三态:
173
+ * - match → 杀整个进程组(detached:true 下 agent 是组长,与 Terminator
174
+ * posix 路径的 kill(-pid) 同语义,P1-12 子进程不孤儿化);
175
+ * - mismatch → pid 已被无关进程占用:绝不杀,文件是陈旧垃圾,清除自愈;
176
+ * - unknown → 查不到身份(ps 不可用/超时/CIM 被拒):同样不杀,清文件。
177
+ * 统一原则:只有身份匹配才杀,验证失败一律不杀(fail-closed)。
178
+ *
179
+ * 同步实现是有意为之:调用方(Bridge.getRunner)是同步方法,改成异步会级联
180
+ * 整个 runner 接口。posix 上的成本与迁移前逐字相同(一次 execFileSync ps);
181
+ * **win32 上同步路径恒返回 `unknown`**(CIM 必须经 PowerShell 收 stdout,没有同步
182
+ * 形态)→ 落到 fail-closed 分支「不杀 + 清文件」,语义正确而不是能力缺失。
153
183
  */
154
- private matchPidToBinary;
184
+ killOrphan(): void;
155
185
  /**
156
186
  * Register this runner with the process-level exit dispatcher (singleton).
157
187
  * Process listeners are installed at most once; subsequent calls only add
@@ -1,11 +1,12 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { spawnProcess, isWindowsCommandNotFoundLine, useDetachedProcessGroup, } from '../../platform/spawn.js';
3
2
  import path from 'node:path';
4
3
  import os from 'node:os';
5
4
  import fs from 'node:fs';
6
5
  import { silentlyUnlink } from '../../common/fs.js';
7
6
  import { getLogger } from '../../logger/index.js';
8
- import { ProcessStopper } from './process-stopper.js';
7
+ import { binaryName, verifyPidIdentityVerdictSync } from '../../platform/identity.js';
8
+ import { createTerminator } from '../../platform/terminator.js';
9
+ import { agentStopperRegistry } from '../../platform/agent-stopper.js';
9
10
  import { SpawnHeartbeat } from './spawn-heartbeat.js';
10
11
  import { createJSONLStream } from './jsonl-stream.js';
11
12
  import { DEFAULT_STOP_GRACE_MS } from '../../config/index.js';
@@ -78,7 +79,7 @@ export function unregisterExitCleanup(handler) {
78
79
  export class SpawningRunner {
79
80
  currentProcess = null;
80
81
  pidFilePath;
81
- stopper;
82
+ terminator;
82
83
  spawnHeartbeat;
83
84
  binary;
84
85
  stopGraceMs;
@@ -105,16 +106,25 @@ export class SpawningRunner {
105
106
  * 非零退出」才判 command-not-found;真挂起由既有 grace/超时兜底。
106
107
  */
107
108
  commandNotFoundSeen = false;
109
+ /**
110
+ * 本 runner 的 agent key('claude'/…):win32 上既是 Terminator 查协议停止
111
+ * 通道的键,也是这里登记通道的键。undefined = 无通道(如 `!` bash)。
112
+ */
113
+ agent;
114
+ /** 已登记的协议停止通道(agent+pid 键);null = 未登记。 */
115
+ stopperRegistration = null;
108
116
  constructor(opts) {
109
117
  // Subclasses set this.binary after super()
110
118
  this.binary = '';
111
119
  this.stopGraceMs = opts.stopGraceMs ?? DEFAULT_STOP_GRACE_MS;
112
120
  this.logTag = opts.logTag ?? 'spawning-runner';
121
+ this.agent = opts.agent;
113
122
  const pidDir = opts.pidDir ?? path.join(os.homedir(), '.lark-remote');
114
123
  const workspaceSuffix = `-${opts.workspace.replace(/[^a-zA-Z0-9]/g, '_')}`;
115
124
  const pidFilePrefix = opts.pidFilePrefix ?? 'spawning';
116
125
  this.pidFilePath = path.join(pidDir, `${pidFilePrefix}${workspaceSuffix}.pid`);
117
- this.stopper = new ProcessStopper({ graceMs: this.stopGraceMs });
126
+ this.terminator =
127
+ opts.terminator ?? createTerminator({ graceMs: this.stopGraceMs, agent: opts.agent });
118
128
  this.spawnHeartbeat = new SpawnHeartbeat(opts.spawnHeartbeatMs ?? 30_000, this.logTag);
119
129
  }
120
130
  get isRunning() {
@@ -149,7 +159,7 @@ export class SpawningRunner {
149
159
  * 'error' for ENOENT/EACCES, but some binaries fail silently without ever
150
160
  * emitting it. Without the race, the spawn lead-in hangs forever → the
151
161
  * workspace serial queue never settles → permanent deadlock, and /stop
152
- * can't recover (ProcessStopper returns early when pid === undefined).
162
+ * can't recover (Terminator.stop returns early when pid === undefined).
153
163
  * The timeout keeps the deadlock bounded.
154
164
  */
155
165
  awaitSpawnError(proc) {
@@ -209,6 +219,9 @@ export class SpawningRunner {
209
219
  proc.stdout?.once('data', () => {
210
220
  this.spawnHeartbeat.notifyStdout();
211
221
  });
222
+ // 协议停止通道登记(design §3.3):子进程起来后立刻登记,win32 上
223
+ // Terminator 停这个 pid 时才能取到通道;进程退出/被停后注销。
224
+ this.registerStopper(proc);
212
225
  this.spawnStderr = '';
213
226
  proc.stderr?.on('data', (chunk) => {
214
227
  const text = chunk.toString('utf-8').trim();
@@ -231,25 +244,79 @@ export class SpawningRunner {
231
244
  });
232
245
  return proc;
233
246
  }
247
+ /**
248
+ * 登记本进程的协议停止通道(design §3.3 表):win32 上没有可拦截的跨进程
249
+ * SIGTERM,「优雅停止」只能经 agent 自有通道请求对方自行退出。stdio 型 agent
250
+ * CLI 的通道就是关 stdin(cc-connect 已验证 claude:关 stdin → 干净退出并跑
251
+ * Stop hooks)。
252
+ *
253
+ * 只在子进程真的挂了 stdin 管道时登记:stdin 为 'ignore' 的子类若照登一条空
254
+ * 通道,优雅段会从「无通道 → 显式跳过 + 直接树杀」退化成「空转满 grace 再
255
+ * 树杀」——照样能停,但白等一个不会被消费的请求。
256
+ */
257
+ registerStopper(proc) {
258
+ const pid = proc.pid;
259
+ if (this.agent === undefined || pid === undefined || !proc.stdin)
260
+ return;
261
+ const stopper = (target) => {
262
+ target.stdin?.end();
263
+ };
264
+ // 上一次 spawn 的登记先撤掉:进程换代后旧 pid 的条目会一直挂在注册表里
265
+ // (pid 被系统复用时可能指向无关进程)。
266
+ this.unregisterStopper();
267
+ agentStopperRegistry.register(this.agent, pid, stopper);
268
+ this.stopperRegistration = { pid, stopper };
269
+ }
270
+ /** 注销本 runner 登记的通道(幂等;未登记时无操作)。 */
271
+ unregisterStopper() {
272
+ const reg = this.stopperRegistration;
273
+ if (!reg || this.agent === undefined)
274
+ return;
275
+ this.stopperRegistration = null;
276
+ agentStopperRegistry.unregister(this.agent, reg.pid, reg.stopper);
277
+ }
234
278
  /**
235
279
  * Stop the current process if one is running. Delegates the actual
236
- * SIGTERM → grace → SIGKILL sequence to `this.stopper.stop(proc, opts)`
237
- * (see `src/runner/common/process-stopper.ts`), forwarding the `immediate`
238
- * flag verbatim so all subclasses inherit identical stop semantics.
280
+ * posix SIGTERM → grace → SIGKILL / win32 协议通道 → grace → taskkill 序列
281
+ * 给 `this.terminator.stop(proc, { immediate })`(见
282
+ * `src/platform/terminator.ts`),`immediate` 在这里收敛成显式 boolean,
283
+ * 所有子类继承同一套停止语义。
239
284
  */
240
285
  async stop(opts) {
241
286
  const proc = this.currentProcess;
242
287
  if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
243
288
  this.spawnHeartbeat.clear();
244
289
  this.currentProcess = null;
290
+ this.unregisterStopper();
245
291
  return;
246
292
  }
247
293
  this.stoppedByUser = true;
248
- await this.stopper.stop(proc, { immediate: opts?.immediate });
294
+ // `immediate: opts?.immediate === true`:Terminator 契约要求显式 boolean,
295
+ // 旧 ProcessStopper 的 `immediate?: boolean` 会静默接受 undefined。
296
+ await this.terminator.stop(proc, { immediate: opts?.immediate === true });
297
+ // 注销必须在 stop() 之后:Terminator 就是在上面那次调用里查表取通道的
298
+ // (immediate 路径不查表,但一并注销同样正确——进程已进入终止流程)。
299
+ this.unregisterStopper();
249
300
  this.currentProcess = null;
250
301
  silentlyUnlink(this.pidFilePath);
251
302
  getLogger().debug(`[${this.logTag}] cleaned pid file ${this.pidFilePath}`);
252
303
  }
304
+ /**
305
+ * P1-10: 杀掉上一次崩溃留下的孤儿 agent 进程。**身份校验是动手的前置条件**:
306
+ * pid 文件里的 pid 可能已被系统回收给无关进程,`kill(pid, 0)` 存活探测分不出来。
307
+ *
308
+ * 判定单源 = `platform/identity`(posix `ps -o command=` / win32 CIM),三态:
309
+ * - match → 杀整个进程组(detached:true 下 agent 是组长,与 Terminator
310
+ * posix 路径的 kill(-pid) 同语义,P1-12 子进程不孤儿化);
311
+ * - mismatch → pid 已被无关进程占用:绝不杀,文件是陈旧垃圾,清除自愈;
312
+ * - unknown → 查不到身份(ps 不可用/超时/CIM 被拒):同样不杀,清文件。
313
+ * 统一原则:只有身份匹配才杀,验证失败一律不杀(fail-closed)。
314
+ *
315
+ * 同步实现是有意为之:调用方(Bridge.getRunner)是同步方法,改成异步会级联
316
+ * 整个 runner 接口。posix 上的成本与迁移前逐字相同(一次 execFileSync ps);
317
+ * **win32 上同步路径恒返回 `unknown`**(CIM 必须经 PowerShell 收 stdout,没有同步
318
+ * 形态)→ 落到 fail-closed 分支「不杀 + 清文件」,语义正确而不是能力缺失。
319
+ */
253
320
  killOrphan() {
254
321
  if (!fs.existsSync(this.pidFilePath))
255
322
  return;
@@ -260,21 +327,23 @@ export class SpawningRunner {
260
327
  silentlyUnlink(this.pidFilePath);
261
328
  return;
262
329
  }
263
- // P1-10: pid 复用防护。pid 文件里的 pid 可能是 bridge 崩溃后系统回收复用
264
- // 给了无关进程——kill(pid, 0) 存活探测无法区分,必须先验证进程身份。
265
- const match = this.matchPidToBinary(pid);
266
- if (match !== 'match') {
267
- // 'gone':ps 查不到/报错 → 进程已消失,陈旧追踪物自愈清除(不杀)。
268
- // 'mismatch':身份不匹配 → pid 已被无关进程占用,绝不能杀;文件是
269
- // 陈旧垃圾,一并清除。
270
- // 统一原则:只有身份匹配才杀;验证失败一律不杀。
271
- getLogger().warn(`[${this.logTag}] killOrphan: pid ${pid} not confirmed as ${this.binary} ` +
272
- `(${match}), skipping kill`);
330
+ const expectedBinary = binaryName(this.binary);
331
+ // matchMode 'agent-invocation'(不是默认的 'executable'):agent CLI 普遍是
332
+ // `#!/usr/bin/env node` 脚本,ps 看到的是 `node <安装路径>/…/cli.js`,名字只在
333
+ // 路径段里(pi → @earendil-works/pi-coding-agent/…)。用默认档会恒判 mismatch
334
+ // → 这些 agent 的孤儿永远回收不掉(2026-09-20 实测安装形态)。
335
+ const verdict = verifyPidIdentityVerdictSync(pid, {
336
+ expectedBinary,
337
+ matchMode: 'agent-invocation',
338
+ });
339
+ if (verdict !== 'match') {
340
+ getLogger().warn(`[${this.logTag}] killOrphan: pid ${pid} not confirmed as ${expectedBinary} ` +
341
+ `(${verdict}), skipping kill`);
273
342
  silentlyUnlink(this.pidFilePath);
274
343
  return;
275
344
  }
276
345
  // 身份匹配:杀整个进程组(detached:true 下 agent 是组长),与
277
- // ProcessStopper 的 kill(-pid) 语义对齐,子进程不会孤儿化(P1-12)。
346
+ // Terminator 的 kill(-pid) 语义对齐,子进程不会孤儿化(P1-12)。
278
347
  getLogger().info(`[${this.logTag}] killing orphan process group ${-pid}`);
279
348
  try {
280
349
  process.kill(-pid, 'SIGTERM');
@@ -288,37 +357,6 @@ export class SpawningRunner {
288
357
  // ignore
289
358
  }
290
359
  }
291
- /**
292
- * P1-10: verify the process behind a stale pid file is actually ours before
293
- * sending any signal. `ps -o command=` (not `comm=`) is used because agent
294
- * binaries may be bash-wrapped scripts whose comm is the interpreter name
295
- * (`/bin/bash`); the full command line contains the binary path/basename.
296
- *
297
- * Returns:
298
- * 'match' — ps succeeded and the command line contains this.binary
299
- * (absolute path) or its basename (PATH lookup).
300
- * 'mismatch' — ps succeeded but the command line is clearly a different
301
- * process (pid was recycled to an unrelated process).
302
- * 'unknown' — ps failed / process gone / output unreadable: cannot tell.
303
- */
304
- matchPidToBinary(pid) {
305
- try {
306
- const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
307
- encoding: 'utf-8',
308
- }).trim();
309
- if (!out || out.includes('<defunct>'))
310
- return 'gone';
311
- const binary = this.binary;
312
- if (!binary)
313
- return 'gone';
314
- const needle = binary.includes('/') ? binary : path.basename(binary);
315
- return out.includes(needle) ? 'match' : 'mismatch';
316
- }
317
- catch {
318
- // ps 失败(进程不存在/权限)→ 视作 gone:不杀,让陈旧文件自愈
319
- return 'gone';
320
- }
321
- }
322
360
  /**
323
361
  * Register this runner with the process-level exit dispatcher (singleton).
324
362
  * Process listeners are installed at most once; subsequent calls only add
@@ -355,12 +393,14 @@ export class SpawningRunner {
355
393
  if (this.currentProcess && this.currentProcess.exitCode === null) {
356
394
  try {
357
395
  // P1-12: 只杀组长会让组内子进程(工具调用起的后台进程)reparent 成孤儿。
358
- // 复用 ProcessStopper 的 immediate 组杀(SIGTERM+SIGKILL 同步发出,
359
- // 进程退出路径 fire-and-forget 足够;正常进程退出时是 no-op)。
360
- void this.stopper.stop(this.currentProcess, { immediate: true });
396
+ // Terminator.cleanupOnExit 在两种平台上都是「fire-and-forget 强杀整个
397
+ // 进程组/进程树」(posix kill(-pgid, SIGKILL) / win32 taskkill /T /F),
398
+ // 退出路径上无法等待,也不需要优雅段。
399
+ this.terminator.cleanupOnExit(this.currentProcess);
361
400
  }
362
401
  catch { }
363
402
  }
403
+ this.unregisterStopper();
364
404
  silentlyUnlink(this.pidFilePath);
365
405
  }
366
406
  }
@@ -12,7 +12,7 @@
12
12
  * (DSH v1 has no `since` resume — reconnection = reopen stream + refetch
13
13
  * history, dedup by event seq).
14
14
  */
15
- import type { DshModelCatalogValue, DshModelSelection, DshPresetListValue, DshServerRequest, DshSessionModelsValue, DshStreamItem, DshTokenUsage } from './types.js';
15
+ import type { DshModelCatalogValue, DshModelSelection, DshPresetListValue, DshServerRequest, DshStreamItem, DshTokenUsage } from './types.js';
16
16
  interface WebSocketMessageEvent {
17
17
  data: unknown;
18
18
  }
@@ -49,8 +49,6 @@ export declare class DshClient {
49
49
  selectModel(payload: DshModelSelection & {
50
50
  sessionId: string;
51
51
  }): Promise<DshModelSelection>;
52
- /** Read the session's current model selection + full model catalog. */
53
- sessionModels(sessionId: string): Promise<DshSessionModelsValue>;
54
52
  /** Read the model catalog (session-independent, llm.models). */
55
53
  listModels(): Promise<DshModelCatalogValue>;
56
54
  /** Read the preset catalog (agentPreset.list). */
@@ -81,10 +81,6 @@ export class DshClient {
81
81
  const value = (await this.unary('session.selectModel', payload));
82
82
  return value.selected;
83
83
  }
84
- /** Read the session's current model selection + full model catalog. */
85
- async sessionModels(sessionId) {
86
- return (await this.unary('session.models', { sessionId }));
87
- }
88
84
  /** Read the model catalog (session-independent, llm.models). */
89
85
  async listModels() {
90
86
  return (await this.unary('llm.models', {}));
@@ -17,12 +17,6 @@ export interface DshSessionEvent {
17
17
  time: number;
18
18
  data: Record<string, unknown>;
19
19
  }
20
- /** assistant/chunk chunk payload (StreamChunk). */
21
- export interface DshChunk {
22
- type: string;
23
- index?: number;
24
- text?: string;
25
- }
26
20
  /** TokenUsage from assistant/message.usage (dsh-llm TokenUsage). */
27
21
  export interface DshTokenUsage {
28
22
  inputTokens: number;
@@ -89,17 +83,6 @@ export interface DshModelSelection {
89
83
  model: string;
90
84
  reasoningEffort?: string;
91
85
  }
92
- /** session.models → value. */
93
- export interface DshSessionModelsValue {
94
- current: DshModelSelection;
95
- routable: boolean;
96
- groups: DshModelGroup[];
97
- failures?: Array<{
98
- id: string;
99
- name: string;
100
- message: string;
101
- }>;
102
- }
103
86
  /** One preset row of agentPreset.list. */
104
87
  export interface DshPresetEntry {
105
88
  id: string;
@@ -115,25 +98,6 @@ export interface DshPresetListValue {
115
98
  authorable: boolean;
116
99
  hasDocument: boolean;
117
100
  }
118
- /** Mux frame payloads we care about (subset of MuxFrame). */
119
- export type DshMuxFrame = {
120
- type: 'session/event';
121
- sessionId: string;
122
- event: DshSessionEvent;
123
- } | {
124
- type: 'approval/requested';
125
- sessionId: string;
126
- approvalId: string;
127
- toolName: string;
128
- callId?: string;
129
- reason?: string;
130
- } | {
131
- type: 'stream/error';
132
- error: {
133
- code: string;
134
- message: string;
135
- };
136
- };
137
101
  /** ServerRequest envelope (frame.method === frame.payload.type). */
138
102
  export interface DshServerRequest {
139
103
  type: 'server-request';
@@ -44,11 +44,18 @@ export interface KimiAcpRunnerOptions {
44
44
  * LLM 请求(引擎带重试),时长无上限,旧的固定 30s 轮询超时已删除。 */
45
45
  compactIdleTimeoutMs?: number;
46
46
  model?: string;
47
+ /**
48
+ * 思考强度(`low`|`high`|`max`,取值由 `KIMI_THINKING_EFFORTS` 校验)。
49
+ * 经 `session/set_config_option {configId:'thinking'}` 下发;不下发则跑
50
+ * kimi 服务端默认档(配置卡片存了也不生效)。
51
+ */
52
+ thinkingEffort?: string;
47
53
  /** Kimi permission mode (user-facing: manual/auto/yolo). */
48
54
  permissionMode?: 'manual' | 'auto' | 'yolo';
49
55
  }
50
56
  export declare class KimiAcpRunner extends BaseAcpRunner<KimiAcpTranslator> {
51
57
  private permissionMode;
58
+ private thinkingEffort?;
52
59
  private readonly compactIdleTimeoutMs;
53
60
  /**
54
61
  * §5.2 compaction baseline: wire.jsonl records sampled by compactBaseline()
@@ -70,7 +77,17 @@ export declare class KimiAcpRunner extends BaseAcpRunner<KimiAcpTranslator> {
70
77
  private bashToolCallIds;
71
78
  /** 当前 run 的 session cwd(terminal/create 未给 cwd 时回退用)。 */
72
79
  private activeCwd;
73
- private readonly processStopper;
80
+ /**
81
+ * 终端子进程终止器(terminal/kill|release 与 dispose 清理用)。agent 必须是
82
+ * 'kimi':win32 上优雅段靠协议停止通道,查错 key 会静默退化成直接树杀。
83
+ */
84
+ private readonly terminator;
85
+ /**
86
+ * shell seam:terminal/create 的可执行解析走这里(design.md §7.2)。kimi 的
87
+ * ACP 服务端固定要 `bash -c`,win32 上没有 Git Bash 时必须在 seam 里变成
88
+ * 明确错误,而不是一个语焉不详的 spawn ENOENT。
89
+ */
90
+ private readonly shell;
74
91
  constructor(opts: KimiAcpRunnerOptions);
75
92
  protected get logTag(): string;
76
93
  protected get turnTimeoutErrorMessage(): string;