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 +1 -1
- package/README.zh.md +1 -1
- package/dist/main.js +199 -16
- package/package.json +1 -1
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.
|
|
11
|
+
**Current release: v0.3.33**
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
package/README.zh.md
CHANGED
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
|
-
|
|
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 (
|
|
119121
|
-
|
|
119122
|
-
|
|
119123
|
-
|
|
119124
|
-
|
|
119125
|
-
|
|
119126
|
-
|
|
119127
|
-
|
|
119128
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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);
|