larkway 0.3.36 → 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.36**
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.36**
11
+ **当前版本:v0.3.38**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -25413,13 +25413,13 @@ var require_lib2 = __commonJS({
25413
25413
  Domain[Domain["Lark"] = 1] = "Lark";
25414
25414
  })(exports.Domain || (exports.Domain = {}));
25415
25415
  exports.LoggerLevel = void 0;
25416
- (function(LoggerLevel2) {
25417
- LoggerLevel2[LoggerLevel2["fatal"] = 0] = "fatal";
25418
- LoggerLevel2[LoggerLevel2["error"] = 1] = "error";
25419
- LoggerLevel2[LoggerLevel2["warn"] = 2] = "warn";
25420
- LoggerLevel2[LoggerLevel2["info"] = 3] = "info";
25421
- LoggerLevel2[LoggerLevel2["debug"] = 4] = "debug";
25422
- LoggerLevel2[LoggerLevel2["trace"] = 5] = "trace";
25416
+ (function(LoggerLevel) {
25417
+ LoggerLevel[LoggerLevel["fatal"] = 0] = "fatal";
25418
+ LoggerLevel[LoggerLevel["error"] = 1] = "error";
25419
+ LoggerLevel[LoggerLevel["warn"] = 2] = "warn";
25420
+ LoggerLevel[LoggerLevel["info"] = 3] = "info";
25421
+ LoggerLevel[LoggerLevel["debug"] = 4] = "debug";
25422
+ LoggerLevel[LoggerLevel["trace"] = 5] = "trace";
25423
25423
  })(exports.LoggerLevel || (exports.LoggerLevel = {}));
25424
25424
  var CEventType = /* @__PURE__ */ Symbol("event-type");
25425
25425
  var CTenantKey = /* @__PURE__ */ Symbol("tenant-key");
@@ -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 {
@@ -114001,6 +114085,34 @@ var init_client = __esm({
114001
114085
  }
114002
114086
  });
114003
114087
 
114088
+ // src/lark/sdkLogger.ts
114089
+ var sdkLogger_exports = {};
114090
+ __export(sdkLogger_exports, {
114091
+ compactErrorText: () => compactErrorText,
114092
+ silentSdkLogger: () => silentSdkLogger
114093
+ });
114094
+ function compactErrorText(err2) {
114095
+ return err2 instanceof Error ? err2.message : String(err2);
114096
+ }
114097
+ var silentSdkLogger;
114098
+ var init_sdkLogger = __esm({
114099
+ "src/lark/sdkLogger.ts"() {
114100
+ "use strict";
114101
+ silentSdkLogger = {
114102
+ error(..._msg) {
114103
+ },
114104
+ warn(..._msg) {
114105
+ },
114106
+ info(..._msg) {
114107
+ },
114108
+ debug(..._msg) {
114109
+ },
114110
+ trace(..._msg) {
114111
+ }
114112
+ };
114113
+ }
114114
+ });
114115
+
114004
114116
  // src/tasklist/teamRegistry.ts
114005
114117
  var teamRegistry_exports = {};
114006
114118
  __export(teamRegistry_exports, {
@@ -127694,6 +127806,19 @@ async function checkClaude(ctx) {
127694
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"
127695
127807
  };
127696
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
+ }
127697
127822
  async function checkCodex(ctx) {
127698
127823
  const results = [];
127699
127824
  const required = await backendRequired(ctx, "codex");
@@ -128029,9 +128154,10 @@ async function checkWsConnectivity(ctx, opts) {
128029
128154
  }
128030
128155
  async function probeTaskScope(appId, appSecret, tasklistGuid) {
128031
128156
  try {
128032
- const { Client: LarkSdkClient2, LoggerLevel: LoggerLevel2 } = await Promise.resolve().then(() => __toESM(require_lib2(), 1));
128157
+ const { Client: LarkSdkClient2 } = await Promise.resolve().then(() => __toESM(require_lib2(), 1));
128033
128158
  const { TaskListClient: TaskListClient2 } = await Promise.resolve().then(() => (init_client(), client_exports));
128034
- const sdkClient = new LarkSdkClient2({ appId, appSecret, loggerLevel: LoggerLevel2.fatal });
128159
+ const { silentSdkLogger: silentSdkLogger2 } = await Promise.resolve().then(() => (init_sdkLogger(), sdkLogger_exports));
128160
+ const sdkClient = new LarkSdkClient2({ appId, appSecret, logger: silentSdkLogger2 });
128035
128161
  const taskClient = new TaskListClient2({
128036
128162
  request: (config) => sdkClient.request(config)
128037
128163
  });
@@ -128120,6 +128246,7 @@ async function runAllChecks(ctx, opts) {
128120
128246
  results.push(await checkClaude(ctx));
128121
128247
  const credChecks = await checkFeishuCreds(ctx);
128122
128248
  results.push(...credChecks);
128249
+ results.push(await checkLarkCli());
128123
128250
  const yamlChecks = await checkBotYaml(ctx);
128124
128251
  results.push(...yamlChecks);
128125
128252
  const wtChecks = await checkWorktrees(ctx);
@@ -130969,6 +131096,7 @@ async function finalizeOnboard(sessionId, form) {
130969
131096
  if (!s._creds) {
130970
131097
  throw new Error("\u5185\u90E8\u9519\u8BEF:creds \u672A\u6301\u6709");
130971
131098
  }
131099
+ s.status = "finalizing";
130972
131100
  try {
130973
131101
  const { botId } = await createBotFromCreds({
130974
131102
  creds: s._creds,
@@ -130990,12 +131118,16 @@ async function finalizeOnboard(sessionId, form) {
130990
131118
  async function cancelOnboard(sessionId) {
130991
131119
  const s = sessions.get(sessionId);
130992
131120
  if (!s) return { cancelled: false };
130993
- 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") {
130994
131125
  return { cancelled: false };
130995
131126
  }
130996
131127
  if (s.status === "awaiting-name" && s._creds) {
130997
131128
  const defaultName = s.prefill?.suggestedName || "\u65B0\u52A9\u624B";
130998
131129
  const form = { name: defaultName };
131130
+ s.status = "finalizing";
130999
131131
  try {
131000
131132
  const { botId } = await createBotFromCreds({
131001
131133
  creds: s._creds,
@@ -132791,6 +132923,7 @@ function exitCodeFor(checks) {
132791
132923
 
132792
132924
  // src/cli/commands/tasklistInit.ts
132793
132925
  var import_node_sdk2 = __toESM(require_lib2(), 1);
132926
+ init_sdkLogger();
132794
132927
  init_client();
132795
132928
  init_paths();
132796
132929
  init_teamRegistry();
@@ -133156,7 +133289,7 @@ async function run10(ctx, args) {
133156
133289
  const sdkClient = new import_node_sdk2.Client({
133157
133290
  appId: creator.app_id,
133158
133291
  appSecret: creator.appSecret,
133159
- loggerLevel: import_node_sdk2.LoggerLevel.fatal
133292
+ logger: silentSdkLogger
133160
133293
  });
133161
133294
  const requester = {
133162
133295
  request: (config) => sdkClient.request(config)
package/dist/main.js CHANGED
@@ -24969,13 +24969,13 @@ var require_lib2 = __commonJS({
24969
24969
  Domain[Domain["Lark"] = 1] = "Lark";
24970
24970
  })(exports.Domain || (exports.Domain = {}));
24971
24971
  exports.LoggerLevel = void 0;
24972
- (function(LoggerLevel2) {
24973
- LoggerLevel2[LoggerLevel2["fatal"] = 0] = "fatal";
24974
- LoggerLevel2[LoggerLevel2["error"] = 1] = "error";
24975
- LoggerLevel2[LoggerLevel2["warn"] = 2] = "warn";
24976
- LoggerLevel2[LoggerLevel2["info"] = 3] = "info";
24977
- LoggerLevel2[LoggerLevel2["debug"] = 4] = "debug";
24978
- LoggerLevel2[LoggerLevel2["trace"] = 5] = "trace";
24972
+ (function(LoggerLevel) {
24973
+ LoggerLevel[LoggerLevel["fatal"] = 0] = "fatal";
24974
+ LoggerLevel[LoggerLevel["error"] = 1] = "error";
24975
+ LoggerLevel[LoggerLevel["warn"] = 2] = "warn";
24976
+ LoggerLevel[LoggerLevel["info"] = 3] = "info";
24977
+ LoggerLevel[LoggerLevel["debug"] = 4] = "debug";
24978
+ LoggerLevel[LoggerLevel["trace"] = 5] = "trace";
24979
24979
  })(exports.LoggerLevel || (exports.LoggerLevel = {}));
24980
24980
  var CEventType = /* @__PURE__ */ Symbol("event-type");
24981
24981
  var CTenantKey = /* @__PURE__ */ Symbol("tenant-key");
@@ -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 {
@@ -118230,14 +118314,45 @@ var CardRenderer = class {
118230
118314
  }
118231
118315
  };
118232
118316
 
118317
+ // src/lark/sdkLogger.ts
118318
+ var silentSdkLogger = {
118319
+ error(..._msg) {
118320
+ },
118321
+ warn(..._msg) {
118322
+ },
118323
+ info(..._msg) {
118324
+ },
118325
+ debug(..._msg) {
118326
+ },
118327
+ trace(..._msg) {
118328
+ }
118329
+ };
118330
+ function compactErrorText(err) {
118331
+ return err instanceof Error ? err.message : String(err);
118332
+ }
118333
+
118233
118334
  // src/claude/sessionStore.ts
118234
- 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";
118235
118336
  import { dirname } from "node:path";
118236
118337
  var STORE_VERSION = 2;
118237
118338
  var TOUCH_DEBOUNCE_MS = 1e3;
118238
118339
  var SessionStore = class _SessionStore {
118239
118340
  #filePath;
118240
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;
118241
118356
  /** Whether a touch flush is pending */
118242
118357
  #touchDirty = false;
118243
118358
  #touchTimer;
@@ -118279,13 +118394,12 @@ var SessionStore = class _SessionStore {
118279
118394
  try {
118280
118395
  parsed = JSON.parse(raw);
118281
118396
  } catch {
118282
- throw new Error(
118283
- `[SessionStore] ${filePath} is not valid JSON \u2014 fix or delete the file and restart.`
118284
- );
118397
+ return await _SessionStore.#recoverFromCorruption(filePath, "is not valid JSON");
118285
118398
  }
118286
118399
  if (typeof parsed !== "object" || parsed === null || !("records" in parsed)) {
118287
- throw new Error(
118288
- `[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)"
118289
118403
  );
118290
118404
  }
118291
118405
  const file = parsed;
@@ -118299,8 +118413,9 @@ var SessionStore = class _SessionStore {
118299
118413
  );
118300
118414
  }
118301
118415
  if (typeof file.records !== "object" || file.records === null) {
118302
- throw new Error(
118303
- `[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"
118304
118419
  );
118305
118420
  }
118306
118421
  const map2 = /* @__PURE__ */ new Map();
@@ -118308,14 +118423,44 @@ var SessionStore = class _SessionStore {
118308
118423
  file.records
118309
118424
  )) {
118310
118425
  if (!isStoredRecord(value)) {
118311
- throw new Error(
118312
- `[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.`
118313
118428
  );
118429
+ continue;
118314
118430
  }
118315
118431
  map2.set(key, value);
118316
118432
  }
118317
118433
  return new _SessionStore(filePath, map2);
118318
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
+ }
118319
118464
  /**
118320
118465
  * Migrate a v1 sessions.json in-place to v2.
118321
118466
  * Backup written as `<path>.v1-backup-<ISO timestamp>` before any write.
@@ -118449,18 +118594,33 @@ var SessionStore = class _SessionStore {
118449
118594
  // Private helpers
118450
118595
  // -------------------------------------------------------------------------
118451
118596
  /**
118452
- * 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.
118453
118600
  */
118454
- 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() {
118455
118609
  const file = {
118456
118610
  version: STORE_VERSION,
118457
118611
  records: Object.fromEntries(this.#map)
118458
118612
  };
118459
118613
  const json2 = JSON.stringify(file, null, 2);
118460
- const tmpPath = `${this.#filePath}.tmp`;
118614
+ const tmpPath = `${this.#filePath}.tmp.${process.pid}.${this.#tmpCounter++}`;
118461
118615
  await mkdir2(dirname(this.#filePath), { recursive: true });
118462
- await writeFile2(tmpPath, json2, "utf8");
118463
- 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
+ }
118464
118624
  }
118465
118625
  };
118466
118626
  function isStoredRecord(value) {
@@ -121793,6 +121953,7 @@ function summarizeMentionPolicyRules(rules) {
121793
121953
  }
121794
121954
  return Array.from(counts.entries()).map(([rule, count]) => `${rule}=${count}`).join(", ");
121795
121955
  }
121956
+ var GIT_OP_TIMEOUT_MS = 10 * 60 * 1e3;
121796
121957
  function execGit(cwd, args) {
121797
121958
  return new Promise((resolve2, reject) => {
121798
121959
  const child = child_process.spawn("git", args, {
@@ -121800,18 +121961,37 @@ function execGit(cwd, args) {
121800
121961
  stdio: ["ignore", "pipe", "pipe"]
121801
121962
  });
121802
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();
121803
121972
  child.stderr.on("data", (c) => {
121804
121973
  stderr += c.toString();
121805
121974
  });
121806
121975
  child.on("close", (code) => {
121807
- 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();
121808
121985
  else
121809
121986
  reject(
121810
121987
  new Error(`git ${args.join(" ")} exited ${code ?? "null"}
121811
121988
  stderr: ${stderr}`)
121812
121989
  );
121813
121990
  });
121814
- child.on("error", reject);
121991
+ child.on("error", (err) => {
121992
+ clearTimeout(timer);
121993
+ reject(err);
121994
+ });
121815
121995
  });
121816
121996
  }
121817
121997
  async function pathExists(p) {
@@ -121833,7 +122013,16 @@ async function isWorktreeGitHealthy(worktreePath) {
121833
122013
  child.on("error", () => resolve2(false));
121834
122014
  });
121835
122015
  }
121836
- 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) {
121837
122026
  const gitDir = path10.join(basePath, ".git");
121838
122027
  if (await pathExists(gitDir)) {
121839
122028
  return;
@@ -121860,23 +122049,48 @@ async function ensureRepoClone(basePath, url, token, label) {
121860
122049
  GIT_TERMINAL_PROMPT: "0",
121861
122050
  [tokenEnvVar]: token ?? ""
121862
122051
  };
121863
- await new Promise((resolve2, reject) => {
121864
- const child = child_process.spawn(
121865
- "git",
121866
- ["clone", "--quiet", url, basePath],
121867
- { stdio: ["ignore", "pipe", "pipe"], env }
121868
- );
121869
- let stderr = "";
121870
- child.stderr.on("data", (b) => {
121871
- stderr += b.toString();
121872
- });
121873
- child.on("close", (code) => {
121874
- if (code === 0) resolve2();
121875
- 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"}
121876
122080
  stderr: ${stderr}`));
122081
+ });
122082
+ child.on("error", (err) => {
122083
+ clearTimeout(timer);
122084
+ reject(err);
122085
+ });
121877
122086
  });
121878
- child.on("error", reject);
121879
- });
122087
+ } catch (err) {
122088
+ if (timedOut) {
122089
+ await fs8.rm(basePath, { recursive: true, force: true }).catch(() => {
122090
+ });
122091
+ }
122092
+ throw err;
122093
+ }
121880
122094
  console.log(`[bridge.handler] clone of ${label} complete.`);
121881
122095
  await execGit(basePath, ["remote", "set-url", "origin", url]);
121882
122096
  } finally {
@@ -122133,7 +122347,8 @@ var BridgeHandler = class {
122133
122347
  /**
122134
122348
  * Enter the main loop: for-await over client.events(), per-thread concurrent dispatch.
122135
122349
  *
122136
- * 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
122137
122352
  * promise chain, so the same thread stays ordered while different threads run
122138
122353
  * concurrently. This fixes the UX problem where multiple operators sending
122139
122354
  * requests simultaneously would block each other for the duration of each
@@ -122168,7 +122383,7 @@ var BridgeHandler = class {
122168
122383
  for await (const event of this.deps.client.events()) {
122169
122384
  if (this.closed) break;
122170
122385
  if (signal?.aborted) break;
122171
- 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;
122172
122387
  this.threadReceivedAt.set(key, Date.now());
122173
122388
  const prev = threadQueues.get(key) ?? Promise.resolve();
122174
122389
  const next = prev.then(() => acquire()).then(() => this.handleOne(event)).catch((err) => {
@@ -122195,6 +122410,7 @@ var BridgeHandler = class {
122195
122410
  async handleOne(event) {
122196
122411
  const settleMessageId = event.message_id;
122197
122412
  let settled = false;
122413
+ let agentRunCompleted = false;
122198
122414
  let cotPublisher;
122199
122415
  let bubbleCreate;
122200
122416
  let cotTurnOutcome = "done";
@@ -122202,7 +122418,7 @@ var BridgeHandler = class {
122202
122418
  if (settled) return;
122203
122419
  settled = true;
122204
122420
  if (ok) this.deps.client.markHandled?.(settleMessageId);
122205
- else this.deps.client.markUnhandled?.(settleMessageId);
122421
+ else this.deps.client.markUnhandled?.(settleMessageId, { replay: !agentRunCompleted });
122206
122422
  };
122207
122423
  try {
122208
122424
  const parsed = parseMessage(event);
@@ -122762,7 +122978,7 @@ var BridgeHandler = class {
122762
122978
  let toolsInFlight = 0;
122763
122979
  let toolUseTotalCount = 0;
122764
122980
  let idleWatchdog;
122765
- if (cardKitProgress) {
122981
+ {
122766
122982
  const cadenceMs = Math.max(50, Math.min(Math.floor(idleTimeoutMs / 4), 15e3));
122767
122983
  idleWatchdog = setInterval(() => {
122768
122984
  if (toolsInFlight > 0) return;
@@ -122810,6 +123026,7 @@ var BridgeHandler = class {
122810
123026
  }
122811
123027
  }
122812
123028
  const result = await handle.done;
123029
+ agentRunCompleted = true;
122813
123030
  if (idleWatchdog) {
122814
123031
  clearInterval(idleWatchdog);
122815
123032
  idleWatchdog = void 0;
@@ -122845,7 +123062,6 @@ var BridgeHandler = class {
122845
123062
  pooled: result.pooled,
122846
123063
  resumeMode: result.resumeMode
122847
123064
  });
122848
- const cardKitTurnTimedOut = cardKitProgress != null && interruptedByIdle;
122849
123065
  const reportedStateRead = await readStateFileDetailed(worktreePath);
122850
123066
  const rawReportedState = reportedStateRead.state;
122851
123067
  const reportedState = rawReportedState?.updated_at != null && rawReportedState.updated_at !== preRunUpdatedAt ? rawReportedState : null;
@@ -122906,7 +123122,7 @@ var BridgeHandler = class {
122906
123122
  }
122907
123123
  const reportedStatus = reportedState?.status;
122908
123124
  const reportedError = reportedState?.error;
122909
- const cardKitTimeoutFailure = cardKitTurnTimedOut && reportedStatus !== "ready" && reportedStatus !== "failed";
123125
+ const cardKitTimeoutFailure = interruptedByIdle && reportedStatus !== "ready" && reportedStatus !== "failed";
122910
123126
  let success;
122911
123127
  let failureReason;
122912
123128
  if (reportedStatus === "failed") {
@@ -123115,8 +123331,13 @@ var BridgeHandler = class {
123115
123331
  );
123116
123332
  break;
123117
123333
  } catch (spawnErr) {
123334
+ if (idleWatchdog) {
123335
+ clearInterval(idleWatchdog);
123336
+ idleWatchdog = void 0;
123337
+ }
123118
123338
  const errMsg = String(spawnErr.message ?? spawnErr);
123119
- 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) {
123120
123341
  console.warn(
123121
123342
  `[bridge.handler] stale session ${currentExisting.sessionId} for thread ${threadId} \u2014 removing and retrying without --resume`
123122
123343
  );
@@ -127492,7 +127713,7 @@ async function writeStatusFile(botId, w) {
127492
127713
 
127493
127714
  // src/claude/runner.ts
127494
127715
  import { spawn as spawn2 } from "node:child_process";
127495
- 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";
127496
127717
  import { join as join2 } from "node:path";
127497
127718
  import { createInterface } from "node:readline";
127498
127719
  var SIGKILL_GRACE_MS = 5e3;
@@ -127686,9 +127907,13 @@ function runClaude(opts) {
127686
127907
  function doKill() {
127687
127908
  if (child.killed || killScheduled) return;
127688
127909
  killScheduled = true;
127910
+ let exited = false;
127911
+ child.once("exit", () => {
127912
+ exited = true;
127913
+ });
127689
127914
  child.kill("SIGTERM");
127690
127915
  killTimer = setTimeout(() => {
127691
- if (!child.killed) child.kill("SIGKILL");
127916
+ if (!exited) child.kill("SIGKILL");
127692
127917
  }, SIGKILL_GRACE_MS);
127693
127918
  killTimer.unref();
127694
127919
  }
@@ -127741,7 +127966,7 @@ function runClaude(opts) {
127741
127966
  clearTimeout(totalTimeoutFallbackHandle);
127742
127967
  rlAbortController.abort();
127743
127968
  if (pidFilePath !== null) {
127744
- void unlink2(pidFilePath).catch(() => {
127969
+ void unlink3(pidFilePath).catch(() => {
127745
127970
  });
127746
127971
  }
127747
127972
  if (exitCode !== 0 && !killScheduled) {
@@ -127771,7 +127996,7 @@ stderr: ${stderr}` : "")
127771
127996
  clearTimeout(totalTimeoutFallbackHandle);
127772
127997
  rlAbortController.abort();
127773
127998
  if (pidFilePath !== null) {
127774
- void unlink2(pidFilePath).catch(() => {
127999
+ void unlink3(pidFilePath).catch(() => {
127775
128000
  });
127776
128001
  }
127777
128002
  if (err.code === "ENOENT") {
@@ -127850,7 +128075,7 @@ var ClaudeRunner = class {
127850
128075
 
127851
128076
  // src/claude/pool.ts
127852
128077
  import { execFile as execFile4, spawn as spawn3 } from "node:child_process";
127853
- 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";
127854
128079
  import path16 from "node:path";
127855
128080
  import { createInterface as createInterface2 } from "node:readline";
127856
128081
  import { randomUUID } from "node:crypto";
@@ -128387,7 +128612,7 @@ ${stderr}` : "")
128387
128612
  try {
128388
128613
  const raw = await readFile6(pidFilePath, "utf8");
128389
128614
  const parsed = JSON.parse(raw);
128390
- if (parsed.pid === entry.child.pid) await unlink3(pidFilePath);
128615
+ if (parsed.pid === entry.child.pid) await unlink4(pidFilePath);
128391
128616
  } catch {
128392
128617
  }
128393
128618
  }
@@ -128514,7 +128739,7 @@ async function reapOrphanedWarmClaudeProcesses(pidListFilePath) {
128514
128739
  } catch {
128515
128740
  }
128516
128741
  }
128517
- await unlink3(pidListFilePath).catch(() => {
128742
+ await unlink4(pidListFilePath).catch(() => {
128518
128743
  });
128519
128744
  }
128520
128745
 
@@ -128682,6 +128907,8 @@ function runCodex(opts, codexBinPath = "codex") {
128682
128907
  ...opts.cwd != null ? { cwd: opts.cwd } : {}
128683
128908
  });
128684
128909
  markPerf("spawn");
128910
+ child.stdin?.on("error", () => {
128911
+ });
128685
128912
  function sendRequest(method, params) {
128686
128913
  const id = nextRequestId++;
128687
128914
  requestById.set(id, method);
@@ -128694,9 +128921,13 @@ function runCodex(opts, codexBinPath = "codex") {
128694
128921
  function doKill() {
128695
128922
  if (child.killed || killScheduled) return;
128696
128923
  killScheduled = true;
128924
+ let exited = false;
128925
+ child.once("exit", () => {
128926
+ exited = true;
128927
+ });
128697
128928
  child.kill("SIGTERM");
128698
128929
  killTimer = setTimeout(() => {
128699
- if (!child.killed) child.kill("SIGKILL");
128930
+ if (!exited) child.kill("SIGKILL");
128700
128931
  }, SIGKILL_GRACE_MS3);
128701
128932
  killTimer.unref();
128702
128933
  }
@@ -128916,7 +129147,7 @@ var CodexRunner = class {
128916
129147
 
128917
129148
  // src/codex/pool.ts
128918
129149
  import { execFile as execFile5, spawn as spawn5 } from "node:child_process";
128919
- 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";
128920
129151
  import path17 from "node:path";
128921
129152
  import { createInterface as createInterface4 } from "node:readline";
128922
129153
  var DEFAULT_WARM_PROCESS_IDLE_MS2 = 10 * 60 * 1e3;
@@ -129178,6 +129409,8 @@ var CodexProcessPool = class {
129178
129409
  this.#pending.clear();
129179
129410
  this.#threadOwners.clear();
129180
129411
  this.#currentSpawnReadyResolved = false;
129412
+ child.stdin?.on("error", () => {
129413
+ });
129181
129414
  void this.#writePidFileBestEffort(child.pid);
129182
129415
  this.#ready = new Promise((resolve2, reject) => {
129183
129416
  this.#readyResolve = resolve2;
@@ -129295,7 +129528,7 @@ var CodexProcessPool = class {
129295
129528
  try {
129296
129529
  const raw = await readFile7(this.#pidFilePath, "utf8");
129297
129530
  const parsed = JSON.parse(raw);
129298
- if (parsed.pid === pid) await unlink4(this.#pidFilePath);
129531
+ if (parsed.pid === pid) await unlink5(this.#pidFilePath);
129299
129532
  } catch {
129300
129533
  }
129301
129534
  }
@@ -129451,7 +129684,7 @@ async function reapOrphanedWarmProcess(pidFilePath) {
129451
129684
  );
129452
129685
  }
129453
129686
  }
129454
- await unlink4(pidFilePath).catch(() => {
129687
+ await unlink5(pidFilePath).catch(() => {
129455
129688
  });
129456
129689
  }
129457
129690
 
@@ -129715,12 +129948,33 @@ function handleUnhandledRejection(reason, log = console) {
129715
129948
  rendered
129716
129949
  );
129717
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
+ }
129718
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
+ };
129719
129971
  process.on("uncaughtException", (err, origin) => {
129720
129972
  handleUncaughtException(err, origin, log);
129973
+ maybeTrip();
129721
129974
  });
129722
129975
  process.on("unhandledRejection", (reason) => {
129723
129976
  handleUnhandledRejection(reason, log);
129977
+ maybeTrip();
129724
129978
  });
129725
129979
  }
129726
129980
 
@@ -131508,7 +131762,7 @@ function printExternalCliProbe(bots) {
131508
131762
  }
131509
131763
  async function fetchBotAvatar(appId, appSecret) {
131510
131764
  try {
131511
- const client = new import_node_sdk2.Client({ appId, appSecret });
131765
+ const client = new import_node_sdk2.Client({ appId, appSecret, logger: silentSdkLogger });
131512
131766
  const resp = await client.request({
131513
131767
  method: "GET",
131514
131768
  url: "/open-apis/bot/v3/info"
@@ -131672,7 +131926,7 @@ async function runV2Mode({
131672
131926
  const taskSdkClient = new import_node_sdk2.Client({
131673
131927
  appId: bot.app_id,
131674
131928
  appSecret,
131675
- loggerLevel: import_node_sdk2.LoggerLevel.fatal
131929
+ logger: silentSdkLogger
131676
131930
  });
131677
131931
  const taskRequester = {
131678
131932
  request: (config2) => taskSdkClient.request(config2)
@@ -131680,8 +131934,7 @@ async function runV2Mode({
131680
131934
  const taskListClient = new TaskListClient(taskRequester);
131681
131935
  await taskListClient.addTasklistMembers(guid, [{ id: bot.app_id, type: "app", role: "editor" }]).catch((err) => {
131682
131936
  console.warn(
131683
- `[larkway] bot "${bot.id}": self-join tasklist ${guid} as editor failed (continuing, best-effort):`,
131684
- err
131937
+ `[larkway] bot "${bot.id}": self-join tasklist ${guid} as editor failed (continuing, best-effort): ${compactErrorText(err)}`
131685
131938
  );
131686
131939
  });
131687
131940
  effectiveTaskHandleTasklistGuid = guid;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.36",
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)",