larkway 0.3.8 → 0.3.9

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.6**
11
+ **Current release: v0.3.9**
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.6**
11
+ **当前版本:v0.3.9**
12
12
 
13
13
  ---
14
14
 
package/dist/cli/index.js CHANGED
@@ -119646,6 +119646,14 @@ var GitIdentitySchema = external_exports.object({
119646
119646
  name: external_exports.string().min(1),
119647
119647
  email: external_exports.string().email()
119648
119648
  });
119649
+ var GitCloneUrlSchema = external_exports.string().min(1).refine(
119650
+ (value) => {
119651
+ if (/^https?:\/\/\S+$/i.test(value)) return true;
119652
+ if (/^ssh:\/\/\S+$/i.test(value)) return true;
119653
+ return /^[A-Za-z0-9_.-]+@[^:\s]+:[^ \t\r\n]+$/.test(value);
119654
+ },
119655
+ "url must be an http(s), ssh://, or scp-like Git clone URL"
119656
+ );
119649
119657
  var BotConfigSchema = external_exports.object({
119650
119658
  /** Unique identifier, kebab-case. Used as key in sessionStore. */
119651
119659
  id: external_exports.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "id must be kebab-case"),
@@ -119720,7 +119728,7 @@ var BotConfigSchema = external_exports.object({
119720
119728
  * Agent workspace runtime: URL is a pointer only; the Agent decides clone
119721
119729
  * timing and destination.
119722
119730
  */
119723
- url: external_exports.string().url().optional()
119731
+ url: GitCloneUrlSchema.optional()
119724
119732
  })
119725
119733
  ).default([]),
119726
119734
  /**
@@ -120016,7 +120024,7 @@ function renderBotYaml(config) {
120016
120024
  const header = [
120017
120025
  "# Larkway bot config (L1) \u2014 generated/edited via `larkway` CLI.",
120018
120026
  "# Credentials are env-var NAMES, never values:",
120019
- "# app_secret_env / gitlab_token_env reference keys in ~/.larkway/.env (chmod 0600).",
120027
+ "# app_secret_env / git_token_env reference keys in ~/.larkway/.env (chmod 0600).",
120020
120028
  "# L2 \u804C\u80FD memory lives alongside in <id>.memory.md (memory_file points at it).",
120021
120029
  ""
120022
120030
  ].join("\n");
@@ -122196,14 +122204,14 @@ async function runAdd(ctx, args) {
122196
122204
  });
122197
122205
  if (repoSlug) {
122198
122206
  const repoBranch = await ui.prompt("\u9ED8\u8BA4\u5206\u652F:", {
122199
- default: setMap.get("repo_branch") ?? "master",
122207
+ default: setMap.get("repo_branch") ?? "main",
122200
122208
  nonInteractive: flags.nonInteractive
122201
122209
  });
122202
- const repoUrl = await ui.prompt("clone URL (\u53EF\u9009,\u7559\u7A7A=\u7528\u9ED8\u8BA4 remote \u63A8\u5BFC):", {
122210
+ const repoUrl = await ui.prompt("clone URL (\u5EFA\u8BAE\u586B\u5199;agent \u7528\u5B83 clone):", {
122203
122211
  default: setMap.get("repo_url") ?? "",
122204
122212
  nonInteractive: flags.nonInteractive
122205
122213
  });
122206
- const repoEntry = { slug: repoSlug, branch: repoBranch || "master" };
122214
+ const repoEntry = { slug: repoSlug, branch: repoBranch || "main" };
122207
122215
  if (repoUrl) repoEntry.url = repoUrl;
122208
122216
  repos.push(repoEntry);
122209
122217
  }
@@ -122212,7 +122220,7 @@ async function runAdd(ctx, args) {
122212
122220
  if (repoSlug) {
122213
122221
  const repoEntry = {
122214
122222
  slug: repoSlug,
122215
- branch: setMap.get("repo_branch") ?? "master"
122223
+ branch: setMap.get("repo_branch") ?? "main"
122216
122224
  };
122217
122225
  const repoUrl = setMap.get("repo_url");
122218
122226
  if (repoUrl) repoEntry.url = repoUrl;
@@ -122220,12 +122228,11 @@ async function runAdd(ctx, args) {
122220
122228
  }
122221
122229
  }
122222
122230
  let gitlab_token_env;
122223
- const defaultGitlabTokenEnv2 = repos.length > 0 ? `LARKWAY_${id.toUpperCase().replace(/-/g, "_")}_GITLAB_TOKEN` : "";
122224
- if (flags.advanced || setMap.has("gitlab_token_env") || repos.length > 0) {
122231
+ if (flags.advanced || setMap.has("gitlab_token_env")) {
122225
122232
  const rawGitlab = await ui.prompt(
122226
- "Git access token \u73AF\u5883\u53D8\u91CF\u540D(\u4EC5\u8BB0\u5F55 KEY,\u771F\u503C\u5199 ~/.larkway/.env):",
122233
+ "Git access token \u73AF\u5883\u53D8\u91CF\u540D(\u53EF\u9009;\u79C1\u6709\u5E93/push/MR \u624D\u9700\u8981):",
122227
122234
  {
122228
- default: setMap.get("gitlab_token_env") ?? defaultGitlabTokenEnv2,
122235
+ default: setMap.get("gitlab_token_env") ?? "",
122229
122236
  nonInteractive: flags.nonInteractive
122230
122237
  }
122231
122238
  );
@@ -122236,10 +122243,6 @@ async function runAdd(ctx, args) {
122236
122243
  return 1;
122237
122244
  }
122238
122245
  }
122239
- if (repos.length > 0 && !gitlab_token_env) {
122240
- ui.failure("agent_workspace repo bot \u5FC5\u987B\u58F0\u660E git_token_env(\u6216 gitlab_token_env);v0.3 \u4E0D\u7EE7\u627F\u5168\u5C40 GITLAB_TOKEN\u3002");
122241
- return 1;
122242
- }
122243
122246
  let lark_cli_profile;
122244
122247
  if (flags.advanced || setMap.has("lark_cli_profile")) {
122245
122248
  const rawProfile = await ui.prompt(
@@ -124556,10 +124559,10 @@ function defaultOnboardPermissionRequests(input) {
124556
124559
  items.push(`Feishu chat allowlist: ${input.chats.join(", ")}`);
124557
124560
  }
124558
124561
  for (const repo of input.repos) {
124559
- items.push(`GitLab repo pointer: ${repo.slug} (${repo.branch})`);
124562
+ items.push(`Git repo pointer: ${repo.slug} (${repo.branch})`);
124560
124563
  }
124561
124564
  if (input.gitlab_token_env) {
124562
- items.push(`GitLab token env name: ${input.gitlab_token_env}`);
124565
+ items.push(`Agent-level Git token env name: ${input.gitlab_token_env}`);
124563
124566
  }
124564
124567
  items.push("Local shell inside the Agent Workspace for task execution and verification");
124565
124568
  return items;
@@ -124706,6 +124709,128 @@ async function cancelOnboard(sessionId) {
124706
124709
  return { cancelled: true };
124707
124710
  }
124708
124711
 
124712
+ // src/runtimeRequirements.ts
124713
+ import { execFileSync } from "node:child_process";
124714
+ function checkCli(command) {
124715
+ try {
124716
+ execFileSync("which", [command], { stdio: ["pipe", "pipe", "pipe"] });
124717
+ } catch {
124718
+ return { ok: false };
124719
+ }
124720
+ try {
124721
+ const version = execFileSync(command, ["--version"], { stdio: ["pipe", "pipe", "pipe"] }).toString().trim().split("\n")[0];
124722
+ return { ok: true, ...version ? { version } : {} };
124723
+ } catch {
124724
+ return { ok: true };
124725
+ }
124726
+ }
124727
+ function repoLooksGitLab(urlOrSlug) {
124728
+ return /(^|[./:@-])gitlab([./:@-]|$)/i.test(urlOrSlug);
124729
+ }
124730
+ function botUsesGitLab(bot) {
124731
+ return bot.repos.some((repo) => repoLooksGitLab(repo.url ?? repo.slug));
124732
+ }
124733
+ function addCliRequirement(requirements, input) {
124734
+ const existing = requirements.get(`cli:${input.command}`);
124735
+ if (existing) {
124736
+ existing.botIds = Array.from(/* @__PURE__ */ new Set([...existing.botIds, ...input.botIds])).sort();
124737
+ if (existing.severity === "optional" && input.severity === "required") {
124738
+ existing.severity = "required";
124739
+ }
124740
+ return;
124741
+ }
124742
+ const probe = checkCli(input.command);
124743
+ requirements.set(`cli:${input.command}`, {
124744
+ id: `cli:${input.command}`,
124745
+ label: input.label ?? input.command,
124746
+ command: input.command,
124747
+ kind: "cli",
124748
+ severity: input.severity,
124749
+ ok: probe.ok,
124750
+ ...probe.version ? { version: probe.version } : {},
124751
+ reason: input.reason,
124752
+ ...input.installHint ? { installHint: input.installHint } : {},
124753
+ botIds: Array.from(new Set(input.botIds)).sort()
124754
+ });
124755
+ }
124756
+ function addSecretRequirement(requirements, input) {
124757
+ const id = `secret:${input.envName}`;
124758
+ const existing = requirements.get(id);
124759
+ if (existing) {
124760
+ existing.botIds = Array.from(/* @__PURE__ */ new Set([...existing.botIds, ...input.botIds])).sort();
124761
+ return;
124762
+ }
124763
+ const value = process.env[input.envName];
124764
+ requirements.set(id, {
124765
+ id,
124766
+ label: input.envName,
124767
+ kind: "secret",
124768
+ severity: input.severity ?? "required",
124769
+ ok: value != null && value !== "",
124770
+ reason: input.reason,
124771
+ installHint: `Set ${input.envName} in ~/.larkway/.env and restart Larkway.`,
124772
+ botIds: Array.from(new Set(input.botIds)).sort()
124773
+ });
124774
+ }
124775
+ function runtimeRequirementsForBots(bots) {
124776
+ const requirements = /* @__PURE__ */ new Map();
124777
+ if (bots.length > 0) {
124778
+ addCliRequirement(requirements, {
124779
+ command: "lark-cli",
124780
+ label: "Feishu CLI",
124781
+ severity: "required",
124782
+ botIds: bots.map((bot) => bot.id),
124783
+ reason: "Required for agents to read Feishu topic history, attachments, docs, and other context.",
124784
+ installHint: "Install and configure lark-cli, then restart Larkway."
124785
+ });
124786
+ }
124787
+ for (const bot of bots) {
124788
+ addCliRequirement(requirements, {
124789
+ command: bot.backend ?? "claude",
124790
+ severity: "required",
124791
+ botIds: [bot.id],
124792
+ reason: `Required to run this bot's ${bot.backend ?? "claude"} backend.`
124793
+ });
124794
+ if (bot.runtime === "agent_workspace" && bot.repos.length > 0) {
124795
+ const tokenEnvName = bot.git_token_env ?? bot.gitlab_token_env;
124796
+ if (tokenEnvName) {
124797
+ addSecretRequirement(requirements, {
124798
+ envName: tokenEnvName,
124799
+ botIds: [bot.id],
124800
+ severity: "optional",
124801
+ reason: "Optional Git identity material. When absent, agents use the host's normal Git auth."
124802
+ });
124803
+ } else {
124804
+ requirements.set(`secret:missing-git-token:${bot.id}`, {
124805
+ id: `secret:missing-git-token:${bot.id}`,
124806
+ label: "Git access token env",
124807
+ kind: "secret",
124808
+ severity: "optional",
124809
+ ok: false,
124810
+ reason: `Bot "${bot.id}" has repo pointers but no git_token_env. It will use the host's normal Git auth.`,
124811
+ installHint: "Add a Git access token only when you want this agent to use a specific GitHub/GitLab identity.",
124812
+ botIds: [bot.id]
124813
+ });
124814
+ }
124815
+ }
124816
+ if (botUsesGitLab(bot)) {
124817
+ addCliRequirement(requirements, {
124818
+ command: "glab",
124819
+ label: "GitLab CLI",
124820
+ severity: "optional",
124821
+ botIds: [bot.id],
124822
+ reason: "Only needed when an agent uses GitLab-specific actions such as MRs, pipelines, or releases.",
124823
+ installHint: "Install glab only for bots that need GitLab-specific workflow commands."
124824
+ });
124825
+ }
124826
+ }
124827
+ return [...requirements.values()].sort((a, b) => {
124828
+ if (a.severity !== b.severity) return a.severity === "required" ? -1 : 1;
124829
+ if (a.kind !== b.kind) return a.kind === "cli" ? -1 : 1;
124830
+ return a.label.localeCompare(b.label);
124831
+ });
124832
+ }
124833
+
124709
124834
  // src/web/api.ts
124710
124835
  var execFileAsync7 = promisify8(execFileCallback2);
124711
124836
  var CHAT_NAME_CACHE_MS = 5 * 60 * 1e3;
@@ -125395,6 +125520,22 @@ var getBridgeLogs = async (req) => {
125395
125520
  json: result
125396
125521
  };
125397
125522
  };
125523
+ var getRuntimeRequirements = async (req) => {
125524
+ const { ctx } = req;
125525
+ const ids = await ctx.stores.botsStore.listBots();
125526
+ const bots = await Promise.all(
125527
+ ids.map(async (id) => ctx.stores.botsStore.readBot(id))
125528
+ );
125529
+ const requirements = runtimeRequirementsForBots(bots);
125530
+ return {
125531
+ status: 200,
125532
+ json: {
125533
+ requirements,
125534
+ missingRequired: requirements.filter((req2) => req2.severity === "required" && !req2.ok),
125535
+ missingOptional: requirements.filter((req2) => req2.severity === "optional" && !req2.ok)
125536
+ }
125537
+ };
125538
+ };
125398
125539
  var BACKEND_META = {
125399
125540
  claude: { id: "claude", name: "Claude Code", short: "Claude", vendor: "Anthropic \u8BA2\u9605", mono: "CC" },
125400
125541
  codex: { id: "codex", name: "Codex", short: "Codex", vendor: "OpenAI \u8BA2\u9605", mono: "CX" }
@@ -125436,6 +125577,7 @@ var ROUTES = {
125436
125577
  "GET /api/bridge": getBridge,
125437
125578
  "POST /api/bridge/restart": postBridgeRestart,
125438
125579
  "GET /api/bridge/logs": getBridgeLogs,
125580
+ "GET /api/runtime/requirements": getRuntimeRequirements,
125439
125581
  "GET /api/backends": getBackends
125440
125582
  };
125441
125583
  function matchRoute(method, pathname, routes = ROUTES) {
package/dist/main.js CHANGED
@@ -107360,7 +107360,6 @@ var require_main = __commonJS({
107360
107360
  });
107361
107361
 
107362
107362
  // src/main.ts
107363
- import { execSync } from "node:child_process";
107364
107363
  import { mkdirSync } from "node:fs";
107365
107364
  import path13 from "node:path";
107366
107365
 
@@ -112529,9 +112528,11 @@ ${recent.join("\n")}` : recent.join("\n");
112529
112528
  });
112530
112529
  elements.push({ tag: "hr" });
112531
112530
  }
112531
+ const mentionPrefix = atMentionMarkdown(opts.mentionOpenId);
112532
112532
  const cleanBody = opts.bodyText ? stripLeakedToolMarkup(opts.bodyText) : "";
112533
- if (cleanBody) {
112534
- const chunks = chunkMarkdown(cleanBody);
112533
+ const bodyWithMention = [mentionPrefix, cleanBody].filter(Boolean).join("\n\n");
112534
+ if (bodyWithMention) {
112535
+ const chunks = chunkMarkdown(bodyWithMention);
112535
112536
  for (let i = 0; i < chunks.length; i++) {
112536
112537
  elements.push({
112537
112538
  tag: "markdown",
@@ -112573,6 +112574,11 @@ ${chunks[i]}`
112573
112574
  };
112574
112575
  return JSON.stringify(card);
112575
112576
  }
112577
+ function atMentionMarkdown(userId) {
112578
+ if (!userId) return "";
112579
+ if (!/^[A-Za-z0-9_:-]+$/.test(userId)) return "";
112580
+ return `<at id=${userId}></at>`;
112581
+ }
112576
112582
  var CardHandleImpl = class {
112577
112583
  messageId;
112578
112584
  /** Transport for the leaf PATCH network call (default = lark-cli). */
@@ -112624,6 +112630,7 @@ var CardHandleImpl = class {
112624
112630
  showToolSummary: this.showToolSummary,
112625
112631
  status: cardStatus,
112626
112632
  failureReason: opts.failureReason,
112633
+ mentionOpenId: opts.mentionOpenId,
112627
112634
  // V2 dynamic-choice buttons — agent-declared, only on finalize.
112628
112635
  choices: opts.choices,
112629
112636
  choicePrompt: opts.choicePrompt,
@@ -113353,6 +113360,33 @@ function renderTurnTakingHint(limit) {
113353
113360
  "</turn-taking>"
113354
113361
  ];
113355
113362
  }
113363
+ function renderRuntimeWarningsBlock(warnings) {
113364
+ if (warnings.length === 0) return [];
113365
+ const hasMissingLarkCli = warnings.some((warning) => warning.command === "lark-cli");
113366
+ return [
113367
+ "<runtime-warnings>",
113368
+ "Bridge \u68C0\u6D4B\u5230\u4EE5\u4E0B\u672C\u673A\u80FD\u529B\u6682\u4E0D\u53EF\u7528\u3002\u8FD9\u662F\u63D0\u793A,\u4E0D\u662F\u5F3A\u5236\u505C\u6B62\u6761\u4EF6:",
113369
+ ...warnings.map((warning) => {
113370
+ const name = warning.command ? `${warning.label} (${warning.command})` : warning.label;
113371
+ const reason = warning.reason ? `: ${warning.reason}` : "";
113372
+ const installHint = warning.installHint ? ` Fix hint: ${warning.installHint}` : "";
113373
+ return `- ${name}${reason}${installHint}`;
113374
+ }),
113375
+ "",
113376
+ "\u5904\u7406\u539F\u5219:",
113377
+ "- \u80FD\u4EC5\u51ED\u5F53\u524D\u6D88\u606F\u7EE7\u7EED\u7684\u4EFB\u52A1,\u7EE7\u7EED\u5904\u7406,\u4E0D\u8981\u56E0\u4E3A warning \u76F4\u63A5\u62D2\u7EDD\u3002",
113378
+ "- \u53EA\u6709\u5F53\u4EFB\u52A1\u786E\u5B9E\u9700\u8981\u7F3A\u5931\u80FD\u529B\u65F6,\u518D\u5728 last_message \u91CC\u7528\u4EA7\u54C1\u5316\u8BED\u8A00\u544A\u8BC9\u7528\u6237\u7F3A\u4EC0\u4E48\u3001\u4F1A\u5F71\u54CD\u4EC0\u4E48\u3001\u5982\u4F55\u7EE7\u7EED\u3002",
113379
+ ...hasMissingLarkCli ? [
113380
+ "- \u5BF9\u7F3A\u5C11 lark-cli \u7684\u60C5\u51B5:\u4E0D\u8981\u989D\u5916 @ \u7528\u6237;\u5728\u5361\u7247\u91CC\u8F7B\u91CF\u8BF4\u660E\u5F53\u524D\u65E0\u6CD5\u81EA\u52A8\u8BFB\u53D6\u98DE\u4E66\u8BDD\u9898\u5386\u53F2\u3001\u9644\u4EF6\u6216\u6587\u6863\u5373\u53EF\u3002",
113381
+ '- \u5982\u679C\u5F53\u524D\u4EFB\u52A1\u9700\u8981\u8FD9\u4E9B\u4E0A\u4E0B\u6587,\u7528 choices \u95EE\u662F\u5426\u5141\u8BB8\u5B89\u88C5\u6700\u65B0\u7248\u98DE\u4E66 CLI\u3002\u5EFA\u8BAE: `choice_prompt: "\u8BFB\u53D6\u98DE\u4E66\u5386\u53F2\u9700\u8981\u672C\u673A\u5B89\u88C5\u6700\u65B0\u7248\u98DE\u4E66 CLI,\u662F\u5426\u5141\u8BB8\u6211\u5C1D\u8BD5\u5B89\u88C5?"`, `choices: [{label:"\u5141\u8BB8\u5B89\u88C5", value:"\u5141\u8BB8\u5B89\u88C5 lark-cli"}, {label:"\u5148\u4E0D\u5B89\u88C5", value:"\u5148\u4E0D\u5B89\u88C5 lark-cli,\u6211\u4F1A\u628A\u8981\u5904\u7406\u7684\u5185\u5BB9\u8D34\u5230\u8BDD\u9898\u91CC"}]`\u3002',
113382
+ "- \u7528\u6237\u660E\u786E\u9009\u62E9/\u56DE\u590D\u5141\u8BB8\u5B89\u88C5\u540E,\u518D\u5C1D\u8BD5\u5B89\u88C5;\u4E0D\u8981\u5728\u672A\u786E\u8BA4\u524D\u6539\u5BBF\u4E3B\u673A\u5168\u5C40\u73AF\u5883\u3002",
113383
+ "- \u63A8\u8350\u5B89\u88C5\u547D\u4EE4: `npx -y @larksuite/cli@latest install`,\u7136\u540E\u8FD0\u884C `lark-cli --version` \u9A8C\u8BC1\u3002",
113384
+ '- \u5982\u9047 npm \u5168\u5C40\u76EE\u5F55\u6743\u9650\u9519\u8BEF(EACCES/permission denied),\u4F7F\u7528\u7528\u6237\u7EA7 prefix \u540E\u91CD\u8BD5: `mkdir -p ~/.npm-global && npm config set prefix "$HOME/.npm-global" && export PATH="$HOME/.npm-global/bin:$PATH" && npx -y @larksuite/cli@latest install`\u3002\u4E0D\u8981\u9ED8\u8BA4\u8981\u6C42 sudo\u3002',
113385
+ "- \u5B89\u88C5\u6210\u529F\u540E,\u5982\u679C\u672C\u8F6E\u9700\u8981\u7ACB\u5373\u8BFB\u53D6\u98DE\u4E66\u4E0A\u4E0B\u6587,\u53EF\u5728\u5F53\u524D shell \u4E2D\u5E26\u4E0A\u4FEE\u590D\u540E\u7684 PATH \u7EE7\u7EED\u5C1D\u8BD5;\u82E5\u9700\u8981 bridge \u540E\u7EED\u8F6E\u6B21\u7A33\u5B9A\u4F7F\u7528,\u8BF7\u63D0\u793A owner \u91CD\u542F Larkway\u3002"
113386
+ ] : [],
113387
+ "</runtime-warnings>"
113388
+ ];
113389
+ }
113356
113390
  function renderWorkspaceBlock(primarySlug, primaryCachePath, defaultBranch, extraRepos) {
113357
113391
  const lines = [
113358
113392
  "<workspace>",
@@ -113423,7 +113457,17 @@ function sceneFacts(parsed, isNewThread) {
113423
113457
  };
113424
113458
  }
113425
113459
  function renderPrompt(input) {
113426
- const { parsed, isNewThread, conventions, peers, turn_taking_limit, agentMemory, extraRepoPaths, larkCliProfile } = input;
113460
+ const {
113461
+ parsed,
113462
+ isNewThread,
113463
+ conventions,
113464
+ peers,
113465
+ turn_taking_limit,
113466
+ agentMemory,
113467
+ extraRepoPaths,
113468
+ larkCliProfile,
113469
+ runtimeWarnings = []
113470
+ } = input;
113427
113471
  const backend = input.backend ?? "claude";
113428
113472
  const profileFlag = larkCliProfile ? ` --profile ${larkCliProfile}` : "";
113429
113473
  const attachmentKeys = parsed.attachments.map((a) => a.fileKey);
@@ -113436,6 +113480,7 @@ function renderPrompt(input) {
113436
113480
  const stateContract = renderStateContract(conventions.stateFilePath);
113437
113481
  const peersBlock = peers && peers.length > 0 ? renderPeersBlock(peers) : [];
113438
113482
  const turnTakingBlock = turn_taking_limit && turn_taking_limit > 0 ? renderTurnTakingHint(turn_taking_limit) : [];
113483
+ const runtimeWarningsBlock = renderRuntimeWarningsBlock(runtimeWarnings);
113439
113484
  const extraRepos = extraRepoPaths ?? conventions.extraRepoPaths ?? [];
113440
113485
  const workspaceBlock = isAgentWorkspace ? renderAgentWorkspaceBlock(conventions, extraRepos) : hasRepo ? renderWorkspaceBlock(
113441
113486
  conventions.defaultProjectSlug ?? "repo",
@@ -113491,6 +113536,7 @@ function renderPrompt(input) {
113491
113536
  "",
113492
113537
  ...agentMemoryBlock,
113493
113538
  ...skillIntroNew,
113539
+ ...runtimeWarningsBlock.length > 0 ? [...runtimeWarningsBlock, ""] : [],
113494
113540
  "<thread-context>",
113495
113541
  `thread_id: ${parsed.threadId}`,
113496
113542
  `message_id: ${parsed.messageId}`,
@@ -113558,6 +113604,7 @@ function renderPrompt(input) {
113558
113604
  return [
113559
113605
  ...agentMemoryBlock,
113560
113606
  ...skillIntroCont,
113607
+ ...runtimeWarningsBlock.length > 0 ? [...runtimeWarningsBlock, ""] : [],
113561
113608
  "<thread-context>",
113562
113609
  `thread_id: ${parsed.threadId}`,
113563
113610
  `message_id: ${parsed.messageId}`,
@@ -114441,6 +114488,11 @@ var BridgeHandler = class {
114441
114488
  constructor(deps) {
114442
114489
  this.deps = deps;
114443
114490
  }
114491
+ runtimeWarnings() {
114492
+ return (this.deps.runtimeRequirements ?? []).filter(
114493
+ (req) => !req.ok && (req.severity === "required" || req.kind === "secret")
114494
+ );
114495
+ }
114444
114496
  /**
114445
114497
  * Enter the main loop: for-await over client.events(), per-thread concurrent dispatch.
114446
114498
  *
@@ -114742,7 +114794,8 @@ var BridgeHandler = class {
114742
114794
  botName: this.deps.botConfig?.name,
114743
114795
  backend: this.deps.botConfig?.backend,
114744
114796
  agentMemory: this.deps.agentMemory,
114745
- larkCliProfile: this.deps.larkCliProfile
114797
+ larkCliProfile: this.deps.larkCliProfile,
114798
+ runtimeWarnings: this.runtimeWarnings()
114746
114799
  });
114747
114800
  const backend = this.deps.botConfig?.backend ?? "claude";
114748
114801
  const permissionMode = this.deps.permissionMode ?? (isAgentWorkspace ? "acceptEdits" : "bypassPermissions");
@@ -117852,6 +117905,14 @@ var GitIdentitySchema = external_exports.object({
117852
117905
  name: external_exports.string().min(1),
117853
117906
  email: external_exports.string().email()
117854
117907
  });
117908
+ var GitCloneUrlSchema = external_exports.string().min(1).refine(
117909
+ (value) => {
117910
+ if (/^https?:\/\/\S+$/i.test(value)) return true;
117911
+ if (/^ssh:\/\/\S+$/i.test(value)) return true;
117912
+ return /^[A-Za-z0-9_.-]+@[^:\s]+:[^ \t\r\n]+$/.test(value);
117913
+ },
117914
+ "url must be an http(s), ssh://, or scp-like Git clone URL"
117915
+ );
117855
117916
  var BotConfigSchema = external_exports.object({
117856
117917
  /** Unique identifier, kebab-case. Used as key in sessionStore. */
117857
117918
  id: external_exports.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "id must be kebab-case"),
@@ -117926,7 +117987,7 @@ var BotConfigSchema = external_exports.object({
117926
117987
  * Agent workspace runtime: URL is a pointer only; the Agent decides clone
117927
117988
  * timing and destination.
117928
117989
  */
117929
- url: external_exports.string().url().optional()
117990
+ url: GitCloneUrlSchema.optional()
117930
117991
  })
117931
117992
  ).default([]),
117932
117993
  /**
@@ -118284,12 +118345,12 @@ var SIGKILL_GRACE_MS = 5e3;
118284
118345
  function buildEnv(botGitIdentity, gitlabToken) {
118285
118346
  const env = { ...process.env };
118286
118347
  delete env["ANTHROPIC_API_KEY"];
118287
- const name = botGitIdentity?.name ?? "larkway-bot";
118288
- const email = botGitIdentity?.email ?? "bot@larkway.local";
118289
- env["GIT_AUTHOR_NAME"] = name;
118290
- env["GIT_AUTHOR_EMAIL"] = email;
118291
- env["GIT_COMMITTER_NAME"] = name;
118292
- env["GIT_COMMITTER_EMAIL"] = email;
118348
+ if (botGitIdentity) {
118349
+ env["GIT_AUTHOR_NAME"] = botGitIdentity.name;
118350
+ env["GIT_AUTHOR_EMAIL"] = botGitIdentity.email;
118351
+ env["GIT_COMMITTER_NAME"] = botGitIdentity.name;
118352
+ env["GIT_COMMITTER_EMAIL"] = botGitIdentity.email;
118353
+ }
118293
118354
  if (gitlabToken !== void 0) {
118294
118355
  env["GITLAB_TOKEN"] = gitlabToken;
118295
118356
  }
@@ -118598,12 +118659,12 @@ function buildCodexEnv(botGitIdentity, gitlabToken) {
118598
118659
  const env = { ...process.env };
118599
118660
  delete env["OPENAI_API_KEY"];
118600
118661
  delete env["ANTHROPIC_API_KEY"];
118601
- const name = botGitIdentity?.name ?? "larkway-bot";
118602
- const email = botGitIdentity?.email ?? "bot@larkway.local";
118603
- env["GIT_AUTHOR_NAME"] = name;
118604
- env["GIT_AUTHOR_EMAIL"] = email;
118605
- env["GIT_COMMITTER_NAME"] = name;
118606
- env["GIT_COMMITTER_EMAIL"] = email;
118662
+ if (botGitIdentity) {
118663
+ env["GIT_AUTHOR_NAME"] = botGitIdentity.name;
118664
+ env["GIT_AUTHOR_EMAIL"] = botGitIdentity.email;
118665
+ env["GIT_COMMITTER_NAME"] = botGitIdentity.name;
118666
+ env["GIT_COMMITTER_EMAIL"] = botGitIdentity.email;
118667
+ }
118607
118668
  if (gitlabToken !== void 0) {
118608
118669
  env["GITLAB_TOKEN"] = gitlabToken;
118609
118670
  }
@@ -118989,33 +119050,144 @@ function checkGrantMatchesBot(normalizedGrantText, bot) {
118989
119050
  return { ok: true };
118990
119051
  }
118991
119052
 
118992
- // src/main.ts
118993
- var STATUS_WRITE_INTERVAL_MS = 3e4;
118994
- var VERSION = resolveLarkwayVersion(import.meta.url);
118995
- function checkCli(name) {
119053
+ // src/runtimeRequirements.ts
119054
+ import { execFileSync } from "node:child_process";
119055
+ function checkCli(command) {
118996
119056
  try {
118997
- const p = execSync(`which ${name}`, { stdio: ["pipe", "pipe", "pipe"] }).toString().trim();
118998
- if (!p) return { ok: false, version: "" };
118999
- const version = execSync(`${name} --version`, {
119000
- stdio: ["pipe", "pipe", "pipe"]
119001
- }).toString().trim().split("\n")[0] ?? "";
119002
- return { ok: true, version };
119057
+ execFileSync("which", [command], { stdio: ["pipe", "pipe", "pipe"] });
119003
119058
  } catch {
119004
- return { ok: false, version: "" };
119059
+ return { ok: false };
119005
119060
  }
119061
+ try {
119062
+ const version = execFileSync(command, ["--version"], { stdio: ["pipe", "pipe", "pipe"] }).toString().trim().split("\n")[0];
119063
+ return { ok: true, ...version ? { version } : {} };
119064
+ } catch {
119065
+ return { ok: true };
119066
+ }
119067
+ }
119068
+ function repoLooksGitLab(urlOrSlug) {
119069
+ return /(^|[./:@-])gitlab([./:@-]|$)/i.test(urlOrSlug);
119006
119070
  }
119007
- function externalClisForBots(bots) {
119008
- const backendBins = bots.map((bot) => bot.backend ?? "claude");
119009
- return Array.from(/* @__PURE__ */ new Set(["lark-cli", ...backendBins, "glab"]));
119071
+ function botUsesGitLab(bot) {
119072
+ return bot.repos.some((repo) => repoLooksGitLab(repo.url ?? repo.slug));
119010
119073
  }
119074
+ function addCliRequirement(requirements, input) {
119075
+ const existing = requirements.get(`cli:${input.command}`);
119076
+ if (existing) {
119077
+ existing.botIds = Array.from(/* @__PURE__ */ new Set([...existing.botIds, ...input.botIds])).sort();
119078
+ if (existing.severity === "optional" && input.severity === "required") {
119079
+ existing.severity = "required";
119080
+ }
119081
+ return;
119082
+ }
119083
+ const probe = checkCli(input.command);
119084
+ requirements.set(`cli:${input.command}`, {
119085
+ id: `cli:${input.command}`,
119086
+ label: input.label ?? input.command,
119087
+ command: input.command,
119088
+ kind: "cli",
119089
+ severity: input.severity,
119090
+ ok: probe.ok,
119091
+ ...probe.version ? { version: probe.version } : {},
119092
+ reason: input.reason,
119093
+ ...input.installHint ? { installHint: input.installHint } : {},
119094
+ botIds: Array.from(new Set(input.botIds)).sort()
119095
+ });
119096
+ }
119097
+ function addSecretRequirement(requirements, input) {
119098
+ const id = `secret:${input.envName}`;
119099
+ const existing = requirements.get(id);
119100
+ if (existing) {
119101
+ existing.botIds = Array.from(/* @__PURE__ */ new Set([...existing.botIds, ...input.botIds])).sort();
119102
+ return;
119103
+ }
119104
+ const value = process.env[input.envName];
119105
+ requirements.set(id, {
119106
+ id,
119107
+ label: input.envName,
119108
+ kind: "secret",
119109
+ severity: input.severity ?? "required",
119110
+ ok: value != null && value !== "",
119111
+ reason: input.reason,
119112
+ installHint: `Set ${input.envName} in ~/.larkway/.env and restart Larkway.`,
119113
+ botIds: Array.from(new Set(input.botIds)).sort()
119114
+ });
119115
+ }
119116
+ function runtimeRequirementsForBots(bots) {
119117
+ const requirements = /* @__PURE__ */ new Map();
119118
+ if (bots.length > 0) {
119119
+ addCliRequirement(requirements, {
119120
+ command: "lark-cli",
119121
+ label: "Feishu CLI",
119122
+ severity: "required",
119123
+ botIds: bots.map((bot) => bot.id),
119124
+ reason: "Required for agents to read Feishu topic history, attachments, docs, and other context.",
119125
+ installHint: "Install and configure lark-cli, then restart Larkway."
119126
+ });
119127
+ }
119128
+ for (const bot of bots) {
119129
+ addCliRequirement(requirements, {
119130
+ command: bot.backend ?? "claude",
119131
+ severity: "required",
119132
+ botIds: [bot.id],
119133
+ reason: `Required to run this bot's ${bot.backend ?? "claude"} backend.`
119134
+ });
119135
+ if (bot.runtime === "agent_workspace" && bot.repos.length > 0) {
119136
+ const tokenEnvName = bot.git_token_env ?? bot.gitlab_token_env;
119137
+ if (tokenEnvName) {
119138
+ addSecretRequirement(requirements, {
119139
+ envName: tokenEnvName,
119140
+ botIds: [bot.id],
119141
+ severity: "optional",
119142
+ reason: "Optional Git identity material. When absent, agents use the host's normal Git auth."
119143
+ });
119144
+ } else {
119145
+ requirements.set(`secret:missing-git-token:${bot.id}`, {
119146
+ id: `secret:missing-git-token:${bot.id}`,
119147
+ label: "Git access token env",
119148
+ kind: "secret",
119149
+ severity: "optional",
119150
+ ok: false,
119151
+ reason: `Bot "${bot.id}" has repo pointers but no git_token_env. It will use the host's normal Git auth.`,
119152
+ installHint: "Add a Git access token only when you want this agent to use a specific GitHub/GitLab identity.",
119153
+ botIds: [bot.id]
119154
+ });
119155
+ }
119156
+ }
119157
+ if (botUsesGitLab(bot)) {
119158
+ addCliRequirement(requirements, {
119159
+ command: "glab",
119160
+ label: "GitLab CLI",
119161
+ severity: "optional",
119162
+ botIds: [bot.id],
119163
+ reason: "Only needed when an agent uses GitLab-specific actions such as MRs, pipelines, or releases.",
119164
+ installHint: "Install glab only for bots that need GitLab-specific workflow commands."
119165
+ });
119166
+ }
119167
+ }
119168
+ return [...requirements.values()].sort((a, b) => {
119169
+ if (a.severity !== b.severity) return a.severity === "required" ? -1 : 1;
119170
+ if (a.kind !== b.kind) return a.kind === "cli" ? -1 : 1;
119171
+ return a.label.localeCompare(b.label);
119172
+ });
119173
+ }
119174
+
119175
+ // src/main.ts
119176
+ var STATUS_WRITE_INTERVAL_MS = 3e4;
119177
+ var VERSION = resolveLarkwayVersion(import.meta.url);
119011
119178
  function printExternalCliProbe(bots) {
119012
- console.log("External CLIs:");
119013
- for (const name of externalClisForBots(bots)) {
119014
- const { ok, version } = checkCli(name);
119015
- if (ok) {
119016
- console.log(` ${name.padEnd(10)} \u2713 ${version}`);
119179
+ console.log("Runtime requirements:");
119180
+ for (const req of runtimeRequirementsForBots(bots)) {
119181
+ const icon = req.ok ? "\u2713" : "\u2717";
119182
+ const tag = req.severity === "optional" ? "optional" : "required";
119183
+ const target = req.command ?? req.label;
119184
+ const botScope = req.botIds.length > 0 ? ` [${req.botIds.join(", ")}]` : "";
119185
+ if (req.ok) {
119186
+ console.log(` ${target.padEnd(14)} ${icon} ${req.version ?? tag}${botScope}`);
119017
119187
  } else {
119018
- console.warn(` ${name.padEnd(10)} \u2717 (not found \u2014 install before running bots that use it)`);
119188
+ const message = req.kind === "secret" ? req.reason : `not found \u2014 ${req.reason}`;
119189
+ console.warn(` ${target.padEnd(14)} ${icon} (${tag}) ${message}${botScope}`);
119190
+ if (req.installHint) console.warn(` ${"".padEnd(14)} ${req.installHint}`);
119019
119191
  }
119020
119192
  }
119021
119193
  }
@@ -119049,18 +119221,16 @@ async function runV2Mode({
119049
119221
  if (bot.runtime === "agent_workspace") {
119050
119222
  const _agentTokenEnvName = bot.git_token_env ?? bot.gitlab_token_env;
119051
119223
  if (bot.repos.length > 0 && !_agentTokenEnvName) {
119052
- console.error(
119053
- `[larkway] SKIPPING bot "${bot.id}": runtime=agent_workspace has repo pointers but no git_token_env (or gitlab_token_env). v0.3 workspace repo bots must reference an explicit per-agent token env name; they do not inherit global GITLAB_TOKEN.`
119224
+ console.warn(
119225
+ `[larkway] bot "${bot.id}": runtime=agent_workspace has repo pointers but no git_token_env. Starting anyway; the agent will use the host's normal Git identity/auth.`
119054
119226
  );
119055
- continue;
119056
119227
  }
119057
119228
  if (_agentTokenEnvName) {
119058
119229
  const agentToken = process.env[_agentTokenEnvName];
119059
119230
  if (agentToken == null || agentToken === "") {
119060
- console.error(
119061
- `[larkway] SKIPPING bot "${bot.id}": runtime=agent_workspace declares token env "${_agentTokenEnvName}", but that env var is unset/empty. Set the real token in ~/.larkway/.env; v0.3 workspace bots do not fall back to global GITLAB_TOKEN.`
119231
+ console.warn(
119232
+ `[larkway] bot "${bot.id}": runtime=agent_workspace declares token env "${_agentTokenEnvName}", but that env var is unset/empty. Starting anyway; the agent will use the host's normal Git identity/auth.`
119062
119233
  );
119063
- continue;
119064
119234
  }
119065
119235
  }
119066
119236
  const permissionGate = await checkWorkspacePermissionGrant(resolveAgentWorkspacePath(bot.id), bot);
@@ -119091,7 +119261,7 @@ async function runV2Mode({
119091
119261
  const gitlabToken = tokenEnvName != null ? process.env[tokenEnvName] : void 0;
119092
119262
  if (tokenEnvName != null && (gitlabToken == null || gitlabToken === "")) {
119093
119263
  console.warn(
119094
- `[larkway] bot "${bot.id}" declares token env "${tokenEnvName}" but that env var is unset/empty \u2014 falling back to global GITLAB_TOKEN.`
119264
+ `[larkway] bot "${bot.id}" declares token env "${tokenEnvName}" but that env var is unset/empty \u2014 leaving the host Git auth environment unchanged.`
119095
119265
  );
119096
119266
  }
119097
119267
  const larkCliProfile = deriveLarkCliProfile(bot.lark_cli_profile, bot.app_id);
@@ -119162,7 +119332,7 @@ async function runV2Mode({
119162
119332
  readOnly: bot.read_only,
119163
119333
  gitlabTokenEnvName: tokenEnvName
119164
119334
  };
119165
- const effectiveGitlabToken = bot.runtime === "agent_workspace" ? gitlabToken ?? "" : gitlabToken;
119335
+ const effectiveGitlabToken = gitlabToken;
119166
119336
  const resolvedPeers = bot.peers.flatMap((peerId) => {
119167
119337
  const peer = bots.find((b) => b.id === peerId);
119168
119338
  if (!peer) return [];
@@ -119189,6 +119359,7 @@ async function runV2Mode({
119189
119359
  gitlabToken: effectiveGitlabToken,
119190
119360
  agentMemory: bot.agent_memory,
119191
119361
  larkCliProfile,
119362
+ runtimeRequirements: runtimeRequirementsForBots([bot]),
119192
119363
  recordRuntimeEvent: async (patch) => {
119193
119364
  await upsertRuntimeEvent(larkwayHome(), bot.id, patch);
119194
119365
  }
@@ -377,6 +377,8 @@ const state = {
377
377
  selected: null,
378
378
  /** Bridge 进程状态(来自 GET /api/bridge;null = 未知)。 */
379
379
  bridge: /** @type {{running:boolean,pid:number|null,platform:string,mode:string}|null} */ (null),
380
+ /** Runtime requirement diagnostics from GET /api/runtime/requirements. */
381
+ requirements: /** @type {{requirements:Array<any>,missingRequired:Array<any>,missingOptional:Array<any>}|null} */ (null),
380
382
  /**
381
383
  * 每个 bot 的实时在线状态(来自 GET /api/status 的 bots[]),按 id 索引。
382
384
  * "serving"=🟢正常服务中 / "degraded"=🟡连接异常 / "offline"=🔴未运行掉线 / null=状态未知。
@@ -867,6 +869,34 @@ function effLive(id) {
867
869
  return bridgeRunning ? botLiveness(id) : "offline";
868
870
  }
869
871
 
872
+ function missingRequiredForBot(id) {
873
+ const missing = state.requirements?.missingRequired;
874
+ if (!Array.isArray(missing)) return [];
875
+ return missing.filter((req) => Array.isArray(req.botIds) && req.botIds.includes(id));
876
+ }
877
+
878
+ function missingRequiredAll() {
879
+ return Array.isArray(state.requirements?.missingRequired) ? state.requirements.missingRequired : [];
880
+ }
881
+
882
+ function requirementShortLabel(req) {
883
+ if (!req) return "运行依赖";
884
+ if (req.command === "lark-cli") return "飞书 CLI";
885
+ if (req.kind === "secret") return req.label === "Git access token env" ? "Git 访问令牌" : req.label;
886
+ return req.label || req.command || "运行依赖";
887
+ }
888
+
889
+ function requirementInstallText(req) {
890
+ if (!req) return "";
891
+ if (req.command === "lark-cli") return "安装并配置 lark-cli 后重启服务。";
892
+ if (req.kind === "secret") return req.installHint || "在看板里粘贴 Git access token,或补齐 bot yaml 的 git_token_env。";
893
+ return req.installHint || "安装缺失的 CLI 后重启服务。";
894
+ }
895
+
896
+ function primaryMissingForBot(id) {
897
+ return missingRequiredForBot(id)[0] ?? null;
898
+ }
899
+
870
900
  /**
871
901
  * pendingNew:bridge 在跑且存在从未上报过 status 的 bot(unknown liveness)的数量。
872
902
  * 对应 LkServiceIndicator pendingNew prop。
@@ -988,6 +1018,8 @@ function buildStatusActionPanel(liveKey, busyAction = null, moreOpen = false) {
988
1018
  };
989
1019
 
990
1020
  const primaryBusy = busyAction === fx.primary.action;
1021
+ const heading = fx.heading;
1022
+ const say = fx.say;
991
1023
 
992
1024
  // Build "more" section for degraded
993
1025
  let moreSection = "";
@@ -1045,8 +1077,8 @@ function buildStatusActionPanel(liveKey, busyAction = null, moreOpen = false) {
1045
1077
  iconSvg + `</span>` +
1046
1078
  `<div style="min-width:0;flex:1">` +
1047
1079
  `<div style="font-size:12.5px;font-weight:700;letter-spacing:.04em;color:${sc.text};margin-bottom:4px">` +
1048
- esc(fx.heading) + `</div>` +
1049
- `<p style="margin:0;font-size:14px;line-height:1.55;color:#334155">${esc(fx.say)}</p>` +
1080
+ esc(heading) + `</div>` +
1081
+ `<p style="margin:0;font-size:14px;line-height:1.55;color:#334155">${esc(say)}</p>` +
1050
1082
  `<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:13px;align-items:center">` +
1051
1083
  fixBtnPrimary(primaryBusy) +
1052
1084
  (fx.secondary ? fixBtnSecondary(fx.secondary) : "") +
@@ -2295,37 +2327,44 @@ function normalizeRepos(raw) {
2295
2327
  if (!Array.isArray(raw)) return [];
2296
2328
  return raw.map((r) => {
2297
2329
  if (typeof r === "string") {
2298
- const [slug, branch = "master"] = r.split(":").map((s) => s.trim());
2330
+ const [slug, branch = "main"] = r.split(":").map((s) => s.trim());
2299
2331
  return { slug, branch, url: "" };
2300
2332
  }
2301
- return { slug: r.slug ?? "", branch: r.branch ?? "master", url: r.url ?? "" };
2333
+ return { slug: r.slug ?? "", branch: r.branch ?? "main", url: r.url ?? "" };
2302
2334
  });
2303
2335
  }
2304
2336
 
2337
+ function inferRepoSlugFromUrl(url) {
2338
+ const text = String(url ?? "").trim();
2339
+ if (!text) return "";
2340
+ const stripGit = (s) => s.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
2341
+
2342
+ const scp = /^[A-Za-z0-9_.-]+@[^:\s]+:(.+)$/.exec(text);
2343
+ if (scp) return stripGit(scp[1] ?? "");
2344
+
2345
+ try {
2346
+ const parsed = new URL(text);
2347
+ return stripGit(parsed.pathname);
2348
+ } catch {
2349
+ return "";
2350
+ }
2351
+ }
2352
+
2305
2353
  /**
2306
- * 生成一个仓库行的 HTML(可读 / 可删)。
2354
+ * 生成一个仓库行的 HTML。UI 只暴露 Git 地址;slug/branch 都是内部细节。
2307
2355
  * @param {number} idx
2308
2356
  * @param {{slug:string,branch:string,url:string}} repo
2309
2357
  */
2310
2358
  function acRepoRowHTML(idx, repo) {
2311
2359
  const xIcon = `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>`;
2360
+ const displayUrl = repo.url || "";
2312
2361
  return (
2313
2362
  `<div class="ac-repo-row" data-repo-idx="${idx}">` +
2314
- `<div class="ac-repo-grid">` +
2315
2363
  `<div class="ac-field">` +
2316
- `<label class="ac-label" for="ac-repo-slug-${idx}">组 / 项目</label>` +
2317
- `<input id="ac-repo-slug-${idx}" class="ac-input ac-mono" type="text" placeholder="chuckwu0/larkway" spellcheck="false" data-repo="slug" data-repo-idx="${idx}" value="${esc(repo.slug)}" />` +
2318
- `</div>` +
2319
- `<div class="ac-field">` +
2320
- `<label class="ac-label" for="ac-repo-branch-${idx}">分支</label>` +
2321
- `<input id="ac-repo-branch-${idx}" class="ac-input ac-mono" type="text" placeholder="main" spellcheck="false" data-repo="branch" data-repo-idx="${idx}" value="${esc(repo.branch)}" />` +
2322
- `</div>` +
2323
- `</div>` +
2324
- `<div class="ac-field" style="margin-top:8px">` +
2325
- `<label class="ac-label" for="ac-repo-url-${idx}">clone 地址 <span class="ac-optional">私有库必需</span></label>` +
2326
- `<input id="ac-repo-url-${idx}" class="ac-input ac-mono" type="text" placeholder="git@github.com:org/repo.git" spellcheck="false" data-repo="url" data-repo-idx="${idx}" value="${esc(repo.url)}" />` +
2364
+ `<label class="ac-label" for="ac-repo-url-${idx}">仓库 Git 地址 <span class="ac-required">必填</span></label>` +
2365
+ `<input id="ac-repo-url-${idx}" class="ac-input ac-mono" type="text" placeholder="git@github.com:org/repo.git" spellcheck="false" data-repo="url" data-repo-idx="${idx}" value="${esc(displayUrl)}" />` +
2327
2366
  `</div>` +
2328
- `<button type="button" class="ac-repo-del" title="移除这个仓库" data-repo-idx="${idx}" aria-label="移除仓库 ${esc(repo.slug || idx + 1)}">` +
2367
+ `<button type="button" class="ac-repo-del" title="移除这个仓库" data-repo-idx="${idx}" aria-label="移除仓库 ${esc(repo.slug || repo.url || idx + 1)}">` +
2329
2368
  xIcon +
2330
2369
  `</button>` +
2331
2370
  `</div>`
@@ -2367,7 +2406,8 @@ function buildAgentConfigHTML(bot, memContent, mode, prefill) {
2367
2406
  // 层二 summary 药丸(收起态)
2368
2407
  const permSummaryHTML = codeAccess
2369
2408
  ? acPillHTML("brand", "m16 18 6-6-6-6M8 6l-6 6 6 6", "可访问代码仓库") +
2370
- acPillHTML("muted", "M21 8 12 3 3 8v8l9 5 9-5ZM3 8l9 5 9-5M12 13v8", repos.length ? `预热 ${repos.length} 个仓库` : "agent 自己 clone")
2409
+ acPillHTML("muted", "M21 8 12 3 3 8v8l9 5 9-5ZM3 8l9 5 9-5M12 13v8", repos.length ? `${repos.length} 个仓库` : "待添加仓库") +
2410
+ acPillHTML("muted", "M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4", gitlabConfigured ? "Agent 级 Git 身份" : "沿用本机 Git 身份")
2371
2411
  : acPillHTML("muted", "M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4", "纯对话 · 不碰任何代码仓库");
2372
2412
 
2373
2413
  // 层三 summary 药丸
@@ -2450,7 +2490,7 @@ function buildAgentConfigHTML(bot, memContent, mode, prefill) {
2450
2490
  `<div class="ac-access-toggle-row${codeAccess ? " is-on" : ""}" id="ac-access-row">` +
2451
2491
  `<div class="ac-access-info">` +
2452
2492
  `<div class="ac-access-title">给它访问代码仓库的权限</div>` +
2453
- `<p class="ac-access-desc">通过配一个 <b>Git 访问令牌</b> 来表达。读和改都用这一个令牌 —— <b>没有读 / 写之分</b>,agent 看任务自己定。不开 = 纯对话 / 自带知识答疑。</p>` +
2493
+ `<p class="ac-access-desc">打开后给这个 Agent 配一组仓库地址;它只有这些 repo 作为默认工作范围。Git 身份是 Agent 级的:不填就沿用这台机器现有的 SSH key、credential helper 或环境变量。</p>` +
2454
2494
  `</div>` +
2455
2495
  `<button type="button" class="ac-toggle${codeAccess ? " is-on" : ""}" id="ac-code-access-btn" role="switch" aria-checked="${codeAccess}" title="开 / 关代码访问权限">` +
2456
2496
  `<span class="ac-toggle-thumb"></span>` +
@@ -2461,40 +2501,43 @@ function buildAgentConfigHTML(bot, memContent, mode, prefill) {
2461
2501
  `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;color:var(--faint)"><path d="M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4"/></svg>` +
2462
2502
  `当前这个 agent 不访问任何代码仓库。需要它读 / 改代码时,再打开上面的开关。` +
2463
2503
  `</div>` +
2464
- // 开启态:令牌 + 仓库
2504
+ // 开启态:仓库 + Agent 级 Git 身份
2465
2505
  `<div class="ac-access-fields" id="ac-access-fields" ${codeAccess ? "" : 'style="display:none"'}>` +
2506
+ // 仓库列表
2507
+ `<div class="ac-repos-section">` +
2508
+ `<div class="ac-repos-header">` +
2509
+ `<span class="ac-repos-title">这个 Agent 可碰的仓库</span>` +
2510
+ `<span class="ac-optional-badge">可多个</span>` +
2511
+ `</div>` +
2512
+ `<p class="ac-hint">每行只填 clone 地址。分支默认 main;同一个 Agent 的所有仓库共用下面的 Git 身份。</p>` +
2513
+ `<div class="ac-repos-empty" id="ac-repos-empty" ${repos.length ? 'style="display:none"' : ""}><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"/></svg>还没有仓库。需要访问代码时,请添加仓库 Git 地址。</div>` +
2514
+ `<div class="ac-repos-list" id="ac-repos-list" ${repos.length ? "" : 'style="display:none"'}>${repos.map((r, i) => acRepoRowHTML(i, r)).join("")}</div>` +
2515
+ `<button type="button" class="ac-add-repo-btn" id="ac-add-repo-btn">` +
2516
+ `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>` +
2517
+ ` 添加仓库` +
2518
+ `</button>` +
2519
+ `</div>` + // ac-repos-section
2520
+ `<details class="ac-token-advanced">` +
2521
+ `<summary>Git 身份 <span>可选,作用于上面所有仓库</span></summary>` +
2466
2522
  `<div class="ac-field">` +
2467
- `<label class="ac-label" for="ac-gitlab-token">Git Access Token</label>` +
2468
- `<p class="ac-hint">把 Git access token 粘这里。只存本机 <code>~/.larkway/.env</code>(权限 0600),不回显、不外发。</p>` +
2523
+ `<label class="ac-label" for="ac-gitlab-token">Agent 级 Git Access Token <span class="ac-optional">可选</span></label>` +
2524
+ `<p class="ac-hint">只配置一个,供这个 Agent 的所有仓库使用。GitHub fine-grained PAT 可以在 GitHub 里只勾选这些仓库;这里只保存 token 真值到本机 <code>~/.larkway/.env</code>(权限 0600),页面不回显。</p>` +
2469
2525
  (gitlabConfigured
2470
2526
  ? `<div class="ac-token-configured" id="ac-token-configured">` +
2471
- `<span class="ac-token-mask">${ICONS.lock} 已配置 <span style="letter-spacing:.12em">••••••</span></span>` +
2527
+ `<span class="ac-token-mask">${ICONS.lock} 已配置此 Agent 的 Git 身份 <span style="letter-spacing:.12em">••••••</span></span>` +
2472
2528
  `<button type="button" class="btn btn-sm ac-token-reset-btn" id="ac-token-reset-btn">重新设置</button>` +
2473
2529
  `</div>` +
2474
2530
  `<div class="ac-secret-wrap" id="ac-token-input-wrap" style="display:none">` +
2475
2531
  `<svg class="ac-secret-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4"/></svg>` +
2476
- `<input id="ac-gitlab-token" name="gitlab_token_value" class="ac-input ac-secret-input" type="password" autocomplete="new-password" placeholder="粘贴新 token(只存本机,不回显)" value="" />` +
2532
+ `<input id="ac-gitlab-token" name="gitlab_token_value" class="ac-input ac-secret-input" type="password" autocomplete="new-password" placeholder="粘贴新的 Agent 级 token" value="" />` +
2477
2533
  `</div>`
2478
2534
  : `<div class="ac-secret-wrap" id="ac-token-input-wrap">` +
2479
2535
  `<svg class="ac-secret-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4"/></svg>` +
2480
- `<input id="ac-gitlab-token" name="gitlab_token_value" class="ac-input ac-secret-input" type="password" autocomplete="new-password" placeholder="粘贴 Git access token(只存本机,不回显)" value="" />` +
2536
+ `<input id="ac-gitlab-token" name="gitlab_token_value" class="ac-input ac-secret-input" type="password" autocomplete="new-password" placeholder="可留空,或粘贴 Agent token" value="" />` +
2481
2537
  `</div>`
2482
2538
  ) +
2483
2539
  `</div>` +
2484
- // 仓库列表
2485
- `<div class="ac-repos-section">` +
2486
- `<div class="ac-repos-header">` +
2487
- `<span class="ac-repos-title">预热这些仓库</span>` +
2488
- `<span class="ac-optional-badge">可选</span>` +
2489
- `</div>` +
2490
- `<p class="ac-hint">列出主要会用到的仓库 → 我们提前 clone 好,agent 一上来就有,提速。<b>留空也行</b> —— agent 会用上面的令牌自己判断需要哪些、自己 clone。</p>` +
2491
- `<div class="ac-repos-empty" id="ac-repos-empty" ${repos.length ? 'style="display:none"' : ""}><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"/></svg>未列仓库 —— agent 会用令牌自己理解需要哪些、自己 clone。</div>` +
2492
- `<div class="ac-repos-list" id="ac-repos-list" ${repos.length ? "" : 'style="display:none"'}>${repos.map((r, i) => acRepoRowHTML(i, r)).join("")}</div>` +
2493
- `<button type="button" class="ac-add-repo-btn" id="ac-add-repo-btn">` +
2494
- `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>` +
2495
- ` 添加仓库` +
2496
- `</button>` +
2497
- `</div>` + // ac-repos-section
2540
+ `</details>` +
2498
2541
  `</div>` + // ac-access-fields
2499
2542
  `</div>` + // ac-perm-body
2500
2543
  `</section>` +
@@ -2652,17 +2695,9 @@ function wireAgentConfigEvents(panel, id, bot) {
2652
2695
  if (accessRow) accessRow.classList.toggle("is-on", on);
2653
2696
  if (noAccessHint) noAccessHint.style.display = on ? "none" : "";
2654
2697
  if (accessFields) accessFields.style.display = on ? "" : "none";
2655
- // 关 → 清令牌 + 仓库
2656
- if (!on) {
2657
- if (gitlabInput) gitlabInput.value = "";
2658
- // 隐藏密码框,重置 tokenReset 标记(关闭等于「清除」)
2659
- const configured = ac.querySelector("#ac-token-configured");
2660
- const inputWrap = ac.querySelector("#ac-token-input-wrap");
2661
- if (configured) configured.style.display = "none";
2662
- if (inputWrap) { inputWrap.style.display = ""; } // 关闭态直接露空框,保存时会带 ""
2663
- delete ac.dataset.tokenReset;
2664
- acClearRepos(ac);
2665
- }
2698
+ // 关掉代码访问只改变本次保存的序列化结果,不立即清空表单里的仓库行。
2699
+ // 这样用户误点开关后再打开,未保存的仓库地址还在。
2700
+ if (!on && gitlabInput) gitlabInput.value = "";
2666
2701
  // open 徽标
2667
2702
  if (permLayerTitleRow) {
2668
2703
  let badge = permLayerTitleRow.querySelector(".ac-open-badge");
@@ -2770,11 +2805,10 @@ function readAgentConfigValues(panel) {
2770
2805
  if (codeAccess) {
2771
2806
  const repoRows = ac.querySelectorAll(".ac-repo-row");
2772
2807
  for (const row of repoRows) {
2773
- const slug = (row.querySelector("[data-repo='slug']")?.value ?? "").trim();
2774
- const branch = (row.querySelector("[data-repo='branch']")?.value ?? "").trim() || "master";
2775
- const url = (row.querySelector("[data-repo='url']")?.value ?? "").trim();
2776
- if (slug) {
2777
- const r = { slug, branch };
2808
+ const url = (row.querySelector("[data-repo='url']")?.value ?? "").trim();
2809
+ const inferredSlug = inferRepoSlugFromUrl(url);
2810
+ if (inferredSlug || url) {
2811
+ const r = { slug: inferredSlug, branch: "main" };
2778
2812
  if (url) r.url = url;
2779
2813
  repos.push(r);
2780
2814
  }
@@ -2803,12 +2837,46 @@ function readAgentConfigValues(panel) {
2803
2837
 
2804
2838
  function acGetRepos(ac) {
2805
2839
  const rows = ac.querySelectorAll(".ac-repo-row");
2806
- return Array.from(rows).map((row) => ({
2807
- slug: (row.querySelector("[data-repo='slug']")?.value ?? "").trim(),
2808
- branch: (row.querySelector("[data-repo='branch']")?.value ?? "").trim() || "master",
2809
- url: (row.querySelector("[data-repo='url']")?.value ?? "").trim(),
2810
- _idx: parseInt(row.dataset.repoIdx ?? "-1", 10),
2811
- }));
2840
+ return Array.from(rows).map((row) => {
2841
+ const url = (row.querySelector("[data-repo='url']")?.value ?? "").trim();
2842
+ return {
2843
+ slug: inferRepoSlugFromUrl(url),
2844
+ branch: "main",
2845
+ url,
2846
+ _idx: parseInt(row.dataset.repoIdx ?? "-1", 10),
2847
+ };
2848
+ });
2849
+ }
2850
+
2851
+ function validateCodeAccessConfig(panel) {
2852
+ const ac = panel?.querySelector?.("#ac-panel") ?? panel;
2853
+ if (!ac) return true;
2854
+ const codeAccessBtn = ac.querySelector("#ac-code-access-btn");
2855
+ const codeAccess = codeAccessBtn?.getAttribute("aria-checked") === "true";
2856
+ if (!codeAccess) return true;
2857
+
2858
+ const repos = acGetRepos(ac);
2859
+ if (repos.length === 0) {
2860
+ toast("已打开代码访问,请至少添加一个仓库。", "warn");
2861
+ ac.querySelector("#ac-add-repo-btn")?.focus();
2862
+ return false;
2863
+ }
2864
+
2865
+ for (const repo of repos) {
2866
+ const row = ac.querySelector(`.ac-repo-row[data-repo-idx="${repo._idx}"]`);
2867
+ const urlInput = row?.querySelector("[data-repo='url']");
2868
+ if (!repo.url) {
2869
+ toast("请填写代码仓库 Git 地址。Git Access Token 是选填。", "warn");
2870
+ urlInput?.focus();
2871
+ return false;
2872
+ }
2873
+ if (!repo.slug) {
2874
+ toast("无法从 Git 地址识别仓库,请检查地址格式。", "warn");
2875
+ urlInput?.focus();
2876
+ return false;
2877
+ }
2878
+ }
2879
+ return true;
2812
2880
  }
2813
2881
 
2814
2882
  function acRebuildRepoRows(ac) {
@@ -2836,8 +2904,7 @@ function acAddRepo(ac) {
2836
2904
  list.appendChild(tmp.firstElementChild);
2837
2905
  list.style.display = "";
2838
2906
  if (empty) empty.style.display = "none";
2839
- // 聚焦新行 slug 输入
2840
- list.querySelector(`#ac-repo-slug-${newIdx}`)?.focus();
2907
+ list.querySelector(`#ac-repo-url-${newIdx}`)?.focus();
2841
2908
  acUpdatePermSummary(ac);
2842
2909
  ac.dispatchEvent(new Event("ac-change", { bubbles: true }));
2843
2910
  }
@@ -2855,14 +2922,6 @@ function acRemoveRepo(ac, idx) {
2855
2922
  ac.dispatchEvent(new Event("ac-change", { bubbles: true }));
2856
2923
  }
2857
2924
 
2858
- function acClearRepos(ac) {
2859
- const list = ac.querySelector("#ac-repos-list");
2860
- const empty = ac.querySelector("#ac-repos-empty");
2861
- if (list) { list.innerHTML = ""; list.style.display = "none"; }
2862
- if (empty) empty.style.display = "";
2863
- acUpdatePermSummary(ac);
2864
- }
2865
-
2866
2925
  // ── summary 药丸实时更新 ─────────────────────────────────────────────────────
2867
2926
 
2868
2927
  function acUpdatePermSummary(ac) {
@@ -2874,11 +2933,19 @@ function acUpdatePermSummary(ac) {
2874
2933
  const repoCount = ac.querySelectorAll(".ac-repo-row").length;
2875
2934
  const html = codeAccess
2876
2935
  ? acPillHTML("brand", "m16 18 6-6-6-6M8 6l-6 6 6 6", "可访问代码仓库") +
2877
- acPillHTML("muted", "M21 8 12 3 3 8v8l9 5 9-5ZM3 8l9 5 9-5M12 13v8", repoCount ? `预热 ${repoCount} 个仓库` : "agent 自己 clone")
2936
+ acPillHTML("muted", "M21 8 12 3 3 8v8l9 5 9-5ZM3 8l9 5 9-5M12 13v8", repoCount ? `${repoCount} 个仓库` : "待添加仓库") +
2937
+ acPillHTML("muted", "M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4", acHasAgentGitIdentity(ac) ? "Agent 级 Git 身份" : "沿用本机 Git 身份")
2878
2938
  : acPillHTML("muted", "M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4", "纯对话 · 不碰任何代码仓库");
2879
2939
  summaryEl.innerHTML = html;
2880
2940
  }
2881
2941
 
2942
+ function acHasAgentGitIdentity(ac) {
2943
+ const typedValue = ac.querySelector("#ac-gitlab-token")?.value ?? "";
2944
+ if (typedValue.trim()) return true;
2945
+ const configuredEl = ac.querySelector("#ac-token-configured");
2946
+ return !!(configuredEl && configuredEl.style.display !== "none");
2947
+ }
2948
+
2882
2949
  function acUpdateAdvSummary(ac) {
2883
2950
  const summaryEl = ac.querySelector("#ac-adv-summary");
2884
2951
  if (!summaryEl) return;
@@ -3400,7 +3467,7 @@ function readFormValues(form) {
3400
3467
  .map((s) => s.trim())
3401
3468
  .filter(Boolean)
3402
3469
  .map((line) => {
3403
- const [slug, branch = "master"] = line.split(":").map((p) => p.trim());
3470
+ const [slug, branch = "main"] = line.split(":").map((p) => p.trim());
3404
3471
  return { slug, branch };
3405
3472
  });
3406
3473
  const peers = peersRaw
@@ -3504,6 +3571,8 @@ async function saveMemory(panel, id) {
3504
3571
  * memory 随「保存」一起提交。序列化字段见 readAgentConfigValues。
3505
3572
  */
3506
3573
  async function saveAcBot(panel, id) {
3574
+ if (!validateCodeAccessConfig(panel)) return;
3575
+
3507
3576
  const vals = readAgentConfigValues(panel);
3508
3577
  const memContent = vals._memContent ?? "";
3509
3578
 
@@ -3602,22 +3671,10 @@ function renderServiceIndicator() {
3602
3671
  icon + label +
3603
3672
  `</button>`;
3604
3673
 
3605
- if (!running) {
3606
- // running=false 红报警条(+ indigo 启动按钮,只读时省略)
3607
- container.innerHTML =
3608
- `<div class="lk-svc-indicator lk-svc-indicator--offline">` +
3609
- `<span class="lk-svc-label">` +
3610
- `<span class="bridge-dot is-stopped" style="margin-right:6px"></span>` +
3611
- `服务未运行` +
3612
- `<span class="lk-svc-sublabel"> · 助手都不会回复</span>` +
3613
- `</span>` +
3614
- svcBtn("btn-svc-start", "#fecaca", ICONS.zap, "启动服务", "start") +
3615
- `</div>`;
3616
- container.querySelector("#btn-svc-start")?.addEventListener("click", () => doFixAction("start"));
3617
- return;
3618
- }
3619
-
3620
- // ── BL-18:重启过渡态 ─────────────────────────
3674
+ // ── BL-18:启动/重启过渡态 ─────────────────────
3675
+ // Must run before the `!running` branch: after clicking "启动服务", the
3676
+ // backend has accepted the start but detectBridgeStatus may still report
3677
+ // running=false for a few seconds while bridge boots and bots reconnect.
3621
3678
  if (!readonly) {
3622
3679
  const rs = state.restart;
3623
3680
 
@@ -3664,6 +3721,21 @@ function renderServiceIndicator() {
3664
3721
  }
3665
3722
  }
3666
3723
 
3724
+ if (!running) {
3725
+ // running=false → 红报警条(+ indigo 启动按钮,只读时省略)
3726
+ container.innerHTML =
3727
+ `<div class="lk-svc-indicator lk-svc-indicator--offline">` +
3728
+ `<span class="lk-svc-label">` +
3729
+ `<span class="bridge-dot is-stopped" style="margin-right:6px"></span>` +
3730
+ `服务未运行` +
3731
+ `<span class="lk-svc-sublabel"> · 助手都不会回复</span>` +
3732
+ `</span>` +
3733
+ svcBtn("btn-svc-start", "#fecaca", ICONS.zap, "启动服务", "start") +
3734
+ `</div>`;
3735
+ container.querySelector("#btn-svc-start")?.addEventListener("click", () => doFixAction("start"));
3736
+ return;
3737
+ }
3738
+
3667
3739
  // 综合 pendingRestart(后端感知新增/已删)与本地 pendingNewCount(客户端推算)
3668
3740
  // 后端字段更权威;本地计算作回退(后端未返回时)。
3669
3741
  const pr = state.pendingRestart;
@@ -3781,29 +3853,33 @@ async function doFixAction(action, triggerEl = null) {
3781
3853
  const errMsg = res.json?.message ?? res.json?.error ?? res.status;
3782
3854
  toast(`操作失败:${errMsg}`, "error");
3783
3855
  // Refresh status even on failure
3856
+ await refreshRuntimeRequirements();
3784
3857
  await refreshBridgeStatus();
3785
3858
  await pollStatus();
3786
3859
  if (state.selected) rerenderStatusAction(state.selected);
3787
3860
  return;
3788
3861
  }
3789
3862
 
3790
- // ── BL-18:POST 成功后 → 进入 restarting 过渡态(restart 动作) ─────────
3791
- if (action === "restart") {
3792
- // 重置任何旧的机器状态,进入 restarting
3793
- stopRestartTicker();
3794
- state.restart = { status: "restarting", startedAt: Date.now(), elapsed: 0 };
3795
- toast("正在重启服务 —— 好了会自动转回正常,不用重复点", "info");
3796
- // 全量刷新三触点(顶栏+名册+hero)
3797
- renderServiceIndicator();
3798
- renderBotList();
3799
- if (state.selected) refreshDetailHero();
3800
- // 启动 1s ticker 刷新已用时显示
3801
- startRestartTicker();
3802
- } else {
3803
- toast("服务已启动", "ok");
3804
- }
3863
+ // ── BL-18:POST 成功后 → 进入 restarting 过渡态(start/restart 都走同一套) ──
3864
+ // "启动服务" "重启服务" 都调用同一个后端 restart endpoint。返回成功只
3865
+ // 代表 supervisor 已接受请求,不代表 bridge 已连回飞书或 bot 已写心跳。
3866
+ stopRestartTicker();
3867
+ state.restart = { status: "restarting", startedAt: Date.now(), elapsed: 0 };
3868
+ toast(
3869
+ action === "start"
3870
+ ? "正在启动服务 —— 连上后会自动转回正常,不用重复点"
3871
+ : "正在重启服务 —— 好了会自动转回正常,不用重复点",
3872
+ "info",
3873
+ );
3874
+ // 全量刷新三触点(顶栏+名册+hero)
3875
+ renderServiceIndicator();
3876
+ renderBotList();
3877
+ if (state.selected) refreshDetailHero();
3878
+ // 启动 1s ticker 刷新已用时显示
3879
+ startRestartTicker();
3805
3880
 
3806
3881
  // Refresh bridge + liveness
3882
+ await refreshRuntimeRequirements();
3807
3883
  await refreshBridgeStatus();
3808
3884
  await pollStatus();
3809
3885
  if (state.selected) rerenderStatusAction(state.selected);
@@ -3819,6 +3895,7 @@ async function doFixAction(action, triggerEl = null) {
3819
3895
  const BURST_INTERVAL = 2000;
3820
3896
  _restartPollHandle = setInterval(async () => {
3821
3897
  burstCount++;
3898
+ await refreshRuntimeRequirements();
3822
3899
  await refreshBridgeStatus();
3823
3900
  await pollStatus();
3824
3901
  if (state.selected) rerenderStatusAction(state.selected);
@@ -4326,6 +4403,7 @@ function renderOnboardNameForm(prefill, sessionId) {
4326
4403
  async function submitOnboardName(prefill, sessionId, modal) {
4327
4404
  if (!modal) modal = document.getElementById("onboard-modal");
4328
4405
  const sid = sessionId ?? onboard.sessionId;
4406
+ if (!validateCodeAccessConfig(modal)) return;
4329
4407
 
4330
4408
  // 从 AC 面板读取表单值
4331
4409
  const vals = readAgentConfigValues(modal);
@@ -4553,6 +4631,7 @@ async function boot() {
4553
4631
 
4554
4632
  // 拉 bot 列表(先于首次状态轮询,确保左侧条目存在好让圆点落上去)
4555
4633
  await loadBots();
4634
+ await refreshRuntimeRequirements();
4556
4635
 
4557
4636
  // 拉 bridge 服务状态并渲染顶栏指示
4558
4637
  await refreshBridgeStatus();
@@ -4560,6 +4639,7 @@ async function boot() {
4560
4639
  // 首次拉状态 + 每 15s 轮询(实时在线状态可视化)
4561
4640
  await pollStatus();
4562
4641
  setInterval(pollStatus, 15000);
4642
+ setInterval(refreshRuntimeRequirements, 30000);
4563
4643
  }
4564
4644
 
4565
4645
  /**
@@ -4571,4 +4651,11 @@ async function pollStatus() {
4571
4651
  renderStatus(res.ok ? res.json : null);
4572
4652
  }
4573
4653
 
4654
+ async function refreshRuntimeRequirements() {
4655
+ const res = await api("GET", "/api/runtime/requirements");
4656
+ state.requirements = res.ok ? res.json : null;
4657
+ renderServiceIndicator();
4658
+ if (state.selected) rerenderStatusAction(state.selected);
4659
+ }
4660
+
4574
4661
  boot();
@@ -3266,18 +3266,12 @@ textarea.input {
3266
3266
  .ac-repo-row {
3267
3267
  position: relative;
3268
3268
  padding: 13px 14px;
3269
+ padding-right: 52px;
3269
3270
  border-radius: 11px;
3270
3271
  border: 1px solid var(--border);
3271
3272
  background: #fff;
3272
3273
  }
3273
3274
 
3274
- .ac-repo-grid {
3275
- display: grid;
3276
- grid-template-columns: 1fr 120px;
3277
- gap: 10px;
3278
- margin-bottom: 10px;
3279
- }
3280
-
3281
3275
  .ac-repo-del {
3282
3276
  position: absolute;
3283
3277
  top: 11px;
@@ -3358,6 +3352,39 @@ textarea.input {
3358
3352
  gap: 16px;
3359
3353
  }
3360
3354
 
3355
+ .ac-token-advanced {
3356
+ border: 1px solid var(--border);
3357
+ border-radius: 10px;
3358
+ background: #fff;
3359
+ padding: 0;
3360
+ }
3361
+
3362
+ .ac-token-advanced > summary {
3363
+ display: flex;
3364
+ align-items: baseline;
3365
+ gap: 8px;
3366
+ padding: 11px 13px;
3367
+ cursor: pointer;
3368
+ list-style: none;
3369
+ font-size: 13.5px;
3370
+ font-weight: 700;
3371
+ color: var(--text);
3372
+ }
3373
+
3374
+ .ac-token-advanced > summary::-webkit-details-marker {
3375
+ display: none;
3376
+ }
3377
+
3378
+ .ac-token-advanced > summary span {
3379
+ font-size: 12.5px;
3380
+ font-weight: 500;
3381
+ color: var(--muted);
3382
+ }
3383
+
3384
+ .ac-token-advanced > .ac-field {
3385
+ padding: 0 13px 13px;
3386
+ }
3387
+
3361
3388
  /* ── 保存栏(edit 模式 sticky,复用 .form-actions-sticky 的 sticky + gradient) ── */
3362
3389
  .ac-save-bar {
3363
3390
  display: flex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "larkway",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",