@co0ontty/wand 4.89.0 → 4.89.1

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.
Files changed (70) hide show
  1. package/dist/agent-dispatch.d.ts +5 -0
  2. package/dist/agent-dispatch.js +8 -0
  3. package/dist/ai-team-runner.d.ts +1 -1
  4. package/dist/ai-team-runner.js +13 -5
  5. package/dist/ai-team-types.js +3 -2
  6. package/dist/build-info.json +3 -3
  7. package/dist/commit-task-archive.d.ts +3 -0
  8. package/dist/commit-task-archive.js +15 -0
  9. package/dist/config.js +1 -0
  10. package/dist/decision-expert-employee.d.ts +1 -1
  11. package/dist/decision-expert-employee.js +3 -3
  12. package/dist/decision-expert-service.d.ts +6 -5
  13. package/dist/decision-expert-service.js +75 -49
  14. package/dist/decision-types.d.ts +2 -1
  15. package/dist/employee-text.d.ts +28 -0
  16. package/dist/employee-text.js +124 -0
  17. package/dist/git-quick-commit.d.ts +13 -4
  18. package/dist/git-quick-commit.js +112 -33
  19. package/dist/local-model-setup.d.ts +7 -1
  20. package/dist/local-model-setup.js +54 -7
  21. package/dist/local-model-types.d.ts +2 -1
  22. package/dist/process-manager.d.ts +4 -1
  23. package/dist/process-manager.js +5 -6
  24. package/dist/server-employee-routes.d.ts +2 -0
  25. package/dist/server-employee-routes.js +19 -45
  26. package/dist/server-file-routes.js +61 -25
  27. package/dist/server-local-model-routes.js +12 -0
  28. package/dist/server-session-routes.d.ts +2 -1
  29. package/dist/server-session-routes.js +13 -10
  30. package/dist/server-speech-routes.d.ts +2 -0
  31. package/dist/server-speech-routes.js +7 -1
  32. package/dist/server-task-routes.d.ts +3 -0
  33. package/dist/server-task-routes.js +13 -10
  34. package/dist/server.js +20 -8
  35. package/dist/session-ai-context.d.ts +6 -4
  36. package/dist/session-ai-context.js +10 -9
  37. package/dist/silicon-employee-dispatch.d.ts +4 -6
  38. package/dist/silicon-employee-dispatch.js +9 -6
  39. package/dist/speech-polisher-employee.d.ts +6 -0
  40. package/dist/speech-polisher-employee.js +23 -0
  41. package/dist/speech-polisher-identity.d.ts +4 -0
  42. package/dist/speech-polisher-identity.js +4 -0
  43. package/dist/speech-polisher-service.d.ts +31 -0
  44. package/dist/speech-polisher-service.js +88 -0
  45. package/dist/speech-service.d.ts +3 -1
  46. package/dist/speech-service.js +14 -3
  47. package/dist/speech-support.d.ts +6 -0
  48. package/dist/speech-support.js +26 -0
  49. package/dist/speech-types.d.ts +5 -0
  50. package/dist/storage.d.ts +2 -0
  51. package/dist/storage.js +18 -1
  52. package/dist/structured-session-manager.d.ts +3 -2
  53. package/dist/structured-session-manager.js +11 -7
  54. package/dist/system-employee.d.ts +6 -4
  55. package/dist/system-employee.js +8 -5
  56. package/dist/types.d.ts +3 -1
  57. package/dist/user-memory.d.ts +2 -0
  58. package/dist/user-memory.js +1 -1
  59. package/dist/web-ui/content/ai-teams.js +10 -10
  60. package/dist/web-ui/content/scripts.js +316 -204
  61. package/dist/web-ui/content/styles.css +1 -1
  62. package/dist/web-ui/content/tailwind.css +1 -1
  63. package/dist/web-ui/embedded-assets.d.ts +1 -0
  64. package/dist/web-ui/embedded-assets.js +4 -3
  65. package/dist/web-ui/index.js +3 -3
  66. package/dist/web-ui/provider-identity.d.ts +2 -0
  67. package/dist/web-ui/provider-identity.js +7 -1
  68. package/dist/web-ui/scripts.d.ts +2 -0
  69. package/dist/web-ui/scripts.js +5 -0
  70. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { type EmployeeTextDeps } from "./employee-text.js";
1
2
  import type { AiTextRequest } from "./types.js";
2
3
  import { GitStatusResult, PushResult, QuickCommitResult, SessionProvider, SessionSnapshot, TagHeadResult } from "./types.js";
3
4
  export type QuickCommitErrorCode = "CWD_MISSING" | "NO_CWD" | "NOT_A_GIT_REPO" | "NO_COMMIT" | "NOTHING_TO_COMMIT" | "NOTHING_TO_PUSH" | "EMPTY_MESSAGE" | "EMPTY_TAG" | "EMPTY_AI_MESSAGE" | "AI_FALLBACK_FAILED" | "INVALID_AI_TAG" | "TAG_EXISTS" | "GIT_ADD_FAILED" | "GIT_DIFF_FAILED" | "GIT_COMMIT_FAILED" | "GIT_TAG_FAILED" | "CLAUDE_CLI_MISSING" | "CLAUDE_CLI_FAILED" | "CLAUDE_TIMEOUT";
@@ -26,12 +27,15 @@ export interface QuickCommitAiOptions {
26
27
  inheritEnv?: boolean;
27
28
  /** 内置「系统运维」员工的角色设定:作为系统提示前缀注入。 */
28
29
  opsPersona?: string;
29
- /** CLI 降级链(按顺序);未设置时只用 provider/model 这一次调用。 */
30
+ /** 员工候选链(按顺序);未设置时只用 provider/model 这一次调用。 */
30
31
  cliCandidates?: import("./types.js").AiCliCandidate[];
31
- /** 系统应用必须经员工渠道;没有可用 CLI 候选时直接失败,不退回默认 provider。 */
32
+ /** SDK 使用当前实例配置与现有免费分组服务,不创建其他执行渠道。 */
33
+ employeeText?: EmployeeTextDeps;
34
+ /** 系统应用必须经员工渠道;没有可用候选时直接失败,不退回默认 provider。 */
32
35
  employeeChannelOnly?: boolean;
33
36
  /** 整条候选链的时间上限;默认 CLI_CHAIN_BUDGET_MS。第一条消息里的调用要短得多。 */
34
37
  budgetMs?: number;
38
+ signal?: AbortSignal;
35
39
  }
36
40
  export declare class QuickCommitError extends Error {
37
41
  readonly code: QuickCommitErrorCode;
@@ -47,11 +51,16 @@ export interface CommitInputOptions {
47
51
  iterationDigest?: string;
48
52
  includeDiff?: boolean;
49
53
  }
54
+ type CliAiTextOptions = QuickCommitAiOptions & {
55
+ deadline?: number;
56
+ };
57
+ export declare function assertGeminiTextCapability(): void;
58
+ export declare function callCliAiText(request: AiTextRequest, cwd: string, language: string, opts: CliAiTextOptions): Promise<string>;
50
59
  /** 角色设定走系统提示前缀;任务自己的输出格式与约束必须排在后面。 */
51
60
  export declare function withOpsPersona(request: AiTextRequest, persona?: string): AiTextRequest;
52
61
  /**
53
- * Run a lightweight AI request through the system employee's CLI chain.
54
- * 所有 Wand 自有文本调用都走本机已安装的 CLI,没有任何直连 API 分支。
62
+ * Run a lightweight AI request through the system employee's configured chain.
63
+ * provider 与引擎由候选决定;SDK 与 CLI 均保留各自适配和身份。
55
64
  * parseOutput 在候选链内解析/校验结果;失败后继续下一条,而非提前宣布成功。
56
65
  */
57
66
  export declare function callConfiguredAiText(request: AiTextRequest, cwd: string, language: string, opts: QuickCommitAiOptions): Promise<string>;
@@ -1,5 +1,9 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { spawn } from "node:child_process";
6
+ import { abortableEmployeeText, prepareEmployeeTextCandidate } from "./employee-text.js";
3
7
  import { buildChildEnv } from "./env-utils.js";
4
8
  import { buildLanguageDirective } from "./language-prompt.js";
5
9
  import { isSessionProvider, providerCliInstalled } from "./session-provider.js";
@@ -306,7 +310,7 @@ async function callClaudeText(request, cwd, language, opts) {
306
310
  const stdout = await runCliText("claude", args, request.prompt, {
307
311
  cwd,
308
312
  timeoutMs: cliTextTimeoutMs(CLAUDE_MESSAGE_TIMEOUT_MS, opts),
309
- inheritEnv: opts.inheritEnv,
313
+ inheritEnv: opts.inheritEnv, signal: opts.signal,
310
314
  });
311
315
  const text = stdout.trim();
312
316
  if (!text)
@@ -538,6 +542,8 @@ function buildGrokTextArgs(request, opts, allowTools = false) {
538
542
  args.push(...systemPromptArgsFrom("grok", request.system));
539
543
  if (allowTools)
540
544
  args.push("--always-approve");
545
+ else
546
+ args.push("--tools", "", "--disallowed-tools", "run_terminal_cmd,read_file,search_replace,grep,list_dir,web_search,web_fetch,todo_write,task,Agent", "--deny", "MCPTool", "--disable-web-search", "--no-subagents");
541
547
  return args;
542
548
  }
543
549
  /**
@@ -568,6 +574,8 @@ function buildQoderTextArgs(request, opts, allowTools = false) {
568
574
  args.push(...systemPromptArgsFrom("qoder", request.system));
569
575
  if (allowTools)
570
576
  args.push("--permission-mode", "bypass_permissions");
577
+ else
578
+ args.push("--tools", "", "--strict-mcp-config");
571
579
  return args;
572
580
  }
573
581
  /** provider 自己的系统提示开关 + 规则文本;没有开关的 provider 返回空。 */
@@ -583,35 +591,48 @@ function contentWithSystemPrompt(provider, request) {
583
591
  return systemPromptFlag(provider) ? request.prompt : composeSystemFallback(request.system, request.prompt);
584
592
  }
585
593
  function runCliText(command, args, prompt, opts) {
594
+ opts.signal?.throwIfAborted();
586
595
  return new Promise((resolve, reject) => {
596
+ const baseEnv = buildChildEnv(opts.inheritEnv !== false);
587
597
  const child = spawn(command, args, {
588
598
  cwd: opts.cwd,
589
- env: buildChildEnv(opts.inheritEnv !== false),
599
+ env: opts.env ? opts.env(baseEnv) : baseEnv,
590
600
  stdio: ["pipe", "pipe", "pipe"],
591
601
  });
592
602
  let stdout = "";
593
603
  let stderr = "";
594
604
  let settled = false;
605
+ const cancelled = () => {
606
+ if (settled)
607
+ return;
608
+ settled = true;
609
+ clearTimeout(timeout);
610
+ child.kill("SIGTERM");
611
+ reject(opts.signal?.reason ?? new Error("AI 文本请求已取消。"));
612
+ };
613
+ const cleanup = () => { clearTimeout(timeout); opts.signal?.removeEventListener("abort", cancelled); };
595
614
  const timeout = setTimeout(() => {
596
615
  settled = true;
616
+ opts.signal?.removeEventListener("abort", cancelled);
597
617
  child.kill("SIGTERM");
598
618
  reject(new QuickCommitError(`${command} 调用超时。`, "CLAUDE_TIMEOUT"));
599
619
  }, opts.timeoutMs);
620
+ opts.signal?.addEventListener("abort", cancelled, { once: true });
600
621
  child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
601
622
  child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
602
623
  child.on("error", (error) => {
603
624
  if (settled)
604
625
  return;
605
626
  settled = true;
606
- clearTimeout(timeout);
627
+ cleanup();
607
628
  const code = error.code === "ENOENT" ? "CLAUDE_CLI_MISSING" : "CLAUDE_CLI_FAILED";
608
629
  reject(new QuickCommitError(error.code === "ENOENT" ? `未找到 ${command} CLI。` : `${command} CLI 失败:${error.message}`, code));
609
630
  });
610
631
  child.on("close", (code) => {
632
+ cleanup();
611
633
  if (settled)
612
634
  return;
613
635
  settled = true;
614
- clearTimeout(timeout);
615
636
  if (code === 0) {
616
637
  resolve(stdout);
617
638
  return;
@@ -632,7 +653,7 @@ function runCliText(command, args, prompt, opts) {
632
653
  if (settled || !expectsStdin)
633
654
  return;
634
655
  settled = true;
635
- clearTimeout(timeout);
656
+ cleanup();
636
657
  child.kill("SIGTERM");
637
658
  reject(new QuickCommitError(`${command} CLI stdin failed: ${error.code ?? "write error"}`, "CLAUDE_CLI_FAILED"));
638
659
  });
@@ -654,7 +675,7 @@ async function callCodexText(request, cwd, opts) {
654
675
  const stdout = await runCliText("codex", args, contentWithSystemPrompt("codex", request), {
655
676
  cwd,
656
677
  timeoutMs: cliTextTimeoutMs(CODEX_MESSAGE_TIMEOUT_MS, opts),
657
- inheritEnv: opts.inheritEnv,
678
+ inheritEnv: opts.inheritEnv, signal: opts.signal,
658
679
  });
659
680
  const text = extractCodexText(stdout);
660
681
  if (!text) {
@@ -663,7 +684,8 @@ async function callCodexText(request, cwd, opts) {
663
684
  return text;
664
685
  }
665
686
  async function callOpenCodeText(request, cwd, opts) {
666
- const args = ["run", "--format", "json"];
687
+ const agent = `wand-text-${randomUUID()}`;
688
+ const args = ["run", "--format", "json", "--pure", "--agent", agent];
667
689
  const model = opts.model?.trim();
668
690
  if (model && model !== "default")
669
691
  args.push("--model", model);
@@ -673,7 +695,18 @@ async function callOpenCodeText(request, cwd, opts) {
673
695
  const stdout = await runCliText("opencode", args, contentWithSystemPrompt("opencode", request), {
674
696
  cwd,
675
697
  timeoutMs: cliTextTimeoutMs(CODEX_MESSAGE_TIMEOUT_MS, opts),
676
- inheritEnv: opts.inheritEnv,
698
+ inheritEnv: opts.inheritEnv, signal: opts.signal,
699
+ // A dedicated per-invocation agent and scalar deny replace rather than merge
700
+ // wildcard/specific allow rules. Preserve inherited provider/model config.
701
+ env: base => {
702
+ const configured = base.OPENCODE_CONFIG_CONTENT ? JSON.parse(base.OPENCODE_CONFIG_CONTENT) : {};
703
+ if (!configured || typeof configured !== "object" || Array.isArray(configured))
704
+ throw new Error("OpenCode 内联配置无效。");
705
+ return { ...base, OPENCODE_PERMISSION: JSON.stringify("deny"), OPENCODE_CONFIG_CONTENT: JSON.stringify({
706
+ ...configured, permission: "deny", autoupdate: false, share: "disabled",
707
+ agent: { ...configured.agent, [agent]: { mode: "primary", permission: "deny", prompt: "Return only the requested text. Do not use tools." } },
708
+ }) };
709
+ },
677
710
  });
678
711
  const text = extractOpenCodeText(stdout);
679
712
  if (!text)
@@ -684,7 +717,7 @@ async function callGrokText(request, cwd, opts) {
684
717
  const stdout = await runCliText("grok", buildGrokTextArgs(request, opts), "", {
685
718
  cwd,
686
719
  timeoutMs: cliTextTimeoutMs(CODEX_MESSAGE_TIMEOUT_MS, opts),
687
- inheritEnv: opts.inheritEnv,
720
+ inheritEnv: opts.inheritEnv, signal: opts.signal,
688
721
  });
689
722
  const text = extractGrokText(stdout);
690
723
  if (!text)
@@ -695,7 +728,7 @@ async function callQoderText(request, cwd, opts) {
695
728
  const stdout = await runCliText("qodercli", buildQoderTextArgs(request, opts), "", {
696
729
  cwd,
697
730
  timeoutMs: cliTextTimeoutMs(CODEX_MESSAGE_TIMEOUT_MS, opts),
698
- inheritEnv: opts.inheritEnv,
731
+ inheritEnv: opts.inheritEnv, signal: opts.signal,
699
732
  });
700
733
  const text = extractQoderText(stdout);
701
734
  if (!text)
@@ -704,25 +737,42 @@ async function callQoderText(request, cwd, opts) {
704
737
  }
705
738
  async function callPiText(request, cwd, opts) {
706
739
  const stdout = await runCliText("pi", buildPiTextArgs(request, opts), "", {
707
- cwd, timeoutMs: cliTextTimeoutMs(CODEX_MESSAGE_TIMEOUT_MS, opts), inheritEnv: opts.inheritEnv,
740
+ cwd, timeoutMs: cliTextTimeoutMs(CODEX_MESSAGE_TIMEOUT_MS, opts), inheritEnv: opts.inheritEnv, signal: opts.signal,
708
741
  });
709
742
  const text = extractPiText(stdout);
710
743
  if (!text)
711
744
  throw new QuickCommitError("Pi 返回了空的 commit message。", "EMPTY_AI_MESSAGE");
712
745
  return text;
713
746
  }
747
+ export function assertGeminiTextCapability() {
748
+ // Gemini ignores supplemental admin policy when a central policy exists.
749
+ // Refuse that unsupported text-only path rather than launching without deny.
750
+ const systemPolicies = process.platform === "darwin" ? "/Library/Application Support/GeminiCli/policies"
751
+ : process.platform === "win32" ? join(process.env.ProgramData ?? "C:\\ProgramData", "gemini-cli", "policies") : "/etc/gemini-cli/policies";
752
+ if (existsSync(systemPolicies) && readdirSync(systemPolicies).some(file => file.endsWith(".toml"))) {
753
+ throw new QuickCommitError("Gemini 中央策略阻止注入纯文本工具边界,请配置可用的文本候选。", "AI_FALLBACK_FAILED");
754
+ }
755
+ }
714
756
  async function callGeminiText(request, cwd, opts) {
715
- const stdout = await runCliText("gemini", buildGeminiTextArgs(opts), contentWithSystemPrompt("gemini", request), {
716
- cwd,
717
- timeoutMs: cliTextTimeoutMs(CODEX_MESSAGE_TIMEOUT_MS, opts),
718
- inheritEnv: opts.inheritEnv,
719
- });
720
- const text = extractGeminiText(stdout);
721
- if (!text)
722
- throw new QuickCommitError("Gemini 返回了空的 commit message。", "EMPTY_AI_MESSAGE");
723
- return text;
757
+ assertGeminiTextCapability();
758
+ const root = mkdtempSync(join(tmpdir(), "wand-gemini-text-"));
759
+ try {
760
+ const policy = join(root, "text-only.toml");
761
+ writeFileSync(policy, '[[rule]]\ntoolName = "*"\ndecision = "deny"\npriority = 999\n', { mode: 0o600 });
762
+ const args = [...buildGeminiTextArgs(opts), "--admin-policy", policy, "--extensions", "none", "--allowed-mcp-server-names", ""];
763
+ const stdout = await runCliText("gemini", args, contentWithSystemPrompt("gemini", request), {
764
+ cwd, timeoutMs: cliTextTimeoutMs(CODEX_MESSAGE_TIMEOUT_MS, opts), inheritEnv: opts.inheritEnv, signal: opts.signal,
765
+ });
766
+ const text = extractGeminiText(stdout);
767
+ if (!text)
768
+ throw new QuickCommitError("Gemini 返回了空的 commit message。", "EMPTY_AI_MESSAGE");
769
+ return text;
770
+ }
771
+ finally {
772
+ rmSync(root, { recursive: true, force: true });
773
+ }
724
774
  }
725
- async function callCliAiText(request, cwd, language, opts) {
775
+ export async function callCliAiText(request, cwd, language, opts) {
726
776
  const provider = defaultProvider(opts.provider);
727
777
  if (provider === "codex") {
728
778
  return callCodexText(request, cwd, opts);
@@ -774,28 +824,56 @@ function singleCandidate(opts) {
774
824
  * 只有一条候选时保持原行为(错误原样抛出,不摘要)。
775
825
  * 仅用于一次性文本生成;执行工具的任务不能使用这条无条件重试路径。
776
826
  */
777
- async function callCliCandidates(request, cwd, language, opts, parseOutput) {
827
+ async function callTextCandidates(request, cwd, language, opts, parseOutput) {
828
+ opts.signal?.throwIfAborted();
778
829
  const { resolveModelGroupModels } = await import("./model-groups.js");
830
+ const modelGroups = opts.modelGroups ?? opts.employeeText?.config.modelGroups;
831
+ const seen = new Set();
779
832
  const chain = (opts.cliCandidates?.length ? opts.cliCandidates : [singleCandidate(opts)])
780
- .flatMap((candidate) => resolveModelGroupModels(opts.modelGroups, candidate.provider, candidate.model, { preferDefault: !candidate.model || candidate.model === "default" })
781
- .map((model) => ({ ...candidate, model: model || undefined })));
833
+ .flatMap((candidate) => resolveModelGroupModels(modelGroups, candidate.provider, candidate.model, { preferDefault: !candidate.model || candidate.model === "default" })
834
+ .map((model) => ({ ...candidate, model: model || undefined })))
835
+ .filter(candidate => {
836
+ const key = JSON.stringify([candidate.provider, candidate.engine ?? "cli", candidate.model ?? "", candidate.thinkingEffort ?? opts.thinkingEffort]);
837
+ if (seen.has(key))
838
+ return false;
839
+ seen.add(key);
840
+ return true;
841
+ });
782
842
  if (opts.employeeChannelOnly && !opts.cliCandidates?.length) {
783
- throw new QuickCommitError("系统员工没有可用的 CLI 候选,已跳过 Wand Agent。", "AI_FALLBACK_FAILED");
843
+ throw new QuickCommitError("系统员工没有可用的执行候选。", "AI_FALLBACK_FAILED");
784
844
  }
785
845
  const deadline = Date.now() + Math.max(1, opts.budgetMs ?? CLI_CHAIN_BUDGET_MS);
786
- if (chain.length === 1) {
787
- return parseOutput(await callCliAiText(request, cwd, language, { ...candidateOptions(opts, chain[0]), deadline }));
788
- }
789
- const installed = chain.filter((candidate) => providerCliInstalled(candidate.provider));
846
+ const call = async (candidate) => {
847
+ if (candidate.engine !== "sdk")
848
+ return callCliAiText(request, cwd, language, { ...candidateOptions(opts, candidate), deadline });
849
+ const controller = new AbortController();
850
+ const timer = setTimeout(() => controller.abort(new QuickCommitError("Wand Agent 调用超时。", "CLAUDE_TIMEOUT")), Math.max(1, deadline - Date.now()));
851
+ const signal = opts.signal ? AbortSignal.any([opts.signal, controller.signal]) : controller.signal;
852
+ try {
853
+ const generate = await abortableEmployeeText(prepareEmployeeTextCandidate({
854
+ ...opts.employeeText,
855
+ config: { ...opts.employeeText?.config, defaultCwd: cwd, inheritEnv: opts.inheritEnv ?? opts.employeeText?.config.inheritEnv, modelGroups },
856
+ }, { ...candidate, model: candidate.model ?? "default", thinkingEffort: candidate.thinkingEffort ?? opts.thinkingEffort ?? "off", mode: "default", kind: "structured" }, request, signal), signal);
857
+ signal.throwIfAborted();
858
+ return await abortableEmployeeText(generate(), signal);
859
+ }
860
+ finally {
861
+ clearTimeout(timer);
862
+ }
863
+ };
864
+ if (chain.length === 1)
865
+ return parseOutput(await call(chain[0]));
866
+ const installed = chain.filter((candidate) => candidate.engine === "sdk" || providerCliInstalled(candidate.provider));
790
867
  const attempts = installed.length ? installed : chain;
791
868
  const errors = [];
792
869
  for (const candidate of attempts) {
870
+ opts.signal?.throwIfAborted();
793
871
  if (Date.now() >= deadline) {
794
872
  errors.push("已超过等待上限");
795
873
  break;
796
874
  }
797
875
  try {
798
- const text = await callCliAiText(request, cwd, language, { ...candidateOptions(opts, candidate), deadline });
876
+ const text = await call(candidate);
799
877
  const result = await parseOutput(text);
800
878
  if (candidate !== attempts[0]) {
801
879
  console.info(`[SystemAi] 候选 ${chain.indexOf(candidate) + 1}/${chain.length} (${candidate.provider}) 成功`);
@@ -803,16 +881,17 @@ async function callCliCandidates(request, cwd, language, opts, parseOutput) {
803
881
  return result;
804
882
  }
805
883
  catch (error) {
884
+ opts.signal?.throwIfAborted();
806
885
  errors.push(`${candidate.provider}: ${getGitErrorMessage(error)}`);
807
886
  // 不记录提示词、路径、CLI 原始响应或可能带鉴权信息的错误正文。
808
887
  const code = error instanceof QuickCommitError ? error.code : "INVALID_AI_RESULT";
809
888
  console.warn(`[SystemAi] 候选 ${chain.indexOf(candidate) + 1}/${chain.length} (${candidate.provider}) 失败: ${code}`);
810
889
  }
811
890
  }
812
- throw new QuickCommitError(`所有 CLI 候选均失败:${errors.join(";")}`, "AI_FALLBACK_FAILED");
891
+ throw new QuickCommitError(`所有执行候选均失败:${errors.join(";")}`, "AI_FALLBACK_FAILED");
813
892
  }
814
893
  export async function callConfiguredAiText(request, cwd, language, opts, parseOutput = (raw) => raw) {
815
- return callCliCandidates(withOpsPersona(request, opts.opsPersona), cwd, language, opts, parseOutput);
894
+ return callTextCandidates(withOpsPersona(request, opts.opsPersona), cwd, language, opts, parseOutput);
816
895
  }
817
896
  /** Read the unstaged + staged tree without touching the index. */
818
897
  async function collectWorkingTreeDiff(cwd) {
@@ -3,6 +3,7 @@ import type { DecisionService } from "./decision-service.js";
3
3
  import { type ModelFile } from "./laya-model-manifest.js";
4
4
  import { runModelSetup } from "./model-setup-process.js";
5
5
  import type { SpeechService } from "./speech-service.js";
6
+ import { type SpeechSupport } from "./speech-support.js";
6
7
  import type { LocalModelKind, LocalModelsStatus } from "./local-model-types.js";
7
8
  export interface LocalModelSetupDeps {
8
9
  configDir: string;
@@ -17,6 +18,7 @@ export interface LocalModelSetupDeps {
17
18
  run?: typeof runModelSetup;
18
19
  checkRuntime?: (python: string, signal: AbortSignal) => Promise<boolean>;
19
20
  timeoutMs?: number;
21
+ speechSupport?: (runtimeAvailable: boolean) => Promise<SpeechSupport>;
20
22
  }
21
23
  /** Deployment jobs only; existing inference owners retain their protocols, rate limits and capabilities. */
22
24
  export declare class LocalModelSetupService {
@@ -28,14 +30,18 @@ export declare class LocalModelSetupService {
28
30
  private readonly jobs;
29
31
  private sequence;
30
32
  private disposed;
33
+ private speechEnableRevision;
34
+ private supportCache;
31
35
  constructor(deps: LocalModelSetupDeps);
32
36
  private supported;
33
37
  private managedModel;
34
38
  private layaModel;
35
39
  private python;
36
40
  status(): Promise<LocalModelsStatus>;
41
+ private speechSupport;
42
+ setSpeechEnabled(enabled: boolean): Promise<void>;
37
43
  private parse;
38
- start(kind: LocalModelKind, action: "download" | "initialize", raw: unknown): void;
44
+ start(kind: LocalModelKind, action: "download" | "initialize" | "activate", raw: unknown): void;
39
45
  private update;
40
46
  private work;
41
47
  private checkRuntime;
@@ -7,6 +7,7 @@ import { LAYA_FILES, LAYA_REPOSITORY, layaFileUrl } from "./laya-model-manifest.
7
7
  import { ModelFileStore } from "./model-file-store.js";
8
8
  import { runModelSetup } from "./model-setup-process.js";
9
9
  import { speechModel } from "./speech-models.js";
10
+ import { speechSupport } from "./speech-support.js";
10
11
  import { systemEnvValue } from "./env-utils.js";
11
12
  import { decisionHardware } from "./decision-hardware.js";
12
13
  import { DECISION_EXPERT_ID } from "./decision-expert-identity.js";
@@ -20,6 +21,8 @@ export class LocalModelSetupService {
20
21
  jobs = new Map();
21
22
  sequence = 0;
22
23
  disposed = false;
24
+ speechEnableRevision = 0;
25
+ supportCache = null;
23
26
  constructor(deps) {
24
27
  this.deps = deps;
25
28
  this.files = new ModelFileStore(deps.fetch);
@@ -55,6 +58,7 @@ export class LocalModelSetupService {
55
58
  const hardware = decisionHardware();
56
59
  const operation = this.operations.get("laya") ?? null;
57
60
  const selected = speech.models.find((value) => value.id === speech.settings.model);
61
+ const support = await this.speechSupport(speech.runtime.available);
58
62
  return {
59
63
  laya: { kind: "laya", label: "LAYA 本地决策", supported,
60
64
  reason: !supported ? hardware.message
@@ -62,12 +66,38 @@ export class LocalModelSetupService {
62
66
  enabled: decision.enabled, model: LAYA_REPOSITORY, modelSize: this.manifest.reduce((sum, file) => sum + file.size, 0),
63
67
  downloaded: model.downloaded, runtimeAvailable: !!python, initialized: decision.state === "ready", busy: this.jobs.has("laya") || decision.queued > 0 || decision.state === "loading",
64
68
  operation: operation ? { ...operation } : null, hardware, decisionEmployeeId: DECISION_EXPERT_ID },
65
- speech: { kind: "speech", label: "服务端语音识别", supported: ["darwin", "linux", "win32"].includes(process.platform), reason: speech.reason,
69
+ speech: { kind: "speech", label: "服务端语音识别", supported: support.supported, supportReason: support.reason, reason: speech.reason,
66
70
  enabled: speech.settings.enabled, model: speech.settings.model, modelSize: selected?.size ?? 0,
67
71
  downloaded: selected?.downloaded ?? false, runtimeAvailable: speech.runtime.available, initialized: speech.initialized === true,
68
72
  busy: speech.busy || this.jobs.has("speech"), operation: this.operations.has("speech") ? { ...this.operations.get("speech") } : null },
69
73
  };
70
74
  }
75
+ speechSupport(runtimeAvailable) {
76
+ if (!this.supportCache || this.supportCache.expires < Date.now() || this.supportCache.runtimeAvailable !== runtimeAvailable) {
77
+ this.supportCache = { expires: Date.now() + 30_000, runtimeAvailable, value: (this.deps.speechSupport ?? speechSupport)(runtimeAvailable) };
78
+ }
79
+ return this.supportCache.value;
80
+ }
81
+ async setSpeechEnabled(enabled) {
82
+ const revision = ++this.speechEnableRevision;
83
+ const settingsRevision = this.deps.speech.settingsRevision();
84
+ if (this.disposed)
85
+ throw new DecisionError("UNAVAILABLE", "模型管理已关闭。", 503);
86
+ if (!enabled) {
87
+ this.cancel("speech");
88
+ this.deps.speech.configure({ enabled: false });
89
+ return;
90
+ }
91
+ const status = await this.deps.speech.status();
92
+ const support = await this.speechSupport(status.runtime.available);
93
+ if (revision !== this.speechEnableRevision || this.disposed || settingsRevision !== this.deps.speech.settingsRevision())
94
+ return;
95
+ if (!support.supported)
96
+ throw new DecisionError("UNSUPPORTED", support.reason || "本机不支持语音识别。", 409);
97
+ if (status.busy || status.download?.phase === "downloading")
98
+ throw new DecisionError("BUSY", "语音识别或模型下载正在进行,请稍后启用。", 409);
99
+ this.start("speech", "activate", {});
100
+ }
71
101
  parse(kind, raw) {
72
102
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
73
103
  throw new DecisionError("INVALID_REQUEST", "模型操作参数无效。");
@@ -90,6 +120,8 @@ export class LocalModelSetupService {
90
120
  throw new DecisionError("UNAVAILABLE", "模型管理已关闭。", 503);
91
121
  if (kind === "laya" && !this.supported())
92
122
  throw new DecisionError("UNSUPPORTED", `LAYA-MLX 需要 Apple Silicon macOS 与 Metal;${decisionHardware().message}`, 409);
123
+ if (kind === "laya" && action === "activate")
124
+ throw new DecisionError("INVALID_REQUEST", "一键启用仅用于语音识别。");
93
125
  const input = this.parse(kind, raw);
94
126
  if (this.jobs.has(kind))
95
127
  throw new DecisionError("BUSY", "该模型已有安装任务,请等待或取消。", 409);
@@ -119,6 +151,8 @@ export class LocalModelSetupService {
119
151
  timer.unref();
120
152
  const signal = abort.signal;
121
153
  const phase = (value, message) => this.update(kind, id, { phase: value, message });
154
+ let activationMaintenance = false;
155
+ const settingsRevision = this.deps.speech.settingsRevision();
122
156
  try {
123
157
  if (kind === "laya") {
124
158
  let model = await this.layaModel();
@@ -170,7 +204,7 @@ export class LocalModelSetupService {
170
204
  }
171
205
  else {
172
206
  const selected = input.model ?? this.deps.speech.settings().model;
173
- if (action === "download") {
207
+ if (action === "download" || action === "activate") {
174
208
  phase("downloading", "下载并校验语音模型");
175
209
  await this.deps.speech.startDownload(selected);
176
210
  while (true) {
@@ -188,7 +222,12 @@ export class LocalModelSetupService {
188
222
  });
189
223
  }
190
224
  }
191
- else {
225
+ if (action !== "download") {
226
+ if (action === "activate") {
227
+ signal.throwIfAborted();
228
+ this.deps.speech.acquireMaintenance();
229
+ activationMaintenance = true;
230
+ }
192
231
  const status = await this.deps.speech.status();
193
232
  signal.throwIfAborted();
194
233
  const backend = input.backend === "auto" || !input.backend ? process.platform === "darwin" ? "metal" : "cpu" : input.backend;
@@ -207,22 +246,30 @@ export class LocalModelSetupService {
207
246
  await rm(work, { recursive: true, force: true }).catch(() => { });
208
247
  }
209
248
  }
210
- phase("initializing", "加载所选语音模型并用合成静音检查,未启用识别或重启服务");
211
- await this.deps.speech.initializeModel(selected, signal);
249
+ phase("initializing", action === "activate" ? "正在检查语音模型,完成后自动启用。" : "加载所选语音模型并用合成静音检查,未启用识别或重启服务");
250
+ await this.deps.speech.initializeModel(selected, signal, action === "activate" ? "auto" : undefined);
251
+ if (action === "activate") {
252
+ signal.throwIfAborted();
253
+ if (settingsRevision !== this.deps.speech.settingsRevision())
254
+ throw new DecisionError("SETTINGS_CHANGED", "语音设置已由其他操作修改,本次启用已取消。");
255
+ this.deps.speech.configure({ enabled: true, acceleration: "auto" });
256
+ }
212
257
  }
213
258
  }
214
259
  signal.throwIfAborted();
215
- phase("completed", action === "download" ? "模型下载及完整性校验完成;可继续初始化" : "运行时和模型初始化检查完成;启用状态保持不变");
260
+ phase("completed", action === "activate" ? "服务端语音识别已启用。" : action === "download" ? "模型下载及完整性校验完成;可继续初始化" : "运行时和模型初始化检查完成;启用状态保持不变");
216
261
  }
217
262
  catch (error) {
218
263
  const cancelled = signal.aborted;
219
264
  const message = cancelled ? "安装任务已取消,完整文件保留,可重试" : error instanceof DecisionError || (error instanceof Error && /请先|校验|自定义|独立/.test(error.message))
220
265
  ? error.message : "安装或初始化失败,请检查网络、编译工具/Python版本、磁盘空间和平台能力,再重试。";
221
266
  this.update(kind, id, { phase: cancelled ? "cancelled" : "failed", message, ...(cancelled ? {} : { error: message }) });
222
- if (kind === "speech" && action === "download" && cancelled)
267
+ if (kind === "speech" && (action === "download" || action === "activate") && cancelled)
223
268
  this.deps.speech.cancelDownload();
224
269
  }
225
270
  finally {
271
+ if (activationMaintenance)
272
+ this.deps.speech.releaseMaintenance();
226
273
  clearTimeout(timer);
227
274
  }
228
275
  }
@@ -3,7 +3,7 @@ export type LocalModelKind = "laya" | "speech";
3
3
  export type ModelSetupPhase = "downloading" | "verifying" | "runtime" | "initializing" | "completed" | "failed" | "cancelled";
4
4
  export interface ModelSetupOperation {
5
5
  id: number;
6
- action: "download" | "initialize";
6
+ action: "download" | "initialize" | "activate";
7
7
  phase: ModelSetupPhase;
8
8
  message: string;
9
9
  received: number;
@@ -14,6 +14,7 @@ export interface LocalModelStatus {
14
14
  kind: LocalModelKind;
15
15
  label: string;
16
16
  supported: boolean;
17
+ supportReason?: string | null;
17
18
  reason: string | null;
18
19
  enabled: boolean;
19
20
  model: string;
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
3
3
  import { WandStorage } from "./storage.js";
4
4
  import { ExecutionMode, ProcessEventHandler, SessionProvider, SessionSnapshot, SessionSource, WandConfig } from "./types.js";
5
5
  import { type PermissionResolution } from "./claude-pty-bridge.js";
6
+ import type { EmployeeTextDeps } from "./employee-text.js";
6
7
  import { type PtyTerminalSnapshot, type PtyHistoryPage } from "./pty-terminal-state.js";
7
8
  import { samplePtyForegrounds } from "./pty-foreground.js";
8
9
  import { type TerminalHost } from "./terminal-host.js";
@@ -34,6 +35,7 @@ export declare const PTY_TURN_IDLE_MS = 3000;
34
35
  */
35
36
  export declare const PTY_FOREGROUND_SAMPLE_MS = 1000;
36
37
  export interface ProcessManagerOptions {
38
+ free?: EmployeeTextDeps["free"];
37
39
  /** Overrides the non-Claude PTY turn quiet window (tests use a short one). */
38
40
  ptyTurnIdleMs?: number;
39
41
  /** Overrides the foreground-process-group sampling interval (tests use a short one). */
@@ -55,6 +57,7 @@ export declare class ProcessManager extends EventEmitter {
55
57
  private readonly ptyForegroundSampleMs;
56
58
  /** Foreground probe; injectable so tests can drive it without a real process table. */
57
59
  private readonly probePtyForegrounds;
60
+ private readonly free;
58
61
  private readonly resolveDecisionEvaluate;
59
62
  /** 24h archive scan timer */
60
63
  private archiveTimer;
@@ -177,7 +180,7 @@ export declare class ProcessManager extends EventEmitter {
177
180
  */
178
181
  setSessionModel(id: string, model: string | null): SessionSnapshot;
179
182
  /**
180
- * 归档会先停掉还在跑的终端,但保留会话 ID 和 provider session id,恢复时用它们续上。
183
+ * 归档只写标记,保留正在运行的终端、会话 ID 和 provider session id。
181
184
  * 取消归档只清标记;重新拉起由恢复入口负责。
182
185
  */
183
186
  setSessionArchived(id: string, archived: boolean): SessionSnapshot;
@@ -563,6 +563,7 @@ export class ProcessManager extends EventEmitter {
563
563
  ptyForegroundSampleMs;
564
564
  /** Foreground probe; injectable so tests can drive it without a real process table. */
565
565
  probePtyForegrounds;
566
+ free;
566
567
  resolveDecisionEvaluate;
567
568
  /** 24h archive scan timer */
568
569
  archiveTimer = null;
@@ -595,6 +596,7 @@ export class ProcessManager extends EventEmitter {
595
596
  this.ptyTurnIdleMs = options.ptyTurnIdleMs ?? PTY_TURN_IDLE_MS;
596
597
  this.ptyForegroundSampleMs = options.ptyForegroundSampleMs ?? PTY_FOREGROUND_SAMPLE_MS;
597
598
  this.probePtyForegrounds = options.samplePtyForegrounds ?? samplePtyForegrounds;
599
+ this.free = options.free;
598
600
  this.resolveDecisionEvaluate = options.resolveDecisionEvaluate ?? (() => undefined);
599
601
  this.terminalHost = terminalHost ?? new InProcessTerminalHost();
600
602
  this.logger = new SessionLogger(configDir || path.join(process.env.HOME || process.cwd(), ".wand"), config.shortcutLogMaxBytes);
@@ -1229,7 +1231,7 @@ export class ProcessManager extends EventEmitter {
1229
1231
  ai: resolveSystemAiContext({
1230
1232
  provider: groupProvider, runner: undefined, command: providerCliCommand(groupProvider),
1231
1233
  structuredState: undefined, selectedModel: null, thinkingEffort: opts?.thinkingEffort,
1232
- }, this.config, this.storage.getSystemSiliconEmployee()),
1234
+ }, this.config, this.storage.getSystemSiliconEmployee(), this.free),
1233
1235
  cwd: resolveSessionCwd(cwd, this.config.defaultCwd),
1234
1236
  language: this.config.language,
1235
1237
  });
@@ -1775,13 +1777,10 @@ export class ProcessManager extends EventEmitter {
1775
1777
  return this.snapshot(record);
1776
1778
  }
1777
1779
  /**
1778
- * 归档会先停掉还在跑的终端,但保留会话 ID 和 provider session id,恢复时用它们续上。
1780
+ * 归档只写标记,保留正在运行的终端、会话 ID 和 provider session id。
1779
1781
  * 取消归档只清标记;重新拉起由恢复入口负责。
1780
1782
  */
1781
1783
  setSessionArchived(id, archived) {
1782
- if (archived && this.mustGet(id).status === "running") {
1783
- this.stop(id);
1784
- }
1785
1784
  const record = this.mustGet(id);
1786
1785
  record.archived = archived;
1787
1786
  record.archivedAt = archived ? (record.archivedAt ?? new Date().toISOString()) : null;
@@ -2354,7 +2353,7 @@ export class ProcessManager extends EventEmitter {
2354
2353
  input: prompt,
2355
2354
  cwd: record.cwd,
2356
2355
  language: this.config.language,
2357
- ai: resolveSystemAiContext(record, this.config, this.storage.getSystemSiliconEmployee()),
2356
+ ai: resolveSystemAiContext(record, this.config, this.storage.getSystemSiliconEmployee(), this.free),
2358
2357
  readNativeTitle: () => readNativeSessionTitle(resolveSessionProvider(record), record.claudeSessionId, record.cwd),
2359
2358
  hasNativeTitle,
2360
2359
  onGenerating: (generating) => {
@@ -1,5 +1,6 @@
1
1
  import type { Express } from "express";
2
2
  import { type SiliconEmployee, type SiliconEmployeeDraft } from "./ai-team-types.js";
3
+ import type { OpenRouterFreeModelsService } from "./openrouter-free-models.js";
3
4
  import type { WandStorage } from "./storage.js";
4
5
  import type { WandTaskAgent } from "./task-types.js";
5
6
  import type { WandConfig } from "./types.js";
@@ -17,6 +18,7 @@ export declare function registerSiliconEmployeeRoutes(app: Express, deps: {
17
18
  storage: WandStorage;
18
19
  notifyEmployeeChanged?: (employeeId: string) => void;
19
20
  config?: WandConfig;
21
+ free?: Pick<OpenRouterFreeModelsService, "resolveForCall">;
20
22
  /** 测试注入点:默认按系统 AI 配置真实调用模型。 */
21
23
  generateDraft?: (request: EmployeeDraftRequest) => Promise<SiliconEmployeeDraft>;
22
24
  /** 测试注入点:CLI 可用性探测。 */