larkway 0.3.31 → 0.3.33

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.30**
11
+ **Current release: v0.3.33**
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.30**
11
+ **当前版本:v0.3.33**
12
12
 
13
13
  ---
14
14
 
package/dist/main.js CHANGED
@@ -118306,6 +118306,84 @@ function renderPrompt(input) {
118306
118306
  ].join("\n");
118307
118307
  }
118308
118308
 
118309
+ // src/lark/rosterResolver.ts
118310
+ import { execFile as execFile2 } from "node:child_process";
118311
+ function parseBotRoster(stdout) {
118312
+ const roster = /* @__PURE__ */ new Map();
118313
+ let json2;
118314
+ try {
118315
+ json2 = JSON.parse(stdout);
118316
+ } catch {
118317
+ return roster;
118318
+ }
118319
+ const items = json2?.data?.items;
118320
+ if (!Array.isArray(items)) return roster;
118321
+ for (const item of items) {
118322
+ if (typeof item !== "object" || item === null) continue;
118323
+ const record = item;
118324
+ const name = record["bot_name"];
118325
+ const id = record["bot_id"];
118326
+ if (typeof name === "string" && name && typeof id === "string" && id) {
118327
+ roster.set(name, id);
118328
+ }
118329
+ }
118330
+ return roster;
118331
+ }
118332
+ function remapPeersToLiveRoster(peers, liveRoster) {
118333
+ const remapped = [];
118334
+ const unresolved = [];
118335
+ const out = peers.map((peer) => {
118336
+ const liveId = liveRoster.get(peer.name);
118337
+ if (!liveId) {
118338
+ unresolved.push(peer.name);
118339
+ return peer;
118340
+ }
118341
+ if (liveId !== peer.id) {
118342
+ remapped.push(peer.name);
118343
+ return { ...peer, id: liveId };
118344
+ }
118345
+ return peer;
118346
+ });
118347
+ return { peers: out, remapped, unresolved };
118348
+ }
118349
+ async function resolveChatBotRoster(chatId, opts = {}) {
118350
+ const cli = opts.larkCliPath ?? "lark-cli";
118351
+ const args = ["im", "chat.members", "bots", "--chat-id", chatId, "--as", "bot"];
118352
+ if (opts.profile) args.push("--profile", opts.profile);
118353
+ const exec = opts.exec ?? defaultExec(opts.timeoutMs ?? 1e4);
118354
+ try {
118355
+ const stdout = await exec(cli, args);
118356
+ const roster = parseBotRoster(stdout);
118357
+ return roster.size > 0 ? roster : null;
118358
+ } catch {
118359
+ return null;
118360
+ }
118361
+ }
118362
+ function defaultExec(timeoutMs) {
118363
+ return (cmd, args) => new Promise((resolve2, reject) => {
118364
+ execFile2(cmd, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (err, stdout) => {
118365
+ if (err) reject(err);
118366
+ else resolve2(stdout);
118367
+ });
118368
+ });
118369
+ }
118370
+ function createCachedRosterResolver(opts) {
118371
+ const ttlMs = opts.ttlMs ?? 5 * 60 * 1e3;
118372
+ const now = opts.now ?? (() => Date.now());
118373
+ const cache = /* @__PURE__ */ new Map();
118374
+ return async (chatId) => {
118375
+ const hit = cache.get(chatId);
118376
+ if (hit && now() - hit.at < ttlMs) return hit.roster;
118377
+ const roster = await resolveChatBotRoster(chatId, {
118378
+ profile: opts.profile,
118379
+ larkCliPath: opts.larkCliPath,
118380
+ exec: opts.exec
118381
+ });
118382
+ cache.set(chatId, { roster, at: now() });
118383
+ return roster;
118384
+ };
118385
+ }
118386
+
118309
118387
  // src/agent/runner.ts
118310
118388
  var _registry = /* @__PURE__ */ new Map();
118311
118389
  function registerRunner(backend, factory) {
@@ -119055,7 +119133,30 @@ var StateFileSchema = external_exports.object({
119055
119133
  * ignored and the legacy card path remains authoritative.
119056
119134
  */
119057
119135
  response_surface: ResponseSurfaceStateSchema.optional(),
119058
- updated_at: external_exports.string()
119136
+ /**
119137
+ * Freshness signal for the handler's stale-guard (it compares this against a
119138
+ * pre-run snapshot to decide whether the bot rewrote state THIS turn).
119139
+ *
119140
+ * **OPTIONAL by thin-channel principle.** This used to be the ONE hard-required
119141
+ * field, so a bot that wrote a perfectly good `status` + `last_message` but
119142
+ * omitted `updated_at` had its ENTIRE state discarded → the card stranded on
119143
+ * "本轮没有拿到 agent 的新回复" every single turn (2026-07-02 排障: one shared
119144
+ * topic reproduced this on every @). Same failure class as the old
119145
+ * `z.string().url()` mr_url and the strict `stage`/`card_color` enums.
119146
+ *
119147
+ * When absent, {@link readStateFileDetailed} server-stamps it from the file's
119148
+ * mtime — which advances only on a real rewrite, so the stale-guard stays
119149
+ * correct even for bots that never write the field.
119150
+ */
119151
+ updated_at: external_exports.string().optional()
119152
+ });
119153
+ var SalvageStateSchema = external_exports.object({
119154
+ status: external_exports.enum(["in_progress", "ready", "failed"]),
119155
+ last_message: external_exports.string().optional().catch(void 0),
119156
+ error: external_exports.string().optional().catch(void 0),
119157
+ card_title: external_exports.string().optional().catch(void 0),
119158
+ card_text: external_exports.string().optional().catch(void 0),
119159
+ updated_at: external_exports.string().optional().catch(void 0)
119059
119160
  });
119060
119161
  function stateDirOf(worktreePath) {
119061
119162
  return path6.join(worktreePath, ".larkway");
@@ -119093,6 +119194,11 @@ async function readStateFileDetailed(worktreePath) {
119093
119194
  console.warn(`[stateFile] read ${file} failed:`, err);
119094
119195
  return { state: null, diagnostics: [] };
119095
119196
  }
119197
+ let mtimeIso;
119198
+ try {
119199
+ mtimeIso = (await fs3.stat(file)).mtime.toISOString();
119200
+ } catch {
119201
+ }
119096
119202
  let parsed;
119097
119203
  try {
119098
119204
  parsed = JSON.parse(raw);
@@ -119117,18 +119223,39 @@ async function readStateFileDetailed(worktreePath) {
119117
119223
  }
119118
119224
  }
119119
119225
  const result = StateFileSchema.safeParse(parsed);
119120
- if (!result.success) {
119121
- console.warn(
119122
- `[stateFile] ${file} failed schema validation:`,
119123
- result.error.issues
119124
- );
119125
- return { state: null, diagnostics: [] };
119126
- }
119127
- const diagnostics = diagnoseStateFile(parsed, result.data);
119128
- for (const diagnostic of diagnostics) {
119226
+ if (result.success) {
119227
+ const state = withStampedUpdatedAt(result.data, mtimeIso);
119228
+ const diagnostics = diagnoseStateFile(parsed, result.data);
119229
+ for (const diagnostic of diagnostics) {
119230
+ console.warn(`[stateFile] ${file}: ${diagnostic}`);
119231
+ }
119232
+ return { state, diagnostics };
119233
+ }
119234
+ const salvaged = SalvageStateSchema.safeParse(parsed);
119235
+ if (salvaged.success) {
119236
+ const droppedFields = summarizeRejectedTopFields(result.error);
119237
+ const state = withStampedUpdatedAt(salvaged.data, mtimeIso);
119238
+ const diagnostic = `state.json \u90E8\u5206\u5B57\u6BB5\u65E0\u6548\u5DF2\u5FFD\u7565\uFF0C\u4FDD\u7559 status/last_message\uFF08\u53D7\u5F71\u54CD\u5B57\u6BB5\uFF1A${droppedFields}\uFF09`;
119129
119239
  console.warn(`[stateFile] ${file}: ${diagnostic}`);
119240
+ return { state, diagnostics: [diagnostic] };
119241
+ }
119242
+ console.warn(
119243
+ `[stateFile] ${file} failed schema validation (unsalvageable):`,
119244
+ result.error.issues
119245
+ );
119246
+ return { state: null, diagnostics: [] };
119247
+ }
119248
+ function withStampedUpdatedAt(state, mtimeIso) {
119249
+ if (state.updated_at != null && state.updated_at !== "") return state;
119250
+ return { ...state, updated_at: mtimeIso ?? (/* @__PURE__ */ new Date()).toISOString() };
119251
+ }
119252
+ function summarizeRejectedTopFields(error) {
119253
+ const fields = /* @__PURE__ */ new Set();
119254
+ for (const issue of error.issues) {
119255
+ const top = issue.path[0];
119256
+ fields.add(top === void 0 ? "(root)" : String(top));
119130
119257
  }
119131
- return { state: result.data, diagnostics };
119258
+ return [...fields].join(", ") || "(unknown)";
119132
119259
  }
119133
119260
  function diagnoseStateFile(parsed, state) {
119134
119261
  if (parsed === null || typeof parsed !== "object") return [];
@@ -120132,7 +120259,7 @@ async function ensureNodeModules(worktreePath) {
120132
120259
  if (await pathExists(modulesMarker)) return;
120133
120260
  const start = Date.now();
120134
120261
  try {
120135
- await execFile2(
120262
+ await execFile3(
120136
120263
  "pnpm",
120137
120264
  ["install", "--offline", "--frozen-lockfile"],
120138
120265
  { cwd: monorepDir, timeoutMs: 18e4 }
@@ -120146,7 +120273,7 @@ async function ensureNodeModules(worktreePath) {
120146
120273
  `[bridge.handler] --offline install failed (will fall back to network): ${offlineErr.message.slice(0, 200)}`
120147
120274
  );
120148
120275
  }
120149
- await execFile2(
120276
+ await execFile3(
120150
120277
  "pnpm",
120151
120278
  ["install", "--frozen-lockfile"],
120152
120279
  { cwd: monorepDir, timeoutMs: 6e5 }
@@ -120155,7 +120282,7 @@ async function ensureNodeModules(worktreePath) {
120155
120282
  `[bridge.handler] pnpm install (network) ok (${((Date.now() - start) / 1e3).toFixed(1)}s) in ${monorepDir}`
120156
120283
  );
120157
120284
  }
120158
- function execFile2(cmd, args, opts) {
120285
+ function execFile3(cmd, args, opts) {
120159
120286
  return new Promise((resolve2, reject) => {
120160
120287
  const child = child_process.spawn(cmd, args, {
120161
120288
  cwd: opts.cwd,
@@ -120223,9 +120350,30 @@ async function createOnlyPostFallback(opts) {
120223
120350
  var BridgeHandler = class {
120224
120351
  deps;
120225
120352
  closed = false;
120353
+ /**
120354
+ * In-flight per-turn completion promises. run() dispatches handleOne
120355
+ * fire-and-forget (thread-serialized), so run() returning does NOT mean the
120356
+ * turn finished. Each entry resolves only when its handleOne AND all trailing
120357
+ * work (terminal render + ledger writes, incl. the failure-path fallback that
120358
+ * runs after the early settle) have completed. {@link whenAllTurnsSettled}
120359
+ * awaits these — the real turn boundary tests must sync assertions/cleanup on.
120360
+ */
120361
+ inFlightTurns = /* @__PURE__ */ new Set();
120226
120362
  constructor(deps) {
120227
120363
  this.deps = deps;
120228
120364
  }
120365
+ /**
120366
+ * Resolves when every dispatched turn has fully completed (handleOne returned
120367
+ * and its trailing render/ledger I/O drained). Test-facing sync point; a bridge
120368
+ * that keeps receiving events would keep finding work, so callers use this
120369
+ * after the event source is exhausted (e.g. right after `await run()` in a
120370
+ * single-message test).
120371
+ */
120372
+ async whenAllTurnsSettled() {
120373
+ while (this.inFlightTurns.size > 0) {
120374
+ await Promise.all([...this.inFlightTurns]);
120375
+ }
120376
+ }
120229
120377
  runtimeWarnings() {
120230
120378
  return (this.deps.runtimeRequirements ?? []).filter(
120231
120379
  (req) => !req.ok && (req.severity === "required" || req.kind === "secret")
@@ -120276,7 +120424,9 @@ var BridgeHandler = class {
120276
120424
  }).finally(() => {
120277
120425
  release();
120278
120426
  if (threadQueues.get(key) === next) threadQueues.delete(key);
120427
+ this.inFlightTurns.delete(next);
120279
120428
  });
120429
+ this.inFlightTurns.add(next);
120280
120430
  threadQueues.set(key, next);
120281
120431
  }
120282
120432
  }
@@ -120659,6 +120809,34 @@ var BridgeHandler = class {
120659
120809
  while (true) {
120660
120810
  attempt++;
120661
120811
  const currentIsNewThread = currentExisting === void 0;
120812
+ let effectivePeers = this.deps.peers;
120813
+ if (effectivePeers?.length && this.deps.resolveLiveRoster) {
120814
+ try {
120815
+ const liveRoster = await this.deps.resolveLiveRoster(parsed.chatId);
120816
+ if (liveRoster) {
120817
+ const { peers: remappedPeers, remapped, unresolved } = remapPeersToLiveRoster(
120818
+ effectivePeers,
120819
+ liveRoster
120820
+ );
120821
+ effectivePeers = remappedPeers;
120822
+ if (remapped.length > 0 || unresolved.length > 0) {
120823
+ await recordEvent({
120824
+ status: "running",
120825
+ appendPath: "peer roster",
120826
+ reason: `live-roster resolve: remapped [${remapped.join(", ") || "none"}] to same-app-scope open_id; unresolved (kept static config id, may not be deliverable) [${unresolved.join(", ") || "none"}].`
120827
+ });
120828
+ }
120829
+ } else {
120830
+ await recordEvent({
120831
+ status: "running",
120832
+ appendPath: "peer roster",
120833
+ reason: "live-roster resolve returned nothing; kept static <peer-bots> open_ids (may be cross-app-scope / undeliverable)."
120834
+ });
120835
+ }
120836
+ } catch (err) {
120837
+ console.warn("[bridge.handler] live-roster resolve failed (using static peers):", err);
120838
+ }
120839
+ }
120662
120840
  const prompt = renderPrompt({
120663
120841
  parsed,
120664
120842
  isNewThread: currentIsNewThread,
@@ -120680,7 +120858,7 @@ var BridgeHandler = class {
120680
120858
  readOnly: conventions.readOnly,
120681
120859
  gitlabTokenEnvName: conventions.gitlabTokenEnvName
120682
120860
  },
120683
- peers: this.deps.peers,
120861
+ peers: effectivePeers,
120684
120862
  turn_taking_limit: this.deps.botConfig?.turn_taking_limit,
120685
120863
  botName: this.deps.botConfig?.name,
120686
120864
  backend: this.deps.botConfig?.backend,
@@ -120823,7 +121001,9 @@ var BridgeHandler = class {
120823
121001
  });
120824
121002
  }
120825
121003
  const fallbackAnswer = trustedAnswerText.trim() || cardKitProgress?.answerText.trim() || "";
120826
- const cardBody = cardKitTimeoutFailure ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\uFF0C\u672A\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\u3002" : reportedState?.last_message ?? (fallbackAnswer ? fallbackAnswer : "\u26A0\uFE0F \u672C\u8F6E\u6CA1\u6709\u62FF\u5230 agent \u7684\u65B0\u56DE\u590D(\u53EF\u80FD\u88AB\u4E2D\u65AD\u6216\u672A\u66F4\u65B0\u72B6\u6001),\u518D @ \u6211\u4E00\u6B21\u91CD\u8BD5\u3002");
121004
+ const noOutputFallback = "\u26A0\uFE0F \u672C\u8F6E agent \u6CA1\u6709\u4EA7\u51FA\u6B63\u6587\uFF0C\u4E5F\u6CA1\u6709\u5199\u5165\u6709\u6548\u72B6\u6001(state.json)\u3002\n" + (result.exitCode !== 0 ? `agent \u8FDB\u7A0B\u5F02\u5E38\u9000\u51FA(exit code ${result.exitCode})\u3002
121005
+ ` : "") + "\u4E0B\u4E00\u6B65\uFF1A\u6362\u4E2A\u8BF4\u6CD5\u91CD\u8BD5\uFF0C\u6216\u65B0\u5F00\u4E00\u4E2A\u8BDD\u9898\u7EE7\u7EED\uFF1B\u82E5\u53CD\u590D\u5982\u6B64\uFF0C\u8BF7\u8BA9\u7EF4\u62A4\u8005\u67E5\u770B\u8BE5 session \u65E5\u5FD7\u3002";
121006
+ const cardBody = cardKitTimeoutFailure ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\uFF0C\u672A\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\u3002" : reportedState?.last_message ?? (fallbackAnswer ? fallbackAnswer : noOutputFallback);
120827
121007
  const noReportThisTurn = reportedState === null;
120828
121008
  const neutralTitle = noReportThisTurn && success && !failureReason ? "\u{1F4AC} \u5DF2\u56DE\u590D" : void 0;
120829
121009
  const baseCardPayload = {
@@ -126297,6 +126477,9 @@ async function runV2Mode({
126297
126477
  gitlabToken: effectiveGitlabToken,
126298
126478
  agentMemory: bot.agent_memory,
126299
126479
  larkCliProfile,
126480
+ // PRB-6/§11.3: resolve peer @ targets to same-app-scope open_ids from the
126481
+ // live chat roster (per-chat cached), only when this bot actually has peers.
126482
+ resolveLiveRoster: resolvedPeers.length > 0 ? createCachedRosterResolver({ profile: larkCliProfile }) : void 0,
126300
126483
  runtimeRequirements: runtimeRequirementsForBots([bot]),
126301
126484
  recordRuntimeEvent: async (patch) => {
126302
126485
  await upsertRuntimeEvent(larkwayHome(), bot.id, patch);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.31",
3
+ "version": "0.3.33",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",