lark-remote 0.5.13 → 0.5.14

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 (126) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/agent-kinds.d.ts +27 -0
  3. package/dist/agent-kinds.js +38 -0
  4. package/dist/bridge/approval-actions.d.ts +95 -0
  5. package/dist/bridge/approval-actions.js +104 -0
  6. package/dist/bridge/final-usage.d.ts +8 -17
  7. package/dist/bridge/final-usage.js +1 -18
  8. package/dist/bridge/index.d.ts +46 -39
  9. package/dist/bridge/index.js +201 -229
  10. package/dist/bridge/queue-manager.d.ts +25 -1
  11. package/dist/bridge/queue-manager.js +179 -160
  12. package/dist/bridge/run-approval.d.ts +2 -1
  13. package/dist/bridge/run-usage.d.ts +16 -30
  14. package/dist/bridge/run-usage.js +41 -50
  15. package/dist/bridge/session-writeback.d.ts +3 -2
  16. package/dist/card/approval-render.d.ts +3 -2
  17. package/dist/card/approval-render.js +3 -2
  18. package/dist/{router → card}/card-helpers.d.ts +7 -7
  19. package/dist/{router → card}/card-helpers.js +8 -8
  20. package/dist/card/card-session.d.ts +4 -2
  21. package/dist/card/card-session.js +8 -6
  22. package/dist/card/card-shared.d.ts +1 -1
  23. package/dist/card/card-shared.js +1 -1
  24. package/dist/card/final-answer-overflow.d.ts +0 -6
  25. package/dist/card/final-answer-overflow.js +1 -1
  26. package/dist/card/run-card-session.d.ts +2 -2
  27. package/dist/card/run-card-session.js +2 -2
  28. package/dist/card/run-renderer.js +7 -22
  29. package/dist/card/run-state.d.ts +28 -50
  30. package/dist/card/run-state.js +23 -0
  31. package/dist/card/usage-format.d.ts +30 -0
  32. package/dist/{router → card}/usage-format.js +3 -10
  33. package/dist/clone.js +6 -1
  34. package/dist/config/index.d.ts +16 -20
  35. package/dist/config/index.js +0 -16
  36. package/dist/config/pi-config.js +6 -4
  37. package/dist/connector/index.d.ts +2 -4
  38. package/dist/connector/index.js +7 -11
  39. package/dist/{runner/dsh → dsh}/auth.js +1 -1
  40. package/dist/dsh/protocol.d.ts +9 -0
  41. package/dist/dsh/protocol.js +61 -0
  42. package/dist/index.js +10 -5
  43. package/dist/model-catalog.d.ts +14 -0
  44. package/dist/model-catalog.js +25 -0
  45. package/dist/platform/path.d.ts +9 -0
  46. package/dist/platform/path.js +18 -0
  47. package/dist/router/active-run-usage.d.ts +4 -0
  48. package/dist/router/active-run-usage.js +12 -0
  49. package/dist/router/commands/ls.js +1 -1
  50. package/dist/router/commands/order.js +1 -1
  51. package/dist/router/commands/resume.d.ts +5 -4
  52. package/dist/router/commands/resume.js +4 -4
  53. package/dist/router/commands/ws.js +1 -1
  54. package/dist/router/config/claude.js +2 -1
  55. package/dist/router/config/common/render.d.ts +5 -1
  56. package/dist/router/config/common/render.js +6 -13
  57. package/dist/router/config/dsh.d.ts +1 -1
  58. package/dist/router/config/index.d.ts +1 -1
  59. package/dist/router/config/types.d.ts +11 -3
  60. package/dist/router/index.d.ts +41 -11
  61. package/dist/router/index.js +165 -226
  62. package/dist/runner/agent-choices-common.d.ts +1 -1
  63. package/dist/runner/claude/runner.d.ts +20 -2
  64. package/dist/runner/claude/runner.js +2 -15
  65. package/dist/runner/codex/app-server/runner.d.ts +3 -1
  66. package/dist/runner/codex/app-server/translator.d.ts +11 -0
  67. package/dist/runner/codex/app-server/translator.js +56 -149
  68. package/dist/runner/common/acp/base-acp-runner.d.ts +3 -1
  69. package/dist/runner/common/connection-based-runner.d.ts +3 -1
  70. package/dist/runner/common/jsonrpc/transport.js +17 -7
  71. package/dist/runner/dsh/client.d.ts +1 -4
  72. package/dist/runner/dsh/client.js +2 -55
  73. package/dist/runner/dsh/daemon.js +9 -2
  74. package/dist/runner/dsh/exit-cancel.js +1 -1
  75. package/dist/runner/dsh/mux-stream.js +21 -2
  76. package/dist/runner/dsh/runner.d.ts +21 -1
  77. package/dist/runner/dsh/runner.js +76 -73
  78. package/dist/runner/dsh/translator.d.ts +1 -1
  79. package/dist/runner/index.d.ts +1 -1
  80. package/dist/runner/kimi/acp/runner.d.ts +22 -1
  81. package/dist/runner/kimi/acp/runner.js +83 -60
  82. package/dist/runner/opencode/acp/error-monitor.d.ts +11 -0
  83. package/dist/runner/opencode/acp/error-monitor.js +31 -2
  84. package/dist/runner/opencode/acp/runner.d.ts +3 -1
  85. package/dist/runner/pi/rpc/runner.d.ts +2 -1
  86. package/dist/runner/probe.d.ts +1 -1
  87. package/dist/runner/probe.js +10 -9
  88. package/dist/runner/registry.d.ts +2 -1
  89. package/dist/runner/types.d.ts +20 -154
  90. package/dist/runner/types.js +4 -24
  91. package/dist/session/claude/session-index.d.ts +11 -4
  92. package/dist/session/claude/session-index.js +30 -8
  93. package/dist/session/claude/session-reader.d.ts +1 -1
  94. package/dist/session/claude/sessions.d.ts +2 -2
  95. package/dist/session/claude/sessions.js +41 -26
  96. package/dist/session/codex/rollout-reader.d.ts +1 -1
  97. package/dist/session/codex/rollout-reader.js +14 -13
  98. package/dist/session/codex/sessions.d.ts +1 -1
  99. package/dist/session/codex/sessions.js +4 -2
  100. package/dist/session/common/jsonl.js +7 -1
  101. package/dist/session/common/pagination.d.ts +2 -0
  102. package/dist/session/common/pagination.js +1 -1
  103. package/dist/session/common/recency.d.ts +6 -0
  104. package/dist/session/common/recency.js +7 -5
  105. package/dist/session/common/two-pass.d.ts +1 -1
  106. package/dist/session/common/usage-accumulator.d.ts +1 -1
  107. package/dist/session/dsh/sessions.d.ts +1 -1
  108. package/dist/session/dsh/sessions.js +37 -12
  109. package/dist/session/kimi/sessions.d.ts +1 -1
  110. package/dist/session/kimi/sessions.js +35 -8
  111. package/dist/session/opencode/sessions.d.ts +1 -1
  112. package/dist/session/opencode/sessions.js +51 -47
  113. package/dist/session/pi/sessions.d.ts +1 -1
  114. package/dist/session/pi/sessions.js +20 -7
  115. package/dist/session/registry.d.ts +2 -1
  116. package/dist/session/session-store.d.ts +1 -2
  117. package/dist/session/session-store.js +14 -7
  118. package/dist/session-contract.d.ts +178 -0
  119. package/dist/session-contract.js +9 -0
  120. package/package.json +1 -1
  121. package/dist/router/usage-format.d.ts +0 -60
  122. /package/dist/{runner/dsh → dsh}/auth.d.ts +0 -0
  123. /package/dist/{runner/dsh → dsh}/home.d.ts +0 -0
  124. /package/dist/{runner/dsh → dsh}/home.js +0 -0
  125. /package/dist/{runner/dsh → dsh}/types.d.ts +0 -0
  126. /package/dist/{runner/dsh → dsh}/types.js +0 -0
@@ -1,19 +1,19 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import YAML from 'yaml';
4
- import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, getAgentConfig, } from '../config/index.js';
4
+ import { getConfigDir, setConfigValue, setConfigValues, mapAgentKey, getAgentConfig, } from '../config/index.js';
5
5
  import { syncAgentChoices } from '../runner/index.js';
6
- import { resolveDshCredentialsFile } from '../runner/dsh/auth.js';
6
+ import { resolveDshCredentialsFile } from '../dsh/auth.js';
7
7
  import { WorkspaceStore } from '../workspace/index.js';
8
8
  import { OrderStore, ORDER_NOT_FOUND_TEXT } from '../order/index.js';
9
9
  import { resolveAlias } from '../order/alias-resolve.js';
10
10
  import { diffConfig, setNestedValue } from './config-diff.js';
11
- import { AGENT_KINDS } from '../runner/types.js';
11
+ import { AGENT_KINDS, resolveAgentKind } from '../agent-kinds.js';
12
12
  import { getLogger } from '../logger/index.js';
13
13
  import { stripPlaceholders } from '../inbound/placeholder.js';
14
14
  import { clampInt } from '../common/clamp.js';
15
- import { activeRunUsage } from './usage-format.js';
16
- import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, SEARCH_INPUT_NAME, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
15
+ import { activeRunUsage } from './active-run-usage.js';
16
+ import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, SEARCH_INPUT_NAME, PAGE_JUMP_INVALID_HINT, } from '../card/card-helpers.js';
17
17
  import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
18
18
  import { cardButton, newSessionButton, resumeCompactButton, agentDisplayName, } from '../card/card-shared.js';
19
19
  import { displayName } from '../platform/path.js';
@@ -290,7 +290,6 @@ const IMMEDIATE_ACTION_CMDS = new Set([
290
290
  'order.aliasRemove',
291
291
  'order.textEdit',
292
292
  'order.textInput',
293
- 'config.toggle',
294
293
  'config.set',
295
294
  'config.input',
296
295
  'config.save',
@@ -349,13 +348,20 @@ const CARD_PAYLOAD_MISSING = '⚠️ 卡片 payload 缺少必要信息';
349
348
  * 卡片只有最近一两张,5 张足够覆盖;无上限会让长跑进程的 Set 随卡片数增长。
350
349
  */
351
350
  const MAX_CONFIG_CARDS = 5;
351
+ /** 本次改动是否落进 `agents.<kind>` 子树。 */
352
+ function touchesAgentsSubtree(keys, kind) {
353
+ return keys.some((k) => k === `agents.${kind}` || k.startsWith(`agents.${kind}.`));
354
+ }
355
+ /**
356
+ * 本次改动是否触及某个 agent 的配置——两种键形态都要认:现行 `agents.<kind>[.*]`
357
+ * 与历史的顶层 `<kind>[.*]`(claude 至今是顶层键)。判定曾抄在三处(agent 变更检测 /
358
+ * agentChoices 同步 / approval 热推),每加一个 agent 都得记得改三遍,与
359
+ * AGENT_KINDS 名单漂移是同一种事故,这里收成一处。
360
+ */
361
+ function touchesAgentConfig(keys, kind) {
362
+ return (touchesAgentsSubtree(keys, kind) || keys.some((k) => k === kind || k.startsWith(`${kind}.`)));
363
+ }
352
364
  export class CommandRouter {
353
- /**
354
- * Valid agent kinds — single source of truth for resume.use / resume.page /
355
- * cmdResume. 名单本体在 `runner/types.ts` 的 AGENT_KINDS(与 AgentKind 类型
356
- * 同源),这里只做别名,避免人手抄第二份。
357
- */
358
- static VALID_AGENTS = AGENT_KINDS;
359
365
  sessionStore;
360
366
  /** 飞书桥接实例(public:测试直接访问断言,替代 as unknown as)。 */
361
367
  bridge;
@@ -428,8 +434,8 @@ export class CommandRouter {
428
434
  /**
429
435
  * 串行化 config.* 卡片回调(2026-07-04)。
430
436
  * CardKit 2.0 input/button 回调经 `enqueueImmediate` 分发,**不进串行队列**,
431
- * 多次快速点击 toggle/input 会并发读写 `pendingConfig` 并同时调
432
- * `updateCardInPlace` → Feishu API 乱序到达,导致 toggle 卡死在某个状态。
437
+ * 多次快速点击下拉/文本框会并发读写 `pendingConfig` 并同时调
438
+ * `updateCardInPlace` → Feishu API 乱序到达,控件卡死在某个状态。
433
439
  * 此 Promise chain 把所有 config.* 动作串成一个执行链,保证后到的动作
434
440
  * 等前一个动作的卡片 patch 完成后再读 `pendingConfig`。
435
441
  */
@@ -583,11 +589,7 @@ export class CommandRouter {
583
589
  case 'resume.use': {
584
590
  // resume.use carries an agent field from /resume [agent] list cards
585
591
  // (both active and completed cards include the agent field).
586
- const resumeAgent = value.agent ?? this.config.defaultAgent;
587
- const validAgents = CommandRouter.VALID_AGENTS;
588
- const resolvedAgent = validAgents.includes(resumeAgent)
589
- ? resumeAgent
590
- : this.config.defaultAgent;
592
+ const resolvedAgent = resolveAgentKind(value.agent, this.config.defaultAgent);
591
593
  const entry = this.sessionStore.get(ctx.userId);
592
594
  const cwd = entry?.cwd;
593
595
  if (!cwd) {
@@ -658,13 +660,12 @@ export class CommandRouter {
658
660
  return await this.handleOrderTextEdit(value, ctx);
659
661
  case 'order.textInput':
660
662
  return await this.handleOrderTextInput(value, ctx);
661
- case 'config.toggle':
662
663
  case 'config.set':
663
664
  case 'config.input':
664
665
  case 'config.save':
665
666
  // 2026-07-04: 串行化所有 config.* 动作。CardKit 2.0 回调经 enqueueImmediate
666
- // 不进串行队列,多次快速点击 toggle/input 会并发读写 pendingConfig 并同时
667
- // 调 updateCardInPlace → Feishu API 乱序到达导致 toggle 卡死。
667
+ // 不进串行队列,多次快速点击下拉/文本框会并发读写 pendingConfig 并同时
668
+ // 调 updateCardInPlace → Feishu API 乱序到达导致控件卡死。
668
669
  // 2026-07-18: 返回 enqueueConfigAction 的结果以支持 toast 响应
669
670
  return this.enqueueConfigAction(value, ctx);
670
671
  case 'compact':
@@ -759,6 +760,20 @@ export class CommandRouter {
759
760
  * Handle queue.immediate: stop current run, clear queue before this message, execute this message immediately.
760
761
  * Instead of asking user to resend, we keep the target task in queue and let it execute.
761
762
  */
763
+ /**
764
+ * 把「执行编辑后的内容」注册成原队列槽位的一次性 replacement(复用入队时
765
+ * 捕获的 binding + 恢复 lane cwd,否则编辑后重跑的 run 会丢 cwd 与绑定)。
766
+ *
767
+ * 重新入队不行:`enqueue` 只能追加到 promise 链尾部,会把编辑任务甩到
768
+ * 后来者后面,违背「立即执行 = 下一个就跑」。三个调用点都必须在任何 await
769
+ * 之前注册——begin 路径消费 replacement 的时机不可预测(见
770
+ * handleQueueImmediate 步骤 1.5 的竞态说明)。
771
+ */
772
+ registerEditedReplacement(cwd, messageId, content, binding, ctx) {
773
+ this.bridge.setTaskReplacement(cwd, messageId, async () => {
774
+ await this.handle(content, ctx, { cwdOverride: cwd, binding });
775
+ });
776
+ }
762
777
  async handleQueueImmediate(value, ctx) {
763
778
  const payload = await this.queuePayloadOrReply(value, ctx);
764
779
  if (!payload)
@@ -808,15 +823,7 @@ export class CommandRouter {
808
823
  // the queue position: enqueue can only append to the promise chain tail,
809
824
  // which would put the edited task behind tasks queued after it.
810
825
  if (targetTask.editedMessage) {
811
- const editedContent = targetTask.editedMessage;
812
- ///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
813
- // cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
814
- this.bridge.setTaskReplacement(cwd, messageId, async () => {
815
- await this.handle(editedContent, ctx, {
816
- cwdOverride: cwd,
817
- binding: targetTask.binding,
818
- });
819
- });
826
+ this.registerEditedReplacement(cwd, messageId, targetTask.editedMessage, targetTask.binding, ctx);
820
827
  }
821
828
  // 2. Stop current running process
822
829
  // (Note: queue executing count is reset inside Bridge.interruptCurrentRun
@@ -833,29 +840,7 @@ export class CommandRouter {
833
840
  const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
834
841
  getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
835
842
  // 3. Remove all tasks BEFORE this one (by position in the current queue).
836
- // The target may have begun while interruptCurrentRun was awaiting (the chain
837
- // advances concurrently): then it is already out of the snapshot and every
838
- // remaining queued task is BEHIND it — clearing must stop, not run off the
839
- // end and delete tasks queued after the target.
840
- const tasks = this.bridge.getQueuedTasks(cwd);
841
- const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
842
- let removedCount = 0;
843
- // Phase 1: remove synchronously. The queue chain advances on any await
844
- // (card PATCH round trips included), so every task before the target must
845
- // be removed before yielding to the event loop — otherwise a task the user
846
- // asked to clear can begin executing while its cancellation card is still
847
- // in flight.
848
- for (let i = 0; i < targetIdx; i++) {
849
- const task = tasks[i];
850
- // 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
851
- // 同步推进的既有不变式。
852
- if (this.bridge.removeFromQueue(cwd, task.messageId)) {
853
- removedCount++;
854
- }
855
- }
856
- if (targetIdx < 0) {
857
- getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
858
- }
843
+ const removedCount = this.clearTasksAheadOfTarget(cwd, messageId);
859
844
  // 4. DO NOT remove the target task - keep it in queue to execute immediately
860
845
  // The queue will naturally execute it after the current task (which we just stopped)
861
846
  // 4.4. Refresh the replacement with the latest edited content (idempotent:
@@ -864,66 +849,92 @@ export class CommandRouter {
864
849
  // the task has begun, because getQueuedTask then returns undefined.
865
850
  const latestTask = this.bridge.getQueuedTask(cwd, messageId);
866
851
  if (latestTask?.editedMessage) {
867
- const editedContent = latestTask.editedMessage;
868
- ///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
869
- this.bridge.setTaskReplacement(cwd, messageId, async () => {
870
- await this.handle(editedContent, ctx, {
871
- cwdOverride: cwd,
872
- binding: latestTask.binding,
873
- });
874
- });
875
- }
876
- // 4.5/6. The target must only be marked executing (and the toast may only
877
- // promise immediate execution) when it is really the next task after the
878
- // stop. While interruptCurrentRun was awaiting, a task ahead of the target
879
- // may have begun and now occupy the cwd — the target is still queued
880
- // but is NOT next, so its card must stay queued and the toast must say so.
881
- const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
882
- if (!targetStillQueued) {
883
- // The target began or was cancelled while earlier awaits were in flight —
884
- // a success toast must never promise execution of a task that will not
885
- // run. The two missing-reason states get distinct feedback: a target
886
- // that began while the interrupt was in flight is already executing, so
887
- // the toast must acknowledge that instead of telling the user nothing
888
- // was scheduled; only a target that was actually cancelled (never began)
889
- // gets the "未安排执行" wording.
890
- if (this.bridge.hasTaskBegan(messageId)) {
891
- await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
892
- }
893
- else {
894
- await this.bridge.sendResult({
895
- text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
896
- }, ctx);
897
- }
898
- return;
852
+ this.registerEditedReplacement(cwd, messageId, latestTask.editedMessage, latestTask.binding, ctx);
899
853
  }
900
- const workspaceBusy = this.bridge.isBusyFor(cwd);
901
- if (stopUnconfirmed) {
902
- const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
903
- await this.bridge.sendResult({
904
- text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
905
- `已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
906
- }, ctx);
854
+ // 4.5/6. 按「目标此刻真的是下一个吗」给唯一一份用户可见反馈。
855
+ await this.reportImmediateOutcome({
856
+ cwd,
857
+ messageId,
858
+ ctx,
859
+ removedCount,
860
+ stopped,
861
+ stopUnconfirmed,
862
+ agentKind: interrupt.agentKind,
863
+ });
864
+ }
865
+ finally {
866
+ releaseRunStart();
867
+ }
868
+ }
869
+ /**
870
+ * 清掉排在目标之前的所有排队任务,返回实际清除条数。
871
+ *
872
+ * 两条红线:
873
+ * - 目标可能在 interruptCurrentRun 等待期间已经接跑(链并发前进),那时它已不在
874
+ * 快照里,剩下的全部排在它**后面**——清除必须在目标位置之前停下,不得越界删掉
875
+ * 用户没要求清除的任务。
876
+ * - 移除循环必须整体同步:队列链在任何 await 处都会推进(卡片 PATCH 也算),
877
+ * 一旦让出事件循环,用户要求清除的任务可能趁撤销卡还在路上就跑起来了。
878
+ * 撤销卡片由 `removeFromQueue` 统一触发(fire-and-forget)。
879
+ */
880
+ clearTasksAheadOfTarget(cwd, messageId) {
881
+ const tasks = this.bridge.getQueuedTasks(cwd);
882
+ const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
883
+ let removedCount = 0;
884
+ for (let i = 0; i < targetIdx; i++) {
885
+ if (this.bridge.removeFromQueue(cwd, tasks[i].messageId)) {
886
+ removedCount++;
907
887
  }
908
- else if (!workspaceBusy) {
909
- // Normal path: nothing new began during the stop, the target is next.
910
- // Mark its own card as executing now so buttons grey out immediately.
911
- // Done BEFORE any removal so getQueuedTask still returns the task with
912
- // its (possibly edited) messagePreview for the card. The queue callback's
913
- // later updateQueueCardToExecuting call becomes a no-op (idempotent).
914
- await this.bridge.markQueueCardExecuting(cwd, messageId);
915
- const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
916
- await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
888
+ }
889
+ if (targetIdx < 0) {
890
+ getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
891
+ }
892
+ return removedCount;
893
+ }
894
+ /**
895
+ * `queue.immediate` 的终态反馈(唯一出口)。
896
+ *
897
+ * 承诺必须与真实调度一致:目标只有确实是停止后的下一个,才可以把它的卡翻成
898
+ * 「执行中」并说「将立即执行」;在途接跑/被撤销/被抢先/远程停止未确认四种状态
899
+ * 各给各的文案(把没发生的事说成发生了,比沉默更糟——用户会据文案决定要不要重发)。
900
+ */
901
+ async reportImmediateOutcome(opts) {
902
+ const { cwd, messageId, ctx, removedCount, stopped, stopUnconfirmed, agentKind } = opts;
903
+ const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
904
+ if (!targetStillQueued) {
905
+ // 目标在途接跑:已经在执行,说「无需重复操作」;真的被撤销了才说「未安排执行」。
906
+ if (this.bridge.hasTaskBegan(messageId)) {
907
+ await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
917
908
  }
918
909
  else {
919
910
  await this.bridge.sendResult({
920
- text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
911
+ text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
921
912
  }, ctx);
922
913
  }
914
+ return;
923
915
  }
924
- finally {
925
- releaseRunStart();
916
+ if (stopUnconfirmed) {
917
+ const name = agentKind ? agentDisplayName(agentKind) : 'agent';
918
+ await this.bridge.sendResult({
919
+ text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
920
+ `已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
921
+ }, ctx);
922
+ return;
926
923
  }
924
+ if (!this.bridge.isBusyFor(cwd)) {
925
+ // Normal path: nothing new began during the stop, the target is next.
926
+ // Mark its own card as executing now so buttons grey out immediately.
927
+ // Done BEFORE any removal so getQueuedTask still returns the task with
928
+ // its (possibly edited) messagePreview for the card. The queue callback's
929
+ // later updateQueueCardToExecuting call becomes a no-op (idempotent).
930
+ await this.bridge.markQueueCardExecuting(cwd, messageId);
931
+ const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
932
+ await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
933
+ return;
934
+ }
935
+ await this.bridge.sendResult({
936
+ text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
937
+ }, ctx);
927
938
  }
928
939
  /**
929
940
  * Handle queue.diagnose: show diagnostic info for why message is queuing.
@@ -1095,8 +1106,6 @@ export class CommandRouter {
1095
1106
  // replacement if the task starts during the subsequent await. Both
1096
1107
  // setTaskReplacement and the begin-path consumption are synchronous, so
1097
1108
  // there is no interleaving window.
1098
- //
1099
- ///Step3:复用原任务 binding(不重新快照)+ 恢复 cwdOverride。
1100
1109
  const inputTask = this.bridge.getQueuedTask(cwd, messageId);
1101
1110
  if (!inputTask) {
1102
1111
  // Task already left the queue (began or was cancelled) before we could
@@ -1106,9 +1115,7 @@ export class CommandRouter {
1106
1115
  // toast/card field).
1107
1116
  return { toast: { type: 'info', content: '任务已不在队列中(可能已开始执行或被撤销)' } };
1108
1117
  }
1109
- this.bridge.setTaskReplacement(cwd, messageId, async () => {
1110
- await this.handle(newMessage, ctx, { cwdOverride: cwd, binding: inputTask.binding });
1111
- });
1118
+ this.registerEditedReplacement(cwd, messageId, newMessage, inputTask.binding, ctx);
1112
1119
  // Update the message preview (and editedMessage so handleQueueImmediate
1113
1120
  // re-enqueues the edited content instead of the stale original closure),
1114
1121
  // and build the updated queue card. Returning the card in the callback
@@ -1247,11 +1254,7 @@ export class CommandRouter {
1247
1254
  * Mirrors handleLsPage — updates the same card, never sends a new one.
1248
1255
  */
1249
1256
  async handleResumePage(value, ctx) {
1250
- const resumeAgent = value.agent ?? this.config.defaultAgent;
1251
- const validAgents = CommandRouter.VALID_AGENTS;
1252
- const resolvedAgent = validAgents.includes(resumeAgent)
1253
- ? resumeAgent
1254
- : this.config.defaultAgent;
1257
+ const resolvedAgent = resolveAgentKind(value.agent, this.config.defaultAgent);
1255
1258
  const { pageSize, alignedOffset, error } = normalizePageArgs(value, RESUME_PAGE_SIZE);
1256
1259
  if (error) {
1257
1260
  return { toast: { type: 'error', content: error } };
@@ -1381,8 +1384,6 @@ export class CommandRouter {
1381
1384
  * Returns null when the order no longer exists; index.ts surfaces the error.
1382
1385
  */
1383
1386
  resolveOrderExecForQueue(orderId) {
1384
- if (!this.orderStore.has(orderId))
1385
- return null;
1386
1387
  const order = this.orderStore.get().find((o) => o.id === orderId);
1387
1388
  if (!order)
1388
1389
  return null;
@@ -1812,6 +1813,26 @@ export class CommandRouter {
1812
1813
  this.configCardIds.delete(oldest);
1813
1814
  }
1814
1815
  }
1816
+ /**
1817
+ * 把一次字段变更交给当前 agent 的 config builder(处理 provider→model 这类
1818
+ * 依赖关系),逐条写进草稿,再把草稿刷到所有配置卡。
1819
+ * `config.set`(下拉)与 `config.input`(输入框提交)共用同一套应用逻辑。
1820
+ */
1821
+ async applyConfigFieldChange(key, newValue, ctx) {
1822
+ try {
1823
+ this.ensurePendingConfig();
1824
+ const defaultAgent = this.pendingConfig.defaultAgent ?? 'claude';
1825
+ const configBuilder = getConfigBuilder(defaultAgent);
1826
+ const patches = configBuilder.handleFieldChange(key, newValue, this.pendingConfig);
1827
+ for (const patch of patches) {
1828
+ this.setNestedValue(this.pendingConfig, patch.key, patch.value);
1829
+ }
1830
+ await this.refreshConfigCards(ctx);
1831
+ }
1832
+ catch (err) {
1833
+ await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
1834
+ }
1835
+ }
1815
1836
  /**
1816
1837
  * 把当前草稿刷到所有已知 config 卡片上(含本次点击的那张)。
1817
1838
  *
@@ -1835,12 +1856,12 @@ export class CommandRouter {
1835
1856
  }
1836
1857
  }
1837
1858
  /**
1838
- * 串行化 config.* 卡片回调(2026-07-04 修复 toggle 卡死 bug)。
1859
+ * 串行化 config.* 卡片回调(2026-07-04 修复连击卡死 bug)。
1839
1860
  *
1840
1861
  * CardKit 2.0 input/button 回调经 `enqueueImmediate` 分发,不进 bridge 的
1841
- * 串行队列;多次快速点击 toggle 会并发读写 `pendingConfig` 并同时调
1862
+ * 串行队列;多次快速点击下拉/文本框会并发读写 `pendingConfig` 并同时调
1842
1863
  * `updateCardInPlace` → 两个 patch 请求乱序到达飞书,后到达的 patch(点击 1
1843
- * 的旧卡片)覆盖先到达的 patch(点击 2 的新卡片),用户看到 toggle 卡死。
1864
+ * 的旧卡片)覆盖先到达的 patch(点击 2 的新卡片),用户看到控件卡死。
1844
1865
  *
1845
1866
  * 此方法把所有 config.* 动作串成一个 Promise chain:每个动作等前一个动作
1846
1867
  * 的 `updateCardInPlace` 完全 settle 后再读 `pendingConfig`。
@@ -1863,28 +1884,6 @@ export class CommandRouter {
1863
1884
  /** config.* 卡片动作分发(public:测试直接调用,替代 as unknown as)。 */
1864
1885
  async dispatchConfigAction(value, ctx) {
1865
1886
  switch (value.cmd) {
1866
- case 'config.toggle': {
1867
- // 通用 boolean 翻转:目前无卡片字段使用(output.show* 已移除),
1868
- // 保留供未来 boolean 配置项复用;configActionQueue 串行化覆盖连击。
1869
- const key = value.key;
1870
- if (!key) {
1871
- await this.bridge.sendResult({ text: '缺少配置项 key' }, ctx);
1872
- return;
1873
- }
1874
- this.ensurePendingConfig();
1875
- const current = getConfigValue(this.pendingConfig, key);
1876
- // 布尔字段未设置时视为 false,而非报错(如布尔配置项尚未写入 config)
1877
- const boolVal = typeof current === 'boolean' ? current : false;
1878
- try {
1879
- // 只改暂存区,不写盘;原地更新卡片而非发送新卡(2026-07-04)
1880
- this.setNestedValue(this.pendingConfig, key, !boolVal);
1881
- await this.refreshConfigCards(ctx);
1882
- }
1883
- catch (err) {
1884
- await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
1885
- }
1886
- return;
1887
- }
1888
1887
  case 'config.set': {
1889
1888
  // Set config value from select dropdown (option from CardKit 2.0)
1890
1889
  const key = value.key;
@@ -1893,20 +1892,7 @@ export class CommandRouter {
1893
1892
  await this.bridge.sendResult({ text: '缺少参数' }, ctx);
1894
1893
  return;
1895
1894
  }
1896
- try {
1897
- this.ensurePendingConfig();
1898
- // 使用 agent config builder 处理字段变更(处理依赖关系如 provider→model)
1899
- const defaultAgent = this.pendingConfig.defaultAgent ?? 'claude';
1900
- const configBuilder = getConfigBuilder(defaultAgent);
1901
- const patches = configBuilder.handleFieldChange(key, newValue, this.pendingConfig);
1902
- for (const patch of patches) {
1903
- this.setNestedValue(this.pendingConfig, patch.key, patch.value);
1904
- }
1905
- await this.refreshConfigCards(ctx);
1906
- }
1907
- catch (err) {
1908
- await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
1909
- }
1895
+ await this.applyConfigFieldChange(key, newValue, ctx);
1910
1896
  return;
1911
1897
  }
1912
1898
  case 'config.input': {
@@ -1924,20 +1910,7 @@ export class CommandRouter {
1924
1910
  await this.bridge.sendResult({ text: '缺少输入值' }, ctx);
1925
1911
  return;
1926
1912
  }
1927
- try {
1928
- this.ensurePendingConfig();
1929
- // 使用 agent config builder 处理字段变更
1930
- const defaultAgent = this.pendingConfig.defaultAgent ?? 'claude';
1931
- const configBuilder = getConfigBuilder(defaultAgent);
1932
- const patches = configBuilder.handleFieldChange(key, newValue, this.pendingConfig);
1933
- for (const patch of patches) {
1934
- this.setNestedValue(this.pendingConfig, patch.key, patch.value);
1935
- }
1936
- await this.refreshConfigCards(ctx);
1937
- }
1938
- catch (err) {
1939
- await this.bridge.sendResult({ text: `设置失败: ${err.message}` }, ctx);
1940
- }
1913
+ await this.applyConfigFieldChange(key, newValue, ctx);
1941
1914
  return;
1942
1915
  }
1943
1916
  case 'config.save': {
@@ -2023,18 +1996,9 @@ export class CommandRouter {
2023
1996
  this.bridge.setIdleTimeout(this.idleTimeoutMs);
2024
1997
  // 2026-07-13: 检测是否有任何 agent 配置变更或 defaultAgent 切换
2025
1998
  const hasDefaultAgentChange = oldDefaultAgent !== this.config.defaultAgent;
2026
- const hasAgentConfigChange = Object.keys(updates).some((k) => k.startsWith('agents.') ||
2027
- k.startsWith('claude.') ||
2028
- k.startsWith('pi.') ||
2029
- k.startsWith('codex.') ||
2030
- k.startsWith('opencode.') ||
2031
- k.startsWith('kimi.') ||
2032
- k === 'defaultAgent' ||
2033
- k === 'claude' ||
2034
- k === 'pi' ||
2035
- k === 'codex' ||
2036
- k === 'opencode' ||
2037
- k === 'kimi');
1999
+ const changedKeys = Object.keys(updates);
2000
+ const hasAgentConfigChange = changedKeys.includes('defaultAgent') ||
2001
+ AGENT_KINDS.some((kind) => touchesAgentConfig(changedKeys, kind));
2038
2002
  // 当 defaultAgent 改变时,清除旧 agent 的 sessionId,
2039
2003
  // 同时清除新 agent 的 sessionId(等效于 /new 命令),
2040
2004
  // 并支持 session 恢复 - 切换回来时如果条件满足则恢复之前的 session。
@@ -2083,43 +2047,30 @@ export class CommandRouter {
2083
2047
  switchSessionId = '';
2084
2048
  }
2085
2049
  }
2086
- // DSH preset 变更 = 下次 run 新建 session:
2087
- // preset 在 session 创建时固定,中途切换会被服务端拒绝(agent-preset-conflict)。
2088
- // 旧 sessionId 存入 previousSessions 停车位(可手动 /resume,但 /resume 会做
2089
- // preset 一致性校验),当前 sessionId 清空——下次 run 走 session.create 新建。
2090
- const hasDshPresetChange = Object.keys(updates).some((k) => k === 'agents.dsh.agentPreset');
2091
- // 已发生 agent 切换时(switchNotice 已设置)preset 分支跳过,避免覆盖切换 notice;
2092
- // 切换分支对 dsh 的 session 已做恢复/清空处理。
2093
- if (hasDshPresetChange && this.config.defaultAgent === 'dsh' && !switchNotice) {
2094
- const oldSessionId = this.sessionStore.getSessionId(ctx.userId, 'dsh');
2095
- if (oldSessionId) {
2096
- this.sessionStore.setPreviousSessionId(ctx.userId, 'dsh', oldSessionId);
2097
- this.sessionStore.clearSessionId(ctx.userId, 'dsh');
2098
- switchNotice = `DSH preset 已变更,旧会话已停放到恢复槽(可 /resume 校验后恢复),下次消息将新建会话`;
2099
- }
2100
- else {
2101
- switchNotice = `DSH preset 已变更,下次消息将新建会话`;
2102
- }
2103
- }
2104
- // DSH host 变更 = 换服务:旧 host 的 sessionId 发往新 host 是错的。
2105
- // 视为 session 边界,仿 preset 分支停车 + 清空当前 sessionId,下次 run 新建会话。
2106
- const hasDshHostChange = Object.keys(updates).some((k) => k === 'agents.dsh.host');
2107
- if (hasDshHostChange && this.config.defaultAgent === 'dsh' && !switchNotice) {
2050
+ // DSH 的两个「会话边界」变更共用一套处理:preset 在 session 创建时固定,
2051
+ // 中途切换会被服务端拒绝(agent-preset-conflict);host 变更等于换服务,
2052
+ // 旧 host 的 sessionId 发往新 host 是错的。两者都把旧 sessionId 停到
2053
+ // previousSessions 恢复槽(可 /resume 校验后恢复)并清空当前值,下次 run
2054
+ // 走 session.create 新建。已发生 agent 切换时(switchNotice 已设置)跳过,
2055
+ // 避免覆盖切换 notice——切换分支对 dsh 的 session 已做恢复/清空处理。
2056
+ const parkDshSessionBoundary = (changed, label) => {
2057
+ if (!changed || this.config.defaultAgent !== 'dsh' || switchNotice)
2058
+ return;
2108
2059
  const oldSessionId = this.sessionStore.getSessionId(ctx.userId, 'dsh');
2109
2060
  if (oldSessionId) {
2110
2061
  this.sessionStore.setPreviousSessionId(ctx.userId, 'dsh', oldSessionId);
2111
2062
  this.sessionStore.clearSessionId(ctx.userId, 'dsh');
2112
- switchNotice = `DSH host 已变更,旧会话已停放到恢复槽(可 /resume 校验后恢复),下次消息将新建会话`;
2063
+ switchNotice = `DSH ${label} 已变更,旧会话已停放到恢复槽(可 /resume 校验后恢复),下次消息将新建会话`;
2113
2064
  }
2114
2065
  else {
2115
- switchNotice = `DSH host 已变更,下次消息将新建会话`;
2066
+ switchNotice = `DSH ${label} 已变更,下次消息将新建会话`;
2116
2067
  }
2117
- }
2068
+ };
2069
+ parkDshSessionBoundary(changedKeys.includes('agents.dsh.agentPreset'), 'preset');
2070
+ parkDshSessionBoundary(changedKeys.includes('agents.dsh.host'), 'host');
2118
2071
  // 2026-07-13: 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
2119
2072
  const currentAgent = this.config.defaultAgent;
2120
- const currentAgentConfigKey = `agents.${currentAgent}`;
2121
- const hasCurrentAgentUpdate = Object.keys(updates).some((k) => k === currentAgentConfigKey || k.startsWith(`${currentAgentConfigKey}.`));
2122
- if (hasCurrentAgentUpdate) {
2073
+ if (touchesAgentsSubtree(changedKeys, currentAgent)) {
2123
2074
  this.config = syncAgentChoices(this.config, currentAgent);
2124
2075
  // agentChoices 同步写盘必须走原子写(tmp+rename),禁裸同步写盘
2125
2076
  atomicWrite(this.configPath, YAML.stringify(this.config));
@@ -2131,19 +2082,8 @@ export class CommandRouter {
2131
2082
  // duck method `updateApprovalMode` (codex thread/settings/update,
2132
2083
  // kimi/opencode session/set_mode) so they take effect for subsequent
2133
2084
  // turns without waiting for the runner to be recreated.
2134
- const hasApprovalConfigChange = Object.keys(updates).some((k) => k === 'agents.codex' ||
2135
- k.startsWith('agents.codex.') ||
2136
- k === 'codex' ||
2137
- k.startsWith('codex.') ||
2138
- k === 'agents.kimi' ||
2139
- k.startsWith('agents.kimi.') ||
2140
- k === 'kimi' ||
2141
- k.startsWith('kimi.') ||
2142
- k === 'agents.opencode' ||
2143
- k.startsWith('agents.opencode.') ||
2144
- k === 'opencode' ||
2145
- k.startsWith('opencode.') ||
2146
- k === 'agents.dsh.permissionPreset');
2085
+ const hasApprovalConfigChange = ['codex', 'kimi', 'opencode'].some((kind) => touchesAgentConfig(changedKeys, kind)) ||
2086
+ changedKeys.includes('agents.dsh.permissionPreset');
2147
2087
  if (hasApprovalConfigChange) {
2148
2088
  this.bridge.syncActiveApprovalModes();
2149
2089
  }
@@ -2356,14 +2296,14 @@ export class CommandRouter {
2356
2296
  cmdStatus(ctx) {
2357
2297
  const entry = this.sessionStore.get(ctx.userId);
2358
2298
  const cwd = entry?.cwd ?? '(未设置)';
2359
- const sessionId = entry?.sessions?.get(this.config.defaultAgent) ?? '(无)';
2299
+ const sessionId = entry?.sessions.get(this.config.defaultAgent) ?? '(无)';
2360
2300
  // 查 bridge.activeRuns 而不是默认 runner.isRunning —— 因为 per-workspace
2361
2301
  // runner 启动的 run 不会反映到默认 runner 状态。
2362
2302
  const running = cwd !== '(未设置)' && this.bridge.isBusyFor(cwd);
2363
2303
  const logger = getLogger();
2364
2304
  const pid = process.pid;
2365
2305
  const configDir = getConfigDir();
2366
- const logFile = typeof logger.getCurrentLogFile === 'function' ? logger.getCurrentLogFile() : '';
2306
+ const logFile = logger.getCurrentLogFile();
2367
2307
  // 构建 agent 状态信息
2368
2308
  const agentLines = [];
2369
2309
  if (cwd !== '(未设置)') {
@@ -2841,7 +2781,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2841
2781
  cmdResume(args, ctx, offset = 0) {
2842
2782
  return buildResumeCommand(args, ctx, offset, {
2843
2783
  defaultAgent: this.config.defaultAgent,
2844
- validAgents: CommandRouter.VALID_AGENTS,
2845
2784
  getSessionEntry: (userId) => this.sessionStore.get(userId),
2846
2785
  setSessionId: (userId, agent, sessionId, cwd) => this.sessionStore.setSessionId(userId, agent, sessionId, cwd),
2847
2786
  defaultReader: () => this.sessionReader,
@@ -1,4 +1,4 @@
1
- import type { AgentKind } from './types.js';
1
+ import type { AgentKind } from '../agent-kinds.js';
2
2
  /** 一对需要同步的字段:agents.<agent> 上的配置键 ↔ agentChoices.<agent> 上的选择键。 */
3
3
  export interface ChoiceField {
4
4
  configKey: string;
@@ -1,4 +1,17 @@
1
- import { type AgentEvent, type SpawnOptions, type AgentSessionReader, type AgentRunner, type AgentStatusInfo } from '../types.js';
1
+ /**
2
+ * Claude Runner 实现
3
+ *
4
+ * ClaudeRunner 从「一次一跑」(claude -p <msg> --permission-mode
5
+ * bypassPermissions)升级为长驻交互会话(--input-format stream-json 双向
6
+ * 通道),同时复用本项目已有的 ApprovalCoordinator + run 卡审批区。
7
+ *
8
+ * 架构:ClaudeRunner 是 workspace-lifetime 的薄包装,进程与协议细节在
9
+ * ClaudeSession(src/runner/claude/session.ts,extends SpawningRunner 复用
10
+ * pid 文件/killOrphan/Terminator/心跳/退出分发器机制)。每次 run() 委托
11
+ * session:确保进程(可 --resume)→ 写 user 消息 → 消费事件直到本 turn result。
12
+ */
13
+ import type { AgentSessionReader } from '../../session-contract.js';
14
+ import { type AgentEvent, type SpawnOptions, type AgentRunner, type AgentStatusInfo } from '../types.js';
2
15
  /**
3
16
  * Claude Agent Runner(长驻交互模式)。
4
17
  *
@@ -23,7 +36,12 @@ export declare class ClaudeRunner implements AgentRunner {
23
36
  pidDir?: string;
24
37
  cwd: string;
25
38
  spawnHeartbeatMs?: number;
26
- sessionReader?: AgentSessionReader;
39
+ /**
40
+ * 由组合根(`src/index.ts`)注入,与 sessionReaderRegistry 里的**同一个**实例。
41
+ * claude 不在此处自己 new:那会让 runner 反向依赖 session 层(runner ↔ session
42
+ * 循环的一半);其余 agent 从一开始就是注入的。
43
+ */
44
+ sessionReader: AgentSessionReader;
27
45
  /** Claude 权限模式(官方 --permission-mode 枚举;'default'=省略参数)。 */
28
46
  permissionMode?: string;
29
47
  /** 会话级空闲回收 TTL(ms);0=禁用。 */