larkway 0.3.41 → 0.3.43

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.41**
11
+ **Current release: v0.3.43**
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.41**
11
+ **当前版本:v0.3.43**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -112702,6 +112702,7 @@ function channelMsgToLarkEvent(msg) {
112702
112702
  if (!message_id || !chat_id || !senderOpenId) return null;
112703
112703
  const thread_id = m?.["thread_id"] ?? msg.threadId ?? m?.["root_id"] ?? msg.rootId ?? message_id;
112704
112704
  const root_id = m?.["root_id"] ?? msg.rootId ?? void 0;
112705
+ const parent_id = m?.["parent_id"] ?? msg.replyToMessageId ?? void 0;
112705
112706
  const rawContent = typeof m?.["content"] === "string" ? m["content"] : void 0;
112706
112707
  const content = rawContent ?? JSON.stringify({ text: stripAtMarkup(msg.content ?? "") });
112707
112708
  return {
@@ -112710,6 +112711,7 @@ function channelMsgToLarkEvent(msg) {
112710
112711
  chat_type: m?.["chat_type"] ?? msg.chatType ?? "group",
112711
112712
  thread_id,
112712
112713
  root_id,
112714
+ parent_id,
112713
112715
  sender_id: senderOpenId,
112714
112716
  mentions: m?.["mentions"] ?? void 0,
112715
112717
  content,
@@ -126102,24 +126104,37 @@ var BotConfigSchema = external_exports.object({
126102
126104
  */
126103
126105
  effort: external_exports.string().min(1).optional(),
126104
126106
  /**
126105
- * docs/larkway-perf-plan.md §4 — opt into a persistent warm process
126106
- * instead of the default one-shot cold-start-per-turn behavior. Takes
126107
- * effect for `backend: "codex"` (a single bot-level `codex app-server`
126108
- * process — src/codex/pool.ts CodexProcessPool) and `backend: "claude"`
126109
- * (one warm process per active thread — src/claude/pool.ts
126110
- * ClaudeProcessPool). Any other backend value is a no-op (main.ts only
126111
- * constructs a pool for these two; botLoader's advisory warn below flags
126112
- * the mismatch upfront). Byte-identical behavior when unset, matching
126113
- * every other perf-plan flag in this schema. `.optional()` (not
126114
- * `.default(false)`, deliberately B1 fix): a `.default()` would make
126115
- * every future `larkway bot` yaml write-out include `warmProcess: false`
126116
- * even when nobody asked for it, and because this schema is `.strict()`
126117
- * would break loading that yaml back with an OLDER larkway build that
126118
- * predates this field. `undefined` and `false` are treated identically at
126119
- * both read sites (main.ts's `bot.warmProcess && …` and the advisory warn
126120
- * below).
126107
+ * docs/larkway-perf-plan.md §4 — persistent warm process instead of a
126108
+ * one-shot cold start per turn. Takes effect for `backend: "codex"` (a
126109
+ * single bot-level `codex app-server` process — src/codex/pool.ts
126110
+ * CodexProcessPool) and `backend: "claude"` (one warm process per active
126111
+ * thread — src/claude/pool.ts ClaudeProcessPool). Any other backend value
126112
+ * is a no-op (main.ts only constructs a pool for these two; botLoader's
126113
+ * advisory warn below flags the mismatch upfront).
126114
+ *
126115
+ * 批D (warm-by-default):
126116
+ * DEFAULT IS NOW ON for the two supported backends — `undefined` means
126117
+ * `true` when backend is codex/claude (see {@link effectiveWarmProcess},
126118
+ * the single source of that rule). Set `warmProcess: false` explicitly to
126119
+ * opt a bot back out. `.optional()` (not `.default(true)`) is still
126120
+ * deliberate: a `.default()` would make every future `larkway bot` yaml
126121
+ * write-out bake the value in, and because this schema is `.strict()` —
126122
+ * would break loading that yaml back with an OLDER larkway build that
126123
+ * predates this field.
126121
126124
  */
126122
126125
  warmProcess: external_exports.boolean().optional(),
126126
+ /**
126127
+ * 批D: pre-warm a BLANK agent process at bridge boot (and keep one on
126128
+ * standby thereafter), so even the FIRST message of a NEW thread skips the
126129
+ * cold start. Only meaningful when the warm pool is on (see
126130
+ * `warmProcess`); default true. backend=claude: ClaudeProcessPool keeps
126131
+ * one blank (no --resume) child matching the bot's static spawn signature,
126132
+ * adopted by the first new-session turn and replenished after adoption.
126133
+ * backend=codex: CodexProcessPool spawns its app-server eagerly at boot
126134
+ * instead of on the first turn. Set false to keep warm pooling but skip
126135
+ * the always-resident standby (saves ~300MB RSS per bot at idle).
126136
+ */
126137
+ prewarmProcess: external_exports.boolean().optional(),
126123
126138
  /**
126124
126139
  * Idle threshold (ms) before a warm process (see `warmProcess` above) with
126125
126140
  * no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
package/dist/main.js CHANGED
@@ -116856,6 +116856,7 @@ function channelMsgToLarkEvent(msg) {
116856
116856
  if (!message_id || !chat_id || !senderOpenId) return null;
116857
116857
  const thread_id = m?.["thread_id"] ?? msg.threadId ?? m?.["root_id"] ?? msg.rootId ?? message_id;
116858
116858
  const root_id = m?.["root_id"] ?? msg.rootId ?? void 0;
116859
+ const parent_id = m?.["parent_id"] ?? msg.replyToMessageId ?? void 0;
116859
116860
  const rawContent = typeof m?.["content"] === "string" ? m["content"] : void 0;
116860
116861
  const content = rawContent ?? JSON.stringify({ text: stripAtMarkup(msg.content ?? "") });
116861
116862
  return {
@@ -116864,6 +116865,7 @@ function channelMsgToLarkEvent(msg) {
116864
116865
  chat_type: m?.["chat_type"] ?? msg.chatType ?? "group",
116865
116866
  thread_id,
116866
116867
  root_id,
116868
+ parent_id,
116867
116869
  sender_id: senderOpenId,
116868
116870
  mentions: m?.["mentions"] ?? void 0,
116869
116871
  content,
@@ -119388,9 +119390,20 @@ async function renderPrompt(input) {
119388
119390
  taskHandleClaimed = false,
119389
119391
  taskHandleCandidates = [],
119390
119392
  taskRoot,
119391
- mtimeFacts = []
119393
+ mtimeFacts = [],
119394
+ queuedFollowups = []
119392
119395
  } = input;
119393
119396
  const backend = input.backend ?? "claude";
119397
+ const userMessageLines = [
119398
+ "<user-message>",
119399
+ `${parsed.senderOpenId}: ${parsed.text}`,
119400
+ ...queuedFollowups.length > 0 ? [
119401
+ "",
119402
+ `(\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:)`,
119403
+ ...queuedFollowups.map((f) => `${f.senderOpenId}: ${f.text}`)
119404
+ ] : [],
119405
+ "</user-message>"
119406
+ ];
119394
119407
  const profileFlag = larkCliProfile ? ` --profile ${larkCliProfile}` : "";
119395
119408
  const attachmentKeys = parsed.attachments.map((a) => a.fileKey);
119396
119409
  const imageKeys = parsed.attachments.filter((a) => a.fileType === "image").map((a) => a.fileKey);
@@ -119522,9 +119535,7 @@ async function renderPrompt(input) {
119522
119535
  ...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
119523
119536
  ...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
119524
119537
  "",
119525
- "<user-message>",
119526
- `${parsed.senderOpenId}: ${parsed.text}`,
119527
- "</user-message>"
119538
+ ...userMessageLines
119528
119539
  ].join("\n");
119529
119540
  }
119530
119541
  return [
@@ -119579,9 +119590,7 @@ async function renderPrompt(input) {
119579
119590
  ...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
119580
119591
  ...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
119581
119592
  "",
119582
- "<user-message>",
119583
- `${parsed.senderOpenId}: ${parsed.text}`,
119584
- "</user-message>"
119593
+ ...userMessageLines
119585
119594
  ].join("\n");
119586
119595
  }
119587
119596
 
@@ -119768,6 +119777,12 @@ function renderTranscriptEntry(input) {
119768
119777
  "",
119769
119778
  indentBlock(parsed.text),
119770
119779
  "",
119780
+ ...input.queuedFollowups && input.queuedFollowups.length > 0 ? [
119781
+ "### Coalesced Follow-ups",
119782
+ "",
119783
+ ...input.queuedFollowups.flatMap((f) => [`- ${f.senderOpenId}:`, indentBlock(f.text)]),
119784
+ ""
119785
+ ] : [],
119771
119786
  "### Feishu Doc Links",
119772
119787
  "",
119773
119788
  ...renderList(parsed.feishuDocLinks),
@@ -122147,6 +122162,20 @@ async function isWorktreeGitHealthy(worktreePath) {
122147
122162
  child.on("error", () => resolve2(false));
122148
122163
  });
122149
122164
  }
122165
+ function canCoalesceFollowup(primary, candidate) {
122166
+ if (primary.larkway_trigger_type != null || candidate.larkway_trigger_type != null) return false;
122167
+ if (primary.reply_anchor_message_id != null || candidate.reply_anchor_message_id != null) return false;
122168
+ const sessionKeyOf = (e) => typeof e.root_id === "string" && e.root_id ? e.root_id : e.message_id;
122169
+ if (sessionKeyOf(primary) !== sessionKeyOf(candidate)) return false;
122170
+ try {
122171
+ const parsed = parseMessage(candidate);
122172
+ if (parsed.attachments.length > 0) return false;
122173
+ if (parsed.text.trim() === "") return false;
122174
+ } catch {
122175
+ return false;
122176
+ }
122177
+ return true;
122178
+ }
122150
122179
  var repoCloneLocks = /* @__PURE__ */ new Map();
122151
122180
  function ensureRepoClone(basePath, url, token, label) {
122152
122181
  const prev = repoCloneLocks.get(basePath) ?? Promise.resolve();
@@ -122496,6 +122525,7 @@ var BridgeHandler = class {
122496
122525
  async run(opts) {
122497
122526
  const signal = opts?.abortSignal;
122498
122527
  const threadQueues = /* @__PURE__ */ new Map();
122528
+ const pendingByKey = /* @__PURE__ */ new Map();
122499
122529
  const MAX_CONCURRENT = 5;
122500
122530
  let running = 0;
122501
122531
  const waiters = [];
@@ -122517,14 +122547,37 @@ var BridgeHandler = class {
122517
122547
  for await (const event of this.deps.client.events()) {
122518
122548
  if (this.closed) break;
122519
122549
  if (signal?.aborted) break;
122520
- const key = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
122550
+ const key = typeof event.root_id === "string" && event.root_id ? event.root_id : typeof event.parent_id === "string" && event.parent_id ? event.parent_id : event.message_id;
122521
122551
  this.threadReceivedAt.set(key, Date.now());
122552
+ const legacySessionKey = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
122553
+ if (legacySessionKey !== key) this.threadReceivedAt.set(legacySessionKey, Date.now());
122554
+ let pendingList = pendingByKey.get(key);
122555
+ if (pendingList == null) {
122556
+ pendingList = [];
122557
+ pendingByKey.set(key, pendingList);
122558
+ }
122559
+ pendingList.push(event);
122522
122560
  const prev = threadQueues.get(key) ?? Promise.resolve();
122523
- const next = prev.then(() => acquire()).then(() => this.handleOne(event)).catch((err) => {
122561
+ const next = prev.then(() => acquire()).then(() => {
122562
+ const pending = pendingByKey.get(key);
122563
+ const primary = pending?.shift();
122564
+ if (primary == null) return;
122565
+ const followups = [];
122566
+ while (pending != null && pending.length > 0 && canCoalesceFollowup(primary, pending[0])) {
122567
+ followups.push(pending.shift());
122568
+ }
122569
+ if (followups.length > 0) {
122570
+ console.log(
122571
+ `[bridge.handler] coalescing ${followups.length} queued follow-up message(s) into turn ${primary.message_id} on thread ${key}`
122572
+ );
122573
+ }
122574
+ return this.handleOne(primary, followups);
122575
+ }).catch((err) => {
122524
122576
  console.error(`[bridge.handler] unhandled error on thread ${key}:`, err);
122525
122577
  }).finally(() => {
122526
122578
  release();
122527
122579
  if (threadQueues.get(key) === next) threadQueues.delete(key);
122580
+ if ((pendingByKey.get(key)?.length ?? 0) === 0) pendingByKey.delete(key);
122528
122581
  this.inFlightTurns.delete(next);
122529
122582
  });
122530
122583
  this.inFlightTurns.add(next);
@@ -122541,7 +122594,14 @@ var BridgeHandler = class {
122541
122594
  // ---------------------------------------------------------------------------
122542
122595
  // Private: single-event lifecycle
122543
122596
  // ---------------------------------------------------------------------------
122544
- async handleOne(event) {
122597
+ /**
122598
+ * @param followups 批D gated coalescing — same-session messages that queued
122599
+ * up behind the previous turn and are merged into THIS one (see run()'s
122600
+ * drain loop + canCoalesceFollowup). They contribute their text to the
122601
+ * prompt and settle together with the primary; everything else about the
122602
+ * turn (card, session, task probes) is driven by `event` alone.
122603
+ */
122604
+ async handleOne(event, followups = []) {
122545
122605
  const settleMessageId = event.message_id;
122546
122606
  let settled = false;
122547
122607
  let agentRunCompleted = false;
@@ -122551,15 +122611,46 @@ var BridgeHandler = class {
122551
122611
  const settle = (ok) => {
122552
122612
  if (settled) return;
122553
122613
  settled = true;
122554
- if (ok) this.deps.client.markHandled?.(settleMessageId);
122555
- else this.deps.client.markUnhandled?.(settleMessageId, { replay: !agentRunCompleted });
122614
+ for (const id of [settleMessageId, ...followups.map((f) => f.message_id)]) {
122615
+ if (ok) this.deps.client.markHandled?.(id);
122616
+ else this.deps.client.markUnhandled?.(id, { replay: !agentRunCompleted });
122617
+ }
122556
122618
  };
122557
122619
  try {
122558
122620
  const parsed = parseMessage(event);
122559
- const { threadId, messageId, senderOpenId } = parsed;
122621
+ const { messageId, senderOpenId } = parsed;
122622
+ let threadId = parsed.threadId;
122560
122623
  const botId = this.deps.botConfig?.id;
122561
122624
  const eventLogId = messageId;
122562
122625
  const eventStartedAt = Date.now();
122626
+ let taskRootInfo;
122627
+ let taskCardAnchorId;
122628
+ let deferredTaskRootProbe;
122629
+ {
122630
+ const realTopic = realTopicThreadId(parsed.raw.thread_id);
122631
+ 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);
122632
+ if (rootCandidate && this.deps.messageLookup) {
122633
+ const probe = this.deps.messageLookup.get(rootCandidate).catch(() => void 0);
122634
+ if (realTopic) {
122635
+ deferredTaskRootProbe = probe;
122636
+ } else {
122637
+ const info = await probe;
122638
+ if (info?.msgType === "todo" && info.content) {
122639
+ const todo = parseTodoShareContent(info.content);
122640
+ if (todo) {
122641
+ const probeThreadId = realTopicThreadId(info.threadId);
122642
+ taskRootInfo = {
122643
+ guid: todo.taskGuid,
122644
+ summary: todo.summaryText,
122645
+ topicLink: probeThreadId ? buildTopicDeepLink(parsed.chatId, probeThreadId) : void 0
122646
+ };
122647
+ taskCardAnchorId = rootCandidate;
122648
+ threadId = rootCandidate;
122649
+ }
122650
+ }
122651
+ }
122652
+ }
122653
+ }
122563
122654
  const turnReceivedAt = this.threadReceivedAt.get(threadId);
122564
122655
  const recordEvent = async (patch) => {
122565
122656
  if (!this.deps.recordRuntimeEvent) return;
@@ -122593,6 +122684,27 @@ var BridgeHandler = class {
122593
122684
  reason: "\u5DF2\u8FDB\u5165 bridge\uFF0C\u51C6\u5907\u521B\u5EFA\u5904\u7406\u5361\u7247\u3002"
122594
122685
  });
122595
122686
  await this.deps.client.addProcessingReaction?.(messageId);
122687
+ for (const f of followups) {
122688
+ if (!this.deps.recordRuntimeEvent) break;
122689
+ try {
122690
+ await this.deps.recordRuntimeEvent({
122691
+ id: f.message_id,
122692
+ botId,
122693
+ botName: this.deps.botConfig?.name,
122694
+ messageId: f.message_id,
122695
+ threadId,
122696
+ chatId: parsed.chatId,
122697
+ senderId: typeof f.sender_id === "string" ? f.sender_id : void 0,
122698
+ triggerType,
122699
+ status: "received",
122700
+ receivedAt: new Date(eventStartedAt).toISOString(),
122701
+ statusPath: ["\u5DF2\u6536\u5230", "\u5408\u5E76\u8FDB\u540C\u8F6E"],
122702
+ reason: `\u6392\u961F\u671F\u95F4\u5230\u8FBE,\u5DF2\u5408\u5E76\u8FDB ${messageId} \u7684\u540C\u4E00\u8F6E\u5904\u7406\u3002`
122703
+ });
122704
+ } catch (err) {
122705
+ console.warn("[bridge.handler] recordRuntimeEvent (coalesced followup) failed (continuing):", err);
122706
+ }
122707
+ }
122596
122708
  const invokeTaskHandleLifecycle = async (fields) => {
122597
122709
  if (!this.deps.taskHandleLifecycle || !botId) return;
122598
122710
  try {
@@ -122607,32 +122719,9 @@ var BridgeHandler = class {
122607
122719
  const isTopLevel = !(typeof parsed.raw.root_id === "string" && parsed.raw.root_id);
122608
122720
  let replyInThread = isTopLevel;
122609
122721
  let replyAnchorId = messageId;
122610
- let taskRootInfo;
122611
- let deferredTaskRootProbe;
122612
- {
122613
- const rootId = typeof parsed.raw.root_id === "string" && parsed.raw.root_id ? parsed.raw.root_id : void 0;
122614
- const rawThreadId = realTopicThreadId(parsed.raw.thread_id);
122615
- if (rootId && this.deps.messageLookup) {
122616
- const probe = this.deps.messageLookup.get(rootId).catch(() => void 0);
122617
- if (rawThreadId) {
122618
- deferredTaskRootProbe = probe;
122619
- } else {
122620
- const info = await probe;
122621
- if (info?.msgType === "todo" && info.content) {
122622
- const todo = parseTodoShareContent(info.content);
122623
- if (todo) {
122624
- const probeThreadId = realTopicThreadId(info.threadId);
122625
- taskRootInfo = {
122626
- guid: todo.taskGuid,
122627
- summary: todo.summaryText,
122628
- topicLink: probeThreadId ? buildTopicDeepLink(parsed.chatId, probeThreadId) : void 0
122629
- };
122630
- replyInThread = true;
122631
- replyAnchorId = rootId;
122632
- }
122633
- }
122634
- }
122635
- }
122722
+ if (taskCardAnchorId) {
122723
+ replyInThread = true;
122724
+ replyAnchorId = taskCardAnchorId;
122636
122725
  }
122637
122726
  const prototypeConfig = this.deps.botConfig?.response_surface_prototype;
122638
122727
  const cardKitAvailable = isResponseSurfaceCardKitAvailable(
@@ -122982,12 +123071,17 @@ var BridgeHandler = class {
122982
123071
  }
122983
123072
  }
122984
123073
  }
123074
+ const queuedFollowups = followups.map((f) => {
123075
+ const p = parseMessage(f);
123076
+ return { senderOpenId: p.senderOpenId, text: p.text };
123077
+ });
122985
123078
  if (isAgentWorkspace) {
122986
123079
  await ensureSessionArtifacts({
122987
123080
  sessionPath: worktreePath,
122988
123081
  parsed,
122989
123082
  isNewThread: existing === void 0,
122990
- larkCliProfile: this.deps.larkCliProfile
123083
+ larkCliProfile: this.deps.larkCliProfile,
123084
+ queuedFollowups
122991
123085
  });
122992
123086
  }
122993
123087
  try {
@@ -123104,6 +123198,7 @@ var BridgeHandler = class {
123104
123198
  const prompt = await renderPrompt({
123105
123199
  parsed,
123106
123200
  isNewThread: currentIsNewThread,
123201
+ queuedFollowups,
123107
123202
  conventions: {
123108
123203
  worktreePath,
123109
123204
  runtime: conventions.runtime,
@@ -127029,24 +127124,37 @@ var BotConfigSchema = external_exports.object({
127029
127124
  */
127030
127125
  effort: external_exports.string().min(1).optional(),
127031
127126
  /**
127032
- * docs/larkway-perf-plan.md §4 — opt into a persistent warm process
127033
- * instead of the default one-shot cold-start-per-turn behavior. Takes
127034
- * effect for `backend: "codex"` (a single bot-level `codex app-server`
127035
- * process — src/codex/pool.ts CodexProcessPool) and `backend: "claude"`
127036
- * (one warm process per active thread — src/claude/pool.ts
127037
- * ClaudeProcessPool). Any other backend value is a no-op (main.ts only
127038
- * constructs a pool for these two; botLoader's advisory warn below flags
127039
- * the mismatch upfront). Byte-identical behavior when unset, matching
127040
- * every other perf-plan flag in this schema. `.optional()` (not
127041
- * `.default(false)`, deliberately B1 fix): a `.default()` would make
127042
- * every future `larkway bot` yaml write-out include `warmProcess: false`
127043
- * even when nobody asked for it, and because this schema is `.strict()`
127044
- * would break loading that yaml back with an OLDER larkway build that
127045
- * predates this field. `undefined` and `false` are treated identically at
127046
- * both read sites (main.ts's `bot.warmProcess && …` and the advisory warn
127047
- * below).
127127
+ * docs/larkway-perf-plan.md §4 — persistent warm process instead of a
127128
+ * one-shot cold start per turn. Takes effect for `backend: "codex"` (a
127129
+ * single bot-level `codex app-server` process — src/codex/pool.ts
127130
+ * CodexProcessPool) and `backend: "claude"` (one warm process per active
127131
+ * thread — src/claude/pool.ts ClaudeProcessPool). Any other backend value
127132
+ * is a no-op (main.ts only constructs a pool for these two; botLoader's
127133
+ * advisory warn below flags the mismatch upfront).
127134
+ *
127135
+ * 批D (warm-by-default):
127136
+ * DEFAULT IS NOW ON for the two supported backends — `undefined` means
127137
+ * `true` when backend is codex/claude (see {@link effectiveWarmProcess},
127138
+ * the single source of that rule). Set `warmProcess: false` explicitly to
127139
+ * opt a bot back out. `.optional()` (not `.default(true)`) is still
127140
+ * deliberate: a `.default()` would make every future `larkway bot` yaml
127141
+ * write-out bake the value in, and because this schema is `.strict()` —
127142
+ * would break loading that yaml back with an OLDER larkway build that
127143
+ * predates this field.
127048
127144
  */
127049
127145
  warmProcess: external_exports.boolean().optional(),
127146
+ /**
127147
+ * 批D: pre-warm a BLANK agent process at bridge boot (and keep one on
127148
+ * standby thereafter), so even the FIRST message of a NEW thread skips the
127149
+ * cold start. Only meaningful when the warm pool is on (see
127150
+ * `warmProcess`); default true. backend=claude: ClaudeProcessPool keeps
127151
+ * one blank (no --resume) child matching the bot's static spawn signature,
127152
+ * adopted by the first new-session turn and replenished after adoption.
127153
+ * backend=codex: CodexProcessPool spawns its app-server eagerly at boot
127154
+ * instead of on the first turn. Set false to keep warm pooling but skip
127155
+ * the always-resident standby (saves ~300MB RSS per bot at idle).
127156
+ */
127157
+ prewarmProcess: external_exports.boolean().optional(),
127050
127158
  /**
127051
127159
  * Idle threshold (ms) before a warm process (see `warmProcess` above) with
127052
127160
  * no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
@@ -127064,6 +127172,13 @@ var BotConfigSchema = external_exports.object({
127064
127172
  */
127065
127173
  warmProcessMaxProcesses: external_exports.number().int().positive().optional()
127066
127174
  }).strict();
127175
+ function effectiveWarmProcess(bot) {
127176
+ if (bot.backend !== "claude" && bot.backend !== "codex") return bot.warmProcess === true;
127177
+ return bot.warmProcess !== false;
127178
+ }
127179
+ function effectivePrewarmProcess(bot) {
127180
+ return effectiveWarmProcess(bot) && bot.prewarmProcess !== false;
127181
+ }
127067
127182
  async function loadBots(botsDir) {
127068
127183
  let entries;
127069
127184
  try {
@@ -128337,6 +128452,8 @@ var INTERRUPT_GRACE_MS = 3e3;
128337
128452
  var DEFAULT_TURN_TIMEOUT_MS = 15 * 60 * 1e3;
128338
128453
  var SHUTDOWN_EXIT_WAIT_MS = SIGKILL_GRACE_MS2 + 2e3;
128339
128454
  var STDERR_BUFFER_CAP_BYTES = 64 * 1024;
128455
+ var MAX_PREWARM_FAILURES = 3;
128456
+ var PREWARM_RESPAWN_BACKOFF_MS = 1e4;
128340
128457
  var ClaudeProcessPool = class {
128341
128458
  #botId;
128342
128459
  #botGitIdentity;
@@ -128370,6 +128487,15 @@ var ClaudeProcessPool = class {
128370
128487
  * -killed while that child still lives. Removed in `#onEntryExit`.
128371
128488
  */
128372
128489
  #dying = /* @__PURE__ */ new Set();
128490
+ // -- 批D blank-standby prewarm state ----------------------------------------
128491
+ /** Set by prewarm(); undefined = prewarm never requested (no blank maintenance). */
128492
+ #prewarmProto;
128493
+ /** Consecutive unprompted blank deaths (any age) — resets on a successful adoption. */
128494
+ #prewarmFailures = 0;
128495
+ /** True once the circuit breaker tripped (MAX_PREWARM_FAILURES) — prewarm off until bridge restart. */
128496
+ #prewarmDisabled = false;
128497
+ #prewarmRespawnTimer;
128498
+ #nextBlankId = 1;
128373
128499
  constructor(opts) {
128374
128500
  this.#botId = opts.botId;
128375
128501
  this.#botGitIdentity = opts.botGitIdentity;
@@ -128386,6 +128512,23 @@ var ClaudeProcessPool = class {
128386
128512
  get pidsForTesting() {
128387
128513
  return [...this.#entries.values()].map((e) => e.child.pid).filter((pid) => pid != null);
128388
128514
  }
128515
+ get blankProcessCountForTesting() {
128516
+ return [...this.#entries.values()].filter((e) => e.blank).length;
128517
+ }
128518
+ /**
128519
+ * 批D: start maintaining ONE blank standby child matching `proto`'s spawn
128520
+ * signature — spawned now, adopted by the first new-session turn whose own
128521
+ * options produce the same signature (see PoolEntry.spawnSignature), and
128522
+ * replenished after each adoption. Call once at bridge boot (main.ts),
128523
+ * only for bots whose cwd is static across threads (agent_workspace
128524
+ * runtime); calling it again replaces the proto for future respawns but
128525
+ * never kills an existing blank. No-op after shutdown() or once the
128526
+ * circuit breaker has tripped.
128527
+ */
128528
+ prewarm(proto) {
128529
+ this.#prewarmProto = proto;
128530
+ this.#maybeSpawnBlank();
128531
+ }
128389
128532
  run(opts) {
128390
128533
  const queue = new TurnEventQueue();
128391
128534
  const markPerf = createPerfMarker(opts.onPerfMarker);
@@ -128433,6 +128576,9 @@ var ClaudeProcessPool = class {
128433
128576
  }
128434
128577
  }
128435
128578
  let entry = this.#entries.get(key);
128579
+ if (entry == null && opts.resumeSessionId == null) {
128580
+ entry = this.#adoptBlankEntry(key, threadId, opts);
128581
+ }
128436
128582
  if (entry == null) {
128437
128583
  if (this.#entries.size >= this.#maxProcesses) {
128438
128584
  const victim = this.#pickLruIdleVictim();
@@ -128462,6 +128608,10 @@ var ClaudeProcessPool = class {
128462
128608
  clearInterval(this.#idleSweepTimer);
128463
128609
  this.#idleSweepTimer = void 0;
128464
128610
  }
128611
+ if (this.#prewarmRespawnTimer) {
128612
+ clearTimeout(this.#prewarmRespawnTimer);
128613
+ this.#prewarmRespawnTimer = void 0;
128614
+ }
128465
128615
  const inFlight = [...this.#entries.values()].map((e) => e.current?.done.catch(() => void 0)).filter((p) => p != null);
128466
128616
  await Promise.race([
128467
128617
  Promise.all(inFlight),
@@ -128508,6 +128658,9 @@ var ClaudeProcessPool = class {
128508
128658
  return `${this.#botId}::${threadId}::${cwd}::${model}::${effort}`;
128509
128659
  }
128510
128660
  #pickLruIdleVictim() {
128661
+ for (const entry of this.#entries.values()) {
128662
+ if (entry.current == null && entry.blank) return entry;
128663
+ }
128511
128664
  let victim;
128512
128665
  for (const entry of this.#entries.values()) {
128513
128666
  if (entry.current != null) continue;
@@ -128660,7 +128813,89 @@ var ClaudeProcessPool = class {
128660
128813
  if (state.interruptEscalateTimer) clearTimeout(state.interruptEscalateTimer);
128661
128814
  }
128662
128815
  // -- process lifecycle -------------------------------------------------------
128663
- #spawnEntry(key, threadId, opts) {
128816
+ /**
128817
+ * The spawn identity a set of run/prewarm options resolves to. Built from
128818
+ * the SAME buildWarmCommand path #spawnEntry actually spawns with, so an
128819
+ * adoption match is by construction a spawn-arg-identical process.
128820
+ * `resumeSessionId` is deliberately dropped: adoption is only ever
128821
+ * attempted for turns with no resume (run() guards), and the blank itself
128822
+ * is spawned without one.
128823
+ */
128824
+ #spawnSignatureOf(opts) {
128825
+ const [bin, args] = buildWarmCommand({
128826
+ prompt: "",
128827
+ cwd: opts.cwd,
128828
+ model: opts.model,
128829
+ effort: opts.effort,
128830
+ permissionMode: opts.permissionMode,
128831
+ agentBinPath: opts.agentBinPath
128832
+ });
128833
+ return JSON.stringify([bin, args, opts.cwd ?? null]);
128834
+ }
128835
+ /**
128836
+ * 批D: find an idle blank whose spawn signature matches this turn's options
128837
+ * and rekey it in place onto (key, threadId). Returns undefined when no
128838
+ * blank matches — the caller falls through to a normal spawn. Schedules a
128839
+ * replacement blank on success.
128840
+ */
128841
+ #adoptBlankEntry(key, threadId, opts) {
128842
+ let blank;
128843
+ for (const e of this.#entries.values()) {
128844
+ if (e.blank && !e.destroyed && e.current == null) {
128845
+ blank = e;
128846
+ break;
128847
+ }
128848
+ }
128849
+ if (blank == null) return void 0;
128850
+ const wanted = this.#spawnSignatureOf(opts);
128851
+ if (blank.spawnSignature !== wanted) {
128852
+ console.warn(
128853
+ `[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.`
128854
+ );
128855
+ return void 0;
128856
+ }
128857
+ this.#entries.delete(blank.key);
128858
+ blank.key = key;
128859
+ blank.threadId = threadId;
128860
+ blank.blank = false;
128861
+ blank.lastUsedAt = Date.now();
128862
+ this.#entries.set(key, blank);
128863
+ this.#rewritePidListBestEffort();
128864
+ this.#prewarmFailures = 0;
128865
+ console.warn(
128866
+ `[claude-pool] blank standby pid=${blank.child.pid ?? "?"} adopted by thread=${threadId} \u2014 this turn skips the cold start entirely.`
128867
+ );
128868
+ queueMicrotask(() => this.#maybeSpawnBlank());
128869
+ return blank;
128870
+ }
128871
+ /**
128872
+ * Spawn the standby blank when prewarm is configured and conditions allow:
128873
+ * at most one blank at a time, never past maxProcesses, never while
128874
+ * shutting down, and never after the circuit breaker tripped.
128875
+ */
128876
+ #maybeSpawnBlank() {
128877
+ if (this.#prewarmProto == null || this.#prewarmDisabled || this.#shuttingDown) return;
128878
+ if (this.#prewarmRespawnTimer != null) return;
128879
+ if (this.#entries.size >= this.#maxProcesses) return;
128880
+ for (const e of this.#entries.values()) {
128881
+ if (e.blank) return;
128882
+ }
128883
+ const proto = this.#prewarmProto;
128884
+ const key = `__blank__::${this.#botId}::${this.#nextBlankId++}`;
128885
+ const opts = {
128886
+ prompt: "",
128887
+ cwd: proto.cwd,
128888
+ model: proto.model,
128889
+ effort: proto.effort,
128890
+ permissionMode: proto.permissionMode,
128891
+ agentBinPath: proto.agentBinPath
128892
+ };
128893
+ const entry = this.#spawnEntry(key, key, opts, { blank: true });
128894
+ console.warn(
128895
+ `[claude-pool] pre-warmed blank standby pid=${entry.child.pid ?? "?"} for bot=${this.#botId} (signature=${entry.spawnSignature.slice(0, 120)}\u2026)`
128896
+ );
128897
+ }
128898
+ #spawnEntry(key, threadId, opts, flags) {
128664
128899
  const [bin, args] = buildWarmCommand(opts);
128665
128900
  const env = buildEnv(this.#botGitIdentity, this.#gitlabToken);
128666
128901
  const child = spawn3(bin, args, {
@@ -128672,6 +128907,8 @@ var ClaudeProcessPool = class {
128672
128907
  key,
128673
128908
  threadId,
128674
128909
  cwd: opts.cwd,
128910
+ blank: flags?.blank === true,
128911
+ spawnSignature: this.#spawnSignatureOf(opts),
128675
128912
  child,
128676
128913
  spawnedAt: Date.now(),
128677
128914
  lastUsedAt: Date.now(),
@@ -128737,6 +128974,32 @@ var ClaudeProcessPool = class {
128737
128974
  this.#dying.delete(entry);
128738
128975
  this.#rewritePidListBestEffort();
128739
128976
  void this.#deleteRunnerPidFileIfMine(entry);
128977
+ if (this.#prewarmProto != null && !this.#shuttingDown && !this.#prewarmDisabled) {
128978
+ const unpromptedBlankDeath = entry.blank && !wasAlreadyMarkedDestroyed;
128979
+ if (unpromptedBlankDeath) {
128980
+ this.#prewarmFailures += 1;
128981
+ if (this.#prewarmFailures >= MAX_PREWARM_FAILURES) {
128982
+ this.#prewarmDisabled = true;
128983
+ const stderr = Buffer.concat(entry.stderrChunks).toString("utf8").trim().slice(-2e3);
128984
+ console.warn(
128985
+ `[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:
128986
+ ${stderr}` : "")
128987
+ );
128988
+ } else {
128989
+ const delay = PREWARM_RESPAWN_BACKOFF_MS * 2 ** (this.#prewarmFailures - 1);
128990
+ console.warn(
128991
+ `[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.`
128992
+ );
128993
+ this.#prewarmRespawnTimer = setTimeout(() => {
128994
+ this.#prewarmRespawnTimer = void 0;
128995
+ this.#maybeSpawnBlank();
128996
+ }, delay);
128997
+ this.#prewarmRespawnTimer.unref?.();
128998
+ }
128999
+ } else {
129000
+ queueMicrotask(() => this.#maybeSpawnBlank());
129001
+ }
129002
+ }
128740
129003
  const state = entry.current;
128741
129004
  entry.current = void 0;
128742
129005
  if (state == null || state.settled) return;
@@ -128789,6 +129052,7 @@ ${stderr}` : "")
128789
129052
  const now = Date.now();
128790
129053
  for (const entry of [...this.#entries.values()]) {
128791
129054
  if (entry.current != null) continue;
129055
+ if (entry.blank) continue;
128792
129056
  if (now - entry.lastUsedAt >= this.#idleMs) {
128793
129057
  this.#destroyEntry(entry, "idle timeout");
128794
129058
  }
@@ -129408,6 +129672,19 @@ var CodexProcessPool = class {
129408
129672
  get childPid() {
129409
129673
  return this.#child?.pid ?? void 0;
129410
129674
  }
129675
+ /**
129676
+ * 批D: spawn the app-server eagerly at bridge boot instead of lazily on the
129677
+ * first turn, so even the bot's first message hits a running process.
129678
+ * Idempotent; no-op while shutting down or after the spawn circuit breaker
129679
+ * (#poolDisabled) tripped. Unlike ClaudeProcessPool's blank standby there
129680
+ * is no post-idle-reap replenish here: after the 10-min idle reap the next
129681
+ * turn re-spawns on demand exactly as before (the reap exists to bound the
129682
+ * app-server's per-thread memory growth — auto-respawning would defeat it).
129683
+ */
129684
+ prewarm() {
129685
+ if (this.#shuttingDown || this.#poolDisabled) return;
129686
+ if (this.#child == null) this.#spawnChild();
129687
+ }
129411
129688
  run(opts) {
129412
129689
  const turnKey = this.#nextTurnKey++;
129413
129690
  const queue = new TurnEventQueue();
@@ -132347,7 +132624,8 @@ async function runV2Mode({
132347
132624
  let codexPool;
132348
132625
  let claudePool;
132349
132626
  let runnerKey;
132350
- if (bot.warmProcess && bot.backend === "codex") {
132627
+ const warmProcessOn = effectiveWarmProcess(bot);
132628
+ if (warmProcessOn && bot.backend === "codex") {
132351
132629
  const pidFilePath = path19.join(botDir, "warm-codex.pid");
132352
132630
  try {
132353
132631
  await reapOrphanedWarmProcess(pidFilePath);
@@ -132362,7 +132640,8 @@ async function runV2Mode({
132362
132640
  });
132363
132641
  runnerKey = `codex-pool:${bot.id}`;
132364
132642
  registerRunner(runnerKey, () => codexPool);
132365
- } else if (bot.warmProcess && bot.backend === "claude") {
132643
+ if (effectivePrewarmProcess(bot)) codexPool.prewarm();
132644
+ } else if (warmProcessOn && bot.backend === "claude") {
132366
132645
  const pidListFilePath = path19.join(botDir, "warm-claude.pids.json");
132367
132646
  try {
132368
132647
  await reapOrphanedWarmClaudeProcesses(pidListFilePath);
@@ -132379,6 +132658,18 @@ async function runV2Mode({
132379
132658
  });
132380
132659
  runnerKey = `claude-pool:${bot.id}`;
132381
132660
  registerRunner(runnerKey, () => claudePool);
132661
+ if (effectivePrewarmProcess(bot) && bot.runtime === "agent_workspace" && agentWorkspacePath) {
132662
+ claudePool.prewarm({
132663
+ cwd: agentWorkspacePath,
132664
+ model: bot.model,
132665
+ effort: bot.effort,
132666
+ // Mirrors handler.ts's own default ("bypassPermissions" when
132667
+ // config.json leaves permissions.mode unset). If these two ever
132668
+ // drift the blank's signature just stops matching — fail-safe, and
132669
+ // the pool logs the mismatch on every missed adoption.
132670
+ permissionMode: configJson.permissions.mode ?? "bypassPermissions"
132671
+ });
132672
+ }
132382
132673
  }
132383
132674
  const handler = new BridgeHandler({
132384
132675
  client,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.41",
3
+ "version": "0.3.43",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",