larkway 0.3.37 → 0.3.39

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.37**
11
+ **Current release: v0.3.39**
12
12
 
13
13
  ---
14
14
 
@@ -105,6 +105,45 @@ Larkway never injects `ANTHROPIC_API_KEY` or any other API key. The subprocess i
105
105
 
106
106
  ---
107
107
 
108
+ ## Security model — read this before inviting the bot anywhere
109
+
110
+ Larkway runs a real coding agent **on your machine, with your permissions**. Be
111
+ explicit about what that means:
112
+
113
+ **Who can trigger it.** By default a bot ships with `chats: []` — *open mode*:
114
+ it responds to an @-mention in **any** group it has been added to. Anyone who
115
+ can add the bot to a group (or is in a group with it) can make it act. To
116
+ narrow this, list allowed group ids in the bot's yaml (`chats: [oc_…]`); the
117
+ bot then ignores every other chat.
118
+
119
+ **What a triggered agent can do.** The agent subprocess is not sandboxed by
120
+ Larkway. With the default permission mode (`bypassPermissions` — required for
121
+ unattended headless operation, where an interactive permission prompt would
122
+ silently hang the turn), a Claude-backend agent can read/write files, run
123
+ commands, and use the network as your OS user. The Codex backend's equivalent
124
+ default is full access. You can tighten this globally via `permissions.mode`
125
+ in `~/.larkway/config.json` (`acceptEdits` / `ask`), at the cost of turns
126
+ stalling on operations the mode blocks. **Note the mode words are Claude
127
+ semantics; on Codex anything other than `ask` currently maps to full access.**
128
+
129
+ **Prompt injection is real.** Everything in the triggering message — and
130
+ thread history / attachments the agent chooses to read — becomes agent input.
131
+ A malicious group member can try to steer the agent ("ignore your instructions
132
+ and run …"). L2 memory and your repo's `CLAUDE.md`/`AGENTS.md` are guidance,
133
+ not enforcement.
134
+
135
+ **Practical posture.**
136
+ - Treat "which groups is this bot in" as the primary security boundary; use
137
+ `chats:` allowlists for any bot with write access.
138
+ - Run write-capable bots against repos where an unwanted commit/MR is
139
+ reviewable and revertable; keep production credentials out of the host's
140
+ environment.
141
+ - Secrets in `~/.larkway/.env` are shared across all bots on the bridge and
142
+ visible to any agent that can read your home directory — don't mix a
143
+ low-trust open bot and high-value credentials on the same host.
144
+
145
+ ---
146
+
108
147
  ## Defining a bot (three layers)
109
148
 
110
149
  | Layer | What it is | Where it lives |
@@ -122,7 +161,7 @@ Secrets live only in `~/.larkway/.env` (mode 0600). Config and memory contain no
122
161
  - **Multiple bots on one bridge** — a read-only Q&A bot and a write-capable engineering bot can share the same process, each with its own L1/L2/L3 definition
123
162
  - **Web UI** — `larkway ui` opens a local management dashboard (127.0.0.1 + token); create bots, edit memory, watch live logs
124
163
  - **Session continuity** — every Feishu thread maps to a persistent `session_id`; the agent remembers what it did in prior turns
125
- - **Agent Workspace** — per-thread git worktrees; the agent can run multiple threads concurrently without git conflicts
164
+ - **Agent Workspace** — each bot gets its own workspace where the agent clones the repo itself, with per-thread session dirs; concurrent threads don't trip over each other's git state (expect disk usage and a slower first turn on large repos — clones are per-session, GC'd after 24h idle)
126
165
  - **Codex runtime pre-checks** — `larkway doctor` validates Codex state directory writability before start
127
166
  - **Topic ↔ Feishu task handle** — turn a topic into a Feishu task and the agent claims it, then keeps its lifecycle (done/failed/reopened, stalled, handed off, overdue) in sync automatically — see below
128
167
 
@@ -157,7 +196,7 @@ platform-fact writeups: [docs/task-handle.md](docs/task-handle.md).
157
196
 
158
197
  - **Node.js 20+ LTS**
159
198
  - **A Claude Code or Codex subscription** with local CLI installed and logged in
160
- - **`lark-cli`** — Feishu long-connection client and message utilities
199
+ - **`lark-cli`** — Feishu long-connection client and message utilities: `npm i -g @larksuite/cli`, then `lark-cli auth login` (the agent uses it to read thread history and attachments; `larkway doctor` checks it's present)
161
200
  - **`glab` + `git`** — for bots that open MRs (optional for read-only bots)
162
201
  - **An always-on host machine** — the bridge must stay running to receive Feishu events; a laptop that sleeps will miss messages; a small server or desktop works well
163
202
 
package/README.zh.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  你在飞书话题里 @ bot,它在你的机器上运行——读真实代码库、执行命令、开 MR——把结果贴回飞书。你定义 agent 知道什么、能做什么。Larkway 只负责传递消息。
10
10
 
11
- **当前版本:v0.3.37**
11
+ **当前版本:v0.3.39**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -112719,15 +112719,27 @@ var init_channelClient = __esm({
112719
112719
  inFlightMessageIds = /* @__PURE__ */ new Set();
112720
112720
  /**
112721
112721
  * Per-message (re-)dispatch counter for the poison-message guard. Incremented
112722
- * every time a message_id is pushed onto the inbound queue (live WS or either
112723
- * gap-fill branch) and once more when a turn is released as unhandled. When the
112724
- * count reaches {@link MAX_MESSAGE_ATTEMPTS}, markUnhandled GIVES UP: it
112725
- * promotes the message to seen (so it stops being re-dispatched) and logs a
112726
- * warning. markHandled clears the entry on terminal success. Not persisted:
112722
+ * ONCE per dispatch — every time a message_id is pushed onto the inbound
112723
+ * queue (live WS or either gap-fill branch), and nowhere else, so the cap
112724
+ * really means "N dispatches". When a turn fails and the count has reached
112725
+ * {@link MAX_MESSAGE_ATTEMPTS}, markUnhandled GIVES UP: it promotes the
112726
+ * message to seen (so it stops being re-dispatched) and logs a warning.
112727
+ * markHandled clears the entry on terminal success. Not persisted:
112727
112728
  * post-restart, an interrupted message starts fresh — same policy as
112728
112729
  * inFlightMessageIds.
112729
112730
  */
112730
112731
  messageAttempts = /* @__PURE__ */ new Map();
112732
+ /**
112733
+ * chatId + create_time for every in-flight message, captured at dispatch.
112734
+ * Consumed by {@link markUnhandled}: a FAILED turn records an unresolved gap
112735
+ * window for its chat so a later replay actually re-pulls (and re-dispatches)
112736
+ * it. Without this there is no steady-state trigger for the re-dispatch that
112737
+ * markUnhandled promises — chats-mode bots have no discovery timer at all,
112738
+ * and open-mode steady-state discovery pulls 0 for already-known chats — so
112739
+ * a turn failing before any visible surface existed was silently dropped.
112740
+ * Cleared in markHandled / markUnhandled; bounded by the in-flight set.
112741
+ */
112742
+ inFlightMessageMeta = /* @__PURE__ */ new Map();
112731
112743
  /**
112732
112744
  * Chats observed from live WS events during this process lifetime.
112733
112745
  *
@@ -112762,6 +112774,17 @@ var init_channelClient = __esm({
112762
112774
  openChatDiscoveryTimer = null;
112763
112775
  openChatDiscoveryRunning = false;
112764
112776
  openChatDiscoveryBootstrapped = false;
112777
+ /**
112778
+ * Chats-mode (allowedChatIds non-empty) counterpart of the open-mode
112779
+ * discovery cycle's unresolved-window replay: those bots never start
112780
+ * discovery, so without this timer a window recorded by markUnhandled had
112781
+ * NO trigger to replay it (the only other gapFill source is a WS reconnect,
112782
+ * which a healthy network may not produce for days). Steady state with no
112783
+ * pending windows does nothing — zero API calls — so the 0.3.28 storm fix
112784
+ * is preserved.
112785
+ */
112786
+ unresolvedReplayTimer = null;
112787
+ unresolvedReplayRunning = false;
112765
112788
  /**
112766
112789
  * Consecutive discovery-cycle failures. Used to SKIP cycles with exponential
112767
112790
  * backoff (storm: a failing +chat-list/gap-fill shouldn't re-fire every
@@ -112967,6 +112990,7 @@ var init_channelClient = __esm({
112967
112990
  return;
112968
112991
  }
112969
112992
  this.inFlightMessageIds.add(ev.message_id);
112993
+ this.noteInFlightMeta(ev);
112970
112994
  this.noteDispatchAttempt(ev.message_id);
112971
112995
  log(`dispatching (channel-sdk): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
112972
112996
  this.queue.push(ev);
@@ -112995,6 +113019,7 @@ var init_channelClient = __esm({
112995
113019
  this.connected = true;
112996
113020
  log(`connected as ${channel.botIdentity?.name ?? "?"} (${channel.botIdentity?.openId ?? "?"})`);
112997
113021
  this.startOpenChatDiscovery(log);
113022
+ this.startUnresolvedReplayTimer(log);
112998
113023
  }
112999
113024
  /**
113000
113025
  * Turn a card-button click into a synthesized LarkMessageEvent pushed onto the
@@ -113235,6 +113260,7 @@ var init_channelClient = __esm({
113235
113260
  })();
113236
113261
  if (!fallbackEv) continue;
113237
113262
  this.inFlightMessageIds.add(fallbackEv.message_id);
113263
+ this.noteInFlightMeta(fallbackEv);
113238
113264
  this.noteDispatchAttempt(fallbackEv.message_id);
113239
113265
  log(`gap-fill dispatching (fallback): message_id=${fallbackEv.message_id} chat=${chatId}`);
113240
113266
  this.queue.push(fallbackEv);
@@ -113242,6 +113268,7 @@ var init_channelClient = __esm({
113242
113268
  continue;
113243
113269
  }
113244
113270
  this.inFlightMessageIds.add(ev.message_id);
113271
+ this.noteInFlightMeta(ev);
113245
113272
  this.noteDispatchAttempt(ev.message_id);
113246
113273
  log(`gap-fill dispatching: message_id=${ev.message_id} thread=${ev.thread_id ?? "?"} chat=${chatId}`);
113247
113274
  this.queue.push(ev);
@@ -113332,6 +113359,37 @@ var init_channelClient = __esm({
113332
113359
  if (tracked === void 0) return;
113333
113360
  if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
113334
113361
  }
113362
+ /**
113363
+ * Chats-mode replay loop (see {@link unresolvedReplayTimer}): every discovery
113364
+ * interval, IF any unresolved gap window is pending, gapFill exactly those
113365
+ * chats. Open-mode bots don't need this — their discovery cycle already
113366
+ * includes unresolved-window chats in its target set.
113367
+ */
113368
+ startUnresolvedReplayTimer(log) {
113369
+ if (this.opts.allowedChatIds.size === 0) return;
113370
+ if (this.unresolvedReplayTimer) return;
113371
+ const intervalMs = resolveOpenChatDiscoveryMs(this.opts.openChatDiscoveryMs);
113372
+ if (intervalMs <= 0) return;
113373
+ this.unresolvedReplayTimer = setInterval(() => {
113374
+ void this.replayUnresolvedWindows(log);
113375
+ }, intervalMs);
113376
+ this.unresolvedReplayTimer.unref?.();
113377
+ }
113378
+ async replayUnresolvedWindows(log) {
113379
+ if (this.closed || this.unresolvedReplayRunning) return;
113380
+ this.pruneUnresolvedGapWindows(Date.now());
113381
+ if (this.unresolvedGapWindowByChat.size === 0) return;
113382
+ this.unresolvedReplayRunning = true;
113383
+ try {
113384
+ const chats = new Set(this.unresolvedGapWindowByChat.keys());
113385
+ log(`unresolved-window replay: re-pulling ${chats.size} chat(s)`);
113386
+ await this.gapFill(Date.now(), log, chats);
113387
+ } catch (e) {
113388
+ log(`unresolved-window replay failed: ${e instanceof Error ? e.message : String(e)}`);
113389
+ } finally {
113390
+ this.unresolvedReplayRunning = false;
113391
+ }
113392
+ }
113335
113393
  startOpenChatDiscovery(log) {
113336
113394
  if (this.opts.allowedChatIds.size > 0) return;
113337
113395
  if (this.openChatDiscoveryTimer) return;
@@ -113601,6 +113659,7 @@ var init_channelClient = __esm({
113601
113659
  */
113602
113660
  markHandled(messageId) {
113603
113661
  this.inFlightMessageIds.delete(messageId);
113662
+ this.inFlightMessageMeta.delete(messageId);
113604
113663
  this.messageAttempts.delete(messageId);
113605
113664
  this.noteSeenMessage(messageId);
113606
113665
  }
@@ -113610,38 +113669,63 @@ var init_channelClient = __esm({
113610
113669
  * self-heal — one transient blip no longer swallows the @ forever). Does not
113611
113670
  * touch persisted seen state.
113612
113671
  *
113613
- * Poison-message guard: count this failed turn as one more attempt. If the
113614
- * message has now failed {@link MAX_MESSAGE_ATTEMPTS} times, GIVE UP — promote
113615
- * it to seen (so it stops being re-dispatched on every gap-fill) and log a
113616
- * visible warning instead of silently looping forever.
113672
+ * Poison-message guard: if the message has already been dispatched
113673
+ * {@link MAX_MESSAGE_ATTEMPTS} times, GIVE UP — promote it to seen (so it
113674
+ * stops being re-dispatched on every gap-fill) and log a visible warning
113675
+ * instead of silently looping forever. Otherwise, record an unresolved gap
113676
+ * window for the message's chat so the next replay cycle re-dispatches it.
113617
113677
  */
113618
- markUnhandled(messageId) {
113678
+ markUnhandled(messageId, opts) {
113619
113679
  this.inFlightMessageIds.delete(messageId);
113620
- const attempts = (this.messageAttempts.get(messageId) ?? 0) + 1;
113621
- this.messageAttempts.set(messageId, attempts);
113680
+ const meta = this.inFlightMessageMeta.get(messageId);
113681
+ this.inFlightMessageMeta.delete(messageId);
113682
+ const attempts = this.messageAttempts.get(messageId) ?? 0;
113622
113683
  if (attempts >= MAX_MESSAGE_ATTEMPTS) {
113623
113684
  console.warn(
113624
113685
  `[channel.client] giving up on message_id=${messageId} after ${attempts} failed attempts \u2014 promoting to seen so it is no longer re-dispatched (poison-message guard)`
113625
113686
  );
113626
113687
  this.messageAttempts.delete(messageId);
113627
113688
  this.noteSeenMessage(messageId);
113689
+ return;
113690
+ }
113691
+ if (meta && opts?.replay !== false) {
113692
+ this.recordUnresolvedGapWindow(
113693
+ meta.chatId,
113694
+ meta.createTimeMs - 1e3,
113695
+ (s) => console.log(`[channel.client] ${s}`)
113696
+ );
113628
113697
  }
113629
113698
  }
113630
113699
  /**
113631
113700
  * Increment the per-message dispatch counter (poison-message guard). Called
113632
- * each time a message_id is pushed onto the inbound queue. The counter is also
113633
- * bumped in {@link markUnhandled} so both dispatch and failed settlement
113634
- * contribute toward the cap.
113701
+ * each time a message_id is pushed onto the inbound queue and ONLY then;
113702
+ * {@link markUnhandled} deliberately reads without bumping, so the cap means
113703
+ * "N real dispatches" (see the messageAttempts field doc).
113635
113704
  */
113636
113705
  noteDispatchAttempt(messageId) {
113637
113706
  this.messageAttempts.set(messageId, (this.messageAttempts.get(messageId) ?? 0) + 1);
113638
113707
  }
113708
+ /**
113709
+ * Capture chatId + create_time for an in-flight message at dispatch time, so
113710
+ * {@link markUnhandled} can queue an unresolved gap window that reaches back
113711
+ * to the message itself. An unparseable create_time falls back to "1 min ago"
113712
+ * — wide enough to cover the message without flooding the replay pull.
113713
+ */
113714
+ noteInFlightMeta(ev) {
113715
+ const t = Number(ev.create_time);
113716
+ const createTimeMs = Number.isFinite(t) && t > 0 ? t < 1e12 ? t * 1e3 : t : Date.now() - 6e4;
113717
+ this.inFlightMessageMeta.set(ev.message_id, { chatId: ev.chat_id, createTimeMs });
113718
+ }
113639
113719
  async close() {
113640
113720
  this.closed = true;
113641
113721
  if (this.openChatDiscoveryTimer) {
113642
113722
  clearInterval(this.openChatDiscoveryTimer);
113643
113723
  this.openChatDiscoveryTimer = null;
113644
113724
  }
113725
+ if (this.unresolvedReplayTimer) {
113726
+ clearInterval(this.unresolvedReplayTimer);
113727
+ this.unresolvedReplayTimer = null;
113728
+ }
113645
113729
  this.queue.close();
113646
113730
  if (this.channel && this.connected) {
113647
113731
  try {
@@ -127722,6 +127806,19 @@ async function checkClaude(ctx) {
127722
127806
  message: required ? claudeLoginHint() : "\u672A\u68C0\u6D4B\u5230 Claude \u767B\u5F55\u6001(\u5F53\u524D\u65E0 bot \u4F7F\u7528 claude backend,\u53EF\u5FFD\u7565)\u3002\u5982\u9700\u4F7F\u7528 Claude Code backend,\u8BF7\u5148\u8FD0\u884C `claude` \u767B\u5F55\u3002"
127723
127807
  };
127724
127808
  }
127809
+ async function checkLarkCli() {
127810
+ try {
127811
+ await execFileAsync4("lark-cli", ["--version"], { timeout: 1e4 });
127812
+ return { id: "lark-cli-binary", label: "lark-cli", status: "ok" };
127813
+ } catch {
127814
+ return {
127815
+ id: "lark-cli-binary",
127816
+ label: "lark-cli",
127817
+ status: "error",
127818
+ message: "\u672A\u627E\u5230 `lark-cli`(\u98DE\u4E66\u957F\u8FDE\u63A5/\u6D88\u606F\u5DE5\u5177,larkway \u786C\u4F9D\u8D56)\u3002\u5B89\u88C5:`npm i -g @larksuite/cli`,\u7136\u540E `lark-cli auth login` \u5B8C\u6210\u6388\u6743\u3002"
127819
+ };
127820
+ }
127821
+ }
127725
127822
  async function checkCodex(ctx) {
127726
127823
  const results = [];
127727
127824
  const required = await backendRequired(ctx, "codex");
@@ -128149,6 +128246,7 @@ async function runAllChecks(ctx, opts) {
128149
128246
  results.push(await checkClaude(ctx));
128150
128247
  const credChecks = await checkFeishuCreds(ctx);
128151
128248
  results.push(...credChecks);
128249
+ results.push(await checkLarkCli());
128152
128250
  const yamlChecks = await checkBotYaml(ctx);
128153
128251
  results.push(...yamlChecks);
128154
128252
  const wtChecks = await checkWorktrees(ctx);
@@ -130998,6 +131096,7 @@ async function finalizeOnboard(sessionId, form) {
130998
131096
  if (!s._creds) {
130999
131097
  throw new Error("\u5185\u90E8\u9519\u8BEF:creds \u672A\u6301\u6709");
131000
131098
  }
131099
+ s.status = "finalizing";
131001
131100
  try {
131002
131101
  const { botId } = await createBotFromCreds({
131003
131102
  creds: s._creds,
@@ -131019,12 +131118,16 @@ async function finalizeOnboard(sessionId, form) {
131019
131118
  async function cancelOnboard(sessionId) {
131020
131119
  const s = sessions.get(sessionId);
131021
131120
  if (!s) return { cancelled: false };
131022
- if (s.status === "done" || s.status === "error" || s.status === "cancelled") {
131121
+ if (s.status === "done" || s.status === "error" || s.status === "cancelled" || // A finalize is already writing this session's bot to disk — treat like a
131122
+ // terminal state (racing a SECOND createBotFromCreds here would land two
131123
+ // bots from one Feishu app). The finalize's own outcome stands.
131124
+ s.status === "finalizing") {
131023
131125
  return { cancelled: false };
131024
131126
  }
131025
131127
  if (s.status === "awaiting-name" && s._creds) {
131026
131128
  const defaultName = s.prefill?.suggestedName || "\u65B0\u52A9\u624B";
131027
131129
  const form = { name: defaultName };
131130
+ s.status = "finalizing";
131028
131131
  try {
131029
131132
  const { botId } = await createBotFromCreds({
131030
131133
  creds: s._creds,
package/dist/main.js CHANGED
@@ -116846,15 +116846,27 @@ var ChannelClient = class {
116846
116846
  inFlightMessageIds = /* @__PURE__ */ new Set();
116847
116847
  /**
116848
116848
  * Per-message (re-)dispatch counter for the poison-message guard. Incremented
116849
- * every time a message_id is pushed onto the inbound queue (live WS or either
116850
- * gap-fill branch) and once more when a turn is released as unhandled. When the
116851
- * count reaches {@link MAX_MESSAGE_ATTEMPTS}, markUnhandled GIVES UP: it
116852
- * promotes the message to seen (so it stops being re-dispatched) and logs a
116853
- * warning. markHandled clears the entry on terminal success. Not persisted:
116849
+ * ONCE per dispatch — every time a message_id is pushed onto the inbound
116850
+ * queue (live WS or either gap-fill branch), and nowhere else, so the cap
116851
+ * really means "N dispatches". When a turn fails and the count has reached
116852
+ * {@link MAX_MESSAGE_ATTEMPTS}, markUnhandled GIVES UP: it promotes the
116853
+ * message to seen (so it stops being re-dispatched) and logs a warning.
116854
+ * markHandled clears the entry on terminal success. Not persisted:
116854
116855
  * post-restart, an interrupted message starts fresh — same policy as
116855
116856
  * inFlightMessageIds.
116856
116857
  */
116857
116858
  messageAttempts = /* @__PURE__ */ new Map();
116859
+ /**
116860
+ * chatId + create_time for every in-flight message, captured at dispatch.
116861
+ * Consumed by {@link markUnhandled}: a FAILED turn records an unresolved gap
116862
+ * window for its chat so a later replay actually re-pulls (and re-dispatches)
116863
+ * it. Without this there is no steady-state trigger for the re-dispatch that
116864
+ * markUnhandled promises — chats-mode bots have no discovery timer at all,
116865
+ * and open-mode steady-state discovery pulls 0 for already-known chats — so
116866
+ * a turn failing before any visible surface existed was silently dropped.
116867
+ * Cleared in markHandled / markUnhandled; bounded by the in-flight set.
116868
+ */
116869
+ inFlightMessageMeta = /* @__PURE__ */ new Map();
116858
116870
  /**
116859
116871
  * Chats observed from live WS events during this process lifetime.
116860
116872
  *
@@ -116889,6 +116901,17 @@ var ChannelClient = class {
116889
116901
  openChatDiscoveryTimer = null;
116890
116902
  openChatDiscoveryRunning = false;
116891
116903
  openChatDiscoveryBootstrapped = false;
116904
+ /**
116905
+ * Chats-mode (allowedChatIds non-empty) counterpart of the open-mode
116906
+ * discovery cycle's unresolved-window replay: those bots never start
116907
+ * discovery, so without this timer a window recorded by markUnhandled had
116908
+ * NO trigger to replay it (the only other gapFill source is a WS reconnect,
116909
+ * which a healthy network may not produce for days). Steady state with no
116910
+ * pending windows does nothing — zero API calls — so the 0.3.28 storm fix
116911
+ * is preserved.
116912
+ */
116913
+ unresolvedReplayTimer = null;
116914
+ unresolvedReplayRunning = false;
116892
116915
  /**
116893
116916
  * Consecutive discovery-cycle failures. Used to SKIP cycles with exponential
116894
116917
  * backoff (storm: a failing +chat-list/gap-fill shouldn't re-fire every
@@ -117094,6 +117117,7 @@ var ChannelClient = class {
117094
117117
  return;
117095
117118
  }
117096
117119
  this.inFlightMessageIds.add(ev.message_id);
117120
+ this.noteInFlightMeta(ev);
117097
117121
  this.noteDispatchAttempt(ev.message_id);
117098
117122
  log(`dispatching (channel-sdk): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
117099
117123
  this.queue.push(ev);
@@ -117122,6 +117146,7 @@ var ChannelClient = class {
117122
117146
  this.connected = true;
117123
117147
  log(`connected as ${channel.botIdentity?.name ?? "?"} (${channel.botIdentity?.openId ?? "?"})`);
117124
117148
  this.startOpenChatDiscovery(log);
117149
+ this.startUnresolvedReplayTimer(log);
117125
117150
  }
117126
117151
  /**
117127
117152
  * Turn a card-button click into a synthesized LarkMessageEvent pushed onto the
@@ -117362,6 +117387,7 @@ var ChannelClient = class {
117362
117387
  })();
117363
117388
  if (!fallbackEv) continue;
117364
117389
  this.inFlightMessageIds.add(fallbackEv.message_id);
117390
+ this.noteInFlightMeta(fallbackEv);
117365
117391
  this.noteDispatchAttempt(fallbackEv.message_id);
117366
117392
  log(`gap-fill dispatching (fallback): message_id=${fallbackEv.message_id} chat=${chatId}`);
117367
117393
  this.queue.push(fallbackEv);
@@ -117369,6 +117395,7 @@ var ChannelClient = class {
117369
117395
  continue;
117370
117396
  }
117371
117397
  this.inFlightMessageIds.add(ev.message_id);
117398
+ this.noteInFlightMeta(ev);
117372
117399
  this.noteDispatchAttempt(ev.message_id);
117373
117400
  log(`gap-fill dispatching: message_id=${ev.message_id} thread=${ev.thread_id ?? "?"} chat=${chatId}`);
117374
117401
  this.queue.push(ev);
@@ -117459,6 +117486,37 @@ var ChannelClient = class {
117459
117486
  if (tracked === void 0) return;
117460
117487
  if (coveredFrom <= tracked) this.unresolvedGapWindowByChat.delete(chatId);
117461
117488
  }
117489
+ /**
117490
+ * Chats-mode replay loop (see {@link unresolvedReplayTimer}): every discovery
117491
+ * interval, IF any unresolved gap window is pending, gapFill exactly those
117492
+ * chats. Open-mode bots don't need this — their discovery cycle already
117493
+ * includes unresolved-window chats in its target set.
117494
+ */
117495
+ startUnresolvedReplayTimer(log) {
117496
+ if (this.opts.allowedChatIds.size === 0) return;
117497
+ if (this.unresolvedReplayTimer) return;
117498
+ const intervalMs = resolveOpenChatDiscoveryMs(this.opts.openChatDiscoveryMs);
117499
+ if (intervalMs <= 0) return;
117500
+ this.unresolvedReplayTimer = setInterval(() => {
117501
+ void this.replayUnresolvedWindows(log);
117502
+ }, intervalMs);
117503
+ this.unresolvedReplayTimer.unref?.();
117504
+ }
117505
+ async replayUnresolvedWindows(log) {
117506
+ if (this.closed || this.unresolvedReplayRunning) return;
117507
+ this.pruneUnresolvedGapWindows(Date.now());
117508
+ if (this.unresolvedGapWindowByChat.size === 0) return;
117509
+ this.unresolvedReplayRunning = true;
117510
+ try {
117511
+ const chats = new Set(this.unresolvedGapWindowByChat.keys());
117512
+ log(`unresolved-window replay: re-pulling ${chats.size} chat(s)`);
117513
+ await this.gapFill(Date.now(), log, chats);
117514
+ } catch (e) {
117515
+ log(`unresolved-window replay failed: ${e instanceof Error ? e.message : String(e)}`);
117516
+ } finally {
117517
+ this.unresolvedReplayRunning = false;
117518
+ }
117519
+ }
117462
117520
  startOpenChatDiscovery(log) {
117463
117521
  if (this.opts.allowedChatIds.size > 0) return;
117464
117522
  if (this.openChatDiscoveryTimer) return;
@@ -117728,6 +117786,7 @@ var ChannelClient = class {
117728
117786
  */
117729
117787
  markHandled(messageId) {
117730
117788
  this.inFlightMessageIds.delete(messageId);
117789
+ this.inFlightMessageMeta.delete(messageId);
117731
117790
  this.messageAttempts.delete(messageId);
117732
117791
  this.noteSeenMessage(messageId);
117733
117792
  }
@@ -117737,38 +117796,63 @@ var ChannelClient = class {
117737
117796
  * self-heal — one transient blip no longer swallows the @ forever). Does not
117738
117797
  * touch persisted seen state.
117739
117798
  *
117740
- * Poison-message guard: count this failed turn as one more attempt. If the
117741
- * message has now failed {@link MAX_MESSAGE_ATTEMPTS} times, GIVE UP — promote
117742
- * it to seen (so it stops being re-dispatched on every gap-fill) and log a
117743
- * visible warning instead of silently looping forever.
117799
+ * Poison-message guard: if the message has already been dispatched
117800
+ * {@link MAX_MESSAGE_ATTEMPTS} times, GIVE UP — promote it to seen (so it
117801
+ * stops being re-dispatched on every gap-fill) and log a visible warning
117802
+ * instead of silently looping forever. Otherwise, record an unresolved gap
117803
+ * window for the message's chat so the next replay cycle re-dispatches it.
117744
117804
  */
117745
- markUnhandled(messageId) {
117805
+ markUnhandled(messageId, opts) {
117746
117806
  this.inFlightMessageIds.delete(messageId);
117747
- const attempts = (this.messageAttempts.get(messageId) ?? 0) + 1;
117748
- this.messageAttempts.set(messageId, attempts);
117807
+ const meta = this.inFlightMessageMeta.get(messageId);
117808
+ this.inFlightMessageMeta.delete(messageId);
117809
+ const attempts = this.messageAttempts.get(messageId) ?? 0;
117749
117810
  if (attempts >= MAX_MESSAGE_ATTEMPTS) {
117750
117811
  console.warn(
117751
117812
  `[channel.client] giving up on message_id=${messageId} after ${attempts} failed attempts \u2014 promoting to seen so it is no longer re-dispatched (poison-message guard)`
117752
117813
  );
117753
117814
  this.messageAttempts.delete(messageId);
117754
117815
  this.noteSeenMessage(messageId);
117816
+ return;
117817
+ }
117818
+ if (meta && opts?.replay !== false) {
117819
+ this.recordUnresolvedGapWindow(
117820
+ meta.chatId,
117821
+ meta.createTimeMs - 1e3,
117822
+ (s) => console.log(`[channel.client] ${s}`)
117823
+ );
117755
117824
  }
117756
117825
  }
117757
117826
  /**
117758
117827
  * Increment the per-message dispatch counter (poison-message guard). Called
117759
- * each time a message_id is pushed onto the inbound queue. The counter is also
117760
- * bumped in {@link markUnhandled} so both dispatch and failed settlement
117761
- * contribute toward the cap.
117828
+ * each time a message_id is pushed onto the inbound queue and ONLY then;
117829
+ * {@link markUnhandled} deliberately reads without bumping, so the cap means
117830
+ * "N real dispatches" (see the messageAttempts field doc).
117762
117831
  */
117763
117832
  noteDispatchAttempt(messageId) {
117764
117833
  this.messageAttempts.set(messageId, (this.messageAttempts.get(messageId) ?? 0) + 1);
117765
117834
  }
117835
+ /**
117836
+ * Capture chatId + create_time for an in-flight message at dispatch time, so
117837
+ * {@link markUnhandled} can queue an unresolved gap window that reaches back
117838
+ * to the message itself. An unparseable create_time falls back to "1 min ago"
117839
+ * — wide enough to cover the message without flooding the replay pull.
117840
+ */
117841
+ noteInFlightMeta(ev) {
117842
+ const t = Number(ev.create_time);
117843
+ const createTimeMs = Number.isFinite(t) && t > 0 ? t < 1e12 ? t * 1e3 : t : Date.now() - 6e4;
117844
+ this.inFlightMessageMeta.set(ev.message_id, { chatId: ev.chat_id, createTimeMs });
117845
+ }
117766
117846
  async close() {
117767
117847
  this.closed = true;
117768
117848
  if (this.openChatDiscoveryTimer) {
117769
117849
  clearInterval(this.openChatDiscoveryTimer);
117770
117850
  this.openChatDiscoveryTimer = null;
117771
117851
  }
117852
+ if (this.unresolvedReplayTimer) {
117853
+ clearInterval(this.unresolvedReplayTimer);
117854
+ this.unresolvedReplayTimer = null;
117855
+ }
117772
117856
  this.queue.close();
117773
117857
  if (this.channel && this.connected) {
117774
117858
  try {
@@ -118248,13 +118332,27 @@ function compactErrorText(err) {
118248
118332
  }
118249
118333
 
118250
118334
  // src/claude/sessionStore.ts
118251
- import { rename as rename2, readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, copyFile } from "node:fs/promises";
118335
+ import { rename as rename2, readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, copyFile, unlink as unlink2 } from "node:fs/promises";
118252
118336
  import { dirname } from "node:path";
118253
118337
  var STORE_VERSION = 2;
118254
118338
  var TOUCH_DEBOUNCE_MS = 1e3;
118255
118339
  var SessionStore = class _SessionStore {
118256
118340
  #filePath;
118257
118341
  #map;
118342
+ /**
118343
+ * Serializes every #flush() through one chain — mirrors TaskHandleStore's
118344
+ * `#flushChain` (same rationale). Concurrent writers are real here: up to
118345
+ * MAX_CONCURRENT turns call `put()`/`delete()` while the touch-debounce
118346
+ * timer fires its own flush. Without serialization, two overlapping
118347
+ * `writeFile(SAME tmp path)+rename` pairs can interleave (both open with
118348
+ * O_TRUNC at their own offset 0) and land corrupt JSON as sessions.json.
118349
+ * Each link's #writeSnapshot() takes its #map snapshot when it actually
118350
+ * RUNS (not at enqueue time), so the LAST queued write is always the LAST
118351
+ * to land on disk.
118352
+ */
118353
+ #flushChain = Promise.resolve();
118354
+ /** Monotonic suffix so no two in-flight tmp files ever share a path. */
118355
+ #tmpCounter = 0;
118258
118356
  /** Whether a touch flush is pending */
118259
118357
  #touchDirty = false;
118260
118358
  #touchTimer;
@@ -118296,13 +118394,12 @@ var SessionStore = class _SessionStore {
118296
118394
  try {
118297
118395
  parsed = JSON.parse(raw);
118298
118396
  } catch {
118299
- throw new Error(
118300
- `[SessionStore] ${filePath} is not valid JSON \u2014 fix or delete the file and restart.`
118301
- );
118397
+ return await _SessionStore.#recoverFromCorruption(filePath, "is not valid JSON");
118302
118398
  }
118303
118399
  if (typeof parsed !== "object" || parsed === null || !("records" in parsed)) {
118304
- throw new Error(
118305
- `[SessionStore] ${filePath} is missing required fields (records) \u2014 fix or delete the file and restart.`
118400
+ return await _SessionStore.#recoverFromCorruption(
118401
+ filePath,
118402
+ "is missing required fields (records)"
118306
118403
  );
118307
118404
  }
118308
118405
  const file = parsed;
@@ -118316,8 +118413,9 @@ var SessionStore = class _SessionStore {
118316
118413
  );
118317
118414
  }
118318
118415
  if (typeof file.records !== "object" || file.records === null) {
118319
- throw new Error(
118320
- `[SessionStore] ${filePath} records field is not an object \u2014 fix or delete the file and restart.`
118416
+ return await _SessionStore.#recoverFromCorruption(
118417
+ filePath,
118418
+ "records field is not an object"
118321
118419
  );
118322
118420
  }
118323
118421
  const map2 = /* @__PURE__ */ new Map();
@@ -118325,14 +118423,44 @@ var SessionStore = class _SessionStore {
118325
118423
  file.records
118326
118424
  )) {
118327
118425
  if (!isStoredRecord(value)) {
118328
- throw new Error(
118329
- `[SessionStore] ${filePath} record "${key}" has unexpected shape \u2014 fix or delete the file and restart.`
118426
+ console.warn(
118427
+ `[SessionStore] ${filePath} record "${key}" has unexpected shape \u2014 skipping it.`
118330
118428
  );
118429
+ continue;
118331
118430
  }
118332
118431
  map2.set(key, value);
118333
118432
  }
118334
118433
  return new _SessionStore(filePath, map2);
118335
118434
  }
118435
+ /**
118436
+ * A corrupt sessions.json must not keep the whole bridge from booting:
118437
+ * load() runs in main.ts startup with no surrounding try/catch, so a throw
118438
+ * here used to take EVERY bot down and require manual file surgery.
118439
+ * Mirror TaskHandleStore's posture instead: move the bad file to a
118440
+ * timestamped `.corrupt-*` backup (never silently lost) and start from an
118441
+ * empty store. Cost: threads lose their resume mapping and start fresh
118442
+ * sessions — a safe degradation compared to a full outage.
118443
+ *
118444
+ * NOTE: an unknown FUTURE `version` still throws (see load) — that file is
118445
+ * valid data written by newer code, not corruption, and must not be nuked.
118446
+ */
118447
+ static async #recoverFromCorruption(filePath, reason) {
118448
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
118449
+ const backupPath = `${filePath}.corrupt-${ts}`;
118450
+ try {
118451
+ await rename2(filePath, backupPath);
118452
+ console.error(
118453
+ `[SessionStore] ${filePath} ${reason} \u2014 moved to ${backupPath}; starting with an empty store (existing threads will start fresh sessions).`
118454
+ );
118455
+ } catch (err) {
118456
+ console.error(
118457
+ `[SessionStore] ${filePath} ${reason} \u2014 backup rename failed (${String(err)}); starting with an empty store anyway.`
118458
+ );
118459
+ }
118460
+ const store = new _SessionStore(filePath, /* @__PURE__ */ new Map());
118461
+ await store.#flush();
118462
+ return store;
118463
+ }
118336
118464
  /**
118337
118465
  * Migrate a v1 sessions.json in-place to v2.
118338
118466
  * Backup written as `<path>.v1-backup-<ISO timestamp>` before any write.
@@ -118397,7 +118525,10 @@ var SessionStore = class _SessionStore {
118397
118525
  lastActiveTs: record.lastActiveTs,
118398
118526
  ...record.senderOpenId !== void 0 ? { senderOpenId: record.senderOpenId } : {},
118399
118527
  ...record.rootText !== void 0 ? { rootText: record.rootText } : {},
118400
- ...record.chatId !== void 0 ? { chatId: record.chatId } : {}
118528
+ ...record.chatId !== void 0 ? { chatId: record.chatId } : {},
118529
+ // BL-38: only persist when > 0 — a 0/undefined counter is a clean thread,
118530
+ // so passing consecutiveStuckCount: 0 naturally clears the field on reset.
118531
+ ...record.consecutiveStuckCount ? { consecutiveStuckCount: record.consecutiveStuckCount } : {}
118401
118532
  };
118402
118533
  this.#map.set(key, stored);
118403
118534
  await this.#flush();
@@ -118466,18 +118597,33 @@ var SessionStore = class _SessionStore {
118466
118597
  // Private helpers
118467
118598
  // -------------------------------------------------------------------------
118468
118599
  /**
118469
- * Atomic write: serialize write to <path>.tmp → fs.rename (POSIX atomic).
118600
+ * Atomic write, serialized through #flushChain (see field doc).
118601
+ * The caller of THIS flush still observes its own link's rejection;
118602
+ * the chain itself swallows it so one failure can't wedge later flushes.
118470
118603
  */
118471
- async #flush() {
118604
+ #flush() {
118605
+ const next = this.#flushChain.then(() => this.#writeSnapshot());
118606
+ this.#flushChain = next.catch(() => {
118607
+ });
118608
+ return next;
118609
+ }
118610
+ /** serialize → write to a unique tmp path → fs.rename (POSIX atomic). */
118611
+ async #writeSnapshot() {
118472
118612
  const file = {
118473
118613
  version: STORE_VERSION,
118474
118614
  records: Object.fromEntries(this.#map)
118475
118615
  };
118476
118616
  const json2 = JSON.stringify(file, null, 2);
118477
- const tmpPath = `${this.#filePath}.tmp`;
118617
+ const tmpPath = `${this.#filePath}.tmp.${process.pid}.${this.#tmpCounter++}`;
118478
118618
  await mkdir2(dirname(this.#filePath), { recursive: true });
118479
- await writeFile2(tmpPath, json2, "utf8");
118480
- await rename2(tmpPath, this.#filePath);
118619
+ try {
118620
+ await writeFile2(tmpPath, json2, "utf8");
118621
+ await rename2(tmpPath, this.#filePath);
118622
+ } catch (err) {
118623
+ await unlink2(tmpPath).catch(() => {
118624
+ });
118625
+ throw err;
118626
+ }
118481
118627
  }
118482
118628
  };
118483
118629
  function isStoredRecord(value) {
@@ -121803,6 +121949,13 @@ function derivePostIdempotencyKey(input) {
121803
121949
  var DEFAULT_CARDKIT_RESPONSE_SURFACE_TIMEOUT_MS = 20 * 60 * 1e3;
121804
121950
  var DEFAULT_CARDKIT_IDLE_TIMEOUT_MS = 3 * 60 * 1e3;
121805
121951
  var COT_BUBBLE_CREATE_BUDGET_MS = 3e3;
121952
+ var DEFAULT_STUCK_SESSION_RESET_AFTER = 3;
121953
+ function resolveStuckSessionResetAfter() {
121954
+ const raw = process.env.LARKWAY_STUCK_SESSION_RESET_AFTER;
121955
+ if (raw === void 0) return DEFAULT_STUCK_SESSION_RESET_AFTER;
121956
+ const n = Number.parseInt(raw, 10);
121957
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_STUCK_SESSION_RESET_AFTER;
121958
+ }
121806
121959
  function summarizeMentionPolicyRules(rules) {
121807
121960
  const counts = /* @__PURE__ */ new Map();
121808
121961
  for (const rule of rules) {
@@ -121810,6 +121963,7 @@ function summarizeMentionPolicyRules(rules) {
121810
121963
  }
121811
121964
  return Array.from(counts.entries()).map(([rule, count]) => `${rule}=${count}`).join(", ");
121812
121965
  }
121966
+ var GIT_OP_TIMEOUT_MS = 10 * 60 * 1e3;
121813
121967
  function execGit(cwd, args) {
121814
121968
  return new Promise((resolve2, reject) => {
121815
121969
  const child = child_process.spawn("git", args, {
@@ -121817,18 +121971,37 @@ function execGit(cwd, args) {
121817
121971
  stdio: ["ignore", "pipe", "pipe"]
121818
121972
  });
121819
121973
  let stderr = "";
121974
+ let timedOut = false;
121975
+ const timer = setTimeout(() => {
121976
+ timedOut = true;
121977
+ child.kill("SIGTERM");
121978
+ const killTimer = setTimeout(() => child.kill("SIGKILL"), 5e3);
121979
+ killTimer.unref();
121980
+ }, GIT_OP_TIMEOUT_MS);
121981
+ timer.unref();
121820
121982
  child.stderr.on("data", (c) => {
121821
121983
  stderr += c.toString();
121822
121984
  });
121823
121985
  child.on("close", (code) => {
121824
- if (code === 0) resolve2();
121986
+ clearTimeout(timer);
121987
+ if (timedOut) {
121988
+ reject(
121989
+ new Error(
121990
+ `git ${args.join(" ")} timed out after ${GIT_OP_TIMEOUT_MS / 1e3}s (killed)
121991
+ stderr: ${stderr}`
121992
+ )
121993
+ );
121994
+ } else if (code === 0) resolve2();
121825
121995
  else
121826
121996
  reject(
121827
121997
  new Error(`git ${args.join(" ")} exited ${code ?? "null"}
121828
121998
  stderr: ${stderr}`)
121829
121999
  );
121830
122000
  });
121831
- child.on("error", reject);
122001
+ child.on("error", (err) => {
122002
+ clearTimeout(timer);
122003
+ reject(err);
122004
+ });
121832
122005
  });
121833
122006
  }
121834
122007
  async function pathExists(p) {
@@ -121850,7 +122023,16 @@ async function isWorktreeGitHealthy(worktreePath) {
121850
122023
  child.on("error", () => resolve2(false));
121851
122024
  });
121852
122025
  }
121853
- async function ensureRepoClone(basePath, url, token, label) {
122026
+ var repoCloneLocks = /* @__PURE__ */ new Map();
122027
+ function ensureRepoClone(basePath, url, token, label) {
122028
+ const prev = repoCloneLocks.get(basePath) ?? Promise.resolve();
122029
+ const next = prev.catch(() => {
122030
+ }).then(() => ensureRepoCloneImpl(basePath, url, token, label));
122031
+ repoCloneLocks.set(basePath, next.catch(() => {
122032
+ }));
122033
+ return next;
122034
+ }
122035
+ async function ensureRepoCloneImpl(basePath, url, token, label) {
121854
122036
  const gitDir = path10.join(basePath, ".git");
121855
122037
  if (await pathExists(gitDir)) {
121856
122038
  return;
@@ -121877,23 +122059,48 @@ async function ensureRepoClone(basePath, url, token, label) {
121877
122059
  GIT_TERMINAL_PROMPT: "0",
121878
122060
  [tokenEnvVar]: token ?? ""
121879
122061
  };
121880
- await new Promise((resolve2, reject) => {
121881
- const child = child_process.spawn(
121882
- "git",
121883
- ["clone", "--quiet", url, basePath],
121884
- { stdio: ["ignore", "pipe", "pipe"], env }
121885
- );
121886
- let stderr = "";
121887
- child.stderr.on("data", (b) => {
121888
- stderr += b.toString();
121889
- });
121890
- child.on("close", (code) => {
121891
- if (code === 0) resolve2();
121892
- else reject(new Error(`git clone ${url} exited ${code ?? "null"}
122062
+ let timedOut = false;
122063
+ try {
122064
+ await new Promise((resolve2, reject) => {
122065
+ const child = child_process.spawn(
122066
+ "git",
122067
+ ["clone", "--quiet", url, basePath],
122068
+ { stdio: ["ignore", "pipe", "pipe"], env }
122069
+ );
122070
+ let stderr = "";
122071
+ const timer = setTimeout(() => {
122072
+ timedOut = true;
122073
+ child.kill("SIGTERM");
122074
+ const killTimer = setTimeout(() => child.kill("SIGKILL"), 5e3);
122075
+ killTimer.unref();
122076
+ }, GIT_OP_TIMEOUT_MS);
122077
+ timer.unref();
122078
+ child.stderr.on("data", (b) => {
122079
+ stderr += b.toString();
122080
+ });
122081
+ child.on("close", (code) => {
122082
+ clearTimeout(timer);
122083
+ if (timedOut) {
122084
+ reject(new Error(
122085
+ `git clone ${url} timed out after ${GIT_OP_TIMEOUT_MS / 1e3}s (killed)
122086
+ stderr: ${stderr}`
122087
+ ));
122088
+ } else if (code === 0) resolve2();
122089
+ else reject(new Error(`git clone ${url} exited ${code ?? "null"}
121893
122090
  stderr: ${stderr}`));
122091
+ });
122092
+ child.on("error", (err) => {
122093
+ clearTimeout(timer);
122094
+ reject(err);
122095
+ });
121894
122096
  });
121895
- child.on("error", reject);
121896
- });
122097
+ } catch (err) {
122098
+ if (timedOut) {
122099
+ await fs8.rm(basePath, { recursive: true, force: true }).catch(() => {
122100
+ });
122101
+ }
122102
+ throw err;
122103
+ }
121897
122104
  console.log(`[bridge.handler] clone of ${label} complete.`);
121898
122105
  await execGit(basePath, ["remote", "set-url", "origin", url]);
121899
122106
  } finally {
@@ -122150,7 +122357,8 @@ var BridgeHandler = class {
122150
122357
  /**
122151
122358
  * Enter the main loop: for-await over client.events(), per-thread concurrent dispatch.
122152
122359
  *
122153
- * Each unique thread_id (or message_id for top-level msgs) gets its own serial
122360
+ * Each unique session key (root_id, or message_id for top-level msgs the
122361
+ * same normalization parseLarkMessage uses for threadId) gets its own serial
122154
122362
  * promise chain, so the same thread stays ordered while different threads run
122155
122363
  * concurrently. This fixes the UX problem where multiple operators sending
122156
122364
  * requests simultaneously would block each other for the duration of each
@@ -122185,7 +122393,7 @@ var BridgeHandler = class {
122185
122393
  for await (const event of this.deps.client.events()) {
122186
122394
  if (this.closed) break;
122187
122395
  if (signal?.aborted) break;
122188
- const key = event.thread_id ?? event.message_id;
122396
+ const key = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
122189
122397
  this.threadReceivedAt.set(key, Date.now());
122190
122398
  const prev = threadQueues.get(key) ?? Promise.resolve();
122191
122399
  const next = prev.then(() => acquire()).then(() => this.handleOne(event)).catch((err) => {
@@ -122212,6 +122420,7 @@ var BridgeHandler = class {
122212
122420
  async handleOne(event) {
122213
122421
  const settleMessageId = event.message_id;
122214
122422
  let settled = false;
122423
+ let agentRunCompleted = false;
122215
122424
  let cotPublisher;
122216
122425
  let bubbleCreate;
122217
122426
  let cotTurnOutcome = "done";
@@ -122219,7 +122428,7 @@ var BridgeHandler = class {
122219
122428
  if (settled) return;
122220
122429
  settled = true;
122221
122430
  if (ok) this.deps.client.markHandled?.(settleMessageId);
122222
- else this.deps.client.markUnhandled?.(settleMessageId);
122431
+ else this.deps.client.markUnhandled?.(settleMessageId, { replay: !agentRunCompleted });
122223
122432
  };
122224
122433
  try {
122225
122434
  const parsed = parseMessage(event);
@@ -122779,7 +122988,7 @@ var BridgeHandler = class {
122779
122988
  let toolsInFlight = 0;
122780
122989
  let toolUseTotalCount = 0;
122781
122990
  let idleWatchdog;
122782
- if (cardKitProgress) {
122991
+ {
122783
122992
  const cadenceMs = Math.max(50, Math.min(Math.floor(idleTimeoutMs / 4), 15e3));
122784
122993
  idleWatchdog = setInterval(() => {
122785
122994
  if (toolsInFlight > 0) return;
@@ -122827,6 +123036,7 @@ var BridgeHandler = class {
122827
123036
  }
122828
123037
  }
122829
123038
  const result = await handle.done;
123039
+ agentRunCompleted = true;
122830
123040
  if (idleWatchdog) {
122831
123041
  clearInterval(idleWatchdog);
122832
123042
  idleWatchdog = void 0;
@@ -122862,7 +123072,6 @@ var BridgeHandler = class {
122862
123072
  pooled: result.pooled,
122863
123073
  resumeMode: result.resumeMode
122864
123074
  });
122865
- const cardKitTurnTimedOut = cardKitProgress != null && interruptedByIdle;
122866
123075
  const reportedStateRead = await readStateFileDetailed(worktreePath);
122867
123076
  const rawReportedState = reportedStateRead.state;
122868
123077
  const reportedState = rawReportedState?.updated_at != null && rawReportedState.updated_at !== preRunUpdatedAt ? rawReportedState : null;
@@ -122893,37 +123102,9 @@ var BridgeHandler = class {
122893
123102
  console.warn("[bridge.handler] taskHandleClaim hook failed (continuing):", err);
122894
123103
  }
122895
123104
  }
122896
- const now = Date.now();
122897
- if (sessionId !== void 0 && currentExisting === void 0) {
122898
- await this.deps.sessionStore.put({
122899
- threadId,
122900
- sessionId,
122901
- botId,
122902
- createdTs: now,
122903
- lastActiveTs: now,
122904
- senderOpenId,
122905
- ...isTopLevel ? { rootText: parsed.text.slice(0, 200), chatId: parsed.chatId } : {}
122906
- });
122907
- } else if (sessionId !== void 0 && currentExisting !== void 0) {
122908
- await this.deps.sessionStore.put({
122909
- threadId,
122910
- sessionId,
122911
- botId,
122912
- createdTs: currentExisting.createdTs,
122913
- lastActiveTs: now,
122914
- senderOpenId,
122915
- rootText: currentExisting.rootText,
122916
- chatId: currentExisting.chatId
122917
- });
122918
- } else if (currentExisting !== void 0 && sessionId === void 0) {
122919
- await this.deps.sessionStore.put({
122920
- ...currentExisting,
122921
- lastActiveTs: now
122922
- });
122923
- }
122924
123105
  const reportedStatus = reportedState?.status;
122925
123106
  const reportedError = reportedState?.error;
122926
- const cardKitTimeoutFailure = cardKitTurnTimedOut && reportedStatus !== "ready" && reportedStatus !== "failed";
123107
+ const cardKitTimeoutFailure = interruptedByIdle && reportedStatus !== "ready" && reportedStatus !== "failed";
122927
123108
  let success;
122928
123109
  let failureReason;
122929
123110
  if (reportedStatus === "failed") {
@@ -122950,10 +123131,53 @@ var BridgeHandler = class {
122950
123131
  reason: failureReason
122951
123132
  });
122952
123133
  }
123134
+ const stuckResetAfter = resolveStuckSessionResetAfter();
123135
+ const prevStuckCount = currentExisting?.consecutiveStuckCount ?? 0;
123136
+ const nextStuckCount = cardKitTimeoutFailure ? prevStuckCount + 1 : success ? 0 : prevStuckCount;
123137
+ const stuckResetTriggered = cardKitTimeoutFailure && nextStuckCount >= stuckResetAfter;
123138
+ const now = Date.now();
123139
+ if (stuckResetTriggered) {
123140
+ await this.deps.sessionStore.delete(threadId, botId);
123141
+ console.info(
123142
+ `[bridge.handler] BL-38 stuck-session reset: thread=${threadId} bot=${botId} consecutiveStuckCount=${nextStuckCount} (>= ${stuckResetAfter}) \u2014 dropped session record; next @ starts fresh`
123143
+ );
123144
+ } else if (sessionId !== void 0 && currentExisting === void 0) {
123145
+ await this.deps.sessionStore.put({
123146
+ threadId,
123147
+ sessionId,
123148
+ botId,
123149
+ createdTs: now,
123150
+ lastActiveTs: now,
123151
+ senderOpenId,
123152
+ // BL-38: a brand-new thread that idle-killed on its very first turn
123153
+ // starts the counter at 1 (0 when clean; only persisted when > 0).
123154
+ consecutiveStuckCount: nextStuckCount,
123155
+ ...isTopLevel ? { rootText: parsed.text.slice(0, 200), chatId: parsed.chatId } : {}
123156
+ });
123157
+ } else if (sessionId !== void 0 && currentExisting !== void 0) {
123158
+ await this.deps.sessionStore.put({
123159
+ threadId,
123160
+ sessionId,
123161
+ botId,
123162
+ createdTs: currentExisting.createdTs,
123163
+ lastActiveTs: now,
123164
+ senderOpenId,
123165
+ rootText: currentExisting.rootText,
123166
+ chatId: currentExisting.chatId,
123167
+ // BL-38: +1 on an idle-stuck turn, 0 (cleared) on any clean turn.
123168
+ consecutiveStuckCount: nextStuckCount
123169
+ });
123170
+ } else if (currentExisting !== void 0 && sessionId === void 0) {
123171
+ await this.deps.sessionStore.put({
123172
+ ...currentExisting,
123173
+ lastActiveTs: now,
123174
+ consecutiveStuckCount: nextStuckCount
123175
+ });
123176
+ }
122953
123177
  const fallbackAnswer = trustedAnswerText.trim() || cardKitProgress?.answerText.trim() || "";
122954
123178
  const noOutputFallback = "\u26A0\uFE0F \u672C\u8F6E agent \u6CA1\u6709\u4EA7\u51FA\u6B63\u6587\uFF0C\u4E5F\u6CA1\u6709\u5199\u5165\u6709\u6548\u72B6\u6001(state.json)\u3002\n" + (result.exitCode !== 0 ? `agent \u8FDB\u7A0B\u5F02\u5E38\u9000\u51FA(exit code ${result.exitCode})\u3002
122955
123179
  ` : "") + "\u4E0B\u4E00\u6B65\uFF1A\u6362\u4E2A\u8BF4\u6CD5\u91CD\u8BD5\uFF0C\u6216\u65B0\u5F00\u4E00\u4E2A\u8BDD\u9898\u7EE7\u7EED\uFF1B\u82E5\u53CD\u590D\u5982\u6B64\uFF0C\u8BF7\u8BA9\u7EF4\u62A4\u8005\u67E5\u770B\u8BE5 session \u65E5\u5FD7\u3002";
122956
- const cardBody = cardKitTimeoutFailure ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\uFF0C\u672A\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\u3002" : reportedState?.last_message ?? (fallbackAnswer ? fallbackAnswer : noOutputFallback);
123180
+ const cardBody = stuckResetTriggered ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\u3002\u8FDE\u7EED\u591A\u6B21\u5361\u6B7B\uFF0C\u5DF2\u91CD\u7F6E\u672C\u8BDD\u9898\u4E0A\u4E0B\u6587 \u2014\u2014 \u4E0B\u6B21 @ \u6211\u5C06\u5168\u65B0\u5F00\u59CB\uFF0C\u8BF7\u628A\u9700\u6C42\u91CD\u65B0\u8BF4\u4E00\u904D\u3002" : cardKitTimeoutFailure ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\uFF0C\u672A\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\u3002" : reportedState?.last_message ?? (fallbackAnswer ? fallbackAnswer : noOutputFallback);
122957
123181
  const noReportThisTurn = reportedState === null;
122958
123182
  const neutralTitle = noReportThisTurn && success && !failureReason ? "\u{1F4AC} \u5DF2\u56DE\u590D" : void 0;
122959
123183
  const baseCardPayload = {
@@ -123132,8 +123356,13 @@ var BridgeHandler = class {
123132
123356
  );
123133
123357
  break;
123134
123358
  } catch (spawnErr) {
123359
+ if (idleWatchdog) {
123360
+ clearInterval(idleWatchdog);
123361
+ idleWatchdog = void 0;
123362
+ }
123135
123363
  const errMsg = String(spawnErr.message ?? spawnErr);
123136
- if (attempt === 1 && currentExisting != null && errMsg.includes("No conversation found")) {
123364
+ const isStaleSessionErr = errMsg.includes("No conversation found") || errMsg.includes("thread/resume failed") && errMsg.includes("no rollout found");
123365
+ if (attempt === 1 && currentExisting != null && isStaleSessionErr) {
123137
123366
  console.warn(
123138
123367
  `[bridge.handler] stale session ${currentExisting.sessionId} for thread ${threadId} \u2014 removing and retrying without --resume`
123139
123368
  );
@@ -127509,7 +127738,7 @@ async function writeStatusFile(botId, w) {
127509
127738
 
127510
127739
  // src/claude/runner.ts
127511
127740
  import { spawn as spawn2 } from "node:child_process";
127512
- import { writeFile as writeFile3, unlink as unlink2, mkdir as mkdir3 } from "node:fs/promises";
127741
+ import { writeFile as writeFile3, unlink as unlink3, mkdir as mkdir3 } from "node:fs/promises";
127513
127742
  import { join as join2 } from "node:path";
127514
127743
  import { createInterface } from "node:readline";
127515
127744
  var SIGKILL_GRACE_MS = 5e3;
@@ -127703,9 +127932,13 @@ function runClaude(opts) {
127703
127932
  function doKill() {
127704
127933
  if (child.killed || killScheduled) return;
127705
127934
  killScheduled = true;
127935
+ let exited = false;
127936
+ child.once("exit", () => {
127937
+ exited = true;
127938
+ });
127706
127939
  child.kill("SIGTERM");
127707
127940
  killTimer = setTimeout(() => {
127708
- if (!child.killed) child.kill("SIGKILL");
127941
+ if (!exited) child.kill("SIGKILL");
127709
127942
  }, SIGKILL_GRACE_MS);
127710
127943
  killTimer.unref();
127711
127944
  }
@@ -127758,7 +127991,7 @@ function runClaude(opts) {
127758
127991
  clearTimeout(totalTimeoutFallbackHandle);
127759
127992
  rlAbortController.abort();
127760
127993
  if (pidFilePath !== null) {
127761
- void unlink2(pidFilePath).catch(() => {
127994
+ void unlink3(pidFilePath).catch(() => {
127762
127995
  });
127763
127996
  }
127764
127997
  if (exitCode !== 0 && !killScheduled) {
@@ -127788,7 +128021,7 @@ stderr: ${stderr}` : "")
127788
128021
  clearTimeout(totalTimeoutFallbackHandle);
127789
128022
  rlAbortController.abort();
127790
128023
  if (pidFilePath !== null) {
127791
- void unlink2(pidFilePath).catch(() => {
128024
+ void unlink3(pidFilePath).catch(() => {
127792
128025
  });
127793
128026
  }
127794
128027
  if (err.code === "ENOENT") {
@@ -127867,7 +128100,7 @@ var ClaudeRunner = class {
127867
128100
 
127868
128101
  // src/claude/pool.ts
127869
128102
  import { execFile as execFile4, spawn as spawn3 } from "node:child_process";
127870
- import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as stat3, unlink as unlink3, writeFile as writeFile4 } from "node:fs/promises";
128103
+ import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as stat3, unlink as unlink4, writeFile as writeFile4 } from "node:fs/promises";
127871
128104
  import path16 from "node:path";
127872
128105
  import { createInterface as createInterface2 } from "node:readline";
127873
128106
  import { randomUUID } from "node:crypto";
@@ -128404,7 +128637,7 @@ ${stderr}` : "")
128404
128637
  try {
128405
128638
  const raw = await readFile6(pidFilePath, "utf8");
128406
128639
  const parsed = JSON.parse(raw);
128407
- if (parsed.pid === entry.child.pid) await unlink3(pidFilePath);
128640
+ if (parsed.pid === entry.child.pid) await unlink4(pidFilePath);
128408
128641
  } catch {
128409
128642
  }
128410
128643
  }
@@ -128531,7 +128764,7 @@ async function reapOrphanedWarmClaudeProcesses(pidListFilePath) {
128531
128764
  } catch {
128532
128765
  }
128533
128766
  }
128534
- await unlink3(pidListFilePath).catch(() => {
128767
+ await unlink4(pidListFilePath).catch(() => {
128535
128768
  });
128536
128769
  }
128537
128770
 
@@ -128699,6 +128932,8 @@ function runCodex(opts, codexBinPath = "codex") {
128699
128932
  ...opts.cwd != null ? { cwd: opts.cwd } : {}
128700
128933
  });
128701
128934
  markPerf("spawn");
128935
+ child.stdin?.on("error", () => {
128936
+ });
128702
128937
  function sendRequest(method, params) {
128703
128938
  const id = nextRequestId++;
128704
128939
  requestById.set(id, method);
@@ -128711,9 +128946,13 @@ function runCodex(opts, codexBinPath = "codex") {
128711
128946
  function doKill() {
128712
128947
  if (child.killed || killScheduled) return;
128713
128948
  killScheduled = true;
128949
+ let exited = false;
128950
+ child.once("exit", () => {
128951
+ exited = true;
128952
+ });
128714
128953
  child.kill("SIGTERM");
128715
128954
  killTimer = setTimeout(() => {
128716
- if (!child.killed) child.kill("SIGKILL");
128955
+ if (!exited) child.kill("SIGKILL");
128717
128956
  }, SIGKILL_GRACE_MS3);
128718
128957
  killTimer.unref();
128719
128958
  }
@@ -128933,7 +129172,7 @@ var CodexRunner = class {
128933
129172
 
128934
129173
  // src/codex/pool.ts
128935
129174
  import { execFile as execFile5, spawn as spawn5 } from "node:child_process";
128936
- import { mkdir as mkdir5, readFile as readFile7, unlink as unlink4, writeFile as writeFile5 } from "node:fs/promises";
129175
+ import { mkdir as mkdir5, readFile as readFile7, unlink as unlink5, writeFile as writeFile5 } from "node:fs/promises";
128937
129176
  import path17 from "node:path";
128938
129177
  import { createInterface as createInterface4 } from "node:readline";
128939
129178
  var DEFAULT_WARM_PROCESS_IDLE_MS2 = 10 * 60 * 1e3;
@@ -129195,6 +129434,8 @@ var CodexProcessPool = class {
129195
129434
  this.#pending.clear();
129196
129435
  this.#threadOwners.clear();
129197
129436
  this.#currentSpawnReadyResolved = false;
129437
+ child.stdin?.on("error", () => {
129438
+ });
129198
129439
  void this.#writePidFileBestEffort(child.pid);
129199
129440
  this.#ready = new Promise((resolve2, reject) => {
129200
129441
  this.#readyResolve = resolve2;
@@ -129312,7 +129553,7 @@ var CodexProcessPool = class {
129312
129553
  try {
129313
129554
  const raw = await readFile7(this.#pidFilePath, "utf8");
129314
129555
  const parsed = JSON.parse(raw);
129315
- if (parsed.pid === pid) await unlink4(this.#pidFilePath);
129556
+ if (parsed.pid === pid) await unlink5(this.#pidFilePath);
129316
129557
  } catch {
129317
129558
  }
129318
129559
  }
@@ -129468,7 +129709,7 @@ async function reapOrphanedWarmProcess(pidFilePath) {
129468
129709
  );
129469
129710
  }
129470
129711
  }
129471
- await unlink4(pidFilePath).catch(() => {
129712
+ await unlink5(pidFilePath).catch(() => {
129472
129713
  });
129473
129714
  }
129474
129715
 
@@ -129732,12 +129973,33 @@ function handleUnhandledRejection(reason, log = console) {
129732
129973
  rendered
129733
129974
  );
129734
129975
  }
129976
+ var BREAKER_THRESHOLD = 50;
129977
+ var BREAKER_WINDOW_MS = 6e4;
129978
+ function createCrashStormBreaker(threshold = BREAKER_THRESHOLD, windowMs = BREAKER_WINDOW_MS) {
129979
+ const hits = [];
129980
+ return function note() {
129981
+ const now = Date.now();
129982
+ hits.push(now);
129983
+ while (hits.length > 0 && hits[0] < now - windowMs) hits.shift();
129984
+ return hits.length >= threshold;
129985
+ };
129986
+ }
129735
129987
  function registerCrashGuard(log = console) {
129988
+ const noteCrash = createCrashStormBreaker();
129989
+ const maybeTrip = () => {
129990
+ if (!noteCrash()) return;
129991
+ log.error(
129992
+ `[larkway] crash-storm breaker TRIPPED: \u2265${BREAKER_THRESHOLD} crash-guard hits within ${BREAKER_WINDOW_MS / 1e3}s \u2014 the process is likely wedged, not weathering a transient. Exiting 1 so a supervisor can restart with a clean process.`
129993
+ );
129994
+ process.exit(1);
129995
+ };
129736
129996
  process.on("uncaughtException", (err, origin) => {
129737
129997
  handleUncaughtException(err, origin, log);
129998
+ maybeTrip();
129738
129999
  });
129739
130000
  process.on("unhandledRejection", (reason) => {
129740
130001
  handleUnhandledRejection(reason, log);
130002
+ maybeTrip();
129741
130003
  });
129742
130004
  }
129743
130005
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",