larkway 0.3.52 → 0.3.53

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.52**
11
+ **Current release: v0.3.53**
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.52**
11
+ **当前版本:v0.3.53**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -108729,6 +108729,40 @@ var init_channelClient = __esm({
108729
108729
  log(`cardAction \u2192 synthesized turn: value=${JSON.stringify(evt.action?.value)} thread=${ev.thread_id ?? "?"}`);
108730
108730
  this.queue.push(ev);
108731
108731
  }
108732
+ /**
108733
+ * Local-dispatch fast path (peer handoff): push an event that carries a REAL
108734
+ * Feishu message_id — one the bridge itself just sent on a sibling bot's
108735
+ * behalf — through the SAME dedup bookkeeping as a live WS delivery. The
108736
+ * point of going through here instead of {@link enqueueSyntheticEvent}: the
108737
+ * real message WILL also arrive over this bot's own WS (it @-mentions this
108738
+ * bot), and marking it in-flight now is what makes that later copy a no-op.
108739
+ * Ordering is symmetric — if the WS copy somehow wins the race, THIS call
108740
+ * becomes the no-op. Either way exactly one turn runs.
108741
+ *
108742
+ * Failure keeps the existing self-heal semantics: markUnhandled removes the
108743
+ * id from in-flight, so gap-fill can re-dispatch the real message later.
108744
+ *
108745
+ * @returns true when the event was dispatched, false when it was deduped
108746
+ * (already seen/in-flight) or the client is closed.
108747
+ */
108748
+ ingestLocalEvent(ev, sourceTag) {
108749
+ if (this.closed) return false;
108750
+ const log = (s) => console.log(`[channel.client] ${s}`);
108751
+ this.noteSeenChat(ev.chat_id);
108752
+ if (typeof ev.chat_type === "string" && ev.chat_type.length > 0) {
108753
+ this.chatTypesById.set(ev.chat_id, ev.chat_type);
108754
+ }
108755
+ if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
108756
+ log(`local-dispatch deduped (${sourceTag}): message_id=${ev.message_id}`);
108757
+ return false;
108758
+ }
108759
+ this.inFlightMessageIds.add(ev.message_id);
108760
+ this.noteInFlightMeta(ev);
108761
+ this.noteDispatchAttempt(ev.message_id);
108762
+ log(`dispatching (local-handoff from ${sourceTag}): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
108763
+ this.queue.push(ev);
108764
+ return true;
108765
+ }
108732
108766
  /**
108733
108767
  * Push an already-built synthetic LarkMessageEvent onto the inbound queue,
108734
108768
  * so handler.ts processes it as an ordinary turn. Same mechanism as
package/dist/main.js CHANGED
@@ -112856,6 +112856,40 @@ var ChannelClient = class {
112856
112856
  log(`cardAction \u2192 synthesized turn: value=${JSON.stringify(evt.action?.value)} thread=${ev.thread_id ?? "?"}`);
112857
112857
  this.queue.push(ev);
112858
112858
  }
112859
+ /**
112860
+ * Local-dispatch fast path (peer handoff): push an event that carries a REAL
112861
+ * Feishu message_id — one the bridge itself just sent on a sibling bot's
112862
+ * behalf — through the SAME dedup bookkeeping as a live WS delivery. The
112863
+ * point of going through here instead of {@link enqueueSyntheticEvent}: the
112864
+ * real message WILL also arrive over this bot's own WS (it @-mentions this
112865
+ * bot), and marking it in-flight now is what makes that later copy a no-op.
112866
+ * Ordering is symmetric — if the WS copy somehow wins the race, THIS call
112867
+ * becomes the no-op. Either way exactly one turn runs.
112868
+ *
112869
+ * Failure keeps the existing self-heal semantics: markUnhandled removes the
112870
+ * id from in-flight, so gap-fill can re-dispatch the real message later.
112871
+ *
112872
+ * @returns true when the event was dispatched, false when it was deduped
112873
+ * (already seen/in-flight) or the client is closed.
112874
+ */
112875
+ ingestLocalEvent(ev, sourceTag) {
112876
+ if (this.closed) return false;
112877
+ const log = (s) => console.log(`[channel.client] ${s}`);
112878
+ this.noteSeenChat(ev.chat_id);
112879
+ if (typeof ev.chat_type === "string" && ev.chat_type.length > 0) {
112880
+ this.chatTypesById.set(ev.chat_id, ev.chat_type);
112881
+ }
112882
+ if (this.seenMessageIds.has(ev.message_id) || this.inFlightMessageIds.has(ev.message_id)) {
112883
+ log(`local-dispatch deduped (${sourceTag}): message_id=${ev.message_id}`);
112884
+ return false;
112885
+ }
112886
+ this.inFlightMessageIds.add(ev.message_id);
112887
+ this.noteInFlightMeta(ev);
112888
+ this.noteDispatchAttempt(ev.message_id);
112889
+ log(`dispatching (local-handoff from ${sourceTag}): message_id=${ev.message_id} thread=${ev.thread_id ?? "?"}`);
112890
+ this.queue.push(ev);
112891
+ return true;
112892
+ }
112859
112893
  /**
112860
112894
  * Push an already-built synthetic LarkMessageEvent onto the inbound queue,
112861
112895
  * so handler.ts processes it as an ordinary turn. Same mechanism as
@@ -114773,7 +114807,8 @@ function renderStateContract(stateFilePath) {
114773
114807
  "- \u9700\u8981\u7528\u6237\u5355\u9009:status=ready + choices(\u6700\u591A 5 \u4E2A `{label, value}`,\u53EF\u914D choice_prompt \u4E00\u884C\u95EE\u9898)\u3002label \u662F\u7B80\u77ED\u9009\u9879\u542B\u4E49;value \u662F\u70B9\u9009\u540E**\u9010\u5B57\u56DE\u4F20\u7ED9\u4F60**\u7684\u5B8C\u6574\u81EA\u63CF\u8FF0\u6307\u4EE4,\u522B\u5199 `optA` \u8FD9\u79CD\u4EE3\u53F7\u3002bridge \u81EA\u52A8\u7F16\u53F7 A/B/C \u5E76\u5728\u6B63\u6587\u751F\u6210\u56FE\u4F8B,**\u6B63\u6587\u522B\u518D\u624B\u52A8\u5217\u4E00\u904D\u9009\u9879**\u3002\u4EC5\u9650\u300C\u5355\u4E2A\u79BB\u6563\u9009\u62E9\u3001\u70B9\u4E00\u4E0B\u5C31\u7B54\u5168\u300D;\u4FE1\u606F\u6536\u96C6/\u591A\u90E8\u5206\u63D0\u95EE\u8BA9\u7528\u6237\u76F4\u63A5\u6253\u5B57,\u522B\u7528\u6309\u94AE\u3002",
114774
114808
  '- \u56FE\u6587\u6DF7\u6392:status=ready + content_blocks(\u6709\u5E8F\u5757\u6570\u7EC4,\u6700\u591A 12 \u5757\u3001\u5176\u4E2D image \u6700\u591A 4 \u5757;\u53EA\u652F\u6301 `{type:"markdown", content}` \u548C `{type:"image", img_key, alt?}`,img_key \u5FC5\u987B\u662F\u4F60\u5DF2\u4E0A\u4F20\u3001\u53EF\u7528\u4E8E\u5361\u7247\u7684 Feishu \u56FE\u7247 key;\u975E\u7A7A\u65F6\u5B83\u5C31\u662F\u4E3B\u6B63\u6587)\u3002',
114775
114809
  "- \u9700\u8981\u8986\u76D6\u5361\u7247\u6B63\u6587:status=ready + last_message(\u4E0D\u5199\u65F6\u6B63\u6587=\u7B54\u6848\u901A\u9053\u5185\u5BB9);dev_url/mr_url \u7B49\u4E1A\u52A1\u5B57\u6BB5\u53EF\u81EA\u7531\u5199\u5165,\u4F46 bridge \u4E0D\u611F\u77E5\u5176\u542B\u4E49,\u8981\u8BA9\u7528\u6237\u770B\u5230\u5C31\u5199\u8FDB\u6B63\u6587\u3002",
114776
- "- \u9700\u8981\u5728\u7EC8\u6001\u5361\u4E0A @ \u4EBA:response_surface \u5199 `{post:{mentions:[{user_id}]}}`;\u8FD9\u53EA\u662F\u89C6\u89C9\u63D0\u793A,\u8981 peer bot \u6D88\u8D39\u6B63\u6587\u5FC5\u987B\u53E6\u53D1\u771F\u5B9E post(\u5361\u7247\u5BF9 agent \u4E0D\u53EF\u8BFB)\u3002",
114810
+ "- \u9700\u8981\u5728\u7EC8\u6001\u5361\u4E0A @ \u4EBA:response_surface \u5199 `{post:{mentions:[{user_id}]}}`;\u8FD9\u53EA\u662F\u89C6\u89C9\u63D0\u793A,\u8981 peer bot \u6D88\u8D39\u6B63\u6587\u5FC5\u987B\u8D70 handoffs \u6216\u53E6\u53D1\u771F\u5B9E post(\u5361\u7247\u5BF9 agent \u4E0D\u53EF\u8BFB)\u3002",
114811
+ "- \u4EA4\u63A5\u7ED9 peer bot:handoffs(\u6700\u591A 3 \u6761 `{to, text}`,to \u5199 peer-bots \u540D\u518C\u91CC\u7684\u540D\u5B57)\u3002bridge \u66FF\u4F60\u53D1\u4E00\u6761\u5E26\u771F\u5B9E at \u6807\u7B7E\u7684 post \u7559\u75D5,\u540C\u6865\u8FDB\u7A0B\u5185\u7684 peer \u8FD8\u4F1A\u88AB**\u672C\u5730\u76F4\u9012**\u7ACB\u5373\u5524\u9192\u2014\u2014\u6BD4\u81EA\u5DF1\u7528 lark-cli \u53D1 post \u66F4\u5FEB\u66F4\u53EF\u9760\u3002text \u5FC5\u987B\u81EA\u5305\u542B:\u5BF9\u65B9\u53EA\u770B\u5F97\u5230\u8FD9\u6761\u6587\u672C,\u80CC\u666F\u3001\u8981\u505A\u4EC0\u4E48\u3001\u671F\u671B\u4EA7\u51FA\u90FD\u5199\u6E05\u695A\u3002",
114777
114812
  "",
114778
114813
  "\u8FB9\u754C\u4E0E\u8D23\u4EFB:",
114779
114814
  "- **\u7EDD\u4E0D\u81EA\u5DF1 PATCH/PUT bridge \u7BA1\u7406\u7684\u5361\u7247/post** \u2014\u2014 \u7F51\u7EDC\u66F4\u65B0\u662F bridge \u7684\u6D3B,\u81EA\u5DF1\u6539\u4F1A\u548C\u5361\u7247 finalize\u3001\u6309\u94AE\u56DE\u4F20\u3001\u5D29\u6E83\u6062\u590D\u51B2\u7A81\u3002",
@@ -114786,7 +114821,7 @@ function renderContractAnchor(stateFilePath) {
114786
114821
  const stateTarget = stateFilePath ? `\`${stateFilePath}\`` : "\u5DE5\u4F5C\u76EE\u5F55\u91CC\u7684 `.larkway/state.json`";
114787
114822
  return [
114788
114823
  "<contract-anchor>",
114789
- `\u5951\u7EA6\u540C\u672C\u8BDD\u9898\u9996\u8F6E\u6CE8\u5165,\u672A\u53D8\u5316:\u7ED9\u7528\u6237\u770B\u7684\u7B54\u6848\u6B63\u6587\u5305\u5728\u72EC\u7ACB\u884C marker \`${ANSWER_BEGIN_MARKER}\` / \`${ANSWER_END_MARKER}\` \u4E4B\u95F4\u8F93\u51FA;\u7ED3\u8BBA\u4E00\u6210\u5F62\u5C31\u5148\u6D41\u51FA\u7B54\u6848\u3002\u7EAF\u6587\u5B57\u56DE\u7B54\u4E0D\u7528\u5199 state.json;\u5931\u8D25/\u7B49\u8865\u5145/choices/\u56FE\u6587\u6DF7\u6392\u65F6\u624D\u5199 ${stateTarget}(\u539F\u5B50\u5199 + \u5237\u65B0 updated_at)\u3002\u505A\u5B8C\u5E72\u51C0\u9000\u51FA\u8FDB\u7A0B\u3002`,
114824
+ `\u5951\u7EA6\u540C\u672C\u8BDD\u9898\u9996\u8F6E\u6CE8\u5165,\u672A\u53D8\u5316:\u7ED9\u7528\u6237\u770B\u7684\u7B54\u6848\u6B63\u6587\u5305\u5728\u72EC\u7ACB\u884C marker \`${ANSWER_BEGIN_MARKER}\` / \`${ANSWER_END_MARKER}\` \u4E4B\u95F4\u8F93\u51FA;\u7ED3\u8BBA\u4E00\u6210\u5F62\u5C31\u5148\u6D41\u51FA\u7B54\u6848\u3002\u7EAF\u6587\u5B57\u56DE\u7B54\u4E0D\u7528\u5199 state.json;\u5931\u8D25/\u7B49\u8865\u5145/choices/\u56FE\u6587\u6DF7\u6392/handoffs \u4EA4\u63A5\u65F6\u624D\u5199 ${stateTarget}(\u539F\u5B50\u5199 + \u5237\u65B0 updated_at)\u3002\u505A\u5B8C\u5E72\u51C0\u9000\u51FA\u8FDB\u7A0B\u3002`,
114790
114825
  "</contract-anchor>"
114791
114826
  ];
114792
114827
  }
@@ -114802,7 +114837,9 @@ function renderPeersBlock(peers) {
114802
114837
  "- \u53EA\u5728\u4F60\u786E\u8BA4\u81EA\u5DF1\u80FD\u529B\u8303\u56F4\u4E4B\u5916\u624D @ peer",
114803
114838
  "- @ peer \u65F6\u5728\u6D88\u606F\u91CC\u8BF4\u6E05\u695A\u300C\u4F60\u9700\u8981\u5B83\u505A\u4EC0\u4E48\u300D",
114804
114839
  "- \u4E0D\u8981\u628A\u540C\u4E00\u4EFB\u52A1\u540C\u65F6\u8F6C\u53D1\u7ED9\u591A\u4E2A peer",
114805
- '- @ peer \u5FC5\u987B\u7528 **post \u6D88\u606F** + at \u6807\u7B7E `{"tag":"at","user_id":"ou_xxx"}`(\u7528\u4E0A\u9762\u7684 open_id),',
114840
+ "- \u4EA4\u63A5\u9996\u9009 state.json \u7684 `handoffs` \u5B57\u6BB5(\u89C1 state-contract):bridge \u4EE3\u53D1\u5E26\u771F\u5B9E at \u6807\u7B7E\u7684 post,",
114841
+ " \u540C\u6865 peer \u8FD8\u4F1A\u88AB\u672C\u5730\u76F4\u9012\u7ACB\u5373\u5524\u9192\u3002\u4EC5\u5F53\u8F6E\u6B21**\u4E2D\u9014**\u9700\u8981\u5373\u65F6 ack/\u591A\u8F6E\u5F80\u6765\u65F6,\u624D\u81EA\u5DF1\u7528 lark-cli \u53D1",
114842
+ ' **post \u6D88\u606F** + at \u6807\u7B7E `{"tag":"at","user_id":"ou_xxx"}`(\u7528\u4E0A\u9762\u7684 open_id),',
114806
114843
  " **\u4E25\u7981\u7528\u7EAF text \u7684 @xxx**(\u7EAF\u6587\u672C @ \u4E0D\u4F1A\u771F\u6B63\u89E6\u8FBE\u5BF9\u65B9 bot)",
114807
114844
  "- **\u5BF9\u65B9\u9700\u8981\u884C\u52A8\u7684\u5B9E\u8D28\u5185\u5BB9,\u5FC5\u987B\u5199\u8FDB\u8FD9\u6761 post \u6D88\u606F\u672C\u4F53,\u4E0D\u8981\u6307\u671B\u5BF9\u65B9\u53BB\u8BFB\u4F60\u7684\u5361\u7247\u603B\u7ED3**\u2014\u2014\u5B9E\u6D4B\u8FC7 peer \u7528\u5DE5\u5177\u8BFB\u53D6\u4F60\u4E4B\u524D\u53D1\u7684\u5361\u7247\u6D88\u606F\u65F6,\u53EA\u62FF\u5230\u300C\u8BF7\u5347\u7EA7\u5BA2\u6237\u7AEF\u67E5\u770B\u300D\u4E4B\u7C7B\u7684\u964D\u7EA7\u5360\u4F4D,\u8BFB\u4E0D\u5230\u5361\u7247\u91CC\u7684\u771F\u5B9E\u7ED3\u8BBA\u3002\u5361\u7247\u662F\u7ED9\u4EBA\u770B\u7684\u5C55\u793A\u5C42,\u4E0D\u662F\u53EF\u9760\u7684 agent \u95F4\u6570\u636E\u901A\u9053\u3002",
114808
114845
  "- \u53D1\u8D77 peer handoff \u540E,\u5728\u4F60\u7684\u534F\u8C03\u5C42/\u5DE5\u4F5C\u533A\u53F0\u8D26\u8BB0\u5F55 task_id\u3001assignee\u3001\u6765\u6E90\u3001\u671F\u671B\u4EA7\u51FA\u3001deadline \u548C\u5347\u7EA7\u4EBA;\u6CA1\u6709\u4E13\u7528 skill \u65F6\u81F3\u5C11\u5199\u5165\u672C session summary\u3002",
@@ -116694,6 +116731,27 @@ var StateFileSchema = external_exports.object({
116694
116731
  * ignored and the legacy card path remains authoritative.
116695
116732
  */
116696
116733
  response_surface: ResponseSurfaceStateSchema.optional(),
116734
+ /**
116735
+ * Peer handoff declarations (local-dispatch fast path). Each entry asks the
116736
+ * bridge to (a) send ONE real Feishu post into this thread with a true at
116737
+ * tag for the named peer bot — the durable, human-visible mirror — and
116738
+ * (b) dispatch the same message to that peer's inbound queue locally when
116739
+ * the peer lives in this bridge process, so the handoff no longer depends
116740
+ * on the peer's WS delivery. `to` is the peer's display name or bot id as
116741
+ * listed in <peer-bots>; unknown targets are skipped with a diagnostic
116742
+ * (never fail the turn). Bridge-opaque beyond that: `text` is sent
116743
+ * VERBATIM — the bridge does not interpret it (thin channel).
116744
+ *
116745
+ * `.catch(undefined)`: soft-fail doctrine — a malformed optional field
116746
+ * degrades to absent, never drags down `status` (same class as
116747
+ * memory_updates above).
116748
+ */
116749
+ handoffs: external_exports.array(
116750
+ external_exports.object({
116751
+ to: external_exports.string().trim().min(1).max(64),
116752
+ text: external_exports.string().trim().min(1).max(1e4)
116753
+ })
116754
+ ).max(3).optional().catch(void 0),
116697
116755
  /**
116698
116756
  * 话题 ↔ 飞书任务句柄认领声明(docs/task-handle.md §5.1/§5.2)。agent 在 turn
116699
116757
  * 内检索到候选任务、消解歧义后,把 guid 写进这里;bridge finalize 时读取并
@@ -116874,6 +116932,149 @@ function tryRepairBareQuotesInLastMessage(raw) {
116874
116932
  return raw.slice(0, valueStart) + escaped + tail.slice(bestEnd);
116875
116933
  }
116876
116934
 
116935
+ // src/lark/postContent.ts
116936
+ function assertSafeUserId(userId) {
116937
+ if (!/^[A-Za-z0-9_:-]+$/.test(userId)) {
116938
+ throw new Error("post mention user_id contains unsupported characters");
116939
+ }
116940
+ }
116941
+ function normalizeText(text) {
116942
+ return text.replace(/\r\n/g, "\n").trim();
116943
+ }
116944
+ function buildPostContent(input) {
116945
+ const text = normalizeText(input.text);
116946
+ const mentions = input.mentions ?? [];
116947
+ if (!text && mentions.length === 0) {
116948
+ throw new Error("post content requires text or at least one mention");
116949
+ }
116950
+ if (mentions.length > 5) {
116951
+ throw new Error("post content supports at most 5 mentions");
116952
+ }
116953
+ const segments = [];
116954
+ if (text) {
116955
+ segments.push({ tag: "text", text });
116956
+ }
116957
+ for (const mention of mentions) {
116958
+ assertSafeUserId(mention.userId);
116959
+ if (segments.length > 0) {
116960
+ segments.push({ tag: "text", text: " " });
116961
+ }
116962
+ segments.push({
116963
+ tag: "at",
116964
+ user_id: mention.userId,
116965
+ user_name: mention.label?.trim() || ""
116966
+ });
116967
+ }
116968
+ const locale = input.locale ?? "zh_cn";
116969
+ return JSON.stringify({
116970
+ [locale]: {
116971
+ title: input.title?.trim() ?? "",
116972
+ content: [segments]
116973
+ }
116974
+ });
116975
+ }
116976
+
116977
+ // src/bridge/localHandoff.ts
116978
+ var LocalHandoffRegistry = class {
116979
+ members = /* @__PURE__ */ new Map();
116980
+ register(info, client) {
116981
+ this.members.set(info.botId, { info, client });
116982
+ }
116983
+ describe(botId) {
116984
+ return this.members.get(botId)?.info;
116985
+ }
116986
+ /** @returns true when the event was locally dispatched (not deduped/unknown). */
116987
+ dispatch(botId, ev, sourceTag) {
116988
+ const member = this.members.get(botId);
116989
+ if (!member) return false;
116990
+ try {
116991
+ return member.client.ingestLocalEvent(ev, sourceTag);
116992
+ } catch (err) {
116993
+ console.warn(`[local-handoff] dispatch to "${botId}" failed (WS delivery will cover):`, err);
116994
+ return false;
116995
+ }
116996
+ }
116997
+ };
116998
+ function normalizeTarget(s) {
116999
+ return s.trim().replace(/^@/, "").toLowerCase();
117000
+ }
117001
+ async function processHandoffs(ctx) {
117002
+ const outcomes = [];
117003
+ for (const [i, declared] of ctx.handoffs.entries()) {
117004
+ const wanted = normalizeTarget(declared.to);
117005
+ const peer = ctx.peers.find((p) => normalizeTarget(p.name) === wanted);
117006
+ const rosterEntry = ctx.roster.find(
117007
+ (r) => normalizeTarget(r.name) === wanted || normalizeTarget(r.botId) === wanted
117008
+ );
117009
+ if (!peer) {
117010
+ outcomes.push({
117011
+ to: declared.to,
117012
+ posted: false,
117013
+ localDispatched: false,
117014
+ detail: `\u672A\u5728 <peer-bots> \u91CC\u627E\u5230 "${declared.to}"\uFF0C\u6B64\u6761 handoff \u8DF3\u8FC7`
117015
+ });
117016
+ continue;
117017
+ }
117018
+ if (!ctx.postClient) {
117019
+ outcomes.push({
117020
+ to: declared.to,
117021
+ posted: false,
117022
+ localDispatched: false,
117023
+ detail: "postClient \u4E0D\u53EF\u7528\uFF0C\u65E0\u6CD5\u53D1\u955C\u50CF post\uFF0C\u6B64\u6761 handoff \u8DF3\u8FC7"
117024
+ });
117025
+ continue;
117026
+ }
117027
+ let mirrorMessageId;
117028
+ try {
117029
+ const content = buildPostContent({
117030
+ text: declared.text,
117031
+ mentions: [{ userId: peer.id, label: peer.name }]
117032
+ });
117033
+ const sent = await ctx.postClient.createPostReply(ctx.replyAnchorId, content, {
117034
+ replyInThread: true,
117035
+ idempotencyKey: `handoff:${ctx.triggerMessageId}:${i}:${rosterEntry?.botId ?? wanted}`
117036
+ });
117037
+ mirrorMessageId = sent.messageId;
117038
+ } catch (err) {
117039
+ outcomes.push({
117040
+ to: declared.to,
117041
+ posted: false,
117042
+ localDispatched: false,
117043
+ detail: `\u955C\u50CF post \u53D1\u9001\u5931\u8D25\uFF08\u672A\u505A\u672C\u5730\u76F4\u9012\uFF09: ${String(err.message ?? err)}`
117044
+ });
117045
+ continue;
117046
+ }
117047
+ let localDispatched = false;
117048
+ let detail = `\u955C\u50CF post \u5DF2\u53D1 (message_id=${mirrorMessageId})`;
117049
+ const targetInfo = rosterEntry && ctx.registry?.describe(rosterEntry.botId);
117050
+ if (ctx.localDispatchEnabled === false) {
117051
+ detail += "\uFF1B\u672C\u5730\u76F4\u9012\u5DF2\u7981\u7528\uFF0C\u8D70 WS \u9001\u8FBE";
117052
+ } else if (!rosterEntry || !targetInfo) {
117053
+ detail += "\uFF1B\u76EE\u6807\u4E0D\u5728\u672C bridge \u8FDB\u7A0B\u5185\uFF0C\u8D70 WS \u9001\u8FBE";
117054
+ } else {
117055
+ const ev = {
117056
+ message_id: mirrorMessageId,
117057
+ chat_id: ctx.chatId,
117058
+ chat_type: "group",
117059
+ thread_id: ctx.threadId,
117060
+ root_id: ctx.threadId,
117061
+ // Informational marker, mirroring "task_comment" — nothing branches on it.
117062
+ larkway_trigger_type: "local_handoff",
117063
+ sender_id: ctx.selfBotId,
117064
+ // Same shape the target's WS copy would carry: an @_user_N placeholder
117065
+ // in the text (stripped by extractText) + mention metadata resolving it.
117066
+ mentions: targetInfo.botOpenId ? [{ key: "@_user_1", id: { open_id: targetInfo.botOpenId }, name: targetInfo.name }] : void 0,
117067
+ content: JSON.stringify({ text: `@_user_1 ${declared.text}` }),
117068
+ create_time: String(Date.now())
117069
+ };
117070
+ localDispatched = ctx.registry.dispatch(rosterEntry.botId, ev, ctx.selfBotId);
117071
+ detail += localDispatched ? `\uFF1B\u5DF2\u672C\u5730\u76F4\u9012\u7ED9 ${rosterEntry.botId}\uFF08WS \u526F\u672C\u5C06\u88AB\u53BB\u91CD\uFF09` : `\uFF1B\u672C\u5730\u76F4\u9012\u672A\u751F\u6548\uFF08\u53BB\u91CD\u6216\u76EE\u6807\u5F02\u5E38\uFF09\uFF0C\u8D70 WS \u9001\u8FBE`;
117072
+ }
117073
+ outcomes.push({ to: declared.to, posted: true, localDispatched, detail });
117074
+ }
117075
+ return outcomes;
117076
+ }
117077
+
116877
117078
  // src/bridge/cardFile.ts
116878
117079
  import fs7 from "node:fs/promises";
116879
117080
  import path10 from "node:path";
@@ -118193,48 +118394,6 @@ async function createCotProgressHandle(opts) {
118193
118394
  return handle;
118194
118395
  }
118195
118396
 
118196
- // src/lark/postContent.ts
118197
- function assertSafeUserId(userId) {
118198
- if (!/^[A-Za-z0-9_:-]+$/.test(userId)) {
118199
- throw new Error("post mention user_id contains unsupported characters");
118200
- }
118201
- }
118202
- function normalizeText(text) {
118203
- return text.replace(/\r\n/g, "\n").trim();
118204
- }
118205
- function buildPostContent(input) {
118206
- const text = normalizeText(input.text);
118207
- const mentions = input.mentions ?? [];
118208
- if (!text && mentions.length === 0) {
118209
- throw new Error("post content requires text or at least one mention");
118210
- }
118211
- if (mentions.length > 5) {
118212
- throw new Error("post content supports at most 5 mentions");
118213
- }
118214
- const segments = [];
118215
- if (text) {
118216
- segments.push({ tag: "text", text });
118217
- }
118218
- for (const mention of mentions) {
118219
- assertSafeUserId(mention.userId);
118220
- if (segments.length > 0) {
118221
- segments.push({ tag: "text", text: " " });
118222
- }
118223
- segments.push({
118224
- tag: "at",
118225
- user_id: mention.userId,
118226
- user_name: mention.label?.trim() || ""
118227
- });
118228
- }
118229
- const locale = input.locale ?? "zh_cn";
118230
- return JSON.stringify({
118231
- [locale]: {
118232
- title: input.title?.trim() ?? "",
118233
- content: [segments]
118234
- }
118235
- });
118236
- }
118237
-
118238
118397
  // src/lark/idempotency.ts
118239
118398
  import { createHash as createHash2 } from "node:crypto";
118240
118399
  var KEY_PREFIX = "lw-p-";
@@ -120180,6 +120339,33 @@ ${memoryVisibilityLines.join("\n")}` : cardBody;
120180
120339
  await deleteCardFile(worktreePath);
120181
120340
  }
120182
120341
  }
120342
+ const declaredHandoffs = reportedState?.handoffs;
120343
+ if (declaredHandoffs && declaredHandoffs.length > 0) {
120344
+ try {
120345
+ const outcomes = await processHandoffs({
120346
+ handoffs: declaredHandoffs,
120347
+ peers: effectivePeers ?? [],
120348
+ roster: this.deps.taskHandleMentionRoster ?? [],
120349
+ selfBotId: this.deps.botConfig?.id ?? "v1-default",
120350
+ postClient: this.deps.postClient,
120351
+ registry: this.deps.localHandoffRegistry,
120352
+ replyAnchorId,
120353
+ chatId: parsed.chatId,
120354
+ threadId,
120355
+ triggerMessageId: messageId,
120356
+ localDispatchEnabled: process.env["LARKWAY_LOCAL_HANDOFF"] !== "off"
120357
+ });
120358
+ for (const o of outcomes) {
120359
+ await recordEvent({
120360
+ status: "running",
120361
+ appendPath: "peer handoff",
120362
+ reason: `\u2192 ${o.to}: ${o.detail}`
120363
+ });
120364
+ }
120365
+ } catch (err) {
120366
+ console.warn("[bridge.handler] processHandoffs failed (turn unaffected):", err);
120367
+ }
120368
+ }
120183
120369
  settle(true);
120184
120370
  await recordEvent({
120185
120371
  status: "completed",
@@ -129270,6 +129456,7 @@ async function runV2Mode({
129270
129456
  const allTaskHandleStores = [];
129271
129457
  const tasklistPollersByGuid = /* @__PURE__ */ new Map();
129272
129458
  const tasklistGuidGroups = /* @__PURE__ */ new Map();
129459
+ const localHandoffRegistry = new LocalHandoffRegistry();
129273
129460
  for (const bot of healthyBots) {
129274
129461
  const appSecret = process.env[bot.app_secret_env];
129275
129462
  const tokenEnvName = bot.git_token_env ?? bot.gitlab_token_env;
@@ -129310,6 +129497,10 @@ async function runV2Mode({
129310
129497
  larkwayDir: larkwayHome()
129311
129498
  });
129312
129499
  console.log(`[larkway] bot "${bot.id}" inbound transport = Channel SDK (WS in-process)`);
129500
+ localHandoffRegistry.register(
129501
+ { botId: bot.id, name: bot.name, botOpenId: bot.bot_open_id },
129502
+ client
129503
+ );
129313
129504
  const outbound = client.outboundCardClient();
129314
129505
  const cardRenderer = new CardRenderer({
129315
129506
  patchIntervalMs: 1500,
@@ -129601,6 +129792,7 @@ async function runV2Mode({
129601
129792
  permissionMode: configJson.permissions.mode,
129602
129793
  peers: resolvedPeers,
129603
129794
  taskHandleMentionRoster,
129795
+ localHandoffRegistry,
129604
129796
  botConfig: {
129605
129797
  id: bot.id,
129606
129798
  name: bot.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.52",
3
+ "version": "0.3.53",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",