lark-remote 0.1.5 → 0.1.6

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 (55) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.en.md +0 -4
  3. package/README.md +0 -4
  4. package/dist/bridge/approval-coordinator.d.ts +108 -0
  5. package/dist/bridge/approval-coordinator.js +258 -0
  6. package/dist/bridge/index.d.ts +62 -0
  7. package/dist/bridge/index.js +402 -21
  8. package/dist/bridge/queue-manager.d.ts +9 -0
  9. package/dist/bridge/queue-manager.js +19 -11
  10. package/dist/card/approval-render.d.ts +16 -0
  11. package/dist/card/approval-render.js +247 -0
  12. package/dist/card/card-shared.d.ts +14 -0
  13. package/dist/card/card-shared.js +28 -0
  14. package/dist/card/run-renderer.js +77 -6
  15. package/dist/card/run-state.d.ts +25 -3
  16. package/dist/card/run-state.js +203 -2
  17. package/dist/config/dir.js +1 -1
  18. package/dist/config/index.d.ts +42 -0
  19. package/dist/config/index.js +23 -1
  20. package/dist/connector/index.d.ts +5 -0
  21. package/dist/connector/index.js +12 -0
  22. package/dist/index.js +44 -22
  23. package/dist/router/config/codex.d.ts +1 -1
  24. package/dist/router/config/codex.js +49 -0
  25. package/dist/router/config/common/render.js +4 -0
  26. package/dist/router/config/types.d.ts +1 -1
  27. package/dist/router/index.d.ts +27 -2
  28. package/dist/router/index.js +109 -28
  29. package/dist/router/utils.d.ts +2 -0
  30. package/dist/router/utils.js +5 -2
  31. package/dist/runner/codex/app-server/client.d.ts +82 -0
  32. package/dist/runner/codex/app-server/client.js +234 -0
  33. package/dist/runner/codex/app-server/connection-manager.d.ts +56 -0
  34. package/dist/runner/codex/app-server/connection-manager.js +170 -0
  35. package/dist/runner/codex/app-server/protocol-types.d.ts +527 -0
  36. package/dist/runner/codex/app-server/protocol-types.js +67 -0
  37. package/dist/runner/codex/app-server/runner.d.ts +121 -0
  38. package/dist/runner/codex/app-server/runner.js +554 -0
  39. package/dist/runner/codex/app-server/translator.d.ts +109 -0
  40. package/dist/runner/codex/app-server/translator.js +734 -0
  41. package/dist/runner/codex/app-server/transport.d.ts +49 -0
  42. package/dist/runner/codex/app-server/transport.js +231 -0
  43. package/dist/runner/codex/factory.d.ts +29 -0
  44. package/dist/runner/codex/factory.js +39 -0
  45. package/dist/runner/codex/index.d.ts +2 -0
  46. package/dist/runner/codex/index.js +2 -0
  47. package/dist/runner/codex/runner.d.ts +3 -0
  48. package/dist/runner/codex/runner.js +5 -0
  49. package/dist/runner/common/usage.d.ts +2 -0
  50. package/dist/runner/common/usage.js +2 -0
  51. package/dist/runner/index.d.ts +2 -2
  52. package/dist/runner/index.js +1 -1
  53. package/dist/runner/types.d.ts +123 -2
  54. package/dist/session/codex/rollout-reader.js +43 -0
  55. package/package.json +1 -1
@@ -12,6 +12,7 @@ import { enforceCardBudget } from '../card/card-budget.js';
12
12
  import { normalizeResultUsage } from '../runner/common/usage.js';
13
13
  import { BashProcessRunner } from '../runner/index.js';
14
14
  import { QueueManager, } from './queue-manager.js';
15
+ import { ApprovalCoordinator, decisionToApprovalAction } from './approval-coordinator.js';
15
16
  /**
16
17
  * Max events to read for the completion notification card (mirror router's
17
18
  * AUTO_RESUME_MAX_EVENTS = 5). Without a cap, readSessionContent returns every
@@ -19,6 +20,8 @@ import { QueueManager, } from './queue-manager.js';
19
20
  * budget (review §P1-21).
20
21
  */
21
22
  const COMPLETION_NOTIFICATION_MAX_EVENTS = 5;
23
+ /** Approval request timeout (ms) — pending approval expires after this. */
24
+ const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
22
25
  /**
23
26
  * Max idle time (no stdout events) before a claude run is considered hung
24
27
  * (§9.12). When this fires the bridge calls `runner.stop()` to unblock the
@@ -80,6 +83,13 @@ export class Bridge {
80
83
  queueManager;
81
84
  /** Active runs per workspace (cwd). Multiple workspaces can run in parallel. */
82
85
  activeRuns = new Map();
86
+ /** Approval coordinators keyed by runId. */
87
+ approvalCoordinators = new Map();
88
+ /**
89
+ * 最近一次已完成的 codex app-server run(按 cwd)。run 结束后 activeRuns
90
+ * 已清空,Compact 需要它来校验 runId 并提供 sessionId。
91
+ */
92
+ lastCompactableCodexRun = new Map();
83
93
  /**
84
94
  * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
85
95
  * queue and may run in parallel with claude runs / other bash runs in the same
@@ -182,6 +192,11 @@ export class Bridge {
182
192
  if (evicted && typeof evicted.unregisterExitHandlers === 'function') {
183
193
  evicted.unregisterExitHandlers();
184
194
  }
195
+ // App-server runner owns a persistent connection — dispose it so the
196
+ // `codex app-server` child process does not leak across runs/evictions.
197
+ if (evicted && typeof evicted.dispose === 'function') {
198
+ void evicted.dispose();
199
+ }
185
200
  workspaceMap.delete(kind);
186
201
  if (workspaceMap.size === 0) {
187
202
  this.runners.delete(cwd);
@@ -483,6 +498,15 @@ export class Bridge {
483
498
  }
484
499
  catch (err) {
485
500
  getLogger().error('[bridge] failed to send result:', err);
501
+ // 不静默:向用户发送一条纯文本消息,告知具体错误(如飞书 11310 element exceeds
502
+ // the limit)。如果用户看到空白无反馈,无法判断是命令无效还是卡片发送失败。
503
+ try {
504
+ const errMsg = err.message ?? String(err);
505
+ await this.connector.sendWithRetry(ctx.chatId, { text: `⚠️ 卡片发送失败:${errMsg}` }, { replyTo: ctx.messageId });
506
+ }
507
+ catch (fallbackErr) {
508
+ getLogger().error('[bridge] fallback text send also failed:', fallbackErr);
509
+ }
486
510
  return false;
487
511
  }
488
512
  }
@@ -511,7 +535,10 @@ export class Bridge {
511
535
  return true;
512
536
  }
513
537
  catch (err) {
514
- getLogger().warn(`[bridge] updateCardInPlace failed, falling back to sendResult: ${err.message}`);
538
+ const errMsg = err.message ?? String(err);
539
+ getLogger().warn(`[bridge] updateCardInPlace failed, falling back to sendResult: ${errMsg}`);
540
+ // 先尝试 fallback 发卡片;若卡片本身有问题(如 11310),
541
+ // sendResult 内部 catch 会发纯文本通知用户。
515
542
  await this.sendResult({ card }, ctx);
516
543
  return false;
517
544
  }
@@ -641,6 +668,10 @@ export class Bridge {
641
668
  * Handles idle watchdog, event processing, session sync, and state transitions.
642
669
  */
643
670
  async runAgentStreamToEnd(runner, cardSession, sessionId, sessionEpochAtStart, ctx, cwd, runId, message, agentKind = this.config.defaultAgent) {
671
+ // Runner 声明的 usage 权威来源(review P3-7):codex exec = 'jsonl',
672
+ // codex app-server = 'live'(turn/started 的 tokenUsage.last 是本 turn 增量)。
673
+ // 其余 runner 未声明,回退到 agentKind 推断(仅 codex 视为 jsonl-first)。
674
+ const usageAuthority = runner.getUsageAuthority?.();
644
675
  // P2-1: idle watchdog 用单 interval + lastEventTs 截止时间判定,而非每事件
645
676
  // clearTimeout + setTimeout 重建 timer。高频事件流下 timer 重建次数与事件数
646
677
  // 成正比(N 事件 = N 次 setTimeout + N 个闭包/秒);interval 方案只建一个
@@ -650,7 +681,7 @@ export class Bridge {
650
681
  let idleInterval = null;
651
682
  let sawResult = false;
652
683
  // 记录 result 是否为 error: error 时跳过 jsonl usage 读取 (见 stream end 处说明)
653
- let resultWasError = false;
684
+ let resultNotSuccess = false;
654
685
  // Final usage read from jsonl after the run completes. live stream-json does
655
686
  // not emit compact_boundary, so the live contextLength is unreliable.
656
687
  let finalContextLength;
@@ -664,6 +695,10 @@ export class Bridge {
664
695
  // done card shows real values instead of the 10% estimate.
665
696
  let liveInputTokens;
666
697
  let liveOutputTokens;
698
+ // Real context window limit from the live result event (codex app-server
699
+ // carries it in ResultEvent.usage.context_limit). jsonl wins when present;
700
+ // live is the fallback (app-server error runs / jsonl 未落盘时).
701
+ let liveContextLimit;
667
702
  // Final input/output for the done card: live value wins (codex/opencode
668
703
  // carry per-run usage in the result event); jsonl is the fallback for
669
704
  // agents whose live stream has no usage (kimi).
@@ -746,7 +781,7 @@ export class Bridge {
746
781
  // 代际守卫:run 在途时 /new(或 new-session 卡片、/cd、/resume、
747
782
  // /config 切换)移动了 session 指针,此 init 的写回是 stale 的——
748
783
  // 跳过,否则 /new 的清空会被在途 run 静默撤销(2026-08-09 事故:
749
- // task-notification 注入触发 Claude 重发 init,e8defc46 复活)。
784
+ // task-notification 注入触发 Claude 重发 init,已终止的 run 被复活)。
750
785
  const pointerMoved = this.sessionStore.getSessionEpoch(ctx.userId, agentKind) !== sessionEpochAtStart;
751
786
  if (pointerMoved) {
752
787
  getLogger().info(`[bridge] system.init write-back skipped: session pointer moved since run start runId=${runId} sessionId=${event.session_id}`);
@@ -773,6 +808,20 @@ export class Bridge {
773
808
  }
774
809
  getLogger().info(`[bridge] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
775
810
  }
811
+ if (event.type === 'turn_started') {
812
+ // app-server runner 在 turn setup 成功后补发 synthetic system.init
813
+ // (§9.22 守卫前提),此处 turn_started 写回保留为双保险:两者都用
814
+ // threadId 写回 sessionId(代际守卫一致),幂等。缺失会导致每次消息
815
+ // 都新建线程(thread/start 而非 thread/resume),会话无法延续。
816
+ const pointerMoved = this.sessionStore.getSessionEpoch(ctx.userId, agentKind) !== sessionEpochAtStart;
817
+ if (!pointerMoved) {
818
+ this.sessionStore.setSessionIdAndCwd(ctx.userId, agentKind, event.threadId, cwd);
819
+ getLogger().info(`[bridge] turn_started session write-back runId=${runId} sessionId=${event.threadId}`);
820
+ }
821
+ else {
822
+ getLogger().info(`[bridge] turn_started write-back skipped: session pointer moved since run start runId=${runId} sessionId=${event.threadId}`);
823
+ }
824
+ }
776
825
  // Track context length from compaction events
777
826
  if (event.type === 'system' &&
778
827
  event.subtype === 'compact_boundary' &&
@@ -786,7 +835,14 @@ export class Bridge {
786
835
  // also guards on sessionId === undefined) from transitioning to
787
836
  // finalizing too early. The real result for this run arrives after
788
837
  // system.init.
789
- if (!sawInit) {
838
+ //
839
+ // Exception: app-server runners (usageAuthority === 'live') 的 init
840
+ // 是 runner 补发的 synthetic init(2026-08-13 修复)。此分支保留为
841
+ // 防御性兜底:任何 live runner 若在无 init 时发出 result(如旧构建、
842
+ // 其它 live runner),session 写回仍执行,但 sawResult 保持 false,
843
+ // 卡片不会把半初始化的 run 当作已收尾。
844
+ const preInitResult = !sawInit;
845
+ if (preInitResult && usageAuthority !== 'live') {
790
846
  getLogger().info(`[bridge] pre-init result ignored (resume replay) runId=${runId}`);
791
847
  // Still reset idle timer — the CLI is alive and producing events.
792
848
  resetIdle();
@@ -795,9 +851,24 @@ export class Bridge {
795
851
  // layer avoids the push + render overhead for a no-op event.
796
852
  continue;
797
853
  }
798
- sawResult = true;
799
- resultWasError = event.subtype === 'error';
800
- getLogger().info(`[bridge] result event received runId=${runId}`);
854
+ if (!preInitResult) {
855
+ sawResult = true;
856
+ }
857
+ resultNotSuccess = event.subtype !== 'success';
858
+ getLogger().info(preInitResult
859
+ ? `[bridge] pre-init result from live runner (app-server setup failure) runId=${runId}`
860
+ : `[bridge] result event received runId=${runId}`);
861
+ // app-server 模式 setup 失败路径(thread/start 成功后 turn/start 失败)
862
+ // 不会有 turn_started 事件,store 不会写回新线程 id;runner 已在 result
863
+ // 里兜底上报(review P3-10)。此处仅当 runner 声明 live usage(app-server)
864
+ // 且会话指针未动时写回,避免下条消息再开一个孤儿线程。exec 模式与其
865
+ // 余 agent 不在此列(它们各自有 system.init / turn_started 写回)。
866
+ if (usageAuthority === 'live' &&
867
+ event.session_id &&
868
+ this.sessionStore.getSessionEpoch(ctx.userId, agentKind) === sessionEpochAtStart) {
869
+ this.sessionStore.setSessionIdAndCwd(ctx.userId, agentKind, event.session_id, cwd);
870
+ getLogger().info(`[bridge] result session write-back runId=${runId} sessionId=${event.session_id}`);
871
+ }
801
872
  // result 后进入 finalizing 非终态(暂存 subtype/errorMsg),由 for-await
802
873
  // 自然结束后在 finally 转 done/error。提取 usage 信息。
803
874
  if (event.usage) {
@@ -814,6 +885,7 @@ export class Bridge {
814
885
  // Input/Output 是本 run、Cache/Total 是 session 累计的混 scope 问题。
815
886
  liveInputTokens = u.inputTokens;
816
887
  liveOutputTokens = u.outputTokens;
888
+ liveContextLimit = u.contextLimit;
817
889
  if (u.cacheReadTokens !== undefined) {
818
890
  finalCacheReadTokens = u.cacheReadTokens;
819
891
  }
@@ -825,6 +897,59 @@ export class Bridge {
825
897
  }
826
898
  }
827
899
  }
900
+ // Approval events (Codex app-server mode)
901
+ if (event.type === 'approval_requested') {
902
+ // 审批请求到达:撤回 Typing 表情再重发,让飞书重新触发提醒,把用户
903
+ // 注意力拉回待审批的卡片(run 在等待人工决策,Typing 应重新闪烁)。
904
+ void (async () => {
905
+ try {
906
+ await this.connector.removeReactionByEmoji(ctx.messageId, 'Typing');
907
+ }
908
+ catch (err) {
909
+ getLogger().warn(`[bridge] remove Typing reaction failed runId=${runId}: ${errorMessage(err)}`);
910
+ }
911
+ try {
912
+ await this.connector.addReaction(ctx.messageId, 'Typing');
913
+ }
914
+ catch (err) {
915
+ getLogger().warn(`[bridge] re-add Typing reaction failed runId=${runId}: ${errorMessage(err)}`);
916
+ }
917
+ })();
918
+ let coordinator = this.approvalCoordinators.get(runId);
919
+ if (!coordinator) {
920
+ coordinator = new ApprovalCoordinator({
921
+ runId,
922
+ userId: ctx.userId,
923
+ chatId: ctx.chatId,
924
+ workspace: cwd,
925
+ approvalTimeoutMs: APPROVAL_TIMEOUT_MS,
926
+ responder: async (requestId, response) => {
927
+ const r = runner;
928
+ if (typeof r.respondApproval === 'function') {
929
+ await r.respondApproval(requestId, response);
930
+ }
931
+ },
932
+ interruptTurn: () => runner.stop(),
933
+ pushToCard: async (events) => {
934
+ for (const ev of events)
935
+ await cardSession.push(ev);
936
+ },
937
+ });
938
+ this.approvalCoordinators.set(runId, coordinator);
939
+ getLogger().info(`[bridge] approval coordinator created runId=${runId.slice(0, 8)} requestId=${event.requestId}`);
940
+ }
941
+ coordinator.onRequested(event);
942
+ }
943
+ else if (event.type === 'approval_resolved') {
944
+ const coordinator = this.approvalCoordinators.get(runId);
945
+ coordinator?.onResolved(event.requestId);
946
+ }
947
+ else if (event.type === 'approval_view_updated') {
948
+ // 乱序流:审批先到、item/started 后到,补全审批卡内容。coordinator
949
+ // 仅在 pending 时更新(已响应/过期不复活)。
950
+ const coordinator = this.approvalCoordinators.get(runId);
951
+ coordinator?.updateView(event.requestId, event.view);
952
+ }
828
953
  // §9.12 红线(§P1-2 方案 A):每个事件(含 result)无条件刷新
829
954
  // lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
830
955
  // 过渡)期间,宽限期恢复为完整的 idleTimeoutMs,避免已产出结果的 turn 被误标
@@ -846,11 +971,11 @@ export class Bridge {
846
971
  // 立即 exit=1), 此时 jsonl 里是上一次成功 turn 的历史 usage, 会被误当作本次 usage 显示,
847
972
  // 误导用户以为本次出错的请求也消耗了 token。error 时只用 live 捕获的 usage (若有)。
848
973
  const finalSessionId = this.sessionStore.getSessionId(ctx.userId, agentKind);
849
- const finalUsage = resultWasError
974
+ const finalUsage = resultNotSuccess
850
975
  ? undefined
851
976
  : this.resolveFinalUsage(finalSessionId, cwd, agentKind);
852
977
  finalContextLength = finalUsage?.contextLength ?? contextLength;
853
- finalContextLimit = finalUsage?.contextLimit;
978
+ finalContextLimit = finalUsage?.contextLimit ?? liveContextLimit;
854
979
  finalCompactCount = finalUsage?.compactCount;
855
980
  finalCumulativeTotalTokens = finalUsage?.cumulativeTotalTokens;
856
981
  finalCumulativeInputTokens = finalUsage?.cumulativeInputTokens;
@@ -867,7 +992,7 @@ export class Bridge {
867
992
  // 值必须被忽略,flow 字段一律取 jsonl 主线程文件的 per-turn 值;
868
993
  // jsonl 缺失(老版本无 token_count)时才回退 live。
869
994
  // contextLength/compactCount 保持 jsonl 优先(它们是水位/历史计数)。
870
- const codexJsonlFirst = agentKind === 'codex';
995
+ const codexJsonlFirst = usageAuthority === 'jsonl' || (usageAuthority === undefined && agentKind === 'codex');
871
996
  if (!codexJsonlFirst && liveInputTokens !== undefined && liveOutputTokens !== undefined) {
872
997
  // 非 codex 且 live 有 input/output → 所有 flow 字段用 live scope
873
998
  finalInputTokens = liveInputTokens;
@@ -892,9 +1017,13 @@ export class Bridge {
892
1017
  // 3) 已终态 + sawResult -> 补充 usage meta(不改 terminal)
893
1018
  const finalState = cardSession.currentState;
894
1019
  if (finalState.terminal === 'finalizing') {
895
- const isError = finalState.resultSubtype === 'error' || !!finalState.errorMsg;
896
- await cardSession.finish(isError ? 'error' : 'done', {
897
- resultSubtype: finalState.resultSubtype,
1020
+ // 三态终态映射(2026-08-14):interrupted(审批超时/取消、/stop)是独立
1021
+ // 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
1022
+ const subtype = finalState.resultSubtype;
1023
+ const isError = subtype === 'error' || !!finalState.errorMsg;
1024
+ const terminal = isError ? 'error' : subtype === 'interrupted' ? 'interrupted' : 'done';
1025
+ await cardSession.finish(terminal, {
1026
+ resultSubtype: subtype,
898
1027
  errorMsg: finalState.errorMsg,
899
1028
  contextLength: finalContextLength,
900
1029
  contextLimit: finalContextLimit,
@@ -1003,6 +1132,11 @@ export class Bridge {
1003
1132
  finally {
1004
1133
  if (idleInterval)
1005
1134
  clearInterval(idleInterval);
1135
+ const coordinator = this.approvalCoordinators.get(runId);
1136
+ if (coordinator) {
1137
+ coordinator.onTurnEnded();
1138
+ this.approvalCoordinators.delete(runId);
1139
+ }
1006
1140
  }
1007
1141
  }
1008
1142
  /**
@@ -1047,14 +1181,35 @@ export class Bridge {
1047
1181
  // (every later message busy-dropped until a manual /stop).
1048
1182
  if (this.activeRuns.get(cwd) === activeRun) {
1049
1183
  this.activeRuns.delete(cwd);
1050
- // Also clean up the runner to prevent memory leak
1051
- // Fix 4: Clean up this specific (cwd, agentKind) runner slot.
1052
- // (P1-1: eviction must also unregister the runner from the exit dispatcher)
1053
- // P2-3: evict by the run's captured agentKind, not the live
1054
- // defaultAgent — switching agent mid-run (via /config, whose
1055
- // clearRunners skips the active cwd) would otherwise delete the new
1056
- // agent's slot (no-op) and leave the original runner lingering.
1057
- this.evictRunnerSlot(cwd, activeRun.agentKind);
1184
+ // Workspace-lifetime runners (codex app-server) keep their persistent
1185
+ // connection across runs: evicting here would kill the connection after
1186
+ // EVERY run, defeating lifetime='workspace' + idle TTL. They are still
1187
+ // evicted+disposed by clearRunners (/config) and interruptCurrentRun
1188
+ // (/stop). Spawn-per-message runners are evicted as before to prevent
1189
+ // stale process references accumulating.
1190
+ if (activeRun.runner.lifetime !== 'workspace') {
1191
+ this.evictRunnerSlot(cwd, activeRun.agentKind);
1192
+ }
1193
+ // Compact 卡片需要 runId + sessionId;record 供终态卡上的 Compact 按钮
1194
+ // 校验与取参(activeRuns 已清空,无法再查到)。异常退出(error/
1195
+ // interrupted/idle_timeout)同样记录——上下文往往更大,压缩后再续。
1196
+ const runTerminal = cardSession.currentState.terminal;
1197
+ if (activeRun.agentKind === 'codex' &&
1198
+ activeRun.runner.lifetime === 'workspace' &&
1199
+ (runTerminal === 'done' ||
1200
+ runTerminal === 'error' ||
1201
+ runTerminal === 'interrupted' ||
1202
+ runTerminal === 'idle_timeout')) {
1203
+ const compactSessionId = cardSession.currentState.sessionId ??
1204
+ this.sessionStore.getSessionId(ctx.userId, 'codex');
1205
+ if (compactSessionId) {
1206
+ this.lastCompactableCodexRun.set(cwd, {
1207
+ runId: activeRun.runId,
1208
+ sessionId: compactSessionId,
1209
+ agentKind: activeRun.agentKind,
1210
+ });
1211
+ }
1212
+ }
1058
1213
  getLogger().info(`[bridge] activeRuns.delete cwd=${cwd} runId=${runId}`);
1059
1214
  }
1060
1215
  }
@@ -1081,6 +1236,7 @@ export class Bridge {
1081
1236
  contextLength: content.usage.contextLength,
1082
1237
  contextLimit: content.usage.contextLimit,
1083
1238
  compactCount: content.usage.compactCount,
1239
+ compactPreContextLength: content.usage.compactPreContextLength,
1084
1240
  cacheReadTokens: content.usage.cacheReadTokens,
1085
1241
  cacheCreationTokens: content.usage.cacheCreationTokens,
1086
1242
  totalTokens: content.usage.totalTokens,
@@ -1165,6 +1321,231 @@ export class Bridge {
1165
1321
  getLogger().warn(`[bridge] failed to send completion notification card:`, err);
1166
1322
  }
1167
1323
  }
1324
+ // =============================================================================
1325
+ // Approval action handlers
1326
+ // =============================================================================
1327
+ /**
1328
+ * Handle an approval response from a card button click.
1329
+ * Routes to the ApprovalCoordinator for the given run.
1330
+ */
1331
+ async handleApprovalRespond(opts) {
1332
+ const log = getLogger();
1333
+ log.info(`[bridge] handleApprovalRespond runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} decision=${opts.decision}`);
1334
+ const coordinator = this.approvalCoordinators.get(opts.runId);
1335
+ if (!coordinator) {
1336
+ // P2-7: run 已结束(coordinator 已释放)时点击审批按钮必须给用户明确反馈,
1337
+ // 不能静默。router 会把该异常转成 error toast。
1338
+ log.warn(`[bridge] no approval coordinator for runId=${opts.runId.slice(0, 8)}`);
1339
+ throw new Error('任务已结束,审批无法响应');
1340
+ }
1341
+ // Build ApprovalAction from the decision string
1342
+ const action = decisionToApprovalAction(opts.decision);
1343
+ await coordinator.submit(action, { requestId: opts.requestId, nonce: opts.nonce });
1344
+ }
1345
+ /**
1346
+ * Handle a permission toggle from a card button click.
1347
+ * Routes to the ApprovalCoordinator for the given run.
1348
+ */
1349
+ async handleApprovalToggle(opts) {
1350
+ const log = getLogger();
1351
+ log.info(`[bridge] handleApprovalToggle runId=${opts.runId.slice(0, 8)}... requestId=${opts.requestId} permId=${opts.permId}`);
1352
+ const coordinator = this.approvalCoordinators.get(opts.runId);
1353
+ if (!coordinator) {
1354
+ log.warn(`[bridge] no approval coordinator for runId=${opts.runId.slice(0, 8)}`);
1355
+ throw new Error('任务已结束,审批无法响应');
1356
+ }
1357
+ // selected 由卡片根据当前渲染状态传回(取反),不再硬编码只授不撤。
1358
+ const toggleAction = {
1359
+ permId: opts.permId,
1360
+ selected: opts.selected ?? true,
1361
+ };
1362
+ await coordinator.togglePerm(toggleAction, { requestId: opts.requestId });
1363
+ }
1364
+ /**
1365
+ * Handle codex.compact card action — trigger a Codex compaction request.
1366
+ * Validates the run exists and is in a terminal state, then calls
1367
+ * the app-server runner's runCompact().
1368
+ */
1369
+ async handleCodexCompact(value, ctx) {
1370
+ const log = getLogger();
1371
+ log.info(`[bridge] handleCodexCompact userId=${ctx.userId} runId=${value.runId?.slice(0, 8)}...`);
1372
+ const { runId } = value;
1373
+ if (!runId) {
1374
+ await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 runId' }, ctx);
1375
+ return;
1376
+ }
1377
+ const cwd = this.resolveCwd(ctx.userId);
1378
+ if (!cwd) {
1379
+ await this.sendResult({ text: '⚠️ 未设置工作目录' }, ctx);
1380
+ return;
1381
+ }
1382
+ // 校验:run 结束后 activeRuns 已清空,用 lastCompactableCodexRun 对照 runId。
1383
+ const last = this.lastCompactableCodexRun.get(cwd);
1384
+ if (!last || last.runId !== runId) {
1385
+ await this.sendResult({ text: '⚠️ 该任务已结束或不属于当前会话' }, ctx);
1386
+ return;
1387
+ }
1388
+ if (!last.sessionId) {
1389
+ await this.sendResult({ text: '⚠️ 未找到可压缩的会话' }, ctx);
1390
+ return;
1391
+ }
1392
+ // Check that the runner has runCompact method
1393
+ const runner = this.getRunner(cwd, last.agentKind);
1394
+ if (!('runCompact' in runner) ||
1395
+ typeof runner.runCompact !== 'function') {
1396
+ await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
1397
+ return;
1398
+ }
1399
+ log.info(`[bridge] handleCodexCompact executing runCompact for runId=${runId} cwd=${cwd}`);
1400
+ await this.streamCodexCompact({
1401
+ sessionId: last.sessionId,
1402
+ cwd,
1403
+ agentKind: last.agentKind,
1404
+ runner,
1405
+ ctx,
1406
+ });
1407
+ }
1408
+ /**
1409
+ * Stream a Codex compaction to a card: start a RunCardSession with
1410
+ * operationKind='compaction' (no recursive Compact button), consume
1411
+ * runner.runCompact() events, read authoritative jsonl usage, and finish
1412
+ * the card. Shared by handleCodexCompact (run card button) and
1413
+ * handleResumeCompact (resume cards) so both flows stay in sync.
1414
+ *
1415
+ * Precondition: callers have already validated that `runner` implements
1416
+ * runCompact and that the session exists in `cwd`.
1417
+ */
1418
+ async streamCodexCompact(opts) {
1419
+ const log = getLogger();
1420
+ const { sessionId, cwd, agentKind, runner, ctx } = opts;
1421
+ log.info(`[bridge] streamCodexCompact sessionId=${sessionId} cwd=${cwd}`);
1422
+ // 防御性能力检查:调用方已校验(runId / resume 两条路径),此处双保险并
1423
+ // 让 TS 收窄 runner 类型('runCompact' in runner 之后的 cast 才合法)。
1424
+ if (!('runCompact' in runner) ||
1425
+ typeof runner.runCompact !== 'function') {
1426
+ await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
1427
+ return;
1428
+ }
1429
+ await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
1430
+ const compactRunId = randomUUID();
1431
+ const cardSession = new RunCardSession({
1432
+ connector: this.connector,
1433
+ chatId: ctx.chatId,
1434
+ replyTo: ctx.messageId,
1435
+ runId: compactRunId,
1436
+ renderOptions: {
1437
+ showThinking: this.config.output.showThinking,
1438
+ showToolUse: this.config.output.showToolUse,
1439
+ showToolResult: this.config.output.showToolResult,
1440
+ agentKind,
1441
+ },
1442
+ });
1443
+ try {
1444
+ await cardSession.start();
1445
+ // operationKind=compaction:卡片不渲染 Compact 按钮(防递归 Compact)。
1446
+ await cardSession.push({
1447
+ type: 'turn_started',
1448
+ threadId: sessionId,
1449
+ turnId: '',
1450
+ operationKind: 'compaction',
1451
+ });
1452
+ let sawResult = false;
1453
+ for await (const event of runner.runCompact('', { cwd, sessionId })) {
1454
+ if (event.type === 'result')
1455
+ sawResult = true;
1456
+ await cardSession.push(event);
1457
+ }
1458
+ // 压缩结束后从会话 jsonl 读权威统计(压缩后上下文、压缩次数、本次压缩
1459
+ // token 消耗与会话累计)。thread/compacted 通知与 jsonl 落盘几乎同时
1460
+ // (实测差 ~9ms),compactCount 未读到则短重试,仍失败优雅降级(无统计)。
1461
+ let finalUsage = this.resolveFinalUsage(sessionId, cwd, agentKind);
1462
+ for (let attempt = 0; !finalUsage?.compactCount && attempt < 2; attempt++) {
1463
+ await new Promise((r) => setTimeout(r, 150));
1464
+ finalUsage = this.resolveFinalUsage(sessionId, cwd, agentKind);
1465
+ }
1466
+ await cardSession.finish(sawResult ? 'done' : 'error', {
1467
+ resultSubtype: sawResult ? 'success' : 'error',
1468
+ errorMsg: sawResult ? undefined : '未收到压缩结果',
1469
+ contextLength: finalUsage?.contextLength,
1470
+ contextLimit: finalUsage?.contextLimit,
1471
+ compactPreContextLength: finalUsage?.compactPreContextLength,
1472
+ compactCount: finalUsage?.compactCount,
1473
+ cacheReadTokens: finalUsage?.cacheReadTokens,
1474
+ cacheCreationTokens: finalUsage?.cacheCreationTokens,
1475
+ totalTokens: finalUsage?.totalTokens,
1476
+ inputTokens: finalUsage?.inputTokens,
1477
+ outputTokens: finalUsage?.outputTokens,
1478
+ cumulativeTotalTokens: finalUsage?.cumulativeTotalTokens,
1479
+ cumulativeInputTokens: finalUsage?.cumulativeInputTokens,
1480
+ cumulativeOutputTokens: finalUsage?.cumulativeOutputTokens,
1481
+ cumulativeCacheReadTokens: finalUsage?.cumulativeCacheReadTokens,
1482
+ cumulativeCacheCreationTokens: finalUsage?.cumulativeCacheCreationTokens,
1483
+ });
1484
+ log.info(`[bridge] streamCodexCompact finished sessionId=${sessionId} sawResult=${sawResult}`);
1485
+ }
1486
+ catch (err) {
1487
+ log.error(`[bridge] streamCodexCompact failed: ${errorMessage(err)}`);
1488
+ await cardSession.finish('error', { errorMsg: errorMessage(err) });
1489
+ }
1490
+ }
1491
+ /**
1492
+ * Handle resume.compact card action — compact a session directly from a
1493
+ * resume card (auto-resume / `/resume <id>`), without a runId.
1494
+ *
1495
+ * Unlike handleCodexCompact (which validates against the last finished run's
1496
+ * runId), the resume card carries the sessionId + agent it was rendered for.
1497
+ * Validation: the session must exist in the current cwd (same rule as
1498
+ * cmdResume / resume.use) and the runner must implement runCompact.
1499
+ */
1500
+ async handleResumeCompact(value, ctx) {
1501
+ const log = getLogger();
1502
+ log.info(`[bridge] handleResumeCompact userId=${ctx.userId} sessionId=${value.sessionId?.slice(0, 16)} agent=${value.agent ?? 'default'}`);
1503
+ const { sessionId } = value;
1504
+ if (!sessionId) {
1505
+ await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 sessionId' }, ctx);
1506
+ return;
1507
+ }
1508
+ const cwd = this.resolveCwd(ctx.userId);
1509
+ if (!cwd) {
1510
+ await this.sendResult({ text: '⚠️ 未设置工作目录' }, ctx);
1511
+ return;
1512
+ }
1513
+ // Resolve agent kind the same way resume.use does: card value wins,
1514
+ // otherwise fall back to defaultAgent.
1515
+ const validAgents = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
1516
+ const agentKind = value.agent && validAgents.includes(value.agent)
1517
+ ? value.agent
1518
+ : this.config.defaultAgent;
1519
+ // Session must exist in the current cwd (same rule as cmdResume /
1520
+ // resume.use): reject ghosts so a stale card cannot compact a session
1521
+ // from another cwd. Read failures get visible feedback (card click
1522
+ // 红线:不许静默失败)。
1523
+ let content;
1524
+ try {
1525
+ content = this.sessionReaderRegistry.get(agentKind).readSessionContent(sessionId, cwd);
1526
+ }
1527
+ catch (err) {
1528
+ await this.sendResult({ text: `⚠️ 读取 session ${sessionId} 失败: ${errorMessage(err)}` }, ctx);
1529
+ return;
1530
+ }
1531
+ if (content.events.length === 0 &&
1532
+ !content.usage &&
1533
+ !content.aiTitle &&
1534
+ !content.recap &&
1535
+ !content.displayTitle) {
1536
+ await this.sendResult({ text: `⚠️ 未找到 session ${sessionId}(当前目录: ${cwd})` }, ctx);
1537
+ return;
1538
+ }
1539
+ // Check that the runner has runCompact (codex app-server only).
1540
+ const runner = this.getRunner(cwd, agentKind);
1541
+ if (!('runCompact' in runner) ||
1542
+ typeof runner.runCompact !== 'function') {
1543
+ await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
1544
+ return;
1545
+ }
1546
+ log.info(`[bridge] handleResumeCompact executing runCompact sessionId=${sessionId} cwd=${cwd}`);
1547
+ await this.streamCodexCompact({ sessionId, cwd, agentKind, runner, ctx });
1548
+ }
1168
1549
  /**
1169
1550
  * Execute a bash command and stream output to a card.
1170
1551
  * `!` commands bypass the serial queue entirely (see body comment + §9.6):
@@ -29,6 +29,13 @@ export interface QueuedTask {
29
29
  workspace: string;
30
30
  timestamp: number;
31
31
  messagePreview: string;
32
+ /**
33
+ * Whether the queued task's message is editable from the queue card.
34
+ * Card actions like Compact are one-shot operations — editing their
35
+ * preview is meaningless, so they enqueue with `editable: false` and the
36
+ * queue card omits the ✏️ 编辑 button.
37
+ */
38
+ editable?: boolean;
32
39
  /**
33
40
  * Edited message content. Set by `updateQueuedTaskMessage` when the user
34
41
  * edits a queued message. The original closure captured at enqueue time
@@ -54,6 +61,8 @@ export interface EnqueueOptions {
54
61
  * real Feishu id and doubles as the reply target.
55
62
  */
56
63
  feishuReplyTo?: string;
64
+ /** Whether the queued task is editable (queue card ✏️ 编辑 button). Defaults to true. */
65
+ editable?: boolean;
57
66
  /** 入队时刻捕获的 agent+session 绑定(方案 D4)。 */
58
67
  binding?: AgentBinding;
59
68
  };
@@ -138,6 +138,7 @@ export class QueueManager {
138
138
  workspace,
139
139
  timestamp: Date.now(),
140
140
  messagePreview,
141
+ editable: taskMeta.editable,
141
142
  binding: taskMeta.binding,
142
143
  };
143
144
  // Check if there are tasks waiting BEFORE adding this one
@@ -395,22 +396,29 @@ export class QueueManager {
395
396
  },
396
397
  { tag: 'hr' },
397
398
  ];
398
- // Show message preview with edit button (only if not executing)
399
+ // Show message preview with edit button (only if not executing AND the
400
+ // task is editable). One-shot card actions like Compact enqueue with
401
+ // editable=false: their preview is not user-editable text, so the ✏️ 编辑
402
+ // button must not appear (queue card is still shown — only editing is
403
+ // meaningless for them).
399
404
  if (messagePreview) {
400
405
  elements.push({
401
406
  tag: 'div',
402
407
  text: { tag: 'lark_md', content: `📝 \`${messagePreview}\`` },
403
408
  });
404
- // Edit button: a pending (queued) task is always editable. The
405
- // executing/cancelled states render via dedicated card builders that
406
- // hard-disable all buttons, so this builder only serves pending cards.
407
- elements.push({
408
- tag: 'button',
409
- text: { tag: 'plain_text', content: '✏️ 编辑' },
410
- type: 'default',
411
- size: 'small',
412
- behaviors: [{ type: 'callback', value: { cmd: 'queue.edit', workspace, messageId } }],
413
- });
409
+ const task = this.indexGet(workspace, messageId);
410
+ if (task?.editable !== false) {
411
+ // Edit button: a pending (queued) task is editable by default. The
412
+ // executing/cancelled states render via dedicated card builders that
413
+ // hard-disable all buttons, so this builder only serves pending cards.
414
+ elements.push({
415
+ tag: 'button',
416
+ text: { tag: 'plain_text', content: '✏️ 编辑' },
417
+ type: 'default',
418
+ size: 'small',
419
+ behaviors: [{ type: 'callback', value: { cmd: 'queue.edit', workspace, messageId } }],
420
+ });
421
+ }
414
422
  elements.push({ tag: 'hr' });
415
423
  }
416
424
  // Action buttons