larkway 0.3.42 → 0.3.44

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.42**
11
+ **Current release: v0.3.44**
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.42**
11
+ **当前版本:v0.3.44**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -112530,6 +112530,11 @@ var init_messageLookupClient = __esm({
112530
112530
  const cached = this.#cache.get(messageId);
112531
112531
  if (cached) return cached;
112532
112532
  }
112533
+ const first = await this.#getOnce(messageId);
112534
+ if (first) return first;
112535
+ return this.#getOnce(messageId);
112536
+ }
112537
+ async #getOnce(messageId) {
112533
112538
  try {
112534
112539
  const channel = this.#resolveChannel();
112535
112540
  if (!channel) return void 0;
@@ -126104,24 +126109,37 @@ var BotConfigSchema = external_exports.object({
126104
126109
  */
126105
126110
  effort: external_exports.string().min(1).optional(),
126106
126111
  /**
126107
- * docs/larkway-perf-plan.md §4 — opt into a persistent warm process
126108
- * instead of the default one-shot cold-start-per-turn behavior. Takes
126109
- * effect for `backend: "codex"` (a single bot-level `codex app-server`
126110
- * process — src/codex/pool.ts CodexProcessPool) and `backend: "claude"`
126111
- * (one warm process per active thread — src/claude/pool.ts
126112
- * ClaudeProcessPool). Any other backend value is a no-op (main.ts only
126113
- * constructs a pool for these two; botLoader's advisory warn below flags
126114
- * the mismatch upfront). Byte-identical behavior when unset, matching
126115
- * every other perf-plan flag in this schema. `.optional()` (not
126116
- * `.default(false)`, deliberately B1 fix): a `.default()` would make
126117
- * every future `larkway bot` yaml write-out include `warmProcess: false`
126118
- * even when nobody asked for it, and because this schema is `.strict()`
126119
- * would break loading that yaml back with an OLDER larkway build that
126120
- * predates this field. `undefined` and `false` are treated identically at
126121
- * both read sites (main.ts's `bot.warmProcess && …` and the advisory warn
126122
- * below).
126112
+ * docs/larkway-perf-plan.md §4 — persistent warm process instead of a
126113
+ * one-shot cold start per turn. Takes effect for `backend: "codex"` (a
126114
+ * single bot-level `codex app-server` process — src/codex/pool.ts
126115
+ * CodexProcessPool) and `backend: "claude"` (one warm process per active
126116
+ * thread — src/claude/pool.ts ClaudeProcessPool). Any other backend value
126117
+ * is a no-op (main.ts only constructs a pool for these two; botLoader's
126118
+ * advisory warn below flags the mismatch upfront).
126119
+ *
126120
+ * 批D (warm-by-default):
126121
+ * DEFAULT IS NOW ON for the two supported backends — `undefined` means
126122
+ * `true` when backend is codex/claude (see {@link effectiveWarmProcess},
126123
+ * the single source of that rule). Set `warmProcess: false` explicitly to
126124
+ * opt a bot back out. `.optional()` (not `.default(true)`) is still
126125
+ * deliberate: a `.default()` would make every future `larkway bot` yaml
126126
+ * write-out bake the value in, and because this schema is `.strict()` —
126127
+ * would break loading that yaml back with an OLDER larkway build that
126128
+ * predates this field.
126123
126129
  */
126124
126130
  warmProcess: external_exports.boolean().optional(),
126131
+ /**
126132
+ * 批D: pre-warm a BLANK agent process at bridge boot (and keep one on
126133
+ * standby thereafter), so even the FIRST message of a NEW thread skips the
126134
+ * cold start. Only meaningful when the warm pool is on (see
126135
+ * `warmProcess`); default true. backend=claude: ClaudeProcessPool keeps
126136
+ * one blank (no --resume) child matching the bot's static spawn signature,
126137
+ * adopted by the first new-session turn and replenished after adoption.
126138
+ * backend=codex: CodexProcessPool spawns its app-server eagerly at boot
126139
+ * instead of on the first turn. Set false to keep warm pooling but skip
126140
+ * the always-resident standby (saves ~300MB RSS per bot at idle).
126141
+ */
126142
+ prewarmProcess: external_exports.boolean().optional(),
126125
126143
  /**
126126
126144
  * Idle threshold (ms) before a warm process (see `warmProcess` above) with
126127
126145
  * no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
package/dist/main.js CHANGED
@@ -116684,6 +116684,11 @@ var ChannelMessageLookupClient = class {
116684
116684
  const cached = this.#cache.get(messageId);
116685
116685
  if (cached) return cached;
116686
116686
  }
116687
+ const first = await this.#getOnce(messageId);
116688
+ if (first) return first;
116689
+ return this.#getOnce(messageId);
116690
+ }
116691
+ async #getOnce(messageId) {
116687
116692
  try {
116688
116693
  const channel = this.#resolveChannel();
116689
116694
  if (!channel) return void 0;
@@ -119225,12 +119230,18 @@ function renderTaskRootBlock(taskRoot) {
119225
119230
  `task_root_claimed: ${taskRoot.claimed ? "yes" : "no"}`
119226
119231
  ];
119227
119232
  if (taskRoot.claimed) {
119228
- lines.push(
119229
- "\u672C\u8BDD\u9898\u5DF2\u8BA4\u9886\u8FD9\u4E2A\u4EFB\u52A1\u3002\u7EF4\u62A4\u9762\u53EA\u6709\u4EFB\u52A1\u8BC4\u8BBA:\u4EC5\u5728\u4EA4\u4ED8/\u5931\u8D25/\u7B49\u62CD\u677F\u4E09\u7C7B\u91CC\u7A0B\u7891\u8282\u70B9\u53D1\u4EFB\u52A1\u8BC4\u8BBA(lark-cli task comments create);\u771F\u6B63\u4EA4\u4ED8\u7684\u90A3\u4E00\u8F6E\u5728 state.json \u7684 task_handle \u91CC\u58F0\u660E done: true,\u5E76\u53D1\u4E00\u6761\u300C\u5DF2\u4EA4\u4ED8,\u770B\u8FC7\u8BF7\u70B9\u5B8C\u6210\u300D\u8BC4\u8BBA\u3002\u4E0D\u8981\u4FEE\u6539\u4EFB\u52A1\u63CF\u8FF0\u3001\u4E0D\u8981\u52FE\u5B8C\u6210/reopen\u2014\u2014\u5B8C\u6210\u6C38\u8FDC\u7531\u4EBA\u5728\u4EFB\u52A1\u4E2D\u5FC3\u70B9\u3002"
119230
- );
119233
+ if (taskRoot.justClaimed) {
119234
+ lines.push(
119235
+ `bridge \u5DF2\u81EA\u52A8\u8BA4\u9886\u8FD9\u4E2A\u4EFB\u52A1(\u673A\u68B0\u7ED1\u5B9A,\u4E0D\u9700\u8981\u4F60\u518D\u5199 state.json \u8BA4\u9886),\u4F46**\u8BA4\u9886\u8BC4\u8BBA\u8FD8\u6B20\u7740**\u3002\u672C\u8F6E\u8BF7:\u2460 \u5148\u8BFB\u4E00\u904D\u8BE5\u4EFB\u52A1\u7684\u73B0\u6709\u8BC4\u8BBA(lark-cli task comments list)\u2014\u2014\u5DF2\u6709**\u5176\u4ED6 agent** \u7684\u8BA4\u9886\u58F0\u660E\u8BF4\u660E\u5BF9\u65B9\u5DF2\u63A5\u624B,\u4E0D\u8981\u518D\u53D1\u8BA4\u9886\u8BC4\u8BBA(\u91CD\u590D\u8BC4\u8BBA=\u9A9A\u6270),\u914D\u5408\u5E72\u6D3B\u5373\u53EF;\u2461 \u8BC4\u8BBA\u533A\u6CA1\u6709\u522B\u4EBA\u8BA4\u9886 \u2192 \u53D1\u4E00\u6761**\u8BA4\u9886\u8BC4\u8BBA**(lark-cli task comments create):\u4E00\u53E5\u8BA4\u9886\u58F0\u660E${taskRoot.topicLink ? " + \u4E0A\u9762\u7684 topic_link(\u7528\u6237\u70B9\u5B83\u76F4\u8FBE\u672C\u8BDD\u9898\u2014\u2014\u4EFB\u52A1\u2192\u8BDD\u9898\u552F\u4E00\u56DE\u8DF3,\u522B\u7701\u7565)" : ""};\u2462 \u6B63\u5E38\u5E72\u6D3B(\u4EFB\u52A1\u6807\u9898/\u63CF\u8FF0\u5C31\u662F\u9700\u6C42)\u3002\u4E4B\u540E\u7EF4\u62A4\u9762\u53EA\u6709\u4EFB\u52A1\u8BC4\u8BBA(\u4EA4\u4ED8/\u5931\u8D25/\u7B49\u62CD\u677F\u624D\u53D1,\u8FC7\u7A0B\u788E\u788E\u5FF5\u4E0D\u53D1);\u771F\u6B63\u4EA4\u4ED8\u7684\u90A3\u4E00\u8F6E\u5728 state.json \u7684 task_handle \u91CC\u58F0\u660E done: true \u5E76\u53D1\u300C\u5DF2\u4EA4\u4ED8,\u770B\u8FC7\u8BF7\u70B9\u5B8C\u6210\u300D\u8BC4\u8BBA\u3002\u4E0D\u8981\u4FEE\u6539\u4EFB\u52A1\u63CF\u8FF0\u3001\u4E0D\u8981\u52FE\u5B8C\u6210/reopen\u2014\u2014\u5B8C\u6210\u6C38\u8FDC\u7531\u4EBA\u5728\u4EFB\u52A1\u4E2D\u5FC3\u70B9\u3002`
119236
+ );
119237
+ } else {
119238
+ lines.push(
119239
+ "\u672C\u8BDD\u9898\u5DF2\u8BA4\u9886\u8FD9\u4E2A\u4EFB\u52A1\u3002\u7EF4\u62A4\u9762\u53EA\u6709\u4EFB\u52A1\u8BC4\u8BBA:\u4EC5\u5728\u4EA4\u4ED8/\u5931\u8D25/\u7B49\u62CD\u677F\u4E09\u7C7B\u91CC\u7A0B\u7891\u8282\u70B9\u53D1\u4EFB\u52A1\u8BC4\u8BBA(lark-cli task comments create);\u771F\u6B63\u4EA4\u4ED8\u7684\u90A3\u4E00\u8F6E\u5728 state.json \u7684 task_handle \u91CC\u58F0\u660E done: true,\u5E76\u53D1\u4E00\u6761\u300C\u5DF2\u4EA4\u4ED8,\u770B\u8FC7\u8BF7\u70B9\u5B8C\u6210\u300D\u8BC4\u8BBA\u3002\u4E0D\u8981\u4FEE\u6539\u4EFB\u52A1\u63CF\u8FF0\u3001\u4E0D\u8981\u52FE\u5B8C\u6210/reopen\u2014\u2014\u5B8C\u6210\u6C38\u8FDC\u7531\u4EBA\u5728\u4EFB\u52A1\u4E2D\u5FC3\u70B9\u3002"
119240
+ );
119241
+ }
119231
119242
  } else {
119232
119243
  lines.push(
119233
- `\u672C\u8F6E\u8BF7\u987A\u5E26\u9759\u9ED8\u8BA4\u9886,\u5206\u4E24\u6B65:\u2460 \u5148\u8BFB\u4E00\u904D\u8BE5\u4EFB\u52A1\u7684\u73B0\u6709\u8BC4\u8BBA(lark-cli task comments list)\u2014\u2014\u5982\u679C\u5DF2\u6709**\u5176\u4ED6 agent** \u7684\u8BA4\u9886\u58F0\u660E,\u8BF4\u660E\u540C\u8BDD\u9898\u7684\u53E6\u4E00\u4E2A bot \u5DF2\u63A5\u624B,\u672C\u8F6E\u4E0D\u8BA4\u9886\u3001\u4E0D\u58F0\u5F20\u3001\u6B63\u5E38\u56DE\u7B54\u5373\u53EF(\u53CC @ \u7ADE\u6001\u62A4\u680F:\u91CD\u590D\u8BA4\u9886 = \u53CC\u4EFD\u7EF4\u62A4 + \u53CC\u4EFD\u5DE1\u68C0\u9A9A\u6270);\u2461 \u6CA1\u6709\u522B\u4EBA\u8BA4\u9886 \u2192 \u628A\u4E0A\u9762\u7684 task_guid \u5199\u5165 state.json \u7684 task_handle.guid,\u5E76\u53D1\u4E00\u6761\u8BA4\u9886\u8BC4\u8BBA(lark-cli task comments create),\u5185\u5BB9 = \u4E00\u53E5\u8BA4\u9886\u58F0\u660E${taskRoot.topicLink ? " + \u4E0A\u9762\u7684 topic_link(\u7528\u6237\u70B9\u5B83\u76F4\u8FBE\u672C\u8BDD\u9898)" : ""}\u3002\u4E4B\u540E\u7EF4\u62A4\u9762\u53EA\u6709\u4EFB\u52A1\u8BC4\u8BBA(\u4EA4\u4ED8/\u5931\u8D25/\u7B49\u62CD\u677F\u624D\u53D1,\u8FC7\u7A0B\u788E\u788E\u5FF5\u4E0D\u53D1);\u4E0D\u8981\u4FEE\u6539\u4EFB\u52A1\u63CF\u8FF0\u3001\u4E0D\u8981\u52FE\u5B8C\u6210\u2014\u2014\u5B8C\u6210\u6C38\u8FDC\u7531\u4EBA\u70B9\u3002`
119244
+ `bridge \u672A\u80FD\u81EA\u52A8\u8BA4\u9886\u8FD9\u4E2A\u4EFB\u52A1\u2014\u2014\u6700\u5E38\u89C1\u7684\u539F\u56E0\u662F\u5B83\u5DF2\u88AB**\u53E6\u4E00\u4E2A\u8BDD\u9898\u6216\u53E6\u4E00\u4E2A agent** \u8BA4\u9886(\u673A\u68B0\u5B88\u536B\u62D2\u7EDD\u91CD\u590D\u8BA4\u9886),\u4E5F\u53EF\u80FD\u662F\u8BA4\u9886\u901A\u9053\u672A\u63A5\u5165\u3002\u672C\u8F6E\u8BF7:\u2460 \u5148\u8BFB\u4E00\u904D\u8BE5\u4EFB\u52A1\u7684\u73B0\u6709\u8BC4\u8BBA(lark-cli task comments list)\u2014\u2014\u5DF2\u6709\u5176\u4ED6 agent \u7684\u8BA4\u9886\u58F0\u660E \u2192 \u5BF9\u65B9\u5DF2\u63A5\u624B,\u4F60\u53EA\u662F\u88AB\u53EB\u6765\u534F\u4F5C:\u5E72\u597D\u88AB @ \u6765\u505A\u7684\u4E8B,\u4E0D\u8981\u8BA4\u9886\u3001\u4E0D\u8981\u91CD\u590D\u53D1\u8BA4\u9886\u8BC4\u8BBA;\u2461 \u8BC4\u8BBA\u533A\u6CA1\u6709\u4EFB\u4F55\u8BA4\u9886\u3001\u4E14\u4F60\u5224\u65AD\u8BE5\u7531\u4F60\u63A5\u624B \u2192 \u628A\u4E0A\u9762\u7684 task_guid \u5199\u5165 state.json \u7684 task_handle.guid,\u5E76\u53D1\u4E00\u6761\u8BA4\u9886\u8BC4\u8BBA(lark-cli task comments create),\u5185\u5BB9 = \u4E00\u53E5\u8BA4\u9886\u58F0\u660E${taskRoot.topicLink ? " + \u4E0A\u9762\u7684 topic_link(\u7528\u6237\u70B9\u5B83\u76F4\u8FBE\u672C\u8BDD\u9898)" : ""}\u3002\u4E4B\u540E\u7EF4\u62A4\u9762\u53EA\u6709\u4EFB\u52A1\u8BC4\u8BBA(\u4EA4\u4ED8/\u5931\u8D25/\u7B49\u62CD\u677F\u624D\u53D1,\u8FC7\u7A0B\u788E\u788E\u5FF5\u4E0D\u53D1);\u4E0D\u8981\u4FEE\u6539\u4EFB\u52A1\u63CF\u8FF0\u3001\u4E0D\u8981\u52FE\u5B8C\u6210\u2014\u2014\u5B8C\u6210\u6C38\u8FDC\u7531\u4EBA\u70B9\u3002`
119234
119245
  );
119235
119246
  }
119236
119247
  lines.push("</task-root>");
@@ -119390,9 +119401,20 @@ async function renderPrompt(input) {
119390
119401
  taskHandleClaimed = false,
119391
119402
  taskHandleCandidates = [],
119392
119403
  taskRoot,
119393
- mtimeFacts = []
119404
+ mtimeFacts = [],
119405
+ queuedFollowups = []
119394
119406
  } = input;
119395
119407
  const backend = input.backend ?? "claude";
119408
+ const userMessageLines = [
119409
+ "<user-message>",
119410
+ `${parsed.senderOpenId}: ${parsed.text}`,
119411
+ ...queuedFollowups.length > 0 ? [
119412
+ "",
119413
+ `(\u4EE5\u4E0B ${queuedFollowups.length} \u6761\u8FFD\u52A0\u6D88\u606F\u5728\u4E0A\u4E00\u8F6E\u5904\u7406\u671F\u95F4\u5230\u8FBE,\u5DF2\u5408\u5E76\u8FDB\u672C\u8F6E \u2014\u2014 \u6309\u987A\u5E8F\u4E00\u5E76\u5904\u7406,\u53EA\u9700\u4E00\u6B21\u7B54\u590D:)`,
119414
+ ...queuedFollowups.map((f) => `${f.senderOpenId}: ${f.text}`)
119415
+ ] : [],
119416
+ "</user-message>"
119417
+ ];
119396
119418
  const profileFlag = larkCliProfile ? ` --profile ${larkCliProfile}` : "";
119397
119419
  const attachmentKeys = parsed.attachments.map((a) => a.fileKey);
119398
119420
  const imageKeys = parsed.attachments.filter((a) => a.fileType === "image").map((a) => a.fileKey);
@@ -119524,9 +119546,7 @@ async function renderPrompt(input) {
119524
119546
  ...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
119525
119547
  ...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
119526
119548
  "",
119527
- "<user-message>",
119528
- `${parsed.senderOpenId}: ${parsed.text}`,
119529
- "</user-message>"
119549
+ ...userMessageLines
119530
119550
  ].join("\n");
119531
119551
  }
119532
119552
  return [
@@ -119581,9 +119601,7 @@ async function renderPrompt(input) {
119581
119601
  ...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
119582
119602
  ...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
119583
119603
  "",
119584
- "<user-message>",
119585
- `${parsed.senderOpenId}: ${parsed.text}`,
119586
- "</user-message>"
119604
+ ...userMessageLines
119587
119605
  ].join("\n");
119588
119606
  }
119589
119607
 
@@ -119770,6 +119788,12 @@ function renderTranscriptEntry(input) {
119770
119788
  "",
119771
119789
  indentBlock(parsed.text),
119772
119790
  "",
119791
+ ...input.queuedFollowups && input.queuedFollowups.length > 0 ? [
119792
+ "### Coalesced Follow-ups",
119793
+ "",
119794
+ ...input.queuedFollowups.flatMap((f) => [`- ${f.senderOpenId}:`, indentBlock(f.text)]),
119795
+ ""
119796
+ ] : [],
119773
119797
  "### Feishu Doc Links",
119774
119798
  "",
119775
119799
  ...renderList(parsed.feishuDocLinks),
@@ -122149,6 +122173,20 @@ async function isWorktreeGitHealthy(worktreePath) {
122149
122173
  child.on("error", () => resolve2(false));
122150
122174
  });
122151
122175
  }
122176
+ function canCoalesceFollowup(primary, candidate) {
122177
+ if (primary.larkway_trigger_type != null || candidate.larkway_trigger_type != null) return false;
122178
+ if (primary.reply_anchor_message_id != null || candidate.reply_anchor_message_id != null) return false;
122179
+ const sessionKeyOf = (e) => typeof e.root_id === "string" && e.root_id ? e.root_id : e.message_id;
122180
+ if (sessionKeyOf(primary) !== sessionKeyOf(candidate)) return false;
122181
+ try {
122182
+ const parsed = parseMessage(candidate);
122183
+ if (parsed.attachments.length > 0) return false;
122184
+ if (parsed.text.trim() === "") return false;
122185
+ } catch {
122186
+ return false;
122187
+ }
122188
+ return true;
122189
+ }
122152
122190
  var repoCloneLocks = /* @__PURE__ */ new Map();
122153
122191
  function ensureRepoClone(basePath, url, token, label) {
122154
122192
  const prev = repoCloneLocks.get(basePath) ?? Promise.resolve();
@@ -122420,6 +122458,15 @@ var BridgeHandler = class {
122420
122458
  * broken handoff and fire a spurious nudge. Read via getThreadReceivedAt().
122421
122459
  */
122422
122460
  threadReceivedAt = /* @__PURE__ */ new Map();
122461
+ /**
122462
+ * v4.2 (docs/task-handle.md §13 revision 5): per-thread OUTCOME of the most
122463
+ * recent finished turn ("completed" | "failed"), recorded at finalize.
122464
+ * StallDetector reads a PEER handler's map via getThreadLastOutcome to tell
122465
+ * "the peer finished successfully" apart from "the peer crashed" — a failed
122466
+ * finish must NOT resolve a handoff. In-memory like threadReceivedAt (empty
122467
+ * after restart → detector falls back to its documented restart posture).
122468
+ */
122469
+ threadLastOutcome = /* @__PURE__ */ new Map();
122423
122470
  constructor(deps) {
122424
122471
  this.deps = deps;
122425
122472
  }
@@ -122432,6 +122479,10 @@ var BridgeHandler = class {
122432
122479
  * — callers must apply their own startup quiet-period before treating that
122433
122480
  * as evidence of a truly broken handoff.
122434
122481
  */
122482
+ /** v4.2 — see threadLastOutcome's field doc. */
122483
+ getThreadLastOutcome(threadId) {
122484
+ return this.threadLastOutcome.get(threadId);
122485
+ }
122435
122486
  getThreadReceivedAt(threadId) {
122436
122487
  return this.threadReceivedAt.get(threadId);
122437
122488
  }
@@ -122498,6 +122549,7 @@ var BridgeHandler = class {
122498
122549
  async run(opts) {
122499
122550
  const signal = opts?.abortSignal;
122500
122551
  const threadQueues = /* @__PURE__ */ new Map();
122552
+ const pendingByKey = /* @__PURE__ */ new Map();
122501
122553
  const MAX_CONCURRENT = 5;
122502
122554
  let running = 0;
122503
122555
  const waiters = [];
@@ -122523,12 +122575,33 @@ var BridgeHandler = class {
122523
122575
  this.threadReceivedAt.set(key, Date.now());
122524
122576
  const legacySessionKey = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
122525
122577
  if (legacySessionKey !== key) this.threadReceivedAt.set(legacySessionKey, Date.now());
122578
+ let pendingList = pendingByKey.get(key);
122579
+ if (pendingList == null) {
122580
+ pendingList = [];
122581
+ pendingByKey.set(key, pendingList);
122582
+ }
122583
+ pendingList.push(event);
122526
122584
  const prev = threadQueues.get(key) ?? Promise.resolve();
122527
- const next = prev.then(() => acquire()).then(() => this.handleOne(event)).catch((err) => {
122585
+ const next = prev.then(() => acquire()).then(() => {
122586
+ const pending = pendingByKey.get(key);
122587
+ const primary = pending?.shift();
122588
+ if (primary == null) return;
122589
+ const followups = [];
122590
+ while (pending != null && pending.length > 0 && canCoalesceFollowup(primary, pending[0])) {
122591
+ followups.push(pending.shift());
122592
+ }
122593
+ if (followups.length > 0) {
122594
+ console.log(
122595
+ `[bridge.handler] coalescing ${followups.length} queued follow-up message(s) into turn ${primary.message_id} on thread ${key}`
122596
+ );
122597
+ }
122598
+ return this.handleOne(primary, followups);
122599
+ }).catch((err) => {
122528
122600
  console.error(`[bridge.handler] unhandled error on thread ${key}:`, err);
122529
122601
  }).finally(() => {
122530
122602
  release();
122531
122603
  if (threadQueues.get(key) === next) threadQueues.delete(key);
122604
+ if ((pendingByKey.get(key)?.length ?? 0) === 0) pendingByKey.delete(key);
122532
122605
  this.inFlightTurns.delete(next);
122533
122606
  });
122534
122607
  this.inFlightTurns.add(next);
@@ -122545,7 +122618,14 @@ var BridgeHandler = class {
122545
122618
  // ---------------------------------------------------------------------------
122546
122619
  // Private: single-event lifecycle
122547
122620
  // ---------------------------------------------------------------------------
122548
- async handleOne(event) {
122621
+ /**
122622
+ * @param followups 批D gated coalescing — same-session messages that queued
122623
+ * up behind the previous turn and are merged into THIS one (see run()'s
122624
+ * drain loop + canCoalesceFollowup). They contribute their text to the
122625
+ * prompt and settle together with the primary; everything else about the
122626
+ * turn (card, session, task probes) is driven by `event` alone.
122627
+ */
122628
+ async handleOne(event, followups = []) {
122549
122629
  const settleMessageId = event.message_id;
122550
122630
  let settled = false;
122551
122631
  let agentRunCompleted = false;
@@ -122555,8 +122635,10 @@ var BridgeHandler = class {
122555
122635
  const settle = (ok) => {
122556
122636
  if (settled) return;
122557
122637
  settled = true;
122558
- if (ok) this.deps.client.markHandled?.(settleMessageId);
122559
- else this.deps.client.markUnhandled?.(settleMessageId, { replay: !agentRunCompleted });
122638
+ for (const id of [settleMessageId, ...followups.map((f) => f.message_id)]) {
122639
+ if (ok) this.deps.client.markHandled?.(id);
122640
+ else this.deps.client.markUnhandled?.(id, { replay: !agentRunCompleted });
122641
+ }
122560
122642
  };
122561
122643
  try {
122562
122644
  const parsed = parseMessage(event);
@@ -122567,7 +122649,33 @@ var BridgeHandler = class {
122567
122649
  const eventStartedAt = Date.now();
122568
122650
  let taskRootInfo;
122569
122651
  let taskCardAnchorId;
122652
+ let taskRootJustClaimed = false;
122570
122653
  let deferredTaskRootProbe;
122654
+ const autoClaimTaskRoot = async (guid) => {
122655
+ if (!this.deps.taskHandleClaim || !botId) return;
122656
+ const alreadyThis = this.deps.taskHandleClaimGuidLookup?.(threadId) === guid;
122657
+ if (alreadyThis) return;
122658
+ if (this.deps.taskGuidClaimedByOtherBot?.(guid)) return;
122659
+ try {
122660
+ await this.deps.taskHandleClaim({
122661
+ botId,
122662
+ threadId,
122663
+ chatId: parsed.chatId,
122664
+ taskGuid: guid,
122665
+ mode: "comment",
122666
+ // Round-2 fix: the mechanical auto-claim must never REPLACE an
122667
+ // existing different-guid claim on this thread (same charter as the
122668
+ // v3 auto-bind guard) — only the agent's own re-declaration may.
122669
+ onlyIfThreadUnclaimed: true,
122670
+ // Round-2 fix: persist "claim comment still owed" so a first-turn
122671
+ // crash after the claim doesn't lose the task→topic backlink.
122672
+ claimCommentPending: true
122673
+ });
122674
+ taskRootJustClaimed = this.deps.taskHandleClaimGuidLookup ? this.deps.taskHandleClaimGuidLookup(threadId) === guid : true;
122675
+ } catch (err) {
122676
+ console.warn("[bridge.handler] task-root auto-claim failed (continuing):", err);
122677
+ }
122678
+ };
122571
122679
  {
122572
122680
  const realTopic = realTopicThreadId(parsed.raw.thread_id);
122573
122681
  const rootCandidate = (typeof parsed.raw.root_id === "string" && parsed.raw.root_id ? parsed.raw.root_id : void 0) ?? (typeof parsed.raw.parent_id === "string" && parsed.raw.parent_id ? parsed.raw.parent_id : void 0);
@@ -122588,6 +122696,7 @@ var BridgeHandler = class {
122588
122696
  };
122589
122697
  taskCardAnchorId = rootCandidate;
122590
122698
  threadId = rootCandidate;
122699
+ await autoClaimTaskRoot(todo.taskGuid);
122591
122700
  }
122592
122701
  }
122593
122702
  }
@@ -122626,6 +122735,27 @@ var BridgeHandler = class {
122626
122735
  reason: "\u5DF2\u8FDB\u5165 bridge\uFF0C\u51C6\u5907\u521B\u5EFA\u5904\u7406\u5361\u7247\u3002"
122627
122736
  });
122628
122737
  await this.deps.client.addProcessingReaction?.(messageId);
122738
+ for (const f of followups) {
122739
+ if (!this.deps.recordRuntimeEvent) break;
122740
+ try {
122741
+ await this.deps.recordRuntimeEvent({
122742
+ id: f.message_id,
122743
+ botId,
122744
+ botName: this.deps.botConfig?.name,
122745
+ messageId: f.message_id,
122746
+ threadId,
122747
+ chatId: parsed.chatId,
122748
+ senderId: typeof f.sender_id === "string" ? f.sender_id : void 0,
122749
+ triggerType,
122750
+ status: "received",
122751
+ receivedAt: new Date(eventStartedAt).toISOString(),
122752
+ statusPath: ["\u5DF2\u6536\u5230", "\u5408\u5E76\u8FDB\u540C\u8F6E"],
122753
+ reason: `\u6392\u961F\u671F\u95F4\u5230\u8FBE,\u5DF2\u5408\u5E76\u8FDB ${messageId} \u7684\u540C\u4E00\u8F6E\u5904\u7406\u3002`
122754
+ });
122755
+ } catch (err) {
122756
+ console.warn("[bridge.handler] recordRuntimeEvent (coalesced followup) failed (continuing):", err);
122757
+ }
122758
+ }
122629
122759
  const invokeTaskHandleLifecycle = async (fields) => {
122630
122760
  if (!this.deps.taskHandleLifecycle || !botId) return;
122631
122761
  try {
@@ -122992,12 +123122,17 @@ var BridgeHandler = class {
122992
123122
  }
122993
123123
  }
122994
123124
  }
123125
+ const queuedFollowups = followups.map((f) => {
123126
+ const p = parseMessage(f);
123127
+ return { senderOpenId: p.senderOpenId, text: p.text };
123128
+ });
122995
123129
  if (isAgentWorkspace) {
122996
123130
  await ensureSessionArtifacts({
122997
123131
  sessionPath: worktreePath,
122998
123132
  parsed,
122999
123133
  isNewThread: existing === void 0,
123000
- larkCliProfile: this.deps.larkCliProfile
123134
+ larkCliProfile: this.deps.larkCliProfile,
123135
+ queuedFollowups
123001
123136
  });
123002
123137
  }
123003
123138
  try {
@@ -123101,6 +123236,7 @@ var BridgeHandler = class {
123101
123236
  summary: todo.summaryText,
123102
123237
  topicLink: rawThreadId ? buildTopicDeepLink(parsed.chatId, rawThreadId) : probeThreadId ? buildTopicDeepLink(parsed.chatId, probeThreadId) : void 0
123103
123238
  };
123239
+ await autoClaimTaskRoot(todo.taskGuid);
123104
123240
  }
123105
123241
  }
123106
123242
  }
@@ -123114,6 +123250,7 @@ var BridgeHandler = class {
123114
123250
  const prompt = await renderPrompt({
123115
123251
  parsed,
123116
123252
  isNewThread: currentIsNewThread,
123253
+ queuedFollowups,
123117
123254
  conventions: {
123118
123255
  worktreePath,
123119
123256
  runtime: conventions.runtime,
@@ -123147,7 +123284,8 @@ var BridgeHandler = class {
123147
123284
  // Exact-guid comparison (see taskHandleClaimGuidLookup's doc);
123148
123285
  // falls back to the boolean lookup only when the precise one
123149
123286
  // isn't wired (older embedding code).
123150
- claimed: this.deps.taskHandleClaimGuidLookup ? this.deps.taskHandleClaimGuidLookup(threadId) === taskRootInfo.guid : this.deps.taskHandleClaimedLookup?.(threadId) ?? false
123287
+ claimed: this.deps.taskHandleClaimGuidLookup ? this.deps.taskHandleClaimGuidLookup(threadId) === taskRootInfo.guid : this.deps.taskHandleClaimedLookup?.(threadId) ?? false,
123288
+ justClaimed: taskRootJustClaimed || this.deps.taskHandleClaimCommentPending?.(threadId) || void 0
123151
123289
  } : void 0,
123152
123290
  mtimeFacts
123153
123291
  });
@@ -123332,6 +123470,7 @@ var BridgeHandler = class {
123332
123470
  reason: failureReason
123333
123471
  });
123334
123472
  }
123473
+ this.threadLastOutcome.set(threadId, success ? "completed" : "failed");
123335
123474
  const stuckResetAfter = resolveStuckSessionResetAfter();
123336
123475
  const prevStuckCount = currentExisting?.consecutiveStuckCount ?? 0;
123337
123476
  const nextStuckCount = cardKitTimeoutFailure ? prevStuckCount + 1 : success ? 0 : prevStuckCount;
@@ -123576,6 +123715,7 @@ var BridgeHandler = class {
123576
123715
  }
123577
123716
  } catch (err) {
123578
123717
  console.error("[bridge.handler] handleOne failed for thread", threadId, err);
123718
+ this.threadLastOutcome.set(threadId, "failed");
123579
123719
  cotTurnOutcome = "error";
123580
123720
  if (cotPublisher) {
123581
123721
  void cotPublisher.finalize("error", { message: String(err) }).catch(() => {
@@ -127039,24 +127179,37 @@ var BotConfigSchema = external_exports.object({
127039
127179
  */
127040
127180
  effort: external_exports.string().min(1).optional(),
127041
127181
  /**
127042
- * docs/larkway-perf-plan.md §4 — opt into a persistent warm process
127043
- * instead of the default one-shot cold-start-per-turn behavior. Takes
127044
- * effect for `backend: "codex"` (a single bot-level `codex app-server`
127045
- * process — src/codex/pool.ts CodexProcessPool) and `backend: "claude"`
127046
- * (one warm process per active thread — src/claude/pool.ts
127047
- * ClaudeProcessPool). Any other backend value is a no-op (main.ts only
127048
- * constructs a pool for these two; botLoader's advisory warn below flags
127049
- * the mismatch upfront). Byte-identical behavior when unset, matching
127050
- * every other perf-plan flag in this schema. `.optional()` (not
127051
- * `.default(false)`, deliberately B1 fix): a `.default()` would make
127052
- * every future `larkway bot` yaml write-out include `warmProcess: false`
127053
- * even when nobody asked for it, and because this schema is `.strict()`
127054
- * would break loading that yaml back with an OLDER larkway build that
127055
- * predates this field. `undefined` and `false` are treated identically at
127056
- * both read sites (main.ts's `bot.warmProcess && …` and the advisory warn
127057
- * below).
127182
+ * docs/larkway-perf-plan.md §4 — persistent warm process instead of a
127183
+ * one-shot cold start per turn. Takes effect for `backend: "codex"` (a
127184
+ * single bot-level `codex app-server` process — src/codex/pool.ts
127185
+ * CodexProcessPool) and `backend: "claude"` (one warm process per active
127186
+ * thread — src/claude/pool.ts ClaudeProcessPool). Any other backend value
127187
+ * is a no-op (main.ts only constructs a pool for these two; botLoader's
127188
+ * advisory warn below flags the mismatch upfront).
127189
+ *
127190
+ * 批D (warm-by-default):
127191
+ * DEFAULT IS NOW ON for the two supported backends — `undefined` means
127192
+ * `true` when backend is codex/claude (see {@link effectiveWarmProcess},
127193
+ * the single source of that rule). Set `warmProcess: false` explicitly to
127194
+ * opt a bot back out. `.optional()` (not `.default(true)`) is still
127195
+ * deliberate: a `.default()` would make every future `larkway bot` yaml
127196
+ * write-out bake the value in, and because this schema is `.strict()` —
127197
+ * would break loading that yaml back with an OLDER larkway build that
127198
+ * predates this field.
127058
127199
  */
127059
127200
  warmProcess: external_exports.boolean().optional(),
127201
+ /**
127202
+ * 批D: pre-warm a BLANK agent process at bridge boot (and keep one on
127203
+ * standby thereafter), so even the FIRST message of a NEW thread skips the
127204
+ * cold start. Only meaningful when the warm pool is on (see
127205
+ * `warmProcess`); default true. backend=claude: ClaudeProcessPool keeps
127206
+ * one blank (no --resume) child matching the bot's static spawn signature,
127207
+ * adopted by the first new-session turn and replenished after adoption.
127208
+ * backend=codex: CodexProcessPool spawns its app-server eagerly at boot
127209
+ * instead of on the first turn. Set false to keep warm pooling but skip
127210
+ * the always-resident standby (saves ~300MB RSS per bot at idle).
127211
+ */
127212
+ prewarmProcess: external_exports.boolean().optional(),
127060
127213
  /**
127061
127214
  * Idle threshold (ms) before a warm process (see `warmProcess` above) with
127062
127215
  * no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
@@ -127074,6 +127227,13 @@ var BotConfigSchema = external_exports.object({
127074
127227
  */
127075
127228
  warmProcessMaxProcesses: external_exports.number().int().positive().optional()
127076
127229
  }).strict();
127230
+ function effectiveWarmProcess(bot) {
127231
+ if (bot.backend !== "claude" && bot.backend !== "codex") return bot.warmProcess === true;
127232
+ return bot.warmProcess !== false;
127233
+ }
127234
+ function effectivePrewarmProcess(bot) {
127235
+ return effectiveWarmProcess(bot) && bot.prewarmProcess !== false;
127236
+ }
127077
127237
  async function loadBots(botsDir) {
127078
127238
  let entries;
127079
127239
  try {
@@ -128347,6 +128507,8 @@ var INTERRUPT_GRACE_MS = 3e3;
128347
128507
  var DEFAULT_TURN_TIMEOUT_MS = 15 * 60 * 1e3;
128348
128508
  var SHUTDOWN_EXIT_WAIT_MS = SIGKILL_GRACE_MS2 + 2e3;
128349
128509
  var STDERR_BUFFER_CAP_BYTES = 64 * 1024;
128510
+ var MAX_PREWARM_FAILURES = 3;
128511
+ var PREWARM_RESPAWN_BACKOFF_MS = 1e4;
128350
128512
  var ClaudeProcessPool = class {
128351
128513
  #botId;
128352
128514
  #botGitIdentity;
@@ -128380,6 +128542,15 @@ var ClaudeProcessPool = class {
128380
128542
  * -killed while that child still lives. Removed in `#onEntryExit`.
128381
128543
  */
128382
128544
  #dying = /* @__PURE__ */ new Set();
128545
+ // -- 批D blank-standby prewarm state ----------------------------------------
128546
+ /** Set by prewarm(); undefined = prewarm never requested (no blank maintenance). */
128547
+ #prewarmProto;
128548
+ /** Consecutive unprompted blank deaths (any age) — resets on a successful adoption. */
128549
+ #prewarmFailures = 0;
128550
+ /** True once the circuit breaker tripped (MAX_PREWARM_FAILURES) — prewarm off until bridge restart. */
128551
+ #prewarmDisabled = false;
128552
+ #prewarmRespawnTimer;
128553
+ #nextBlankId = 1;
128383
128554
  constructor(opts) {
128384
128555
  this.#botId = opts.botId;
128385
128556
  this.#botGitIdentity = opts.botGitIdentity;
@@ -128396,6 +128567,23 @@ var ClaudeProcessPool = class {
128396
128567
  get pidsForTesting() {
128397
128568
  return [...this.#entries.values()].map((e) => e.child.pid).filter((pid) => pid != null);
128398
128569
  }
128570
+ get blankProcessCountForTesting() {
128571
+ return [...this.#entries.values()].filter((e) => e.blank).length;
128572
+ }
128573
+ /**
128574
+ * 批D: start maintaining ONE blank standby child matching `proto`'s spawn
128575
+ * signature — spawned now, adopted by the first new-session turn whose own
128576
+ * options produce the same signature (see PoolEntry.spawnSignature), and
128577
+ * replenished after each adoption. Call once at bridge boot (main.ts),
128578
+ * only for bots whose cwd is static across threads (agent_workspace
128579
+ * runtime); calling it again replaces the proto for future respawns but
128580
+ * never kills an existing blank. No-op after shutdown() or once the
128581
+ * circuit breaker has tripped.
128582
+ */
128583
+ prewarm(proto) {
128584
+ this.#prewarmProto = proto;
128585
+ this.#maybeSpawnBlank();
128586
+ }
128399
128587
  run(opts) {
128400
128588
  const queue = new TurnEventQueue();
128401
128589
  const markPerf = createPerfMarker(opts.onPerfMarker);
@@ -128443,6 +128631,9 @@ var ClaudeProcessPool = class {
128443
128631
  }
128444
128632
  }
128445
128633
  let entry = this.#entries.get(key);
128634
+ if (entry == null && opts.resumeSessionId == null) {
128635
+ entry = this.#adoptBlankEntry(key, threadId, opts);
128636
+ }
128446
128637
  if (entry == null) {
128447
128638
  if (this.#entries.size >= this.#maxProcesses) {
128448
128639
  const victim = this.#pickLruIdleVictim();
@@ -128472,6 +128663,10 @@ var ClaudeProcessPool = class {
128472
128663
  clearInterval(this.#idleSweepTimer);
128473
128664
  this.#idleSweepTimer = void 0;
128474
128665
  }
128666
+ if (this.#prewarmRespawnTimer) {
128667
+ clearTimeout(this.#prewarmRespawnTimer);
128668
+ this.#prewarmRespawnTimer = void 0;
128669
+ }
128475
128670
  const inFlight = [...this.#entries.values()].map((e) => e.current?.done.catch(() => void 0)).filter((p) => p != null);
128476
128671
  await Promise.race([
128477
128672
  Promise.all(inFlight),
@@ -128518,6 +128713,9 @@ var ClaudeProcessPool = class {
128518
128713
  return `${this.#botId}::${threadId}::${cwd}::${model}::${effort}`;
128519
128714
  }
128520
128715
  #pickLruIdleVictim() {
128716
+ for (const entry of this.#entries.values()) {
128717
+ if (entry.current == null && entry.blank) return entry;
128718
+ }
128521
128719
  let victim;
128522
128720
  for (const entry of this.#entries.values()) {
128523
128721
  if (entry.current != null) continue;
@@ -128670,7 +128868,89 @@ var ClaudeProcessPool = class {
128670
128868
  if (state.interruptEscalateTimer) clearTimeout(state.interruptEscalateTimer);
128671
128869
  }
128672
128870
  // -- process lifecycle -------------------------------------------------------
128673
- #spawnEntry(key, threadId, opts) {
128871
+ /**
128872
+ * The spawn identity a set of run/prewarm options resolves to. Built from
128873
+ * the SAME buildWarmCommand path #spawnEntry actually spawns with, so an
128874
+ * adoption match is by construction a spawn-arg-identical process.
128875
+ * `resumeSessionId` is deliberately dropped: adoption is only ever
128876
+ * attempted for turns with no resume (run() guards), and the blank itself
128877
+ * is spawned without one.
128878
+ */
128879
+ #spawnSignatureOf(opts) {
128880
+ const [bin, args] = buildWarmCommand({
128881
+ prompt: "",
128882
+ cwd: opts.cwd,
128883
+ model: opts.model,
128884
+ effort: opts.effort,
128885
+ permissionMode: opts.permissionMode,
128886
+ agentBinPath: opts.agentBinPath
128887
+ });
128888
+ return JSON.stringify([bin, args, opts.cwd ?? null]);
128889
+ }
128890
+ /**
128891
+ * 批D: find an idle blank whose spawn signature matches this turn's options
128892
+ * and rekey it in place onto (key, threadId). Returns undefined when no
128893
+ * blank matches — the caller falls through to a normal spawn. Schedules a
128894
+ * replacement blank on success.
128895
+ */
128896
+ #adoptBlankEntry(key, threadId, opts) {
128897
+ let blank;
128898
+ for (const e of this.#entries.values()) {
128899
+ if (e.blank && !e.destroyed && e.current == null) {
128900
+ blank = e;
128901
+ break;
128902
+ }
128903
+ }
128904
+ if (blank == null) return void 0;
128905
+ const wanted = this.#spawnSignatureOf(opts);
128906
+ if (blank.spawnSignature !== wanted) {
128907
+ console.warn(
128908
+ `[claude-pool] blank standby exists but its spawn signature doesn't match this turn's options \u2014 not adopting (blank=${blank.spawnSignature} turn=${wanted}). Check the prewarm wiring in main.ts.`
128909
+ );
128910
+ return void 0;
128911
+ }
128912
+ this.#entries.delete(blank.key);
128913
+ blank.key = key;
128914
+ blank.threadId = threadId;
128915
+ blank.blank = false;
128916
+ blank.lastUsedAt = Date.now();
128917
+ this.#entries.set(key, blank);
128918
+ this.#rewritePidListBestEffort();
128919
+ this.#prewarmFailures = 0;
128920
+ console.warn(
128921
+ `[claude-pool] blank standby pid=${blank.child.pid ?? "?"} adopted by thread=${threadId} \u2014 this turn skips the cold start entirely.`
128922
+ );
128923
+ queueMicrotask(() => this.#maybeSpawnBlank());
128924
+ return blank;
128925
+ }
128926
+ /**
128927
+ * Spawn the standby blank when prewarm is configured and conditions allow:
128928
+ * at most one blank at a time, never past maxProcesses, never while
128929
+ * shutting down, and never after the circuit breaker tripped.
128930
+ */
128931
+ #maybeSpawnBlank() {
128932
+ if (this.#prewarmProto == null || this.#prewarmDisabled || this.#shuttingDown) return;
128933
+ if (this.#prewarmRespawnTimer != null) return;
128934
+ if (this.#entries.size >= this.#maxProcesses) return;
128935
+ for (const e of this.#entries.values()) {
128936
+ if (e.blank) return;
128937
+ }
128938
+ const proto = this.#prewarmProto;
128939
+ const key = `__blank__::${this.#botId}::${this.#nextBlankId++}`;
128940
+ const opts = {
128941
+ prompt: "",
128942
+ cwd: proto.cwd,
128943
+ model: proto.model,
128944
+ effort: proto.effort,
128945
+ permissionMode: proto.permissionMode,
128946
+ agentBinPath: proto.agentBinPath
128947
+ };
128948
+ const entry = this.#spawnEntry(key, key, opts, { blank: true });
128949
+ console.warn(
128950
+ `[claude-pool] pre-warmed blank standby pid=${entry.child.pid ?? "?"} for bot=${this.#botId} (signature=${entry.spawnSignature.slice(0, 120)}\u2026)`
128951
+ );
128952
+ }
128953
+ #spawnEntry(key, threadId, opts, flags) {
128674
128954
  const [bin, args] = buildWarmCommand(opts);
128675
128955
  const env = buildEnv(this.#botGitIdentity, this.#gitlabToken);
128676
128956
  const child = spawn3(bin, args, {
@@ -128682,6 +128962,8 @@ var ClaudeProcessPool = class {
128682
128962
  key,
128683
128963
  threadId,
128684
128964
  cwd: opts.cwd,
128965
+ blank: flags?.blank === true,
128966
+ spawnSignature: this.#spawnSignatureOf(opts),
128685
128967
  child,
128686
128968
  spawnedAt: Date.now(),
128687
128969
  lastUsedAt: Date.now(),
@@ -128747,6 +129029,32 @@ var ClaudeProcessPool = class {
128747
129029
  this.#dying.delete(entry);
128748
129030
  this.#rewritePidListBestEffort();
128749
129031
  void this.#deleteRunnerPidFileIfMine(entry);
129032
+ if (this.#prewarmProto != null && !this.#shuttingDown && !this.#prewarmDisabled) {
129033
+ const unpromptedBlankDeath = entry.blank && !wasAlreadyMarkedDestroyed;
129034
+ if (unpromptedBlankDeath) {
129035
+ this.#prewarmFailures += 1;
129036
+ if (this.#prewarmFailures >= MAX_PREWARM_FAILURES) {
129037
+ this.#prewarmDisabled = true;
129038
+ const stderr = Buffer.concat(entry.stderrChunks).toString("utf8").trim().slice(-2e3);
129039
+ console.warn(
129040
+ `[claude-pool] blank standby died unprompted ${this.#prewarmFailures} times in a row \u2014 disabling prewarm until the bridge restarts (warm pooling itself stays on; turns just spawn on demand).` + (stderr ? ` last stderr tail:
129041
+ ${stderr}` : "")
129042
+ );
129043
+ } else {
129044
+ const delay = PREWARM_RESPAWN_BACKOFF_MS * 2 ** (this.#prewarmFailures - 1);
129045
+ console.warn(
129046
+ `[claude-pool] blank standby pid=${entry.child.pid ?? "?"} died unprompted after ${Math.round((Date.now() - entry.spawnedAt) / 1e3)}s (${this.#prewarmFailures}/${MAX_PREWARM_FAILURES}) \u2014 respawning in ${delay}ms.`
129047
+ );
129048
+ this.#prewarmRespawnTimer = setTimeout(() => {
129049
+ this.#prewarmRespawnTimer = void 0;
129050
+ this.#maybeSpawnBlank();
129051
+ }, delay);
129052
+ this.#prewarmRespawnTimer.unref?.();
129053
+ }
129054
+ } else {
129055
+ queueMicrotask(() => this.#maybeSpawnBlank());
129056
+ }
129057
+ }
128750
129058
  const state = entry.current;
128751
129059
  entry.current = void 0;
128752
129060
  if (state == null || state.settled) return;
@@ -128799,6 +129107,7 @@ ${stderr}` : "")
128799
129107
  const now = Date.now();
128800
129108
  for (const entry of [...this.#entries.values()]) {
128801
129109
  if (entry.current != null) continue;
129110
+ if (entry.blank) continue;
128802
129111
  if (now - entry.lastUsedAt >= this.#idleMs) {
128803
129112
  this.#destroyEntry(entry, "idle timeout");
128804
129113
  }
@@ -129418,6 +129727,19 @@ var CodexProcessPool = class {
129418
129727
  get childPid() {
129419
129728
  return this.#child?.pid ?? void 0;
129420
129729
  }
129730
+ /**
129731
+ * 批D: spawn the app-server eagerly at bridge boot instead of lazily on the
129732
+ * first turn, so even the bot's first message hits a running process.
129733
+ * Idempotent; no-op while shutting down or after the spawn circuit breaker
129734
+ * (#poolDisabled) tripped. Unlike ClaudeProcessPool's blank standby there
129735
+ * is no post-idle-reap replenish here: after the 10-min idle reap the next
129736
+ * turn re-spawns on demand exactly as before (the reap exists to bound the
129737
+ * app-server's per-thread memory growth — auto-respawning would defeat it).
129738
+ */
129739
+ prewarm() {
129740
+ if (this.#shuttingDown || this.#poolDisabled) return;
129741
+ if (this.#child == null) this.#spawnChild();
129742
+ }
129421
129743
  run(opts) {
129422
129744
  const turnKey = this.#nextTurnKey++;
129423
129745
  const queue = new TurnEventQueue();
@@ -130215,7 +130537,7 @@ function isStallNudgeState(value) {
130215
130537
  function isTaskHandleRecord(value) {
130216
130538
  if (typeof value !== "object" || value === null) return false;
130217
130539
  const v = value;
130218
- return typeof v["threadId"] === "string" && typeof v["taskGuid"] === "string" && typeof v["chatId"] === "string" && typeof v["claimedTs"] === "number" && (v["lastSeenCommentId"] === void 0 || typeof v["lastSeenCommentId"] === "string") && (v["lastTurnOutcome"] === void 0 || v["lastTurnOutcome"] === "completed" || v["lastTurnOutcome"] === "failed") && (v["stallNudge"] === void 0 || isStallNudgeState(v["stallNudge"])) && (v["stallSuppressUntilActivityAfter"] === void 0 || typeof v["stallSuppressUntilActivityAfter"] === "number") && (v["lastTurnMentions"] === void 0 || Array.isArray(v["lastTurnMentions"]) && v["lastTurnMentions"].every((m) => typeof m === "string")) && (v["lastTurnMentionsAt"] === void 0 || typeof v["lastTurnMentionsAt"] === "number") && (v["mode"] === void 0 || v["mode"] === "comment") && (v["doneDeclared"] === void 0 || typeof v["doneDeclared"] === "boolean");
130540
+ return typeof v["threadId"] === "string" && typeof v["taskGuid"] === "string" && typeof v["chatId"] === "string" && typeof v["claimedTs"] === "number" && (v["lastSeenCommentId"] === void 0 || typeof v["lastSeenCommentId"] === "string") && (v["lastTurnOutcome"] === void 0 || v["lastTurnOutcome"] === "completed" || v["lastTurnOutcome"] === "failed") && (v["stallNudge"] === void 0 || isStallNudgeState(v["stallNudge"])) && (v["stallSuppressUntilActivityAfter"] === void 0 || typeof v["stallSuppressUntilActivityAfter"] === "number") && (v["lastTurnMentions"] === void 0 || Array.isArray(v["lastTurnMentions"]) && v["lastTurnMentions"].every((m) => typeof m === "string")) && (v["lastTurnMentionsAt"] === void 0 || typeof v["lastTurnMentionsAt"] === "number") && (v["mode"] === void 0 || v["mode"] === "comment") && (v["doneDeclared"] === void 0 || typeof v["doneDeclared"] === "boolean") && (v["claimCommentPending"] === void 0 || typeof v["claimCommentPending"] === "boolean");
130219
130541
  }
130220
130542
  var TaskHandleStore = class _TaskHandleStore {
130221
130543
  #filePath;
@@ -130431,7 +130753,8 @@ var TaskHandleStore = class _TaskHandleStore {
130431
130753
  taskGuid: input.taskGuid,
130432
130754
  chatId: input.chatId,
130433
130755
  claimedTs: Date.now(),
130434
- ...input.mode ? { mode: input.mode } : {}
130756
+ ...input.mode ? { mode: input.mode } : {},
130757
+ ...input.claimCommentPending ? { claimCommentPending: true } : {}
130435
130758
  };
130436
130759
  });
130437
130760
  return { claimed, reason };
@@ -130911,7 +131234,11 @@ async function applyTaskHandleWriteback(patch, deps) {
130911
131234
  // Agent declared delivery — its own "已交付" comment is the
130912
131235
  // user-facing artifact; bridge just stops patrolling the
130913
131236
  // "delivered, human hasn't ticked complete yet" window.
130914
- ...patch.agentDeclaredDone === true ? { doneDeclared: true } : {}
131237
+ ...patch.agentDeclaredDone === true ? { doneDeclared: true } : {},
131238
+ // v4.2 round-2: a successfully completed turn ran under a
131239
+ // <task-root> that instructed the claim comment — consider
131240
+ // the backlink posted; only crashed turns keep it owed.
131241
+ claimCommentPending: void 0
130915
131242
  } : current
130916
131243
  );
130917
131244
  break;
@@ -131560,10 +131887,11 @@ var CandidateAlertStore = class _CandidateAlertStore {
131560
131887
  // src/tasklist/stallDetector.ts
131561
131888
  var DEFAULT_INTERVAL_MS3 = 6e4;
131562
131889
  var DEFAULT_JITTER_MS3 = 1e4;
131563
- var DEFAULT_STALL_THRESHOLD_MS = 24 * 60 * 6e4;
131890
+ var DEFAULT_STALL_THRESHOLD_MS = 60 * 6e4;
131564
131891
  var DEFAULT_STALL_FAST_THRESHOLD_MS = 30 * 6e4;
131565
131892
  var DEFAULT_STALL_HANDOFF_THRESHOLD_MS = 5 * 6e4;
131566
- var DEFAULT_NUDGE_COOLDOWN_MS = 24 * 60 * 6e4;
131893
+ var DEFAULT_NUDGE_COOLDOWN_MS = 60 * 6e4;
131894
+ var DUE_NUDGE_COOLDOWN_FLOOR_MS = 24 * 60 * 6e4;
131567
131895
  var DEFAULT_ESCALATE_AFTER_NUDGES = 2;
131568
131896
  var DEFAULT_PENDING_CONFIRM_TIMEOUT_MS = 30 * 6e4;
131569
131897
  var DEFAULT_HANDOFF_STARTUP_QUIET_MS = 6 * 6e4;
@@ -131742,7 +132070,7 @@ var StallDetector = class {
131742
132070
  const { ms: threshold, reason } = this.#effectiveThreshold(record, threadId);
131743
132071
  if (!nudge || nudge.count === 0) {
131744
132072
  if (now - lastActiveTs < threshold) return;
131745
- if (reason === "due" && !justClearedPendingTimeout && nudge?.lastNudgeSentAt !== void 0 && now - nudge.lastNudgeSentAt < this.#nudgeCooldownMs) {
132073
+ if (reason === "due" && !justClearedPendingTimeout && nudge?.lastNudgeSentAt !== void 0 && now - nudge.lastNudgeSentAt < this.#cooldownFor(reason)) {
131746
132074
  return;
131747
132075
  }
131748
132076
  await this.#sendNudge(threadId);
@@ -131767,7 +132095,7 @@ var StallDetector = class {
131767
132095
  });
131768
132096
  return;
131769
132097
  }
131770
- if (now - nudge.lastNudgeSentAt < this.#nudgeCooldownMs) return;
132098
+ if (now - nudge.lastNudgeSentAt < this.#cooldownFor(reason)) return;
131771
132099
  if (nudge.count >= this.#escalateAfterNudges) {
131772
132100
  await this.#escalate(threadId);
131773
132101
  } else {
@@ -131806,6 +132134,10 @@ var StallDetector = class {
131806
132134
  * until gap-fill has had a chance to redeliver anything in flight across
131807
132135
  * a restart.
131808
132136
  */
132137
+ /** Cooldown for a given tier — the due tier keeps a 24h floor (see DUE_NUDGE_COOLDOWN_FLOOR_MS). An explicit operator nudgeCooldownMs LONGER than the floor still wins. */
132138
+ #cooldownFor(reason) {
132139
+ return reason === "due" ? Math.max(this.#nudgeCooldownMs, DUE_NUDGE_COOLDOWN_FLOOR_MS) : this.#nudgeCooldownMs;
132140
+ }
131809
132141
  #effectiveThreshold(record, threadId) {
131810
132142
  const base = record.lastTurnOutcome === "failed" ? { ms: this.#stallFastThresholdMs, reason: "failed" } : { ms: this.#stallThresholdMs, reason: "normal" };
131811
132143
  const dueMs = this.#deps.getTaskDueMs?.(record.taskGuid);
@@ -131819,6 +132151,14 @@ var StallDetector = class {
131819
132151
  const now = Date.now();
131820
132152
  let brokenFloorMs;
131821
132153
  for (const peerBotId of record.lastTurnMentions) {
132154
+ {
132155
+ const peerOutcome = this.#deps.getPeerLastTurnOutcome?.(peerBotId, threadId);
132156
+ const peerEndedAt = this.#deps.getPeerLastActiveTs?.(peerBotId, threadId);
132157
+ if (peerOutcome === "failed" && peerEndedAt !== void 0 && peerEndedAt > mentionAt) {
132158
+ brokenFloorMs = this.#stallHandoffThresholdMs;
132159
+ break;
132160
+ }
132161
+ }
131822
132162
  const receivedAt = this.#deps.getPeerReceivedAt(peerBotId, threadId);
131823
132163
  if (receivedAt === void 0 || receivedAt <= mentionAt) {
131824
132164
  const completedAt2 = this.#deps.getPeerLastActiveTs?.(peerBotId, threadId);
@@ -132102,6 +132442,7 @@ async function runV2Mode({
132102
132442
  const { basename } = path19;
132103
132443
  const instances = [];
132104
132444
  const handlersByBotId = /* @__PURE__ */ new Map();
132445
+ const allTaskHandleStores = [];
132105
132446
  const tasklistPollersByGuid = /* @__PURE__ */ new Map();
132106
132447
  const tasklistGuidGroups = /* @__PURE__ */ new Map();
132107
132448
  for (const bot of healthyBots) {
@@ -132195,6 +132536,8 @@ async function runV2Mode({
132195
132536
  let taskHandleClaim;
132196
132537
  let taskHandleClaimedLookup;
132197
132538
  let taskHandleClaimGuidLookup;
132539
+ let taskGuidClaimedByOtherBot;
132540
+ let taskHandleClaimCommentPending;
132198
132541
  let taskHandleCandidatesLookup;
132199
132542
  let taskCommentPoller;
132200
132543
  let stallDetector;
@@ -132224,6 +132567,7 @@ async function runV2Mode({
132224
132567
  effectiveTaskHandleTasklistGuid = guid;
132225
132568
  }
132226
132569
  const taskHandleStore = await TaskHandleStore.load(resolveTaskHandlesPath(bot.id));
132570
+ allTaskHandleStores.push({ botId: bot.id, store: taskHandleStore });
132227
132571
  taskHandleLifecycle = (patch) => applyTaskHandleWriteback(patch, { store: taskHandleStore, client: taskListClient });
132228
132572
  taskHandleClaim = async (patch) => {
132229
132573
  const result = await taskHandleStore.claim(patch);
@@ -132235,6 +132579,8 @@ async function runV2Mode({
132235
132579
  };
132236
132580
  taskHandleClaimedLookup = (threadId) => taskHandleStore.get(threadId) !== void 0;
132237
132581
  taskHandleClaimGuidLookup = (threadId) => taskHandleStore.get(threadId)?.taskGuid;
132582
+ taskGuidClaimedByOtherBot = (guid2) => allTaskHandleStores.some((e) => e.botId !== bot.id && e.store.list().some((r) => r.taskGuid === guid2));
132583
+ taskHandleClaimCommentPending = (threadId) => taskHandleStore.get(threadId)?.claimCommentPending === true;
132238
132584
  if (guid) {
132239
132585
  const group = tasklistGuidGroups.get(guid) ?? { client: taskListClient, clientOwnerBotId: bot.id, bots: [] };
132240
132586
  group.bots.push({ botId: bot.id, sessionStore, taskHandleStore });
@@ -132297,6 +132643,9 @@ async function runV2Mode({
132297
132643
  // window — "did their turn genuinely finish", not "did it start
132298
132644
  // or get queued". See stallDetector.ts's module doc revision 3.
132299
132645
  getPeerLastActiveTs: (peerBotId, threadId) => handlersByBotId.get(peerBotId)?.getThreadLastActiveTs(threadId),
132646
+ // v4.2 revision 5: the peer's most recent finished-turn OUTCOME —
132647
+ // a failed finish must not resolve a handoff (see stallDetector.ts).
132648
+ getPeerLastTurnOutcome: (peerBotId, threadId) => handlersByBotId.get(peerBotId)?.getThreadLastOutcome(threadId),
132300
132649
  // v3.3 due-date stall detection (docs/task-handle.md §14): free
132301
132650
  // read of TasklistPoller's own per-guid `due` observation — same
132302
132651
  // "populated after, read lazily" closure trick as
@@ -132357,7 +132706,8 @@ async function runV2Mode({
132357
132706
  let codexPool;
132358
132707
  let claudePool;
132359
132708
  let runnerKey;
132360
- if (bot.warmProcess && bot.backend === "codex") {
132709
+ const warmProcessOn = effectiveWarmProcess(bot);
132710
+ if (warmProcessOn && bot.backend === "codex") {
132361
132711
  const pidFilePath = path19.join(botDir, "warm-codex.pid");
132362
132712
  try {
132363
132713
  await reapOrphanedWarmProcess(pidFilePath);
@@ -132372,7 +132722,8 @@ async function runV2Mode({
132372
132722
  });
132373
132723
  runnerKey = `codex-pool:${bot.id}`;
132374
132724
  registerRunner(runnerKey, () => codexPool);
132375
- } else if (bot.warmProcess && bot.backend === "claude") {
132725
+ if (effectivePrewarmProcess(bot)) codexPool.prewarm();
132726
+ } else if (warmProcessOn && bot.backend === "claude") {
132376
132727
  const pidListFilePath = path19.join(botDir, "warm-claude.pids.json");
132377
132728
  try {
132378
132729
  await reapOrphanedWarmClaudeProcesses(pidListFilePath);
@@ -132389,6 +132740,18 @@ async function runV2Mode({
132389
132740
  });
132390
132741
  runnerKey = `claude-pool:${bot.id}`;
132391
132742
  registerRunner(runnerKey, () => claudePool);
132743
+ if (effectivePrewarmProcess(bot) && bot.runtime === "agent_workspace" && agentWorkspacePath) {
132744
+ claudePool.prewarm({
132745
+ cwd: agentWorkspacePath,
132746
+ model: bot.model,
132747
+ effort: bot.effort,
132748
+ // Mirrors handler.ts's own default ("bypassPermissions" when
132749
+ // config.json leaves permissions.mode unset). If these two ever
132750
+ // drift the blank's signature just stops matching — fail-safe, and
132751
+ // the pool logs the mismatch on every missed adoption.
132752
+ permissionMode: configJson.permissions.mode ?? "bypassPermissions"
132753
+ });
132754
+ }
132392
132755
  }
132393
132756
  const handler = new BridgeHandler({
132394
132757
  client,
@@ -132445,6 +132808,8 @@ async function runV2Mode({
132445
132808
  taskHandleClaim,
132446
132809
  taskHandleClaimedLookup,
132447
132810
  taskHandleClaimGuidLookup,
132811
+ taskGuidClaimedByOtherBot,
132812
+ taskHandleClaimCommentPending,
132448
132813
  taskHandleCandidatesLookup
132449
132814
  });
132450
132815
  handlersByBotId.set(bot.id, handler);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.42",
3
+ "version": "0.3.44",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",