lark-remote 0.5.9 → 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 (53) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/binder.js +7 -1
  3. package/dist/bridge/index.d.ts +9 -0
  4. package/dist/bridge/index.js +28 -3
  5. package/dist/bridge/queue-manager.d.ts +31 -0
  6. package/dist/bridge/queue-manager.js +78 -0
  7. package/dist/card/approval-render.d.ts +14 -0
  8. package/dist/card/approval-render.js +155 -29
  9. package/dist/card/bash-card-session.js +8 -0
  10. package/dist/card/bash-renderer.js +11 -23
  11. package/dist/card/card-budget.js +24 -4
  12. package/dist/card/run-renderer.js +31 -9
  13. package/dist/common/text-width.js +5 -0
  14. package/dist/common/tool-summary.js +4 -2
  15. package/dist/config/codex-config.js +5 -3
  16. package/dist/config/index.js +13 -1
  17. package/dist/index.js +42 -24
  18. package/dist/instance-lock.d.ts +9 -1
  19. package/dist/instance-lock.js +42 -7
  20. package/dist/platform/identity.js +4 -3
  21. package/dist/platform/path.js +3 -1
  22. package/dist/platform/terminator-win32.d.ts +13 -1
  23. package/dist/platform/terminator-win32.js +4 -14
  24. package/dist/router/index.js +179 -122
  25. package/dist/runner/codex/app-server/runner.js +12 -0
  26. package/dist/runner/common/acp/base-translator.d.ts +12 -0
  27. package/dist/runner/common/acp/base-translator.js +22 -0
  28. package/dist/runner/common/jsonl-stream.js +12 -1
  29. package/dist/runner/common/jsonrpc/connection-manager.d.ts +6 -0
  30. package/dist/runner/common/jsonrpc/connection-manager.js +34 -9
  31. package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
  32. package/dist/runner/common/jsonrpc/transport.js +7 -1
  33. package/dist/runner/dsh/approval.js +16 -3
  34. package/dist/runner/dsh/client.d.ts +31 -1
  35. package/dist/runner/dsh/client.js +126 -6
  36. package/dist/runner/dsh/exit-cancel.js +62 -23
  37. package/dist/runner/dsh/runner.d.ts +7 -0
  38. package/dist/runner/dsh/runner.js +42 -5
  39. package/dist/runner/kimi/acp/runner.js +42 -5
  40. package/dist/runner/kimi/acp/translator.d.ts +8 -0
  41. package/dist/runner/kimi/acp/translator.js +10 -0
  42. package/dist/runner/pi/rpc/runner.js +33 -5
  43. package/dist/runner/resolve-agent-choices.js +5 -1
  44. package/dist/runner/sync-agent-choices.js +4 -1
  45. package/dist/session/claude/session-index.js +9 -2
  46. package/dist/session/codex/rollout-reader.js +51 -26
  47. package/dist/session/common/usage-accumulator.d.ts +10 -1
  48. package/dist/session/common/usage-accumulator.js +10 -2
  49. package/dist/session/dsh/sessions.js +29 -9
  50. package/dist/session/kimi/sessions.js +9 -5
  51. package/dist/session/opencode/sessions.js +13 -6
  52. package/dist/session/session-store.js +4 -3
  53. package/package.json +1 -1
@@ -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 会丢弃
@@ -822,128 +840,143 @@ export class CommandRouter {
822
840
  await this.bridge.sendResult({ text: '⚠️ 正在压缩会话,消息将在压缩完成后按顺序执行。' }, ctx);
823
841
  return;
824
842
  }
825
- // 1.5. If the user edited the message, the original closure (captured at
826
- // enqueue time with stale content) must NOT run. Register a one-shot
827
- // replacement closure on the task's EXISTING queue slot BEFORE any await:
828
- // interruptCurrentRun / markQueueCardExecuting can let the chain advance to
829
- // this task's begin, and begin consumes the replacement at that point — a
830
- // registration after those awaits would arrive too late (the stale closure
831
- // runs and the replacement leaks as a permanent dead closure). Registering
832
- // on the existing slot (instead of removeFromQueue + re-enqueue) preserves
833
- // the queue position: enqueue can only append to the promise chain tail,
834
- // which would put the edited task behind tasks queued after it.
835
- if (targetTask.editedMessage) {
836
- const editedContent = targetTask.editedMessage;
837
- ///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
838
- // cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
839
- this.bridge.setTaskReplacement(cwd, messageId, async () => {
840
- await this.handle(editedContent, ctx, {
841
- cwdOverride: cwd,
842
- binding: targetTask.binding,
843
+ // 1.4. Hold the target's run-card start until this handler has sent its
844
+ // final feedback. The queue chain can advance during the awaits below
845
+ // (interruptCurrentRun / the queue-card PATCH): the target may begin and
846
+ // call forwardToClaude before the toast is sent. Without this hold the
847
+ // run card would land first, leaving the toast as the newest message —
848
+ // the chat list then previews the toast instead of the run card's title
849
+ // (which is how the user follows a run without opening the conversation).
850
+ // Registered before any await, released in finally on every exit path;
851
+ // forwardToClaude waits for it before creating the run card.
852
+ const releaseRunStart = this.bridge.holdQueueTaskRun(cwd, messageId);
853
+ try {
854
+ // 1.5. If the user edited the message, the original closure (captured at
855
+ // enqueue time with stale content) must NOT run. Register a one-shot
856
+ // replacement closure on the task's EXISTING queue slot BEFORE any await:
857
+ // interruptCurrentRun / markQueueCardExecuting can let the chain advance to
858
+ // this task's begin, and begin consumes the replacement at that point — a
859
+ // registration after those awaits would arrive too late (the stale closure
860
+ // runs and the replacement leaks as a permanent dead closure). Registering
861
+ // on the existing slot (instead of removeFromQueue + re-enqueue) preserves
862
+ // the queue position: enqueue can only append to the promise chain tail,
863
+ // which would put the edited task behind tasks queued after it.
864
+ if (targetTask.editedMessage) {
865
+ const editedContent = targetTask.editedMessage;
866
+ ///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
867
+ // cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
868
+ this.bridge.setTaskReplacement(cwd, messageId, async () => {
869
+ await this.handle(editedContent, ctx, {
870
+ cwdOverride: cwd,
871
+ binding: targetTask.binding,
872
+ });
843
873
  });
874
+ }
875
+ // 2. Stop current running process
876
+ // (Note: queue executing count is reset inside Bridge.interruptCurrentRun
877
+ // so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
878
+ const interrupt = await this.bridge.interruptCurrentRunDetailed({
879
+ userId: ctx.userId,
880
+ chatId: ctx.chatId,
881
+ cwd,
844
882
  });
845
- }
846
- // 2. Stop current running process
847
- // (Note: queue executing count is reset inside Bridge.interruptCurrentRun
848
- // so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
849
- const interrupt = await this.bridge.interruptCurrentRunDetailed({
850
- userId: ctx.userId,
851
- chatId: ctx.chatId,
852
- cwd,
853
- });
854
- const stopped = interrupt.stopped;
855
- // A remote (DSH) stop is only a request; when it cannot be confirmed the
856
- // target must not be promised "immediate" execution — its prompt would sit
857
- // behind the still-running turn with no visible progress.
858
- const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
859
- getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
860
- // 3. Remove all tasks BEFORE this one (by position in the current queue).
861
- // The target may have begun while interruptCurrentRun was awaiting (the chain
862
- // advances concurrently): then it is already out of the snapshot and every
863
- // remaining queued task is BEHIND it — clearing must stop, not run off the
864
- // end and delete tasks queued after the target.
865
- const tasks = this.bridge.getQueuedTasks(cwd);
866
- const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
867
- let removedCount = 0;
868
- // Phase 1: remove synchronously. The queue chain advances on any await
869
- // (card PATCH round trips included), so every task before the target must
870
- // be removed before yielding to the event loop — otherwise a task the user
871
- // asked to clear can begin executing while its cancellation card is still
872
- // in flight.
873
- for (let i = 0; i < targetIdx; i++) {
874
- const task = tasks[i];
875
- // 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
876
- // 同步推进的既有不变式。
877
- if (this.bridge.removeFromQueue(cwd, task.messageId)) {
878
- removedCount++;
883
+ const stopped = interrupt.stopped;
884
+ // A remote (DSH) stop is only a request; when it cannot be confirmed the
885
+ // target must not be promised "immediate" execution — its prompt would sit
886
+ // behind the still-running turn with no visible progress.
887
+ const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
888
+ getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
889
+ // 3. Remove all tasks BEFORE this one (by position in the current queue).
890
+ // The target may have begun while interruptCurrentRun was awaiting (the chain
891
+ // advances concurrently): then it is already out of the snapshot and every
892
+ // remaining queued task is BEHIND it — clearing must stop, not run off the
893
+ // end and delete tasks queued after the target.
894
+ const tasks = this.bridge.getQueuedTasks(cwd);
895
+ const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
896
+ let removedCount = 0;
897
+ // Phase 1: remove synchronously. The queue chain advances on any await
898
+ // (card PATCH round trips included), so every task before the target must
899
+ // be removed before yielding to the event loop — otherwise a task the user
900
+ // asked to clear can begin executing while its cancellation card is still
901
+ // in flight.
902
+ for (let i = 0; i < targetIdx; i++) {
903
+ const task = tasks[i];
904
+ // 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
905
+ // 同步推进的既有不变式。
906
+ if (this.bridge.removeFromQueue(cwd, task.messageId)) {
907
+ removedCount++;
908
+ }
879
909
  }
880
- }
881
- if (targetIdx < 0) {
882
- getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
883
- }
884
- // 4. DO NOT remove the target task - keep it in queue to execute immediately
885
- // The queue will naturally execute it after the current task (which we just stopped)
886
- // 4.4. Refresh the replacement with the latest edited content (idempotent:
887
- // re-registering over the same slot). Covers the user editing the message
888
- // again while interruptCurrentRun was awaiting; skipped automatically once
889
- // the task has begun, because getQueuedTask then returns undefined.
890
- const latestTask = this.bridge.getQueuedTask(cwd, messageId);
891
- if (latestTask?.editedMessage) {
892
- const editedContent = latestTask.editedMessage;
893
- ///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
894
- this.bridge.setTaskReplacement(cwd, messageId, async () => {
895
- await this.handle(editedContent, ctx, {
896
- cwdOverride: cwd,
897
- binding: latestTask.binding,
910
+ if (targetIdx < 0) {
911
+ getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
912
+ }
913
+ // 4. DO NOT remove the target task - keep it in queue to execute immediately
914
+ // The queue will naturally execute it after the current task (which we just stopped)
915
+ // 4.4. Refresh the replacement with the latest edited content (idempotent:
916
+ // re-registering over the same slot). Covers the user editing the message
917
+ // again while interruptCurrentRun was awaiting; skipped automatically once
918
+ // the task has begun, because getQueuedTask then returns undefined.
919
+ const latestTask = this.bridge.getQueuedTask(cwd, messageId);
920
+ if (latestTask?.editedMessage) {
921
+ const editedContent = latestTask.editedMessage;
922
+ ///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
923
+ this.bridge.setTaskReplacement(cwd, messageId, async () => {
924
+ await this.handle(editedContent, ctx, {
925
+ cwdOverride: cwd,
926
+ binding: latestTask.binding,
927
+ });
898
928
  });
899
- });
900
- }
901
- // 4.5/6. The target must only be marked executing (and the toast may only
902
- // promise immediate execution) when it is really the next task after the
903
- // stop. While interruptCurrentRun was awaiting, a task ahead of the target
904
- // may have begun and now occupy the cwd — the target is still queued
905
- // but is NOT next, so its card must stay queued and the toast must say so.
906
- const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
907
- if (!targetStillQueued) {
908
- // The target began or was cancelled while earlier awaits were in flight —
909
- // a success toast must never promise execution of a task that will not
910
- // run. The two missing-reason states get distinct feedback: a target
911
- // that began while the interrupt was in flight is already executing, so
912
- // the toast must acknowledge that instead of telling the user nothing
913
- // was scheduled; only a target that was actually cancelled (never began)
914
- // gets the "未安排执行" wording.
915
- if (this.bridge.hasTaskBegan(messageId)) {
916
- await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
929
+ }
930
+ // 4.5/6. The target must only be marked executing (and the toast may only
931
+ // promise immediate execution) when it is really the next task after the
932
+ // stop. While interruptCurrentRun was awaiting, a task ahead of the target
933
+ // may have begun and now occupy the cwd — the target is still queued
934
+ // but is NOT next, so its card must stay queued and the toast must say so.
935
+ const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
936
+ if (!targetStillQueued) {
937
+ // The target began or was cancelled while earlier awaits were in flight —
938
+ // a success toast must never promise execution of a task that will not
939
+ // run. The two missing-reason states get distinct feedback: a target
940
+ // that began while the interrupt was in flight is already executing, so
941
+ // the toast must acknowledge that instead of telling the user nothing
942
+ // was scheduled; only a target that was actually cancelled (never began)
943
+ // gets the "未安排执行" wording.
944
+ if (this.bridge.hasTaskBegan(messageId)) {
945
+ await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
946
+ }
947
+ else {
948
+ await this.bridge.sendResult({
949
+ text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
950
+ }, ctx);
951
+ }
952
+ return;
953
+ }
954
+ const workspaceBusy = this.bridge.isBusyFor(cwd);
955
+ if (stopUnconfirmed) {
956
+ const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
957
+ await this.bridge.sendResult({
958
+ text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
959
+ `已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
960
+ }, ctx);
961
+ }
962
+ else if (!workspaceBusy) {
963
+ // Normal path: nothing new began during the stop, the target is next.
964
+ // Mark its own card as executing now so buttons grey out immediately.
965
+ // Done BEFORE any removal so getQueuedTask still returns the task with
966
+ // its (possibly edited) messagePreview for the card. The queue callback's
967
+ // later updateQueueCardToExecuting call becomes a no-op (idempotent).
968
+ await this.bridge.markQueueCardExecuting(cwd, messageId);
969
+ const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
970
+ await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
917
971
  }
918
972
  else {
919
973
  await this.bridge.sendResult({
920
- text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
974
+ text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
921
975
  }, ctx);
922
976
  }
923
- return;
924
- }
925
- const workspaceBusy = this.bridge.isBusyFor(cwd);
926
- if (stopUnconfirmed) {
927
- const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
928
- await this.bridge.sendResult({
929
- text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
930
- `已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
931
- }, ctx);
932
- }
933
- else if (!workspaceBusy) {
934
- // Normal path: nothing new began during the stop, the target is next.
935
- // Mark its own card as executing now so buttons grey out immediately.
936
- // Done BEFORE any removal so getQueuedTask still returns the task with
937
- // its (possibly edited) messagePreview for the card. The queue callback's
938
- // later updateQueueCardToExecuting call becomes a no-op (idempotent).
939
- await this.bridge.markQueueCardExecuting(cwd, messageId);
940
- const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
941
- await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
942
977
  }
943
- else {
944
- await this.bridge.sendResult({
945
- text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
946
- }, ctx);
978
+ finally {
979
+ releaseRunStart();
947
980
  }
948
981
  }
949
982
  /**
@@ -958,6 +991,11 @@ export class CommandRouter {
958
991
  const task = this.bridge.getQueuedTask(cwd, messageId);
959
992
  const queueInfo = this.bridge.getQueueInfo(cwd);
960
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;
961
999
  // Build diagnostic info
962
1000
  const now = new Date().toLocaleString('zh-CN');
963
1001
  const pid = process.pid;
@@ -997,7 +1035,7 @@ export class CommandRouter {
997
1035
  tag: 'div',
998
1036
  text: {
999
1037
  tag: 'lark_md',
1000
- content: `**队列状态**\n- 排队的任务数: ${queueInfo.position}\n- 当前正在运行: ${queueInfo.isRunning ? '✅ 是' : '❌ 否'}\n- 排在当前任务之前的消息数: ${queueInfo.tasksAhead}`,
1038
+ content: `**队列状态**\n- 排队的任务数: ${queueInfo.position}\n- 当前正在运行: ${queueInfo.isRunning ? '✅ 是' : '❌ 否'}\n- 排在当前任务之前的消息数: ${tasksAhead}`,
1001
1039
  },
1002
1040
  },
1003
1041
  { tag: 'hr' },
@@ -1188,7 +1226,7 @@ export class CommandRouter {
1188
1226
  const resolvedTarget = path.resolve(targetPath);
1189
1227
  // 与 ls.browse 对齐:目标只要存在且是目录即可切换(不再做子树/父级限制)。
1190
1228
  // 安全边界由 binder owner 认证兜底,见 docs/zh/architecture/design.md §9.7 更新说明。
1191
- if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
1229
+ if (!isDirectorySafe(resolvedTarget)) {
1192
1230
  await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
1193
1231
  return;
1194
1232
  }
@@ -1355,7 +1393,7 @@ export class CommandRouter {
1355
1393
  return;
1356
1394
  }
1357
1395
  const resolvedTarget = path.resolve(targetPath);
1358
- if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
1396
+ if (!isDirectorySafe(resolvedTarget)) {
1359
1397
  await this.bridge.sendResult({ text: `路径无效: ${resolvedTarget}` }, ctx);
1360
1398
  return;
1361
1399
  }
@@ -1402,8 +1440,12 @@ export class CommandRouter {
1402
1440
  async handleJobKill(value, ctx) {
1403
1441
  const runId = typeof value.runId === 'string' ? value.runId : '';
1404
1442
  const jobId = typeof value.jobId === 'string' ? value.jobId : '';
1405
- if (!runId || !jobId)
1443
+ // miss path 必回复(本文件的设计约束):旧版/异常卡片 payload 缺参时
1444
+ // 静默 return 会让点击毫无反应,用户无从判断。
1445
+ if (!runId || !jobId) {
1446
+ await this.bridge.sendResult({ text: CARD_PAYLOAD_MISSING }, ctx);
1406
1447
  return;
1448
+ }
1407
1449
  if (await this.bridge.killBackgroundJob(runId, jobId, ctx))
1408
1450
  return;
1409
1451
  await this.bridge.sendResult({ text: '停止失败:任务可能已自行结束,或记录已回收' }, ctx);
@@ -1728,12 +1770,14 @@ export class CommandRouter {
1728
1770
  }
1729
1771
  // Refresh the /ws list card in place so "recent" sort is immediately visible
1730
1772
  await this.refreshListCard('ws', value, ctx);
1731
- // 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 型兜底会被双重吞掉,点击毫无反馈。
1732
1777
  return {
1733
- toast: {
1734
- type: 'success',
1735
- content: fallbackToast ?? '',
1736
- },
1778
+ toast: fallbackToast
1779
+ ? { type: 'warning', content: fallbackToast }
1780
+ : { type: 'success', content: '' },
1737
1781
  };
1738
1782
  }
1739
1783
  /**
@@ -2588,7 +2632,15 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2588
2632
  if (!fs.existsSync(resolved)) {
2589
2633
  return { text: `路径不存在: ${resolved}` };
2590
2634
  }
2591
- 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
+ }
2592
2644
  if (!stat.isDirectory()) {
2593
2645
  return { text: `不是目录: ${resolved}` };
2594
2646
  }
@@ -3182,6 +3234,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3182
3234
  return { text: `workspace "${name}" 不存在` };
3183
3235
  if (!fs.existsSync(wsPath))
3184
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}` };
3185
3242
  // Canonicalize via realpath so it matches Claude JSONL cwd (2026-06-21).
3186
3243
  // TOCTOU guard: mirror cmdCd — if the path vanishes
3187
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
@@ -269,6 +269,10 @@ export class BaseAcpTranslator {
269
269
  }
270
270
  /**
271
271
  * tool_call_update → user/tool_result. status:'failed' → is_error:true.
272
+ *
273
+ * 终态(completed/failed)产出 tool_result;非终态的 tool_result 由
274
+ * {@link emitToolResultForNonTerminalUpdate} 裁定(reducer 见到 tool_result
275
+ * 即置 status:'ok'——无内容的非终态 tool_result 会把运行中的工具提前翻成 ✅)。
272
276
  */
273
277
  handleToolCallUpdate(event, _sessionId) {
274
278
  const isError = event.status === 'failed';
@@ -293,6 +297,13 @@ export class BaseAcpTranslator {
293
297
  else if (event.rawInput !== undefined && event.rawInput !== null) {
294
298
  this.trackToolCallArgs(event.toolCallId, stringifyUnknown(event.rawInput));
295
299
  }
300
+ // 非终态是否发 tool_result 由子类裁定(emitToolResultForNonTerminalUpdate):
301
+ // opencode 的 in-progress 带输出快照,seam 契约钉死「patch + tool_result」
302
+ // 都要发(面板在工具运行时显示部分输出);kimi 的 in-progress 只带流式
303
+ // args,空 tool_result 会让 reducer 把运行中的工具提前置 ✅ + 空输出。
304
+ if (!this.emitToolResultForNonTerminalUpdate(content)) {
305
+ return useUpdate ? [useUpdate] : [];
306
+ }
296
307
  }
297
308
  const events = [];
298
309
  if (useUpdate)
@@ -390,6 +401,17 @@ export class BaseAcpTranslator {
390
401
  extractToolResultContent(event) {
391
402
  return event.rawOutput ?? '';
392
403
  }
404
+ /**
405
+ * 非终态 tool_call_update 是否附带 tool_result。默认 true(opencode seam
406
+ * 契约:in-progress 的输出快照随 tool_result 进面板)。kimi 覆写为
407
+ * 「有内容才发」——它的 in-progress 只带流式 args,空 tool_result 会让
408
+ * reducer 把运行中的工具提前置 ✅ + 空输出(终态丢失时面板停在错误的
409
+ * 「已完成」)。
410
+ */
411
+ emitToolResultForNonTerminalUpdate(content) {
412
+ void content;
413
+ return true;
414
+ }
393
415
  /**
394
416
  * usage_update → context occupancy bookkeeping. The wire shape is
395
417
  * {used, size} (+ opencode: cost:{amount}) — context occupancy, not
@@ -1,4 +1,5 @@
1
1
  import { getLogger } from '../../logger/index.js';
2
+ import { StringDecoder } from 'node:string_decoder';
2
3
  /**
3
4
  * Parse JSONL (newline-delimited JSON) from a readable stream.
4
5
  *
@@ -30,6 +31,8 @@ export function createJSONLStream(stdout, options) {
30
31
  // and push complete lines; the residual tail stays as array fragments until
31
32
  // the next newline or stream end. Net allocation: O(n) instead of O(n²).
32
33
  const partialFragments = [];
34
+ // 跨 chunk 多字节字符的边界安全解码器(见 stdout 'data' 处理)。
35
+ const decoder = new StringDecoder('utf8');
33
36
  // accumulated byte length of partialFragments. Backpressure (above)
34
37
  // only counts COMPLETE lines in lineQueue; a never-newline stream (malformed
35
38
  // output, binary garbage) accumulates fragments without ever completing a
@@ -60,6 +63,11 @@ export function createJSONLStream(stdout, options) {
60
63
  // Flush any trailing partial line and mark the stream finished. Shared by the
61
64
  // 'end'/'close'/'error' handlers so flush logic lives in one place (nit).
62
65
  function flushAndFinish() {
66
+ // decoder.end() 吐出被 chunk 边界切开的最后一个多字节字符的残余字节
67
+ //(无残尾时返回空串):不加这句,末尾被切开的字符会凭空丢失。
68
+ const tail = decoder.end();
69
+ if (tail)
70
+ partialFragments.push(tail);
63
71
  if (partialFragments.length > 0) {
64
72
  const trimmed = partialFragments.join('').trim();
65
73
  if (trimmed)
@@ -109,7 +117,10 @@ export function createJSONLStream(stdout, options) {
109
117
  }
110
118
  }
111
119
  stdout.on('data', (chunk) => {
112
- const text = chunk.toString('utf-8');
120
+ // StringDecoder 按字节边界解码:多字节字符(中文/emoji)被 chunk 边界
121
+ // 切开时,裸 toString 会产出不可逆的 U+FFFD(同族修复见
122
+ // jsonrpc/transport.ts,claude 的中文输出走本流)。
123
+ const text = decoder.write(chunk);
113
124
  // split on newlines. The first segment continues the pending partial
114
125
  // line (joined with accumulated fragments — one join per chunk, not per
115
126
  // byte); subsequent segments are complete lines. The last segment is the
@@ -147,6 +147,12 @@ export declare class ConnectionManager<TClient extends ConnectionClient = JsonRp
147
147
  *
148
148
  * `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
149
149
  * setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
150
+ *
151
+ * 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
152
+ * baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
153
+ * 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
154
+ * 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
155
+ * 误报 "Codex app-server connection closed"。
150
156
  */
151
157
  notifyIdle(cwd: string): void;
152
158
  private createClient;
@@ -84,7 +84,17 @@ export class ConnectionManager {
84
84
  if (existing) {
85
85
  // If a create is in flight, wait for it
86
86
  if (existing.createPromise) {
87
- return existing.createPromise;
87
+ const client = await existing.createPromise;
88
+ // 快速路径复核(与下方建槽方的竞态复核对称):await 期间槽位被
89
+ // release()/disposeAll() 删掉时,建槽方会 dispose 该连接并重试——
90
+ // 直接返回会把「已被建槽方 dispose 的连接」交给本调用方(表现为
91
+ // setupTurn 抛 ConnectionLostError 走 respawn-retry 自愈)。槽位
92
+ // 不再持有该 client 时与本轮创建同路:重试。
93
+ const current = this.slots.get(cwd);
94
+ if (current && current.client === client) {
95
+ return client;
96
+ }
97
+ continue;
88
98
  }
89
99
  // Reuse when the (session-aware) reuse predicate holds — e.g. the client
90
100
  // is ready and its transport is alive (default), or it is bound to the
@@ -183,18 +193,27 @@ export class ConnectionManager {
183
193
  *
184
194
  * `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
185
195
  * setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
196
+ *
197
+ * 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
198
+ * baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
199
+ * 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
200
+ * 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
201
+ * 误报 "Codex app-server connection closed"。
186
202
  */
187
203
  notifyIdle(cwd) {
188
204
  if (this.idleTtlMs <= 0)
189
205
  return;
190
206
  const slot = this.slots.get(cwd);
191
- if (slot) {
192
- this.clearIdleTimer(slot);
193
- slot.idleTimer = setTimeout(() => {
194
- getLogger().info(`[${this.logTag}] idle timeout for cwd=${cwd}, releasing connection`);
195
- this.disposeSlot(cwd);
196
- }, this.idleTtlMs);
197
- }
207
+ if (!slot)
208
+ return;
209
+ this.clearIdleTimer(slot);
210
+ slot.idleTimer = setTimeout(() => {
211
+ slot.idleTimer = null;
212
+ if (this.slots.get(cwd) !== slot)
213
+ return;
214
+ getLogger().info(`[${this.logTag}] idle timeout for cwd=${cwd}, releasing connection`);
215
+ this.disposeSlot(cwd);
216
+ }, this.idleTtlMs);
198
217
  }
199
218
  // =========================================================================
200
219
  // Internal
@@ -222,7 +241,13 @@ export class ConnectionManager {
222
241
  getLogger().info(`[${this.logTag}] connection closed cwd=${cwd}`);
223
242
  // 只清理自己创建的 slot:并发 release + 重新 acquire 后 slot 可能已被
224
243
  // 新连接占据,误删会破坏新连接。
225
- if (this.slots.get(cwd)?.client === client) {
244
+ const current = this.slots.get(cwd);
245
+ if (current?.client === client) {
246
+ // 进程自退(codex app-server 偶发 exit 0,非本程序发起,不经
247
+ // disposeSlot)也要撤掉挂在旧 slot 上的 idle TTL:定时器不随 slot
248
+ // 删除而取消,到期时会按 cwd 误杀继任连接的在途 turn(2026-10-24
249
+ // 线上事故,见 notifyIdle 的身份校验与对应回归测试)。
250
+ this.clearIdleTimer(current);
226
251
  this.slots.delete(cwd);
227
252
  this.onConnectionLost?.(cwd);
228
253
  }