lark-remote 0.1.7 → 0.1.9

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 (75) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/bridge/approval-coordinator.d.ts +30 -1
  3. package/dist/bridge/approval-coordinator.js +93 -5
  4. package/dist/bridge/index.d.ts +36 -6
  5. package/dist/bridge/index.js +56 -10
  6. package/dist/card/approval-render.js +287 -48
  7. package/dist/card/card-shared.d.ts +6 -4
  8. package/dist/card/card-shared.js +6 -4
  9. package/dist/card/run-renderer.d.ts +7 -0
  10. package/dist/card/run-renderer.js +22 -11
  11. package/dist/config/index.d.ts +24 -1
  12. package/dist/config/index.js +34 -3
  13. package/dist/index.js +40 -16
  14. package/dist/order/alias-resolve.d.ts +6 -2
  15. package/dist/order/index.d.ts +9 -2
  16. package/dist/order/index.js +33 -0
  17. package/dist/router/card-action-payload.d.ts +30 -0
  18. package/dist/router/card-action-payload.js +39 -0
  19. package/dist/router/config/claude.js +8 -3
  20. package/dist/router/config/dsh.d.ts +51 -0
  21. package/dist/router/config/dsh.js +179 -0
  22. package/dist/router/config/index.d.ts +1 -1
  23. package/dist/router/config/index.js +4 -1
  24. package/dist/router/config/types.d.ts +6 -0
  25. package/dist/router/index.d.ts +39 -4
  26. package/dist/router/index.js +471 -90
  27. package/dist/router/utils.js +12 -2
  28. package/dist/runner/agent-choices-common.js +3 -0
  29. package/dist/runner/claude/runner.js +22 -0
  30. package/dist/runner/claude/session.d.ts +26 -0
  31. package/dist/runner/claude/session.js +105 -9
  32. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -0
  33. package/dist/runner/codex/app-server/protocol-types.js +1 -1
  34. package/dist/runner/codex/app-server/runner.d.ts +10 -0
  35. package/dist/runner/codex/app-server/runner.js +56 -1
  36. package/dist/runner/codex/app-server/translator.d.ts +7 -0
  37. package/dist/runner/codex/app-server/translator.js +45 -0
  38. package/dist/runner/common/acp/protocol-types.d.ts +33 -0
  39. package/dist/runner/common/acp/protocol-types.js +2 -0
  40. package/dist/runner/common/connection-based-runner.js +10 -5
  41. package/dist/runner/dsh/client.d.ts +95 -0
  42. package/dist/runner/dsh/client.js +418 -0
  43. package/dist/runner/dsh/fake-dsh-server.d.ts +60 -0
  44. package/dist/runner/dsh/fake-dsh-server.js +156 -0
  45. package/dist/runner/dsh/index.d.ts +3 -0
  46. package/dist/runner/dsh/index.js +4 -0
  47. package/dist/runner/dsh/runner.d.ts +57 -0
  48. package/dist/runner/dsh/runner.js +211 -0
  49. package/dist/runner/dsh/translator.d.ts +28 -0
  50. package/dist/runner/dsh/translator.js +98 -0
  51. package/dist/runner/dsh/types.d.ts +155 -0
  52. package/dist/runner/dsh/types.js +14 -0
  53. package/dist/runner/index.d.ts +1 -0
  54. package/dist/runner/index.js +1 -0
  55. package/dist/runner/kimi/acp/runner.js +84 -1
  56. package/dist/runner/kimi/acp/translator.d.ts +14 -0
  57. package/dist/runner/kimi/acp/translator.js +108 -3
  58. package/dist/runner/pi/rpc/runner.d.ts +1 -7
  59. package/dist/runner/pi/rpc/runner.js +8 -12
  60. package/dist/runner/probe.js +6 -1
  61. package/dist/runner/question-common.d.ts +26 -0
  62. package/dist/runner/question-common.js +42 -0
  63. package/dist/runner/types.d.ts +94 -10
  64. package/dist/session/claude/sessions.js +37 -15
  65. package/dist/session/dsh/index.d.ts +1 -0
  66. package/dist/session/dsh/index.js +2 -0
  67. package/dist/session/dsh/sessions.d.ts +44 -0
  68. package/dist/session/dsh/sessions.js +252 -0
  69. package/dist/session/index.d.ts +1 -0
  70. package/dist/session/index.js +1 -0
  71. package/dist/workspace/index.d.ts +12 -0
  72. package/dist/workspace/index.js +28 -2
  73. package/package.json +2 -1
  74. package/dist/order/alias-store.d.ts +0 -38
  75. package/dist/order/alias-store.js +0 -106
@@ -2,11 +2,10 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import YAML from 'yaml';
5
- import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, assertSafeKeyPart, walkNestedContainer, } from '../config/index.js';
5
+ import { getConfigDir, getConfigValue, setConfigValue, setConfigValues, mapAgentKey, assertSafeKeyPart, walkNestedContainer, getAgentConfig, } from '../config/index.js';
6
6
  import { syncAgentChoices } from '../runner/index.js';
7
7
  import { WorkspaceStore } from '../workspace/index.js';
8
8
  import { OrderStore } from '../order/index.js';
9
- import { AliasStore } from '../order/alias-store.js';
10
9
  import { resolveAlias } from '../order/alias-resolve.js';
11
10
  import { getLogger } from '../logger/index.js';
12
11
  import { activeRunUsage, clampInt } from './utils.js';
@@ -31,8 +30,11 @@ const ACTIVE_PAGE_SIZE = 20;
31
30
  * 2026-08-13 实测:每行 2 个元素(div + column_set,行间 hr),20 行 + 分页栏
32
31
  * = 61 个 body 元素,触发飞书 ErrCode 11310 "element exceeds the limit"
33
32
  * (21+ 条指令时第 1 页必炸)。改为 15 行:3*15 - 1 + 2 = 46 个元素,留足余量。
33
+ * 2026-08-19 别名并卡后每行变 3 个元素(文本 div + 操作 column_set + hr),
34
+ * 从 15 降为 8:3*8 - 1 + 2 = 25 个元素,仍在余量内,同时保证手机窄屏
35
+ * 每条指令的操作控件(执行/别名/删除)不被挤压。
34
36
  */
35
- const ORDER_PAGE_SIZE = 15;
37
+ const ORDER_PAGE_SIZE = 8;
36
38
  /**
37
39
  * /ws 列表页大小。从 15 降为 5:头部 2 + 5 行 × 3(div + column_set + hr)+
38
40
  * 底栏排序 1 + 分页栏 2 ≈ 20 个 body 元素,远低于单卡 60 元素上限(ErrCode 11310),
@@ -110,6 +112,9 @@ export function isImmediateAction(cmd) {
110
112
  cmd === 'queue.input' ||
111
113
  cmd === 'order.delete' ||
112
114
  cmd === 'order.page' ||
115
+ cmd === 'order.aliasEdit' ||
116
+ cmd === 'order.aliasInput' ||
117
+ cmd === 'order.aliasRemove' ||
113
118
  cmd === 'config.toggle' ||
114
119
  cmd === 'config.set' ||
115
120
  cmd === 'config.input' ||
@@ -121,11 +126,13 @@ export function isImmediateAction(cmd) {
121
126
  cmd === 'approval.toggle' ||
122
127
  cmd === 'approval.answer' ||
123
128
  cmd === 'approval.answerSubmit' ||
124
- cmd === 'approval.answerCustom');
129
+ cmd === 'approval.answerCustom' ||
130
+ cmd === 'approval.answerNote' ||
131
+ cmd === 'approval.planFeedback');
125
132
  }
126
133
  export class CommandRouter {
127
134
  /** Valid agent kinds — single source of truth for resume.use / resume.page / cmdResume. */
128
- static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi'];
135
+ static VALID_AGENTS = ['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh'];
129
136
  sessionStore;
130
137
  /** 飞书桥接实例(public:测试直接访问断言,替代 as unknown as)。 */
131
138
  bridge;
@@ -135,7 +142,6 @@ export class CommandRouter {
135
142
  configPath;
136
143
  workspaceStore;
137
144
  orderStore;
138
- aliasStore;
139
145
  exitHandler;
140
146
  pendingExit = false;
141
147
  /**
@@ -190,7 +196,6 @@ export class CommandRouter {
190
196
  this.configPath = opts.configPath;
191
197
  this.workspaceStore = new WorkspaceStore(opts.workspacePath);
192
198
  this.orderStore = new OrderStore(opts.ordersPath);
193
- this.aliasStore = new AliasStore(opts.aliasesPath);
194
199
  this.exitHandler = opts.exitHandler ?? (() => process.exit(0));
195
200
  this.restartSpawner = opts.restartSpawner;
196
201
  // idle watchdog 窗口从 config.idle.watchdogMinutes 读取
@@ -254,9 +259,14 @@ export class CommandRouter {
254
259
  /**
255
260
  * 一次别名展开:bridge 收到用户消息后、命令分发前调用。
256
261
  * 仅 `$name` 开头的消息可能被展开;未知别名 / 非 `$` 消息原样返回。
262
+ * 别名来自 order 的 `alias` 字段(1 条指令 = 1 个别名),展开文本即指令文本。
257
263
  */
258
264
  expandAliasMessage(message) {
259
- return resolveAlias(message, this.aliasStore.list()) ?? message;
265
+ const entries = this.orderStore
266
+ .get()
267
+ .filter((o) => typeof o.alias === 'string')
268
+ .map((o) => ({ name: o.alias, text: o.text }));
269
+ return resolveAlias(message, entries) ?? message;
260
270
  }
261
271
  /**
262
272
  * Handle a CardKit card button click (§6.2).
@@ -351,6 +361,12 @@ export class CommandRouter {
351
361
  return await this.handleOrderDelete(value, ctx);
352
362
  case 'order.page':
353
363
  return await this.handleOrderPage(value, ctx);
364
+ case 'order.aliasEdit':
365
+ return await this.handleOrderAliasEdit(value, ctx);
366
+ case 'order.aliasInput':
367
+ return await this.handleOrderAliasInput(value, ctx);
368
+ case 'order.aliasRemove':
369
+ return await this.handleOrderAliasRemove(value, ctx);
354
370
  case 'config.toggle':
355
371
  case 'config.set':
356
372
  case 'config.input':
@@ -365,6 +381,8 @@ export class CommandRouter {
365
381
  case 'approval.answer':
366
382
  case 'approval.answerSubmit':
367
383
  case 'approval.answerCustom':
384
+ case 'approval.answerNote':
385
+ case 'approval.planFeedback':
368
386
  return this.handleApprovalAction(value, ctx);
369
387
  case 'codex.compact':
370
388
  await this.bridge.handleCodexCompact(value, ctx);
@@ -403,6 +421,14 @@ export class CommandRouter {
403
421
  * Routes to the appropriate bridge method based on value.cmd.
404
422
  * Returns a toast response for immediate user feedback.
405
423
  */
424
+ /** 答案类操作的统一失败反馈:重复投递(同一 nonce 第二次点击)是已生效的
425
+ * 中性事件,报 error 会误导(首次点击实际已成功)。 */
426
+ answerFailureToast(msg) {
427
+ if (/already submitted \(duplicate nonce\)/.test(msg)) {
428
+ return { toast: { type: 'info', content: '该选项已处理,请勿重复点击' } };
429
+ }
430
+ return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
431
+ }
406
432
  async handleApprovalAction(value, _ctx) {
407
433
  const { runId, requestId, decision, nonce, permId } = value;
408
434
  if (value.cmd === 'approval.respond') {
@@ -465,7 +491,7 @@ export class CommandRouter {
465
491
  }
466
492
  catch (err) {
467
493
  const msg = err instanceof Error ? err.message : String(err);
468
- return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
494
+ return this.answerFailureToast(msg);
469
495
  }
470
496
  }
471
497
  if (value.cmd === 'approval.answerSubmit') {
@@ -483,7 +509,7 @@ export class CommandRouter {
483
509
  }
484
510
  catch (err) {
485
511
  const msg = err instanceof Error ? err.message : String(err);
486
- return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
512
+ return this.answerFailureToast(msg);
487
513
  }
488
514
  }
489
515
  if (value.cmd === 'approval.answerCustom') {
@@ -506,7 +532,48 @@ export class CommandRouter {
506
532
  }
507
533
  catch (err) {
508
534
  const msg = err instanceof Error ? err.message : String(err);
509
- return { toast: { type: 'error', content: `答案提交失败:${msg}` } };
535
+ return this.answerFailureToast(msg);
536
+ }
537
+ }
538
+ if (value.cmd === 'approval.answerNote') {
539
+ if (!runId ||
540
+ requestId === undefined ||
541
+ value.questionIndex === undefined ||
542
+ !nonce ||
543
+ !value.inputValue) {
544
+ return { toast: { type: 'error', content: '缺少补充说明参数' } };
545
+ }
546
+ try {
547
+ await this.bridge.handleApprovalAnswerNote({
548
+ runId,
549
+ requestId,
550
+ questionIndex: value.questionIndex,
551
+ text: value.inputValue,
552
+ nonce,
553
+ });
554
+ return { toast: { type: 'success', content: '补充说明已保存' } };
555
+ }
556
+ catch (err) {
557
+ const msg = err instanceof Error ? err.message : String(err);
558
+ return this.answerFailureToast(msg);
559
+ }
560
+ }
561
+ if (value.cmd === 'approval.planFeedback') {
562
+ if (!runId || requestId === undefined || !nonce || !value.inputValue) {
563
+ return { toast: { type: 'error', content: '缺少修改意见参数' } };
564
+ }
565
+ try {
566
+ await this.bridge.handleApprovalPlanFeedback({
567
+ runId,
568
+ requestId,
569
+ text: value.inputValue,
570
+ nonce,
571
+ });
572
+ return { toast: { type: 'success', content: '修改意见已保存' } };
573
+ }
574
+ catch (err) {
575
+ const msg = err instanceof Error ? err.message : String(err);
576
+ return { toast: { type: 'error', content: `修改意见保存失败:${msg}` } };
510
577
  }
511
578
  }
512
579
  return { toast: { type: 'error', content: '未知的审批操作' } };
@@ -1124,6 +1191,136 @@ export class CommandRouter {
1124
1191
  await this.bridge.updateCardInPlace(result.card, ctx);
1125
1192
  return { toast: { type: 'success', content: '' } };
1126
1193
  }
1194
+ /**
1195
+ * Handle order.aliasEdit: 展示别名编辑卡(input + ✓ 提交图标)。
1196
+ * 有别名时预填;提交 `order.aliasInput` 处理(含校验与唯一性)。
1197
+ */
1198
+ async handleOrderAliasEdit(value, ctx) {
1199
+ const orderId = value.orderId;
1200
+ const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1201
+ if (!orderId) {
1202
+ return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1203
+ }
1204
+ this.orderStore.reload();
1205
+ const order = this.orderStore.get().find((o) => o.id === orderId);
1206
+ if (!order) {
1207
+ return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1208
+ }
1209
+ const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
1210
+ // CardKit 2.0 input 自带 ✓ 提交图标(input_value 经 raw 回传,红线)。
1211
+ // 不用 form 容器(触发 300123 无 submit button / 200621 嵌套 column)。
1212
+ const editCard = {
1213
+ schema: '2.0',
1214
+ config: { wide_screen_mode: true },
1215
+ header: {
1216
+ template: 'blue',
1217
+ title: { tag: 'plain_text', content: '✏️ 给指令起别名' },
1218
+ },
1219
+ body: {
1220
+ elements: [
1221
+ {
1222
+ tag: 'div',
1223
+ text: { tag: 'lark_md', content: `**当前指令:**\n\`${displayText}\`` },
1224
+ },
1225
+ { tag: 'hr' },
1226
+ {
1227
+ tag: 'div',
1228
+ text: {
1229
+ tag: 'lark_md',
1230
+ content: '💡 输入别名名后点击右侧 ✓ 提交;留空提交 = 删除该别名',
1231
+ },
1232
+ },
1233
+ {
1234
+ tag: 'column_set',
1235
+ columns: [
1236
+ {
1237
+ tag: 'column',
1238
+ width: 'weighted',
1239
+ weight: 3,
1240
+ elements: [
1241
+ {
1242
+ tag: 'input',
1243
+ name: 'aliasName',
1244
+ placeholder: { tag: 'plain_text', content: '输入 $别名名(如 all)...' },
1245
+ default_value: order.alias ?? '',
1246
+ behaviors: [
1247
+ {
1248
+ type: 'callback',
1249
+ value: { cmd: 'order.aliasInput', orderId, offset },
1250
+ },
1251
+ ],
1252
+ },
1253
+ ],
1254
+ },
1255
+ ],
1256
+ },
1257
+ ],
1258
+ },
1259
+ };
1260
+ await this.bridge.updateCardInPlace(editCard, ctx);
1261
+ }
1262
+ /**
1263
+ * Handle order.aliasInput: 提交别名(绑/改/解绑),随后重渲染 /order 列表卡。
1264
+ * 留空 = 解绑;非空按命名规则 + 全局唯一校验,失败仅 toast 不重渲染。
1265
+ */
1266
+ async handleOrderAliasInput(value, ctx) {
1267
+ const orderId = value.orderId;
1268
+ const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1269
+ const name = value.inputValue ?? value.formValue?.['aliasName'];
1270
+ if (!orderId) {
1271
+ return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1272
+ }
1273
+ this.orderStore.reload();
1274
+ const order = this.orderStore.get().find((o) => o.id === orderId);
1275
+ if (!order) {
1276
+ return { toast: { type: 'error', content: '指令不存在或已被删除' } };
1277
+ }
1278
+ const trimmed = name?.trim() ?? '';
1279
+ // 变更前快照旧别名:setAlias 会原地变异 order 对象(delete entry.alias),
1280
+ // 删除后 order.alias 已为 undefined,直接用会让成功文案显示 `$undefined`。
1281
+ const prevAlias = order.alias;
1282
+ try {
1283
+ this.orderStore.setAlias(order.id, trimmed === '' ? undefined : trimmed);
1284
+ }
1285
+ catch (err) {
1286
+ return { toast: { type: 'error', content: err.message } };
1287
+ }
1288
+ // 成功:重渲染列表卡(保持页码)。必须把 card 放进 callback 响应体让飞书
1289
+ // 原地替换 pre-click 编辑卡——toast-only 响应会停留在编辑界面(飞书保留
1290
+ // 点击前那张卡),见 handleQueueInput 同款注释。
1291
+ const result = this.cmdOrder([], ctx, offset);
1292
+ return {
1293
+ toast: {
1294
+ type: 'success',
1295
+ content: trimmed === '' ? `✅ 已移除别名 $${prevAlias ?? ''}` : `✅ 已绑定别名 $${trimmed}`,
1296
+ },
1297
+ card: { type: 'raw', data: result.card },
1298
+ };
1299
+ }
1300
+ /** Handle order.aliasRemove: 删除某条指令的别名并原地重渲染列表卡。 */
1301
+ async handleOrderAliasRemove(value, ctx) {
1302
+ const orderId = value.orderId;
1303
+ const offset = Math.max(0, Math.trunc(Number(value.offset) || 0));
1304
+ if (!orderId) {
1305
+ return { toast: { type: 'error', content: '卡片 payload 缺少必要信息' } };
1306
+ }
1307
+ this.orderStore.reload();
1308
+ const order = this.orderStore.get().find((o) => o.id === orderId);
1309
+ if (!order || !order.alias) {
1310
+ return { toast: { type: 'error', content: '指令不存在或没有别名' } };
1311
+ }
1312
+ // 变更前快照旧别名:setAlias(undefined) 会原地变异 order(delete alias),
1313
+ // 删除后 order.alias 已为 undefined,直接拼会让成功文案显示 `$undefined`。
1314
+ const removedAlias = order.alias;
1315
+ this.orderStore.setAlias(order.id, undefined);
1316
+ // 必须把 card 放进 callback 响应体让飞书原地替换 pre-click 卡片,否则删除
1317
+ // 后卡片停留在旧状态(别名视觉上未消失)。同 handleQueueInput 语义。
1318
+ const result = this.cmdOrder([], ctx, offset);
1319
+ return {
1320
+ toast: { type: 'success', content: `✅ 已移除别名 $${removedAlias}` },
1321
+ card: { type: 'raw', data: result.card },
1322
+ };
1323
+ }
1127
1324
  /**
1128
1325
  * Handle ws.use card action: switch to the workspace, then refresh the /ws
1129
1326
  * list card in place so "recent" sort order is immediately visible.
@@ -1144,7 +1341,7 @@ export class CommandRouter {
1144
1341
  // immediate action the callback response is swallowed by enqueueImmediate
1145
1342
  // (fire-and-forget), and even if delivered it is transient — the user gets
1146
1343
  // no perceivable feedback on the switch. Same rationale as config.save
1147
- // agent-switch notice (74e99f0): persistent message first, toast only as a
1344
+ // agent-switch notice: persistent message first, toast only as a
1148
1345
  // fallback if sendResult fails.
1149
1346
  let fallbackToast;
1150
1347
  if (useResult.card || useResult.text) {
@@ -1377,6 +1574,8 @@ export class CommandRouter {
1377
1574
  // 2026-08-03: 卡片刷新失败不吞切换通知——保存已成功,仅记录日志,
1378
1575
  // 后续切换消息照常发送;真正写盘/传播阶段的失败才走外层 catch 报「保存失败」。
1379
1576
  try {
1577
+ // host 可能已变更 → 重新预取 DSH 目录(内部按 host 缓存,未变不重复拉)
1578
+ await this.prefetchConfigCardCatalogs();
1380
1579
  await this.bridge.updateCardInPlace(this.buildConfigCard().card, ctx);
1381
1580
  }
1382
1581
  catch (refreshErr) {
@@ -1553,6 +1752,38 @@ export class CommandRouter {
1553
1752
  switchSessionId = '';
1554
1753
  }
1555
1754
  }
1755
+ // DSH preset 变更 = 下次 run 新建 session(§4.3 D1):
1756
+ // preset 在 session 创建时固定,中途切换会被服务端拒绝(agent-preset-conflict)。
1757
+ // 旧 sessionId 存入 previousSessions 停车位(可手动 /resume,但 /resume 会做
1758
+ // preset 一致性校验),当前 sessionId 清空——下次 run 走 session.create 新建。
1759
+ const hasDshPresetChange = Object.keys(updates).some((k) => k === 'agents.dsh.agentPreset');
1760
+ // 已发生 agent 切换时(switchNotice 已设置)preset 分支跳过,避免覆盖切换 notice;
1761
+ // 切换分支对 dsh 的 session 已做恢复/清空处理。
1762
+ if (hasDshPresetChange && this.config.defaultAgent === 'dsh' && !switchNotice) {
1763
+ const oldSessionId = this.sessionStore.getSessionId(ctx.userId, 'dsh');
1764
+ if (oldSessionId) {
1765
+ this.sessionStore.setPreviousSessionId(ctx.userId, 'dsh', oldSessionId);
1766
+ this.sessionStore.clearSessionId(ctx.userId, 'dsh');
1767
+ switchNotice = `DSH preset 已变更,旧会话已停放到恢复槽(可 /resume 校验后恢复),下次消息将新建会话`;
1768
+ }
1769
+ else {
1770
+ switchNotice = `DSH preset 已变更,下次消息将新建会话`;
1771
+ }
1772
+ }
1773
+ // DSH host 变更 = 换服务(CC-02):旧 host 的 sessionId 发往新 host 是错的。
1774
+ // 视为 session 边界,仿 preset 分支停车 + 清空当前 sessionId,下次 run 新建会话。
1775
+ const hasDshHostChange = Object.keys(updates).some((k) => k === 'agents.dsh.host');
1776
+ if (hasDshHostChange && this.config.defaultAgent === 'dsh' && !switchNotice) {
1777
+ const oldSessionId = this.sessionStore.getSessionId(ctx.userId, 'dsh');
1778
+ if (oldSessionId) {
1779
+ this.sessionStore.setPreviousSessionId(ctx.userId, 'dsh', oldSessionId);
1780
+ this.sessionStore.clearSessionId(ctx.userId, 'dsh');
1781
+ switchNotice = `DSH host 已变更,旧会话已停放到恢复槽(可 /resume 校验后恢复),下次消息将新建会话`;
1782
+ }
1783
+ else {
1784
+ switchNotice = `DSH host 已变更,下次消息将新建会话`;
1785
+ }
1786
+ }
1556
1787
  // 2026-07-13: 同步 agent 配置到 agentChoices(用于切换 agent 时恢复配置)
1557
1788
  const currentAgent = this.config.defaultAgent;
1558
1789
  const currentAgentConfigKey = `agents.${currentAgent}`;
@@ -1667,7 +1898,7 @@ export class CommandRouter {
1667
1898
  {
1668
1899
  cmd: 'order',
1669
1900
  label: '/order /o save|list',
1670
- desc: '收藏指令 /order save,快捷别名 /order alias',
1901
+ desc: '收藏指令 /order save,指令可起别名(卡片上 +别名)',
1671
1902
  },
1672
1903
  ];
1673
1904
  const bodyElements = [];
@@ -1731,6 +1962,16 @@ export class CommandRouter {
1731
1962
  content: '**快捷命令**\n`!<bash-command>` — 执行 bash 命令并流式输出到卡片',
1732
1963
  },
1733
1964
  });
1965
+ // 快捷别名($name)
1966
+ bodyElements.push({ tag: 'hr' });
1967
+ bodyElements.push({
1968
+ tag: 'div',
1969
+ text: {
1970
+ tag: 'lark_md',
1971
+ content: '**快捷别名**\n`$<name>` — 触发已保存指令的别名(在 /order 卡片给指令起别名后生效),' +
1972
+ '如 `$all` 等价于执行「跑全量测试」。只匹配消息开头的 `$name`,`!`/`/` 开头不展开',
1973
+ },
1974
+ });
1734
1975
  // Agent 说明
1735
1976
  bodyElements.push({ tag: 'hr' });
1736
1977
  bodyElements.push({
@@ -2579,6 +2820,32 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2579
2820
  * /resume 核心实现(public:handleResumePage / handle / resume.use 共用,
2580
2821
  * 也是测试的直接 seam,避免测试用 @ts-expect-error 摸私有方法)。
2581
2822
  */
2823
+ /**
2824
+ * DSH /resume preset 一致性校验:返回 mismatch 详情,或 undefined(一致/不可判定)。
2825
+ *
2826
+ * session 的 preset 在创建时固定(§2.1)。通过 listSessions 拿到目标 session 的
2827
+ * agentPreset 与当前配置比对:不一致时不静默复用(返回提示),一致或查询失败
2828
+ * (无法判定)时不阻断,避免 DSH 离线导致 /resume 完全不可用。
2829
+ */
2830
+ dshPresetMismatch(reader, sessionId, cwd) {
2831
+ const currentPreset = this.config.agents?.dsh?.agentPreset;
2832
+ if (!currentPreset)
2833
+ return undefined; // 配置跟随服务端默认,无法判定 → 放行
2834
+ let sessionPreset;
2835
+ try {
2836
+ const { sessions } = reader.listSessions(cwd, { limit: 100 });
2837
+ sessionPreset = sessions.find((s) => s.sessionId === sessionId)?.agentPreset;
2838
+ }
2839
+ catch (err) {
2840
+ getLogger().warn(`[router] dshPresetMismatch listSessions failed for ${sessionId}: ${err.message}`);
2841
+ return undefined;
2842
+ }
2843
+ if (!sessionPreset)
2844
+ return undefined; // 服务端未返回 preset → 无法判定,放行
2845
+ if (sessionPreset === currentPreset)
2846
+ return undefined;
2847
+ return { sessionPreset, currentPreset };
2848
+ }
2582
2849
  cmdResume(args, ctx, offset = 0) {
2583
2850
  const entry = this.sessionStore.get(ctx.userId);
2584
2851
  const cwd = entry?.cwd;
@@ -2625,6 +2892,19 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2625
2892
  text: `未找到 session ${sessionIdArg}(当前目录: ${cwd})。请确认该 session 属于此目录,或先 /cd 到正确目录。`,
2626
2893
  };
2627
2894
  }
2895
+ // DSH preset 一致性校验(§6 风险 1):session 的 preset 在创建时固定,
2896
+ // 若与当前配置不符,直接复用会让后续 run 报 agent-preset-conflict。
2897
+ // 不静默复用——明确提示用户,由用户决定是否继续(配置里手动改/清 preset)。
2898
+ if (agentKind === 'dsh' && this.config.defaultAgent === 'dsh') {
2899
+ const presetMismatch = this.dshPresetMismatch(reader, sessionIdArg, cwd);
2900
+ if (presetMismatch) {
2901
+ return {
2902
+ text: `⚠️ 该 DSH session 的 preset 是「${presetMismatch.sessionPreset}」,与当前配置「${presetMismatch.currentPreset}」不一致。\n` +
2903
+ `preset 在 session 创建时固定,无法中途切换(DSH 返回 agent-preset-conflict)。\n` +
2904
+ `继续恢复将在下次 run 失败。请在 /config 中把预设模式改回「${presetMismatch.sessionPreset}」后重试,或保持当前配置并发新消息新建会话。`,
2905
+ };
2906
+ }
2907
+ }
2628
2908
  // L3: 校验通过后才写入
2629
2909
  this.sessionStore.setSessionId(ctx.userId, agentKind, sessionIdArg, cwd);
2630
2910
  const { events: content, usage, isActive, aiTitle, recap, displayTitle, activeRunRunId, } = this.readSessionDisplayState(sessionIdArg, cwd, {
@@ -2984,6 +3264,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2984
3264
  async cmdConfig(args, ctx) {
2985
3265
  // Ensure probe cache is populated before building the card.
2986
3266
  await probeAllAgents();
3267
+ // 动态清单预取(DSH 模型/预设目录):卡渲染前拉取,失败回退兜底清单
3268
+ await this.prefetchConfigCardCatalogs();
2987
3269
  // Check if this is a direct set command: /config <key> <value>
2988
3270
  // Command-style set: immediate write to disk, clear pendingConfig
2989
3271
  if (args.length >= 2) {
@@ -3030,6 +3312,18 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3030
3312
  this.ensurePendingConfig();
3031
3313
  return this.buildConfigCard();
3032
3314
  }
3315
+ /**
3316
+ * DSH /config 卡片构建前异步预取模型/预设目录(§4.2 动态清单)。
3317
+ * 只在 defaultAgent 为 dsh 时生效;其他 agent 无 prefetch 钩子直接跳过。
3318
+ * 预取失败不阻断——DshConfigBuilder 内部已回退固定兜底清单。
3319
+ */
3320
+ async prefetchConfigCardCatalogs() {
3321
+ if (this.config.defaultAgent !== 'dsh')
3322
+ return;
3323
+ const builder = getConfigBuilder('dsh');
3324
+ const dshConf = getAgentConfig(this.config, 'dsh');
3325
+ await builder.prefetch?.(dshConf?.host);
3326
+ }
3033
3327
  /** Build an interactive config card with CardKit 2.0 tabs + batch save */
3034
3328
  buildConfigCard() {
3035
3329
  // 使用 pendingConfig(若存在)或当前 config
@@ -3143,11 +3437,9 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3143
3437
  /** /order 实现(public:测试直接调用,替代 as unknown as)。 */
3144
3438
  cmdOrder(args, _ctx, offset = 0) {
3145
3439
  const sub = args[0]?.toLowerCase();
3146
- // /order — list orders + aliases (CardKit 2.0 with pagination)
3147
- // `/order alias`(无参数)也并入列表卡片。
3148
- if (!sub || sub === 'list' || (sub === 'alias' && !args[1])) {
3440
+ // /order — list orders (CardKit 2.0 with pagination)
3441
+ if (!sub || sub === 'list') {
3149
3442
  this.orderStore.reload();
3150
- this.aliasStore.reload();
3151
3443
  const allOrders = this.orderStore.get();
3152
3444
  const elements = [];
3153
3445
  if (allOrders.length === 0) {
@@ -3166,40 +3458,119 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3166
3458
  for (let i = 0; i < pageOrders.length; i++) {
3167
3459
  const order = pageOrders[i];
3168
3460
  const displayText = order.text.length > 100 ? order.text.slice(0, 97) + '...' : order.text;
3169
- // 文字在上方
3170
- elements.push({ tag: 'div', text: { tag: 'lark_md', content: displayText } });
3171
- // 按钮在下方,一左一右
3461
+ // 文字在上方(有别名时用 `$name` 前缀标识触发词)
3172
3462
  elements.push({
3173
- tag: 'column_set',
3174
- columns: [
3463
+ tag: 'div',
3464
+ text: {
3465
+ tag: 'lark_md',
3466
+ content: order.alias ? `\`$${order.alias}\` ${displayText}` : displayText,
3467
+ },
3468
+ });
3469
+ // 操作行:左侧别名按钮(无别名显示 +别名,有别名点击可改),
3470
+ // 右侧「删除指令」按钮。两列均 weighted 1:1,跨行对齐。
3471
+ const leftColumn = order.alias
3472
+ ? [
3175
3473
  {
3176
- tag: 'column',
3177
- width: 'auto',
3178
- elements: [
3474
+ tag: 'button',
3475
+ text: { tag: 'plain_text', content: `$${order.alias}` },
3476
+ type: 'default',
3477
+ size: 'small',
3478
+ behaviors: [
3179
3479
  {
3180
- tag: 'button',
3181
- text: { tag: 'plain_text', content: '▶ 执行' },
3182
- type: 'primary',
3183
- size: 'small',
3184
- behaviors: [
3185
- { type: 'callback', value: { cmd: 'order.exec', orderId: order.id } },
3186
- ],
3480
+ type: 'callback',
3481
+ value: { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset },
3482
+ },
3483
+ ],
3484
+ },
3485
+ {
3486
+ tag: 'button',
3487
+ text: { tag: 'plain_text', content: '✕' },
3488
+ type: 'default',
3489
+ size: 'small',
3490
+ behaviors: [
3491
+ {
3492
+ type: 'callback',
3493
+ value: {
3494
+ cmd: 'order.aliasRemove',
3495
+ orderId: order.id,
3496
+ aliasName: order.alias,
3497
+ offset: safeOffset,
3498
+ },
3499
+ },
3500
+ ],
3501
+ },
3502
+ ]
3503
+ : [
3504
+ {
3505
+ tag: 'button',
3506
+ text: { tag: 'plain_text', content: '+别名' },
3507
+ type: 'default',
3508
+ size: 'small',
3509
+ behaviors: [
3510
+ {
3511
+ type: 'callback',
3512
+ value: { cmd: 'order.aliasEdit', orderId: order.id, offset: safeOffset },
3187
3513
  },
3188
3514
  ],
3189
3515
  },
3516
+ ];
3517
+ elements.push({
3518
+ tag: 'column_set',
3519
+ columns: [
3190
3520
  {
3191
3521
  tag: 'column',
3192
- width: 'auto',
3522
+ width: 'weighted',
3523
+ weight: 1,
3524
+ elements: leftColumn,
3525
+ },
3526
+ {
3527
+ tag: 'column',
3528
+ width: 'weighted',
3529
+ weight: 1,
3530
+ vertical_align: 'center',
3193
3531
  elements: [
3194
3532
  {
3195
- tag: 'button',
3196
- text: { tag: 'plain_text', content: '删除' },
3197
- type: 'danger',
3198
- size: 'small',
3199
- behaviors: [
3533
+ tag: 'column_set',
3534
+ columns: [
3200
3535
  {
3201
- type: 'callback',
3202
- value: { cmd: 'order.delete', orderId: order.id, offset: safeOffset },
3536
+ tag: 'column',
3537
+ width: 'auto',
3538
+ elements: [
3539
+ {
3540
+ tag: 'button',
3541
+ text: { tag: 'plain_text', content: '▶ 执行' },
3542
+ type: 'primary',
3543
+ size: 'small',
3544
+ behaviors: [
3545
+ {
3546
+ type: 'callback',
3547
+ value: { cmd: 'order.exec', orderId: order.id },
3548
+ },
3549
+ ],
3550
+ },
3551
+ ],
3552
+ },
3553
+ {
3554
+ tag: 'column',
3555
+ width: 'auto',
3556
+ elements: [
3557
+ {
3558
+ tag: 'button',
3559
+ text: { tag: 'plain_text', content: '删除' },
3560
+ type: 'danger',
3561
+ size: 'small',
3562
+ behaviors: [
3563
+ {
3564
+ type: 'callback',
3565
+ value: {
3566
+ cmd: 'order.delete',
3567
+ orderId: order.id,
3568
+ offset: safeOffset,
3569
+ },
3570
+ },
3571
+ ],
3572
+ },
3573
+ ],
3203
3574
  },
3204
3575
  ],
3205
3576
  },
@@ -3225,22 +3596,6 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3225
3596
  elements.push(bar);
3226
3597
  }
3227
3598
  }
3228
- // 别名区:并入 /order 卡片(`/order alias` 列出全部)。
3229
- const aliases = this.aliasStore.list();
3230
- if (aliases.length > 0) {
3231
- elements.push({ tag: 'hr' });
3232
- elements.push({
3233
- tag: 'div',
3234
- text: { tag: 'lark_md', content: '**⚡ 别名**(输入 $name 触发)' },
3235
- });
3236
- for (const alias of aliases) {
3237
- const text = alias.text.length > 100 ? alias.text.slice(0, 97) + '...' : alias.text;
3238
- elements.push({
3239
- tag: 'div',
3240
- text: { tag: 'lark_md', content: `$${alias.name} → ${text}` },
3241
- });
3242
- }
3243
- }
3244
3599
  return {
3245
3600
  card: {
3246
3601
  schema: '2.0',
@@ -3250,46 +3605,34 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3250
3605
  },
3251
3606
  };
3252
3607
  }
3253
- // /order alias <name> <text> — 注册;/order alias <name> — 查看;/order alias remove <name> — 删除
3608
+ // /order alias — CLI 别名管理(卡片是主入口,命令是备选):
3609
+ // /order alias add <orderId|N> <name> — 给某条指令绑定别名
3610
+ // /order alias rm <orderId|N> — 移除某条指令的别名
3611
+ // /order alias list — 列出已绑定的别名(并入列表卡片)
3254
3612
  if (sub === 'alias') {
3255
- const name = args[1];
3256
- if (name === 'remove') {
3613
+ const op = args[1];
3614
+ if (op === 'add') {
3257
3615
  const target = args[2];
3258
- if (!target) {
3259
- return { text: '用法: /order alias remove <name>' };
3260
- }
3261
- // remove 是保留子命令,不能作为别名名:后面跟多词文本说明用户想注册
3262
- // 名为 remove 的别名,明确报错而不是静默删除已存在的 target(P2 review)。
3263
- if (args.length > 3) {
3264
- return { text: '`remove` 是保留子命令,不能作为别名名,请换一个名称' };
3616
+ const name = args[3];
3617
+ if (!target || !name) {
3618
+ return { text: '用法: /order alias add <orderId|序号> <别名名>' };
3265
3619
  }
3266
- const entry = this.aliasStore.get(target);
3267
- if (!entry) {
3268
- return { text: `别名 $${target} 不存在` };
3269
- }
3270
- this.aliasStore.remove(target);
3271
- // 回显原内容:单词场景无法区分「注册 remove」与「删除」,误删后
3272
- // 用户可直接按回显内容重新注册,消除静默破坏(P3 review)。
3273
- const preview = entry.text.length > 50 ? entry.text.slice(0, 50) + '...' : entry.text;
3274
- return { text: `✅ 已删除别名 $${target}(原内容:${preview})` };
3620
+ return this.cmdOrderAliasAdd(target, name);
3275
3621
  }
3276
- const text = args.slice(2).join(' ');
3277
- if (!text) {
3278
- const entry = name ? this.aliasStore.get(name) : undefined;
3279
- if (!entry) {
3280
- return { text: name ? `别名 $${name} 不存在` : '用法: /order alias <name> <text>' };
3622
+ if (op === 'rm') {
3623
+ const target = args[2];
3624
+ if (!target) {
3625
+ return { text: '用法: /order alias rm <orderId|序号>' };
3281
3626
  }
3282
- return { text: `$${entry.name} → ${entry.text}` };
3627
+ return this.cmdOrderAliasRemove(target);
3283
3628
  }
3284
- try {
3285
- const existed = this.aliasStore.has(name);
3286
- this.aliasStore.set(name, text);
3287
- const preview = text.length > 50 ? text.slice(0, 50) + '...' : text;
3288
- return { text: `${existed ? '✅ 已更新别名' : '✅ 已保存别名'} $${name} → ${preview}` };
3289
- }
3290
- catch (err) {
3291
- return { text: `保存失败: ${err.message}` };
3629
+ if (!op || op === 'list') {
3630
+ // 别名管理列表并入列表卡片
3631
+ return this.cmdOrder([], _ctx, offset);
3292
3632
  }
3633
+ return {
3634
+ text: '用法: /order alias [add <orderId|序号> <别名名> | rm <orderId|序号> | list]',
3635
+ };
3293
3636
  }
3294
3637
  // /order save <text>
3295
3638
  if (sub === 'save') {
@@ -3305,7 +3648,45 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3305
3648
  return { text: `保存失败: ${err.message}` };
3306
3649
  }
3307
3650
  }
3308
- return { text: '用法: /order [list|save <text>|alias <name> <text>|alias remove <name>]' };
3651
+ return {
3652
+ text: '用法: /order [list|save <text>|alias add <orderId|序号> <别名名>|alias rm <orderId|序号>]',
3653
+ };
3654
+ }
3655
+ /** /order alias add:按 orderId 或列表序号(1 基)解析目标指令并绑定别名。 */
3656
+ cmdOrderAliasAdd(target, name) {
3657
+ const order = this.resolveOrderTarget(target);
3658
+ if (!order)
3659
+ return { text: `指令不存在: ${target}` };
3660
+ try {
3661
+ this.orderStore.setAlias(order.id, name);
3662
+ return { text: `✅ 已给指令绑定别名 $${name}` };
3663
+ }
3664
+ catch (err) {
3665
+ return { text: `保存失败: ${err.message}` };
3666
+ }
3667
+ }
3668
+ /** /order alias rm:按 orderId 或列表序号(1 基)移除指令的别名。 */
3669
+ cmdOrderAliasRemove(target) {
3670
+ const order = this.resolveOrderTarget(target);
3671
+ if (!order)
3672
+ return { text: `指令不存在: ${target}` };
3673
+ if (!order.alias)
3674
+ return { text: '该指令没有别名' };
3675
+ // 先读别名名再解绑:setAlias(undefined) 会 delete 同一对象引用上的 alias 字段。
3676
+ const aliasName = order.alias;
3677
+ this.orderStore.setAlias(order.id, undefined);
3678
+ return { text: `✅ 已移除别名 $${aliasName}` };
3679
+ }
3680
+ /** 将 `/order alias add|rm` 的目标解析为 order:优先 orderId 精确匹配,回退列表序号(1 基)。 */
3681
+ resolveOrderTarget(target) {
3682
+ const all = this.orderStore.get();
3683
+ const byId = all.find((o) => o.id === target);
3684
+ if (byId)
3685
+ return byId;
3686
+ const n = Number(target);
3687
+ if (Number.isInteger(n) && n >= 1 && n <= all.length)
3688
+ return all[n - 1];
3689
+ return undefined;
3309
3690
  }
3310
3691
  }
3311
3692
  //# sourceMappingURL=index.js.map