lark-remote 0.1.9 → 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,26 @@ 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
+
8
28
  ## [0.1.9] - 2026-08-20
9
29
 
10
30
  ### 新增
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
 
@@ -121,6 +121,9 @@ export declare class ApprovalCoordinator {
121
121
  * 计划审批修改意见(ExitPlanMode):输入框提交 → coordinator 记录并回流
122
122
  * 卡片回显,随后「拒绝并附意见」/「批准并采纳修改」按钮复用该文本。
123
123
  * 同一请求可多次修改(新 nonce 来自新渲染)。
124
+ * 仅 kind === 'tool' 且 toolName === 'ExitPlanMode' 的计划审批接受反馈;
125
+ * 其余审批类型(command/file/permissions/question)没有反馈类决策,
126
+ * 存意见无意义(卡片侧也不渲染该输入框,此处兜底防异常卡片 payload)。
124
127
  */
125
128
  planFeedback(action: {
126
129
  text: string;
@@ -274,6 +274,9 @@ export class ApprovalCoordinator {
274
274
  * 计划审批修改意见(ExitPlanMode):输入框提交 → coordinator 记录并回流
275
275
  * 卡片回显,随后「拒绝并附意见」/「批准并采纳修改」按钮复用该文本。
276
276
  * 同一请求可多次修改(新 nonce 来自新渲染)。
277
+ * 仅 kind === 'tool' 且 toolName === 'ExitPlanMode' 的计划审批接受反馈;
278
+ * 其余审批类型(command/file/permissions/question)没有反馈类决策,
279
+ * 存意见无意义(卡片侧也不渲染该输入框,此处兜底防异常卡片 payload)。
277
280
  */
278
281
  async planFeedback(action, ctx) {
279
282
  const tracked = this.approvals.get(ctx.requestId);
@@ -283,6 +286,9 @@ export class ApprovalCoordinator {
283
286
  if (tracked.state !== 'pending') {
284
287
  throw new Error(`Approval request ${ctx.requestId} is no longer pending`);
285
288
  }
289
+ if (tracked.kind !== 'tool' || tracked.view.toolName !== 'ExitPlanMode') {
290
+ throw new Error('仅计划审批(ExitPlanMode)支持修改意见');
291
+ }
286
292
  const text = action.text.trim();
287
293
  if (!text) {
288
294
  throw new Error('请输入修改意见');
@@ -1287,6 +1287,10 @@ export class Bridge {
1287
1287
  showToolUse: this.config.output.showToolUse,
1288
1288
  showToolResult: this.config.output.showToolResult,
1289
1289
  agentKind: activeRun.agentKind,
1290
+ // 与 createRunSession 的 renderOptions 一致:fallback 卡也要按
1291
+ // runner 能力门控 Compact 按钮(缺省 undefined = 显示按钮,
1292
+ // DSH 等无 runCompact 的 agent 会渲染出点了报错的死按钮)。
1293
+ compactSupported: this.hasRunCompact(cwd, activeRun.agentKind),
1290
1294
  }),
1291
1295
  }, ctx);
1292
1296
  runCardSent = sent;
package/dist/index.js CHANGED
@@ -532,17 +532,21 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
532
532
  // 直返列表外,过期/重复 nonce/非法选项等错误 toast 被静默吞掉)。
533
533
  // 审批响应尤其不能落串行队列:run 任务占用队列头直到 turn 结束,审批响应
534
534
  // 排在后面会形成死锁(run 不结束不执行、run 结束 coordinator 已删响应空转)。
535
- // order.aliasInput / order.aliasRemove 也返回 toast,需直返避免被吞。
535
+ // order.aliasInput / order.aliasRemove / order.textInput 也返回 toast+card,
536
+ // 需直返避免被吞(order.textInput 曾漏在列表外,编辑卡停留在编辑界面)。
537
+ // approval.planFeedback 同 answer 系列:附意见的 toast 需直返 + 不落串行队列。
536
538
  if (actionValue.cmd === 'queue.input' ||
537
539
  actionValue.cmd === 'order.aliasInput' ||
538
540
  actionValue.cmd === 'order.aliasRemove' ||
541
+ actionValue.cmd === 'order.textInput' ||
539
542
  actionValue.cmd === 'config.save' ||
540
543
  actionValue.cmd === 'approval.respond' ||
541
544
  actionValue.cmd === 'approval.toggle' ||
542
545
  actionValue.cmd === 'approval.answer' ||
543
546
  actionValue.cmd === 'approval.answerSubmit' ||
544
547
  actionValue.cmd === 'approval.answerCustom' ||
545
- actionValue.cmd === 'approval.answerNote') {
548
+ actionValue.cmd === 'approval.answerNote' ||
549
+ actionValue.cmd === 'approval.planFeedback') {
546
550
  return router.handleCardAction(fullValue, { userId, chatId, messageId });
547
551
  }
548
552
  // order.exec → equivalent queued message (Plan A): resolve the order text
@@ -22,6 +22,14 @@ export declare class OrderStore {
22
22
  private persist;
23
23
  save(text: string): OrderEntry;
24
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;
25
33
  /**
26
34
  * 给指令绑定别名(或解绑)。别名全局唯一,撞名抛错;名称必须通过
27
35
  * `ALIAS_NAME_PATTERN`(字母/数字/下划线、不能数字开头、≤20 字符)。
@@ -84,6 +84,30 @@ export class OrderStore {
84
84
  this.persist();
85
85
  }
86
86
  }
87
+ /**
88
+ * 修改指令文本。保留 usedAt / alias / createdAt(编辑不应重置使用统计或别名)。
89
+ * newText===oldText 短路 persist,避免空写盘。长度 > MAX_TEXT_LENGTH 抛错(与 save 一致)。
90
+ * 内部统一 trim:卡片(handleOrderTextInput)与 CLI(/order edit)都直接传原始文本,
91
+ * 避免双 trim 语义混淆、也避免两条入口存出不同结果。纯空白(trim 后空)抛错。
92
+ * @returns 更新后的 entry;id 不存在返回 undefined。
93
+ */
94
+ updateText(id, newText) {
95
+ const trimmed = newText.trim();
96
+ if (trimmed === '') {
97
+ throw new Error('指令文本不能为空');
98
+ }
99
+ if (trimmed.length > MAX_TEXT_LENGTH) {
100
+ throw new Error(`指令文本超过 ${MAX_TEXT_LENGTH} 字符限制`);
101
+ }
102
+ const entry = this.data.find((e) => e.id === id);
103
+ if (!entry)
104
+ return undefined;
105
+ if (entry.text === trimmed)
106
+ return entry; // no-op: 短路 persist
107
+ entry.text = trimmed;
108
+ this.persist();
109
+ return entry;
110
+ }
87
111
  /**
88
112
  * 给指令绑定别名(或解绑)。别名全局唯一,撞名抛错;名称必须通过
89
113
  * `ALIAS_NAME_PATTERN`(字母/数字/下划线、不能数字开头、≤20 字符)。
@@ -308,6 +308,22 @@ export declare class CommandRouter {
308
308
  private handleOrderAliasInput;
309
309
  /** Handle order.aliasRemove: 删除某条指令的别名并原地重渲染列表卡。 */
310
310
  private handleOrderAliasRemove;
311
+ /**
312
+ * Handle order.textEdit: 展示指令文本编辑卡(input + ✓ 提交图标)。
313
+ * 预填当前 text(完整内容,不截断——input 组件有自有滚动条);
314
+ * 提交 `order.textInput` 处理(trim + 长度校验 + OrderStore.updateText)。
315
+ * 完全镜像 handleOrderAliasEdit 的 4 段结构(reload → find → 构造 editCard →
316
+ * updateCardInPlace)。
317
+ */
318
+ private handleOrderTextEdit;
319
+ /**
320
+ * Handle order.textInput: 提交指令文本编辑。校验 orderId → OrderStore.updateText
321
+ * (store 内部统一 trim + 空/超长校验)。成功 toast + 原地重渲染列表卡(同
322
+ * handleOrderAliasInput 语义:toast-only 响应会让飞书停留在 pre-click 编辑卡,
323
+ * 必须把 card 放进 callback 响应体);失败仅 error toast。
324
+ * 保留 usedAt / alias / createdAt(OrderStore.updateText 只动 text 字段)。
325
+ */
326
+ private handleOrderTextInput;
311
327
  /**
312
328
  * Handle ws.use card action: switch to the workspace, then refresh the /ws
313
329
  * list card in place so "recent" sort order is immediately visible.
@@ -115,6 +115,8 @@ export function isImmediateAction(cmd) {
115
115
  cmd === 'order.aliasEdit' ||
116
116
  cmd === 'order.aliasInput' ||
117
117
  cmd === 'order.aliasRemove' ||
118
+ cmd === 'order.textEdit' ||
119
+ cmd === 'order.textInput' ||
118
120
  cmd === 'config.toggle' ||
119
121
  cmd === 'config.set' ||
120
122
  cmd === 'config.input' ||
@@ -367,6 +369,10 @@ export class CommandRouter {
367
369
  return await this.handleOrderAliasInput(value, ctx);
368
370
  case 'order.aliasRemove':
369
371
  return await this.handleOrderAliasRemove(value, ctx);
372
+ case 'order.textEdit':
373
+ return await this.handleOrderTextEdit(value, ctx);
374
+ case 'order.textInput':
375
+ return await this.handleOrderTextInput(value, ctx);
370
376
  case 'config.toggle':
371
377
  case 'config.set':
372
378
  case 'config.input':
@@ -1321,6 +1327,122 @@ export class CommandRouter {
1321
1327
  card: { type: 'raw', data: result.card },
1322
1328
  };
1323
1329
  }
1330
+ /**
1331
+ * Handle order.textEdit: 展示指令文本编辑卡(input + ✓ 提交图标)。
1332
+ * 预填当前 text(完整内容,不截断——input 组件有自有滚动条);
1333
+ * 提交 `order.textInput` 处理(trim + 长度校验 + OrderStore.updateText)。
1334
+ * 完全镜像 handleOrderAliasEdit 的 4 段结构(reload → find → 构造 editCard →
1335
+ * updateCardInPlace)。
1336
+ */
1337
+ async handleOrderTextEdit(value, ctx) {
1338
+ const orderId = value.orderId;
1339
+ const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1340
+ if (!orderId) {
1341
+ return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1342
+ }
1343
+ this.orderStore.reload();
1344
+ const order = this.orderStore.get().find((o) => o.id === orderId);
1345
+ if (!order) {
1346
+ return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1347
+ }
1348
+ // 列表卡显示的是截断版(≤100 字符 + ...),编辑卡预览也保持一致;
1349
+ // input 组件 default_value 预填完整 text(不受截断影响),用户提交后生效完整文本。
1350
+ const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
1351
+ // CardKit 2.0 input 自带 ✓ 提交图标(input_value 经 raw 回传,红线)。
1352
+ // 不用 form 容器(触发 300123 无 submit button / 200621 嵌套 column)。
1353
+ const editCard = {
1354
+ schema: '2.0',
1355
+ config: { wide_screen_mode: true },
1356
+ header: {
1357
+ template: 'blue',
1358
+ title: { tag: 'plain_text', content: '✏️ 编辑指令' },
1359
+ },
1360
+ body: {
1361
+ elements: [
1362
+ {
1363
+ tag: 'div',
1364
+ text: { tag: 'lark_md', content: `**当前指令:**\n\`${displayText}\`` },
1365
+ },
1366
+ { tag: 'hr' },
1367
+ {
1368
+ tag: 'div',
1369
+ text: {
1370
+ tag: 'lark_md',
1371
+ content: '💡 修改后点击右侧 ✓ 提交;空白提交 = 报错;最多 200 字符',
1372
+ },
1373
+ },
1374
+ {
1375
+ tag: 'column_set',
1376
+ columns: [
1377
+ {
1378
+ tag: 'column',
1379
+ width: 'weighted',
1380
+ weight: 3,
1381
+ elements: [
1382
+ {
1383
+ tag: 'input',
1384
+ name: 'text',
1385
+ placeholder: { tag: 'plain_text', content: '输入新的指令文本...' },
1386
+ default_value: order.text,
1387
+ // max_length 与 MAX_TEXT_LENGTH=200 对齐:超长在输入侧直接截断,
1388
+ // 避免提交后才报错(后端 updateText 仍会二次校验,双保险)。
1389
+ max_length: 200,
1390
+ behaviors: [
1391
+ {
1392
+ type: 'callback',
1393
+ value: { cmd: 'order.textInput', orderId, offset },
1394
+ },
1395
+ ],
1396
+ },
1397
+ ],
1398
+ },
1399
+ ],
1400
+ },
1401
+ ],
1402
+ },
1403
+ };
1404
+ await this.bridge.updateCardInPlace(editCard, ctx);
1405
+ }
1406
+ /**
1407
+ * Handle order.textInput: 提交指令文本编辑。校验 orderId → OrderStore.updateText
1408
+ * (store 内部统一 trim + 空/超长校验)。成功 toast + 原地重渲染列表卡(同
1409
+ * handleOrderAliasInput 语义:toast-only 响应会让飞书停留在 pre-click 编辑卡,
1410
+ * 必须把 card 放进 callback 响应体);失败仅 error toast。
1411
+ * 保留 usedAt / alias / createdAt(OrderStore.updateText 只动 text 字段)。
1412
+ */
1413
+ async handleOrderTextInput(value, ctx) {
1414
+ const orderId = value.orderId;
1415
+ const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1416
+ const raw = value.inputValue ?? value.formValue?.['text'];
1417
+ if (!orderId) {
1418
+ return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1419
+ }
1420
+ this.orderStore.reload();
1421
+ const order = this.orderStore.get().find((o) => o.id === orderId);
1422
+ if (!order) {
1423
+ return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1424
+ }
1425
+ let updated;
1426
+ try {
1427
+ updated = this.orderStore.updateText(order.id, raw ?? '');
1428
+ }
1429
+ catch (err) {
1430
+ return { toast: { type: 'error', content: err.message } };
1431
+ }
1432
+ if (!updated) {
1433
+ // reload + find 后 updateText 不应返回 undefined;防御性兜底(如并发删除)。
1434
+ return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1435
+ }
1436
+ // 成功:重渲染列表卡(保持页码)。必须把 card 放进 callback 响应体让飞书
1437
+ // 原地替换 pre-click 编辑卡——toast-only 响应会停留在编辑界面(飞书保留
1438
+ // 点击前那张卡),见 handleQueueInput / handleOrderAliasInput 同款注释。
1439
+ const result = this.cmdOrder([], ctx, offset);
1440
+ const preview = updated.text.length > 50 ? updated.text.slice(0, 50) + '...' : updated.text;
1441
+ return {
1442
+ toast: { type: 'success', content: `✅ 已更新指令: ${preview}` },
1443
+ card: { type: 'raw', data: result.card },
1444
+ };
1445
+ }
1324
1446
  /**
1325
1447
  * Handle ws.use card action: switch to the workspace, then refresh the /ws
1326
1448
  * list card in place so "recent" sort order is immediately visible.
@@ -1897,8 +2019,8 @@ export class CommandRouter {
1897
2019
  { cmd: 'resume', label: '/resume /r [agent] [list|id|N]', desc: '列出或切换 Agent session' },
1898
2020
  {
1899
2021
  cmd: 'order',
1900
- label: '/order /o save|list',
1901
- desc: '收藏指令 /order save,指令可起别名(卡片上 +别名)',
2022
+ label: '/order /o save|list|edit',
2023
+ desc: '收藏指令 /order save,编辑指令 /order edit <id|N> <新文本>,指令可起别名(卡片上 +别名)',
1902
2024
  },
1903
2025
  ];
1904
2026
  const bodyElements = [];
@@ -3550,6 +3672,31 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3550
3672
  },
3551
3673
  ],
3552
3674
  },
3675
+ {
3676
+ // 编辑:弹蓝色 header 输入卡(CardKit 2.0 input + ✓ 提交),
3677
+ // 镜像 order.aliasEdit 流程。order.textEdit 是 control-only action
3678
+ // (isImmediateAction 注册),不 spawn agent。
3679
+ tag: 'column',
3680
+ width: 'auto',
3681
+ elements: [
3682
+ {
3683
+ tag: 'button',
3684
+ text: { tag: 'plain_text', content: '编辑' },
3685
+ type: 'default',
3686
+ size: 'small',
3687
+ behaviors: [
3688
+ {
3689
+ type: 'callback',
3690
+ value: {
3691
+ cmd: 'order.textEdit',
3692
+ orderId: order.id,
3693
+ offset: safeOffset,
3694
+ },
3695
+ },
3696
+ ],
3697
+ },
3698
+ ],
3699
+ },
3553
3700
  {
3554
3701
  tag: 'column',
3555
3702
  width: 'auto',
@@ -3648,8 +3795,30 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3648
3795
  return { text: `保存失败: ${err.message}` };
3649
3796
  }
3650
3797
  }
3798
+ // /order edit <orderId|序号> <新文本> — CLI 备选路径(卡片是主入口)。
3799
+ // 与 /order save / alias add|rm 对称;复用 resolveOrderTarget。
3800
+ // 与卡片路径(handleOrderTextInput)对齐 trim 语义,避免「卡片 trim、CLI 不 trim」
3801
+ // 两条入口对同一文本存出不同结果。
3802
+ if (sub === 'edit') {
3803
+ const target = args[1];
3804
+ const newText = args.slice(2).join(' ').trim();
3805
+ if (!target || !newText) {
3806
+ return { text: '用法: /order edit <orderId|序号> <新文本>' };
3807
+ }
3808
+ const order = this.resolveOrderTarget(target);
3809
+ if (!order)
3810
+ return { text: `指令不存在: ${target}` };
3811
+ try {
3812
+ this.orderStore.updateText(order.id, newText);
3813
+ const preview = newText.length > 50 ? newText.slice(0, 50) + '...' : newText;
3814
+ return { text: `✅ 已更新指令: ${preview}` };
3815
+ }
3816
+ catch (err) {
3817
+ return { text: `保存失败: ${err.message}` };
3818
+ }
3819
+ }
3651
3820
  return {
3652
- text: '用法: /order [list|save <text>|alias add <orderId|序号> <别名名>|alias rm <orderId|序号>]',
3821
+ text: '用法: /order [list|save <text>|edit <orderId|序号> <新文本>|alias add <orderId|序号> <别名名>|alias rm <orderId|序号>]',
3653
3822
  };
3654
3823
  }
3655
3824
  /** /order alias add:按 orderId 或列表序号(1 基)解析目标指令并绑定别名。 */
@@ -312,6 +312,10 @@ export class ClaudeSession extends SpawningRunner {
312
312
  this.cwd = opts.cwd;
313
313
  this.autoApprove = false;
314
314
  this.pendingToolInputs.clear();
315
+ // 每次 run 独立:上次 run 会话内跟踪到的计划文件不得泄漏到本次
316
+ // (否则本次 ExitPlanMode 无 plan/planFilePath 时 resolvePlanContent
317
+ // 会误读上一次的计划文件当当前计划)。
318
+ this.planFilePath = '';
315
319
  let proc;
316
320
  try {
317
321
  proc = await this.spawnChild(opts);
@@ -44,6 +44,13 @@ export declare class DshRunner implements AgentRunner {
44
44
  private activeAbort;
45
45
  private activeSessionId;
46
46
  private running;
47
+ /**
48
+ * 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
49
+ * (session.selectModel 会写服务端全局默认,每次 run 都写是写放大)。
50
+ * configuredModel 在实例生命周期内不变(/config 改模型会 clearRunners
51
+ * 重建实例 + 换新 sessionId),故「每 session 对齐一次」足以覆盖配置变化。
52
+ */
53
+ private readonly alignedSessions;
47
54
  constructor(opts: DshRunnerOptions);
48
55
  get isRunning(): boolean;
49
56
  getStatusInfo(): AgentStatusInfo;
@@ -28,6 +28,13 @@ export class DshRunner {
28
28
  activeAbort = null;
29
29
  activeSessionId = null;
30
30
  running = false;
31
+ /**
32
+ * 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
33
+ * (session.selectModel 会写服务端全局默认,每次 run 都写是写放大)。
34
+ * configuredModel 在实例生命周期内不变(/config 改模型会 clearRunners
35
+ * 重建实例 + 换新 sessionId),故「每 session 对齐一次」足以覆盖配置变化。
36
+ */
37
+ alignedSessions = new Set();
31
38
  constructor(opts) {
32
39
  this.sessionReader = opts.sessionReader;
33
40
  this.configuredModel = opts.model;
@@ -70,9 +77,32 @@ export class DshRunner {
70
77
  sessionId = created.sessionId;
71
78
  }
72
79
  this.activeSessionId = sessionId;
73
- // 模型/推理强度变更 = 保留 session,每次 run 前对齐(§4.3):
74
- // 该调用同时会写服务端全局默认;失败仅告警不阻断 run(模型校验由 DSH 负责)。
75
- if (this.configuredModel) {
80
+ // Startup abort:createSession 挂起期间 stop() 已 abort(此时 sessionId 尚
81
+ // 未知,stop() 无法 cancel)。createSession 返回后立即检查——已 abort 则
82
+ // cancel 这个刚建的 session 并中断,不让启动流程继续(selectModel/prompt)。
83
+ // 必须 yield interrupted result(不能裸 return):调用方依赖 result 收尾
84
+ // run 卡,否则 for-await 自然结束后无终态、卡片停留「运行中」。
85
+ if (abort.signal.aborted) {
86
+ try {
87
+ await this.client.unary('session.cancel', { sessionId });
88
+ }
89
+ catch {
90
+ /* 取消失败仅告警,session 已 abort 无需继续 */
91
+ }
92
+ getLogger().info(`[dsh] run aborted during startup, cancelled session ${sessionId}`);
93
+ terminalEmitted = true;
94
+ yield {
95
+ type: 'result',
96
+ subtype: 'interrupted',
97
+ session_id: sessionId,
98
+ errorMessage: 'run stopped',
99
+ };
100
+ return;
101
+ }
102
+ // 模型/推理强度对齐(§4.3):会话首次 run 时 selectModel 一次
103
+ // (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
104
+ // 跳过。失败仅告警不阻断 run(模型校验由 DSH 负责)。
105
+ if (this.configuredModel && !this.alignedSessions.has(sessionId)) {
76
106
  try {
77
107
  await this.client.selectModel({
78
108
  sessionId,
@@ -80,6 +110,7 @@ export class DshRunner {
80
110
  model: this.configuredModel,
81
111
  ...(this.reasoningEffort ? { reasoningEffort: this.reasoningEffort } : {}),
82
112
  });
113
+ this.alignedSessions.add(sessionId);
83
114
  }
84
115
  catch (err) {
85
116
  getLogger().warn(`[dsh] selectModel failed for session ${sessionId} (run continues): ${err.message}`);
@@ -5,8 +5,9 @@ import type { AgentKind, Runner } from './index.js';
5
5
  * up `config.defaultAgent` here to pick the concrete runner.
6
6
  *
7
7
  * Singleton caching: the factory MAY cache its instance internally. Most
8
- * current agents (claude, opencode, pi, kimi) are spawn-per-message; codex is
9
- * workspace-lifetime (app-server). Each factory returns a runner per call.
8
+ * current spawn-type agents (claude, opencode, pi, kimi) are spawn-per-message; codex is
9
+ * workspace-lifetime (app-server); dsh is HTTP+WS direct connection (no local
10
+ * subprocess). Each factory returns a runner per call.
10
11
  *
11
12
  * Dynamic config reload: factory can read latest config via registry's
12
13
  * configContainer, enabling runtime config changes to take effect.
@@ -9,8 +9,9 @@ let globalRegistry;
9
9
  * up `config.defaultAgent` here to pick the concrete runner.
10
10
  *
11
11
  * Singleton caching: the factory MAY cache its instance internally. Most
12
- * current agents (claude, opencode, pi, kimi) are spawn-per-message; codex is
13
- * workspace-lifetime (app-server). Each factory returns a runner per call.
12
+ * current spawn-type agents (claude, opencode, pi, kimi) are spawn-per-message; codex is
13
+ * workspace-lifetime (app-server); dsh is HTTP+WS direct connection (no local
14
+ * subprocess). Each factory returns a runner per call.
14
15
  *
15
16
  * Dynamic config reload: factory can read latest config via registry's
16
17
  * configContainer, enabling runtime config changes to take effect.
@@ -337,7 +337,7 @@ export interface SpawnOptions {
337
337
  settings?: string;
338
338
  }
339
339
  /**
340
- * Seam (§3): the contract any agent runner (claude/codex/opencode/pi/kimi)
340
+ * Seam (§3): the contract any agent runner (claude/codex/opencode/pi/kimi/dsh)
341
341
  * must satisfy for the router/bridge.
342
342
  * Declared here so callers depend on the interface, not the concrete
343
343
  * runner classes — test stubs satisfy this structurally.
@@ -474,7 +474,7 @@ export interface AgentRunner extends Runner {
474
474
  }
475
475
  /** Agent self-describing status info for /status display. */
476
476
  export interface AgentStatusInfo {
477
- /** Agent kind (claude/codex/opencode/pi/kimi). */
477
+ /** Agent kind (claude/codex/opencode/pi/kimi/dsh). */
478
478
  kind: AgentKind;
479
479
  /** Display model name. */
480
480
  model: string;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared pagination / truncation helpers for agent session readers.
3
3
  *
4
- * The five readers (claude/codex/opencode/pi/kimi) all implement the same
4
+ * The six readers (claude/codex/opencode/pi/kimi/dsh) all implement the same
5
5
  * offset-clamp + slice + total pattern for `listSessions`, and the same
6
6
  * maxEvents tail-cap for `readSessionContent`. These were duplicated (and had
7
7
  * drifted on the default limit). Centralized here so the contract is defined
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared pagination / truncation helpers for agent session readers.
3
3
  *
4
- * The five readers (claude/codex/opencode/pi/kimi) all implement the same
4
+ * The six readers (claude/codex/opencode/pi/kimi/dsh) all implement the same
5
5
  * offset-clamp + slice + total pattern for `listSessions`, and the same
6
6
  * maxEvents tail-cap for `readSessionContent`. These were duplicated (and had
7
7
  * drifted on the default limit). Centralized here so the contract is defined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",