lark-remote 0.5.10 → 0.5.12

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 (49) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/binder.js +7 -1
  3. package/dist/bridge/index.js +12 -3
  4. package/dist/card/approval-render.d.ts +14 -0
  5. package/dist/card/approval-render.js +155 -29
  6. package/dist/card/bash-card-session.js +8 -0
  7. package/dist/card/bash-renderer.js +11 -23
  8. package/dist/card/card-budget.js +24 -4
  9. package/dist/card/run-renderer.js +31 -9
  10. package/dist/common/text-width.js +5 -0
  11. package/dist/common/tool-summary.js +4 -2
  12. package/dist/config/codex-config.js +5 -3
  13. package/dist/config/index.js +13 -1
  14. package/dist/index.js +42 -24
  15. package/dist/instance-lock.d.ts +9 -1
  16. package/dist/instance-lock.js +42 -7
  17. package/dist/platform/identity.js +4 -3
  18. package/dist/platform/path.js +3 -1
  19. package/dist/platform/terminator-win32.d.ts +13 -1
  20. package/dist/platform/terminator-win32.js +4 -14
  21. package/dist/router/index.js +52 -10
  22. package/dist/runner/codex/app-server/runner.js +12 -0
  23. package/dist/runner/common/acp/base-translator.d.ts +12 -0
  24. package/dist/runner/common/acp/base-translator.js +22 -0
  25. package/dist/runner/common/jsonl-stream.js +12 -1
  26. package/dist/runner/common/jsonrpc/connection-manager.js +11 -1
  27. package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
  28. package/dist/runner/common/jsonrpc/transport.js +7 -1
  29. package/dist/runner/dsh/approval.js +16 -3
  30. package/dist/runner/dsh/client.d.ts +31 -1
  31. package/dist/runner/dsh/client.js +126 -6
  32. package/dist/runner/dsh/exit-cancel.js +62 -23
  33. package/dist/runner/dsh/runner.d.ts +7 -0
  34. package/dist/runner/dsh/runner.js +42 -5
  35. package/dist/runner/kimi/acp/runner.js +42 -5
  36. package/dist/runner/kimi/acp/translator.d.ts +8 -0
  37. package/dist/runner/kimi/acp/translator.js +10 -0
  38. package/dist/runner/pi/rpc/runner.js +33 -5
  39. package/dist/runner/resolve-agent-choices.js +5 -1
  40. package/dist/runner/sync-agent-choices.js +4 -1
  41. package/dist/session/claude/session-index.js +9 -2
  42. package/dist/session/codex/rollout-reader.js +51 -26
  43. package/dist/session/common/usage-accumulator.d.ts +10 -1
  44. package/dist/session/common/usage-accumulator.js +10 -2
  45. package/dist/session/dsh/sessions.js +29 -9
  46. package/dist/session/kimi/sessions.js +9 -5
  47. package/dist/session/opencode/sessions.js +13 -6
  48. package/dist/session/session-store.js +4 -3
  49. package/package.json +1 -1
@@ -9,7 +9,7 @@ import { displayWidth, truncateToDisplayWidth } from '../common/text-width.js';
9
9
  import { isContentFilterFailure } from '../common/turn-failure.js';
10
10
  import { formatClock, formatTimestamp } from './time.js';
11
11
  import { formatUsageStats } from '../router/usage-format.js';
12
- import { renderApprovalArea } from './approval-render.js';
12
+ import { renderApprovalArea, renderApprovalAreaCompact } from './approval-render.js';
13
13
  const REASONING_BYTES = 4_500;
14
14
  const TEXT_BYTES = 10_000;
15
15
  const DEGRADED_THINKING_KEEP = 2;
@@ -448,6 +448,24 @@ function approvalArea(state) {
448
448
  }
449
449
  return elements;
450
450
  }
451
+ /**
452
+ * 紧凑审批区(skeleton/truncatedSkeleton 兜底专用):完整审批区不受估算器
453
+ * 管辖、不参与 degraded/extreme 分层裁剪,pathological 输入(超长 reason、
454
+ * 题数×选项爆元素数)会让所有兜底层一起超限——超限卡被飞书整卡拒收(11310),
455
+ * 审批彻底不可见,run 挂到审批超时/看门狗。兜底层换用紧凑形态(固定小体积、
456
+ * 保留决策/跳过按钮),结构性保证审批入口在任何降级档都活着。
457
+ */
458
+ function approvalAreaCompact(state) {
459
+ const elements = [];
460
+ for (const slot of state.approvals ?? []) {
461
+ elements.push(...renderApprovalAreaCompact(slot.view, {
462
+ expired: slot.expired,
463
+ runId: state.runId,
464
+ terminal: state.terminal,
465
+ }));
466
+ }
467
+ return elements;
468
+ }
451
469
  /** 底部操作行之前的内容区提示:被 MAX_BLOCKS 截掉的早期块计数(信息保真)。 */
452
470
  function omittedBlocksHint(state) {
453
471
  return state.omittedBlocks !== undefined && state.omittedBlocks > 0
@@ -654,11 +672,12 @@ function elapsedLabel(state) {
654
672
  return `${hours}h${String(minutes % 60).padStart(2, '0')}m`;
655
673
  return `${Math.floor(hours / 24)}d${String(hours % 24).padStart(2, '0')}h`;
656
674
  }
657
- /** token 数量的紧凑写法:`950` / `12K` / `1.2M`。 */
675
+ /** token 数量的紧凑写法:`950` / `12K` / `1.0M`。 */
658
676
  function formatTokens(tokens) {
659
677
  if (tokens < 1000)
660
678
  return String(tokens);
661
- if (tokens < 1_000_000)
679
+ // 千位边界取 M 档:999_500 起四舍五入会得到 "1000K",直接按 M 档展示。
680
+ if (tokens < 999_500)
662
681
  return `${Math.round(tokens / 1000)}K`;
663
682
  return `${(tokens / 1_000_000).toFixed(1)}M`;
664
683
  }
@@ -885,20 +904,23 @@ export function renderRunCard(state, options = {}) {
885
904
  return extremeCard;
886
905
  }
887
906
  const skeletonCard = assembleRunCard(state, options, buildSkeletonElements(state, options));
888
- // skeleton 卡是静态结构,理论上必 ≤28KB;保留兜底断言以防未来结构膨胀
889
- if (Buffer.byteLength(JSON.stringify(skeletonCard), 'utf8') <= CARD_BUDGET_BYTES) {
907
+ // skeleton 卡是静态结构,理论上必 ≤28KB;但审批区/summary 携带 agent 产出
908
+ // 文本,仍可能把任一维度顶爆(此前只查字节——题数×选项的元素数超限会漏网)。
909
+ // 保留兜底断言以防未来结构膨胀;超限则降级到 truncatedSkeleton。
910
+ if (withinRunCardBudget(skeletonCard)) {
890
911
  return skeletonCard;
891
912
  }
892
- // 理论上不可达:skeleton 已是静态最小结构。若仍超限(errorMsg 等无界文本
893
- // 灌进 summary),硬截断只丢 summary——审批入口与 stop/新会话按钮是红线,
894
- // 任何一级兜底都不能把它们截掉(丢了用户就再也停不下这个 run)。
913
+ // 理论上不可达:skeleton 已是静态最小结构。若仍超限(超长 reason、题数×
914
+ // 选项爆元素数等审批区输入),硬截断只丢 summary 与完整审批区——审批入口
915
+ // (紧凑形态:标题 + 摘要 + 决策/跳过按钮)与 stop/新会话按钮是红线,任何
916
+ // 一级兜底都不能把它们截掉(丢了用户就再也停不下这个 run / 答不了审批)。
895
917
  const truncatedSkeleton = {
896
918
  ...skeletonCard,
897
919
  body: {
898
920
  elements: [
899
921
  statusRow(state),
900
922
  markdownDiv('_⚠️ 输出过大_'),
901
- ...approvalArea(state),
923
+ ...approvalAreaCompact(state),
902
924
  ...actionRow(state, options),
903
925
  ],
904
926
  },
@@ -54,6 +54,11 @@ function charWidth(ch) {
54
54
  return 2;
55
55
  if (code >= 0x2600 && code <= 0x27bf)
56
56
  return 2;
57
+ // Misc Technical 块(U+2300–U+23FF):⏱⏹⏳⏸⏺ 等计时器/播放器符号在
58
+ // 状态行里实际使用,手机端按 emoji 宽度渲染——按 1 计会系统性低估行宽,
59
+ // 窄屏余量比设计值更小。
60
+ if (code >= 0x2300 && code <= 0x23ff)
61
+ return 2;
57
62
  return 1;
58
63
  }
59
64
  /**
@@ -61,7 +61,9 @@ export function summarizeToolInput(name, input) {
61
61
  if (typeof v !== 'string')
62
62
  return '';
63
63
  const oneLine = v.replace(/\s+/g, ' ').trim();
64
- return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
64
+ // 码点安全截断:裸 slice 切在代理对中间会留下孤立代理项进卡片
65
+ // (同族修复见 tool-render.ts 的 toolHeaderText)。
66
+ return truncate(oneLine, max);
65
67
  };
66
68
  /** First non-empty of several keys(DSH 同名工具跨版本改过参数名)。 */
67
69
  const pickAny = (keys, max = HEADER_SUMMARY_MAX) => {
@@ -82,7 +84,7 @@ export function summarizeToolInput(name, input) {
82
84
  for (const item of v) {
83
85
  if (typeof item === 'string' && item.trim()) {
84
86
  const oneLine = item.replace(/\s+/g, ' ').trim();
85
- return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
87
+ return truncate(oneLine, max);
86
88
  }
87
89
  if (item && typeof item === 'object') {
88
90
  const path = item.path;
@@ -518,9 +518,11 @@ export function loadCodexConfig(opts = {}) {
518
518
  };
519
519
  }
520
520
  catch {
521
- // config.toml 解析失败(readModelCatalogJsonPath 已把此场景标记为 catalog 声明退化 ''):
522
- // catalogMode 为 true 时绝不回退 bundled/FALLBACK,返回空 provider/模型列表;
523
- // 非 catalog 才走 FALLBACK 兜底。
521
+ // config.toml 读取/解析失败:readModelCatalogJsonPath 对此返回
522
+ // TOML_PARSE_FAILED_SENTINEL(isCodexCatalogMode → false),进入本 catch
523
+ // 时 catalogMode 恒为 false——下面的 `if (catalogMode)` 分支实际不可达,
524
+ // 仅作防御保留。真正「catalog 声明退化」的场景('' 哨兵)在 parse 之前
525
+ // 就已按 FALLBACK 兜底处理,不会走到这里。
524
526
  if (catalogMode) {
525
527
  return {
526
528
  currentModel: '',
@@ -353,7 +353,19 @@ export function loadConfig(configPath) {
353
353
  process.exit(1);
354
354
  }
355
355
  const raw = fs.readFileSync(configPath, 'utf-8');
356
- const parsed = YAML.parse(raw);
356
+ // YAML 语法错误(tab 缩进等)必须与校验失败同路径(console.error + exit):
357
+ // 直接上抛会冒到 main().catch——此时 logger 尚未 initLogger,getLogger()
358
+ // 写的是默认目录的自定义 --config-dir 对不上,且 logger 只写文件,终端
359
+ // 零输出,用户只看到进程无声退出。
360
+ let parsed;
361
+ try {
362
+ parsed = YAML.parse(raw);
363
+ }
364
+ catch (err) {
365
+ console.error(`[lark-remote] config YAML parse failed at ${configPath}:`);
366
+ console.error(` ${err.message}`);
367
+ process.exit(1);
368
+ }
357
369
  const result = AppConfigSchema.safeParse(parsed);
358
370
  if (!result.success) {
359
371
  const errors = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');
package/dist/index.js CHANGED
@@ -34,9 +34,17 @@ import { InboundAuthorizer } from './inbound/authorize.js';
34
34
  import { stripPlaceholders } from './inbound/placeholder.js';
35
35
  import { buildSessionHistoryCard, pickResumeCardEvents } from './router/card-helpers.js';
36
36
  import { newSessionButton, resumeCompactButton, agentDisplayName } from './card/card-shared.js';
37
+ import { sleep } from './common/sleep.js';
37
38
  import path from 'node:path';
38
39
  import fs from 'node:fs';
39
40
  import { bestEffortUnlink } from './common/fs.js';
41
+ /**
42
+ * 会话变更类卡片动作执行前冲刷入站装配窗口的等待预算(ms)。flush 本身无下载
43
+ * 在途时瞬时完成(顺序保证完整);预算只挡「in-flight 媒体下载卡住」的场景
44
+ * (下载超时 5 分钟/资源)——SDK 把卡片动作放进与消息共享的 per-chat 串行
45
+ * lane 并 await 到底,无界等待会让整个 chat 停摆到下载超时。
46
+ */
47
+ const SESSION_MUTATING_FLUSH_BUDGET_MS = 2_000;
40
48
  /**
41
49
  * /stop 命令别名单源:普通消息的停止分支与 clone 活跃期拦截的排除条件
42
50
  * 共用同一判定,防止将来加别名时两处漂移。
@@ -363,9 +371,12 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
363
371
  };
364
372
  let cwd = sessionStore.getCwd(turn.userId) ?? '';
365
373
  if (!cwd && workspaceStore) {
366
- const workspaces = workspaceStore.list();
367
- if (workspaces.length > 0)
368
- cwd = workspaces[0].path;
374
+ // 兜底取「最近使用」而非插入序首位:WorkspaceStore.list() 按 Map 插入序
375
+ // 返回(touch 原地改时间戳不重排),[0] 恒为最早保存的别名——触发时会把
376
+ // 消息静默发到最旧工作区,与「最近使用」直觉相反。
377
+ const recent = [...workspaceStore.list()].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
378
+ if (recent.length > 0)
379
+ cwd = recent[0].path;
369
380
  }
370
381
  const binding = bridge.currentBinding(turn.userId);
371
382
  bridge.enqueue(cwd, async () => {
@@ -456,6 +467,14 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
456
467
  logger.warn(`[media] rejected inbound media from non-owner ${msg.userId}`);
457
468
  return;
458
469
  }
470
+ // clone 状态机活跃期:一切输入归 clone 流程、不落盘附件
471
+ // (inbound-unified-input-design.md §5.8;文本入口有同款闸门)。静默丢弃
472
+ // 而非发回执——装配器的「📎 已保存」回执会与 clone 引导话术冲突,诱导
473
+ // 用户「说一句话」触发 clone 的婉拒回复。
474
+ if (cloneSession.isActive()) {
475
+ logger.info(`[media] dropped inbound media during clone session ${msg.messageId}`);
476
+ return;
477
+ }
459
478
  // 读 router.config(活引用):/config 保存后 setConfigValues 返回新对象,
460
479
  // 启动时捕获的 config 参数会过期(修复)。
461
480
  if (!router.config.inboundMedia.enabled) {
@@ -599,26 +618,14 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
599
618
  // Add Typing reaction to indicate "still alive"
600
619
  void connector.addReaction(messageId, 'Typing');
601
620
  if (actionValue.cmd === 'stop') {
602
- if (!actionValue.runId) {
603
- logger.warn('[control] ignored stop card action without runId');
604
- // Card button clicks must have visible feedback (design constraint: card button
605
- // 点击必须有可见反馈,见 design.md §6.2)。
606
- void bridge
607
- .sendResult({ text: '⚠️ 无效的停止请求,缺少必要信息' }, { userId, chatId, messageId })
608
- .catch((err) => logger.error('[control] card stop feedback failed:', err));
609
- return;
610
- }
611
- void (async () => {
612
- const stopped = await bridge.interruptCurrentRun({
613
- userId,
614
- chatId,
615
- runId: actionValue.runId,
616
- });
617
- if (!stopped) {
618
- // runId mismatch or run already exited — mirror /stop text miss path.
619
- await bridge.sendResult({ text: '该任务已结束,无需终止' }, { userId, chatId, messageId });
620
- }
621
- })().catch((err) => logger.error('[control] card stop failed:', err));
621
+ // 委托 router 的 handleCardStop(单一来源):miss 反馈与 DSH unconfirmed
622
+ // 警告(「未能确认 turn 已停止,可能仍在后台运行」)都在那里。此前这里
623
+ // 内联只调 interruptCurrentRun——unconfirmed 反馈被丢,run 卡被翻成
624
+ // interrupted 却暗示干净停止;miss 文案也与 router 形成两份漂移拷贝。
625
+ // stop 绕串行队列的原则不变:本分支在队列分发之前直接 return。
626
+ void router
627
+ .handleCardAction({ ...actionValue, cmd: 'stop' }, { userId, chatId, messageId })
628
+ .catch((err) => logger.error('[control] card stop failed:', err));
622
629
  return;
623
630
  }
624
631
  const isImmediate = isImmediateAction(actionValue.cmd);
@@ -632,8 +639,19 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
632
639
  // 保证窗口内先到的文本消息在本动作之前 commit(绑定旧 session/cwd/agent)。
633
640
  // 否则 700ms 静默窗内的消息会漂移到动作之后的新状态:例如输入「重新构建」
634
641
  // 后立即点「新会话」,消息会落到新会话。名单以 router 单源为准。
642
+ //
643
+ // 有界等待:flush 会等 in-flight 媒体下载落定(超时 5 分钟/
644
+ // 资源),而 SDK 的 chatQueue.cardActions 默认 'same'——卡片动作与消息共享
645
+ // 同一 per-chat 串行 lane 且被 await 到底。无界等待下,一个大视频在途 +
646
+ // 点「新会话」= 整个 chat 的所有消息与卡片动作停摆到下载超时。超预算后
647
+ // 放行本动作,flush 在后台继续:窗口内仍有未落定下载时,文本 commit 可能
648
+ // 落到本动作之后(漂移风险收窄到「卡住的下载 + 2s 内点击」的组合,可接受;
649
+ // 完整解法需要把 binding 快照进装配器,见 inbound-unified-input-design.md)。
635
650
  if (SESSION_MUTATING_ACTION_CMDS.has(actionValue.cmd)) {
636
- await assembler.flush(userId, chatId, 'flush');
651
+ const flushP = assembler
652
+ .flush(userId, chatId, 'flush')
653
+ .catch((err) => logger.error('[control] inbound flush failed:', err));
654
+ await Promise.race([flushP, sleep(SESSION_MUTATING_FLUSH_BUDGET_MS)]);
637
655
  }
638
656
  // queue.input / config.save / approval.respond / approval.toggle /
639
657
  // approval.answer 系列返回 CardActionResponse toast 给点击用户即时反馈。
@@ -33,7 +33,15 @@ export declare class InstanceLock {
33
33
  * 与 CIM CommandLine 首 token 归一后必然一致(bun/node 垫片场景亦然)。
34
34
  */
35
35
  private lockContents;
36
- /** 原子创建锁文件;已存在返回 false,其它 IO 错误向上抛。 */
36
+ /**
37
+ * 原子创建锁文件;已存在返回 false,其它 IO 错误向上抛。
38
+ *
39
+ * 先写完整临时文件再 `link()`:`open(wx)` 与 `write` 之间存在微秒级「空文件」
40
+ * 窗口,并发进程在该窗口读到的锁是「空 → 损坏 → 陈旧」,rename 夺走后双方
41
+ * 各持一份(A 的 fd 写进已被删掉的 inode、B 建了自己的锁 → 双实例)。
42
+ * link() 创建即含全部内容,窗口不存在;文件系统不支持 hardlink 时退回 wx
43
+ * 直建(该类 FS 上保留既有行为,竞态面收窄到该类文件系统)。
44
+ */
37
45
  private tryCreate;
38
46
  /**
39
47
  * 把夺来的活锁放回原位。优先 `link`(目标已存在会失败,不会覆盖更新的锁),
@@ -177,22 +177,57 @@ export class InstanceLock {
177
177
  lockContents(pid) {
178
178
  return `${pid}\n${binaryName(process.execPath)}`;
179
179
  }
180
- /** 原子创建锁文件;已存在返回 false,其它 IO 错误向上抛。 */
180
+ /**
181
+ * 原子创建锁文件;已存在返回 false,其它 IO 错误向上抛。
182
+ *
183
+ * 先写完整临时文件再 `link()`:`open(wx)` 与 `write` 之间存在微秒级「空文件」
184
+ * 窗口,并发进程在该窗口读到的锁是「空 → 损坏 → 陈旧」,rename 夺走后双方
185
+ * 各持一份(A 的 fd 写进已被删掉的 inode、B 建了自己的锁 → 双实例)。
186
+ * link() 创建即含全部内容,窗口不存在;文件系统不支持 hardlink 时退回 wx
187
+ * 直建(该类 FS 上保留既有行为,竞态面收窄到该类文件系统)。
188
+ */
181
189
  tryCreate() {
190
+ const tmp = `${this.lockPath}.new-${process.pid}-${randomBytes(6).toString('hex')}`;
182
191
  try {
183
- const fd = fs.openSync(this.lockPath, 'wx');
192
+ const fd = fs.openSync(tmp, 'wx');
184
193
  try {
185
194
  fs.writeSync(fd, this.lockContents(process.pid));
186
195
  }
187
196
  finally {
188
197
  fs.closeSync(fd);
189
198
  }
190
- return true;
199
+ try {
200
+ fs.linkSync(tmp, this.lockPath);
201
+ return true;
202
+ }
203
+ catch (err) {
204
+ const code = err.code;
205
+ if (code === 'EEXIST')
206
+ return false;
207
+ if (code === 'EPERM' || code === 'EOPNOTSUPP' || code === 'ENOSYS' || code === 'EXDEV') {
208
+ // hardlink 不可用(网络/overlay FS):退回 wx 直建。
209
+ try {
210
+ const direct = fs.openSync(this.lockPath, 'wx');
211
+ try {
212
+ fs.writeSync(direct, this.lockContents(process.pid));
213
+ }
214
+ finally {
215
+ fs.closeSync(direct);
216
+ }
217
+ return true;
218
+ }
219
+ catch (err2) {
220
+ if (err2.code === 'EEXIST')
221
+ return false;
222
+ throw err2;
223
+ }
224
+ }
225
+ throw err;
226
+ }
191
227
  }
192
- catch (err) {
193
- if (err.code === 'EEXIST')
194
- return false;
195
- throw err;
228
+ finally {
229
+ // 成功 link 后 tmp 与 lockPath 同 inode:删 tmp 只去掉多余名字(nlink 2→1)。
230
+ bestEffortUnlink(tmp);
196
231
  }
197
232
  }
198
233
  /**
@@ -12,9 +12,9 @@
12
12
  * (killOrphan)应按需调用,真机矩阵需实测整体耗时;
13
13
  * - platform 显式注入,win32 语义可在任意宿主上单测。
14
14
  */
15
- import { execFileSync, spawn } from 'node:child_process';
16
15
  import path from 'node:path';
17
16
  import { currentPlatform, isWin32 } from './select.js';
17
+ import { spawnProcess, spawnProcessSync } from './spawn.js';
18
18
  const DEFAULT_TIMEOUT_MS = 3000;
19
19
  /** 拉起一次性查询进程并收全 stdout;超时/拉起失败/进程报错一律 null。 */
20
20
  function collect(file, args, timeoutMs) {
@@ -24,7 +24,7 @@ function collect(file, args, timeoutMs) {
24
24
  // stderr 用 ignore:查询进程的错误输出我们不需要,pipe 而不消费在极端
25
25
  // 情况下(管道缓冲满)会阻塞子进程,把一次性查询拖成挂起。
26
26
  // windowsHide:避免查询进程在桌面闪控制台窗口
27
- proc = spawn(file, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
27
+ proc = spawnProcess(file, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
28
28
  }
29
29
  catch {
30
30
  resolve(null);
@@ -243,12 +243,13 @@ export function verifyPidIdentityVerdictSync(pid, opts) {
243
243
  return 'unknown';
244
244
  let stdout;
245
245
  try {
246
- stdout = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
246
+ const res = spawnProcessSync('ps', ['-o', 'command=', '-p', String(pid)], {
247
247
  encoding: 'utf-8',
248
248
  timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
249
249
  stdio: ['ignore', 'pipe', 'ignore'],
250
250
  windowsHide: true,
251
251
  });
252
+ stdout = String(res.stdout ?? '');
252
253
  }
253
254
  catch {
254
255
  // 进程不存在 / ps 不可用 / 超时:查不到身份 → unknown(不杀)
@@ -21,8 +21,10 @@ function resolveOptions(opts) {
21
21
  */
22
22
  export function pathKey(p, opts) {
23
23
  const { platform } = resolveOptions(opts);
24
+ // linux:剥尾部分隔符,与 darwin/win32 同口径(否则 `/cd ~/repo/` 与
25
+ // `/cd ~/repo` 会被判成两个目录——darwin 分支注释写明的不变量)。
24
26
  if (platform === 'linux')
25
- return p;
27
+ return p.replace(/\/+$/, '');
26
28
  if (isWin32(platform)) {
27
29
  // win32:\ 与 / 都是分隔符,且路径大小写不敏感
28
30
  return p.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase();
@@ -1,3 +1,15 @@
1
+ /**
2
+ * win32 终止器:协议停止通道 → grace 等待 → taskkill 树杀。
3
+ *
4
+ * 状态机:
5
+ * 1. 进程已退出 → nothing(via='already-exited')
6
+ * 2. immediate → taskkill /PID <pid> /T /F
7
+ * 3. 优雅 → AgentStopperRegistry 查协议通道
8
+ * - 无通道:打日志显式跳过优雅段(不等一个不会来的退出事件)→ 直接树杀
9
+ * - 有通道:调用后与 exit 事件竞速等 graceMs,仍存活 → 树杀
10
+ *
11
+ * 依赖(spawn / 等待器 / 日志)全部构造注入,win32 语义可在任意宿主上单测。
12
+ */
1
13
  import type { ChildProcess, SpawnOptions } from 'node:child_process';
2
14
  import type { Terminator } from './terminator.js';
3
15
  import { AgentStopperRegistry } from './agent-stopper.js';
@@ -11,7 +23,7 @@ export interface Win32TerminatorDeps {
11
23
  agent?: string;
12
24
  /** 协议通道注册表注入(测试隔离);默认进程级单例 */
13
25
  stoppers?: AgentStopperRegistry;
14
- /** spawn 注入(测试用);默认 node:child_process */
26
+ /** spawn 注入(测试用);默认 platform/spawn.ts 的 spawnProcess */
15
27
  spawn?: SpawnLike;
16
28
  /** grace 等待器注入(测试用);默认与 exit 事件竞速 */
17
29
  waitForExit?: WaitForExit;
@@ -1,17 +1,5 @@
1
- /**
2
- * win32 终止器:协议停止通道 → grace 等待 → taskkill 树杀。
3
- *
4
- * 状态机:
5
- * 1. 进程已退出 → nothing(via='already-exited')
6
- * 2. immediate → taskkill /PID <pid> /T /F
7
- * 3. 优雅 → AgentStopperRegistry 查协议通道
8
- * - 无通道:打日志显式跳过优雅段(不等一个不会来的退出事件)→ 直接树杀
9
- * - 有通道:调用后与 exit 事件竞速等 graceMs,仍存活 → 树杀
10
- *
11
- * 依赖(spawn / 等待器 / 日志)全部构造注入,win32 语义可在任意宿主上单测。
12
- */
13
- import { spawn as nodeSpawn } from 'node:child_process';
14
1
  import { agentStopperRegistry } from './agent-stopper.js';
2
+ import { spawnProcess } from './spawn.js';
15
3
  /** 退出判定统一口径(红线):exitCode !== null || signalCode !== null。 */
16
4
  function isAlive(proc) {
17
5
  return proc.exitCode === null && proc.signalCode === null;
@@ -36,7 +24,9 @@ async function defaultWaitForExit(proc, ms) {
36
24
  });
37
25
  }
38
26
  export function createWin32Terminator(deps) {
39
- const spawnFn = deps.spawn ?? nodeSpawn;
27
+ // 默认走 platform/spawn.ts 单点收口:taskkill 是系统二进制,
28
+ // cross-spawn 在 win32 之外直通、在 win32 垫片场景下受控执行。
29
+ const spawnFn = deps.spawn ?? spawnProcess;
40
30
  const stoppers = deps.stoppers ?? agentStopperRegistry;
41
31
  const waitForExit = deps.waitForExit ?? defaultWaitForExit;
42
32
  const log = deps.log;
@@ -319,6 +319,10 @@ const IMMEDIATE_ACTION_CMDS = new Set([
319
319
  'queue.diagnose',
320
320
  'queue.edit',
321
321
  'queue.input',
322
+ // 终态卡「停止后台任务」:纯远程 job/kill 请求,不产生 agent 工作(§9.19
323
+ // 控制操作免排队)。排进串行队列会等当前 run 结束才杀任务——stop 类按钮
324
+ // 排队正是 §9.6 要防的形态,且排队期间还占 lane 多出一张排队卡。
325
+ 'job.kill',
322
326
  'order.delete',
323
327
  'order.page',
324
328
  'order.aliasEdit',
@@ -365,6 +369,20 @@ export const COMPACT_ACTION_CMDS = new Set(['compact', 'resume.compact']);
365
369
  function payloadOffset(value) {
366
370
  return Math.max(0, Math.trunc(Number(value.offset) || 0));
367
371
  }
372
+ /**
373
+ * existsSync + statSync 的 TOCTOU 安全目录校验:路径在两次 syscalls 之间被删时
374
+ * statSync 会抛错——落进 enqueueImmediate / 队列任务的静默 catch,点击/命令
375
+ * 毫无反馈(违反「点击必有反馈」红线;同族 handleLsRefresh/Page/Filter 均已
376
+ * try/catch 防护)。统一返回 false 走「路径无效」回复。
377
+ */
378
+ function isDirectorySafe(p) {
379
+ try {
380
+ return fs.existsSync(p) && fs.statSync(p).isDirectory();
381
+ }
382
+ catch {
383
+ return false;
384
+ }
385
+ }
368
386
  /**
369
387
  * 搜索框提交值单源读取:`inputValue` 主路(CardKit 2.0 input 提交(键盘回车/
370
388
  * 完成键)经 raw `action.input_value` 回传,SDK normalizer 会丢弃
@@ -973,6 +991,11 @@ export class CommandRouter {
973
991
  const task = this.bridge.getQueuedTask(cwd, messageId);
974
992
  const queueInfo = this.bridge.getQueueInfo(cwd);
975
993
  const activeRuns = this.bridge.getAllActiveRuns();
994
+ // 「排在当前任务之前」按诊断目标自己的位置算(与 sendQueueStatusCard 同法)。
995
+ // getQueueInfo.tasksAhead 是队尾视角(len-1),诊断目标不是队尾时数字误导。
996
+ const queuedTasks = this.bridge.getQueuedTasks(cwd);
997
+ const targetIdx = queuedTasks.findIndex((t) => t.messageId === messageId);
998
+ const tasksAhead = targetIdx >= 0 ? targetIdx : queueInfo.tasksAhead;
976
999
  // Build diagnostic info
977
1000
  const now = new Date().toLocaleString('zh-CN');
978
1001
  const pid = process.pid;
@@ -1012,7 +1035,7 @@ export class CommandRouter {
1012
1035
  tag: 'div',
1013
1036
  text: {
1014
1037
  tag: 'lark_md',
1015
- content: `**队列状态**\n- 排队的任务数: ${queueInfo.position}\n- 当前正在运行: ${queueInfo.isRunning ? '✅ 是' : '❌ 否'}\n- 排在当前任务之前的消息数: ${queueInfo.tasksAhead}`,
1038
+ content: `**队列状态**\n- 排队的任务数: ${queueInfo.position}\n- 当前正在运行: ${queueInfo.isRunning ? '✅ 是' : '❌ 否'}\n- 排在当前任务之前的消息数: ${tasksAhead}`,
1016
1039
  },
1017
1040
  },
1018
1041
  { tag: 'hr' },
@@ -1203,7 +1226,7 @@ export class CommandRouter {
1203
1226
  const resolvedTarget = path.resolve(targetPath);
1204
1227
  // 与 ls.browse 对齐:目标只要存在且是目录即可切换(不再做子树/父级限制)。
1205
1228
  // 安全边界由 binder owner 认证兜底,见 docs/zh/architecture/design.md §9.7 更新说明。
1206
- if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
1229
+ if (!isDirectorySafe(resolvedTarget)) {
1207
1230
  await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
1208
1231
  return;
1209
1232
  }
@@ -1370,7 +1393,7 @@ export class CommandRouter {
1370
1393
  return;
1371
1394
  }
1372
1395
  const resolvedTarget = path.resolve(targetPath);
1373
- if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
1396
+ if (!isDirectorySafe(resolvedTarget)) {
1374
1397
  await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
1375
1398
  return;
1376
1399
  }
@@ -1417,8 +1440,12 @@ export class CommandRouter {
1417
1440
  async handleJobKill(value, ctx) {
1418
1441
  const runId = typeof value.runId === 'string' ? value.runId : '';
1419
1442
  const jobId = typeof value.jobId === 'string' ? value.jobId : '';
1420
- if (!runId || !jobId)
1443
+ // miss path 必回复(本文件的设计约束):旧版/异常卡片 payload 缺参时
1444
+ // 静默 return 会让点击毫无反应,用户无从判断。
1445
+ if (!runId || !jobId) {
1446
+ await this.bridge.sendResult({ text: CARD_PAYLOAD_MISSING }, ctx);
1421
1447
  return;
1448
+ }
1422
1449
  if (await this.bridge.killBackgroundJob(runId, jobId, ctx))
1423
1450
  return;
1424
1451
  await this.bridge.sendResult({ text: '停止失败:任务可能已自行结束,或记录已回收' }, ctx);
@@ -1743,12 +1770,14 @@ export class CommandRouter {
1743
1770
  }
1744
1771
  // Refresh the /ws list card in place so "recent" sort is immediately visible
1745
1772
  await this.refreshListCard('ws', value, ctx);
1746
- // Toast feedback (suppress text when a persistent message was already sent)
1773
+ // Toast feedback (suppress text when a persistent message was already sent).
1774
+ // 兜底文案必须用 warning 型:ws.use 不在 DIRECT_RETURN_CMDS,toast 经
1775
+ // enqueueImmediate fire-and-forget,而 forwardActionFeedback 只转发
1776
+ // error/warning 型——success 型兜底会被双重吞掉,点击毫无反馈。
1747
1777
  return {
1748
- toast: {
1749
- type: 'success',
1750
- content: fallbackToast ?? '',
1751
- },
1778
+ toast: fallbackToast
1779
+ ? { type: 'warning', content: fallbackToast }
1780
+ : { type: 'success', content: '' },
1752
1781
  };
1753
1782
  }
1754
1783
  /**
@@ -2603,7 +2632,15 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2603
2632
  if (!fs.existsSync(resolved)) {
2604
2633
  return { text: `路径不存在: ${resolved}` };
2605
2634
  }
2606
- const stat = fs.statSync(resolved);
2635
+ // TOCTOU:existsSync 与 statSync 之间路径被删时 statSync 抛错,会落进队列
2636
+ // 任务的静默 catch(用户无反馈)——按路径无效回复,与下方 realpath 守卫同口径。
2637
+ let stat;
2638
+ try {
2639
+ stat = fs.statSync(resolved);
2640
+ }
2641
+ catch {
2642
+ return { text: `路径无效: ${resolved}` };
2643
+ }
2607
2644
  if (!stat.isDirectory()) {
2608
2645
  return { text: `不是目录: ${resolved}` };
2609
2646
  }
@@ -3197,6 +3234,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3197
3234
  return { text: `workspace "${name}" 不存在` };
3198
3235
  if (!fs.existsSync(wsPath))
3199
3236
  return { text: `路径不存在: ${wsPath}` };
3237
+ // 目录校验与 /cd、ls.switch 对齐:目录被删后同路径出现同名文件(或
3238
+ // workspace.json 被手改)时 realpath 照样成功——放行会让下一次 run
3239
+ // 以文件为 cwd spawn(ENOTDIR)。
3240
+ if (!isDirectorySafe(wsPath))
3241
+ return { text: `不是目录: ${wsPath}` };
3200
3242
  // Canonicalize via realpath so it matches Claude JSONL cwd (2026-06-21).
3201
3243
  // TOCTOU guard: mirror cmdCd — if the path vanishes
3202
3244
  // between existsSync and realpath, reply gracefully instead of
@@ -175,6 +175,11 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
175
175
  this.currentTranslator = null;
176
176
  this.activeThreadId = null;
177
177
  this.foreignThreadIds.clear();
178
+ // pendingApprovals 必须随轮次清理(与 base-acp-runner 同口径):连接长驻,
179
+ // 服务端请求 id 在 respawn 后会从小数字重新计数——留着旧条目等于让下一轮
180
+ // 迟到的卡片点击把载荷回给无关的新请求(coordinator 的超时 cancel 也可能
181
+ // 落在新一轮的连接上)。
182
+ this.pendingApprovals.clear();
178
183
  }
179
184
  async releaseConnection(cwd) {
180
185
  await this.connectionManager.release(cwd);
@@ -394,6 +399,13 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
394
399
  this.noteForeignThread(params);
395
400
  return;
396
401
  }
402
+ if (method === NotificationMethod.SERVER_REQUEST_RESOLVED) {
403
+ // 审批被外部结算(Web UI 先答 / 服务端自动放行)时同步摘除 runner 侧
404
+ // 条目——否则迟到的卡片点击会向已结算的 requestId 回信(服务端报错)。
405
+ const requestId = params?.requestId;
406
+ if (requestId !== undefined)
407
+ this.pendingApprovals.delete(requestId);
408
+ }
397
409
  const events = this.currentTranslator?.handleNotification(method, params) ?? [];
398
410
  this.pushEvents(events);
399
411
  }
@@ -132,6 +132,10 @@ export declare abstract class BaseAcpTranslator {
132
132
  protected handleToolCall(event: ToolCallEvent, _sessionId: string): AcpTranslatorEvent[];
133
133
  /**
134
134
  * tool_call_update → user/tool_result. status:'failed' → is_error:true.
135
+ *
136
+ * 终态(completed/failed)产出 tool_result;非终态的 tool_result 由
137
+ * {@link emitToolResultForNonTerminalUpdate} 裁定(reducer 见到 tool_result
138
+ * 即置 status:'ok'——无内容的非终态 tool_result 会把运行中的工具提前翻成 ✅)。
135
139
  */
136
140
  protected handleToolCallUpdate(event: ToolCallUpdateEvent, _sessionId: string): AcpTranslatorEvent[];
137
141
  /**
@@ -183,6 +187,14 @@ export declare abstract class BaseAcpTranslator {
183
187
  * ({output} / {error}).
184
188
  */
185
189
  protected extractToolResultContent(event: ToolCallUpdateEvent): unknown;
190
+ /**
191
+ * 非终态 tool_call_update 是否附带 tool_result。默认 true(opencode seam
192
+ * 契约:in-progress 的输出快照随 tool_result 进面板)。kimi 覆写为
193
+ * 「有内容才发」——它的 in-progress 只带流式 args,空 tool_result 会让
194
+ * reducer 把运行中的工具提前置 ✅ + 空输出(终态丢失时面板停在错误的
195
+ * 「已完成」)。
196
+ */
197
+ protected emitToolResultForNonTerminalUpdate(content: unknown): boolean;
186
198
  /**
187
199
  * usage_update → context occupancy bookkeeping. The wire shape is
188
200
  * {used, size} (+ opencode: cost:{amount}) — context occupancy, not