larkway 0.3.12 → 0.3.14

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.12**
11
+ **Current release: v0.3.14**
12
12
 
13
13
  ---
14
14
 
package/README.zh.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  你在飞书话题里 @ bot,它在你的机器上运行——读真实代码库、执行命令、开 MR——把结果贴回飞书。你定义 agent 知道什么、能做什么。Larkway 只负责传递消息。
10
10
 
11
- **当前版本:v0.3.12**
11
+ **当前版本:v0.3.14**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -107550,6 +107550,7 @@ var channelClient_exports = {};
107550
107550
  __export(channelClient_exports, {
107551
107551
  ChannelClient: () => ChannelClient,
107552
107552
  channelMsgToLarkEvent: () => channelMsgToLarkEvent,
107553
+ resolveRecoveredThreadId: () => resolveRecoveredThreadId,
107553
107554
  synthesizeCardActionEvent: () => synthesizeCardActionEvent
107554
107555
  });
107555
107556
  import { execFile as execFileCallback } from "node:child_process";
@@ -107633,6 +107634,16 @@ function expandMessagesWithThreadReplies(messages) {
107633
107634
  }
107634
107635
  return expanded;
107635
107636
  }
107637
+ function resolveRecoveredThreadId(m) {
107638
+ const explicit = nonEmptyStringField(m, "thread_id") ?? nonEmptyStringField(m, "root_id");
107639
+ if (explicit) return explicit;
107640
+ const link = nonEmptyStringField(m, "message_app_link");
107641
+ if (link) {
107642
+ const match = link.match(/open_thread_id=(omt_[A-Za-z0-9_-]+)/);
107643
+ if (match) return match[1] ?? null;
107644
+ }
107645
+ return null;
107646
+ }
107636
107647
  function cardActionChoice(value) {
107637
107648
  if (value && typeof value === "object") {
107638
107649
  const choice = value["larkway_choice"];
@@ -107682,7 +107693,7 @@ function channelMsgToLarkEvent(msg) {
107682
107693
  create_time: m?.["create_time"] ?? String(msg.createTime ?? Date.now())
107683
107694
  };
107684
107695
  }
107685
- var import_node_sdk, execFile4, LEARNED_CHATS_LIMIT, SEEN_MESSAGES_LIMIT, OPEN_CHAT_DISCOVERY_LOOKBACK_MS, OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS, PROCESSING_REACTION_EMOJI, DEFAULT_CONNECT_GRACE_MS, ChannelClient;
107696
+ var import_node_sdk, execFile4, LEARNED_CHATS_LIMIT, SEEN_MESSAGES_LIMIT, MAX_MESSAGE_ATTEMPTS, OPEN_CHAT_DISCOVERY_LOOKBACK_MS, OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS, PROCESSING_REACTION_EMOJI, DEFAULT_CONNECT_GRACE_MS, ChannelClient;
107686
107697
  var init_channelClient = __esm({
107687
107698
  "src/lark/channelClient.ts"() {
107688
107699
  "use strict";
@@ -107692,6 +107703,7 @@ var init_channelClient = __esm({
107692
107703
  execFile4 = promisify4(execFileCallback);
107693
107704
  LEARNED_CHATS_LIMIT = 100;
107694
107705
  SEEN_MESSAGES_LIMIT = 1e3;
107706
+ MAX_MESSAGE_ATTEMPTS = 5;
107695
107707
  OPEN_CHAT_DISCOVERY_LOOKBACK_MS = 9e4;
107696
107708
  OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS = 30 * 60 * 1e3;
107697
107709
  PROCESSING_REACTION_EMOJI = "Typing";
@@ -107713,14 +107725,42 @@ var init_channelClient = __esm({
107713
107725
  */
107714
107726
  lastDisconnectAt = 0;
107715
107727
  /**
107716
- * Set of message_ids that have been dispatched (pushed onto the inbound queue)
107717
- * during this process lifetime. Used by gapFill to avoid dispatching the same
107718
- * message twice once from the live WS handler, once from history catch-up.
107719
- * Bounded: we only add live-delivered messages here (not synthetics like
107720
- * cardAction turns), and Feishu message_id space is stable and non-recycling
107721
- * within any reasonable bridge uptime.
107728
+ * Message_ids that have reached a terminal SUCCESS (handler.markHandled) OR
107729
+ * were explicitly acknowledged. These are persisted so open-chat recovery
107730
+ * does not replay an already-completed message after a restart. gap-fill
107731
+ * skips anything in this set.
107732
+ *
107733
+ * Bounded: we only add completed/acknowledged messages here (not synthetics
107734
+ * like cardAction turns), and Feishu message_id space is stable and
107735
+ * non-recycling within any reasonable bridge uptime.
107722
107736
  */
107723
107737
  seenMessageIds = /* @__PURE__ */ new Set();
107738
+ /**
107739
+ * Message_ids that have been DISPATCHED (pushed onto the inbound queue) but
107740
+ * whose turn has not yet reached a terminal outcome. This is the no-duplicate
107741
+ * guard: gap-fill (and the WS path) skip a message that is already in-flight,
107742
+ * so a message delivered live is never also gap-filled, and two overlapping
107743
+ * gap-fill windows never double-dispatch the same message.
107744
+ *
107745
+ * CRITICAL (the core self-heal): a message stays here only while its turn is
107746
+ * running. handler.markHandled() promotes it into {@link seenMessageIds} on
107747
+ * SUCCESS; handler.markUnhandled() REMOVES it on FAILURE so the next gap-fill
107748
+ * window re-dispatches it — one transient blip (e.g. a TLS timeout creating
107749
+ * the card) no longer swallows the @ forever. NOT persisted: an in-flight
107750
+ * message interrupted by a restart SHOULD be re-dispatchable.
107751
+ */
107752
+ inFlightMessageIds = /* @__PURE__ */ new Set();
107753
+ /**
107754
+ * Per-message (re-)dispatch counter for the poison-message guard. Incremented
107755
+ * every time a message_id is pushed onto the inbound queue (live WS or either
107756
+ * gap-fill branch) and once more when a turn is released as unhandled. When the
107757
+ * count reaches {@link MAX_MESSAGE_ATTEMPTS}, markUnhandled GIVES UP: it
107758
+ * promotes the message to seen (so it stops being re-dispatched) and logs a
107759
+ * warning. markHandled clears the entry on terminal success. Not persisted:
107760
+ * post-restart, an interrupted message starts fresh — same policy as
107761
+ * inFlightMessageIds.
107762
+ */
107763
+ messageAttempts = /* @__PURE__ */ new Map();
107724
107764
  /**
107725
107765
  * Chats observed from live WS events during this process lifetime.
107726
107766
  *
@@ -107822,7 +107862,11 @@ var init_channelClient = __esm({
107822
107862
  return;
107823
107863
  }
107824
107864
  this.noteSeenChat(ev.chat_id);
107825
- this.noteSeenMessage(ev.message_id);
107865
+ if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
107866
+ return;
107867
+ }
107868
+ this.inFlightMessageIds.add(ev.message_id);
107869
+ this.noteDispatchAttempt(ev.message_id);
107826
107870
  log(`dispatching (channel-sdk): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
107827
107871
  this.queue.push(ev);
107828
107872
  });
@@ -107972,7 +108016,7 @@ var init_channelClient = __esm({
107972
108016
  const m = raw;
107973
108017
  const messageId = m["message_id"];
107974
108018
  if (!messageId) continue;
107975
- if (this.seenMessageIds.has(messageId)) continue;
108019
+ if (this.seenMessageIds.has(messageId) || this.inFlightMessageIds.has(messageId)) continue;
107976
108020
  const mentions = m["mentions"];
107977
108021
  const mentionsBot = Array.isArray(mentions) && mentions.some(
107978
108022
  (mn) => {
@@ -107981,6 +108025,11 @@ var init_channelClient = __esm({
107981
108025
  }
107982
108026
  );
107983
108027
  if (!mentionsBot) continue;
108028
+ const recoveredThread = resolveRecoveredThreadId(m);
108029
+ const isThreadReply = recoveredThread !== null && recoveredThread !== messageId;
108030
+ if (isThreadReply && !nonEmptyStringField(m, "root_id")) {
108031
+ m["root_id"] = recoveredThread;
108032
+ }
107984
108033
  const ev = channelMsgToLarkEvent({
107985
108034
  raw: {
107986
108035
  event: {
@@ -107999,21 +108048,23 @@ var init_channelClient = __esm({
107999
108048
  message_id: mid,
108000
108049
  chat_id: cid,
108001
108050
  chat_type: m["chat_type"] ?? "group",
108002
- thread_id: m["thread_id"] ?? m["root_id"] ?? mid,
108003
- root_id: m["root_id"],
108051
+ thread_id: m["thread_id"] ?? (isThreadReply ? recoveredThread ?? void 0 : void 0) ?? m["root_id"] ?? mid,
108052
+ root_id: m["root_id"] ?? (isThreadReply ? recoveredThread ?? void 0 : void 0),
108004
108053
  sender_id: sid,
108005
108054
  content: typeof m["content"] === "string" ? m["content"] : JSON.stringify({ text: "" }),
108006
108055
  create_time: m["create_time"] ?? String(Date.now())
108007
108056
  };
108008
108057
  })();
108009
108058
  if (!fallbackEv) continue;
108010
- this.noteSeenMessage(fallbackEv.message_id);
108059
+ this.inFlightMessageIds.add(fallbackEv.message_id);
108060
+ this.noteDispatchAttempt(fallbackEv.message_id);
108011
108061
  log(`gap-fill dispatching (fallback): message_id=${fallbackEv.message_id} chat=${chatId}`);
108012
108062
  this.queue.push(fallbackEv);
108013
108063
  totalDispatched++;
108014
108064
  continue;
108015
108065
  }
108016
- this.noteSeenMessage(ev.message_id);
108066
+ this.inFlightMessageIds.add(ev.message_id);
108067
+ this.noteDispatchAttempt(ev.message_id);
108017
108068
  log(`gap-fill dispatching: message_id=${ev.message_id} thread=${ev.thread_id ?? "?"} chat=${chatId}`);
108018
108069
  this.queue.push(ev);
108019
108070
  totalDispatched++;
@@ -108237,8 +108288,52 @@ var init_channelClient = __esm({
108237
108288
  * already handled message after a restart.
108238
108289
  */
108239
108290
  acknowledgeMessage(messageId) {
108291
+ this.markHandled(messageId);
108292
+ }
108293
+ /**
108294
+ * Terminal SUCCESS: promote a message out of the in-flight set into the
108295
+ * persisted seen set. After this, neither the WS path nor any gap-fill window
108296
+ * (this process or post-restart) re-dispatches it. Persistence flows through
108297
+ * {@link noteSeenMessage} (the same channel-seen-messages json the success
108298
+ * set has always used), so post-restart recovery skips completed messages.
108299
+ */
108300
+ markHandled(messageId) {
108301
+ this.inFlightMessageIds.delete(messageId);
108302
+ this.messageAttempts.delete(messageId);
108240
108303
  this.noteSeenMessage(messageId);
108241
108304
  }
108305
+ /**
108306
+ * Terminal FAILURE/ABORT: release a message from the in-flight set WITHOUT
108307
+ * marking it seen, so the next gap-fill window can re-dispatch it (the core
108308
+ * self-heal — one transient blip no longer swallows the @ forever). Does not
108309
+ * touch persisted seen state.
108310
+ *
108311
+ * Poison-message guard: count this failed turn as one more attempt. If the
108312
+ * message has now failed {@link MAX_MESSAGE_ATTEMPTS} times, GIVE UP — promote
108313
+ * it to seen (so it stops being re-dispatched on every gap-fill) and log a
108314
+ * visible warning instead of silently looping forever.
108315
+ */
108316
+ markUnhandled(messageId) {
108317
+ this.inFlightMessageIds.delete(messageId);
108318
+ const attempts = (this.messageAttempts.get(messageId) ?? 0) + 1;
108319
+ this.messageAttempts.set(messageId, attempts);
108320
+ if (attempts >= MAX_MESSAGE_ATTEMPTS) {
108321
+ console.warn(
108322
+ `[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)`
108323
+ );
108324
+ this.messageAttempts.delete(messageId);
108325
+ this.noteSeenMessage(messageId);
108326
+ }
108327
+ }
108328
+ /**
108329
+ * Increment the per-message dispatch counter (poison-message guard). Called
108330
+ * each time a message_id is pushed onto the inbound queue. The counter is also
108331
+ * bumped in {@link markUnhandled} so both dispatch and failed settlement
108332
+ * contribute toward the cap.
108333
+ */
108334
+ noteDispatchAttempt(messageId) {
108335
+ this.messageAttempts.set(messageId, (this.messageAttempts.get(messageId) ?? 0) + 1);
108336
+ }
108242
108337
  async close() {
108243
108338
  this.closed = true;
108244
108339
  if (this.openChatDiscoveryTimer) {
package/dist/main.js CHANGED
@@ -111778,6 +111778,7 @@ var ChannelCardClient = class {
111778
111778
  var execFile = promisify(execFileCallback);
111779
111779
  var LEARNED_CHATS_LIMIT = 100;
111780
111780
  var SEEN_MESSAGES_LIMIT = 1e3;
111781
+ var MAX_MESSAGE_ATTEMPTS = 5;
111781
111782
  var OPEN_CHAT_DISCOVERY_LOOKBACK_MS = 9e4;
111782
111783
  var OPEN_CHAT_DISCOVERY_BOOTSTRAP_LOOKBACK_MS = 30 * 60 * 1e3;
111783
111784
  var PROCESSING_REACTION_EMOJI = "Typing";
@@ -111859,6 +111860,16 @@ function expandMessagesWithThreadReplies(messages) {
111859
111860
  }
111860
111861
  return expanded;
111861
111862
  }
111863
+ function resolveRecoveredThreadId(m) {
111864
+ const explicit = nonEmptyStringField(m, "thread_id") ?? nonEmptyStringField(m, "root_id");
111865
+ if (explicit) return explicit;
111866
+ const link = nonEmptyStringField(m, "message_app_link");
111867
+ if (link) {
111868
+ const match = link.match(/open_thread_id=(omt_[A-Za-z0-9_-]+)/);
111869
+ if (match) return match[1] ?? null;
111870
+ }
111871
+ return null;
111872
+ }
111862
111873
  function cardActionChoice(value) {
111863
111874
  if (value && typeof value === "object") {
111864
111875
  const choice = value["larkway_choice"];
@@ -111925,14 +111936,42 @@ var ChannelClient = class {
111925
111936
  */
111926
111937
  lastDisconnectAt = 0;
111927
111938
  /**
111928
- * Set of message_ids that have been dispatched (pushed onto the inbound queue)
111929
- * during this process lifetime. Used by gapFill to avoid dispatching the same
111930
- * message twice once from the live WS handler, once from history catch-up.
111931
- * Bounded: we only add live-delivered messages here (not synthetics like
111932
- * cardAction turns), and Feishu message_id space is stable and non-recycling
111933
- * within any reasonable bridge uptime.
111939
+ * Message_ids that have reached a terminal SUCCESS (handler.markHandled) OR
111940
+ * were explicitly acknowledged. These are persisted so open-chat recovery
111941
+ * does not replay an already-completed message after a restart. gap-fill
111942
+ * skips anything in this set.
111943
+ *
111944
+ * Bounded: we only add completed/acknowledged messages here (not synthetics
111945
+ * like cardAction turns), and Feishu message_id space is stable and
111946
+ * non-recycling within any reasonable bridge uptime.
111934
111947
  */
111935
111948
  seenMessageIds = /* @__PURE__ */ new Set();
111949
+ /**
111950
+ * Message_ids that have been DISPATCHED (pushed onto the inbound queue) but
111951
+ * whose turn has not yet reached a terminal outcome. This is the no-duplicate
111952
+ * guard: gap-fill (and the WS path) skip a message that is already in-flight,
111953
+ * so a message delivered live is never also gap-filled, and two overlapping
111954
+ * gap-fill windows never double-dispatch the same message.
111955
+ *
111956
+ * CRITICAL (the core self-heal): a message stays here only while its turn is
111957
+ * running. handler.markHandled() promotes it into {@link seenMessageIds} on
111958
+ * SUCCESS; handler.markUnhandled() REMOVES it on FAILURE so the next gap-fill
111959
+ * window re-dispatches it — one transient blip (e.g. a TLS timeout creating
111960
+ * the card) no longer swallows the @ forever. NOT persisted: an in-flight
111961
+ * message interrupted by a restart SHOULD be re-dispatchable.
111962
+ */
111963
+ inFlightMessageIds = /* @__PURE__ */ new Set();
111964
+ /**
111965
+ * Per-message (re-)dispatch counter for the poison-message guard. Incremented
111966
+ * every time a message_id is pushed onto the inbound queue (live WS or either
111967
+ * gap-fill branch) and once more when a turn is released as unhandled. When the
111968
+ * count reaches {@link MAX_MESSAGE_ATTEMPTS}, markUnhandled GIVES UP: it
111969
+ * promotes the message to seen (so it stops being re-dispatched) and logs a
111970
+ * warning. markHandled clears the entry on terminal success. Not persisted:
111971
+ * post-restart, an interrupted message starts fresh — same policy as
111972
+ * inFlightMessageIds.
111973
+ */
111974
+ messageAttempts = /* @__PURE__ */ new Map();
111936
111975
  /**
111937
111976
  * Chats observed from live WS events during this process lifetime.
111938
111977
  *
@@ -112034,7 +112073,11 @@ var ChannelClient = class {
112034
112073
  return;
112035
112074
  }
112036
112075
  this.noteSeenChat(ev.chat_id);
112037
- this.noteSeenMessage(ev.message_id);
112076
+ if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
112077
+ return;
112078
+ }
112079
+ this.inFlightMessageIds.add(ev.message_id);
112080
+ this.noteDispatchAttempt(ev.message_id);
112038
112081
  log(`dispatching (channel-sdk): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
112039
112082
  this.queue.push(ev);
112040
112083
  });
@@ -112184,7 +112227,7 @@ var ChannelClient = class {
112184
112227
  const m = raw;
112185
112228
  const messageId = m["message_id"];
112186
112229
  if (!messageId) continue;
112187
- if (this.seenMessageIds.has(messageId)) continue;
112230
+ if (this.seenMessageIds.has(messageId) || this.inFlightMessageIds.has(messageId)) continue;
112188
112231
  const mentions = m["mentions"];
112189
112232
  const mentionsBot = Array.isArray(mentions) && mentions.some(
112190
112233
  (mn) => {
@@ -112193,6 +112236,11 @@ var ChannelClient = class {
112193
112236
  }
112194
112237
  );
112195
112238
  if (!mentionsBot) continue;
112239
+ const recoveredThread = resolveRecoveredThreadId(m);
112240
+ const isThreadReply = recoveredThread !== null && recoveredThread !== messageId;
112241
+ if (isThreadReply && !nonEmptyStringField(m, "root_id")) {
112242
+ m["root_id"] = recoveredThread;
112243
+ }
112196
112244
  const ev = channelMsgToLarkEvent({
112197
112245
  raw: {
112198
112246
  event: {
@@ -112211,21 +112259,23 @@ var ChannelClient = class {
112211
112259
  message_id: mid,
112212
112260
  chat_id: cid,
112213
112261
  chat_type: m["chat_type"] ?? "group",
112214
- thread_id: m["thread_id"] ?? m["root_id"] ?? mid,
112215
- root_id: m["root_id"],
112262
+ thread_id: m["thread_id"] ?? (isThreadReply ? recoveredThread ?? void 0 : void 0) ?? m["root_id"] ?? mid,
112263
+ root_id: m["root_id"] ?? (isThreadReply ? recoveredThread ?? void 0 : void 0),
112216
112264
  sender_id: sid,
112217
112265
  content: typeof m["content"] === "string" ? m["content"] : JSON.stringify({ text: "" }),
112218
112266
  create_time: m["create_time"] ?? String(Date.now())
112219
112267
  };
112220
112268
  })();
112221
112269
  if (!fallbackEv) continue;
112222
- this.noteSeenMessage(fallbackEv.message_id);
112270
+ this.inFlightMessageIds.add(fallbackEv.message_id);
112271
+ this.noteDispatchAttempt(fallbackEv.message_id);
112223
112272
  log(`gap-fill dispatching (fallback): message_id=${fallbackEv.message_id} chat=${chatId}`);
112224
112273
  this.queue.push(fallbackEv);
112225
112274
  totalDispatched++;
112226
112275
  continue;
112227
112276
  }
112228
- this.noteSeenMessage(ev.message_id);
112277
+ this.inFlightMessageIds.add(ev.message_id);
112278
+ this.noteDispatchAttempt(ev.message_id);
112229
112279
  log(`gap-fill dispatching: message_id=${ev.message_id} thread=${ev.thread_id ?? "?"} chat=${chatId}`);
112230
112280
  this.queue.push(ev);
112231
112281
  totalDispatched++;
@@ -112449,8 +112499,52 @@ var ChannelClient = class {
112449
112499
  * already handled message after a restart.
112450
112500
  */
112451
112501
  acknowledgeMessage(messageId) {
112502
+ this.markHandled(messageId);
112503
+ }
112504
+ /**
112505
+ * Terminal SUCCESS: promote a message out of the in-flight set into the
112506
+ * persisted seen set. After this, neither the WS path nor any gap-fill window
112507
+ * (this process or post-restart) re-dispatches it. Persistence flows through
112508
+ * {@link noteSeenMessage} (the same channel-seen-messages json the success
112509
+ * set has always used), so post-restart recovery skips completed messages.
112510
+ */
112511
+ markHandled(messageId) {
112512
+ this.inFlightMessageIds.delete(messageId);
112513
+ this.messageAttempts.delete(messageId);
112452
112514
  this.noteSeenMessage(messageId);
112453
112515
  }
112516
+ /**
112517
+ * Terminal FAILURE/ABORT: release a message from the in-flight set WITHOUT
112518
+ * marking it seen, so the next gap-fill window can re-dispatch it (the core
112519
+ * self-heal — one transient blip no longer swallows the @ forever). Does not
112520
+ * touch persisted seen state.
112521
+ *
112522
+ * Poison-message guard: count this failed turn as one more attempt. If the
112523
+ * message has now failed {@link MAX_MESSAGE_ATTEMPTS} times, GIVE UP — promote
112524
+ * it to seen (so it stops being re-dispatched on every gap-fill) and log a
112525
+ * visible warning instead of silently looping forever.
112526
+ */
112527
+ markUnhandled(messageId) {
112528
+ this.inFlightMessageIds.delete(messageId);
112529
+ const attempts = (this.messageAttempts.get(messageId) ?? 0) + 1;
112530
+ this.messageAttempts.set(messageId, attempts);
112531
+ if (attempts >= MAX_MESSAGE_ATTEMPTS) {
112532
+ console.warn(
112533
+ `[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)`
112534
+ );
112535
+ this.messageAttempts.delete(messageId);
112536
+ this.noteSeenMessage(messageId);
112537
+ }
112538
+ }
112539
+ /**
112540
+ * Increment the per-message dispatch counter (poison-message guard). Called
112541
+ * each time a message_id is pushed onto the inbound queue. The counter is also
112542
+ * bumped in {@link markUnhandled} so both dispatch and failed settlement
112543
+ * contribute toward the cap.
112544
+ */
112545
+ noteDispatchAttempt(messageId) {
112546
+ this.messageAttempts.set(messageId, (this.messageAttempts.get(messageId) ?? 0) + 1);
112547
+ }
112454
112548
  async close() {
112455
112549
  this.closed = true;
112456
112550
  if (this.openChatDiscoveryTimer) {
@@ -112786,6 +112880,30 @@ function extractRawMessageId(raw) {
112786
112880
  const id = msg["id"];
112787
112881
  return typeof id === "string" ? id : null;
112788
112882
  }
112883
+ async function createCardWithRetry(outbound, replyToMessageId, cardJson, opts, maxAttempts = 3) {
112884
+ let lastErr;
112885
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
112886
+ try {
112887
+ return await outbound.createCard(replyToMessageId, cardJson, opts);
112888
+ } catch (err) {
112889
+ lastErr = err;
112890
+ if (attempt === maxAttempts) {
112891
+ console.error(
112892
+ `[lark.card] createCard failed after ${attempt} attempts:`,
112893
+ err
112894
+ );
112895
+ throw err;
112896
+ }
112897
+ const delay = 400 * Math.pow(2, attempt - 1);
112898
+ console.warn(
112899
+ `[lark.card] createCard attempt ${attempt} failed, retrying in ${delay}ms:`,
112900
+ err.message
112901
+ );
112902
+ await new Promise((r) => setTimeout(r, delay));
112903
+ }
112904
+ }
112905
+ throw lastErr;
112906
+ }
112789
112907
  var CardRenderer = class {
112790
112908
  /**
112791
112909
  * Transport for the two OUTBOUND card calls (create + patch). Injected
@@ -112827,7 +112945,8 @@ var CardRenderer = class {
112827
112945
  status: "thinking"
112828
112946
  });
112829
112947
  const replyInThread = opts?.replyInThread ?? true;
112830
- const { messageId } = await this.outbound.createCard(
112948
+ const { messageId } = await createCardWithRetry(
112949
+ this.outbound,
112831
112950
  replyToMessageId,
112832
112951
  initialCardJson,
112833
112952
  { replyInThread, threadId: opts?.threadId }
@@ -114699,383 +114818,395 @@ var BridgeHandler = class {
114699
114818
  // Private: single-event lifecycle
114700
114819
  // ---------------------------------------------------------------------------
114701
114820
  async handleOne(event) {
114702
- const parsed = parseMessage(event);
114703
- const { threadId, messageId, senderOpenId } = parsed;
114704
- const botId = this.deps.botConfig?.id;
114705
- const eventLogId = messageId;
114706
- const eventStartedAt = Date.now();
114707
- const recordEvent = async (patch) => {
114708
- if (!this.deps.recordRuntimeEvent) return;
114709
- try {
114710
- await this.deps.recordRuntimeEvent({ id: eventLogId, ...patch });
114711
- } catch (err) {
114712
- console.warn("[bridge.handler] recordRuntimeEvent failed (continuing):", err);
114713
- }
114821
+ const settleMessageId = event.message_id;
114822
+ let settled = false;
114823
+ const settle = (ok) => {
114824
+ if (settled) return;
114825
+ settled = true;
114826
+ if (ok) this.deps.client.markHandled?.(settleMessageId);
114827
+ else this.deps.client.markUnhandled?.(settleMessageId);
114714
114828
  };
114715
- const triggerType = typeof parsed.raw.root_id === "string" && parsed.raw.root_id ? "thread_reply" : "mention";
114716
- await recordEvent({
114717
- botId,
114718
- botName: this.deps.botConfig?.name,
114719
- messageId,
114720
- threadId,
114721
- chatId: parsed.chatId,
114722
- senderId: senderOpenId,
114723
- triggerType,
114724
- textPreview: parsed.text.slice(0, 120),
114725
- status: "received",
114726
- receivedAt: new Date(eventStartedAt).toISOString(),
114727
- statusPath: ["\u5DF2\u6536\u5230"],
114728
- reason: "\u5DF2\u8FDB\u5165 bridge\uFF0C\u51C6\u5907\u521B\u5EFA\u5904\u7406\u5361\u7247\u3002"
114729
- });
114730
- await this.deps.client.addProcessingReaction?.(messageId);
114731
- const existing = this.deps.sessionStore.get(threadId, botId);
114732
- const isNewThread = existing === void 0;
114733
- const isTopLevel = !(typeof parsed.raw.root_id === "string" && parsed.raw.root_id);
114734
- const replyInThread = isTopLevel;
114735
- let card;
114736
114829
  try {
114737
- card = await this.deps.cardRenderer.start(messageId, { replyInThread, threadId });
114738
- await recordEvent({
114739
- status: "running",
114740
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
114741
- appendPath: "\u5DF2\u521B\u5EFA\u5361\u7247",
114742
- reason: "\u5DF2\u4EA4\u7ED9\u672C\u5730 Agent \u5904\u7406\u3002"
114743
- });
114744
- } catch (err) {
114745
- console.error("[bridge.handler] Failed to start card for thread", threadId, err);
114830
+ const parsed = parseMessage(event);
114831
+ const { threadId, messageId, senderOpenId } = parsed;
114832
+ const botId = this.deps.botConfig?.id;
114833
+ const eventLogId = messageId;
114834
+ const eventStartedAt = Date.now();
114835
+ const recordEvent = async (patch) => {
114836
+ if (!this.deps.recordRuntimeEvent) return;
114837
+ try {
114838
+ await this.deps.recordRuntimeEvent({ id: eventLogId, ...patch });
114839
+ } catch (err) {
114840
+ console.warn("[bridge.handler] recordRuntimeEvent failed (continuing):", err);
114841
+ }
114842
+ };
114843
+ const triggerType = typeof parsed.raw.root_id === "string" && parsed.raw.root_id ? "thread_reply" : "mention";
114746
114844
  await recordEvent({
114747
- status: "running",
114748
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
114749
- appendPath: "\u5361\u7247\u521B\u5EFA\u5931\u8D25\uFF0C\u7EE7\u7EED\u6267\u884C",
114750
- reason: "\u5361\u7247\u521B\u5EFA\u5931\u8D25\uFF0C\u4F46 bridge \u4F1A\u7EE7\u7EED\u542F\u52A8\u672C\u5730 Agent\u3002"
114845
+ botId,
114846
+ botName: this.deps.botConfig?.name,
114847
+ messageId,
114848
+ threadId,
114849
+ chatId: parsed.chatId,
114850
+ senderId: senderOpenId,
114851
+ triggerType,
114852
+ textPreview: parsed.text.slice(0, 120),
114853
+ status: "received",
114854
+ receivedAt: new Date(eventStartedAt).toISOString(),
114855
+ statusPath: ["\u5DF2\u6536\u5230"],
114856
+ reason: "\u5DF2\u8FDB\u5165 bridge\uFF0C\u51C6\u5907\u521B\u5EFA\u5904\u7406\u5361\u7247\u3002"
114751
114857
  });
114752
- } finally {
114753
- await this.deps.client.removeProcessingReaction?.(messageId);
114754
- }
114755
- try {
114756
- const { conventions } = this.deps;
114757
- const isAgentWorkspace = conventions.runtime === "agent_workspace";
114758
- if (isAgentWorkspace) {
114759
- if (!conventions.agentWorkspacePath || !conventions.workspaceSessionsDir || !conventions.workspaceReposPath) {
114760
- throw new Error("agent_workspace runtime requires workspace path conventions");
114761
- }
114762
- }
114763
- const worktreePath = isAgentWorkspace ? path8.join(conventions.workspaceSessionsDir, threadId) : path8.join(conventions.worktreesDir, threadId);
114764
- const runCwd = isAgentWorkspace ? conventions.agentWorkspacePath : worktreePath;
114765
- const hasRepo = !isAgentWorkspace && !!conventions.repoCachePath;
114766
- const buildWorktree = hasRepo && !conventions.readOnly;
114767
- const extraRepos = conventions.extraRepoPaths ?? [];
114768
- if (isAgentWorkspace) {
114769
- await ensureAgentWorkspace({
114770
- agentId: botId ?? "v1-default",
114771
- workspacePath: conventions.agentWorkspacePath,
114772
- reposPath: conventions.workspaceReposPath,
114773
- sessionPath: worktreePath,
114774
- bot: {
114775
- name: this.deps.botConfig?.name ?? "Larkway Agent",
114776
- description: this.deps.botConfig?.description ?? "Local agent served through Larkway.",
114777
- gitlab_token_env: this.deps.botConfig?.git_token_env ?? this.deps.botConfig?.gitlab_token_env
114778
- },
114779
- agentMemory: this.deps.agentMemory,
114780
- repos: [
114781
- ...conventions.defaultProjectSlug ? [
114782
- {
114783
- slug: conventions.defaultProjectSlug,
114784
- branch: conventions.defaultBranch,
114785
- url: conventions.primaryRepoUrl,
114786
- suggestedPath: conventions.repoCachePath ?? conventions.workspaceReposPath
114787
- }
114788
- ] : [],
114789
- ...extraRepos.map((repo) => ({
114790
- slug: repo.slug,
114791
- url: repo.url,
114792
- suggestedPath: repo.cachePath
114793
- }))
114794
- ]
114858
+ await this.deps.client.addProcessingReaction?.(messageId);
114859
+ const existing = this.deps.sessionStore.get(threadId, botId);
114860
+ const isNewThread = existing === void 0;
114861
+ const isTopLevel = !(typeof parsed.raw.root_id === "string" && parsed.raw.root_id);
114862
+ const replyInThread = isTopLevel;
114863
+ let card;
114864
+ try {
114865
+ card = await this.deps.cardRenderer.start(messageId, { replyInThread, threadId });
114866
+ await recordEvent({
114867
+ status: "running",
114868
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
114869
+ appendPath: "\u5DF2\u521B\u5EFA\u5361\u7247",
114870
+ reason: "\u5DF2\u4EA4\u7ED9\u672C\u5730 Agent \u5904\u7406\u3002"
114871
+ });
114872
+ } catch (err) {
114873
+ console.error("[bridge.handler] Failed to start card for thread", threadId, err);
114874
+ await recordEvent({
114875
+ status: "running",
114876
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
114877
+ appendPath: "\u5361\u7247\u521B\u5EFA\u5931\u8D25\uFF0C\u7EE7\u7EED\u6267\u884C",
114878
+ reason: "\u5361\u7247\u521B\u5EFA\u5931\u8D25\uFF0C\u4F46 bridge \u4F1A\u7EE7\u7EED\u542F\u52A8\u672C\u5730 Agent\u3002"
114795
114879
  });
114880
+ } finally {
114881
+ await this.deps.client.removeProcessingReaction?.(messageId);
114796
114882
  }
114797
- if (hasRepo) {
114798
- await ensureRepoClone(
114799
- conventions.repoCachePath,
114800
- conventions.primaryRepoUrl,
114801
- this.deps.gitlabToken,
114802
- conventions.defaultProjectSlug ?? "primary"
114803
- );
114804
- try {
114805
- await execGit(conventions.repoCachePath, ["fetch", "origin", "--quiet"]);
114806
- } catch (err) {
114807
- console.warn("[bridge.handler] primary repo fetch failed (continuing):", err);
114883
+ try {
114884
+ const { conventions } = this.deps;
114885
+ const isAgentWorkspace = conventions.runtime === "agent_workspace";
114886
+ if (isAgentWorkspace) {
114887
+ if (!conventions.agentWorkspacePath || !conventions.workspaceSessionsDir || !conventions.workspaceReposPath) {
114888
+ throw new Error("agent_workspace runtime requires workspace path conventions");
114889
+ }
114808
114890
  }
114809
- }
114810
- for (const repo of isAgentWorkspace ? [] : extraRepos) {
114811
- await ensureRepoClone(
114812
- repo.cachePath,
114813
- repo.url,
114814
- this.deps.gitlabToken,
114815
- repo.slug
114816
- );
114817
- try {
114818
- await execGit(repo.cachePath, ["fetch", "origin", "--quiet"]);
114819
- } catch (err) {
114820
- console.warn(
114821
- `[bridge.handler] extra repo ${repo.slug} fetch failed (continuing):`,
114822
- err
114823
- );
114891
+ const worktreePath = isAgentWorkspace ? path8.join(conventions.workspaceSessionsDir, threadId) : path8.join(conventions.worktreesDir, threadId);
114892
+ const runCwd = isAgentWorkspace ? conventions.agentWorkspacePath : worktreePath;
114893
+ const hasRepo = !isAgentWorkspace && !!conventions.repoCachePath;
114894
+ const buildWorktree = hasRepo && !conventions.readOnly;
114895
+ const extraRepos = conventions.extraRepoPaths ?? [];
114896
+ if (isAgentWorkspace) {
114897
+ await ensureAgentWorkspace({
114898
+ agentId: botId ?? "v1-default",
114899
+ workspacePath: conventions.agentWorkspacePath,
114900
+ reposPath: conventions.workspaceReposPath,
114901
+ sessionPath: worktreePath,
114902
+ bot: {
114903
+ name: this.deps.botConfig?.name ?? "Larkway Agent",
114904
+ description: this.deps.botConfig?.description ?? "Local agent served through Larkway.",
114905
+ gitlab_token_env: this.deps.botConfig?.git_token_env ?? this.deps.botConfig?.gitlab_token_env
114906
+ },
114907
+ agentMemory: this.deps.agentMemory,
114908
+ repos: [
114909
+ ...conventions.defaultProjectSlug ? [
114910
+ {
114911
+ slug: conventions.defaultProjectSlug,
114912
+ branch: conventions.defaultBranch,
114913
+ url: conventions.primaryRepoUrl,
114914
+ suggestedPath: conventions.repoCachePath ?? conventions.workspaceReposPath
114915
+ }
114916
+ ] : [],
114917
+ ...extraRepos.map((repo) => ({
114918
+ slug: repo.slug,
114919
+ url: repo.url,
114920
+ suggestedPath: repo.cachePath
114921
+ }))
114922
+ ]
114923
+ });
114824
114924
  }
114825
- }
114826
- let worktreeExists = await pathExists(worktreePath);
114827
- if (worktreeExists && buildWorktree) {
114828
- const healthy = await isWorktreeGitHealthy(worktreePath);
114829
- if (!healthy) {
114830
- console.warn(
114831
- `[bridge.handler] worktree ${worktreePath} exists but git health check failed \u2014 removing stale dir and rebuilding (BL-8: migrated worktree with dead .git pointer)`
114925
+ if (hasRepo) {
114926
+ await ensureRepoClone(
114927
+ conventions.repoCachePath,
114928
+ conventions.primaryRepoUrl,
114929
+ this.deps.gitlabToken,
114930
+ conventions.defaultProjectSlug ?? "primary"
114832
114931
  );
114833
114932
  try {
114834
- await fs5.rm(worktreePath, { recursive: true, force: true });
114835
- } catch (rmErr) {
114836
- console.warn("[bridge.handler] failed to remove stale worktree (will attempt rebuild anyway):", rmErr);
114933
+ await execGit(conventions.repoCachePath, ["fetch", "origin", "--quiet"]);
114934
+ } catch (err) {
114935
+ console.warn("[bridge.handler] primary repo fetch failed (continuing):", err);
114837
114936
  }
114838
- worktreeExists = false;
114839
114937
  }
114840
- }
114841
- if (!worktreeExists) {
114842
- if (buildWorktree) {
114843
- const slug = threadId.replace(/^om_/, "").slice(0, 16);
114844
- const botSegment = this.deps.botConfig?.id ? `${this.deps.botConfig.id}/` : "";
114845
- const branchName = `larkway/${botSegment}${slug}`;
114846
- await execGit(conventions.repoCachePath, [
114847
- "worktree",
114848
- "add",
114849
- worktreePath,
114850
- "-b",
114851
- branchName,
114852
- `origin/${conventions.defaultBranch}`
114853
- ]);
114854
- console.log(
114855
- `[bridge.handler] created worktree ${worktreePath} on branch ${branchName}`
114938
+ for (const repo of isAgentWorkspace ? [] : extraRepos) {
114939
+ await ensureRepoClone(
114940
+ repo.cachePath,
114941
+ repo.url,
114942
+ this.deps.gitlabToken,
114943
+ repo.slug
114856
114944
  );
114857
- } else {
114858
- await fs5.mkdir(worktreePath, { recursive: true });
114859
- if (conventions.readOnly && conventions.repoCachePath) {
114860
- console.log(
114861
- `[bridge.handler] created scratch dir ${worktreePath} (read_only bot: repo read-only at ${conventions.repoCachePath}, no worktree)`
114945
+ try {
114946
+ await execGit(repo.cachePath, ["fetch", "origin", "--quiet"]);
114947
+ } catch (err) {
114948
+ console.warn(
114949
+ `[bridge.handler] extra repo ${repo.slug} fetch failed (continuing):`,
114950
+ err
114862
114951
  );
114863
- } else {
114952
+ }
114953
+ }
114954
+ let worktreeExists = await pathExists(worktreePath);
114955
+ if (worktreeExists && buildWorktree) {
114956
+ const healthy = await isWorktreeGitHealthy(worktreePath);
114957
+ if (!healthy) {
114958
+ console.warn(
114959
+ `[bridge.handler] worktree ${worktreePath} exists but git health check failed \u2014 removing stale dir and rebuilding (BL-8: migrated worktree with dead .git pointer)`
114960
+ );
114961
+ try {
114962
+ await fs5.rm(worktreePath, { recursive: true, force: true });
114963
+ } catch (rmErr) {
114964
+ console.warn("[bridge.handler] failed to remove stale worktree (will attempt rebuild anyway):", rmErr);
114965
+ }
114966
+ worktreeExists = false;
114967
+ }
114968
+ }
114969
+ if (!worktreeExists) {
114970
+ if (buildWorktree) {
114971
+ const slug = threadId.replace(/^om_/, "").slice(0, 16);
114972
+ const botSegment = this.deps.botConfig?.id ? `${this.deps.botConfig.id}/` : "";
114973
+ const branchName = `larkway/${botSegment}${slug}`;
114974
+ await execGit(conventions.repoCachePath, [
114975
+ "worktree",
114976
+ "add",
114977
+ worktreePath,
114978
+ "-b",
114979
+ branchName,
114980
+ `origin/${conventions.defaultBranch}`
114981
+ ]);
114864
114982
  console.log(
114865
- `[bridge.handler] created scratch dir ${worktreePath} (bot has no repos)`
114983
+ `[bridge.handler] created worktree ${worktreePath} on branch ${branchName}`
114866
114984
  );
114985
+ } else {
114986
+ await fs5.mkdir(worktreePath, { recursive: true });
114987
+ if (conventions.readOnly && conventions.repoCachePath) {
114988
+ console.log(
114989
+ `[bridge.handler] created scratch dir ${worktreePath} (read_only bot: repo read-only at ${conventions.repoCachePath}, no worktree)`
114990
+ );
114991
+ } else {
114992
+ console.log(
114993
+ `[bridge.handler] created scratch dir ${worktreePath} (bot has no repos)`
114994
+ );
114995
+ }
114867
114996
  }
114868
114997
  }
114869
- }
114870
- if (isAgentWorkspace) {
114871
- await ensureSessionArtifacts({
114872
- sessionPath: worktreePath,
114873
- parsed,
114874
- isNewThread: existing === void 0,
114875
- larkCliProfile: this.deps.larkCliProfile
114876
- });
114877
- }
114878
- try {
114879
- await writeWorktreeSettings(worktreePath, {
114880
- allowExtra: this.deps.permissionsAllowExtra
114881
- });
114882
- } catch (err) {
114883
- console.warn("[bridge.handler] writeWorktreeSettings failed (continuing):", err);
114884
- }
114885
- try {
114886
- await ensureStateFile(worktreePath);
114887
- } catch (err) {
114888
- console.warn("[bridge.handler] ensureStateFile failed (continuing):", err);
114889
- }
114890
- if (card) {
114998
+ if (isAgentWorkspace) {
114999
+ await ensureSessionArtifacts({
115000
+ sessionPath: worktreePath,
115001
+ parsed,
115002
+ isNewThread: existing === void 0,
115003
+ larkCliProfile: this.deps.larkCliProfile
115004
+ });
115005
+ }
114891
115006
  try {
114892
- await writeCardFile(worktreePath, {
114893
- messageId: card.messageId,
114894
- chatId: parsed.chatId,
114895
- threadId,
114896
- botId: this.deps.botConfig?.id ?? "",
114897
- replyInThread,
114898
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
115007
+ await writeWorktreeSettings(worktreePath, {
115008
+ allowExtra: this.deps.permissionsAllowExtra
114899
115009
  });
114900
115010
  } catch (err) {
114901
- console.warn("[bridge.handler] writeCardFile failed (continuing):", err);
115011
+ console.warn("[bridge.handler] writeWorktreeSettings failed (continuing):", err);
114902
115012
  }
114903
- }
114904
- if (buildWorktree) {
114905
115013
  try {
114906
- await ensureNodeModules(worktreePath);
115014
+ await ensureStateFile(worktreePath);
114907
115015
  } catch (err) {
114908
- console.warn("[bridge.handler] ensureNodeModules failed (continuing):", err);
114909
- }
114910
- }
114911
- const preRunUpdatedAt = (await readStateFile(worktreePath))?.updated_at;
114912
- let currentExisting = existing;
114913
- let attempt = 0;
114914
- while (true) {
114915
- attempt++;
114916
- const currentIsNewThread = currentExisting === void 0;
114917
- const prompt = renderPrompt({
114918
- parsed,
114919
- isNewThread: currentIsNewThread,
114920
- conventions: {
114921
- worktreePath,
114922
- runtime: conventions.runtime,
114923
- agentWorkspacePath: conventions.agentWorkspacePath,
114924
- workspaceSessionPath: isAgentWorkspace ? worktreePath : void 0,
114925
- workspaceReposPath: conventions.workspaceReposPath,
114926
- stateFilePath: stateFilePathOf(worktreePath),
114927
- repoCachePath: conventions.repoCachePath,
114928
- primaryRepoUrl: conventions.primaryRepoUrl,
114929
- defaultBranch: conventions.defaultBranch,
114930
- defaultProjectSlug: conventions.defaultProjectSlug,
114931
- extraRepoPaths: conventions.extraRepoPaths,
114932
- devHostname: conventions.devHostname,
114933
- portRangeStart: conventions.portRangeStart,
114934
- portRangeEnd: conventions.portRangeEnd,
114935
- readOnly: conventions.readOnly,
114936
- gitlabTokenEnvName: conventions.gitlabTokenEnvName
114937
- },
114938
- peers: this.deps.peers,
114939
- turn_taking_limit: this.deps.botConfig?.turn_taking_limit,
114940
- botName: this.deps.botConfig?.name,
114941
- backend: this.deps.botConfig?.backend,
114942
- agentMemory: this.deps.agentMemory,
114943
- larkCliProfile: this.deps.larkCliProfile,
114944
- runtimeWarnings: this.runtimeWarnings()
114945
- });
114946
- const backend = this.deps.botConfig?.backend ?? "claude";
114947
- const permissionMode = this.deps.permissionMode ?? (isAgentWorkspace ? "acceptEdits" : "bypassPermissions");
114948
- const timeoutMs = this.deps.subprocessTimeoutMs ?? 60 * 60 * 1e3;
114949
- const handle = createRunner(backend).run({
114950
- prompt,
114951
- resumeSessionId: currentExisting?.sessionId,
114952
- permissionMode,
114953
- timeoutMs,
114954
- cwd: runCwd,
114955
- // V2: inject per-bot git identity; absent in V1 → runner.ts uses "larkway-bot" fallback
114956
- botGitIdentity: this.deps.botConfig?.git_identity,
114957
- gitlabToken: this.deps.gitlabToken
114958
- });
114959
- let sessionId;
114960
- let lastText = "";
114961
- try {
114962
- for await (const ev of handle.events) {
114963
- if (card) card.handle(ev);
114964
- if (ev.type === "system_init") {
114965
- sessionId = ev.sessionId;
114966
- }
114967
- if (ev.type === "text_delta") {
114968
- lastText = ev.text;
114969
- }
114970
- }
114971
- const result = await handle.done;
114972
- const rawReportedState = await readStateFile(worktreePath);
114973
- const reportedState = rawReportedState?.updated_at != null && rawReportedState.updated_at !== preRunUpdatedAt ? rawReportedState : null;
114974
- const now = Date.now();
114975
- if (sessionId !== void 0 && currentExisting === void 0) {
114976
- await this.deps.sessionStore.put({
114977
- threadId,
114978
- sessionId,
114979
- botId,
114980
- createdTs: now,
114981
- lastActiveTs: now,
114982
- senderOpenId
114983
- });
114984
- } else if (sessionId !== void 0 && currentExisting !== void 0) {
114985
- await this.deps.sessionStore.put({
115016
+ console.warn("[bridge.handler] ensureStateFile failed (continuing):", err);
115017
+ }
115018
+ if (card) {
115019
+ try {
115020
+ await writeCardFile(worktreePath, {
115021
+ messageId: card.messageId,
115022
+ chatId: parsed.chatId,
114986
115023
  threadId,
114987
- sessionId,
114988
- botId,
114989
- createdTs: currentExisting.createdTs,
114990
- lastActiveTs: now,
114991
- senderOpenId
114992
- });
114993
- } else if (currentExisting !== void 0 && sessionId === void 0) {
114994
- await this.deps.sessionStore.put({
114995
- ...currentExisting,
114996
- lastActiveTs: now
115024
+ botId: this.deps.botConfig?.id ?? "",
115025
+ replyInThread,
115026
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
114997
115027
  });
115028
+ } catch (err) {
115029
+ console.warn("[bridge.handler] writeCardFile failed (continuing):", err);
114998
115030
  }
114999
- if (card) {
115000
- const reportedStatus = reportedState?.status;
115001
- const reportedError = reportedState?.error;
115002
- let success;
115003
- let failureReason;
115004
- if (reportedStatus === "failed") {
115005
- success = false;
115006
- failureReason = reportedError ?? "bot \u62A5\u544A failed (\u65E0 error \u5B57\u6BB5)";
115007
- } else if (reportedStatus === "ready") {
115008
- success = true;
115009
- } else if (result.exitCode === 0) {
115010
- success = true;
115011
- } else {
115012
- success = false;
115013
- failureReason = `claude exited ${result.exitCode} \u4E14 bot \u672A\u66F4\u65B0 state.json status \u2014 \u53EF\u80FD\u5D29\u6E83`;
115014
- }
115015
- const cardBody = reportedState?.last_message ?? (lastText.trim() ? lastText : "\u26A0\uFE0F \u672C\u8F6E\u6CA1\u6709\u62FF\u5230 agent \u7684\u65B0\u56DE\u590D(\u53EF\u80FD\u88AB\u4E2D\u65AD\u6216\u672A\u66F4\u65B0\u72B6\u6001),\u518D @ \u6211\u4E00\u6B21\u91CD\u8BD5\u3002");
115016
- const noReportThisTurn = reportedState === null;
115017
- const neutralTitle = noReportThisTurn && success && !failureReason ? "\u{1F4AC} \u5DF2\u56DE\u590D" : void 0;
115018
- await card.finalize({
115019
- finalText: cardBody,
115020
- success,
115021
- failureReason,
115022
- titleOverride: reportedState?.card_title ?? neutralTitle,
115023
- colorOverride: reportedState?.card_color ?? (neutralTitle ? "neutral" : void 0),
115024
- // V2 dynamic-choice buttons — agent-declared, rendered verbatim.
115025
- // reportedState is null when state.json wasn't freshly written
115026
- // (stale-guard), so stale leftover choices never reappear.
115027
- choices: reportedState?.choices,
115028
- choicePrompt: reportedState?.choice_prompt
115029
- });
115030
- await deleteCardFile(worktreePath);
115031
+ }
115032
+ if (buildWorktree) {
115033
+ try {
115034
+ await ensureNodeModules(worktreePath);
115035
+ } catch (err) {
115036
+ console.warn("[bridge.handler] ensureNodeModules failed (continuing):", err);
115031
115037
  }
115032
- this.deps.client.acknowledgeMessage(messageId);
115033
- await recordEvent({
115034
- status: "completed",
115035
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
115036
- durationMs: Date.now() - eventStartedAt,
115037
- appendPath: "\u5DF2\u5B8C\u6210",
115038
- reason: "Agent \u5DF2\u7ED3\u675F\uFF0C\u6D88\u606F\u5DF2\u786E\u8BA4\u3002"
115038
+ }
115039
+ const preRunUpdatedAt = (await readStateFile(worktreePath))?.updated_at;
115040
+ let currentExisting = existing;
115041
+ let attempt = 0;
115042
+ while (true) {
115043
+ attempt++;
115044
+ const currentIsNewThread = currentExisting === void 0;
115045
+ const prompt = renderPrompt({
115046
+ parsed,
115047
+ isNewThread: currentIsNewThread,
115048
+ conventions: {
115049
+ worktreePath,
115050
+ runtime: conventions.runtime,
115051
+ agentWorkspacePath: conventions.agentWorkspacePath,
115052
+ workspaceSessionPath: isAgentWorkspace ? worktreePath : void 0,
115053
+ workspaceReposPath: conventions.workspaceReposPath,
115054
+ stateFilePath: stateFilePathOf(worktreePath),
115055
+ repoCachePath: conventions.repoCachePath,
115056
+ primaryRepoUrl: conventions.primaryRepoUrl,
115057
+ defaultBranch: conventions.defaultBranch,
115058
+ defaultProjectSlug: conventions.defaultProjectSlug,
115059
+ extraRepoPaths: conventions.extraRepoPaths,
115060
+ devHostname: conventions.devHostname,
115061
+ portRangeStart: conventions.portRangeStart,
115062
+ portRangeEnd: conventions.portRangeEnd,
115063
+ readOnly: conventions.readOnly,
115064
+ gitlabTokenEnvName: conventions.gitlabTokenEnvName
115065
+ },
115066
+ peers: this.deps.peers,
115067
+ turn_taking_limit: this.deps.botConfig?.turn_taking_limit,
115068
+ botName: this.deps.botConfig?.name,
115069
+ backend: this.deps.botConfig?.backend,
115070
+ agentMemory: this.deps.agentMemory,
115071
+ larkCliProfile: this.deps.larkCliProfile,
115072
+ runtimeWarnings: this.runtimeWarnings()
115039
115073
  });
115040
- break;
115041
- } catch (spawnErr) {
115042
- const errMsg = String(spawnErr.message ?? spawnErr);
115043
- if (attempt === 1 && currentExisting != null && errMsg.includes("No conversation found")) {
115044
- console.warn(
115045
- `[bridge.handler] stale session ${currentExisting.sessionId} for thread ${threadId} \u2014 removing and retrying without --resume`
115046
- );
115047
- await this.deps.sessionStore.delete(threadId, botId);
115048
- currentExisting = void 0;
115049
- continue;
115074
+ const backend = this.deps.botConfig?.backend ?? "claude";
115075
+ const permissionMode = this.deps.permissionMode ?? (isAgentWorkspace ? "acceptEdits" : "bypassPermissions");
115076
+ const timeoutMs = this.deps.subprocessTimeoutMs ?? 60 * 60 * 1e3;
115077
+ const handle = createRunner(backend).run({
115078
+ prompt,
115079
+ resumeSessionId: currentExisting?.sessionId,
115080
+ permissionMode,
115081
+ timeoutMs,
115082
+ cwd: runCwd,
115083
+ // V2: inject per-bot git identity; absent in V1 → runner.ts uses "larkway-bot" fallback
115084
+ botGitIdentity: this.deps.botConfig?.git_identity,
115085
+ gitlabToken: this.deps.gitlabToken
115086
+ });
115087
+ let sessionId;
115088
+ let lastText = "";
115089
+ try {
115090
+ for await (const ev of handle.events) {
115091
+ if (card) card.handle(ev);
115092
+ if (ev.type === "system_init") {
115093
+ sessionId = ev.sessionId;
115094
+ }
115095
+ if (ev.type === "text_delta") {
115096
+ lastText = ev.text;
115097
+ }
115098
+ }
115099
+ const result = await handle.done;
115100
+ const rawReportedState = await readStateFile(worktreePath);
115101
+ const reportedState = rawReportedState?.updated_at != null && rawReportedState.updated_at !== preRunUpdatedAt ? rawReportedState : null;
115102
+ const now = Date.now();
115103
+ if (sessionId !== void 0 && currentExisting === void 0) {
115104
+ await this.deps.sessionStore.put({
115105
+ threadId,
115106
+ sessionId,
115107
+ botId,
115108
+ createdTs: now,
115109
+ lastActiveTs: now,
115110
+ senderOpenId
115111
+ });
115112
+ } else if (sessionId !== void 0 && currentExisting !== void 0) {
115113
+ await this.deps.sessionStore.put({
115114
+ threadId,
115115
+ sessionId,
115116
+ botId,
115117
+ createdTs: currentExisting.createdTs,
115118
+ lastActiveTs: now,
115119
+ senderOpenId
115120
+ });
115121
+ } else if (currentExisting !== void 0 && sessionId === void 0) {
115122
+ await this.deps.sessionStore.put({
115123
+ ...currentExisting,
115124
+ lastActiveTs: now
115125
+ });
115126
+ }
115127
+ if (card) {
115128
+ const reportedStatus = reportedState?.status;
115129
+ const reportedError = reportedState?.error;
115130
+ let success;
115131
+ let failureReason;
115132
+ if (reportedStatus === "failed") {
115133
+ success = false;
115134
+ failureReason = reportedError ?? "bot \u62A5\u544A failed (\u65E0 error \u5B57\u6BB5)";
115135
+ } else if (reportedStatus === "ready") {
115136
+ success = true;
115137
+ } else if (result.exitCode === 0) {
115138
+ success = true;
115139
+ } else {
115140
+ success = false;
115141
+ failureReason = `claude exited ${result.exitCode} \u4E14 bot \u672A\u66F4\u65B0 state.json status \u2014 \u53EF\u80FD\u5D29\u6E83`;
115142
+ }
115143
+ const cardBody = reportedState?.last_message ?? (lastText.trim() ? lastText : "\u26A0\uFE0F \u672C\u8F6E\u6CA1\u6709\u62FF\u5230 agent \u7684\u65B0\u56DE\u590D(\u53EF\u80FD\u88AB\u4E2D\u65AD\u6216\u672A\u66F4\u65B0\u72B6\u6001),\u518D @ \u6211\u4E00\u6B21\u91CD\u8BD5\u3002");
115144
+ const noReportThisTurn = reportedState === null;
115145
+ const neutralTitle = noReportThisTurn && success && !failureReason ? "\u{1F4AC} \u5DF2\u56DE\u590D" : void 0;
115146
+ await card.finalize({
115147
+ finalText: cardBody,
115148
+ success,
115149
+ failureReason,
115150
+ titleOverride: reportedState?.card_title ?? neutralTitle,
115151
+ colorOverride: reportedState?.card_color ?? (neutralTitle ? "neutral" : void 0),
115152
+ // V2 dynamic-choice buttons — agent-declared, rendered verbatim.
115153
+ // reportedState is null when state.json wasn't freshly written
115154
+ // (stale-guard), so stale leftover choices never reappear.
115155
+ choices: reportedState?.choices,
115156
+ choicePrompt: reportedState?.choice_prompt
115157
+ });
115158
+ await deleteCardFile(worktreePath);
115159
+ }
115160
+ settle(true);
115161
+ await recordEvent({
115162
+ status: "completed",
115163
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
115164
+ durationMs: Date.now() - eventStartedAt,
115165
+ appendPath: "\u5DF2\u5B8C\u6210",
115166
+ reason: "Agent \u5DF2\u7ED3\u675F\uFF0C\u6D88\u606F\u5DF2\u786E\u8BA4\u3002"
115167
+ });
115168
+ break;
115169
+ } catch (spawnErr) {
115170
+ const errMsg = String(spawnErr.message ?? spawnErr);
115171
+ if (attempt === 1 && currentExisting != null && errMsg.includes("No conversation found")) {
115172
+ console.warn(
115173
+ `[bridge.handler] stale session ${currentExisting.sessionId} for thread ${threadId} \u2014 removing and retrying without --resume`
115174
+ );
115175
+ await this.deps.sessionStore.delete(threadId, botId);
115176
+ currentExisting = void 0;
115177
+ continue;
115178
+ }
115179
+ throw spawnErr;
115050
115180
  }
115051
- throw spawnErr;
115052
115181
  }
115053
- }
115054
- } catch (err) {
115055
- console.error("[bridge.handler] handleOne failed for thread", threadId, err);
115056
- await this.deps.client.removeProcessingReaction?.(messageId);
115057
- await recordEvent({
115058
- status: "failed",
115059
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
115060
- durationMs: Date.now() - eventStartedAt,
115061
- appendPath: "\u5F02\u5E38",
115062
- reason: String(err)
115063
- });
115064
- this.deps.client.acknowledgeMessage(messageId);
115065
- if (card) {
115066
- try {
115067
- await card.finalize({
115068
- success: false,
115069
- failureReason: String(err)
115070
- // No choices on the hard-crash path: reportedState isn't in scope
115071
- // here, and a crashed turn offering pick-an-option buttons is wrong.
115072
- });
115073
- } catch (finalizeErr) {
115074
- console.error("[bridge.handler] finalize(failure) also failed:", finalizeErr);
115182
+ } catch (err) {
115183
+ console.error("[bridge.handler] handleOne failed for thread", threadId, err);
115184
+ await this.deps.client.removeProcessingReaction?.(messageId);
115185
+ await recordEvent({
115186
+ status: "failed",
115187
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
115188
+ durationMs: Date.now() - eventStartedAt,
115189
+ appendPath: "\u5F02\u5E38",
115190
+ reason: String(err)
115191
+ });
115192
+ settle(false);
115193
+ if (card) {
115194
+ try {
115195
+ await card.finalize({
115196
+ success: false,
115197
+ failureReason: String(err)
115198
+ // No choices on the hard-crash path: reportedState isn't in scope
115199
+ // here, and a crashed turn offering pick-an-option buttons is wrong.
115200
+ });
115201
+ } catch (finalizeErr) {
115202
+ console.error("[bridge.handler] finalize(failure) also failed:", finalizeErr);
115203
+ }
115204
+ const wtPath = this.deps.conventions.runtime === "agent_workspace" && this.deps.conventions.workspaceSessionsDir ? path8.join(this.deps.conventions.workspaceSessionsDir, threadId) : path8.join(this.deps.conventions.worktreesDir, threadId);
115205
+ await deleteCardFile(wtPath);
115075
115206
  }
115076
- const wtPath = this.deps.conventions.runtime === "agent_workspace" && this.deps.conventions.workspaceSessionsDir ? path8.join(this.deps.conventions.workspaceSessionsDir, threadId) : path8.join(this.deps.conventions.worktreesDir, threadId);
115077
- await deleteCardFile(wtPath);
115078
115207
  }
115208
+ } finally {
115209
+ settle(false);
115079
115210
  }
115080
115211
  }
115081
115212
  };
@@ -119066,18 +119197,8 @@ var CodexRunner = class {
119066
119197
  // src/lark/profileBootstrap.ts
119067
119198
  import { spawnSync } from "node:child_process";
119068
119199
  function ensureLarkCliProfile(botId, profileName, appId, appSecret, _spawnSync = spawnSync, _console = console) {
119069
- try {
119070
- const showResult = _spawnSync("lark-cli", ["config", "show", "--profile", profileName], {
119071
- encoding: "utf-8",
119072
- timeout: 5e3
119073
- });
119074
- if (showResult.status === 0 && typeof showResult.stdout === "string" && showResult.stdout.includes(appId)) {
119075
- return;
119076
- }
119077
- } catch {
119078
- }
119079
119200
  _console.log(
119080
- `[larkway] bot "${botId}": creating lark-cli profile "${profileName}" for app ${appId.slice(0, 8)}\u2026`
119201
+ `[larkway] bot "${botId}": provisioning lark-cli profile "${profileName}" for app ${appId.slice(0, 8)}\u2026`
119081
119202
  );
119082
119203
  try {
119083
119204
  const initResult = _spawnSync(
@@ -119090,11 +119211,11 @@ function ensureLarkCliProfile(botId, profileName, appId, appSecret, _spawnSync =
119090
119211
  }
119091
119212
  );
119092
119213
  if (initResult.status === 0) {
119093
- _console.log(`[larkway] bot "${botId}": lark-cli profile "${profileName}" created/updated OK.`);
119214
+ _console.log(`[larkway] bot "${botId}": lark-cli profile "${profileName}" provisioned OK.`);
119094
119215
  } else {
119095
119216
  const stderr = typeof initResult.stderr === "string" ? initResult.stderr.trim() : "";
119096
119217
  _console.warn(
119097
- `[larkway] WARNING: bot "${botId}" failed to create lark-cli profile "${profileName}" (exit ${initResult.status}${stderr ? `: ${stderr}` : ""}). Multi-bot lark-cli calls may use the wrong app credentials. Fix: lark-cli config init --app-id ${appId} --app-secret-stdin --name ${profileName}`
119218
+ `[larkway] WARNING: bot "${botId}" failed to provision lark-cli profile "${profileName}" (exit ${initResult.status}${stderr ? `: ${stderr}` : ""}). Multi-bot lark-cli calls may use the wrong app credentials. Fix: lark-cli config init --app-id ${appId} --app-secret-stdin --name ${profileName}`
119098
119219
  );
119099
119220
  }
119100
119221
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",