larkway 0.3.15 → 0.3.17

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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  You @ the bot in a Feishu thread. It runs on your machine — reading your real codebase, executing commands, opening MRs — and posts the result back. You define what the agent knows and what it can do. Larkway just carries the messages.
10
10
 
11
- **Current release: v0.3.15**
11
+ **Current release: v0.3.17**
12
12
 
13
13
  ---
14
14
 
package/README.zh.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  你在飞书话题里 @ bot,它在你的机器上运行——读真实代码库、执行命令、开 MR——把结果贴回飞书。你定义 agent 知道什么、能做什么。Larkway 只负责传递消息。
10
10
 
11
- **当前版本:v0.3.15**
11
+ **当前版本:v0.3.17**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -112093,7 +112093,7 @@ function channelMsgToLarkEvent(msg) {
112093
112093
  create_time: m?.["create_time"] ?? String(msg.createTime ?? Date.now())
112094
112094
  };
112095
112095
  }
112096
- var import_node_sdk, execFile4, LEARNED_CHATS_LIMIT, SEEN_MESSAGES_LIMIT, MAX_MESSAGE_ATTEMPTS, OPEN_CHAT_DISCOVERY_LOOKBACK_MS, OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS, PROCESSING_REACTION_EMOJI, DEFAULT_CONNECT_GRACE_MS, ChannelClient;
112096
+ var import_node_sdk, execFile4, LEARNED_CHATS_LIMIT, SEEN_MESSAGES_LIMIT, MAX_MESSAGE_ATTEMPTS, OPEN_CHAT_DISCOVERY_LOOKBACK_MS, OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS, PROCESSING_REACTION_EMOJI, GAP_FILL_MAX_ATTEMPTS, GAP_FILL_BACKOFF_BASE_MS, UNRESOLVED_WINDOW_MAX_CHATS, UNRESOLVED_WINDOW_MAX_AGE_MS, DEFAULT_CONNECT_GRACE_MS, ChannelClient;
112097
112097
  var init_channelClient = __esm({
112098
112098
  "src/lark/channelClient.ts"() {
112099
112099
  "use strict";
@@ -112107,6 +112107,10 @@ var init_channelClient = __esm({
112107
112107
  OPEN_CHAT_DISCOVERY_LOOKBACK_MS = 9e4;
112108
112108
  OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS = 30 * 60 * 1e3;
112109
112109
  PROCESSING_REACTION_EMOJI = "Typing";
112110
+ GAP_FILL_MAX_ATTEMPTS = 3;
112111
+ GAP_FILL_BACKOFF_BASE_MS = 1e3;
112112
+ UNRESOLVED_WINDOW_MAX_CHATS = 50;
112113
+ UNRESOLVED_WINDOW_MAX_AGE_MS = 30 * 60 * 1e3;
112110
112114
  DEFAULT_CONNECT_GRACE_MS = 3e3;
112111
112115
  ChannelClient = class {
112112
112116
  opts;
@@ -112171,6 +112175,27 @@ var init_channelClient = __esm({
112171
112175
  * search space.
112172
112176
  */
112173
112177
  recentlySeenChatIds = /* @__PURE__ */ new Set();
112178
+ /**
112179
+ * PER-CHAT unresolved gapFill windows: chatId → the OLDEST windowStart (ms) for
112180
+ * which that chat's lark-cli history pull still failed after all retries. On a
112181
+ * later gapFill that ACTUALLY pulls this chat, we extend the look-back to cover
112182
+ * its oldest unresolved windowStart and only clear it once the pull truly
112183
+ * reached back that far (its `--start` <= the tracked windowStart). Per-chat (not
112184
+ * a single shared list) so a successful run over chat-set {B} can never falsely
112185
+ * resolve chat A's window (BLOCKER 1), and the look-back-vs-clamp mismatch can
112186
+ * never mark a window resolved before it was reached (BLOCKER 2).
112187
+ *
112188
+ * Bounded: one timestamp per chat (so naturally bounded by #chats), pruned by age
112189
+ * (UNRESOLVED_WINDOW_MAX_AGE_MS — older = unrecoverable from history anyway) and
112190
+ * capped at UNRESOLVED_WINDOW_MAX_CHATS tracked chats.
112191
+ */
112192
+ unresolvedGapWindowByChat = /* @__PURE__ */ new Map();
112193
+ /**
112194
+ * Backoff sleep used by the per-chat history-pull retry. Indirected through a
112195
+ * field purely so tests can observe/await the backoff deterministically; in
112196
+ * production it is the real timer-based {@link sleep}.
112197
+ */
112198
+ gapFillSleep = sleep;
112174
112199
  openChatDiscoveryTimer = null;
112175
112200
  openChatDiscoveryRunning = false;
112176
112201
  openChatDiscoveryBootstrapped = false;
@@ -112192,6 +112217,28 @@ var init_channelClient = __esm({
112192
112217
  }
112193
112218
  this.opts = opts;
112194
112219
  }
112220
+ /**
112221
+ * TEST SEAM (deletable): override the gap-fill retry backoff sleep so unit
112222
+ * tests can observe the backoff durations and avoid real timers. No-op for
112223
+ * production — the default is the real {@link sleep}. Returns the recorded
112224
+ * backoff arg via the provided callback's own bookkeeping.
112225
+ */
112226
+ setGapFillSleepForTest(fn) {
112227
+ this.gapFillSleep = fn;
112228
+ }
112229
+ /** TEST-ONLY read of the per-chat unresolved-window replay map (chatId → windowStart). */
112230
+ unresolvedGapWindowsForTest() {
112231
+ return new Map(this.unresolvedGapWindowByChat);
112232
+ }
112233
+ /**
112234
+ * TEST-ONLY direct gapFill invocation with an explicit chat-set override —
112235
+ * mirrors exactly how open-chat discovery calls gapFill on a SUBSET of chats.
112236
+ * Used to reproduce the cross-chat-set replay isolation (BLOCKER 1) without
112237
+ * standing up the full discovery timer.
112238
+ */
112239
+ async gapFillForTest(disconnectAt, chatIds) {
112240
+ await this.gapFill(disconnectAt, (s) => console.log(`[channel.client] ${s}`), chatIds);
112241
+ }
112195
112242
  /**
112196
112243
  * Async iterator over inbound events — interface-compatible with LarkClient.
112197
112244
  * Connects the WS on first call. The SDK's policy gate (requireMention +
@@ -112257,7 +112304,25 @@ var init_channelClient = __esm({
112257
112304
  // ── WS robustness knobs (node-sdk ≥1.64; all OFF by default) ──────────
112258
112305
  // Abort a handshake that hangs on a stuck DNS/proxy/NAT path so the retry
112259
112306
  // loop can try again, instead of waiting indefinitely. Successful TLS
112260
- // handshakes are tens of ms; 15s is a wide safety margin.
112307
+ // handshakes are tens of ms; 15s is a wide safety margin. KEPT ON: it is
112308
+ // the right behaviour (abort + reconnect beats hanging forever).
112309
+ //
112310
+ // CAVEAT — raw '_WebSocket' 'error' on abort: when this timeout fires the
112311
+ // SDK aborts the underlying ws, which emits a RAW 'error' event on the
112312
+ // socket. That socket is owned privately inside node-sdk's WSClient
112313
+ // (no public `on()`, no accessor for the raw ws — verified against
112314
+ // node-sdk 1.67.0 types: WSClient is not an EventEmitter and keeps the
112315
+ // `_WebSocket` in a closure), so we CANNOT attach a precise 'error'
112316
+ // listener here. With no listener, Node re-throws it as an
112317
+ // uncaughtException → it would kill the whole (multi-bot) process.
112318
+ // → That raw error is instead caught by the process-level crash guard
112319
+ // in main.ts (registerCrashGuard: uncaughtException handler that logs
112320
+ // and never exits). The channel-level `channel.on("error", …)` below
112321
+ // is a DIFFERENT, higher-level error and does NOT cover this raw case.
112322
+ // → Residual uncertainty (left for acceptance load-testing): that the
112323
+ // process guard reliably catches this specific raw abort path under
112324
+ // real network flap. If a future node-sdk / @larksuite/channel exposes
112325
+ // the ws or attaches its own listener, prefer that and drop the guard.
112261
112326
  handshakeTimeoutMs: 15e3,
112262
112327
  // Liveness watchdog (SECONDS): if no inbound frame arrives within this
112263
112328
  // window after the last ping, treat the socket as dead and reconnect —
@@ -112374,9 +112439,6 @@ var init_channelClient = __esm({
112374
112439
  const MAX_GAP_FILL_WINDOW_MS = 5 * 60 * 1e3;
112375
112440
  const BUFFER_MS = 3e4;
112376
112441
  const now = Date.now();
112377
- const windowStart = Math.max(disconnectAt - BUFFER_MS, now - MAX_GAP_FILL_WINDOW_MS);
112378
- const startIso = new Date(windowStart).toISOString();
112379
- const endIso = new Date(now + BUFFER_MS).toISOString();
112380
112442
  const larkCli = this.opts.larkCliPath ?? "lark-cli";
112381
112443
  const profileArgs = this.opts.larkCliProfile ? ["--profile", this.opts.larkCliProfile] : [];
112382
112444
  const botOpenId = this.opts.botOpenId;
@@ -112386,16 +112448,29 @@ var init_channelClient = __esm({
112386
112448
  ...this.opts.allowedChatIds,
112387
112449
  ...this.recentlySeenChatIds
112388
112450
  ]);
112451
+ this.pruneUnresolvedGapWindows(now);
112452
+ let oldestRelevantUnresolved = Infinity;
112453
+ for (const chatId of gapFillChatIds) {
112454
+ const ws = this.unresolvedGapWindowByChat.get(chatId);
112455
+ if (ws !== void 0 && ws < oldestRelevantUnresolved) oldestRelevantUnresolved = ws;
112456
+ }
112457
+ const hasReplay = oldestRelevantUnresolved !== Infinity;
112458
+ const lookBackFrom = Math.min(disconnectAt, hasReplay ? oldestRelevantUnresolved : disconnectAt);
112459
+ const clampCeilingMs = hasReplay ? UNRESOLVED_WINDOW_MAX_AGE_MS : MAX_GAP_FILL_WINDOW_MS;
112460
+ const windowStart = Math.max(lookBackFrom - BUFFER_MS, now - clampCeilingMs);
112461
+ const startIso = new Date(windowStart).toISOString();
112462
+ const endIso = new Date(now + BUFFER_MS).toISOString();
112389
112463
  if (gapFillChatIds.size === 0) {
112390
112464
  log(
112391
112465
  `gap-fill skipped: no known chats for window=${startIso}..${endIso} (allowedChatIds is empty and no live chat has been seen yet)`
112392
112466
  );
112393
112467
  return;
112394
112468
  }
112469
+ let anyChatFailed = false;
112395
112470
  for (const chatId of gapFillChatIds) {
112396
112471
  if (this.closed) break;
112397
112472
  try {
112398
- const { stdout: stdout2 } = await execFile4(larkCli, [
112473
+ const args = [
112399
112474
  "im",
112400
112475
  "+chat-messages-list",
112401
112476
  "--as",
@@ -112414,7 +112489,8 @@ var init_channelClient = __esm({
112414
112489
  "json",
112415
112490
  "--no-reactions",
112416
112491
  ...profileArgs
112417
- ]);
112492
+ ];
112493
+ const { stdout: stdout2 } = await this.execWithRetry(larkCli, args, chatId, log);
112418
112494
  let messages;
112419
112495
  try {
112420
112496
  messages = parseLarkCliMessages(stdout2) ?? [];
@@ -112482,16 +112558,91 @@ var init_channelClient = __esm({
112482
112558
  this.queue.push(ev);
112483
112559
  totalDispatched++;
112484
112560
  }
112561
+ this.resolveUnresolvedGapWindow(chatId, windowStart);
112485
112562
  } catch (e) {
112563
+ anyChatFailed = true;
112564
+ this.recordUnresolvedGapWindow(chatId, windowStart, log);
112486
112565
  log(
112487
- `gap-fill: lark-cli failed for chat ${chatId}: ` + (e instanceof Error ? e.message : String(e))
112566
+ `gap-fill: lark-cli failed for chat ${chatId} after ${GAP_FILL_MAX_ATTEMPTS} attempt(s): ` + (e instanceof Error ? e.message : String(e))
112488
112567
  );
112489
112568
  }
112490
112569
  }
112491
112570
  log(
112492
- `gap-fill complete: window=${startIso}..${endIso}, fetched=${totalFetched}, dispatched=${totalDispatched}`
112571
+ `gap-fill complete: window=${startIso}..${endIso}, fetched=${totalFetched}, dispatched=${totalDispatched}` + (anyChatFailed ? ` (some chats failed \u2014 per-chat windows queued for replay)` : ``)
112493
112572
  );
112494
112573
  }
112574
+ /**
112575
+ * Run a lark-cli history pull with bounded retries + exponential backoff.
112576
+ * Retries on ANY thrown error (transient TLS timeout being the motivating case),
112577
+ * up to {@link GAP_FILL_MAX_ATTEMPTS}. Backoff is GAP_FILL_BACKOFF_BASE_MS *
112578
+ * 2^(attempt-1) (~1s / 2s). Re-throws the last error if all attempts fail so the
112579
+ * caller can flag the window for replay. Backoff goes through {@link gapFillSleep}
112580
+ * (injectable) so tests can observe it deterministically.
112581
+ */
112582
+ async execWithRetry(larkCli, args, chatId, log) {
112583
+ let lastErr;
112584
+ for (let attempt = 1; attempt <= GAP_FILL_MAX_ATTEMPTS; attempt++) {
112585
+ if (this.closed) throw lastErr ?? new Error("closed");
112586
+ try {
112587
+ return await execFile4(larkCli, args);
112588
+ } catch (e) {
112589
+ lastErr = e;
112590
+ if (attempt < GAP_FILL_MAX_ATTEMPTS) {
112591
+ const backoffMs = GAP_FILL_BACKOFF_BASE_MS * 2 ** (attempt - 1);
112592
+ log(
112593
+ `gap-fill: lark-cli pull failed for chat ${chatId} (attempt ${attempt}/${GAP_FILL_MAX_ATTEMPTS}) \u2014 retrying in ${backoffMs}ms: ` + (e instanceof Error ? e.message : String(e))
112594
+ );
112595
+ await this.gapFillSleep(backoffMs);
112596
+ }
112597
+ }
112598
+ }
112599
+ throw lastErr;
112600
+ }
112601
+ /** Drop per-chat unresolved windows older than the replay max age (unrecoverable). */
112602
+ pruneUnresolvedGapWindows(now) {
112603
+ const cutoff = now - UNRESOLVED_WINDOW_MAX_AGE_MS;
112604
+ for (const [chatId, windowStart] of this.unresolvedGapWindowByChat) {
112605
+ if (windowStart < cutoff) this.unresolvedGapWindowByChat.delete(chatId);
112606
+ }
112607
+ }
112608
+ /**
112609
+ * Record (or keep the OLDEST) unresolved window for a chat whose pull failed.
112610
+ * Bounded by chat count: if the map is at capacity and this is a new chat, we
112611
+ * evict the chat with the NEWEST window (least at risk of aging out) so the
112612
+ * oldest at-risk windows survive to be replayed first.
112613
+ */
112614
+ recordUnresolvedGapWindow(chatId, windowStart, log) {
112615
+ const existing = this.unresolvedGapWindowByChat.get(chatId);
112616
+ if (existing !== void 0 && existing <= windowStart) return;
112617
+ if (existing === void 0 && this.unresolvedGapWindowByChat.size >= UNRESOLVED_WINDOW_MAX_CHATS) {
112618
+ let newestChat = null;
112619
+ let newestWs = -Infinity;
112620
+ for (const [c, ws] of this.unresolvedGapWindowByChat) {
112621
+ if (ws > newestWs) {
112622
+ newestWs = ws;
112623
+ newestChat = c;
112624
+ }
112625
+ }
112626
+ if (newestChat !== null && newestWs > windowStart) this.unresolvedGapWindowByChat.delete(newestChat);
112627
+ else if (newestChat !== null) return;
112628
+ }
112629
+ this.unresolvedGapWindowByChat.set(chatId, windowStart);
112630
+ log(
112631
+ `gap-fill: queued unresolved window for chat ${chatId} start=${new Date(windowStart).toISOString()} (tracked chats=${this.unresolvedGapWindowByChat.size})`
112632
+ );
112633
+ }
112634
+ /**
112635
+ * Resolve a chat's unresolved window on a SUCCESSFUL pull — but ONLY if this
112636
+ * run's `coveredFrom` (its lark-cli `--start`) actually reached back to at or
112637
+ * before the tracked windowStart. If the clamp kept `coveredFrom` NEWER than the
112638
+ * tracked window, the old window was NOT really covered → keep it queued so a
112639
+ * later, wider replay can reach it (BLOCKER 2).
112640
+ */
112641
+ resolveUnresolvedGapWindow(chatId, coveredFrom) {
112642
+ const tracked = this.unresolvedGapWindowByChat.get(chatId);
112643
+ if (tracked === void 0) return;
112644
+ if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
112645
+ }
112495
112646
  startOpenChatDiscovery(log) {
112496
112647
  if (this.opts.allowedChatIds.size > 0) return;
112497
112648
  if (this.openChatDiscoveryTimer) return;
package/dist/main.js CHANGED
@@ -15450,7 +15450,7 @@ var require_object_inspect = __commonJS({
15450
15450
  } else if (indexOf(seen, obj) >= 0) {
15451
15451
  return "[Circular]";
15452
15452
  }
15453
- function inspect(value, from, noIndent) {
15453
+ function inspect2(value, from, noIndent) {
15454
15454
  if (from) {
15455
15455
  seen = $arrSlice.call(seen);
15456
15456
  seen.push(from);
@@ -15468,7 +15468,7 @@ var require_object_inspect = __commonJS({
15468
15468
  }
15469
15469
  if (typeof obj === "function" && !isRegExp(obj)) {
15470
15470
  var name = nameOf(obj);
15471
- var keys = arrObjKeys(obj, inspect);
15471
+ var keys = arrObjKeys(obj, inspect2);
15472
15472
  return "[Function" + (name ? ": " + name : " (anonymous)") + "]" + (keys.length > 0 ? " { " + $join.call(keys, ", ") + " }" : "");
15473
15473
  }
15474
15474
  if (isSymbol(obj)) {
@@ -15492,16 +15492,16 @@ var require_object_inspect = __commonJS({
15492
15492
  if (obj.length === 0) {
15493
15493
  return "[]";
15494
15494
  }
15495
- var xs = arrObjKeys(obj, inspect);
15495
+ var xs = arrObjKeys(obj, inspect2);
15496
15496
  if (indent && !singleLineValues(xs)) {
15497
15497
  return "[" + indentedJoin(xs, indent) + "]";
15498
15498
  }
15499
15499
  return "[ " + $join.call(xs, ", ") + " ]";
15500
15500
  }
15501
15501
  if (isError(obj)) {
15502
- var parts = arrObjKeys(obj, inspect);
15502
+ var parts = arrObjKeys(obj, inspect2);
15503
15503
  if (!("cause" in Error.prototype) && "cause" in obj && !isEnumerable.call(obj, "cause")) {
15504
- return "{ [" + String(obj) + "] " + $join.call($concat.call("[cause]: " + inspect(obj.cause), parts), ", ") + " }";
15504
+ return "{ [" + String(obj) + "] " + $join.call($concat.call("[cause]: " + inspect2(obj.cause), parts), ", ") + " }";
15505
15505
  }
15506
15506
  if (parts.length === 0) {
15507
15507
  return "[" + String(obj) + "]";
@@ -15519,7 +15519,7 @@ var require_object_inspect = __commonJS({
15519
15519
  var mapParts = [];
15520
15520
  if (mapForEach) {
15521
15521
  mapForEach.call(obj, function(value, key) {
15522
- mapParts.push(inspect(key, obj, true) + " => " + inspect(value, obj));
15522
+ mapParts.push(inspect2(key, obj, true) + " => " + inspect2(value, obj));
15523
15523
  });
15524
15524
  }
15525
15525
  return collectionOf("Map", mapSize.call(obj), mapParts, indent);
@@ -15528,7 +15528,7 @@ var require_object_inspect = __commonJS({
15528
15528
  var setParts = [];
15529
15529
  if (setForEach) {
15530
15530
  setForEach.call(obj, function(value) {
15531
- setParts.push(inspect(value, obj));
15531
+ setParts.push(inspect2(value, obj));
15532
15532
  });
15533
15533
  }
15534
15534
  return collectionOf("Set", setSize.call(obj), setParts, indent);
@@ -15543,16 +15543,16 @@ var require_object_inspect = __commonJS({
15543
15543
  return weakCollectionOf("WeakRef");
15544
15544
  }
15545
15545
  if (isNumber(obj)) {
15546
- return markBoxed(inspect(Number(obj)));
15546
+ return markBoxed(inspect2(Number(obj)));
15547
15547
  }
15548
15548
  if (isBigInt(obj)) {
15549
- return markBoxed(inspect(bigIntValueOf.call(obj)));
15549
+ return markBoxed(inspect2(bigIntValueOf.call(obj)));
15550
15550
  }
15551
15551
  if (isBoolean2(obj)) {
15552
15552
  return markBoxed(booleanValueOf.call(obj));
15553
15553
  }
15554
15554
  if (isString(obj)) {
15555
- return markBoxed(inspect(String(obj)));
15555
+ return markBoxed(inspect2(String(obj)));
15556
15556
  }
15557
15557
  if (typeof window !== "undefined" && obj === window) {
15558
15558
  return "{ [object Window] }";
@@ -15561,7 +15561,7 @@ var require_object_inspect = __commonJS({
15561
15561
  return "{ [object globalThis] }";
15562
15562
  }
15563
15563
  if (!isDate(obj) && !isRegExp(obj)) {
15564
- var ys = arrObjKeys(obj, inspect);
15564
+ var ys = arrObjKeys(obj, inspect2);
15565
15565
  var isPlainObject = gPO ? gPO(obj) === Object.prototype : obj instanceof Object || obj.constructor === Object;
15566
15566
  var protoTag = obj instanceof Object ? "" : "null prototype";
15567
15567
  var stringTag = !isPlainObject && toStringTag && Object(obj) === obj && toStringTag in obj ? $slice.call(toStr(obj), 8, -1) : protoTag ? "Object" : "";
@@ -15815,13 +15815,13 @@ var require_object_inspect = __commonJS({
15815
15815
  var lineJoiner = "\n" + indent.prev + indent.base;
15816
15816
  return lineJoiner + $join.call(xs, "," + lineJoiner) + "\n" + indent.prev;
15817
15817
  }
15818
- function arrObjKeys(obj, inspect) {
15818
+ function arrObjKeys(obj, inspect2) {
15819
15819
  var isArr = isArray(obj);
15820
15820
  var xs = [];
15821
15821
  if (isArr) {
15822
15822
  xs.length = obj.length;
15823
15823
  for (var i = 0; i < obj.length; i++) {
15824
- xs[i] = has(obj, i) ? inspect(obj[i], obj) : "";
15824
+ xs[i] = has(obj, i) ? inspect2(obj[i], obj) : "";
15825
15825
  }
15826
15826
  }
15827
15827
  var syms = typeof gOPS === "function" ? gOPS(obj) : [];
@@ -15842,15 +15842,15 @@ var require_object_inspect = __commonJS({
15842
15842
  if (hasShammedSymbols && symMap["$" + key] instanceof Symbol) {
15843
15843
  continue;
15844
15844
  } else if ($test.call(/[^\w$]/, key)) {
15845
- xs.push(inspect(key, obj) + ": " + inspect(obj[key], obj));
15845
+ xs.push(inspect2(key, obj) + ": " + inspect2(obj[key], obj));
15846
15846
  } else {
15847
- xs.push(key + ": " + inspect(obj[key], obj));
15847
+ xs.push(key + ": " + inspect2(obj[key], obj));
15848
15848
  }
15849
15849
  }
15850
15850
  if (typeof gOPS === "function") {
15851
15851
  for (var j = 0; j < syms.length; j++) {
15852
15852
  if (isEnumerable.call(obj, syms[j])) {
15853
- xs.push("[" + inspect(syms[j]) + "]: " + inspect(obj[syms[j]], obj));
15853
+ xs.push("[" + inspect2(syms[j]) + "]: " + inspect2(obj[syms[j]], obj));
15854
15854
  }
15855
15855
  }
15856
15856
  }
@@ -15863,7 +15863,7 @@ var require_object_inspect = __commonJS({
15863
15863
  var require_side_channel_list = __commonJS({
15864
15864
  "node_modules/.pnpm/side-channel-list@1.0.1/node_modules/side-channel-list/index.js"(exports, module) {
15865
15865
  "use strict";
15866
- var inspect = require_object_inspect();
15866
+ var inspect2 = require_object_inspect();
15867
15867
  var $TypeError = require_type();
15868
15868
  var listGetNode = function(list, key, isDelete) {
15869
15869
  var prev = list;
@@ -15917,7 +15917,7 @@ var require_side_channel_list = __commonJS({
15917
15917
  var channel = {
15918
15918
  assert: function(key) {
15919
15919
  if (!channel.has(key)) {
15920
- throw new $TypeError("Side channel does not contain " + inspect(key));
15920
+ throw new $TypeError("Side channel does not contain " + inspect2(key));
15921
15921
  }
15922
15922
  },
15923
15923
  "delete": function(key) {
@@ -15981,7 +15981,7 @@ var require_side_channel_map = __commonJS({
15981
15981
  "use strict";
15982
15982
  var GetIntrinsic = require_get_intrinsic();
15983
15983
  var callBound = require_call_bound();
15984
- var inspect = require_object_inspect();
15984
+ var inspect2 = require_object_inspect();
15985
15985
  var $TypeError = require_type();
15986
15986
  var $Map = GetIntrinsic("%Map%", true);
15987
15987
  var $mapGet = callBound("Map.prototype.get", true);
@@ -15995,7 +15995,7 @@ var require_side_channel_map = __commonJS({
15995
15995
  var channel = {
15996
15996
  assert: function(key) {
15997
15997
  if (!channel.has(key)) {
15998
- throw new $TypeError("Side channel does not contain " + inspect(key));
15998
+ throw new $TypeError("Side channel does not contain " + inspect2(key));
15999
15999
  }
16000
16000
  },
16001
16001
  "delete": function(key) {
@@ -16037,7 +16037,7 @@ var require_side_channel_weakmap = __commonJS({
16037
16037
  "use strict";
16038
16038
  var GetIntrinsic = require_get_intrinsic();
16039
16039
  var callBound = require_call_bound();
16040
- var inspect = require_object_inspect();
16040
+ var inspect2 = require_object_inspect();
16041
16041
  var getSideChannelMap = require_side_channel_map();
16042
16042
  var $TypeError = require_type();
16043
16043
  var $WeakMap = GetIntrinsic("%WeakMap%", true);
@@ -16053,7 +16053,7 @@ var require_side_channel_weakmap = __commonJS({
16053
16053
  var channel = {
16054
16054
  assert: function(key) {
16055
16055
  if (!channel.has(key)) {
16056
- throw new $TypeError("Side channel does not contain " + inspect(key));
16056
+ throw new $TypeError("Side channel does not contain " + inspect2(key));
16057
16057
  }
16058
16058
  },
16059
16059
  "delete": function(key) {
@@ -16109,7 +16109,7 @@ var require_side_channel = __commonJS({
16109
16109
  "node_modules/.pnpm/side-channel@1.1.0/node_modules/side-channel/index.js"(exports, module) {
16110
16110
  "use strict";
16111
16111
  var $TypeError = require_type();
16112
- var inspect = require_object_inspect();
16112
+ var inspect2 = require_object_inspect();
16113
16113
  var getSideChannelList = require_side_channel_list();
16114
16114
  var getSideChannelMap = require_side_channel_map();
16115
16115
  var getSideChannelWeakMap = require_side_channel_weakmap();
@@ -16119,7 +16119,7 @@ var require_side_channel = __commonJS({
16119
16119
  var channel = {
16120
16120
  assert: function(key) {
16121
16121
  if (!channel.has(key)) {
16122
- throw new $TypeError("Side channel does not contain " + inspect(key));
16122
+ throw new $TypeError("Side channel does not contain " + inspect2(key));
16123
16123
  }
16124
16124
  },
16125
16125
  "delete": function(key) {
@@ -116182,6 +116182,10 @@ var MAX_MESSAGE_ATTEMPTS = 5;
116182
116182
  var OPEN_CHAT_DISCOVERY_LOOKBACK_MS = 9e4;
116183
116183
  var OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS = 30 * 60 * 1e3;
116184
116184
  var PROCESSING_REACTION_EMOJI = "Typing";
116185
+ var GAP_FILL_MAX_ATTEMPTS = 3;
116186
+ var GAP_FILL_BACKOFF_BASE_MS = 1e3;
116187
+ var UNRESOLVED_WINDOW_MAX_CHATS = 50;
116188
+ var UNRESOLVED_WINDOW_MAX_AGE_MS = 30 * 60 * 1e3;
116185
116189
  function stripAtMarkup(s) {
116186
116190
  return s.replace(/<at\b[^>]*>.*?<\/at>/gi, "").replace(/<at\b[^>]*\/>/gi, "").trim();
116187
116191
  }
@@ -116382,6 +116386,27 @@ var ChannelClient = class {
116382
116386
  * search space.
116383
116387
  */
116384
116388
  recentlySeenChatIds = /* @__PURE__ */ new Set();
116389
+ /**
116390
+ * PER-CHAT unresolved gapFill windows: chatId → the OLDEST windowStart (ms) for
116391
+ * which that chat's lark-cli history pull still failed after all retries. On a
116392
+ * later gapFill that ACTUALLY pulls this chat, we extend the look-back to cover
116393
+ * its oldest unresolved windowStart and only clear it once the pull truly
116394
+ * reached back that far (its `--start` <= the tracked windowStart). Per-chat (not
116395
+ * a single shared list) so a successful run over chat-set {B} can never falsely
116396
+ * resolve chat A's window (BLOCKER 1), and the look-back-vs-clamp mismatch can
116397
+ * never mark a window resolved before it was reached (BLOCKER 2).
116398
+ *
116399
+ * Bounded: one timestamp per chat (so naturally bounded by #chats), pruned by age
116400
+ * (UNRESOLVED_WINDOW_MAX_AGE_MS — older = unrecoverable from history anyway) and
116401
+ * capped at UNRESOLVED_WINDOW_MAX_CHATS tracked chats.
116402
+ */
116403
+ unresolvedGapWindowByChat = /* @__PURE__ */ new Map();
116404
+ /**
116405
+ * Backoff sleep used by the per-chat history-pull retry. Indirected through a
116406
+ * field purely so tests can observe/await the backoff deterministically; in
116407
+ * production it is the real timer-based {@link sleep}.
116408
+ */
116409
+ gapFillSleep = sleep;
116385
116410
  openChatDiscoveryTimer = null;
116386
116411
  openChatDiscoveryRunning = false;
116387
116412
  openChatDiscoveryBootstrapped = false;
@@ -116403,6 +116428,28 @@ var ChannelClient = class {
116403
116428
  }
116404
116429
  this.opts = opts;
116405
116430
  }
116431
+ /**
116432
+ * TEST SEAM (deletable): override the gap-fill retry backoff sleep so unit
116433
+ * tests can observe the backoff durations and avoid real timers. No-op for
116434
+ * production — the default is the real {@link sleep}. Returns the recorded
116435
+ * backoff arg via the provided callback's own bookkeeping.
116436
+ */
116437
+ setGapFillSleepForTest(fn) {
116438
+ this.gapFillSleep = fn;
116439
+ }
116440
+ /** TEST-ONLY read of the per-chat unresolved-window replay map (chatId → windowStart). */
116441
+ unresolvedGapWindowsForTest() {
116442
+ return new Map(this.unresolvedGapWindowByChat);
116443
+ }
116444
+ /**
116445
+ * TEST-ONLY direct gapFill invocation with an explicit chat-set override —
116446
+ * mirrors exactly how open-chat discovery calls gapFill on a SUBSET of chats.
116447
+ * Used to reproduce the cross-chat-set replay isolation (BLOCKER 1) without
116448
+ * standing up the full discovery timer.
116449
+ */
116450
+ async gapFillForTest(disconnectAt, chatIds) {
116451
+ await this.gapFill(disconnectAt, (s) => console.log(`[channel.client] ${s}`), chatIds);
116452
+ }
116406
116453
  /**
116407
116454
  * Async iterator over inbound events — interface-compatible with LarkClient.
116408
116455
  * Connects the WS on first call. The SDK's policy gate (requireMention +
@@ -116468,7 +116515,25 @@ var ChannelClient = class {
116468
116515
  // ── WS robustness knobs (node-sdk ≥1.64; all OFF by default) ──────────
116469
116516
  // Abort a handshake that hangs on a stuck DNS/proxy/NAT path so the retry
116470
116517
  // loop can try again, instead of waiting indefinitely. Successful TLS
116471
- // handshakes are tens of ms; 15s is a wide safety margin.
116518
+ // handshakes are tens of ms; 15s is a wide safety margin. KEPT ON: it is
116519
+ // the right behaviour (abort + reconnect beats hanging forever).
116520
+ //
116521
+ // CAVEAT — raw '_WebSocket' 'error' on abort: when this timeout fires the
116522
+ // SDK aborts the underlying ws, which emits a RAW 'error' event on the
116523
+ // socket. That socket is owned privately inside node-sdk's WSClient
116524
+ // (no public `on()`, no accessor for the raw ws — verified against
116525
+ // node-sdk 1.67.0 types: WSClient is not an EventEmitter and keeps the
116526
+ // `_WebSocket` in a closure), so we CANNOT attach a precise 'error'
116527
+ // listener here. With no listener, Node re-throws it as an
116528
+ // uncaughtException → it would kill the whole (multi-bot) process.
116529
+ // → That raw error is instead caught by the process-level crash guard
116530
+ // in main.ts (registerCrashGuard: uncaughtException handler that logs
116531
+ // and never exits). The channel-level `channel.on("error", …)` below
116532
+ // is a DIFFERENT, higher-level error and does NOT cover this raw case.
116533
+ // → Residual uncertainty (left for acceptance load-testing): that the
116534
+ // process guard reliably catches this specific raw abort path under
116535
+ // real network flap. If a future node-sdk / @larksuite/channel exposes
116536
+ // the ws or attaches its own listener, prefer that and drop the guard.
116472
116537
  handshakeTimeoutMs: 15e3,
116473
116538
  // Liveness watchdog (SECONDS): if no inbound frame arrives within this
116474
116539
  // window after the last ping, treat the socket as dead and reconnect —
@@ -116585,9 +116650,6 @@ var ChannelClient = class {
116585
116650
  const MAX_GAP_FILL_WINDOW_MS = 5 * 60 * 1e3;
116586
116651
  const BUFFER_MS = 3e4;
116587
116652
  const now = Date.now();
116588
- const windowStart = Math.max(disconnectAt - BUFFER_MS, now - MAX_GAP_FILL_WINDOW_MS);
116589
- const startIso = new Date(windowStart).toISOString();
116590
- const endIso = new Date(now + BUFFER_MS).toISOString();
116591
116653
  const larkCli = this.opts.larkCliPath ?? "lark-cli";
116592
116654
  const profileArgs = this.opts.larkCliProfile ? ["--profile", this.opts.larkCliProfile] : [];
116593
116655
  const botOpenId = this.opts.botOpenId;
@@ -116597,16 +116659,29 @@ var ChannelClient = class {
116597
116659
  ...this.opts.allowedChatIds,
116598
116660
  ...this.recentlySeenChatIds
116599
116661
  ]);
116662
+ this.pruneUnresolvedGapWindows(now);
116663
+ let oldestRelevantUnresolved = Infinity;
116664
+ for (const chatId of gapFillChatIds) {
116665
+ const ws = this.unresolvedGapWindowByChat.get(chatId);
116666
+ if (ws !== void 0 && ws < oldestRelevantUnresolved) oldestRelevantUnresolved = ws;
116667
+ }
116668
+ const hasReplay = oldestRelevantUnresolved !== Infinity;
116669
+ const lookBackFrom = Math.min(disconnectAt, hasReplay ? oldestRelevantUnresolved : disconnectAt);
116670
+ const clampCeilingMs = hasReplay ? UNRESOLVED_WINDOW_MAX_AGE_MS : MAX_GAP_FILL_WINDOW_MS;
116671
+ const windowStart = Math.max(lookBackFrom - BUFFER_MS, now - clampCeilingMs);
116672
+ const startIso = new Date(windowStart).toISOString();
116673
+ const endIso = new Date(now + BUFFER_MS).toISOString();
116600
116674
  if (gapFillChatIds.size === 0) {
116601
116675
  log(
116602
116676
  `gap-fill skipped: no known chats for window=${startIso}..${endIso} (allowedChatIds is empty and no live chat has been seen yet)`
116603
116677
  );
116604
116678
  return;
116605
116679
  }
116680
+ let anyChatFailed = false;
116606
116681
  for (const chatId of gapFillChatIds) {
116607
116682
  if (this.closed) break;
116608
116683
  try {
116609
- const { stdout } = await execFile(larkCli, [
116684
+ const args = [
116610
116685
  "im",
116611
116686
  "+chat-messages-list",
116612
116687
  "--as",
@@ -116625,7 +116700,8 @@ var ChannelClient = class {
116625
116700
  "json",
116626
116701
  "--no-reactions",
116627
116702
  ...profileArgs
116628
- ]);
116703
+ ];
116704
+ const { stdout } = await this.execWithRetry(larkCli, args, chatId, log);
116629
116705
  let messages;
116630
116706
  try {
116631
116707
  messages = parseLarkCliMessages(stdout) ?? [];
@@ -116693,16 +116769,91 @@ var ChannelClient = class {
116693
116769
  this.queue.push(ev);
116694
116770
  totalDispatched++;
116695
116771
  }
116772
+ this.resolveUnresolvedGapWindow(chatId, windowStart);
116696
116773
  } catch (e) {
116774
+ anyChatFailed = true;
116775
+ this.recordUnresolvedGapWindow(chatId, windowStart, log);
116697
116776
  log(
116698
- `gap-fill: lark-cli failed for chat ${chatId}: ` + (e instanceof Error ? e.message : String(e))
116777
+ `gap-fill: lark-cli failed for chat ${chatId} after ${GAP_FILL_MAX_ATTEMPTS} attempt(s): ` + (e instanceof Error ? e.message : String(e))
116699
116778
  );
116700
116779
  }
116701
116780
  }
116702
116781
  log(
116703
- `gap-fill complete: window=${startIso}..${endIso}, fetched=${totalFetched}, dispatched=${totalDispatched}`
116782
+ `gap-fill complete: window=${startIso}..${endIso}, fetched=${totalFetched}, dispatched=${totalDispatched}` + (anyChatFailed ? ` (some chats failed \u2014 per-chat windows queued for replay)` : ``)
116783
+ );
116784
+ }
116785
+ /**
116786
+ * Run a lark-cli history pull with bounded retries + exponential backoff.
116787
+ * Retries on ANY thrown error (transient TLS timeout being the motivating case),
116788
+ * up to {@link GAP_FILL_MAX_ATTEMPTS}. Backoff is GAP_FILL_BACKOFF_BASE_MS *
116789
+ * 2^(attempt-1) (~1s / 2s). Re-throws the last error if all attempts fail so the
116790
+ * caller can flag the window for replay. Backoff goes through {@link gapFillSleep}
116791
+ * (injectable) so tests can observe it deterministically.
116792
+ */
116793
+ async execWithRetry(larkCli, args, chatId, log) {
116794
+ let lastErr;
116795
+ for (let attempt = 1; attempt <= GAP_FILL_MAX_ATTEMPTS; attempt++) {
116796
+ if (this.closed) throw lastErr ?? new Error("closed");
116797
+ try {
116798
+ return await execFile(larkCli, args);
116799
+ } catch (e) {
116800
+ lastErr = e;
116801
+ if (attempt < GAP_FILL_MAX_ATTEMPTS) {
116802
+ const backoffMs = GAP_FILL_BACKOFF_BASE_MS * 2 ** (attempt - 1);
116803
+ log(
116804
+ `gap-fill: lark-cli pull failed for chat ${chatId} (attempt ${attempt}/${GAP_FILL_MAX_ATTEMPTS}) \u2014 retrying in ${backoffMs}ms: ` + (e instanceof Error ? e.message : String(e))
116805
+ );
116806
+ await this.gapFillSleep(backoffMs);
116807
+ }
116808
+ }
116809
+ }
116810
+ throw lastErr;
116811
+ }
116812
+ /** Drop per-chat unresolved windows older than the replay max age (unrecoverable). */
116813
+ pruneUnresolvedGapWindows(now) {
116814
+ const cutoff = now - UNRESOLVED_WINDOW_MAX_AGE_MS;
116815
+ for (const [chatId, windowStart] of this.unresolvedGapWindowByChat) {
116816
+ if (windowStart < cutoff) this.unresolvedGapWindowByChat.delete(chatId);
116817
+ }
116818
+ }
116819
+ /**
116820
+ * Record (or keep the OLDEST) unresolved window for a chat whose pull failed.
116821
+ * Bounded by chat count: if the map is at capacity and this is a new chat, we
116822
+ * evict the chat with the NEWEST window (least at risk of aging out) so the
116823
+ * oldest at-risk windows survive to be replayed first.
116824
+ */
116825
+ recordUnresolvedGapWindow(chatId, windowStart, log) {
116826
+ const existing = this.unresolvedGapWindowByChat.get(chatId);
116827
+ if (existing !== void 0 && existing <= windowStart) return;
116828
+ if (existing === void 0 && this.unresolvedGapWindowByChat.size >= UNRESOLVED_WINDOW_MAX_CHATS) {
116829
+ let newestChat = null;
116830
+ let newestWs = -Infinity;
116831
+ for (const [c, ws] of this.unresolvedGapWindowByChat) {
116832
+ if (ws > newestWs) {
116833
+ newestWs = ws;
116834
+ newestChat = c;
116835
+ }
116836
+ }
116837
+ if (newestChat !== null && newestWs > windowStart) this.unresolvedGapWindowByChat.delete(newestChat);
116838
+ else if (newestChat !== null) return;
116839
+ }
116840
+ this.unresolvedGapWindowByChat.set(chatId, windowStart);
116841
+ log(
116842
+ `gap-fill: queued unresolved window for chat ${chatId} start=${new Date(windowStart).toISOString()} (tracked chats=${this.unresolvedGapWindowByChat.size})`
116704
116843
  );
116705
116844
  }
116845
+ /**
116846
+ * Resolve a chat's unresolved window on a SUCCESSFUL pull — but ONLY if this
116847
+ * run's `coveredFrom` (its lark-cli `--start`) actually reached back to at or
116848
+ * before the tracked windowStart. If the clamp kept `coveredFrom` NEWER than the
116849
+ * tracked window, the old window was NOT really covered → keep it queued so a
116850
+ * later, wider replay can reach it (BLOCKER 2).
116851
+ */
116852
+ resolveUnresolvedGapWindow(chatId, coveredFrom) {
116853
+ const tracked = this.unresolvedGapWindowByChat.get(chatId);
116854
+ if (tracked === void 0) return;
116855
+ if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
116856
+ }
116706
116857
  startOpenChatDiscovery(log) {
116707
116858
  if (this.opts.allowedChatIds.size > 0) return;
116708
116859
  if (this.openChatDiscoveryTimer) return;
@@ -116989,6 +117140,7 @@ function summarizeInput(input) {
116989
117140
  }
116990
117141
  return s.length > 60 ? s.slice(0, 57) + "\u2026" : s;
116991
117142
  }
117143
+ var MAX_CARD_ELEMENTS = 50;
116992
117144
  var CHOICE_MARKERS = ["A", "B", "C", "D", "E"];
116993
117145
  function choiceMarker(i) {
116994
117146
  return CHOICE_MARKERS[i] ?? String(i + 1);
@@ -117014,6 +117166,65 @@ function buildChoiceRow(choices) {
117014
117166
  function buildChoiceLegend(choices) {
117015
117167
  return choices.map((c, i) => `**${choiceMarker(i)}.** ${c.label}`).join("\n");
117016
117168
  }
117169
+ function plainText(content) {
117170
+ return { tag: "plain_text", content };
117171
+ }
117172
+ function buildImageElement(block) {
117173
+ const element = {
117174
+ tag: "img",
117175
+ img_key: block.img_key,
117176
+ alt: plainText(block.alt || "\u56FE\u7247\u9884\u89C8"),
117177
+ scale_type: block.mode,
117178
+ preview: block.preview
117179
+ };
117180
+ if (block.title) {
117181
+ element["title"] = plainText(block.title);
117182
+ }
117183
+ return element;
117184
+ }
117185
+ function canPushElement(elements, reservedTail = 0) {
117186
+ return elements.length + reservedTail < MAX_CARD_ELEMENTS;
117187
+ }
117188
+ function pushElement(elements, element, reservedTail = 0) {
117189
+ if (!canPushElement(elements, reservedTail)) return false;
117190
+ elements.push(element);
117191
+ return true;
117192
+ }
117193
+ function pushMarkdownElements(elements, text, reservedTail = 0) {
117194
+ const chunks = chunkMarkdown(text);
117195
+ let truncated = false;
117196
+ for (let i = 0; i < chunks.length; i++) {
117197
+ const content = i === 0 ? chunks[i] : `(\u7EED ${i + 1})
117198
+ ${chunks[i]}`;
117199
+ if (!pushElement(elements, { tag: "markdown", content }, reservedTail)) {
117200
+ truncated = true;
117201
+ break;
117202
+ }
117203
+ }
117204
+ if (truncated && canPushElement(elements, reservedTail)) {
117205
+ pushElement(elements, {
117206
+ tag: "markdown",
117207
+ content: "_\u5185\u5BB9\u8FC7\u957F,\u540E\u7EED\u90E8\u5206\u5DF2\u7701\u7565_"
117208
+ }, reservedTail);
117209
+ }
117210
+ }
117211
+ function buildContentBlockElements(blocks, opts = {}) {
117212
+ const elements = [];
117213
+ const mentionPrefix = atMentionMarkdown(opts.mentionOpenId);
117214
+ const reservedTail = opts.reservedTail ?? 0;
117215
+ if (mentionPrefix) {
117216
+ pushMarkdownElements(elements, mentionPrefix, reservedTail);
117217
+ }
117218
+ for (const block of blocks) {
117219
+ if (block.type === "markdown") {
117220
+ const clean = stripLeakedToolMarkup(block.content);
117221
+ if (clean) pushMarkdownElements(elements, clean, reservedTail);
117222
+ continue;
117223
+ }
117224
+ pushElement(elements, buildImageElement(block), reservedTail);
117225
+ }
117226
+ return elements;
117227
+ }
117017
117228
  function stripLeakedToolMarkup(text) {
117018
117229
  return text.replace(/<function_calls>[\s\S]*?<\/function_calls>/gi, "").replace(/<invoke\b[^>]*>[\s\S]*?<\/invoke>/gi, "").replace(/<(?:function_calls|invoke|parameter)\b[\s\S]*$/i, "").replace(/<\/?(?:function_calls|invoke|parameter)\b[^>]*>/gi, "").replace(/\n{3,}/g, "\n\n").trim();
117019
117230
  }
@@ -117046,6 +117257,11 @@ function chunkMarkdown(text, maxLen = 2800) {
117046
117257
  }
117047
117258
  function buildCardJson(opts) {
117048
117259
  const elements = [];
117260
+ const hasContentBlocks = !!opts.contentBlocks?.length;
117261
+ const failureReserve = opts.status === "failure" && opts.failureReason ? 2 : 0;
117262
+ const choicesReserve = opts.choices && opts.choices.length ? 4 : 0;
117263
+ const legacyImagesReserve = !hasContentBlocks && opts.imageBlocks?.length ? opts.imageBlocks.length + 1 : 0;
117264
+ const tailReserve = failureReserve + choicesReserve + legacyImagesReserve;
117049
117265
  const TOOL_LINES_CAP = 1;
117050
117266
  if (opts.showToolSummary && opts.toolLines.length > 0 && !opts.hideTools && opts.status !== "success") {
117051
117267
  const total = opts.toolLines.length;
@@ -117053,44 +117269,52 @@ function buildCardJson(opts) {
117053
117269
  const omitted = total - recent.length;
117054
117270
  const content = omitted > 0 ? `_(\u7565\u524D ${omitted} \u6761\u5DE5\u5177\u8C03\u7528)_
117055
117271
  ${recent.join("\n")}` : recent.join("\n");
117056
- elements.push({
117272
+ pushElement(elements, {
117057
117273
  tag: "markdown",
117058
117274
  content
117059
117275
  });
117060
- elements.push({ tag: "hr" });
117276
+ pushElement(elements, { tag: "hr" });
117061
117277
  }
117062
117278
  const mentionPrefix = atMentionMarkdown(opts.mentionOpenId);
117063
117279
  const cleanBody = opts.bodyText ? stripLeakedToolMarkup(opts.bodyText) : "";
117064
117280
  const bodyWithMention = [mentionPrefix, cleanBody].filter(Boolean).join("\n\n");
117065
- if (bodyWithMention) {
117066
- const chunks = chunkMarkdown(bodyWithMention);
117067
- for (let i = 0; i < chunks.length; i++) {
117068
- elements.push({
117069
- tag: "markdown",
117070
- content: i === 0 ? chunks[i] : `(\u7EED ${i + 1})
117071
- ${chunks[i]}`
117072
- });
117073
- }
117281
+ if (hasContentBlocks) {
117282
+ elements.push(
117283
+ ...buildContentBlockElements(opts.contentBlocks ?? [], {
117284
+ mentionOpenId: opts.mentionOpenId,
117285
+ reservedTail: tailReserve
117286
+ })
117287
+ );
117288
+ } else if (bodyWithMention) {
117289
+ pushMarkdownElements(elements, bodyWithMention, tailReserve);
117074
117290
  } else if (opts.status === "thinking") {
117075
- elements.push({
117291
+ pushElement(elements, {
117076
117292
  tag: "markdown",
117077
117293
  content: "\u{1F914} \u601D\u8003\u4E2D\u2026"
117078
117294
  });
117079
117295
  }
117080
117296
  if (opts.status === "failure" && opts.failureReason) {
117081
- elements.push({ tag: "hr" });
117082
- elements.push({
117297
+ pushElement(elements, { tag: "hr" }, choicesReserve);
117298
+ pushElement(elements, {
117083
117299
  tag: "markdown",
117084
117300
  content: `\u26A0\uFE0F **\u9519\u8BEF**: ${opts.failureReason}`
117085
- });
117301
+ }, choicesReserve);
117302
+ }
117303
+ if (!hasContentBlocks && opts.imageBlocks && opts.imageBlocks.length) {
117304
+ if (bodyWithMention || opts.status === "failure" && opts.failureReason) {
117305
+ pushElement(elements, { tag: "hr" }, choicesReserve);
117306
+ }
117307
+ for (const block of opts.imageBlocks) {
117308
+ pushElement(elements, buildImageElement(block), choicesReserve);
117309
+ }
117086
117310
  }
117087
117311
  if (opts.choices && opts.choices.length) {
117088
- elements.push({ tag: "hr" });
117312
+ pushElement(elements, { tag: "hr" });
117089
117313
  if (opts.choicePrompt) {
117090
- elements.push({ tag: "markdown", content: opts.choicePrompt });
117314
+ pushElement(elements, { tag: "markdown", content: opts.choicePrompt });
117091
117315
  }
117092
- elements.push({ tag: "markdown", content: buildChoiceLegend(opts.choices) });
117093
- elements.push(buildChoiceRow(opts.choices));
117316
+ pushElement(elements, { tag: "markdown", content: buildChoiceLegend(opts.choices) });
117317
+ pushElement(elements, buildChoiceRow(opts.choices));
117094
117318
  }
117095
117319
  const headerColor = opts.status === "thinking" || opts.status === "streaming" ? "blue" : opts.status === "success" ? "green" : "red";
117096
117320
  const statusText = opts.status === "thinking" ? "\u23F3 \u5904\u7406\u4E2D" : opts.status === "streaming" ? "\u{1F527} \u5904\u7406\u4E2D" : opts.status === "success" ? "\u2705 \u5B8C\u6210" : "\u274C \u51FA\u9519\u4E86";
@@ -117165,6 +117389,8 @@ var CardHandleImpl = class {
117165
117389
  // V2 dynamic-choice buttons — agent-declared, only on finalize.
117166
117390
  choices: opts.choices,
117167
117391
  choicePrompt: opts.choicePrompt,
117392
+ imageBlocks: opts.imageBlocks,
117393
+ contentBlocks: opts.contentBlocks,
117168
117394
  titleOverride: opts.titleOverride,
117169
117395
  // Hide tool-use summary on every finalize — the agent finished, so the
117170
117396
  // Feishu card should present the result/error only. Diagnostics remain in
@@ -117877,6 +118103,9 @@ function renderStateContract(stateFilePath) {
117877
118103
  "- error: \u5931\u8D25\u539F\u56E0(\u642D\u914D status=failed)",
117878
118104
  "- card_title: \u5361\u7247\u6807\u9898\u8986\u76D6(\u53EF\u9009)",
117879
118105
  "- card_color: \u5361\u7247\u914D\u8272(\u53EF\u9009,success/failure/neutral,\u4E5F\u53EF\u76F4\u63A5\u5199 green/red/grey)",
118106
+ "- image_blocks: \u53EF\u9009\u56FE\u7247\u9884\u89C8\u5757\u6570\u7EC4,\u6700\u591A 4 \u4E2A\u3002\u6BCF\u9879 `{img_key, alt?, title?, mode?, preview?}`; `img_key` \u5FC5\u987B\u662F\u5DF2\u4E0A\u4F20/\u53EF\u7528\u4E8E\u5361\u7247\u7684 Feishu \u56FE\u7247 key,`alt` \u7701\u7565\u65F6 bridge \u9ED8\u8BA4\u201C\u56FE\u7247\u9884\u89C8\u201D,`mode` \u53EA\u5141\u8BB8 `crop_center`/`fit_horizontal` \u5E76\u6620\u5C04\u5230 Card JSON 2.0 `scale_type`,`preview` \u9ED8\u8BA4 true\u3002bridge \u4E0D\u8D1F\u8D23\u4E0B\u8F7D/\u4E0A\u4F20/\u9009\u62E9\u56FE\u7247;\u8FD9\u4E9B\u7531\u4F60\u7528 lark-cli \u7B49\u5DE5\u5177\u5148\u5B8C\u6210\u3002",
118107
+ '- content_blocks: \u53EF\u9009\u6709\u5E8F\u6B63\u6587\u5757\u6570\u7EC4,\u6700\u591A 12 \u4E2A block\u3001\u6700\u591A 4 \u4E2A image block\u3002\u53EA\u652F\u6301\u7A84 union:`{type:"markdown", content}` \u548C `{type:"image", img_key, alt?, title?, mode?, preview?}`;\u4E0D\u652F\u6301 raw card JSON\u3002\u7528\u4E8E\u6B63\u6587\u4E0E\u56FE\u7247\u4EA4\u9519\u6392\u7248,\u4F8B\u5982 markdown -> image -> markdown -> image\u3002\u82E5 `content_blocks` \u975E\u7A7A,bridge \u4EE5\u5B83\u4F5C\u4E3A\u4E3B\u6B63\u6587\u5E76\u5FFD\u7565 `last_message` + `image_blocks` \u7684\u6B63\u6587\u6E32\u67D3,\u907F\u514D\u91CD\u590D;\u82E5\u7701\u7565\u5219\u4FDD\u6301\u65E7 `last_message` + `image_blocks` \u884C\u4E3A\u3002',
118108
+ "- scheduled reply / daily social ops review card \u7B49\u9700\u8981\u201C\u5E73\u53F0\u6B63\u6587 + \u5339\u914D\u56FE\u7247\u201D\u540C\u6BB5\u76F8\u90BB\u5C55\u793A\u7684\u573A\u666F,\u5E94\u5148\u53D6\u5F97\u5404\u56FE\u7247 `img_key`,\u518D\u5199 `content_blocks` \u4E3A `\u5E73\u53F0 markdown -> \u5BF9\u5E94 image -> \u4E0B\u4E2A\u5E73\u53F0 markdown -> \u5BF9\u5E94 image`;\u4E0D\u8981\u7528\u5355\u72EC\u8BDD\u9898\u56FE\u7247\u6D88\u606F\u6216\u5C3E\u90E8 `image_blocks` \u4EE3\u66FF\u9A8C\u6536\u9762\u3002",
117880
118109
  "- dev_url / mr_url / \u5176\u4F59\u4E1A\u52A1\u5B57\u6BB5:\u81EA\u7531\u5199\u5165,bridge \u4E0D\u611F\u77E5\u5176\u4E1A\u52A1\u542B\u4E49;\u8981\u8BA9\u8FD0\u8425\u770B\u5230,\u8BF7\u5199\u8FDB last_message",
117881
118110
  "- updated_at: ISO 8601 timestamp",
117882
118111
  "",
@@ -117886,7 +118115,7 @@ function renderStateContract(stateFilePath) {
117886
118115
  "\u6302\u7740\u4E0D\u9000\u51FA = \u5361\u7247\u6C38\u8FDC\u5361\u5728\u300C\u5904\u7406\u4E2D\u300D)\u3002",
117887
118116
  "",
117888
118117
  "\u5361\u7247\u5C55\u793A\u539F\u5219:",
117889
- "- bridge \u53EF\u80FD\u5728\u8FD0\u884C\u4E2D\u5C55\u793A\u5DE5\u5177\u6458\u8981,\u8FD9\u662F\u901A\u7528\u8FDB\u5EA6\u4FE1\u53F7;\u6700\u7EC8\u6210\u529F\u5361\u7247\u4EE5\u4F60\u7684 last_message \u4E3A\u4E3B\u3002",
118118
+ "- bridge \u53EF\u80FD\u5728\u8FD0\u884C\u4E2D\u5C55\u793A\u5DE5\u5177\u6458\u8981,\u8FD9\u662F\u901A\u7528\u8FDB\u5EA6\u4FE1\u53F7;\u6700\u7EC8\u6210\u529F\u5361\u7247\u4EE5\u4F60\u7684 `content_blocks`(\u82E5\u975E\u7A7A)\u6216 `last_message` \u4E3A\u4E3B\u3002",
117890
118119
  "- \u4E0D\u8981\u4F9D\u8D56 bridge \u4ECE\u8F93\u51FA\u91CC\u89E3\u6790\u4E1A\u52A1\u9636\u6BB5\u3001MR\u3001\u9884\u89C8\u5730\u5740\u6216\u4E0B\u4E00\u6B65\u52A8\u4F5C;\u9700\u8981\u5C55\u793A\u7684\u5185\u5BB9\u4F60\u81EA\u5DF1\u5199\u8FDB last_message\u3002",
117891
118120
  "- \u4E0D\u8981\u6C42\u56FA\u5B9A\u683C\u5F0F\u3002\u6839\u636E\u4EFB\u52A1\u9009\u62E9\u6700\u6E05\u695A\u7684\u8868\u8FBE:\u77ED\u7ED3\u8BBA\u3001\u5206\u70B9\u3001\u8868\u683C\u3001\u94FE\u63A5\u3001\u4E0B\u4E00\u6B65\u95EE\u9898\u90FD\u53EF\u4EE5\u3002",
117892
118121
  "- \u5982\u679C\u672C\u8F6E\u6D89\u53CA repo / \u4EE3\u7801 / \u6587\u6863\u4FEE\u6539,last_message \u5E94\u5305\u542B\u8DB3\u591F\u8BA9\u8FD0\u8425\u9A8C\u6536\u7684\u8BC1\u636E,\u4F8B\u5982\u4F60\u5B9E\u9645\u4F7F\u7528\u7684 workspace/repo\u3001\u5173\u952E diff \u6216\u94FE\u63A5\u3001\u8FD0\u884C\u8FC7\u7684\u6D4B\u8BD5/\u68C0\u67E5\u547D\u4EE4\u548C\u7ED3\u679C\u3002\u5177\u4F53\u8BC1\u636E\u7531\u4EFB\u52A1\u51B3\u5B9A;dogfood E2E \u7684\u4E25\u683C\u6E05\u5355\u53EA\u5728 dogfood guide \u4E2D\u8981\u6C42\u3002",
@@ -117904,6 +118133,7 @@ function renderStateContract(stateFilePath) {
117904
118133
  " \u6240\u4EE5\u4F60**\u4E0D\u8981\u5728 `last_message` \u91CC\u518D\u624B\u52A8\u5217\u4E00\u904D\u9009\u9879**(\u4F1A\u91CD\u590D);\u6B63\u6587\u53EA\u5199\u95EE\u9898\u80CC\u666F,\u9009\u9879\u4EA4\u7ED9 bridge \u6E32\u67D3",
117905
118134
  "- \u628A `value` \u5199\u6210\u4E00\u53E5\u81EA\u63CF\u8FF0\u7684\u5B8C\u6574\u6307\u4EE4(\u5B83\u5C31\u662F\u4F60\u5C06\u6536\u5230\u7684\u4EFB\u52A1\u6587\u672C),\u4E0D\u8981\u5199\u6210 `optA` \u8FD9\u79CD\u4EE3\u53F7",
117906
118135
  "- \u6CA1\u6709\u9700\u8981\u9009\u7684\u5C31**\u7701\u7565** `choices`(\u5361\u7247\u4FDD\u6301\u5E72\u51C0,\u65E0\u6309\u94AE)",
118136
+ "- \u82E5\u540C\u65F6\u5199 `content_blocks` \u548C `choices`,bridge \u59CB\u7EC8\u628A choices \u6E32\u67D3\u5728\u6B63\u6587\u5185\u5BB9\u4E4B\u540E;\u82E5 `status=failed`,error \u4ECD\u4F1A\u663E\u793A,\u4E0D\u4F1A\u88AB content_blocks \u8986\u76D6\u3002",
117907
118137
  "",
117908
118138
  "\u5199 status=ready \u524D\u5FC5\u987B\u81EA\u5DF1\u7528\u4EE3\u7801\u9A8C\u8FC7(dev server \u7528 curl -I \u770B 200;\u6587\u4EF6 ls/test -f;\u547D\u4EE4\u770B exit code)\u3002bridge \u4E0D\u518D\u66FF\u4F60 probe,\u9A8C\u8BC1\u5B8C\u5168\u662F\u4F60\u7684\u8D23\u4EFB\u3002",
117909
118139
  "\u7B2C\u4E00\u4E2A\u5DE5\u5177\u5931\u8D25\u7ACB\u523B\u5199 status=failed + error,\u522B\u88F8\u5954\u649E\u591A\u4E2A\u5DE5\u5177\u5237\u6210\u8D85\u65F6;\u9700\u6C42\u6709\u6B67\u4E49\u5148\u505C\u4E0B\u5199 status=in_progress + last_message='\u7B49X\u786E\u8BA4',\u522B\u778E\u731C\u5C31\u5F00\u505A\u4E0D\u53EF\u9006\u52A8\u4F5C\u3002",
@@ -118681,6 +118911,48 @@ var optionalUrl = external_exports.preprocess(
118681
118911
  (v) => typeof v === "string" && v.trim() === "" ? void 0 : v,
118682
118912
  external_exports.string().optional()
118683
118913
  );
118914
+ var imageAlt = external_exports.preprocess(
118915
+ (v) => typeof v === "string" && v.trim() === "" ? void 0 : v,
118916
+ external_exports.string().trim().min(1).max(200).default("\u56FE\u7247\u9884\u89C8")
118917
+ );
118918
+ var imageTitle = external_exports.preprocess(
118919
+ (v) => typeof v === "string" && v.trim() === "" ? void 0 : v,
118920
+ external_exports.string().trim().min(1).max(200).optional()
118921
+ );
118922
+ var ImageBlockSchema = external_exports.object({
118923
+ img_key: external_exports.string().trim().min(1).max(256),
118924
+ alt: imageAlt,
118925
+ title: imageTitle,
118926
+ /**
118927
+ * Agent-facing mode maps directly to Feishu Card JSON 2.0 `scale_type`.
118928
+ * Keep the enum narrow until we have live-card evidence for more modes.
118929
+ */
118930
+ mode: external_exports.enum(["crop_center", "fit_horizontal"]).default("fit_horizontal"),
118931
+ preview: external_exports.boolean().default(true)
118932
+ });
118933
+ var ContentMarkdownBlockSchema = external_exports.object({
118934
+ type: external_exports.literal("markdown"),
118935
+ content: external_exports.string().trim().min(1).max(2e4)
118936
+ });
118937
+ var ContentImageBlockSchema = ImageBlockSchema.extend({
118938
+ type: external_exports.literal("image")
118939
+ });
118940
+ var ContentBlockSchema = external_exports.discriminatedUnion("type", [
118941
+ ContentMarkdownBlockSchema,
118942
+ ContentImageBlockSchema
118943
+ ]);
118944
+ var ContentBlocksSchema = external_exports.array(ContentBlockSchema).max(12).superRefine((blocks, ctx) => {
118945
+ const imageCount = blocks.filter((b) => b.type === "image").length;
118946
+ if (imageCount > 4) {
118947
+ ctx.addIssue({
118948
+ code: external_exports.ZodIssueCode.too_big,
118949
+ type: "array",
118950
+ maximum: 4,
118951
+ inclusive: true,
118952
+ message: "content_blocks supports at most 4 image blocks"
118953
+ });
118954
+ }
118955
+ });
118684
118956
  var StateFileSchema = external_exports.object({
118685
118957
  // Thin channel: the bridge only validates `status`. Any other key the bot
118686
118958
  // writes (incl. a legacy `stage`) is a business field — z.object STRIPS
@@ -118757,6 +119029,20 @@ var StateFileSchema = external_exports.object({
118757
119029
  * V1 bots never write this field → V1 cards are byte-for-byte unchanged.
118758
119030
  */
118759
119031
  choices: external_exports.array(external_exports.object({ label: external_exports.string().min(1), value: external_exports.string().min(1) })).max(5).optional(),
119032
+ /**
119033
+ * V2 image blocks (thin-channel). Agents upload or otherwise obtain `img_key`
119034
+ * themselves, then declare generic image previews here. Bridge only renders the
119035
+ * already-declared keys into Feishu Card JSON 2.0 image elements; it does not
119036
+ * download, upload, choose assets, or interpret platform workflows.
119037
+ */
119038
+ image_blocks: external_exports.array(ImageBlockSchema).max(4).optional(),
119039
+ /**
119040
+ * V2 ordered card body blocks (thin-channel). When present and non-empty,
119041
+ * these are the authoritative card body sequence and take precedence over
119042
+ * legacy `last_message` + tail-appended `image_blocks` rendering. Narrow union
119043
+ * only: markdown and image. No raw card JSON escape hatch.
119044
+ */
119045
+ content_blocks: ContentBlocksSchema.optional(),
118760
119046
  /**
118761
119047
  * Optional one-line prompt rendered above the choice buttons (e.g. "选哪个方案?").
118762
119048
  * Rendered VERBATIM, bridge-opaque. Only meaningful alongside `choices`.
@@ -119566,7 +119852,9 @@ var BridgeHandler = class {
119566
119852
  // reportedState is null when state.json wasn't freshly written
119567
119853
  // (stale-guard), so stale leftover choices never reappear.
119568
119854
  choices: reportedState?.choices,
119569
- choicePrompt: reportedState?.choice_prompt
119855
+ choicePrompt: reportedState?.choice_prompt,
119856
+ imageBlocks: reportedState?.image_blocks,
119857
+ contentBlocks: reportedState?.content_blocks
119570
119858
  });
119571
119859
  await deleteCardFile(worktreePath);
119572
119860
  }
@@ -123852,6 +124140,30 @@ function runtimeRequirementsForBots(bots) {
123852
124140
  });
123853
124141
  }
123854
124142
 
124143
+ // src/crashGuard.ts
124144
+ import { inspect } from "node:util";
124145
+ function handleUncaughtException(err, origin, log = console) {
124146
+ log.error(
124147
+ `[larkway] uncaughtException (origin=${origin}) \u2014 bridge STAYS UP (crash guard). Likely a transient WS handshake-timeout raw socket error; if it recurs, investigate:`,
124148
+ err
124149
+ );
124150
+ }
124151
+ function handleUnhandledRejection(reason, log = console) {
124152
+ const rendered = reason instanceof Error ? reason : inspect(reason, { depth: 4 });
124153
+ log.error(
124154
+ "[larkway] unhandledRejection \u2014 bridge STAYS UP (crash guard). Full reason:",
124155
+ rendered
124156
+ );
124157
+ }
124158
+ function registerCrashGuard(log = console) {
124159
+ process.on("uncaughtException", (err, origin) => {
124160
+ handleUncaughtException(err, origin, log);
124161
+ });
124162
+ process.on("unhandledRejection", (reason) => {
124163
+ handleUnhandledRejection(reason, log);
124164
+ });
124165
+ }
124166
+
123855
124167
  // src/main.ts
123856
124168
  var STATUS_WRITE_INTERVAL_MS = 3e4;
123857
124169
  var VERSION = resolveLarkwayVersion(import.meta.url);
@@ -124115,6 +124427,7 @@ async function runV2Mode({
124115
124427
  process.on("SIGTERM", () => {
124116
124428
  void shutdown("SIGTERM");
124117
124429
  });
124430
+ registerCrashGuard();
124118
124431
  if (dryRun) {
124119
124432
  console.log("[dry-run] V2 mode \u2014 all bots wired OK, exiting.");
124120
124433
  await Promise.all(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",