larkway 0.3.46 → 0.3.47

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.46**
11
+ **Current release: v0.3.47**
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.46**
11
+ **当前版本:v0.3.47**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -131387,7 +131387,11 @@ var execFileAsync7 = promisify8(execFileCallback2);
131387
131387
  var CHAT_NAME_CACHE_MS = 5 * 60 * 1e3;
131388
131388
  var chatNameCache = /* @__PURE__ */ new Map();
131389
131389
  var eventExecFile = execFileAsync7;
131390
+ var updateExecFile = execFileAsync7;
131390
131391
  var LARKWAY_VERSION = resolveLarkwayVersion(import.meta.url, "0.0.0");
131392
+ var UPDATE_CHECK_TIMEOUT_MS = 5e3;
131393
+ var UPDATE_RUN_TIMEOUT_MS = 5 * 60 * 1e3;
131394
+ var UPDATE_MAX_BUFFER = 2 * 1024 * 1024;
131391
131395
  function createManagementContext(opts = {}) {
131392
131396
  const stores = {
131393
131397
  botsStore: opts.stores?.botsStore ?? botsStore_exports,
@@ -131449,6 +131453,174 @@ var BOT_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
131449
131453
  function badBotId(id) {
131450
131454
  return BOT_ID_RE.test(id) ? null : { status: 400, json: { error: `\u975E\u6CD5\u7684\u52A9\u624B id "${id}"` } };
131451
131455
  }
131456
+ function firstVersion(text) {
131457
+ return text.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/)?.[0] ?? null;
131458
+ }
131459
+ function compareVersion(a, b) {
131460
+ const aa = a.split(/[+-]/)[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
131461
+ const bb = b.split(/[+-]/)[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
131462
+ for (let i = 0; i < 3; i++) {
131463
+ if ((aa[i] ?? 0) !== (bb[i] ?? 0)) return (aa[i] ?? 0) - (bb[i] ?? 0);
131464
+ }
131465
+ return 0;
131466
+ }
131467
+ async function readGlobalLarkwayVersion() {
131468
+ try {
131469
+ const { stdout: stdout2, stderr } = await updateExecFile("larkway", ["--version"], {
131470
+ timeout: UPDATE_CHECK_TIMEOUT_MS,
131471
+ maxBuffer: UPDATE_MAX_BUFFER
131472
+ });
131473
+ return firstVersion(`${stdout2}
131474
+ ${stderr}`) ?? LARKWAY_VERSION;
131475
+ } catch {
131476
+ return LARKWAY_VERSION;
131477
+ }
131478
+ }
131479
+ async function readLatestLarkwayVersion() {
131480
+ try {
131481
+ const { stdout: stdout2, stderr } = await updateExecFile("npm", ["view", "larkway", "version"], {
131482
+ timeout: UPDATE_CHECK_TIMEOUT_MS,
131483
+ maxBuffer: UPDATE_MAX_BUFFER
131484
+ });
131485
+ return firstVersion(`${stdout2}
131486
+ ${stderr}`);
131487
+ } catch {
131488
+ return null;
131489
+ }
131490
+ }
131491
+ var healthScanExecFile = execFileAsync7;
131492
+ var healthScanBackendLoginProbe = defaultBackendLoginProbe;
131493
+ async function defaultBackendLoginProbe(backend) {
131494
+ try {
131495
+ if (backend === "claude") return await detectClaudeLogin();
131496
+ if (backend === "codex") return await isCodexReady();
131497
+ return null;
131498
+ } catch {
131499
+ return null;
131500
+ }
131501
+ }
131502
+ var HEALTH_SCAN_TTL_MS = 10 * 60 * 1e3;
131503
+ var HEALTH_SCAN_PROBE_TIMEOUT_MS = 1e4;
131504
+ var healthScanCache = /* @__PURE__ */ new Map();
131505
+ var BOT_PERMISSION_BASELINE = [
131506
+ { scope: "im:message", reason: "\u6536\u53D1\u6D88\u606F \u2014\u2014 @ \u89E6\u53D1\u4E0E\u8BDD\u9898\u56DE\u590D\u7684\u6839\u672C" },
131507
+ { scope: "im:message:send_as_bot", reason: "\u4EE5\u5E94\u7528\u8EAB\u4EFD\u53D1\u6D88\u606F \u2014\u2014 \u7B54\u6848\u6295\u9012" },
131508
+ { scope: "im:resource", reason: "\u6D88\u606F\u56FE\u7247/\u9644\u4EF6\u4E0B\u8F7D \u2014\u2014 agent \u8BFB\u7528\u6237\u53D1\u7684\u622A\u56FE\u548C\u6587\u4EF6" },
131509
+ { scope: "im:chat:readonly", reason: "\u7FA4\u4FE1\u606F\u8BFB\u53D6 \u2014\u2014 \u6F0F\u6D88\u606F\u8865\u6293(gap-fill)\u4E0E\u7FA4\u540D\u89E3\u6790" },
131510
+ { scope: "im:message.reactions:write_only", reason: "\u8868\u60C5\u56DE\u5E94 \u2014\u2014 \u300C\u6536\u5230\u300D\u5373\u65F6\u53CD\u9988" },
131511
+ { scope: "cardkit:card:read", reason: "\u5361\u7247\u8BFB\u53D6 \u2014\u2014 \u6D41\u5F0F\u7B54\u6848\u5361\u72B6\u6001\u56DE\u8BFB" },
131512
+ { scope: "cardkit:card:write", reason: "\u5361\u7247\u521B\u5EFA\u4E0E\u66F4\u65B0 \u2014\u2014 \u6D41\u5F0F\u7B54\u6848\u5361\u672C\u4F53" },
131513
+ { scope: "task:task", reason: "\u4EFB\u52A1\u8BFB\u5199 \u2014\u2014 \u8BDD\u9898\u2194\u4EFB\u52A1\u7ED1\u5B9A(\u4E0D\u7528\u4EFB\u52A1\u6D3E\u5355\u53EF\u4E0D\u5F00)" },
131514
+ { scope: "task:comment", reason: "\u4EFB\u52A1\u8BC4\u8BBA \u2014\u2014 \u4EFB\u52A1\u8FDB\u5C55\u6C47\u62A5(\u4E0D\u7528\u4EFB\u52A1\u6D3E\u5355\u53EF\u4E0D\u5F00)" }
131515
+ ];
131516
+ async function runHealthScan(bot, hostConfigStore) {
131517
+ const credentials = [];
131518
+ const backend = bot.backend || "claude";
131519
+ const login = await healthScanBackendLoginProbe(backend);
131520
+ credentials.push({
131521
+ id: "backend-login",
131522
+ label: backend === "claude" ? "Claude \u767B\u5F55" : backend === "codex" ? "Codex \u767B\u5F55" : `${backend} \u767B\u5F55`,
131523
+ status: login === null ? "unknown" : login ? "ok" : "fail",
131524
+ severity: "required",
131525
+ global: true,
131526
+ ...login === false ? { hint: `\u7EC8\u7AEF\u8FD0\u884C \`${backend === "claude" ? "claude" : "codex login"}\` \u5B8C\u6210\u767B\u5F55` } : {}
131527
+ });
131528
+ const profile = bot.lark_cli_profile ?? bot.app_id;
131529
+ try {
131530
+ const { stdout: stdout2 } = await healthScanExecFile(
131531
+ process.env.LARK_CLI_PATH || "lark-cli",
131532
+ ["--profile", profile, "auth", "status", "--json"],
131533
+ { timeout: HEALTH_SCAN_PROBE_TIMEOUT_MS, maxBuffer: 1024 * 1024 }
131534
+ );
131535
+ const payload = healthScanParseJson(stdout2);
131536
+ const botIdentity = payload?.identities?.bot;
131537
+ credentials.push({
131538
+ id: "lark-profile",
131539
+ label: "lark-cli profile",
131540
+ status: botIdentity?.available === true ? "ok" : botIdentity ? "fail" : "unknown",
131541
+ severity: "required",
131542
+ global: false,
131543
+ ...botIdentity?.available !== true ? { hint: "\u91CD\u542F Larkway \u4F1A\u81EA\u52A8\u91CD\u65B0\u914D\u7F6E profile;\u82E5\u4ECD\u5931\u8D25,\u68C0\u67E5 keychain \u662F\u5426\u9501\u5B9A" } : {}
131544
+ });
131545
+ } catch {
131546
+ credentials.push({
131547
+ id: "lark-profile",
131548
+ label: "lark-cli profile",
131549
+ status: "unknown",
131550
+ severity: "required",
131551
+ global: false,
131552
+ hint: "lark-cli \u63A2\u6D4B\u5931\u8D25(\u672A\u5B89\u88C5\u6216\u8D85\u65F6)\u2014\u2014 \u88C5\u597D\u5DE5\u5177\u540E\u91CD\u65B0\u4F53\u68C0,\u8FD9\u9879\u4F1A\u81EA\u52A8\u8865\u4E0A"
131553
+ });
131554
+ }
131555
+ const tokenEnv = bot.git_token_env ?? bot.gitlab_token_env;
131556
+ if (Array.isArray(bot.repos) && bot.repos.length > 0 && tokenEnv) {
131557
+ const secret = await hostConfigStore.readSecret(tokenEnv).catch(() => null);
131558
+ credentials.push({
131559
+ id: "git-token",
131560
+ label: "Git \u8BBF\u95EE\u4EE4\u724C",
131561
+ status: secret ? "ok" : "fail",
131562
+ severity: "recommended",
131563
+ global: false,
131564
+ ...secret ? {} : { hint: `\u73AF\u5883\u91CC\u6CA1\u8BFB\u5230 ${tokenEnv} \u2014\u2014 \u914D\u597D\u540E clone / \u63A8\u9001 / \u5F00 MR \u624D\u8D70\u8FD9\u4E2A bot \u81EA\u5DF1\u7684\u8D26\u53F7` }
131565
+ });
131566
+ }
131567
+ return {
131568
+ credentials,
131569
+ permissions: {
131570
+ status: "unknown",
131571
+ unknownReason: "lark-cli \u6682\u65F6\u67E5\u4E0D\u5230\u5E94\u7528\u5DF2\u5F00\u901A\u54EA\u4E9B\u6743\u9650(auth scopes \u53EA\u62A5\u7528\u6237\u6388\u6743)\u2014\u2014 \u8BF7\u5BF9\u7167\u57FA\u7EBF\u6E05\u5355\u5728\u5F00\u653E\u5E73\u53F0\u6838\u5BF9",
131572
+ baseline: BOT_PERMISSION_BASELINE,
131573
+ consoleUrl: `https://open.feishu.cn/app/${encodeURIComponent(bot.app_id)}/auth`
131574
+ },
131575
+ checkedAt: Date.now(),
131576
+ fromCache: false
131577
+ };
131578
+ }
131579
+ function healthScanParseJson(text) {
131580
+ const raw = String(text || "").trim();
131581
+ if (!raw) return null;
131582
+ try {
131583
+ const direct = JSON.parse(raw);
131584
+ if (direct && typeof direct === "object" && !Array.isArray(direct)) return direct;
131585
+ } catch {
131586
+ }
131587
+ const first = raw.indexOf("{");
131588
+ const last = raw.lastIndexOf("}");
131589
+ if (first >= 0 && last > first) {
131590
+ try {
131591
+ const parsed = JSON.parse(raw.slice(first, last + 1));
131592
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
131593
+ } catch {
131594
+ }
131595
+ }
131596
+ return null;
131597
+ }
131598
+ var getBotHealthScan = async (req) => {
131599
+ const { ctx, params } = req;
131600
+ const id = params.id;
131601
+ {
131602
+ const e = badBotId(id);
131603
+ if (e) return e;
131604
+ }
131605
+ if (ctx.mode !== "local") {
131606
+ return { status: 403, json: { error: "\u53EA\u652F\u6301\u5728\u672C\u673A\u52A9\u624B\u4E0A\u4E0B\u6587\u4E2D\u4F53\u68C0\u3002" } };
131607
+ }
131608
+ const force = req.method === "POST";
131609
+ const cached = healthScanCache.get(id);
131610
+ if (!force && cached && Date.now() - cached.at < HEALTH_SCAN_TTL_MS) {
131611
+ return { status: 200, json: { ...cached.json, fromCache: true } };
131612
+ }
131613
+ let bot;
131614
+ try {
131615
+ bot = await ctx.stores.botsStore.readBot(id);
131616
+ } catch (e) {
131617
+ const msg = e instanceof Error ? e.message : String(e);
131618
+ return { status: msg.includes("not found") ? 404 : 500, json: { error: msg } };
131619
+ }
131620
+ const json2 = await runHealthScan(bot, ctx.stores.hostConfig);
131621
+ healthScanCache.set(id, { json: json2, at: Date.now() });
131622
+ return { status: 200, json: json2 };
131623
+ };
131452
131624
  var getContext = async (req) => {
131453
131625
  const { ctx } = req;
131454
131626
  return {
@@ -131456,6 +131628,114 @@ var getContext = async (req) => {
131456
131628
  json: { mode: ctx.mode, centralAvailable: false, version: LARKWAY_VERSION }
131457
131629
  };
131458
131630
  };
131631
+ var getUpdateVersion = async () => {
131632
+ const [currentVersion, latestVersion] = await Promise.all([
131633
+ readGlobalLarkwayVersion(),
131634
+ readLatestLarkwayVersion()
131635
+ ]);
131636
+ return {
131637
+ status: 200,
131638
+ json: {
131639
+ currentVersion,
131640
+ latestVersion,
131641
+ updateAvailable: latestVersion ? compareVersion(latestVersion, currentVersion) > 0 : false
131642
+ }
131643
+ };
131644
+ };
131645
+ var updateJob = { status: "idle", log: [] };
131646
+ var updateJobRunner = defaultUpdateJobRunner;
131647
+ async function defaultUpdateJobRunner(onLine) {
131648
+ const { spawn: spawn6 } = await import("node:child_process");
131649
+ return new Promise((resolve2, reject) => {
131650
+ const child = spawn6("larkway", ["update", "--latest", "--json"], {
131651
+ stdio: ["ignore", "pipe", "pipe"]
131652
+ });
131653
+ let buf = "";
131654
+ const feed = (chunk) => {
131655
+ buf += String(chunk);
131656
+ let idx;
131657
+ while ((idx = buf.indexOf("\n")) >= 0) {
131658
+ const line = buf.slice(0, idx).trim();
131659
+ buf = buf.slice(idx + 1);
131660
+ if (line) onLine(line);
131661
+ }
131662
+ };
131663
+ child.stdout.on("data", feed);
131664
+ child.stderr.on("data", feed);
131665
+ child.on("error", reject);
131666
+ child.on("close", (code) => {
131667
+ if (buf.trim()) onLine(buf.trim());
131668
+ resolve2({ code: code ?? 1 });
131669
+ });
131670
+ });
131671
+ }
131672
+ function updatePhaseOf(step2) {
131673
+ if (/npm i/i.test(step2)) return { phase: "\u4E0B\u8F7D\u5B89\u88C5", phaseIndex: 1 };
131674
+ if (/stop/i.test(step2)) return { phase: "\u91CD\u542F\u670D\u52A1", phaseIndex: 2 };
131675
+ if (/start/i.test(step2)) return { phase: "\u6536\u5C3E", phaseIndex: 3 };
131676
+ return { phase: step2, phaseIndex: updateJob.phaseIndex ?? 1 };
131677
+ }
131678
+ async function runUpdateJob() {
131679
+ updateJob = {
131680
+ status: "running",
131681
+ phase: "\u51C6\u5907",
131682
+ phaseIndex: 1,
131683
+ phaseCount: 3,
131684
+ log: [],
131685
+ startedAt: Date.now()
131686
+ };
131687
+ const pushLog = (line) => {
131688
+ updateJob.log.push(line);
131689
+ if (updateJob.log.length > 60) updateJob.log.splice(0, updateJob.log.length - 60);
131690
+ };
131691
+ try {
131692
+ const { code } = await updateJobRunner((line) => {
131693
+ pushLog(line);
131694
+ try {
131695
+ const ev = JSON.parse(line);
131696
+ if (typeof ev.step === "string") {
131697
+ const { phase, phaseIndex } = updatePhaseOf(ev.step);
131698
+ updateJob.phase = phase;
131699
+ updateJob.phaseIndex = phaseIndex;
131700
+ }
131701
+ if (ev.ok === false && typeof ev.error === "string") updateJob.error = ev.error;
131702
+ } catch {
131703
+ }
131704
+ });
131705
+ if (code === 0) {
131706
+ updateJob.status = "done";
131707
+ updateJob.phase = "\u5B8C\u6210";
131708
+ updateJob.phaseIndex = updateJob.phaseCount;
131709
+ } else {
131710
+ updateJob.status = "failed";
131711
+ updateJob.error = updateJob.error ?? `updater \u9000\u51FA\u7801 ${code}`;
131712
+ }
131713
+ } catch (e) {
131714
+ updateJob.status = "failed";
131715
+ updateJob.error = e instanceof Error ? e.message : String(e);
131716
+ }
131717
+ updateJob.finishedAt = Date.now();
131718
+ }
131719
+ var postUpdateVersion = async () => {
131720
+ if (updateJob.status === "running") {
131721
+ return { status: 409, json: { ok: false, error: "\u66F4\u65B0\u5DF2\u5728\u8FDB\u884C\u4E2D\u3002" } };
131722
+ }
131723
+ void runUpdateJob();
131724
+ return { status: 200, json: { ok: true, started: true } };
131725
+ };
131726
+ var getUpdateVersionStatus = async () => {
131727
+ return {
131728
+ status: 200,
131729
+ json: {
131730
+ status: updateJob.status,
131731
+ phase: updateJob.phase,
131732
+ phaseIndex: updateJob.phaseIndex,
131733
+ phaseCount: updateJob.phaseCount,
131734
+ error: updateJob.error,
131735
+ log: updateJob.log
131736
+ }
131737
+ };
131738
+ };
131459
131739
  var getBots = async (req) => {
131460
131740
  const { ctx } = req;
131461
131741
  const dir = await ctx.activeBotsDir();
@@ -132124,8 +132404,13 @@ var getBackends = async (_req) => {
132124
132404
  };
132125
132405
  var ROUTES = {
132126
132406
  "GET /api/context": getContext,
132407
+ "GET /api/update_version": getUpdateVersion,
132408
+ "POST /api/update_version": postUpdateVersion,
132409
+ "GET /api/update_version/status": getUpdateVersionStatus,
132127
132410
  "GET /api/bots": getBots,
132128
132411
  "GET /api/bot/:id": getBot,
132412
+ "GET /api/bot/:id/health-scan": getBotHealthScan,
132413
+ "POST /api/bot/:id/health-scan": getBotHealthScan,
132129
132414
  "GET /api/bot/:id/events": getBotEvents,
132130
132415
  "PUT /api/bot/:id": putBot,
132131
132416
  "DELETE /api/bot/:id": deleteBot2,
@@ -132366,9 +132651,9 @@ function writeBody(res, status, contentType, buf) {
132366
132651
  }
132367
132652
  async function openBrowser(url) {
132368
132653
  try {
132369
- const { spawn: spawn5 } = await import("node:child_process");
132654
+ const { spawn: spawn6 } = await import("node:child_process");
132370
132655
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
132371
- spawn5(cmd, [url], { stdio: "ignore", detached: true }).unref();
132656
+ spawn6(cmd, [url], { stdio: "ignore", detached: true }).unref();
132372
132657
  } catch {
132373
132658
  }
132374
132659
  }
@@ -133059,7 +133344,7 @@ function resolveOwnerOpenId(profile, _spawnSync = spawnSync) {
133059
133344
  }
133060
133345
 
133061
133346
  // src/lark/profileBootstrap.ts
133062
- import { spawnSync as spawnSync2 } from "node:child_process";
133347
+ import { spawn as spawn5, spawnSync as spawnSync2 } from "node:child_process";
133063
133348
  function deriveLarkCliProfile(explicitProfile, appId) {
133064
133349
  return explicitProfile ?? appId;
133065
133350
  }
package/dist/main.js CHANGED
@@ -118871,6 +118871,9 @@ function extractFeishuDocLinks(text) {
118871
118871
  if (!matches) return [];
118872
118872
  return [...new Set(matches)];
118873
118873
  }
118874
+ function isStopCommand(event) {
118875
+ return /^\/stop$/i.test(extractText(event));
118876
+ }
118874
118877
  function parseMessage(event) {
118875
118878
  let parsedContent = null;
118876
118879
  try {
@@ -122445,6 +122448,16 @@ var BridgeHandler = class {
122445
122448
  * awaits these — the real turn boundary tests must sync assertions/cleanup on.
122446
122449
  */
122447
122450
  inFlightTurns = /* @__PURE__ */ new Set();
122451
+ /**
122452
+ * BL-42 /stop: per-queue-key kill hook for the CURRENTLY RUNNING turn.
122453
+ * run()'s dispatch loop intercepts a bare `/stop` message and invokes the
122454
+ * hook for its queue key instead of enqueueing the message (queueing would
122455
+ * make it wait behind the very turn it is trying to stop). Registered by
122456
+ * handleOne right after the agent subprocess spawns; deleted on every
122457
+ * teardown path. Serial-per-key dispatch guarantees at most one live entry
122458
+ * per key, and register/delete never interleave across turns.
122459
+ */
122460
+ activeTurnStops = /* @__PURE__ */ new Map();
122448
122461
  /**
122449
122462
  * v3.2 交接断链检测 (docs/task-handle.md §13, revision 2): per-thread "most
122450
122463
  * recently RECEIVED" timestamp, stamped in run()'s for-await loop body —
@@ -122572,6 +122585,10 @@ var BridgeHandler = class {
122572
122585
  if (this.closed) break;
122573
122586
  if (signal?.aborted) break;
122574
122587
  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;
122588
+ if (isStopCommand(event)) {
122589
+ this.handleStopCommand(key, event, pendingByKey);
122590
+ continue;
122591
+ }
122575
122592
  this.threadReceivedAt.set(key, Date.now());
122576
122593
  const legacySessionKey = typeof event.root_id === "string" && event.root_id ? event.root_id : event.message_id;
122577
122594
  if (legacySessionKey !== key) this.threadReceivedAt.set(legacySessionKey, Date.now());
@@ -122595,7 +122612,7 @@ var BridgeHandler = class {
122595
122612
  `[bridge.handler] coalescing ${followups.length} queued follow-up message(s) into turn ${primary.message_id} on thread ${key}`
122596
122613
  );
122597
122614
  }
122598
- return this.handleOne(primary, followups);
122615
+ return this.handleOne(primary, followups, key);
122599
122616
  }).catch((err) => {
122600
122617
  console.error(`[bridge.handler] unhandled error on thread ${key}:`, err);
122601
122618
  }).finally(() => {
@@ -122615,6 +122632,38 @@ var BridgeHandler = class {
122615
122632
  async close() {
122616
122633
  this.closed = true;
122617
122634
  }
122635
+ /**
122636
+ * BL-42 /stop (see run()'s intercept site). Every consumed message —
122637
+ * the /stop itself AND the queued messages it drops — is acknowledged so
122638
+ * neither live-WS dedup nor any gap-fill window (this process or
122639
+ * post-restart) ever re-dispatches them: replaying a dropped message after
122640
+ * a /stop would resurrect exactly the work the user just cancelled.
122641
+ *
122642
+ * Known benign race: between "drain took the primary" and "handleOne
122643
+ * registered the kill hook" (subprocess starting) a /stop finds no hook and
122644
+ * only clears the queue — the user can simply /stop again once the card
122645
+ * shows activity.
122646
+ */
122647
+ handleStopCommand(key, event, pendingByKey) {
122648
+ this.deps.client.acknowledgeMessage(event.message_id);
122649
+ const pending = pendingByKey.get(key);
122650
+ const droppedCount = pending?.length ?? 0;
122651
+ if (pending) {
122652
+ for (const dropped of pending) this.deps.client.acknowledgeMessage(dropped.message_id);
122653
+ pendingByKey.delete(key);
122654
+ }
122655
+ const stop = this.activeTurnStops.get(key);
122656
+ if (stop) {
122657
+ console.log(
122658
+ `[bridge.handler] /stop: killing in-flight turn on thread ${key}` + (droppedCount > 0 ? ` and dropping ${droppedCount} queued message(s)` : "")
122659
+ );
122660
+ stop();
122661
+ } else {
122662
+ console.log(
122663
+ `[bridge.handler] /stop on thread ${key}: no in-flight turn` + (droppedCount > 0 ? `; dropped ${droppedCount} queued message(s)` : " (nothing to do)")
122664
+ );
122665
+ }
122666
+ }
122618
122667
  // ---------------------------------------------------------------------------
122619
122668
  // Private: single-event lifecycle
122620
122669
  // ---------------------------------------------------------------------------
@@ -122625,7 +122674,7 @@ var BridgeHandler = class {
122625
122674
  * prompt and settle together with the primary; everything else about the
122626
122675
  * turn (card, session, task probes) is driven by `event` alone.
122627
122676
  */
122628
- async handleOne(event, followups = []) {
122677
+ async handleOne(event, followups = [], queueKey) {
122629
122678
  const settleMessageId = event.message_id;
122630
122679
  let settled = false;
122631
122680
  let agentRunCompleted = false;
@@ -122885,7 +122934,8 @@ var BridgeHandler = class {
122885
122934
  });
122886
122935
  }
122887
122936
  };
122888
- if (!isNewThread) await createCotBubble(messageId);
122937
+ const triggerInTopic = realTopicThreadId(parsed.raw.thread_id) !== void 0;
122938
+ if (!isNewThread && (triggerInTopic || !taskCardAnchorId)) await createCotBubble(messageId);
122889
122939
  const createCardKitPlaceholder = async () => {
122890
122940
  if (!card && cardKitAvailable && this.deps.cardKitClient) {
122891
122941
  try {
@@ -123187,7 +123237,7 @@ var BridgeHandler = class {
123187
123237
  console.warn("[bridge.handler] mtime fact computation failed (continuing):", err);
123188
123238
  }
123189
123239
  }
123190
- if (isNewThread) {
123240
+ {
123191
123241
  const anchorMessageId = cardKitProgress?.messageId ?? card?.messageId ?? messageId;
123192
123242
  await createCotBubble(anchorMessageId);
123193
123243
  }
@@ -123317,6 +123367,7 @@ var BridgeHandler = class {
123317
123367
  });
123318
123368
  let lastActivityAt = Date.now();
123319
123369
  let interruptedByIdle = false;
123370
+ let stoppedByUser = false;
123320
123371
  let toolsInFlight = 0;
123321
123372
  let toolUseTotalCount = 0;
123322
123373
  let idleWatchdog;
@@ -123336,6 +123387,15 @@ var BridgeHandler = class {
123336
123387
  }, cadenceMs);
123337
123388
  idleWatchdog.unref?.();
123338
123389
  }
123390
+ if (queueKey) {
123391
+ this.activeTurnStops.set(queueKey, () => {
123392
+ stoppedByUser = true;
123393
+ try {
123394
+ handle.kill();
123395
+ } catch {
123396
+ }
123397
+ });
123398
+ }
123339
123399
  let pidFileWriteSettled = Promise.resolve();
123340
123400
  if (isAgentWorkspace && handle.pid != null) {
123341
123401
  const sessionPidFile = path10.join(worktreePath, ".larkway", "runner.pid");
@@ -123373,11 +123433,12 @@ var BridgeHandler = class {
123373
123433
  clearInterval(idleWatchdog);
123374
123434
  idleWatchdog = void 0;
123375
123435
  }
123376
- cotTurnOutcome = interruptedByIdle ? "error" : "done";
123436
+ if (queueKey) this.activeTurnStops.delete(queueKey);
123437
+ cotTurnOutcome = interruptedByIdle || stoppedByUser ? "error" : "done";
123377
123438
  if (cotPublisher) {
123378
123439
  void cotPublisher.finalize(
123379
- interruptedByIdle ? "error" : "done",
123380
- interruptedByIdle ? { message: "idle timeout" } : void 0
123440
+ cotTurnOutcome,
123441
+ stoppedByUser ? { message: "stopped by user" } : interruptedByIdle ? { message: "idle timeout" } : void 0
123381
123442
  ).catch(() => {
123382
123443
  });
123383
123444
  }
@@ -123443,7 +123504,7 @@ var BridgeHandler = class {
123443
123504
  }
123444
123505
  const reportedStatus = reportedState?.status;
123445
123506
  const reportedError = reportedState?.error;
123446
- const cardKitTimeoutFailure = interruptedByIdle && reportedStatus !== "ready" && reportedStatus !== "failed";
123507
+ const cardKitTimeoutFailure = !stoppedByUser && interruptedByIdle && reportedStatus !== "ready" && reportedStatus !== "failed";
123447
123508
  let success;
123448
123509
  let failureReason;
123449
123510
  if (reportedStatus === "failed") {
@@ -123451,6 +123512,14 @@ var BridgeHandler = class {
123451
123512
  failureReason = reportedError ?? "bot \u62A5\u544A failed (\u65E0 error \u5B57\u6BB5)";
123452
123513
  } else if (reportedStatus === "ready") {
123453
123514
  success = true;
123515
+ } else if (stoppedByUser) {
123516
+ success = false;
123517
+ failureReason = "\u5DF2\u88AB\u7528\u6237 /stop \u505C\u6B62";
123518
+ await recordEvent({
123519
+ status: "running",
123520
+ appendPath: "\u7528\u6237\u505C\u6B62",
123521
+ reason: failureReason
123522
+ });
123454
123523
  } else if (cardKitTimeoutFailure) {
123455
123524
  success = false;
123456
123525
  failureReason = `agent turn idle for ${idleTimeoutMs}ms with no activity (treated as stuck); run interrupted`;
@@ -123517,15 +123586,15 @@ var BridgeHandler = class {
123517
123586
  const fallbackAnswer = trustedAnswerText.trim() || cardKitProgress?.answerText.trim() || "";
123518
123587
  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
123519
123588
  ` : "") + "\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";
123520
- const cardBody = stuckResetTriggered ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\u3002\u8FDE\u7EED\u591A\u6B21\u5361\u6B7B\uFF0C\u5DF2\u91CD\u7F6E\u672C\u8BDD\u9898\u4E0A\u4E0B\u6587 \u2014\u2014 \u4E0B\u6B21 @ \u6211\u5C06\u5168\u65B0\u5F00\u59CB\uFF0C\u8BF7\u628A\u9700\u6C42\u91CD\u65B0\u8BF4\u4E00\u904D\u3002" : 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);
123589
+ const cardBody = stoppedByUser ? "\u23F9 \u5DF2\u6309 /stop \u505C\u6B62\u672C\u8F6E\u3002\u672C\u8BDD\u9898 session \u4FDD\u7559\uFF0C\u9700\u8981\u7EE7\u7EED\u65F6\u518D @ \u6211\u3002" : stuckResetTriggered ? "\u26A0\uFE0F \u672C\u8F6E\u88AB\u4E2D\u65AD\uFF08\u957F\u65F6\u95F4\u65E0\u6D3B\u6027\uFF0C\u5224\u5B9A\u5361\u6B7B\uFF09\u3002\u8FDE\u7EED\u591A\u6B21\u5361\u6B7B\uFF0C\u5DF2\u91CD\u7F6E\u672C\u8BDD\u9898\u4E0A\u4E0B\u6587 \u2014\u2014 \u4E0B\u6B21 @ \u6211\u5C06\u5168\u65B0\u5F00\u59CB\uFF0C\u8BF7\u628A\u9700\u6C42\u91CD\u65B0\u8BF4\u4E00\u904D\u3002" : 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);
123521
123590
  const noReportThisTurn = reportedState === null;
123522
123591
  const neutralTitle = noReportThisTurn && success && !failureReason ? "\u{1F4AC} \u5DF2\u56DE\u590D" : void 0;
123523
123592
  const baseCardPayload = {
123524
123593
  finalText: cardBody,
123525
123594
  success,
123526
123595
  failureReason,
123527
- titleOverride: reportedState?.card_title ?? (cardKitTimeoutFailure ? "\u5DF2\u4E2D\u65AD" : neutralTitle),
123528
- colorOverride: reportedState?.card_color ?? (neutralTitle ? "neutral" : void 0),
123596
+ titleOverride: reportedState?.card_title ?? (stoppedByUser ? "\u5DF2\u505C\u6B62" : cardKitTimeoutFailure ? "\u5DF2\u4E2D\u65AD" : neutralTitle),
123597
+ colorOverride: reportedState?.card_color ?? (stoppedByUser || neutralTitle ? "neutral" : void 0),
123529
123598
  // V2 dynamic-choice buttons — agent-declared, rendered verbatim.
123530
123599
  // reportedState is null when state.json wasn't freshly written
123531
123600
  // (stale-guard), so stale leftover choices never reappear.
@@ -123700,6 +123769,7 @@ var BridgeHandler = class {
123700
123769
  clearInterval(idleWatchdog);
123701
123770
  idleWatchdog = void 0;
123702
123771
  }
123772
+ if (queueKey) this.activeTurnStops.delete(queueKey);
123703
123773
  const errMsg = String(spawnErr.message ?? spawnErr);
123704
123774
  const isStaleSessionErr = errMsg.includes("No conversation found") || errMsg.includes("thread/resume failed") && errMsg.includes("no rollout found");
123705
123775
  if (attempt === 1 && currentExisting != null && isStaleSessionErr) {
@@ -123802,6 +123872,7 @@ var BridgeHandler = class {
123802
123872
  void bubbleCreate.then((handle) => handle.finalize(cotTurnOutcome).catch(() => {
123803
123873
  }));
123804
123874
  }
123875
+ if (queueKey) this.activeTurnStops.delete(queueKey);
123805
123876
  settle(false);
123806
123877
  }
123807
123878
  }
@@ -130237,7 +130308,7 @@ async function reapOrphanedWarmProcess(pidFilePath) {
130237
130308
  }
130238
130309
 
130239
130310
  // src/lark/profileBootstrap.ts
130240
- import { spawnSync } from "node:child_process";
130311
+ import { spawn as spawn6, spawnSync } from "node:child_process";
130241
130312
  function ensureLarkCliProfile(botId, profileName, appId, appSecret, _spawnSync = spawnSync, _console = console) {
130242
130313
  _console.log(
130243
130314
  `[larkway] bot "${botId}": provisioning lark-cli profile "${profileName}" for app ${appId.slice(0, 8)}\u2026`