@andyqiu/codeforge 0.6.12 → 0.7.0

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/dist/index.js CHANGED
@@ -34,15 +34,15 @@ __export(exports_worktree_ops, {
34
34
  commitWorktreeIfDirty: () => commitWorktreeIfDirty
35
35
  });
36
36
  import { execFile as execFile2 } from "node:child_process";
37
- import { promises as fs8 } from "node:fs";
38
- import * as path11 from "node:path";
37
+ import { promises as fs7 } from "node:fs";
38
+ import * as path9 from "node:path";
39
39
  async function ensureWorktree(opts) {
40
- const root = path11.resolve(opts.root);
41
- const dir = opts.worktrees_dir ?? path11.join(root, ".git", "codeforge-worktrees");
42
- await fs8.mkdir(dir, { recursive: true });
43
- const wtPath = path11.join(dir, sanitizeBranch(opts.branch));
40
+ const root = path9.resolve(opts.root);
41
+ const dir = opts.worktrees_dir ?? path9.join(root, ".git", "codeforge-worktrees");
42
+ await fs7.mkdir(dir, { recursive: true });
43
+ const wtPath = path9.join(dir, sanitizeBranch(opts.branch));
44
44
  try {
45
- const stat = await fs8.stat(wtPath);
45
+ const stat = await fs7.stat(wtPath);
46
46
  if (stat.isDirectory()) {
47
47
  return { path: wtPath, branch: opts.branch, created: false };
48
48
  }
@@ -76,7 +76,7 @@ function isWorktreeAbsentError(err) {
76
76
  return /is not a working tree/i.test(msg) || /No such file or directory/i.test(msg) || /not a valid path/i.test(msg);
77
77
  }
78
78
  async function deleteBranchIfExists(opts) {
79
- const root = path11.resolve(opts.root);
79
+ const root = path9.resolve(opts.root);
80
80
  const timeout = opts.git_timeout_ms ?? 5000;
81
81
  try {
82
82
  await runGit(root, ["rev-parse", "--verify", `refs/heads/${opts.branch}`], 3000);
@@ -133,7 +133,7 @@ function sanitizeBranch(name) {
133
133
  return name.replace(/[/\\:*?"<>|]/g, "-");
134
134
  }
135
135
  function runGitAllowFail(cwd, args, timeout) {
136
- return new Promise((resolve10, reject) => {
136
+ return new Promise((resolve9, reject) => {
137
137
  execFile2("git", args, { cwd, timeout, windowsHide: true, encoding: "utf8" }, (err, stdout, stderr) => {
138
138
  if (err) {
139
139
  const errCode = err.code;
@@ -141,30 +141,30 @@ function runGitAllowFail(cwd, args, timeout) {
141
141
  reject(new Error(`git ${args.join(" ")} 失败: ${stderr || err.message}`));
142
142
  return;
143
143
  }
144
- resolve10({
144
+ resolve9({
145
145
  code: typeof errCode === "number" ? errCode : 1,
146
146
  stdout: stdout ?? "",
147
147
  stderr: stderr ?? err.message ?? ""
148
148
  });
149
149
  return;
150
150
  }
151
- resolve10({ code: 0, stdout, stderr: stderr ?? "" });
151
+ resolve9({ code: 0, stdout, stderr: stderr ?? "" });
152
152
  });
153
153
  });
154
154
  }
155
155
  function runGit(cwd, args, timeout) {
156
- return new Promise((resolve10, reject) => {
156
+ return new Promise((resolve9, reject) => {
157
157
  execFile2("git", args, { cwd, timeout, windowsHide: true, encoding: "utf8" }, (err, stdout, stderr) => {
158
158
  if (err) {
159
159
  reject(new Error(`git ${args.join(" ")} 失败: ${stderr || err.message}`));
160
160
  return;
161
161
  }
162
- resolve10(stdout);
162
+ resolve9(stdout);
163
163
  });
164
164
  });
165
165
  }
166
166
  async function tryMerge(opts) {
167
- const root = path11.resolve(opts.root);
167
+ const root = path9.resolve(opts.root);
168
168
  const timeout = opts.git_timeout_ms ?? 1e4;
169
169
  const status = await runGitAllowFail(root, ["status", "--porcelain"], 3000);
170
170
  if (status.code !== 0) {
@@ -210,27 +210,27 @@ async function tryMerge(opts) {
210
210
  };
211
211
  }
212
212
  async function mergeCommit(opts) {
213
- const root = path11.resolve(opts.root);
213
+ const root = path9.resolve(opts.root);
214
214
  await runGit(root, ["commit", "-m", opts.message], opts.git_timeout_ms ?? 1e4);
215
215
  }
216
216
  async function mergeAbort(opts) {
217
- const root = path11.resolve(opts.root);
217
+ const root = path9.resolve(opts.root);
218
218
  await runGitAllowFail(root, ["merge", "--abort"], opts.git_timeout_ms ?? 5000);
219
219
  }
220
220
  async function getMergeConflicts(opts) {
221
- const out = await runGitAllowFail(path11.resolve(opts.root), ["diff", "--name-only", "--diff-filter=U"], opts.git_timeout_ms ?? 3000);
221
+ const out = await runGitAllowFail(path9.resolve(opts.root), ["diff", "--name-only", "--diff-filter=U"], opts.git_timeout_ms ?? 3000);
222
222
  if (out.code !== 0)
223
223
  return [];
224
224
  return out.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
225
225
  }
226
226
  async function worktreeHasChanges(opts) {
227
- const out = await runGitAllowFail(path11.resolve(opts.worktree_path), ["status", "--porcelain"], opts.git_timeout_ms ?? 3000);
227
+ const out = await runGitAllowFail(path9.resolve(opts.worktree_path), ["status", "--porcelain"], opts.git_timeout_ms ?? 3000);
228
228
  if (out.code !== 0)
229
229
  return false;
230
230
  return out.stdout.trim().length > 0;
231
231
  }
232
232
  async function commitWorktreeIfDirty(opts) {
233
- const wt = path11.resolve(opts.worktree_path);
233
+ const wt = path9.resolve(opts.worktree_path);
234
234
  const timeout = opts.git_timeout_ms ?? 1e4;
235
235
  const status = await runGitAllowFail(wt, ["status", "--porcelain"], 3000);
236
236
  if (status.code !== 0) {
@@ -10661,1732 +10661,1755 @@ class ApprovalStore {
10661
10661
  }
10662
10662
  }
10663
10663
 
10664
- // tools/review-approval.ts
10665
- var description4 = [
10666
- "reviewer 专用:写入 APPROVE 审批记录。",
10667
- "**何时调用**:reviewer 给出 `## Decision\\nAPPROVE` 之前必须调本工具。",
10668
- "**两层语义独立**(ADR:decision-token-vs-approval-verdict-layering):",
10669
- " - 本工具 `verdict` 字段属审批层,合法值:APPROVE / APPROVE_WITH_NOTES",
10670
- " - reviewer 输出的 `## Decision` 节首行属协议层,合法值:APPROVE / REQUEST_CHANGES / BLOCK",
10671
- " - ⚠️ 严禁把 `APPROVE_WITH_NOTES` 字面量写进 `## Decision` 节首行(容错层会归一,但其他变体如 APPROVE_MINOR 会失败 → merge-loop 误判死循环)",
10672
- ' - ✅ 正确:verdict="APPROVE_WITH_NOTES" + `## Decision\\nAPPROVE`(首行写 APPROVE,详情在审批 notes)',
10673
- "**pendingIds 格式**:推荐 `session:<sid>` / `plan:<plan_id>` / `decision:<hash>`;旧 `pc-<ts>-NNN` 仍兼容。",
10674
- "**何时不调**:REQUEST_CHANGES / BLOCK 不调(无 APPROVE = 无审批记录)。",
10675
- "**fallback**:codeforge 解析 reviewer boomerang 见 APPROVE 但无记录 → 自动以 source='codeforge-fallback' 补写。"
10676
- ].join(`
10677
- `);
10678
- var ArgsSchema4 = z4.object({
10679
- verdict: z4.enum(["APPROVE", "APPROVE_WITH_NOTES"]).describe("审批层裁决(审计字段,与协议层独立);REQUEST_CHANGES / BLOCK 不应调本工具。⚠️ 不要把本字段值复制到 reviewer 输出的 `## Decision` 节首行 — 那是协议层 3 档。"),
10680
- pendingIds: z4.array(z4.string().min(1)).min(1, "pendingIds 至少 1 条").describe("本次 APPROVE 覆盖的 id 列表。推荐 session:<sid> / plan:<plan_id> / decision:<hash>;旧 pc-xxx 兼容"),
10681
- notes: z4.string().min(1, "notes 不能为空").max(2000, "notes 过长(> 2000 字),建议拆条").describe("审阅意见摘要(建议 ≤ 500 字)"),
10682
- decisionLine: z4.string().optional().describe("`## Decision` 节首行原文(默认 verdict 字面量,机审证据)"),
10683
- source: z4.enum(["reviewer", "codeforge-fallback"]).optional().describe("写入来源;默认 'reviewer',codeforge 补写时传 'codeforge-fallback'"),
10684
- reviewerAgent: z4.string().optional().describe("写入 agent name(默认 'reviewer';fallback 时为 'codeforge')"),
10685
- sessionId: z4.string().optional().describe("reviewer 子 session id(boomerang 溯源用,可选)"),
10686
- model: z4.string().optional().describe("审批模型 id(审计用,可选)"),
10687
- coveredSha: z4.string().optional().describe("approval 写入时 worktree HEAD sha;reviewer 调此工具时传入(git -C <worktreePath> rev-parse HEAD)。pre-check 强绑定核心字段,缺失则 pre-check 不命中。ADR:merge-approval-pre-check"),
10688
- reviewTarget: z4.string().optional().describe("本次审阅的 review_target 值(reviewer.md 词表:code / code:typescript / code:python / code:csharp-lua-c / plan_only / adr / docs / decision_only)。pre-check 仅放行 startsWith('code') 的值;缺失或其他值均不命中。ADR:merge-approval-pre-check")
10689
- });
10690
- var _approvalStore = null;
10691
- function getApprovalStore() {
10692
- if (!_approvalStore)
10693
- _approvalStore = ApprovalStore.forProject(process.cwd());
10694
- return _approvalStore;
10695
- }
10696
- async function execute4(input) {
10697
- const parsed = ArgsSchema4.safeParse(input);
10698
- if (!parsed.success) {
10699
- return {
10700
- ok: false,
10701
- error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
10702
- };
10703
- }
10704
- const args = parsed.data;
10705
- const approvals = getApprovalStore();
10706
- const now = new Date().toISOString();
10707
- const written = [];
10708
- for (const id of args.pendingIds) {
10709
- const meta = {
10710
- pendingId: id,
10711
- verdict: args.verdict,
10712
- reviewer: {
10713
- agent: args.reviewerAgent ?? (args.source === "codeforge-fallback" ? "codeforge" : "reviewer"),
10714
- ...args.sessionId ? { sessionId: args.sessionId } : {},
10715
- ...args.model ? { model: args.model } : {},
10716
- source: args.source ?? "reviewer"
10717
- },
10718
- targets: [],
10719
- decisionLine: args.decisionLine ?? args.verdict,
10720
- notes: args.notes,
10721
- createdAt: now,
10722
- ...args.coveredSha ? { coveredSha: args.coveredSha } : {},
10723
- ...args.reviewTarget ? { reviewTarget: args.reviewTarget } : {},
10724
- escapeHatch: null
10725
- };
10726
- const file = await approvals.record(meta);
10727
- written.push({ pendingId: id, file });
10728
- }
10729
- return { ok: true, written };
10730
- }
10731
- // tools/browser-navigate.ts
10732
- import { z as z5 } from "zod";
10664
+ // lib/session-worktree.ts
10665
+ init_worktree_ops();
10666
+ import { execFile as execFile3 } from "node:child_process";
10667
+ import { promises as fs9 } from "node:fs";
10668
+ import * as path11 from "node:path";
10733
10669
 
10734
- // lib/browser-control.ts
10735
- import * as path9 from "node:path";
10736
- var DEFAULT_CONFIG2 = {
10737
- enabled: false,
10738
- headless: true,
10739
- allow: ["^https?://"],
10740
- block: ["^file:", "^data:", "^about:", "^javascript:"],
10741
- actionTimeoutMs: 15000,
10742
- idleTimeoutMs: 5 * 60000,
10743
- screenshotDir: "",
10744
- bufferLimit: 500
10745
- };
10746
- function defaultScreenshotDir(root = process.cwd()) {
10747
- return path9.join(runtimeDir(root), "browser", "screenshots");
10748
- }
10749
- function checkUrl(url, cfg = DEFAULT_CONFIG2) {
10750
- if (typeof url !== "string" || url.trim() === "") {
10751
- return { ok: false, reason: "empty_url" };
10752
- }
10753
- for (const pat of cfg.block) {
10670
+ // lib/file-lock.ts
10671
+ import { promises as fs8 } from "node:fs";
10672
+ import * as crypto3 from "node:crypto";
10673
+ import * as os4 from "node:os";
10674
+ import * as path10 from "node:path";
10675
+ async function withFileLock(lockPath, fn, opts = {}) {
10676
+ const retryMs = opts.retryMs ?? 50;
10677
+ const timeoutMs = opts.timeoutMs ?? 1e4;
10678
+ const staleMs = opts.staleMs ?? 300000;
10679
+ await fs8.mkdir(path10.dirname(lockPath), { recursive: true });
10680
+ const deadline = Date.now() + timeoutMs;
10681
+ const myHost = os4.hostname();
10682
+ const myToken = crypto3.randomBytes(16).toString("hex");
10683
+ let acquired = false;
10684
+ while (!acquired) {
10754
10685
  try {
10755
- if (new RegExp(pat).test(url)) {
10756
- return { ok: false, reason: `blocked: matches ${pat}` };
10686
+ const handle = await fs8.open(lockPath, "wx");
10687
+ try {
10688
+ const meta = {
10689
+ pid: process.pid,
10690
+ host: myHost,
10691
+ token: myToken,
10692
+ acquired_at: new Date().toISOString()
10693
+ };
10694
+ await handle.writeFile(JSON.stringify(meta), "utf8");
10695
+ await handle.close();
10696
+ } catch (writeErr) {
10697
+ await handle.close().catch(() => {});
10698
+ await fs8.rm(lockPath, { force: true }).catch(() => {});
10699
+ throw writeErr;
10757
10700
  }
10758
- } catch {}
10701
+ acquired = true;
10702
+ } catch (err) {
10703
+ const e = err;
10704
+ if (e.code !== "EEXIST")
10705
+ throw err;
10706
+ const cleaned = await tryCleanStaleLock(lockPath, staleMs, myHost);
10707
+ if (cleaned)
10708
+ continue;
10709
+ if (Date.now() >= deadline) {
10710
+ throw new Error(`withFileLock: 等待 ${lockPath} 超过 ${timeoutMs}ms 仍未拿到锁`);
10711
+ }
10712
+ await sleep(retryMs);
10713
+ }
10759
10714
  }
10760
- if (cfg.allow.length === 0)
10761
- return { ok: true };
10762
- for (const pat of cfg.allow) {
10763
- try {
10764
- if (new RegExp(pat).test(url))
10765
- return { ok: true };
10766
- } catch {}
10715
+ try {
10716
+ return await fn();
10717
+ } finally {
10718
+ await releaseLockSafely(lockPath, myToken).catch(() => {});
10767
10719
  }
10768
- return { ok: false, reason: "not_in_allow_list" };
10769
10720
  }
10770
-
10771
- class NoopBrowserController {
10772
- reason;
10773
- constructor(reason = "browser is disabled (set tools.browser.enabled=true to use)") {
10774
- this.reason = reason;
10721
+ async function tryCleanStaleLock(lockPath, staleMs, myHost) {
10722
+ let mtimeMs;
10723
+ try {
10724
+ const st = await fs8.stat(lockPath);
10725
+ mtimeMs = st.mtimeMs;
10726
+ } catch {
10727
+ return true;
10775
10728
  }
10776
- async navigate(url) {
10777
- return { ok: false, url, error: this.reason };
10729
+ const age = Date.now() - mtimeMs;
10730
+ if (age <= staleMs)
10731
+ return false;
10732
+ let meta = null;
10733
+ try {
10734
+ const raw = await fs8.readFile(lockPath, "utf8");
10735
+ meta = JSON.parse(raw);
10736
+ } catch {
10737
+ meta = null;
10778
10738
  }
10779
- async screenshot() {
10780
- return { ok: false, error: this.reason };
10739
+ if (meta && meta.host === myHost && typeof meta.pid === "number" && meta.pid > 0) {
10740
+ try {
10741
+ process.kill(meta.pid, 0);
10742
+ return false;
10743
+ } catch (err) {
10744
+ const e = err;
10745
+ if (e.code === "EPERM")
10746
+ return false;
10747
+ }
10781
10748
  }
10782
- async click(selector) {
10783
- return { ok: false, selector, found: false, error: this.reason };
10749
+ return await deleteIfMtimeMatch(lockPath, mtimeMs);
10750
+ }
10751
+ async function deleteIfMtimeMatch(lockPath, expectedMtimeMs) {
10752
+ try {
10753
+ const st = await fs8.stat(lockPath);
10754
+ if (st.mtimeMs !== expectedMtimeMs) {
10755
+ return false;
10756
+ }
10757
+ } catch {
10758
+ return true;
10784
10759
  }
10785
- async fill(selector, _value) {
10786
- return { ok: false, selector, found: false, error: this.reason };
10760
+ await fs8.rm(lockPath, { force: true }).catch(() => {});
10761
+ return true;
10762
+ }
10763
+ async function releaseLockSafely(lockPath, myToken) {
10764
+ let raw;
10765
+ try {
10766
+ raw = await fs8.readFile(lockPath, "utf8");
10767
+ } catch {
10768
+ return;
10787
10769
  }
10788
- async consoleLogs() {
10789
- return [];
10770
+ let meta;
10771
+ try {
10772
+ meta = JSON.parse(raw);
10773
+ } catch {
10774
+ return;
10790
10775
  }
10791
- async networkRequests() {
10792
- return [];
10776
+ if (meta.token === myToken) {
10777
+ await fs8.rm(lockPath, { force: true });
10793
10778
  }
10794
- async close() {}
10795
10779
  }
10796
- async function tryCreatePlaywrightController(cfg = DEFAULT_CONFIG2, resolver = defaultPlaywrightResolver) {
10797
- if (!cfg.enabled)
10798
- return null;
10799
- let mod;
10780
+ function sleep(ms) {
10781
+ return new Promise((resolve9) => setTimeout(resolve9, ms));
10782
+ }
10783
+
10784
+ // lib/session-worktree.ts
10785
+ var REGISTRY_VERSION = 1;
10786
+ var DEFAULT_WORKTREE_SUBDIR = path11.join(".git", "codeforge-worktrees");
10787
+ function debugLog(msg) {
10788
+ if (process.env["CODEFORGE_DEBUG"]) {
10789
+ console.debug(`[session-worktree] ${msg}`);
10790
+ }
10791
+ }
10792
+ function registryDir(mainRoot) {
10793
+ return path11.join(runtimeDir(path11.resolve(mainRoot), { ensure: false }), "session-worktrees");
10794
+ }
10795
+ function registryPath(mainRoot) {
10796
+ return path11.join(registryDir(mainRoot), "registry.json");
10797
+ }
10798
+ function registryLockPath(mainRoot) {
10799
+ return path11.join(registryDir(mainRoot), "registry.lock");
10800
+ }
10801
+ async function readRegistry(mainRoot) {
10802
+ const file = registryPath(mainRoot);
10800
10803
  try {
10801
- mod = await resolver();
10802
- } catch {
10803
- return null;
10804
+ const raw = await fs9.readFile(file, "utf8");
10805
+ const parsed = JSON.parse(raw);
10806
+ if (parsed.version !== REGISTRY_VERSION || !Array.isArray(parsed.entries)) {
10807
+ return { version: REGISTRY_VERSION, entries: [] };
10808
+ }
10809
+ return parsed;
10810
+ } catch (err) {
10811
+ const e = err;
10812
+ if (e.code === "ENOENT")
10813
+ return { version: REGISTRY_VERSION, entries: [] };
10814
+ return { version: REGISTRY_VERSION, entries: [] };
10804
10815
  }
10805
- if (!mod || typeof mod !== "object")
10806
- return null;
10807
- const chromium = mod.chromium;
10808
- if (!chromium || typeof chromium.launch !== "function")
10809
- return null;
10810
- const browser = await chromium.launch({ headless: cfg.headless });
10811
- const context = await browser.newContext();
10812
- const page = await context.newPage();
10813
- const consoleBuf = [];
10814
- const networkBuf = [];
10815
- page.on?.("console", (...args) => {
10816
- const msg = args[0];
10817
- consoleBuf.push({
10818
- level: normalizeLevel(msg.type?.()),
10819
- text: msg.text?.() ?? "",
10820
- timestamp: Date.now()
10821
- });
10822
- if (consoleBuf.length > cfg.bufferLimit)
10823
- consoleBuf.splice(0, consoleBuf.length - cfg.bufferLimit);
10816
+ }
10817
+ async function writeRegistry(mainRoot, reg) {
10818
+ const file = registryPath(mainRoot);
10819
+ await fs9.mkdir(path11.dirname(file), { recursive: true });
10820
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
10821
+ await fs9.writeFile(tmp, JSON.stringify(reg, null, 2), "utf8");
10822
+ await fs9.rename(tmp, file);
10823
+ }
10824
+ async function mutateRegistry(mainRoot, fn) {
10825
+ const lockPath = registryLockPath(mainRoot);
10826
+ await fs9.mkdir(path11.dirname(lockPath), { recursive: true });
10827
+ return await withFileLock(lockPath, async () => {
10828
+ const reg = await readRegistry(mainRoot);
10829
+ const result = await fn(reg);
10830
+ await writeRegistry(mainRoot, reg);
10831
+ return result;
10824
10832
  });
10825
- page.on?.("request", (...args) => {
10826
- const req = args[0];
10827
- networkBuf.push({
10828
- url: req.url(),
10829
- method: req.method(),
10830
- start_ts: Date.now()
10833
+ }
10834
+ async function bindSessionWorktree(opts) {
10835
+ if (!opts.sessionId || opts.sessionId.trim() === "") {
10836
+ throw new Error("bindSessionWorktree: sessionId 不能为空");
10837
+ }
10838
+ const mainRoot = path11.resolve(opts.mainRoot);
10839
+ const branch = opts.branchName ?? `codeforge/session-${opts.sessionId}`;
10840
+ const worktreesDir = opts.worktrees_dir ?? path11.join(mainRoot, DEFAULT_WORKTREE_SUBDIR);
10841
+ const lockPath = registryLockPath(mainRoot);
10842
+ await fs9.mkdir(path11.dirname(lockPath), { recursive: true });
10843
+ return await withFileLock(lockPath, async () => {
10844
+ const reg = await readRegistry(mainRoot);
10845
+ const existing = reg.entries.find((e) => e.sessionId === opts.sessionId);
10846
+ if (existing && existing.status === "active")
10847
+ return existing;
10848
+ const baseSha = (await runGit2(mainRoot, ["rev-parse", "HEAD"])).trim();
10849
+ const wt = await ensureWorktree({
10850
+ root: mainRoot,
10851
+ branch,
10852
+ worktrees_dir: worktreesDir
10831
10853
  });
10832
- if (networkBuf.length > cfg.bufferLimit)
10833
- networkBuf.splice(0, networkBuf.length - cfg.bufferLimit);
10834
- });
10835
- page.on?.("response", (...args) => {
10836
- const res = args[0];
10837
- const item = networkBuf.find((n) => n.url === res.url() && n.end_ts === undefined);
10838
- if (item) {
10839
- item.status = res.status();
10840
- item.end_ts = Date.now();
10841
- }
10842
- });
10843
- page.on?.("requestfailed", (...args) => {
10844
- const req = args[0];
10845
- const item = networkBuf.find((n) => n.url === req.url() && n.end_ts === undefined);
10846
- if (item) {
10847
- item.failed = req.failure?.()?.errorText ?? "failed";
10848
- item.end_ts = Date.now();
10849
- }
10854
+ const now = new Date().toISOString();
10855
+ const entry = {
10856
+ sessionId: opts.sessionId,
10857
+ branch,
10858
+ worktreePath: wt.path,
10859
+ baseSha,
10860
+ ...opts.requiredPlanId ? { requiredPlanId: opts.requiredPlanId, planReadOk: false } : {},
10861
+ status: "active",
10862
+ createdAt: now,
10863
+ updatedAt: now
10864
+ };
10865
+ const idx = reg.entries.findIndex((e) => e.sessionId === opts.sessionId);
10866
+ if (idx >= 0)
10867
+ reg.entries[idx] = entry;
10868
+ else
10869
+ reg.entries.push(entry);
10870
+ await writeRegistry(mainRoot, reg);
10871
+ return entry;
10850
10872
  });
10851
- return {
10852
- async navigate(url) {
10853
- const c = checkUrl(url, cfg);
10854
- if (!c.ok)
10855
- return { ok: false, url, error: c.reason };
10856
- const start = Date.now();
10857
- try {
10858
- const resp = await page.goto(url, { timeout: cfg.actionTimeoutMs });
10859
- return {
10860
- ok: true,
10861
- url,
10862
- status: resp?.status?.() ?? 200,
10863
- loaded_in_ms: Date.now() - start
10864
- };
10865
- } catch (err) {
10866
- return { ok: false, url, error: describe3(err) };
10867
- }
10868
- },
10869
- async screenshot(opts) {
10870
- try {
10871
- const dir = cfg.screenshotDir && cfg.screenshotDir.trim().length > 0 ? cfg.screenshotDir : defaultScreenshotDir();
10872
- const path10 = `${dir}/${Date.now()}.png`;
10873
- if (opts?.selector) {
10874
- const el = await page.locator(opts.selector).first();
10875
- await el.screenshot({ path: path10 });
10876
- } else {
10877
- await page.screenshot({ path: path10, fullPage: opts?.fullPage });
10878
- }
10879
- return { ok: true, path: path10 };
10880
- } catch (err) {
10881
- return { ok: false, error: describe3(err) };
10882
- }
10883
- },
10884
- async click(selector) {
10885
- try {
10886
- await page.locator(selector).first().click({ timeout: cfg.actionTimeoutMs });
10887
- return { ok: true, selector, found: true };
10888
- } catch (err) {
10889
- return { ok: false, selector, found: false, error: describe3(err) };
10890
- }
10891
- },
10892
- async fill(selector, value) {
10893
- try {
10894
- await page.locator(selector).first().fill(value, { timeout: cfg.actionTimeoutMs });
10895
- return { ok: true, selector, found: true };
10896
- } catch (err) {
10897
- return { ok: false, selector, found: false, error: describe3(err) };
10898
- }
10899
- },
10900
- async consoleLogs(opts = {}) {
10901
- return consoleBuf.filter((e) => (opts.sinceTs === undefined || e.timestamp >= opts.sinceTs) && (opts.level === undefined || e.level === opts.level));
10902
- },
10903
- async networkRequests(opts = {}) {
10904
- return networkBuf.filter((e) => (opts.sinceTs === undefined || e.start_ts >= opts.sinceTs) && (opts.method === undefined || e.method.toUpperCase() === opts.method.toUpperCase()));
10905
- },
10906
- async close() {
10907
- try {
10908
- await context.close();
10909
- } catch {}
10910
- try {
10911
- await browser.close();
10912
- } catch {}
10913
- }
10914
- };
10915
10873
  }
10916
- async function defaultPlaywrightResolver() {
10917
- return await import("playwright");
10874
+ async function getSessionWorktree(sessionId, mainRoot) {
10875
+ const reg = await readRegistry(mainRoot);
10876
+ return reg.entries.find((e) => e.sessionId === sessionId) ?? null;
10918
10877
  }
10919
- function normalizeLevel(t) {
10920
- switch (t) {
10921
- case "info":
10922
- case "warn":
10923
- case "error":
10924
- case "debug":
10925
- return t;
10926
- default:
10927
- return "log";
10928
- }
10878
+ async function markPlanReadOk(opts) {
10879
+ return await mutateRegistry(opts.mainRoot, (reg) => {
10880
+ const entry = reg.entries.find((e) => e.sessionId === opts.sessionId);
10881
+ if (!entry || entry.requiredPlanId !== opts.planId)
10882
+ return false;
10883
+ entry.planReadOk = true;
10884
+ entry.updatedAt = new Date().toISOString();
10885
+ return true;
10886
+ });
10929
10887
  }
10930
- function describe3(err) {
10931
- if (err instanceof Error)
10932
- return err.message;
10933
- if (typeof err === "string")
10934
- return err;
10935
- try {
10936
- return JSON.stringify(err);
10937
- } catch {
10938
- return String(err);
10939
- }
10888
+ async function touchEntryUpdatedAt(opts) {
10889
+ return await mutateRegistry(opts.mainRoot, (reg) => {
10890
+ const entry = reg.entries.find((e) => e.sessionId === opts.sessionId);
10891
+ if (!entry || entry.status !== "active")
10892
+ return false;
10893
+ entry.updatedAt = new Date().toISOString();
10894
+ return true;
10895
+ });
10940
10896
  }
10941
- async function createBrowserController(opts = {}) {
10942
- const cfg = { ...DEFAULT_CONFIG2, ...opts.cfg };
10943
- if (!cfg.enabled) {
10944
- return new NoopBrowserController("browser disabled in config");
10897
+ async function mergeSessionBack(opts) {
10898
+ const mainRoot = path11.resolve(opts.mainRoot);
10899
+ const entry = await getSessionWorktree(opts.sessionId, mainRoot);
10900
+ if (!entry) {
10901
+ throw new Error(`mergeSessionBack: session ${opts.sessionId} 没有绑定 worktree`);
10945
10902
  }
10946
- if (opts.factory) {
10947
- const c = await opts.factory(cfg);
10948
- if (c)
10949
- return c;
10903
+ if (entry.status === "interrupted_dirty") {
10904
+ throw new Error(`mergeSessionBack: session ${opts.sessionId} 处于 interrupted_dirty 状态,` + `请先手动处理 worktree (${entry.worktreePath}) 后再 merge`);
10950
10905
  }
10951
- const playwright = await tryCreatePlaywrightController(cfg);
10952
- if (playwright)
10953
- return playwright;
10954
- return new NoopBrowserController("browser enabled but no driver available (npm i playwright 后重试)");
10955
- }
10956
-
10957
- // tools/browser-navigate.ts
10958
- var description5 = [
10959
- "打开指定 URL(受 tools.browser 配置控制;默认禁用)。",
10960
- "**何时调用**:需要看 web 页面真实渲染、调试前端、复现 bug 报告链接。",
10961
- "**注意**:仅允许 http/https;file:/data: 等协议被默认安全策略拒绝。"
10962
- ].join(`
10963
- `);
10964
- var ArgsSchema5 = z5.object({
10965
- url: z5.string().min(1).describe("要打开的 URL;必须是 http(s) 协议")
10966
- });
10967
- var _controller = null;
10968
- var _cfg;
10969
- async function getController() {
10970
- if (!_controller) {
10971
- _controller = await createBrowserController({ cfg: _cfg });
10906
+ if (entry.status !== "active") {
10907
+ throw new Error(`mergeSessionBack: session ${opts.sessionId} 状态为 ${entry.status},无法 merge`);
10972
10908
  }
10973
- return _controller;
10974
- }
10975
- async function execute5(input) {
10976
- const parsed = ArgsSchema5.safeParse(input);
10977
- if (!parsed.success) {
10978
- return {
10979
- ok: false,
10980
- url: typeof input?.url === "string" ? String(input.url) : "",
10981
- error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
10982
- };
10909
+ const wt = entry.worktreePath;
10910
+ const branch = entry.branch;
10911
+ const baseSha = entry.baseSha;
10912
+ const wtStatus = (await runGit2(wt, ["status", "--porcelain"])).trim();
10913
+ if (wtStatus.length > 0) {
10914
+ await runGit2(wt, ["add", "-A"]);
10915
+ await runGit2(wt, ["commit", "-m", `session(${opts.sessionId}): auto-commit before merge`]);
10916
+ }
10917
+ const mainStatus = (await runGit2(mainRoot, ["status", "--porcelain"])).trim();
10918
+ if (mainStatus.length > 0) {
10919
+ throw new Error(`mergeSessionBack: 主仓 ${mainRoot} 有未提交改动,请先 commit/stash 后再 merge`);
10983
10920
  }
10984
10921
  try {
10985
- const ctrl = await getController();
10986
- return await ctrl.navigate(parsed.data.url);
10922
+ await runGit2(mainRoot, ["merge", "--squash", branch]);
10987
10923
  } catch (err) {
10988
- return {
10989
- ok: false,
10990
- url: parsed.data.url,
10991
- error: err instanceof Error ? err.message : String(err)
10992
- };
10993
- }
10994
- }
10995
- // tools/browser-click.ts
10996
- import { z as z6 } from "zod";
10997
- var description6 = [
10998
- "点击页面元素。属于「写操作」,semi 模式下应当让用户确认。",
10999
- "**何时调用**:表单提交、按钮触发、SPA 导航。",
11000
- "**避坑**:selector 优先用稳定属性(data-testid > role > 文本 > id),尽量不用 xpath。"
11001
- ].join(`
11002
- `);
11003
- var ArgsSchema6 = z6.object({
11004
- selector: z6.string().min(1).describe("CSS / Playwright locator 字符串,必须能唯一定位")
11005
- });
11006
- var _controller2 = null;
11007
- var _cfg2;
11008
- async function getController2() {
11009
- if (!_controller2) {
11010
- _controller2 = await createBrowserController({ cfg: _cfg2 });
11011
- }
11012
- return _controller2;
11013
- }
11014
- async function execute6(input) {
11015
- const parsed = ArgsSchema6.safeParse(input);
11016
- if (!parsed.success) {
11017
- return {
11018
- ok: false,
11019
- selector: typeof input?.selector === "string" ? String(input.selector) : "",
11020
- found: false,
11021
- error: parsed.error.issues.map((i) => i.message).join("; ")
11022
- };
10924
+ await runGit2(mainRoot, ["reset", "--merge"]).catch(() => {
10925
+ return runGit2(mainRoot, ["reset", "--hard", "HEAD"]).catch(() => {});
10926
+ });
10927
+ throw new Error(`mergeSessionBack: squash merge 失败(已 reset 主仓兜底): ${err.message}`);
11023
10928
  }
10929
+ const buildScript = await getBuildScript(mainRoot);
10930
+ if (buildScript) {
10931
+ const stagedRaw = await runGit2(mainRoot, [
10932
+ "diff",
10933
+ "--cached",
10934
+ "--name-only",
10935
+ "--diff-filter=ACMR"
10936
+ ]);
10937
+ const stagedPaths = stagedRaw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
10938
+ const canSkipDevOnce = await shouldSkipDevOnce(mainRoot, stagedPaths, wt);
10939
+ if (canSkipDevOnce) {} else {
10940
+ try {
10941
+ await runCmd("npm", ["run", buildScript], mainRoot);
10942
+ } catch (err) {
10943
+ await runGit2(mainRoot, ["reset", "--hard", "HEAD"]).catch(() => {});
10944
+ const msg = err instanceof Error ? err.message : String(err);
10945
+ throw new Error(`${buildScript} 失败已 reset 主仓: ${msg}`);
10946
+ }
10947
+ }
10948
+ } else {}
10949
+ const squashedRaw = await runGit2(wt, ["log", "--format=%s", `${baseSha}..HEAD`]);
10950
+ const squashedCommits = squashedRaw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
10951
+ const message = opts.commitMessage ?? buildMergeMessage(opts.sessionId, branch, baseSha, squashedCommits);
10952
+ await runGitWithEnv(mainRoot, ["commit", "-m", message], {
10953
+ SKIP_DEV_SYNC_CHECK: "1"
10954
+ });
10955
+ const newSha = (await runGit2(mainRoot, ["rev-parse", "HEAD"])).trim();
11024
10956
  try {
11025
- const ctrl = await getController2();
11026
- return await ctrl.click(parsed.data.selector);
10957
+ await removeWorktree({ root: mainRoot, worktree_path: wt, force: true });
11027
10958
  } catch (err) {
11028
- return {
11029
- ok: false,
11030
- selector: parsed.data.selector,
11031
- found: false,
11032
- error: err instanceof Error ? err.message : String(err)
11033
- };
10959
+ debugLog(`removeWorktree (merge) 非预期失败 (session=${opts.sessionId}): ${err.message}`);
11034
10960
  }
11035
- }
11036
- // tools/browser-fill.ts
11037
- import { z as z7 } from "zod";
11038
- var description7 = [
11039
- "向 input/textarea/contenteditable 元素写入文本。",
11040
- "**何时调用**:登录表单、搜索框、富文本初始化。",
11041
- "**安全**:value 不会被自动 mask;不要把真实凭据塞进来。"
11042
- ].join(`
11043
- `);
11044
- var ArgsSchema7 = z7.object({
11045
- selector: z7.string().min(1).describe("CSS / Playwright locator"),
11046
- value: z7.string().describe("要填入的文本;原样写入,不做转义")
11047
- });
11048
- var _controller3 = null;
11049
- var _cfg3;
11050
- async function getController3() {
11051
- if (!_controller3) {
11052
- _controller3 = await createBrowserController({ cfg: _cfg3 });
10961
+ await deleteBranchIfExists({ root: mainRoot, branch }).catch((err) => {
10962
+ debugLog(`deleteBranchIfExists (merge) 非预期失败: ${err.message}`);
10963
+ return { deleted: false };
10964
+ });
10965
+ await mutateRegistry(mainRoot, (reg) => {
10966
+ const e = reg.entries.find((x) => x.sessionId === opts.sessionId);
10967
+ if (e) {
10968
+ e.status = "merged";
10969
+ e.updatedAt = new Date().toISOString();
10970
+ }
10971
+ });
10972
+ if (opts.planStore && entry.requiredPlanId) {
10973
+ await opts.planStore.markMerged(entry.requiredPlanId, opts.sessionId).catch((err) => {
10974
+ console.warn(`[session-worktree] planStore.markMerged(${entry.requiredPlanId}) 失败: ${err instanceof Error ? err.message : String(err)}`);
10975
+ });
11053
10976
  }
11054
- return _controller3;
10977
+ return { sha: newSha, squashedCommits };
11055
10978
  }
11056
- async function execute7(input) {
11057
- const parsed = ArgsSchema7.safeParse(input);
11058
- if (!parsed.success) {
11059
- return {
11060
- ok: false,
11061
- selector: typeof input?.selector === "string" ? String(input.selector) : "",
11062
- found: false,
11063
- error: parsed.error.issues.map((i) => i.message).join("; ")
11064
- };
10979
+ async function discardSession(opts) {
10980
+ const mainRoot = path11.resolve(opts.mainRoot);
10981
+ const entry = await getSessionWorktree(opts.sessionId, mainRoot);
10982
+ if (!entry) {
10983
+ return;
10984
+ }
10985
+ if (entry.status === "merged" || entry.status === "discarded") {
10986
+ return;
11065
10987
  }
11066
10988
  try {
11067
- const ctrl = await getController3();
11068
- return await ctrl.fill(parsed.data.selector, parsed.data.value);
10989
+ await removeWorktree({
10990
+ root: mainRoot,
10991
+ worktree_path: entry.worktreePath,
10992
+ force: true
10993
+ });
11069
10994
  } catch (err) {
11070
- return {
11071
- ok: false,
11072
- selector: parsed.data.selector,
11073
- found: false,
11074
- error: err instanceof Error ? err.message : String(err)
11075
- };
10995
+ debugLog(`removeWorktree (discard) 非预期失败: ${err.message}`);
11076
10996
  }
11077
- }
11078
- // tools/browser-screenshot.ts
11079
- import { z as z8 } from "zod";
11080
- var description8 = [
11081
- "截取当前页面(或指定元素)的屏幕快照。",
11082
- "**何时调用**:前端 dev 调试、回归对比、用户报 bug 没截图时让 agent 自取。",
11083
- "**注意**:必须先 navigate;输出路径写到 runtime 目录的 browser/screenshots/(XDG 全局位置,详见 lib/runtime-paths.ts)。"
11084
- ].join(`
11085
- `);
11086
- var ArgsSchema8 = z8.object({
11087
- fullPage: z8.boolean().optional().describe("是否截全长页面(默认仅可视区)"),
11088
- selector: z8.string().optional().describe("CSS 选择器;指定时只截该元素")
11089
- });
11090
- var _controller4 = null;
11091
- var _cfg4;
11092
- async function getController4() {
11093
- if (!_controller4) {
11094
- _controller4 = await createBrowserController({ cfg: _cfg4 });
10997
+ await deleteBranchIfExists({ root: mainRoot, branch: entry.branch }).catch((err) => {
10998
+ debugLog(`deleteBranchIfExists (discard) 非预期失败: ${err.message}`);
10999
+ return { deleted: false };
11000
+ });
11001
+ await mutateRegistry(mainRoot, (reg) => {
11002
+ const e = reg.entries.find((x) => x.sessionId === opts.sessionId);
11003
+ if (e) {
11004
+ e.status = "discarded";
11005
+ e.updatedAt = new Date().toISOString();
11006
+ }
11007
+ });
11008
+ if (opts.planStore && entry.requiredPlanId) {
11009
+ await opts.planStore.markDiscarded(entry.requiredPlanId, opts.sessionId).catch((err) => {
11010
+ console.warn(`[session-worktree] planStore.markDiscarded(${entry.requiredPlanId}) 失败: ${err instanceof Error ? err.message : String(err)}`);
11011
+ });
11095
11012
  }
11096
- return _controller4;
11097
11013
  }
11098
- async function execute8(input) {
11099
- const parsed = ArgsSchema8.safeParse(input);
11100
- if (!parsed.success) {
11101
- return { ok: false, error: parsed.error.issues.map((i) => i.message).join("; ") };
11102
- }
11103
- try {
11104
- const ctrl = await getController4();
11105
- return await ctrl.screenshot(parsed.data);
11106
- } catch (err) {
11107
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
11108
- }
11014
+ async function markInterruptedDirty(opts) {
11015
+ await mutateRegistry(opts.mainRoot, (reg) => {
11016
+ const e = reg.entries.find((x) => x.sessionId === opts.sessionId);
11017
+ if (e && e.status === "active") {
11018
+ e.status = "interrupted_dirty";
11019
+ e.updatedAt = new Date().toISOString();
11020
+ }
11021
+ });
11109
11022
  }
11110
- // tools/browser-console.ts
11111
- import { z as z9 } from "zod";
11112
- var description9 = [
11113
- "读取浏览器控制台缓冲区(log/info/warn/error/debug)。",
11114
- "**何时调用**:调前端 bug、看页面 runtime error、跟 React/Vue 警告。",
11115
- "**注意**:缓冲区上限 500 条,超出会丢最早;只能读 navigate 之后的日志。"
11116
- ].join(`
11117
- `);
11118
- var ArgsSchema9 = z9.object({
11119
- level: z9.enum(["log", "info", "warn", "error", "debug"]).optional().describe("只过滤指定级别;缺省返回全部"),
11120
- sinceTs: z9.number().optional().describe("只返回 timestamp >= sinceTs 的条目")
11121
- });
11122
- var _controller5 = null;
11123
- var _cfg5;
11124
- async function getController5() {
11125
- if (!_controller5) {
11126
- _controller5 = await createBrowserController({ cfg: _cfg5 });
11023
+ async function getCurrentWorktreeHead(worktreePath) {
11024
+ try {
11025
+ return (await runGit2(path11.resolve(worktreePath), ["rev-parse", "HEAD"])).trim();
11026
+ } catch {
11027
+ return "";
11127
11028
  }
11128
- return _controller5;
11129
11029
  }
11130
- async function execute9(input) {
11131
- const parsed = ArgsSchema9.safeParse(input);
11132
- if (!parsed.success) {
11133
- return {
11134
- ok: false,
11135
- entries: [],
11136
- error: parsed.error.issues.map((i) => i.message).join("; ")
11137
- };
11138
- }
11030
+ async function isWorktreeDirty(worktreePath) {
11139
11031
  try {
11140
- const ctrl = await getController5();
11141
- const entries = await ctrl.consoleLogs(parsed.data);
11142
- return { ok: true, entries };
11143
- } catch (err) {
11144
- return {
11145
- ok: false,
11146
- entries: [],
11147
- error: err instanceof Error ? err.message : String(err)
11148
- };
11032
+ const out = (await runGit2(worktreePath, ["status", "--porcelain"])).trim();
11033
+ return out.length > 0;
11034
+ } catch {
11035
+ return false;
11149
11036
  }
11150
11037
  }
11151
- // tools/browser-network.ts
11152
- import { z as z10 } from "zod";
11153
- var description10 = [
11154
- "读取浏览器网络请求缓冲区(含 status / 失败原因 / 耗时)。",
11155
- "**何时调用**:调 API 调用 4xx/5xx、CORS 报错、查 page 加载耗时分布。",
11156
- "**注意**:缓冲区上限 500 条;只看 navigate 之后发起的请求。"
11157
- ].join(`
11158
- `);
11159
- var ArgsSchema10 = z10.object({
11160
- method: z10.string().optional().describe("HTTP 方法过滤(GET/POST/...),不区分大小写"),
11161
- sinceTs: z10.number().optional().describe("只返回 start_ts >= sinceTs 的请求")
11162
- });
11163
- var _controller6 = null;
11164
- var _cfg6;
11165
- async function getController6() {
11166
- if (!_controller6) {
11167
- _controller6 = await createBrowserController({ cfg: _cfg6 });
11168
- }
11169
- return _controller6;
11038
+ async function checkpointCommit(opts) {
11039
+ await runGit2(opts.worktreePath, ["add", "-A"]);
11040
+ await runGit2(opts.worktreePath, ["commit", "-m", opts.message]);
11170
11041
  }
11171
- async function execute10(input) {
11172
- const parsed = ArgsSchema10.safeParse(input);
11173
- if (!parsed.success) {
11174
- return {
11175
- ok: false,
11176
- entries: [],
11177
- error: parsed.error.issues.map((i) => i.message).join("; ")
11178
- };
11179
- }
11180
- try {
11181
- const ctrl = await getController6();
11182
- const entries = await ctrl.networkRequests(parsed.data);
11183
- return { ok: true, entries };
11184
- } catch (err) {
11185
- return {
11186
- ok: false,
11187
- entries: [],
11188
- error: err instanceof Error ? err.message : String(err)
11189
- };
11190
- }
11191
- }
11192
- // tools/model-chain.ts
11193
- import { z as z11 } from "zod";
11194
-
11195
- // lib/model-config.ts
11196
- import { promises as fs7 } from "node:fs";
11197
- import * as path10 from "node:path";
11198
-
11199
- // lib/model-tier.ts
11200
- var TIER_ORDER = ["quick", "balanced", "deep", "ultra"];
11201
-
11202
- // lib/model-config.ts
11203
- var CONFIG_FILE = "codeforge.json";
11204
- var DEFAULT_RUNTIME_FALLBACK = {
11205
- enabled: true,
11206
- max_fallback_attempts: 3,
11207
- notify_on_fallback: true,
11208
- trigger_events: ["session.error", "model.unavailable", "model.rate_limited"]
11209
- };
11210
- var PROVIDER_MODEL_RE = /^[a-z0-9-]+\/[a-zA-Z0-9._-]+$/;
11211
- function findConfigFileSync(opts = {}) {
11212
- const root = opts.root ?? process.cwd();
11213
- const fsSync = __require("node:fs");
11214
- const abs = path10.resolve(root, opts.file ?? CONFIG_FILE);
11215
- return fsSync.existsSync(abs) ? abs : null;
11042
+ function runGit2(cwd, args, timeoutMs = 1e4) {
11043
+ return new Promise((resolve10, reject) => {
11044
+ execFile3("git", args, { cwd, timeout: timeoutMs, windowsHide: true, encoding: "utf8" }, (err, stdout, stderr) => {
11045
+ if (err) {
11046
+ reject(new Error(`git ${args.join(" ")} (cwd=${cwd}) 失败: ${stderr?.trim() || err.message}`));
11047
+ return;
11048
+ }
11049
+ resolve10(stdout);
11050
+ });
11051
+ });
11216
11052
  }
11217
- function loadModelConfigSync(opts = {}) {
11218
- const root = opts.root ?? process.cwd();
11219
- const abs = path10.resolve(root, opts.file ?? CONFIG_FILE);
11220
- const fsSync = __require("node:fs");
11221
- if (!fsSync.existsSync(abs)) {
11222
- return { ok: false, warnings: [], error: `config_not_found: ${abs}` };
11223
- }
11224
- let raw;
11225
- try {
11226
- raw = fsSync.readFileSync(abs, "utf8");
11227
- } catch (e) {
11228
- return { ok: false, path: abs, warnings: [], error: `read_failed: ${e.message}` };
11229
- }
11230
- return parseAndValidate(raw, abs);
11053
+ function runGitWithEnv(cwd, args, envOverrides, timeoutMs = 1e4) {
11054
+ const inheritedEnv = process["env"];
11055
+ return new Promise((resolve10, reject) => {
11056
+ execFile3("git", args, {
11057
+ cwd,
11058
+ timeout: timeoutMs,
11059
+ windowsHide: true,
11060
+ encoding: "utf8",
11061
+ env: Object.assign({}, inheritedEnv, envOverrides)
11062
+ }, (err, stdout, stderr) => {
11063
+ if (err) {
11064
+ reject(new Error(`git ${args.join(" ")} (cwd=${cwd}) 失败: ${stderr?.trim() || err.message}`));
11065
+ return;
11066
+ }
11067
+ resolve10(stdout);
11068
+ });
11069
+ });
11231
11070
  }
11232
- async function loadModelConfig(opts = {}) {
11233
- const root = opts.root ?? process.cwd();
11234
- const abs = path10.resolve(root, opts.file ?? CONFIG_FILE);
11235
- let raw;
11236
- try {
11237
- raw = await fs7.readFile(abs, "utf8");
11238
- } catch (e) {
11239
- const code = e.code;
11240
- if (code === "ENOENT") {
11241
- return { ok: false, path: abs, warnings: [], error: `config_not_found: ${abs}` };
11242
- }
11243
- return { ok: false, path: abs, warnings: [], error: `read_failed: ${e.message}` };
11244
- }
11245
- return parseAndValidate(raw, abs);
11071
+ async function getBuildScript(mainRoot) {
11072
+ const cfg = getCodeforgeConfig({ root: mainRoot });
11073
+ const merge = cfg["merge"];
11074
+ if (!merge || typeof merge !== "object" || Array.isArray(merge))
11075
+ return null;
11076
+ const script = merge["postMergeScript"];
11077
+ if (typeof script !== "string")
11078
+ return null;
11079
+ const trimmed = script.trim();
11080
+ return trimmed.length > 0 ? trimmed : null;
11246
11081
  }
11247
- function parseAndValidate(raw, abs) {
11248
- let parsed;
11082
+ async function shouldSkipDevOnce(mainRoot, stagedPaths, worktreePath) {
11083
+ let distMtimeSec;
11249
11084
  try {
11250
- parsed = JSON.parse(raw);
11251
- } catch (e) {
11252
- return { ok: false, path: abs, warnings: [], error: `invalid_json: ${e.message}` };
11253
- }
11254
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
11255
- return { ok: false, path: abs, warnings: [], error: "invalid_root: must be an object" };
11085
+ const st = await fs9.stat(path11.join(mainRoot, "dist/index.js"));
11086
+ distMtimeSec = Math.floor(st.mtimeMs / 1000);
11087
+ } catch {
11088
+ return false;
11256
11089
  }
11257
- const root = parsed;
11258
- const modelsNode = root.models;
11259
- if (!modelsNode || typeof modelsNode !== "object" || Array.isArray(modelsNode)) {
11260
- return { ok: false, path: abs, warnings: [], error: "missing_models: codeforge.json must have a top-level `models` object" };
11090
+ const relevant = stagedPaths.filter((p) => /^(plugins|lib|src)\//.test(p) && !/\.(md|test\.ts)$/.test(p));
11091
+ if (relevant.length === 0)
11092
+ return true;
11093
+ for (const rel of relevant) {
11094
+ const srcMtimeSec = await statSourceMtime(rel, mainRoot, worktreePath);
11095
+ if (srcMtimeSec === null)
11096
+ return false;
11097
+ if (srcMtimeSec > distMtimeSec)
11098
+ return false;
11261
11099
  }
11262
- const v = validateConfig(modelsNode);
11263
- if (!v.ok)
11264
- return { ok: false, path: abs, warnings: [], error: v.error };
11265
- return { ok: true, path: abs, config: v.config, warnings: v.warnings };
11100
+ return true;
11266
11101
  }
11267
- function validateConfig(input) {
11268
- const warnings = [];
11269
- if (!input || typeof input !== "object" || Array.isArray(input)) {
11270
- return { ok: false, warnings, error: "config_root_must_be_object" };
11271
- }
11272
- const obj = input;
11273
- const agentsRaw = obj.agents;
11274
- if (!agentsRaw || typeof agentsRaw !== "object" || Array.isArray(agentsRaw)) {
11275
- return { ok: false, warnings, error: "agents_required_object" };
11276
- }
11277
- const agents = {};
11278
- for (const [name, value] of Object.entries(agentsRaw)) {
11279
- const r = normalizeAgent(name, value);
11280
- if (!r.ok)
11281
- return { ok: false, warnings, error: r.error };
11282
- agents[name] = r.binding;
11283
- }
11284
- if (Object.keys(agents).length === 0) {
11285
- return { ok: false, warnings, error: "agents_must_have_at_least_one_entry" };
11286
- }
11287
- let categories;
11288
- if (obj.categories !== undefined) {
11289
- if (!obj.categories || typeof obj.categories !== "object" || Array.isArray(obj.categories)) {
11290
- return { ok: false, warnings, error: "categories_must_be_object" };
11291
- }
11292
- categories = {};
11293
- for (const [name, value] of Object.entries(obj.categories)) {
11294
- const r = normalizeCategory(name, value);
11295
- if (!r.ok)
11296
- return { ok: false, warnings, error: r.error };
11297
- categories[name] = r.binding;
11298
- }
11299
- }
11300
- let runtime;
11301
- if (obj.runtime_fallback !== undefined) {
11302
- const r = normalizeRuntime(obj.runtime_fallback);
11303
- if (!r.ok)
11304
- return { ok: false, warnings, error: r.error };
11305
- runtime = r.cfg;
11306
- }
11307
- let tiers;
11308
- if (obj.tiers !== undefined) {
11309
- const r = normalizeTiers(obj.tiers);
11310
- if (!r.ok)
11311
- return { ok: false, warnings, error: r.error };
11312
- tiers = r.cfg;
11102
+ async function statSourceMtime(rel, mainRoot, worktreePath) {
11103
+ if (worktreePath) {
11104
+ try {
11105
+ const st = await fs9.stat(path11.join(worktreePath, rel));
11106
+ return Math.floor(st.mtimeMs / 1000);
11107
+ } catch {}
11313
11108
  }
11314
- for (const [name, agent] of Object.entries(agents)) {
11315
- if (agent.category && !categories?.[agent.category]) {
11316
- warnings.push(`agent[${name}].category="${agent.category}" 未在 categories 定义,将忽略 category 链`);
11317
- }
11109
+ try {
11110
+ const st = await fs9.stat(path11.join(mainRoot, rel));
11111
+ return Math.floor(st.mtimeMs / 1000);
11112
+ } catch {
11113
+ return null;
11318
11114
  }
11319
- for (const [name, agent] of Object.entries(agents)) {
11320
- if (!agent.tier)
11321
- continue;
11322
- const mappedCat = tiers?.category_map?.[agent.tier];
11323
- const hasOverride = agent.tier_overrides?.[agent.tier] !== undefined;
11324
- if (!mappedCat && !hasOverride) {
11325
- warnings.push(`agent[${name}].tier="${agent.tier}" 既无 models.tiers.category_map["${agent.tier}"] 映射,` + `也无 tier_overrides["${agent.tier}"];该 agent 将不参与 tier 体系(adapter 返 null)。`);
11326
- } else if (mappedCat && !categories?.[mappedCat] && !hasOverride) {
11327
- warnings.push(`agent[${name}].tier="${agent.tier}" 通过 category_map 映射到 "${mappedCat}",` + `但 categories.${mappedCat} 不存在;该 agent 将不参与 tier 体系。`);
11328
- }
11115
+ }
11116
+ function runCmd(cmd, args, cwd, timeoutMs = 300000) {
11117
+ return new Promise((resolve10, reject) => {
11118
+ execFile3(cmd, args, { cwd, timeout: timeoutMs, windowsHide: true, encoding: "utf8" }, (err, stdout, stderr) => {
11119
+ if (err) {
11120
+ reject(new Error(`${cmd} ${args.join(" ")} (cwd=${cwd}) 失败: ${stderr?.trim() || err.message}`));
11121
+ return;
11122
+ }
11123
+ resolve10(stdout);
11124
+ });
11125
+ });
11126
+ }
11127
+ function buildMergeMessage(sessionId, branch, baseSha, squashed) {
11128
+ const subject = `session(${sessionId}): merge ${branch}`;
11129
+ const body = squashed.length > 0 ? `
11130
+
11131
+ Squashed commits:
11132
+ ${squashed.map((s) => ` - ${s}`).join(`
11133
+ `)}` : "";
11134
+ const footer = `
11135
+
11136
+ Codeforge-Session: ${sessionId}
11137
+ Codeforge-Base: ${baseSha.slice(0, 12)}`;
11138
+ return subject + body + footer;
11139
+ }
11140
+ var ORPHAN_GRACE_MS = 60000;
11141
+ var SEMANTIC_ORPHAN_MIN_AGE_MS = 6 * 60 * 60000;
11142
+ var SEMANTIC_ORPHAN_UNKNOWN_TIMEOUT_MS = 72 * 60 * 60000;
11143
+ async function pruneDiscardedRegistryEntries(mainRoot, opts = {}) {
11144
+ const keepRecent = opts.keepRecent ?? 50;
11145
+ if (keepRecent < 0) {
11146
+ throw new Error(`pruneDiscardedRegistryEntries: keepRecent 必须 ≥ 0,收到 ${keepRecent}`);
11329
11147
  }
11330
- return {
11331
- ok: true,
11332
- warnings,
11333
- config: {
11334
- $schema: typeof obj.$schema === "string" ? obj.$schema : undefined,
11335
- _doc: typeof obj._doc === "string" ? obj._doc : undefined,
11336
- agents,
11337
- categories,
11338
- runtime_fallback: runtime,
11339
- tiers
11148
+ return await mutateRegistry(path11.resolve(mainRoot), (reg) => {
11149
+ const discarded = [];
11150
+ const others = [];
11151
+ for (const e of reg.entries) {
11152
+ if (e.status === "discarded")
11153
+ discarded.push(e);
11154
+ else
11155
+ others.push(e);
11340
11156
  }
11341
- };
11157
+ discarded.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0);
11158
+ const kept = discarded.slice(0, keepRecent);
11159
+ const pruned = discarded.length - kept.length;
11160
+ reg.entries = [...others, ...kept];
11161
+ return { pruned, kept: kept.length };
11162
+ });
11342
11163
  }
11343
- function normalizeAgent(name, raw) {
11344
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
11345
- return { ok: false, error: `agent[${name}]: must be object` };
11164
+ async function pruneOrphanWorktrees(mainRoot, opts = {}) {
11165
+ const resolved = path11.resolve(mainRoot);
11166
+ const cleaned = [];
11167
+ const failed = [];
11168
+ let skipped = 0;
11169
+ let gitWorktrees = [];
11170
+ try {
11171
+ const mod = await Promise.resolve().then(() => (init_worktree_ops(), exports_worktree_ops));
11172
+ gitWorktrees = await mod.listWorktrees({ root: resolved });
11173
+ } catch {
11174
+ gitWorktrees = [];
11346
11175
  }
11347
- const o = raw;
11348
- if (typeof o.model !== "string" || !PROVIDER_MODEL_RE.test(o.model)) {
11176
+ const gitWorktreePaths = new Set(gitWorktrees.map((w) => path11.resolve(w.path)));
11177
+ await mutateRegistry(resolved, async (reg2) => {
11178
+ const now = Date.now();
11179
+ for (const entry of reg2.entries) {
11180
+ if (entry.status !== "active")
11181
+ continue;
11182
+ const wt = entry.worktreePath;
11183
+ let dirExists = true;
11184
+ let dirMtimeMs = 0;
11185
+ try {
11186
+ const st = await fs9.stat(wt);
11187
+ dirExists = st.isDirectory();
11188
+ dirMtimeMs = st.mtimeMs;
11189
+ } catch {
11190
+ dirExists = false;
11191
+ }
11192
+ if (dirExists && now - dirMtimeMs < ORPHAN_GRACE_MS) {
11193
+ skipped++;
11194
+ continue;
11195
+ }
11196
+ let branchExists = true;
11197
+ try {
11198
+ await runGit2(resolved, ["rev-parse", "--verify", `refs/heads/${entry.branch}`]);
11199
+ } catch {
11200
+ branchExists = false;
11201
+ }
11202
+ if (!dirExists || !branchExists) {
11203
+ const reason = !dirExists ? "worktreePath 不存在" : "branch 不存在";
11204
+ entry.status = "discarded";
11205
+ entry.updatedAt = new Date().toISOString();
11206
+ if (dirExists) {
11207
+ try {
11208
+ await removeWorktree({
11209
+ root: resolved,
11210
+ worktree_path: wt,
11211
+ force: true
11212
+ });
11213
+ } catch (err) {
11214
+ failed.push({
11215
+ worktreePath: wt,
11216
+ error: err instanceof Error ? err.message : String(err)
11217
+ });
11218
+ }
11219
+ }
11220
+ cleaned.push({ sessionId: entry.sessionId, worktreePath: wt, reason });
11221
+ }
11222
+ }
11223
+ });
11224
+ if (opts.isSessionAlive) {
11225
+ const minAge = opts.semanticOrphanMinAgeMs ?? SEMANTIC_ORPHAN_MIN_AGE_MS;
11226
+ const unknownTimeout = opts.semanticOrphanUnknownTimeoutMs ?? SEMANTIC_ORPHAN_UNKNOWN_TIMEOUT_MS;
11227
+ const probe = opts.isSessionAlive;
11228
+ await mutateRegistry(resolved, async (reg2) => {
11229
+ const now = Date.now();
11230
+ for (const entry of reg2.entries) {
11231
+ if (entry.status !== "active")
11232
+ continue;
11233
+ const updatedMs = Date.parse(entry.updatedAt);
11234
+ if (!Number.isFinite(updatedMs) || now - updatedMs < minAge) {
11235
+ skipped++;
11236
+ continue;
11237
+ }
11238
+ let aliveResult;
11239
+ try {
11240
+ aliveResult = await probe(entry.sessionId);
11241
+ } catch {
11242
+ skipped++;
11243
+ continue;
11244
+ }
11245
+ if (aliveResult.source === "unknown") {
11246
+ if (now - updatedMs < unknownTimeout) {
11247
+ skipped++;
11248
+ continue;
11249
+ }
11250
+ } else if (aliveResult.alive) {
11251
+ skipped++;
11252
+ continue;
11253
+ }
11254
+ try {
11255
+ await removeWorktree({
11256
+ root: resolved,
11257
+ worktree_path: entry.worktreePath,
11258
+ force: true
11259
+ });
11260
+ } catch (err) {
11261
+ failed.push({
11262
+ worktreePath: entry.worktreePath,
11263
+ error: `D 类 removeWorktree 失败: ${err instanceof Error ? err.message : String(err)}`
11264
+ });
11265
+ continue;
11266
+ }
11267
+ await deleteBranchIfExists({ root: resolved, branch: entry.branch }).catch(() => {});
11268
+ entry.status = "discarded";
11269
+ entry.updatedAt = new Date().toISOString();
11270
+ const reasonSource = aliveResult.source === "unknown" ? `unknown-timeout (registry.updatedAt 已老于 ${unknownTimeout / 3600000}h)` : `opencode session ${aliveResult.source}: dead`;
11271
+ cleaned.push({
11272
+ sessionId: entry.sessionId,
11273
+ worktreePath: entry.worktreePath,
11274
+ reason: `D 类语义孤儿 (${reasonSource})`
11275
+ });
11276
+ }
11277
+ });
11278
+ }
11279
+ const codeforgeWorktreeRoot = path11.resolve(path11.join(resolved, DEFAULT_WORKTREE_SUBDIR));
11280
+ const fsWorktreePaths = [];
11281
+ try {
11282
+ const names = await fs9.readdir(codeforgeWorktreeRoot);
11283
+ for (const name of names) {
11284
+ fsWorktreePaths.push(path11.resolve(path11.join(codeforgeWorktreeRoot, name)));
11285
+ }
11286
+ } catch {}
11287
+ const candidatePaths = new Set([
11288
+ ...gitWorktreePaths,
11289
+ ...fsWorktreePaths
11290
+ ]);
11291
+ const reg = await readRegistry(resolved);
11292
+ const knownPaths = new Set(reg.entries.map((e) => path11.resolve(e.worktreePath)));
11293
+ for (const candidate of candidatePaths) {
11294
+ if (knownPaths.has(candidate))
11295
+ continue;
11296
+ if (candidate !== codeforgeWorktreeRoot && !candidate.startsWith(codeforgeWorktreeRoot + path11.sep)) {
11297
+ continue;
11298
+ }
11299
+ let dirExists = true;
11300
+ try {
11301
+ const st = await fs9.stat(candidate);
11302
+ if (Date.now() - st.mtimeMs < ORPHAN_GRACE_MS) {
11303
+ skipped++;
11304
+ continue;
11305
+ }
11306
+ } catch {
11307
+ dirExists = false;
11308
+ }
11309
+ let removed = false;
11310
+ let lastError = null;
11311
+ try {
11312
+ await removeWorktree({
11313
+ root: resolved,
11314
+ worktree_path: candidate,
11315
+ force: true
11316
+ });
11317
+ removed = true;
11318
+ } catch (err) {
11319
+ lastError = err instanceof Error ? err.message : String(err);
11320
+ }
11321
+ if (removed) {
11322
+ try {
11323
+ await fs9.stat(candidate);
11324
+ removed = false;
11325
+ lastError = lastError ?? "git worktree remove 返回成功但目录仍存在(C 类 fs-only orphan)";
11326
+ } catch {}
11327
+ }
11328
+ if (!removed && dirExists) {
11329
+ try {
11330
+ await fs9.rm(candidate, { recursive: true, force: true });
11331
+ removed = true;
11332
+ } catch (err) {
11333
+ lastError = `git remove 失败: ${lastError}; fs.rm 也失败: ${err instanceof Error ? err.message : String(err)}`;
11334
+ }
11335
+ }
11336
+ if (removed) {
11337
+ const reason = gitWorktreePaths.has(candidate) ? "fs-only orphan (git 知道但 registry 无 entry)" : "fs-only orphan (git 不知道的纯 fs 残留)";
11338
+ cleaned.push({ worktreePath: candidate, reason });
11339
+ } else {
11340
+ failed.push({ worktreePath: candidate, error: lastError ?? "unknown" });
11341
+ }
11342
+ }
11343
+ let discardedPruned = 0;
11344
+ try {
11345
+ const r = await pruneDiscardedRegistryEntries(resolved);
11346
+ discardedPruned = r.pruned;
11347
+ } catch {}
11348
+ let gitAdminPruned = 0;
11349
+ try {
11350
+ const out = await runGit2(resolved, ["worktree", "prune", "--verbose"]);
11351
+ gitAdminPruned = out.split(`
11352
+ `).filter((l) => /\bRemoving\b/i.test(l)).length;
11353
+ } catch {}
11354
+ return { cleaned, failed, skipped, discardedPruned, gitAdminPruned };
11355
+ }
11356
+
11357
+ // tools/review-approval.ts
11358
+ var description4 = [
11359
+ "reviewer 专用:写入 APPROVE 审批记录。",
11360
+ "**何时调用**:reviewer 给出 `## Decision\\nAPPROVE` 之前必须调本工具。",
11361
+ "**两层语义独立**(ADR:decision-token-vs-approval-verdict-layering):",
11362
+ " - 本工具 `verdict` 字段属审批层,合法值:APPROVE / APPROVE_WITH_NOTES",
11363
+ " - reviewer 输出的 `## Decision` 节首行属协议层,合法值:APPROVE / REQUEST_CHANGES / BLOCK",
11364
+ " - ⚠️ 严禁把 `APPROVE_WITH_NOTES` 字面量写进 `## Decision` 节首行(容错层会归一,但其他变体如 APPROVE_MINOR 会失败 → merge-loop 误判死循环)",
11365
+ ' - ✅ 正确:verdict="APPROVE_WITH_NOTES" + `## Decision\\nAPPROVE`(首行写 APPROVE,详情在审批 notes)',
11366
+ "**pendingIds 格式**:推荐 `session:<sid>` / `plan:<plan_id>` / `decision:<hash>`;旧 `pc-<ts>-NNN` 仍兼容。",
11367
+ "**session:<sid> 自动补全**(ADR:review-approval-auto-covered-sha):",
11368
+ " coveredSha 缺失时工具自动从 worktree HEAD 捕获(捕获失败 fail-closed → pre-check 安全 miss → 走完整 review);",
11369
+ " reviewTarget 缺失时默认 'code'([Session Merge Review] 流按合同即 code review)。",
11370
+ " 显式传入的值优先,不覆盖。plan:/decision:/pc- id 不补全。",
11371
+ "**何时不调**:REQUEST_CHANGES / BLOCK 不调(无 APPROVE = 无审批记录)。",
11372
+ "**fallback**:codeforge 解析 reviewer boomerang 见 APPROVE 但无记录 → 自动以 source='codeforge-fallback' 补写。"
11373
+ ].join(`
11374
+ `);
11375
+ var ArgsSchema4 = z4.object({
11376
+ verdict: z4.enum(["APPROVE", "APPROVE_WITH_NOTES"]).describe("审批层裁决(审计字段,与协议层独立);REQUEST_CHANGES / BLOCK 不应调本工具。⚠️ 不要把本字段值复制到 reviewer 输出的 `## Decision` 节首行 — 那是协议层 3 档。"),
11377
+ pendingIds: z4.array(z4.string().min(1)).min(1, "pendingIds 至少 1 条").describe("本次 APPROVE 覆盖的 id 列表。推荐 session:<sid> / plan:<plan_id> / decision:<hash>;旧 pc-xxx 兼容"),
11378
+ notes: z4.string().min(1, "notes 不能为空").max(2000, "notes 过长(> 2000 字),建议拆条").describe("审阅意见摘要(建议 ≤ 500 字)"),
11379
+ decisionLine: z4.string().optional().describe("`## Decision` 节首行原文(默认 verdict 字面量,机审证据)"),
11380
+ source: z4.enum(["reviewer", "codeforge-fallback"]).optional().describe("写入来源;默认 'reviewer',codeforge 补写时传 'codeforge-fallback'"),
11381
+ reviewerAgent: z4.string().optional().describe("写入 agent name(默认 'reviewer';fallback 时为 'codeforge')"),
11382
+ sessionId: z4.string().optional().describe("reviewer 子 session id(boomerang 溯源用,可选)"),
11383
+ model: z4.string().optional().describe("审批模型 id(审计用,可选)"),
11384
+ coveredSha: z4.string().optional().describe("approval 写入时 worktree HEAD sha;对 session:<sid> id 工具会自动从 worktree HEAD 捕获(缺失时),显式传入值优先。ADR:review-approval-auto-covered-sha / ADR:merge-approval-pre-check"),
11385
+ reviewTarget: z4.string().optional().describe("本次审阅的 review_target 值(reviewer.md 词表:code / code:typescript / code:python / code:csharp-lua-c / plan_only / adr / docs / decision_only)。对 session:<sid> id 缺失时默认 'code',显式传入值优先。pre-check 仅放行 startsWith('code') 的值。ADR:review-approval-auto-covered-sha / ADR:merge-approval-pre-check")
11386
+ });
11387
+ var _approvalStore = null;
11388
+ function getApprovalStore() {
11389
+ if (!_approvalStore)
11390
+ _approvalStore = ApprovalStore.forProject(process.cwd());
11391
+ return _approvalStore;
11392
+ }
11393
+ var _worktreeResolvers = { getSessionWorktree, getCurrentWorktreeHead };
11394
+ async function execute4(input) {
11395
+ const parsed = ArgsSchema4.safeParse(input);
11396
+ if (!parsed.success) {
11349
11397
  return {
11350
11398
  ok: false,
11351
- error: `agent[${name}].model invalid: "${String(o.model)}" (expect <provider>/<id>)`
11399
+ error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
11352
11400
  };
11353
11401
  }
11354
- const fallbacks = normalizeFallbackList(`agent[${name}].fallback_models`, o.fallback_models);
11355
- if (!fallbacks.ok)
11356
- return { ok: false, error: fallbacks.error };
11357
- const thinking = o.thinking !== undefined ? normalizeThinking(`agent[${name}]`, o.thinking) : undefined;
11358
- if (thinking && !thinking.ok)
11359
- return { ok: false, error: thinking.error };
11360
- let tier;
11361
- if (o.tier !== undefined) {
11362
- if (typeof o.tier !== "string" || !isValidTierLevel(o.tier)) {
11363
- return {
11364
- ok: false,
11365
- error: `agent[${name}].tier="${String(o.tier)}" 不是合法 TierLevel (期望 ${TIER_ORDER.join("/")})`
11366
- };
11402
+ const args = parsed.data;
11403
+ const approvals = getApprovalStore();
11404
+ const now = new Date().toISOString();
11405
+ const written = [];
11406
+ for (const id of args.pendingIds) {
11407
+ let coveredSha = args.coveredSha;
11408
+ let reviewTarget = args.reviewTarget;
11409
+ const sessionMatch = /^session:(.+)$/.exec(id);
11410
+ if (sessionMatch) {
11411
+ const sid = sessionMatch[1];
11412
+ if (!coveredSha) {
11413
+ try {
11414
+ const entry = await _worktreeResolvers.getSessionWorktree(sid, process.cwd());
11415
+ if (entry?.worktreePath) {
11416
+ const head = await _worktreeResolvers.getCurrentWorktreeHead(entry.worktreePath);
11417
+ if (head)
11418
+ coveredSha = head;
11419
+ }
11420
+ } catch {}
11421
+ }
11422
+ if (!reviewTarget)
11423
+ reviewTarget = "code";
11367
11424
  }
11368
- tier = o.tier;
11425
+ const meta = {
11426
+ pendingId: id,
11427
+ verdict: args.verdict,
11428
+ reviewer: {
11429
+ agent: args.reviewerAgent ?? (args.source === "codeforge-fallback" ? "codeforge" : "reviewer"),
11430
+ ...args.sessionId ? { sessionId: args.sessionId } : {},
11431
+ ...args.model ? { model: args.model } : {},
11432
+ source: args.source ?? "reviewer"
11433
+ },
11434
+ targets: [],
11435
+ decisionLine: args.decisionLine ?? args.verdict,
11436
+ notes: args.notes,
11437
+ createdAt: now,
11438
+ ...coveredSha ? { coveredSha } : {},
11439
+ ...reviewTarget ? { reviewTarget } : {},
11440
+ escapeHatch: null
11441
+ };
11442
+ const file = await approvals.record(meta);
11443
+ written.push({ pendingId: id, file });
11369
11444
  }
11370
- let tierOverrides;
11371
- if (o.tier_overrides !== undefined) {
11372
- const r = normalizeTierOverrides(name, o.tier_overrides);
11373
- if (!r.ok)
11374
- return { ok: false, error: r.error };
11375
- tierOverrides = r.value;
11445
+ return { ok: true, written };
11446
+ }
11447
+ // tools/browser-navigate.ts
11448
+ import { z as z5 } from "zod";
11449
+
11450
+ // lib/browser-control.ts
11451
+ import * as path12 from "node:path";
11452
+ var DEFAULT_CONFIG2 = {
11453
+ enabled: false,
11454
+ headless: true,
11455
+ allow: ["^https?://"],
11456
+ block: ["^file:", "^data:", "^about:", "^javascript:"],
11457
+ actionTimeoutMs: 15000,
11458
+ idleTimeoutMs: 5 * 60000,
11459
+ screenshotDir: "",
11460
+ bufferLimit: 500
11461
+ };
11462
+ function defaultScreenshotDir(root = process.cwd()) {
11463
+ return path12.join(runtimeDir(root), "browser", "screenshots");
11464
+ }
11465
+ function checkUrl(url, cfg = DEFAULT_CONFIG2) {
11466
+ if (typeof url !== "string" || url.trim() === "") {
11467
+ return { ok: false, reason: "empty_url" };
11468
+ }
11469
+ for (const pat of cfg.block) {
11470
+ try {
11471
+ if (new RegExp(pat).test(url)) {
11472
+ return { ok: false, reason: `blocked: matches ${pat}` };
11473
+ }
11474
+ } catch {}
11475
+ }
11476
+ if (cfg.allow.length === 0)
11477
+ return { ok: true };
11478
+ for (const pat of cfg.allow) {
11479
+ try {
11480
+ if (new RegExp(pat).test(url))
11481
+ return { ok: true };
11482
+ } catch {}
11483
+ }
11484
+ return { ok: false, reason: "not_in_allow_list" };
11485
+ }
11486
+
11487
+ class NoopBrowserController {
11488
+ reason;
11489
+ constructor(reason = "browser is disabled (set tools.browser.enabled=true to use)") {
11490
+ this.reason = reason;
11491
+ }
11492
+ async navigate(url) {
11493
+ return { ok: false, url, error: this.reason };
11494
+ }
11495
+ async screenshot() {
11496
+ return { ok: false, error: this.reason };
11497
+ }
11498
+ async click(selector) {
11499
+ return { ok: false, selector, found: false, error: this.reason };
11500
+ }
11501
+ async fill(selector, _value) {
11502
+ return { ok: false, selector, found: false, error: this.reason };
11503
+ }
11504
+ async consoleLogs() {
11505
+ return [];
11506
+ }
11507
+ async networkRequests() {
11508
+ return [];
11509
+ }
11510
+ async close() {}
11511
+ }
11512
+ async function tryCreatePlaywrightController(cfg = DEFAULT_CONFIG2, resolver = defaultPlaywrightResolver) {
11513
+ if (!cfg.enabled)
11514
+ return null;
11515
+ let mod;
11516
+ try {
11517
+ mod = await resolver();
11518
+ } catch {
11519
+ return null;
11376
11520
  }
11521
+ if (!mod || typeof mod !== "object")
11522
+ return null;
11523
+ const chromium = mod.chromium;
11524
+ if (!chromium || typeof chromium.launch !== "function")
11525
+ return null;
11526
+ const browser = await chromium.launch({ headless: cfg.headless });
11527
+ const context = await browser.newContext();
11528
+ const page = await context.newPage();
11529
+ const consoleBuf = [];
11530
+ const networkBuf = [];
11531
+ page.on?.("console", (...args) => {
11532
+ const msg = args[0];
11533
+ consoleBuf.push({
11534
+ level: normalizeLevel(msg.type?.()),
11535
+ text: msg.text?.() ?? "",
11536
+ timestamp: Date.now()
11537
+ });
11538
+ if (consoleBuf.length > cfg.bufferLimit)
11539
+ consoleBuf.splice(0, consoleBuf.length - cfg.bufferLimit);
11540
+ });
11541
+ page.on?.("request", (...args) => {
11542
+ const req = args[0];
11543
+ networkBuf.push({
11544
+ url: req.url(),
11545
+ method: req.method(),
11546
+ start_ts: Date.now()
11547
+ });
11548
+ if (networkBuf.length > cfg.bufferLimit)
11549
+ networkBuf.splice(0, networkBuf.length - cfg.bufferLimit);
11550
+ });
11551
+ page.on?.("response", (...args) => {
11552
+ const res = args[0];
11553
+ const item = networkBuf.find((n) => n.url === res.url() && n.end_ts === undefined);
11554
+ if (item) {
11555
+ item.status = res.status();
11556
+ item.end_ts = Date.now();
11557
+ }
11558
+ });
11559
+ page.on?.("requestfailed", (...args) => {
11560
+ const req = args[0];
11561
+ const item = networkBuf.find((n) => n.url === req.url() && n.end_ts === undefined);
11562
+ if (item) {
11563
+ item.failed = req.failure?.()?.errorText ?? "failed";
11564
+ item.end_ts = Date.now();
11565
+ }
11566
+ });
11377
11567
  return {
11378
- ok: true,
11379
- binding: {
11380
- model: o.model,
11381
- variant: typeof o.variant === "string" ? o.variant : undefined,
11382
- category: typeof o.category === "string" ? o.category : undefined,
11383
- thinking: thinking?.value,
11384
- fallback_models: fallbacks.value,
11385
- tier,
11386
- tier_overrides: tierOverrides,
11387
- _doc: typeof o._doc === "string" ? o._doc : undefined
11568
+ async navigate(url) {
11569
+ const c = checkUrl(url, cfg);
11570
+ if (!c.ok)
11571
+ return { ok: false, url, error: c.reason };
11572
+ const start = Date.now();
11573
+ try {
11574
+ const resp = await page.goto(url, { timeout: cfg.actionTimeoutMs });
11575
+ return {
11576
+ ok: true,
11577
+ url,
11578
+ status: resp?.status?.() ?? 200,
11579
+ loaded_in_ms: Date.now() - start
11580
+ };
11581
+ } catch (err) {
11582
+ return { ok: false, url, error: describe3(err) };
11583
+ }
11584
+ },
11585
+ async screenshot(opts) {
11586
+ try {
11587
+ const dir = cfg.screenshotDir && cfg.screenshotDir.trim().length > 0 ? cfg.screenshotDir : defaultScreenshotDir();
11588
+ const path13 = `${dir}/${Date.now()}.png`;
11589
+ if (opts?.selector) {
11590
+ const el = await page.locator(opts.selector).first();
11591
+ await el.screenshot({ path: path13 });
11592
+ } else {
11593
+ await page.screenshot({ path: path13, fullPage: opts?.fullPage });
11594
+ }
11595
+ return { ok: true, path: path13 };
11596
+ } catch (err) {
11597
+ return { ok: false, error: describe3(err) };
11598
+ }
11599
+ },
11600
+ async click(selector) {
11601
+ try {
11602
+ await page.locator(selector).first().click({ timeout: cfg.actionTimeoutMs });
11603
+ return { ok: true, selector, found: true };
11604
+ } catch (err) {
11605
+ return { ok: false, selector, found: false, error: describe3(err) };
11606
+ }
11607
+ },
11608
+ async fill(selector, value) {
11609
+ try {
11610
+ await page.locator(selector).first().fill(value, { timeout: cfg.actionTimeoutMs });
11611
+ return { ok: true, selector, found: true };
11612
+ } catch (err) {
11613
+ return { ok: false, selector, found: false, error: describe3(err) };
11614
+ }
11615
+ },
11616
+ async consoleLogs(opts = {}) {
11617
+ return consoleBuf.filter((e) => (opts.sinceTs === undefined || e.timestamp >= opts.sinceTs) && (opts.level === undefined || e.level === opts.level));
11618
+ },
11619
+ async networkRequests(opts = {}) {
11620
+ return networkBuf.filter((e) => (opts.sinceTs === undefined || e.start_ts >= opts.sinceTs) && (opts.method === undefined || e.method.toUpperCase() === opts.method.toUpperCase()));
11621
+ },
11622
+ async close() {
11623
+ try {
11624
+ await context.close();
11625
+ } catch {}
11626
+ try {
11627
+ await browser.close();
11628
+ } catch {}
11388
11629
  }
11389
11630
  };
11390
11631
  }
11391
- function normalizeCategory(name, raw) {
11392
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
11393
- return { ok: false, error: `category[${name}]: must be object` };
11394
- }
11395
- const o = raw;
11396
- if (typeof o.model !== "string" || !PROVIDER_MODEL_RE.test(o.model)) {
11397
- return {
11398
- ok: false,
11399
- error: `category[${name}].model invalid: "${String(o.model)}"`
11400
- };
11401
- }
11402
- const fallbacks = normalizeFallbackList(`category[${name}].fallback_models`, o.fallback_models);
11403
- if (!fallbacks.ok)
11404
- return { ok: false, error: fallbacks.error };
11405
- const thinking = o.thinking !== undefined ? normalizeThinking(`category[${name}]`, o.thinking) : undefined;
11406
- if (thinking && !thinking.ok)
11407
- return { ok: false, error: thinking.error };
11408
- return {
11409
- ok: true,
11410
- binding: {
11411
- model: o.model,
11412
- variant: typeof o.variant === "string" ? o.variant : undefined,
11413
- thinking: thinking?.value,
11414
- fallback_models: fallbacks.value,
11415
- _doc: typeof o._doc === "string" ? o._doc : undefined
11416
- }
11417
- };
11632
+ async function defaultPlaywrightResolver() {
11633
+ return await import("playwright");
11418
11634
  }
11419
- function normalizeFallbackList(ctx, raw) {
11420
- if (raw === undefined)
11421
- return { ok: true, value: [] };
11422
- if (!Array.isArray(raw))
11423
- return { ok: false, error: `${ctx}: must be array` };
11424
- const seen = new Set;
11425
- const result = [];
11426
- for (const it of raw) {
11427
- if (typeof it !== "string" || !PROVIDER_MODEL_RE.test(it)) {
11428
- return { ok: false, error: `${ctx}: invalid entry "${String(it)}"` };
11429
- }
11430
- if (seen.has(it))
11431
- continue;
11432
- seen.add(it);
11433
- result.push(it);
11635
+ function normalizeLevel(t) {
11636
+ switch (t) {
11637
+ case "info":
11638
+ case "warn":
11639
+ case "error":
11640
+ case "debug":
11641
+ return t;
11642
+ default:
11643
+ return "log";
11434
11644
  }
11435
- return { ok: true, value: result };
11436
11645
  }
11437
- function normalizeThinking(ctx, raw) {
11438
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
11439
- return { ok: false, error: `${ctx}.thinking: must be object` };
11440
- }
11441
- const o = raw;
11442
- if (o.type !== "enabled" && o.type !== "disabled") {
11443
- return { ok: false, error: `${ctx}.thinking.type: must be "enabled" or "disabled"` };
11444
- }
11445
- if (o.budget_tokens !== undefined && (typeof o.budget_tokens !== "number" || o.budget_tokens < 0)) {
11446
- return { ok: false, error: `${ctx}.thinking.budget_tokens: must be non-negative number` };
11646
+ function describe3(err) {
11647
+ if (err instanceof Error)
11648
+ return err.message;
11649
+ if (typeof err === "string")
11650
+ return err;
11651
+ try {
11652
+ return JSON.stringify(err);
11653
+ } catch {
11654
+ return String(err);
11447
11655
  }
11448
- return { ok: true, value: { ...o, type: o.type } };
11449
11656
  }
11450
- function normalizeRuntime(raw) {
11451
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
11452
- return { ok: false, error: "runtime_fallback: must be object" };
11453
- }
11454
- const o = raw;
11455
- const cfg = { ...DEFAULT_RUNTIME_FALLBACK };
11456
- if (o.enabled !== undefined) {
11457
- if (typeof o.enabled !== "boolean")
11458
- return { ok: false, error: "runtime_fallback.enabled: boolean" };
11459
- cfg.enabled = o.enabled;
11460
- }
11461
- if (o.max_fallback_attempts !== undefined) {
11462
- if (typeof o.max_fallback_attempts !== "number" || o.max_fallback_attempts < 1) {
11463
- return { ok: false, error: "runtime_fallback.max_fallback_attempts: integer >=1" };
11464
- }
11465
- cfg.max_fallback_attempts = Math.floor(o.max_fallback_attempts);
11466
- }
11467
- if (o.notify_on_fallback !== undefined) {
11468
- if (typeof o.notify_on_fallback !== "boolean") {
11469
- return { ok: false, error: "runtime_fallback.notify_on_fallback: boolean" };
11470
- }
11471
- cfg.notify_on_fallback = o.notify_on_fallback;
11657
+ async function createBrowserController(opts = {}) {
11658
+ const cfg = { ...DEFAULT_CONFIG2, ...opts.cfg };
11659
+ if (!cfg.enabled) {
11660
+ return new NoopBrowserController("browser disabled in config");
11472
11661
  }
11473
- if (o.trigger_events !== undefined) {
11474
- if (!Array.isArray(o.trigger_events) || o.trigger_events.some((x) => typeof x !== "string")) {
11475
- return { ok: false, error: "runtime_fallback.trigger_events: string[]" };
11476
- }
11477
- cfg.trigger_events = [...new Set(o.trigger_events)];
11662
+ if (opts.factory) {
11663
+ const c = await opts.factory(cfg);
11664
+ if (c)
11665
+ return c;
11478
11666
  }
11479
- if (typeof o._doc === "string")
11480
- cfg._doc = o._doc;
11481
- return { ok: true, cfg };
11667
+ const playwright = await tryCreatePlaywrightController(cfg);
11668
+ if (playwright)
11669
+ return playwright;
11670
+ return new NoopBrowserController("browser enabled but no driver available (npm i playwright 后重试)");
11482
11671
  }
11483
- function isValidTierLevel(x) {
11484
- return typeof x === "string" && TIER_ORDER.includes(x);
11672
+
11673
+ // tools/browser-navigate.ts
11674
+ var description5 = [
11675
+ "打开指定 URL(受 tools.browser 配置控制;默认禁用)。",
11676
+ "**何时调用**:需要看 web 页面真实渲染、调试前端、复现 bug 报告链接。",
11677
+ "**注意**:仅允许 http/https;file:/data: 等协议被默认安全策略拒绝。"
11678
+ ].join(`
11679
+ `);
11680
+ var ArgsSchema5 = z5.object({
11681
+ url: z5.string().min(1).describe("要打开的 URL;必须是 http(s) 协议")
11682
+ });
11683
+ var _controller = null;
11684
+ var _cfg;
11685
+ async function getController() {
11686
+ if (!_controller) {
11687
+ _controller = await createBrowserController({ cfg: _cfg });
11688
+ }
11689
+ return _controller;
11485
11690
  }
11486
- function normalizeTierOverrides(agentName, raw) {
11487
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
11691
+ async function execute5(input) {
11692
+ const parsed = ArgsSchema5.safeParse(input);
11693
+ if (!parsed.success) {
11488
11694
  return {
11489
11695
  ok: false,
11490
- error: `agent[${agentName}].tier_overrides 必须是 object(不是 array / null / 其他类型)`
11696
+ url: typeof input?.url === "string" ? String(input.url) : "",
11697
+ error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
11491
11698
  };
11492
11699
  }
11493
- const o = raw;
11494
- const result = {};
11495
- for (const [key, value] of Object.entries(o)) {
11496
- if (!isValidTierLevel(key)) {
11497
- return {
11498
- ok: false,
11499
- error: `agent[${agentName}].tier_overrides.${key}: 非法 TierLevel key (期望 ${TIER_ORDER.join("/")})`
11500
- };
11501
- }
11502
- if (!value || typeof value !== "object" || Array.isArray(value)) {
11503
- return {
11504
- ok: false,
11505
- error: `agent[${agentName}].tier_overrides.${key}: 必须是 object`
11506
- };
11507
- }
11508
- const ov = value;
11509
- const partial = {};
11510
- if (ov.level !== undefined) {
11511
- if (ov.level !== key) {
11512
- return {
11513
- ok: false,
11514
- error: `agent[${agentName}].tier_overrides.${key}.level="${String(ov.level)}" 与 key "${key}" 错配`
11515
- };
11516
- }
11517
- partial.level = key;
11518
- }
11519
- if (ov.model !== undefined) {
11520
- if (typeof ov.model !== "string" || !PROVIDER_MODEL_RE.test(ov.model)) {
11521
- return {
11522
- ok: false,
11523
- error: `agent[${agentName}].tier_overrides.${key}.model="${String(ov.model)}" 格式非法 (期望 <provider>/<id>)`
11524
- };
11525
- }
11526
- partial.model = ov.model;
11527
- }
11528
- if (ov.thinking !== undefined) {
11529
- const t = normalizeThinking(`agent[${agentName}].tier_overrides.${key}`, ov.thinking);
11530
- if (!t.ok)
11531
- return { ok: false, error: t.error };
11532
- partial.thinking = t.value;
11533
- }
11534
- if (ov.fallback_models !== undefined) {
11535
- const f = normalizeFallbackList(`agent[${agentName}].tier_overrides.${key}.fallback_models`, ov.fallback_models);
11536
- if (!f.ok)
11537
- return { ok: false, error: f.error };
11538
- partial.fallback_models = f.value;
11539
- }
11540
- result[key] = partial;
11541
- }
11542
- return { ok: true, value: result };
11543
- }
11544
- function normalizeTiers(raw) {
11545
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
11546
- return { ok: false, error: "models.tiers 必须是 object(不是 array / null)" };
11547
- }
11548
- const o = raw;
11549
- const cfg = {};
11550
- if (o.category_map !== undefined) {
11551
- if (!o.category_map || typeof o.category_map !== "object" || Array.isArray(o.category_map)) {
11552
- return { ok: false, error: "models.tiers.category_map 必须是 object" };
11553
- }
11554
- const map = {};
11555
- for (const [key, value] of Object.entries(o.category_map)) {
11556
- if (!isValidTierLevel(key)) {
11557
- return {
11558
- ok: false,
11559
- error: `models.tiers.category_map.${key}: 非法 TierLevel key (期望 ${TIER_ORDER.join("/")})`
11560
- };
11561
- }
11562
- if (typeof value !== "string" || value.length === 0) {
11563
- return {
11564
- ok: false,
11565
- error: `models.tiers.category_map.${key}: 必须是非空字符串(category 名)`
11566
- };
11567
- }
11568
- map[key] = value;
11569
- }
11570
- cfg.category_map = map;
11571
- }
11572
- if (typeof o._doc === "string")
11573
- cfg._doc = o._doc;
11574
- return { ok: true, cfg };
11575
- }
11576
- function resolveAgentModel(config, agent) {
11577
- const a = config.agents[agent];
11578
- if (!a)
11579
- return null;
11580
- const seen = new Set;
11581
- const chain = [];
11582
- const push = (m) => {
11583
- if (!m || seen.has(m))
11584
- return;
11585
- seen.add(m);
11586
- chain.push(m);
11587
- };
11588
- push(a.model);
11589
- for (const m of a.fallback_models ?? [])
11590
- push(m);
11591
- let categoryName;
11592
- let usedCategory = false;
11593
- if (a.category && config.categories?.[a.category]) {
11594
- categoryName = a.category;
11595
- const c = config.categories[a.category];
11596
- const beforeLen = chain.length;
11597
- push(c.model);
11598
- for (const m of c.fallback_models ?? [])
11599
- push(m);
11600
- usedCategory = chain.length > beforeLen;
11700
+ try {
11701
+ const ctrl = await getController();
11702
+ return await ctrl.navigate(parsed.data.url);
11703
+ } catch (err) {
11704
+ return {
11705
+ ok: false,
11706
+ url: parsed.data.url,
11707
+ error: err instanceof Error ? err.message : String(err)
11708
+ };
11601
11709
  }
11602
- const source = a.fallback_models && a.fallback_models.length > 0 ? usedCategory ? "merged" : "agent" : usedCategory ? "category" : "agent";
11603
- return {
11604
- agent,
11605
- model: a.model,
11606
- variant: a.variant,
11607
- thinking: a.thinking,
11608
- chain,
11609
- source,
11610
- category: categoryName
11611
- };
11612
11710
  }
11613
- function nextFallback(config, agent, current) {
11614
- const r = resolveAgentModel(config, agent);
11615
- if (!r)
11616
- return null;
11617
- const idx = r.chain.indexOf(current);
11618
- if (idx < 0)
11619
- return r.chain[0] ?? null;
11620
- return r.chain[idx + 1] ?? null;
11711
+ // tools/browser-click.ts
11712
+ import { z as z6 } from "zod";
11713
+ var description6 = [
11714
+ "点击页面元素。属于「写操作」,semi 模式下应当让用户确认。",
11715
+ "**何时调用**:表单提交、按钮触发、SPA 导航。",
11716
+ "**避坑**:selector 优先用稳定属性(data-testid > role > 文本 > id),尽量不用 xpath。"
11717
+ ].join(`
11718
+ `);
11719
+ var ArgsSchema6 = z6.object({
11720
+ selector: z6.string().min(1).describe("CSS / Playwright locator 字符串,必须能唯一定位")
11721
+ });
11722
+ var _controller2 = null;
11723
+ var _cfg2;
11724
+ async function getController2() {
11725
+ if (!_controller2) {
11726
+ _controller2 = await createBrowserController({ cfg: _cfg2 });
11727
+ }
11728
+ return _controller2;
11621
11729
  }
11622
- function listAllAgentChains(config) {
11623
- return Object.keys(config.agents).sort().map((name) => resolveAgentModel(config, name)).filter((x) => x !== null);
11730
+ async function execute6(input) {
11731
+ const parsed = ArgsSchema6.safeParse(input);
11732
+ if (!parsed.success) {
11733
+ return {
11734
+ ok: false,
11735
+ selector: typeof input?.selector === "string" ? String(input.selector) : "",
11736
+ found: false,
11737
+ error: parsed.error.issues.map((i) => i.message).join("; ")
11738
+ };
11739
+ }
11740
+ try {
11741
+ const ctrl = await getController2();
11742
+ return await ctrl.click(parsed.data.selector);
11743
+ } catch (err) {
11744
+ return {
11745
+ ok: false,
11746
+ selector: parsed.data.selector,
11747
+ found: false,
11748
+ error: err instanceof Error ? err.message : String(err)
11749
+ };
11750
+ }
11624
11751
  }
11625
-
11626
- // tools/model-chain.ts
11627
- var description11 = [
11628
- "查询 CodeForge 配置文件 codeforge.json:每个 agent 的主模型 + fallback 链。",
11629
- "**何时调用**:",
11630
- "- 用户问「coder 用什么模型 / 备用是什么」",
11631
- "- 当前模型不可用时,决定下一个该试哪个",
11632
- "- 审计:把整个项目的模型配置一次性列给用户看",
11633
- "返回 JSON:{ ok, agents: [...], current_chain?, next_fallback? }"
11752
+ // tools/browser-fill.ts
11753
+ import { z as z7 } from "zod";
11754
+ var description7 = [
11755
+ " input/textarea/contenteditable 元素写入文本。",
11756
+ "**何时调用**:登录表单、搜索框、富文本初始化。",
11757
+ "**安全**:value 不会被自动 mask;不要把真实凭据塞进来。"
11634
11758
  ].join(`
11635
11759
  `);
11636
- var ArgsSchema11 = z11.object({
11637
- agent: z11.string().optional().describe("查指定 agent;不传 列出全部"),
11638
- current: z11.string().optional().describe("当前已用过的模型(<provider>/<id>),用于算下一档"),
11639
- root: z11.string().optional().describe("项目根目录,默认 process.cwd()"),
11640
- config_file: z11.string().optional().describe("配置文件名;默认 codeforge.json")
11760
+ var ArgsSchema7 = z7.object({
11761
+ selector: z7.string().min(1).describe("CSS / Playwright locator"),
11762
+ value: z7.string().describe("要填入的文本;原样写入,不做转义")
11641
11763
  });
11642
- async function execute11(rawArgs) {
11643
- const parsed = ArgsSchema11.safeParse(rawArgs ?? {});
11764
+ var _controller3 = null;
11765
+ var _cfg3;
11766
+ async function getController3() {
11767
+ if (!_controller3) {
11768
+ _controller3 = await createBrowserController({ cfg: _cfg3 });
11769
+ }
11770
+ return _controller3;
11771
+ }
11772
+ async function execute7(input) {
11773
+ const parsed = ArgsSchema7.safeParse(input);
11644
11774
  if (!parsed.success) {
11645
11775
  return {
11646
11776
  ok: false,
11647
- error: "invalid_args: " + parsed.error.issues.map((i) => `${i.path.map(String).join(".")}: ${i.message}`).join("; ")
11777
+ selector: typeof input?.selector === "string" ? String(input.selector) : "",
11778
+ found: false,
11779
+ error: parsed.error.issues.map((i) => i.message).join("; ")
11648
11780
  };
11649
11781
  }
11650
- const args = parsed.data;
11651
- const loadResult = await loadModelConfig({
11652
- root: args.root,
11653
- file: args.config_file
11654
- });
11655
- if (!loadResult.ok || !loadResult.config) {
11782
+ try {
11783
+ const ctrl = await getController3();
11784
+ return await ctrl.fill(parsed.data.selector, parsed.data.value);
11785
+ } catch (err) {
11656
11786
  return {
11657
11787
  ok: false,
11658
- error: loadResult.error ?? "load_failed",
11659
- config_path: loadResult.path
11788
+ selector: parsed.data.selector,
11789
+ found: false,
11790
+ error: err instanceof Error ? err.message : String(err)
11660
11791
  };
11661
11792
  }
11662
- const cfg = loadResult.config;
11663
- const allChains = listAllAgentChains(cfg).map(toEntry);
11664
- const out = {
11665
- ok: true,
11666
- config_path: loadResult.path ?? "",
11667
- warnings: loadResult.warnings,
11668
- agents: allChains
11669
- };
11670
- if (args.agent) {
11671
- const r = resolveAgentModel(cfg, args.agent);
11672
- if (!r) {
11673
- return {
11674
- ok: false,
11675
- error: `agent_not_found: "${args.agent}" 未在 codeforge.json 配置`,
11676
- config_path: loadResult.path
11677
- };
11678
- }
11679
- out.current_chain = toEntry(r);
11680
- if (args.current) {
11681
- out.next_fallback = nextFallback(cfg, args.agent, args.current);
11682
- }
11683
- }
11684
- return out;
11685
11793
  }
11686
- function toEntry(r) {
11687
- return {
11688
- agent: r.agent,
11689
- model: r.model,
11690
- category: r.category,
11691
- source: r.source,
11692
- chain: r.chain
11693
- };
11794
+ // tools/browser-screenshot.ts
11795
+ import { z as z8 } from "zod";
11796
+ var description8 = [
11797
+ "截取当前页面(或指定元素)的屏幕快照。",
11798
+ "**何时调用**:前端 dev 调试、回归对比、用户报 bug 没截图时让 agent 自取。",
11799
+ "**注意**:必须先 navigate;输出路径写到 runtime 目录的 browser/screenshots/(XDG 全局位置,详见 lib/runtime-paths.ts)。"
11800
+ ].join(`
11801
+ `);
11802
+ var ArgsSchema8 = z8.object({
11803
+ fullPage: z8.boolean().optional().describe("是否截全长页面(默认仅可视区)"),
11804
+ selector: z8.string().optional().describe("CSS 选择器;指定时只截该元素")
11805
+ });
11806
+ var _controller4 = null;
11807
+ var _cfg4;
11808
+ async function getController4() {
11809
+ if (!_controller4) {
11810
+ _controller4 = await createBrowserController({ cfg: _cfg4 });
11811
+ }
11812
+ return _controller4;
11694
11813
  }
11695
- // tools/session-merge.ts
11696
- import { z as z12 } from "zod";
11697
-
11698
- // lib/session-worktree.ts
11699
- init_worktree_ops();
11700
- import { execFile as execFile3 } from "node:child_process";
11701
- import { promises as fs10 } from "node:fs";
11702
- import * as path13 from "node:path";
11703
-
11704
- // lib/file-lock.ts
11705
- import { promises as fs9 } from "node:fs";
11706
- import * as crypto3 from "node:crypto";
11707
- import * as os4 from "node:os";
11708
- import * as path12 from "node:path";
11709
- async function withFileLock(lockPath, fn, opts = {}) {
11710
- const retryMs = opts.retryMs ?? 50;
11711
- const timeoutMs = opts.timeoutMs ?? 1e4;
11712
- const staleMs = opts.staleMs ?? 300000;
11713
- await fs9.mkdir(path12.dirname(lockPath), { recursive: true });
11714
- const deadline = Date.now() + timeoutMs;
11715
- const myHost = os4.hostname();
11716
- const myToken = crypto3.randomBytes(16).toString("hex");
11717
- let acquired = false;
11718
- while (!acquired) {
11719
- try {
11720
- const handle = await fs9.open(lockPath, "wx");
11721
- try {
11722
- const meta = {
11723
- pid: process.pid,
11724
- host: myHost,
11725
- token: myToken,
11726
- acquired_at: new Date().toISOString()
11727
- };
11728
- await handle.writeFile(JSON.stringify(meta), "utf8");
11729
- await handle.close();
11730
- } catch (writeErr) {
11731
- await handle.close().catch(() => {});
11732
- await fs9.rm(lockPath, { force: true }).catch(() => {});
11733
- throw writeErr;
11734
- }
11735
- acquired = true;
11736
- } catch (err) {
11737
- const e = err;
11738
- if (e.code !== "EEXIST")
11739
- throw err;
11740
- const cleaned = await tryCleanStaleLock(lockPath, staleMs, myHost);
11741
- if (cleaned)
11742
- continue;
11743
- if (Date.now() >= deadline) {
11744
- throw new Error(`withFileLock: 等待 ${lockPath} 超过 ${timeoutMs}ms 仍未拿到锁`);
11745
- }
11746
- await sleep(retryMs);
11747
- }
11814
+ async function execute8(input) {
11815
+ const parsed = ArgsSchema8.safeParse(input);
11816
+ if (!parsed.success) {
11817
+ return { ok: false, error: parsed.error.issues.map((i) => i.message).join("; ") };
11748
11818
  }
11749
11819
  try {
11750
- return await fn();
11751
- } finally {
11752
- await releaseLockSafely(lockPath, myToken).catch(() => {});
11820
+ const ctrl = await getController4();
11821
+ return await ctrl.screenshot(parsed.data);
11822
+ } catch (err) {
11823
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
11753
11824
  }
11754
11825
  }
11755
- async function tryCleanStaleLock(lockPath, staleMs, myHost) {
11756
- let mtimeMs;
11757
- try {
11758
- const st = await fs9.stat(lockPath);
11759
- mtimeMs = st.mtimeMs;
11760
- } catch {
11761
- return true;
11826
+ // tools/browser-console.ts
11827
+ import { z as z9 } from "zod";
11828
+ var description9 = [
11829
+ "读取浏览器控制台缓冲区(log/info/warn/error/debug)。",
11830
+ "**何时调用**:调前端 bug、看页面 runtime error、跟 React/Vue 警告。",
11831
+ "**注意**:缓冲区上限 500 条,超出会丢最早;只能读 navigate 之后的日志。"
11832
+ ].join(`
11833
+ `);
11834
+ var ArgsSchema9 = z9.object({
11835
+ level: z9.enum(["log", "info", "warn", "error", "debug"]).optional().describe("只过滤指定级别;缺省返回全部"),
11836
+ sinceTs: z9.number().optional().describe("只返回 timestamp >= sinceTs 的条目")
11837
+ });
11838
+ var _controller5 = null;
11839
+ var _cfg5;
11840
+ async function getController5() {
11841
+ if (!_controller5) {
11842
+ _controller5 = await createBrowserController({ cfg: _cfg5 });
11843
+ }
11844
+ return _controller5;
11845
+ }
11846
+ async function execute9(input) {
11847
+ const parsed = ArgsSchema9.safeParse(input);
11848
+ if (!parsed.success) {
11849
+ return {
11850
+ ok: false,
11851
+ entries: [],
11852
+ error: parsed.error.issues.map((i) => i.message).join("; ")
11853
+ };
11762
11854
  }
11763
- const age = Date.now() - mtimeMs;
11764
- if (age <= staleMs)
11765
- return false;
11766
- let meta = null;
11767
11855
  try {
11768
- const raw = await fs9.readFile(lockPath, "utf8");
11769
- meta = JSON.parse(raw);
11770
- } catch {
11771
- meta = null;
11772
- }
11773
- if (meta && meta.host === myHost && typeof meta.pid === "number" && meta.pid > 0) {
11774
- try {
11775
- process.kill(meta.pid, 0);
11776
- return false;
11777
- } catch (err) {
11778
- const e = err;
11779
- if (e.code === "EPERM")
11780
- return false;
11781
- }
11856
+ const ctrl = await getController5();
11857
+ const entries = await ctrl.consoleLogs(parsed.data);
11858
+ return { ok: true, entries };
11859
+ } catch (err) {
11860
+ return {
11861
+ ok: false,
11862
+ entries: [],
11863
+ error: err instanceof Error ? err.message : String(err)
11864
+ };
11782
11865
  }
11783
- return await deleteIfMtimeMatch(lockPath, mtimeMs);
11784
11866
  }
11785
- async function deleteIfMtimeMatch(lockPath, expectedMtimeMs) {
11786
- try {
11787
- const st = await fs9.stat(lockPath);
11788
- if (st.mtimeMs !== expectedMtimeMs) {
11789
- return false;
11790
- }
11791
- } catch {
11792
- return true;
11867
+ // tools/browser-network.ts
11868
+ import { z as z10 } from "zod";
11869
+ var description10 = [
11870
+ "读取浏览器网络请求缓冲区(含 status / 失败原因 / 耗时)。",
11871
+ "**何时调用**:调 API 调用 4xx/5xx、CORS 报错、查 page 加载耗时分布。",
11872
+ "**注意**:缓冲区上限 500 条;只看 navigate 之后发起的请求。"
11873
+ ].join(`
11874
+ `);
11875
+ var ArgsSchema10 = z10.object({
11876
+ method: z10.string().optional().describe("HTTP 方法过滤(GET/POST/...),不区分大小写"),
11877
+ sinceTs: z10.number().optional().describe("只返回 start_ts >= sinceTs 的请求")
11878
+ });
11879
+ var _controller6 = null;
11880
+ var _cfg6;
11881
+ async function getController6() {
11882
+ if (!_controller6) {
11883
+ _controller6 = await createBrowserController({ cfg: _cfg6 });
11793
11884
  }
11794
- await fs9.rm(lockPath, { force: true }).catch(() => {});
11795
- return true;
11885
+ return _controller6;
11796
11886
  }
11797
- async function releaseLockSafely(lockPath, myToken) {
11798
- let raw;
11799
- try {
11800
- raw = await fs9.readFile(lockPath, "utf8");
11801
- } catch {
11802
- return;
11887
+ async function execute10(input) {
11888
+ const parsed = ArgsSchema10.safeParse(input);
11889
+ if (!parsed.success) {
11890
+ return {
11891
+ ok: false,
11892
+ entries: [],
11893
+ error: parsed.error.issues.map((i) => i.message).join("; ")
11894
+ };
11803
11895
  }
11804
- let meta;
11805
11896
  try {
11806
- meta = JSON.parse(raw);
11807
- } catch {
11808
- return;
11809
- }
11810
- if (meta.token === myToken) {
11811
- await fs9.rm(lockPath, { force: true });
11897
+ const ctrl = await getController6();
11898
+ const entries = await ctrl.networkRequests(parsed.data);
11899
+ return { ok: true, entries };
11900
+ } catch (err) {
11901
+ return {
11902
+ ok: false,
11903
+ entries: [],
11904
+ error: err instanceof Error ? err.message : String(err)
11905
+ };
11812
11906
  }
11813
11907
  }
11814
- function sleep(ms) {
11815
- return new Promise((resolve10) => setTimeout(resolve10, ms));
11816
- }
11908
+ // tools/model-chain.ts
11909
+ import { z as z11 } from "zod";
11817
11910
 
11818
- // lib/session-worktree.ts
11819
- var REGISTRY_VERSION = 1;
11820
- var DEFAULT_WORKTREE_SUBDIR = path13.join(".git", "codeforge-worktrees");
11821
- function debugLog(msg) {
11822
- if (process.env["CODEFORGE_DEBUG"]) {
11823
- console.debug(`[session-worktree] ${msg}`);
11824
- }
11825
- }
11826
- function registryDir(mainRoot) {
11827
- return path13.join(runtimeDir(path13.resolve(mainRoot), { ensure: false }), "session-worktrees");
11828
- }
11829
- function registryPath(mainRoot) {
11830
- return path13.join(registryDir(mainRoot), "registry.json");
11911
+ // lib/model-config.ts
11912
+ import { promises as fs10 } from "node:fs";
11913
+ import * as path13 from "node:path";
11914
+
11915
+ // lib/model-tier.ts
11916
+ var TIER_ORDER = ["quick", "balanced", "deep", "ultra"];
11917
+
11918
+ // lib/model-config.ts
11919
+ var CONFIG_FILE = "codeforge.json";
11920
+ var DEFAULT_RUNTIME_FALLBACK = {
11921
+ enabled: true,
11922
+ max_fallback_attempts: 3,
11923
+ notify_on_fallback: true,
11924
+ trigger_events: ["session.error", "model.unavailable", "model.rate_limited"]
11925
+ };
11926
+ var PROVIDER_MODEL_RE = /^[a-z0-9-]+\/[a-zA-Z0-9._-]+$/;
11927
+ function findConfigFileSync(opts = {}) {
11928
+ const root = opts.root ?? process.cwd();
11929
+ const fsSync = __require("node:fs");
11930
+ const abs = path13.resolve(root, opts.file ?? CONFIG_FILE);
11931
+ return fsSync.existsSync(abs) ? abs : null;
11831
11932
  }
11832
- function registryLockPath(mainRoot) {
11833
- return path13.join(registryDir(mainRoot), "registry.lock");
11933
+ function loadModelConfigSync(opts = {}) {
11934
+ const root = opts.root ?? process.cwd();
11935
+ const abs = path13.resolve(root, opts.file ?? CONFIG_FILE);
11936
+ const fsSync = __require("node:fs");
11937
+ if (!fsSync.existsSync(abs)) {
11938
+ return { ok: false, warnings: [], error: `config_not_found: ${abs}` };
11939
+ }
11940
+ let raw;
11941
+ try {
11942
+ raw = fsSync.readFileSync(abs, "utf8");
11943
+ } catch (e) {
11944
+ return { ok: false, path: abs, warnings: [], error: `read_failed: ${e.message}` };
11945
+ }
11946
+ return parseAndValidate(raw, abs);
11834
11947
  }
11835
- async function readRegistry(mainRoot) {
11836
- const file = registryPath(mainRoot);
11948
+ async function loadModelConfig(opts = {}) {
11949
+ const root = opts.root ?? process.cwd();
11950
+ const abs = path13.resolve(root, opts.file ?? CONFIG_FILE);
11951
+ let raw;
11837
11952
  try {
11838
- const raw = await fs10.readFile(file, "utf8");
11839
- const parsed = JSON.parse(raw);
11840
- if (parsed.version !== REGISTRY_VERSION || !Array.isArray(parsed.entries)) {
11841
- return { version: REGISTRY_VERSION, entries: [] };
11953
+ raw = await fs10.readFile(abs, "utf8");
11954
+ } catch (e) {
11955
+ const code = e.code;
11956
+ if (code === "ENOENT") {
11957
+ return { ok: false, path: abs, warnings: [], error: `config_not_found: ${abs}` };
11842
11958
  }
11843
- return parsed;
11844
- } catch (err) {
11845
- const e = err;
11846
- if (e.code === "ENOENT")
11847
- return { version: REGISTRY_VERSION, entries: [] };
11848
- return { version: REGISTRY_VERSION, entries: [] };
11959
+ return { ok: false, path: abs, warnings: [], error: `read_failed: ${e.message}` };
11849
11960
  }
11961
+ return parseAndValidate(raw, abs);
11850
11962
  }
11851
- async function writeRegistry(mainRoot, reg) {
11852
- const file = registryPath(mainRoot);
11853
- await fs10.mkdir(path13.dirname(file), { recursive: true });
11854
- const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
11855
- await fs10.writeFile(tmp, JSON.stringify(reg, null, 2), "utf8");
11856
- await fs10.rename(tmp, file);
11857
- }
11858
- async function mutateRegistry(mainRoot, fn) {
11859
- const lockPath = registryLockPath(mainRoot);
11860
- await fs10.mkdir(path13.dirname(lockPath), { recursive: true });
11861
- return await withFileLock(lockPath, async () => {
11862
- const reg = await readRegistry(mainRoot);
11863
- const result = await fn(reg);
11864
- await writeRegistry(mainRoot, reg);
11865
- return result;
11866
- });
11867
- }
11868
- async function bindSessionWorktree(opts) {
11869
- if (!opts.sessionId || opts.sessionId.trim() === "") {
11870
- throw new Error("bindSessionWorktree: sessionId 不能为空");
11963
+ function parseAndValidate(raw, abs) {
11964
+ let parsed;
11965
+ try {
11966
+ parsed = JSON.parse(raw);
11967
+ } catch (e) {
11968
+ return { ok: false, path: abs, warnings: [], error: `invalid_json: ${e.message}` };
11871
11969
  }
11872
- const mainRoot = path13.resolve(opts.mainRoot);
11873
- const branch = opts.branchName ?? `codeforge/session-${opts.sessionId}`;
11874
- const worktreesDir = opts.worktrees_dir ?? path13.join(mainRoot, DEFAULT_WORKTREE_SUBDIR);
11875
- const lockPath = registryLockPath(mainRoot);
11876
- await fs10.mkdir(path13.dirname(lockPath), { recursive: true });
11877
- return await withFileLock(lockPath, async () => {
11878
- const reg = await readRegistry(mainRoot);
11879
- const existing = reg.entries.find((e) => e.sessionId === opts.sessionId);
11880
- if (existing && existing.status === "active")
11881
- return existing;
11882
- const baseSha = (await runGit2(mainRoot, ["rev-parse", "HEAD"])).trim();
11883
- const wt = await ensureWorktree({
11884
- root: mainRoot,
11885
- branch,
11886
- worktrees_dir: worktreesDir
11887
- });
11888
- const now = new Date().toISOString();
11889
- const entry = {
11890
- sessionId: opts.sessionId,
11891
- branch,
11892
- worktreePath: wt.path,
11893
- baseSha,
11894
- ...opts.requiredPlanId ? { requiredPlanId: opts.requiredPlanId, planReadOk: false } : {},
11895
- status: "active",
11896
- createdAt: now,
11897
- updatedAt: now
11898
- };
11899
- const idx = reg.entries.findIndex((e) => e.sessionId === opts.sessionId);
11900
- if (idx >= 0)
11901
- reg.entries[idx] = entry;
11902
- else
11903
- reg.entries.push(entry);
11904
- await writeRegistry(mainRoot, reg);
11905
- return entry;
11906
- });
11907
- }
11908
- async function getSessionWorktree(sessionId, mainRoot) {
11909
- const reg = await readRegistry(mainRoot);
11910
- return reg.entries.find((e) => e.sessionId === sessionId) ?? null;
11911
- }
11912
- async function markPlanReadOk(opts) {
11913
- return await mutateRegistry(opts.mainRoot, (reg) => {
11914
- const entry = reg.entries.find((e) => e.sessionId === opts.sessionId);
11915
- if (!entry || entry.requiredPlanId !== opts.planId)
11916
- return false;
11917
- entry.planReadOk = true;
11918
- entry.updatedAt = new Date().toISOString();
11919
- return true;
11920
- });
11921
- }
11922
- async function touchEntryUpdatedAt(opts) {
11923
- return await mutateRegistry(opts.mainRoot, (reg) => {
11924
- const entry = reg.entries.find((e) => e.sessionId === opts.sessionId);
11925
- if (!entry || entry.status !== "active")
11926
- return false;
11927
- entry.updatedAt = new Date().toISOString();
11928
- return true;
11929
- });
11930
- }
11931
- async function mergeSessionBack(opts) {
11932
- const mainRoot = path13.resolve(opts.mainRoot);
11933
- const entry = await getSessionWorktree(opts.sessionId, mainRoot);
11934
- if (!entry) {
11935
- throw new Error(`mergeSessionBack: session ${opts.sessionId} 没有绑定 worktree`);
11970
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
11971
+ return { ok: false, path: abs, warnings: [], error: "invalid_root: must be an object" };
11936
11972
  }
11937
- if (entry.status === "interrupted_dirty") {
11938
- throw new Error(`mergeSessionBack: session ${opts.sessionId} 处于 interrupted_dirty 状态,` + `请先手动处理 worktree (${entry.worktreePath}) 后再 merge`);
11973
+ const root = parsed;
11974
+ const modelsNode = root.models;
11975
+ if (!modelsNode || typeof modelsNode !== "object" || Array.isArray(modelsNode)) {
11976
+ return { ok: false, path: abs, warnings: [], error: "missing_models: codeforge.json must have a top-level `models` object" };
11939
11977
  }
11940
- if (entry.status !== "active") {
11941
- throw new Error(`mergeSessionBack: session ${opts.sessionId} 状态为 ${entry.status},无法 merge`);
11978
+ const v = validateConfig(modelsNode);
11979
+ if (!v.ok)
11980
+ return { ok: false, path: abs, warnings: [], error: v.error };
11981
+ return { ok: true, path: abs, config: v.config, warnings: v.warnings };
11982
+ }
11983
+ function validateConfig(input) {
11984
+ const warnings = [];
11985
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
11986
+ return { ok: false, warnings, error: "config_root_must_be_object" };
11942
11987
  }
11943
- const wt = entry.worktreePath;
11944
- const branch = entry.branch;
11945
- const baseSha = entry.baseSha;
11946
- const wtStatus = (await runGit2(wt, ["status", "--porcelain"])).trim();
11947
- if (wtStatus.length > 0) {
11948
- await runGit2(wt, ["add", "-A"]);
11949
- await runGit2(wt, ["commit", "-m", `session(${opts.sessionId}): auto-commit before merge`]);
11988
+ const obj = input;
11989
+ const agentsRaw = obj.agents;
11990
+ if (!agentsRaw || typeof agentsRaw !== "object" || Array.isArray(agentsRaw)) {
11991
+ return { ok: false, warnings, error: "agents_required_object" };
11950
11992
  }
11951
- const mainStatus = (await runGit2(mainRoot, ["status", "--porcelain"])).trim();
11952
- if (mainStatus.length > 0) {
11953
- throw new Error(`mergeSessionBack: 主仓 ${mainRoot} 有未提交改动,请先 commit/stash 后再 merge`);
11993
+ const agents = {};
11994
+ for (const [name, value] of Object.entries(agentsRaw)) {
11995
+ const r = normalizeAgent(name, value);
11996
+ if (!r.ok)
11997
+ return { ok: false, warnings, error: r.error };
11998
+ agents[name] = r.binding;
11954
11999
  }
11955
- try {
11956
- await runGit2(mainRoot, ["merge", "--squash", branch]);
11957
- } catch (err) {
11958
- await runGit2(mainRoot, ["reset", "--merge"]).catch(() => {
11959
- return runGit2(mainRoot, ["reset", "--hard", "HEAD"]).catch(() => {});
11960
- });
11961
- throw new Error(`mergeSessionBack: squash merge 失败(已 reset 主仓兜底): ${err.message}`);
12000
+ if (Object.keys(agents).length === 0) {
12001
+ return { ok: false, warnings, error: "agents_must_have_at_least_one_entry" };
11962
12002
  }
11963
- const buildScript = await getBuildScript(mainRoot);
11964
- if (buildScript) {
11965
- const stagedRaw = await runGit2(mainRoot, [
11966
- "diff",
11967
- "--cached",
11968
- "--name-only",
11969
- "--diff-filter=ACMR"
11970
- ]);
11971
- const stagedPaths = stagedRaw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
11972
- const canSkipDevOnce = await shouldSkipDevOnce(mainRoot, stagedPaths, wt);
11973
- if (canSkipDevOnce) {} else {
11974
- try {
11975
- await runCmd("npm", ["run", buildScript], mainRoot);
11976
- } catch (err) {
11977
- await runGit2(mainRoot, ["reset", "--hard", "HEAD"]).catch(() => {});
11978
- const msg = err instanceof Error ? err.message : String(err);
11979
- throw new Error(`${buildScript} 失败已 reset 主仓: ${msg}`);
11980
- }
12003
+ let categories;
12004
+ if (obj.categories !== undefined) {
12005
+ if (!obj.categories || typeof obj.categories !== "object" || Array.isArray(obj.categories)) {
12006
+ return { ok: false, warnings, error: "categories_must_be_object" };
11981
12007
  }
11982
- } else {}
11983
- const squashedRaw = await runGit2(wt, ["log", "--format=%s", `${baseSha}..HEAD`]);
11984
- const squashedCommits = squashedRaw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
11985
- const message = opts.commitMessage ?? buildMergeMessage(opts.sessionId, branch, baseSha, squashedCommits);
11986
- await runGitWithEnv(mainRoot, ["commit", "-m", message], {
11987
- SKIP_DEV_SYNC_CHECK: "1"
11988
- });
11989
- const newSha = (await runGit2(mainRoot, ["rev-parse", "HEAD"])).trim();
11990
- try {
11991
- await removeWorktree({ root: mainRoot, worktree_path: wt, force: true });
11992
- } catch (err) {
11993
- debugLog(`removeWorktree (merge) 非预期失败 (session=${opts.sessionId}): ${err.message}`);
11994
- }
11995
- await deleteBranchIfExists({ root: mainRoot, branch }).catch((err) => {
11996
- debugLog(`deleteBranchIfExists (merge) 非预期失败: ${err.message}`);
11997
- return { deleted: false };
11998
- });
11999
- await mutateRegistry(mainRoot, (reg) => {
12000
- const e = reg.entries.find((x) => x.sessionId === opts.sessionId);
12001
- if (e) {
12002
- e.status = "merged";
12003
- e.updatedAt = new Date().toISOString();
12008
+ categories = {};
12009
+ for (const [name, value] of Object.entries(obj.categories)) {
12010
+ const r = normalizeCategory(name, value);
12011
+ if (!r.ok)
12012
+ return { ok: false, warnings, error: r.error };
12013
+ categories[name] = r.binding;
12004
12014
  }
12005
- });
12006
- if (opts.planStore && entry.requiredPlanId) {
12007
- await opts.planStore.markMerged(entry.requiredPlanId, opts.sessionId).catch((err) => {
12008
- console.warn(`[session-worktree] planStore.markMerged(${entry.requiredPlanId}) 失败: ${err instanceof Error ? err.message : String(err)}`);
12009
- });
12010
12015
  }
12011
- return { sha: newSha, squashedCommits };
12012
- }
12013
- async function discardSession(opts) {
12014
- const mainRoot = path13.resolve(opts.mainRoot);
12015
- const entry = await getSessionWorktree(opts.sessionId, mainRoot);
12016
- if (!entry) {
12017
- return;
12016
+ let runtime;
12017
+ if (obj.runtime_fallback !== undefined) {
12018
+ const r = normalizeRuntime(obj.runtime_fallback);
12019
+ if (!r.ok)
12020
+ return { ok: false, warnings, error: r.error };
12021
+ runtime = r.cfg;
12018
12022
  }
12019
- if (entry.status === "merged" || entry.status === "discarded") {
12020
- return;
12023
+ let tiers;
12024
+ if (obj.tiers !== undefined) {
12025
+ const r = normalizeTiers(obj.tiers);
12026
+ if (!r.ok)
12027
+ return { ok: false, warnings, error: r.error };
12028
+ tiers = r.cfg;
12021
12029
  }
12022
- try {
12023
- await removeWorktree({
12024
- root: mainRoot,
12025
- worktree_path: entry.worktreePath,
12026
- force: true
12027
- });
12028
- } catch (err) {
12029
- debugLog(`removeWorktree (discard) 非预期失败: ${err.message}`);
12030
+ for (const [name, agent] of Object.entries(agents)) {
12031
+ if (agent.category && !categories?.[agent.category]) {
12032
+ warnings.push(`agent[${name}].category="${agent.category}" 未在 categories 定义,将忽略 category 链`);
12033
+ }
12030
12034
  }
12031
- await deleteBranchIfExists({ root: mainRoot, branch: entry.branch }).catch((err) => {
12032
- debugLog(`deleteBranchIfExists (discard) 非预期失败: ${err.message}`);
12033
- return { deleted: false };
12034
- });
12035
- await mutateRegistry(mainRoot, (reg) => {
12036
- const e = reg.entries.find((x) => x.sessionId === opts.sessionId);
12037
- if (e) {
12038
- e.status = "discarded";
12039
- e.updatedAt = new Date().toISOString();
12035
+ for (const [name, agent] of Object.entries(agents)) {
12036
+ if (!agent.tier)
12037
+ continue;
12038
+ const mappedCat = tiers?.category_map?.[agent.tier];
12039
+ const hasOverride = agent.tier_overrides?.[agent.tier] !== undefined;
12040
+ if (!mappedCat && !hasOverride) {
12041
+ warnings.push(`agent[${name}].tier="${agent.tier}" 既无 models.tiers.category_map["${agent.tier}"] 映射,` + `也无 tier_overrides["${agent.tier}"];该 agent 将不参与 tier 体系(adapter 返 null)。`);
12042
+ } else if (mappedCat && !categories?.[mappedCat] && !hasOverride) {
12043
+ warnings.push(`agent[${name}].tier="${agent.tier}" 通过 category_map 映射到 "${mappedCat}",` + `但 categories.${mappedCat} 不存在;该 agent 将不参与 tier 体系。`);
12040
12044
  }
12041
- });
12042
- if (opts.planStore && entry.requiredPlanId) {
12043
- await opts.planStore.markDiscarded(entry.requiredPlanId, opts.sessionId).catch((err) => {
12044
- console.warn(`[session-worktree] planStore.markDiscarded(${entry.requiredPlanId}) 失败: ${err instanceof Error ? err.message : String(err)}`);
12045
- });
12046
12045
  }
12047
- }
12048
- async function markInterruptedDirty(opts) {
12049
- await mutateRegistry(opts.mainRoot, (reg) => {
12050
- const e = reg.entries.find((x) => x.sessionId === opts.sessionId);
12051
- if (e && e.status === "active") {
12052
- e.status = "interrupted_dirty";
12053
- e.updatedAt = new Date().toISOString();
12046
+ return {
12047
+ ok: true,
12048
+ warnings,
12049
+ config: {
12050
+ $schema: typeof obj.$schema === "string" ? obj.$schema : undefined,
12051
+ _doc: typeof obj._doc === "string" ? obj._doc : undefined,
12052
+ agents,
12053
+ categories,
12054
+ runtime_fallback: runtime,
12055
+ tiers
12054
12056
  }
12055
- });
12057
+ };
12056
12058
  }
12057
- async function getCurrentWorktreeHead(worktreePath) {
12058
- try {
12059
- return (await runGit2(path13.resolve(worktreePath), ["rev-parse", "HEAD"])).trim();
12060
- } catch {
12061
- return "";
12059
+ function normalizeAgent(name, raw) {
12060
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
12061
+ return { ok: false, error: `agent[${name}]: must be object` };
12062
12062
  }
12063
- }
12064
- async function isWorktreeDirty(worktreePath) {
12065
- try {
12066
- const out = (await runGit2(worktreePath, ["status", "--porcelain"])).trim();
12067
- return out.length > 0;
12068
- } catch {
12069
- return false;
12063
+ const o = raw;
12064
+ if (typeof o.model !== "string" || !PROVIDER_MODEL_RE.test(o.model)) {
12065
+ return {
12066
+ ok: false,
12067
+ error: `agent[${name}].model invalid: "${String(o.model)}" (expect <provider>/<id>)`
12068
+ };
12070
12069
  }
12071
- }
12072
- async function checkpointCommit(opts) {
12073
- await runGit2(opts.worktreePath, ["add", "-A"]);
12074
- await runGit2(opts.worktreePath, ["commit", "-m", opts.message]);
12075
- }
12076
- function runGit2(cwd, args, timeoutMs = 1e4) {
12077
- return new Promise((resolve11, reject) => {
12078
- execFile3("git", args, { cwd, timeout: timeoutMs, windowsHide: true, encoding: "utf8" }, (err, stdout, stderr) => {
12079
- if (err) {
12080
- reject(new Error(`git ${args.join(" ")} (cwd=${cwd}) 失败: ${stderr?.trim() || err.message}`));
12081
- return;
12082
- }
12083
- resolve11(stdout);
12084
- });
12085
- });
12086
- }
12087
- function runGitWithEnv(cwd, args, envOverrides, timeoutMs = 1e4) {
12088
- const inheritedEnv = process["env"];
12089
- return new Promise((resolve11, reject) => {
12090
- execFile3("git", args, {
12091
- cwd,
12092
- timeout: timeoutMs,
12093
- windowsHide: true,
12094
- encoding: "utf8",
12095
- env: Object.assign({}, inheritedEnv, envOverrides)
12096
- }, (err, stdout, stderr) => {
12097
- if (err) {
12098
- reject(new Error(`git ${args.join(" ")} (cwd=${cwd}) 失败: ${stderr?.trim() || err.message}`));
12099
- return;
12100
- }
12101
- resolve11(stdout);
12102
- });
12103
- });
12104
- }
12105
- async function getBuildScript(mainRoot) {
12106
- const cfg = getCodeforgeConfig({ root: mainRoot });
12107
- const merge = cfg["merge"];
12108
- if (!merge || typeof merge !== "object" || Array.isArray(merge))
12109
- return null;
12110
- const script = merge["postMergeScript"];
12111
- if (typeof script !== "string")
12112
- return null;
12113
- const trimmed = script.trim();
12114
- return trimmed.length > 0 ? trimmed : null;
12115
- }
12116
- async function shouldSkipDevOnce(mainRoot, stagedPaths, worktreePath) {
12117
- let distMtimeSec;
12118
- try {
12119
- const st = await fs10.stat(path13.join(mainRoot, "dist/index.js"));
12120
- distMtimeSec = Math.floor(st.mtimeMs / 1000);
12121
- } catch {
12122
- return false;
12070
+ const fallbacks = normalizeFallbackList(`agent[${name}].fallback_models`, o.fallback_models);
12071
+ if (!fallbacks.ok)
12072
+ return { ok: false, error: fallbacks.error };
12073
+ const thinking = o.thinking !== undefined ? normalizeThinking(`agent[${name}]`, o.thinking) : undefined;
12074
+ if (thinking && !thinking.ok)
12075
+ return { ok: false, error: thinking.error };
12076
+ let tier;
12077
+ if (o.tier !== undefined) {
12078
+ if (typeof o.tier !== "string" || !isValidTierLevel(o.tier)) {
12079
+ return {
12080
+ ok: false,
12081
+ error: `agent[${name}].tier="${String(o.tier)}" 不是合法 TierLevel (期望 ${TIER_ORDER.join("/")})`
12082
+ };
12083
+ }
12084
+ tier = o.tier;
12123
12085
  }
12124
- const relevant = stagedPaths.filter((p) => /^(plugins|lib|src)\//.test(p) && !/\.(md|test\.ts)$/.test(p));
12125
- if (relevant.length === 0)
12126
- return true;
12127
- for (const rel of relevant) {
12128
- const srcMtimeSec = await statSourceMtime(rel, mainRoot, worktreePath);
12129
- if (srcMtimeSec === null)
12130
- return false;
12131
- if (srcMtimeSec > distMtimeSec)
12132
- return false;
12086
+ let tierOverrides;
12087
+ if (o.tier_overrides !== undefined) {
12088
+ const r = normalizeTierOverrides(name, o.tier_overrides);
12089
+ if (!r.ok)
12090
+ return { ok: false, error: r.error };
12091
+ tierOverrides = r.value;
12133
12092
  }
12134
- return true;
12093
+ return {
12094
+ ok: true,
12095
+ binding: {
12096
+ model: o.model,
12097
+ variant: typeof o.variant === "string" ? o.variant : undefined,
12098
+ category: typeof o.category === "string" ? o.category : undefined,
12099
+ thinking: thinking?.value,
12100
+ fallback_models: fallbacks.value,
12101
+ tier,
12102
+ tier_overrides: tierOverrides,
12103
+ _doc: typeof o._doc === "string" ? o._doc : undefined
12104
+ }
12105
+ };
12135
12106
  }
12136
- async function statSourceMtime(rel, mainRoot, worktreePath) {
12137
- if (worktreePath) {
12138
- try {
12139
- const st = await fs10.stat(path13.join(worktreePath, rel));
12140
- return Math.floor(st.mtimeMs / 1000);
12141
- } catch {}
12107
+ function normalizeCategory(name, raw) {
12108
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
12109
+ return { ok: false, error: `category[${name}]: must be object` };
12142
12110
  }
12143
- try {
12144
- const st = await fs10.stat(path13.join(mainRoot, rel));
12145
- return Math.floor(st.mtimeMs / 1000);
12146
- } catch {
12147
- return null;
12111
+ const o = raw;
12112
+ if (typeof o.model !== "string" || !PROVIDER_MODEL_RE.test(o.model)) {
12113
+ return {
12114
+ ok: false,
12115
+ error: `category[${name}].model invalid: "${String(o.model)}"`
12116
+ };
12148
12117
  }
12118
+ const fallbacks = normalizeFallbackList(`category[${name}].fallback_models`, o.fallback_models);
12119
+ if (!fallbacks.ok)
12120
+ return { ok: false, error: fallbacks.error };
12121
+ const thinking = o.thinking !== undefined ? normalizeThinking(`category[${name}]`, o.thinking) : undefined;
12122
+ if (thinking && !thinking.ok)
12123
+ return { ok: false, error: thinking.error };
12124
+ return {
12125
+ ok: true,
12126
+ binding: {
12127
+ model: o.model,
12128
+ variant: typeof o.variant === "string" ? o.variant : undefined,
12129
+ thinking: thinking?.value,
12130
+ fallback_models: fallbacks.value,
12131
+ _doc: typeof o._doc === "string" ? o._doc : undefined
12132
+ }
12133
+ };
12149
12134
  }
12150
- function runCmd(cmd, args, cwd, timeoutMs = 300000) {
12151
- return new Promise((resolve11, reject) => {
12152
- execFile3(cmd, args, { cwd, timeout: timeoutMs, windowsHide: true, encoding: "utf8" }, (err, stdout, stderr) => {
12153
- if (err) {
12154
- reject(new Error(`${cmd} ${args.join(" ")} (cwd=${cwd}) 失败: ${stderr?.trim() || err.message}`));
12155
- return;
12156
- }
12157
- resolve11(stdout);
12158
- });
12159
- });
12135
+ function normalizeFallbackList(ctx, raw) {
12136
+ if (raw === undefined)
12137
+ return { ok: true, value: [] };
12138
+ if (!Array.isArray(raw))
12139
+ return { ok: false, error: `${ctx}: must be array` };
12140
+ const seen = new Set;
12141
+ const result = [];
12142
+ for (const it of raw) {
12143
+ if (typeof it !== "string" || !PROVIDER_MODEL_RE.test(it)) {
12144
+ return { ok: false, error: `${ctx}: invalid entry "${String(it)}"` };
12145
+ }
12146
+ if (seen.has(it))
12147
+ continue;
12148
+ seen.add(it);
12149
+ result.push(it);
12150
+ }
12151
+ return { ok: true, value: result };
12160
12152
  }
12161
- function buildMergeMessage(sessionId, branch, baseSha, squashed) {
12162
- const subject = `session(${sessionId}): merge ${branch}`;
12163
- const body = squashed.length > 0 ? `
12164
-
12165
- Squashed commits:
12166
- ${squashed.map((s) => ` - ${s}`).join(`
12167
- `)}` : "";
12168
- const footer = `
12169
-
12170
- Codeforge-Session: ${sessionId}
12171
- Codeforge-Base: ${baseSha.slice(0, 12)}`;
12172
- return subject + body + footer;
12153
+ function normalizeThinking(ctx, raw) {
12154
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
12155
+ return { ok: false, error: `${ctx}.thinking: must be object` };
12156
+ }
12157
+ const o = raw;
12158
+ if (o.type !== "enabled" && o.type !== "disabled") {
12159
+ return { ok: false, error: `${ctx}.thinking.type: must be "enabled" or "disabled"` };
12160
+ }
12161
+ if (o.budget_tokens !== undefined && (typeof o.budget_tokens !== "number" || o.budget_tokens < 0)) {
12162
+ return { ok: false, error: `${ctx}.thinking.budget_tokens: must be non-negative number` };
12163
+ }
12164
+ return { ok: true, value: { ...o, type: o.type } };
12173
12165
  }
12174
- var ORPHAN_GRACE_MS = 60000;
12175
- var SEMANTIC_ORPHAN_MIN_AGE_MS = 6 * 60 * 60000;
12176
- var SEMANTIC_ORPHAN_UNKNOWN_TIMEOUT_MS = 72 * 60 * 60000;
12177
- async function pruneDiscardedRegistryEntries(mainRoot, opts = {}) {
12178
- const keepRecent = opts.keepRecent ?? 50;
12179
- if (keepRecent < 0) {
12180
- throw new Error(`pruneDiscardedRegistryEntries: keepRecent 必须 ≥ 0,收到 ${keepRecent}`);
12166
+ function normalizeRuntime(raw) {
12167
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
12168
+ return { ok: false, error: "runtime_fallback: must be object" };
12181
12169
  }
12182
- return await mutateRegistry(path13.resolve(mainRoot), (reg) => {
12183
- const discarded = [];
12184
- const others = [];
12185
- for (const e of reg.entries) {
12186
- if (e.status === "discarded")
12187
- discarded.push(e);
12188
- else
12189
- others.push(e);
12170
+ const o = raw;
12171
+ const cfg = { ...DEFAULT_RUNTIME_FALLBACK };
12172
+ if (o.enabled !== undefined) {
12173
+ if (typeof o.enabled !== "boolean")
12174
+ return { ok: false, error: "runtime_fallback.enabled: boolean" };
12175
+ cfg.enabled = o.enabled;
12176
+ }
12177
+ if (o.max_fallback_attempts !== undefined) {
12178
+ if (typeof o.max_fallback_attempts !== "number" || o.max_fallback_attempts < 1) {
12179
+ return { ok: false, error: "runtime_fallback.max_fallback_attempts: integer >=1" };
12190
12180
  }
12191
- discarded.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0);
12192
- const kept = discarded.slice(0, keepRecent);
12193
- const pruned = discarded.length - kept.length;
12194
- reg.entries = [...others, ...kept];
12195
- return { pruned, kept: kept.length };
12196
- });
12197
- }
12198
- async function pruneOrphanWorktrees(mainRoot, opts = {}) {
12199
- const resolved = path13.resolve(mainRoot);
12200
- const cleaned = [];
12201
- const failed = [];
12202
- let skipped = 0;
12203
- let gitWorktrees = [];
12204
- try {
12205
- const mod = await Promise.resolve().then(() => (init_worktree_ops(), exports_worktree_ops));
12206
- gitWorktrees = await mod.listWorktrees({ root: resolved });
12207
- } catch {
12208
- gitWorktrees = [];
12181
+ cfg.max_fallback_attempts = Math.floor(o.max_fallback_attempts);
12209
12182
  }
12210
- const gitWorktreePaths = new Set(gitWorktrees.map((w) => path13.resolve(w.path)));
12211
- await mutateRegistry(resolved, async (reg2) => {
12212
- const now = Date.now();
12213
- for (const entry of reg2.entries) {
12214
- if (entry.status !== "active")
12215
- continue;
12216
- const wt = entry.worktreePath;
12217
- let dirExists = true;
12218
- let dirMtimeMs = 0;
12219
- try {
12220
- const st = await fs10.stat(wt);
12221
- dirExists = st.isDirectory();
12222
- dirMtimeMs = st.mtimeMs;
12223
- } catch {
12224
- dirExists = false;
12225
- }
12226
- if (dirExists && now - dirMtimeMs < ORPHAN_GRACE_MS) {
12227
- skipped++;
12228
- continue;
12229
- }
12230
- let branchExists = true;
12231
- try {
12232
- await runGit2(resolved, ["rev-parse", "--verify", `refs/heads/${entry.branch}`]);
12233
- } catch {
12234
- branchExists = false;
12235
- }
12236
- if (!dirExists || !branchExists) {
12237
- const reason = !dirExists ? "worktreePath 不存在" : "branch 不存在";
12238
- entry.status = "discarded";
12239
- entry.updatedAt = new Date().toISOString();
12240
- if (dirExists) {
12241
- try {
12242
- await removeWorktree({
12243
- root: resolved,
12244
- worktree_path: wt,
12245
- force: true
12246
- });
12247
- } catch (err) {
12248
- failed.push({
12249
- worktreePath: wt,
12250
- error: err instanceof Error ? err.message : String(err)
12251
- });
12252
- }
12253
- }
12254
- cleaned.push({ sessionId: entry.sessionId, worktreePath: wt, reason });
12255
- }
12183
+ if (o.notify_on_fallback !== undefined) {
12184
+ if (typeof o.notify_on_fallback !== "boolean") {
12185
+ return { ok: false, error: "runtime_fallback.notify_on_fallback: boolean" };
12256
12186
  }
12257
- });
12258
- if (opts.isSessionAlive) {
12259
- const minAge = opts.semanticOrphanMinAgeMs ?? SEMANTIC_ORPHAN_MIN_AGE_MS;
12260
- const unknownTimeout = opts.semanticOrphanUnknownTimeoutMs ?? SEMANTIC_ORPHAN_UNKNOWN_TIMEOUT_MS;
12261
- const probe = opts.isSessionAlive;
12262
- await mutateRegistry(resolved, async (reg2) => {
12263
- const now = Date.now();
12264
- for (const entry of reg2.entries) {
12265
- if (entry.status !== "active")
12266
- continue;
12267
- const updatedMs = Date.parse(entry.updatedAt);
12268
- if (!Number.isFinite(updatedMs) || now - updatedMs < minAge) {
12269
- skipped++;
12270
- continue;
12271
- }
12272
- let aliveResult;
12273
- try {
12274
- aliveResult = await probe(entry.sessionId);
12275
- } catch {
12276
- skipped++;
12277
- continue;
12278
- }
12279
- if (aliveResult.source === "unknown") {
12280
- if (now - updatedMs < unknownTimeout) {
12281
- skipped++;
12282
- continue;
12283
- }
12284
- } else if (aliveResult.alive) {
12285
- skipped++;
12286
- continue;
12287
- }
12288
- try {
12289
- await removeWorktree({
12290
- root: resolved,
12291
- worktree_path: entry.worktreePath,
12292
- force: true
12293
- });
12294
- } catch (err) {
12295
- failed.push({
12296
- worktreePath: entry.worktreePath,
12297
- error: `D 类 removeWorktree 失败: ${err instanceof Error ? err.message : String(err)}`
12298
- });
12299
- continue;
12300
- }
12301
- await deleteBranchIfExists({ root: resolved, branch: entry.branch }).catch(() => {});
12302
- entry.status = "discarded";
12303
- entry.updatedAt = new Date().toISOString();
12304
- const reasonSource = aliveResult.source === "unknown" ? `unknown-timeout (registry.updatedAt 已老于 ${unknownTimeout / 3600000}h)` : `opencode session ${aliveResult.source}: dead`;
12305
- cleaned.push({
12306
- sessionId: entry.sessionId,
12307
- worktreePath: entry.worktreePath,
12308
- reason: `D 类语义孤儿 (${reasonSource})`
12309
- });
12310
- }
12311
- });
12187
+ cfg.notify_on_fallback = o.notify_on_fallback;
12188
+ }
12189
+ if (o.trigger_events !== undefined) {
12190
+ if (!Array.isArray(o.trigger_events) || o.trigger_events.some((x) => typeof x !== "string")) {
12191
+ return { ok: false, error: "runtime_fallback.trigger_events: string[]" };
12192
+ }
12193
+ cfg.trigger_events = [...new Set(o.trigger_events)];
12194
+ }
12195
+ if (typeof o._doc === "string")
12196
+ cfg._doc = o._doc;
12197
+ return { ok: true, cfg };
12198
+ }
12199
+ function isValidTierLevel(x) {
12200
+ return typeof x === "string" && TIER_ORDER.includes(x);
12201
+ }
12202
+ function normalizeTierOverrides(agentName, raw) {
12203
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
12204
+ return {
12205
+ ok: false,
12206
+ error: `agent[${agentName}].tier_overrides 必须是 object(不是 array / null / 其他类型)`
12207
+ };
12312
12208
  }
12313
- const codeforgeWorktreeRoot = path13.resolve(path13.join(resolved, DEFAULT_WORKTREE_SUBDIR));
12314
- const fsWorktreePaths = [];
12315
- try {
12316
- const names = await fs10.readdir(codeforgeWorktreeRoot);
12317
- for (const name of names) {
12318
- fsWorktreePaths.push(path13.resolve(path13.join(codeforgeWorktreeRoot, name)));
12209
+ const o = raw;
12210
+ const result = {};
12211
+ for (const [key, value] of Object.entries(o)) {
12212
+ if (!isValidTierLevel(key)) {
12213
+ return {
12214
+ ok: false,
12215
+ error: `agent[${agentName}].tier_overrides.${key}: 非法 TierLevel key (期望 ${TIER_ORDER.join("/")})`
12216
+ };
12319
12217
  }
12320
- } catch {}
12321
- const candidatePaths = new Set([
12322
- ...gitWorktreePaths,
12323
- ...fsWorktreePaths
12324
- ]);
12325
- const reg = await readRegistry(resolved);
12326
- const knownPaths = new Set(reg.entries.map((e) => path13.resolve(e.worktreePath)));
12327
- for (const candidate of candidatePaths) {
12328
- if (knownPaths.has(candidate))
12329
- continue;
12330
- if (candidate !== codeforgeWorktreeRoot && !candidate.startsWith(codeforgeWorktreeRoot + path13.sep)) {
12331
- continue;
12218
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
12219
+ return {
12220
+ ok: false,
12221
+ error: `agent[${agentName}].tier_overrides.${key}: 必须是 object`
12222
+ };
12332
12223
  }
12333
- let dirExists = true;
12334
- try {
12335
- const st = await fs10.stat(candidate);
12336
- if (Date.now() - st.mtimeMs < ORPHAN_GRACE_MS) {
12337
- skipped++;
12338
- continue;
12224
+ const ov = value;
12225
+ const partial = {};
12226
+ if (ov.level !== undefined) {
12227
+ if (ov.level !== key) {
12228
+ return {
12229
+ ok: false,
12230
+ error: `agent[${agentName}].tier_overrides.${key}.level="${String(ov.level)}" 与 key "${key}" 错配`
12231
+ };
12339
12232
  }
12340
- } catch {
12341
- dirExists = false;
12233
+ partial.level = key;
12342
12234
  }
12343
- let removed = false;
12344
- let lastError = null;
12345
- try {
12346
- await removeWorktree({
12347
- root: resolved,
12348
- worktree_path: candidate,
12349
- force: true
12350
- });
12351
- removed = true;
12352
- } catch (err) {
12353
- lastError = err instanceof Error ? err.message : String(err);
12235
+ if (ov.model !== undefined) {
12236
+ if (typeof ov.model !== "string" || !PROVIDER_MODEL_RE.test(ov.model)) {
12237
+ return {
12238
+ ok: false,
12239
+ error: `agent[${agentName}].tier_overrides.${key}.model="${String(ov.model)}" 格式非法 (期望 <provider>/<id>)`
12240
+ };
12241
+ }
12242
+ partial.model = ov.model;
12354
12243
  }
12355
- if (removed) {
12356
- try {
12357
- await fs10.stat(candidate);
12358
- removed = false;
12359
- lastError = lastError ?? "git worktree remove 返回成功但目录仍存在(C 类 fs-only orphan)";
12360
- } catch {}
12244
+ if (ov.thinking !== undefined) {
12245
+ const t = normalizeThinking(`agent[${agentName}].tier_overrides.${key}`, ov.thinking);
12246
+ if (!t.ok)
12247
+ return { ok: false, error: t.error };
12248
+ partial.thinking = t.value;
12361
12249
  }
12362
- if (!removed && dirExists) {
12363
- try {
12364
- await fs10.rm(candidate, { recursive: true, force: true });
12365
- removed = true;
12366
- } catch (err) {
12367
- lastError = `git remove 失败: ${lastError}; fs.rm 也失败: ${err instanceof Error ? err.message : String(err)}`;
12250
+ if (ov.fallback_models !== undefined) {
12251
+ const f = normalizeFallbackList(`agent[${agentName}].tier_overrides.${key}.fallback_models`, ov.fallback_models);
12252
+ if (!f.ok)
12253
+ return { ok: false, error: f.error };
12254
+ partial.fallback_models = f.value;
12255
+ }
12256
+ result[key] = partial;
12257
+ }
12258
+ return { ok: true, value: result };
12259
+ }
12260
+ function normalizeTiers(raw) {
12261
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
12262
+ return { ok: false, error: "models.tiers 必须是 object(不是 array / null)" };
12263
+ }
12264
+ const o = raw;
12265
+ const cfg = {};
12266
+ if (o.category_map !== undefined) {
12267
+ if (!o.category_map || typeof o.category_map !== "object" || Array.isArray(o.category_map)) {
12268
+ return { ok: false, error: "models.tiers.category_map 必须是 object" };
12269
+ }
12270
+ const map = {};
12271
+ for (const [key, value] of Object.entries(o.category_map)) {
12272
+ if (!isValidTierLevel(key)) {
12273
+ return {
12274
+ ok: false,
12275
+ error: `models.tiers.category_map.${key}: 非法 TierLevel key (期望 ${TIER_ORDER.join("/")})`
12276
+ };
12277
+ }
12278
+ if (typeof value !== "string" || value.length === 0) {
12279
+ return {
12280
+ ok: false,
12281
+ error: `models.tiers.category_map.${key}: 必须是非空字符串(category 名)`
12282
+ };
12368
12283
  }
12284
+ map[key] = value;
12369
12285
  }
12370
- if (removed) {
12371
- const reason = gitWorktreePaths.has(candidate) ? "fs-only orphan (git 知道但 registry 无 entry)" : "fs-only orphan (git 不知道的纯 fs 残留)";
12372
- cleaned.push({ worktreePath: candidate, reason });
12373
- } else {
12374
- failed.push({ worktreePath: candidate, error: lastError ?? "unknown" });
12286
+ cfg.category_map = map;
12287
+ }
12288
+ if (typeof o._doc === "string")
12289
+ cfg._doc = o._doc;
12290
+ return { ok: true, cfg };
12291
+ }
12292
+ function resolveAgentModel(config, agent) {
12293
+ const a = config.agents[agent];
12294
+ if (!a)
12295
+ return null;
12296
+ const seen = new Set;
12297
+ const chain = [];
12298
+ const push = (m) => {
12299
+ if (!m || seen.has(m))
12300
+ return;
12301
+ seen.add(m);
12302
+ chain.push(m);
12303
+ };
12304
+ push(a.model);
12305
+ for (const m of a.fallback_models ?? [])
12306
+ push(m);
12307
+ let categoryName;
12308
+ let usedCategory = false;
12309
+ if (a.category && config.categories?.[a.category]) {
12310
+ categoryName = a.category;
12311
+ const c = config.categories[a.category];
12312
+ const beforeLen = chain.length;
12313
+ push(c.model);
12314
+ for (const m of c.fallback_models ?? [])
12315
+ push(m);
12316
+ usedCategory = chain.length > beforeLen;
12317
+ }
12318
+ const source = a.fallback_models && a.fallback_models.length > 0 ? usedCategory ? "merged" : "agent" : usedCategory ? "category" : "agent";
12319
+ return {
12320
+ agent,
12321
+ model: a.model,
12322
+ variant: a.variant,
12323
+ thinking: a.thinking,
12324
+ chain,
12325
+ source,
12326
+ category: categoryName
12327
+ };
12328
+ }
12329
+ function nextFallback(config, agent, current) {
12330
+ const r = resolveAgentModel(config, agent);
12331
+ if (!r)
12332
+ return null;
12333
+ const idx = r.chain.indexOf(current);
12334
+ if (idx < 0)
12335
+ return r.chain[0] ?? null;
12336
+ return r.chain[idx + 1] ?? null;
12337
+ }
12338
+ function listAllAgentChains(config) {
12339
+ return Object.keys(config.agents).sort().map((name) => resolveAgentModel(config, name)).filter((x) => x !== null);
12340
+ }
12341
+
12342
+ // tools/model-chain.ts
12343
+ var description11 = [
12344
+ "查询 CodeForge 配置文件 codeforge.json:每个 agent 的主模型 + fallback 链。",
12345
+ "**何时调用**:",
12346
+ "- 用户问「coder 用什么模型 / 备用是什么」",
12347
+ "- 当前模型不可用时,决定下一个该试哪个",
12348
+ "- 审计:把整个项目的模型配置一次性列给用户看",
12349
+ "返回 JSON:{ ok, agents: [...], current_chain?, next_fallback? }"
12350
+ ].join(`
12351
+ `);
12352
+ var ArgsSchema11 = z11.object({
12353
+ agent: z11.string().optional().describe("查指定 agent;不传 → 列出全部"),
12354
+ current: z11.string().optional().describe("当前已用过的模型(<provider>/<id>),用于算下一档"),
12355
+ root: z11.string().optional().describe("项目根目录,默认 process.cwd()"),
12356
+ config_file: z11.string().optional().describe("配置文件名;默认 codeforge.json")
12357
+ });
12358
+ async function execute11(rawArgs) {
12359
+ const parsed = ArgsSchema11.safeParse(rawArgs ?? {});
12360
+ if (!parsed.success) {
12361
+ return {
12362
+ ok: false,
12363
+ error: "invalid_args: " + parsed.error.issues.map((i) => `${i.path.map(String).join(".")}: ${i.message}`).join("; ")
12364
+ };
12365
+ }
12366
+ const args = parsed.data;
12367
+ const loadResult = await loadModelConfig({
12368
+ root: args.root,
12369
+ file: args.config_file
12370
+ });
12371
+ if (!loadResult.ok || !loadResult.config) {
12372
+ return {
12373
+ ok: false,
12374
+ error: loadResult.error ?? "load_failed",
12375
+ config_path: loadResult.path
12376
+ };
12377
+ }
12378
+ const cfg = loadResult.config;
12379
+ const allChains = listAllAgentChains(cfg).map(toEntry);
12380
+ const out = {
12381
+ ok: true,
12382
+ config_path: loadResult.path ?? "",
12383
+ warnings: loadResult.warnings,
12384
+ agents: allChains
12385
+ };
12386
+ if (args.agent) {
12387
+ const r = resolveAgentModel(cfg, args.agent);
12388
+ if (!r) {
12389
+ return {
12390
+ ok: false,
12391
+ error: `agent_not_found: "${args.agent}" 未在 codeforge.json 配置`,
12392
+ config_path: loadResult.path
12393
+ };
12394
+ }
12395
+ out.current_chain = toEntry(r);
12396
+ if (args.current) {
12397
+ out.next_fallback = nextFallback(cfg, args.agent, args.current);
12375
12398
  }
12376
12399
  }
12377
- let discardedPruned = 0;
12378
- try {
12379
- const r = await pruneDiscardedRegistryEntries(resolved);
12380
- discardedPruned = r.pruned;
12381
- } catch {}
12382
- let gitAdminPruned = 0;
12383
- try {
12384
- const out = await runGit2(resolved, ["worktree", "prune", "--verbose"]);
12385
- gitAdminPruned = out.split(`
12386
- `).filter((l) => /\bRemoving\b/i.test(l)).length;
12387
- } catch {}
12388
- return { cleaned, failed, skipped, discardedPruned, gitAdminPruned };
12400
+ return out;
12401
+ }
12402
+ function toEntry(r) {
12403
+ return {
12404
+ agent: r.agent,
12405
+ model: r.model,
12406
+ category: r.category,
12407
+ source: r.source,
12408
+ chain: r.chain
12409
+ };
12389
12410
  }
12411
+ // tools/session-merge.ts
12412
+ import { z as z12 } from "zod";
12390
12413
 
12391
12414
  // lib/merge-gate.ts
12392
12415
  import { promises as fs11 } from "node:fs";
@@ -14545,7 +14568,7 @@ function buildReviewerPrompt(args) {
14545
14568
  ];
14546
14569
  if (args.planId)
14547
14570
  lines.push(`plan_id: ${args.planId}`);
14548
- lines.push(`round: ${args.round}/${args.maxRounds}`, "", "请按 reviewer.md 「worktree-session 审阅」模式执行:", "1. 若有 plan_id → 先 plan_read(plan_id=...) 拿方案", "2. 跑 `git -C <worktree_path> diff <base_sha>..HEAD` 看改动", '3. APPROVE 前必须先调 review_approval(verdict=APPROVE, pendingIds=["session:<session_id>"]) 写审批', "4. 输出 ## Decision 节,首行 APPROVE / REQUEST_CHANGES / BLOCK 之一");
14571
+ lines.push(`round: ${args.round}/${args.maxRounds}`, "", "请按 reviewer.md 「worktree-session 审阅」模式执行:", "1. 若有 plan_id → 先 plan_read(plan_id=...) 拿方案", "2. 跑 `git -C <worktree_path> diff <base_sha>..HEAD` 看改动", '3. APPROVE 前必须先调 review_approval(verdict=APPROVE, pendingIds=["session:<session_id>"], reviewTarget="code") 写审批', " (coveredSha 工具会自动从 worktree HEAD 捕获,无需手传)", "4. 输出 ## Decision 节,首行 APPROVE / REQUEST_CHANGES / BLOCK 之一");
14549
14572
  if (args.prevSummary) {
14550
14573
  lines.push("", "## 上一轮 reviewer 意见", "", args.prevSummary, "", "请确认 coder 是否已按意见修复");
14551
14574
  }
@@ -20143,35 +20166,21 @@ var toolPolicyServer = async (ctx) => {
20143
20166
  var handler19 = toolPolicyServer;
20144
20167
 
20145
20168
  // plugins/update-checker.ts
20146
- import { existsSync as existsSync6, rmSync } from "node:fs";
20147
- import { homedir as homedir9 } from "node:os";
20148
- import { join as join25 } from "node:path";
20169
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
20170
+ import { homedir as homedir8 } from "node:os";
20171
+ import { dirname as dirname15, join as join25 } from "node:path";
20149
20172
  import { spawnSync as spawnSync2 } from "node:child_process";
20150
20173
 
20151
20174
  // lib/update-checker-impl.ts
20152
- import { createHash as createHash4 } from "node:crypto";
20153
- import {
20154
- copyFileSync,
20155
- existsSync as existsSync5,
20156
- mkdirSync as mkdirSync3,
20157
- mkdtempSync,
20158
- readFileSync as readFileSync5,
20159
- readdirSync as readdirSync3,
20160
- renameSync,
20161
- statSync as statSync4,
20162
- unlinkSync,
20163
- writeFileSync as writeFileSync2
20164
- } from "node:fs";
20165
- import { homedir as homedir8, tmpdir } from "node:os";
20175
+ import { readFileSync as readFileSync5 } from "node:fs";
20166
20176
  import { dirname as dirname14, join as join24 } from "node:path";
20167
20177
  import { fileURLToPath as fileURLToPath2 } from "node:url";
20168
20178
  import * as https from "node:https";
20169
- import * as zlib from "node:zlib";
20170
20179
 
20171
20180
  // lib/version-injected.ts
20172
20181
  function getInjectedVersion() {
20173
20182
  try {
20174
- const v = "0.6.12";
20183
+ const v = "0.7.0";
20175
20184
  if (typeof v === "string" && /^\d+\.\d+\.\d+/.test(v)) {
20176
20185
  return v;
20177
20186
  }
@@ -20182,48 +20191,6 @@ function getInjectedVersion() {
20182
20191
  }
20183
20192
 
20184
20193
  // lib/update-checker-impl.ts
20185
- async function checkUpdateOnce(opts) {
20186
- const local = opts.localVersion ?? readLocalVersion();
20187
- const cacheFile = opts.cacheFile ?? defaultCacheFile();
20188
- const now = (opts.now ?? Date.now)();
20189
- if (!opts.forceFresh) {
20190
- const cached = readCache(cacheFile);
20191
- if (cached && cached.repo === opts.repo && now - cached.checkedAt < opts.intervalMs) {
20192
- return {
20193
- hasUpdate: cmpVersion(local, cached.remote) < 0,
20194
- local,
20195
- remote: cached.remote,
20196
- fromCache: true,
20197
- cacheAgeMs: now - cached.checkedAt
20198
- };
20199
- }
20200
- }
20201
- const fetcher = opts.fetcher ?? fetchLatestTagFromGitHub;
20202
- let remote = null;
20203
- let fetchErr;
20204
- try {
20205
- remote = await fetcher(opts.repo);
20206
- } catch (e) {
20207
- fetchErr = e instanceof Error ? e.message : String(e);
20208
- }
20209
- if (remote === null) {
20210
- writeCache(cacheFile, { checkedAt: now, remote: local, repo: opts.repo });
20211
- return {
20212
- hasUpdate: false,
20213
- local,
20214
- remote: "(no release)",
20215
- fromCache: false,
20216
- error: fetchErr ?? "no_release"
20217
- };
20218
- }
20219
- writeCache(cacheFile, { checkedAt: now, remote, repo: opts.repo });
20220
- return {
20221
- hasUpdate: cmpVersion(local, remote) < 0,
20222
- local,
20223
- remote,
20224
- fromCache: false
20225
- };
20226
- }
20227
20194
  function cmpVersion(a, b) {
20228
20195
  const pa = parseSemver(a);
20229
20196
  const pb = parseSemver(b);
@@ -20267,97 +20234,6 @@ function readLocalVersion() {
20267
20234
  return "0.0.0";
20268
20235
  }
20269
20236
  }
20270
- function defaultCacheDir() {
20271
- return process.env["CODEFORGE_CACHE_DIR"] ?? join24(homedir8(), ".cache", "codeforge");
20272
- }
20273
- function defaultCacheFile() {
20274
- return join24(defaultCacheDir(), "update-check.json");
20275
- }
20276
- function readCache(file) {
20277
- try {
20278
- if (!existsSync5(file))
20279
- return null;
20280
- const raw = readFileSync5(file, "utf8");
20281
- const obj = JSON.parse(raw);
20282
- if (obj && typeof obj === "object" && typeof obj.checkedAt === "number" && typeof obj.remote === "string" && typeof obj.repo === "string") {
20283
- return obj;
20284
- }
20285
- return null;
20286
- } catch {
20287
- return null;
20288
- }
20289
- }
20290
- function writeCache(file, entry) {
20291
- try {
20292
- mkdirSync3(dirname14(file), { recursive: true });
20293
- writeFileSync2(file, JSON.stringify(entry, null, 2), "utf8");
20294
- } catch {}
20295
- }
20296
- var GITHUB_API_HOST = "api.github.com";
20297
- var MAX_REDIRECTS = 5;
20298
- function fetchLatestTagFromGitHub(repo) {
20299
- return getJsonWithRedirect(`https://${GITHUB_API_HOST}/repos/${repo}/releases/latest`, MAX_REDIRECTS).then((body) => {
20300
- if (body === null)
20301
- return null;
20302
- try {
20303
- const json = JSON.parse(body);
20304
- return typeof json.tag_name === "string" && json.tag_name.length > 0 ? json.tag_name : null;
20305
- } catch (e) {
20306
- throw e instanceof Error ? e : new Error(String(e));
20307
- }
20308
- });
20309
- }
20310
- function getJsonWithRedirect(url2, hopsLeft) {
20311
- return new Promise((resolve17, reject) => {
20312
- const u = new URL(url2);
20313
- const headers = {
20314
- "User-Agent": "codeforge-update-checker",
20315
- Accept: "application/vnd.github+json"
20316
- };
20317
- if (process.env["GITHUB_TOKEN"]) {
20318
- headers["Authorization"] = `Bearer ${process.env["GITHUB_TOKEN"]}`;
20319
- }
20320
- const req = https.request({
20321
- host: u.hostname,
20322
- path: u.pathname + (u.search ?? ""),
20323
- method: "GET",
20324
- headers,
20325
- timeout: 5000
20326
- }, (res) => {
20327
- const status = res.statusCode ?? 0;
20328
- if (status >= 300 && status < 400 && res.headers.location) {
20329
- res.resume();
20330
- if (hopsLeft <= 0) {
20331
- reject(new Error("too_many_redirects"));
20332
- return;
20333
- }
20334
- const next = new URL(res.headers.location, url2).toString();
20335
- getJsonWithRedirect(next, hopsLeft - 1).then(resolve17, reject);
20336
- return;
20337
- }
20338
- if (status === 404) {
20339
- res.resume();
20340
- resolve17(null);
20341
- return;
20342
- }
20343
- if (status >= 400) {
20344
- res.resume();
20345
- reject(new Error(`http_${status}`));
20346
- return;
20347
- }
20348
- let body = "";
20349
- res.setEncoding("utf8");
20350
- res.on("data", (chunk) => body += chunk);
20351
- res.on("end", () => resolve17(body));
20352
- });
20353
- req.on("timeout", () => {
20354
- req.destroy();
20355
- reject(new Error("timeout"));
20356
- });
20357
- req.on("error", reject);
20358
- req.end();
20359
- });
20360
- }
20361
20237
  async function fetchLatestFromNpm(opts) {
20362
20238
  const registry = (opts.registry ?? "https://registry.npmjs.org").replace(/\/+$/, "");
20363
20239
  const channel = opts.channel ?? "latest";
@@ -20426,256 +20302,75 @@ function defaultHttpFetcher(url2, timeoutMs) {
20426
20302
  req.end();
20427
20303
  });
20428
20304
  }
20429
- async function downloadAndExtractBundle(opts) {
20430
- const tmpRoot = opts.tmpDir ?? mkdtempSync(join24(tmpdir(), "codeforge-update-"));
20431
- mkdirSync3(tmpRoot, { recursive: true });
20432
- const fetcher = opts.tarballFetcher ?? defaultBinaryFetcher;
20433
- const tarballBuf = await fetcher(opts.tarballUrl);
20434
- verifyIntegrity(tarballBuf, opts.expectedIntegrity);
20435
- const tarBuf = zlib.gunzipSync(tarballBuf);
20436
- extractTarToDir(tarBuf, tmpRoot);
20437
- const bundlePath = join24(tmpRoot, "package", "dist", "index.js");
20438
- if (!existsSync5(bundlePath)) {
20439
- throw new Error(`bundle_not_found: ${bundlePath}`);
20440
- }
20441
- return { bundlePath, extractDir: tmpRoot };
20442
- }
20443
- function verifyIntegrity(buf, expected) {
20444
- const m = /^([a-z0-9]+)-(.+)$/i.exec(expected.trim());
20445
- if (!m) {
20446
- throw new Error(`integrity_format_invalid: ${expected}`);
20447
- }
20448
- const algo = m[1].toLowerCase();
20449
- const expectedB64 = m[2];
20450
- if (algo !== "sha512" && algo !== "sha256" && algo !== "sha384") {
20451
- throw new Error(`integrity_algo_unsupported: ${algo}`);
20452
- }
20453
- const actualB64 = createHash4(algo).update(buf).digest("base64");
20454
- if (actualB64 !== expectedB64) {
20455
- throw new Error(`integrity_mismatch: expected ${algo}=${expectedB64.slice(0, 16)}... got ${actualB64.slice(0, 16)}...`);
20456
- }
20457
- }
20458
- function extractTarToDir(tarBuf, destRoot) {
20459
- let offset = 0;
20460
- while (offset + 512 <= tarBuf.length) {
20461
- const header = tarBuf.subarray(offset, offset + 512);
20462
- if (header.every((b) => b === 0))
20463
- break;
20464
- const nameRaw = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
20465
- if (!nameRaw) {
20466
- offset += 512;
20467
- continue;
20468
- }
20469
- const sizeOctal = header.subarray(124, 124 + 12).toString("ascii").replace(/\0.*$/, "").trim();
20470
- const size = sizeOctal ? parseInt(sizeOctal, 8) : 0;
20471
- const typeFlag = header.subarray(156, 157).toString("ascii");
20472
- const prefixRaw = header.subarray(345, 345 + 155).toString("utf8").replace(/\0.*$/, "");
20473
- const fullName = prefixRaw ? `${prefixRaw}/${nameRaw}` : nameRaw;
20474
- offset += 512;
20475
- if (typeFlag === "0" || typeFlag === "" || typeFlag === "\x00") {
20476
- const fileBuf = tarBuf.subarray(offset, offset + size);
20477
- const dest = join24(destRoot, fullName);
20478
- mkdirSync3(dirname14(dest), { recursive: true });
20479
- writeFileSync2(dest, fileBuf);
20480
- } else if (typeFlag === "5") {
20481
- mkdirSync3(join24(destRoot, fullName), { recursive: true });
20482
- }
20483
- offset += Math.ceil(size / 512) * 512;
20484
- }
20485
- }
20486
- function defaultBinaryFetcher(url2) {
20487
- return downloadBinary(url2, 3);
20488
- }
20489
- function downloadBinary(url2, hopsLeft) {
20490
- return new Promise((resolve17, reject) => {
20491
- const u = new URL(url2);
20492
- const req = https.request({
20493
- host: u.hostname,
20494
- port: u.port || undefined,
20495
- path: u.pathname + (u.search ?? ""),
20496
- method: "GET",
20497
- headers: { "User-Agent": "codeforge-update-checker" },
20498
- timeout: 30000
20499
- }, (res) => {
20500
- const status = res.statusCode ?? 0;
20501
- if (status >= 300 && status < 400 && res.headers.location) {
20502
- res.resume();
20503
- if (hopsLeft <= 0) {
20504
- reject(new Error("too_many_redirects"));
20505
- return;
20506
- }
20507
- const next = new URL(res.headers.location, url2).toString();
20508
- downloadBinary(next, hopsLeft - 1).then(resolve17, reject);
20509
- return;
20510
- }
20511
- if (status >= 400) {
20512
- res.resume();
20513
- reject(new Error(`http_${status}`));
20514
- return;
20515
- }
20516
- const chunks = [];
20517
- res.on("data", (chunk) => chunks.push(chunk));
20518
- res.on("end", () => resolve17(Buffer.concat(chunks)));
20519
- });
20520
- req.on("timeout", () => {
20521
- req.destroy();
20522
- reject(new Error("timeout"));
20523
- });
20524
- req.on("error", reject);
20525
- req.end();
20526
- });
20305
+
20306
+ // plugins/update-checker.ts
20307
+ var PLUGIN_NAME20 = "update-checker";
20308
+ var PLUGIN_VERSION = "3.0.0";
20309
+ var _updateCheckStarted = false;
20310
+ function getCacheFile() {
20311
+ return join25(process.env["CODEFORGE_CACHE_DIR"] ?? join25(homedir8(), ".cache", "codeforge"), "update-check.json");
20527
20312
  }
20528
- function atomicReplaceBundle(opts) {
20529
- const { source, target, oldVersion } = opts;
20530
- const keep = opts.keepBackups ?? 3;
20531
- if (!existsSync5(source)) {
20532
- throw new Error(`atomic_source_missing: ${source}`);
20533
- }
20534
- mkdirSync3(dirname14(target), { recursive: true });
20535
- const newPath = `${target}.new`;
20536
- const backupPath = `${target}.bak.${oldVersion}`;
20537
- let strategy = "rename";
20313
+ function readLastInstalledVersion() {
20538
20314
  try {
20539
- copyFileSync(source, newPath);
20540
- if (existsSync5(target)) {
20541
- try {
20542
- renameSync(target, backupPath);
20543
- } catch (e) {
20544
- const code = e.code;
20545
- if (code === "EBUSY" || code === "EPERM" || code === "EACCES") {
20546
- copyFileSync(target, backupPath);
20547
- copyFileSync(newPath, target);
20548
- try {
20549
- unlinkSync(newPath);
20550
- } catch {}
20551
- strategy = "copy_fallback";
20552
- cleanupOldBackups(target, keep);
20553
- return { backupPath, strategy };
20554
- }
20555
- throw e;
20556
- }
20557
- }
20558
- try {
20559
- renameSync(newPath, target);
20560
- } catch (e) {
20561
- const code = e.code;
20562
- if (code === "EBUSY" || code === "EPERM" || code === "EACCES") {
20563
- copyFileSync(newPath, target);
20564
- try {
20565
- unlinkSync(newPath);
20566
- } catch {}
20567
- strategy = "copy_fallback";
20568
- } else {
20569
- throw e;
20570
- }
20571
- }
20572
- cleanupOldBackups(target, keep);
20573
- return { backupPath, strategy };
20574
- } catch (e) {
20575
- try {
20576
- if (existsSync5(newPath))
20577
- unlinkSync(newPath);
20578
- } catch {}
20579
- throw e;
20315
+ const f = getCacheFile();
20316
+ if (!existsSync5(f))
20317
+ return null;
20318
+ const o = JSON.parse(readFileSync6(f, "utf8"));
20319
+ return typeof o.installedVersion === "string" ? o.installedVersion : null;
20320
+ } catch {
20321
+ return null;
20580
20322
  }
20581
20323
  }
20582
- function cleanupOldBackups(target, keep) {
20583
- if (keep <= 0)
20584
- return;
20324
+ function writeLastInstalledVersion(v) {
20585
20325
  try {
20586
- const dir = dirname14(target);
20587
- const base = target.substring(dir.length + 1);
20588
- const prefix = `${base}.bak.`;
20589
- const all = readdirSync3(dir).filter((f) => f.startsWith(prefix)).map((f) => {
20590
- const full = join24(dir, f);
20591
- let mtimeMs = 0;
20592
- try {
20593
- mtimeMs = statSync4(full).mtimeMs;
20594
- } catch {}
20595
- return { full, mtimeMs };
20596
- }).sort((a, b) => b.mtimeMs - a.mtimeMs);
20597
- const toRemove = all.slice(keep);
20598
- for (const item of toRemove) {
20599
- try {
20600
- unlinkSync(item.full);
20601
- } catch {}
20602
- }
20326
+ const f = getCacheFile();
20327
+ mkdirSync3(dirname15(f), { recursive: true });
20328
+ writeFileSync2(f, JSON.stringify({ installedVersion: v }, null, 2), "utf8");
20603
20329
  } catch {}
20604
20330
  }
20605
- function loadCompatibility(opts) {
20331
+ function resolveNodeBin() {
20332
+ const w = process.platform === "win32";
20606
20333
  try {
20607
- let file = opts?.file;
20608
- if (!file) {
20609
- const root = opts?.cwd ?? inferPluginRoot();
20610
- if (!root)
20611
- return null;
20612
- file = join24(root, "compatibility.json");
20613
- }
20614
- if (!existsSync5(file))
20615
- return null;
20616
- const raw = readFileSync5(file, "utf8");
20617
- const obj = JSON.parse(raw);
20618
- if (!obj || typeof obj !== "object")
20619
- return null;
20620
- const oc = obj["opencode"];
20621
- if (!oc || typeof oc !== "object")
20622
- return null;
20623
- const o = oc;
20624
- const min_version = typeof o["min_version"] === "string" ? o["min_version"] : "";
20625
- const max_version = typeof o["max_version"] === "string" ? o["max_version"] : null;
20626
- const tested_versions = Array.isArray(o["tested_versions"]) ? o["tested_versions"].filter((v) => typeof v === "string") : [];
20627
- if (!min_version)
20628
- return null;
20629
- return { min_version, max_version, tested_versions };
20630
- } catch {
20631
- return null;
20334
+ const r = spawnSync2(w ? "where" : "which", ["node"], { encoding: "utf8", stdio: "pipe" });
20335
+ if (r.status === 0 && r.stdout.trim())
20336
+ return r.stdout.trim().split(/\r?\n/)[0].trim();
20337
+ } catch {}
20338
+ for (const c of w ? ["C:\\Program Files\\nodejs\\node.exe"] : ["/usr/local/bin/node", "/usr/bin/node", "/opt/homebrew/bin/node", "/opt/homebrew/opt/node/bin/node"]) {
20339
+ try {
20340
+ if (spawnSync2(c, ["--version"], { stdio: "pipe", timeout: 2000 }).status === 0)
20341
+ return c;
20342
+ } catch {}
20632
20343
  }
20344
+ return process.execPath;
20633
20345
  }
20634
- function inferPluginRoot() {
20346
+ function resolveNpmBin() {
20347
+ const w = process.platform === "win32";
20635
20348
  try {
20636
- const here = fileURLToPath2(import.meta.url);
20637
- return dirname14(dirname14(here));
20638
- } catch {
20639
- return null;
20349
+ const r = spawnSync2(w ? "where" : "which", ["npm"], { encoding: "utf8", stdio: "pipe" });
20350
+ if (r.status === 0 && r.stdout.trim())
20351
+ return r.stdout.trim().split(/\r?\n/)[0].trim();
20352
+ } catch {}
20353
+ for (const c of w ? ["C:\\Program Files\\nodejs\\npm.cmd"] : ["/usr/local/bin/npm", "/usr/bin/npm", "/opt/homebrew/bin/npm"]) {
20354
+ try {
20355
+ if (spawnSync2(c, ["--version"], { stdio: "pipe", timeout: 2000 }).status === 0)
20356
+ return c;
20357
+ } catch {}
20640
20358
  }
20359
+ return "npm";
20641
20360
  }
20642
- function compareOpencodeVersion(opts) {
20643
- const cur = (opts.currentOpencodeVer ?? "").trim();
20644
- const compat = opts.compat;
20645
- if (!compat) {
20646
- return { status: "unknown", message: "compatibility.json 不可用,跳过版本校验" };
20647
- }
20648
- if (!cur || cur === "unknown") {
20649
- return { status: "unknown", message: "无法检测 opencode 版本(OPENCODE_VERSION 未设)" };
20650
- }
20651
- if (compat.tested_versions.includes(cur)) {
20652
- return {
20653
- status: "supported",
20654
- message: `opencode ${cur} 在 CodeForge 已测试版本列表内`
20655
- };
20656
- }
20657
- if (cmpVersion(cur, compat.min_version) < 0) {
20658
- return {
20659
- status: "below_min",
20660
- message: `opencode ${cur} 低于 CodeForge 要求的最低版本 ${compat.min_version}`
20661
- };
20662
- }
20663
- if (compat.max_version && cmpVersion(cur, compat.max_version) > 0) {
20664
- return {
20665
- status: "above_max",
20666
- message: `opencode ${cur} 高于 CodeForge 已知兼容上限 ${compat.max_version}`
20667
- };
20668
- }
20669
- const testedDisplay = compat.tested_versions.length > 0 ? compat.tested_versions.join(", ") : "(空)";
20670
- return {
20671
- status: "untested",
20672
- message: `opencode ${cur} 未在 CodeForge 已测试版本列表中(已测:${testedDisplay})`
20673
- };
20361
+ function getNpmGlobalRoot(npmBin) {
20362
+ try {
20363
+ const r = spawnSync2(npmBin, ["root", "-g"], {
20364
+ encoding: "utf8",
20365
+ stdio: "pipe",
20366
+ timeout: 1e4,
20367
+ shell: process.platform === "win32"
20368
+ });
20369
+ if (r.status === 0 && r.stdout.trim())
20370
+ return r.stdout.trim();
20371
+ } catch {}
20372
+ return null;
20674
20373
  }
20675
-
20676
- // plugins/update-checker.ts
20677
- var PLUGIN_NAME20 = "update-checker";
20678
- var PLUGIN_VERSION = "2.0.0";
20679
20374
  logLifecycle(PLUGIN_NAME20, "import", { version: PLUGIN_VERSION });
20680
20375
  var updateCheckerServer = async (ctx) => {
20681
20376
  const yieldResult = shouldYieldToLocalPlugin({ directory: ctx.directory });
@@ -20699,45 +20394,20 @@ var updateCheckerServer = async (ctx) => {
20699
20394
  logLifecycle(PLUGIN_NAME20, "activate", {
20700
20395
  version: PLUGIN_VERSION,
20701
20396
  auto_check_enabled: u.auto_check_enabled,
20702
- interval_hours: u.interval_hours,
20703
20397
  package: u.package,
20704
20398
  registry: u.registry,
20705
20399
  channel: u.channel,
20706
- auto_install: u.auto_install,
20707
- backup_keep: u.backup_keep,
20708
- repo_fallback: u.repo,
20709
- config_source: "codeforge.json"
20710
- });
20711
- await safeAsync(PLUGIN_NAME20, "opencode_version_check", async () => {
20712
- const compat = loadCompatibility();
20713
- const opencodeVer = detectOpencodeVersion();
20714
- const verdict = compareOpencodeVersion({
20715
- currentOpencodeVer: opencodeVer,
20716
- compat
20717
- });
20718
- safeWriteLog(PLUGIN_NAME20, {
20719
- level: "info",
20720
- msg: "opencode_version_check",
20721
- opencodeVer,
20722
- status: verdict.status,
20723
- verdict_message: verdict.message
20724
- });
20725
- if (verdict.status === "untested" || verdict.status === "above_max") {
20726
- await postToast(ctx, `[CodeForge] ⚠ ${verdict.message}
20727
- 遇到问题请在 issue 中附带本提示`);
20728
- } else if (verdict.status === "below_min") {
20729
- const minVer = compat?.min_version ?? "(unknown)";
20730
- await postToast(ctx, `[CodeForge] ⚠ ${verdict.message}
20731
- 请升级 opencode 到 ${minVer}+`);
20732
- }
20400
+ auto_install: u.auto_install
20733
20401
  });
20734
20402
  if (!u.auto_check_enabled) {
20735
- safeWriteLog(PLUGIN_NAME20, {
20736
- level: "info",
20737
- msg: "auto-check disabled (codeforge.json update.auto_check_enabled=false)"
20738
- });
20403
+ safeWriteLog(PLUGIN_NAME20, { level: "info", msg: "auto_check_disabled" });
20404
+ return {};
20405
+ }
20406
+ if (_updateCheckStarted) {
20407
+ safeWriteLog(PLUGIN_NAME20, { level: "info", msg: "check_skipped_already_running" });
20739
20408
  return {};
20740
20409
  }
20410
+ _updateCheckStarted = true;
20741
20411
  setImmediate(() => {
20742
20412
  safeAsync(PLUGIN_NAME20, "checkAndMaybeUpdate", async () => {
20743
20413
  const local = readLocalVersion();
@@ -20750,224 +20420,72 @@ var updateCheckerServer = async (ctx) => {
20750
20420
  timeoutMs: 5000
20751
20421
  });
20752
20422
  } catch (e) {
20753
- safeWriteLog(PLUGIN_NAME20, {
20754
- level: "warn",
20755
- msg: "npm_fetch_failed",
20756
- error: e.message
20757
- });
20758
- await fallbackToGitHubReleases(ctx, u);
20423
+ safeWriteLog(PLUGIN_NAME20, { level: "warn", msg: "npm_fetch_failed", error: e.message });
20759
20424
  return;
20760
20425
  }
20761
- if (!npmResult) {
20762
- safeWriteLog(PLUGIN_NAME20, {
20763
- level: "info",
20764
- msg: "npm_no_release",
20765
- package: u.package,
20766
- channel: u.channel
20767
- });
20426
+ if (!npmResult)
20768
20427
  return;
20769
- }
20770
- const hasUpdate = cmpVersion(local, npmResult.version) < 0;
20771
- safeWriteLog(PLUGIN_NAME20, {
20772
- level: "info",
20773
- msg: "npm_check_result",
20774
- local,
20775
- remote: npmResult.version,
20776
- hasUpdate,
20777
- tarballUrl: npmResult.tarballUrl
20778
- });
20428
+ const remote = npmResult.version;
20429
+ const hasUpdate = cmpVersion(local, remote) < 0;
20430
+ safeWriteLog(PLUGIN_NAME20, { level: "info", msg: "npm_check_result", local, remote, hasUpdate });
20779
20431
  if (!hasUpdate)
20780
20432
  return;
20433
+ const lastInstalled = readLastInstalledVersion();
20434
+ if (lastInstalled === remote) {
20435
+ safeWriteLog(PLUGIN_NAME20, { level: "info", msg: "install_skipped_already_installed", version: remote });
20436
+ return;
20437
+ }
20781
20438
  if (!u.auto_install) {
20782
- await postToast(ctx, `[CodeForge] 有新版本 ${local} → ${npmResult.version}
20783
- 更新命令:npx ${u.package} install --global`);
20439
+ await postToast(ctx, `[codeforge] 有新版本 ${local} → ${remote}
20440
+ 运行:codeforge upgrade`);
20784
20441
  return;
20785
20442
  }
20786
- await safeAsync(PLUGIN_NAME20, "auto_install_full", async () => {
20787
- const target = getOpencodeBundlePath();
20788
- if (!target) {
20789
- safeWriteLog(PLUGIN_NAME20, {
20790
- level: "warn",
20791
- msg: "auto_install_skip",
20792
- reason: "无法定位 opencode bundle 路径"
20793
- });
20794
- await postToast(ctx, `[CodeForge] 有新版本 ${local} → ${npmResult.version}
20795
- 自动安装失败:找不到 bundle,请手动 npx ${u.package} install`);
20443
+ await safeAsync(PLUGIN_NAME20, "auto_install", async () => {
20444
+ const nodeBin = resolveNodeBin();
20445
+ const npmBin = resolveNpmBin();
20446
+ safeWriteLog(PLUGIN_NAME20, { level: "info", msg: "auto_install_start", local, remote, nodeBin, npmBin });
20447
+ const r1 = spawnSync2(npmBin, ["install", "-g", `${u.package}@${remote}`], {
20448
+ stdio: "pipe",
20449
+ encoding: "utf8",
20450
+ timeout: 120000,
20451
+ shell: process.platform === "win32"
20452
+ });
20453
+ if (r1.status !== 0) {
20454
+ safeWriteLog(PLUGIN_NAME20, { level: "warn", msg: "npm_install_failed", status: r1.status, stderr: (r1.stderr ?? "").slice(0, 300) });
20455
+ await postToast(ctx, `[codeforge] 自动升级失败(${local} → ${remote}),请手动运行:codeforge upgrade`);
20456
+ return;
20457
+ }
20458
+ safeWriteLog(PLUGIN_NAME20, { level: "info", msg: "npm_install_success", remote });
20459
+ const npmRoot = getNpmGlobalRoot(npmBin);
20460
+ const codeForgeBin = npmRoot ? join25(npmRoot, "@andyqiu", "codeforge", "bin", "codeforge.mjs") : null;
20461
+ if (!codeForgeBin || !existsSync5(codeForgeBin)) {
20462
+ safeWriteLog(PLUGIN_NAME20, { level: "warn", msg: "codeforge_bin_not_found", path: codeForgeBin ?? "null" });
20463
+ await postToast(ctx, `[codeforge] ⚠ npm 包已升级 ${local} → ${remote},但资产部署未完成。下次启动将重试,或手动运行:codeforge upgrade`);
20796
20464
  return;
20797
20465
  }
20798
- const { bundlePath, extractDir } = await downloadAndExtractBundle({
20799
- tarballUrl: npmResult.tarballUrl,
20800
- expectedIntegrity: npmResult.integrity
20466
+ const r2 = spawnSync2(nodeBin, [codeForgeBin, "install", "--global", "--skip-build"], {
20467
+ stdio: "pipe",
20468
+ encoding: "utf8",
20469
+ timeout: 60000,
20470
+ shell: false
20801
20471
  });
20802
- try {
20803
- const installMjs = join25(extractDir, "package", "install.mjs");
20804
- if (existsSync6(installMjs)) {
20805
- const nodeBin = resolveNodeBin();
20806
- const r = spawnSync2(nodeBin, [installMjs, "--global", "--skip-build"], {
20807
- cwd: join25(extractDir, "package"),
20808
- stdio: "pipe",
20809
- encoding: "utf8"
20810
- });
20811
- if (r.status === 0) {
20812
- safeWriteLog(PLUGIN_NAME20, {
20813
- level: "info",
20814
- msg: "auto_install_full_success",
20815
- local,
20816
- remote: npmResult.version,
20817
- strategy: "install.mjs"
20818
- });
20819
- await postToast(ctx, `[CodeForge] ✅ 已全量更新 ${local} → ${npmResult.version}(bundle + agents/skills/commands/workflows,重启 opencode 生效)
20820
- 回滚:npx ${u.package} rollback`);
20821
- return;
20822
- }
20823
- safeWriteLog(PLUGIN_NAME20, {
20824
- level: "warn",
20825
- msg: "install_mjs_failed",
20826
- nodeBin,
20827
- status: r.status,
20828
- stderr: r.stderr?.slice(0, 500),
20829
- stdout: r.stdout?.slice(0, 500)
20830
- });
20831
- } else {
20832
- safeWriteLog(PLUGIN_NAME20, {
20833
- level: "info",
20834
- msg: "install_mjs_absent_fallback_bundle_only",
20835
- installMjs
20836
- });
20837
- }
20838
- const { backupPath, strategy } = atomicReplaceBundle({
20839
- source: bundlePath,
20840
- target,
20841
- oldVersion: local,
20842
- keepBackups: u.backup_keep
20843
- });
20844
- safeWriteLog(PLUGIN_NAME20, {
20845
- level: "info",
20846
- msg: "auto_install_bundle_only_success",
20847
- local,
20848
- remote: npmResult.version,
20849
- target,
20850
- backupPath,
20851
- strategy
20852
- });
20853
- await postToast(ctx, `[CodeForge] ✅ 已更新 bundle ${local} → ${npmResult.version}(仅 bundle,agents/skills 未变;重启生效)
20854
- 回滚:npx ${u.package} rollback`);
20855
- } finally {
20856
- try {
20857
- rmSync(extractDir, { recursive: true, force: true });
20858
- } catch {}
20472
+ if (r2.status !== 0) {
20473
+ safeWriteLog(PLUGIN_NAME20, { level: "warn", msg: "codeforge_install_failed", status: r2.status, stderr: (r2.stderr ?? "").slice(0, 300) });
20474
+ await postToast(ctx, `[codeforge] ⚠ npm 包已升级 ${local} → ${remote},但资产部署失败。下次启动将重试,或手动运行:codeforge upgrade`);
20475
+ return;
20859
20476
  }
20477
+ writeLastInstalledVersion(remote);
20478
+ safeWriteLog(PLUGIN_NAME20, { level: "info", msg: "auto_install_full_success", local, remote, nodeBin, npmBin });
20479
+ await postToast(ctx, `[codeforge] ✅ 已升级 ${local} → ${remote}(重启 opencode 生效)
20480
+ 回滚:npm install -g ${u.package}@${local}`);
20860
20481
  });
20861
20482
  });
20862
20483
  });
20863
20484
  return {};
20864
20485
  };
20865
- function resolveNodeBin() {
20866
- const IS_WIN = process.platform === "win32";
20867
- try {
20868
- const r = spawnSync2(IS_WIN ? "where" : "which", ["node"], {
20869
- encoding: "utf8",
20870
- stdio: "pipe"
20871
- });
20872
- if (r.status === 0 && r.stdout.trim()) {
20873
- const first = r.stdout.trim().split(/\r?\n/)[0].trim();
20874
- if (first)
20875
- return first;
20876
- }
20877
- } catch {}
20878
- const candidates = IS_WIN ? [
20879
- "C:\\Program Files\\nodejs\\node.exe",
20880
- "C:\\Program Files (x86)\\nodejs\\node.exe"
20881
- ] : [
20882
- "/usr/local/bin/node",
20883
- "/usr/bin/node",
20884
- "/opt/homebrew/bin/node",
20885
- "/opt/homebrew/opt/node/bin/node"
20886
- ];
20887
- for (const c of candidates) {
20888
- try {
20889
- const t = spawnSync2(c, ["--version"], {
20890
- encoding: "utf8",
20891
- stdio: "pipe",
20892
- timeout: 2000
20893
- });
20894
- if (t.status === 0)
20895
- return c;
20896
- } catch {}
20897
- }
20898
- return process.execPath;
20899
- }
20900
- function detectOpencodeVersion() {
20901
- const env = process.env["OPENCODE_VERSION"];
20902
- if (env && env.trim().length > 0)
20903
- return env.trim();
20904
- return "unknown";
20905
- }
20906
- function getOpencodeBundlePath() {
20907
- const candidates = [];
20908
- candidates.push(join25(homedir9(), ".config", "opencode", "codeforge", "index.js"));
20909
- if (process.platform === "win32") {
20910
- const appData = process.env["APPDATA"];
20911
- if (appData)
20912
- candidates.push(join25(appData, "opencode", "codeforge", "index.js"));
20913
- const localAppData = process.env["LOCALAPPDATA"];
20914
- if (localAppData)
20915
- candidates.push(join25(localAppData, "opencode", "codeforge", "index.js"));
20916
- }
20917
- for (const c of candidates) {
20918
- if (existsSync6(c))
20919
- return c;
20920
- }
20921
- return candidates[0] ?? null;
20922
- }
20923
- async function fallbackToGitHubReleases(ctx, u) {
20924
- await safeAsync(PLUGIN_NAME20, "github_fallback", async () => {
20925
- const result = await checkUpdateOnce({
20926
- repo: u.repo,
20927
- intervalMs: u.interval_hours * 3600 * 1000
20928
- });
20929
- await reportLegacyResult(ctx, result, u.repo);
20930
- });
20931
- }
20932
- async function reportLegacyResult(ctx, result, repo) {
20933
- if (result.error && !result.fromCache) {
20934
- safeWriteLog(PLUGIN_NAME20, {
20935
- level: "warn",
20936
- msg: `update check failed: ${result.error}`,
20937
- ...result
20938
- });
20939
- return;
20940
- }
20941
- if (!result.hasUpdate) {
20942
- safeWriteLog(PLUGIN_NAME20, {
20943
- level: "info",
20944
- msg: `up-to-date (local=${result.local}, remote=${result.remote}${result.fromCache ? ", from_cache" : ""})`,
20945
- ...result
20946
- });
20947
- return;
20948
- }
20949
- const updateCmd = `bunx --bun github:${repo} install`;
20950
- const toast = `[CodeForge] 有新版本:${result.local} → ${result.remote}
20951
- 更新命令:${updateCmd}`;
20952
- safeWriteLog(PLUGIN_NAME20, {
20953
- level: "info",
20954
- msg: "new_version_available_github_fallback",
20955
- local: result.local,
20956
- remote: result.remote,
20957
- update_command: updateCmd,
20958
- fromCache: result.fromCache
20959
- });
20960
- await postToast(ctx, toast);
20961
- }
20962
20486
  async function postToast(ctx, message) {
20963
20487
  await safeAsync(PLUGIN_NAME20, "client.app.log", async () => {
20964
- await ctx.client.app.log({
20965
- body: {
20966
- service: PLUGIN_NAME20,
20967
- level: "info",
20968
- message
20969
- }
20970
- });
20488
+ await ctx.client.app.log({ body: { service: PLUGIN_NAME20, level: "info", message } });
20971
20489
  });
20972
20490
  }
20973
20491
  var handler20 = updateCheckerServer;