lark-remote 0.4.2 → 0.4.3

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 (81) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/bridge/approval-coordinator.d.ts +2 -0
  3. package/dist/bridge/approval-coordinator.js +16 -7
  4. package/dist/bridge/index.d.ts +22 -0
  5. package/dist/bridge/index.js +138 -56
  6. package/dist/card/card-budget.js +39 -26
  7. package/dist/card/run-renderer.js +9 -2
  8. package/dist/card/text-truncate.d.ts +9 -10
  9. package/dist/card/text-truncate.js +48 -43
  10. package/dist/cli.js +17 -0
  11. package/dist/config/codex-config.d.ts +3 -0
  12. package/dist/config/codex-config.js +4 -1
  13. package/dist/config/dir.js +6 -6
  14. package/dist/config/index.d.ts +7 -5
  15. package/dist/config/index.js +52 -13
  16. package/dist/config/kimi-config.d.ts +4 -1
  17. package/dist/config/kimi-config.js +5 -2
  18. package/dist/config/pi-config.d.ts +2 -7
  19. package/dist/config/pi-config.js +6 -6
  20. package/dist/config/wizard.js +8 -4
  21. package/dist/connector/index.d.ts +19 -0
  22. package/dist/connector/index.js +134 -23
  23. package/dist/inbound/placeholder.d.ts +3 -2
  24. package/dist/inbound/placeholder.js +17 -7
  25. package/dist/index.js +28 -6
  26. package/dist/instance-lock.d.ts +31 -15
  27. package/dist/instance-lock.js +64 -62
  28. package/dist/platform/agent-stopper.d.ts +29 -7
  29. package/dist/platform/agent-stopper.js +40 -8
  30. package/dist/platform/identity.d.ts +59 -3
  31. package/dist/platform/identity.js +105 -30
  32. package/dist/platform/path.d.ts +0 -7
  33. package/dist/platform/path.js +1 -51
  34. package/dist/platform/shell.d.ts +14 -0
  35. package/dist/platform/shell.js +35 -2
  36. package/dist/platform/sleep-blocker.d.ts +6 -0
  37. package/dist/platform/sleep-blocker.js +31 -5
  38. package/dist/platform/spawn.d.ts +1 -1
  39. package/dist/platform/spawn.js +1 -1
  40. package/dist/platform/terminator-posix.d.ts +1 -29
  41. package/dist/platform/terminator-posix.js +32 -30
  42. package/dist/platform/terminator-win32.js +4 -2
  43. package/dist/platform/terminator.d.ts +2 -2
  44. package/dist/platform/terminator.js +17 -2
  45. package/dist/platform/types.d.ts +3 -0
  46. package/dist/router/card-action-feedback.d.ts +17 -0
  47. package/dist/router/card-action-feedback.js +31 -0
  48. package/dist/router/config/claude.js +8 -5
  49. package/dist/router/index.d.ts +2 -0
  50. package/dist/router/index.js +35 -15
  51. package/dist/runner/claude/runner.js +1 -1
  52. package/dist/runner/claude/session.d.ts +2 -6
  53. package/dist/runner/claude/session.js +7 -8
  54. package/dist/runner/codex/app-server/protocol-types.d.ts +4 -0
  55. package/dist/runner/codex/app-server/protocol-types.js +4 -0
  56. package/dist/runner/codex/app-server/runner.js +3 -0
  57. package/dist/runner/common/acp/base-acp-runner.js +7 -0
  58. package/dist/runner/common/connection-based-runner.d.ts +18 -0
  59. package/dist/runner/common/connection-based-runner.js +31 -0
  60. package/dist/runner/common/jsonrpc/client.js +6 -1
  61. package/dist/runner/common/jsonrpc/connection-manager.d.ts +20 -0
  62. package/dist/runner/common/jsonrpc/connection-manager.js +27 -1
  63. package/dist/runner/common/jsonrpc/transport.d.ts +29 -1
  64. package/dist/runner/common/jsonrpc/transport.js +79 -10
  65. package/dist/runner/common/spawning-runner.d.ts +49 -19
  66. package/dist/runner/common/spawning-runner.js +94 -54
  67. package/dist/runner/dsh/client.d.ts +1 -3
  68. package/dist/runner/dsh/client.js +0 -4
  69. package/dist/runner/dsh/types.d.ts +0 -36
  70. package/dist/runner/kimi/acp/runner.d.ts +18 -1
  71. package/dist/runner/kimi/acp/runner.js +100 -42
  72. package/dist/runner/kimi/acp/translator.d.ts +0 -1
  73. package/dist/runner/opencode/acp/translator.d.ts +0 -1
  74. package/dist/runner/pi/rpc/protocol-types.d.ts +4 -0
  75. package/dist/runner/pi/rpc/runner.js +3 -0
  76. package/dist/runner/resolve-agent-choices.js +0 -5
  77. package/package.json +2 -2
  78. package/dist/runner/common/process-stopper.d.ts +0 -7
  79. package/dist/runner/common/process-stopper.js +0 -8
  80. package/dist/runner/dsh/fake-dsh-server.d.ts +0 -66
  81. package/dist/runner/dsh/fake-dsh-server.js +0 -162
@@ -22,6 +22,8 @@ export const FEISHU_MAX_TABLES = 5;
22
22
  * 共用,避免三处各自定义后数值漂移。
23
23
  */
24
24
  export const CARD_BUDGET_BYTES = 28_000;
25
+ /** markdown table 分隔行判据:以 `|` 开头,只含 `| - : 空格`。 */
26
+ const TABLE_SEPARATOR_RE = /^\|[-: |]+$/;
25
27
  /**
26
28
  * Count the number of markdown tables in a string.
27
29
  *
@@ -32,43 +34,24 @@ export const CARD_BUDGET_BYTES = 28_000;
32
34
  export function countMarkdownTables(text) {
33
35
  let count = 0;
34
36
  for (const line of text.split('\n')) {
35
- if (/^\|[-: |]+$/.test(line.trim())) {
37
+ if (TABLE_SEPARATOR_RE.test(line.trim())) {
36
38
  count++;
37
39
  }
38
40
  }
39
41
  return count;
40
42
  }
41
43
  /**
42
- * Truncate markdown tables in a string to at most `maxTables`, keeping the
43
- * **newest** (last) tables and removing the oldest ones.
44
- *
45
- * Strategy: find all table separator lines, determine which tables to remove
46
- * (those whose separator is among the oldest `count - maxTables`), then remove
47
- * each such table entirely (header row + separator row + data rows).
48
- *
49
- * A "table block" is a contiguous run of lines where the first line is the
50
- * header row, the second is the separator row (`|---|`), and subsequent lines
51
- * are data rows. We remove entire table blocks from the text.
44
+ * 按文档顺序定位字符串里的 markdown table 块。
52
45
  *
53
- * When tables are removed, a hint is inserted at the removal point indicating
54
- * how many earlier tables were omitted.
46
+ * `truncateMarkdownTables`(单串)与 `enforceCardBudget`(整卡预算)共用这一份
47
+ * 识别口径——两处各写一遍分隔行正数是下一个 bug 的温床。
55
48
  */
56
- export function truncateMarkdownTables(text, maxTables = FEISHU_MAX_TABLES) {
57
- if (maxTables <= 0)
58
- return text;
59
- const tableCount = countMarkdownTables(text);
60
- if (tableCount <= maxTables)
61
- return text;
49
+ function findMarkdownTableBlocks(text) {
62
50
  const lines = text.split('\n');
63
- // Step 1: Find all table separator line indices
64
- const separatorIndices = [];
65
- for (let i = 0; i < lines.length; i++) {
66
- if (/^\|[-: |]+$/.test(lines[i].trim())) {
67
- separatorIndices.push(i);
68
- }
69
- }
70
51
  const blocks = [];
71
- for (const sepIdx of separatorIndices) {
52
+ for (let sepIdx = 0; sepIdx < lines.length; sepIdx++) {
53
+ if (!TABLE_SEPARATOR_RE.test(lines[sepIdx].trim()))
54
+ continue;
72
55
  // Header is the line just before the separator
73
56
  let start = sepIdx - 1;
74
57
  if (start < 0)
@@ -90,30 +73,37 @@ export function truncateMarkdownTables(text, maxTables = FEISHU_MAX_TABLES) {
90
73
  while (end < lines.length && /^\|/.test(lines[end].trim())) {
91
74
  end++;
92
75
  }
93
- blocks.push({ start, end, separatorIdx: sepIdx });
76
+ blocks.push({ start, end });
94
77
  }
95
- // Step 3: Determine which blocks to remove (oldest first)
96
- const toRemoveCount = blocks.length - maxTables;
97
- // blocks are in document order (oldest first), so remove from the front
98
- const blocksToRemove = new Set(blocks.slice(0, toRemoveCount));
99
- // Map: start line index → block (for lookup when processing lines)
78
+ return blocks;
79
+ }
80
+ /**
81
+ * 删掉 `text` 中文档顺序**最旧**的至多 `maxDrop` 个 table 块(保留其余,含最新
82
+ * 的那些),并在最后一个删除点插入省略提示。`maxDrop <= 0` 时原样返回。
83
+ *
84
+ * 与 `truncateMarkdownTables` 同一口径:删最旧、留最新。
85
+ */
86
+ export function dropOldestMarkdownTables(text, maxDrop) {
87
+ if (maxDrop <= 0)
88
+ return text;
89
+ const lines = text.split('\n');
90
+ const blocksToRemove = findMarkdownTableBlocks(text).slice(0, maxDrop);
91
+ if (blocksToRemove.length === 0)
92
+ return text;
100
93
  const blockByStart = new Map();
101
- for (const b of blocks) {
94
+ for (const b of blocksToRemove)
102
95
  blockByStart.set(b.start, b);
103
- }
104
- // Step 4: Rebuild the text, skipping removed table blocks and inserting hints
96
+ const toRemove = blocksToRemove.length;
105
97
  const result = [];
106
98
  let i = 0;
107
- let removedSoFar = 0;
99
+ let dropped = 0;
108
100
  while (i < lines.length) {
109
101
  const block = blockByStart.get(i);
110
- if (block && blocksToRemove.has(block)) {
111
- // Skip the entire table block
112
- removedSoFar++;
102
+ if (block) {
103
+ dropped++;
113
104
  i = block.end;
114
- // Insert hint at the removal point
115
- if (removedSoFar === toRemoveCount) {
116
- result.push(`_💡 前 ${toRemoveCount} 个表格已省略_`);
105
+ if (dropped === toRemove) {
106
+ result.push(`_💡 前 ${dropped} 个表格已省略_`);
117
107
  }
118
108
  continue;
119
109
  }
@@ -122,4 +112,19 @@ export function truncateMarkdownTables(text, maxTables = FEISHU_MAX_TABLES) {
122
112
  }
123
113
  return result.join('\n');
124
114
  }
115
+ /**
116
+ * Truncate markdown tables in a string to at most `maxTables`, keeping the
117
+ * **newest** (last) tables and removing the oldest ones.
118
+ *
119
+ * 注意:`maxTables` 是**这一个字符串**的上限。飞书 11310 数的是**整卡**,
120
+ * 表分散在多个字段时必须走 `card-budget.ts` 的整卡预算,不能逐字段调用本函数。
121
+ */
122
+ export function truncateMarkdownTables(text, maxTables = FEISHU_MAX_TABLES) {
123
+ if (maxTables <= 0)
124
+ return text;
125
+ const dropCount = findMarkdownTableBlocks(text).length - maxTables;
126
+ if (dropCount <= 0)
127
+ return text;
128
+ return dropOldestMarkdownTables(text, dropCount);
129
+ }
125
130
  //# sourceMappingURL=text-truncate.js.map
package/dist/cli.js CHANGED
@@ -1,4 +1,21 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * CLI 引导层(bootstrap):在 import 应用模块图之前决定运行时(bun/node)并拉起它。
4
+ *
5
+ * **这是全仓唯一被认可的 spawn 收口例外**(即 AGENTS.md「子进程 spawn 只走
6
+ * platform/spawn.ts」红线的显式豁免)。三个理由缺一不可:
7
+ * 1. 时序:preflight(Node 版本守卫、-v/-h/--advance-help)必须在 import 应用
8
+ * 模块图之前跑完——dist/cli.js 刻意不使用 ES2020+ 语法以便在 node 12 上仍
9
+ * 可被解析,而 dist/index.js 是 ES2022;先 import platform/spawn 就等于先把
10
+ * 应用模块图拖进来,守卫当场失效(报错堆栈全落在 Node 内部)。
11
+ * 2. 依赖:platform/spawn.ts 依赖 cross-spawn(应用依赖树的一部分),在「刚
12
+ * 诊断出 Node 太老」的那个时刻并不可加载。
13
+ * 3. 语义:这里 spawn 的是**另一份运行时**(bun 重启自己),不是 agent/工具
14
+ * 子进程,不参与组杀、pid 文件、windowsHide 注入这整套 runner 语义。
15
+ *
16
+ * 结论:不要为了“统一收口”把这里改走 platform/spawn;反过来,除本文件之外
17
+ * 任何地方新增子进程都必须走 platform/spawn.ts。
18
+ */
2
19
  import fs from 'node:fs';
3
20
  import path from 'node:path';
4
21
  import { spawn, spawnSync } from 'node:child_process';
@@ -11,6 +11,9 @@
11
11
  /**
12
12
  * Resolve codex home directory.
13
13
  * Priority: explicit codexHome param > $CODEX_HOME env > ~/.codex
14
+ *
15
+ * 空串/纯空白按「未设置」处理:`??` 只挡 undefined,放过空串会让
16
+ * `path.join('', 'config.toml')` 变成相对**进程 cwd** 的路径。
14
17
  */
15
18
  export declare function resolveCodexHome(codexHome?: string): string;
16
19
  /** loadCodexConfig() return type */
@@ -22,9 +22,12 @@ const CODEX_MAX_BUFFER = 4 * 1024 * 1024;
22
22
  /**
23
23
  * Resolve codex home directory.
24
24
  * Priority: explicit codexHome param > $CODEX_HOME env > ~/.codex
25
+ *
26
+ * 空串/纯空白按「未设置」处理:`??` 只挡 undefined,放过空串会让
27
+ * `path.join('', 'config.toml')` 变成相对**进程 cwd** 的路径。
25
28
  */
26
29
  export function resolveCodexHome(codexHome) {
27
- return codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
30
+ return codexHome?.trim() || process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
28
31
  }
29
32
  /**
30
33
  * Fallback lists
@@ -133,20 +133,20 @@ export function printAdvanceHelp() {
133
133
  '',
134
134
  'Codex(agents.codex.appServer.*):',
135
135
  ' binary (默认 codex) codex 二进制路径',
136
- ' requestTimeoutMs (默认 60000) 单次 RPC 请求超时(ms)',
137
- ' idleTtlMs (默认 1800000) 连接空闲回收(ms)',
136
+ ' requestTimeoutMs (默认 60000) 单次 RPC 请求超时(ms),0=不超时',
137
+ ' idleTtlMs (默认 1800000) 连接空闲回收(ms),0=不回收',
138
138
  ' turnIdleTimeoutMinutes(默认 30) turn 无输出超时(分钟),0=关闭(卡片也可改)',
139
139
  '',
140
140
  'Kimi(agents.kimi.acp.*):',
141
141
  ' binary (默认 kimi) kimi 二进制路径',
142
- ' requestTimeoutMs (默认 60000) 单次请求超时(ms)',
143
- ' idleTtlMs (默认 1800000) 连接空闲回收(ms)',
142
+ ' requestTimeoutMs (默认 60000) 单次请求超时(ms),0=不超时',
143
+ ' idleTtlMs (默认 1800000) 连接空闲回收(ms),0=不回收',
144
144
  ' turnIdleTimeoutMinutes(默认 30) turn 无输出超时(分钟),0=关闭(卡片也可改)',
145
145
  '',
146
146
  'OpenCode(agents.opencode.acp.*):',
147
147
  ' binary (默认 opencode) opencode 二进制路径',
148
- ' requestTimeoutMs (默认 60000) 单次请求超时(ms)',
149
- ' idleTtlMs (默认 1800000) 连接空闲回收(ms)',
148
+ ' requestTimeoutMs (默认 60000) 单次请求超时(ms),0=不超时',
149
+ ' idleTtlMs (默认 1800000) 连接空闲回收(ms),0=不回收',
150
150
  ' turnIdleTimeoutMinutes(默认 30) turn 无输出超时(分钟),0=关闭(卡片也可改)',
151
151
  '',
152
152
  'Pi:',
@@ -3,11 +3,13 @@ export { getConfigDir } from './dir.js';
3
3
  /** Valid effort values for Claude */
4
4
  export declare const CLAUDE_EFFORTS: readonly ["low", "medium", "high", "xhigh", "max"];
5
5
  /**
6
- * Claude 官方 --permission-mode 枚举(claude --help 输出,2026-08 实测)。
7
- * 额外支持 'default':Claude settings.json 的官方未设置值,等价于不传
8
- * --permission-mode(交互式默认:高风险工具逐个询问)。
9
- * 'manual' 是 'default' 的别名(claude CLI 等价),schema 保留以向后兼容旧配置,
10
- * 但 UI 下拉不展示(见 router/config/claude.ts)。
6
+ * Claude 的权限模式取值:'default' 之外都是 CLI `--permission-mode` 的真实枚举
7
+ * (2026-09-20 实测 claude --help:acceptEdits | auto | bypassPermissions | manual
8
+ * | dontAsk | plan)。
9
+ * 'default' 是本项目自造的值,不是 CLI 的取值 —— 它表示"不传 --permission-mode",
10
+ * 由 CLI 自己决定默认模式,转换在 runner/claude/session.ts 的 spawn 参数处。
11
+ * 'manual' 与 'default' 不是别名关系:一个是显式传给 CLI 的取值,一个是省略该参数,
12
+ * schema 保留两者只为兼容旧配置(UI 下拉的处理见 router/config/claude.ts)。
11
13
  */
12
14
  export declare const CLAUDE_PERMISSION_MODES: readonly ["default", "acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan"];
13
15
  /** Valid thinking levels for Pi */
@@ -10,11 +10,13 @@ import { KIMI_THINKING_EFFORTS } from './kimi-config.js';
10
10
  /** Valid effort values for Claude */
11
11
  export const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
12
12
  /**
13
- * Claude 官方 --permission-mode 枚举(claude --help 输出,2026-08 实测)。
14
- * 额外支持 'default':Claude settings.json 的官方未设置值,等价于不传
15
- * --permission-mode(交互式默认:高风险工具逐个询问)。
16
- * 'manual' 是 'default' 的别名(claude CLI 等价),schema 保留以向后兼容旧配置,
17
- * 但 UI 下拉不展示(见 router/config/claude.ts)。
13
+ * Claude 的权限模式取值:'default' 之外都是 CLI `--permission-mode` 的真实枚举
14
+ * (2026-09-20 实测 claude --help:acceptEdits | auto | bypassPermissions | manual
15
+ * | dontAsk | plan)。
16
+ * 'default' 是本项目自造的值,不是 CLI 的取值 —— 它表示"不传 --permission-mode",
17
+ * 由 CLI 自己决定默认模式,转换在 runner/claude/session.ts 的 spawn 参数处。
18
+ * 'manual' 与 'default' 不是别名关系:一个是显式传给 CLI 的取值,一个是省略该参数,
19
+ * schema 保留两者只为兼容旧配置(UI 下拉的处理见 router/config/claude.ts)。
18
20
  */
19
21
  export const CLAUDE_PERMISSION_MODES = [
20
22
  'default',
@@ -96,9 +98,9 @@ const ClaudeConfigSchema = z.object({
96
98
  const CodexAppServerConfigSchema = z.object({
97
99
  /** Path to the codex binary for app-server mode. */
98
100
  binary: z.string().default('codex'),
99
- /** Request timeout in milliseconds. */
101
+ /** Request timeout in milliseconds. 0 = 不设超时(不是「0ms 即超时」)。 */
100
102
  requestTimeoutMs: z.number().int().min(0).default(60000),
101
- /** Idle TTL for connection pool in milliseconds. */
103
+ /** Idle TTL in milliseconds. 0 = 不回收常驻连接(对齐 claude.idleTtlMinutes)。 */
102
104
  idleTtlMs: z.number().int().min(0).default(1800000),
103
105
  /** Turn idle timeout in minutes: no app-server output for this long triggers
104
106
  * turn/interrupt and fails the run. 0 disables the timeout. */
@@ -141,9 +143,9 @@ const OpencodeConfigSchema = z.object({
141
143
  .object({
142
144
  /** Path to opencode binary for ACP mode. */
143
145
  binary: z.string().default('opencode'),
144
- /** Request timeout in milliseconds. */
146
+ /** Request timeout in milliseconds. 0 = 不设超时(不是「0ms 即超时」)。 */
145
147
  requestTimeoutMs: z.number().int().min(0).default(60000),
146
- /** Idle TTL for ACP connection in milliseconds. */
148
+ /** Idle TTL in milliseconds. 0 = 不回收常驻连接(对齐 claude.idleTtlMinutes)。 */
147
149
  idleTtlMs: z.number().int().min(0).default(1800000),
148
150
  /** Turn idle timeout in minutes (0 disables). */
149
151
  turnIdleTimeoutMinutes: z.number().int().min(0).default(DEFAULT_TURN_IDLE_TIMEOUT_MINUTES),
@@ -165,7 +167,7 @@ const PiConfigSchema = z.object({
165
167
  const KimiConfigSchema = z.object({
166
168
  /** Model ID or alias, e.g. 'kimi-code/k3'. */
167
169
  model: z.string().default('kimi-code/k3'),
168
- /** Thinking effort: 'on', 'max'. */
170
+ /** Thinking effort,取值见 KIMI_THINKING_EFFORTS('low' | 'high' | 'max')。 */
169
171
  thinkingEffort: z.enum(KIMI_THINKING_EFFORTS).default('max'),
170
172
  /** Permission mode: 'manual' (approve each), 'auto' (engine decides), 'yolo' (allow all). */
171
173
  permissionMode: z.enum(['manual', 'auto', 'yolo']).default('manual'),
@@ -174,9 +176,9 @@ const KimiConfigSchema = z.object({
174
176
  .object({
175
177
  /** Path to kimi binary for ACP mode. */
176
178
  binary: z.string().default('kimi'),
177
- /** Request timeout in milliseconds. */
179
+ /** Request timeout in milliseconds. 0 = 不设超时(不是「0ms 即超时」)。 */
178
180
  requestTimeoutMs: z.number().int().min(0).default(60000),
179
- /** Idle TTL for ACP connection in milliseconds. */
181
+ /** Idle TTL in milliseconds. 0 = 不回收常驻连接(对齐 claude.idleTtlMinutes)。 */
180
182
  idleTtlMs: z.number().int().min(0).default(1800000),
181
183
  /** Turn idle timeout in minutes (0 disables). */
182
184
  turnIdleTimeoutMinutes: z.number().int().min(0).default(DEFAULT_TURN_IDLE_TIMEOUT_MINUTES),
@@ -442,9 +444,46 @@ export function setConfigValues(configPath, config, updates) {
442
444
  const errors = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');
443
445
  throw new Error(`[lark-remote] config validation failed:\n${errors}`);
444
446
  }
445
- atomicWrite(configPath, YAML.stringify(result.data));
447
+ atomicWrite(configPath, renderConfigPatch(configPath, updates, result.data));
446
448
  return result.data;
447
449
  }
450
+ /**
451
+ * 写盘内容:读磁盘原文,把改动逐条打在 YAML Document 上(setIn 会按需补中间层),
452
+ * 而不是把校验结果整份 stringify —— 后者会把 dir.ts 正引导用户手写的注释、以及
453
+ * schema 不认识的键(Zod 默认剥离)在第一次保存时清光,还会把每个默认值都落进文件。
454
+ *
455
+ * 原文读不到或解析不了(进程启动后被改坏)时退回校验结果:那份原文已无保留价值,
456
+ * 与修复前的行为一致。
457
+ */
458
+ function renderConfigPatch(configPath, updates, validated) {
459
+ let doc;
460
+ try {
461
+ doc = YAML.parseDocument(fs.readFileSync(configPath, 'utf-8'), { prettyErrors: true });
462
+ if (doc.errors.length > 0)
463
+ throw new Error(doc.errors[0].message);
464
+ }
465
+ catch (err) {
466
+ getLogger().warn(`[config] existing file unusable, rewriting from validated config: ${err.message}`);
467
+ return YAML.stringify(validated);
468
+ }
469
+ for (const [key, value] of Object.entries(updates)) {
470
+ const parts = mapAgentKey(key).split('.');
471
+ if (value === undefined) {
472
+ doc.deleteIn(parts);
473
+ continue;
474
+ }
475
+ // 保存载荷里只写"校验结果中存活下来"的键:schema 不认识的键(§P2-24 的 foo.bar)
476
+ // 会被 Zod 剥掉,跟着打在 Document 上就是把垃圾永久写进用户配置文件。
477
+ // 磁盘上原有的未知键不受影响 —— 那是用户手写的,本函数不碰。
478
+ const accepted = getConfigValue(validated, parts.join('.'));
479
+ if (accepted === undefined) {
480
+ getLogger().warn(`[config] save payload key not in schema, not written: ${key}`);
481
+ continue;
482
+ }
483
+ doc.setIn(parts, accepted);
484
+ }
485
+ return doc.toString();
486
+ }
448
487
  /**
449
488
  * 按 dot-separated key 删除嵌套值(不存在的键静默忽略)。
450
489
  */
@@ -7,7 +7,10 @@
7
7
  /** Valid thinking effort values for kimi (Layer 1: Schema enum source) */
8
8
  export declare const KIMI_THINKING_EFFORTS: readonly ["low", "high", "max"];
9
9
  export type KimiThinkingEffort = (typeof KIMI_THINKING_EFFORTS)[number];
10
- /** Fallback efforts when all values are filtered out */
10
+ /**
11
+ * 兜底 effort 全集就是 schema 枚举本身(不是另一份字面量,否则新增取值要改两处)。
12
+ * 类型放宽为 readonly string[]:消费侧拿的是外部输入过滤后的兜底列表,不做枚举窄化。
13
+ */
11
14
  export declare const FALLBACK_EFFORTS: readonly string[];
12
15
  /** Load Kimi config and return available models */
13
16
  interface KimiConfigResult {
@@ -8,8 +8,11 @@ import { getLogger } from '../logger/index.js';
8
8
  import { spawnProcessSync } from '../platform/spawn.js';
9
9
  /** Valid thinking effort values for kimi (Layer 1: Schema enum source) */
10
10
  export const KIMI_THINKING_EFFORTS = ['low', 'high', 'max'];
11
- /** Fallback efforts when all values are filtered out */
12
- export const FALLBACK_EFFORTS = ['low', 'high', 'max'];
11
+ /**
12
+ * 兜底 effort 全集就是 schema 枚举本身(不是另一份字面量,否则新增取值要改两处)。
13
+ * 类型放宽为 readonly string[]:消费侧拿的是外部输入过滤后的兜底列表,不做枚举窄化。
14
+ */
15
+ export const FALLBACK_EFFORTS = KIMI_THINKING_EFFORTS;
13
16
  /** 清空缓存(仅供测试使用,生产代码无调用点) */
14
17
  export function invalidateKimiConfigCache() {
15
18
  kimiConfigCache = null;
@@ -6,14 +6,8 @@
6
6
  interface PiProviderInfo {
7
7
  /** Provider 名称 */
8
8
  name: string;
9
- /** API 端点(可选) */
10
- baseUrl?: string;
11
- /** API 类型 */
12
- api?: string;
13
9
  /** 该 provider 下的模型列表 */
14
10
  models: string[];
15
- /** 是否有认证信息 */
16
- isAuthenticated: boolean;
17
11
  }
18
12
  /** loadPiConfig() 的返回类型,包含 provider 和 model 的聚合信息 */
19
13
  export interface PiConfigResult {
@@ -29,7 +23,8 @@ export declare function _getAuthFilePath(): string;
29
23
  /**
30
24
  * 读取 pi 的 provider 和 model 配置
31
25
  *
32
- * @returns provider 列表,每个 provider 包含名称、模型列表和认证状态
26
+ * @returns provider 列表,每项含名称与其模型列表;auth.json 里有凭据但
27
+ * models.json 未声明的 provider 以空模型列出(只为让 provider 选项不丢)
33
28
  */
34
29
  export declare function loadPiProviderConfig(): PiProviderInfo[];
35
30
  /**
@@ -11,9 +11,12 @@ import path from 'node:path';
11
11
  * 默认 ~/.pi/agent。每次调用时动态读取环境变量,避免模块级缓存导致
12
12
  * singleFork 模式下测试设置 env 过晚(与 CLAUDE_SETTINGS_PATH 模式类似,
13
13
  * 但 pi-config 的常量原本在模块级求值,改为惰性求值以支持运行时切换)。
14
+ *
15
+ * 空串/纯空白按「未设置」处理:`??` 只挡 undefined,放过空串会让 models.json
16
+ * 解析成相对**进程 cwd** 的路径。
14
17
  */
15
18
  function getPiConfigDir() {
16
- return process.env.PI_CONFIG_DIR ?? path.join(os.homedir(), '.pi', 'agent');
19
+ return process.env.PI_CONFIG_DIR?.trim() || path.join(os.homedir(), '.pi', 'agent');
17
20
  }
18
21
  /** @internal Exported for testing only — verify env var is respected. */
19
22
  export function _getModelsFilePath() {
@@ -55,7 +58,8 @@ function getPiModelFallback(provider) {
55
58
  /**
56
59
  * 读取 pi 的 provider 和 model 配置
57
60
  *
58
- * @returns provider 列表,每个 provider 包含名称、模型列表和认证状态
61
+ * @returns provider 列表,每项含名称与其模型列表;auth.json 里有凭据但
62
+ * models.json 未声明的 provider 以空模型列出(只为让 provider 选项不丢)
59
63
  */
60
64
  export function loadPiProviderConfig() {
61
65
  const results = new Map();
@@ -87,10 +91,7 @@ export function loadPiProviderConfig() {
87
91
  const cfg = config;
88
92
  results.set(name, {
89
93
  name,
90
- baseUrl: cfg.baseUrl,
91
- api: cfg.api,
92
94
  models: (cfg.models ?? []).map((m) => m.id),
93
- isAuthenticated: !!authData[name],
94
95
  });
95
96
  }
96
97
  // 4. 对于 auth.json 中有凭据但未在 models.json 中定义的 provider
@@ -100,7 +101,6 @@ export function loadPiProviderConfig() {
100
101
  results.set(name, {
101
102
  name,
102
103
  models: [],
103
- isAuthenticated: true,
104
104
  });
105
105
  }
106
106
  }
@@ -5,6 +5,7 @@ import YAML from 'yaml';
5
5
  import { AppConfigSchema } from './index.js';
6
6
  import { needsQrImage, renderQrImage, renderTerminalQr } from './qr.js';
7
7
  import { silentlyUnlink } from '../common/fs.js';
8
+ import { atomicWrite } from '../persistence/atomic-write.js';
8
9
  /** Where the fallback QR image is written (next to the config it will seed). */
9
10
  const QR_IMAGE_NAME = 'qr-code.gif';
10
11
  /**
@@ -96,12 +97,15 @@ function hasFeishuCredentials(configPath) {
96
97
  const feishu = parsed?.feishu;
97
98
  return Boolean(feishu && feishu.appId && feishu.appSecret);
98
99
  }
99
- /** Persist a full config file seeded with the given feishu credentials. */
100
+ /**
101
+ * Persist a full config file seeded with the given feishu credentials.
102
+ *
103
+ * 走 atomicWrite(tmp+rename)而不是裸 writeFileSync:这份文件里有 appSecret,
104
+ * 半截落盘 = 下次启动读不出凭据 + 盘上留着残密钥。目录创建由 atomicWrite 负责。
105
+ */
100
106
  function writeConfigWithCredentials(configPath, appId, appSecret) {
101
- const dir = path.dirname(configPath);
102
- fs.mkdirSync(dir, { recursive: true });
103
107
  const config = AppConfigSchema.parse({ feishu: { appId, appSecret } });
104
- fs.writeFileSync(configPath, YAML.stringify(config), 'utf-8');
108
+ atomicWrite(configPath, YAML.stringify(config));
105
109
  }
106
110
  /**
107
111
  * Ensure a usable config exists before `loadConfig` is called.
@@ -158,6 +158,15 @@ export declare class FeishuConnector {
158
158
  streamCard(chatId: string, initial: object, producer: CardStreamProducer, opts?: {
159
159
  replyTo?: string;
160
160
  }): Promise<string>;
161
+ /**
162
+ * 原地替换已发消息的卡片内容。
163
+ *
164
+ * 走 `rawClient.im.v1.message.patch` 而不是 `channel.updateCard()`,唯一目的就是
165
+ * 拿到返回值:飞书的业务码拒绝以 HTTP 200 + `{code!=0}` 返回,SDK 的 `patchCard`
166
+ * 把返回值丢了,调用方于是拿到假成功(卡片定格在被打回的那一帧,用户却收到
167
+ * 「已保存」)。抛出去让 `bridge.updateCardInPlace()` 的 catch 走 sendResult 兜底。
168
+ * `rawClient` 缺失(测试 mock)时回退 SDK 入口,行为退化为「看不见业务码」。
169
+ */
161
170
  updateCard(messageId: string, card: object): Promise<void>;
162
171
  /** Format error for logging without causing circular serialization */
163
172
  private formatError;
@@ -180,6 +189,8 @@ export declare class FeishuConnector {
180
189
  * 标签('sendFile' | 'sendImage')。
181
190
  */
182
191
  private uploadAndSendMedia;
192
+ /** 单次「上传 + 发媒体消息」;文件流在此拥有,任何失败路径都销毁(P2-18)。 */
193
+ private uploadWithToken;
183
194
  /**
184
195
  * Upload a file to Feishu and send it to the specified chat.
185
196
  * Uses the im/v1/files upload API.
@@ -196,9 +207,17 @@ export declare class FeishuConnector {
196
207
  * fetching it on every file send wastes a round-trip and an unguarded fetch
197
208
  * could return data.code != 0 with an undefined token. Cache with expiry and
198
209
  * validate the response code.
210
+ *
211
+ * 缓存的两条失效路径:到期(下面 expire)与被服务端判 token 无效
212
+ * (invalidateTenantToken,见 §B13)。
199
213
  */
200
214
  private cachedToken;
201
215
  private cachedTokenExpireAt;
216
+ /** 在途的取 token 请求:并发发送合流成一次请求(飞书该端点有限流)。 */
217
+ private tokenFetch;
202
218
  private getTenantAccessToken;
219
+ /** 清掉缓存的 token:鉴权被拒时旧值已经没有价值,留着只会继续失败。 */
220
+ private invalidateTenantToken;
221
+ private fetchTenantAccessToken;
203
222
  }
204
223
  export {};