lark-remote 0.1.10 → 0.1.12
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.
- package/CHANGELOG.md +22 -0
- package/dist/config/codex-config.d.ts +22 -0
- package/dist/config/codex-config.js +108 -14
- package/dist/index.js +6 -0
- package/dist/router/index.js +12 -5
- package/dist/runner/common/acp/protocol-types.d.ts +38 -1
- package/dist/runner/common/acp/protocol-types.js +6 -0
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/kimi/acp/runner.d.ts +51 -0
- package/dist/runner/kimi/acp/runner.js +311 -3
- package/dist/runner/opencode/acp/error-monitor.d.ts +61 -0
- package/dist/runner/opencode/acp/error-monitor.js +150 -0
- package/dist/runner/opencode/acp/runner.d.ts +17 -0
- package/dist/runner/opencode/acp/runner.js +26 -0
- package/dist/runner/types.d.ts +23 -0
- package/dist/session/codex/rollout-reader.d.ts +25 -1
- package/dist/session/codex/rollout-reader.js +257 -167
- package/dist/session/codex/sessions.d.ts +2 -1
- package/dist/session/codex/sessions.js +4 -1
- package/package.json +1 -1
|
@@ -11,19 +11,31 @@
|
|
|
11
11
|
* pattern, same forceFinish/stopRequested semantics, same turn idle timeout,
|
|
12
12
|
* same ConnectionLostError retry.
|
|
13
13
|
*/
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
14
16
|
import { ConnectionManager as KimiAcpConnectionManager, } from '../../common/jsonrpc/connection-manager.js';
|
|
15
17
|
import { KimiAcpTranslator } from './translator.js';
|
|
16
|
-
import { RpcErrorCode, ServerRequestMethod, } from '../../common/acp/protocol-types.js';
|
|
18
|
+
import { NotificationMethod, RpcErrorCode, ServerRequestMethod, } from '../../common/acp/protocol-types.js';
|
|
17
19
|
import { findOptionIdByKind } from '../../common/acp/protocol-helpers.js';
|
|
18
20
|
import { mapAnswersByIndex } from '../../question-common.js';
|
|
19
21
|
import { getLogger } from '../../../logger/index.js';
|
|
20
22
|
import { ConnectionBasedRunner } from '../../common/connection-based-runner.js';
|
|
23
|
+
import { ProcessStopper } from '../../common/process-stopper.js';
|
|
21
24
|
/** R2: how long to wait for the background compaction record after the
|
|
22
25
|
* /compact prompt settles (design doc §6.1). Real kimi 0.36.0 lands
|
|
23
26
|
* context.apply_compaction ~8s after end_turn. */
|
|
24
27
|
const COMPACT_POLL_TIMEOUT_MS = 30_000;
|
|
25
28
|
/** Poll interval for the wire.jsonl compaction record. */
|
|
26
29
|
const COMPACT_POLL_INTERVAL_MS = 1_000;
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// kimi terminal 工具(Bash 执行下放客户端)
|
|
32
|
+
// 契约:kimi-code/packages/acp-server/src/acp-terminal/acpTerminalRunner.ts
|
|
33
|
+
// 一次性 bash -c <script>,非交互 PTY、无 stdin;服务端每 250ms 轮询
|
|
34
|
+
// terminal/output,客户端只回累计 stdout。创建/轮询/等待/杀/释放五个
|
|
35
|
+
// reverse RPC 全部由本 runner 本地 spawn 子进程实现。
|
|
36
|
+
// =============================================================================
|
|
37
|
+
/** terminal/create 未显式给 outputByteLimit 时的缓冲上限(对齐服务端默认 4MB)。 */
|
|
38
|
+
const TERMINAL_DEFAULT_OUTPUT_LIMIT = 4 * 1024 * 1024;
|
|
27
39
|
/**
|
|
28
40
|
* Map user-facing permission mode to ACP mode id.
|
|
29
41
|
*
|
|
@@ -162,6 +174,21 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
|
|
|
162
174
|
compactPollTimeoutMs;
|
|
163
175
|
/** Pending approval requests: requestId → kind + view + options. */
|
|
164
176
|
pendingApprovals = new Map();
|
|
177
|
+
/** 存活 terminal(kimi 把 Bash 执行下放客户端):terminalId → 本地句柄。 */
|
|
178
|
+
terminals = new Map();
|
|
179
|
+
/** terminalId 自增序号(term-1, term-2, …)。 */
|
|
180
|
+
terminalSeq = 0;
|
|
181
|
+
/**
|
|
182
|
+
* kimi 服务端对 Bash 工具的 tool_call/tool_call_update 通知(title='Bash')。
|
|
183
|
+
* Bash 的真实执行走 terminal/* reverse RPC 下放客户端,tool 事件由本 runner
|
|
184
|
+
* 自产(tool_use 带命令、tool_result 带本地输出);这些通知必须过滤,否则
|
|
185
|
+
* 卡片出现双份 Bash 面板(通知版无 rawInput、terminal embed 无 rawOutput,
|
|
186
|
+
* 只能渲染「_无输出_」)。收集到的 toolCallId 用于过滤对应的 update。
|
|
187
|
+
*/
|
|
188
|
+
bashToolCallIds = new Set();
|
|
189
|
+
/** 当前 run 的 session cwd(terminal/create 未给 cwd 时回退用)。 */
|
|
190
|
+
activeCwd = null;
|
|
191
|
+
processStopper = new ProcessStopper({ graceMs: 2_000 });
|
|
165
192
|
/** Tracker for the in-flight prompt request — needed for cancellation. */
|
|
166
193
|
promptSettled = false;
|
|
167
194
|
constructor(opts) {
|
|
@@ -178,8 +205,13 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
|
|
|
178
205
|
initializeParams: {
|
|
179
206
|
protocolVersion: 1,
|
|
180
207
|
clientCapabilities: {
|
|
208
|
+
// fs 保持关闭:kimi 服务端在 fs 能力为 false 时走本地磁盘兜底
|
|
209
|
+
// (acpFsService.ts AcpHostFileSystem.inner),kimi 是本地子进程,
|
|
210
|
+
// 本地盘即工作区盘,读写正常;声明 true 则服务端改发 fs/* reverse
|
|
211
|
+
// RPC,lark-remote 未实现该面,文件读写会 METHOD_NOT_FOUND 失败。
|
|
181
212
|
fs: { readTextFile: false, writeTextFile: false },
|
|
182
|
-
terminal
|
|
213
|
+
// terminal 常开:yolo/auto 自主跑命令;manual 走 session/request_permission 审批。
|
|
214
|
+
terminal: true,
|
|
183
215
|
// AskUserQuestion 走 elicitation form(原生多题+多选;form 失败时
|
|
184
216
|
// kimi 服务端自动回退 request_permission 桥,客户端两者都处理)。
|
|
185
217
|
// 注意:ACP SDK 对 elicitation.form 的 zod schema 是对象
|
|
@@ -223,6 +255,7 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
|
|
|
223
255
|
this.currentTranslator = null;
|
|
224
256
|
this.promptSettled = false;
|
|
225
257
|
this.activeSessionId = null;
|
|
258
|
+
this.activeCwd = null;
|
|
226
259
|
}
|
|
227
260
|
async releaseConnection(cwd) {
|
|
228
261
|
await this.connectionManager.release(cwd);
|
|
@@ -231,6 +264,8 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
|
|
|
231
264
|
this.connectionManager.notifyIdle(cwd);
|
|
232
265
|
}
|
|
233
266
|
async disposeConnections() {
|
|
267
|
+
// 关闭前 kill/release 所有存活 terminal,避免孤儿 bash 进程。
|
|
268
|
+
await this.cleanupTerminals();
|
|
234
269
|
await this.connectionManager.disposeAll();
|
|
235
270
|
}
|
|
236
271
|
/**
|
|
@@ -245,10 +280,14 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
|
|
|
245
280
|
const client = await this.connectionManager.acquire(opts.cwd);
|
|
246
281
|
this.connectionManager.notifyActivity(opts.cwd);
|
|
247
282
|
this.currentClient = client;
|
|
283
|
+
this.activeCwd = opts.cwd;
|
|
248
284
|
client.setHooks({
|
|
249
285
|
onNotification: (method, params) => this.handleNotification(method, params),
|
|
250
286
|
onServerRequest: (id, method, params) => this.handleServerRequest(id, method, params),
|
|
251
|
-
onClose: () =>
|
|
287
|
+
onClose: () => {
|
|
288
|
+
void this.cleanupTerminals();
|
|
289
|
+
this.failTurn('Kimi ACP connection closed');
|
|
290
|
+
},
|
|
252
291
|
});
|
|
253
292
|
const sessionId = opts.sessionId;
|
|
254
293
|
if (!sessionId) {
|
|
@@ -382,11 +421,13 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
|
|
|
382
421
|
const client = await this.connectionManager.acquire(opts.cwd);
|
|
383
422
|
this.connectionManager.notifyActivity(opts.cwd);
|
|
384
423
|
this.currentClient = client;
|
|
424
|
+
this.activeCwd = opts.cwd;
|
|
385
425
|
client.setHooks({
|
|
386
426
|
onNotification: (method, params) => this.handleNotification(method, params),
|
|
387
427
|
onServerRequest: (id, method, params) => this.handleServerRequest(id, method, params),
|
|
388
428
|
onClose: () => {
|
|
389
429
|
getLogger().warn(`[${this.logTag}] client connection closed`);
|
|
430
|
+
void this.cleanupTerminals();
|
|
390
431
|
this.failTurn('Kimi ACP connection closed');
|
|
391
432
|
},
|
|
392
433
|
});
|
|
@@ -494,10 +535,61 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
|
|
|
494
535
|
getLogger().warn(`[${this.logTag}] compaction record not observed within ${this.compactPollTimeoutMs}ms (baseline=${baselineCount}); proceeding without confirmation`);
|
|
495
536
|
}
|
|
496
537
|
handleNotification(method, params) {
|
|
538
|
+
// Terminal-backed Bash 的通知走 runner 自产事件(见 bashToolCallIds),
|
|
539
|
+
// 不交给 translator;其余通知(Read/Edit/text/thinking)正常翻译。
|
|
540
|
+
if (method === NotificationMethod.SESSION_UPDATE && this.filterBashNotifications(params)) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
497
543
|
const events = this.currentTranslator?.handleNotification(method, params) ?? [];
|
|
498
544
|
this.pushEvents(events);
|
|
499
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* Filter kimi's own Bash tool notifications so the run card doesn't get a
|
|
548
|
+
* duplicate, output-less Bash panel alongside the runner-produced one.
|
|
549
|
+
*
|
|
550
|
+
* - tool_call title='Bash' → remember the toolCallId, drop the notification
|
|
551
|
+
* (the tool_use comes from terminal/create with the real command).
|
|
552
|
+
* - tool_call_update for a remembered id → drop (streaming args deltas and
|
|
553
|
+
* the terminal embed final update; the tool_result is emitted locally).
|
|
554
|
+
* Returns true when the notification should be swallowed.
|
|
555
|
+
*/
|
|
556
|
+
filterBashNotifications(params) {
|
|
557
|
+
if (typeof params !== 'object' || params === null)
|
|
558
|
+
return false;
|
|
559
|
+
const update = params.update;
|
|
560
|
+
if (!update || typeof update !== 'object')
|
|
561
|
+
return false;
|
|
562
|
+
if (update.sessionUpdate === 'tool_call') {
|
|
563
|
+
if (update.title === 'Bash') {
|
|
564
|
+
if (typeof update.toolCallId === 'string') {
|
|
565
|
+
this.bashToolCallIds.add(update.toolCallId);
|
|
566
|
+
}
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
if (update.sessionUpdate === 'tool_call_update') {
|
|
572
|
+
const toolCallId = update.toolCallId;
|
|
573
|
+
if (typeof toolCallId === 'string' && this.bashToolCallIds.has(toolCallId)) {
|
|
574
|
+
// 终态后清掉 id,避免集合随会话无限增长(漏清无碍:id 全局唯一)。
|
|
575
|
+
if (update.status === 'completed' || update.status === 'failed') {
|
|
576
|
+
this.bashToolCallIds.delete(toolCallId);
|
|
577
|
+
}
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
500
584
|
handleServerRequest(id, method, params) {
|
|
585
|
+
// kimi terminal 工具:Bash 执行下放客户端(acp-terminal reverse RPC)。
|
|
586
|
+
// 请求本身是纯 request/response I/O,不交给 translator(避免落入下方
|
|
587
|
+
// 「空事件 → 拒绝」兜底);对应的 tool_use/tool_result 事件由
|
|
588
|
+
// handleTerminalCreate 自产(带本地命令与输出,见 bashToolCallIds)。
|
|
589
|
+
if (method.startsWith('terminal/')) {
|
|
590
|
+
void this.handleTerminalRequest(id, method, params);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
501
593
|
const events = this.currentTranslator?.handleServerRequest(id, method, params) ?? [];
|
|
502
594
|
if (events.length === 0) {
|
|
503
595
|
// Unhandled/unsupported server request must be explicitly responded to
|
|
@@ -527,5 +619,221 @@ export class KimiAcpRunner extends ConnectionBasedRunner {
|
|
|
527
619
|
}
|
|
528
620
|
this.pushEvents(events);
|
|
529
621
|
}
|
|
622
|
+
/**
|
|
623
|
+
* Handle a kimi terminal/* reverse RPC by spawning a one-shot local process.
|
|
624
|
+
*
|
|
625
|
+
* 契约(kimi-code acp-terminal/acpTerminalRunner.ts):
|
|
626
|
+
* create → 立即回 {terminalId},进程异步跑,不阻塞 RPC;
|
|
627
|
+
* output → 回累计 stdout(非增量,服务端按 emitted 偏移切片);
|
|
628
|
+
* wait_for_exit → 未退出则等 exit 再回 {exitCode, signal};
|
|
629
|
+
* kill/release → 杀进程并回 {}。
|
|
630
|
+
*/
|
|
631
|
+
async handleTerminalRequest(id, method, params) {
|
|
632
|
+
try {
|
|
633
|
+
switch (method) {
|
|
634
|
+
case ServerRequestMethod.TERMINAL_CREATE:
|
|
635
|
+
this.handleTerminalCreate(id, params);
|
|
636
|
+
return;
|
|
637
|
+
case ServerRequestMethod.TERMINAL_OUTPUT:
|
|
638
|
+
this.handleTerminalOutput(id, params);
|
|
639
|
+
return;
|
|
640
|
+
case ServerRequestMethod.TERMINAL_WAIT_FOR_EXIT:
|
|
641
|
+
await this.handleTerminalWaitForExit(id, params);
|
|
642
|
+
return;
|
|
643
|
+
case ServerRequestMethod.TERMINAL_KILL:
|
|
644
|
+
await this.handleTerminalKill(id, params);
|
|
645
|
+
return;
|
|
646
|
+
case ServerRequestMethod.TERMINAL_RELEASE:
|
|
647
|
+
await this.handleTerminalRelease(id, params);
|
|
648
|
+
return;
|
|
649
|
+
default:
|
|
650
|
+
this.currentClient?.respondError(id, RpcErrorCode.METHOD_NOT_FOUND, `Unsupported server request: ${method}`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
getLogger().warn(`[${this.logTag}] terminal request failed method=${method}: ${err.message}`);
|
|
655
|
+
this.currentClient?.respondError(id, RpcErrorCode.INTERNAL_ERROR, `Terminal request failed: ${err.message}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* terminal/create: spawn 一次性进程(bash -c <script>),流式累积
|
|
660
|
+
* stdout(+stderr) 到缓冲,立即回 {terminalId}。
|
|
661
|
+
*/
|
|
662
|
+
handleTerminalCreate(id, params) {
|
|
663
|
+
const env = params.env
|
|
664
|
+
? Object.fromEntries(params.env.map((e) => [e.name, e.value]))
|
|
665
|
+
: undefined;
|
|
666
|
+
const cwd = params.cwd ?? this.activeCwd ?? undefined;
|
|
667
|
+
const outputLimit = params.outputByteLimit ?? TERMINAL_DEFAULT_OUTPUT_LIMIT;
|
|
668
|
+
const proc = spawn(params.command, params.args ?? [], {
|
|
669
|
+
cwd,
|
|
670
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
671
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
672
|
+
// ProcessStopper 用负 PID 杀进程组(kill(-pgid));子进程必须是组长
|
|
673
|
+
// 才能命中,否则 kill(-pid) 抛 ESRCH 被吞 → kill/release/清理全失效。
|
|
674
|
+
// 与 JsonlRpcTransport / spawning-runner 的 detached:true 同模式。
|
|
675
|
+
detached: true,
|
|
676
|
+
});
|
|
677
|
+
// 每个流独立 decoder:多字节字符跨 chunk 拆分时由 decoder 保留不完整
|
|
678
|
+
// 序列,避免按 chunk 单独 toString 产生 U+FFFD 替换符。
|
|
679
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
680
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
681
|
+
const append = (chunk, decoder) => {
|
|
682
|
+
if (handle.truncated)
|
|
683
|
+
return;
|
|
684
|
+
const remaining = handle.outputLimit - handle.outputBytes;
|
|
685
|
+
if (remaining > 0 && chunk.length <= remaining) {
|
|
686
|
+
handle.output += decoder.write(chunk);
|
|
687
|
+
handle.outputBytes += chunk.length;
|
|
688
|
+
}
|
|
689
|
+
else if (remaining > 0) {
|
|
690
|
+
// 只喂 remaining 字节;decoder 内部保留末尾不完整序列,不会在字节
|
|
691
|
+
// 边界产生替换符。outputBytes 按「喂入字节」计,保证不超过上限。
|
|
692
|
+
handle.output += decoder.write(chunk.subarray(0, remaining));
|
|
693
|
+
handle.outputBytes += remaining;
|
|
694
|
+
}
|
|
695
|
+
// 契约(手册):truncated = 缓冲 >= limit——恰好填满也标 true。
|
|
696
|
+
if (handle.outputBytes >= handle.outputLimit)
|
|
697
|
+
handle.truncated = true;
|
|
698
|
+
};
|
|
699
|
+
proc.stdout?.on('data', (chunk) => append(chunk, stdoutDecoder));
|
|
700
|
+
proc.stderr?.on('data', (chunk) => append(chunk, stderrDecoder));
|
|
701
|
+
// spawn 失败(如 ENOENT)只发 'error' 不发 'exit'——一并 resolve,防止
|
|
702
|
+
// wait_for_exit 永久挂起(runner 层 ENOENT 兜底红线)。
|
|
703
|
+
// 用 resolveExit 承接,避免回调在 handle 初始化前引用它(TDZ)。
|
|
704
|
+
let resolveExit;
|
|
705
|
+
const exitPromise = new Promise((resolve) => {
|
|
706
|
+
resolveExit = resolve;
|
|
707
|
+
});
|
|
708
|
+
// 与 resolveExit 同款 TDZ 规避:tool_result 发射闭包在 terminalId 初始化
|
|
709
|
+
// 后赋值,exit/error 回调执行时经 emitResult 间接调用。
|
|
710
|
+
let emitTerminalResult = undefined;
|
|
711
|
+
const emitResult = () => {
|
|
712
|
+
emitTerminalResult?.();
|
|
713
|
+
};
|
|
714
|
+
const handle = {
|
|
715
|
+
proc,
|
|
716
|
+
output: '',
|
|
717
|
+
outputBytes: 0,
|
|
718
|
+
outputLimit,
|
|
719
|
+
truncated: false,
|
|
720
|
+
exitCode: null,
|
|
721
|
+
signal: null,
|
|
722
|
+
exitPromise,
|
|
723
|
+
};
|
|
724
|
+
proc.once('exit', (code, signal) => {
|
|
725
|
+
handle.exitCode = code;
|
|
726
|
+
handle.signal = signal;
|
|
727
|
+
resolveExit?.();
|
|
728
|
+
emitResult();
|
|
729
|
+
});
|
|
730
|
+
proc.once('error', (err) => {
|
|
731
|
+
getLogger().warn(`[${this.logTag}] terminal process error: ${err.message}`);
|
|
732
|
+
if (handle.exitCode === null && handle.signal === null) {
|
|
733
|
+
handle.exitCode = 1;
|
|
734
|
+
handle.signal = null;
|
|
735
|
+
}
|
|
736
|
+
resolveExit?.();
|
|
737
|
+
emitResult();
|
|
738
|
+
});
|
|
739
|
+
const terminalId = `term-${++this.terminalSeq}`;
|
|
740
|
+
this.terminals.set(terminalId, handle);
|
|
741
|
+
// Bash 工具事件由本 runner 自产(kimi 通知已过滤):create 即推 tool_use
|
|
742
|
+
// 带真实命令,让 run 卡立刻出现可识别的 Bash 面板。
|
|
743
|
+
const script = Array.isArray(params.args) && params.args.length > 1 ? params.args[1] : params.command;
|
|
744
|
+
let resultEmitted = false;
|
|
745
|
+
emitTerminalResult = () => {
|
|
746
|
+
if (resultEmitted)
|
|
747
|
+
return;
|
|
748
|
+
resultEmitted = true;
|
|
749
|
+
this.pushEvents([
|
|
750
|
+
{
|
|
751
|
+
type: 'user',
|
|
752
|
+
message: {
|
|
753
|
+
content: [
|
|
754
|
+
{
|
|
755
|
+
type: 'tool_result',
|
|
756
|
+
tool_use_id: terminalId,
|
|
757
|
+
content: handle.output,
|
|
758
|
+
is_error: handle.exitCode !== 0,
|
|
759
|
+
},
|
|
760
|
+
],
|
|
761
|
+
},
|
|
762
|
+
timestamp: new Date().toISOString(),
|
|
763
|
+
},
|
|
764
|
+
]);
|
|
765
|
+
};
|
|
766
|
+
this.pushEvents([
|
|
767
|
+
{
|
|
768
|
+
type: 'assistant',
|
|
769
|
+
message: {
|
|
770
|
+
content: [
|
|
771
|
+
{
|
|
772
|
+
type: 'tool_use',
|
|
773
|
+
id: terminalId,
|
|
774
|
+
name: 'Bash',
|
|
775
|
+
input: { command: script, cwd },
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
},
|
|
779
|
+
timestamp: new Date().toISOString(),
|
|
780
|
+
},
|
|
781
|
+
]);
|
|
782
|
+
getLogger().info(`[${this.logTag}] terminal created id=${terminalId} command=${params.command} args=${JSON.stringify(params.args ?? [])}`);
|
|
783
|
+
this.currentClient?.respond(id, { terminalId });
|
|
784
|
+
}
|
|
785
|
+
/** terminal/output: 回累计全量输出 + truncated + 当前退出状态(可未退出)。 */
|
|
786
|
+
handleTerminalOutput(id, params) {
|
|
787
|
+
const handle = this.terminals.get(params.terminalId);
|
|
788
|
+
if (!handle) {
|
|
789
|
+
this.currentClient?.respondError(id, RpcErrorCode.INVALID_PARAMS, `Unknown terminal: ${params.terminalId}`);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
this.currentClient?.respond(id, {
|
|
793
|
+
output: handle.output,
|
|
794
|
+
truncated: handle.truncated,
|
|
795
|
+
exitStatus: { exitCode: handle.exitCode, signal: handle.signal },
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
/** terminal/wait_for_exit: 未退出则等 exit promise 再回 {exitCode, signal}。 */
|
|
799
|
+
async handleTerminalWaitForExit(id, params) {
|
|
800
|
+
const handle = this.terminals.get(params.terminalId);
|
|
801
|
+
if (!handle) {
|
|
802
|
+
this.currentClient?.respondError(id, RpcErrorCode.INVALID_PARAMS, `Unknown terminal: ${params.terminalId}`);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
await handle.exitPromise;
|
|
806
|
+
this.currentClient?.respond(id, { exitCode: handle.exitCode, signal: handle.signal });
|
|
807
|
+
}
|
|
808
|
+
/** terminal/kill: 立即杀进程组,回 {}。 */
|
|
809
|
+
async handleTerminalKill(id, params) {
|
|
810
|
+
const handle = this.terminals.get(params.terminalId);
|
|
811
|
+
if (!handle) {
|
|
812
|
+
this.currentClient?.respondError(id, RpcErrorCode.INVALID_PARAMS, `Unknown terminal: ${params.terminalId}`);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
await this.processStopper.stop(handle.proc, { immediate: true });
|
|
816
|
+
this.currentClient?.respond(id, {});
|
|
817
|
+
}
|
|
818
|
+
/** terminal/release: 从 map 删除;进程仍在则一并杀,回 {}。 */
|
|
819
|
+
async handleTerminalRelease(id, params) {
|
|
820
|
+
const handle = this.terminals.get(params.terminalId);
|
|
821
|
+
if (!handle) {
|
|
822
|
+
this.currentClient?.respondError(id, RpcErrorCode.INVALID_PARAMS, `Unknown terminal: ${params.terminalId}`);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
this.terminals.delete(params.terminalId);
|
|
826
|
+
// stop 对已退出进程是 no-op;仍在跑则立即 SIGTERM+SIGKILL。
|
|
827
|
+
await this.processStopper.stop(handle.proc, { immediate: true });
|
|
828
|
+
this.currentClient?.respond(id, {});
|
|
829
|
+
}
|
|
830
|
+
/** Kill every live terminal (dispose/connection-close 清理钩子)。 */
|
|
831
|
+
async cleanupTerminals() {
|
|
832
|
+
const handles = [...this.terminals.values()];
|
|
833
|
+
this.terminals.clear();
|
|
834
|
+
for (const handle of handles) {
|
|
835
|
+
await this.processStopper.stop(handle.proc, { immediate: true });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
530
838
|
}
|
|
531
839
|
//# sourceMappingURL=runner.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpencodeLogErrorMonitor: watches opencode's own log file for LLM stream
|
|
3
|
+
* errors of the active ACP session and reports them immediately.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: opencode ACP (v1.18.x) does NOT forward retry/error state
|
|
6
|
+
* to the client over the ACP protocol. Its session/update notifications are
|
|
7
|
+
* limited to content deltas (agent_message_chunk / agent_thought_chunk /
|
|
8
|
+
* tool_call / tool_call_update), usage and available-commands updates — there
|
|
9
|
+
* is no error/retry/status event type, and session/list carries no status.
|
|
10
|
+
* When a retryable provider error hits (e.g. opencode-go "Weekly usage limit
|
|
11
|
+
* reached"), the session enters a retry backoff (often the full reset time,
|
|
12
|
+
* ~1h) and session/prompt stays pending with zero events, so the client would
|
|
13
|
+
* hang until its own idle watchdog.
|
|
14
|
+
*
|
|
15
|
+
* The only channel that carries the real error is opencode's own log file
|
|
16
|
+
* (`$XDG_DATA_HOME/opencode/log/opencode.log`), where every LLM stream
|
|
17
|
+
* failure is written as `message="stream error" ... session.id=<id>
|
|
18
|
+
* small=false ... error.error="<reason>"`.
|
|
19
|
+
*
|
|
20
|
+
* This monitor tails the log (delta from the turn-start offset), scoped to
|
|
21
|
+
* the active sessionId + `small=false` (main turn; the `small=true` title
|
|
22
|
+
* stream must not kill the turn). Only limit/quota-class errors trigger the
|
|
23
|
+
* callback — they are the ones opencode backs off for a long time; transient
|
|
24
|
+
* 5xx/network errors retry in seconds and must be left to opencode's own
|
|
25
|
+
* retry loop (the idle watchdog remains the fallback for silent hangs).
|
|
26
|
+
*/
|
|
27
|
+
export interface OpencodeLogErrorMonitorOptions {
|
|
28
|
+
/** Path to opencode's own log file (default: resolveOpencodeLogPath()). */
|
|
29
|
+
logPath: string;
|
|
30
|
+
/** Tail poll interval in milliseconds. Defaults to 2000. */
|
|
31
|
+
pollIntervalMs?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve opencode's log file path from the XDG data dir
|
|
35
|
+
* (`$XDG_DATA_HOME/opencode/log/opencode.log`, default
|
|
36
|
+
* `~/.local/share/opencode/log/opencode.log`). Matches opencode's own
|
|
37
|
+
* data-dir layout (open source v1.18.x: `opencode/src/cli/global.ts`).
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveOpencodeLogPath(): string;
|
|
40
|
+
export declare class OpencodeLogErrorMonitor {
|
|
41
|
+
private readonly logPath;
|
|
42
|
+
private readonly pollIntervalMs;
|
|
43
|
+
private offset;
|
|
44
|
+
private pendingTail;
|
|
45
|
+
private timer;
|
|
46
|
+
private sessionId;
|
|
47
|
+
private onError;
|
|
48
|
+
constructor(opts: OpencodeLogErrorMonitorOptions);
|
|
49
|
+
/**
|
|
50
|
+
* Start monitoring the given session. Replaces any previous monitor state;
|
|
51
|
+
* the log offset is snapshotted now, so pre-existing entries never fire.
|
|
52
|
+
*/
|
|
53
|
+
start(sessionId: string, onError: (message: string) => void): void;
|
|
54
|
+
/** Stop monitoring and clear all state. Safe to call multiple times. */
|
|
55
|
+
stop(): void;
|
|
56
|
+
private currentSize;
|
|
57
|
+
private poll;
|
|
58
|
+
/** Match a main-turn stream error line for the monitored session with a
|
|
59
|
+
* limit/quota reason; returns the extracted error text or null. */
|
|
60
|
+
private matchLimitError;
|
|
61
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpencodeLogErrorMonitor: watches opencode's own log file for LLM stream
|
|
3
|
+
* errors of the active ACP session and reports them immediately.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: opencode ACP (v1.18.x) does NOT forward retry/error state
|
|
6
|
+
* to the client over the ACP protocol. Its session/update notifications are
|
|
7
|
+
* limited to content deltas (agent_message_chunk / agent_thought_chunk /
|
|
8
|
+
* tool_call / tool_call_update), usage and available-commands updates — there
|
|
9
|
+
* is no error/retry/status event type, and session/list carries no status.
|
|
10
|
+
* When a retryable provider error hits (e.g. opencode-go "Weekly usage limit
|
|
11
|
+
* reached"), the session enters a retry backoff (often the full reset time,
|
|
12
|
+
* ~1h) and session/prompt stays pending with zero events, so the client would
|
|
13
|
+
* hang until its own idle watchdog.
|
|
14
|
+
*
|
|
15
|
+
* The only channel that carries the real error is opencode's own log file
|
|
16
|
+
* (`$XDG_DATA_HOME/opencode/log/opencode.log`), where every LLM stream
|
|
17
|
+
* failure is written as `message="stream error" ... session.id=<id>
|
|
18
|
+
* small=false ... error.error="<reason>"`.
|
|
19
|
+
*
|
|
20
|
+
* This monitor tails the log (delta from the turn-start offset), scoped to
|
|
21
|
+
* the active sessionId + `small=false` (main turn; the `small=true` title
|
|
22
|
+
* stream must not kill the turn). Only limit/quota-class errors trigger the
|
|
23
|
+
* callback — they are the ones opencode backs off for a long time; transient
|
|
24
|
+
* 5xx/network errors retry in seconds and must be left to opencode's own
|
|
25
|
+
* retry loop (the idle watchdog remains the fallback for silent hangs).
|
|
26
|
+
*/
|
|
27
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
/** Error text we fail fast on: quota/limit conditions opencode backs off on. */
|
|
31
|
+
const LIMIT_ERROR_RE = /usage limit|quota|rate limit|rate_limit|too many requests|insufficient balance|free usage/i;
|
|
32
|
+
const STREAM_ERROR_RE = /message="stream error"/;
|
|
33
|
+
const MAIN_TURN_RE = /small=false/;
|
|
34
|
+
const ERROR_MESSAGE_RE = /error\.error="((?:[^"\\]|\\.)*)"/;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve opencode's log file path from the XDG data dir
|
|
37
|
+
* (`$XDG_DATA_HOME/opencode/log/opencode.log`, default
|
|
38
|
+
* `~/.local/share/opencode/log/opencode.log`). Matches opencode's own
|
|
39
|
+
* data-dir layout (open source v1.18.x: `opencode/src/cli/global.ts`).
|
|
40
|
+
*/
|
|
41
|
+
export function resolveOpencodeLogPath() {
|
|
42
|
+
const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
|
|
43
|
+
return join(dataHome, 'opencode', 'log', 'opencode.log');
|
|
44
|
+
}
|
|
45
|
+
export class OpencodeLogErrorMonitor {
|
|
46
|
+
logPath;
|
|
47
|
+
pollIntervalMs;
|
|
48
|
+
offset = 0;
|
|
49
|
+
pendingTail = '';
|
|
50
|
+
timer = null;
|
|
51
|
+
sessionId = null;
|
|
52
|
+
onError = null;
|
|
53
|
+
constructor(opts) {
|
|
54
|
+
this.logPath = opts.logPath;
|
|
55
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Start monitoring the given session. Replaces any previous monitor state;
|
|
59
|
+
* the log offset is snapshotted now, so pre-existing entries never fire.
|
|
60
|
+
*/
|
|
61
|
+
start(sessionId, onError) {
|
|
62
|
+
this.stop();
|
|
63
|
+
this.sessionId = sessionId;
|
|
64
|
+
this.onError = onError;
|
|
65
|
+
this.offset = this.currentSize();
|
|
66
|
+
this.pendingTail = '';
|
|
67
|
+
this.timer = setInterval(() => this.poll(), this.pollIntervalMs);
|
|
68
|
+
this.timer.unref?.();
|
|
69
|
+
}
|
|
70
|
+
/** Stop monitoring and clear all state. Safe to call multiple times. */
|
|
71
|
+
stop() {
|
|
72
|
+
if (this.timer !== null) {
|
|
73
|
+
clearInterval(this.timer);
|
|
74
|
+
this.timer = null;
|
|
75
|
+
}
|
|
76
|
+
this.sessionId = null;
|
|
77
|
+
this.onError = null;
|
|
78
|
+
this.pendingTail = '';
|
|
79
|
+
}
|
|
80
|
+
currentSize() {
|
|
81
|
+
try {
|
|
82
|
+
return statSync(this.logPath).size;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
poll() {
|
|
89
|
+
if (this.sessionId === null || this.onError === null)
|
|
90
|
+
return;
|
|
91
|
+
const size = this.currentSize();
|
|
92
|
+
if (size < this.offset) {
|
|
93
|
+
// Log rotated/truncated: restart from the top of the current file.
|
|
94
|
+
this.offset = 0;
|
|
95
|
+
this.pendingTail = '';
|
|
96
|
+
}
|
|
97
|
+
if (size > this.offset) {
|
|
98
|
+
let chunk;
|
|
99
|
+
try {
|
|
100
|
+
const fd = openSync(this.logPath, 'r');
|
|
101
|
+
try {
|
|
102
|
+
const buf = Buffer.alloc(size - this.offset);
|
|
103
|
+
readSync(fd, buf, 0, buf.length, this.offset);
|
|
104
|
+
chunk = buf.toString('utf8');
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.offset = size;
|
|
114
|
+
this.pendingTail += chunk;
|
|
115
|
+
}
|
|
116
|
+
// Scan complete lines (and any trailing line without newline).
|
|
117
|
+
for (const line of this.pendingTail.split('\n')) {
|
|
118
|
+
const message = this.matchLimitError(line);
|
|
119
|
+
if (message === null)
|
|
120
|
+
continue;
|
|
121
|
+
const onError = this.onError;
|
|
122
|
+
this.stop();
|
|
123
|
+
onError(message);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Keep only the last partial line (everything before the last '\n' was
|
|
127
|
+
// fully scanned above and did not match).
|
|
128
|
+
const lastNewline = this.pendingTail.lastIndexOf('\n');
|
|
129
|
+
if (lastNewline >= 0) {
|
|
130
|
+
this.pendingTail = this.pendingTail.slice(lastNewline + 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Match a main-turn stream error line for the monitored session with a
|
|
134
|
+
* limit/quota reason; returns the extracted error text or null. */
|
|
135
|
+
matchLimitError(line) {
|
|
136
|
+
if (this.sessionId === null)
|
|
137
|
+
return null;
|
|
138
|
+
if (!line.includes(this.sessionId))
|
|
139
|
+
return null;
|
|
140
|
+
if (!STREAM_ERROR_RE.test(line))
|
|
141
|
+
return null;
|
|
142
|
+
if (!MAIN_TURN_RE.test(line))
|
|
143
|
+
return null;
|
|
144
|
+
const message = ERROR_MESSAGE_RE.exec(line)?.[1];
|
|
145
|
+
if (!message || !LIMIT_ERROR_RE.test(message))
|
|
146
|
+
return null;
|
|
147
|
+
return message;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=error-monitor.js.map
|
|
@@ -43,6 +43,10 @@ export interface OpencodeAcpRunnerOptions {
|
|
|
43
43
|
model?: string;
|
|
44
44
|
/** Configured session mode: 'build' (default) or 'plan' (opencode agent names). */
|
|
45
45
|
mode?: 'build' | 'plan';
|
|
46
|
+
/** Path to opencode's own log file (default: resolveOpencodeLogPath()). */
|
|
47
|
+
errorMonitorLogPath?: string;
|
|
48
|
+
/** Tail poll interval for the opencode log error monitor. Defaults to 2000ms. */
|
|
49
|
+
errorMonitorPollIntervalMs?: number;
|
|
46
50
|
}
|
|
47
51
|
export declare class OpencodeAcpRunner extends ConnectionBasedRunner<JsonRpcClient, OpencodeAcpTranslatorEvent> {
|
|
48
52
|
private connectionManager;
|
|
@@ -55,6 +59,9 @@ export declare class OpencodeAcpRunner extends ConnectionBasedRunner<JsonRpcClie
|
|
|
55
59
|
private currentModeId?;
|
|
56
60
|
/** Current model value (`provider/model`), parsed from configOptions. */
|
|
57
61
|
private currentModelValue?;
|
|
62
|
+
/** Tails opencode's own log to surface LLM stream errors the ACP protocol
|
|
63
|
+
* never forwards (quota/limit errors back off for ~1h → turn would hang). */
|
|
64
|
+
private readonly errorMonitor;
|
|
58
65
|
/** Pending approval requests: requestId → kind + view + options. */
|
|
59
66
|
private pendingApprovals;
|
|
60
67
|
/** Tracker for the in-flight prompt request — needed for cancellation. */
|
|
@@ -71,6 +78,16 @@ export declare class OpencodeAcpRunner extends ConnectionBasedRunner<JsonRpcClie
|
|
|
71
78
|
protected shouldDeferStop(): boolean;
|
|
72
79
|
protected cancelCurrentTurn(): Promise<void>;
|
|
73
80
|
protected clearTurnState(): void;
|
|
81
|
+
/**
|
|
82
|
+
* Start watching opencode's own log for LLM stream errors of the active
|
|
83
|
+
* session. opencode ACP does not surface retry/error state to the client
|
|
84
|
+
* (no error/status notification type, session/list has no status field), so
|
|
85
|
+
* the log is the only channel that carries the real provider error. Without
|
|
86
|
+
* this, a quota error (e.g. opencode-go weekly limit) leaves session/prompt
|
|
87
|
+
* pending for the full retry-after backoff (~1h) and the Feishu card hangs
|
|
88
|
+
* until the idle watchdog.
|
|
89
|
+
*/
|
|
90
|
+
private startErrorMonitor;
|
|
74
91
|
protected releaseConnection(cwd: string): Promise<void>;
|
|
75
92
|
protected notifyIdle(cwd: string): void;
|
|
76
93
|
protected disposeConnections(): Promise<void>;
|