lark-remote 0.1.8 → 0.1.10

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.10] - 2026-08-22
9
+
10
+ ### 新增
11
+
12
+ - `/order` 卡片指令文本编辑:卡片「编辑」按钮弹出输入框原地修改(保留别名/使用统计/创建时间),CLI `/order edit <orderId|序号> <新文本>` 备选入口;长度上限 200 字符、空白提交报错、文本未变化短路写盘
13
+
14
+ ### 修复
15
+
16
+ - cardAction 直返白名单补齐:`order.textInput` 与 `approval.planFeedback` 不再被 fire-and-forget 的 `enqueueImmediate` 吞掉返回值(编辑卡停留编辑界面 / 计划审批意见 toast 消失)
17
+ - DSH `selectModel` 写放大:`session.selectModel`(会写服务端全局默认)改为每 session 首次 run 对齐一次,不再每次 run 重复写;启动期 `/stop` 会取消刚创建的 session 并产出 `interrupted` 终态而非静默返回
18
+ - order 末页删空守卫:删除末页唯一指令后 `offset` 回退到非空页(不再出现空白卡)
19
+ - 计划审批意见门控:仅 `ExitPlanMode` 工具审批接受修改意见,其他审批类型拒绝
20
+ - 计划文件跨 run 重置:`doStartProcess` 清空会话内追踪的计划文件,避免旧计划被 `resolvePlanContent` 误读
21
+ - finalizeRun fallback 卡按 `compactSupported` 门控 Compact 按钮(无 `runCompact` 能力的 agent 不再渲染死按钮)
22
+
23
+ ### 变更
24
+
25
+ - ESLint 告警清零(0 errors 0 warnings)
26
+ - 注释与文档同步 DSH 接入("5 个 agent"→"6 个 agent")
27
+
28
+ ## [0.1.9] - 2026-08-20
29
+
30
+ ### 新增
31
+
32
+ - ExitPlanMode 计划审批:计划全文折叠展示 + 五决策对齐 TUI(自动放行 / 附意见 / 采纳修改)
33
+ - 卡片别名管理重构:`alias-store` 并入 `alias-resolve`,`/order` 卡片新增「+别名」交互(绑定/修改/删除,已绑定指令显示 `$name` 标签 + ✕)
34
+ - DSH / connection-based runner 启动 / 配置热更 / 失败路径修复(CC-01~CC-08)
35
+
36
+ ### 变更
37
+
38
+ - 别名 API 由「触发词 → 文本」改为「指令 → 别名」:`/order alias add <orderId|序号> <别名>` 绑定、`/order alias rm` 移除;别名随指令持久化在 `orders.json`,删除指令时别名一并删除;名称不能为保留子命令、不能数字开头
39
+ - 用户文档同步别名卡片化交互,移除 `/order alias <name> <text>` 旧用法
40
+
41
+ ### 修复
42
+
43
+ - 别名卡片成功回调:携带 `card` 替换 pre-click 卡,删除文案对齐新交互
44
+ - kimi ACP runner 模型下发:迁移到纯 ACP 后 `session/set_config_option` 必须紧跟 `session/new|resume` 下发,否则实际跑的是默认模型(CC-07)
45
+
8
46
  ## [0.1.8] - 2026-08-19
9
47
 
10
48
  ### 新增
package/README.en.md CHANGED
@@ -6,7 +6,7 @@ English | [简体中文](README.md)
6
6
  [![npm](https://img.shields.io/npm/v/lark-remote.svg)](https://www.npmjs.com/package/lark-remote)
7
7
  [![CI](https://github.com/bungabungawoda/lark-remote/actions/workflows/ci.yml/badge.svg)](https://github.com/bungabungawoda/lark-remote/actions/workflows/ci.yml)
8
8
 
9
- A bridge between Feishu (Lark) private chat and local coding agents. Talk to Claude Code (or Codex / opencode / pi / Kimi) from Feishu — the agent reads/writes files and runs commands in a local directory you pick, with execution streamed live into a single CardKit 2.0 card.
9
+ A bridge between Feishu (Lark) private chat and local coding agents. Talk to Claude Code (or Codex / opencode / pi / Kimi / DSH) from Feishu — the agent reads/writes files and runs commands in a local directory you pick, with execution streamed live into a single CardKit 2.0 card.
10
10
 
11
11
  Designed for a single-user, peer-to-peer private chat. The agent's system prompt is never modified.
12
12
 
@@ -32,7 +32,7 @@ In Codex approval mode, an approval card arrives before any command runs — app
32
32
  - OS: macOS / Linux. **Windows is not supported yet** (relies on POSIX behaviors such as Unix signals, bash, and file locks)
33
33
  - Node.js 20+ ([Bun](https://bun.sh/) for development)
34
34
  - A Feishu custom app (either way): scan-to-create (a QR code pops up in the terminal on first launch; scan it with the Feishu app to create the app and write credentials automatically); or create one manually in the Open Platform — enable bot capability, subscribe to `im.message.receive_v1` and `card.action.trigger` via **long connection** (WebSocket, no public address needed), with at least the `im:message` scope.
35
- - Claude Code CLI installed locally and logged in once in a terminal (`claude` → browser OAuth). Same idea for other agents (codex / opencode / pi / kimi): install the corresponding CLI first.
35
+ - Claude Code CLI installed locally and logged in once in a terminal (`claude` → browser OAuth). Same idea for other agents (codex / opencode / pi / kimi): install the corresponding CLI first. DSH connects to a local DSH Web Host via HTTP+WebSocket (no local subprocess), default address `http://127.0.0.1:3080`.
36
36
 
37
37
  ## Installation
38
38
 
@@ -64,7 +64,7 @@ feishu:
64
64
  appId: cli_xxx
65
65
  appSecret: xxx
66
66
 
67
- # default agent: claude | codex | opencode | pi | kimi (default: claude)
67
+ # default agent: claude | codex | opencode | pi | kimi | dsh (default: claude)
68
68
  defaultAgent: claude
69
69
 
70
70
  claude:
@@ -124,7 +124,7 @@ Your original message also gets an emoji reaction: `Typing` while the run is in
124
124
  | `/reconnect` | - | Reconnect to Feishu |
125
125
  | `/restart` | - | In-place bridge self-restart (new process takes over with the same config) |
126
126
  | `/config get\|set` | `/c` | View/change runtime config (agent-aware card) |
127
- | `/order save\|list` | `/o` | Save or list frequently used prompts |
127
+ | `/order save\|list\|edit` | `/o` | Save, list, or edit frequently used prompts |
128
128
  | `!<cmd>` | - | Run a bash command with streaming card output (bypasses the serial queue) |
129
129
  | `/exit` | `/e` | Exit the bridge |
130
130
 
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![npm](https://img.shields.io/npm/v/lark-remote.svg)](https://www.npmjs.com/package/lark-remote)
7
7
  [![CI](https://github.com/bungabungawoda/lark-remote/actions/workflows/ci.yml/badge.svg)](https://github.com/bungabungawoda/lark-remote/actions/workflows/ci.yml)
8
8
 
9
- 飞书私聊 ↔ 本地 Coding Agent 桥接。在飞书里和 Claude Code(或 Codex / opencode / pi / Kimi)对话,agent 在你指定的本地目录里读写文件、跑命令,执行过程以 CardKit 2.0 卡片**单卡实时流式**呈现。
9
+ 飞书私聊 ↔ 本地 Coding Agent 桥接。在飞书里和 Claude Code(或 Codex / opencode / pi / Kimi / DSH)对话,agent 在你指定的本地目录里读写文件、跑命令,执行过程以 CardKit 2.0 卡片**单卡实时流式**呈现。
10
10
 
11
11
  单用户、p2p 私聊场景设计,不修改 agent 的 system prompt。
12
12
 
@@ -32,7 +32,7 @@ Codex 审批模式下,执行命令前会收到审批卡片,可直接在飞
32
32
  - 操作系统:macOS / Linux。**暂不支持 Windows**(依赖 Unix 信号、bash、文件锁等 POSIX 行为)
33
33
  - Node.js 20+(开发时用 [Bun](https://bun.sh/))
34
34
  - 飞书自建应用(二选一):扫码创建(首次启动终端弹二维码,飞书 App 扫码即自动创建并写入凭据);或手动在开放平台建应用——开启机器人能力,订阅 `im.message.receive_v1` 和 `card.action.trigger`,订阅方式选「长连接」(WebSocket,无需公网地址),权限至少 `im:message`。
35
- - Claude Code CLI:本地安装并在终端完成一次登录(`claude` → 浏览器 OAuth)。使用其他 agent(codex / opencode / pi / kimi)同理,先装好对应 CLI。
35
+ - Claude Code CLI:本地安装并在终端完成一次登录(`claude` → 浏览器 OAuth)。使用其他 agent(codex / opencode / pi / kimi)同理,先装好对应 CLI;DSH 需本地 DSH Web Host 在跑(默认 `http://127.0.0.1:3080`),lark-remote 通过 HTTP+WebSocket 直连、不 spawn 本地子进程。
36
36
 
37
37
  ## 安装
38
38
 
@@ -64,7 +64,7 @@ feishu:
64
64
  appId: cli_xxx
65
65
  appSecret: xxx
66
66
 
67
- # 默认 agent:claude | codex | opencode | pi | kimi,默认 claude
67
+ # 默认 agent:claude | codex | opencode | pi | kimi | dsh,默认 claude
68
68
  defaultAgent: claude
69
69
 
70
70
  claude:
@@ -124,7 +124,7 @@ bridge 启动后不在终端输出,运行日志写入 `~/.lark-remote/logs/YYY
124
124
  | `/reconnect` | - | 重连飞书 |
125
125
  | `/restart` | - | 原地自重启 bridge(新进程同 config 接管) |
126
126
  | `/config get\|set` | `/c` | 查改运行时配置(agent-aware 卡片) |
127
- | `/order save\|list\|alias` | `/o` | 收藏常用指令;`/order alias` 注册快捷别名(输入 `$name` 展开) |
127
+ | `/order save\|list\|edit\|alias` | `/o` | 收藏常用指令;`/order edit` 修改指令文本(保留别名/使用统计);`/order alias` 注册快捷别名(输入 `$name` 展开) |
128
128
  | `!<cmd>` | - | 执行 bash 命令并流式输出到卡片(绕过串行队列) |
129
129
  | `/exit` | `/e` | 退出 bridge |
130
130
 
@@ -22,6 +22,8 @@ export interface TrackedApproval {
22
22
  answers?: Map<number, string[]>;
23
23
  /** AskUserQuestion 补充说明(Codex user_note):问题索引 → 文本。 */
24
24
  notes?: Map<number, string>;
25
+ /** 计划审批修改意见(ExitPlanMode):planFeedback 回流,供反馈类决策注入。 */
26
+ feedback?: string;
25
27
  }
26
28
  type ApprovalResponder = (requestId: number | string, response: unknown) => Promise<void>;
27
29
  export type { ApprovalAction } from '../runner/types.js';
@@ -115,6 +117,20 @@ export declare class ApprovalCoordinator {
115
117
  requestId: number | string;
116
118
  nonce?: string;
117
119
  }): Promise<void>;
120
+ /**
121
+ * 计划审批修改意见(ExitPlanMode):输入框提交 → coordinator 记录并回流
122
+ * 卡片回显,随后「拒绝并附意见」/「批准并采纳修改」按钮复用该文本。
123
+ * 同一请求可多次修改(新 nonce 来自新渲染)。
124
+ * 仅 kind === 'tool' 且 toolName === 'ExitPlanMode' 的计划审批接受反馈;
125
+ * 其余审批类型(command/file/permissions/question)没有反馈类决策,
126
+ * 存意见无意义(卡片侧也不渲染该输入框,此处兜底防异常卡片 payload)。
127
+ */
128
+ planFeedback(action: {
129
+ text: string;
130
+ }, ctx: {
131
+ requestId: number | string;
132
+ nonce?: string;
133
+ }): Promise<void>;
118
134
  /**
119
135
  * Update a pending approval's view (out-of-order item/started arrives late).
120
136
  * Only applies while the approval is still pending — resolved/expired
@@ -132,6 +148,8 @@ export declare class ApprovalCoordinator {
132
148
  private recordAnswer;
133
149
  private getPendingQuestion;
134
150
  private allQuestionsAnswered;
151
+ /** 推送审批卡视图更新(approval_view_updated 重渲染;失败仅记日志)。 */
152
+ private pushApprovalViewUpdate;
135
153
  /** 推送 question 卡片视图更新(选项勾选状态变化)。 */
136
154
  private pushQuestionViewUpdate;
137
155
  /** 全部问题已答:构建 answers 并提交(非空校验 + 原子状态迁移)。 */
@@ -97,12 +97,33 @@ export class ApprovalCoordinator {
97
97
  if (!tracked.view.availableDecisions.includes(decision) && !safetyOverride) {
98
98
  throw new Error(`Decision "${decision}" not in available decisions: ${tracked.view.availableDecisions.join(', ')}`);
99
99
  }
100
+ // 反馈类决策:注入用户已填写的修改意见(卡片输入框经 planFeedback 回流到
101
+ // coordinator)。decisionToApprovalAction 只带决策名,payload 在此补齐;
102
+ // 调用方直接传完整 payload 时以显式值为准。
103
+ let effectiveAction = action;
104
+ if (action.action === 'decline_with_feedback' || action.action === 'accept_with_feedback') {
105
+ const explicit = action.action === 'decline_with_feedback' ? action.message : action.plan;
106
+ const text = explicit.trim() || tracked.feedback?.trim() || '';
107
+ if (!text) {
108
+ throw new Error('请先填写修改意见');
109
+ }
110
+ effectiveAction =
111
+ action.action === 'decline_with_feedback'
112
+ ? { action: 'decline_with_feedback', message: text }
113
+ : {
114
+ action: 'accept_with_feedback',
115
+ // 批准并采纳修改:新计划 = 原计划 + 用户意见(无原计划时意见即计划)。
116
+ plan: tracked.view.plan?.trim()
117
+ ? `${tracked.view.plan.trim()}\n\n## 用户修改意见\n${text}`
118
+ : `# 用户修改意见\n\n${text}`,
119
+ };
120
+ }
100
121
  // Atomic state transition: pending → submitting(同步,无 await)
101
122
  tracked.state = 'submitting';
102
123
  this.clearTimer(tracked);
103
124
  // Send response via the responder
104
125
  try {
105
- await this.responder(ctx.requestId, action);
126
+ await this.responder(ctx.requestId, effectiveAction);
106
127
  tracked.state = 'resolved';
107
128
  // 提交成功后立即从卡片移除审批区(agent 无关):claude 控制协议没有
108
129
  // resolved 回发,不推就会「已点允许按钮仍在」残留 UI;codex 的 server
@@ -249,6 +270,34 @@ export class ApprovalCoordinator {
249
270
  q.note = text;
250
271
  this.pushQuestionViewUpdate(ctx.requestId, tracked);
251
272
  }
273
+ /**
274
+ * 计划审批修改意见(ExitPlanMode):输入框提交 → coordinator 记录并回流
275
+ * 卡片回显,随后「拒绝并附意见」/「批准并采纳修改」按钮复用该文本。
276
+ * 同一请求可多次修改(新 nonce 来自新渲染)。
277
+ * 仅 kind === 'tool' 且 toolName === 'ExitPlanMode' 的计划审批接受反馈;
278
+ * 其余审批类型(command/file/permissions/question)没有反馈类决策,
279
+ * 存意见无意义(卡片侧也不渲染该输入框,此处兜底防异常卡片 payload)。
280
+ */
281
+ async planFeedback(action, ctx) {
282
+ const tracked = this.approvals.get(ctx.requestId);
283
+ if (!tracked) {
284
+ throw new Error(`Approval request ${ctx.requestId} not found`);
285
+ }
286
+ if (tracked.state !== 'pending') {
287
+ throw new Error(`Approval request ${ctx.requestId} is no longer pending`);
288
+ }
289
+ if (tracked.kind !== 'tool' || tracked.view.toolName !== 'ExitPlanMode') {
290
+ throw new Error('仅计划审批(ExitPlanMode)支持修改意见');
291
+ }
292
+ const text = action.text.trim();
293
+ if (!text) {
294
+ throw new Error('请输入修改意见');
295
+ }
296
+ this.assertFreshNonce(ctx.requestId, ctx.nonce);
297
+ tracked.feedback = text;
298
+ tracked.view.planFeedback = text;
299
+ this.pushApprovalViewUpdate(ctx.requestId, tracked);
300
+ }
252
301
  /**
253
302
  * Update a pending approval's view (out-of-order item/started arrives late).
254
303
  * Only applies while the approval is still pending — resolved/expired
@@ -320,8 +369,8 @@ export class ApprovalCoordinator {
320
369
  }
321
370
  return true;
322
371
  }
323
- /** 推送 question 卡片视图更新(选项勾选状态变化)。 */
324
- pushQuestionViewUpdate(requestId, tracked) {
372
+ /** 推送审批卡视图更新(approval_view_updated 重渲染;失败仅记日志)。 */
373
+ pushApprovalViewUpdate(requestId, tracked) {
325
374
  void this.pushToCard([
326
375
  {
327
376
  type: 'approval_view_updated',
@@ -333,6 +382,10 @@ export class ApprovalCoordinator {
333
382
  getLogger().warn(`[approval-coordinator] question view update failed requestId=${requestId}: ${err instanceof Error ? err.message : String(err)}`);
334
383
  });
335
384
  }
385
+ /** 推送 question 卡片视图更新(选项勾选状态变化)。 */
386
+ pushQuestionViewUpdate(requestId, tracked) {
387
+ this.pushApprovalViewUpdate(requestId, tracked);
388
+ }
336
389
  /** 全部问题已答:构建 answers 并提交(非空校验 + 原子状态迁移)。 */
337
390
  async respondQuestionAnswers(tracked, ctx) {
338
391
  this.assertFreshNonce(ctx.requestId, ctx.nonce);
@@ -413,6 +466,10 @@ export class ApprovalCoordinator {
413
466
  return 'acceptAll';
414
467
  case 'answer':
415
468
  return 'answer';
469
+ case 'decline_with_feedback':
470
+ return 'declineWithFeedback';
471
+ case 'accept_with_feedback':
472
+ return 'acceptWithFeedback';
416
473
  case 'decline':
417
474
  return 'decline';
418
475
  case 'cancel':
@@ -431,6 +488,10 @@ export function decisionToApprovalAction(decision) {
431
488
  return { action: 'accept_with_execpolicy_amendment' };
432
489
  case 'acceptAll':
433
490
  return { action: 'accept_all' };
491
+ case 'declineWithFeedback':
492
+ return { action: 'decline_with_feedback', message: '' };
493
+ case 'acceptWithFeedback':
494
+ return { action: 'accept_with_feedback', plan: '' };
434
495
  case 'decline':
435
496
  return { action: 'decline' };
436
497
  case 'cancel':
@@ -121,6 +121,13 @@ export declare class Bridge {
121
121
  * blocked the queue forever.
122
122
  */
123
123
  private runners;
124
+ /**
125
+ * Runner 槽位在「活跃运行期间配置变更」时被标 stale(CC-06/P1):clearRunners() 对
126
+ * 活跃 workspace 只标 stale、不立即 evict(避免误杀长驻连接),当前 run 结束后由
127
+ * finalizeRun 安全 evict+dispose,下一轮 getRunner 创建新配置的 runner。
128
+ * key 为 `${cwd}\u0000${agentKind}`。
129
+ */
130
+ private staleRunnerSlots;
124
131
  /** Queue manager for per-workspace serial processing queue. */
125
132
  /** 普通消息队列管理器(public:测试注入 queue-card 状态用)。 */
126
133
  queueManager: QueueManager;
@@ -383,6 +390,16 @@ export declare class Bridge {
383
390
  decision: string;
384
391
  nonce: string;
385
392
  }): Promise<void>;
393
+ /**
394
+ * 计划审批修改意见(ExitPlanMode 输入框):记录到 coordinator 并回流卡片。
395
+ * 「拒绝并附意见」/「批准并采纳修改」按钮复用该文本。
396
+ */
397
+ handleApprovalPlanFeedback(opts: {
398
+ runId: string;
399
+ requestId: number | string;
400
+ text: string;
401
+ nonce: string;
402
+ }): Promise<void>;
386
403
  /**
387
404
  * Handle a permission toggle from a card button click.
388
405
  * Routes to the ApprovalCoordinator for the given run.
@@ -89,6 +89,13 @@ export class Bridge {
89
89
  * blocked the queue forever.
90
90
  */
91
91
  runners = new Map();
92
+ /**
93
+ * Runner 槽位在「活跃运行期间配置变更」时被标 stale(CC-06/P1):clearRunners() 对
94
+ * 活跃 workspace 只标 stale、不立即 evict(避免误杀长驻连接),当前 run 结束后由
95
+ * finalizeRun 安全 evict+dispose,下一轮 getRunner 创建新配置的 runner。
96
+ * key 为 `${cwd}\u0000${agentKind}`。
97
+ */
98
+ staleRunnerSlots = new Set();
92
99
  /** Queue manager for per-workspace serial processing queue. */
93
100
  /** 普通消息队列管理器(public:测试注入 queue-card 状态用)。 */
94
101
  queueManager;
@@ -259,10 +266,18 @@ export class Bridge {
259
266
  // keeping it is correct; finalizeRun deletes the cache entry when the run
260
267
  // ends, and the next run picks up the new config/agent via getRunner.
261
268
  for (const cwd of [...this.runners.keys()]) {
262
- if (this.activeRuns.has(cwd))
269
+ if (this.activeRuns.has(cwd)) {
270
+ // CC-06/P1: 活跃 workspace 不能立即 evict(会误杀长驻连接),但必须标记 stale,
271
+ // 否则 finalizeRun 对 workspace-lifetime runner 也不 evict → 下一轮复用旧配置实例。
272
+ // 这里只标待淘汰,当前 run 结束后由 finalizeRun 安全 evict。
273
+ for (const kind of [...this.runners.get(cwd).keys()]) {
274
+ this.staleRunnerSlots.add(`${cwd}\u0000${kind}`);
275
+ }
263
276
  continue;
277
+ }
264
278
  for (const kind of [...this.runners.get(cwd).keys()]) {
265
279
  this.evictRunnerSlot(cwd, kind);
280
+ this.staleRunnerSlots.delete(`${cwd}\u0000${kind}`);
266
281
  }
267
282
  }
268
283
  }
@@ -287,6 +302,7 @@ export class Bridge {
287
302
  void evicted.dispose();
288
303
  }
289
304
  workspaceMap.delete(kind);
305
+ this.staleRunnerSlots.delete(`${cwd}\u0000${kind}`);
290
306
  if (workspaceMap.size === 0) {
291
307
  this.runners.delete(cwd);
292
308
  }
@@ -1271,6 +1287,10 @@ export class Bridge {
1271
1287
  showToolUse: this.config.output.showToolUse,
1272
1288
  showToolResult: this.config.output.showToolResult,
1273
1289
  agentKind: activeRun.agentKind,
1290
+ // 与 createRunSession 的 renderOptions 一致:fallback 卡也要按
1291
+ // runner 能力门控 Compact 按钮(缺省 undefined = 显示按钮,
1292
+ // DSH 等无 runCompact 的 agent 会渲染出点了报错的死按钮)。
1293
+ compactSupported: this.hasRunCompact(cwd, activeRun.agentKind),
1274
1294
  }),
1275
1295
  }, ctx);
1276
1296
  runCardSent = sent;
@@ -1302,9 +1322,13 @@ export class Bridge {
1302
1322
  // evicted+disposed by clearRunners (/config) and interruptCurrentRun
1303
1323
  // (/stop). Spawn-per-message runners are evicted as before to prevent
1304
1324
  // stale process references accumulating.
1305
- if (activeRun.runner.lifetime !== 'workspace') {
1325
+ // CC-06/P1: 配置变更期间(clearRunners 标了 stale)的 workspace-lifetime
1326
+ // runner 也必须在 run 结束后 evict,否则下一轮复用旧配置实例。
1327
+ const slotKey = `${cwd}\u0000${activeRun.agentKind}`;
1328
+ if (activeRun.runner.lifetime !== 'workspace' || this.staleRunnerSlots.has(slotKey)) {
1306
1329
  this.evictRunnerSlot(cwd, activeRun.agentKind);
1307
1330
  }
1331
+ this.staleRunnerSlots.delete(slotKey);
1308
1332
  // Compact 卡片需要 runId + sessionId;record 供终态卡上的 Compact 按钮
1309
1333
  // 校验与取参(activeRuns 已清空,无法再查到)。异常退出(error/
1310
1334
  // interrupted/idle_timeout)同样记录——上下文往往更大,压缩后再续。
@@ -1447,6 +1471,15 @@ export class Bridge {
1447
1471
  await coordinator.submit(action, { requestId: opts.requestId, nonce: opts.nonce });
1448
1472
  });
1449
1473
  }
1474
+ /**
1475
+ * 计划审批修改意见(ExitPlanMode 输入框):记录到 coordinator 并回流卡片。
1476
+ * 「拒绝并附意见」/「批准并采纳修改」按钮复用该文本。
1477
+ */
1478
+ async handleApprovalPlanFeedback(opts) {
1479
+ return this.withCoordinator(opts.runId, async (coordinator) => {
1480
+ await coordinator.planFeedback({ text: opts.text }, { requestId: opts.requestId, nonce: opts.nonce });
1481
+ });
1482
+ }
1450
1483
  /**
1451
1484
  * Handle a permission toggle from a card button click.
1452
1485
  * Routes to the ApprovalCoordinator for the given run.
@@ -4,10 +4,14 @@
4
4
  * Renders command/file/permissions approval areas with button mappings per §8.3.
5
5
  * Uses CardKit 2.0 (no tag:"action" container — 200861 rule).
6
6
  */
7
+ import { collapsibleMarkdownPanel } from './collapsible.js';
8
+ import { truncateUtf8, truncateMarkdownTables } from './text-truncate.js';
7
9
  /** Unique nonce for card button callbacks (SDK dedup + router validation). */
8
10
  function mkNonce() {
9
11
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
10
12
  }
13
+ /** 计划面板正文字节预算(折叠展示 + 截断保护 28KB 卡片预算)。 */
14
+ const PLAN_PANEL_BYTES = 6000;
11
15
  /** reason div(3 处共用);反引号中和防 lark_md 解析错误(review P2-2)。 */
12
16
  function reasonDiv(approval) {
13
17
  if (!approval.reason)
@@ -71,20 +75,26 @@ function renderDecisionButtons(approval, expired, runId) {
71
75
  },
72
76
  ];
73
77
  }
78
+ // ExitPlanMode 计划审批用 TUI 语义文案;其他审批保持原文案。
79
+ const isPlanExit = approval.kind === 'tool' && approval.toolName === 'ExitPlanMode';
74
80
  const BUTTONS = {
75
- accept: { label: '✅ 允许', type: 'primary' },
81
+ accept: { label: isPlanExit ? '✅ 批准并执行' : '✅ 允许', type: 'primary' },
76
82
  acceptForSession: { label: '✅ 允许本次会话', type: 'primary' },
77
83
  acceptWithExecpolicyAmendment: { label: '🔒 允许并记住命令', type: 'primary' },
78
- acceptAll: { label: '✅ 允许所有', type: 'primary' },
84
+ acceptAll: { label: isPlanExit ? '✅ 批准并自动放行' : '✅ 允许所有', type: 'primary' },
85
+ acceptWithFeedback: { label: '✍️ 批准并采纳修改', type: 'primary' },
86
+ declineWithFeedback: { label: '✏️ 拒绝并附意见', type: 'default' },
79
87
  decline: { label: '❌ 拒绝', type: 'danger' },
80
88
  };
81
89
  // Fixed presentation order; only offer decisions the protocol listed
82
- // (accept/decline are always available; acceptAll is Claude 专属「允许所有」)。
90
+ // (accept/decline are always available; 其余按 availableDecisions 出现)。
83
91
  const offered = [
84
92
  'accept',
85
93
  'acceptForSession',
86
94
  'acceptWithExecpolicyAmendment',
87
95
  'acceptAll',
96
+ 'acceptWithFeedback',
97
+ 'declineWithFeedback',
88
98
  'decline',
89
99
  ].filter((d) => d === 'accept' || d === 'decline' || approval.availableDecisions.includes(d));
90
100
  // 决策按钮逐个作为 body 直接元素返回(CardKit body 纵向堆叠 = 上下排列)。
@@ -113,18 +123,21 @@ function renderDecisionButtons(approval, expired, runId) {
113
123
  // =============================================================================
114
124
  /**
115
125
  * 通用工具权限请求(kind === 'tool'):展示工具名 + 用途说明(reason)+ 原始
116
- * input(若有)。ExitPlanMode 的 input 为空对象,工具名本身即审批内容,
117
- * 不再落入 command 槽位显示无意义 `{}`。
126
+ * input(若有)。ExitPlanMode 走独立计划审批分支(计划全文 + 修改意见输入)。
118
127
  */
119
128
  function renderToolApproval(approval, expired, runId) {
129
+ // ExitPlanMode:对齐 TUI「先看完整计划再交互」——计划全文折叠面板 +
130
+ // 修改意见输入 + 五决策按钮(批准并执行 / 批准并自动放行 / 拒绝并附意见 /
131
+ // 批准并采纳修改 / 拒绝)。
132
+ if (approval.toolName === 'ExitPlanMode') {
133
+ return renderPlanApproval(approval, expired, runId);
134
+ }
120
135
  const elements = [];
121
- // ExitPlanMode 是「退出计划模式」:标题用计划审批语义;其他工具用通用标题。
122
- const isPlanExit = approval.toolName === 'ExitPlanMode';
123
136
  elements.push({
124
137
  tag: 'div',
125
138
  text: {
126
139
  tag: 'lark_md',
127
- content: isPlanExit ? '**📋 计划审批**' : '**🔧 工具请求**',
140
+ content: '**🔧 工具请求**',
128
141
  },
129
142
  });
130
143
  const toolLabel = approval.toolName ?? '(unknown tool)';
@@ -144,6 +157,90 @@ function renderToolApproval(approval, expired, runId) {
144
157
  elements.push(...renderDecisionButtons(approval, expired, runId));
145
158
  return elements;
146
159
  }
160
+ /**
161
+ * ExitPlanMode 计划审批区:
162
+ * - 计划全文(input.plan 或计划文件读取)折叠展示,反引号中和 + 表格限数 +
163
+ * 字节预算截断;
164
+ * - 计划文件路径(有则展示,含模型写错目录的退化场景);
165
+ * - 修改意见输入框(approval.planFeedback → coordinator 回流回显),供
166
+ * 「拒绝并附意见」/「批准并采纳修改」复用;
167
+ * - 决策按钮由 renderDecisionButtons 按 availableDecisions 渲染。
168
+ */
169
+ function renderPlanApproval(approval, expired, runId) {
170
+ const elements = [];
171
+ elements.push({
172
+ tag: 'div',
173
+ text: { tag: 'lark_md', content: '**📋 计划审批**' },
174
+ });
175
+ pushIf(elements, reasonDiv(approval));
176
+ if (approval.planFilePath) {
177
+ const neutralizedPath = approval.planFilePath.replace(/`/g, '·');
178
+ elements.push({
179
+ tag: 'div',
180
+ text: { tag: 'lark_md', content: `📄 ${neutralizedPath}` },
181
+ });
182
+ }
183
+ if (approval.plan) {
184
+ // 反引号中和防 11311(同 reasonDiv/command 策略);表格限数防 11310;
185
+ // 字节预算截断防 28KB 超限。
186
+ const neutralized = approval.plan.replace(/`/g, '·');
187
+ const tableSafe = truncateMarkdownTables(neutralized);
188
+ const content = truncateUtf8(tableSafe, PLAN_PANEL_BYTES);
189
+ elements.push(collapsibleMarkdownPanel({
190
+ title: '📋 计划',
191
+ expanded: false,
192
+ border: 'blue',
193
+ content,
194
+ }));
195
+ }
196
+ else {
197
+ elements.push({
198
+ tag: 'div',
199
+ text: {
200
+ tag: 'lark_md',
201
+ content: '📋 未获取到计划文件内容(可参考上方正文摘要)',
202
+ },
203
+ });
204
+ }
205
+ if (!expired) {
206
+ elements.push(renderPlanFeedbackInput(approval.requestId, runId));
207
+ if (approval.planFeedback) {
208
+ const neutralized = approval.planFeedback.replace(/`/g, '·');
209
+ elements.push({
210
+ tag: 'div',
211
+ text: { tag: 'lark_md', content: `📝 ${neutralized}` },
212
+ });
213
+ }
214
+ }
215
+ elements.push(...renderDecisionButtons(approval, expired, runId));
216
+ return elements;
217
+ }
218
+ /**
219
+ * 计划修改意见输入行:输入后点击右侧 ✓ 提交图标,input_value 经 connector
220
+ * raw 事件回传(CardKit input 的 input_value 会被 SDK normalizer 丢弃,
221
+ * 必须走 includeRawEvent 路径,同 answerNote)。
222
+ */
223
+ function renderPlanFeedbackInput(requestId, runId) {
224
+ return {
225
+ tag: 'input',
226
+ name: `plan-feedback-${requestId}`,
227
+ placeholder: {
228
+ tag: 'plain_text',
229
+ content: '填写修改意见(可配合「拒绝并附意见」/「批准并采纳修改」)…',
230
+ },
231
+ behaviors: [
232
+ {
233
+ type: 'callback',
234
+ value: {
235
+ cmd: 'approval.planFeedback',
236
+ requestId,
237
+ runId,
238
+ nonce: mkNonce(),
239
+ },
240
+ },
241
+ ],
242
+ };
243
+ }
147
244
  // =============================================================================
148
245
  // Command Approval
149
246
  // =============================================================================
package/dist/index.js CHANGED
@@ -241,7 +241,13 @@ function initializeRunner(config, configDir, cliArgs) {
241
241
  sessionReaderRegistry.register('kimi', kimiSessionReader);
242
242
  // Register DshRunner (HTTP-only DSH Web Host agent) and DshSessionReader.
243
243
  const dshSessionReader = new DshSessionReader({
244
- host: getAgentConfig(config, 'dsh')?.host,
244
+ // hostProvider 每次调用读最新 config:/config 修改 host 后 runner 重建为
245
+ // 新 host,reader(/resume、用量、完成卡)也跟随新 host(CC-02)。
246
+ hostProvider: () => {
247
+ const container = agentRegistry.getConfigContainer();
248
+ const latestConfig = container?.current ?? config;
249
+ return getAgentConfig(latestConfig, 'dsh')?.host;
250
+ },
245
251
  });
246
252
  agentRegistry.register('dsh', (_ws) => {
247
253
  // Read latest config from container so /config host changes take effect
@@ -526,19 +532,28 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
526
532
  // 直返列表外,过期/重复 nonce/非法选项等错误 toast 被静默吞掉)。
527
533
  // 审批响应尤其不能落串行队列:run 任务占用队列头直到 turn 结束,审批响应
528
534
  // 排在后面会形成死锁(run 不结束不执行、run 结束 coordinator 已删响应空转)。
535
+ // order.aliasInput / order.aliasRemove / order.textInput 也返回 toast+card,
536
+ // 需直返避免被吞(order.textInput 曾漏在列表外,编辑卡停留在编辑界面)。
537
+ // approval.planFeedback 同 answer 系列:附意见的 toast 需直返 + 不落串行队列。
529
538
  if (actionValue.cmd === 'queue.input' ||
539
+ actionValue.cmd === 'order.aliasInput' ||
540
+ actionValue.cmd === 'order.aliasRemove' ||
541
+ actionValue.cmd === 'order.textInput' ||
530
542
  actionValue.cmd === 'config.save' ||
531
543
  actionValue.cmd === 'approval.respond' ||
532
544
  actionValue.cmd === 'approval.toggle' ||
533
545
  actionValue.cmd === 'approval.answer' ||
534
546
  actionValue.cmd === 'approval.answerSubmit' ||
535
- actionValue.cmd === 'approval.answerCustom') {
547
+ actionValue.cmd === 'approval.answerCustom' ||
548
+ actionValue.cmd === 'approval.answerNote' ||
549
+ actionValue.cmd === 'approval.planFeedback') {
536
550
  return router.handleCardAction(fullValue, { userId, chatId, messageId });
537
551
  }
538
552
  // order.exec → equivalent queued message (Plan A): resolve the order text
539
553
  // at the enqueue boundary and route it through router.handle, exactly like
540
554
  // a hand-typed message. See order-exec-dispatch.ts for the contract and
541
555
  // why an internal key replaces the Feishu card messageId.
556
+ // (order.aliasEdit 是控制动作,走 enqueueImmediate 弹卡即可,不需直返。)
542
557
  if (actionValue.cmd === 'order.exec') {
543
558
  void dispatchOrderExecForQueue({
544
559
  router,
@@ -650,7 +665,6 @@ async function main() {
650
665
  configPath: path.join(configDir, 'config.yaml'),
651
666
  workspacePath: path.join(configDir, 'workspace.json'),
652
667
  ordersPath: path.join(configDir, 'orders.json'),
653
- aliasesPath: path.join(configDir, 'aliases.json'),
654
668
  restartSpawner: () => spawnReplacementBridge(path.join(configDir, 'logs')),
655
669
  sessionReaderRegistry,
656
670
  devMode: cliArgs.dev,
@@ -1,4 +1,8 @@
1
- import type { AliasEntry } from './alias-store.js';
1
+ /** 参与别名展开的条目:别名名 + 展开文本(即指令文本)。 */
2
+ export interface AliasResolveEntry {
3
+ name: string;
4
+ text: string;
5
+ }
2
6
  /**
3
7
  * 一次别名展开。
4
8
  *
@@ -10,4 +14,4 @@ import type { AliasEntry } from './alias-store.js';
10
14
  *
11
15
  * @returns 展开后的完整消息;无匹配时返回 undefined。
12
16
  */
13
- export declare function resolveAlias(message: string, aliases: readonly AliasEntry[]): string | undefined;
17
+ export declare function resolveAlias(message: string, aliases: readonly AliasResolveEntry[]): string | undefined;
@@ -1,8 +1,10 @@
1
- interface OrderEntry {
1
+ export interface OrderEntry {
2
2
  id: string;
3
3
  text: string;
4
4
  createdAt: string;
5
5
  usedAt?: string;
6
+ /** 可选别名名(该指令的 $name 触发词)。全局唯一,由 OrderStore 校验。 */
7
+ alias?: string;
6
8
  }
7
9
  /**
8
10
  * Global persistent order store.
@@ -20,8 +22,21 @@ export declare class OrderStore {
20
22
  private persist;
21
23
  save(text: string): OrderEntry;
22
24
  updateUsedAt(id: string): void;
25
+ /**
26
+ * 修改指令文本。保留 usedAt / alias / createdAt(编辑不应重置使用统计或别名)。
27
+ * newText===oldText 短路 persist,避免空写盘。长度 > MAX_TEXT_LENGTH 抛错(与 save 一致)。
28
+ * 内部统一 trim:卡片(handleOrderTextInput)与 CLI(/order edit)都直接传原始文本,
29
+ * 避免双 trim 语义混淆、也避免两条入口存出不同结果。纯空白(trim 后空)抛错。
30
+ * @returns 更新后的 entry;id 不存在返回 undefined。
31
+ */
32
+ updateText(id: string, newText: string): OrderEntry | undefined;
33
+ /**
34
+ * 给指令绑定别名(或解绑)。别名全局唯一,撞名抛错;名称必须通过
35
+ * `ALIAS_NAME_PATTERN`(字母/数字/下划线、不能数字开头、≤20 字符)。
36
+ * @returns 绑定后的条目。
37
+ */
38
+ setAlias(id: string, name: string | undefined): OrderEntry | undefined;
23
39
  get(): OrderEntry[];
24
40
  has(id: string): boolean;
25
41
  remove(id: string): void;
26
42
  }
27
- export {};