lark-remote 0.1.13 → 0.2.0

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 (132) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.en.md +2 -6
  3. package/README.md +3 -7
  4. package/dist/bridge/final-usage.d.ts +24 -0
  5. package/dist/bridge/final-usage.js +55 -0
  6. package/dist/bridge/index.d.ts +0 -7
  7. package/dist/bridge/index.js +21 -63
  8. package/dist/bridge/queue-manager.js +4 -3
  9. package/dist/card/approval-render.js +39 -94
  10. package/dist/card/bash-renderer.js +12 -21
  11. package/dist/card/card-shared.d.ts +13 -1
  12. package/dist/card/card-shared.js +21 -30
  13. package/dist/card/run-renderer.d.ts +0 -22
  14. package/dist/card/run-renderer.js +206 -153
  15. package/dist/card/run-state.d.ts +29 -1
  16. package/dist/card/run-state.js +172 -84
  17. package/dist/card/text-truncate.d.ts +0 -9
  18. package/dist/card/text-truncate.js +0 -9
  19. package/dist/card/tool-render.d.ts +4 -1
  20. package/dist/card/tool-render.js +76 -32
  21. package/dist/cli.js +2 -2
  22. package/dist/common/fs.d.ts +14 -0
  23. package/dist/common/fs.js +57 -3
  24. package/dist/config/codex-config.js +2 -0
  25. package/dist/config/index.d.ts +0 -5
  26. package/dist/config/index.js +0 -11
  27. package/dist/config/kimi-config.js +1 -0
  28. package/dist/config/opencode-config.js +13 -2
  29. package/dist/config/qr.d.ts +36 -0
  30. package/dist/config/qr.js +124 -0
  31. package/dist/config/wizard.js +69 -27
  32. package/dist/connector/index.d.ts +3 -2
  33. package/dist/connector/index.js +5 -3
  34. package/dist/index.js +14 -41
  35. package/dist/instance-lock.js +7 -0
  36. package/dist/persistence/atomic-write.d.ts +2 -0
  37. package/dist/persistence/atomic-write.js +20 -5
  38. package/dist/persistence/load-json-file.d.ts +1 -2
  39. package/dist/persistence/load-json-file.js +2 -3
  40. package/dist/platform/agent-stopper.d.ts +22 -0
  41. package/dist/platform/agent-stopper.js +19 -0
  42. package/dist/platform/command.d.ts +11 -0
  43. package/dist/platform/command.js +107 -0
  44. package/dist/platform/identity.d.ts +33 -0
  45. package/dist/platform/identity.js +187 -0
  46. package/dist/platform/path.d.ts +24 -0
  47. package/dist/platform/path.js +110 -0
  48. package/dist/platform/select.d.ts +9 -0
  49. package/dist/platform/select.js +12 -0
  50. package/dist/platform/shell.d.ts +51 -0
  51. package/dist/platform/shell.js +75 -0
  52. package/dist/platform/spawn.d.ts +41 -0
  53. package/dist/platform/spawn.js +77 -0
  54. package/dist/platform/terminator-posix.d.ts +38 -0
  55. package/dist/platform/terminator-posix.js +103 -0
  56. package/dist/platform/terminator-win32.d.ts +21 -0
  57. package/dist/platform/terminator-win32.js +110 -0
  58. package/dist/platform/terminator.d.ts +44 -0
  59. package/dist/platform/terminator.js +24 -0
  60. package/dist/platform/types.d.ts +23 -0
  61. package/dist/platform/types.js +5 -0
  62. package/dist/restart.js +4 -2
  63. package/dist/router/card-helpers.d.ts +33 -6
  64. package/dist/router/card-helpers.js +67 -11
  65. package/dist/router/config/common/render.d.ts +1 -1
  66. package/dist/router/config/common/render.js +1 -1
  67. package/dist/router/config-diff.d.ts +4 -0
  68. package/dist/router/config-diff.js +70 -0
  69. package/dist/router/index.d.ts +66 -6
  70. package/dist/router/index.js +450 -315
  71. package/dist/router/utils.d.ts +6 -0
  72. package/dist/router/utils.js +12 -0
  73. package/dist/runner/bash/runner.d.ts +9 -2
  74. package/dist/runner/bash/runner.js +45 -20
  75. package/dist/runner/claude/session.d.ts +4 -1
  76. package/dist/runner/claude/session.js +18 -4
  77. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
  78. package/dist/runner/codex/app-server/translator.d.ts +18 -0
  79. package/dist/runner/codex/app-server/translator.js +146 -0
  80. package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
  81. package/dist/runner/common/acp/base-acp-runner.js +248 -0
  82. package/dist/runner/common/acp/base-translator.d.ts +141 -0
  83. package/dist/runner/common/acp/base-translator.js +271 -0
  84. package/dist/runner/common/acp/protocol-helpers.d.ts +39 -0
  85. package/dist/runner/common/acp/protocol-helpers.js +34 -0
  86. package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
  87. package/dist/runner/common/jsonrpc/transport.js +25 -5
  88. package/dist/runner/common/process-stopper.d.ts +4 -26
  89. package/dist/runner/common/process-stopper.js +4 -76
  90. package/dist/runner/common/runner-utils.d.ts +0 -5
  91. package/dist/runner/common/runner-utils.js +0 -7
  92. package/dist/runner/common/spawning-runner.d.ts +42 -132
  93. package/dist/runner/common/spawning-runner.js +52 -271
  94. package/dist/runner/common/usage.d.ts +2 -0
  95. package/dist/runner/common/usage.js +2 -0
  96. package/dist/runner/index.d.ts +1 -1
  97. package/dist/runner/kimi/acp/runner.d.ts +45 -42
  98. package/dist/runner/kimi/acp/runner.js +140 -293
  99. package/dist/runner/kimi/acp/translator.d.ts +18 -100
  100. package/dist/runner/kimi/acp/translator.js +56 -242
  101. package/dist/runner/opencode/acp/runner.d.ts +22 -46
  102. package/dist/runner/opencode/acp/runner.js +73 -256
  103. package/dist/runner/opencode/acp/translator.d.ts +28 -103
  104. package/dist/runner/opencode/acp/translator.js +51 -236
  105. package/dist/runner/pi/rpc/translator.d.ts +3 -0
  106. package/dist/runner/pi/rpc/translator.js +47 -9
  107. package/dist/runner/probe.d.ts +8 -11
  108. package/dist/runner/probe.js +17 -21
  109. package/dist/runner/registry.d.ts +4 -4
  110. package/dist/runner/registry.js +4 -4
  111. package/dist/runner/types.d.ts +38 -1
  112. package/dist/session/claude/sessions.js +34 -57
  113. package/dist/session/codex/rollout-reader.d.ts +10 -0
  114. package/dist/session/codex/rollout-reader.js +123 -41
  115. package/dist/session/common/constants.d.ts +7 -0
  116. package/dist/session/common/constants.js +9 -0
  117. package/dist/session/common/two-pass.d.ts +22 -0
  118. package/dist/session/common/two-pass.js +70 -0
  119. package/dist/session/dsh/sessions.js +6 -5
  120. package/dist/session/kimi/sessions.d.ts +6 -0
  121. package/dist/session/kimi/sessions.js +50 -73
  122. package/dist/session/opencode/sessions.js +26 -8
  123. package/dist/session/pi/sessions.js +54 -73
  124. package/dist/update/index.d.ts +1 -2
  125. package/dist/update/index.js +1 -2
  126. package/dist/update/install.d.ts +2 -1
  127. package/dist/update/install.js +58 -9
  128. package/dist/update/version-check.d.ts +5 -0
  129. package/dist/update/version-check.js +9 -0
  130. package/package.json +9 -6
  131. package/dist/update/startup-hint.d.ts +0 -5
  132. package/dist/update/startup-hint.js +0 -11
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ export function decideRuntime(isBun, probe) {
14
14
  /** Check whether `bun` is present and runs `--version` successfully. */
15
15
  function probeBun() {
16
16
  try {
17
- const res = spawnSync('bun', ['--version'], { stdio: 'ignore' });
17
+ const res = spawnSync('bun', ['--version'], { stdio: 'ignore', windowsHide: true });
18
18
  return { error: res.error !== undefined, status: res.status };
19
19
  }
20
20
  catch {
@@ -75,7 +75,7 @@ function main() {
75
75
  isBun: process.isBun ?? false,
76
76
  probe: probeBun(),
77
77
  importEntry: () => import(entryUrl.href),
78
- spawnBun: (e, a) => spawn('bun', [e, ...a], { stdio: 'inherit' }),
78
+ spawnBun: (e, a) => spawn('bun', [e, ...a], { stdio: 'inherit', windowsHide: true }),
79
79
  onSignal: (sig, handler) => process.on(sig, handler),
80
80
  offSignal: (sig, handler) => process.off(sig, handler),
81
81
  killSelf: (sig) => process.kill(process.pid, sig),
@@ -6,3 +6,17 @@
6
6
  * an error worth propagating.
7
7
  */
8
8
  export declare function silentlyUnlink(path: string): void;
9
+ export declare function isBusyError(err: unknown): boolean;
10
+ export interface BusyRetryOptions {
11
+ /** 平台注入(测试用);默认当前宿主。占用重试仅 win32 生效 */
12
+ platform?: NodeJS.Platform;
13
+ /** 重试耗尽(或 posix 首次占用即失败)时回调,用于告警/保留现场 */
14
+ onGiveUp?: (err: unknown) => void;
15
+ }
16
+ /**
17
+ * 对 sync 文件操作套占用退避重试:win32 上遇 EPERM/EBUSY 按退避序列重试,
18
+ * 其他错误或重试耗尽后抛最后一个错误;posix 上占用错误不重试、立即失败。
19
+ * `onGiveUp` 仅在「占用导致的放弃」时回调(告警/保留现场);非占用错误
20
+ * (如 EXDEV)不走回调、直接透传,避免误触发调用方的占用善后逻辑。
21
+ */
22
+ export declare function withBusyRetry<T>(op: () => T, opts?: BusyRetryOptions): T;
package/dist/common/fs.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import fs from 'node:fs';
2
+ import { getLogger } from '../logger/index.js';
3
+ import { currentPlatform, isWin32 } from '../platform/select.js';
2
4
  /**
3
5
  * Unlink a file, silently ignoring ENOENT and other errors.
4
6
  *
@@ -8,10 +10,62 @@ import fs from 'node:fs';
8
10
  */
9
11
  export function silentlyUnlink(path) {
10
12
  try {
11
- fs.unlinkSync(path);
13
+ withBusyRetry(() => fs.unlinkSync(path));
12
14
  }
13
- catch {
14
- /* ignore — file may not exist */
15
+ catch (err) {
16
+ // 占用放弃(win32 重试耗尽 / posix 首次占用即失败):按设计「告警,
17
+ // 不静默吞」,但清理路径不向调用方抛错(保持本函数契约)
18
+ getLogger().warn(`[fs] silentlyUnlink gave up on busy file ${path}: ${String(err)}`);
19
+ }
20
+ }
21
+ /**
22
+ * win32 句柄占用重试:rename/unlink 目标被
23
+ * 其他句柄持有时报 EPERM/EBUSY(共享冲突),短暂退避后重试通常即可成功。
24
+ * 仅 win32 启用重试:posix 上这两个错误码不是「占用瞬态」语义(如 NFS 权限
25
+ * 问题),立即失败——避免忙等阻塞单线程 bridge 的事件循环。
26
+ */
27
+ const BUSY_CODES = new Set(['EPERM', 'EBUSY']);
28
+ /**
29
+ * 退避参数(取参照项目上线验证值):
30
+ * 最多 5 次,第 n 次失败后睡 BASE_RETRY_DELAY_MS × n——总阻塞上界 ~250ms。
31
+ * Windows 上杀毒/索引器持锁更久,窗口比短退避更稳。
32
+ */
33
+ const BASE_RETRY_DELAY_MS = 25;
34
+ const MAX_RETRY_ATTEMPTS = 5;
35
+ export function isBusyError(err) {
36
+ return BUSY_CODES.has(err?.code ?? '');
37
+ }
38
+ function busySleep(ms) {
39
+ if (ms <= 0)
40
+ return;
41
+ const end = Date.now() + ms;
42
+ // 同步退避只能忙等(调用方均为 sync API);总上界 ~250ms
43
+ while (Date.now() < end) {
44
+ /* spin */
45
+ }
46
+ }
47
+ /**
48
+ * 对 sync 文件操作套占用退避重试:win32 上遇 EPERM/EBUSY 按退避序列重试,
49
+ * 其他错误或重试耗尽后抛最后一个错误;posix 上占用错误不重试、立即失败。
50
+ * `onGiveUp` 仅在「占用导致的放弃」时回调(告警/保留现场);非占用错误
51
+ * (如 EXDEV)不走回调、直接透传,避免误触发调用方的占用善后逻辑。
52
+ */
53
+ export function withBusyRetry(op, opts) {
54
+ const win32 = isWin32(opts?.platform ?? currentPlatform);
55
+ for (let attempt = 1;; attempt++) {
56
+ try {
57
+ return op();
58
+ }
59
+ catch (err) {
60
+ if (!isBusyError(err))
61
+ throw err;
62
+ if (!win32 || attempt >= MAX_RETRY_ATTEMPTS) {
63
+ // 真正放弃才回调:中间重试失败不能触发「保留现场」类善后
64
+ opts?.onGiveUp?.(err);
65
+ throw err;
66
+ }
67
+ busySleep(BASE_RETRY_DELAY_MS * attempt);
68
+ }
15
69
  }
16
70
  }
17
71
  //# sourceMappingURL=fs.js.map
@@ -176,6 +176,7 @@ export function getCodexCatalogModels(codexHome) {
176
176
  maxBuffer: 4 * 1024 * 1024,
177
177
  env: process.env,
178
178
  stdio: ['ignore', 'pipe', 'ignore'],
179
+ windowsHide: true, // codex 是 npm .cmd 垫片:不隐藏会闪 cmd.exe 控制台
179
180
  });
180
181
  const models = parseCodexModelsOutput(stdout);
181
182
  catalogCache = { key, models, ts: now, failed: models.length === 0 };
@@ -284,6 +285,7 @@ function execFileAsync(file, args) {
284
285
  timeout: 8_000,
285
286
  maxBuffer: 4 * 1024 * 1024,
286
287
  env: process.env,
288
+ windowsHide: true,
287
289
  }, (err, stdout) => {
288
290
  if (err)
289
291
  reject(err);
@@ -241,11 +241,6 @@ export declare const AppConfigSchema: z.ZodObject<{
241
241
  idle: z.ZodDefault<z.ZodObject<{
242
242
  watchdogMinutes: z.ZodDefault<z.ZodNumber>;
243
243
  }, z.core.$strip>>;
244
- output: z.ZodDefault<z.ZodObject<{
245
- showThinking: z.ZodDefault<z.ZodBoolean>;
246
- showToolUse: z.ZodDefault<z.ZodBoolean>;
247
- showToolResult: z.ZodDefault<z.ZodBoolean>;
248
- }, z.core.$strip>>;
249
244
  logging: z.ZodDefault<z.ZodObject<{
250
245
  level: z.ZodDefault<z.ZodEnum<{
251
246
  debug: "debug";
@@ -202,11 +202,6 @@ const IdleConfigSchema = z.object({
202
202
  */
203
203
  watchdogMinutes: z.number().int().min(0).default(DEFAULTS.IDLE_WATCHDOG_MINUTES),
204
204
  });
205
- const OutputConfigSchema = z.object({
206
- showThinking: z.boolean().default(true),
207
- showToolUse: z.boolean().default(true),
208
- showToolResult: z.boolean().default(true),
209
- });
210
205
  const LoggingConfigSchema = z.object({
211
206
  level: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
212
207
  });
@@ -256,7 +251,6 @@ export const AppConfigSchema = z.object({
256
251
  /** Agent-specific saved choices for quick switching. */
257
252
  agentChoices: AgentChoicesSchema.optional(),
258
253
  idle: IdleConfigSchema.default(IdleConfigSchema.parse({})),
259
- output: OutputConfigSchema.default(OutputConfigSchema.parse({})),
260
254
  logging: LoggingConfigSchema.default(LoggingConfigSchema.parse({})),
261
255
  /** 入站媒体(图片/文件)落盘配置。 */
262
256
  inboundMedia: InboundMediaConfigSchema.default(InboundMediaConfigSchema.parse({})),
@@ -280,11 +274,6 @@ claude:
280
274
  idle:
281
275
  watchdogMinutes: ${DEFAULTS.IDLE_WATCHDOG_MINUTES}
282
276
 
283
- output:
284
- showThinking: true
285
- showToolUse: true
286
- showToolResult: true
287
-
288
277
  logging:
289
278
  level: info
290
279
 
@@ -34,6 +34,7 @@ export function loadKimiConfig() {
34
34
  stdio: ['ignore', 'pipe', 'ignore'],
35
35
  timeout: 10000,
36
36
  maxBuffer: 2 * 1024 * 1024,
37
+ windowsHide: true, // kimi 是 npm .cmd 垫片:不隐藏会闪 cmd.exe 控制台
37
38
  });
38
39
  const data = JSON.parse(output.toString());
39
40
  if (!data.models || Object.keys(data.models).length === 0) {
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * 从 opencode models --verbose 命令获取 provider 和 model 信息
5
5
  */
6
- import { execSync } from 'node:child_process';
6
+ import { resolveExecutable } from '../platform/command.js';
7
+ import { spawnProcessSync } from '../platform/spawn.js';
7
8
  import { getLogger } from '../logger/index.js';
8
9
  /**
9
10
  * 内置的 fallback 列表
@@ -66,13 +67,23 @@ export function loadOpencodeConfig() {
66
67
  return buildFallbackResult();
67
68
  }
68
69
  try {
70
+ // 经 seam 解析真实可执行文件(win32 PATHEXT),cross-spawn 同步执行
71
+ const resolved = resolveExecutable('opencode');
72
+ if (!resolved)
73
+ throw new Error('opencode not found');
69
74
  // 执行 opencode models --verbose 获取所有模型信息
70
- const output = execSync('opencode models --verbose', {
75
+ const res = spawnProcessSync(resolved.file, ['models', '--verbose'], {
71
76
  encoding: 'utf-8',
72
77
  // P1-8:30s → 10s,与 codex/kimi 对齐;maxBuffer 防模型清单 ENOBUFS 隐性截断
73
78
  timeout: 10000,
74
79
  maxBuffer: 64 * 1024 * 1024,
75
80
  });
81
+ if (res.error)
82
+ throw res.error;
83
+ if (res.status !== 0) {
84
+ throw new Error(`opencode models exited code=${res.status}: ${String(res.stderr).slice(-200)}`);
85
+ }
86
+ const output = String(res.stdout);
76
87
  // 按 provider 分组收集模型(只存 model ID,不存 provider/model 完整格式)
77
88
  const providerModels = new Map();
78
89
  // 解析输出:每个模型以 provider/model 格式开头,后面跟着 JSON 块
@@ -0,0 +1,36 @@
1
+ export type TerminalQrMode = 'full-block' | 'half-block';
2
+ export interface TerminalQr {
3
+ mode: TerminalQrMode;
4
+ /** Columns the rendered symbol occupies — compare against the terminal width. */
5
+ columns: number;
6
+ /** Symbol rows, one text row per line, no trailing newline. */
7
+ text: string;
8
+ }
9
+ /**
10
+ * Render a scannable symbol for the current terminal.
11
+ *
12
+ * Prefers the Windows-safe full-block rendering; when it is too wide for the
13
+ * terminal, POSIX terminals fall back to the compact half-block rendering
14
+ * (macOS/Linux terminals draw its glyphs correctly). Windows deliberately does
15
+ * not: half blocks are exactly what does not scan there, so the caller falls
16
+ * back to the image file instead. Returns `null` when nothing fits.
17
+ */
18
+ export declare function renderTerminalQr(text: string, options?: {
19
+ columns?: number;
20
+ platform?: NodeJS.Platform;
21
+ }): TerminalQr | null;
22
+ /**
23
+ * Render the symbol as a GIF.
24
+ *
25
+ * The escape hatch for terminals where block rendering still does not scan
26
+ * (narrow window, missing glyphs): a real image file always scans.
27
+ */
28
+ export declare function renderQrImage(text: string, cellSize?: number): Buffer;
29
+ /**
30
+ * Whether the wizard should also write the QR image file.
31
+ *
32
+ * Windows always gets the file: its consoles are exactly where block glyphs
33
+ * render unreliably, so the image is the dependable path there. Elsewhere the
34
+ * file is only written when the terminal could not show a symbol at all.
35
+ */
36
+ export declare function needsQrImage(terminalQr: TerminalQr | null, platform?: NodeJS.Platform): boolean;
@@ -0,0 +1,124 @@
1
+ import qrcode from 'qrcode-generator';
2
+ import { currentPlatform, isWin32 } from '../platform/select.js';
3
+ /**
4
+ * Terminal rendering for the first-run wizard's QR symbol.
5
+ *
6
+ * Everything is drawn from one module matrix. Two renderers exist because
7
+ * terminals disagree about block glyphs:
8
+ *
9
+ * - `full-block` draws each module as two `█` columns. It only relies on the
10
+ * full block glyph, which every console font has, and stays square in a
11
+ * typical 1:2 terminal cell. This is the Windows-safe rendering.
12
+ * - `half-block` packs two QR rows into one text row with `▀`/`▄`. It is half
13
+ * as tall, but needs a font that draws those glyphs as exact half-height
14
+ * blocks — Windows consoles (raster/CJK fonts, extra line spacing) often
15
+ * don't, which is why the symbol printed there often cannot be scanned.
16
+ *
17
+ * Both renderers emit the 4-module light quiet zone the QR spec requires;
18
+ * `qrcode-terminal`'s half-block output had none, and a missing quiet zone
19
+ * alone can make scanners fail.
20
+ *
21
+ * Polarity follows `qrcode-terminal`: glyphs are painted in the foreground
22
+ * colour and stand for the *light* modules, so the terminal's dark background
23
+ * supplies the dark modules (the common default theme).
24
+ */
25
+ /** Light modules that must surround a symbol for scanners to lock on (QR spec). */
26
+ const QUIET_ZONE = 4;
27
+ const LIGHT_FULL = '██';
28
+ const DARK_FULL = ' ';
29
+ const LIGHT_HALF_BOTH = '█';
30
+ const LIGHT_HALF_TOP = '▀';
31
+ const LIGHT_HALF_BOTTOM = '▄';
32
+ const DARK_HALF_BOTH = ' ';
33
+ /** Used when stdout reports no width — non-TTY pipes, and consoles that report 0. */
34
+ const DEFAULT_COLUMNS = 80;
35
+ function buildSymbol(text) {
36
+ const symbol = qrcode(0, 'L');
37
+ symbol.addData(text);
38
+ symbol.make();
39
+ return symbol;
40
+ }
41
+ function renderFullBlock(symbol) {
42
+ const count = symbol.getModuleCount();
43
+ const lightRow = LIGHT_FULL.repeat(count + QUIET_ZONE * 2);
44
+ const pad = LIGHT_FULL.repeat(QUIET_ZONE);
45
+ const rows = new Array(count + QUIET_ZONE * 2).fill(lightRow);
46
+ for (let row = 0; row < count; row++) {
47
+ let line = pad;
48
+ for (let col = 0; col < count; col++) {
49
+ line += symbol.isDark(row, col) ? DARK_FULL : LIGHT_FULL;
50
+ }
51
+ rows[QUIET_ZONE + row] = line + pad;
52
+ }
53
+ return rows.join('\n');
54
+ }
55
+ function renderHalfBlock(symbol) {
56
+ const count = symbol.getModuleCount();
57
+ // Module counts are always odd (4 * version + 17), so the final row pairs
58
+ // with an implicit light row.
59
+ const textRows = Math.ceil(count / 2);
60
+ const quietRows = QUIET_ZONE / 2;
61
+ const lightRow = LIGHT_HALF_BOTH.repeat(count + QUIET_ZONE * 2);
62
+ const rows = new Array(textRows + quietRows * 2).fill(lightRow);
63
+ for (let pair = 0; pair < count; pair += 2) {
64
+ let line = LIGHT_HALF_BOTH.repeat(QUIET_ZONE);
65
+ for (let col = 0; col < count; col++) {
66
+ const top = symbol.isDark(pair, col);
67
+ const bottom = pair + 1 < count && symbol.isDark(pair + 1, col);
68
+ if (!top && !bottom)
69
+ line += LIGHT_HALF_BOTH;
70
+ else if (!top)
71
+ line += LIGHT_HALF_TOP;
72
+ else if (!bottom)
73
+ line += LIGHT_HALF_BOTTOM;
74
+ else
75
+ line += DARK_HALF_BOTH;
76
+ }
77
+ rows[quietRows + pair / 2] = line + LIGHT_HALF_BOTH.repeat(QUIET_ZONE);
78
+ }
79
+ return rows.join('\n');
80
+ }
81
+ /**
82
+ * Render a scannable symbol for the current terminal.
83
+ *
84
+ * Prefers the Windows-safe full-block rendering; when it is too wide for the
85
+ * terminal, POSIX terminals fall back to the compact half-block rendering
86
+ * (macOS/Linux terminals draw its glyphs correctly). Windows deliberately does
87
+ * not: half blocks are exactly what does not scan there, so the caller falls
88
+ * back to the image file instead. Returns `null` when nothing fits.
89
+ */
90
+ export function renderTerminalQr(text, options = {}) {
91
+ const columns = options.columns && options.columns > 0 ? options.columns : DEFAULT_COLUMNS;
92
+ const platform = options.platform ?? currentPlatform;
93
+ const symbol = buildSymbol(text);
94
+ const width = symbol.getModuleCount() + QUIET_ZONE * 2;
95
+ if (width * 2 <= columns) {
96
+ return { mode: 'full-block', columns: width * 2, text: renderFullBlock(symbol) };
97
+ }
98
+ if (!isWin32(platform) && width <= columns) {
99
+ return { mode: 'half-block', columns: width, text: renderHalfBlock(symbol) };
100
+ }
101
+ return null;
102
+ }
103
+ /**
104
+ * Render the symbol as a GIF.
105
+ *
106
+ * The escape hatch for terminals where block rendering still does not scan
107
+ * (narrow window, missing glyphs): a real image file always scans.
108
+ */
109
+ export function renderQrImage(text, cellSize = 8) {
110
+ const symbol = buildSymbol(text);
111
+ const dataUrl = symbol.createDataURL(cellSize);
112
+ return Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
113
+ }
114
+ /**
115
+ * Whether the wizard should also write the QR image file.
116
+ *
117
+ * Windows always gets the file: its consoles are exactly where block glyphs
118
+ * render unreliably, so the image is the dependable path there. Elsewhere the
119
+ * file is only written when the terminal could not show a symbol at all.
120
+ */
121
+ export function needsQrImage(terminalQr, platform = currentPlatform) {
122
+ return terminalQr === null || isWin32(platform);
123
+ }
124
+ //# sourceMappingURL=qr.js.map
@@ -1,41 +1,83 @@
1
1
  import { registerApp } from '@larksuite/channel';
2
- import qrcode from 'qrcode-terminal';
3
2
  import fs from 'node:fs';
4
3
  import path from 'node:path';
5
4
  import YAML from 'yaml';
6
5
  import { AppConfigSchema } from './index.js';
6
+ import { needsQrImage, renderQrImage, renderTerminalQr } from './qr.js';
7
+ import { silentlyUnlink } from '../common/fs.js';
8
+ /** Where the fallback QR image is written (next to the config it will seed). */
9
+ const QR_IMAGE_NAME = 'qr-code.gif';
10
+ /**
11
+ * Print the scan-to-create QR code.
12
+ *
13
+ * The terminal symbol uses full blocks on Windows (half blocks there often
14
+ * render wrong and cannot be scanned); the image file is offered whenever the
15
+ * terminal cannot show a trustworthy symbol — Windows, or a window too narrow
16
+ * for the symbol to fit without wrapping.
17
+ *
18
+ * Returns true when the fallback image was written, so the caller only cleans
19
+ * up a file that actually exists.
20
+ */
21
+ function printRegistrationQr(info, imagePath) {
22
+ console.log('请用飞书 App 扫描以下二维码完成应用创建:\n');
23
+ const qr = renderTerminalQr(info.url, { columns: process.stdout.columns });
24
+ if (qr)
25
+ console.log(`${qr.text}\n`);
26
+ else
27
+ console.log('(当前终端太窄,二维码显示不下:可拉宽窗口重试,或扫下面的图片)\n');
28
+ let wroteImage = false;
29
+ if (needsQrImage(qr)) {
30
+ try {
31
+ fs.mkdirSync(path.dirname(imagePath), { recursive: true });
32
+ fs.writeFileSync(imagePath, renderQrImage(info.url));
33
+ wroteImage = true;
34
+ console.log(`二维码图片:${imagePath}(或用图片查看器打开后扫码)`);
35
+ }
36
+ catch (err) {
37
+ console.log(`二维码图片写入失败:${String(err)}`);
38
+ }
39
+ }
40
+ const mins = Math.max(1, Math.round(info.expireIn / 60));
41
+ console.log(`\n二维码有效期:约 ${mins} 分钟`);
42
+ console.log(`也可以直接在浏览器打开:${info.url}\n`);
43
+ return wroteImage;
44
+ }
7
45
  /**
8
46
  * Run the QR-code registration wizard. The user scans the QR with their
9
47
  * Feishu/Lark app to create a new application; the SDK polls until the
10
48
  * creation completes and returns the credentials.
11
49
  */
12
- async function runRegistrationWizard() {
50
+ async function runRegistrationWizard(configPath) {
13
51
  console.log('\n[lark-remote] 未检测到飞书应用配置,进入扫码创建向导。\n');
14
- const result = await registerApp({
15
- source: 'lark-remote',
16
- onQRCodeReady: (info) => {
17
- console.log('请用飞书 App 扫描以下二维码完成应用创建:\n');
18
- qrcode.generate(info.url, { small: true });
19
- const mins = Math.max(1, Math.round(info.expireIn / 60));
20
- console.log(`\n二维码有效期:约 ${mins} 分钟`);
21
- console.log(`也可以直接在浏览器打开:${info.url}\n`);
22
- },
23
- onStatusChange: (info) => {
24
- if (info.status === 'domain_switched') {
25
- console.log('识别到国际版租户,已切换到 larksuite.com 域名。');
26
- }
27
- else if (info.status === 'slow_down') {
28
- console.log('轮询速度过快,已自动降速。');
29
- }
30
- },
31
- });
32
- console.log('\n✓ 应用创建成功');
33
- console.log(` App ID: ${result.client_id}`);
34
- if (result.user_info?.tenant_brand) {
35
- console.log(` Tenant: ${result.user_info.tenant_brand}`);
52
+ const imagePath = path.join(path.dirname(configPath), QR_IMAGE_NAME);
53
+ let wroteImage = false;
54
+ try {
55
+ const result = await registerApp({
56
+ source: 'lark-remote',
57
+ onQRCodeReady: (info) => {
58
+ wroteImage = printRegistrationQr(info, imagePath);
59
+ },
60
+ onStatusChange: (info) => {
61
+ if (info.status === 'domain_switched') {
62
+ console.log('识别到国际版租户,已切换到 larksuite.com 域名。');
63
+ }
64
+ else if (info.status === 'slow_down') {
65
+ console.log('轮询速度过快,已自动降速。');
66
+ }
67
+ },
68
+ });
69
+ console.log('\n✓ 应用创建成功');
70
+ console.log(` App ID: ${result.client_id}`);
71
+ if (result.user_info?.tenant_brand) {
72
+ console.log(` Tenant: ${result.user_info.tenant_brand}`);
73
+ }
74
+ console.log('');
75
+ return { appId: result.client_id, appSecret: result.client_secret };
76
+ }
77
+ finally {
78
+ if (wroteImage)
79
+ silentlyUnlink(imagePath);
36
80
  }
37
- console.log('');
38
- return { appId: result.client_id, appSecret: result.client_secret };
39
81
  }
40
82
  function isInteractiveTerminal() {
41
83
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
@@ -77,7 +119,7 @@ export async function ensureConfig(configPath) {
77
119
  return;
78
120
  if (!isInteractiveTerminal())
79
121
  return;
80
- const { appId, appSecret } = await runRegistrationWizard();
122
+ const { appId, appSecret } = await runRegistrationWizard(configPath);
81
123
  writeConfigWithCredentials(configPath, appId, appSecret);
82
124
  console.log(`[lark-remote] 凭证已写入 ${configPath},继续启动...\n`);
83
125
  }
@@ -6,9 +6,10 @@ import type { AppConfig } from '../config/index.js';
6
6
  * 必须远小于用户连击间隔,否则同一按钮的连续点击会被当成"重复事件"丢弃:
7
7
  * - SDK 的 cardAction dedup eventId = `card:{messageId}:{operator.openId}:{actionId}`
8
8
  * - actionId = `tag|name|option|JSON.stringify(value)`,不含时间戳/事件序号
9
- * - config 卡片原地更新(updateCardInPlace),用户在同一张卡上连点 toggle:
9
+ * - config 卡片原地更新(updateCardInPlace),用户在同一张卡上重复触发
10
+ * 固定 value 的按钮(如 config.toggle):
10
11
  * messageId / operator / value 三段都相同 → eventId 完全相同 → 第二次点击被
11
- * seenCache drop,toggle 不可逆("显示工具结果"等开关第二次点击静默失效)。
12
+ * seenCache drop,toggle 不可逆(第二次点击静默失效)。
12
13
  *
13
14
  * SDK 默认 12h 太长;60s 仍会误伤连击。300ms 挡飞书瞬时重投递(<100ms 级),
14
15
  * 放过用户连击(慢点两次通常 >500ms)。代价:削弱 message 秒级重投递防护——
@@ -6,6 +6,7 @@ import { sleep } from '../common/sleep.js';
6
6
  import axios from 'axios';
7
7
  import fs from 'node:fs';
8
8
  import { silentlyUnlink } from '../common/fs.js';
9
+ import { displayName } from '../platform/path.js';
9
10
  import https from 'node:https';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
@@ -29,9 +30,10 @@ const noKeepAliveAgent = new https.Agent({ keepAlive: false });
29
30
  * 必须远小于用户连击间隔,否则同一按钮的连续点击会被当成"重复事件"丢弃:
30
31
  * - SDK 的 cardAction dedup eventId = `card:{messageId}:{operator.openId}:{actionId}`
31
32
  * - actionId = `tag|name|option|JSON.stringify(value)`,不含时间戳/事件序号
32
- * - config 卡片原地更新(updateCardInPlace),用户在同一张卡上连点 toggle:
33
+ * - config 卡片原地更新(updateCardInPlace),用户在同一张卡上重复触发
34
+ * 固定 value 的按钮(如 config.toggle):
33
35
  * messageId / operator / value 三段都相同 → eventId 完全相同 → 第二次点击被
34
- * seenCache drop,toggle 不可逆("显示工具结果"等开关第二次点击静默失效)。
36
+ * seenCache drop,toggle 不可逆(第二次点击静默失效)。
35
37
  *
36
38
  * SDK 默认 12h 太长;60s 仍会误伤连击。300ms 挡飞书瞬时重投递(<100ms 级),
37
39
  * 放过用户连击(慢点两次通常 >500ms)。代价:削弱 message 秒级重投递防护——
@@ -403,7 +405,7 @@ export class FeishuConnector {
403
405
  if (stat.size > MAX_FILE_UPLOAD_SIZE) {
404
406
  throw new Error(`File too large (${(stat.size / 1_000_000).toFixed(1)}MB), exceeds ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB limit`);
405
407
  }
406
- const fileName = filePath.split('/').pop() ?? 'file';
408
+ const fileName = displayName(filePath);
407
409
  // P2-18: capture the read stream so it can be destroyed on any failure
408
410
  // path. Without this, a token/upload/send failure leaks the fd (axios
409
411
  // only closes the stream when it fully consumes it on success).
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { probeAllAgents } from './runner/probe.js';
9
9
  import { warmCodexCatalogCache } from './config/codex-config.js';
10
10
  import { SessionReaderRegistry, SessionStore } from './session/index.js';
11
11
  import { ClaudeSessionReader, CodexSessionReader, OpencodeSessionReader, PiSessionReader, KimiSessionReader, DshSessionReader, } from './session/index.js';
12
- import { CommandRouter, isImmediateAction } from './router/index.js';
12
+ import { CommandRouter, isImmediateAction, DIRECT_RETURN_CMDS, } from './router/index.js';
13
13
  import { dispatchOrderExecForQueue } from './router/order-exec-dispatch.js';
14
14
  import { buildCardActionFullValue } from './router/card-action-payload.js';
15
15
  import { Bridge } from './bridge/index.js';
@@ -117,13 +117,14 @@ function setupInstanceLockAndHandlers(instanceLock, logger, configDir) {
117
117
  /** Initialize per-workspace runner factory + agent registry. */
118
118
  function initializeRunner(config, configDir, cliArgs) {
119
119
  const agentRegistry = new AgentRegistry();
120
+ // W2.10 单点化:runner factory 取最新 config 的唯一入口(P1-15 语义保持,
121
+ // container 未接线时回退启动快照)。
122
+ const latest = () => agentRegistry.getConfigContainer()?.current ?? config;
120
123
  // P1-15: Claude factory reads from configContainer (not closure) so runtime
121
124
  // config changes (model, effort, stopGraceMs) take effect after
122
125
  // bridge.setConfig() + clearRunners(). Same pattern as codex/pi/kimi.
123
126
  agentRegistry.register('claude', (ws) => {
124
- const container = agentRegistry.getConfigContainer();
125
- const latestConfig = container?.current ?? config;
126
- const claudeConfig = latestConfig.claude;
127
+ const claudeConfig = latest().claude;
127
128
  return new ClaudeRunner({
128
129
  model: claudeConfig.model,
129
130
  effort: claudeConfig.effort,
@@ -141,9 +142,7 @@ function initializeRunner(config, configDir, cliArgs) {
141
142
  // + clearRunners().
142
143
  const codexSessionReader = new CodexSessionReader({ codexHome: process.env.CODEX_HOME });
143
144
  agentRegistry.register('codex', (_ws) => {
144
- const container = agentRegistry.getConfigContainer();
145
- const latestConfig = container?.current ?? config;
146
- const codexConfig = getAgentConfig(latestConfig, 'codex');
145
+ const codexConfig = getAgentConfig(latest(), 'codex');
147
146
  return new CodexAppServerRunner({
148
147
  kind: 'codex',
149
148
  model: codexConfig?.model,
@@ -169,10 +168,7 @@ function initializeRunner(config, configDir, cliArgs) {
169
168
  // Using `opencode session list` and `opencode export` instead of HTTP API
170
169
  const opencodeSessionReader = new OpencodeSessionReader();
171
170
  agentRegistry.register('opencode', (_ws) => {
172
- // Get latest config from container (set below for pi)
173
- const container = agentRegistry.getConfigContainer();
174
- const latestConfig = container?.current ?? config;
175
- const ocConfig = getAgentConfig(latestConfig, 'opencode');
171
+ const ocConfig = getAgentConfig(latest(), 'opencode');
176
172
  // Build model string with validation: provider/model format
177
173
  let model;
178
174
  if (ocConfig?.modelID) {
@@ -201,10 +197,7 @@ function initializeRunner(config, configDir, cliArgs) {
201
197
  agentRegistry.setConfigContainer(configContainer);
202
198
  const piSessionReader = new PiSessionReader();
203
199
  agentRegistry.register('pi', (ws) => {
204
- // 每次 factory 调用时从 registry 获取最新 config
205
- const container = agentRegistry.getConfigContainer();
206
- const latestConfig = container?.current ?? config;
207
- const piConf = getAgentConfig(latestConfig, 'pi');
200
+ const piConf = getAgentConfig(latest(), 'pi');
208
201
  return new PiRpcRunner({
209
202
  provider: piConf?.provider ?? 'Volcano',
210
203
  model: piConf?.model ?? 'glm-5.2',
@@ -221,10 +214,7 @@ function initializeRunner(config, configDir, cliArgs) {
221
214
  // Register KimiAcpRunner (pure ACP mode) and KimiSessionReader
222
215
  const kimiSessionReader = new KimiSessionReader();
223
216
  agentRegistry.register('kimi', (_ws) => {
224
- // 每次 factory 调用时从 registry 获取最新 config
225
- const container = agentRegistry.getConfigContainer();
226
- const latestConfig = container?.current ?? config;
227
- const kimiConf = getAgentConfig(latestConfig, 'kimi');
217
+ const kimiConf = getAgentConfig(latest(), 'kimi');
228
218
  const acpConf = kimiConf?.acp;
229
219
  return new KimiAcpRunner({
230
220
  kind: 'kimi',
@@ -244,18 +234,10 @@ function initializeRunner(config, configDir, cliArgs) {
244
234
  const dshSessionReader = new DshSessionReader({
245
235
  // hostProvider 每次调用读最新 config:/config 修改 host 后 runner 重建为
246
236
  // 新 host,reader(/resume、用量、完成卡)也跟随新 host(CC-02)。
247
- hostProvider: () => {
248
- const container = agentRegistry.getConfigContainer();
249
- const latestConfig = container?.current ?? config;
250
- return getAgentConfig(latestConfig, 'dsh')?.host;
251
- },
237
+ hostProvider: () => getAgentConfig(latest(), 'dsh')?.host,
252
238
  });
253
239
  agentRegistry.register('dsh', (_ws) => {
254
- // Read latest config from container so /config host changes take effect
255
- // after bridge.setConfig() + clearRunners().
256
- const container = agentRegistry.getConfigContainer();
257
- const latestConfig = container?.current ?? config;
258
- const dshConf = getAgentConfig(latestConfig, 'dsh');
240
+ const dshConf = getAgentConfig(latest(), 'dsh');
259
241
  return new DshRunner({
260
242
  kind: 'dsh',
261
243
  sessionReader: dshSessionReader,
@@ -541,18 +523,9 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
541
523
  // order.aliasInput / order.aliasRemove / order.textInput 也返回 toast+card,
542
524
  // 需直返避免被吞(order.textInput 曾漏在列表外,编辑卡停留在编辑界面)。
543
525
  // approval.planFeedback 同 answer 系列:附意见的 toast 需直返 + 不落串行队列。
544
- if (actionValue.cmd === 'queue.input' ||
545
- actionValue.cmd === 'order.aliasInput' ||
546
- actionValue.cmd === 'order.aliasRemove' ||
547
- actionValue.cmd === 'order.textInput' ||
548
- actionValue.cmd === 'config.save' ||
549
- actionValue.cmd === 'approval.respond' ||
550
- actionValue.cmd === 'approval.toggle' ||
551
- actionValue.cmd === 'approval.answer' ||
552
- actionValue.cmd === 'approval.answerSubmit' ||
553
- actionValue.cmd === 'approval.answerCustom' ||
554
- actionValue.cmd === 'approval.answerNote' ||
555
- actionValue.cmd === 'approval.planFeedback') {
526
+ // W2.1:直返名单以 router 的 DIRECT_RETURN_CMDS 为单一来源(历史注释:
527
+ // order.textInput 曾漏在列表外、answer 家族曾漏在直返列表外——两份拷贝漂移)。
528
+ if (DIRECT_RETURN_CMDS.has(actionValue.cmd)) {
556
529
  return router.handleCardAction(fullValue, { userId, chatId, messageId });
557
530
  }
558
531
  // order.exec → equivalent queued message (Plan A): resolve the order text