lark-remote 0.5.10 → 0.5.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 +38 -0
- package/dist/binder.js +7 -1
- package/dist/bridge/index.js +12 -3
- package/dist/card/approval-render.d.ts +14 -0
- package/dist/card/approval-render.js +155 -29
- package/dist/card/bash-card-session.js +8 -0
- package/dist/card/bash-renderer.js +11 -23
- package/dist/card/card-budget.js +24 -4
- package/dist/card/run-renderer.js +31 -9
- package/dist/common/text-width.js +5 -0
- package/dist/common/tool-summary.js +4 -2
- package/dist/config/codex-config.js +5 -3
- package/dist/config/index.js +13 -1
- package/dist/index.js +42 -24
- package/dist/instance-lock.d.ts +9 -1
- package/dist/instance-lock.js +42 -7
- package/dist/platform/identity.js +4 -3
- package/dist/platform/path.js +3 -1
- package/dist/platform/terminator-win32.d.ts +13 -1
- package/dist/platform/terminator-win32.js +4 -14
- package/dist/router/index.js +52 -10
- package/dist/runner/codex/app-server/runner.js +12 -0
- package/dist/runner/common/acp/base-translator.d.ts +12 -0
- package/dist/runner/common/acp/base-translator.js +22 -0
- package/dist/runner/common/jsonl-stream.js +12 -1
- package/dist/runner/common/jsonrpc/connection-manager.js +11 -1
- package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
- package/dist/runner/common/jsonrpc/transport.js +7 -1
- package/dist/runner/dsh/approval.js +16 -3
- package/dist/runner/dsh/client.d.ts +31 -1
- package/dist/runner/dsh/client.js +126 -6
- package/dist/runner/dsh/exit-cancel.js +62 -23
- package/dist/runner/dsh/runner.d.ts +7 -0
- package/dist/runner/dsh/runner.js +42 -5
- package/dist/runner/kimi/acp/runner.js +42 -5
- package/dist/runner/kimi/acp/translator.d.ts +8 -0
- package/dist/runner/kimi/acp/translator.js +10 -0
- package/dist/runner/pi/rpc/runner.js +33 -5
- package/dist/runner/resolve-agent-choices.js +5 -1
- package/dist/runner/sync-agent-choices.js +4 -1
- package/dist/session/claude/session-index.js +9 -2
- package/dist/session/codex/rollout-reader.js +51 -26
- package/dist/session/common/usage-accumulator.d.ts +10 -1
- package/dist/session/common/usage-accumulator.js +10 -2
- package/dist/session/dsh/sessions.js +29 -9
- package/dist/session/kimi/sessions.js +9 -5
- package/dist/session/opencode/sessions.js +13 -6
- package/dist/session/session-store.js +4 -3
- package/package.json +1 -1
|
@@ -144,6 +144,11 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
144
144
|
*/
|
|
145
145
|
compactBaselineRecords = [];
|
|
146
146
|
/** 存活 terminal(kimi 把 Bash 执行下放客户端):terminalId → 本地句柄。 */
|
|
147
|
+
// 已知限制(待验证 kimi 0.4x 契约):句柄只在
|
|
148
|
+
// terminal/release 与连接关闭(cleanupTerminals)时删除。若服务端只 kill
|
|
149
|
+
// 不 release(中断 turn 的场景),已退出进程的句柄(含 ≤4MB output 缓冲)
|
|
150
|
+
// 会滞留到连接释放。kimi 契约上工具结束会发 release(mock 测试同驱动);
|
|
151
|
+
// 若真机确认存在只 kill 不 release 的路径,再补 exit 后延迟回收。
|
|
147
152
|
terminals = new Map();
|
|
148
153
|
/** terminalId 自增序号(term-1, term-2, …)。 */
|
|
149
154
|
terminalSeq = 0;
|
|
@@ -422,6 +427,11 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
422
427
|
while (true) {
|
|
423
428
|
if (this.forceFinish || this.stopRequested)
|
|
424
429
|
return 'stopped';
|
|
430
|
+
// turn 已死亡(failTurn,如连接关闭)时不再轮询:forceFinish 与
|
|
431
|
+
// stopRequested 均为 false 的死亡路径会让循环空转到 10 分钟沉默超时,
|
|
432
|
+
// 结果还被 isCurrent() 丢弃——纯浪费的每秒文件读。
|
|
433
|
+
if (!this._isRunning)
|
|
434
|
+
return 'unknown';
|
|
425
435
|
const records = reader.call(this.sessionReader, sessionId, opts.cwd);
|
|
426
436
|
if (records.length !== lastTotalCount) {
|
|
427
437
|
lastTotalCount = records.length;
|
|
@@ -506,8 +516,15 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
506
516
|
// (error = "rejected"), otherwise the server hangs waiting for a
|
|
507
517
|
// response. For question elicitation (§5.4), auto-respond cancelled.
|
|
508
518
|
const rpParams = params;
|
|
509
|
-
|
|
510
|
-
|
|
519
|
+
// elicitation/create 的表单属性全不可解析时(translator 返回 [])同样回
|
|
520
|
+
// cancelled 而不是 METHOD_NOT_FOUND:§5.4 的不变量是「识别到非工具审批
|
|
521
|
+
// 形态 → 自动回应 cancelled,防止服务端挂起」;错误响应虽也能解悬挂,
|
|
522
|
+
// 但 kimi 侧可能把 JSON-RPC error 当工具级失败让整个 turn 报错,而不是
|
|
523
|
+
// 跳过提问继续。
|
|
524
|
+
const isQuestionElicitation = method === ServerRequestMethod.REQUEST_PERMISSION &&
|
|
525
|
+
(rpParams.isQuestion || !rpParams.toolCall);
|
|
526
|
+
const isUnparseableElicitationForm = method === ServerRequestMethod.ELICITATION_CREATE;
|
|
527
|
+
if (isQuestionElicitation || isUnparseableElicitationForm) {
|
|
511
528
|
this.currentClient?.respond(id, { outcome: { outcome: 'cancelled' } });
|
|
512
529
|
getLogger().info(`[${this.logTag}] auto-responded cancelled to question elicitation requestId=${id}`);
|
|
513
530
|
}
|
|
@@ -633,8 +650,27 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
633
650
|
proc.once('exit', (code, signal) => {
|
|
634
651
|
handle.exitCode = code;
|
|
635
652
|
handle.signal = signal;
|
|
636
|
-
|
|
637
|
-
|
|
653
|
+
// 'exit' 可先于 stdout 缓冲派发:立刻 emit 会按旧快照推送 tool_result,
|
|
654
|
+
// 卡片面板丢尾部输出(terminal/output 反向 RPC 读到的是补齐后的全量,
|
|
655
|
+
// 只影响卡片)。等流 end 或 200ms 宽限——不等 'close':它要等所有共享
|
|
656
|
+
// 管道的子孙进程退出,`nohup &` 类后台任务会把它拖到永远。
|
|
657
|
+
//
|
|
658
|
+
// wait_for_exit 必须在 tool_result 入队之后才 resolve:服务端拿到响应
|
|
659
|
+
// 后会立刻 release + end_turn,先 resolve 会让回合结果早于延迟推送的
|
|
660
|
+
// tool_result 到达,Bash 输出被丢弃(卡片面板空输出)。
|
|
661
|
+
const flushThenEmit = () => {
|
|
662
|
+
clearTimeout(emitTimer);
|
|
663
|
+
emitResult();
|
|
664
|
+
resolveExit?.();
|
|
665
|
+
};
|
|
666
|
+
const emitTimer = setTimeout(flushThenEmit, 200);
|
|
667
|
+
emitTimer.unref?.();
|
|
668
|
+
if (proc.stdout && !proc.stdout.destroyed) {
|
|
669
|
+
proc.stdout.once('end', flushThenEmit);
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
flushThenEmit();
|
|
673
|
+
}
|
|
638
674
|
});
|
|
639
675
|
proc.once('error', (err) => {
|
|
640
676
|
getLogger().warn(`[${this.logTag}] terminal process error: ${err.message}`);
|
|
@@ -642,8 +678,9 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
642
678
|
handle.exitCode = 1;
|
|
643
679
|
handle.signal = null;
|
|
644
680
|
}
|
|
645
|
-
|
|
681
|
+
// 同 exit:tool_result 先入队,再放行 wait_for_exit。
|
|
646
682
|
emitResult();
|
|
683
|
+
resolveExit?.();
|
|
647
684
|
});
|
|
648
685
|
const terminalId = `term-${++this.terminalSeq}`;
|
|
649
686
|
this.terminals.set(terminalId, handle);
|
|
@@ -29,6 +29,14 @@ import { type RequestPermissionParams, type AgentMessageChunkEvent, type AgentTh
|
|
|
29
29
|
import { BaseAcpTranslator, type AcpTranslatorEvent } from '../../common/acp/base-translator.js';
|
|
30
30
|
export declare class KimiAcpTranslator extends BaseAcpTranslator {
|
|
31
31
|
protected readonly logTag = "kimi-acp-translator";
|
|
32
|
+
/**
|
|
33
|
+
* 非终态 tool_call_update 不附带空 tool_result:kimi 非 Bash 工具的
|
|
34
|
+
* in-progress 只带流式 args(rawOutput 缺省 → content ''),空 tool_result
|
|
35
|
+
* 会让 reducer 把运行中的工具提前置 ✅ + 空输出(终态到达后自愈,但终态
|
|
36
|
+
* 丢失时面板停在错误的「已完成」)。opencode 走基类默认 true(其
|
|
37
|
+
* in-progress 带输出快照,seam 契约钉死要发)。
|
|
38
|
+
*/
|
|
39
|
+
protected emitToolResultForNonTerminalUpdate(content: unknown): boolean;
|
|
32
40
|
/** Accumulated text per tool-call or message (keyed by a logical item id). */
|
|
33
41
|
private textByItem;
|
|
34
42
|
/** Accumulated thinking per item. */
|
|
@@ -42,6 +42,16 @@ const THINKING_ITEM_ID = 'thinking';
|
|
|
42
42
|
// =============================================================================
|
|
43
43
|
export class KimiAcpTranslator extends BaseAcpTranslator {
|
|
44
44
|
logTag = 'kimi-acp-translator';
|
|
45
|
+
/**
|
|
46
|
+
* 非终态 tool_call_update 不附带空 tool_result:kimi 非 Bash 工具的
|
|
47
|
+
* in-progress 只带流式 args(rawOutput 缺省 → content ''),空 tool_result
|
|
48
|
+
* 会让 reducer 把运行中的工具提前置 ✅ + 空输出(终态到达后自愈,但终态
|
|
49
|
+
* 丢失时面板停在错误的「已完成」)。opencode 走基类默认 true(其
|
|
50
|
+
* in-progress 带输出快照,seam 契约钉死要发)。
|
|
51
|
+
*/
|
|
52
|
+
emitToolResultForNonTerminalUpdate(content) {
|
|
53
|
+
return !(content === undefined || content === null || content === '');
|
|
54
|
+
}
|
|
45
55
|
/** Accumulated text per tool-call or message (keyed by a logical item id). */
|
|
46
56
|
textByItem = new Map();
|
|
47
57
|
/** Accumulated thinking per item. */
|
|
@@ -113,9 +113,24 @@ export class PiRpcRunner extends ConnectionBasedRunner {
|
|
|
113
113
|
const client = await this.manager.acquire(opts.cwd, { sessionId: opts.sessionId });
|
|
114
114
|
this.manager.notifyActivity(opts.cwd);
|
|
115
115
|
this.currentClient = client;
|
|
116
|
+
// 轮次代际锚点(与 BaseAcpRunner 同口径):pi 的 shouldReuse 要求
|
|
117
|
+
// sessionId 匹配,/new、/resume 切换后的首条消息会让 acquire **同步
|
|
118
|
+
// dispose** 旧连接;terminator 等到进程退出后,旧 client 上残留的上一轮
|
|
119
|
+
// onClose 闭包会把假错误推进新 turn 的共享队列(用户消息未发出即丢失)。
|
|
120
|
+
// 旧轮迟到的 agent_settled 同理(新 turn 空转立即"成功")。所有 hook
|
|
121
|
+
// 落地前必须过 isCurrentTurn。
|
|
122
|
+
const generation = this.turnGeneration;
|
|
116
123
|
client.setHooks({
|
|
117
|
-
onEvent: (evt) =>
|
|
118
|
-
|
|
124
|
+
onEvent: (evt) => {
|
|
125
|
+
if (!this.isCurrentTurn(generation, client))
|
|
126
|
+
return;
|
|
127
|
+
this.handleEvent(evt);
|
|
128
|
+
},
|
|
129
|
+
onClose: () => {
|
|
130
|
+
if (!this.isCurrentTurn(generation, client))
|
|
131
|
+
return;
|
|
132
|
+
this.failTurn('Pi RPC connection closed');
|
|
133
|
+
},
|
|
119
134
|
});
|
|
120
135
|
let sessionId = opts.sessionId;
|
|
121
136
|
if (!sessionId) {
|
|
@@ -157,9 +172,19 @@ export class PiRpcRunner extends ConnectionBasedRunner {
|
|
|
157
172
|
const client = await this.manager.acquire(opts.cwd, { sessionId: opts.sessionId });
|
|
158
173
|
this.manager.notifyActivity(opts.cwd);
|
|
159
174
|
this.currentClient = client;
|
|
175
|
+
// 同 setupTurn:hook 带轮次代际锚点,旧连接的迟到回调不得进入本轮。
|
|
176
|
+
const generation = this.turnGeneration;
|
|
160
177
|
client.setHooks({
|
|
161
|
-
onEvent: (evt) =>
|
|
162
|
-
|
|
178
|
+
onEvent: (evt) => {
|
|
179
|
+
if (!this.isCurrentTurn(generation, client))
|
|
180
|
+
return;
|
|
181
|
+
this.handleEvent(evt);
|
|
182
|
+
},
|
|
183
|
+
onClose: () => {
|
|
184
|
+
if (!this.isCurrentTurn(generation, client))
|
|
185
|
+
return;
|
|
186
|
+
this.failTurn('Pi RPC connection closed');
|
|
187
|
+
},
|
|
163
188
|
});
|
|
164
189
|
this.activeSessionId = opts.sessionId;
|
|
165
190
|
const translator = new PiRpcTranslator();
|
|
@@ -170,7 +195,10 @@ export class PiRpcRunner extends ConnectionBasedRunner {
|
|
|
170
195
|
this.currentTurnId = turnId;
|
|
171
196
|
this.pushEvents([translator.produceTurnStarted(opts.sessionId, turnId)]);
|
|
172
197
|
this.promptSettled = false;
|
|
173
|
-
|
|
198
|
+
// compact 是完整的 LLM 请求(同步跑完才回响应,协议文档自述),大上下文
|
|
199
|
+
// 压缩轻松超过默认 60s 请求超时——超时只会出假错误卡,压缩仍在后台完成。
|
|
200
|
+
// 活性交给 turn idle 看门狗与 /stop(与 ACP 侧 turn 级请求同口径)。
|
|
201
|
+
const response = await client.request({ type: 'compact' }, Number.POSITIVE_INFINITY);
|
|
174
202
|
this.promptSettled = true;
|
|
175
203
|
// Guard: a stop may have already ended this turn (deferred-stop during
|
|
176
204
|
// compaction) — don't push a stale result into a (possibly reused) queue.
|
|
@@ -25,7 +25,11 @@ export function resolveAgentChoices(config) {
|
|
|
25
25
|
const source = agentChoices;
|
|
26
26
|
for (const { configKey, choicesKey } of fields) {
|
|
27
27
|
const value = source[choicesKey];
|
|
28
|
-
|
|
28
|
+
// 只填「配置里根本没写」的字段(undefined):显式清空(config.yaml 写了
|
|
29
|
+
// `model: ""`)是用户意志,旧真值判断 `!target[configKey]` 会把空串当
|
|
30
|
+
// 「未配置」,用残留 choices 把清空值填回去——重启后永远回不到默认。
|
|
31
|
+
// value 为空串同样不填(choices 里记录的「跟随默认」)。
|
|
32
|
+
if (target[configKey] === undefined && value) {
|
|
29
33
|
target[configKey] = value;
|
|
30
34
|
}
|
|
31
35
|
}
|
|
@@ -14,7 +14,10 @@ export function syncAgentChoices(config, agent) {
|
|
|
14
14
|
const source = agentCfg;
|
|
15
15
|
for (const { configKey, choicesKey } of fields) {
|
|
16
16
|
const value = source[configKey];
|
|
17
|
-
|
|
17
|
+
// 显式空串也要同步(用户清空 = 回到默认):旧真值判断不同步空串,
|
|
18
|
+
// agentChoices 里残留上一次的选择——/config 卡片显示的与实际生效的
|
|
19
|
+
// 配置从此分叉,重启后 resolve 还会把残留填回 config。
|
|
20
|
+
if (value !== undefined)
|
|
18
21
|
agentChoices[choicesKey] = value;
|
|
19
22
|
}
|
|
20
23
|
return updated;
|
|
@@ -62,8 +62,15 @@ export function parseSessionJsonl(filePath) {
|
|
|
62
62
|
if (typeof obj.cwd === 'string' && obj.cwd) {
|
|
63
63
|
cwdSet.add(obj.cwd);
|
|
64
64
|
}
|
|
65
|
-
// Find first real user message for summary (same logic as summarizeSession)
|
|
66
|
-
|
|
65
|
+
// Find first real user message for summary (same logic as summarizeSession).
|
|
66
|
+
// 过滤谓词与 scalarScan 的 lastUserMessage 同源:isMeta(/clear 等命令回显
|
|
67
|
+
// 与项目规则注入)和 isCompactSummary(压缩摘要)不是用户说的话,当列表
|
|
68
|
+
// 标题会误导(codex 修过同型缺陷:注入文本被当成「最近输入」)。
|
|
69
|
+
if (!foundSummary &&
|
|
70
|
+
obj.type === 'user' &&
|
|
71
|
+
!obj.isCompactSummary &&
|
|
72
|
+
!obj.isMeta &&
|
|
73
|
+
!!obj.message) {
|
|
67
74
|
const msg = obj.message;
|
|
68
75
|
if (msg.content) {
|
|
69
76
|
const text = extractText(msg.content);
|
|
@@ -455,6 +455,14 @@ export function readCodexSessionContent(sessionId, opts = {}) {
|
|
|
455
455
|
if (!entry) {
|
|
456
456
|
return { events: [] };
|
|
457
457
|
}
|
|
458
|
+
// subagent rollout 不得当父会话历史返回:子线程 rollout 的
|
|
459
|
+
// session_meta.session_id 是父 id(§9.27),索引冲突解决在主文件缺失时会让
|
|
460
|
+
// 子文件占据该 id 的槽位,threadId 检查因等于父 id 而天然通过——list/active
|
|
461
|
+
// 都已过滤(isSubagent),按 id 读取必须同口径,否则「列表没有、active 报
|
|
462
|
+
// false、读内容却成功」三处语义互相矛盾。
|
|
463
|
+
if (entry.isSubagent) {
|
|
464
|
+
return { events: [] };
|
|
465
|
+
}
|
|
458
466
|
// Cwd guard: when a cwd is provided, the session's working directory must
|
|
459
467
|
// match. Without this, /resume <id> finds sessions by global ID lookup and
|
|
460
468
|
// allows resuming a session from cwd B while the user is in cwd A.
|
|
@@ -495,6 +503,10 @@ export function readCodexSessionSummary(sessionId, opts = {}) {
|
|
|
495
503
|
if (!entry) {
|
|
496
504
|
return {};
|
|
497
505
|
}
|
|
506
|
+
// subagent rollout 同样不得当父会话摘要(与 readCodexSessionContent 同口径)。
|
|
507
|
+
if (entry.isSubagent) {
|
|
508
|
+
return {};
|
|
509
|
+
}
|
|
498
510
|
// Cwd guard: same semantics as readCodexSessionContent — when a cwd is
|
|
499
511
|
// provided, the session's working directory must match.
|
|
500
512
|
if (opts.cwd && !samePath(entry.cwd, opts.cwd)) {
|
|
@@ -560,42 +572,55 @@ function walkRolloutFiles(sessionsDir) {
|
|
|
560
572
|
if (!fs.existsSync(sessionsDir))
|
|
561
573
|
return [];
|
|
562
574
|
const result = [];
|
|
563
|
-
try
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
575
|
+
// per-entry try/catch:目录在 readdir 与 stat 之间被删(并发归档/清理)时,
|
|
576
|
+
// 旧实现整个 walk 提前终止并返回部分结果——索引漏掉后续文件,会话从
|
|
577
|
+
// /resume 列表与 total 消失直到 5s TTL 重建。单条目失败只跳过自己。
|
|
578
|
+
const years = safeReaddir(sessionsDir);
|
|
579
|
+
for (const year of years) {
|
|
580
|
+
if (!/^\d{4}$/.test(year))
|
|
581
|
+
continue;
|
|
582
|
+
const yearPath = path.join(sessionsDir, year);
|
|
583
|
+
if (!safeIsDir(yearPath))
|
|
584
|
+
continue;
|
|
585
|
+
for (const month of safeReaddir(yearPath)) {
|
|
586
|
+
if (!/^\d{2}$/.test(month))
|
|
567
587
|
continue;
|
|
568
|
-
const
|
|
569
|
-
if (!
|
|
588
|
+
const monthPath = path.join(yearPath, month);
|
|
589
|
+
if (!safeIsDir(monthPath))
|
|
570
590
|
continue;
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
if (!/^\d{2}$/.test(month))
|
|
591
|
+
for (const day of safeReaddir(monthPath)) {
|
|
592
|
+
if (!/^\d{2}$/.test(day))
|
|
574
593
|
continue;
|
|
575
|
-
const
|
|
576
|
-
if (!
|
|
594
|
+
const dayPath = path.join(monthPath, day);
|
|
595
|
+
if (!safeIsDir(dayPath))
|
|
577
596
|
continue;
|
|
578
|
-
const
|
|
579
|
-
for (const
|
|
580
|
-
|
|
581
|
-
continue;
|
|
582
|
-
const dayPath = path.join(monthPath, day);
|
|
583
|
-
if (!fs.statSync(dayPath).isDirectory())
|
|
584
|
-
continue;
|
|
585
|
-
const files = fs
|
|
586
|
-
.readdirSync(dayPath)
|
|
587
|
-
.filter((f) => f.startsWith('rollout-') && f.endsWith('.jsonl'));
|
|
588
|
-
for (const file of files) {
|
|
589
|
-
result.push(path.join(dayPath, file));
|
|
590
|
-
}
|
|
597
|
+
const files = safeReaddir(dayPath).filter((f) => f.startsWith('rollout-') && f.endsWith('.jsonl'));
|
|
598
|
+
for (const file of files) {
|
|
599
|
+
result.push(path.join(dayPath, file));
|
|
591
600
|
}
|
|
592
601
|
}
|
|
593
602
|
}
|
|
594
603
|
}
|
|
604
|
+
return result;
|
|
605
|
+
}
|
|
606
|
+
/** readdir 的竞态安全版:目录消失/不可读返回空并记 debug(外层不再中断)。 */
|
|
607
|
+
function safeReaddir(dir) {
|
|
608
|
+
try {
|
|
609
|
+
return fs.readdirSync(dir);
|
|
610
|
+
}
|
|
595
611
|
catch (err) {
|
|
596
|
-
getLogger().
|
|
612
|
+
getLogger().debug(`[codex-rollout-reader] readdir failed (raced delete?): ${err}`);
|
|
613
|
+
return [];
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/** statSync 的竞态安全目录判定。 */
|
|
617
|
+
function safeIsDir(p) {
|
|
618
|
+
try {
|
|
619
|
+
return fs.statSync(p).isDirectory();
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return false;
|
|
597
623
|
}
|
|
598
|
-
return result;
|
|
599
624
|
}
|
|
600
625
|
const INDEX_TTL_MS = 5_000; // 5 seconds — covers a single /resume flow
|
|
601
626
|
const sessionIndexCache = new Map();
|
|
@@ -55,9 +55,18 @@ export declare class UsageAccumulator {
|
|
|
55
55
|
*/
|
|
56
56
|
bumpCompact(): void;
|
|
57
57
|
}
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* 统一契约:上下文窗口占用 = 末轮 input + output + cacheRead + cacheCreation。
|
|
60
|
+
*
|
|
61
|
+
* 口径以 design.md §9.13/§9.21 为准(文档在 6 处钉死该公式):上下文占用衡量
|
|
62
|
+
* 「本轮结束后窗口里有多少 token」,末轮 output 已进入历史、下一个 prompt 会
|
|
63
|
+
* 全量携带;与 max() 另一侧的 compact postTokens(含全部内容的压缩后水位)
|
|
64
|
+
* 语义对齐。此前实现漏加 output(长收尾 turn 可低估数千~3 万 token),且
|
|
65
|
+
* compact 会话与非 compact 会话两口径不可比。
|
|
66
|
+
*/
|
|
59
67
|
export declare function contextWindowOccupancy(l: {
|
|
60
68
|
input: number;
|
|
69
|
+
output: number;
|
|
61
70
|
cacheRead: number;
|
|
62
71
|
cacheCreation: number;
|
|
63
72
|
}): number;
|
|
@@ -69,9 +69,17 @@ export class UsageAccumulator {
|
|
|
69
69
|
this._compactCount++;
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* 统一契约:上下文窗口占用 = 末轮 input + output + cacheRead + cacheCreation。
|
|
74
|
+
*
|
|
75
|
+
* 口径以 design.md §9.13/§9.21 为准(文档在 6 处钉死该公式):上下文占用衡量
|
|
76
|
+
* 「本轮结束后窗口里有多少 token」,末轮 output 已进入历史、下一个 prompt 会
|
|
77
|
+
* 全量携带;与 max() 另一侧的 compact postTokens(含全部内容的压缩后水位)
|
|
78
|
+
* 语义对齐。此前实现漏加 output(长收尾 turn 可低估数千~3 万 token),且
|
|
79
|
+
* compact 会话与非 compact 会话两口径不可比。
|
|
80
|
+
*/
|
|
73
81
|
export function contextWindowOccupancy(l) {
|
|
74
|
-
return l.input + l.cacheRead + l.cacheCreation;
|
|
82
|
+
return l.input + l.output + l.cacheRead + l.cacheCreation;
|
|
75
83
|
}
|
|
76
84
|
/** 统一契约:session 全量累计字段组装(4 个 reader 共用:claude/dsh/kimi/pi;
|
|
77
85
|
* codex 走 last_token_usage 口径、opencode 保留自己的条件赋值,原为逐字段复制的 4 连拷贝)。 */
|
|
@@ -285,7 +285,11 @@ export class DshSessionReader {
|
|
|
285
285
|
const input = lastUsage.inputTokens ?? 0;
|
|
286
286
|
const cacheRead = lastUsage.cacheReadTokens ?? 0;
|
|
287
287
|
const cacheWrite = lastUsage.cacheWriteTokens ?? 0;
|
|
288
|
-
|
|
288
|
+
// 在途 turn(turn/start 无 turn/end,run 进行中读历史)的采样优先于
|
|
289
|
+
// 上一轮:旧实现只认已结算的 lastTurnUsage,mid-run 读取会把当前 turn
|
|
290
|
+
// 已消耗的量显示成上一个完整 turn 的数字。
|
|
291
|
+
const flow = lastTurnUsage ??
|
|
292
|
+
turnUsage ?? {
|
|
289
293
|
input,
|
|
290
294
|
output: lastUsage.outputTokens ?? 0,
|
|
291
295
|
cacheRead,
|
|
@@ -294,7 +298,8 @@ export class DshSessionReader {
|
|
|
294
298
|
sessionUsage = {
|
|
295
299
|
inputTokens: flow.input,
|
|
296
300
|
outputTokens: flow.output,
|
|
297
|
-
|
|
301
|
+
// §9.21 口径:含末轮 output(下一 prompt 全量携带,与 postTokens 水位同语义)。
|
|
302
|
+
contextLength: input + flow.output + cacheRead + cacheWrite,
|
|
298
303
|
cacheReadTokens: flow.cacheRead,
|
|
299
304
|
cacheCreationTokens: flow.cacheWrite,
|
|
300
305
|
totalTokens: flow.input + flow.output + flow.cacheRead + flow.cacheWrite,
|
|
@@ -432,11 +437,27 @@ const TEXT_MAX_BYTES = 2_000;
|
|
|
432
437
|
/** 工具原始参数上限(标题已有摘要,正文只用于看完整命令)。 */
|
|
433
438
|
const TOOL_INPUT_MAX_BYTES = 600;
|
|
434
439
|
/** Map one DSH SessionEvent to display content events (+ title/usage side effects). */
|
|
440
|
+
/**
|
|
441
|
+
* 单条畸形事件只跳过自己,不炸整张卡:DSH 服务端格式漂移时(time 非法、
|
|
442
|
+
* content 非数组),旧实现会让异常冒到外层 catch 把整个 resume 卡变成空卡
|
|
443
|
+
* (fail-whole)。时间戳缺失由卡片层兜底;content 非数组按无块处理。
|
|
444
|
+
*/
|
|
445
|
+
function safeEventTimestamp(time) {
|
|
446
|
+
if (typeof time !== 'string' && typeof time !== 'number')
|
|
447
|
+
return undefined;
|
|
448
|
+
const d = new Date(time);
|
|
449
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
450
|
+
}
|
|
451
|
+
function asBlocks(value) {
|
|
452
|
+
if (!Array.isArray(value))
|
|
453
|
+
return [];
|
|
454
|
+
return value.filter((b) => !!b && typeof b === 'object');
|
|
455
|
+
}
|
|
435
456
|
function mapEvent(ev) {
|
|
436
457
|
const out = { events: [] };
|
|
437
458
|
switch (ev.type) {
|
|
438
459
|
case 'user/message': {
|
|
439
|
-
const content = ev.data.content
|
|
460
|
+
const content = asBlocks(ev.data.content);
|
|
440
461
|
const text = content
|
|
441
462
|
.filter((c) => c.type === 'text')
|
|
442
463
|
.map((c) => c.text ?? '')
|
|
@@ -456,14 +477,13 @@ function mapEvent(ev) {
|
|
|
456
477
|
type: isNotice ? 'notice' : 'user',
|
|
457
478
|
...(isNotice ? { title: '🔔 通知' } : {}),
|
|
458
479
|
content: isNotice ? truncateUtf8(text.trim(), NOTICE_MAX_BYTES) : text,
|
|
459
|
-
timestamp:
|
|
480
|
+
timestamp: safeEventTimestamp(ev.time),
|
|
460
481
|
});
|
|
461
482
|
break;
|
|
462
483
|
}
|
|
463
484
|
case 'assistant/message': {
|
|
464
|
-
const content = ev.data.message
|
|
465
|
-
|
|
466
|
-
const timestamp = new Date(ev.time).toISOString();
|
|
485
|
+
const content = asBlocks(ev.data.message?.content);
|
|
486
|
+
const timestamp = safeEventTimestamp(ev.time);
|
|
467
487
|
// DSH 的块类型是 text / reasoning / tool-call(不是 Anthropic 的
|
|
468
488
|
// thinking)。只认 text 会让 resume 卡"只有工具调用、没有思考与输出"
|
|
469
489
|
// (2026-10-19 反馈:一个 31 turn 的会话里 reasoning 块 371 个、text 12 个,
|
|
@@ -503,7 +523,7 @@ function mapEvent(ev) {
|
|
|
503
523
|
type: 'tool_use',
|
|
504
524
|
title: summary ? `🔧 ${name} — ${summary}` : `🔧 ${name}`,
|
|
505
525
|
content: truncateUtf8(args, TOOL_INPUT_MAX_BYTES),
|
|
506
|
-
timestamp:
|
|
526
|
+
timestamp: safeEventTimestamp(ev.time),
|
|
507
527
|
};
|
|
508
528
|
break;
|
|
509
529
|
}
|
|
@@ -515,7 +535,7 @@ function mapEvent(ev) {
|
|
|
515
535
|
type: 'tool_result',
|
|
516
536
|
title: isError ? '🔴 结果(失败)' : '🟢 结果',
|
|
517
537
|
content: truncateUtf8(raw, TOOL_RESULT_MAX_BYTES),
|
|
518
|
-
timestamp:
|
|
538
|
+
timestamp: safeEventTimestamp(ev.time),
|
|
519
539
|
});
|
|
520
540
|
break;
|
|
521
541
|
}
|
|
@@ -227,7 +227,10 @@ export class KimiSessionReader {
|
|
|
227
227
|
entryWorkDir = extractWorkDir(state);
|
|
228
228
|
// Unreadable state — cannot determine cwd, entryWorkDir stays undefined
|
|
229
229
|
}
|
|
230
|
-
|
|
230
|
+
// cwd 判定与守卫(checkCwdGuard 的 samePath)同口径:严格字符串比较会
|
|
231
|
+
// 把大小写(darwin)/尾分隔符形态不同的会话从列表里滤掉,而 /resume <id>
|
|
232
|
+
// 的守卫却判等通过——「列表没有、按 id 却能读」。
|
|
233
|
+
if (entryWorkDir === undefined || !samePath(entryWorkDir, realCwd)) {
|
|
231
234
|
continue;
|
|
232
235
|
}
|
|
233
236
|
let summary = 'New Session';
|
|
@@ -332,9 +335,10 @@ export class KimiSessionReader {
|
|
|
332
335
|
cacheCreation: inputCacheCreation,
|
|
333
336
|
});
|
|
334
337
|
if (updateContextLength) {
|
|
335
|
-
// unified contract: input + cacheRead + cacheCreation
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
+
// unified contract (§9.21): input + output + cacheRead + cacheCreation.
|
|
339
|
+
// 末轮 output 已进入历史、下一 prompt 全量携带——与 compact postTokens
|
|
340
|
+
// 水位同语义(此前漏加 output 会系统性低估长收尾 turn)。
|
|
341
|
+
mainContextLength = inputOther + output + inputCacheRead + inputCacheCreation;
|
|
338
342
|
}
|
|
339
343
|
};
|
|
340
344
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
@@ -593,7 +597,7 @@ export class KimiSessionReader {
|
|
|
593
597
|
// different cwd when state.json is unavailable.
|
|
594
598
|
if (!cwdGuardPassed) {
|
|
595
599
|
const indexWorkDir = indexEntry?.workDir;
|
|
596
|
-
if (indexWorkDir && indexWorkDir
|
|
600
|
+
if (indexWorkDir && !samePath(indexWorkDir, realCwd)) {
|
|
597
601
|
return null;
|
|
598
602
|
}
|
|
599
603
|
// v2 sessions have empty index workDir (v1-only field) and may lack
|
|
@@ -243,14 +243,16 @@ export class OpencodeSessionReader {
|
|
|
243
243
|
// all render 0). Skip zero-total steps so per-turn usage reflects
|
|
244
244
|
// the last step that actually consumed tokens.
|
|
245
245
|
// ccusage-aligned: cache.write -> cacheCreationTokens.
|
|
246
|
-
// contextLength = input + cacheRead + cacheCreation
|
|
247
|
-
//
|
|
248
|
-
//
|
|
246
|
+
// contextLength = input + output + cacheRead + cacheCreation —
|
|
247
|
+
// the unified context-window occupancy contract across all readers
|
|
248
|
+
// (§9.21:末轮 output 已进入历史、下一 prompt 全量携带,与 compact
|
|
249
|
+
// postTokens 水位同语义).
|
|
249
250
|
if ((part.tokens.total ?? 0) !== 0) {
|
|
250
251
|
usage = {
|
|
251
252
|
inputTokens: part.tokens.input,
|
|
252
253
|
outputTokens: part.tokens.output,
|
|
253
|
-
contextLength: part.tokens.input +
|
|
254
|
+
contextLength: (part.tokens.input ?? 0) +
|
|
255
|
+
(part.tokens.output ?? 0) +
|
|
254
256
|
(part.tokens.cache?.read ?? 0) +
|
|
255
257
|
(part.tokens.cache?.write ?? 0),
|
|
256
258
|
cacheReadTokens: part.tokens.cache?.read,
|
|
@@ -460,7 +462,8 @@ export class OpencodeSessionReader {
|
|
|
460
462
|
latest = {
|
|
461
463
|
inputTokens: tokens.input,
|
|
462
464
|
outputTokens: tokens.output,
|
|
463
|
-
|
|
465
|
+
// §9.21 口径:含末轮 output(与 postTokens 水位同语义)。
|
|
466
|
+
contextLength: tokens.input + tokens.output + cacheRead + cacheWrite,
|
|
464
467
|
cacheReadTokens: cacheRead,
|
|
465
468
|
cacheCreationTokens: cacheWrite,
|
|
466
469
|
totalTokens: total,
|
|
@@ -628,7 +631,11 @@ export class OpencodeSessionReader {
|
|
|
628
631
|
* stdout when it is redirected to a file fd, so we read the file instead.
|
|
629
632
|
*/
|
|
630
633
|
captureExportViaFileFd(sessionId) {
|
|
631
|
-
|
|
634
|
+
// sessionId 经 path.basename 净化:它来自用户 /resume <id> 输入或畸形 CLI
|
|
635
|
+
// 数据,含 / 或 .. 时 path.join 会归一化出 tmpdir 之外的路径(写入内容为
|
|
636
|
+
// CLI stdout,可能截断/覆盖目标文件)。
|
|
637
|
+
const safeId = path.basename(sessionId);
|
|
638
|
+
const tmp = path.join(os.tmpdir(), `opencode-export-${safeId}-${process.pid}-${Date.now()}.json`);
|
|
632
639
|
let fd;
|
|
633
640
|
try {
|
|
634
641
|
const exportOpts = {
|
|
@@ -230,9 +230,10 @@ export class SessionStore {
|
|
|
230
230
|
for (const [userId, data] of Object.entries(parsed)) {
|
|
231
231
|
if (typeof data === 'object' && data !== null) {
|
|
232
232
|
const entry = data;
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
233
|
+
// 四个 session 字段缺失 = 损坏记录,整条跳过。cwd 允许缺失:
|
|
234
|
+
// 全新用户(未 /cd)先切 agent 时 previous/arrival 停车位已落盘,
|
|
235
|
+
// cwd 尚为空——不能因 cwd 缺失把停车位整条丢掉
|
|
236
|
+
// (anchor_r7_parked_previous_survives_rebuild_without_cwd)。
|
|
236
237
|
if (!entry.sessions ||
|
|
237
238
|
!entry.previousSessions ||
|
|
238
239
|
!entry.arrivalSessions ||
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lark-remote",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.12",
|
|
4
4
|
"description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|