lark-remote 0.5.1 → 0.5.3

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 CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.3] - 2026-10-12
9
+
10
+ ### 修复
11
+
12
+ - **中断后紧接着发下一条消息,不再报 `thread already has an active writer`**:点停止打断正在运行的 codex 时,中断流程会把复用的运行实例从缓存摘掉并立即销毁连接,而队列链在这一步的等待窗口里就把下一条消息发了出去、并已取到同一个实例——那条运行的长驻连接于是挂在无人托管的实例上,`codex app-server` 进程要等空闲超时(默认 30 分钟)才回收,期间一直持有该会话的写锁,同一会话的下一次恢复因此被拒。现在中断只把实例标为「待替换」,统一由下一次运行收尾时回收,标过的实例不再返回缓存;被摘掉的实例也会在运行结束时主动关闭连接,不再挂 30 分钟的超时。逐条消息新建实例的 agent 不受影响,行为照旧
13
+
14
+ ### 变更
15
+
16
+ - **注释里的运维现场数据与仓库外引用清理**:一处测试注释记录的崩溃进程号、实例名与精确到秒的重启时间改为占位描述;若干注释不再指向仓库之外的维护规范文件名。无行为变化
17
+
18
+ ## [0.5.2] - 2026-10-11
19
+
20
+ ### 修复
21
+
22
+ - **codex 运行结束卡片的 token 统计不再翻倍**:codex app-server 实时上报的输入 token 按协议口径已包含命中缓存的部分,而卡片契约要求它表示「未命中缓存的输入」。入口没做减法时,Input 会等于整个上下文用量、缓存占比的分母被重复计数、Total 再被「总量与分项之和取大」抬高一倍(实测同一轮 36 万显示成 72 万)。现在实时通知与 jsonl 兜底读取两条入口统一减掉缓存部分
23
+ - **压缩卡片的「压缩前」不再显示摘要调用自身的用量**:codex 把生成摘要那次调用自己的 token 记录写在压缩事件之前,此前「压缩前」取的是紧贴压缩事件的那一条,于是把摘要调用的消耗当成压缩前的上下文窗口(实测 36 万的窗口显示成 20 万)。现在按压缩事件自带的摘要调用用量回退一条,取压缩前最后一次真实窗口;更早的、不带该字段的数据保持原读法
24
+ - **排队卡片不再复述「你正在排队」**:占用执行位的是普通消息时,卡片仍写着「正在执行: 💬 消息」并附一句「消息将按顺序执行…」,这两行对用户没有新信息。现在只有可能挡住队列、需要用户归因的任务(Compact、命令)才自报身份;Compact 占用时仍额外说明压缩结束后会按顺序执行你的消息
25
+
26
+ ### 变更
27
+
28
+ - **消除一处全量测试并发下的时序假红**:bash 退出清理用例在收到首个事件后立刻读取 shell 自己写入的 pid 文件,而重定向落盘可能晚于该事件,整套测试并发跑时会随机报文件不存在。改为与同一用例里另一处 pid 文件相同的有界等待。无行为变化
29
+
8
30
  ## [0.5.1] - 2026-10-09
9
31
 
10
32
  ### 修复
@@ -320,6 +320,17 @@ export class Bridge {
320
320
  if (evicted && typeof evicted.unregisterExitHandlers === 'function') {
321
321
  evicted.unregisterExitHandlers();
322
322
  }
323
+ // 2026-10-11:先标记 orphan 再 dispose。evict 与「run 刚拿走本实例」竞态时
324
+ // (queue 链在 interruptCurrentRun await 期间推进),dispose 只能关掉当时
325
+ // 已存在的连接,run 后续 acquire 的新连接会挂在无人托管的实例上——orphan
326
+ // 标记让 executeTurn 在 run 结束时主动 dispose 这批连接,而不是挂 idle
327
+ // timer 等待 30 分钟(期间持有 agent 侧会话写锁,codex 报
328
+ // "thread already has an active writer")。鸭子探测与 dispose/unregister
329
+ // 一致:claude(stream-json,无长驻连接)无此方法,跳过。
330
+ if (evicted &&
331
+ typeof evicted.markOrphaned === 'function') {
332
+ evicted.markOrphaned();
333
+ }
323
334
  // App-server runner owns a persistent connection — dispose it so the
324
335
  // `codex app-server` child process does not leak across runs/evictions.
325
336
  if (evicted && typeof evicted.dispose === 'function') {
@@ -574,7 +585,22 @@ export class Bridge {
574
585
  // (eviction must also unregister the runner from the exit dispatcher)
575
586
  // evict by the run's captured agentKind, not the live
576
587
  // defaultAgent (may have changed mid-run via /config).
577
- this.evictRunnerSlot(cwd, active.agentKind);
588
+ //
589
+ // 2026-10-11:workspace-lifetime runner 不能在这里立即 evict+dispose。
590
+ // interruptCurrentRun 的 await 窗口内 queue 链会推进下一条消息,其
591
+ // getRunner 可能刚拿走本实例(缓存命中先于本行执行)——立即 evict 会
592
+ // 让那个 run 的长驻连接脱管:进程残留至 idle TTL 才回收,期间持有
593
+ // agent 侧会话写锁(codex "thread already has an active writer",
594
+ // 线上实测)。改为标 stale,由 finalizeRun 在下一次 run 结束时统一
595
+ // evict+dispose;「stop 后下一 run 换新实例」的语义
596
+ // 保留(stale 命中即最后一轮复用)。run-per-message runner 无长驻
597
+ // 连接,保持立即 evict 不变。
598
+ if (active.runner.lifetime === 'workspace') {
599
+ this.staleRunnerSlots.add(`${cwd}\u0000${active.agentKind}`);
600
+ }
601
+ else {
602
+ this.evictRunnerSlot(cwd, active.agentKind);
603
+ }
578
604
  // Reset queue executing count: the task was interrupted externally, so
579
605
  // its promise-chain settle (.then/.catch) may lag behind the process
580
606
  // kill. Without this reset, subsequent messages would incorrectly show
@@ -432,7 +432,7 @@ export class QueueManager {
432
432
  ];
433
433
  // 「队列位置 / 前面还有」只在前面确实还有等待中的消息时渲染。前面为空时
434
434
  // 这两个数是「第 1 位 / 0 条」,与「排队中」字面矛盾;此时挡住用户的只有
435
- // 正在执行的任务,交给下一段如实说明。
435
+ // 正在执行的任务,由下一段的占用者说明(若占用者是普通消息则不额外渲染)。
436
436
  if (tasksAhead > 0) {
437
437
  elements.push({
438
438
  tag: 'div',
@@ -443,28 +443,26 @@ export class QueueManager {
443
443
  });
444
444
  }
445
445
  if (occupant) {
446
- elements.push({
447
- tag: 'div',
448
- text: { tag: 'lark_md', content: `**正在执行:** ${occupant.label}` },
449
- });
450
- elements.push({
451
- tag: 'div',
452
- text: {
453
- tag: 'lark_md',
454
- content: occupant.kind === 'compact'
455
- ? '💡 压缩结束后将按顺序执行你的消息'
456
- : '💡 消息将按顺序执行…',
457
- },
458
- });
446
+ // 只有可能挡住队列、需要用户归因的占用者才自报身份。「正在执行: 💬 消息」
447
+ // 对普通消息占用者没有信息量——用户刚发的就是消息,「💡 消息将按顺序执行…」
448
+ // 也只是复述排队本身,一并省去(2026-10-11)。压缩额外说明何时轮到用户。
449
+ if (occupant.kind !== 'message') {
450
+ elements.push({
451
+ tag: 'div',
452
+ text: { tag: 'lark_md', content: `**正在执行:** ${occupant.label}` },
453
+ });
454
+ }
455
+ if (occupant.kind === 'compact') {
456
+ elements.push({
457
+ tag: 'div',
458
+ text: { tag: 'lark_md', content: '💡 压缩结束后将按顺序执行你的消息' },
459
+ });
460
+ }
459
461
  }
460
462
  else {
461
463
  // 防御分支:开卡判据是 lane 非空,所以「有排队卡但没有占用者」说明计数
462
464
  // 与占用者记录漂移了。不改行为,只留证据(与 duplicate queue key 同口径)。
463
465
  getLogger().warn(`[queue-manager] queue card without lane occupant cwd=${cwd} tasksAhead=${tasksAhead}`);
464
- elements.push({
465
- tag: 'div',
466
- text: { tag: 'lark_md', content: '💡 消息将按顺序执行…' },
467
- });
468
466
  }
469
467
  elements.push({ tag: 'hr' });
470
468
  // Show message preview with edit button (only if not executing AND the
package/dist/cli.js CHANGED
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * CLI 引导层(bootstrap):在 import 应用模块图之前决定运行时(bun/node)并拉起它。
4
4
  *
5
- * **这是全仓唯一被认可的 spawn 收口例外**(即 AGENTS.md「子进程 spawn 只走
6
- * platform/spawn.ts」红线的显式豁免)。三个理由缺一不可:
5
+ * **这是全仓唯一被认可的 spawn 收口例外**(红线:子进程 spawn 只走
6
+ * platform/spawn.ts,此处显式豁免)。三个理由缺一不可:
7
7
  * 1. 时序:preflight(Node 版本守卫、-v/-h/--advanced-help)必须在 import 应用
8
8
  * 模块图之前跑完——dist/cli.js 刻意不使用 ES2020+ 语法以便在 node 12 上仍
9
9
  * 可被解析,而 dist/index.js 是 ES2022;先 import platform/spawn 就等于先把
@@ -6,7 +6,7 @@
6
6
  * 2. 拿不到的东西(不支持类型 / 超限 / 下载失败 / 媒体保存已关闭):明确告知,
7
7
  * 而不是把占位符塞给 agent。
8
8
  *
9
- * 用户可见文案指代本程序时只能写 `lark-remote`(AGENTS.md 命名纪律)。
9
+ * 命名纪律:用户可见文案指代本程序时只能写 `lark-remote`。
10
10
  */
11
11
  import type { PlaceholderKind } from './placeholder.js';
12
12
  import type { RejectedItem } from './turn.js';
@@ -6,7 +6,7 @@
6
6
  * 2. 拿不到的东西(不支持类型 / 超限 / 下载失败 / 媒体保存已关闭):明确告知,
7
7
  * 而不是把占位符塞给 agent。
8
8
  *
9
- * 用户可见文案指代本程序时只能写 `lark-remote`(AGENTS.md 命名纪律)。
9
+ * 命名纪律:用户可见文案指代本程序时只能写 `lark-remote`。
10
10
  */
11
11
  import path from 'node:path';
12
12
  /** 单条回执最多列出的文件数;超出折叠为 "… 等 N 个文件"(沿用旧提示口径)。 */
@@ -9,7 +9,7 @@ function defaultLog(level, message) {
9
9
  else
10
10
  logger.info(message);
11
11
  }
12
- /** 退出判定统一口径(CLAUDE.md 红线):exitCode !== null || signalCode !== null。 */
12
+ /** 退出判定统一口径(红线):exitCode !== null || signalCode !== null。 */
13
13
  function isAlive(proc) {
14
14
  return proc.exitCode === null && proc.signalCode === null;
15
15
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { spawn as nodeSpawn } from 'node:child_process';
14
14
  import { agentStopperRegistry } from './agent-stopper.js';
15
- /** 退出判定统一口径(CLAUDE.md 红线):exitCode !== null || signalCode !== null。 */
15
+ /** 退出判定统一口径(红线):exitCode !== null || signalCode !== null。 */
16
16
  function isAlive(proc) {
17
17
  return proc.exitCode === null && proc.signalCode === null;
18
18
  }
@@ -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
  };
@@ -31,6 +31,10 @@ export declare abstract class ConnectionBasedRunner<TClient, TEvent = AgentEvent
31
31
  protected forceFinish: boolean;
32
32
  /** stop() 在 turn/start 或 prompt 响应前到达时置位,完成后再补发 cancel/interrupt。 */
33
33
  protected stopRequested: boolean;
34
+ /** 缓存层除名标记(evictRunnerSlot → markOrphaned)。置位后 run 结束时主动
35
+ * dispose 全部连接而不是挂 idle timer:被除名实例上创建的连接无人托管,
36
+ * 不主动收会残留进程至 idle TTL(30 分钟),期间持有 agent 侧会话写锁。 */
37
+ private orphaned;
34
38
  protected constructor(opts: {
35
39
  kind: AgentKind;
36
40
  sessionReader: AgentSessionReader;
@@ -96,6 +100,16 @@ export declare abstract class ConnectionBasedRunner<TClient, TEvent = AgentEvent
96
100
  * Dispose the runner: release the connection.
97
101
  */
98
102
  dispose(): Promise<void>;
103
+ /**
104
+ * Mark this runner as evicted from the bridge's runner cache while a run may
105
+ * still be holding/creating connections on it (queue-advance race during
106
+ * interruptCurrentRun's await window, 2026-10-11). The active run's finally
107
+ * disposes connections eagerly instead of arming the idle TTL timer —
108
+ * otherwise the agent server process lingers for up to 30 minutes holding
109
+ * its session write lock (codex: "thread already has an active writer").
110
+ * One-way by design: an evicted instance never returns to the cache.
111
+ */
112
+ markOrphaned(): void;
99
113
  getUsageAuthority(): 'live';
100
114
  killOrphan(): void;
101
115
  registerExitHandlers(): void;
@@ -22,6 +22,10 @@ export class ConnectionBasedRunner {
22
22
  forceFinish = false;
23
23
  /** stop() 在 turn/start 或 prompt 响应前到达时置位,完成后再补发 cancel/interrupt。 */
24
24
  stopRequested = false;
25
+ /** 缓存层除名标记(evictRunnerSlot → markOrphaned)。置位后 run 结束时主动
26
+ * dispose 全部连接而不是挂 idle timer:被除名实例上创建的连接无人托管,
27
+ * 不主动收会残留进程至 idle TTL(30 分钟),期间持有 agent 侧会话写锁。 */
28
+ orphaned = false;
25
29
  constructor(opts, defaultTurnIdleTimeoutMs = TURN_IDLE_TIMEOUT_MS) {
26
30
  this.kind = opts.kind;
27
31
  this.sessionReader = opts.sessionReader;
@@ -130,7 +134,19 @@ export class ConnectionBasedRunner {
130
134
  this.currentTurnId = null;
131
135
  this.stopRequested = false;
132
136
  this.clearTurnState();
133
- this.notifyIdle(opts.cwd);
137
+ if (this.orphaned) {
138
+ // 已被缓存层除名(evictRunnerSlot → markOrphaned):本次 run 创建的
139
+ // 连接挂在无人托管的实例上。主动收掉而不是挂 idle timer——否则 agent
140
+ // server 进程残留至 idle TTL,期间持有 agent 侧会话写锁(codex
141
+ // "thread already has an active writer",2026-10-11 线上实测)。
142
+ // dispose() 内部自带 try/catch,这里 fire-and-forget 不阻塞事件流收尾。
143
+ void this.disposeConnections().catch((err) => {
144
+ getLogger().warn(`[${this.logTag}] orphan dispose error: ${err.message}`);
145
+ });
146
+ }
147
+ else {
148
+ this.notifyIdle(opts.cwd);
149
+ }
134
150
  }
135
151
  }
136
152
  /**
@@ -178,6 +194,18 @@ export class ConnectionBasedRunner {
178
194
  getLogger().warn(`[${this.logTag}] dispose error: ${err.message}`);
179
195
  }
180
196
  }
197
+ /**
198
+ * Mark this runner as evicted from the bridge's runner cache while a run may
199
+ * still be holding/creating connections on it (queue-advance race during
200
+ * interruptCurrentRun's await window, 2026-10-11). The active run's finally
201
+ * disposes connections eagerly instead of arming the idle TTL timer —
202
+ * otherwise the agent server process lingers for up to 30 minutes holding
203
+ * its session write lock (codex: "thread already has an active writer").
204
+ * One-way by design: an evicted instance never returns to the cache.
205
+ */
206
+ markOrphaned() {
207
+ this.orphaned = true;
208
+ }
181
209
  getUsageAuthority() {
182
210
  return 'live';
183
211
  }
@@ -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.1",
3
+ "version": "0.5.3",
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",