larkway 0.3.55 → 0.3.57

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.55**
11
+ **Current release: v0.3.57**
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.55**
11
+ **当前版本:v0.3.57**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -10515,12 +10515,12 @@ var require_form_data = __commonJS({
10515
10515
  if (value.end != void 0 && value.end != Infinity && value.start != void 0) {
10516
10516
  callback(null, value.end + 1 - (value.start ? value.start : 0));
10517
10517
  } else {
10518
- fs8.stat(value.path, function(err2, stat6) {
10518
+ fs8.stat(value.path, function(err2, stat7) {
10519
10519
  if (err2) {
10520
10520
  callback(err2);
10521
10521
  return;
10522
10522
  }
10523
- var fileSize = stat6.size - (value.start ? value.start : 0);
10523
+ var fileSize = stat7.size - (value.start ? value.start : 0);
10524
10524
  callback(null, fileSize);
10525
10525
  });
10526
10526
  }
@@ -108202,6 +108202,7 @@ __export(channelClient_exports, {
108202
108202
  ChannelClient: () => ChannelClient,
108203
108203
  DEFAULT_OPEN_CHAT_DISCOVERY_MS: () => DEFAULT_OPEN_CHAT_DISCOVERY_MS,
108204
108204
  channelMsgToLarkEvent: () => channelMsgToLarkEvent,
108205
+ isBotSentMessage: () => isBotSentMessage,
108205
108206
  resolveOpenChatDiscoveryMs: () => resolveOpenChatDiscoveryMs,
108206
108207
  resolveRecoveredThreadId: () => resolveRecoveredThreadId,
108207
108208
  synthesizeCardActionEvent: () => synthesizeCardActionEvent
@@ -108210,6 +108211,17 @@ import { execFile as execFileCallback } from "node:child_process";
108210
108211
  import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile4, rename as rename3, unlink as unlink4 } from "node:fs/promises";
108211
108212
  import path9 from "node:path";
108212
108213
  import { promisify as promisify4 } from "node:util";
108214
+ function isChatAccessGoneError(e) {
108215
+ const parts = [];
108216
+ if (e instanceof Error) parts.push(e.message);
108217
+ if (e && typeof e === "object") {
108218
+ for (const key of ["stderr", "stdout"]) {
108219
+ const v = e[key];
108220
+ if (typeof v === "string") parts.push(v);
108221
+ }
108222
+ }
108223
+ return parts.some((s) => s.includes("230002"));
108224
+ }
108213
108225
  function stripAtMarkup(s) {
108214
108226
  return s.replace(/<at\b[^>]*>.*?<\/at>/gi, "").replace(/<at\b[^>]*\/>/gi, "").trim();
108215
108227
  }
@@ -108297,6 +108309,16 @@ function resolveRecoveredThreadId(m) {
108297
108309
  }
108298
108310
  return null;
108299
108311
  }
108312
+ function isBotSentMessage(m, botOpenId) {
108313
+ const sender = m["sender"];
108314
+ if (typeof sender === "string") return sender === botOpenId;
108315
+ if (sender && typeof sender === "object") {
108316
+ const s = sender;
108317
+ if (s["sender_type"] === "app") return true;
108318
+ if (typeof s["id"] === "string" && s["id"] === botOpenId) return true;
108319
+ }
108320
+ return false;
108321
+ }
108300
108322
  function cardActionChoice(value) {
108301
108323
  if (value && typeof value === "object") {
108302
108324
  const choice = value["larkway_choice"];
@@ -108348,7 +108370,7 @@ function channelMsgToLarkEvent(msg, fallbackChatType) {
108348
108370
  create_time: m?.["create_time"] ?? String(msg.createTime ?? Date.now())
108349
108371
  };
108350
108372
  }
108351
- var import_node_sdk, execFile4, LEARNED_CHATS_LIMIT, SEEN_MESSAGES_LIMIT, MAX_MESSAGE_ATTEMPTS, OPEN_CHAT_DISCOVERY_LOOKBACK_BUFFER_MS, OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS, PROCESSING_REACTION_EMOJI, DEFAULT_OPEN_CHAT_DISCOVERY_MS, OPEN_CHAT_DISCOVERY_JITTER_CAP_MS, OPEN_CHAT_DISCOVERY_MAX_BACKOFF_CYCLES, GAP_FILL_MAX_ATTEMPTS, GAP_FILL_BACKOFF_BASE_MS, UNRESOLVED_WINDOW_MAX_CHATS, UNRESOLVED_WINDOW_MAX_AGE_MS, DEFAULT_CONNECT_GRACE_MS, ChannelClient;
108373
+ var import_node_sdk, execFile4, LEARNED_CHATS_LIMIT, SEEN_MESSAGES_LIMIT, MAX_MESSAGE_ATTEMPTS, OPEN_CHAT_DISCOVERY_LOOKBACK_BUFFER_MS, OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS, PROCESSING_REACTION_EMOJI, DEFAULT_OPEN_CHAT_DISCOVERY_MS, OPEN_CHAT_DISCOVERY_JITTER_CAP_MS, OPEN_CHAT_DISCOVERY_MAX_BACKOFF_CYCLES, GAP_FILL_MAX_ATTEMPTS, GAP_FILL_BACKOFF_BASE_MS, UNRESOLVED_WINDOW_MAX_CHATS, UNRESOLVED_WINDOW_MAX_AGE_MS, CHAT_ACCESS_GONE_UNTRACK_AFTER, DEFAULT_CONNECT_GRACE_MS, ChannelClient;
108352
108374
  var init_channelClient = __esm({
108353
108375
  "src/lark/channelClient.ts"() {
108354
108376
  "use strict";
@@ -108373,6 +108395,7 @@ var init_channelClient = __esm({
108373
108395
  GAP_FILL_BACKOFF_BASE_MS = 1e3;
108374
108396
  UNRESOLVED_WINDOW_MAX_CHATS = 50;
108375
108397
  UNRESOLVED_WINDOW_MAX_AGE_MS = 30 * 60 * 1e3;
108398
+ CHAT_ACCESS_GONE_UNTRACK_AFTER = 3;
108376
108399
  DEFAULT_CONNECT_GRACE_MS = 3e3;
108377
108400
  ChannelClient = class {
108378
108401
  opts;
@@ -108471,6 +108494,16 @@ var init_channelClient = __esm({
108471
108494
  * capped at UNRESOLVED_WINDOW_MAX_CHATS tracked chats.
108472
108495
  */
108473
108496
  unresolvedGapWindowByChat = /* @__PURE__ */ new Map();
108497
+ /**
108498
+ * chatId → count of CONSECUTIVE gap-fill cycles whose pull failed with
108499
+ * 230002 (bot removed from the chat). At {@link CHAT_ACCESS_GONE_UNTRACK_AFTER}
108500
+ * the chat is dropped from gap-fill tracking entirely (see
108501
+ * {@link noteChatAccessGone}) — the fix for the 2026-07-17 amplifier where a
108502
+ * stale chat's unresolved window was replayed (3 retries each) every cycle,
108503
+ * forever, burning quota against a deterministic error. Reset by any
108504
+ * successful pull of the chat or any live sighting (noteSeenChat).
108505
+ */
108506
+ chatAccessGoneCountByChat = /* @__PURE__ */ new Map();
108474
108507
  /**
108475
108508
  * Backoff sleep used by the per-chat history-pull retry. Indirected through a
108476
108509
  * field purely so tests can observe/await the backoff deterministically; in
@@ -108558,6 +108591,44 @@ var init_channelClient = __esm({
108558
108591
  unresolvedGapWindowsForTest() {
108559
108592
  return new Map(this.unresolvedGapWindowByChat);
108560
108593
  }
108594
+ /** TEST-ONLY read of the tracked (gap-fillable) chat id set. */
108595
+ trackedChatIdsForTest() {
108596
+ return new Set(this.recentlySeenChatIds);
108597
+ }
108598
+ /**
108599
+ * Seed gap-fill's tracked-chat set from durable session history
108600
+ * (sessions.json), BEFORE connect. Fix for the 2026-07-17 p2p message-loss
108601
+ * incident: p2p chats are invisible to bot-side chat-list discovery (the API
108602
+ * returns groups only), so once the in-memory tracking state was gone —
108603
+ * restart plus a missing/stale runtime cache — a p2p chat could never
108604
+ * re-enter the gap-fill list and its messages dropped during a WS outage
108605
+ * were lost forever. sessions.json persists the chatId (and, going forward,
108606
+ * chatType) of every thread the bot has served, so seeding from it makes a
108607
+ * p2p chat permanently gap-fillable once it has ever been seen.
108608
+ *
108609
+ * Deliberately does NOT persist to the runtime channel-seen-chats cache:
108610
+ * this runs before {@link loadRecentlySeenChatIds} merges the cache in, and
108611
+ * persisting the (possibly smaller) seed set here would overwrite cached
108612
+ * chats learned in previous runs. The union is persisted by the next
108613
+ * ordinary noteSeenChat.
108614
+ */
108615
+ seedTrackedChats(entries) {
108616
+ let added = 0;
108617
+ for (const { chatId, chatType } of entries) {
108618
+ if (!chatId.startsWith("oc_")) continue;
108619
+ const before = this.recentlySeenChatIds.size;
108620
+ this.recentlySeenChatIds.add(chatId);
108621
+ if (this.recentlySeenChatIds.size > before) added++;
108622
+ if (chatType && !this.chatTypesById.has(chatId)) {
108623
+ this.chatTypesById.set(chatId, chatType);
108624
+ }
108625
+ }
108626
+ if (added > 0) {
108627
+ console.log(
108628
+ `[channel.client] seeded ${added} chat(s) from session history for gap-fill`
108629
+ );
108630
+ }
108631
+ }
108561
108632
  /**
108562
108633
  * TEST-ONLY: run exactly ONE open-chat discovery cycle (same code path the
108563
108634
  * interval timer invokes), awaited to completion. Lets tests assert the
@@ -108693,10 +108764,10 @@ var init_channelClient = __esm({
108693
108764
  log(`dropped (unmappable raw): ${JSON.stringify(msg.messageId ?? "?")}`);
108694
108765
  return;
108695
108766
  }
108696
- this.noteSeenChat(ev.chat_id);
108697
- if (typeof ev.chat_type === "string" && ev.chat_type.length > 0) {
108698
- this.chatTypesById.set(ev.chat_id, ev.chat_type);
108699
- }
108767
+ this.noteSeenChat(
108768
+ ev.chat_id,
108769
+ typeof ev.chat_type === "string" && ev.chat_type.length > 0 ? ev.chat_type : void 0
108770
+ );
108700
108771
  if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
108701
108772
  return;
108702
108773
  }
@@ -108769,10 +108840,10 @@ var init_channelClient = __esm({
108769
108840
  ingestLocalEvent(ev, sourceTag) {
108770
108841
  if (this.closed) return false;
108771
108842
  const log = (s) => console.log(`[channel.client] ${s}`);
108772
- this.noteSeenChat(ev.chat_id);
108773
- if (typeof ev.chat_type === "string" && ev.chat_type.length > 0) {
108774
- this.chatTypesById.set(ev.chat_id, ev.chat_type);
108775
- }
108843
+ this.noteSeenChat(
108844
+ ev.chat_id,
108845
+ typeof ev.chat_type === "string" && ev.chat_type.length > 0 ? ev.chat_type : void 0
108846
+ );
108776
108847
  if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
108777
108848
  log(`local-dispatch deduped (${sourceTag}): message_id=${ev.message_id}`);
108778
108849
  return false;
@@ -108979,14 +109050,19 @@ var init_channelClient = __esm({
108979
109050
  const messageId = m["message_id"];
108980
109051
  if (!messageId) continue;
108981
109052
  if (this.seenMessageIds.has(messageId) || this.inFlightMessageIds.has(messageId)) continue;
108982
- const mentions = m["mentions"];
108983
- const mentionsBot = Array.isArray(mentions) && mentions.some(
108984
- (mn) => {
108985
- if (typeof mn?.id === "string") return mn.id === botOpenId;
108986
- return mn?.id?.open_id === botOpenId;
108987
- }
108988
- );
108989
- if (!mentionsBot) continue;
109053
+ const isP2pChat = this.chatTypesById.get(chatId) === "p2p";
109054
+ if (isP2pChat) {
109055
+ if (isBotSentMessage(m, botOpenId)) continue;
109056
+ } else {
109057
+ const mentions = m["mentions"];
109058
+ const mentionsBot = Array.isArray(mentions) && mentions.some(
109059
+ (mn) => {
109060
+ if (typeof mn?.id === "string") return mn.id === botOpenId;
109061
+ return mn?.id?.open_id === botOpenId;
109062
+ }
109063
+ );
109064
+ if (!mentionsBot) continue;
109065
+ }
108990
109066
  const recoveredThread = resolveRecoveredThreadId(m);
108991
109067
  const isThreadReply = recoveredThread !== null && recoveredThread !== messageId;
108992
109068
  if (isThreadReply && !nonEmptyStringField(m, "root_id")) {
@@ -109040,7 +109116,12 @@ var init_channelClient = __esm({
109040
109116
  totalDispatched++;
109041
109117
  }
109042
109118
  this.resolveUnresolvedGapWindow(chatId, windowStart);
109119
+ this.chatAccessGoneCountByChat.delete(chatId);
109043
109120
  } catch (e) {
109121
+ if (isChatAccessGoneError(e)) {
109122
+ this.noteChatAccessGone(chatId, log);
109123
+ continue;
109124
+ }
109044
109125
  anyChatFailed = true;
109045
109126
  this.recordUnresolvedGapWindow(chatId, windowStart, log);
109046
109127
  log(
@@ -109068,6 +109149,7 @@ var init_channelClient = __esm({
109068
109149
  return await execFile4(larkCli, args);
109069
109150
  } catch (e) {
109070
109151
  lastErr = e;
109152
+ if (isChatAccessGoneError(e)) throw e;
109071
109153
  if (attempt < GAP_FILL_MAX_ATTEMPTS) {
109072
109154
  const backoffMs = GAP_FILL_BACKOFF_BASE_MS * 2 ** (attempt - 1);
109073
109155
  log(
@@ -109124,6 +109206,33 @@ var init_channelClient = __esm({
109124
109206
  if (tracked === void 0) return;
109125
109207
  if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
109126
109208
  }
109209
+ /**
109210
+ * A gap-fill cycle hit 230002 for this chat (bot removed from it). Drop any
109211
+ * queued unresolved window IMMEDIATELY — replaying it burns quota against a
109212
+ * deterministic error (the 2026-07-17 amplifier) — and count a strike; at
109213
+ * {@link CHAT_ACCESS_GONE_UNTRACK_AFTER} consecutive strikes, untrack the
109214
+ * chat entirely so no future cycle pulls it. Self-healing: if the bot is
109215
+ * re-invited, the next live message (or discovery listing) re-tracks the
109216
+ * chat via {@link noteSeenChat}, which also resets the strikes.
109217
+ */
109218
+ noteChatAccessGone(chatId, log) {
109219
+ this.unresolvedGapWindowByChat.delete(chatId);
109220
+ const strikes = (this.chatAccessGoneCountByChat.get(chatId) ?? 0) + 1;
109221
+ if (strikes < CHAT_ACCESS_GONE_UNTRACK_AFTER) {
109222
+ this.chatAccessGoneCountByChat.set(chatId, strikes);
109223
+ log(
109224
+ `gap-fill: chat ${chatId} inaccessible (230002 \u2014 bot not in chat), strike ${strikes}/${CHAT_ACCESS_GONE_UNTRACK_AFTER}`
109225
+ );
109226
+ return;
109227
+ }
109228
+ this.chatAccessGoneCountByChat.delete(chatId);
109229
+ const wasTracked = this.recentlySeenChatIds.delete(chatId);
109230
+ this.chatTypesById.delete(chatId);
109231
+ if (wasTracked) void this.persistRecentlySeenChatIds();
109232
+ log(
109233
+ `gap-fill: chat ${chatId} untracked after ${CHAT_ACCESS_GONE_UNTRACK_AFTER} consecutive inaccessible cycles (bot removed from chat?) \u2014 re-tracks automatically on the next live message`
109234
+ );
109235
+ }
109127
109236
  /**
109128
109237
  * Chats-mode replay loop (see {@link unresolvedReplayTimer}): every discovery
109129
109238
  * interval, IF any unresolved gap window is pending, gapFill exactly those
@@ -109284,24 +109393,35 @@ var init_channelClient = __esm({
109284
109393
  try {
109285
109394
  const raw = await readFile5(file, "utf8");
109286
109395
  const parsed = JSON.parse(raw);
109287
- if (!Array.isArray(parsed)) return;
109396
+ const ids = Array.isArray(parsed) ? parsed : arrayField(parsed, "chats") ?? [];
109288
109397
  let count = 0;
109289
- for (const chatId of parsed) {
109398
+ for (const chatId of ids) {
109290
109399
  if (typeof chatId !== "string" || !chatId.startsWith("oc_")) continue;
109291
109400
  this.recentlySeenChatIds.add(chatId);
109292
109401
  count++;
109293
109402
  }
109403
+ const types2 = !Array.isArray(parsed) && parsed && typeof parsed === "object" ? parsed["chatTypes"] : void 0;
109404
+ if (types2 && typeof types2 === "object") {
109405
+ for (const [chatId, t] of Object.entries(types2)) {
109406
+ if (typeof t !== "string" || t.length === 0) continue;
109407
+ if (!this.recentlySeenChatIds.has(chatId)) continue;
109408
+ this.chatTypesById.set(chatId, t);
109409
+ }
109410
+ }
109294
109411
  if (count > 0) log(`loaded ${count} learned chat(s) for gap-fill`);
109295
109412
  } catch (err2) {
109296
109413
  if (err2.code === "ENOENT") return;
109297
109414
  log(`learned chats load failed: ${err2.message}`);
109298
109415
  }
109299
109416
  }
109300
- noteSeenChat(chatId) {
109417
+ noteSeenChat(chatId, chatType) {
109418
+ const typeChanged = chatType !== void 0 && this.chatTypesById.get(chatId) !== chatType;
109419
+ if (typeChanged) this.chatTypesById.set(chatId, chatType);
109301
109420
  if (!chatId.startsWith("oc_")) return;
109421
+ this.chatAccessGoneCountByChat.delete(chatId);
109302
109422
  const before = this.recentlySeenChatIds.size;
109303
109423
  this.recentlySeenChatIds.add(chatId);
109304
- if (this.recentlySeenChatIds.size === before) return;
109424
+ if (this.recentlySeenChatIds.size === before && !typeChanged) return;
109305
109425
  void this.persistRecentlySeenChatIds();
109306
109426
  }
109307
109427
  async loadSeenMessageIds(log) {
@@ -109366,7 +109486,12 @@ var init_channelClient = __esm({
109366
109486
  const file = this.learnedChatsPath();
109367
109487
  if (!file) return;
109368
109488
  const chats = [...this.recentlySeenChatIds].sort().slice(-LEARNED_CHATS_LIMIT);
109369
- await this.atomicWriteJson(file, chats);
109489
+ const chatTypes = {};
109490
+ for (const chatId of chats) {
109491
+ const t = this.chatTypesById.get(chatId);
109492
+ if (t) chatTypes[chatId] = t;
109493
+ }
109494
+ await this.atomicWriteJson(file, { chats, chatTypes });
109370
109495
  }
109371
109496
  async addProcessingReaction(messageId) {
109372
109497
  if (this.processingReactions.has(messageId)) return;
@@ -121335,7 +121460,7 @@ var jsYaml = {
121335
121460
  };
121336
121461
 
121337
121462
  // src/config/botLoader.ts
121338
- import { readdir, readFile } from "node:fs/promises";
121463
+ import { readdir, readFile, stat } from "node:fs/promises";
121339
121464
  import path from "node:path";
121340
121465
 
121341
121466
  // src/responseSurface.ts
@@ -122026,6 +122151,10 @@ ${issues}`);
122026
122151
  async function loadBots(botsDir) {
122027
122152
  return (await loadBotsDetailed(botsDir)).bots;
122028
122153
  }
122154
+ var ScheduleSliceSchema = BotConfigSchema.pick({
122155
+ schedules: true,
122156
+ schedule_chat_id: true
122157
+ }).strip();
122029
122158
 
122030
122159
  // src/cli/botsStore.ts
122031
122160
  init_paths();
@@ -122183,7 +122312,7 @@ __export(hostConfig_exports, {
122183
122312
  writeHostConfig: () => writeHostConfig,
122184
122313
  writeSecret: () => writeSecret
122185
122314
  });
122186
- import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2, rename as rename2, chmod, access as access2, stat, unlink as unlink2 } from "node:fs/promises";
122315
+ import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2, rename as rename2, chmod, access as access2, stat as stat2, unlink as unlink2 } from "node:fs/promises";
122187
122316
  import path4 from "node:path";
122188
122317
 
122189
122318
  // src/config.ts
@@ -122360,7 +122489,7 @@ async function removeSecret(envName) {
122360
122489
  const body = [...map2.entries()].map(([k, v]) => `${k}=${quoteIfNeeded(v)}`).join("\n") + "\n";
122361
122490
  await atomicWrite2(file, body);
122362
122491
  try {
122363
- const s = await stat(file);
122492
+ const s = await stat2(file);
122364
122493
  if ((s.mode & 511) !== 384) await chmod(file, 384);
122365
122494
  } catch {
122366
122495
  await chmod(file, 384);
@@ -122376,7 +122505,7 @@ async function envFileExists() {
122376
122505
  }
122377
122506
 
122378
122507
  // src/cli/commands/init.ts
122379
- import { access as access5, stat as stat2 } from "node:fs/promises";
122508
+ import { access as access5, stat as stat3 } from "node:fs/promises";
122380
122509
  import { execFile as execFile3 } from "node:child_process";
122381
122510
  import path8 from "node:path";
122382
122511
  import "node:os";
@@ -122555,8 +122684,8 @@ async function writeAlways(filePath, content) {
122555
122684
  }
122556
122685
  async function ensureRelativeSymlink(linkPath, target) {
122557
122686
  try {
122558
- const stat6 = await fs.lstat(linkPath);
122559
- if (stat6.isSymbolicLink()) {
122687
+ const stat7 = await fs.lstat(linkPath);
122688
+ if (stat7.isSymbolicLink()) {
122560
122689
  const currentTarget = await fs.readlink(linkPath);
122561
122690
  if (currentTarget === target) return;
122562
122691
  }
@@ -123170,7 +123299,7 @@ async function detectLocalRepos(cwd) {
123170
123299
  for (const name of siblings.slice(0, 20)) {
123171
123300
  const p = path8.join(parent, name);
123172
123301
  try {
123173
- const s = await stat2(p);
123302
+ const s = await stat3(p);
123174
123303
  if (s.isDirectory()) candidates.push(p);
123175
123304
  } catch {
123176
123305
  }
@@ -123930,8 +124059,8 @@ async function checkWorktrees(ctx) {
123930
124059
  const gitPath = path10.join(worktreeDir, ".git");
123931
124060
  let gitContent = null;
123932
124061
  try {
123933
- const stat6 = await readFile7(gitPath, "utf-8").catch(() => null);
123934
- gitContent = stat6;
124062
+ const stat7 = await readFile7(gitPath, "utf-8").catch(() => null);
124063
+ gitContent = stat7;
123935
124064
  } catch {
123936
124065
  }
123937
124066
  if (gitContent === null) {
@@ -125759,7 +125888,7 @@ import { readFile as readFile10, writeFile as writeFile8, unlink as unlink5, mkd
125759
125888
  import { promisify as promisify6 } from "node:util";
125760
125889
  import path15 from "node:path";
125761
125890
  import process3 from "node:process";
125762
- import { stat as stat3 } from "node:fs/promises";
125891
+ import { stat as stat4 } from "node:fs/promises";
125763
125892
 
125764
125893
  // src/bridge/statusFile.ts
125765
125894
  init_paths();
@@ -126087,7 +126216,7 @@ async function tailBridgeLog(larkwayDir, n = 80) {
126087
126216
  const logPath = bridgeLogPath(larkwayDir);
126088
126217
  let content;
126089
126218
  try {
126090
- const s = await stat3(logPath);
126219
+ const s = await stat4(logPath);
126091
126220
  const MAX_READ = 256 * 1024;
126092
126221
  if (s.size > MAX_READ) {
126093
126222
  const { open } = await import("node:fs/promises");
@@ -126681,12 +126810,12 @@ async function run7(ctx, args) {
126681
126810
  import http from "node:http";
126682
126811
  import { randomBytes } from "node:crypto";
126683
126812
  import { existsSync } from "node:fs";
126684
- import { readFile as readFile14, stat as stat5 } from "node:fs/promises";
126813
+ import { readFile as readFile14, stat as stat6 } from "node:fs/promises";
126685
126814
  import path25 from "node:path";
126686
126815
  import { fileURLToPath as fileURLToPath2 } from "node:url";
126687
126816
 
126688
126817
  // src/web/api.ts
126689
- import { readdir as readdir6, readFile as readFile13, stat as stat4 } from "node:fs/promises";
126818
+ import { readdir as readdir6, readFile as readFile13, stat as stat5 } from "node:fs/promises";
126690
126819
  import { execFile as execFileCallback2 } from "node:child_process";
126691
126820
  import path24 from "node:path";
126692
126821
  import { promisify as promisify8 } from "node:util";
@@ -128282,7 +128411,7 @@ var getStatus = async (req) => {
128282
128411
  let logExists = false;
128283
128412
  let logSizeKb = 0;
128284
128413
  try {
128285
- const s = await stat4(logPath);
128414
+ const s = await stat5(logPath);
128286
128415
  logExists = true;
128287
128416
  logSizeKb = Math.round(s.size / 1024);
128288
128417
  } catch {
@@ -128744,7 +128873,7 @@ async function handleStatic(res, pathname, token) {
128744
128873
  let target = resolved;
128745
128874
  let isIndexFallback = false;
128746
128875
  try {
128747
- const s = await stat5(target);
128876
+ const s = await stat6(target);
128748
128877
  if (s.isDirectory()) {
128749
128878
  target = path25.join(target, "index.html");
128750
128879
  }
package/dist/main.js CHANGED
@@ -10067,12 +10067,12 @@ var require_form_data = __commonJS({
10067
10067
  if (value.end != void 0 && value.end != Infinity && value.start != void 0) {
10068
10068
  callback(null, value.end + 1 - (value.start ? value.start : 0));
10069
10069
  } else {
10070
- fs17.stat(value.path, function(err, stat4) {
10070
+ fs17.stat(value.path, function(err, stat5) {
10071
10071
  if (err) {
10072
10072
  callback(err);
10073
10073
  return;
10074
10074
  }
10075
- var fileSize = stat4.size - (value.start ? value.start : 0);
10075
+ var fileSize = stat5.size - (value.start ? value.start : 0);
10076
10076
  callback(null, fileSize);
10077
10077
  });
10078
10078
  }
@@ -112365,6 +112365,18 @@ var GAP_FILL_MAX_ATTEMPTS = 3;
112365
112365
  var GAP_FILL_BACKOFF_BASE_MS = 1e3;
112366
112366
  var UNRESOLVED_WINDOW_MAX_CHATS = 50;
112367
112367
  var UNRESOLVED_WINDOW_MAX_AGE_MS = 30 * 60 * 1e3;
112368
+ var CHAT_ACCESS_GONE_UNTRACK_AFTER = 3;
112369
+ function isChatAccessGoneError(e) {
112370
+ const parts = [];
112371
+ if (e instanceof Error) parts.push(e.message);
112372
+ if (e && typeof e === "object") {
112373
+ for (const key of ["stderr", "stdout"]) {
112374
+ const v = e[key];
112375
+ if (typeof v === "string") parts.push(v);
112376
+ }
112377
+ }
112378
+ return parts.some((s) => s.includes("230002"));
112379
+ }
112368
112380
  function stripAtMarkup(s) {
112369
112381
  return s.replace(/<at\b[^>]*>.*?<\/at>/gi, "").replace(/<at\b[^>]*\/>/gi, "").trim();
112370
112382
  }
@@ -112453,6 +112465,16 @@ function resolveRecoveredThreadId(m) {
112453
112465
  }
112454
112466
  return null;
112455
112467
  }
112468
+ function isBotSentMessage(m, botOpenId) {
112469
+ const sender = m["sender"];
112470
+ if (typeof sender === "string") return sender === botOpenId;
112471
+ if (sender && typeof sender === "object") {
112472
+ const s = sender;
112473
+ if (s["sender_type"] === "app") return true;
112474
+ if (typeof s["id"] === "string" && s["id"] === botOpenId) return true;
112475
+ }
112476
+ return false;
112477
+ }
112456
112478
  function cardActionChoice(value) {
112457
112479
  if (value && typeof value === "object") {
112458
112480
  const choice = value["larkway_choice"];
@@ -112601,6 +112623,16 @@ var ChannelClient = class {
112601
112623
  * capped at UNRESOLVED_WINDOW_MAX_CHATS tracked chats.
112602
112624
  */
112603
112625
  unresolvedGapWindowByChat = /* @__PURE__ */ new Map();
112626
+ /**
112627
+ * chatId → count of CONSECUTIVE gap-fill cycles whose pull failed with
112628
+ * 230002 (bot removed from the chat). At {@link CHAT_ACCESS_GONE_UNTRACK_AFTER}
112629
+ * the chat is dropped from gap-fill tracking entirely (see
112630
+ * {@link noteChatAccessGone}) — the fix for the 2026-07-17 amplifier where a
112631
+ * stale chat's unresolved window was replayed (3 retries each) every cycle,
112632
+ * forever, burning quota against a deterministic error. Reset by any
112633
+ * successful pull of the chat or any live sighting (noteSeenChat).
112634
+ */
112635
+ chatAccessGoneCountByChat = /* @__PURE__ */ new Map();
112604
112636
  /**
112605
112637
  * Backoff sleep used by the per-chat history-pull retry. Indirected through a
112606
112638
  * field purely so tests can observe/await the backoff deterministically; in
@@ -112688,6 +112720,44 @@ var ChannelClient = class {
112688
112720
  unresolvedGapWindowsForTest() {
112689
112721
  return new Map(this.unresolvedGapWindowByChat);
112690
112722
  }
112723
+ /** TEST-ONLY read of the tracked (gap-fillable) chat id set. */
112724
+ trackedChatIdsForTest() {
112725
+ return new Set(this.recentlySeenChatIds);
112726
+ }
112727
+ /**
112728
+ * Seed gap-fill's tracked-chat set from durable session history
112729
+ * (sessions.json), BEFORE connect. Fix for the 2026-07-17 p2p message-loss
112730
+ * incident: p2p chats are invisible to bot-side chat-list discovery (the API
112731
+ * returns groups only), so once the in-memory tracking state was gone —
112732
+ * restart plus a missing/stale runtime cache — a p2p chat could never
112733
+ * re-enter the gap-fill list and its messages dropped during a WS outage
112734
+ * were lost forever. sessions.json persists the chatId (and, going forward,
112735
+ * chatType) of every thread the bot has served, so seeding from it makes a
112736
+ * p2p chat permanently gap-fillable once it has ever been seen.
112737
+ *
112738
+ * Deliberately does NOT persist to the runtime channel-seen-chats cache:
112739
+ * this runs before {@link loadRecentlySeenChatIds} merges the cache in, and
112740
+ * persisting the (possibly smaller) seed set here would overwrite cached
112741
+ * chats learned in previous runs. The union is persisted by the next
112742
+ * ordinary noteSeenChat.
112743
+ */
112744
+ seedTrackedChats(entries) {
112745
+ let added = 0;
112746
+ for (const { chatId, chatType } of entries) {
112747
+ if (!chatId.startsWith("oc_")) continue;
112748
+ const before = this.recentlySeenChatIds.size;
112749
+ this.recentlySeenChatIds.add(chatId);
112750
+ if (this.recentlySeenChatIds.size > before) added++;
112751
+ if (chatType && !this.chatTypesById.has(chatId)) {
112752
+ this.chatTypesById.set(chatId, chatType);
112753
+ }
112754
+ }
112755
+ if (added > 0) {
112756
+ console.log(
112757
+ `[channel.client] seeded ${added} chat(s) from session history for gap-fill`
112758
+ );
112759
+ }
112760
+ }
112691
112761
  /**
112692
112762
  * TEST-ONLY: run exactly ONE open-chat discovery cycle (same code path the
112693
112763
  * interval timer invokes), awaited to completion. Lets tests assert the
@@ -112823,10 +112893,10 @@ var ChannelClient = class {
112823
112893
  log(`dropped (unmappable raw): ${JSON.stringify(msg.messageId ?? "?")}`);
112824
112894
  return;
112825
112895
  }
112826
- this.noteSeenChat(ev.chat_id);
112827
- if (typeof ev.chat_type === "string" && ev.chat_type.length > 0) {
112828
- this.chatTypesById.set(ev.chat_id, ev.chat_type);
112829
- }
112896
+ this.noteSeenChat(
112897
+ ev.chat_id,
112898
+ typeof ev.chat_type === "string" && ev.chat_type.length > 0 ? ev.chat_type : void 0
112899
+ );
112830
112900
  if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
112831
112901
  return;
112832
112902
  }
@@ -112899,10 +112969,10 @@ var ChannelClient = class {
112899
112969
  ingestLocalEvent(ev, sourceTag) {
112900
112970
  if (this.closed) return false;
112901
112971
  const log = (s) => console.log(`[channel.client] ${s}`);
112902
- this.noteSeenChat(ev.chat_id);
112903
- if (typeof ev.chat_type === "string" && ev.chat_type.length > 0) {
112904
- this.chatTypesById.set(ev.chat_id, ev.chat_type);
112905
- }
112972
+ this.noteSeenChat(
112973
+ ev.chat_id,
112974
+ typeof ev.chat_type === "string" && ev.chat_type.length > 0 ? ev.chat_type : void 0
112975
+ );
112906
112976
  if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
112907
112977
  log(`local-dispatch deduped (${sourceTag}): message_id=${ev.message_id}`);
112908
112978
  return false;
@@ -113109,14 +113179,19 @@ var ChannelClient = class {
113109
113179
  const messageId = m["message_id"];
113110
113180
  if (!messageId) continue;
113111
113181
  if (this.seenMessageIds.has(messageId) || this.inFlightMessageIds.has(messageId)) continue;
113112
- const mentions = m["mentions"];
113113
- const mentionsBot = Array.isArray(mentions) && mentions.some(
113114
- (mn) => {
113115
- if (typeof mn?.id === "string") return mn.id === botOpenId;
113116
- return mn?.id?.open_id === botOpenId;
113117
- }
113118
- );
113119
- if (!mentionsBot) continue;
113182
+ const isP2pChat = this.chatTypesById.get(chatId) === "p2p";
113183
+ if (isP2pChat) {
113184
+ if (isBotSentMessage(m, botOpenId)) continue;
113185
+ } else {
113186
+ const mentions = m["mentions"];
113187
+ const mentionsBot = Array.isArray(mentions) && mentions.some(
113188
+ (mn) => {
113189
+ if (typeof mn?.id === "string") return mn.id === botOpenId;
113190
+ return mn?.id?.open_id === botOpenId;
113191
+ }
113192
+ );
113193
+ if (!mentionsBot) continue;
113194
+ }
113120
113195
  const recoveredThread = resolveRecoveredThreadId(m);
113121
113196
  const isThreadReply = recoveredThread !== null && recoveredThread !== messageId;
113122
113197
  if (isThreadReply && !nonEmptyStringField(m, "root_id")) {
@@ -113170,7 +113245,12 @@ var ChannelClient = class {
113170
113245
  totalDispatched++;
113171
113246
  }
113172
113247
  this.resolveUnresolvedGapWindow(chatId, windowStart);
113248
+ this.chatAccessGoneCountByChat.delete(chatId);
113173
113249
  } catch (e) {
113250
+ if (isChatAccessGoneError(e)) {
113251
+ this.noteChatAccessGone(chatId, log);
113252
+ continue;
113253
+ }
113174
113254
  anyChatFailed = true;
113175
113255
  this.recordUnresolvedGapWindow(chatId, windowStart, log);
113176
113256
  log(
@@ -113198,6 +113278,7 @@ var ChannelClient = class {
113198
113278
  return await execFile(larkCli, args);
113199
113279
  } catch (e) {
113200
113280
  lastErr = e;
113281
+ if (isChatAccessGoneError(e)) throw e;
113201
113282
  if (attempt < GAP_FILL_MAX_ATTEMPTS) {
113202
113283
  const backoffMs = GAP_FILL_BACKOFF_BASE_MS * 2 ** (attempt - 1);
113203
113284
  log(
@@ -113254,6 +113335,33 @@ var ChannelClient = class {
113254
113335
  if (tracked === void 0) return;
113255
113336
  if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
113256
113337
  }
113338
+ /**
113339
+ * A gap-fill cycle hit 230002 for this chat (bot removed from it). Drop any
113340
+ * queued unresolved window IMMEDIATELY — replaying it burns quota against a
113341
+ * deterministic error (the 2026-07-17 amplifier) — and count a strike; at
113342
+ * {@link CHAT_ACCESS_GONE_UNTRACK_AFTER} consecutive strikes, untrack the
113343
+ * chat entirely so no future cycle pulls it. Self-healing: if the bot is
113344
+ * re-invited, the next live message (or discovery listing) re-tracks the
113345
+ * chat via {@link noteSeenChat}, which also resets the strikes.
113346
+ */
113347
+ noteChatAccessGone(chatId, log) {
113348
+ this.unresolvedGapWindowByChat.delete(chatId);
113349
+ const strikes = (this.chatAccessGoneCountByChat.get(chatId) ?? 0) + 1;
113350
+ if (strikes < CHAT_ACCESS_GONE_UNTRACK_AFTER) {
113351
+ this.chatAccessGoneCountByChat.set(chatId, strikes);
113352
+ log(
113353
+ `gap-fill: chat ${chatId} inaccessible (230002 \u2014 bot not in chat), strike ${strikes}/${CHAT_ACCESS_GONE_UNTRACK_AFTER}`
113354
+ );
113355
+ return;
113356
+ }
113357
+ this.chatAccessGoneCountByChat.delete(chatId);
113358
+ const wasTracked = this.recentlySeenChatIds.delete(chatId);
113359
+ this.chatTypesById.delete(chatId);
113360
+ if (wasTracked) void this.persistRecentlySeenChatIds();
113361
+ log(
113362
+ `gap-fill: chat ${chatId} untracked after ${CHAT_ACCESS_GONE_UNTRACK_AFTER} consecutive inaccessible cycles (bot removed from chat?) \u2014 re-tracks automatically on the next live message`
113363
+ );
113364
+ }
113257
113365
  /**
113258
113366
  * Chats-mode replay loop (see {@link unresolvedReplayTimer}): every discovery
113259
113367
  * interval, IF any unresolved gap window is pending, gapFill exactly those
@@ -113414,24 +113522,35 @@ var ChannelClient = class {
113414
113522
  try {
113415
113523
  const raw = await readFile2(file, "utf8");
113416
113524
  const parsed = JSON.parse(raw);
113417
- if (!Array.isArray(parsed)) return;
113525
+ const ids = Array.isArray(parsed) ? parsed : arrayField(parsed, "chats") ?? [];
113418
113526
  let count = 0;
113419
- for (const chatId of parsed) {
113527
+ for (const chatId of ids) {
113420
113528
  if (typeof chatId !== "string" || !chatId.startsWith("oc_")) continue;
113421
113529
  this.recentlySeenChatIds.add(chatId);
113422
113530
  count++;
113423
113531
  }
113532
+ const types2 = !Array.isArray(parsed) && parsed && typeof parsed === "object" ? parsed["chatTypes"] : void 0;
113533
+ if (types2 && typeof types2 === "object") {
113534
+ for (const [chatId, t] of Object.entries(types2)) {
113535
+ if (typeof t !== "string" || t.length === 0) continue;
113536
+ if (!this.recentlySeenChatIds.has(chatId)) continue;
113537
+ this.chatTypesById.set(chatId, t);
113538
+ }
113539
+ }
113424
113540
  if (count > 0) log(`loaded ${count} learned chat(s) for gap-fill`);
113425
113541
  } catch (err) {
113426
113542
  if (err.code === "ENOENT") return;
113427
113543
  log(`learned chats load failed: ${err.message}`);
113428
113544
  }
113429
113545
  }
113430
- noteSeenChat(chatId) {
113546
+ noteSeenChat(chatId, chatType) {
113547
+ const typeChanged = chatType !== void 0 && this.chatTypesById.get(chatId) !== chatType;
113548
+ if (typeChanged) this.chatTypesById.set(chatId, chatType);
113431
113549
  if (!chatId.startsWith("oc_")) return;
113550
+ this.chatAccessGoneCountByChat.delete(chatId);
113432
113551
  const before = this.recentlySeenChatIds.size;
113433
113552
  this.recentlySeenChatIds.add(chatId);
113434
- if (this.recentlySeenChatIds.size === before) return;
113553
+ if (this.recentlySeenChatIds.size === before && !typeChanged) return;
113435
113554
  void this.persistRecentlySeenChatIds();
113436
113555
  }
113437
113556
  async loadSeenMessageIds(log) {
@@ -113496,7 +113615,12 @@ var ChannelClient = class {
113496
113615
  const file = this.learnedChatsPath();
113497
113616
  if (!file) return;
113498
113617
  const chats = [...this.recentlySeenChatIds].sort().slice(-LEARNED_CHATS_LIMIT);
113499
- await this.atomicWriteJson(file, chats);
113618
+ const chatTypes = {};
113619
+ for (const chatId of chats) {
113620
+ const t = this.chatTypesById.get(chatId);
113621
+ if (t) chatTypes[chatId] = t;
113622
+ }
113623
+ await this.atomicWriteJson(file, { chats, chatTypes });
113500
113624
  }
113501
113625
  async addProcessingReaction(messageId) {
113502
113626
  if (this.processingReactions.has(messageId)) return;
@@ -114300,6 +114424,7 @@ var SessionStore = class _SessionStore {
114300
114424
  ...record.senderOpenId !== void 0 ? { senderOpenId: record.senderOpenId } : {},
114301
114425
  ...record.rootText !== void 0 ? { rootText: record.rootText } : {},
114302
114426
  ...record.chatId !== void 0 ? { chatId: record.chatId } : {},
114427
+ ...record.chatType !== void 0 ? { chatType: record.chatType } : {},
114303
114428
  // BL-38: only persist when > 0 — a 0/undefined counter is a clean thread,
114304
114429
  // so passing consecutiveStuckCount: 0 naturally clears the field on reset.
114305
114430
  ...record.consecutiveStuckCount ? { consecutiveStuckCount: record.consecutiveStuckCount } : {},
@@ -114439,7 +114564,7 @@ var SessionStore = class _SessionStore {
114439
114564
  function isStoredRecord(value) {
114440
114565
  if (typeof value !== "object" || value === null) return false;
114441
114566
  const v = value;
114442
- return typeof v["threadId"] === "string" && typeof v["sessionId"] === "string" && typeof v["botId"] === "string" && typeof v["createdTs"] === "number" && typeof v["lastActiveTs"] === "number" && (v["senderOpenId"] === void 0 || typeof v["senderOpenId"] === "string") && (v["rootText"] === void 0 || typeof v["rootText"] === "string") && (v["chatId"] === void 0 || typeof v["chatId"] === "string") && (v["turnCount"] === void 0 || typeof v["turnCount"] === "number") && (v["harvestedAt"] === void 0 || typeof v["harvestedAt"] === "number") && (v["approxChars"] === void 0 || typeof v["approxChars"] === "number") && (v["needsFreshStart"] === void 0 || isFreshStartMarker(v["needsFreshStart"]));
114567
+ return typeof v["threadId"] === "string" && typeof v["sessionId"] === "string" && typeof v["botId"] === "string" && typeof v["createdTs"] === "number" && typeof v["lastActiveTs"] === "number" && (v["senderOpenId"] === void 0 || typeof v["senderOpenId"] === "string") && (v["rootText"] === void 0 || typeof v["rootText"] === "string") && (v["chatId"] === void 0 || typeof v["chatId"] === "string") && (v["chatType"] === void 0 || typeof v["chatType"] === "string") && (v["turnCount"] === void 0 || typeof v["turnCount"] === "number") && (v["harvestedAt"] === void 0 || typeof v["harvestedAt"] === "number") && (v["approxChars"] === void 0 || typeof v["approxChars"] === "number") && (v["needsFreshStart"] === void 0 || isFreshStartMarker(v["needsFreshStart"]));
114443
114568
  }
114444
114569
  function isFreshStartMarker(value) {
114445
114570
  if (typeof value !== "object" || value === null) return false;
@@ -116052,8 +116177,8 @@ async function writeAlways(filePath, content) {
116052
116177
  }
116053
116178
  async function ensureRelativeSymlink(linkPath, target) {
116054
116179
  try {
116055
- const stat4 = await fs4.lstat(linkPath);
116056
- if (stat4.isSymbolicLink()) {
116180
+ const stat5 = await fs4.lstat(linkPath);
116181
+ if (stat5.isSymbolicLink()) {
116057
116182
  const currentTarget = await fs4.readlink(linkPath);
116058
116183
  if (currentTarget === target) return;
116059
116184
  }
@@ -120116,7 +120241,15 @@ var BridgeHandler = class {
120116
120241
  turnCount: 1,
120117
120242
  // 批H (H2): volume accounting starts with this turn's own contribution.
120118
120243
  approxChars: turnToolResultChars + trustedAnswerText.length,
120119
- ...isTopLevel ? { rootText: parsed.text.slice(0, 200), chatId: parsed.chatId } : {}
120244
+ ...isTopLevel ? {
120245
+ rootText: parsed.text.slice(0, 200),
120246
+ chatId: parsed.chatId,
120247
+ // 2026-07-17 p2p message-loss fix: persist the chat's type
120248
+ // alongside chatId so ChannelClient.seedTrackedChats can
120249
+ // mark a p2p chat gap-fillable across restarts (p2p is
120250
+ // invisible to bot chat-list discovery).
120251
+ ...typeof parsed.raw.chat_type === "string" && parsed.raw.chat_type ? { chatType: parsed.raw.chat_type } : {}
120252
+ } : {}
120120
120253
  });
120121
120254
  } else if (sessionId !== void 0 && currentExisting !== void 0) {
120122
120255
  await this.deps.sessionStore.put({
@@ -120128,6 +120261,7 @@ var BridgeHandler = class {
120128
120261
  senderOpenId,
120129
120262
  rootText: currentExisting.rootText,
120130
120263
  chatId: currentExisting.chatId,
120264
+ chatType: currentExisting.chatType,
120131
120265
  // BL-38: +1 on an idle-stuck turn, 0 (cleared) on any clean turn.
120132
120266
  consecutiveStuckCount: nextStuckCount,
120133
120267
  // 批F (F2): a reseed turn restarts the count at 1 (this turn ran
@@ -121175,7 +121309,7 @@ async function cleanupAgentSession(threadId, agentId, dryRun) {
121175
121309
  }
121176
121310
 
121177
121311
  // src/config/botLoader.ts
121178
- import { readdir as readdir2, readFile as readFile5 } from "node:fs/promises";
121312
+ import { readdir as readdir2, readFile as readFile5, stat as stat2 } from "node:fs/promises";
121179
121313
  import path17 from "node:path";
121180
121314
 
121181
121315
  // node_modules/.pnpm/js-yaml@4.1.1/node_modules/js-yaml/dist/js-yaml.mjs
@@ -124363,9 +124497,56 @@ ${issues}`);
124363
124497
  }
124364
124498
  return { bots: deduped, skipped, strippedPeers };
124365
124499
  }
124500
+ var ScheduleSliceSchema = BotConfigSchema.pick({
124501
+ schedules: true,
124502
+ schedule_chat_id: true
124503
+ }).strip();
124504
+ function createScheduleConfigReloader(botsDir, botId) {
124505
+ let filePath;
124506
+ let lastMtimeMs;
124507
+ return async () => {
124508
+ try {
124509
+ if (filePath === void 0) {
124510
+ const entries = (await readdir2(botsDir)).filter(
124511
+ (f) => f.endsWith(".yaml") || f.endsWith(".yml")
124512
+ );
124513
+ for (const f of entries.sort()) {
124514
+ const p = path17.join(botsDir, f);
124515
+ try {
124516
+ const parsed2 = jsYaml.load(await readFile5(p, "utf-8"));
124517
+ if (parsed2 && parsed2["id"] === botId) {
124518
+ filePath = p;
124519
+ break;
124520
+ }
124521
+ } catch {
124522
+ }
124523
+ }
124524
+ if (filePath === void 0) return null;
124525
+ }
124526
+ const st = await stat2(filePath);
124527
+ if (lastMtimeMs !== void 0 && st.mtimeMs === lastMtimeMs) return null;
124528
+ const parsed = jsYaml.load(await readFile5(filePath, "utf-8"));
124529
+ const result = ScheduleSliceSchema.safeParse(parsed);
124530
+ if (!result.success) return null;
124531
+ lastMtimeMs = st.mtimeMs;
124532
+ return {
124533
+ schedules: result.data.schedules,
124534
+ schedule_chat_id: result.data.schedule_chat_id
124535
+ };
124536
+ } catch {
124537
+ try {
124538
+ if (filePath !== void 0) await stat2(filePath);
124539
+ } catch {
124540
+ filePath = void 0;
124541
+ lastMtimeMs = void 0;
124542
+ }
124543
+ return null;
124544
+ }
124545
+ };
124546
+ }
124366
124547
 
124367
124548
  // src/bridge/reconcile.ts
124368
- import { readdir as readdir3, stat as stat2 } from "node:fs/promises";
124549
+ import { readdir as readdir3, stat as stat3 } from "node:fs/promises";
124369
124550
  import { join as pathJoin2 } from "node:path";
124370
124551
 
124371
124552
  // src/bridge/postFile.ts
@@ -124989,7 +125170,7 @@ async function reconcileOrphanedCards(deps) {
124989
125170
  let ageMs = 0;
124990
125171
  try {
124991
125172
  const target = state ? stateFilePathOf(wtPath) : wtPath;
124992
- ageMs = now - (await stat2(target)).mtimeMs;
125173
+ ageMs = now - (await stat3(target)).mtimeMs;
124993
125174
  } catch {
124994
125175
  ageMs = 0;
124995
125176
  }
@@ -125509,7 +125690,7 @@ var ClaudeRunner = class {
125509
125690
 
125510
125691
  // src/claude/pool.ts
125511
125692
  import { execFile as execFile4, spawn as spawn3 } from "node:child_process";
125512
- import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as stat3, unlink as unlink4, writeFile as writeFile4 } from "node:fs/promises";
125693
+ import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as stat4, unlink as unlink4, writeFile as writeFile4 } from "node:fs/promises";
125513
125694
  import path20 from "node:path";
125514
125695
  import { createInterface as createInterface2 } from "node:readline";
125515
125696
  import { randomUUID } from "node:crypto";
@@ -126282,7 +126463,7 @@ async function reapOrphanedWarmClaudeProcesses(pidListFilePath) {
126282
126463
  return;
126283
126464
  }
126284
126465
  try {
126285
- const st = await stat3(pidListFilePath);
126466
+ const st = await stat4(pidListFilePath);
126286
126467
  const ageMs = Date.now() - st.mtimeMs;
126287
126468
  if (ageMs < REAP_FRESHNESS_GUARD_MS) {
126288
126469
  console.warn(
@@ -129534,6 +129715,9 @@ function decideCronDue(nextFireAt, now, graceMinutes) {
129534
129715
  if (overdueMinutes > graceMinutes) return { kind: "misfire_skip", overdueMinutes };
129535
129716
  return { kind: "fire" };
129536
129717
  }
129718
+ function fingerprintConfig(schedules, defaultChatId) {
129719
+ return JSON.stringify([schedules, defaultChatId ?? null]);
129720
+ }
129537
129721
  function decideOneShotDue(wake, now) {
129538
129722
  const at = Date.parse(wake.at);
129539
129723
  if (Number.isNaN(at)) return { kind: "expired" };
@@ -129550,6 +129734,9 @@ var BotScheduler = class {
129550
129734
  #statePath;
129551
129735
  #wakesDir;
129552
129736
  #cron = [];
129737
+ #defaultChatId;
129738
+ /** Fingerprint of the applied config — reload no-ops (and stays silent) when unchanged. */
129739
+ #configFingerprint;
129553
129740
  #timer;
129554
129741
  #running = false;
129555
129742
  /** Awaitable current cycle — same M1 shutdown-drain shape as CommentPoller. */
@@ -129559,14 +129746,22 @@ var BotScheduler = class {
129559
129746
  this.#tickMs = opts?.tickMs ?? DEFAULT_TICK_MS;
129560
129747
  this.#statePath = path23.join(deps.botDir, "schedule-state.json");
129561
129748
  this.#wakesDir = path23.join(deps.botDir, "wakes");
129562
- for (const [i, entry] of deps.schedules.entries()) {
129749
+ this.#defaultChatId = deps.defaultChatId;
129750
+ this.#configFingerprint = fingerprintConfig(deps.schedules, deps.defaultChatId);
129751
+ this.#applyCronConfig(deps.schedules);
129752
+ }
129753
+ /** (Re)build the parsed cron entry list from a config slice. */
129754
+ #applyCronConfig(schedules) {
129755
+ const next = [];
129756
+ for (const [i, entry] of schedules.entries()) {
129563
129757
  if (entry.enabled === false) continue;
129564
129758
  try {
129565
- this.#cron.push({ key: cronEntryKey(i, entry), entry, spec: parseCron(entry.cron) });
129759
+ next.push({ key: cronEntryKey(i, entry), entry, spec: parseCron(entry.cron) });
129566
129760
  } catch (err) {
129567
129761
  this.#log(`schedule entry ${i} skipped (bad cron "${entry.cron}"): ${String(err)}`);
129568
129762
  }
129569
129763
  }
129764
+ this.#cron = next;
129570
129765
  }
129571
129766
  #log(line) {
129572
129767
  (this.#deps.log ?? ((s) => console.log(`[schedule] ${s}`)))(
@@ -129631,6 +129826,11 @@ var BotScheduler = class {
129631
129826
  // -- tick ------------------------------------------------------------------
129632
129827
  async #tick() {
129633
129828
  if (!this.#running) return;
129829
+ try {
129830
+ await this.#maybeReloadConfig();
129831
+ } catch (err) {
129832
+ this.#log(`config reload failed (keeping current schedules): ${String(err)}`);
129833
+ }
129634
129834
  try {
129635
129835
  await this.#tickCron();
129636
129836
  } catch (err) {
@@ -129642,6 +129842,20 @@ var BotScheduler = class {
129642
129842
  this.#log(`one-shot tick failed (next tick retries): ${String(err)}`);
129643
129843
  }
129644
129844
  }
129845
+ /** Hot-reload: apply a changed yaml schedules slice mid-flight (docs/schedule.md). */
129846
+ async #maybeReloadConfig() {
129847
+ if (!this.#deps.reloadConfig) return;
129848
+ const next = await this.#deps.reloadConfig();
129849
+ if (!next) return;
129850
+ const fingerprint = fingerprintConfig(next.schedules, next.schedule_chat_id);
129851
+ if (fingerprint === this.#configFingerprint) return;
129852
+ this.#configFingerprint = fingerprint;
129853
+ this.#defaultChatId = next.schedule_chat_id;
129854
+ this.#applyCronConfig(next.schedules);
129855
+ this.#log(
129856
+ `schedules hot-reloaded from yaml: ${this.#cron.length} active cron entr${this.#cron.length === 1 ? "y" : "ies"} (no restart needed)`
129857
+ );
129858
+ }
129645
129859
  async #tickCron() {
129646
129860
  if (this.#cron.length === 0) return;
129647
129861
  const now = this.#now();
@@ -129669,7 +129883,7 @@ var BotScheduler = class {
129669
129883
  `cron "${entry.cron}" missed by ${decision.overdueMinutes.toFixed(1)}min (> grace ${grace}min) \u2014 skipped, next at ${advance?.toISOString() ?? "never"}`
129670
129884
  );
129671
129885
  } else {
129672
- const chatId = entry.chat_id ?? this.#deps.defaultChatId;
129886
+ const chatId = entry.chat_id ?? this.#defaultChatId;
129673
129887
  if (!chatId) {
129674
129888
  this.#log(`cron "${entry.cron}" due but no chat_id/schedule_chat_id \u2014 skipped`);
129675
129889
  } else {
@@ -129733,7 +129947,7 @@ var BotScheduler = class {
129733
129947
  await unlink6(full).catch(() => void 0);
129734
129948
  continue;
129735
129949
  }
129736
- const chatId = wake.chat_id ?? this.#deps.defaultChatId;
129950
+ const chatId = wake.chat_id ?? this.#defaultChatId;
129737
129951
  if (!chatId) {
129738
129952
  this.#log(`wake ${file} due but no chat_id/schedule_chat_id \u2014 dropped`);
129739
129953
  await unlink6(full).catch(() => void 0);
@@ -129886,6 +130100,11 @@ async function runV2Mode({
129886
130100
  larkCliProfile,
129887
130101
  larkwayDir: larkwayHome()
129888
130102
  });
130103
+ client.seedTrackedChats(
130104
+ sessionStore.list().flatMap(
130105
+ (r) => r.chatId ? [{ chatId: r.chatId, ...r.chatType ? { chatType: r.chatType } : {} }] : []
130106
+ )
130107
+ );
129889
130108
  console.log(`[larkway] bot "${bot.id}" inbound transport = Channel SDK (WS in-process)`);
129890
130109
  localHandoffRegistry.register(
129891
130110
  { botId: bot.id, name: bot.name, botOpenId: bot.bot_open_id },
@@ -130255,6 +130474,13 @@ async function runV2Mode({
130255
130474
  botDir,
130256
130475
  schedules: bot.schedules,
130257
130476
  defaultChatId: bot.schedule_chat_id,
130477
+ // Hot-reload: edits to the bot yaml's schedules:/schedule_chat_id go
130478
+ // live within one tick (≤30s) — no bridge restart. Every other yaml
130479
+ // field still needs a restart (loaded once at boot).
130480
+ reloadConfig: createScheduleConfigReloader(
130481
+ process.env["LARKWAY_BOTS_DIR"] ? path24.resolve(process.env["LARKWAY_BOTS_DIR"]) : path24.join(larkwayHome(), "bots"),
130482
+ bot.id
130483
+ ),
130258
130484
  log: (line) => console.log(`[schedule] ${line}`),
130259
130485
  fire: async ({ prompt, note, chatId, source, id, occurrence }) => {
130260
130486
  let mirrorId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.55",
3
+ "version": "0.3.57",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",