larkway 0.3.42 → 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.42**
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.42**
11
+ **当前版本:v0.3.43**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -126104,24 +126104,37 @@ var BotConfigSchema = external_exports.object({
126104
126104
  */
126105
126105
  effort: external_exports.string().min(1).optional(),
126106
126106
  /**
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).
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.
126123
126124
  */
126124
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(),
126125
126138
  /**
126126
126139
  * Idle threshold (ms) before a warm process (see `warmProcess` above) with
126127
126140
  * no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
package/dist/main.js CHANGED
@@ -119390,9 +119390,20 @@ async function renderPrompt(input) {
119390
119390
  taskHandleClaimed = false,
119391
119391
  taskHandleCandidates = [],
119392
119392
  taskRoot,
119393
- mtimeFacts = []
119393
+ mtimeFacts = [],
119394
+ queuedFollowups = []
119394
119395
  } = input;
119395
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
+ ];
119396
119407
  const profileFlag = larkCliProfile ? ` --profile ${larkCliProfile}` : "";
119397
119408
  const attachmentKeys = parsed.attachments.map((a) => a.fileKey);
119398
119409
  const imageKeys = parsed.attachments.filter((a) => a.fileType === "image").map((a) => a.fileKey);
@@ -119524,9 +119535,7 @@ async function renderPrompt(input) {
119524
119535
  ...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
119525
119536
  ...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
119526
119537
  "",
119527
- "<user-message>",
119528
- `${parsed.senderOpenId}: ${parsed.text}`,
119529
- "</user-message>"
119538
+ ...userMessageLines
119530
119539
  ].join("\n");
119531
119540
  }
119532
119541
  return [
@@ -119581,9 +119590,7 @@ async function renderPrompt(input) {
119581
119590
  ...taskHandleBlock.length > 0 ? ["", ...taskHandleBlock] : [],
119582
119591
  ...taskRootBlock.length > 0 ? ["", ...taskRootBlock] : [],
119583
119592
  "",
119584
- "<user-message>",
119585
- `${parsed.senderOpenId}: ${parsed.text}`,
119586
- "</user-message>"
119593
+ ...userMessageLines
119587
119594
  ].join("\n");
119588
119595
  }
119589
119596
 
@@ -119770,6 +119777,12 @@ function renderTranscriptEntry(input) {
119770
119777
  "",
119771
119778
  indentBlock(parsed.text),
119772
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
+ ] : [],
119773
119786
  "### Feishu Doc Links",
119774
119787
  "",
119775
119788
  ...renderList(parsed.feishuDocLinks),
@@ -122149,6 +122162,20 @@ async function isWorktreeGitHealthy(worktreePath) {
122149
122162
  child.on("error", () => resolve2(false));
122150
122163
  });
122151
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
+ }
122152
122179
  var repoCloneLocks = /* @__PURE__ */ new Map();
122153
122180
  function ensureRepoClone(basePath, url, token, label) {
122154
122181
  const prev = repoCloneLocks.get(basePath) ?? Promise.resolve();
@@ -122498,6 +122525,7 @@ var BridgeHandler = class {
122498
122525
  async run(opts) {
122499
122526
  const signal = opts?.abortSignal;
122500
122527
  const threadQueues = /* @__PURE__ */ new Map();
122528
+ const pendingByKey = /* @__PURE__ */ new Map();
122501
122529
  const MAX_CONCURRENT = 5;
122502
122530
  let running = 0;
122503
122531
  const waiters = [];
@@ -122523,12 +122551,33 @@ var BridgeHandler = class {
122523
122551
  this.threadReceivedAt.set(key, Date.now());
122524
122552
  const legacySessionKey = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
122525
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);
122526
122560
  const prev = threadQueues.get(key) ?? Promise.resolve();
122527
- 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) => {
122528
122576
  console.error(`[bridge.handler] unhandled error on thread ${key}:`, err);
122529
122577
  }).finally(() => {
122530
122578
  release();
122531
122579
  if (threadQueues.get(key) === next) threadQueues.delete(key);
122580
+ if ((pendingByKey.get(key)?.length ?? 0) === 0) pendingByKey.delete(key);
122532
122581
  this.inFlightTurns.delete(next);
122533
122582
  });
122534
122583
  this.inFlightTurns.add(next);
@@ -122545,7 +122594,14 @@ var BridgeHandler = class {
122545
122594
  // ---------------------------------------------------------------------------
122546
122595
  // Private: single-event lifecycle
122547
122596
  // ---------------------------------------------------------------------------
122548
- 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 = []) {
122549
122605
  const settleMessageId = event.message_id;
122550
122606
  let settled = false;
122551
122607
  let agentRunCompleted = false;
@@ -122555,8 +122611,10 @@ var BridgeHandler = class {
122555
122611
  const settle = (ok) => {
122556
122612
  if (settled) return;
122557
122613
  settled = true;
122558
- if (ok) this.deps.client.markHandled?.(settleMessageId);
122559
- 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
+ }
122560
122618
  };
122561
122619
  try {
122562
122620
  const parsed = parseMessage(event);
@@ -122626,6 +122684,27 @@ var BridgeHandler = class {
122626
122684
  reason: "\u5DF2\u8FDB\u5165 bridge\uFF0C\u51C6\u5907\u521B\u5EFA\u5904\u7406\u5361\u7247\u3002"
122627
122685
  });
122628
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
+ }
122629
122708
  const invokeTaskHandleLifecycle = async (fields) => {
122630
122709
  if (!this.deps.taskHandleLifecycle || !botId) return;
122631
122710
  try {
@@ -122992,12 +123071,17 @@ var BridgeHandler = class {
122992
123071
  }
122993
123072
  }
122994
123073
  }
123074
+ const queuedFollowups = followups.map((f) => {
123075
+ const p = parseMessage(f);
123076
+ return { senderOpenId: p.senderOpenId, text: p.text };
123077
+ });
122995
123078
  if (isAgentWorkspace) {
122996
123079
  await ensureSessionArtifacts({
122997
123080
  sessionPath: worktreePath,
122998
123081
  parsed,
122999
123082
  isNewThread: existing === void 0,
123000
- larkCliProfile: this.deps.larkCliProfile
123083
+ larkCliProfile: this.deps.larkCliProfile,
123084
+ queuedFollowups
123001
123085
  });
123002
123086
  }
123003
123087
  try {
@@ -123114,6 +123198,7 @@ var BridgeHandler = class {
123114
123198
  const prompt = await renderPrompt({
123115
123199
  parsed,
123116
123200
  isNewThread: currentIsNewThread,
123201
+ queuedFollowups,
123117
123202
  conventions: {
123118
123203
  worktreePath,
123119
123204
  runtime: conventions.runtime,
@@ -127039,24 +127124,37 @@ var BotConfigSchema = external_exports.object({
127039
127124
  */
127040
127125
  effort: external_exports.string().min(1).optional(),
127041
127126
  /**
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).
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.
127058
127144
  */
127059
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(),
127060
127158
  /**
127061
127159
  * Idle threshold (ms) before a warm process (see `warmProcess` above) with
127062
127160
  * no in-flight turn gets SIGTERM'd. Only meaningful when `warmProcess` is
@@ -127074,6 +127172,13 @@ var BotConfigSchema = external_exports.object({
127074
127172
  */
127075
127173
  warmProcessMaxProcesses: external_exports.number().int().positive().optional()
127076
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
+ }
127077
127182
  async function loadBots(botsDir) {
127078
127183
  let entries;
127079
127184
  try {
@@ -128347,6 +128452,8 @@ var INTERRUPT_GRACE_MS = 3e3;
128347
128452
  var DEFAULT_TURN_TIMEOUT_MS = 15 * 60 * 1e3;
128348
128453
  var SHUTDOWN_EXIT_WAIT_MS = SIGKILL_GRACE_MS2 + 2e3;
128349
128454
  var STDERR_BUFFER_CAP_BYTES = 64 * 1024;
128455
+ var MAX_PREWARM_FAILURES = 3;
128456
+ var PREWARM_RESPAWN_BACKOFF_MS = 1e4;
128350
128457
  var ClaudeProcessPool = class {
128351
128458
  #botId;
128352
128459
  #botGitIdentity;
@@ -128380,6 +128487,15 @@ var ClaudeProcessPool = class {
128380
128487
  * -killed while that child still lives. Removed in `#onEntryExit`.
128381
128488
  */
128382
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;
128383
128499
  constructor(opts) {
128384
128500
  this.#botId = opts.botId;
128385
128501
  this.#botGitIdentity = opts.botGitIdentity;
@@ -128396,6 +128512,23 @@ var ClaudeProcessPool = class {
128396
128512
  get pidsForTesting() {
128397
128513
  return [...this.#entries.values()].map((e) => e.child.pid).filter((pid) => pid != null);
128398
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
+ }
128399
128532
  run(opts) {
128400
128533
  const queue = new TurnEventQueue();
128401
128534
  const markPerf = createPerfMarker(opts.onPerfMarker);
@@ -128443,6 +128576,9 @@ var ClaudeProcessPool = class {
128443
128576
  }
128444
128577
  }
128445
128578
  let entry = this.#entries.get(key);
128579
+ if (entry == null && opts.resumeSessionId == null) {
128580
+ entry = this.#adoptBlankEntry(key, threadId, opts);
128581
+ }
128446
128582
  if (entry == null) {
128447
128583
  if (this.#entries.size >= this.#maxProcesses) {
128448
128584
  const victim = this.#pickLruIdleVictim();
@@ -128472,6 +128608,10 @@ var ClaudeProcessPool = class {
128472
128608
  clearInterval(this.#idleSweepTimer);
128473
128609
  this.#idleSweepTimer = void 0;
128474
128610
  }
128611
+ if (this.#prewarmRespawnTimer) {
128612
+ clearTimeout(this.#prewarmRespawnTimer);
128613
+ this.#prewarmRespawnTimer = void 0;
128614
+ }
128475
128615
  const inFlight = [...this.#entries.values()].map((e) => e.current?.done.catch(() => void 0)).filter((p) => p != null);
128476
128616
  await Promise.race([
128477
128617
  Promise.all(inFlight),
@@ -128518,6 +128658,9 @@ var ClaudeProcessPool = class {
128518
128658
  return `${this.#botId}::${threadId}::${cwd}::${model}::${effort}`;
128519
128659
  }
128520
128660
  #pickLruIdleVictim() {
128661
+ for (const entry of this.#entries.values()) {
128662
+ if (entry.current == null && entry.blank) return entry;
128663
+ }
128521
128664
  let victim;
128522
128665
  for (const entry of this.#entries.values()) {
128523
128666
  if (entry.current != null) continue;
@@ -128670,7 +128813,89 @@ var ClaudeProcessPool = class {
128670
128813
  if (state.interruptEscalateTimer) clearTimeout(state.interruptEscalateTimer);
128671
128814
  }
128672
128815
  // -- process lifecycle -------------------------------------------------------
128673
- #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) {
128674
128899
  const [bin, args] = buildWarmCommand(opts);
128675
128900
  const env = buildEnv(this.#botGitIdentity, this.#gitlabToken);
128676
128901
  const child = spawn3(bin, args, {
@@ -128682,6 +128907,8 @@ var ClaudeProcessPool = class {
128682
128907
  key,
128683
128908
  threadId,
128684
128909
  cwd: opts.cwd,
128910
+ blank: flags?.blank === true,
128911
+ spawnSignature: this.#spawnSignatureOf(opts),
128685
128912
  child,
128686
128913
  spawnedAt: Date.now(),
128687
128914
  lastUsedAt: Date.now(),
@@ -128747,6 +128974,32 @@ var ClaudeProcessPool = class {
128747
128974
  this.#dying.delete(entry);
128748
128975
  this.#rewritePidListBestEffort();
128749
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
+ }
128750
129003
  const state = entry.current;
128751
129004
  entry.current = void 0;
128752
129005
  if (state == null || state.settled) return;
@@ -128799,6 +129052,7 @@ ${stderr}` : "")
128799
129052
  const now = Date.now();
128800
129053
  for (const entry of [...this.#entries.values()]) {
128801
129054
  if (entry.current != null) continue;
129055
+ if (entry.blank) continue;
128802
129056
  if (now - entry.lastUsedAt >= this.#idleMs) {
128803
129057
  this.#destroyEntry(entry, "idle timeout");
128804
129058
  }
@@ -129418,6 +129672,19 @@ var CodexProcessPool = class {
129418
129672
  get childPid() {
129419
129673
  return this.#child?.pid ?? void 0;
129420
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
+ }
129421
129688
  run(opts) {
129422
129689
  const turnKey = this.#nextTurnKey++;
129423
129690
  const queue = new TurnEventQueue();
@@ -132357,7 +132624,8 @@ async function runV2Mode({
132357
132624
  let codexPool;
132358
132625
  let claudePool;
132359
132626
  let runnerKey;
132360
- if (bot.warmProcess && bot.backend === "codex") {
132627
+ const warmProcessOn = effectiveWarmProcess(bot);
132628
+ if (warmProcessOn && bot.backend === "codex") {
132361
132629
  const pidFilePath = path19.join(botDir, "warm-codex.pid");
132362
132630
  try {
132363
132631
  await reapOrphanedWarmProcess(pidFilePath);
@@ -132372,7 +132640,8 @@ async function runV2Mode({
132372
132640
  });
132373
132641
  runnerKey = `codex-pool:${bot.id}`;
132374
132642
  registerRunner(runnerKey, () => codexPool);
132375
- } else if (bot.warmProcess && bot.backend === "claude") {
132643
+ if (effectivePrewarmProcess(bot)) codexPool.prewarm();
132644
+ } else if (warmProcessOn && bot.backend === "claude") {
132376
132645
  const pidListFilePath = path19.join(botDir, "warm-claude.pids.json");
132377
132646
  try {
132378
132647
  await reapOrphanedWarmClaudeProcesses(pidListFilePath);
@@ -132389,6 +132658,18 @@ async function runV2Mode({
132389
132658
  });
132390
132659
  runnerKey = `claude-pool:${bot.id}`;
132391
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
+ }
132392
132673
  }
132393
132674
  const handler = new BridgeHandler({
132394
132675
  client,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.42",
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)",