@modesto_ma/dsh-guanxiang 0.2.0-beta.2 → 0.2.0-beta.4

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/lib/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { homedir, tmpdir } from "node:os";
3
+ import { homedir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
- import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { createTwoFilesPatch, structuredPatch } from "diff";
6
7
  import { parse } from "yaml";
7
8
  import { createWriteStream } from "node:fs";
8
9
  import { Readable, Transform } from "node:stream";
9
10
  import { pipeline } from "node:stream/promises";
10
- import { execFile } from "node:child_process";
11
11
  //#region src/settings/contract.ts
12
12
  /**
13
13
  * 设置面板与 Host 路由共用的路由常量和 JSON 结构。浏览器端 bundle 也会
@@ -21,20 +21,22 @@ const STATE_ROUTE = "/guanxiang/state";
21
21
  const SETTINGS_ROUTE = "/guanxiang/settings";
22
22
  /** POST:立即重新拉取全部仓库,或 body `{ url }` 指定的一个仓库。 */
23
23
  const SYNC_ROUTE = "/guanxiang/sync";
24
- /** PUT:body `{ tokenEnv, value }` 把令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受已保存的访问令牌配置引用的名称和分析接口令牌的名称。 */
24
+ /** PUT:body `{ tokenEnv, value }` 把分析接口令牌存入 DSH 凭据库;DELETE:body `{ tokenEnv }` 删除已存的值。只接受分析接口令牌的名称。 */
25
25
  const TOKEN_ROUTE = "/guanxiang/token";
26
+ /** POST:body {@link HostAddRequest} 经 dsh-repo 添加主机;DELETE:body `{ url }` 删除面板添加的主机。 */
27
+ const HOSTS_ROUTE = "/guanxiang/hosts";
28
+ /** PUT:body `{ url, value }` 经 dsh-repo 保存主机令牌;DELETE:body `{ url }` 清除已存的令牌。 */
29
+ const HOST_TOKEN_ROUTE = "/guanxiang/host-token";
26
30
  /**
27
31
  * POST:日志平台分析接口(Bearer 令牌鉴权,不走 DSH 登录)。它在 DSH 的
28
32
  * `/api` 命名空间之外,网关可以把它和需要登录的 DSH 接口分开暴露。
29
33
  */
30
34
  const ANALYZE_ROUTE = "/open-api/logalyze";
31
- /** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier), which {@link GitCredentialSpec.tokenEnv} follows. */
35
+ /** Credential reference grammar of `@deepseek-ai/dsh-credentials` (a POSIX shell identifier). */
32
36
  const TOKEN_ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
37
  /** Keys of {@link EditableSettings}, in panel order. */
34
38
  const EDITABLE_KEYS = [
35
- "reposDir",
36
39
  "repos",
37
- "gitCredentials",
38
40
  "fetchIntervalMs",
39
41
  "maxAppsPerRequest",
40
42
  "dispatchTimeoutMs",
@@ -402,94 +404,6 @@ function mountAnalyzeRoute(ctx, gate) {
402
404
  attach(ctx);
403
405
  }
404
406
  //#endregion
405
- //#region src/repos/credentials.ts
406
- /** Environment variables the helper reads; set only on git child processes. */
407
- const USERNAME_VARIABLE = "GUANXIANG_GIT_USERNAME";
408
- const TOKEN_VARIABLE = "GUANXIANG_GIT_TOKEN";
409
- /** Answers git's `get` request from the two variables and ignores `store` and `erase`. */
410
- const HELPER = `!f() { test "$1" = get || exit 0; printf 'username=%s\\npassword=%s\\n' "$${USERNAME_VARIABLE}" "$${TOKEN_VARIABLE}"; }; f`;
411
- /** Longest accepted token; personal access tokens are far shorter. */
412
- const MAX_TOKEN_LENGTH = 4096;
413
- /**
414
- * Normalize an access-token address prefix to `scheme://host[:port][/path]`
415
- * with a lowercase host and no trailing slash.
416
- * @param value - candidate prefix.
417
- * @returns the normalized prefix, or undefined when it is not an `https`/`http` URL without credentials, query, or fragment.
418
- */
419
- function normalizeCredentialUrl(value) {
420
- let url;
421
- try {
422
- url = new URL(value);
423
- } catch {
424
- return;
425
- }
426
- if (url.protocol !== "https:" && url.protocol !== "http:") return void 0;
427
- if (url.host === "" || url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return void 0;
428
- return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
429
- }
430
- /**
431
- * Whether a token value can be handed to git: non-empty, bounded, and free of
432
- * control characters, which would break the credential helper protocol.
433
- * @param value - candidate token.
434
- * @returns whether it is accepted.
435
- */
436
- function isAcceptedToken(value) {
437
- return value.length > 0 && value.length <= 4096 && !/[\u0000-\u001f\u007f]/.test(value);
438
- }
439
- /**
440
- * The access-token configuration for a repository address: the entry whose
441
- * prefix has the same scheme and host and the longest path that contains the
442
- * repository path. scp-like addresses, SSH URLs, and local paths never match.
443
- * @param url - repository address.
444
- * @param specs - configured access tokens with normalized prefixes.
445
- * @returns the matching entry, if any.
446
- */
447
- function credentialFor(url, specs) {
448
- let repo;
449
- try {
450
- repo = new URL(url);
451
- } catch {
452
- return;
453
- }
454
- let best;
455
- for (const spec of specs) {
456
- const prefix = new URL(spec.url);
457
- if (prefix.protocol !== repo.protocol || prefix.host !== repo.host) continue;
458
- const path = prefix.pathname.replace(/\/+$/, "");
459
- if (path !== "" && repo.pathname !== path && !repo.pathname.startsWith(`${path}/`)) continue;
460
- if (best === void 0 || path.length > best.length) best = {
461
- spec,
462
- length: path.length
463
- };
464
- }
465
- return best?.spec;
466
- }
467
- /**
468
- * Environment that makes one git process answer the credential request for
469
- * the token's host with the given token. Existing `GIT_CONFIG_COUNT` entries
470
- * are kept; the helper list for that host is reset first so a stale password
471
- * from another helper cannot shadow the token.
472
- * @param spec - matching access-token configuration.
473
- * @param token - token value.
474
- * @param env - environment of the git process before this call.
475
- * @returns variables to add to the git process environment.
476
- */
477
- function credentialEnvironment(spec, token, env = process.env) {
478
- const prefix = new URL(spec.url);
479
- const key = `credential.${prefix.protocol}//${prefix.host}.helper`;
480
- const parsed = Number(env.GIT_CONFIG_COUNT ?? "0");
481
- const base = Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
482
- return {
483
- GIT_CONFIG_COUNT: String(base + 2),
484
- [`GIT_CONFIG_KEY_${base}`]: key,
485
- [`GIT_CONFIG_VALUE_${base}`]: "",
486
- [`GIT_CONFIG_KEY_${base + 1}`]: key,
487
- [`GIT_CONFIG_VALUE_${base + 1}`]: HELPER,
488
- [USERNAME_VARIABLE]: spec.username,
489
- [TOKEN_VARIABLE]: token
490
- };
491
- }
492
- //#endregion
493
407
  //#region src/config.ts
494
408
  /**
495
409
  * 插件配置:cordis.yml 中的 `config`(经 Standard Schema 在加载时校验)与
@@ -537,12 +451,16 @@ const repoSchema = z.object({
537
451
  url: z.string().trim().min(1, "git 地址不能为空").refine(isAcceptedGitUrl, "不支持的 git 地址(支持 https/ssh/git/file URL、user@host:path 或绝对路径)"),
538
452
  branch: z.string().trim().optional().transform((value) => value === "" ? void 0 : value).refine((value) => value === void 0 || isAcceptedBranch(value), "分支名不合法")
539
453
  }).strict();
540
- const credentialSchema = z.object({
541
- url: z.string().trim().refine((value) => normalizeCredentialUrl(value) !== void 0, "令牌地址不合法:填 https://主机[:端口][/路径],不含用户名、查询参数或片段").transform((value) => normalizeCredentialUrl(value)),
542
- alias: z.string().trim().max(64, "令牌别名不能超过 64 个字符").regex(/^[^\u0000-\u001f\u007f]*$/, "令牌别名不能包含控制字符").optional().transform((value) => value === "" ? void 0 : value),
543
- username: z.string().trim().regex(/^\S+$/, "用户名不能为空或包含空白").default("oauth2"),
544
- tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN, "令牌名称只能包含字母、数字和下划线,且不能以数字开头(它是凭据库中的键名,不是令牌本身)")
545
- }).strict();
454
+ /**
455
+ * An access token of earlier versions, which kept HTTPS tokens by address
456
+ * prefix. Read only to move each one to a dsh-repo host once; never written.
457
+ */
458
+ const legacyCredentialSchema = z.object({
459
+ url: z.string().trim().min(1),
460
+ alias: z.string().trim().optional(),
461
+ username: z.string().optional(),
462
+ tokenEnv: z.string().trim().regex(TOKEN_ENV_PATTERN)
463
+ }).strip();
546
464
  const hostSchema = z.string().trim().toLowerCase().transform((value) => value.replace(/^\*\./, "")).refine((value) => HOST_NAME.test(value), "域名不合法:只填主机名,例如 oss.example.com");
547
465
  const durationMs = (min) => z.number().int().min(min).max(MAX_TIMER_DELAY_MS);
548
466
  const downloadSchema = z.object({
@@ -562,7 +480,6 @@ const authorSchema = z.object({
562
480
  }).strict();
563
481
  /** Field validators shared by the cordis config and the panel overlay. */
564
482
  const editableFields = {
565
- reposDir: z.string().trim().min(1, "拉取目录不能为空"),
566
483
  repos: z.array(repoSchema).superRefine((repos, issues) => {
567
484
  const seen = /* @__PURE__ */ new Set();
568
485
  for (const [index, repo] of repos.entries()) {
@@ -574,17 +491,6 @@ const editableFields = {
574
491
  seen.add(repo.url);
575
492
  }
576
493
  }),
577
- gitCredentials: z.array(credentialSchema).superRefine((credentials, issues) => {
578
- const seen = /* @__PURE__ */ new Set();
579
- for (const [index, credential] of credentials.entries()) {
580
- if (seen.has(credential.url)) issues.addIssue({
581
- code: "custom",
582
- path: [index, "url"],
583
- message: `重复的令牌地址:${credential.url}`
584
- });
585
- seen.add(credential.url);
586
- }
587
- }),
588
494
  fetchIntervalMs: durationMs(0),
589
495
  maxAppsPerRequest: z.number().int().min(1).max(32),
590
496
  dispatchTimeoutMs: durationMs(1e3),
@@ -594,10 +500,11 @@ const editableFields = {
594
500
  };
595
501
  /** Standard Schema the Cordis loader validates `config` with before `apply()`. */
596
502
  const pluginConfigSchema = z.object({
597
- reposDir: editableFields.reposDir.optional(),
503
+ /** Ignored: dsh-repo's `mirror.dir` decides where repositories are mirrored. Accepted so older compositions still load. */
504
+ reposDir: z.string().optional(),
598
505
  repos: editableFields.repos.default([]),
599
- /** HTTPS access tokens by address prefix; each names a credential reference, never a token value. */
600
- gitCredentials: editableFields.gitCredentials.default([]),
506
+ /** Moved once to dsh-repo hosts at start; see {@link legacyCredentialSchema}. */
507
+ gitCredentials: z.array(legacyCredentialSchema).default([]),
601
508
  fetchIntervalMs: editableFields.fetchIntervalMs.default(0),
602
509
  maxAppsPerRequest: editableFields.maxAppsPerRequest.default(4),
603
510
  dispatchTimeoutMs: editableFields.dispatchTimeoutMs.default(1800 * 1e3),
@@ -610,14 +517,14 @@ const pluginConfigSchema = z.object({
610
517
  */
611
518
  analyzeTokenRef: z.string().trim().regex(TOKEN_ENV_PATTERN, "令牌名称只能包含字母、数字和下划线,且不能以数字开头").default("GUANXIANG_ANALYZE_TOKEN"),
612
519
  /**
613
- * Author and committer of SKILL improvement commits; omitted uses the git
614
- * identity configured on the host (`user.name` / `user.email`).
520
+ * Author of SKILL improvement commits; omitted uses the user of the token
521
+ * dsh-repo holds for the repository's host, who is always the committer.
615
522
  */
616
523
  skillUpdateAuthor: authorSchema.optional(),
617
524
  /** Registered `ctx.subagents` provider the children start on. */
618
525
  subagentProvider: z.string().trim().min(1).default("spawn"),
619
- /** Timeout of one git command (clone, fetch, worktree), in milliseconds. */
620
- gitTimeoutMs: durationMs(1e3).default(600 * 1e3),
526
+ /** Ignored: dsh-repo's `mirror.gitTimeoutMs` bounds git commands. Accepted so older compositions still load. */
527
+ gitTimeoutMs: z.number().optional(),
621
528
  /**
622
529
  * How long loading `/guanxiang` waits for the repository refresh before it
623
530
  * renders the catalog it already has, in milliseconds; the refresh keeps
@@ -632,9 +539,11 @@ const pluginConfigSchema = z.object({
632
539
  }).strict().prefault({});
633
540
  /** Panel overlay: any subset of the editable settings. */
634
541
  const overlaySchema = z.object({
635
- reposDir: editableFields.reposDir.optional(),
542
+ /** Ignored legacy key; the next panel save drops it. */
543
+ reposDir: z.string().optional(),
636
544
  repos: editableFields.repos.optional(),
637
- gitCredentials: editableFields.gitCredentials.optional(),
545
+ /** Legacy access tokens, moved once to dsh-repo hosts; the next panel save drops them. */
546
+ gitCredentials: z.array(legacyCredentialSchema).optional(),
638
547
  fetchIntervalMs: editableFields.fetchIntervalMs.optional(),
639
548
  maxAppsPerRequest: editableFields.maxAppsPerRequest.optional(),
640
549
  dispatchTimeoutMs: editableFields.dispatchTimeoutMs.optional(),
@@ -675,42 +584,20 @@ function resolveDshHome(env = process.env) {
675
584
  const configured = env.DSH_HOME?.trim();
676
585
  return configured !== void 0 && configured !== "" ? configured : join(homedir(), ".dsh");
677
586
  }
678
- /**
679
- * Expand `~` and make a configured directory absolute.
680
- * @param path - configured directory.
681
- * @returns absolute directory.
682
- */
683
- function expandHome(path) {
684
- if (path === "~") return homedir();
685
- if (path.startsWith("~/")) return join(homedir(), path.slice(2));
686
- return resolve(path);
687
- }
688
587
  function copyRepos(repos) {
689
588
  return repos.map((repo) => repo.branch === void 0 ? { url: repo.url } : {
690
589
  url: repo.url,
691
590
  branch: repo.branch
692
591
  });
693
592
  }
694
- function copyCredentials(credentials) {
695
- return credentials.map((credential) => ({
696
- url: credential.url,
697
- ...credential.alias === void 0 ? {} : { alias: credential.alias },
698
- username: credential.username,
699
- tokenEnv: credential.tokenEnv
700
- }));
701
- }
702
593
  /**
703
- * The editable settings a configuration implies, with `reposDir` defaulted to
704
- * `$DSH_HOME/guanxiang/repos`.
594
+ * The editable settings a configuration implies.
705
595
  * @param config - validated plugin configuration.
706
- * @param env - process environment.
707
596
  * @returns complete editable settings.
708
597
  */
709
- function defaultsOf(config, env = process.env) {
598
+ function defaultsOf(config) {
710
599
  return {
711
- reposDir: config.reposDir ?? join(resolveDshHome(env), "guanxiang", "repos"),
712
600
  repos: copyRepos(config.repos),
713
- gitCredentials: copyCredentials(config.gitCredentials),
714
601
  fetchIntervalMs: config.fetchIntervalMs,
715
602
  maxAppsPerRequest: config.maxAppsPerRequest,
716
603
  dispatchTimeoutMs: config.dispatchTimeoutMs,
@@ -731,9 +618,7 @@ function defaultsOf(config, env = process.env) {
731
618
  */
732
619
  function applyOverlay(defaults, overlay) {
733
620
  return {
734
- reposDir: overlay.reposDir ?? defaults.reposDir,
735
621
  repos: overlay.repos === void 0 ? defaults.repos : copyRepos(overlay.repos),
736
- gitCredentials: overlay.gitCredentials === void 0 ? defaults.gitCredentials : copyCredentials(overlay.gitCredentials),
737
622
  fetchIntervalMs: overlay.fetchIntervalMs ?? defaults.fetchIntervalMs,
738
623
  maxAppsPerRequest: overlay.maxAppsPerRequest ?? defaults.maxAppsPerRequest,
739
624
  dispatchTimeoutMs: overlay.dispatchTimeoutMs ?? defaults.dispatchTimeoutMs,
@@ -743,6 +628,80 @@ function applyOverlay(defaults, overlay) {
743
628
  };
744
629
  }
745
630
  //#endregion
631
+ //#region src/repos/lookup.ts
632
+ /** Name dsh-repo provides the service under. */
633
+ const DSH_REPOS_SERVICE = "dshRepos";
634
+ /** Oldest dsh-repo release providing every method {@link REQUIRED_METHODS} lists. */
635
+ const DSH_REPO_MIN_RELEASE = "0.1.0-beta.3";
636
+ /** Methods this plugin calls; `getBranch` also marks a provider that honors `baseSha` and `newBranch` on commits. */
637
+ const REQUIRED_METHODS = [
638
+ "getBranch",
639
+ "commitFiles",
640
+ "createChange",
641
+ "listHosts",
642
+ "addHost",
643
+ "removeHost",
644
+ "setHostToken",
645
+ "mirrorSettings",
646
+ "syncMirror",
647
+ "readMirrorFile",
648
+ "listMirrorTree",
649
+ "acquireSnapshot"
650
+ ];
651
+ /**
652
+ * Classify the value `ctx.get('dshRepos')` returned. The provider is another
653
+ * package that may be older or newer than this one, so its version and
654
+ * methods are checked at run time.
655
+ * @param value - the looked-up service, or undefined.
656
+ * @returns the usable service, or why there is none.
657
+ */
658
+ function lookupRepos(value) {
659
+ if (value === void 0 || value === null) return { availability: "missing" };
660
+ const service = value;
661
+ if (service.version !== 1 || REQUIRED_METHODS.some((method) => typeof service[method] !== "function")) return { availability: "incompatible" };
662
+ return {
663
+ availability: "ready",
664
+ service: value
665
+ };
666
+ }
667
+ /**
668
+ * The failure class of a `dshRepos` rejection.
669
+ * @param error - the rejection.
670
+ * @returns its `code` when it is a `RepoError`.
671
+ */
672
+ function repoErrorCode(error) {
673
+ if (!(error instanceof Error) || error.name !== "RepoError") return void 0;
674
+ const code = error.code;
675
+ return typeof code === "string" ? code : void 0;
676
+ }
677
+ /**
678
+ * Why the service cannot be used, as one line for the panel and tool errors.
679
+ * @param availability - lookup outcome other than `ready`.
680
+ * @returns the reason and the fix.
681
+ */
682
+ function unavailableMessage(availability) {
683
+ return availability === "missing" ? `观象需要 dsh-repo 插件提供的 ${DSH_REPOS_SERVICE} 服务来拉取仓库和提交 SKILL 修改;请安装 dsh-repo` : `dsh-repo 的 ${DSH_REPOS_SERVICE} 服务与观象不兼容(需要服务版本 1,即 dsh-repo ${DSH_REPO_MIN_RELEASE} 及以上);请升级 dsh-repo`;
684
+ }
685
+ /**
686
+ * The usable service, or a thrown error saying why there is none.
687
+ * @param value - the looked-up service, or undefined.
688
+ * @returns the service.
689
+ * @throws Error with {@link unavailableMessage} when it is missing or incompatible.
690
+ */
691
+ function requireRepos(value) {
692
+ const lookup = lookupRepos(value);
693
+ if (lookup.availability !== "ready") throw new Error(unavailableMessage(lookup.availability));
694
+ return lookup.service;
695
+ }
696
+ /**
697
+ * Readable message of a `dshRepos` rejection or any other thrown value.
698
+ * @param error - the rejection.
699
+ * @returns its message.
700
+ */
701
+ function messageOf$1(error) {
702
+ return error instanceof Error ? error.message : String(error);
703
+ }
704
+ //#endregion
746
705
  //#region src/abort.ts
747
706
  /**
748
707
  * Cancellation helpers shared by the entry skill and the dispatch tool.
@@ -772,218 +731,58 @@ async function abortable(promise, signal) {
772
731
  }
773
732
  }
774
733
  //#endregion
775
- //#region src/skill-update/git.ts
734
+ //#region src/skill-update/change.ts
776
735
  /**
777
- * 在拉取仓库中用 git 底层命令生成 SKILL 修改的提交并推送:用临时索引文件
778
- * 在分析时的提交上替换文件、写出树对象,不改动任何检出、快照或本地分支;
779
- * 推送时用 GitLab push options 顺带创建合并请求。
736
+ * 检查 SKILL 修改:从 dsh-repo 镜像读取分析时的提交中 SKILL 目录的文件,与提议
737
+ * 的文件比较,算出逐文件行数和 unified diff,不改动任何镜像或快照。提议内容的
738
+ * 摘要用作新分支名,提交本身经 dsh-repo 完成。
780
739
  *
781
- * @module dsh-guanxiang/skill-update/git
782
- */
783
- const DIFF_FLAGS = [
784
- "--no-color",
785
- "--no-ext-diff",
786
- "--no-renames"
787
- ];
788
- /**
789
- * Write the proposed files over the skill directory of `base` into a new tree.
790
- * @param commands - git runner and timeout.
791
- * @param dir - repository directory whose object database receives the objects.
792
- * @param base - commit the analysis ran on.
793
- * @param skillDir - skill directory relative to the repository root, `/`-separated.
794
- * @param files - proposed files relative to `skillDir`.
795
- * @returns the tree, per-file line counts, and the diff; `files` is empty when nothing changes.
796
- * @throws when a proposed path replaces a symbolic link or a directory, or a git command fails.
797
- */
798
- async function prepareChange(commands, dir, base, skillDir, files) {
799
- const { git, timeoutMs } = commands;
800
- const scratch = await mkdtemp(join(tmpdir(), "guanxiang-index-"));
801
- try {
802
- const env = { GIT_INDEX_FILE: join(scratch, "index") };
803
- await git(["read-tree", base], {
804
- cwd: dir,
805
- timeoutMs,
806
- env
807
- });
808
- const listing = await git([
809
- "ls-tree",
810
- "-r",
811
- "-t",
812
- "-z",
813
- base,
814
- "--",
815
- `${skillDir}/`
816
- ], {
817
- cwd: dir,
818
- timeoutMs
819
- });
820
- const modes = /* @__PURE__ */ new Map();
821
- for (const record of listing.split("\0")) {
822
- const tab = record.indexOf(" ");
823
- if (tab < 0) continue;
824
- modes.set(record.slice(tab + 1), record.slice(0, record.indexOf(" ")));
825
- }
826
- for (const file of files) {
827
- const path = `${skillDir}/${file.path}`;
828
- const existing = modes.get(path);
829
- if (existing === "120000") throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`);
830
- if (existing === "040000") throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`);
831
- const blob = (await git([
832
- "hash-object",
833
- "-w",
834
- "--stdin"
835
- ], {
836
- cwd: dir,
837
- timeoutMs,
838
- input: file.content
839
- })).trim();
840
- await git([
841
- "update-index",
842
- "--add",
843
- "--cacheinfo",
844
- `${existing === "100755" ? "100755" : "100644"},${blob},${path}`
845
- ], {
846
- cwd: dir,
847
- timeoutMs,
848
- env
849
- });
850
- }
851
- const tree = (await git(["write-tree"], {
852
- cwd: dir,
853
- timeoutMs,
854
- env
855
- })).trim();
856
- const stats = (await git([
857
- "diff",
858
- ...DIFF_FLAGS,
859
- "--numstat",
860
- base,
861
- tree,
862
- "--",
863
- `${skillDir}/`
864
- ], {
865
- cwd: dir,
866
- timeoutMs
867
- })).split("\n").filter((line) => line !== "").map((line) => {
868
- const [added = "-", removed = "-", ...path] = line.split(" ");
869
- return {
870
- path: path.join(" "),
871
- ...added === "-" ? {} : { added: Number(added) },
872
- ...removed === "-" ? {} : { removed: Number(removed) }
873
- };
874
- });
875
- return {
876
- base,
877
- tree,
878
- files: stats,
879
- diff: stats.length === 0 ? "" : await git([
880
- "diff",
881
- ...DIFF_FLAGS,
882
- base,
883
- tree,
884
- "--",
885
- `${skillDir}/`
886
- ], {
887
- cwd: dir,
888
- timeoutMs
889
- })
890
- };
891
- } finally {
892
- await rm(scratch, {
893
- recursive: true,
894
- force: true
740
+ * @module dsh-guanxiang/skill-update/change
741
+ */
742
+ /**
743
+ * Compare the proposed files with the skill directory of the base commit.
744
+ * @param repos - the `dshRepos` service holding the mirror.
745
+ * @param target - repository, base commit, and skill directory.
746
+ * @param files - proposed files relative to the skill directory.
747
+ * @param signal - cancellation.
748
+ * @returns per-file line counts, the diff, and the digest; `files` is empty when nothing changes.
749
+ * @throws when a proposed path replaces a symbolic link, a submodule, or a directory, or the mirror cannot be read.
750
+ */
751
+ async function prepareChange(repos, target, files, signal) {
752
+ const { url, base, skillDir } = target;
753
+ const listing = await repos.listMirrorTree(url, base, skillDir, signal === void 0 ? {} : { signal });
754
+ const types = new Map(listing.map((entry) => [entry.path, entry.type]));
755
+ const stats = [];
756
+ const patches = [];
757
+ const digest = createHash("sha256").update(`${base}\n`);
758
+ for (const file of [...files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
759
+ const path = `${skillDir}/${file.path}`;
760
+ const type = types.get(path);
761
+ if (type === "symlink") throw new Error(`${path} 在仓库中是符号链接,不能用提议覆盖`);
762
+ if (type === "dir" || type === "submodule") throw new Error(`${path} 在仓库中是目录,不能用文件覆盖`);
763
+ const before = type === "file" ? await repos.readMirrorFile(url, base, path, signal === void 0 ? {} : { signal }) ?? "" : void 0;
764
+ if (before === file.content) continue;
765
+ const structured = structuredPatch(path, path, before ?? "", file.content, void 0, void 0, { context: 3 });
766
+ let added = 0;
767
+ let removed = 0;
768
+ for (const hunk of structured.hunks) for (const line of hunk.lines) if (line.startsWith("+")) added += 1;
769
+ else if (line.startsWith("-")) removed += 1;
770
+ stats.push({
771
+ path,
772
+ added,
773
+ removed
895
774
  });
775
+ const header = `diff --git a/${path} b/${path}\n${before === void 0 ? "new file mode 100644\n" : ""}`;
776
+ const patch = createTwoFilesPatch(before === void 0 ? "/dev/null" : `a/${path}`, `b/${path}`, before ?? "", file.content, void 0, void 0, { context: 3 });
777
+ patches.push(header + patch.replace(/^=+\n/, ""));
778
+ digest.update(`${path}\0${file.content}\0`);
896
779
  }
897
- }
898
- /**
899
- * Whether the remote already has a branch.
900
- * @param commands - git runner and timeout.
901
- * @param dir - clone directory.
902
- * @param branch - branch name.
903
- * @param env - network environment of the clone's address.
904
- * @returns whether `refs/heads/<branch>` exists on `origin`.
905
- */
906
- async function remoteBranchExists(commands, dir, branch, env) {
907
- return (await commands.git([
908
- "ls-remote",
909
- "--heads",
910
- "origin",
911
- `refs/heads/${branch}`
912
- ], {
913
- cwd: dir,
914
- timeoutMs: commands.timeoutMs,
915
- env
916
- })).trim() !== "";
917
- }
918
- /**
919
- * Commit a prepared tree on its base commit without touching any ref.
920
- * @param commands - git runner and timeout.
921
- * @param dir - clone directory.
922
- * @param change - prepared tree and its base.
923
- * @param message - full commit message.
924
- * @param author - explicit identity; omitted uses the host's git configuration.
925
- * @returns the new commit id.
926
- */
927
- async function commitChange(commands, dir, change, message, author) {
928
- const env = author === void 0 ? void 0 : {
929
- GIT_AUTHOR_NAME: author.name,
930
- GIT_AUTHOR_EMAIL: author.email,
931
- GIT_COMMITTER_NAME: author.name,
932
- GIT_COMMITTER_EMAIL: author.email
780
+ return {
781
+ base,
782
+ digest: digest.digest("hex"),
783
+ files: stats,
784
+ diff: patches.join("")
933
785
  };
934
- return (await commands.git([
935
- "commit-tree",
936
- change.tree,
937
- "-p",
938
- change.base,
939
- "-F",
940
- "-"
941
- ], {
942
- cwd: dir,
943
- timeoutMs: commands.timeoutMs,
944
- input: message,
945
- ...env === void 0 ? {} : { env }
946
- })).trim();
947
- }
948
- /** Push options cannot carry line breaks; collapse every whitespace run. */
949
- function oneLine(text) {
950
- return text.replace(/\s+/g, " ").trim();
951
- }
952
- /**
953
- * Push a commit to a new remote branch and ask GitLab to open a merge request
954
- * for it (`merge_request.*` push options). The push carries an empty lease on
955
- * the branch, so it fails instead of updating a branch that appeared after
956
- * {@link remoteBranchExists} was checked. A server that does not support push
957
- * options rejects the push.
958
- * @param commands - git runner and timeout.
959
- * @param dir - clone directory.
960
- * @param commit - commit to push.
961
- * @param branch - new branch name.
962
- * @param request - merge request target and text.
963
- * @param env - network environment of the clone's address.
964
- * @returns the merge request URL the server printed, if any.
965
- */
966
- async function pushForMergeRequest(commands, dir, commit, branch, request, env) {
967
- const options = [
968
- "merge_request.create",
969
- `merge_request.target=${request.target}`,
970
- `merge_request.title=${oneLine(request.title)}`,
971
- `merge_request.description=${oneLine(request.description)}`
972
- ].flatMap((option) => ["-o", option]);
973
- const output = await commands.git([
974
- "push",
975
- "--porcelain",
976
- `--force-with-lease=refs/heads/${branch}:`,
977
- ...options,
978
- "origin",
979
- `${commit}:refs/heads/${branch}`
980
- ], {
981
- cwd: dir,
982
- timeoutMs: commands.timeoutMs,
983
- env,
984
- output: "all"
985
- });
986
- return /https?:\/\/\S+\/-\/merge_requests\/\d+/.exec(output)?.[0];
987
786
  }
988
787
  //#endregion
989
788
  //#region src/repos/manifest.ts
@@ -1213,11 +1012,13 @@ async function readProposal(workDir, limits) {
1213
1012
  * - 只接受本进程中由同一会话派发过的任务运行目录;应用、仓库和分析时的提交
1214
1013
  * 取自派发记录,不取自运行目录里可被 Agent 改写的内容。
1215
1014
  * - 只改应用 SKILL 目录 `.dsh/skills/<skill>/` 下的文件,提交以分析时的提交为
1216
- * 父提交,推送到新分支 `guanxiang/skill-<应用 id>-<树对象前缀>`,从不推送已有
1217
- * 分支或默认分支。
1218
- * - 推送前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
1219
- * 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才推送;
1220
- * 没有审批服务时工具报错,不推送。
1015
+ * 父提交,提交到新分支 `guanxiang/skill-<应用 id>-<改动摘要前缀>`,从不提交到
1016
+ * 已有分支或默认分支。
1017
+ * - 提交和创建合并请求经 dsh-repo 的 `dshRepos` 服务完成,支持 GitLab 与
1018
+ * GitHub;令牌由 dsh-repo 按主机配置。没有该服务或版本不符时工具报错。
1019
+ * - 提交前经 DSH 审批服务(`ctx.approval`)请求一次性授权;dsh_channels 把它
1020
+ * 显示为飞书决策卡片,DSH Web 显示为审批提示。只有 `allowed-once` 才提交;
1021
+ * 没有审批服务时工具报错,不提交。
1221
1022
  * - 子会话中不提供本工具。
1222
1023
  *
1223
1024
  * @module dsh-guanxiang/skill-update/tool
@@ -1282,7 +1083,11 @@ const OUTPUT_SCHEMA$1 = {
1282
1083
  items: {
1283
1084
  type: "object",
1284
1085
  additionalProperties: false,
1285
- required: ["path"],
1086
+ required: [
1087
+ "path",
1088
+ "added",
1089
+ "removed"
1090
+ ],
1286
1091
  properties: {
1287
1092
  path: { type: "string" },
1288
1093
  added: { type: "number" },
@@ -1291,7 +1096,8 @@ const OUTPUT_SCHEMA$1 = {
1291
1096
  }
1292
1097
  },
1293
1098
  diffPath: { type: "string" },
1294
- mergeRequestUrl: { type: "string" }
1099
+ mergeRequestUrl: { type: "string" },
1100
+ mergeRequestError: { type: "string" }
1295
1101
  }
1296
1102
  };
1297
1103
  /**
@@ -1300,7 +1106,7 @@ const OUTPUT_SCHEMA$1 = {
1300
1106
  * @returns one line.
1301
1107
  */
1302
1108
  function describeFiles(files) {
1303
- return files.map((file) => file.added === void 0 ? `${file.path}(二进制)` : `${file.path}(+${file.added} −${file.removed ?? 0})`).join("、");
1109
+ return files.map((file) => `${file.path}(+${file.added} −${file.removed})`).join("、");
1304
1110
  }
1305
1111
  /**
1306
1112
  * Model-facing rendering of a submission.
@@ -1312,18 +1118,15 @@ function renderProposeValue(value) {
1312
1118
  return [{
1313
1119
  type: "text",
1314
1120
  text: `${{
1315
- opened: `已推送到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ""}`,
1316
- pushed: `已推送到 ${where},但服务器没有返回合并请求链接;请在 GitLab 上确认合并请求是否已创建。`,
1317
- exists: `${where} 已存在,同一改动可能已经提交过,本次没有推送。`,
1318
- rejected: "授权被拒绝,没有推送:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。",
1319
- cancelled: "授权请求被撤回,没有推送。",
1320
- unavailable: "当前没有可以回答授权请求的界面,没有推送。"
1121
+ opened: `已提交到 ${where} 并创建合并请求:${value.mergeRequestUrl ?? ""}`,
1122
+ pushed: `已提交到 ${where},但创建合并请求失败(${value.mergeRequestError ?? "未知原因"});请在代码托管平台上从该分支手动创建。`,
1123
+ exists: `${where} 已存在,同一改动可能已经提交过,本次没有提交。`,
1124
+ rejected: "授权被拒绝,没有提交:用户拒绝了这次修改,或会话的审批策略为 never(例如「完全访问」权限模式)。",
1125
+ cancelled: "授权请求被撤回,没有提交。",
1126
+ unavailable: "当前没有可以回答授权请求的界面,没有提交。"
1321
1127
  }[value.status]}\n改动:${describeFiles(value.files)}\ndiff:${value.diffPath}`
1322
1128
  }];
1323
1129
  }
1324
- function messageOf$2(error) {
1325
- return error instanceof Error ? error.message : String(error);
1326
- }
1327
1130
  /**
1328
1131
  * Write a file in an agent-writable directory without following whatever
1329
1132
  * entry the agent left at its path: the content goes to a new file created
@@ -1346,7 +1149,7 @@ function skillDirOf(app) {
1346
1149
  var SkillUpdater = class {
1347
1150
  deps;
1348
1151
  tasks = /* @__PURE__ */ new Map();
1349
- /** @param deps - registry, settings, git, and approval access. */
1152
+ /** @param deps - registry, settings, approval, and `dshRepos` access. */
1350
1153
  constructor(deps) {
1351
1154
  this.deps = deps;
1352
1155
  }
@@ -1373,7 +1176,7 @@ var SkillUpdater = class {
1373
1176
  } catch (error) {
1374
1177
  return {
1375
1178
  ok: false,
1376
- error: messageOf$2(error)
1179
+ error: messageOf$1(error)
1377
1180
  };
1378
1181
  }
1379
1182
  }
@@ -1388,24 +1191,23 @@ var SkillUpdater = class {
1388
1191
  /**
1389
1192
  * Read the proposal, build its tree, and write the diff file.
1390
1193
  * @returns undefined when the task wrote no proposal.
1391
- * @throws ProposalError or a git failure.
1194
+ * @throws ProposalError, or an error when dsh-repo is unavailable or the mirror cannot be read.
1392
1195
  */
1393
- async prepare(task) {
1196
+ async prepare(task, signal) {
1394
1197
  const proposal = await readProposal(task.workDir, this.deps.settings().skillUpdates);
1395
1198
  if (proposal === void 0) return void 0;
1396
- const commands = {
1397
- git: this.deps.git,
1398
- timeoutMs: this.deps.gitTimeoutMs
1399
- };
1400
- const { change, target } = await this.deps.registry.withClone(task.url, async (clone) => ({
1401
- change: await prepareChange(commands, clone.dir, task.commit, skillDirOf(task.app), proposal.files),
1402
- target: clone.branch
1403
- }));
1199
+ const target = this.deps.registry.branchOf(task.url);
1200
+ const change = await prepareChange(requireRepos(this.deps.repos()), {
1201
+ url: task.url,
1202
+ base: task.commit,
1203
+ skillDir: skillDirOf(task.app)
1204
+ }, proposal.files, signal);
1404
1205
  if (change.files.length === 0) throw new ProposalError(`提议的文件与提交 ${task.commit.slice(0, 12)} 中的 SKILL 相同,没有改动`);
1405
1206
  const diffPath = join(task.workDir, DIFF_FILE);
1406
1207
  await replaceFile(diffPath, change.diff);
1407
1208
  return {
1408
1209
  reason: proposal.reason,
1210
+ files: proposal.files,
1409
1211
  change,
1410
1212
  target,
1411
1213
  diffPath
@@ -1418,7 +1220,7 @@ var SkillUpdater = class {
1418
1220
  definition() {
1419
1221
  return {
1420
1222
  name: PROPOSE_TOOL_NAME,
1421
- description: "观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后推送新分支并创建合并请求,不会直接改动默认分支。仅在派发结果标出了 SKILL 修改提议时使用;不要自己用 git 推送。",
1223
+ description: "观象:把派发结果中某个应用的 SKILL 修改提议提交为合并请求。工具先请求用户授权,批准后提交到新分支并创建合并请求,不会直接改动默认分支。仅在派发结果标出了 SKILL 修改提议时使用;不要自己用 git 推送。",
1422
1224
  parameters: PARAMETERS,
1423
1225
  output: {
1424
1226
  schema: OUTPUT_SCHEMA$1,
@@ -1433,7 +1235,7 @@ var SkillUpdater = class {
1433
1235
  * @param rawArgs - model arguments.
1434
1236
  * @param exec - execution context of the calling agent.
1435
1237
  * @returns how the submission ended.
1436
- * @throws when the arguments, task, proposal, or approval service are unusable, or git fails; nothing is pushed then.
1238
+ * @throws when the arguments, task, proposal, approval service, or `dshRepos` service are unusable, or the commit fails; nothing is committed then.
1437
1239
  */
1438
1240
  async execute(rawArgs, exec) {
1439
1241
  const parsed = argsSchema$1.safeParse(rawArgs);
@@ -1447,11 +1249,18 @@ var SkillUpdater = class {
1447
1249
  if (task === void 0) throw new Error(`${parsed.data.workDir} 不是本次 DSH 运行中观象派发任务的运行目录;DSH 重启后需要重新分析`);
1448
1250
  if (task.sessionId !== agent.session.header.id) throw new Error(`${parsed.data.workDir} 不是当前会话派发的任务`);
1449
1251
  const approval = this.deps.approval();
1450
- if (approval === void 0) throw new Error("当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时推送");
1451
- const prepared = await this.prepare(task);
1252
+ if (approval === void 0) throw new Error("当前 DSH 组合没有审批服务(@deepseek-ai/dsh-user-approval),观象不会在未经授权时提交");
1253
+ const repos = requireRepos(this.deps.repos());
1254
+ let prepared;
1255
+ try {
1256
+ prepared = await this.prepare(task, exec.signal);
1257
+ } catch (error) {
1258
+ exec.signal.throwIfAborted();
1259
+ throw error;
1260
+ }
1452
1261
  if (prepared === void 0) throw new Error(`${task.workDir} 中没有 SKILL 修改提议(${PROPOSAL_DIR}/${REASON_FILE} 与 ${PROPOSAL_DIR}/${FILES_DIR}/)`);
1453
1262
  const { change, target, diffPath, reason } = prepared;
1454
- const branch = `guanxiang/skill-${task.app.id}-${change.tree.slice(0, 10)}`;
1263
+ const branch = `guanxiang/skill-${task.app.id}-${change.digest.slice(0, 10)}`;
1455
1264
  const base = {
1456
1265
  app: task.app.id,
1457
1266
  repo: task.url,
@@ -1460,17 +1269,13 @@ var SkillUpdater = class {
1460
1269
  files: [...change.files],
1461
1270
  diffPath
1462
1271
  };
1463
- const commands = {
1464
- git: this.deps.git,
1465
- timeoutMs: this.deps.gitTimeoutMs
1466
- };
1467
- if (await this.deps.registry.withClone(task.url, (clone) => remoteBranchExists(commands, clone.dir, branch, clone.env))) return {
1272
+ if (await repos.getBranch(task.url, branch, { signal: exec.signal }) !== void 0) return {
1468
1273
  ...base,
1469
1274
  status: "exists"
1470
1275
  };
1471
1276
  const preview = reason.replace(/\s+/g, " ").slice(0, REASON_PREVIEW_CHARS);
1472
- const zh = `观象将向 ${task.url} 推送新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`;
1473
- const en = `GuanXiang will push branch ${branch} to ${task.url} and open a merge request into ${target}. Files: ${change.files.map((file) => file.path).join(", ")}. Summary: ${summary}`;
1277
+ const zh = `观象将在 ${task.url} 提交新分支 ${branch},并创建合并请求合入 ${target}。改动:${describeFiles(change.files)}。摘要:${summary}。理由:${preview}`;
1278
+ const en = `GuanXiang will commit branch ${branch} to ${task.url} and open a merge request into ${target}. Files: ${change.files.map((file) => file.path).join(", ")}. Summary: ${summary}`;
1474
1279
  const outcome = await approval.request({
1475
1280
  agent,
1476
1281
  toolName: PROPOSE_TOOL_NAME,
@@ -1489,23 +1294,50 @@ var SkillUpdater = class {
1489
1294
  };
1490
1295
  const message = `${summary}\n\n${reason}\n\nProposed-by: 观象 GuanXiang\nAnalyzed-commit: ${task.commit}\nSession: ${task.sessionId}\n`;
1491
1296
  const description = `由观象在日志分析中提出(应用 ${task.app.id},分析提交 ${task.commit.slice(0, 12)},会话 ${task.sessionId})。理由:${reason.slice(0, REASON_DESCRIPTION_CHARS)}`;
1492
- const url = await this.deps.registry.withClone(task.url, async (clone) => {
1493
- exec.signal.throwIfAborted();
1494
- const commit = await commitChange(commands, clone.dir, change, message, this.deps.author);
1495
- return await pushForMergeRequest(commands, clone.dir, commit, branch, {
1496
- target,
1297
+ const changed = new Set(change.files.map((file) => file.path));
1298
+ const files = prepared.files.map((file) => ({
1299
+ action: "write",
1300
+ path: `${skillDirOf(task.app)}/${file.path}`,
1301
+ content: file.content
1302
+ })).filter((file) => changed.has(file.path));
1303
+ exec.signal.throwIfAborted();
1304
+ try {
1305
+ await repos.commitFiles(task.url, {
1306
+ branch,
1307
+ baseSha: task.commit,
1308
+ newBranch: true,
1309
+ message,
1310
+ files,
1311
+ ...this.deps.author === void 0 ? {} : { author: this.deps.author },
1312
+ signal: exec.signal
1313
+ });
1314
+ } catch (error) {
1315
+ if (repoErrorCode(error) === "conflict") return {
1316
+ ...base,
1317
+ status: "exists"
1318
+ };
1319
+ throw error;
1320
+ }
1321
+ try {
1322
+ const opened = await repos.createChange(task.url, {
1497
1323
  title: summary,
1498
- description
1499
- }, clone.env);
1500
- });
1501
- return url === void 0 ? {
1502
- ...base,
1503
- status: "pushed"
1504
- } : {
1505
- ...base,
1506
- status: "opened",
1507
- mergeRequestUrl: url
1508
- };
1324
+ body: description,
1325
+ sourceBranch: branch,
1326
+ targetBranch: target,
1327
+ signal: exec.signal
1328
+ });
1329
+ return {
1330
+ ...base,
1331
+ status: "opened",
1332
+ mergeRequestUrl: opened.webUrl
1333
+ };
1334
+ } catch (error) {
1335
+ return {
1336
+ ...base,
1337
+ status: "pushed",
1338
+ mergeRequestError: messageOf$1(error)
1339
+ };
1340
+ }
1509
1341
  }
1510
1342
  };
1511
1343
  //#endregion
@@ -1903,7 +1735,7 @@ function isInside(child, parent) {
1903
1735
  function textOf(blocks) {
1904
1736
  return blocks.flatMap((block) => block.type === "text" && typeof block.text === "string" ? [block.text] : []).join("\n").trim();
1905
1737
  }
1906
- function messageOf$1(error) {
1738
+ function messageOf(error) {
1907
1739
  return error instanceof Error ? error.message : String(error);
1908
1740
  }
1909
1741
  /** Runs dispatches; one instance serves every registration of the tool. */
@@ -1959,7 +1791,7 @@ var Dispatcher = class {
1959
1791
  try {
1960
1792
  await stat(path);
1961
1793
  } catch (error) {
1962
- throw new Error(`日志路径不可读:${path}(${error.code ?? messageOf$1(error)})`);
1794
+ throw new Error(`日志路径不可读:${path}(${error.code ?? messageOf(error)})`);
1963
1795
  }
1964
1796
  return {
1965
1797
  path,
@@ -2066,7 +1898,7 @@ var Dispatcher = class {
2066
1898
  ...base,
2067
1899
  status: stop.aborted && !signal.aborted ? "timeout" : "failed",
2068
1900
  output: "",
2069
- diagnostic: messageOf$1(error)
1901
+ diagnostic: messageOf(error)
2070
1902
  };
2071
1903
  }
2072
1904
  activations[index] = activation;
@@ -2098,7 +1930,7 @@ var Dispatcher = class {
2098
1930
  childId: activation.childId,
2099
1931
  status: "error",
2100
1932
  output: "",
2101
- diagnostic: messageOf$1(error)
1933
+ diagnostic: messageOf(error)
2102
1934
  };
2103
1935
  }
2104
1936
  }));
@@ -2108,7 +1940,7 @@ var Dispatcher = class {
2108
1940
  await activation.dispose();
2109
1941
  } catch (error) {
2110
1942
  const result = results.find((entry) => entry.childId === activation.childId);
2111
- if (result !== void 0) result.diagnostic = [result.diagnostic, `子 Agent 清理失败:${messageOf$1(error)}`].filter(Boolean).join(";");
1943
+ if (result !== void 0) result.diagnostic = [result.diagnostic, `子 Agent 清理失败:${messageOf(error)}`].filter(Boolean).join(";");
2112
1944
  }
2113
1945
  }));
2114
1946
  signal.throwIfAborted();
@@ -2116,201 +1948,40 @@ var Dispatcher = class {
2116
1948
  }
2117
1949
  };
2118
1950
  //#endregion
2119
- //#region src/repos/git.ts
2120
- /**
2121
- * 本机 git 命令的调用封装:沿用本机已有凭据(SSH key 或 credential helper),
2122
- * 或由调用方经环境变量提供的访问令牌;禁止交互式提示,避免后台拉取卡在
2123
- * 密码输入上。
2124
- *
2125
- * @module dsh-guanxiang/repos/git
2126
- */
2127
- /** A failed git command with its combined output. */
2128
- var GitError = class extends Error {
2129
- args;
2130
- output;
2131
- /**
2132
- * @param args - git arguments that failed.
2133
- * @param output - trimmed stderr (or stdout when stderr is empty).
2134
- */
2135
- constructor(args, output) {
2136
- super(`git ${args.join(" ")} 失败:${output === "" ? "无输出" : output}`);
2137
- this.args = args;
2138
- this.output = output;
2139
- }
2140
- };
2141
- /**
2142
- * Environment for non-interactive git: no terminal prompts, and SSH in batch
2143
- * mode unless the user configured their own SSH command.
2144
- * @param env - base environment.
2145
- * @returns environment for git child processes.
2146
- */
2147
- function gitEnvironment(env = process.env) {
2148
- const next = {
2149
- ...env,
2150
- GIT_TERMINAL_PROMPT: "0"
2151
- };
2152
- if (env.GIT_SSH_COMMAND === void 0 && env.GIT_SSH === void 0) next.GIT_SSH_COMMAND = "ssh -o BatchMode=yes";
2153
- return next;
2154
- }
2155
- /**
2156
- * Default runner backed by the `git` executable on PATH.
2157
- * @param args - git arguments.
2158
- * @param options - working directory, timeout, extra environment, input, and output selection.
2159
- * @returns stdout, or stdout followed by stderr.
2160
- */
2161
- const runGit = (args, options) => new Promise((resolve, reject) => {
2162
- const child = execFile("git", [...args], {
2163
- cwd: options.cwd,
2164
- env: {
2165
- ...gitEnvironment(),
2166
- ...options.env
2167
- },
2168
- timeout: options.timeoutMs,
2169
- maxBuffer: 16 * 1024 * 1024,
2170
- windowsHide: true
2171
- }, (error, stdout, stderr) => {
2172
- if (error === null) {
2173
- resolve(options.output === "all" ? `${stdout}${stderr}` : stdout);
2174
- return;
2175
- }
2176
- const output = (options.output === "all" || stderr.trim() === "" ? `${stdout}${stderr}` : stderr).trim();
2177
- reject(new GitError(args, error.killed === true ? `超时(${options.timeoutMs} ms)后被终止${output === "" ? "" : `:${output}`}` : output || error.message));
2178
- });
2179
- child.stdin?.on("error", () => {});
2180
- child.stdin?.end(options.input);
2181
- });
2182
- //#endregion
2183
1951
  //#region src/repos/registry.ts
2184
- /**
2185
- * 应用注册表:把设置中的 git 地址拉取到 `<reposDir>/<仓库名>`,从远端分支最新
2186
- * 提交读取各仓库的应用描述,对外提供「已就绪的应用」,并为每次派发提供该
2187
- * 提交的只读代码快照。
2188
- *
2189
- * - 拉取目录只做 fetch,不检出:新地址 `git clone --no-checkout`(先克隆到临时
2190
- * 目录再改名,半成品不会被当成仓库),已有目录 `git fetch`。应用描述与 SKILL
2191
- * 直接从提交的 git 对象读取,所以随时可以更新,不受正在进行的分析影响。
2192
- * - 子 Agent 在 `<reposDir>/.snapshots/<仓库名>/` 下按提交创建的 `git worktree`
2193
- * 快照里工作。快照创建后不再改动;同一提交的派发共用一份,没有使用者且已不是
2194
- * 最新提交时删除。进程内首次同步某仓库时清掉上次运行遗留的快照。
2195
- * - 更新失败时继续使用已有提交,并在状态里给出原因;应用描述或 SKILL 不合法时
2196
- * 该应用不可用。
2197
- * - 多个仓库声明同一个应用 id 时,这些仓库全部不可用。
2198
- *
2199
- * @module dsh-guanxiang/repos/registry
2200
- */
2201
- /**
2202
- * Checkout directory name for a git address: its last path segment without
2203
- * `.git`, restricted to `[A-Za-z0-9._-]`.
2204
- * @param url - git address.
2205
- * @returns directory name.
2206
- */
2207
- function repoDirName(url) {
2208
- const name = (url.replace(/^[^@/]+@[^:/]+:/, "").replace(/[?#].*$/, "").replace(/\/+$/, "").split(/[/\\:]/).pop() ?? "").replace(/\.git$/, "").replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[.-]+/, "");
2209
- return name === "" ? "repo" : name;
2210
- }
2211
- /**
2212
- * Assign each address a distinct directory; colliding names get a short hash
2213
- * of their address appended.
2214
- * @param reposDir - parent directory (may start with `~`).
2215
- * @param urls - configured addresses.
2216
- * @returns absolute directory per address.
2217
- */
2218
- function assignDirs(reposDir, urls) {
2219
- const root = expandHome(reposDir);
2220
- const counts = /* @__PURE__ */ new Map();
2221
- for (const url of urls) counts.set(repoDirName(url), (counts.get(repoDirName(url)) ?? 0) + 1);
2222
- const dirs = /* @__PURE__ */ new Map();
2223
- for (const url of urls) {
2224
- const name = repoDirName(url);
2225
- const unique = (counts.get(name) ?? 0) > 1 ? `${name}-${createHash("sha1").update(url).digest("hex").slice(0, 8)}` : name;
2226
- dirs.set(url, join(root, unique));
2227
- }
2228
- return dirs;
2229
- }
2230
- /**
2231
- * Directory holding the commit snapshots of one clone.
2232
- * @param dir - clone directory `<reposDir>/<name>`.
2233
- * @returns `<reposDir>/.snapshots/<name>`.
2234
- */
2235
- function snapshotRoot(dir) {
2236
- return join(dirname(dir), ".snapshots", basename(dir));
2237
- }
2238
- /**
2239
- * Run a task after every task queued earlier under the same key.
2240
- * @param locks - tail of each key's queue.
2241
- * @param key - queue key.
2242
- * @param task - work to run.
2243
- * @returns the task's result.
2244
- */
2245
- function serialize(locks, key, task) {
2246
- const run = (locks.get(key) ?? Promise.resolve()).then(task);
2247
- const tail = run.then(() => void 0, () => void 0);
2248
- locks.set(key, tail);
2249
- tail.then(() => {
2250
- if (locks.get(key) === tail) locks.delete(key);
2251
- });
2252
- return run;
2253
- }
2254
- function messageOf(error) {
2255
- if (error instanceof GitError) return error.output === "" ? error.message : error.output;
2256
- return error instanceof Error ? error.message : String(error);
2257
- }
2258
- async function pathKind(path) {
2259
- try {
2260
- return (await stat(path)).isDirectory() ? "directory" : "other";
2261
- } catch (error) {
2262
- if (error.code === "ENOENT") return "missing";
2263
- throw error;
2264
- }
2265
- }
2266
- /** Clones, updates, and indexes the configured application repositories. */
1952
+ /** Mirrors, updates, and indexes the configured application repositories. */
2267
1953
  var RepoRegistry = class {
2268
1954
  deps;
2269
1955
  entries = [];
2270
- /** Orders clone and fetch work per clone directory. */
2271
- syncLocks = /* @__PURE__ */ new Map();
2272
- /** Orders `git worktree` work per clone directory. */
2273
- worktreeLocks = /* @__PURE__ */ new Map();
2274
- /** Snapshot roots already cleared of leftovers from an earlier run. */
2275
- clearedRoots = /* @__PURE__ */ new Set();
2276
1956
  now;
2277
- /** @param deps - git runner, timeout, and change notification. */
1957
+ /** @param deps - service lookup and change notification. */
2278
1958
  constructor(deps) {
2279
1959
  this.deps = deps;
2280
1960
  this.now = deps.now ?? Date.now;
2281
1961
  }
2282
1962
  /**
2283
1963
  * Replace the configured repositories. An address keeps its status when its
2284
- * branch and directory are unchanged; clones of removed addresses stay on
2285
- * disk, and their snapshots are deleted once no dispatch holds them.
2286
- * @param reposDir - parent directory of the clones.
1964
+ * branch is unchanged.
2287
1965
  * @param repos - configured repositories.
2288
- * @returns entries that are new or changed and therefore need a sync.
1966
+ * @returns addresses that are new or changed and therefore need a sync.
2289
1967
  */
2290
- configure(reposDir, repos) {
2291
- const dirs = assignDirs(reposDir, repos.map((repo) => repo.url));
1968
+ configure(repos) {
2292
1969
  const previous = new Map(this.entries.map((entry) => [entry.spec.url, entry]));
2293
1970
  const changed = [];
2294
1971
  this.entries = repos.map((spec) => {
2295
- const dir = dirs.get(spec.url);
2296
1972
  const kept = previous.get(spec.url);
2297
- if (kept !== void 0 && kept.dir === dir && kept.spec.branch === spec.branch) {
1973
+ if (kept !== void 0 && kept.spec.branch === spec.branch) {
2298
1974
  previous.delete(spec.url);
2299
1975
  return kept;
2300
1976
  }
2301
1977
  changed.push(spec.url);
2302
1978
  return {
2303
1979
  spec: { ...spec },
2304
- dir,
2305
- snapshots: /* @__PURE__ */ new Map(),
2306
1980
  activeRuns: 0,
2307
1981
  retired: false
2308
1982
  };
2309
1983
  });
2310
- for (const entry of previous.values()) {
2311
- entry.retired = true;
2312
- this.pruneSnapshots(entry);
2313
- }
1984
+ for (const entry of previous.values()) entry.retired = true;
2314
1985
  this.deps.onChange();
2315
1986
  return changed;
2316
1987
  }
@@ -2331,16 +2002,14 @@ var RepoRegistry = class {
2331
2002
  await Promise.all(this.entries.filter((entry) => wanted.has(entry.spec.url)).map((entry) => this.syncEntry(entry)));
2332
2003
  }
2333
2004
  /**
2334
- * Sync the configured repositories with the given addresses again after any
2335
- * sync already running for them, so the new sync sees settings changed since
2336
- * that sync started (an access token, for example). An idle repository
2337
- * starts syncing before this returns, so its status reads `syncing` at once.
2338
- * @param urls - addresses to sync.
2005
+ * Sync every configured repository again after any sync already running for
2006
+ * it, so the new sync sees changes made since that sync started (a host
2007
+ * token, for example). An idle repository starts syncing before this
2008
+ * returns, so its status reads `syncing` at once.
2339
2009
  * @returns when those syncs settled.
2340
2010
  */
2341
- async resyncUrls(urls) {
2342
- const wanted = new Set(urls);
2343
- await Promise.all(this.entries.filter((entry) => wanted.has(entry.spec.url)).map((entry) => entry.syncing === void 0 ? this.syncEntry(entry) : entry.syncing.then(() => void 0, () => void 0).then(() => this.syncEntry(entry))));
2011
+ async resyncAll() {
2012
+ await Promise.all(this.entries.map((entry) => entry.syncing === void 0 ? this.syncEntry(entry) : entry.syncing.then(() => void 0, () => void 0).then(() => this.syncEntry(entry))));
2344
2013
  }
2345
2014
  /**
2346
2015
  * Sync the repositories that never synced or whose last sync is at least
@@ -2371,7 +2040,7 @@ var RepoRegistry = class {
2371
2040
  return {
2372
2041
  url: entry.spec.url,
2373
2042
  ...entry.spec.branch === void 0 ? {} : { branch: entry.spec.branch },
2374
- dir: entry.dir,
2043
+ ...entry.dir === void 0 ? {} : { dir: entry.dir },
2375
2044
  phase,
2376
2045
  ...entry.commit === void 0 ? {} : { commit: entry.commit },
2377
2046
  ...entry.commitTime === void 0 ? {} : { commitTime: entry.commitTime },
@@ -2406,76 +2075,64 @@ var RepoRegistry = class {
2406
2075
  }
2407
2076
  /**
2408
2077
  * Hold a snapshot of each requested application's latest synced commit for
2409
- * one dispatch, creating the snapshots that do not exist yet. Every id must
2410
- * resolve to a usable application, or nothing is held.
2078
+ * one dispatch. Every id must resolve to a usable application, or nothing is
2079
+ * held.
2411
2080
  * @param ids - requested application ids (duplicates allowed).
2412
2081
  * @returns the lease.
2413
- * @throws when an id is unknown, its repository is not usable, or a snapshot cannot be created.
2082
+ * @throws when an id is unknown, its repository is not usable, dsh-repo is unavailable, or a snapshot cannot be created.
2414
2083
  */
2415
2084
  async acquire(ids) {
2416
2085
  const unique = [...new Set(ids)];
2417
2086
  const ready = new Map(this.readyEntries().map((item) => [item.ready.app.id, item]));
2418
2087
  const missing = unique.filter((id) => !ready.has(id));
2419
2088
  if (missing.length > 0) throw new Error(this.unknownMessage(missing));
2089
+ const service = requireRepos(this.deps.repos());
2420
2090
  const held = unique.map((id) => {
2421
2091
  const { entry, ready: app } = ready.get(id);
2422
- const snapshot = this.snapshotFor(entry, app.commit);
2423
- snapshot.users += 1;
2424
2092
  entry.activeRuns += 1;
2425
2093
  return {
2426
2094
  id,
2427
2095
  entry,
2428
2096
  app,
2429
- snapshot
2097
+ snapshot: service.acquireSnapshot(app.url, app.commit)
2430
2098
  };
2431
2099
  });
2432
2100
  this.deps.onChange();
2101
+ const outcomes = await Promise.allSettled(held.map((item) => item.snapshot));
2102
+ const leases = outcomes.flatMap((outcome) => outcome.status === "fulfilled" ? [outcome.value] : []);
2433
2103
  let released = false;
2434
2104
  const release = () => {
2435
2105
  if (released) return;
2436
2106
  released = true;
2437
- for (const { entry, snapshot } of held) {
2438
- snapshot.users -= 1;
2439
- entry.activeRuns -= 1;
2440
- }
2441
- for (const entry of new Set(held.map((item) => item.entry))) this.pruneSnapshots(entry);
2107
+ for (const lease of leases) lease.release();
2108
+ for (const { entry } of held) entry.activeRuns -= 1;
2442
2109
  this.deps.onChange();
2443
2110
  };
2444
- const created = await Promise.allSettled(held.map((item) => item.snapshot.created));
2445
- for (const [index, outcome] of created.entries()) {
2111
+ for (const [index, outcome] of outcomes.entries()) {
2446
2112
  if (outcome.status === "fulfilled") continue;
2447
2113
  release();
2448
- throw new Error(`应用「${held[index]?.id}」的代码快照创建失败:${messageOf(outcome.reason)}`);
2114
+ throw new Error(`应用「${held[index]?.id}」的代码快照创建失败:${messageOf$1(outcome.reason)}`);
2449
2115
  }
2450
2116
  return {
2451
- apps: new Map(held.map(({ id, app, snapshot }) => [id, {
2117
+ apps: new Map(held.map(({ id, app }, index) => [id, {
2452
2118
  ...app,
2453
- dir: snapshot.dir
2119
+ dir: leases[index].dir
2454
2120
  }])),
2455
2121
  release
2456
2122
  };
2457
2123
  }
2458
2124
  /**
2459
- * Run a task on the clone of a configured address after every sync queued
2460
- * before it, so the task never overlaps a fetch of the same clone.
2125
+ * The branch the last successful sync of a configured address resolved: the
2126
+ * configured branch, or the remote's default branch when none is configured.
2461
2127
  * @param url - configured repository address.
2462
- * @param task - work on the clone.
2463
- * @returns the task's result.
2464
- * @throws when the address is not configured, its token cannot be read, or the task fails.
2128
+ * @returns the branch.
2129
+ * @throws when the address is not configured or has not synced yet.
2465
2130
  */
2466
- async withClone(url, task) {
2131
+ branchOf(url) {
2467
2132
  const entry = this.entries.find((item) => item.spec.url === url);
2468
2133
  if (entry === void 0) throw new Error(`仓库 ${url} 已不在配置中`);
2469
- return await serialize(this.syncLocks, entry.dir, async () => {
2470
- if (entry.retired) throw new Error(`仓库 ${url} 已不在配置中`);
2471
- const env = await this.deps.auth?.(url) ?? {};
2472
- const branch = entry.spec.branch ?? (await this.remoteHead(entry.dir, env)).replace(/^refs\/remotes\/origin\//, "");
2473
- return await task({
2474
- dir: entry.dir,
2475
- branch,
2476
- env
2477
- });
2478
- });
2134
+ if (entry.branch === void 0) throw new Error(`仓库 ${url} 还没有同步成功`);
2135
+ return entry.branch;
2479
2136
  }
2480
2137
  unknownMessage(ids) {
2481
2138
  const status = new Map(this.status().flatMap((repo) => repo.app === void 0 ? [] : [[repo.app.id, repo]]));
@@ -2496,7 +2153,7 @@ var RepoRegistry = class {
2496
2153
  }
2497
2154
  syncEntry(entry) {
2498
2155
  if (entry.syncing !== void 0) return entry.syncing;
2499
- const run = serialize(this.syncLocks, entry.dir, () => this.runSync(entry)).finally(() => {
2156
+ const run = this.runSync(entry).finally(() => {
2500
2157
  entry.syncing = void 0;
2501
2158
  this.deps.onChange();
2502
2159
  });
@@ -2506,16 +2163,20 @@ var RepoRegistry = class {
2506
2163
  }
2507
2164
  async runSync(entry) {
2508
2165
  if (entry.retired) return;
2166
+ let service;
2509
2167
  let head;
2510
2168
  try {
2511
- head = await this.update(entry);
2169
+ service = requireRepos(this.deps.repos());
2170
+ head = await service.syncMirror(entry.spec.url, entry.spec.branch === void 0 ? {} : { branch: entry.spec.branch });
2512
2171
  } catch (error) {
2513
2172
  entry.syncedAt = this.now();
2514
- entry.error = entry.commit !== void 0 && entry.app !== void 0 ? `更新失败,继续使用已有提交 ${entry.commit.slice(0, 8)}:${messageOf(error)}` : messageOf(error);
2173
+ entry.error = entry.commit !== void 0 && entry.app !== void 0 ? `更新失败,继续使用已有提交 ${entry.commit.slice(0, 8)}:${messageOf$1(error)}` : messageOf$1(error);
2515
2174
  return;
2516
2175
  }
2517
- const manifest = await checkAppManifest(this.commitReader(entry.dir, head.commit));
2176
+ const manifest = await checkAppManifest((path) => service.readMirrorFile(entry.spec.url, head.commit, path));
2518
2177
  entry.syncedAt = this.now();
2178
+ entry.dir = head.dir;
2179
+ entry.branch = head.branch;
2519
2180
  entry.commit = head.commit;
2520
2181
  entry.commitTime = head.commitTime;
2521
2182
  if (manifest.ok) {
@@ -2525,202 +2186,6 @@ var RepoRegistry = class {
2525
2186
  entry.app = void 0;
2526
2187
  entry.error = manifest.error;
2527
2188
  }
2528
- this.pruneSnapshots(entry);
2529
- }
2530
- /**
2531
- * Clone or fetch, and resolve the commit the configured branch points to.
2532
- * The first visit of a snapshot root in this process deletes the snapshots
2533
- * an earlier run left behind.
2534
- */
2535
- async update(entry) {
2536
- const { git, gitTimeoutMs: timeoutMs } = this.deps;
2537
- const { dir, spec } = entry;
2538
- const root = snapshotRoot(dir);
2539
- const firstVisit = !this.clearedRoots.has(root);
2540
- if (firstVisit) {
2541
- this.clearedRoots.add(root);
2542
- await rm(root, {
2543
- recursive: true,
2544
- force: true
2545
- });
2546
- }
2547
- const env = await this.deps.auth?.(spec.url) ?? {};
2548
- const kind = await pathKind(dir);
2549
- if (kind === "missing") {
2550
- await mkdir(dirname(dir), { recursive: true });
2551
- const partial = `${dir}.partial-${randomBytes(4).toString("hex")}`;
2552
- try {
2553
- await git([
2554
- "clone",
2555
- "--quiet",
2556
- "--no-checkout",
2557
- ...spec.branch === void 0 ? [] : ["--branch", spec.branch],
2558
- "--",
2559
- spec.url,
2560
- partial
2561
- ], {
2562
- timeoutMs,
2563
- env
2564
- });
2565
- await rename(partial, dir);
2566
- } catch (error) {
2567
- await rm(partial, {
2568
- recursive: true,
2569
- force: true
2570
- });
2571
- throw error;
2572
- }
2573
- } else {
2574
- if (kind !== "directory") throw new Error(`${dir} 已存在且不是目录`);
2575
- if (await pathKind(join(dir, ".git")) === "missing") {
2576
- if (!((await readdir(dir)).length === 0)) throw new Error(`${dir} 已存在且不是 git 仓库,请移走后重新拉取`);
2577
- await rm(dir, { recursive: true });
2578
- return await this.update(entry);
2579
- }
2580
- const origin = (await git([
2581
- "remote",
2582
- "get-url",
2583
- "origin"
2584
- ], {
2585
- cwd: dir,
2586
- timeoutMs
2587
- })).trim();
2588
- if (origin !== spec.url) throw new Error(`${dir} 已被另一个仓库占用(origin 为 ${origin})`);
2589
- await git([
2590
- "fetch",
2591
- "--quiet",
2592
- "--prune",
2593
- "origin"
2594
- ], {
2595
- cwd: dir,
2596
- timeoutMs,
2597
- env
2598
- });
2599
- if (firstVisit) await git(["worktree", "prune"], {
2600
- cwd: dir,
2601
- timeoutMs
2602
- });
2603
- }
2604
- const [commit = "", commitTime = ""] = (await git([
2605
- "log",
2606
- "-1",
2607
- "--format=%H%n%cI",
2608
- spec.branch === void 0 ? await this.remoteHead(dir, env) : `refs/remotes/origin/${spec.branch}`,
2609
- "--"
2610
- ], {
2611
- cwd: dir,
2612
- timeoutMs
2613
- })).trim().split("\n");
2614
- return {
2615
- commit,
2616
- commitTime
2617
- };
2618
- }
2619
- /**
2620
- * Reader for the files of one commit, from the clone's object database.
2621
- * Only regular files are read; a symbolic link is reported as unreadable.
2622
- */
2623
- commitReader(dir, commit) {
2624
- const { git, gitTimeoutMs: timeoutMs } = this.deps;
2625
- return async (path) => {
2626
- const listing = await git([
2627
- "ls-tree",
2628
- "-z",
2629
- commit,
2630
- "--",
2631
- path
2632
- ], {
2633
- cwd: dir,
2634
- timeoutMs
2635
- });
2636
- if (listing === "") return void 0;
2637
- const [mode, type, object] = listing.slice(0, listing.indexOf(" ")).split(" ");
2638
- if (type !== "blob" || mode === "120000" || object === void 0) throw new Error(`${path} 不是普通文件`);
2639
- return await git([
2640
- "cat-file",
2641
- "blob",
2642
- object
2643
- ], {
2644
- cwd: dir,
2645
- timeoutMs
2646
- });
2647
- };
2648
- }
2649
- /** The snapshot of `commit`, created in the background on first use. */
2650
- snapshotFor(entry, commit) {
2651
- const existing = entry.snapshots.get(commit);
2652
- if (existing !== void 0) return existing;
2653
- const dir = join(snapshotRoot(entry.dir), `${commit.slice(0, 12)}-${randomBytes(3).toString("hex")}`);
2654
- const created = serialize(this.worktreeLocks, entry.dir, async () => {
2655
- await mkdir(dirname(dir), { recursive: true });
2656
- await this.deps.git([
2657
- "worktree",
2658
- "add",
2659
- "--detach",
2660
- dir,
2661
- commit
2662
- ], {
2663
- cwd: entry.dir,
2664
- timeoutMs: this.deps.gitTimeoutMs
2665
- });
2666
- });
2667
- const snapshot = {
2668
- commit,
2669
- dir,
2670
- users: 0,
2671
- created
2672
- };
2673
- entry.snapshots.set(commit, snapshot);
2674
- created.catch(() => {
2675
- if (entry.snapshots.get(commit) === snapshot) entry.snapshots.delete(commit);
2676
- this.removeSnapshot(entry.dir, dir);
2677
- });
2678
- return snapshot;
2679
- }
2680
- /** Delete the snapshots no lease holds, except the latest commit's of a configured repository. */
2681
- pruneSnapshots(entry) {
2682
- for (const [commit, snapshot] of entry.snapshots) {
2683
- if (snapshot.users > 0 || commit === entry.commit && !entry.retired) continue;
2684
- entry.snapshots.delete(commit);
2685
- snapshot.created.then(() => this.removeSnapshot(entry.dir, snapshot.dir), () => void 0);
2686
- }
2687
- }
2688
- async removeSnapshot(clone, dir) {
2689
- try {
2690
- await serialize(this.worktreeLocks, clone, async () => {
2691
- await rm(dir, {
2692
- recursive: true,
2693
- force: true
2694
- });
2695
- await this.deps.git(["worktree", "prune"], {
2696
- cwd: clone,
2697
- timeoutMs: this.deps.gitTimeoutMs
2698
- });
2699
- });
2700
- } catch {}
2701
- }
2702
- async remoteHead(dir, env) {
2703
- const { git, gitTimeoutMs: timeoutMs } = this.deps;
2704
- const read = async () => (await git(["symbolic-ref", "refs/remotes/origin/HEAD"], {
2705
- cwd: dir,
2706
- timeoutMs
2707
- })).trim();
2708
- try {
2709
- return await read();
2710
- } catch (error) {
2711
- if (!(error instanceof GitError)) throw error;
2712
- await git([
2713
- "remote",
2714
- "set-head",
2715
- "origin",
2716
- "--auto"
2717
- ], {
2718
- cwd: dir,
2719
- timeoutMs,
2720
- env
2721
- });
2722
- return await read();
2723
- }
2724
2189
  }
2725
2190
  };
2726
2191
  //#endregion
@@ -2979,16 +2444,21 @@ function createEntrySkillProvider(source) {
2979
2444
  }
2980
2445
  //#endregion
2981
2446
  //#region src/service.ts
2982
- /**
2983
- * 观象运行时:持有生效设置(cordis.yml + 面板覆盖)、应用注册表、入口
2984
- * SKILL、派发工具与 SKILL 提议工具的注册,并为设置面板提供状态与写入口。
2985
- *
2986
- * @module dsh-guanxiang/service
2987
- */
2988
2447
  /** A settings write the panel reports as a field error. */
2989
2448
  var SettingsError = class extends Error {};
2449
+ /** Longest accepted token; personal access tokens are far shorter. */
2450
+ const MAX_TOKEN_LENGTH = 4096;
2451
+ const tokenValue = z.string().trim().refine((value) => value.length > 0 && value.length <= MAX_TOKEN_LENGTH && !/[\u0000-\u001f\u007f]/.test(value), `令牌不能为空、超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`);
2990
2452
  const tokenClearSchema = z.object({ tokenEnv: z.string() }).strict();
2991
- const tokenSetSchema = tokenClearSchema.extend({ value: z.string().trim().refine(isAcceptedToken, `令牌不能为空、超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`) }).strict();
2453
+ const tokenSetSchema = tokenClearSchema.extend({ value: tokenValue }).strict();
2454
+ const hostUrlSchema = z.object({ url: z.string().trim().min(1, "主机地址不能为空") }).strict();
2455
+ const hostTokenSchema = hostUrlSchema.extend({ value: tokenValue }).strict();
2456
+ const hostAddSchema = z.object({
2457
+ kind: z.enum(["github", "gitlab"]).optional(),
2458
+ url: z.string().trim().min(1, "主机地址不能为空"),
2459
+ alias: z.string().trim().max(64, "别名不能超过 64 个字符").optional().transform((value) => value === "" ? void 0 : value),
2460
+ token: tokenValue.optional()
2461
+ }).strict();
2992
2462
  /** The plugin's long-lived state. */
2993
2463
  var GuanxiangService = class {
2994
2464
  ctx;
@@ -3004,6 +2474,7 @@ var GuanxiangService = class {
3004
2474
  active = true;
3005
2475
  refreshWaitMs;
3006
2476
  analyzeTokenRef;
2477
+ legacyCredentials;
3007
2478
  booted = Promise.withResolvers();
3008
2479
  /**
3009
2480
  * @param ctx - plugin context.
@@ -3017,12 +2488,10 @@ var GuanxiangService = class {
3017
2488
  this.defaults = defaultsOf(config);
3018
2489
  this.refreshWaitMs = config.refreshWaitMs;
3019
2490
  this.analyzeTokenRef = config.analyzeTokenRef;
2491
+ this.legacyCredentials = config.gitCredentials;
3020
2492
  this.current = this.defaults;
3021
- const git = options.git ?? runGit;
3022
2493
  this.registry = new RepoRegistry({
3023
- git,
3024
- gitTimeoutMs: config.gitTimeoutMs,
3025
- auth: (url) => this.gitAuth(url),
2494
+ repos: () => ctx.get?.(DSH_REPOS_SERVICE),
3026
2495
  onChange: () => {
3027
2496
  this.onRegistryChange();
3028
2497
  }
@@ -3030,10 +2499,9 @@ var GuanxiangService = class {
3030
2499
  this.updater = new SkillUpdater({
3031
2500
  registry: this.registry,
3032
2501
  settings: () => this.current,
3033
- git,
3034
- gitTimeoutMs: config.gitTimeoutMs,
3035
2502
  ...config.skillUpdateAuthor === void 0 ? {} : { author: config.skillUpdateAuthor },
3036
- approval: () => ctx.get?.("approval")
2503
+ approval: () => ctx.get?.("approval"),
2504
+ repos: () => ctx.get?.(DSH_REPOS_SERVICE)
3037
2505
  });
3038
2506
  this.dispatcher = new Dispatcher({
3039
2507
  registry: this.registry,
@@ -3050,7 +2518,10 @@ var GuanxiangService = class {
3050
2518
  maxAppsPerRequest: () => this.current.maxAppsPerRequest,
3051
2519
  skillUpdates: () => this.current.skillUpdates.enabled,
3052
2520
  autoInvoke: config.autoInvoke,
3053
- inRepositories: (cwd) => isInside(cwd, expandHome(this.current.reposDir))
2521
+ inRepositories: (cwd) => {
2522
+ const lookup = lookupRepos(ctx.get?.(DSH_REPOS_SERVICE));
2523
+ return lookup.availability === "ready" && isInside(cwd, lookup.service.mirrorSettings().dir);
2524
+ }
3054
2525
  });
3055
2526
  }
3056
2527
  /** Register the entry skill provider; called once from `apply()`. */
@@ -3095,7 +2566,8 @@ var GuanxiangService = class {
3095
2566
  }
3096
2567
  }
3097
2568
  /**
3098
- * Load the panel overlay, configure the registry, and sync every repository.
2569
+ * Load the panel overlay, configure the registry, move legacy access tokens
2570
+ * to dsh-repo hosts, and sync every repository.
3099
2571
  * @returns when the first sync round settled.
3100
2572
  */
3101
2573
  async boot() {
@@ -3113,9 +2585,53 @@ var GuanxiangService = class {
3113
2585
  this.ctx.logger?.warn?.("guanxiang: 忽略面板设置:%s", read.error);
3114
2586
  }
3115
2587
  this.current = applyOverlay(this.defaults, this.overlay);
3116
- this.registry.configure(this.current.reposDir, this.current.repos);
2588
+ this.registry.configure(this.current.repos);
2589
+ await this.migrateCredentials();
3117
2590
  await this.registry.syncAll();
3118
2591
  }
2592
+ /**
2593
+ * React to dsh-repo becoming available (or reloading): move legacy tokens
2594
+ * and sync every repository again.
2595
+ * @returns when the syncs settled.
2596
+ */
2597
+ async reposAvailable() {
2598
+ await this.booted.promise;
2599
+ if (!this.active) return;
2600
+ await this.migrateCredentials();
2601
+ await this.registry.resyncAll();
2602
+ }
2603
+ /**
2604
+ * Add each access token of earlier versions whose address dsh-repo does not
2605
+ * know yet as a dsh-repo host, carrying the stored token value. The panel's
2606
+ * list replaces the cordis.yml list, as it did before. An entry whose token
2607
+ * cannot be resolved yet is left for a later start instead of creating a
2608
+ * host without a token. Failures are logged; the legacy entry stays and the
2609
+ * next start tries again.
2610
+ */
2611
+ async migrateCredentials() {
2612
+ const legacy = this.overlay.gitCredentials ?? this.legacyCredentials;
2613
+ if (legacy.length === 0) return;
2614
+ const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE));
2615
+ if (lookup.availability !== "ready") return;
2616
+ const store = this.credentialStore();
2617
+ if (store === void 0) return;
2618
+ for (const credential of legacy) try {
2619
+ const token = (await store.resolve(credential.tokenEnv))?.value;
2620
+ if (token === void 0 || token === "") {
2621
+ this.ctx.logger?.warn?.("guanxiang: 访问令牌 %s 尚未设置,暂不迁移到 dsh-repo", credential.url);
2622
+ continue;
2623
+ }
2624
+ await lookup.service.addHost({
2625
+ url: credential.url,
2626
+ ...credential.alias === void 0 || credential.alias === "" ? {} : { alias: credential.alias },
2627
+ token
2628
+ });
2629
+ this.ctx.logger?.info?.("guanxiang: 已把访问令牌 %s 迁移为 dsh-repo 主机", credential.url);
2630
+ } catch (error) {
2631
+ if (repoErrorCode(error) === "conflict") continue;
2632
+ this.ctx.logger?.warn?.("guanxiang: 迁移访问令牌 %s 到 dsh-repo 失败:%s", credential.url, messageOf$1(error));
2633
+ }
2634
+ }
3119
2635
  /** Stop registering tools; in-flight syncs finish without side effects on the host. */
3120
2636
  shutdown() {
3121
2637
  this.active = false;
@@ -3133,7 +2649,8 @@ var GuanxiangService = class {
3133
2649
  overridden: overriddenKeys(this.overlay),
3134
2650
  ...this.overlayError === void 0 ? {} : { overlayError: this.overlayError },
3135
2651
  repos: this.registry.status(),
3136
- credentials: await this.credentialsState(),
2652
+ hosts: await this.hostsState(),
2653
+ credentialsAvailable: this.credentialStore() !== void 0,
3137
2654
  analyze: await this.analyzeState()
3138
2655
  };
3139
2656
  }
@@ -3164,42 +2681,109 @@ var GuanxiangService = class {
3164
2681
  credentialStore() {
3165
2682
  return this.ctx.get?.("credentials");
3166
2683
  }
3167
- async credentialsState() {
3168
- const store = this.credentialStore();
3169
- if (store === void 0) return {
3170
- available: false,
3171
- tokens: []
3172
- };
3173
- const names = [...new Set(this.current.gitCredentials.map((credential) => credential.tokenEnv))];
3174
- return {
3175
- available: true,
3176
- tokens: await Promise.all(names.map((tokenEnv) => describeToken(store, tokenEnv)))
2684
+ async hostsState() {
2685
+ const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE));
2686
+ if (lookup.availability !== "ready") return {
2687
+ availability: lookup.availability,
2688
+ hosts: []
3177
2689
  };
2690
+ const mirrorDir = lookup.service.mirrorSettings().dir;
2691
+ try {
2692
+ return {
2693
+ availability: "ready",
2694
+ mirrorDir,
2695
+ hosts: (await lookup.service.listHosts()).map((host) => ({
2696
+ kind: host.kind,
2697
+ url: host.url,
2698
+ ...host.alias === void 0 ? {} : { alias: host.alias },
2699
+ origin: host.origin,
2700
+ token: {
2701
+ tokenEnv: host.tokenRef,
2702
+ ...host.token
2703
+ }
2704
+ }))
2705
+ };
2706
+ } catch (error) {
2707
+ return {
2708
+ availability: "ready",
2709
+ mirrorDir,
2710
+ hosts: [],
2711
+ error: messageOf$1(error)
2712
+ };
2713
+ }
3178
2714
  }
3179
2715
  /**
3180
- * Environment carrying the access token for one repository address; empty
3181
- * when no access-token configuration matches it.
3182
- * @param url - repository address.
3183
- * @returns variables for its network git commands.
3184
- * @throws when a configuration matches but its token cannot be read.
2716
+ * Add a dsh-repo host, optionally with its token, then sync every repository.
2717
+ * @param raw - request body `{ kind?, url, alias?, token? }`.
2718
+ * @returns the new panel state.
2719
+ * @throws SettingsError when the body is invalid, dsh-repo is unavailable, or it refuses the host.
3185
2720
  */
3186
- async gitAuth(url) {
3187
- const spec = credentialFor(url, this.current.gitCredentials);
3188
- if (spec === void 0) return {};
3189
- const store = this.credentialStore();
3190
- if (store === void 0) throw new Error(`地址匹配访问令牌 ${spec.url},但当前 DSH 组合没有凭据服务,无法读取 ${spec.tokenEnv}`);
3191
- const token = await store.resolve(spec.tokenEnv);
3192
- if (token === void 0) throw new Error(`地址匹配访问令牌 ${spec.url},但 ${spec.tokenEnv} 未设置:请在观象面板「访问令牌」中保存令牌,或设置同名环境变量`);
3193
- if (!isAcceptedToken(token.value)) throw new Error(`${spec.tokenEnv} 的值不能用作令牌:不能超过 ${MAX_TOKEN_LENGTH} 个字符或包含换行等控制字符`);
3194
- return credentialEnvironment(spec, token.value);
2721
+ async addHost(raw) {
2722
+ const parsed = hostAddSchema.safeParse(raw);
2723
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
2724
+ const { kind, url, alias, token } = parsed.data;
2725
+ await this.withRepos((repos) => repos.addHost({
2726
+ url,
2727
+ ...kind === void 0 ? {} : { kind },
2728
+ ...alias === void 0 ? {} : { alias },
2729
+ ...token === void 0 ? {} : { token }
2730
+ }));
2731
+ return await this.state();
2732
+ }
2733
+ /**
2734
+ * Remove a host added from a panel, and its stored token.
2735
+ * @param raw - request body `{ url }`.
2736
+ * @returns the new panel state.
2737
+ * @throws SettingsError when the body is invalid, dsh-repo is unavailable, or the host is unknown or configured in cordis.yml.
2738
+ */
2739
+ async removeHost(raw) {
2740
+ const parsed = hostUrlSchema.safeParse(raw);
2741
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
2742
+ await this.withRepos((repos) => repos.removeHost(parsed.data.url));
2743
+ return await this.state();
2744
+ }
2745
+ /**
2746
+ * Store a host's token through dsh-repo, then sync every repository.
2747
+ * @param raw - request body `{ url, value }`.
2748
+ * @returns the new panel state.
2749
+ * @throws SettingsError when the body is invalid, dsh-repo is unavailable, or the host is unknown.
2750
+ */
2751
+ async setHostToken(raw) {
2752
+ const parsed = hostTokenSchema.safeParse(raw);
2753
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
2754
+ await this.withRepos((repos) => repos.setHostToken(parsed.data.url, parsed.data.value));
2755
+ return await this.state();
3195
2756
  }
3196
2757
  /**
3197
- * Store the token of one access-token configuration in DSH's credential
3198
- * store, then re-sync the repositories that use it; or store the analyze
3199
- * API's bearer token (`analyzeTokenRef`), which applies from the next request.
2758
+ * Remove a host's stored token; an environment variable of the same name applies again.
2759
+ * @param raw - request body `{ url }`.
2760
+ * @returns the new panel state.
2761
+ * @throws SettingsError on the same conditions as {@link setHostToken}.
2762
+ */
2763
+ async clearHostToken(raw) {
2764
+ const parsed = hostUrlSchema.safeParse(raw);
2765
+ if (!parsed.success) throw new SettingsError(describeIssue(parsed.error));
2766
+ await this.withRepos((repos) => repos.setHostToken(parsed.data.url, void 0));
2767
+ return await this.state();
2768
+ }
2769
+ /** Run one host change and start a resync, reporting dsh-repo failures as field errors. */
2770
+ async withRepos(change) {
2771
+ const lookup = lookupRepos(this.ctx.get?.(DSH_REPOS_SERVICE));
2772
+ if (lookup.availability !== "ready") throw new SettingsError(unavailableMessage(lookup.availability));
2773
+ try {
2774
+ await change(lookup.service);
2775
+ } catch (error) {
2776
+ if (repoErrorCode(error) !== void 0) throw new SettingsError(messageOf$1(error));
2777
+ throw error;
2778
+ }
2779
+ this.registry.resyncAll();
2780
+ }
2781
+ /**
2782
+ * Store the analyze API's bearer token (`analyzeTokenRef`) in DSH's
2783
+ * credential store; it applies from the next request.
3200
2784
  * @param raw - request body `{ tokenEnv, value }`.
3201
2785
  * @returns the new panel state.
3202
- * @throws SettingsError when the body is invalid, the name is neither referenced by the saved settings nor `analyzeTokenRef`, or no credential store is loaded.
2786
+ * @throws SettingsError when the body is invalid, the name is not `analyzeTokenRef`, or no credential store is loaded.
3203
2787
  */
3204
2788
  async setToken(raw) {
3205
2789
  const parsed = tokenSetSchema.safeParse(raw);
@@ -3207,8 +2791,8 @@ var GuanxiangService = class {
3207
2791
  return await this.writeToken(parsed.data.tokenEnv, parsed.data.value);
3208
2792
  }
3209
2793
  /**
3210
- * Remove the stored token of one access-token configuration; an environment
3211
- * variable of the same name applies again afterwards.
2794
+ * Remove the stored analyze token; an environment variable of the same name
2795
+ * applies again afterwards.
3212
2796
  * @param raw - request body `{ tokenEnv }`.
3213
2797
  * @returns the new panel state.
3214
2798
  * @throws SettingsError on the same conditions as {@link setToken}.
@@ -3219,17 +2803,16 @@ var GuanxiangService = class {
3219
2803
  return await this.writeToken(parsed.data.tokenEnv, void 0);
3220
2804
  }
3221
2805
  async writeToken(tokenEnv, value) {
3222
- if (tokenEnv !== this.analyzeTokenRef && !this.current.gitCredentials.some((credential) => credential.tokenEnv === tokenEnv)) throw new SettingsError(`${tokenEnv} 不是已保存的访问令牌配置引用的名称`);
2806
+ if (tokenEnv !== this.analyzeTokenRef) throw new SettingsError(`${tokenEnv} 不是分析接口令牌的名称`);
3223
2807
  const store = this.credentialStore();
3224
2808
  if (store === void 0) throw new SettingsError("当前 DSH 组合没有凭据服务(@deepseek-ai/dsh-credentials-local),无法保存令牌");
3225
2809
  if (value === void 0) await store.unset(tokenEnv);
3226
2810
  else await store.set(tokenEnv, value);
3227
- this.registry.resyncUrls(this.current.repos.map((repo) => repo.url).filter((url) => credentialFor(url, this.current.gitCredentials)?.tokenEnv === tokenEnv));
3228
2811
  return await this.state();
3229
2812
  }
3230
2813
  /**
3231
2814
  * Validate and persist a complete settings payload from the panel, then
3232
- * sync repositories whose address, branch, or directory changed.
2815
+ * sync repositories whose address or branch changed.
3233
2816
  * @param raw - request body.
3234
2817
  * @returns the new panel state.
3235
2818
  * @throws SettingsError when the payload is invalid.
@@ -3260,20 +2843,12 @@ var GuanxiangService = class {
3260
2843
  if (url !== void 0 && !this.registry.has(url)) throw new SettingsError(`未配置的仓库:${url}`);
3261
2844
  url === void 0 ? this.registry.syncAll() : this.registry.syncUrls([url]);
3262
2845
  }
3263
- /**
3264
- * Make an overlay effective and sync the repositories whose address, branch,
3265
- * directory, or matching access-token configuration changed.
3266
- */
2846
+ /** Make an overlay effective and sync the repositories whose address or branch changed. */
3267
2847
  apply(overlay) {
3268
- const previous = this.current.gitCredentials;
3269
2848
  this.overlay = overlay;
3270
2849
  this.overlayError = void 0;
3271
2850
  this.current = applyOverlay(this.defaults, overlay);
3272
- const changed = this.registry.configure(this.current.reposDir, this.current.repos);
3273
- const next = this.current.gitCredentials;
3274
- const reauthorized = this.current.repos.map((repo) => repo.url).filter((url) => JSON.stringify(credentialFor(url, previous)) !== JSON.stringify(credentialFor(url, next)));
3275
- this.registry.syncUrls(changed);
3276
- this.registry.resyncUrls(reauthorized.filter((url) => !changed.includes(url)));
2851
+ this.registry.syncUrls(this.registry.configure(this.current.repos));
3277
2852
  }
3278
2853
  onRegistryChange() {
3279
2854
  if (!this.active) return;
@@ -3452,6 +3027,28 @@ function registerSettingsRoutes(server, gate, service) {
3452
3027
  state: req.method?.toUpperCase() === "DELETE" ? await service.clearToken(body) : await service.setToken(body)
3453
3028
  });
3454
3029
  })
3030
+ }),
3031
+ server.register({
3032
+ kind: "exact",
3033
+ path: HOSTS_ROUTE,
3034
+ handler: guarded(gate, ["POST", "DELETE"], async (req, res) => {
3035
+ const body = await readJsonBody(req, false);
3036
+ sendJson(res, 200, {
3037
+ ok: true,
3038
+ state: req.method?.toUpperCase() === "DELETE" ? await service.removeHost(body) : await service.addHost(body)
3039
+ });
3040
+ })
3041
+ }),
3042
+ server.register({
3043
+ kind: "exact",
3044
+ path: HOST_TOKEN_ROUTE,
3045
+ handler: guarded(gate, ["PUT", "DELETE"], async (req, res) => {
3046
+ const body = await readJsonBody(req, false);
3047
+ sendJson(res, 200, {
3048
+ ok: true,
3049
+ state: req.method?.toUpperCase() === "DELETE" ? await service.clearHostToken(body) : await service.setHostToken(body)
3050
+ });
3051
+ })
3455
3052
  })
3456
3053
  ];
3457
3054
  return () => {
@@ -3485,8 +3082,8 @@ function mountSettingsRoutes(ctx, service) {
3485
3082
  * 任务匹配已注册应用时由顶层 Agent 自行加载)和
3486
3083
  * `guanxiang_dispatch` 工具(按应用派发子 Agent,工作目录为该应用的代码
3487
3084
  * 仓库,首条消息用 `/<skill>` 加载仓库里的日志分析 SKILL),以及开启 SKILL
3488
- * 改进时的 `guanxiang_propose_skill` 工具(经用户授权后把子 Agent 的 SKILL
3489
- * 修改提议推送到新分支并创建合并请求)。应用仓库在设置
3085
+ * 改进时的 `guanxiang_propose_skill` 工具(经用户授权后经 dsh-repo 把子 Agent
3086
+ * 的 SKILL 修改提议提交到新分支并创建合并请求)。应用仓库在设置
3490
3087
  * 面板中只填 git 地址,由插件拉取;应用描述来自各仓库的
3491
3088
  * `.dsh/guanxiang.yaml`。另提供日志平台分析接口 `POST /open-api/logalyze`,
3492
3089
  * 经 dsh-channels 的 `feishuTopics` 服务在飞书群话题中开始分析。
@@ -3503,7 +3100,7 @@ const inject = [
3503
3100
  ];
3504
3101
  /**
3505
3102
  * Install the entry skill, the dispatch and SKILL proposal tools, the settings
3506
- * routes, and the analyze route, then clone or update the configured
3103
+ * routes, and the analyze route, then mirror or update the configured
3507
3104
  * repositories in the background.
3508
3105
  * @param ctx - plugin context.
3509
3106
  * @param config - loader-validated configuration.
@@ -3523,8 +3120,13 @@ function apply(ctx, config, options = {}) {
3523
3120
  service.shutdown();
3524
3121
  };
3525
3122
  }, "guanxiang: repositories");
3123
+ ctx.inject?.([DSH_REPOS_SERVICE], () => {
3124
+ service.reposAvailable().catch((error) => {
3125
+ ctx.logger?.error?.("guanxiang: 同步仓库失败:%s", error instanceof Error ? error.message : String(error));
3126
+ });
3127
+ });
3526
3128
  }
3527
3129
  //#endregion
3528
- export { ANALYZE_ROUTE, pluginConfigSchema as Config, pluginConfigSchema, DISPATCH_TOOL_NAME, Dispatcher, ENTRY_SKILL_NAME, GuanxiangService, MANIFEST_PATH, PROPOSE_TOOL_NAME, RepoRegistry, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, SkillUpdater, analyzeRequestHash, apply, inject, name, parseAnalyzeRequest, readAppManifest, renderChildPrompt, renderEntrySkill, resolveConfig };
3130
+ export { ANALYZE_ROUTE, pluginConfigSchema as Config, pluginConfigSchema, DISPATCH_TOOL_NAME, Dispatcher, ENTRY_SKILL_NAME, GuanxiangService, HOSTS_ROUTE, HOST_TOKEN_ROUTE, MANIFEST_PATH, PROPOSE_TOOL_NAME, RepoRegistry, SETTINGS_ROUTE, STATE_ROUTE, SYNC_ROUTE, SkillUpdater, analyzeRequestHash, apply, inject, name, parseAnalyzeRequest, readAppManifest, renderChildPrompt, renderEntrySkill, resolveConfig };
3529
3131
 
3530
3132
  //# sourceMappingURL=index.js.map