lark-remote 0.3.0 → 0.4.1

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 (60) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.en.md +6 -6
  3. package/README.md +8 -7
  4. package/dist/binder.d.ts +1 -1
  5. package/dist/binder.js +1 -1
  6. package/dist/bridge/final-usage.js +2 -2
  7. package/dist/bridge/inbound-media.d.ts +13 -20
  8. package/dist/bridge/inbound-media.js +107 -91
  9. package/dist/bridge/index.d.ts +6 -6
  10. package/dist/bridge/index.js +81 -87
  11. package/dist/cli.js +5 -1
  12. package/dist/clone.d.ts +1 -1
  13. package/dist/common/fs.js +1 -1
  14. package/dist/config/dir.d.ts +9 -0
  15. package/dist/config/dir.js +69 -0
  16. package/dist/config/index.d.ts +22 -3
  17. package/dist/config/index.js +26 -4
  18. package/dist/connector/index.d.ts +28 -1
  19. package/dist/connector/index.js +155 -31
  20. package/dist/error-classification.d.ts +7 -0
  21. package/dist/error-classification.js +37 -1
  22. package/dist/inbound/event.d.ts +41 -0
  23. package/dist/inbound/event.js +11 -0
  24. package/dist/inbound/placeholder.d.ts +26 -0
  25. package/dist/inbound/placeholder.js +149 -0
  26. package/dist/inbound/prompt.d.ts +20 -0
  27. package/dist/inbound/prompt.js +47 -0
  28. package/dist/inbound/receipt.d.ts +28 -0
  29. package/dist/inbound/receipt.js +62 -0
  30. package/dist/inbound/turn-assembler.d.ts +63 -0
  31. package/dist/inbound/turn-assembler.js +233 -0
  32. package/dist/inbound/turn.d.ts +48 -0
  33. package/dist/inbound/turn.js +10 -0
  34. package/dist/index.js +108 -73
  35. package/dist/platform/command.js +1 -1
  36. package/dist/platform/sleep-blocker.d.ts +17 -0
  37. package/dist/platform/sleep-blocker.js +73 -0
  38. package/dist/platform/terminator-win32.js +1 -1
  39. package/dist/restart.d.ts +4 -4
  40. package/dist/restart.js +5 -5
  41. package/dist/router/config/kimi.js +4 -3
  42. package/dist/router/config/opencode.d.ts +1 -1
  43. package/dist/router/config/opencode.js +11 -0
  44. package/dist/router/index.d.ts +17 -0
  45. package/dist/router/index.js +53 -15
  46. package/dist/runner/common/acp/base-acp-runner.js +7 -2
  47. package/dist/runner/common/acp/base-translator.d.ts +19 -0
  48. package/dist/runner/common/acp/base-translator.js +82 -0
  49. package/dist/runner/common/acp/protocol-types.d.ts +16 -2
  50. package/dist/runner/common/acp/protocol-types.js +3 -0
  51. package/dist/runner/common/connection-based-runner.js +6 -2
  52. package/dist/runner/kimi/acp/runner.d.ts +1 -1
  53. package/dist/runner/kimi/acp/runner.js +1 -1
  54. package/dist/runner/kimi/acp/translator.js +60 -4
  55. package/dist/runner/opencode/acp/runner.d.ts +1 -1
  56. package/dist/session/session-store.d.ts +1 -1
  57. package/dist/session/session-store.js +1 -1
  58. package/dist/workspace/index.d.ts +1 -1
  59. package/dist/workspace/index.js +1 -1
  60. package/package.json +1 -1
@@ -100,7 +100,7 @@ export function createWin32Terminator(deps) {
100
100
  // M0 真机验证项(§9.2 矩阵):本方法在 process.on('exit') 里调用,而
101
101
  // killTree 依赖 libuv uv_spawn 在 exit handler 内同步发起 taskkill——
102
102
  // 大概率可用(uv_spawn 本身同步),但与 posix 路径(同步 process.kill)
103
- // 可靠性不等价;失败表现为 win32 上 bridge 退出后 agent 变孤儿。
103
+ // 可靠性不等价;失败表现为 win32 上 lark-remote 退出后 agent 变孤儿。
104
104
  if (!isAlive(proc))
105
105
  return;
106
106
  killTree(proc.pid);
package/dist/restart.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Self-restart without an external watchdog/cron: the only process that can
3
- * reliably start the successor is the dying bridge itself. The old process
3
+ * reliably start the successor is the dying lark-remote itself. The old process
4
4
  * spawns a detached replacement (same executable + argv, hence same
5
5
  * --config-dir), then exits cleanly. The child learns the old pid via env
6
6
  * and waits for it to die before acquiring the instance lock, so the lock
@@ -9,7 +9,7 @@
9
9
  /** Env var telling a replacement child which parent pid to wait for before acquiring the instance lock. */
10
10
  export declare const RESTART_WAIT_PID_ENV = "LARK_REMOTE_RESTART_WAIT_PID";
11
11
  /**
12
- * Spawn a detached bridge process (shared by /restart replacement and /clone
12
+ * Spawn a detached lark-remote process (shared by /restart replacement and /clone
13
13
  * new-instance) whose early output — before the child's own file logger
14
14
  * initializes — lands on logFilePath. Returns the child pid, or null when
15
15
  * spawn failed synchronously / no pid; filesystem errors (unwritable log
@@ -26,7 +26,7 @@ export declare function spawnDetachedBridge(args: string[], logFilePath: string,
26
26
  env?: NodeJS.ProcessEnv;
27
27
  }): number | null;
28
28
  /**
29
- * Spawn a detached replacement bridge with the same executable and argv
29
+ * Spawn a detached replacement lark-remote with the same executable and argv
30
30
  * (including --config-dir) and return its pid. The caller is expected to
31
31
  * exit right after. stdio is redirected to restart-child.log so early
32
32
  * startup failures (before the file logger initializes) are not lost.
@@ -34,7 +34,7 @@ export declare function spawnDetachedBridge(args: string[], logFilePath: string,
34
34
  export declare function spawnReplacementBridge(logsDir: string): number;
35
35
  /**
36
36
  * On startup, if this process was spawned as a restart replacement, wait for
37
- * the previous bridge process to exit (and release the instance lock) before
37
+ * the previous lark-remote process to exit (and release the instance lock) before
38
38
  * continuing. No-op for normal starts. On timeout, proceed anyway — lock
39
39
  * acquisition remains the authority.
40
40
  */
package/dist/restart.js CHANGED
@@ -5,7 +5,7 @@ import { getLogger } from './logger/index.js';
5
5
  import { sleep } from './common/sleep.js';
6
6
  /**
7
7
  * Self-restart without an external watchdog/cron: the only process that can
8
- * reliably start the successor is the dying bridge itself. The old process
8
+ * reliably start the successor is the dying lark-remote itself. The old process
9
9
  * spawns a detached replacement (same executable + argv, hence same
10
10
  * --config-dir), then exits cleanly. The child learns the old pid via env
11
11
  * and waits for it to die before acquiring the instance lock, so the lock
@@ -16,7 +16,7 @@ export const RESTART_WAIT_PID_ENV = 'LARK_REMOTE_RESTART_WAIT_PID';
16
16
  const WAIT_TIMEOUT_MS = 20_000;
17
17
  const POLL_MS = 100;
18
18
  /**
19
- * Spawn a detached bridge process (shared by /restart replacement and /clone
19
+ * Spawn a detached lark-remote process (shared by /restart replacement and /clone
20
20
  * new-instance) whose early output — before the child's own file logger
21
21
  * initializes — lands on logFilePath. Returns the child pid, or null when
22
22
  * spawn failed synchronously / no pid; filesystem errors (unwritable log
@@ -51,7 +51,7 @@ export function spawnDetachedBridge(args, logFilePath, opts) {
51
51
  }
52
52
  }
53
53
  /**
54
- * Spawn a detached replacement bridge with the same executable and argv
54
+ * Spawn a detached replacement lark-remote with the same executable and argv
55
55
  * (including --config-dir) and return its pid. The caller is expected to
56
56
  * exit right after. stdio is redirected to restart-child.log so early
57
57
  * startup failures (before the file logger initializes) are not lost.
@@ -61,13 +61,13 @@ export function spawnReplacementBridge(logsDir) {
61
61
  env: { ...process.env, [RESTART_WAIT_PID_ENV]: String(process.pid) },
62
62
  });
63
63
  if (pid === null) {
64
- throw new Error('spawn replacement bridge failed: no pid');
64
+ throw new Error('spawn replacement lark-remote failed: no pid');
65
65
  }
66
66
  return pid;
67
67
  }
68
68
  /**
69
69
  * On startup, if this process was spawned as a restart replacement, wait for
70
- * the previous bridge process to exit (and release the instance lock) before
70
+ * the previous lark-remote process to exit (and release the instance lock) before
71
71
  * continuing. No-op for normal starts. On timeout, proceed anyway — lock
72
72
  * acquisition remains the authority.
73
73
  */
@@ -7,9 +7,10 @@
7
7
  import { loadKimiConfig, KIMI_THINKING_EFFORTS, FALLBACK_EFFORTS, } from '../../config/kimi-config.js';
8
8
  import { DEFAULT_TURN_IDLE_TIMEOUT_MINUTES } from '../../config/index.js';
9
9
  const PERMISSION_MODE_OPTIONS = [
10
- { text: 'manual(逐项审批)', value: 'manual' },
11
- { text: 'auto(引擎裁决)', value: 'auto' },
12
- { text: 'yolo(全部放行)', value: 'yolo' },
10
+ // kimi 0.40.0(2026-09-02)起官方语义:manual=始终询问 / yolo=必要时询问 / auto=完全自动
11
+ { text: 'manual(始终询问)', value: 'manual' },
12
+ { text: 'auto(完全自动)', value: 'auto' },
13
+ { text: 'yolo(必要时询问)', value: 'yolo' },
13
14
  ];
14
15
  export class KimiConfigBuilder {
15
16
  buildFields(displayConfig) {
@@ -2,7 +2,7 @@
2
2
  * OpenCode config builder — builds fields and handles field changes for OpenCode agent.
3
3
  */
4
4
  import type { AgentConfigCardBuilder, ConfigField } from './types.js';
5
- import type { AppConfig } from '../../config/index.js';
5
+ import { type AppConfig } from '../../config/index.js';
6
6
  export declare class OpencodeConfigBuilder implements AgentConfigCardBuilder {
7
7
  buildFields(displayConfig: AppConfig): ConfigField[];
8
8
  handleFieldChange(key: string, value: unknown, config: AppConfig): Array<{
@@ -2,6 +2,7 @@
2
2
  * OpenCode config builder — builds fields and handles field changes for OpenCode agent.
3
3
  */
4
4
  import { loadOpencodeConfig } from '../../config/opencode-config.js';
5
+ import { DEFAULT_TURN_IDLE_TIMEOUT_MINUTES } from '../../config/index.js';
5
6
  import { resetModelPatch } from './common/model-patch.js';
6
7
  const MODE_OPTIONS = [
7
8
  { text: 'build(默认,逐项审批)', value: 'build' },
@@ -38,6 +39,16 @@ export class OpencodeConfigBuilder {
38
39
  options: MODE_OPTIONS,
39
40
  currentValue: currentMode,
40
41
  });
42
+ // Turn 空闲超时(纯 ACP 对齐 kimi:卡片暴露 turnIdleTimeoutMinutes,
43
+ // binary/requestTimeoutMs/idleTtlMs 为 YAML-only 工程参数)
44
+ const currentTurnIdleTimeoutMinutes = displayConfig.agents?.opencode?.acp?.turnIdleTimeoutMinutes ??
45
+ DEFAULT_TURN_IDLE_TIMEOUT_MINUTES;
46
+ fields.push({
47
+ key: 'agents.opencode.acp.turnIdleTimeoutMinutes',
48
+ label: 'Turn 空闲超时(分钟, 0关闭)',
49
+ type: 'input',
50
+ currentValue: String(currentTurnIdleTimeoutMinutes),
51
+ });
41
52
  return fields;
42
53
  }
43
54
  handleFieldChange(key, value, config) {
@@ -121,6 +121,11 @@ export declare class CommandRouter {
121
121
  * returns its pid. Injected from index.ts; /restart is unavailable without it.
122
122
  */
123
123
  private restartSpawner?;
124
+ /**
125
+ * 冲刷装配器窗口内未提交的入站 turn(/exit、/restart 干净退出前调用,
126
+ * 避免静默期窗口内的内容丢失)。index.ts 注入。
127
+ */
128
+ private flushInbound?;
124
129
  private idleTimeoutMs;
125
130
  /** Dev mode flag: --dev means the bridge was started from source (bun src/index.ts). */
126
131
  private devMode;
@@ -197,12 +202,24 @@ export declare class CommandRouter {
197
202
  * The reader is fetched from the registry each time to reflect config changes.
198
203
  */
199
204
  private get sessionReader();
205
+ /**
206
+ * 注入入站装配器的冲刷回调(/exit、/restart 干净退出前调用,
207
+ * 避免静默期窗口内未提交的 turn 丢失)。index.ts 在构造后接线。
208
+ */
209
+ setInboundFlusher(flush: () => Promise<void>): void;
200
210
  /**
201
211
  * Route a message: if it starts with /, handle as command; if it starts with !, execute as bash; otherwise forward to Claude.
212
+ *
213
+ * 命令识别前置条件(2026-09-15 P0 修复):
214
+ * 裸前缀判定会被结构占位符命中 —— SDK 把富文本里的图片渲染成 `![image](img_v3_…)`,
215
+ * 首字符恰好是 `!`,整段用户消息被当 shell 命令执行(`executeBash "[image](img_v3_…"`)。
216
+ * 因此命令前缀只在「调用方声明纯文本 + 剥离占位符后非空且无占位符 + 首字符是 / 或 !」
217
+ * 三条同时成立时生效,其余一律按文本转发。
202
218
  */
203
219
  handle(message: string, ctx: CommandContext, opts?: {
204
220
  cwdOverride?: string;
205
221
  binding?: AgentBinding;
222
+ allowCommandPrefix?: boolean;
206
223
  }): Promise<CommandResult | null>;
207
224
  /**
208
225
  * 一次别名展开:bridge 收到用户消息后、命令分发前调用。
@@ -9,6 +9,7 @@ import { OrderStore } from '../order/index.js';
9
9
  import { resolveAlias } from '../order/alias-resolve.js';
10
10
  import { diffConfig, setNestedValue } from './config-diff.js';
11
11
  import { getLogger } from '../logger/index.js';
12
+ import { stripPlaceholders } from '../inbound/placeholder.js';
12
13
  import { activeRunUsage, clampInt } from './utils.js';
13
14
  import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, PAGE_JUMP_INVALID_HINT, } from './card-helpers.js';
14
15
  import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
@@ -342,6 +343,11 @@ export class CommandRouter {
342
343
  * returns its pid. Injected from index.ts; /restart is unavailable without it.
343
344
  */
344
345
  restartSpawner;
346
+ /**
347
+ * 冲刷装配器窗口内未提交的入站 turn(/exit、/restart 干净退出前调用,
348
+ * 避免静默期窗口内的内容丢失)。index.ts 注入。
349
+ */
350
+ flushInbound;
345
351
  idleTimeoutMs;
346
352
  /** Dev mode flag: --dev means the bridge was started from source (bun src/index.ts). */
347
353
  devMode;
@@ -416,13 +422,33 @@ export class CommandRouter {
416
422
  get sessionReader() {
417
423
  return this.sessionReaderRegistry.get(this.config.defaultAgent);
418
424
  }
425
+ /**
426
+ * 注入入站装配器的冲刷回调(/exit、/restart 干净退出前调用,
427
+ * 避免静默期窗口内未提交的 turn 丢失)。index.ts 在构造后接线。
428
+ */
429
+ setInboundFlusher(flush) {
430
+ this.flushInbound = flush;
431
+ }
419
432
  /**
420
433
  * Route a message: if it starts with /, handle as command; if it starts with !, execute as bash; otherwise forward to Claude.
434
+ *
435
+ * 命令识别前置条件(2026-09-15 P0 修复):
436
+ * 裸前缀判定会被结构占位符命中 —— SDK 把富文本里的图片渲染成 `![image](img_v3_…)`,
437
+ * 首字符恰好是 `!`,整段用户消息被当 shell 命令执行(`executeBash "[image](img_v3_…"`)。
438
+ * 因此命令前缀只在「调用方声明纯文本 + 剥离占位符后非空且无占位符 + 首字符是 / 或 !」
439
+ * 三条同时成立时生效,其余一律按文本转发。
421
440
  */
422
441
  async handle(message, ctx, opts) {
423
442
  const trimmed = message.trim();
424
- const startsWithBang = trimmed.startsWith('!');
425
- const startsWithSlash = trimmed.startsWith('/');
443
+ // 双保险:任何调用点都必须先剥离结构占位符,再判命令前缀
444
+ //(index.ts 传 allowCommandPrefix = rawContentType === 'text',但那层不是唯一入口)。
445
+ const guard = stripPlaceholders(trimmed);
446
+ // 默认 true:order.exec 等既有内部调用点传的是用户已确认的指令文本,
447
+ // 且剥离后无占位符 —— 默认放行不改变它们的语义(见 command-guard.test.ts)。
448
+ const allowCommandPrefix = opts?.allowCommandPrefix ?? true;
449
+ const commandEligible = allowCommandPrefix && guard.kinds.length === 0 && guard.clean !== '';
450
+ const startsWithBang = commandEligible && guard.clean.startsWith('!');
451
+ const startsWithSlash = commandEligible && guard.clean.startsWith('/');
426
452
  getLogger().info(`[router] handle message="${message.slice(0, 50)}..." trimmed="${trimmed.slice(0, 50)}..." startsWithSlash=${startsWithSlash} startsWithBang=${startsWithBang}`);
427
453
  // 复制分身流程活跃期:一切消息(含命令)交给 clone 状态机处理,
428
454
  // 不转发 coding agent、不进命令分发(index.ts 已在入队前拦截,这里
@@ -432,20 +458,20 @@ export class CommandRouter {
432
458
  return null;
433
459
  }
434
460
  if (startsWithSlash) {
435
- const result = await this.executeCommand(trimmed, ctx);
461
+ const result = await this.executeCommand(guard.clean, ctx);
436
462
  if (result) {
437
463
  await this.bridge.sendResult(result, ctx);
438
464
  }
439
465
  if (this.pendingExit) {
440
- // 干净退出前冲刷待合批的媒体保存提示,避免 500ms 窗口内的提示丢失。
441
- await this.bridge.flushAllMediaNotifications();
466
+ // 干净退出前冲刷装配器窗口内未提交的入站 turn,避免静默期窗口内的内容丢失。
467
+ await this.flushInbound?.();
442
468
  this.exitHandler();
443
469
  }
444
470
  return result;
445
471
  }
446
472
  // Handle bang commands (!command)
447
- if (trimmed.startsWith('!')) {
448
- const cmd = trimmed.slice(1).trim();
473
+ if (startsWithBang) {
474
+ const cmd = guard.clean.slice(1).trim();
449
475
  if (!cmd) {
450
476
  await this.bridge.sendResult({ text: '请输入要执行的命令,例如 !ls' }, ctx);
451
477
  return null;
@@ -456,7 +482,9 @@ export class CommandRouter {
456
482
  // Forward to Claude (P1-14: pass the enqueue-time workspace through so the
457
483
  // run uses the same cwd as the serial queue lane, even if /cd ran while
458
484
  // the message was queued)
459
- await this.bridge.forwardToClaude(trimmed, ctx, opts);
485
+ // 剥离占位符后再转发:agent 只该看到用户文本(附图已转成 attachments 块,
486
+ // 那是装配器生成的协议块,stripPlaceholders 会原样保留)。
487
+ await this.bridge.forwardToClaude(guard.clean, ctx, opts);
460
488
  return null;
461
489
  }
462
490
  /**
@@ -607,7 +635,7 @@ export class CommandRouter {
607
635
  // 必须同样消费,否则点击 /restart 按钮 spawn 成功后旧进程不退出,
608
636
  // 新进程撞单例锁退出 → 重启两头落空(2026-08-01 红绿 anchor 锁定)。
609
637
  if (this.pendingExit) {
610
- await this.bridge.flushAllMediaNotifications();
638
+ await this.flushInbound?.();
611
639
  this.exitHandler();
612
640
  }
613
641
  return;
@@ -2030,8 +2058,8 @@ export class CommandRouter {
2030
2058
  { cmd: 'ws', label: '/ws', desc: 'workspace 管理(save|use|remove)' },
2031
2059
  { cmd: 'config', label: '/config /c', desc: '查看和修改配置(卡片交互)' },
2032
2060
  { cmd: 'active', label: '/active', desc: '查看所有正在进行中的 session' },
2033
- { cmd: 'exit', label: '/exit /e', desc: '退出 bridge' },
2034
- { cmd: 'restart', label: '/restart', desc: '重启 bridge(新进程,config 不变)' },
2061
+ { cmd: 'exit', label: '/exit /e', desc: '退出 lark-remote' },
2062
+ { cmd: 'restart', label: '/restart', desc: '重启 lark-remote(新进程,config 不变)' },
2035
2063
  { cmd: 'update', label: '/update /u', desc: '检查并升级到最新版本' },
2036
2064
  {
2037
2065
  cmd: 'clone',
@@ -2140,6 +2168,16 @@ export class CommandRouter {
2140
2168
  content: '**Agent**\n通过 `/config` → 默认 Agent 切换:Claude / Codex / Opencode / Pi / Kimi',
2141
2169
  },
2142
2170
  });
2171
+ // 系统休眠说明
2172
+ bodyElements.push({ tag: 'hr' });
2173
+ bodyElements.push({
2174
+ tag: 'div',
2175
+ text: {
2176
+ tag: 'lark_md',
2177
+ content: '**系统休眠**\nlark-remote 运行期间会阻止系统自动休眠,以免远程连接不上,' +
2178
+ '不影响显示器睡眠',
2179
+ },
2180
+ });
2143
2181
  return {
2144
2182
  card: {
2145
2183
  schema: '2.0',
@@ -2228,7 +2266,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2228
2266
  async cmdExit() {
2229
2267
  // Signal handle() to invoke exitHandler after the reply is sent.
2230
2268
  this.pendingExit = true;
2231
- return { text: 'bridge 正在退出...' };
2269
+ return { text: 'lark-remote 正在退出...' };
2232
2270
  }
2233
2271
  cmdRestart() {
2234
2272
  if (!this.restartSpawner) {
@@ -2240,7 +2278,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2240
2278
  // 链路——spawn 失败则旧进程保持存活,不会两头落空。
2241
2279
  const pid = this.restartSpawner();
2242
2280
  this.pendingExit = true;
2243
- return { text: `♻️ bridge 重启中(新进程 pid ${pid}),启动通知稍后送达…` };
2281
+ return { text: `♻️ lark-remote 重启中(新进程 pid ${pid}),启动通知稍后送达…` };
2244
2282
  }
2245
2283
  catch (err) {
2246
2284
  getLogger().error('[router] restart spawn failed:', err);
@@ -3413,8 +3451,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3413
3451
  return { card };
3414
3452
  }
3415
3453
  cmdActive(_args, _ctx, offset = 0) {
3416
- // List all active runs from bridge memory (not from file system scan)
3417
- // New semantics (2026-07-20): only shows runs started by THIS bridge process
3454
+ // List all active runs from lark-remote memory (not from file system scan)
3455
+ // New semantics (2026-07-20): only shows runs started by THIS lark-remote process
3418
3456
  const activeRuns = this.bridge.getActiveRuns();
3419
3457
  const activeBashRuns = this.bridge.getActiveBashRuns();
3420
3458
  if (activeRuns.length === 0 && activeBashRuns.length === 0) {
@@ -1,4 +1,4 @@
1
- import { respondAcpApproval, sendAcpSetMode } from './protocol-helpers.js';
1
+ import { respondAcpApproval, sendAcpSetMode, truncateWithEllipsis, } from './protocol-helpers.js';
2
2
  import { getLogger } from '../../../logger/index.js';
3
3
  import { ConnectionBasedRunner } from '../connection-based-runner.js';
4
4
  export class BaseAcpRunner extends ConnectionBasedRunner {
@@ -87,7 +87,12 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
87
87
  options,
88
88
  ...(proto ? { proto } : {}),
89
89
  });
90
- getLogger().info(`[${this.logTag}] approval requested requestId=${ev.requestId} kind=${ev.kind}`);
90
+ // 命令预览进日志:审批卡在飞书侧,日志是本地唯一的事后追溯通道
91
+ // (kimi 的 request_permission 不带 rawInput,曾只剩 kind=command 可查)。
92
+ const commandPreview = ev.view.command
93
+ ? ` command=${truncateWithEllipsis(ev.view.command, 120)}`
94
+ : '';
95
+ getLogger().info(`[${this.logTag}] approval requested requestId=${ev.requestId} kind=${ev.kind}${commandPreview}`);
91
96
  }
92
97
  }
93
98
  }
@@ -59,6 +59,16 @@ export declare abstract class BaseAcpTranslator {
59
59
  protected liveCostUsd?: number;
60
60
  /** Current turn id, set by produceTurnStarted. */
61
61
  protected currentTurnId: string;
62
+ /**
63
+ * Latest streaming-args text per tool call (keyed by wire toolCallId).
64
+ * kimi ACP sends the permission request BEFORE tool.call.started (verified
65
+ * live against kimi 0.42.0, 2026-09-13): the request_permission toolCall
66
+ * carries only title + a truncated summary in content, NO rawInput — the
67
+ * full args only ever reach the client as accumulated text in the
68
+ * tool_call/tool_call_update streaming deltas. Tracking them here lets the
69
+ * approval handler correlate by toolCallId and recover the real command.
70
+ */
71
+ private toolCallArgsByCallId;
62
72
  /**
63
73
  * Handle a notification from the ACP server and return translated events.
64
74
  * Wire envelope: {sessionId, update: {sessionUpdate: '<kind>', ...}} — the
@@ -115,6 +125,15 @@ export declare abstract class BaseAcpTranslator {
115
125
  * tool_call_update → user/tool_result. status:'failed' → is_error:true.
116
126
  */
117
127
  protected handleToolCallUpdate(event: ToolCallUpdateEvent, _sessionId: string): AcpTranslatorEvent[];
128
+ /**
129
+ * Latest tracked streaming-args text for a tool call (undefined when never
130
+ * seen or already terminated). Approval handlers correlate
131
+ * request_permission's toolCall.toolCallId against this to recover the real
132
+ * command (see toolCallArgsByCallId).
133
+ */
134
+ protected getTrackedToolCallArgs(toolCallId: string): string | undefined;
135
+ /** Record latest args text, bounded so a long session cannot grow it forever. */
136
+ private trackToolCallArgs;
118
137
  /**
119
138
  * tool_result content extraction. Default: rawOutput passthrough (kimi
120
139
  * semantics). opencode overrides — its rawOutput is an OBJECT
@@ -11,6 +11,36 @@ const KIND_TO_TOOL = {
11
11
  search: 'Grep',
12
12
  fetch: 'WebFetch',
13
13
  };
14
+ /**
15
+ * Join the text payloads of a tool_call(/_update) content array
16
+ * (`[{type:'content', content:{type:'text', text}}]` entries). Returns
17
+ * undefined when no text entry exists (diff/terminal-only content).
18
+ */
19
+ function extractToolCallContentText(content) {
20
+ if (!Array.isArray(content))
21
+ return undefined;
22
+ let text = '';
23
+ let found = false;
24
+ for (const entry of content) {
25
+ const e = entry;
26
+ if (e?.type === 'content' && e.content?.type === 'text' && typeof e.content.text === 'string') {
27
+ text += e.content.text;
28
+ found = true;
29
+ }
30
+ }
31
+ return found ? text : undefined;
32
+ }
33
+ /** Best-effort stringify for tracked rawInput objects (never throws). */
34
+ function stringifyUnknown(value) {
35
+ if (typeof value === 'string')
36
+ return value;
37
+ try {
38
+ return JSON.stringify(value) ?? String(value);
39
+ }
40
+ catch {
41
+ return String(value);
42
+ }
43
+ }
14
44
  // =============================================================================
15
45
  // Translator base class
16
46
  // =============================================================================
@@ -25,6 +55,16 @@ export class BaseAcpTranslator {
25
55
  liveCostUsd;
26
56
  /** Current turn id, set by produceTurnStarted. */
27
57
  currentTurnId = '';
58
+ /**
59
+ * Latest streaming-args text per tool call (keyed by wire toolCallId).
60
+ * kimi ACP sends the permission request BEFORE tool.call.started (verified
61
+ * live against kimi 0.42.0, 2026-09-13): the request_permission toolCall
62
+ * carries only title + a truncated summary in content, NO rawInput — the
63
+ * full args only ever reach the client as accumulated text in the
64
+ * tool_call/tool_call_update streaming deltas. Tracking them here lets the
65
+ * approval handler correlate by toolCallId and recover the real command.
66
+ */
67
+ toolCallArgsByCallId = new Map();
28
68
  /**
29
69
  * Handle a notification from the ACP server and return translated events.
30
70
  * Wire envelope: {sessionId, update: {sessionUpdate: '<kind>', ...}} — the
@@ -191,6 +231,13 @@ export class BaseAcpTranslator {
191
231
  // Keep raw string as-is
192
232
  }
193
233
  }
234
+ const argsText = extractToolCallContentText(event.content);
235
+ if (argsText !== undefined) {
236
+ this.trackToolCallArgs(event.toolCallId, argsText);
237
+ }
238
+ else if (event.rawInput !== undefined && event.rawInput !== null) {
239
+ this.trackToolCallArgs(event.toolCallId, stringifyUnknown(event.rawInput));
240
+ }
194
241
  const mapped = event.kind ? KIND_TO_TOOL[event.kind] : undefined;
195
242
  const name = mapped ?? event.title;
196
243
  const summary = mapped && event.title && event.title !== name ? event.title : undefined;
@@ -218,6 +265,22 @@ export class BaseAcpTranslator {
218
265
  handleToolCallUpdate(event, _sessionId) {
219
266
  const isError = event.status === 'failed';
220
267
  const content = this.extractToolResultContent(event);
268
+ // Terminal updates end the args-streaming phase — drop the tracked entry.
269
+ // In-progress delta text is cumulative (events-map.ts
270
+ // toolCallDeltaToSessionUpdate: content.text = accumulator.args), so the
271
+ // latest text replaces the previous one rather than appending.
272
+ if (event.status === 'completed' || event.status === 'failed') {
273
+ this.toolCallArgsByCallId.delete(event.toolCallId);
274
+ }
275
+ else {
276
+ const argsText = extractToolCallContentText(event.content);
277
+ if (argsText !== undefined) {
278
+ this.trackToolCallArgs(event.toolCallId, argsText);
279
+ }
280
+ else if (event.rawInput !== undefined && event.rawInput !== null) {
281
+ this.trackToolCallArgs(event.toolCallId, stringifyUnknown(event.rawInput));
282
+ }
283
+ }
221
284
  return [
222
285
  {
223
286
  type: 'user',
@@ -235,6 +298,25 @@ export class BaseAcpTranslator {
235
298
  },
236
299
  ];
237
300
  }
301
+ /**
302
+ * Latest tracked streaming-args text for a tool call (undefined when never
303
+ * seen or already terminated). Approval handlers correlate
304
+ * request_permission's toolCall.toolCallId against this to recover the real
305
+ * command (see toolCallArgsByCallId).
306
+ */
307
+ getTrackedToolCallArgs(toolCallId) {
308
+ return this.toolCallArgsByCallId.get(toolCallId);
309
+ }
310
+ /** Record latest args text, bounded so a long session cannot grow it forever. */
311
+ trackToolCallArgs(toolCallId, argsText) {
312
+ this.toolCallArgsByCallId.delete(toolCallId);
313
+ this.toolCallArgsByCallId.set(toolCallId, argsText);
314
+ if (this.toolCallArgsByCallId.size > 100) {
315
+ const oldest = this.toolCallArgsByCallId.keys().next().value;
316
+ if (oldest !== undefined)
317
+ this.toolCallArgsByCallId.delete(oldest);
318
+ }
319
+ }
238
320
  /**
239
321
  * tool_result content extraction. Default: rawOutput passthrough (kimi
240
322
  * semantics). opencode overrides — its rawOutput is an OBJECT
@@ -22,6 +22,9 @@
22
22
  * 以 `turn_diff` 快照发出——禁止把 delta 直接当快照或把全文当增量重复追加。
23
23
  * 4. `tool_call` 事件可能**没有 rawInput**(工具 lazy-create,参数在后续
24
24
  * tool_call_update 才补齐);translator 必须归一化为 `{}`,不得因缺失崩溃。
25
+ * 5. `session/request_permission` 的 toolCall **不一定带 rawInput**(kimi 实测:
26
+ * 审批门先于 tool.call.started,只有 title + content 截断摘要);完整命令
27
+ * 需按 toolCallId 关联审批前的流式 args 文本恢复(BaseAcpTranslator 追踪)。
25
28
  */
26
29
  export type { JsonRpcRequest, JsonRpcNotification, JsonRpcSuccessResponse, JsonRpcErrorResponse, } from '../jsonrpc/types.js';
27
30
  /** Standard JSON-RPC error codes. */
@@ -226,9 +229,20 @@ export interface PermissionOption {
226
229
  }
227
230
  export interface RequestPermissionParams {
228
231
  sessionId: string;
232
+ /**
233
+ * Approval target. Divergence by server (2026-09-13 live probes):
234
+ * - kimi 0.42: {toolCallId, title: toolName, content: [truncated summary
235
+ * text]} — NO rawInput; the approval gate fires BEFORE tool.call.started,
236
+ * so the full args only exist in the pre-approval tool_call/_update
237
+ * streaming deltas (correlate by toolCallId, see BaseAcpTranslator).
238
+ * - opencode: {toolCallId, title: full command for bash/shell, rawInput:
239
+ * full input object} — title/rawInput are directly usable.
240
+ */
229
241
  toolCall?: {
230
- title: string;
231
- rawInput: string;
242
+ toolCallId?: string;
243
+ title?: string;
244
+ rawInput?: unknown;
245
+ content?: unknown[];
232
246
  [key: string]: unknown;
233
247
  };
234
248
  options: PermissionOption[];
@@ -22,6 +22,9 @@
22
22
  * 以 `turn_diff` 快照发出——禁止把 delta 直接当快照或把全文当增量重复追加。
23
23
  * 4. `tool_call` 事件可能**没有 rawInput**(工具 lazy-create,参数在后续
24
24
  * tool_call_update 才补齐);translator 必须归一化为 `{}`,不得因缺失崩溃。
25
+ * 5. `session/request_permission` 的 toolCall **不一定带 rawInput**(kimi 实测:
26
+ * 审批门先于 tool.call.started,只有 title + content 截断摘要);完整命令
27
+ * 需按 toolCallId 关联审批前的流式 args 文本恢复(BaseAcpTranslator 追踪)。
25
28
  */
26
29
  /** Standard JSON-RPC error codes. */
27
30
  export const RpcErrorCode = {
@@ -1,8 +1,12 @@
1
1
  import { ConnectionLostError } from './jsonrpc/client.js';
2
2
  import { getLogger } from '../../logger/index.js';
3
3
  import { syntheticInitEvent } from './runner-utils.js';
4
- /** How long to wait for turn output notifications before failing. */
5
- const TURN_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
4
+ import { DEFAULT_TURN_IDLE_TIMEOUT_MINUTES } from '../../config/index.js';
5
+ /** How long to wait for turn output notifications before failing.
6
+ * 30 分钟(单源于 config 的 DEFAULT_TURN_IDLE_TIMEOUT_MINUTES):ACP/app-server
7
+ * 类 agent 常因长工具调用(pip/npm install 等)或等待 SubAgent 长时间静默,
8
+ * 10 分钟会误杀正常进行中的 run。未接配置的 runner(如 opencode)直接吃此默认。 */
9
+ const TURN_IDLE_TIMEOUT_MS = DEFAULT_TURN_IDLE_TIMEOUT_MINUTES * 60 * 1000;
6
10
  export class ConnectionBasedRunner {
7
11
  kind;
8
12
  sessionReader;
@@ -36,7 +36,7 @@ export interface KimiAcpRunnerOptions {
36
36
  requestTimeoutMs?: number;
37
37
  /** Idle TTL for connection manager. */
38
38
  idleTtlMs?: number;
39
- /** How long to wait for turn output before failing. Defaults to 10 min. */
39
+ /** How long to wait for turn output before failing. Defaults to 30 min. */
40
40
  turnIdleTimeoutMs?: number;
41
41
  /** §5.2: compaction 等待的沉默超时——距上次观察到任何新 compaction 记录
42
42
  * (begin/terminal)超过该时长仍未终态才放弃(outcome unknown,卡片 error,
@@ -27,7 +27,7 @@ import { mapAnswersByIndex } from '../../question-common.js';
27
27
  import { getLogger } from '../../../logger/index.js';
28
28
  import { BaseAcpRunner } from '../../common/acp/base-acp-runner.js';
29
29
  import { ProcessStopper } from '../../common/process-stopper.js';
30
- /** §5.2: compaction 等待的默认沉默超时(对齐 turnIdleTimeoutMs 默认值)。 */
30
+ /** §5.2: compaction 等待的默认沉默超时(10 分钟,独立于 turn idle 默认 30 分钟)。 */
31
31
  const COMPACT_IDLE_TIMEOUT_MS = 10 * 60_000;
32
32
  /** Poll interval for the wire.jsonl compaction record. */
33
33
  const COMPACT_POLL_INTERVAL_MS = 1_000;