lark-remote 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.en.md +0 -1
  3. package/README.md +0 -1
  4. package/dist/bridge/approval-coordinator.d.ts +108 -0
  5. package/dist/bridge/approval-coordinator.js +258 -0
  6. package/dist/bridge/index.d.ts +62 -0
  7. package/dist/bridge/index.js +423 -20
  8. package/dist/bridge/queue-manager.d.ts +9 -0
  9. package/dist/bridge/queue-manager.js +19 -11
  10. package/dist/card/approval-render.d.ts +16 -0
  11. package/dist/card/approval-render.js +247 -0
  12. package/dist/card/card-shared.d.ts +14 -0
  13. package/dist/card/card-shared.js +28 -0
  14. package/dist/card/run-renderer.js +78 -7
  15. package/dist/card/run-state.d.ts +25 -3
  16. package/dist/card/run-state.js +210 -2
  17. package/dist/config/dir.d.ts +1 -0
  18. package/dist/config/dir.js +4 -0
  19. package/dist/config/index.d.ts +43 -0
  20. package/dist/config/index.js +25 -1
  21. package/dist/connector/index.d.ts +5 -0
  22. package/dist/connector/index.js +32 -0
  23. package/dist/index.js +95 -23
  24. package/dist/router/config/codex.d.ts +1 -1
  25. package/dist/router/config/codex.js +49 -0
  26. package/dist/router/config/common/render.js +4 -0
  27. package/dist/router/config/index.d.ts +0 -10
  28. package/dist/router/config/index.js +0 -17
  29. package/dist/router/config/types.d.ts +1 -1
  30. package/dist/router/index.d.ts +61 -1
  31. package/dist/router/index.js +323 -48
  32. package/dist/router/utils.d.ts +2 -0
  33. package/dist/router/utils.js +5 -2
  34. package/dist/runner/codex/app-server/client.d.ts +82 -0
  35. package/dist/runner/codex/app-server/client.js +234 -0
  36. package/dist/runner/codex/app-server/connection-manager.d.ts +56 -0
  37. package/dist/runner/codex/app-server/connection-manager.js +170 -0
  38. package/dist/runner/codex/app-server/protocol-types.d.ts +527 -0
  39. package/dist/runner/codex/app-server/protocol-types.js +67 -0
  40. package/dist/runner/codex/app-server/runner.d.ts +121 -0
  41. package/dist/runner/codex/app-server/runner.js +554 -0
  42. package/dist/runner/codex/app-server/translator.d.ts +109 -0
  43. package/dist/runner/codex/app-server/translator.js +734 -0
  44. package/dist/runner/codex/app-server/transport.d.ts +49 -0
  45. package/dist/runner/codex/app-server/transport.js +231 -0
  46. package/dist/runner/codex/argv.d.ts +1 -1
  47. package/dist/runner/codex/argv.js +1 -1
  48. package/dist/runner/codex/factory.d.ts +29 -0
  49. package/dist/runner/codex/factory.js +39 -0
  50. package/dist/runner/codex/index.d.ts +2 -0
  51. package/dist/runner/codex/index.js +2 -0
  52. package/dist/runner/codex/runner.d.ts +3 -0
  53. package/dist/runner/codex/runner.js +5 -0
  54. package/dist/runner/common/runner-utils.d.ts +12 -0
  55. package/dist/runner/common/runner-utils.js +21 -0
  56. package/dist/runner/common/spawning-runner.js +9 -1
  57. package/dist/runner/common/usage.d.ts +2 -0
  58. package/dist/runner/common/usage.js +2 -0
  59. package/dist/runner/index.d.ts +2 -2
  60. package/dist/runner/index.js +1 -1
  61. package/dist/runner/types.d.ts +123 -2
  62. package/dist/session/claude/session-index.d.ts +93 -0
  63. package/dist/session/claude/session-index.js +374 -0
  64. package/dist/session/claude/sessions.d.ts +0 -18
  65. package/dist/session/claude/sessions.js +102 -319
  66. package/dist/session/codex/rollout-reader.js +46 -3
  67. package/dist/update/index.d.ts +10 -0
  68. package/dist/update/index.js +11 -0
  69. package/dist/update/install.d.ts +29 -0
  70. package/dist/update/install.js +93 -0
  71. package/dist/update/startup-hint.d.ts +5 -0
  72. package/dist/update/startup-hint.js +11 -0
  73. package/dist/update/version-check.d.ts +31 -0
  74. package/dist/update/version-check.js +149 -0
  75. package/package.json +1 -14
@@ -32,8 +32,204 @@ export function reduceRunState(state, event) {
32
32
  return reducePlanEvent(state, event);
33
33
  if (event.type === 'file_change')
34
34
  return reduceFileChangeEvent(state, event);
35
+ if (event.type === 'turn_started')
36
+ return reduceTurnStartedEvent(state, event);
37
+ if (event.type === 'turn_diff')
38
+ return reduceTurnDiffEvent(state, event);
39
+ if (event.type === 'approval_requested') {
40
+ const approvals = state.approvals ?? [];
41
+ const next = { view: event.view, expired: false };
42
+ const idx = approvals.findIndex((a) => a.view.requestId === event.requestId);
43
+ if (idx >= 0) {
44
+ // 同一 requestId 重复到达:原地替换,保持槽位顺序。
45
+ return {
46
+ ...state,
47
+ approvals: approvals.map((a, i) => (i === idx ? next : a)),
48
+ };
49
+ }
50
+ return { ...state, approvals: [...approvals, next] };
51
+ }
52
+ if (event.type === 'approval_resolved') {
53
+ return {
54
+ ...state,
55
+ approvals: (state.approvals ?? []).filter((a) => a.view.requestId !== event.requestId),
56
+ };
57
+ }
58
+ if (event.type === 'approval_view_updated') {
59
+ return {
60
+ ...state,
61
+ approvals: (state.approvals ?? []).map((a) => a.view.requestId === event.requestId ? { ...a, view: event.view } : a),
62
+ };
63
+ }
64
+ if (event.type === 'approval_expired') {
65
+ return {
66
+ ...state,
67
+ approvals: (state.approvals ?? []).map((a) => a.view.requestId === event.requestId ? { ...a, expired: true } : a),
68
+ };
69
+ }
35
70
  return state;
36
71
  }
72
+ /** Handle app-server turn_started — records the operation kind (turn vs compact). */
73
+ function reduceTurnStartedEvent(state, event) {
74
+ if (event.type !== 'turn_started')
75
+ return state;
76
+ return {
77
+ ...state,
78
+ operationKind: event.operationKind,
79
+ footer: state.footer ?? 'thinking',
80
+ };
81
+ }
82
+ /**
83
+ * Handle app-server turn_diff events. The translator emits FULL per-item
84
+ * snapshots (accumulated text/reasoning/tool output/plan, current file-change
85
+ * set). Each snapshot is scoped to a thread item (event.itemId); the block is
86
+ * replaced in place to avoid duplication, and a NEW block is appended on the
87
+ * item's first sight — so interleaved items keep their real chronology and
88
+ * the active streaming content sits at the bottom, like the exec path.
89
+ */
90
+ function reduceTurnDiffEvent(state, event) {
91
+ if (event.type !== 'turn_diff')
92
+ return state;
93
+ let next = state;
94
+ const ts = event.timestamp;
95
+ const complete = event.complete === true;
96
+ if (event.text !== undefined) {
97
+ const blocks = markThinkingInactive(next.blocks);
98
+ next = {
99
+ ...next,
100
+ blocks: keepLatestBlocks(upsertSnapshotBlock(blocks, (b) => b.kind === 'text' && b.itemId === event.itemId, () => ({
101
+ kind: 'text',
102
+ itemId: event.itemId,
103
+ content: keepLatest(event.text, MAX_TEXT_CHARS),
104
+ timestamp: ts,
105
+ ...(complete ? { completedAt: ts } : {}),
106
+ }), (existing) => {
107
+ const textBlock = existing;
108
+ return {
109
+ ...textBlock,
110
+ content: keepLatest(event.text, MAX_TEXT_CHARS),
111
+ ...(complete ? { completedAt: ts } : {}),
112
+ };
113
+ }, !complete)),
114
+ footer: 'streaming',
115
+ };
116
+ }
117
+ if (event.reasoning !== undefined) {
118
+ next = {
119
+ ...next,
120
+ blocks: keepLatestBlocks(upsertSnapshotBlock(next.blocks, (b) => b.kind === 'thinking' && b.itemId === event.itemId, () => ({
121
+ kind: 'thinking',
122
+ itemId: event.itemId,
123
+ content: keepLatest(event.reasoning, MAX_REASONING_CHARS),
124
+ active: !complete,
125
+ timestamp: ts,
126
+ ...(complete ? { completedAt: ts } : {}),
127
+ }), (existing) => {
128
+ const thinkingBlock = existing;
129
+ return {
130
+ ...thinkingBlock,
131
+ content: keepLatest(event.reasoning, MAX_REASONING_CHARS),
132
+ active: !complete,
133
+ ...(complete ? { completedAt: ts } : {}),
134
+ };
135
+ }, !complete)),
136
+ footer: 'thinking',
137
+ };
138
+ }
139
+ if (event.toolOutput !== undefined) {
140
+ next = {
141
+ ...next,
142
+ blocks: keepLatestBlocks(upsertSnapshotBlock(next.blocks, (b) => b.kind === 'tool' && b.tool.id === event.itemId, () => ({
143
+ kind: 'tool',
144
+ tool: {
145
+ id: event.itemId,
146
+ name: 'command',
147
+ input: '',
148
+ output: keepLatest(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
149
+ status: complete ? (event.toolStatus ?? 'ok') : 'running',
150
+ startedAt: ts,
151
+ ...(complete ? { completedAt: ts } : {}),
152
+ },
153
+ }), (existing) => ({
154
+ kind: 'tool',
155
+ tool: {
156
+ ...existing.tool,
157
+ output: keepLatest(event.toolOutput, MAX_TOOL_DETAIL_CHARS),
158
+ status: complete
159
+ ? (event.toolStatus ?? 'ok')
160
+ : existing.tool.status,
161
+ ...(complete ? { completedAt: ts } : {}),
162
+ },
163
+ }), !complete)),
164
+ footer: 'tool_running',
165
+ };
166
+ }
167
+ if (event.plan !== undefined) {
168
+ const newPlan = keepLatest(event.plan, MAX_REASONING_CHARS * 2);
169
+ next = {
170
+ ...next,
171
+ plan: newPlan,
172
+ blocks: keepLatestBlocks(upsertSnapshotBlock(next.blocks, (b) => b.kind === 'plan' && b.itemId === event.itemId, () => ({
173
+ kind: 'plan',
174
+ itemId: event.itemId,
175
+ content: newPlan,
176
+ active: !complete,
177
+ timestamp: ts,
178
+ ...(complete ? { completedAt: ts } : {}),
179
+ }), (existing) => {
180
+ const planBlock = existing;
181
+ return {
182
+ ...planBlock,
183
+ content: newPlan,
184
+ active: !complete,
185
+ ...(complete ? { completedAt: ts } : {}),
186
+ };
187
+ }, !complete)),
188
+ };
189
+ }
190
+ if (event.fileChanges !== undefined) {
191
+ let blocks = next.blocks;
192
+ for (const fc of event.fileChanges) {
193
+ const fileBlock = {
194
+ kind: 'file_change',
195
+ itemId: event.itemId,
196
+ path: fc.path,
197
+ operation: fc.kind === 'add' ? 'create' : fc.kind === 'delete' ? 'delete' : 'edit',
198
+ diff: fc.diff,
199
+ timestamp: ts,
200
+ ...(complete ? { completedAt: ts } : {}),
201
+ };
202
+ // 同一 item 的同一路径原地替换(权威 diff 校正),新路径/新 item 追加。
203
+ blocks = upsertSnapshotBlock(blocks, (b) => b.kind === 'file_change' && b.itemId === event.itemId && b.path === fc.path, () => fileBlock, () => fileBlock, !complete);
204
+ }
205
+ next = { ...next, blocks: keepLatestBlocks(blocks) };
206
+ }
207
+ return next;
208
+ }
209
+ /**
210
+ * Replace the last block matching `predicate` with `make`; if none matches,
211
+ * append a block built by `fallback`. Snapshot semantics for app-server
212
+ * turn_diff streams.
213
+ *
214
+ * `moveToEndWhenNotLast`: 当命中块不在数组末尾且为流式更新(complete=false)
215
+ * 时,把更新后的块移到末尾。正常协议下 item 串行、流式块总是最后一个,
216
+ * 此分支不触发;若真实 server 在同一个 item 上跨其他 item 续流 delta(违反
217
+ * item 生命周期),流式内容仍会出现在卡片底部,而不是钉在旧位置。
218
+ */
219
+ function upsertSnapshotBlock(blocks, predicate, fallback, update, moveToEndWhenNotLast = false) {
220
+ for (let i = blocks.length - 1; i >= 0; i--) {
221
+ if (predicate(blocks[i])) {
222
+ const copy = blocks.slice();
223
+ copy[i] = update ? update(blocks[i]) : fallback();
224
+ if (moveToEndWhenNotLast && i < copy.length - 1) {
225
+ const [moved] = copy.splice(i, 1);
226
+ copy.push(moved);
227
+ }
228
+ return copy;
229
+ }
230
+ }
231
+ return [...blocks, fallback()];
232
+ }
37
233
  /** Handle system init and compact_boundary events. */
38
234
  function reduceSystemEvent(state, event) {
39
235
  if (event.type !== 'system')
@@ -61,11 +257,23 @@ function reduceSystemEvent(state, event) {
61
257
  function reduceResultEvent(state, event) {
62
258
  if (event.type !== 'result')
63
259
  return state;
260
+ // Pre-init result guard: Claude CLI --resume emits a historical result (from
261
+ // the previous turn) before sending system.init for the new run. Without this
262
+ // guard, that stale result would prematurely transition to 'finalizing',
263
+ // freezing the card at "⏳ 等待进程退出" for the entire run duration.
264
+ // sessionId is set by system.init; undefined means init hasn't arrived yet.
265
+ if (state.sessionId === undefined)
266
+ return state;
64
267
  const resultEvent = event;
65
268
  const incomingSubtype = event.subtype;
66
269
  const incomingErrorMsg = incomingSubtype === 'error' ? (resultEvent.errorMessage ?? 'Agent 返回错误结果') : undefined;
67
- // 合并 subtype:error 优先(任一为 error 即 error)
68
- const mergedSubtype = state.resultSubtype === 'error' || incomingSubtype === 'error' ? 'error' : 'success';
270
+ // 合并 subtype:error 优先,其次 interrupted(任一为 error 即 error;
271
+ // 否则任一为 interrupted 即 interrupted;interrupted 不得并入 success)。
272
+ const mergedSubtype = state.resultSubtype === 'error' || incomingSubtype === 'error'
273
+ ? 'error'
274
+ : state.resultSubtype === 'interrupted' || incomingSubtype === 'interrupted'
275
+ ? 'interrupted'
276
+ : 'success';
69
277
  // 合并 errorMsg:首个非 undefined 胜出(existing ?? incoming)
70
278
  const mergedErrorMsg = state.errorMsg ?? incomingErrorMsg;
71
279
  return {
@@ -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
  }
@@ -77,6 +80,7 @@ export function printHelp() {
77
80
  ' --config-dir <path> 自定义配置目录(默认 ~/.lark-remote,可用于同机多实例)',
78
81
  ' --settings <path> 指定 Claude 配置文件路径',
79
82
  ' --dev 开发模式:标记从源码 bun src/index.ts 启动(看门狗据此选择拉起方式)',
83
+ ' --update 升级到最新版本后退出(用于 cron/脚本自动化升级)',
80
84
  ' -h, --help 显示本帮助信息',
81
85
  ' -v, --version 显示版本号',
82
86
  ];
@@ -10,6 +10,8 @@ export declare const PI_THINKING_LEVELS: readonly ["off", "minimal", "low", "med
10
10
  * instead of re-literalizing `5000` (Clean Code P3-1, G25).
11
11
  */
12
12
  export declare const DEFAULT_STOP_GRACE_MS: 5000;
13
+ /** Codex app-server turn idle timeout default (minutes). */
14
+ export declare const DEFAULT_TURN_IDLE_TIMEOUT_MINUTES: 10;
13
15
  /** 模型 ID → alias 映射。 */
14
16
  export declare const MODEL_ID_TO_ALIAS: Record<string, string>;
15
17
  /** alias → model ID 反向映射。 */
@@ -31,6 +33,26 @@ export declare const CodexConfigSchema: z.ZodObject<{
31
33
  modelProvider: z.ZodOptional<z.ZodString>;
32
34
  reasoningEffort: z.ZodOptional<z.ZodString>;
33
35
  stopGraceMs: z.ZodDefault<z.ZodNumber>;
36
+ serviceMode: z.ZodOptional<z.ZodEnum<{
37
+ exec: "exec";
38
+ "app-server": "app-server";
39
+ }>>;
40
+ approvalPolicy: z.ZodOptional<z.ZodEnum<{
41
+ untrusted: "untrusted";
42
+ "on-request": "on-request";
43
+ never: "never";
44
+ }>>;
45
+ sandbox: z.ZodOptional<z.ZodEnum<{
46
+ "danger-full-access": "danger-full-access";
47
+ "read-only": "read-only";
48
+ "workspace-write": "workspace-write";
49
+ }>>;
50
+ appServer: z.ZodOptional<z.ZodObject<{
51
+ binary: z.ZodDefault<z.ZodString>;
52
+ requestTimeoutMs: z.ZodDefault<z.ZodNumber>;
53
+ idleTtlMs: z.ZodDefault<z.ZodNumber>;
54
+ turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
55
+ }, z.core.$strip>>;
34
56
  }, z.core.$strip>;
35
57
  /** OpenCode-specific configuration (run mode: opencode run --format json --auto). */
36
58
  declare const OpencodeConfigSchema: z.ZodObject<{
@@ -82,6 +104,26 @@ export declare const AppConfigSchema: z.ZodObject<{
82
104
  modelProvider: z.ZodOptional<z.ZodString>;
83
105
  reasoningEffort: z.ZodOptional<z.ZodString>;
84
106
  stopGraceMs: z.ZodDefault<z.ZodNumber>;
107
+ serviceMode: z.ZodOptional<z.ZodEnum<{
108
+ exec: "exec";
109
+ "app-server": "app-server";
110
+ }>>;
111
+ approvalPolicy: z.ZodOptional<z.ZodEnum<{
112
+ untrusted: "untrusted";
113
+ "on-request": "on-request";
114
+ never: "never";
115
+ }>>;
116
+ sandbox: z.ZodOptional<z.ZodEnum<{
117
+ "danger-full-access": "danger-full-access";
118
+ "read-only": "read-only";
119
+ "workspace-write": "workspace-write";
120
+ }>>;
121
+ appServer: z.ZodOptional<z.ZodObject<{
122
+ binary: z.ZodDefault<z.ZodString>;
123
+ requestTimeoutMs: z.ZodDefault<z.ZodNumber>;
124
+ idleTtlMs: z.ZodDefault<z.ZodNumber>;
125
+ turnIdleTimeoutMinutes: z.ZodDefault<z.ZodNumber>;
126
+ }, z.core.$strip>>;
85
127
  }, z.core.$strip>>;
86
128
  opencode: z.ZodOptional<z.ZodObject<{
87
129
  providerID: z.ZodDefault<z.ZodString>;
@@ -151,6 +193,7 @@ export declare const AppConfigSchema: z.ZodObject<{
151
193
  pi: "pi";
152
194
  kimi: "kimi";
153
195
  }>>;
196
+ checkUpdateOnStartup: z.ZodDefault<z.ZodBoolean>;
154
197
  }, z.core.$strip>;
155
198
  export type AppConfig = z.infer<typeof AppConfigSchema>;
156
199
  export declare function loadConfig(configPath: string): AppConfig;
@@ -16,6 +16,7 @@ const DEFAULTS = {
16
16
  CLAUDE_MODEL: 'claude-opus-4-8',
17
17
  STOP_GRACE_MS: 5000,
18
18
  IDLE_WATCHDOG_MINUTES: 15,
19
+ TURN_IDLE_TIMEOUT_MINUTES: 10,
19
20
  };
20
21
  /**
21
22
  * Default SIGTERM→SIGKILL grace period (ms). Exported so runners (SpawningRunner)
@@ -23,6 +24,8 @@ const DEFAULTS = {
23
24
  * instead of re-literalizing `5000` (Clean Code P3-1, G25).
24
25
  */
25
26
  export const DEFAULT_STOP_GRACE_MS = DEFAULTS.STOP_GRACE_MS;
27
+ /** Codex app-server turn idle timeout default (minutes). */
28
+ export const DEFAULT_TURN_IDLE_TIMEOUT_MINUTES = DEFAULTS.TURN_IDLE_TIMEOUT_MINUTES;
26
29
  /** 模型 ID → alias 映射。 */
27
30
  export const MODEL_ID_TO_ALIAS = {
28
31
  'claude-opus-4-8': 'opus',
@@ -47,13 +50,24 @@ const ClaudeConfigSchema = z.object({
47
50
  // permissionMode 硬编码为 bypassPermissions(runner 内部),不通过 config 配置
48
51
  stopGraceMs: z.number().int().min(0).default(DEFAULTS.STOP_GRACE_MS),
49
52
  });
53
+ /** Codex app-server connection configuration. */
54
+ const CodexAppServerConfigSchema = z.object({
55
+ /** Path to the codex binary for app-server mode. */
56
+ binary: z.string().default('codex'),
57
+ /** Request timeout in milliseconds. */
58
+ requestTimeoutMs: z.number().int().min(0).default(60000),
59
+ /** Idle TTL for connection pool in milliseconds. */
60
+ idleTtlMs: z.number().int().min(0).default(1800000),
61
+ /** Turn idle timeout in minutes: no app-server output for this long triggers
62
+ * turn/interrupt and fails the run. 0 disables the timeout. */
63
+ turnIdleTimeoutMinutes: z.number().int().min(0).default(DEFAULT_TURN_IDLE_TIMEOUT_MINUTES),
64
+ });
50
65
  /** Codex-specific configuration. */
51
66
  export const CodexConfigSchema = z.object({
52
67
  /** model to use. Undefined → codex reads from its config.toml. */
53
68
  model: z.string().optional(),
54
69
  /** model provider, e.g. 'volcengine-coding-plan'. Undefined → codex reads from its config.toml. */
55
70
  modelProvider: z.string().optional(),
56
- // approvalPolicy 和 sandboxPolicy 在 runner 内硬编码(exec 模式使用 approval_policy=never)
57
71
  /**
58
72
  * Reasoning effort level。codex 标准档位 minimal/low/medium/high/xhigh/max/ultra
59
73
  * 之外还接受 none 与模型自定义值(ReasoningEffort::Custom),目录声明什么就存什么
@@ -63,6 +77,14 @@ export const CodexConfigSchema = z.object({
63
77
  reasoningEffort: z.string().min(1).optional(),
64
78
  /** Stop grace period in milliseconds. */
65
79
  stopGraceMs: z.number().int().min(0).default(5000),
80
+ /** Service mode: 'exec' (spawn-per-message) or 'app-server' (persistent connection). */
81
+ serviceMode: z.enum(['exec', 'app-server']).optional(),
82
+ /** Approval policy (Codex 官方 AskForApproval 标准值). 仅 app-server 模式有效(exec 固定 approval_policy="never")。 */
83
+ approvalPolicy: z.enum(['untrusted', 'on-request', 'never']).optional(),
84
+ /** Sandbox mode (Codex 官方 SandboxMode 标准值). 仅 app-server 模式有效(exec 固定 --sandbox danger-full-access)。 */
85
+ sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
86
+ /** App-server connection configuration. */
87
+ appServer: CodexAppServerConfigSchema.optional(),
66
88
  });
67
89
  /** OpenCode-specific configuration (run mode: opencode run --format json --auto). */
68
90
  const OpencodeConfigSchema = z.object({
@@ -153,6 +175,8 @@ export const AppConfigSchema = z.object({
153
175
  logging: LoggingConfigSchema.default(LoggingConfigSchema.parse({})),
154
176
  /** Default agent for new runs. */
155
177
  defaultAgent: z.enum(['claude', 'codex', 'opencode', 'pi', 'kimi']).default('claude'),
178
+ /** Check for updates on bridge startup (default: false). */
179
+ checkUpdateOnStartup: z.boolean().default(false),
156
180
  });
157
181
  const TEMPLATE = `feishu:
158
182
  appId: ""
@@ -68,6 +68,11 @@ export declare class FeishuConnector {
68
68
  /** Format error for logging without causing circular serialization */
69
69
  private formatError;
70
70
  addReaction(messageId: string, emoji: string): Promise<void>;
71
+ /**
72
+ * Remove a reaction by emoji (e.g. retract the Typing indicator).
73
+ * The SDK resolves the reaction id internally; missing reaction is a no-op.
74
+ */
75
+ removeReactionByEmoji(messageId: string, emoji: string): Promise<void>;
71
76
  /**
72
77
  * Upload a file to Feishu and send it to the specified chat.
73
78
  * Uses the im/v1/files upload API.
@@ -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
  *
@@ -251,6 +264,18 @@ export class FeishuConnector {
251
264
  getLogger().error('[feishu] addReaction failed:', this.formatError(err));
252
265
  }
253
266
  }
267
+ /**
268
+ * Remove a reaction by emoji (e.g. retract the Typing indicator).
269
+ * The SDK resolves the reaction id internally; missing reaction is a no-op.
270
+ */
271
+ async removeReactionByEmoji(messageId, emoji) {
272
+ try {
273
+ await this.channel.removeReactionByEmoji(messageId, emoji);
274
+ }
275
+ catch (err) {
276
+ getLogger().error('[feishu] removeReactionByEmoji failed:', this.formatError(err));
277
+ }
278
+ }
254
279
  /**
255
280
  * Upload a file to Feishu and send it to the specified chat.
256
281
  * Uses the im/v1/files upload API.
@@ -288,18 +313,22 @@ export class FeishuConnector {
288
313
  form.append('file_name', fileName);
289
314
  form.append('file_type', 'stream');
290
315
  // P2-18: timeout on the upload (large file, slow link) — 120s.
316
+ // httpsAgent: noKeepAlive — prevents Bun from reusing a server-RST'd
317
+ // keep-alive socket (ECONNRESET after ~30s; see noKeepAliveAgent docs).
291
318
  const uploadResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/files', form, {
292
319
  headers: {
293
320
  ...form.getHeaders(),
294
321
  Authorization: `Bearer ${accessToken}`,
295
322
  },
296
323
  timeout: 120000,
324
+ httpsAgent: noKeepAliveAgent,
297
325
  });
298
326
  if (uploadResp.data.code !== 0) {
299
327
  throw new Error(`File upload failed: ${uploadResp.data.msg}`);
300
328
  }
301
329
  const fileKey = uploadResp.data.data.file_key;
302
330
  // Send file message. P2-18: timeout 30s.
331
+ // httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
303
332
  const sendResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/messages', {
304
333
  receive_id: chatId,
305
334
  msg_type: 'file',
@@ -311,6 +340,7 @@ export class FeishuConnector {
311
340
  },
312
341
  params: { receive_id_type: 'chat_id' },
313
342
  timeout: 30000,
343
+ httpsAgent: noKeepAliveAgent,
314
344
  });
315
345
  if (sendResp.data.code !== 0) {
316
346
  throw new Error(`File message failed: ${sendResp.data.msg}`);
@@ -347,12 +377,14 @@ export class FeishuConnector {
347
377
  return this.cachedToken;
348
378
  }
349
379
  // P2-18: timeout 30s on the token request.
380
+ // httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
350
381
  const tokenResp = await axios.post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
351
382
  app_id: this.appId,
352
383
  app_secret: this.appSecret,
353
384
  }, {
354
385
  headers: { 'Content-Type': 'application/json; charset=utf-8' },
355
386
  timeout: 30000,
387
+ httpsAgent: noKeepAliveAgent,
356
388
  });
357
389
  if (tokenResp.data.code !== 0) {
358
390
  throw new Error(`Failed to get tenant_access_token: ${tokenResp.data.msg ?? 'unknown error'}`);