lark-remote 0.1.4 → 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,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.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
+
8
28
  ## [0.1.4] - 2026-08-13
9
29
 
10
30
  ### 新增
@@ -115,3 +135,4 @@ Initial release.
115
135
  [0.1.2]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.2
116
136
  [0.1.3]: https://github.com/bungabungawoda/lark-remote/releases/tag/v0.1.3
117
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
@@ -725,6 +725,12 @@ export class Bridge {
725
725
  // Extract agent-specific options from config
726
726
  const agentOpts = this.getAgentRunOptions(agentKind);
727
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;
728
734
  for await (const event of runner.run(message, runOpts)) {
729
735
  // 只有终态才阻止事件处理。finalizing 是非终态,应该继续处理
730
736
  // 这样在 result 事件后,后台任务输出仍能被正确处理
@@ -736,6 +742,7 @@ export class Bridge {
736
742
  continue;
737
743
  }
738
744
  if (event.type === 'system' && event.subtype === 'init') {
745
+ sawInit = true;
739
746
  // 代际守卫:run 在途时 /new(或 new-session 卡片、/cd、/resume、
740
747
  // /config 切换)移动了 session 指针,此 init 的写回是 stale 的——
741
748
  // 跳过,否则 /new 的清空会被在途 run 静默撤销(2026-08-09 事故:
@@ -773,6 +780,21 @@ export class Bridge {
773
780
  contextLength = event.compactMetadata.postTokens;
774
781
  }
775
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
+ }
776
798
  sawResult = true;
777
799
  resultWasError = event.subtype === 'error';
778
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';
@@ -444,7 +445,7 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
444
445
  if (actionValue.cmd === 'stop') {
445
446
  if (!actionValue.runId) {
446
447
  logger.warn('[control] ignored stop card action without runId');
447
- // Card button clicks must have visible feedback (AGENTS.md 红线:卡片按钮
448
+ // Card button clicks must have visible feedback (design constraint: card button
448
449
  // 点击必须有可见反馈,见 design.md §6.2)。
449
450
  void bridge
450
451
  .sendResult({ text: '⚠️ 无效的停止请求,缺少必要信息' }, { userId, chatId, messageId })
@@ -531,6 +532,32 @@ async function main() {
531
532
  printVersion();
532
533
  process.exit(0);
533
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
+ }
534
561
  const { config, configDir, logger, instanceLock } = await initializeCliAndConfig();
535
562
  // Restart handoff: if spawned as a /restart replacement, wait for the old
536
563
  // bridge to die (and release the instance lock) before acquiring it.
@@ -574,6 +601,8 @@ async function main() {
574
601
  ordersPath: path.join(configDir, 'orders.json'),
575
602
  restartSpawner: () => spawnReplacementBridge(path.join(configDir, 'logs')),
576
603
  sessionReaderRegistry,
604
+ devMode: cliArgs.dev,
605
+ updateCachePath: path.join(configDir, 'update-cache.json'),
577
606
  });
578
607
  setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config);
579
608
  try {
@@ -586,6 +615,27 @@ async function main() {
586
615
  if (binder.isBound()) {
587
616
  // 仅已绑定时发送启动通知;未绑定时不打扰(PIN 引导已在控制台输出)
588
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
+ }
589
639
  }
590
640
  // Auto-restore: resume the persisted sessionId if available
591
641
  const restoredContact = startupContactStore.getContact();
@@ -16,13 +16,3 @@ import type { AgentConfigCardBuilder } from './types.js';
16
16
  export declare function getConfigBuilder(agent: AgentKind): AgentConfigCardBuilder;
17
17
  /** List all registered agent kinds. */
18
18
  export declare function listRegisteredAgents(): AgentKind[];
19
- /**
20
- * Sort agent kinds for UI lists: agents that are definitively unavailable
21
- * (availability === false) sink to the bottom; everything else keeps its
22
- * input order. The sort is stable, so relative order within both groups is
23
- * preserved (e.g. registration order in the config card selector).
24
- *
25
- * `undefined` (probe cache missing/expired) is treated as "keep in place",
26
- * so a stale cache cannot misplace installed agents.
27
- */
28
- export declare function sortAgentsByAvailability(kinds: AgentKind[], isAvailable: (kind: AgentKind) => boolean | undefined): AgentKind[];
@@ -35,21 +35,4 @@ export function getConfigBuilder(agent) {
35
35
  export function listRegisteredAgents() {
36
36
  return Object.keys(builders);
37
37
  }
38
- /**
39
- * Sort agent kinds for UI lists: agents that are definitively unavailable
40
- * (availability === false) sink to the bottom; everything else keeps its
41
- * input order. The sort is stable, so relative order within both groups is
42
- * preserved (e.g. registration order in the config card selector).
43
- *
44
- * `undefined` (probe cache missing/expired) is treated as "keep in place",
45
- * so a stale cache cannot misplace installed agents.
46
- */
47
- export function sortAgentsByAvailability(kinds, isAvailable) {
48
- const unavailable = new Set(kinds.filter((kind) => isAvailable(kind) === false));
49
- return [...kinds].sort((a, b) => {
50
- const rankA = unavailable.has(a) ? 1 : 0;
51
- const rankB = unavailable.has(b) ? 1 : 0;
52
- return rankA - rankB;
53
- });
54
- }
55
38
  //# sourceMappingURL=index.js.map
@@ -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;
@@ -91,6 +92,12 @@ export declare class CommandRouter {
91
92
  */
92
93
  private restartSpawner?;
93
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;
94
101
  private pendingConfig;
95
102
  /**
96
103
  * Monotonic counter minting unique internal keys for order.exec enqueue
@@ -135,6 +142,19 @@ export declare class CommandRouter {
135
142
  idleTimeoutMs?: number;
136
143
  /** Session reader registry (required). */
137
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
+ };
138
158
  });
139
159
  /**
140
160
  * Resolve the active session reader for the current defaultAgent.
@@ -307,7 +327,7 @@ export declare class CommandRouter {
307
327
  *
308
328
  * @param updates 以 config 路径形式给出的变更 key(卡片路径来自 diffConfig;
309
329
  * 直写路径传 mapAgentKey(key),保证 pi./codex./opencode./kimi. 命中 agents.*)。
310
- * @returns defaultAgent 切换时的通知文案(未切换返回 undefined)
330
+ * @returns 结构化切换结果(含 notice 文案 + 新 agent/sessionId 信息)
311
331
  */
312
332
  private propagateConfigSave;
313
333
  private executeCommand;
@@ -317,6 +337,14 @@ export declare class CommandRouter {
317
337
  private cmdStop;
318
338
  private cmdExit;
319
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;
320
348
  private cmdNew;
321
349
  private cmdCd;
322
350
  private readSessionDisplayState;
@@ -330,6 +358,13 @@ export declare class CommandRouter {
330
358
  private switchCwdAndNotify;
331
359
  /** Build card for auto-resumed session with new session button */
332
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;
333
368
  private static readonly LS_PAGE_SIZE;
334
369
  private cmdLs;
335
370
  private cmdWs;