lark-remote 0.1.12 → 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 (160) hide show
  1. package/CHANGELOG.md +37 -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/inbound-media.d.ts +0 -3
  7. package/dist/bridge/inbound-media.js +0 -1
  8. package/dist/bridge/index.d.ts +7 -7
  9. package/dist/bridge/index.js +100 -133
  10. package/dist/bridge/queue-manager.js +4 -3
  11. package/dist/card/approval-render.js +39 -94
  12. package/dist/card/bash-renderer.js +12 -21
  13. package/dist/card/card-shared.d.ts +14 -5
  14. package/dist/card/card-shared.js +23 -34
  15. package/dist/card/run-renderer.d.ts +0 -22
  16. package/dist/card/run-renderer.js +206 -153
  17. package/dist/card/run-state.d.ts +29 -1
  18. package/dist/card/run-state.js +172 -84
  19. package/dist/card/text-truncate.d.ts +0 -9
  20. package/dist/card/text-truncate.js +0 -9
  21. package/dist/card/tool-render.d.ts +4 -1
  22. package/dist/card/tool-render.js +76 -32
  23. package/dist/cli.js +2 -2
  24. package/dist/common/fs.d.ts +14 -0
  25. package/dist/common/fs.js +57 -3
  26. package/dist/common/truncate.d.ts +0 -6
  27. package/dist/common/truncate.js +1 -1
  28. package/dist/common/ttl-cache.d.ts +24 -0
  29. package/dist/common/ttl-cache.js +53 -0
  30. package/dist/config/codex-config.js +14 -11
  31. package/dist/config/index.d.ts +0 -8
  32. package/dist/config/index.js +0 -16
  33. package/dist/config/kimi-config.d.ts +1 -1
  34. package/dist/config/kimi-config.js +2 -1
  35. package/dist/config/opencode-config.d.ts +1 -1
  36. package/dist/config/opencode-config.js +16 -10
  37. package/dist/config/qr.d.ts +36 -0
  38. package/dist/config/qr.js +124 -0
  39. package/dist/config/wizard.js +69 -27
  40. package/dist/connector/index.d.ts +3 -2
  41. package/dist/connector/index.js +5 -3
  42. package/dist/index.js +14 -41
  43. package/dist/instance-lock.js +7 -0
  44. package/dist/persistence/atomic-write.d.ts +2 -0
  45. package/dist/persistence/atomic-write.js +20 -5
  46. package/dist/persistence/load-json-file.d.ts +1 -2
  47. package/dist/persistence/load-json-file.js +2 -3
  48. package/dist/platform/agent-stopper.d.ts +22 -0
  49. package/dist/platform/agent-stopper.js +19 -0
  50. package/dist/platform/command.d.ts +11 -0
  51. package/dist/platform/command.js +107 -0
  52. package/dist/platform/identity.d.ts +33 -0
  53. package/dist/platform/identity.js +187 -0
  54. package/dist/platform/path.d.ts +24 -0
  55. package/dist/platform/path.js +110 -0
  56. package/dist/platform/select.d.ts +9 -0
  57. package/dist/platform/select.js +12 -0
  58. package/dist/platform/shell.d.ts +51 -0
  59. package/dist/platform/shell.js +75 -0
  60. package/dist/platform/spawn.d.ts +41 -0
  61. package/dist/platform/spawn.js +77 -0
  62. package/dist/platform/terminator-posix.d.ts +38 -0
  63. package/dist/platform/terminator-posix.js +103 -0
  64. package/dist/platform/terminator-win32.d.ts +21 -0
  65. package/dist/platform/terminator-win32.js +110 -0
  66. package/dist/platform/terminator.d.ts +44 -0
  67. package/dist/platform/terminator.js +24 -0
  68. package/dist/platform/types.d.ts +23 -0
  69. package/dist/platform/types.js +5 -0
  70. package/dist/restart.js +4 -2
  71. package/dist/router/card-helpers.d.ts +33 -6
  72. package/dist/router/card-helpers.js +67 -11
  73. package/dist/router/config/common/render.d.ts +1 -1
  74. package/dist/router/config/common/render.js +1 -1
  75. package/dist/router/config-diff.d.ts +4 -0
  76. package/dist/router/config-diff.js +70 -0
  77. package/dist/router/index.d.ts +73 -6
  78. package/dist/router/index.js +663 -590
  79. package/dist/router/utils.d.ts +6 -0
  80. package/dist/router/utils.js +12 -0
  81. package/dist/runner/bash/runner.d.ts +9 -2
  82. package/dist/runner/bash/runner.js +45 -20
  83. package/dist/runner/claude/session.d.ts +4 -4
  84. package/dist/runner/claude/session.js +18 -9
  85. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
  86. package/dist/runner/codex/app-server/runner.d.ts +1 -16
  87. package/dist/runner/codex/app-server/runner.js +2 -2
  88. package/dist/runner/codex/app-server/translator.d.ts +18 -0
  89. package/dist/runner/codex/app-server/translator.js +146 -0
  90. package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
  91. package/dist/runner/common/acp/base-acp-runner.js +248 -0
  92. package/dist/runner/common/acp/base-translator.d.ts +141 -0
  93. package/dist/runner/common/acp/base-translator.js +271 -0
  94. package/dist/runner/common/acp/protocol-helpers.d.ts +70 -0
  95. package/dist/runner/common/acp/protocol-helpers.js +82 -0
  96. package/dist/runner/common/connection-based-runner.d.ts +0 -2
  97. package/dist/runner/common/connection-based-runner.js +1 -1
  98. package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
  99. package/dist/runner/common/jsonrpc/transport.js +25 -5
  100. package/dist/runner/common/process-stopper.d.ts +4 -26
  101. package/dist/runner/common/process-stopper.js +4 -76
  102. package/dist/runner/common/runner-utils.d.ts +0 -5
  103. package/dist/runner/common/runner-utils.js +0 -7
  104. package/dist/runner/common/spawning-runner.d.ts +42 -132
  105. package/dist/runner/common/spawning-runner.js +52 -271
  106. package/dist/runner/common/usage.d.ts +2 -0
  107. package/dist/runner/common/usage.js +2 -0
  108. package/dist/runner/dsh/index.d.ts +0 -2
  109. package/dist/runner/dsh/index.js +0 -2
  110. package/dist/runner/dsh/runner.d.ts +0 -2
  111. package/dist/runner/dsh/runner.js +1 -1
  112. package/dist/runner/index.d.ts +1 -1
  113. package/dist/runner/kimi/acp/runner.d.ts +63 -53
  114. package/dist/runner/kimi/acp/runner.js +189 -334
  115. package/dist/runner/kimi/acp/translator.d.ts +18 -101
  116. package/dist/runner/kimi/acp/translator.js +61 -255
  117. package/dist/runner/opencode/acp/runner.d.ts +22 -46
  118. package/dist/runner/opencode/acp/runner.js +74 -297
  119. package/dist/runner/opencode/acp/translator.d.ts +28 -104
  120. package/dist/runner/opencode/acp/translator.js +54 -249
  121. package/dist/runner/pi/rpc/index.d.ts +0 -2
  122. package/dist/runner/pi/rpc/index.js +0 -2
  123. package/dist/runner/pi/rpc/translator.d.ts +3 -0
  124. package/dist/runner/pi/rpc/translator.js +47 -9
  125. package/dist/runner/probe.d.ts +8 -11
  126. package/dist/runner/probe.js +17 -21
  127. package/dist/runner/registry.d.ts +4 -4
  128. package/dist/runner/registry.js +4 -4
  129. package/dist/runner/resolve-agent-choices.js +6 -2
  130. package/dist/runner/sync-agent-choices.js +1 -1
  131. package/dist/runner/types.d.ts +38 -1
  132. package/dist/session/claude/session-index.js +3 -1
  133. package/dist/session/claude/sessions.js +37 -64
  134. package/dist/session/codex/rollout-reader.d.ts +10 -14
  135. package/dist/session/codex/rollout-reader.js +126 -48
  136. package/dist/session/common/constants.d.ts +7 -0
  137. package/dist/session/common/constants.js +9 -0
  138. package/dist/session/common/pagination.d.ts +0 -2
  139. package/dist/session/common/pagination.js +1 -1
  140. package/dist/session/common/recency.d.ts +7 -0
  141. package/dist/session/common/recency.js +10 -0
  142. package/dist/session/common/two-pass.d.ts +22 -0
  143. package/dist/session/common/two-pass.js +70 -0
  144. package/dist/session/common/usage-accumulator.d.ts +14 -0
  145. package/dist/session/common/usage-accumulator.js +15 -0
  146. package/dist/session/dsh/sessions.js +15 -11
  147. package/dist/session/kimi/sessions.d.ts +24 -4
  148. package/dist/session/kimi/sessions.js +99 -103
  149. package/dist/session/opencode/sessions.d.ts +2 -14
  150. package/dist/session/opencode/sessions.js +35 -15
  151. package/dist/session/pi/sessions.js +64 -95
  152. package/dist/update/index.d.ts +2 -3
  153. package/dist/update/index.js +1 -2
  154. package/dist/update/install.d.ts +2 -1
  155. package/dist/update/install.js +58 -9
  156. package/dist/update/version-check.d.ts +5 -0
  157. package/dist/update/version-check.js +9 -0
  158. package/package.json +9 -7
  159. package/dist/update/startup-hint.d.ts +0 -5
  160. package/dist/update/startup-hint.js +0 -11
@@ -6,25 +6,29 @@
6
6
  * → synthetic init → session/prompt → consume session/update notifications
7
7
  * until prompt settles → result event.
8
8
  *
9
- * Structurally aligned with codex AppServerRunner (design doc §3.1) via the
10
- * shared ConnectionBasedRunner base: same notification queue + waitResolve
11
- * pattern, same forceFinish/stopRequested semantics, same turn idle timeout,
12
- * same ConnectionLostError retry.
9
+ * Turn/connection/compact/approval orchestration lives in BaseAcpRunner.
10
+ * Kimi-specific parts:
11
+ * - compaction is a BACKGROUND task: after the compact prompt settles, poll
12
+ * wire.jsonl for a NEW terminal compaction record (§5.2, never fake success)
13
+ * - terminal/* reverse RPC: kimi delegates Bash execution to the client —
14
+ * this runner spawns one-shot local bash processes (handleTerminalRequest)
15
+ * - terminal-backed Bash tool notifications are filtered (bashToolCallIds) so
16
+ * the run card doesn't get duplicate output-less Bash panels
17
+ * - AskUserQuestion flows through elicitation forms + a request_permission
18
+ * question bridge (buildQuestionResponse)
13
19
  */
14
- import { spawn } from 'node:child_process';
20
+ import { spawnProcess, mergeProcessEnv, useDetachedProcessGroup } from '../../../platform/spawn.js';
15
21
  import { StringDecoder } from 'node:string_decoder';
16
- import { ConnectionManager as KimiAcpConnectionManager, } from '../../common/jsonrpc/connection-manager.js';
22
+ import { ConnectionManager, } from '../../common/jsonrpc/connection-manager.js';
17
23
  import { KimiAcpTranslator } from './translator.js';
18
24
  import { NotificationMethod, RpcErrorCode, ServerRequestMethod, } from '../../common/acp/protocol-types.js';
19
- import { findOptionIdByKind } from '../../common/acp/protocol-helpers.js';
25
+ import { KIMI_APPROVAL_KINDS, buildAcpPermissionOutcome, } from '../../common/acp/protocol-helpers.js';
20
26
  import { mapAnswersByIndex } from '../../question-common.js';
21
27
  import { getLogger } from '../../../logger/index.js';
22
- import { ConnectionBasedRunner } from '../../common/connection-based-runner.js';
28
+ import { BaseAcpRunner } from '../../common/acp/base-acp-runner.js';
23
29
  import { ProcessStopper } from '../../common/process-stopper.js';
24
- /** R2: how long to wait for the background compaction record after the
25
- * /compact prompt settles (design doc §6.1). Real kimi 0.36.0 lands
26
- * context.apply_compaction ~8s after end_turn. */
27
- const COMPACT_POLL_TIMEOUT_MS = 30_000;
30
+ /** §5.2: compaction 等待的默认沉默超时(对齐 turnIdleTimeoutMs 默认值)。 */
31
+ const COMPACT_IDLE_TIMEOUT_MS = 10 * 60_000;
28
32
  /** Poll interval for the wire.jsonl compaction record. */
29
33
  const COMPACT_POLL_INTERVAL_MS = 1_000;
30
34
  // =============================================================================
@@ -36,6 +40,16 @@ const COMPACT_POLL_INTERVAL_MS = 1_000;
36
40
  // =============================================================================
37
41
  /** terminal/create 未显式给 outputByteLimit 时的缓冲上限(对齐服务端默认 4MB)。 */
38
42
  const TERMINAL_DEFAULT_OUTPUT_LIMIT = 4 * 1024 * 1024;
43
+ /** 压缩终态记录:complete / apply_compaction 完成,cancel 为失败或取消(共用)。 */
44
+ function isCompactionTerminalLike(record) {
45
+ return (record.type === 'full_compaction.complete' ||
46
+ record.type === 'context.apply_compaction' ||
47
+ record.type === 'full_compaction.cancel');
48
+ }
49
+ /** 沉默窗口的展示文案(测试用小窗口显示秒,生产默认显示分钟)。 */
50
+ function formatIdleWindow(ms) {
51
+ return ms >= 60_000 ? `${Math.round(ms / 60_000)} 分钟` : `${Math.round(ms / 1000)} 秒`;
52
+ }
39
53
  /**
40
54
  * Map user-facing permission mode to ACP mode id.
41
55
  *
@@ -58,53 +72,6 @@ function toAcpMode(mode) {
58
72
  return 'yolo';
59
73
  }
60
74
  }
61
- /**
62
- * Build the ACP protocol response for a permission approval decision.
63
- *
64
- * accept → {outcome:{outcome:'selected', optionId:<approve_once kind>}}
65
- * accept_for_session → {outcome:{outcome:'selected', optionId:<approve_always kind>}}
66
- * decline → {outcome:{outcome:'selected', optionId:<reject kind>}}
67
- * cancel → {outcome:{outcome:'cancelled'}}
68
- *
69
- * optionId is looked up from the request's options[] by kind (approval.ts:28-29):
70
- * approve_once / approve_always / allow_once for accept;
71
- * approve_always / allow_always for accept_for_session;
72
- * reject / reject_once for decline. If not found, fall back to cancelled
73
- * (safe universal default — the server treats it as "user declined").
74
- *
75
- * 2026-08-15 live test: approve_once + approve_always + reject observed.
76
- */
77
- function buildApprovalResponse(action, pending) {
78
- if (action === 'cancel') {
79
- return { outcome: { outcome: 'cancelled' } };
80
- }
81
- if (action === 'accept') {
82
- const optionId = findOptionIdByKind(pending.options, [
83
- 'approve_once',
84
- 'approve_always',
85
- 'allow_once',
86
- ]);
87
- if (optionId) {
88
- return { outcome: { outcome: 'selected', optionId } };
89
- }
90
- // Fallback: can't find allow option → cancel is safe
91
- return { outcome: { outcome: 'cancelled' } };
92
- }
93
- if (action === 'accept_for_session') {
94
- // §P4: 卡片「允许本次会话」→ always 类 optionId(kimi approve_always)
95
- const optionId = findOptionIdByKind(pending.options, ['approve_always', 'allow_always']);
96
- if (optionId) {
97
- return { outcome: { outcome: 'selected', optionId } };
98
- }
99
- return { outcome: { outcome: 'cancelled' } };
100
- }
101
- // decline
102
- const optionId = findOptionIdByKind(pending.options, ['reject', 'reject_once']);
103
- if (optionId) {
104
- return { outcome: { outcome: 'selected', optionId } };
105
- }
106
- return { outcome: { outcome: 'cancelled' } };
107
- }
108
75
  /**
109
76
  * AskUserQuestion 回编(kind === 'question'):
110
77
  * - elicitation form:answer → {action:'accept', content:{q0..qn}}(多选数组/
@@ -165,15 +132,15 @@ function buildQuestionResponse(action, pending, response) {
165
132
  // =============================================================================
166
133
  // Runner
167
134
  // =============================================================================
168
- export class KimiAcpRunner extends ConnectionBasedRunner {
169
- connectionManager;
170
- currentTranslator = null;
171
- activeSessionId = null;
172
- model;
135
+ export class KimiAcpRunner extends BaseAcpRunner {
173
136
  permissionMode;
174
- compactPollTimeoutMs;
175
- /** Pending approval requests: requestId → kind + view + options. */
176
- pendingApprovals = new Map();
137
+ compactIdleTimeoutMs;
138
+ /**
139
+ * §5.2 compaction baseline: wire.jsonl records sampled by compactBaseline()
140
+ * BEFORE the compact prompt fires (reset per compact run). onCompactPromptFired
141
+ * derives both the terminal-count baseline and the total-count probe from it.
142
+ */
143
+ compactBaselineRecords = [];
177
144
  /** 存活 terminal(kimi 把 Bash 执行下放客户端):terminalId → 本地句柄。 */
178
145
  terminals = new Map();
179
146
  /** terminalId 自增序号(term-1, term-2, …)。 */
@@ -189,13 +156,7 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
189
156
  /** 当前 run 的 session cwd(terminal/create 未给 cwd 时回退用)。 */
190
157
  activeCwd = null;
191
158
  processStopper = new ProcessStopper({ graceMs: 2_000 });
192
- /** Tracker for the in-flight prompt request — needed for cancellation. */
193
- promptSettled = false;
194
159
  constructor(opts) {
195
- super({ kind: opts.kind, sessionReader: opts.sessionReader }, opts.turnIdleTimeoutMs);
196
- this.model = opts.model;
197
- this.permissionMode = opts.permissionMode ?? 'manual';
198
- this.compactPollTimeoutMs = opts.compactPollTimeoutMs ?? COMPACT_POLL_TIMEOUT_MS;
199
160
  const managerOpts = {
200
161
  binary: opts.binary ?? 'kimi',
201
162
  args: opts.acpArgs ?? ['acp'],
@@ -222,7 +183,14 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
222
183
  },
223
184
  },
224
185
  };
225
- this.connectionManager = new KimiAcpConnectionManager(managerOpts);
186
+ super({
187
+ kind: opts.kind,
188
+ sessionReader: opts.sessionReader,
189
+ turnIdleTimeoutMs: opts.turnIdleTimeoutMs,
190
+ }, new ConnectionManager(managerOpts));
191
+ this.model = opts.model;
192
+ this.permissionMode = opts.permissionMode ?? 'manual';
193
+ this.compactIdleTimeoutMs = opts.compactIdleTimeoutMs ?? COMPACT_IDLE_TIMEOUT_MS;
226
194
  }
227
195
  get logTag() {
228
196
  return 'kimi-acp-runner';
@@ -233,144 +201,113 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
233
201
  get turnInterruptedErrorMessage() {
234
202
  return 'Kimi ACP turn interrupted';
235
203
  }
236
- currentSessionId() {
237
- return this.activeSessionId;
204
+ get connectionClosedMessage() {
205
+ return 'Kimi ACP connection closed';
238
206
  }
239
207
  shouldDeferStop() {
240
208
  return !this.promptSettled;
241
209
  }
242
- async cancelCurrentTurn() {
243
- if (!this.currentClient || !this.activeSessionId)
244
- return;
210
+ createTranslator() {
211
+ return new KimiAcpTranslator();
212
+ }
213
+ async applyTurnSettings(client, sessionId) {
214
+ // CC-07: 下发配置的模型(provider/model)。session/new|resume 只带 cwd/mcpServers,
215
+ // 不带 model;不主动下发则实际跑 kimi 服务端默认模型。
216
+ // 仿 opencode 用 session/set_config_option。失败仅告警不阻断。
217
+ if (this.model) {
218
+ try {
219
+ await client.request('session/set_config_option', {
220
+ sessionId,
221
+ configId: 'model',
222
+ value: this.model,
223
+ });
224
+ }
225
+ catch (err) {
226
+ getLogger().warn(`[${this.logTag}] set_config_option model failed (non-fatal): ${err.message}`);
227
+ }
228
+ }
229
+ // Set permission mode (§5.1): EVERY fresh ACP session starts in
230
+ // 'default' (= manual approvals, acp-server modes.ts DEFAULT_MODE_ID),
231
+ // so the mode must be sent unconditionally — skipping yolo leaves the
232
+ // session in manual and tool calls stall on unanswered approvals
233
+ // (live dark-test repro: approval_requested → idle timeout).
234
+ const modeParams = {
235
+ sessionId,
236
+ modeId: toAcpMode(this.permissionMode),
237
+ };
245
238
  try {
246
- // session/cancel is a NOTIFICATION (no id, no response) per ACP spec.
247
- const cancelParams = { sessionId: this.activeSessionId };
248
- this.currentClient.notify('session/cancel', cancelParams);
239
+ await client.request('session/set_mode', modeParams);
249
240
  }
250
241
  catch (err) {
251
- getLogger().warn(`[${this.logTag}] session/cancel failed: ${err.message}`);
242
+ getLogger().warn(`[${this.logTag}] session/set_mode failed (non-fatal): ${err.message}`);
252
243
  }
253
244
  }
254
- clearTurnState() {
255
- this.currentTranslator = null;
256
- this.promptSettled = false;
257
- this.activeSessionId = null;
258
- this.activeCwd = null;
259
- }
260
- async releaseConnection(cwd) {
261
- await this.connectionManager.release(cwd);
262
- }
263
- notifyIdle(cwd) {
264
- this.connectionManager.notifyIdle(cwd);
265
- }
266
- async disposeConnections() {
267
- // 关闭前 kill/release 所有存活 terminal,避免孤儿 bash 进程。
268
- await this.cleanupTerminals();
269
- await this.connectionManager.disposeAll();
270
- }
271
245
  /**
272
- * Run a compact operation on the current session.
273
- *
274
- * Design doc §6.1: acquire → session/resume → session/prompt with "/compact"
275
- * text (builtin slash command, host intercepts) → consume notifications until
276
- * prompt settles.
246
+ * §5.2: compaction is a BACKGROUND task — the prompt settles before the
247
+ * wire.jsonl terminal record lands. Baselines are captured BEFORE the
248
+ * compact prompt fires (see compactBaseline override — a begin record
249
+ * landing after the fire must count as new activity, not baseline), then
250
+ * wait for a NEW terminal (complete/apply → completed; cancel → cancelled)
251
+ * before producing the result. There is NO total timeout — silence beyond
252
+ * compactIdleTimeoutMs yields 'unknown' (never a fake success). Keeps the
253
+ * connection alive while the background compaction finishes — otherwise
254
+ * dispose/exit kills it and the record never lands (S5/S6 打回复现 /
255
+ * 2026-08-31 事故).
277
256
  */
278
- async *runCompact(_message, opts) {
279
- yield* this.executeTurn(opts, async () => {
280
- const client = await this.connectionManager.acquire(opts.cwd);
281
- this.connectionManager.notifyActivity(opts.cwd);
282
- this.currentClient = client;
283
- this.activeCwd = opts.cwd;
284
- client.setHooks({
285
- onNotification: (method, params) => this.handleNotification(method, params),
286
- onServerRequest: (id, method, params) => this.handleServerRequest(id, method, params),
287
- onClose: () => {
288
- void this.cleanupTerminals();
289
- this.failTurn('Kimi ACP connection closed');
290
- },
291
- });
292
- const sessionId = opts.sessionId;
293
- if (!sessionId) {
294
- throw new Error('compact requires a sessionId');
295
- }
296
- this.activeSessionId = sessionId;
297
- // Cold-connection fallback: session/resume loads the session into memory.
298
- // On a reused connection the session is already loaded — resume is
299
- // idempotent and harmless; after connection rebuild it's required.
300
- const resumeParams = {
301
- sessionId,
302
- cwd: opts.cwd,
303
- };
304
- await client.request('session/resume', resumeParams);
305
- const translator = new KimiAcpTranslator();
306
- translator.setOperationKind('compact');
307
- this.currentTranslator = translator;
308
- // Emit turn_started with operationKind='compaction' before the prompt
309
- // (consumeTurn yields it while the background compaction runs).
310
- const turnId = `compact-${Date.now()}`;
311
- const turnStarted = translator.produceTurnStarted(sessionId, turnId);
312
- this.currentTurnId = turnId;
313
- this.pushEvents([turnStarted]);
314
- // Send /compact as prompt text
315
- const promptParams = {
316
- sessionId,
317
- prompt: [{ type: 'text', text: '/compact' }],
318
- };
319
- // R2: compaction is a BACKGROUND task — the prompt settles before the
320
- // wire.jsonl compaction record lands (real kimi: ~8s). Baseline the
321
- // record count, fire the prompt without awaiting (notifications flow
322
- // through consumeTurn), then poll for a NEW record before producing
323
- // the result. This keeps the connection alive while the background
324
- // compaction finishes — otherwise dispose/exit kills it and the record
325
- // never lands (S5/S6 打回复现).
326
- const compactionReader = this.sessionReader.readCompactionRecords;
327
- const baselineCount = compactionReader
328
- ? compactionReader.call(this.sessionReader, sessionId, opts.cwd).length
329
- : 0;
330
- this.promptSettled = false;
331
- // ACP holds the session/prompt response for the ENTIRE turn (unlike
332
- // codex turn/start), so the RPC-level timeout must not apply here —
333
- // turn liveness is guarded by the rolling idle watchdog instead.
334
- const promptPromise = client.request('session/prompt', promptParams, Number.POSITIVE_INFINITY);
335
- promptPromise.then(async (result) => {
336
- this.promptSettled = true;
337
- if (this.stopRequested)
338
- return; // already cancelled
339
- if (compactionReader) {
340
- await this.waitForCompactionRecord(compactionReader, opts, baselineCount);
257
+ compactBaseline(sessionId, opts) {
258
+ const compactionReader = this.sessionReader.readCompactionRecords;
259
+ this.compactBaselineRecords = compactionReader
260
+ ? compactionReader.call(this.sessionReader, sessionId, opts.cwd)
261
+ : [];
262
+ return this.compactBaselineRecords.length;
263
+ }
264
+ onCompactPromptFired(promptPromise, translator, sessionId, opts, baselineTotalCount) {
265
+ const compactionReader = this.sessionReader.readCompactionRecords;
266
+ // Derived from the pre-fire snapshot captured by compactBaseline() —
267
+ // re-reading here would race the prompt fire and misclassify new records.
268
+ const baselineTerminalCount = this.compactBaselineRecords.filter(isCompactionTerminalLike).length;
269
+ promptPromise.then(async (result) => {
270
+ this.promptSettled = true;
271
+ if (this.stopRequested)
272
+ return; // already cancelled
273
+ if (compactionReader) {
274
+ // §5.4 可选防线(日志探针):prompt settle 后既无新 begin 也无新
275
+ // terminal → compact 未被引擎接受(可能已在跑或被拒绝)。不解析
276
+ // "already running" 文本、不影响卡片。
277
+ const totalNow = compactionReader.call(this.sessionReader, sessionId, opts.cwd).length;
278
+ if (totalNow === baselineTotalCount) {
279
+ getLogger().warn(`[${this.logTag}] compact 未被引擎接受(无新 begin/terminal 记录,可能已在跑或被拒绝)`);
341
280
  }
281
+ const outcome = await this.waitForCompactionTerminal(compactionReader, opts, baselineTerminalCount);
342
282
  if (this.forceFinish)
343
283
  return; // stopped while polling
344
- // Translate prompt result into a result event
345
- const resultEvent = translator.handlePromptResponse(sessionId, result);
346
- this.pushEvents([resultEvent]);
347
- }, (err) => {
348
- this.promptSettled = true;
349
- if (this.stopRequested) {
350
- getLogger().warn(`[${this.logTag}] compact prompt rejected after stopRequested: ${err.message}`);
351
- return;
284
+ if (outcome === 'completed') {
285
+ // 压缩完成:正常翻译 prompt 结果(subtype success)。
286
+ const resultEvent = translator.handlePromptResponse(sessionId, result);
287
+ this.pushEvents([resultEvent]);
288
+ }
289
+ else if (outcome === 'cancelled') {
290
+ this.pushEvents([
291
+ translator.produceErrorResult(sessionId, '压缩未完成:已被取消或压缩请求失败(可重试)'),
292
+ ]);
352
293
  }
353
- const resultEvent = translator.produceErrorResult(sessionId, err.message);
294
+ else if (outcome === 'unknown') {
295
+ this.pushEvents([
296
+ translator.produceErrorResult(sessionId, `压缩状态未知:超过 ${formatIdleWindow(this.compactIdleTimeoutMs)} 未观察到完成/取消记录,压缩可能仍在后台进行`),
297
+ ]);
298
+ }
299
+ // outcome === 'stopped':不推 result,由 consumeTurn 的 interrupted 兜底。
300
+ }
301
+ else {
302
+ const resultEvent = translator.handlePromptResponse(sessionId, result);
354
303
  this.pushEvents([resultEvent]);
355
- });
356
- });
304
+ }
305
+ }, (err) => this.handlePromptRejected(err, translator, sessionId, 'compact'));
357
306
  }
358
- /**
359
- * Respond to an approval server request. `response` is the bridge
360
- * ApprovalAction (`{ action: 'accept' | 'decline' | 'cancel' }`).
361
- */
362
- async respondApproval(requestId, response) {
363
- const client = this.currentClient;
364
- const pending = this.pendingApprovals.get(requestId);
365
- if (!client || !pending)
366
- return;
367
- const action = response?.action ?? 'decline';
368
- const acpResponse = pending.kind === 'question'
307
+ buildApprovalOutcome(action, pending, response) {
308
+ return pending.kind === 'question'
369
309
  ? buildQuestionResponse(action, pending, response)
370
- : buildApprovalResponse(action, pending);
371
- client.respond(requestId, acpResponse);
372
- this.pendingApprovals.delete(requestId);
373
- getLogger().info(`[${this.logTag}] approval responded requestId=${requestId} action=${action}`);
310
+ : buildAcpPermissionOutcome(action, pending.options, KIMI_APPROVAL_KINDS);
374
311
  }
375
312
  getStatusInfo() {
376
313
  return {
@@ -395,144 +332,53 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
395
332
  if (settings.permissionMode !== undefined) {
396
333
  this.permissionMode = settings.permissionMode;
397
334
  }
398
- if (!this.currentClient || !this.activeSessionId)
399
- return;
400
- const modeParams = {
401
- sessionId: this.activeSessionId,
402
- modeId: toAcpMode(this.permissionMode),
403
- };
404
- try {
405
- await this.currentClient.request('session/set_mode', modeParams);
406
- getLogger().info(`[${this.logTag}] session/set_mode hot-applied session=${this.activeSessionId} modeId=${modeParams.modeId}`);
407
- }
408
- catch (err) {
409
- getLogger().warn(`[${this.logTag}] session/set_mode failed (non-fatal): ${err.message}`);
410
- }
335
+ await this.sendApprovalModeUpdate(toAcpMode(this.permissionMode));
411
336
  }
412
337
  // =========================================================================
413
338
  // Internal
414
339
  // =========================================================================
415
340
  /**
416
- * Acquire the connection and set up the turn:
417
- * session/new (new session) or session/resume (existing sessionId)
418
- * → set permission mode if needed → session/prompt.
419
- */
420
- async setupTurn(message, opts) {
421
- const client = await this.connectionManager.acquire(opts.cwd);
422
- this.connectionManager.notifyActivity(opts.cwd);
423
- this.currentClient = client;
424
- this.activeCwd = opts.cwd;
425
- client.setHooks({
426
- onNotification: (method, params) => this.handleNotification(method, params),
427
- onServerRequest: (id, method, params) => this.handleServerRequest(id, method, params),
428
- onClose: () => {
429
- getLogger().warn(`[${this.logTag}] client connection closed`);
430
- void this.cleanupTerminals();
431
- this.failTurn('Kimi ACP connection closed');
432
- },
433
- });
434
- let sessionId;
435
- if (opts.sessionId) {
436
- const resumeParams = {
437
- sessionId: opts.sessionId,
438
- cwd: opts.cwd,
439
- };
440
- await client.request('session/resume', resumeParams);
441
- sessionId = opts.sessionId;
442
- }
443
- else {
444
- const newParams = { cwd: opts.cwd, mcpServers: [] };
445
- const newResult = await client.request('session/new', newParams);
446
- sessionId = newResult.sessionId;
447
- }
448
- this.activeSessionId = sessionId;
449
- // CC-07: 下发配置的模型(provider/model)。session/new|resume 只带 cwd/mcpServers,
450
- // 不带 model;不主动下发则实际跑 kimi 服务端默认模型(旧 KimiRunner 通过 -m 传模型,
451
- // 迁移到纯 ACP 后丢失)。仿 opencode 用 session/set_config_option。失败仅告警不阻断。
452
- if (this.model) {
453
- try {
454
- await client.request('session/set_config_option', {
455
- sessionId,
456
- configId: 'model',
457
- value: this.model,
458
- });
459
- }
460
- catch (err) {
461
- getLogger().warn(`[${this.logTag}] set_config_option model failed (non-fatal): ${err.message}`);
462
- }
463
- }
464
- // Set permission mode (§5.1): EVERY fresh ACP session starts in
465
- // 'default' (= manual approvals, acp-server modes.ts DEFAULT_MODE_ID),
466
- // so the mode must be sent unconditionally — skipping yolo leaves the
467
- // session in manual and tool calls stall on unanswered approvals
468
- // (live dark-test repro: approval_requested → idle timeout).
469
- const modeParams = {
470
- sessionId,
471
- modeId: toAcpMode(this.permissionMode),
472
- };
473
- try {
474
- await client.request('session/set_mode', modeParams);
475
- }
476
- catch (err) {
477
- getLogger().warn(`[${this.logTag}] session/set_mode failed (non-fatal): ${err.message}`);
478
- }
479
- const translator = new KimiAcpTranslator();
480
- this.currentTranslator = translator;
481
- // Emit turn_started before prompt
482
- const turnId = `turn-${Date.now()}`;
483
- const turnStarted = translator.produceTurnStarted(sessionId, turnId);
484
- this.currentTurnId = turnId;
485
- this.pushEvents([turnStarted]);
486
- // Send the prompt
487
- const promptParams = {
488
- sessionId,
489
- prompt: [{ type: 'text', text: message }],
490
- };
491
- // Fire the prompt request; notifications will be buffered in the queue.
492
- // We process them concurrently.
493
- this.promptSettled = false;
494
- const promptPromise = client.request('session/prompt', promptParams, Number.POSITIVE_INFINITY);
495
- // Wait for prompt to settle, then push the result event.
496
- // Notifications are pushed concurrently by the hook handlers.
497
- promptPromise.then((result) => {
498
- this.promptSettled = true;
499
- if (this.stopRequested)
500
- return; // already cancelled
501
- const resultEvent = translator.handlePromptResponse(sessionId, result);
502
- this.pushEvents([resultEvent]);
503
- }, (err) => {
504
- this.promptSettled = true;
505
- if (this.stopRequested) {
506
- getLogger().warn(`[${this.logTag}] prompt rejected after stopRequested: ${err.message}`);
507
- return;
508
- }
509
- const resultEvent = translator.produceErrorResult(sessionId, err.message);
510
- this.pushEvents([resultEvent]);
511
- });
512
- }
513
- /**
514
- * R2: poll wire.jsonl until a NEW compaction record
515
- * (context.apply_compaction / full_compaction.complete) appears.
341
+ * §5.2: 等 wire.jsonl 出现 NEW 压缩终态记录,返回判别结果而非 void。
516
342
  *
517
- * The /compact prompt settles before the background compaction lands its
518
- * record; returning early would let the caller dispose the connection and
519
- * kill the background job. Timeout is WARN-only (the compact may have been
520
- * a no-op) — never fails the turn. Aborts early on stop/forceFinish.
343
+ * 压缩是完整 LLM 请求(引擎 ≤5 次重试 + overflow 收缩重试),时长无上限;
344
+ * 旧「固定 30s 超时 = 成功」已删除。出口:
345
+ * completed — 观察到 full_compaction.complete / context.apply_compaction;
346
+ * cancelled — 观察到 full_compaction.cancel(API 失败/abort 与用户取消共用,
347
+ * 绝不当成功);
348
+ * stopped — 等待中 stop()/forceFinish(consumeTurn 产 interrupted 兜底);
349
+ * unknown — 距上次观察到任何新 compaction 记录(begin/terminal)超过
350
+ * compactIdleTimeoutMs(沉默超时,压缩可能仍在后台进行)。
351
+ * 观察到新记录即重置沉默时钟,并顺带重置 turn idle 时钟(lastEventAt +
352
+ * wakeWaiters),避免数分钟的压缩被 consumeTurn 滚动空闲看门狗误杀。
521
353
  */
522
- async waitForCompactionRecord(reader, opts, baselineCount) {
354
+ async waitForCompactionTerminal(reader, opts, baselineTerminalCount) {
523
355
  const sessionId = this.activeSessionId ?? opts.sessionId ?? '';
524
- const deadline = Date.now() + this.compactPollTimeoutMs;
525
- while (Date.now() < deadline) {
356
+ let lastActivityAt = Date.now();
357
+ let lastTotalCount = reader.call(this.sessionReader, sessionId, opts.cwd).length;
358
+ while (true) {
526
359
  if (this.forceFinish || this.stopRequested)
527
- return;
360
+ return 'stopped';
528
361
  const records = reader.call(this.sessionReader, sessionId, opts.cwd);
529
- if (records.length > baselineCount) {
530
- getLogger().info(`[${this.logTag}] compaction record observed (${records.length} > baseline ${baselineCount})`);
531
- return;
362
+ if (records.length !== lastTotalCount) {
363
+ lastTotalCount = records.length;
364
+ lastActivityAt = Date.now();
365
+ // 压缩仍在活动(新 begin / 重试 / terminal):重置 turn idle 时钟,
366
+ // 让 consumeTurn 重新计算滚动截止时间。
367
+ this.lastEventAt = Date.now();
368
+ this.wakeWaiters();
369
+ }
370
+ const terminalRecords = records.filter(isCompactionTerminalLike);
371
+ if (terminalRecords.length > baselineTerminalCount) {
372
+ const newest = terminalRecords[terminalRecords.length - 1];
373
+ getLogger().info(`[${this.logTag}] compaction terminal observed (${newest.type}, ${terminalRecords.length} > baseline ${baselineTerminalCount})`);
374
+ return newest.type === 'full_compaction.cancel' ? 'cancelled' : 'completed';
375
+ }
376
+ if (Date.now() - lastActivityAt >= this.compactIdleTimeoutMs) {
377
+ getLogger().warn(`[${this.logTag}] no compaction terminal within ${this.compactIdleTimeoutMs}ms of last activity (baselineTerminal=${baselineTerminalCount}); reporting unknown`);
378
+ return 'unknown';
532
379
  }
533
380
  await new Promise((resolve) => setTimeout(resolve, COMPACT_POLL_INTERVAL_MS));
534
381
  }
535
- getLogger().warn(`[${this.logTag}] compaction record not observed within ${this.compactPollTimeoutMs}ms (baseline=${baselineCount}); proceeding without confirmation`);
536
382
  }
537
383
  handleNotification(method, params) {
538
384
  // Terminal-backed Bash 的通知走 runner 自产事件(见 bashToolCallIds),
@@ -606,17 +452,7 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
606
452
  }
607
453
  return;
608
454
  }
609
- for (const ev of events) {
610
- if (ev.type === 'approval_requested') {
611
- this.pendingApprovals.set(ev.requestId, {
612
- kind: ev.kind,
613
- view: ev.view,
614
- options: params.options,
615
- proto: method === ServerRequestMethod.ELICITATION_CREATE ? 'elicitation' : 'permission',
616
- });
617
- getLogger().info(`[${this.logTag}] approval requested requestId=${ev.requestId} kind=${ev.kind}`);
618
- }
619
- }
455
+ this.registerApprovalEvents(events, params.options, method === ServerRequestMethod.ELICITATION_CREATE ? 'elicitation' : 'permission');
620
456
  this.pushEvents(events);
621
457
  }
622
458
  /**
@@ -665,14 +501,17 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
665
501
  : undefined;
666
502
  const cwd = params.cwd ?? this.activeCwd ?? undefined;
667
503
  const outputLimit = params.outputByteLimit ?? TERMINAL_DEFAULT_OUTPUT_LIMIT;
668
- const proc = spawn(params.command, params.args ?? [], {
504
+ const proc = spawnProcess(params.command, params.args ?? [], {
669
505
  cwd,
670
- env: env ? { ...process.env, ...env } : undefined,
506
+ // env 覆盖大小写不敏感合并(win32 PATH/Path 双键防护,v2 §8.3)
507
+ env: env ? mergeProcessEnv(process.env, env) : undefined,
671
508
  stdio: ['ignore', 'pipe', 'pipe'],
672
509
  // ProcessStopper 用负 PID 杀进程组(kill(-pgid));子进程必须是组长
673
510
  // 才能命中,否则 kill(-pid) 抛 ESRCH 被吞 → kill/release/清理全失效。
674
- // 与 JsonlRpcTransport / spawning-runner 的 detached:true 同模式。
675
- detached: true,
511
+ // 与 JsonlRpcTransport / spawning-runner 同模式;win32 不 detached
512
+ // (`.cmd` 垫片在 DETACHED_PROCESS 下丢 stdio),树杀由 taskkill /T 负责。
513
+ detached: useDetachedProcessGroup(),
514
+ windowsHide: true,
676
515
  });
677
516
  // 每个流独立 decoder:多字节字符跨 chunk 拆分时由 decoder 保留不完整
678
517
  // 序列,避免按 chunk 单独 toString 产生 U+FFFD 替换符。
@@ -835,5 +674,21 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
835
674
  await this.processStopper.stop(handle.proc, { immediate: true });
836
675
  }
837
676
  }
677
+ // =========================================================================
678
+ // BaseAcpRunner hooks
679
+ // =========================================================================
680
+ onConnectionAcquired(opts) {
681
+ this.activeCwd = opts.cwd;
682
+ }
683
+ onConnectionClosed() {
684
+ void this.cleanupTerminals();
685
+ }
686
+ onClearTurnState() {
687
+ this.activeCwd = null;
688
+ }
689
+ async beforeDisposeConnections() {
690
+ // 关闭前 kill/release 所有存活 terminal,避免孤儿 bash 进程。
691
+ await this.cleanupTerminals();
692
+ }
838
693
  }
839
694
  //# sourceMappingURL=runner.js.map