lark-remote 0.5.0 → 0.5.2

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.
@@ -42,6 +42,8 @@ export declare class CodexAppServerRunner extends ConnectionBasedRunner<JsonRpcC
42
42
  private connectionManager;
43
43
  private currentTranslator;
44
44
  private activeThreadId;
45
+ /** 本轮已记录过日志的外来(子 agent)线程 id,避免逐条 delta 刷盘。 */
46
+ private foreignThreadIds;
45
47
  private model?;
46
48
  private modelProvider?;
47
49
  private reasoningEffort?;
@@ -91,7 +93,33 @@ export declare class CodexAppServerRunner extends ConnectionBasedRunner<JsonRpcC
91
93
  * thread/resume(按 sessionId 恢复既有线程)→ turn/start。
92
94
  */
93
95
  protected setupTurn(message: string, opts: SpawnOptions): Promise<void>;
96
+ /**
97
+ * Route one server notification into the current turn's translator.
98
+ *
99
+ * App-server 把已知线程的通知全部复用同一条 stdio 连接:agent 用
100
+ * multi-agent v2 派生子 agent 后,子线程的 `turn/started`、`item/*`、
101
+ * `turn/completed`、`thread/tokenUsage/updated` 会与本轮通知交错到达
102
+ * (2026-10-09 实测)。一个 turn 只属于一个线程,因此外来线程的通知一律
103
+ * 丢弃——否则子线程的 `turn/started` 会覆盖本轮 threadId,会话写回把子
104
+ * 线程 id 当成顶层会话指针存盘(冷连接 `thread/resume` 直接报
105
+ * "cannot resume an unloaded multi-agent v2 sub-agent"),且子线程的
106
+ * `turn/completed` 会提前结束本轮 run、token 用量也会串台。
107
+ */
94
108
  private handleNotification;
109
+ /**
110
+ * 线程归属闸门:通知是否属于本轮 turn 的线程。
111
+ *
112
+ * - 无 threadId 的通知(如部分 warning)没有线程维度可用,放行;
113
+ * - `serverRequest/resolved` 保持线程无关:审批按 requestId 关联(桥侧
114
+ * ApprovalCoordinator 跟踪),子 agent 的审批同样必须能结算,否则卡片
115
+ * 上的审批条目会一直挂在 pending。
116
+ */
117
+ private belongsToActiveThread;
118
+ /**
119
+ * 每个被忽略的外来线程只记一条日志:这是会话指针被污染的入口,出问题时
120
+ * 必须能从日志直接看到「本轮忽略了哪些子线程」,同时避免逐条 delta 刷盘。
121
+ */
122
+ private noteForeignThread;
95
123
  private handleServerRequest;
96
124
  private buildGrantedPermissions;
97
125
  /** 拒绝权限审批:空 fileSystem + 关闭网络(真实 GrantedPermissionProfile)。 */
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { ConnectionManager, } from '../../common/jsonrpc/connection-manager.js';
14
14
  import { CodexAppServerTranslator } from './translator.js';
15
+ import { NotificationMethod, } from './protocol-types.js';
15
16
  import { RpcErrorCode } from '../../common/acp/protocol-types.js';
16
17
  import { mapAnswersByIndex } from '../../question-common.js';
17
18
  import { getLogger } from '../../../logger/index.js';
@@ -102,6 +103,8 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
102
103
  connectionManager;
103
104
  currentTranslator = null;
104
105
  activeThreadId = null;
106
+ /** 本轮已记录过日志的外来(子 agent)线程 id,避免逐条 delta 刷盘。 */
107
+ foreignThreadIds = new Set();
105
108
  model;
106
109
  modelProvider;
107
110
  reasoningEffort;
@@ -171,6 +174,7 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
171
174
  clearTurnState() {
172
175
  this.currentTranslator = null;
173
176
  this.activeThreadId = null;
177
+ this.foreignThreadIds.clear();
174
178
  }
175
179
  async releaseConnection(cwd) {
176
180
  await this.connectionManager.release(cwd);
@@ -362,7 +366,8 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
362
366
  // 故 thread.id 可同时用作协议 threadId 与 store/session reader 的 session
363
367
  // 键(bridge 用 turn_started 通知的 threadId 写回,/resume、Compact 按此
364
368
  // 键定位 JSONL)。forked/subagent 线程二者会分叉(openai/codex#29327),
365
- // 桥只把主线程作为顶层会话,不在此链路内。
369
+ // 桥只把主线程作为顶层会话——子线程的通知由 handleNotification 的线程
370
+ // 归属闸门挡掉,不会进来(见 belongsToActiveThread)。
366
371
  threadId = threadResult.thread.id;
367
372
  }
368
373
  this.activeThreadId = threadId;
@@ -372,10 +377,58 @@ export class CodexAppServerRunner extends ConnectionBasedRunner {
372
377
  const turnResult = await client.request('turn/start', turnParams);
373
378
  this.currentTurnId = turnResult.turn.id;
374
379
  }
380
+ /**
381
+ * Route one server notification into the current turn's translator.
382
+ *
383
+ * App-server 把已知线程的通知全部复用同一条 stdio 连接:agent 用
384
+ * multi-agent v2 派生子 agent 后,子线程的 `turn/started`、`item/*`、
385
+ * `turn/completed`、`thread/tokenUsage/updated` 会与本轮通知交错到达
386
+ * (2026-10-09 实测)。一个 turn 只属于一个线程,因此外来线程的通知一律
387
+ * 丢弃——否则子线程的 `turn/started` 会覆盖本轮 threadId,会话写回把子
388
+ * 线程 id 当成顶层会话指针存盘(冷连接 `thread/resume` 直接报
389
+ * "cannot resume an unloaded multi-agent v2 sub-agent"),且子线程的
390
+ * `turn/completed` 会提前结束本轮 run、token 用量也会串台。
391
+ */
375
392
  handleNotification(method, params) {
393
+ if (!this.belongsToActiveThread(method, params)) {
394
+ this.noteForeignThread(params);
395
+ return;
396
+ }
376
397
  const events = this.currentTranslator?.handleNotification(method, params) ?? [];
377
398
  this.pushEvents(events);
378
399
  }
400
+ /**
401
+ * 线程归属闸门:通知是否属于本轮 turn 的线程。
402
+ *
403
+ * - 无 threadId 的通知(如部分 warning)没有线程维度可用,放行;
404
+ * - `serverRequest/resolved` 保持线程无关:审批按 requestId 关联(桥侧
405
+ * ApprovalCoordinator 跟踪),子 agent 的审批同样必须能结算,否则卡片
406
+ * 上的审批条目会一直挂在 pending。
407
+ */
408
+ belongsToActiveThread(method, params) {
409
+ const active = this.activeThreadId;
410
+ if (!active)
411
+ return true;
412
+ if (method === NotificationMethod.SERVER_REQUEST_RESOLVED)
413
+ return true;
414
+ const threadId = params?.threadId;
415
+ if (typeof threadId !== 'string' || threadId.length === 0)
416
+ return true;
417
+ return threadId === active;
418
+ }
419
+ /**
420
+ * 每个被忽略的外来线程只记一条日志:这是会话指针被污染的入口,出问题时
421
+ * 必须能从日志直接看到「本轮忽略了哪些子线程」,同时避免逐条 delta 刷盘。
422
+ */
423
+ noteForeignThread(params) {
424
+ const threadId = params?.threadId;
425
+ if (typeof threadId !== 'string' || threadId.length === 0)
426
+ return;
427
+ if (this.foreignThreadIds.has(threadId))
428
+ return;
429
+ this.foreignThreadIds.add(threadId);
430
+ getLogger().info(`[${this.logTag}] ignoring notifications from foreign thread threadId=${threadId} activeThreadId=${this.activeThreadId}`);
431
+ }
379
432
  handleServerRequest(id, method, params) {
380
433
  const events = this.currentTranslator?.handleServerRequest(id, method, params) ?? [];
381
434
  if (events.length === 0) {
@@ -456,10 +456,18 @@ export class CodexAppServerTranslator {
456
456
  }
457
457
  handleTokenUsageUpdated(params) {
458
458
  const last = params.tokenUsage.last;
459
+ // 协议原生 inputTokens 含 cached(total = input + output),而 lark-remote 的
460
+ // 卡片契约是 ccusage 口径「input_tokens 表示未命中缓存的输入」(design §9.21)。
461
+ // 在入口归一,否则 done 卡 Input 会等于 Context、cache% 分母重复计数、
462
+ // Total 被 max(total, input+output+cache) 抬高一倍(实测 364K → 727K)。
463
+ // jsonl 链路有同一处减法(session/codex/rollout-reader.ts buildUsageFromScan)。
464
+ const cachedInputTokens = last.cachedInputTokens === undefined
465
+ ? undefined
466
+ : Math.min(last.cachedInputTokens, last.inputTokens);
459
467
  this.usage = {
460
- inputTokens: last.inputTokens,
468
+ inputTokens: last.inputTokens - (cachedInputTokens ?? 0),
461
469
  outputTokens: last.outputTokens,
462
- cachedInputTokens: last.cachedInputTokens,
470
+ cachedInputTokens,
463
471
  totalTokens: last.totalTokens,
464
472
  contextLimit: params.tokenUsage.modelContextWindow,
465
473
  };
@@ -402,7 +402,16 @@ export interface Runner {
402
402
  */
403
403
  unregisterExitHandlers?(): void;
404
404
  }
405
- export type AgentKind = 'claude' | 'codex' | 'opencode' | 'pi' | 'kimi' | 'dsh';
405
+ /**
406
+ * Agent 名单单源(运行时数组 + 类型同源,二者不可能漂移)。
407
+ *
408
+ * 历史上这份名单被手抄成多份(router 的 VALID_AGENTS、bridge 的 resume.compact
409
+ * 校验、各 agent 的 config builder…),漂移过:dsh 加入后 bridge 的
410
+ * resume.compact 仍只有 5 个,带 dsh 的 resume 卡片会静默退回 defaultAgent。
411
+ * 新增 agent 只改这里。
412
+ */
413
+ export declare const AGENT_KINDS: readonly ["claude", "codex", "opencode", "pi", "kimi", "dsh"];
414
+ export type AgentKind = (typeof AGENT_KINDS)[number];
406
415
  /** Unified session descriptor returned by every AgentSessionReader. */
407
416
  export interface AgentSession {
408
417
  sessionId: string;
@@ -4,5 +4,16 @@
4
4
  * 本文件包含所有 Agent 相关的类型定义,从 runner/index.ts 提取。
5
5
  * 所有下游模块应从本文件 import 类型,而非直接依赖实现。
6
6
  */
7
- export {};
7
+ // =============================================================================
8
+ // Multi-Agent Adapter Types
9
+ // =============================================================================
10
+ /**
11
+ * Agent 名单单源(运行时数组 + 类型同源,二者不可能漂移)。
12
+ *
13
+ * 历史上这份名单被手抄成多份(router 的 VALID_AGENTS、bridge 的 resume.compact
14
+ * 校验、各 agent 的 config builder…),漂移过:dsh 加入后 bridge 的
15
+ * resume.compact 仍只有 5 个,带 dsh 的 resume 卡片会静默退回 defaultAgent。
16
+ * 新增 agent 只改这里。
17
+ */
18
+ export const AGENT_KINDS = ['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh'];
8
19
  //# sourceMappingURL=types.js.map
@@ -42,6 +42,15 @@ function subtractRawUsage(a, b) {
42
42
  total_tokens: a.total_tokens - b.total_tokens,
43
43
  };
44
44
  }
45
+ /**
46
+ * `compacted` 事件自带的摘要调用 usage(`latest_token_usage_record.usage`)。
47
+ * 用于把「压缩前窗口」与「摘要调用自身的消耗」区分开——两者都写在 compacted 前。
48
+ */
49
+ function readCompactedCallTotal(payload) {
50
+ const record = payload?.latest_token_usage_record;
51
+ const total = record?.usage?.total_tokens;
52
+ return typeof total === 'number' ? total : undefined;
53
+ }
45
54
  export function readCodexRollout(filePath) {
46
55
  if (!fs.existsSync(filePath)) {
47
56
  return null;
@@ -136,6 +145,8 @@ function scanCodexRollout(filePath, includeEvents) {
136
145
  // pi/opencode /resume "last turn" display semantics). When
137
146
  // last_token_usage is absent we derive it as total - previous_total.
138
147
  let lastRawUsage;
148
+ /** 上一条被接受的 raw usage;compacted 分支回退一条时用(见下方注释)。 */
149
+ let previousRawUsage;
139
150
  // Context window limit from the same token_count event as lastRawUsage
140
151
  // (codex reports info.model_context_window per turn; absent on old data).
141
152
  let lastContextLimit;
@@ -202,6 +213,7 @@ function scanCodexRollout(filePath, includeEvents) {
202
213
  lastTotalUsage = total;
203
214
  }
204
215
  if (raw && (raw.input_tokens || raw.cached_input_tokens || raw.output_tokens)) {
216
+ previousRawUsage = lastRawUsage;
205
217
  lastRawUsage = raw;
206
218
  const ctxWindow = info?.model_context_window;
207
219
  lastContextLimit = typeof ctxWindow === 'number' ? ctxWindow : undefined;
@@ -223,10 +235,19 @@ function scanCodexRollout(filePath, includeEvents) {
223
235
  }
224
236
  }
225
237
  else if (parsed.type === 'compacted') {
226
- // 顶层 compacted 事件:压缩次数 +1,压缩前水位取最近一次非零 token_count。
238
+ // 顶层 compacted 事件:压缩次数 +1,压缩前水位取「压缩调用之前」那条非零
239
+ // token_count 的 total_tokens。真实 rollout 落盘顺序是
240
+ // 末轮 → 摘要调用 → compacted → 压缩后水位:摘要调用自身的 token_count 紧贴
241
+ // 在 compacted 之前,直接取 lastRawUsage 会把摘要调用的消耗当成压缩前窗口
242
+ // (实测把 363K 的窗口显示成 207K)。compacted 自带的摘要调用 usage 用于判定
243
+ // 并回退一条;旧数据没有 latest_token_usage_record 时保持原语义。
227
244
  compactCount++;
228
- if (lastRawUsage?.total_tokens !== undefined) {
229
- compactPreContextLength = lastRawUsage.total_tokens;
245
+ const compactCallTotal = readCompactedCallTotal(parsed.payload);
246
+ const preCompactUsage = compactCallTotal !== undefined && lastRawUsage?.total_tokens === compactCallTotal
247
+ ? previousRawUsage
248
+ : lastRawUsage;
249
+ if (preCompactUsage?.total_tokens !== undefined) {
250
+ compactPreContextLength = preCompactUsage.total_tokens;
230
251
  }
231
252
  }
232
253
  else if (includeEvents && parsed.type === 'response_item' && parsed.payload) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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",