larkway 0.3.37 → 0.3.38

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.38**
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.38**
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.
@@ -118466,18 +118594,33 @@ var SessionStore = class _SessionStore {
118466
118594
  // Private helpers
118467
118595
  // -------------------------------------------------------------------------
118468
118596
  /**
118469
- * Atomic write: serialize write to <path>.tmp → fs.rename (POSIX atomic).
118597
+ * Atomic write, serialized through #flushChain (see field doc).
118598
+ * The caller of THIS flush still observes its own link's rejection;
118599
+ * the chain itself swallows it so one failure can't wedge later flushes.
118470
118600
  */
118471
- async #flush() {
118601
+ #flush() {
118602
+ const next = this.#flushChain.then(() => this.#writeSnapshot());
118603
+ this.#flushChain = next.catch(() => {
118604
+ });
118605
+ return next;
118606
+ }
118607
+ /** serialize → write to a unique tmp path → fs.rename (POSIX atomic). */
118608
+ async #writeSnapshot() {
118472
118609
  const file = {
118473
118610
  version: STORE_VERSION,
118474
118611
  records: Object.fromEntries(this.#map)
118475
118612
  };
118476
118613
  const json2 = JSON.stringify(file, null, 2);
118477
- const tmpPath = `${this.#filePath}.tmp`;
118614
+ const tmpPath = `${this.#filePath}.tmp.${process.pid}.${this.#tmpCounter++}`;
118478
118615
  await mkdir2(dirname(this.#filePath), { recursive: true });
118479
- await writeFile2(tmpPath, json2, "utf8");
118480
- await rename2(tmpPath, this.#filePath);
118616
+ try {
118617
+ await writeFile2(tmpPath, json2, "utf8");
118618
+ await rename2(tmpPath, this.#filePath);
118619
+ } catch (err) {
118620
+ await unlink2(tmpPath).catch(() => {
118621
+ });
118622
+ throw err;
118623
+ }
118481
118624
  }
118482
118625
  };
118483
118626
  function isStoredRecord(value) {
@@ -121810,6 +121953,7 @@ function summarizeMentionPolicyRules(rules) {
121810
121953
  }
121811
121954
  return Array.from(counts.entries()).map(([rule, count]) => `${rule}=${count}`).join(", ");
121812
121955
  }
121956
+ var GIT_OP_TIMEOUT_MS = 10 * 60 * 1e3;
121813
121957
  function execGit(cwd, args) {
121814
121958
  return new Promise((resolve2, reject) => {
121815
121959
  const child = child_process.spawn("git", args, {
@@ -121817,18 +121961,37 @@ function execGit(cwd, args) {
121817
121961
  stdio: ["ignore", "pipe", "pipe"]
121818
121962
  });
121819
121963
  let stderr = "";
121964
+ let timedOut = false;
121965
+ const timer = setTimeout(() => {
121966
+ timedOut = true;
121967
+ child.kill("SIGTERM");
121968
+ const killTimer = setTimeout(() => child.kill("SIGKILL"), 5e3);
121969
+ killTimer.unref();
121970
+ }, GIT_OP_TIMEOUT_MS);
121971
+ timer.unref();
121820
121972
  child.stderr.on("data", (c) => {
121821
121973
  stderr += c.toString();
121822
121974
  });
121823
121975
  child.on("close", (code) => {
121824
- if (code === 0) resolve2();
121976
+ clearTimeout(timer);
121977
+ if (timedOut) {
121978
+ reject(
121979
+ new Error(
121980
+ `git ${args.join(" ")} timed out after ${GIT_OP_TIMEOUT_MS / 1e3}s (killed)
121981
+ stderr: ${stderr}`
121982
+ )
121983
+ );
121984
+ } else if (code === 0) resolve2();
121825
121985
  else
121826
121986
  reject(
121827
121987
  new Error(`git ${args.join(" ")} exited ${code ?? "null"}
121828
121988
  stderr: ${stderr}`)
121829
121989
  );
121830
121990
  });
121831
- child.on("error", reject);
121991
+ child.on("error", (err) => {
121992
+ clearTimeout(timer);
121993
+ reject(err);
121994
+ });
121832
121995
  });
121833
121996
  }
121834
121997
  async function pathExists(p) {
@@ -121850,7 +122013,16 @@ async function isWorktreeGitHealthy(worktreePath) {
121850
122013
  child.on("error", () => resolve2(false));
121851
122014
  });
121852
122015
  }
121853
- async function ensureRepoClone(basePath, url, token, label) {
122016
+ var repoCloneLocks = /* @__PURE__ */ new Map();
122017
+ function ensureRepoClone(basePath, url, token, label) {
122018
+ const prev = repoCloneLocks.get(basePath) ?? Promise.resolve();
122019
+ const next = prev.catch(() => {
122020
+ }).then(() => ensureRepoCloneImpl(basePath, url, token, label));
122021
+ repoCloneLocks.set(basePath, next.catch(() => {
122022
+ }));
122023
+ return next;
122024
+ }
122025
+ async function ensureRepoCloneImpl(basePath, url, token, label) {
121854
122026
  const gitDir = path10.join(basePath, ".git");
121855
122027
  if (await pathExists(gitDir)) {
121856
122028
  return;
@@ -121877,23 +122049,48 @@ async function ensureRepoClone(basePath, url, token, label) {
121877
122049
  GIT_TERMINAL_PROMPT: "0",
121878
122050
  [tokenEnvVar]: token ?? ""
121879
122051
  };
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"}
122052
+ let timedOut = false;
122053
+ try {
122054
+ await new Promise((resolve2, reject) => {
122055
+ const child = child_process.spawn(
122056
+ "git",
122057
+ ["clone", "--quiet", url, basePath],
122058
+ { stdio: ["ignore", "pipe", "pipe"], env }
122059
+ );
122060
+ let stderr = "";
122061
+ const timer = setTimeout(() => {
122062
+ timedOut = true;
122063
+ child.kill("SIGTERM");
122064
+ const killTimer = setTimeout(() => child.kill("SIGKILL"), 5e3);
122065
+ killTimer.unref();
122066
+ }, GIT_OP_TIMEOUT_MS);
122067
+ timer.unref();
122068
+ child.stderr.on("data", (b) => {
122069
+ stderr += b.toString();
122070
+ });
122071
+ child.on("close", (code) => {
122072
+ clearTimeout(timer);
122073
+ if (timedOut) {
122074
+ reject(new Error(
122075
+ `git clone ${url} timed out after ${GIT_OP_TIMEOUT_MS / 1e3}s (killed)
122076
+ stderr: ${stderr}`
122077
+ ));
122078
+ } else if (code === 0) resolve2();
122079
+ else reject(new Error(`git clone ${url} exited ${code ?? "null"}
121893
122080
  stderr: ${stderr}`));
122081
+ });
122082
+ child.on("error", (err) => {
122083
+ clearTimeout(timer);
122084
+ reject(err);
122085
+ });
121894
122086
  });
121895
- child.on("error", reject);
121896
- });
122087
+ } catch (err) {
122088
+ if (timedOut) {
122089
+ await fs8.rm(basePath, { recursive: true, force: true }).catch(() => {
122090
+ });
122091
+ }
122092
+ throw err;
122093
+ }
121897
122094
  console.log(`[bridge.handler] clone of ${label} complete.`);
121898
122095
  await execGit(basePath, ["remote", "set-url", "origin", url]);
121899
122096
  } finally {
@@ -122150,7 +122347,8 @@ var BridgeHandler = class {
122150
122347
  /**
122151
122348
  * Enter the main loop: for-await over client.events(), per-thread concurrent dispatch.
122152
122349
  *
122153
- * Each unique thread_id (or message_id for top-level msgs) gets its own serial
122350
+ * Each unique session key (root_id, or message_id for top-level msgs the
122351
+ * same normalization parseLarkMessage uses for threadId) gets its own serial
122154
122352
  * promise chain, so the same thread stays ordered while different threads run
122155
122353
  * concurrently. This fixes the UX problem where multiple operators sending
122156
122354
  * requests simultaneously would block each other for the duration of each
@@ -122185,7 +122383,7 @@ var BridgeHandler = class {
122185
122383
  for await (const event of this.deps.client.events()) {
122186
122384
  if (this.closed) break;
122187
122385
  if (signal?.aborted) break;
122188
- const key = event.thread_id ?? event.message_id;
122386
+ const key = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
122189
122387
  this.threadReceivedAt.set(key, Date.now());
122190
122388
  const prev = threadQueues.get(key) ?? Promise.resolve();
122191
122389
  const next = prev.then(() => acquire()).then(() => this.handleOne(event)).catch((err) => {
@@ -122212,6 +122410,7 @@ var BridgeHandler = class {
122212
122410
  async handleOne(event) {
122213
122411
  const settleMessageId = event.message_id;
122214
122412
  let settled = false;
122413
+ let agentRunCompleted = false;
122215
122414
  let cotPublisher;
122216
122415
  let bubbleCreate;
122217
122416
  let cotTurnOutcome = "done";
@@ -122219,7 +122418,7 @@ var BridgeHandler = class {
122219
122418
  if (settled) return;
122220
122419
  settled = true;
122221
122420
  if (ok) this.deps.client.markHandled?.(settleMessageId);
122222
- else this.deps.client.markUnhandled?.(settleMessageId);
122421
+ else this.deps.client.markUnhandled?.(settleMessageId, { replay: !agentRunCompleted });
122223
122422
  };
122224
122423
  try {
122225
122424
  const parsed = parseMessage(event);
@@ -122779,7 +122978,7 @@ var BridgeHandler = class {
122779
122978
  let toolsInFlight = 0;
122780
122979
  let toolUseTotalCount = 0;
122781
122980
  let idleWatchdog;
122782
- if (cardKitProgress) {
122981
+ {
122783
122982
  const cadenceMs = Math.max(50, Math.min(Math.floor(idleTimeoutMs / 4), 15e3));
122784
122983
  idleWatchdog = setInterval(() => {
122785
122984
  if (toolsInFlight > 0) return;
@@ -122827,6 +123026,7 @@ var BridgeHandler = class {
122827
123026
  }
122828
123027
  }
122829
123028
  const result = await handle.done;
123029
+ agentRunCompleted = true;
122830
123030
  if (idleWatchdog) {
122831
123031
  clearInterval(idleWatchdog);
122832
123032
  idleWatchdog = void 0;
@@ -122862,7 +123062,6 @@ var BridgeHandler = class {
122862
123062
  pooled: result.pooled,
122863
123063
  resumeMode: result.resumeMode
122864
123064
  });
122865
- const cardKitTurnTimedOut = cardKitProgress != null && interruptedByIdle;
122866
123065
  const reportedStateRead = await readStateFileDetailed(worktreePath);
122867
123066
  const rawReportedState = reportedStateRead.state;
122868
123067
  const reportedState = rawReportedState?.updated_at != null && rawReportedState.updated_at !== preRunUpdatedAt ? rawReportedState : null;
@@ -122923,7 +123122,7 @@ var BridgeHandler = class {
122923
123122
  }
122924
123123
  const reportedStatus = reportedState?.status;
122925
123124
  const reportedError = reportedState?.error;
122926
- const cardKitTimeoutFailure = cardKitTurnTimedOut && reportedStatus !== "ready" && reportedStatus !== "failed";
123125
+ const cardKitTimeoutFailure = interruptedByIdle && reportedStatus !== "ready" && reportedStatus !== "failed";
122927
123126
  let success;
122928
123127
  let failureReason;
122929
123128
  if (reportedStatus === "failed") {
@@ -123132,8 +123331,13 @@ var BridgeHandler = class {
123132
123331
  );
123133
123332
  break;
123134
123333
  } catch (spawnErr) {
123334
+ if (idleWatchdog) {
123335
+ clearInterval(idleWatchdog);
123336
+ idleWatchdog = void 0;
123337
+ }
123135
123338
  const errMsg = String(spawnErr.message ?? spawnErr);
123136
- if (attempt === 1 && currentExisting != null && errMsg.includes("No conversation found")) {
123339
+ const isStaleSessionErr = errMsg.includes("No conversation found") || errMsg.includes("thread/resume failed") && errMsg.includes("no rollout found");
123340
+ if (attempt === 1 && currentExisting != null && isStaleSessionErr) {
123137
123341
  console.warn(
123138
123342
  `[bridge.handler] stale session ${currentExisting.sessionId} for thread ${threadId} \u2014 removing and retrying without --resume`
123139
123343
  );
@@ -127509,7 +127713,7 @@ async function writeStatusFile(botId, w) {
127509
127713
 
127510
127714
  // src/claude/runner.ts
127511
127715
  import { spawn as spawn2 } from "node:child_process";
127512
- import { writeFile as writeFile3, unlink as unlink2, mkdir as mkdir3 } from "node:fs/promises";
127716
+ import { writeFile as writeFile3, unlink as unlink3, mkdir as mkdir3 } from "node:fs/promises";
127513
127717
  import { join as join2 } from "node:path";
127514
127718
  import { createInterface } from "node:readline";
127515
127719
  var SIGKILL_GRACE_MS = 5e3;
@@ -127703,9 +127907,13 @@ function runClaude(opts) {
127703
127907
  function doKill() {
127704
127908
  if (child.killed || killScheduled) return;
127705
127909
  killScheduled = true;
127910
+ let exited = false;
127911
+ child.once("exit", () => {
127912
+ exited = true;
127913
+ });
127706
127914
  child.kill("SIGTERM");
127707
127915
  killTimer = setTimeout(() => {
127708
- if (!child.killed) child.kill("SIGKILL");
127916
+ if (!exited) child.kill("SIGKILL");
127709
127917
  }, SIGKILL_GRACE_MS);
127710
127918
  killTimer.unref();
127711
127919
  }
@@ -127758,7 +127966,7 @@ function runClaude(opts) {
127758
127966
  clearTimeout(totalTimeoutFallbackHandle);
127759
127967
  rlAbortController.abort();
127760
127968
  if (pidFilePath !== null) {
127761
- void unlink2(pidFilePath).catch(() => {
127969
+ void unlink3(pidFilePath).catch(() => {
127762
127970
  });
127763
127971
  }
127764
127972
  if (exitCode !== 0 && !killScheduled) {
@@ -127788,7 +127996,7 @@ stderr: ${stderr}` : "")
127788
127996
  clearTimeout(totalTimeoutFallbackHandle);
127789
127997
  rlAbortController.abort();
127790
127998
  if (pidFilePath !== null) {
127791
- void unlink2(pidFilePath).catch(() => {
127999
+ void unlink3(pidFilePath).catch(() => {
127792
128000
  });
127793
128001
  }
127794
128002
  if (err.code === "ENOENT") {
@@ -127867,7 +128075,7 @@ var ClaudeRunner = class {
127867
128075
 
127868
128076
  // src/claude/pool.ts
127869
128077
  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";
128078
+ import { mkdir as mkdir4, readFile as readFile6, rename as rename3, stat as stat3, unlink as unlink4, writeFile as writeFile4 } from "node:fs/promises";
127871
128079
  import path16 from "node:path";
127872
128080
  import { createInterface as createInterface2 } from "node:readline";
127873
128081
  import { randomUUID } from "node:crypto";
@@ -128404,7 +128612,7 @@ ${stderr}` : "")
128404
128612
  try {
128405
128613
  const raw = await readFile6(pidFilePath, "utf8");
128406
128614
  const parsed = JSON.parse(raw);
128407
- if (parsed.pid === entry.child.pid) await unlink3(pidFilePath);
128615
+ if (parsed.pid === entry.child.pid) await unlink4(pidFilePath);
128408
128616
  } catch {
128409
128617
  }
128410
128618
  }
@@ -128531,7 +128739,7 @@ async function reapOrphanedWarmClaudeProcesses(pidListFilePath) {
128531
128739
  } catch {
128532
128740
  }
128533
128741
  }
128534
- await unlink3(pidListFilePath).catch(() => {
128742
+ await unlink4(pidListFilePath).catch(() => {
128535
128743
  });
128536
128744
  }
128537
128745
 
@@ -128699,6 +128907,8 @@ function runCodex(opts, codexBinPath = "codex") {
128699
128907
  ...opts.cwd != null ? { cwd: opts.cwd } : {}
128700
128908
  });
128701
128909
  markPerf("spawn");
128910
+ child.stdin?.on("error", () => {
128911
+ });
128702
128912
  function sendRequest(method, params) {
128703
128913
  const id = nextRequestId++;
128704
128914
  requestById.set(id, method);
@@ -128711,9 +128921,13 @@ function runCodex(opts, codexBinPath = "codex") {
128711
128921
  function doKill() {
128712
128922
  if (child.killed || killScheduled) return;
128713
128923
  killScheduled = true;
128924
+ let exited = false;
128925
+ child.once("exit", () => {
128926
+ exited = true;
128927
+ });
128714
128928
  child.kill("SIGTERM");
128715
128929
  killTimer = setTimeout(() => {
128716
- if (!child.killed) child.kill("SIGKILL");
128930
+ if (!exited) child.kill("SIGKILL");
128717
128931
  }, SIGKILL_GRACE_MS3);
128718
128932
  killTimer.unref();
128719
128933
  }
@@ -128933,7 +129147,7 @@ var CodexRunner = class {
128933
129147
 
128934
129148
  // src/codex/pool.ts
128935
129149
  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";
129150
+ import { mkdir as mkdir5, readFile as readFile7, unlink as unlink5, writeFile as writeFile5 } from "node:fs/promises";
128937
129151
  import path17 from "node:path";
128938
129152
  import { createInterface as createInterface4 } from "node:readline";
128939
129153
  var DEFAULT_WARM_PROCESS_IDLE_MS2 = 10 * 60 * 1e3;
@@ -129195,6 +129409,8 @@ var CodexProcessPool = class {
129195
129409
  this.#pending.clear();
129196
129410
  this.#threadOwners.clear();
129197
129411
  this.#currentSpawnReadyResolved = false;
129412
+ child.stdin?.on("error", () => {
129413
+ });
129198
129414
  void this.#writePidFileBestEffort(child.pid);
129199
129415
  this.#ready = new Promise((resolve2, reject) => {
129200
129416
  this.#readyResolve = resolve2;
@@ -129312,7 +129528,7 @@ var CodexProcessPool = class {
129312
129528
  try {
129313
129529
  const raw = await readFile7(this.#pidFilePath, "utf8");
129314
129530
  const parsed = JSON.parse(raw);
129315
- if (parsed.pid === pid) await unlink4(this.#pidFilePath);
129531
+ if (parsed.pid === pid) await unlink5(this.#pidFilePath);
129316
129532
  } catch {
129317
129533
  }
129318
129534
  }
@@ -129468,7 +129684,7 @@ async function reapOrphanedWarmProcess(pidFilePath) {
129468
129684
  );
129469
129685
  }
129470
129686
  }
129471
- await unlink4(pidFilePath).catch(() => {
129687
+ await unlink5(pidFilePath).catch(() => {
129472
129688
  });
129473
129689
  }
129474
129690
 
@@ -129732,12 +129948,33 @@ function handleUnhandledRejection(reason, log = console) {
129732
129948
  rendered
129733
129949
  );
129734
129950
  }
129951
+ var BREAKER_THRESHOLD = 50;
129952
+ var BREAKER_WINDOW_MS = 6e4;
129953
+ function createCrashStormBreaker(threshold = BREAKER_THRESHOLD, windowMs = BREAKER_WINDOW_MS) {
129954
+ const hits = [];
129955
+ return function note() {
129956
+ const now = Date.now();
129957
+ hits.push(now);
129958
+ while (hits.length > 0 && hits[0] < now - windowMs) hits.shift();
129959
+ return hits.length >= threshold;
129960
+ };
129961
+ }
129735
129962
  function registerCrashGuard(log = console) {
129963
+ const noteCrash = createCrashStormBreaker();
129964
+ const maybeTrip = () => {
129965
+ if (!noteCrash()) return;
129966
+ log.error(
129967
+ `[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.`
129968
+ );
129969
+ process.exit(1);
129970
+ };
129736
129971
  process.on("uncaughtException", (err, origin) => {
129737
129972
  handleUncaughtException(err, origin, log);
129973
+ maybeTrip();
129738
129974
  });
129739
129975
  process.on("unhandledRejection", (reason) => {
129740
129976
  handleUnhandledRejection(reason, log);
129977
+ maybeTrip();
129741
129978
  });
129742
129979
  }
129743
129980
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.37",
3
+ "version": "0.3.38",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",