larkway 0.3.8 → 0.3.10

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.
@@ -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.10",
4
4
  "description": "Thin bridge: Feishu thread to local Claude Code CLI",
5
5
  "license": "MIT",
6
6
  "author": "Chuck Wu (chuckwu0)",
@@ -19,18 +19,6 @@
19
19
  "bin": {
20
20
  "larkway": "bin/larkway"
21
21
  },
22
- "scripts": {
23
- "start": "tsx src/main.ts",
24
- "cli": "tsx src/cli/index.ts",
25
- "start:supervised": "bin/start-bridge.sh",
26
- "dev": "tsx watch src/main.ts",
27
- "build": "node scripts/build.mjs",
28
- "prepack": "node scripts/build.mjs",
29
- "typecheck": "tsc --noEmit",
30
- "check:links": "bash bin/check-doc-links.sh",
31
- "test": "vitest run",
32
- "test:v0.3": "bash bin/v0.3-local-acceptance.sh"
33
- },
34
22
  "dependencies": {},
35
23
  "devDependencies": {
36
24
  "@types/js-yaml": "^4.0.9",
@@ -48,7 +36,6 @@
48
36
  "qrcode-terminal": "^0.12.0",
49
37
  "zod": "^3.22.0"
50
38
  },
51
- "packageManager": "pnpm@9.0.0",
52
39
  "engines": {
53
40
  "node": ">=20"
54
41
  },
@@ -56,5 +43,16 @@
56
43
  "dist/",
57
44
  "bin/larkway",
58
45
  "README.md"
59
- ]
60
- }
46
+ ],
47
+ "scripts": {
48
+ "start": "tsx src/main.ts",
49
+ "cli": "tsx src/cli/index.ts",
50
+ "start:supervised": "bin/start-bridge.sh",
51
+ "dev": "tsx watch src/main.ts",
52
+ "build": "node scripts/build.mjs",
53
+ "typecheck": "tsc --noEmit",
54
+ "check:links": "bash bin/check-doc-links.sh",
55
+ "test": "vitest run",
56
+ "test:v0.3": "bash bin/v0.3-local-acceptance.sh"
57
+ }
58
+ }