lark-remote 0.1.3 → 0.1.5

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,87 @@ 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.5] - 2026-08-14
9
+
10
+ ### 新增
11
+
12
+ - `/update` / `/update check` 命令:检查 npm registry 最新版本并一键升级,升级后自动重启 bridge;开发模式(`--dev`)下自动拒绝自更新
13
+ - `--update` CLI 参数:非交互式升级(适用于 cron / 脚本自动化),升级后退出
14
+ - `checkUpdateOnStartup` 配置项:启动时静默检查版本更新并推送提示(默认关闭)
15
+ - 纯内存 session index(`session-index.ts`):替代旧的 `readCwdFromJsonl`,完整 `cwdSet` 支持 EnterWorktree 连续搬迁 A→B→C 场景
16
+ - `verify-test-classification.ts` 脚本:测试分类校验工具
17
+
18
+ ### 修复
19
+
20
+ - `/ws`、`/order` 卡片分页从 5 条/页调整为 15 条/页,修复飞书 ErrCode 11310 元素超限
21
+ - CLI `--dev` 帮助文本内部术语"看门狗"替换为用户可理解的"空闲超时自动停止"
22
+
23
+ ### 变更
24
+
25
+ - 移除 husky / lint-staged pre-commit 和 pre-push 钩子及相关 devDependencies
26
+ - `sessions.ts` 重构:迁移到 `SessionIndex` + `parseSessionJsonl`,移除 `readCwdFromJsonl`
27
+
28
+ ## [0.1.4] - 2026-08-13
29
+
30
+ ### 新增
31
+
32
+ - 工作区列表支持 `lastUsedAt` 排序,最近使用的 workspace 排在前面;排序切换 UI 移至卡片上方,明确显示当前模式与切换目标
33
+ - `/config` 卡片:未安装的 Coding Agent 沉底排序,已安装 agent 保持注册顺序;探测结果过期(`undefined`)时保持原位不误沉底
34
+
35
+ ### 修复
36
+
37
+ - `/ws`、`/order` 卡片分页降为 15 条/页,修复飞书 ErrCode 11310 元素超限
38
+ - workspace 排序相关 P2/P3 review findings 修复
39
+
40
+ ## [0.1.3] - 2026-08-12
41
+
42
+ ### 新增
43
+
44
+ - Agent 可用性探测:启动时自动检测本机已安装/可用的 Agent CLI(`which` 探测),不可用的 Agent 会在 `/config` 卡片中标注;探测失败不阻塞启动,打开 `/config` 时会重试
45
+ - 首次运行引导:PIN 绑定成功后自动设置默认工作目录,并发送欢迎卡片 + 帮助卡片
46
+ - `message.patch` 业务码可观测性探针
47
+ - Context 统计支持显示占比:codex 上报 `context window` 上限时,卡片按百分比展示上下文用量
48
+
49
+ ### 修复
50
+
51
+ - `displayTitle`/`summary` 跳过 task-notification 注入
52
+ - 修复 `scanClaudeSessions` 对迁移到其他目录的会话的遗漏(S2)
53
+ - 修复 probe 生命周期、工厂回退、卡片回调等评审发现的问题(F1-F8)
54
+ - 清理引用已删除文档(lessons-redlines、resume-card-budget-failure-analysis)导致的失效链接
55
+
56
+ ### 变更
57
+
58
+ - 提取 `createAgentRegistries` 工厂,统一各 Agent 注册逻辑
59
+ - 移除 Agent 二进制路径配置,CLI 名称改为硬编码
60
+ - 移除 `pnpm-lock.yaml`,统一使用 `bun.lock` 管理依赖
61
+ - 新增 husky lint-staged pre-commit / pre-push 钩子(提交前 lint + typecheck)
62
+ - 启用 npm provenance(构建产物可溯源)
63
+ - 移除 watchdog 相关用户文档及 `open-source-playbook` 等临时文档
64
+
65
+ ### 测试
66
+
67
+ - 合并 stub 工厂、净化 fixtures、移除冗余测试
68
+ - 新增 P1-15 configContainer 锚点测试(含 codex 负向断言)
69
+
70
+ ## [0.1.2] - 2026-08-10
71
+
72
+ ### 修复
73
+
74
+ - claude / opencode / kimi 的 Agent 工厂改为从 `configContainer` 读取配置,避免使用过期闭包导致配置不生效(P1-15)
75
+ - codex / kimi 的 `readSessionContent` 增加 cwd 校验,防止越界读取会话
76
+ - `listClaudeSessions` / `isClaudeSessionActive` 支持跨目录回退,处理会话迁移后的定位
77
+ - 修复 `handleQueueInput` 竞态:替换任务在 await 之前注册
78
+ - 完成卡片 `resume.use` 携带 agent 字段,确保会话 reader 路由正确
79
+
80
+ ### 维护
81
+
82
+ - 升级 `@vitest/coverage-v8` 至 4.1.10
83
+ - 移除 CLI 子进程 smoke test,清理测试套件
84
+
85
+ ## [0.1.1] - 2026-08-09
86
+
87
+ 版本号 0.1.0 → 0.1.1,无用户可见功能变更。
88
+
8
89
  ## [0.1.0] - 2026-08-06
9
90
 
10
91
  Initial release.
@@ -50,3 +131,8 @@ Initial release.
50
131
  - Live Feishu API tests gated behind `FEISHU_LIVE_TEST=1` (external contributors can run full test suite without credentials)
51
132
 
52
133
  [0.1.0]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.0
134
+ [0.1.1]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.1
135
+ [0.1.2]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.2
136
+ [0.1.3]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.3
137
+ [0.1.4]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.4
138
+ [0.1.5]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.5
package/README.en.md CHANGED
@@ -150,10 +150,13 @@ Full docs under [`docs/`](docs/):
150
150
  - Template for adding a new agent: [`docs/en/guides/add-new-agent.md`](docs/en/guides/add-new-agent.md)
151
151
  - Codex config card guide: [`docs/en/guides/codex-config.md`](docs/en/guides/codex-config.md)
152
152
  - Feishu CardKit 2.0 component reference: [Official Feishu docs](https://open.feishu.cn/document/feishu-cards/card-json-v2-components/component-json-v2-overview)
153
- - AI collaboration rules and red lines: [`CLAUDE.md`](CLAUDE.md)
154
153
 
155
154
  > This package is a CLI tool only — no programmatic API is exposed.
156
155
 
156
+ ## Acknowledgments
157
+
158
+ This project was inspired by [lark-coding-agent-bridge](https://github.com/zarazhangrui/lark-coding-agent-bridge) — thanks for demonstrating the feasibility of bridging Feishu with local coding agents.
159
+
157
160
  ## License
158
161
 
159
162
  [MIT](LICENSE) © bungabungawoda
package/README.md CHANGED
@@ -149,10 +149,13 @@ FEISHU_LIVE_TEST=1 bun run test tests/feishu-reaction-emoji-live.test.ts
149
149
  - 新增 agent 接入模板:[`docs/zh/guides/add-new-agent.md`](docs/zh/guides/add-new-agent.md)
150
150
  - Codex 配置卡片指南:[`docs/zh/guides/codex-config.md`](docs/zh/guides/codex-config.md)
151
151
  - 飞书 CardKit 2.0 组件参考:[飞书开放平台官方文档](https://open.feishu.cn/document/feishu-cards/card-json-v2-components/component-json-v2-overview)
152
- - AI 协作规则与红线:[`CLAUDE.md`](CLAUDE.md)
153
152
 
154
153
  > 本包为纯 CLI 工具,不提供编程 API。
155
154
 
155
+ ## 致谢
156
+
157
+ 本项目受到了 [lark-coding-agent-bridge](https://github.com/zarazhangrui/lark-coding-agent-bridge) 的启发,感谢该项目展示了飞书与本地 Coding Agent 桥接的可行性。
158
+
156
159
  ## License
157
160
 
158
161
  [MIT](LICENSE) © bungabungawoda
@@ -57,7 +57,11 @@ interface BridgeDeps {
57
57
  /** Workspace store for fallback when sessionStore has no cwd */
58
58
  workspaceStore?: {
59
59
  get(name: string): string | undefined;
60
- list(): [string, string][];
60
+ list(): Array<{
61
+ name: string;
62
+ path: string;
63
+ lastUsedAt: number;
64
+ }>;
61
65
  };
62
66
  /** Override the idle watchdog timeout (default 15 min). Tests use a small value. */
63
67
  idleTimeoutMs?: number;
@@ -542,7 +542,9 @@ export class Bridge {
542
542
  if (!cwd && this.workspaceStore) {
543
543
  const workspaces = this.workspaceStore.list();
544
544
  if (workspaces.length > 0) {
545
- cwd = workspaces[0][1];
545
+ // NOTE: fallback uses insertion order, not sort preference — by design
546
+ // (workspace-sorting.md §9: cwd fallback stays insertion-order for now)
547
+ cwd = workspaces[0].path;
546
548
  getLogger().info(`[bridge] resolveCwd fallback cwd=${cwd} (first saved workspace)`);
547
549
  }
548
550
  }
@@ -723,6 +725,12 @@ export class Bridge {
723
725
  // Extract agent-specific options from config
724
726
  const agentOpts = this.getAgentRunOptions(agentKind);
725
727
  const runOpts = { cwd, sessionId, ...agentOpts };
728
+ // Track whether system.init has been received for this run.
729
+ // Claude CLI --resume emits a stale result (from the previous turn)
730
+ // before sending system.init; bridge must not treat that as the run
731
+ // ending (sawResult, usage capture, or cardSession.push would all
732
+ // be wrong for the historical event).
733
+ let sawInit = false;
726
734
  for await (const event of runner.run(message, runOpts)) {
727
735
  // 只有终态才阻止事件处理。finalizing 是非终态,应该继续处理
728
736
  // 这样在 result 事件后,后台任务输出仍能被正确处理
@@ -734,6 +742,7 @@ export class Bridge {
734
742
  continue;
735
743
  }
736
744
  if (event.type === 'system' && event.subtype === 'init') {
745
+ sawInit = true;
737
746
  // 代际守卫:run 在途时 /new(或 new-session 卡片、/cd、/resume、
738
747
  // /config 切换)移动了 session 指针,此 init 的写回是 stale 的——
739
748
  // 跳过,否则 /new 的清空会被在途 run 静默撤销(2026-08-09 事故:
@@ -771,6 +780,21 @@ export class Bridge {
771
780
  contextLength = event.compactMetadata.postTokens;
772
781
  }
773
782
  if (event.type === 'result') {
783
+ // Pre-init result guard: Claude CLI --resume emits a stale result
784
+ // (from the previous turn) before system.init. Ignoring it prevents
785
+ // premature sawResult/usage capture and the run-state reducer (which
786
+ // also guards on sessionId === undefined) from transitioning to
787
+ // finalizing too early. The real result for this run arrives after
788
+ // system.init.
789
+ if (!sawInit) {
790
+ getLogger().info(`[bridge] pre-init result ignored (resume replay) runId=${runId}`);
791
+ // Still reset idle timer — the CLI is alive and producing events.
792
+ resetIdle();
793
+ // Do NOT push to cardSession: the run-state reducer would also
794
+ // skip it (sessionId === undefined), but skipping at the bridge
795
+ // layer avoids the push + render overhead for a no-op event.
796
+ continue;
797
+ }
774
798
  sawResult = true;
775
799
  resultWasError = event.subtype === 'error';
776
800
  getLogger().info(`[bridge] result event received runId=${runId}`);
@@ -400,7 +400,7 @@ function buildSkeletonElements(state) {
400
400
  // Summary (token stats etc.) — static, bounded size
401
401
  elements.push(...buildSummaryContent(state));
402
402
  // Bottom action row: stop (if running/finalizing) + new session (always).
403
- // Degraded paths must keep the action buttons reachable (AGENTS.md red line).
403
+ // Degraded paths must keep the action buttons reachable (design constraint).
404
404
  const actionButtons = [];
405
405
  const showStop = state.terminal === 'running' || state.terminal === 'finalizing';
406
406
  if (showStop) {
@@ -61,6 +61,13 @@ function reduceSystemEvent(state, event) {
61
61
  function reduceResultEvent(state, event) {
62
62
  if (event.type !== 'result')
63
63
  return state;
64
+ // Pre-init result guard: Claude CLI --resume emits a historical result (from
65
+ // the previous turn) before sending system.init for the new run. Without this
66
+ // guard, that stale result would prematurely transition to 'finalizing',
67
+ // freezing the card at "⏳ 等待进程退出" for the entire run duration.
68
+ // sessionId is set by system.init; undefined means init hasn't arrived yet.
69
+ if (state.sessionId === undefined)
70
+ return state;
64
71
  const resultEvent = event;
65
72
  const incomingSubtype = event.subtype;
66
73
  const incomingErrorMsg = incomingSubtype === 'error' ? (resultEvent.errorMessage ?? 'Agent 返回错误结果') : undefined;
@@ -4,6 +4,7 @@ interface CliArgs {
4
4
  dev?: boolean;
5
5
  help?: boolean;
6
6
  version?: boolean;
7
+ update?: boolean;
7
8
  }
8
9
  export declare function resolveConfigDir(configDirArg: string | undefined): string;
9
10
  export declare function getConfigDir(): string;
@@ -52,6 +52,9 @@ export function parseCliArgs(args = process.argv.slice(2)) {
52
52
  else if (arg === '--dev') {
53
53
  result.dev = true;
54
54
  }
55
+ else if (arg === '--update') {
56
+ result.update = true;
57
+ }
55
58
  }
56
59
  return result;
57
60
  }
@@ -76,7 +79,8 @@ export function printHelp() {
76
79
  'Options:',
77
80
  ' --config-dir <path> 自定义配置目录(默认 ~/.lark-remote,可用于同机多实例)',
78
81
  ' --settings <path> 指定 Claude 配置文件路径',
79
- ' --dev 开发模式:标记从源码 bun src/index.ts 启动(看门狗据此选择拉起方式)',
82
+ ' --dev 开发模式:标记从源码 bun src/index.ts 启动(空闲超时自动停止据此选择拉起方式)',
83
+ ' --update 升级到最新版本后退出(用于 cron/脚本自动化升级)',
80
84
  ' -h, --help 显示本帮助信息',
81
85
  ' -v, --version 显示版本号',
82
86
  ];
@@ -151,6 +151,7 @@ export declare const AppConfigSchema: z.ZodObject<{
151
151
  pi: "pi";
152
152
  kimi: "kimi";
153
153
  }>>;
154
+ checkUpdateOnStartup: z.ZodDefault<z.ZodBoolean>;
154
155
  }, z.core.$strip>;
155
156
  export type AppConfig = z.infer<typeof AppConfigSchema>;
156
157
  export declare function loadConfig(configPath: string): AppConfig;
@@ -153,6 +153,8 @@ export const AppConfigSchema = z.object({
153
153
  logging: LoggingConfigSchema.default(LoggingConfigSchema.parse({})),
154
154
  /** Default agent for new runs. */
155
155
  defaultAgent: z.enum(['claude', 'codex', 'opencode', 'pi', 'kimi']).default('claude'),
156
+ /** Check for updates on bridge startup (default: false). */
157
+ checkUpdateOnStartup: z.boolean().default(false),
156
158
  });
157
159
  const TEMPLATE = `feishu:
158
160
  appId: ""
@@ -3,7 +3,20 @@ import { getLogger } from '../logger/index.js';
3
3
  import { MAX_FILE_UPLOAD_SIZE } from './file-limits.js';
4
4
  import axios from 'axios';
5
5
  import fs from 'node:fs';
6
+ import https from 'node:https';
6
7
  import FormData from 'form-data';
8
+ /**
9
+ * Dedicated https.Agent with keepAlive disabled for sendFile's three axios
10
+ * calls (token, upload, send). Bun's Node.js http compat layer has a bug
11
+ * where it reuses keep-alive sockets that the server has already RST'd —
12
+ * the write goes into a dead socket and the caller gets ECONNRESET after
13
+ * ~30s. Node.js proper does not exhibit this because it checks socket
14
+ * health before reuse. Disabling keep-alive forces a fresh TCP+TLS
15
+ * handshake per request, eliminating the stale-socket path at the cost of
16
+ * one extra round-trip per file send (acceptable for an infrequent
17
+ * operation).
18
+ */
19
+ const noKeepAliveAgent = new https.Agent({ keepAlive: false });
7
20
  /**
8
21
  * dedup 缓存 TTL(毫秒)。控制飞书事件去重窗口,全局作用于 message + cardAction。
9
22
  *
@@ -288,18 +301,22 @@ export class FeishuConnector {
288
301
  form.append('file_name', fileName);
289
302
  form.append('file_type', 'stream');
290
303
  // P2-18: timeout on the upload (large file, slow link) — 120s.
304
+ // httpsAgent: noKeepAlive — prevents Bun from reusing a server-RST'd
305
+ // keep-alive socket (ECONNRESET after ~30s; see noKeepAliveAgent docs).
291
306
  const uploadResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/files', form, {
292
307
  headers: {
293
308
  ...form.getHeaders(),
294
309
  Authorization: `Bearer ${accessToken}`,
295
310
  },
296
311
  timeout: 120000,
312
+ httpsAgent: noKeepAliveAgent,
297
313
  });
298
314
  if (uploadResp.data.code !== 0) {
299
315
  throw new Error(`File upload failed: ${uploadResp.data.msg}`);
300
316
  }
301
317
  const fileKey = uploadResp.data.data.file_key;
302
318
  // Send file message. P2-18: timeout 30s.
319
+ // httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
303
320
  const sendResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/messages', {
304
321
  receive_id: chatId,
305
322
  msg_type: 'file',
@@ -311,6 +328,7 @@ export class FeishuConnector {
311
328
  },
312
329
  params: { receive_id_type: 'chat_id' },
313
330
  timeout: 30000,
331
+ httpsAgent: noKeepAliveAgent,
314
332
  });
315
333
  if (sendResp.data.code !== 0) {
316
334
  throw new Error(`File message failed: ${sendResp.data.msg}`);
@@ -347,12 +365,14 @@ export class FeishuConnector {
347
365
  return this.cachedToken;
348
366
  }
349
367
  // P2-18: timeout 30s on the token request.
368
+ // httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
350
369
  const tokenResp = await axios.post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
351
370
  app_id: this.appId,
352
371
  app_secret: this.appSecret,
353
372
  }, {
354
373
  headers: { 'Content-Type': 'application/json; charset=utf-8' },
355
374
  timeout: 30000,
375
+ httpsAgent: noKeepAliveAgent,
356
376
  });
357
377
  if (tokenResp.data.code !== 0) {
358
378
  throw new Error(`Failed to get tenant_access_token: ${tokenResp.data.msg ?? 'unknown error'}`);
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { StartupContactStore, sendStartupHello } from './startup-contact.js';
16
16
  import { OwnerBinder, formatPinGuidance } from './binder.js';
17
17
  import { InstanceAlreadyRunningError, InstanceLock } from './instance-lock.js';
18
18
  import { spawnReplacementBridge, waitForPreviousInstance } from './restart.js';
19
+ import { checkLatestVersion, isNewer, runInstallLatest, formatUpdateHint } from './update/index.js';
19
20
  import { classifyRejection } from './error-classification.js';
20
21
  import { WorkspaceStore } from './workspace/index.js';
21
22
  import { markdownDiv } from './card/collapsible.js';
@@ -394,7 +395,9 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
394
395
  if (!workspace && workspaceStore) {
395
396
  const workspaces = workspaceStore.list();
396
397
  if (workspaces.length > 0) {
397
- workspace = workspaces[0][1];
398
+ // NOTE: fallback uses insertion order, not sort preference — by design
399
+ // (workspace-sorting.md §9: cwd fallback stays insertion-order for now)
400
+ workspace = workspaces[0].path;
398
401
  }
399
402
  }
400
403
  // Step4/D4: 入队时刻(T0)快照 agent+session,随任务闭包带到 T1 执行时刻,
@@ -442,7 +445,7 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
442
445
  if (actionValue.cmd === 'stop') {
443
446
  if (!actionValue.runId) {
444
447
  logger.warn('[control] ignored stop card action without runId');
445
- // Card button clicks must have visible feedback (AGENTS.md 红线:卡片按钮
448
+ // Card button clicks must have visible feedback (design constraint: card button
446
449
  // 点击必须有可见反馈,见 design.md §6.2)。
447
450
  void bridge
448
451
  .sendResult({ text: '⚠️ 无效的停止请求,缺少必要信息' }, { userId, chatId, messageId })
@@ -529,6 +532,32 @@ async function main() {
529
532
  printVersion();
530
533
  process.exit(0);
531
534
  }
535
+ // --update: upgrade to latest version and exit (for cron/script automation)
536
+ if (cliArgs.update) {
537
+ // --update 在 acquireLock 之前执行;先解析 configDir 并初始化 logger,
538
+ // 让版本检查的日志落到正确目录(默认 ~/.lark-remote 或 --config-dir 指定)。
539
+ const updateConfigDir = resolveConfigDir(cliArgs.configDir);
540
+ initLogger({ dir: path.join(updateConfigDir, 'logs') });
541
+ try {
542
+ const { current, latest } = await checkLatestVersion();
543
+ if (!isNewer(current, latest)) {
544
+ console.log(`Already up to date: ${current}`);
545
+ process.exit(0);
546
+ }
547
+ console.log(`Updating ${current} → ${latest} ...`);
548
+ const result = await runInstallLatest();
549
+ if (!result.success) {
550
+ console.error(`Update failed: ${result.error}`);
551
+ process.exit(1);
552
+ }
553
+ console.log(`✅ Updated to ${latest}. Restart lark-remote to use the new version.`);
554
+ process.exit(0);
555
+ }
556
+ catch (err) {
557
+ console.error(`Update failed: ${err.message}`);
558
+ process.exit(1);
559
+ }
560
+ }
532
561
  const { config, configDir, logger, instanceLock } = await initializeCliAndConfig();
533
562
  // Restart handoff: if spawned as a /restart replacement, wait for the old
534
563
  // bridge to die (and release the instance lock) before acquiring it.
@@ -572,6 +601,8 @@ async function main() {
572
601
  ordersPath: path.join(configDir, 'orders.json'),
573
602
  restartSpawner: () => spawnReplacementBridge(path.join(configDir, 'logs')),
574
603
  sessionReaderRegistry,
604
+ devMode: cliArgs.dev,
605
+ updateCachePath: path.join(configDir, 'update-cache.json'),
575
606
  });
576
607
  setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config);
577
608
  try {
@@ -584,6 +615,27 @@ async function main() {
584
615
  if (binder.isBound()) {
585
616
  // 仅已绑定时发送启动通知;未绑定时不打扰(PIN 引导已在控制台输出)
586
617
  await sendStartupHello(connector, startupContactStore, { dev: cliArgs.dev });
618
+ // 启动时静默检查版本(由 checkUpdateOnStartup 控制,默认关闭)
619
+ if (config.checkUpdateOnStartup) {
620
+ void (async () => {
621
+ try {
622
+ const cachePath = path.join(configDir, 'update-cache.json');
623
+ const { current, latest } = await checkLatestVersion({ cachePath });
624
+ const hint = formatUpdateHint(current, latest);
625
+ if (hint) {
626
+ const contact = startupContactStore.getContact();
627
+ const recipient = contact?.chatId ?? contact?.userId;
628
+ if (recipient) {
629
+ await connector.sendWithRetry(recipient, { text: hint });
630
+ }
631
+ }
632
+ }
633
+ catch (err) {
634
+ // Non-fatal: startup check failure must not crash the bridge
635
+ logger.warn(`[startup] update check failed: ${err.message}`);
636
+ }
637
+ })();
638
+ }
587
639
  }
588
640
  // Auto-restore: resume the persisted sessionId if available
589
641
  const restoredContact = startupContactStore.getContact();
@@ -2,6 +2,7 @@ import type { SessionStore, SessionReaderRegistry } from '../session/index.js';
2
2
  import type { AppConfig } from '../config/index.js';
3
3
  import type { Bridge } from '../bridge/index.js';
4
4
  import type { AgentBinding } from '../bridge/queue-manager.js';
5
+ import { type VersionCheckResult, type InstallResult } from '../update/index.js';
5
6
  interface CommandContext {
6
7
  userId: string;
7
8
  chatId: string;
@@ -17,6 +18,7 @@ interface CommandResult {
17
18
  * These bypass the serial queue and execute immediately.
18
19
  *
19
20
  * ws.use — /ws list card "使用 <name>"
21
+ * ws.page — /ws list card "上一页/下一页"
20
22
  * queue.immediate — queue card "立即执行" button (§9.6)
21
23
  * queue.cancel — queue card "撤销" button
22
24
  * queue.diagnose — queue card "诊断" button
@@ -25,6 +27,7 @@ interface CommandResult {
25
27
  * new-session — clear sessionId only
26
28
  * stop — interrupt current run
27
29
  * ls.file — send file only
30
+ * ws.page — paginate /ws list only
28
31
  * ws.remove — delete workspace alias only
29
32
  * resume.use — set sessionId + agent for correct reader routing
30
33
  * help.* — read-only help commands
@@ -89,6 +92,12 @@ export declare class CommandRouter {
89
92
  */
90
93
  private restartSpawner?;
91
94
  private idleTimeoutMs;
95
+ /** Dev mode flag: --dev means the bridge was started from source (bun src/index.ts). */
96
+ private devMode;
97
+ /** Cache file for version checks (<configDir>/update-cache.json). */
98
+ private updateCachePath?;
99
+ /** Injected update functions (for testability; defaults to real implementation). */
100
+ private updateFns;
92
101
  private pendingConfig;
93
102
  /**
94
103
  * Monotonic counter minting unique internal keys for order.exec enqueue
@@ -98,6 +107,11 @@ export declare class CommandRouter {
98
107
  * millisecond); the counter guarantees uniqueness regardless of timing.
99
108
  */
100
109
  private orderExecKeyCounter;
110
+ /**
111
+ * Per-user sort preference for /ws list (memory-only, not persisted).
112
+ * Default 'recent' (most recently used first), can toggle to 'alpha'.
113
+ */
114
+ private wsSortPreference;
101
115
  /**
102
116
  * Session reader registry for multi-agent path. Resolves the right
103
117
  * reader by `config.defaultAgent` (and per-agent override params).
@@ -128,6 +142,19 @@ export declare class CommandRouter {
128
142
  idleTimeoutMs?: number;
129
143
  /** Session reader registry (required). */
130
144
  sessionReaderRegistry: SessionReaderRegistry;
145
+ /** Dev mode: bridge was started from source (--dev flag). */
146
+ devMode?: boolean;
147
+ /** Version check cache path (<configDir>/update-cache.json). */
148
+ updateCachePath?: string;
149
+ /** Override update functions for testability. */
150
+ updateFns?: {
151
+ checkLatestVersion?: (opts?: {
152
+ cachePath?: string;
153
+ bypassCache?: boolean;
154
+ }) => Promise<VersionCheckResult>;
155
+ isNewer?: (current: string, latest: string) => boolean | null;
156
+ runInstallLatest?: () => Promise<InstallResult>;
157
+ };
131
158
  });
132
159
  /**
133
160
  * Resolve the active session reader for the current defaultAgent.
@@ -235,12 +262,31 @@ export declare class CommandRouter {
235
262
  * Mirrors handleLsPage — updates the same card, never sends a new one.
236
263
  */
237
264
  private handleOrderPage;
265
+ /**
266
+ * Handle ws.use card action: switch to the workspace, then refresh the /ws
267
+ * list card in place so "recent" sort order is immediately visible.
268
+ *
269
+ * Unlike the command-line `/ws use` (which returns text), the card action
270
+ * uses toast + inplace refresh for a cleaner UX (matching ws.remove/ws.sort).
271
+ * If auto-resume produces a card, it is sent as a separate reply.
272
+ */
273
+ private handleWsUse;
238
274
  /**
239
275
  * Handle ws.remove: remove the workspace alias and refresh the /ws list card
240
276
  * in place (mirrors handleOrderDelete). Without this the stale alias would
241
277
  * remain visible on the card the user just clicked.
242
278
  */
243
279
  private handleWsRemove;
280
+ /**
281
+ * Handle ws.page: paginate the /ws list in place.
282
+ * Mirrors handleOrderPage — updates the same card, never sends a new one.
283
+ */
284
+ private handleWsPage;
285
+ /**
286
+ * Handle ws.sort: toggle sort preference and refresh the /ws list card.
287
+ * Sort preference is memory-only (not persisted); restart resets to 'recent'.
288
+ */
289
+ private handleWsSort;
244
290
  /**
245
291
  * `ls.file` card button: send file to Feishu, rejecting if > 30MB.
246
292
  */
@@ -281,7 +327,7 @@ export declare class CommandRouter {
281
327
  *
282
328
  * @param updates 以 config 路径形式给出的变更 key(卡片路径来自 diffConfig;
283
329
  * 直写路径传 mapAgentKey(key),保证 pi./codex./opencode./kimi. 命中 agents.*)。
284
- * @returns defaultAgent 切换时的通知文案(未切换返回 undefined)
330
+ * @returns 结构化切换结果(含 notice 文案 + 新 agent/sessionId 信息)
285
331
  */
286
332
  private propagateConfigSave;
287
333
  private executeCommand;
@@ -291,6 +337,14 @@ export declare class CommandRouter {
291
337
  private cmdStop;
292
338
  private cmdExit;
293
339
  private cmdRestart;
340
+ /**
341
+ * /update: check for newer version and upgrade if available.
342
+ * /update check: only check, don't upgrade.
343
+ *
344
+ * Flow: check version → if newer, install → spawn replacement bridge → exit.
345
+ * Reuses /restart's restartSpawner + pendingExit mechanism.
346
+ */
347
+ private cmdUpdate;
294
348
  private cmdNew;
295
349
  private cmdCd;
296
350
  private readSessionDisplayState;
@@ -304,6 +358,13 @@ export declare class CommandRouter {
304
358
  private switchCwdAndNotify;
305
359
  /** Build card for auto-resumed session with new session button */
306
360
  private buildAutoResumeCard;
361
+ /**
362
+ * Build card for config.save agent switch.
363
+ * When the new agent has a restored session, renders a Resume card (mirrors
364
+ * buildAutoResumeCard) with session history + stop/new buttons.
365
+ * When the session was cleared, renders a compact notice card instead.
366
+ */
367
+ private buildConfigSwitchCard;
307
368
  private static readonly LS_PAGE_SIZE;
308
369
  private cmdLs;
309
370
  private cmdWs;