honeydo 0.1.0 → 0.1.2

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.
@@ -25,7 +25,7 @@
25
25
  * backend's own timeout flag.
26
26
  */
27
27
  import { spawn } from "node:child_process";
28
- import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
28
+ import { copyFileSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
29
29
  import { homedir } from "node:os";
30
30
  import { dirname, resolve } from "node:path";
31
31
  import { emitKeypressEvents } from "node:readline";
@@ -60,6 +60,38 @@ export const QUOTA_CACHE_PATH = `${homedir()}/.config/gcli/quota-cache.json`;
60
60
  const QUOTA_TTL_OK_MS = 60_000;
61
61
  const QUOTA_TTL_FAIL_MS = 15_000;
62
62
  const QUOTA_FETCH_TIMEOUT_MS = 2_500;
63
+ // ---------------------------------------------------------------------------
64
+ // hermes backend — paths & constants
65
+ // ---------------------------------------------------------------------------
66
+ const HERMES_BIN = "hermes";
67
+ /** hermes 主配置文件(model 段 + providers 段是本子命令的编辑目标)。 */
68
+ export const HERMES_CONFIG_PATH = `${homedir()}/.hermes/config.yaml`;
69
+ /** hermes env 文件(provider API key 的落点;全程保持 0o600)。 */
70
+ export const HERMES_ENV_PATH = `${homedir()}/.hermes/.env`;
71
+ /** hermes cron 任务定义(重 pin 只读它;改动经 `hermes cron edit` 下发)。 */
72
+ export const HERMES_CRON_JOBS_PATH = `${homedir()}/.hermes/cron/jobs.json`;
73
+ /** hermes 会话库(session_model_usage 表在此 —— 实测在顶层 state.db)。 */
74
+ export const HERMES_STATE_DB_PATH = `${homedir()}/.hermes/state.db`;
75
+ /** cc-switch name → hermes provider 字段映射(keyEnv/modelOverride)。 */
76
+ export const HERMES_PROVIDERS_REGISTRY_PATH = `${homedir()}/.config/gcli/hermes-providers.json`;
77
+ /** 上一次切换的一跳记录(0o600,含旧 token 的 prevValue)。 */
78
+ export const HERMES_STATE_PATH = `${homedir()}/.config/gcli/hermes-state.json`;
79
+ /** hermes providers 条目的 transport 常量(cc-switch 只提供 anthropic 兼容端点)。 */
80
+ export const HERMES_TRANSPORT = "anthropic_messages";
81
+ /** 切换后验证 ping 的硬超时。 */
82
+ export const HERMES_VERIFY_TIMEOUT_MS = 60_000;
83
+ /**
84
+ * 内置 seed(防推导造出与现有配置平行的条目):键是 cc-switch provider 名,
85
+ * 值是 hermes 侧已手工建立好的 id/keyEnv。
86
+ */
87
+ export const HERMES_PROVIDER_SEEDS = {
88
+ // cc-switch 真实条目名(09-06 sqlite3 实证):kimi 系的 coding plan 条目叫 "kimi"
89
+ kimi: { id: "kimi-coding", keyEnv: "KIMI_CODING_API_KEY" },
90
+ "Kimi For Coding": { id: "kimi-coding", keyEnv: "KIMI_CODING_API_KEY" },
91
+ "glm flash lastest": { id: "glm-flash", keyEnv: "BIGMODEL_API_KEY" },
92
+ };
93
+ /** `status`/`rollback` 为 hermes 子命令的保留字位置参数。 */
94
+ const HERMES_RESERVED_WORDS = new Set(["status", "rollback"]);
63
95
  /** Provider-name allowlist (C4b). Names outside this set are rejected. */
64
96
  const PROVIDER_NAME_RE = /^[A-Za-z0-9 &._-]+$/;
65
97
  // ---------------------------------------------------------------------------
@@ -87,6 +119,8 @@ export function parseSubcommand(argv) {
87
119
  return { subcommand: "claude", rest: argv.slice(1) };
88
120
  if (first === "api")
89
121
  return { subcommand: "api", rest: argv.slice(1) };
122
+ if (first === "hermes")
123
+ return { subcommand: "hermes", rest: argv.slice(1) };
90
124
  if (first.startsWith("-"))
91
125
  return { subcommand: undefined, rest: argv };
92
126
  return { error: `unknown subcommand: ${first}` };
@@ -466,15 +500,28 @@ export function buildClaudeArgs(opts) {
466
500
  * high-quality output in ~45s vs claude-agent's 53min). We rely on a sufficient
467
501
  * --max-tokens budget (default 80000) to cover both thinking and text, not on
468
502
  * disabling thinking. Disabling it would discard the very capability we chose
469
- * k3 for.
503
+ * k3 for. (`--thinking auto`, the CLI default, preserves this omitted-field
504
+ * behaviour. bigmodel GLM endpoints default thinking ON and share max_tokens
505
+ * between thinking and text — small explicit budgets can end up thinking-only;
506
+ * --thinking off/on send explicit disabled/enabled for that case.)
470
507
  */
471
508
  export function buildApiBody(req) {
472
- return {
509
+ const body = {
473
510
  model: req.model,
474
511
  max_tokens: req.maxTokens,
475
512
  stream: req.stream,
476
513
  messages: [{ role: "user", content: req.prompt }],
477
514
  };
515
+ if (req.thinking === "off") {
516
+ body.thinking = { type: "disabled" };
517
+ }
518
+ else if (req.thinking === "on") {
519
+ body.thinking = {
520
+ type: "enabled",
521
+ budget_tokens: Math.floor(req.maxTokens / 2),
522
+ };
523
+ }
524
+ return body;
478
525
  }
479
526
  /**
480
527
  * Build the full URL for the messages endpoint. The cc-switch base_url is
@@ -497,24 +544,7 @@ export function buildApiEndpoint(baseUrl) {
497
544
  * `data:{...}` (kimi, no space) and `data: {...}` (with space) parse the same.
498
545
  */
499
546
  export function extractTextDelta(line) {
500
- const trimmed = line.trim();
501
- if (!trimmed.startsWith("data:"))
502
- return null;
503
- const payload = trimmed.slice("data:".length).trim();
504
- if (!payload || payload === "[DONE]")
505
- return null;
506
- try {
507
- const evt = JSON.parse(payload);
508
- if (evt.type === "content_block_delta" &&
509
- evt.delta?.type === "text_delta" &&
510
- typeof evt.delta.text === "string") {
511
- return evt.delta.text;
512
- }
513
- return null;
514
- }
515
- catch {
516
- return null;
517
- }
547
+ return extractSseLineMeta(line).text;
518
548
  }
519
549
  /**
520
550
  * Aggregate text from a non-streaming messages response body.
@@ -539,6 +569,564 @@ export function extractNonStreamText(body) {
539
569
  }
540
570
  return out;
541
571
  }
572
+ const SSE_LINE_EMPTY = {
573
+ text: null,
574
+ thinkingChars: 0,
575
+ parsed: false,
576
+ };
577
+ /**
578
+ * SSE line → structured meta (text delta + thinking volume + stop_reason).
579
+ *
580
+ * One JSON.parse per line feeding both the text aggregation and the "why did a
581
+ * stream produce no text" diagnosis (thinking-only streams that end in
582
+ * stop_reason=max_tokens are an endpoint budget issue, not a transport one).
583
+ * Non-`data:` lines / malformed JSON → empty meta.
584
+ */
585
+ export function extractSseLineMeta(line) {
586
+ const trimmed = line.trim();
587
+ if (!trimmed.startsWith("data:"))
588
+ return SSE_LINE_EMPTY;
589
+ const payload = trimmed.slice("data:".length).trim();
590
+ if (!payload || payload === "[DONE]")
591
+ return SSE_LINE_EMPTY;
592
+ try {
593
+ const evt = JSON.parse(payload);
594
+ const meta = {
595
+ text: null,
596
+ thinkingChars: 0,
597
+ parsed: true,
598
+ };
599
+ if (evt.type === "content_block_delta") {
600
+ if (evt.delta?.type === "text_delta" &&
601
+ typeof evt.delta.text === "string") {
602
+ meta.text = evt.delta.text;
603
+ }
604
+ else if (evt.delta?.type === "thinking_delta" &&
605
+ typeof evt.delta.thinking === "string") {
606
+ meta.thinkingChars = evt.delta.thinking.length;
607
+ }
608
+ }
609
+ else if (evt.type === "message_delta" &&
610
+ typeof evt.delta?.stop_reason === "string") {
611
+ meta.stopReason = evt.delta.stop_reason;
612
+ }
613
+ return meta;
614
+ }
615
+ catch {
616
+ return SSE_LINE_EMPTY;
617
+ }
618
+ }
619
+ /**
620
+ * Inspect a 200 non-streaming messages body that yielded no text, so the error
621
+ * can say WHY (thinking-only under a starved max_tokens budget vs an empty
622
+ * content array from a flaky gateway — the former is deterministic, the latter
623
+ * is worth retrying).
624
+ */
625
+ export function describeNoTextBody(body) {
626
+ const info = { blocks: "", sawThinking: false };
627
+ if (typeof body !== "object" || body === null)
628
+ return info;
629
+ const obj = body;
630
+ if (typeof obj.stop_reason === "string")
631
+ info.stopReason = obj.stop_reason;
632
+ if (!Array.isArray(obj.content))
633
+ return info;
634
+ const names = [];
635
+ for (const block of obj.content) {
636
+ if (typeof block === "object" && block !== null) {
637
+ const type = block.type;
638
+ if (typeof type === "string") {
639
+ names.push(type);
640
+ if (type === "thinking")
641
+ info.sawThinking = true;
642
+ }
643
+ }
644
+ }
645
+ info.blocks = names.join(",");
646
+ return info;
647
+ }
648
+ /** HTTP statuses worth an automatic retry (transient server/gateway trouble). */
649
+ export const API_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
650
+ // ---------------------------------------------------------------------------
651
+ // hermes backend — pure helpers (unit-tested in cli.test.ts)
652
+ // ---------------------------------------------------------------------------
653
+ /**
654
+ * Strip a trailing context-window marker like `[1M]` (a claude-agent
655
+ * convention cc-switch stores in model names; raw APIs reject it).
656
+ * Only a suffix anchored at the very end is removed.
657
+ */
658
+ export function stripContextSuffix(model) {
659
+ return model.replace(/\[[^\]]*\]$/, "");
660
+ }
661
+ /** cc-switch 值入行级文件(.env/config.yaml)前的消毒:拒绝裸换行(防行注入)。 */
662
+ function containsLineBreak(value) {
663
+ return value.includes("\n") || value.includes("\r");
664
+ }
665
+ /**
666
+ * Parse argv for `gcli hermes ...`. Strict like parseApiArgs (unknown flags
667
+ * are exit-2 errors — there is no backend to forward to). `--no-verify` is
668
+ * accepted via allowNegative. At most one positional.
669
+ */
670
+ export function parseHermesArgs(argv) {
671
+ try {
672
+ const { values, positionals } = parseArgs({
673
+ args: argv,
674
+ options: {
675
+ model: { type: "string" },
676
+ "dry-run": { type: "boolean" },
677
+ verify: { type: "boolean" },
678
+ "keep-on-fail": { type: "boolean" },
679
+ help: { type: "boolean" },
680
+ },
681
+ strict: true,
682
+ allowPositionals: true,
683
+ allowNegative: true,
684
+ });
685
+ if (positionals.length > 1) {
686
+ return {
687
+ error: `hermes: unexpected extra argument: "${positionals[1]}" (usage: gcli hermes <provider|status|rollback> [--model <m>] [--dry-run] [--no-verify] [--keep-on-fail])`,
688
+ };
689
+ }
690
+ return {
691
+ provider: positionals[0],
692
+ model: typeof values.model === "string" ? values.model : undefined,
693
+ dryRun: values["dry-run"] === true,
694
+ verify: values.verify !== false,
695
+ keepOnFail: values["keep-on-fail"] === true,
696
+ help: values.help === true,
697
+ };
698
+ }
699
+ catch (err) {
700
+ return { error: err instanceof Error ? err.message : String(err) };
701
+ }
702
+ }
703
+ /**
704
+ * Derive a hermes provider id from a cc-switch display name: lowercase,
705
+ * non-[a-z0-9] runs folded to `-`, leading/trailing dashes trimmed.
706
+ * May return "" for names with no alphanumerics (caller treats as error).
707
+ */
708
+ export function deriveHermesId(ccName) {
709
+ return ccName
710
+ .toLowerCase()
711
+ .replace(/[^a-z0-9]+/g, "-")
712
+ .replace(/^-+|-+$/g, "");
713
+ }
714
+ /** Derive the .env key name from a hermes provider id: UPPER + `_API_KEY`. */
715
+ export function deriveKeyEnv(id) {
716
+ return `${id.toUpperCase().replace(/-/g, "_")}_API_KEY`;
717
+ }
718
+ /** Safe YAML scalars are written bare; anything else is single-quoted. */
719
+ const SAFE_YAML_SCALAR_RE = /^[A-Za-z0-9._/:@-]+$/;
720
+ /** Quote a scalar for our line-level YAML writer ('' escapes a single quote). */
721
+ export function quoteYamlScalar(value) {
722
+ if (value === "")
723
+ return "''";
724
+ if (SAFE_YAML_SCALAR_RE.test(value))
725
+ return value;
726
+ return `'${value.replace(/'/g, "''")}'`;
727
+ }
728
+ /** Inverse of quoteYamlScalar for reading (unescapes '' inside '...'). */
729
+ function unquoteYamlScalar(raw) {
730
+ const t = raw.trim();
731
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
732
+ return t.slice(1, -1).replace(/''/g, "'");
733
+ }
734
+ return t;
735
+ }
736
+ /** col-0 bare section header, e.g. `model:` / `providers:` (nothing after `:`). */
737
+ const TOP_SECTION_RE = /^(\S[^:]*):\s*$/;
738
+ /**
739
+ * Where to insert new lines at a section's end: just before endLine, unless
740
+ * the preceding element is the trailing-newline artifact (`"a\n".split("\n")
741
+ * → ["a",""]`), in which case before that empty element — so an appended
742
+ * block keeps the file's trailing newline and gains no stray blank line.
743
+ */
744
+ function sectionInsertAt(lines, span) {
745
+ let at = span.endLine;
746
+ while (at > span.headerLine + 1 && lines[at - 1] === "")
747
+ at--;
748
+ return at;
749
+ }
750
+ /**
751
+ * Build the top-level section table: col-0 `key:` headers delimit sections;
752
+ * any other col-0 line (a scalar like `timezone: Asia/Shanghai`) is still a
753
+ * boundary. endLine is exclusive.
754
+ */
755
+ function findTopLevelSections(lines) {
756
+ const col0 = [];
757
+ for (let i = 0; i < lines.length; i++) {
758
+ if (/^\S/.test(lines[i]))
759
+ col0.push(i);
760
+ }
761
+ const spans = [];
762
+ for (let j = 0; j < col0.length; j++) {
763
+ const start = col0[j];
764
+ const m = TOP_SECTION_RE.exec(lines[start]);
765
+ if (m === null)
766
+ continue;
767
+ const end = j + 1 < col0.length ? col0[j + 1] : lines.length;
768
+ spans.push({ key: m[1], headerLine: start, endLine: end });
769
+ }
770
+ return spans;
771
+ }
772
+ /**
773
+ * Edit the top-level `model:` section (2-space `key: value` lines only):
774
+ * replace the wanted keys in place; insert missing ones at the section end;
775
+ * any unexpected structure (deeper nesting, a bare `key:` sub-section) is an
776
+ * error — 宁报错不猜.
777
+ */
778
+ function editModelSection(lines, span, model) {
779
+ const wanted = [
780
+ ["default", model.default],
781
+ ["provider", model.provider],
782
+ ["base_url", model.base_url],
783
+ ];
784
+ const out = [...lines];
785
+ const found = new Set();
786
+ for (let i = span.headerLine + 1; i < span.endLine; i++) {
787
+ const line = out[i];
788
+ if (line.trim() === "" || line.trimStart().startsWith("#"))
789
+ continue;
790
+ // exactly 2-space indent, then `key: value`; a 4-space line (nested) or a
791
+ // bare `key:` (sub-section) fails this match → structural error.
792
+ const m = /^( {2})([^\s:#][^:]*?):(.+)$/.exec(line);
793
+ if (m === null) {
794
+ return {
795
+ error: `config.yaml model 段第 ${i + 1} 行结构无法识别(意外嵌套?): ${line.trim()}`,
796
+ };
797
+ }
798
+ const key = m[2].trim();
799
+ const w = wanted.find(([k]) => k === key);
800
+ if (w !== undefined) {
801
+ out[i] = `${m[1]}${key}: ${quoteYamlScalar(w[1])}`;
802
+ found.add(key);
803
+ }
804
+ }
805
+ const missing = wanted.filter(([k]) => !found.has(k));
806
+ if (missing.length > 0) {
807
+ out.splice(sectionInsertAt(out, span), 0, ...missing.map(([k, v]) => ` ${k}: ${quoteYamlScalar(v)}`));
808
+ }
809
+ return { lines: out };
810
+ }
811
+ /**
812
+ * Edit the top-level `providers:` section: entries are 2-space `id:` blocks
813
+ * with 4-space `key: value` fields. Upserts the wanted fields of the entry
814
+ * `prov.id` (missing fields appended at the block end); appends a new block
815
+ * at the section end when the id is absent. Any other shape → error.
816
+ */
817
+ function editProvidersSection(lines, span, prov) {
818
+ const wanted = [
819
+ ["name", prov.name],
820
+ ["base_url", prov.base_url],
821
+ ["transport", prov.transport],
822
+ ["key_env", prov.key_env],
823
+ ["default_model", prov.default_model],
824
+ ];
825
+ const entries = [];
826
+ let cur;
827
+ for (let i = span.headerLine + 1; i < span.endLine; i++) {
828
+ const line = lines[i];
829
+ if (line.trim() === "" || line.trimStart().startsWith("#"))
830
+ continue;
831
+ const h = /^ {2}(\S[^:]*):\s*$/.exec(line);
832
+ if (h !== null) {
833
+ cur = { id: h[1], start: i, end: i + 1 };
834
+ entries.push(cur);
835
+ continue;
836
+ }
837
+ const f = /^ {4}\S[^:]*:/.exec(line);
838
+ if (f !== null && cur !== undefined) {
839
+ cur.end = i + 1;
840
+ continue;
841
+ }
842
+ return {
843
+ error: `config.yaml providers 段第 ${i + 1} 行结构无法识别(意外嵌套?): ${line.trim()}`,
844
+ };
845
+ }
846
+ const out = [...lines];
847
+ const target = entries.find((e) => e.id === prov.id);
848
+ if (target === undefined) {
849
+ const block = [
850
+ ` ${prov.id}:`,
851
+ ...wanted.map(([k, v]) => ` ${k}: ${quoteYamlScalar(v)}`),
852
+ ];
853
+ out.splice(sectionInsertAt(out, span), 0, ...block);
854
+ return { lines: out };
855
+ }
856
+ const found = new Set();
857
+ for (let i = target.start + 1; i < target.end; i++) {
858
+ const line = out[i];
859
+ const f = /^( {4})([^\s:#][^:]*?):/.exec(line);
860
+ if (f === null)
861
+ continue; // blank/comment line inside the block
862
+ const key = f[2].trim();
863
+ const w = wanted.find(([k]) => k === key);
864
+ if (w !== undefined) {
865
+ out[i] = `${f[1]}${key}: ${quoteYamlScalar(w[1])}`;
866
+ found.add(key);
867
+ }
868
+ }
869
+ const missing = wanted.filter(([k]) => !found.has(k));
870
+ if (missing.length > 0) {
871
+ out.splice(target.end, 0, ...missing.map(([k, v]) => ` ${k}: ${quoteYamlScalar(v)}`));
872
+ }
873
+ return { lines: out };
874
+ }
875
+ /**
876
+ * Targeted line-level YAML editor for hermes' config.yaml (zero-dep: no yaml
877
+ * lib guaranteed). Only the top-level `model:` (3 keys) and `providers:`
878
+ * (one entry) sections are touched; everything else is byte-preserved.
879
+ * Missing sections / unexpected nesting → {error}, never a guess (callers
880
+ * must not write on error). Idempotent by construction.
881
+ */
882
+ export function editHermesConfig(text, edit) {
883
+ const lines = text.split("\n");
884
+ const modelSpan = findTopLevelSections(lines).find((s) => s.key === "model");
885
+ if (modelSpan === undefined) {
886
+ return { error: "config.yaml 缺少顶层 model: 段" };
887
+ }
888
+ const r1 = editModelSection(lines, modelSpan, edit.model);
889
+ if ("error" in r1)
890
+ return r1;
891
+ const provSpan = findTopLevelSections(r1.lines).find((s) => s.key === "providers");
892
+ if (provSpan === undefined) {
893
+ return { error: "config.yaml 缺少顶层 providers: 段" };
894
+ }
895
+ const r2 = editProvidersSection(r1.lines, provSpan, edit.provider);
896
+ if ("error" in r2)
897
+ return r2;
898
+ return { text: r2.lines.join("\n") };
899
+ }
900
+ /**
901
+ * Read (not edit) the parts of config.yaml the hermes backend needs: the
902
+ * current model section values and the list of providers entry ids.
903
+ * Missing model: section or unparseable model lines → {error}.
904
+ */
905
+ export function parseHermesConfig(text) {
906
+ const lines = text.split("\n");
907
+ const sections = findTopLevelSections(lines);
908
+ const modelSpan = sections.find((s) => s.key === "model");
909
+ if (modelSpan === undefined) {
910
+ return { error: "config.yaml 缺少顶层 model: 段" };
911
+ }
912
+ const model = {};
913
+ for (let i = modelSpan.headerLine + 1; i < modelSpan.endLine; i++) {
914
+ const line = lines[i];
915
+ if (line.trim() === "" || line.trimStart().startsWith("#"))
916
+ continue;
917
+ const m = /^ {2}([^\s:#][^:]*?):(.*)$/.exec(line);
918
+ if (m === null) {
919
+ return {
920
+ error: `config.yaml model 段第 ${i + 1} 行结构无法识别: ${line.trim()}`,
921
+ };
922
+ }
923
+ const key = m[1].trim();
924
+ const val = unquoteYamlScalar(m[2]);
925
+ if (key === "default")
926
+ model.default = val;
927
+ else if (key === "provider")
928
+ model.provider = val;
929
+ else if (key === "base_url")
930
+ model.base_url = val;
931
+ }
932
+ const providerIds = [];
933
+ const provSpan = sections.find((s) => s.key === "providers");
934
+ if (provSpan !== undefined) {
935
+ for (let i = provSpan.headerLine + 1; i < provSpan.endLine; i++) {
936
+ const h = /^ {2}(\S[^:]*):\s*$/.exec(lines[i]);
937
+ if (h !== null)
938
+ providerIds.push(h[1]);
939
+ }
940
+ }
941
+ return { info: { model, providerIds } };
942
+ }
943
+ /**
944
+ * Upsert one `KEY=value` line in a .env text (pure). Only active lines
945
+ * (`^KEY=`) match — commented-out occurrences are left alone; comments and
946
+ * blank lines are byte-preserved. value=null deletes the line(s); a missing
947
+ * key with a non-null value is appended at the end.
948
+ */
949
+ export function upsertEnvLines(text, key, value) {
950
+ const lines = text.split("\n");
951
+ const prefix = `${key}=`;
952
+ let seen = false;
953
+ const out = [];
954
+ for (const line of lines) {
955
+ if (line.startsWith(prefix)) {
956
+ seen = true;
957
+ if (value !== null)
958
+ out.push(`${key}=${value}`);
959
+ continue; // null → drop the line
960
+ }
961
+ out.push(line);
962
+ }
963
+ if (!seen && value !== null) {
964
+ // keep a trailing newline trailing: insert before a final empty element
965
+ if (out.length > 0 && out[out.length - 1] === "") {
966
+ out.splice(out.length - 1, 0, `${key}=${value}`);
967
+ }
968
+ else {
969
+ out.push(`${key}=${value}`);
970
+ }
971
+ }
972
+ return out.join("\n");
973
+ }
974
+ /**
975
+ * Plan cron re-pins: jobs that are enabled AND pinned to `oldProvider`
976
+ * (jobs.json shape `{jobs: [...]}`, a bare array tolerated). Malformed input
977
+ * yields an empty plan, never a throw.
978
+ */
979
+ export function buildCronRepinPlan(jobsJson, oldProvider) {
980
+ const jobs = Array.isArray(jobsJson)
981
+ ? jobsJson
982
+ : typeof jobsJson === "object" && jobsJson !== null
983
+ ? jobsJson.jobs
984
+ : undefined;
985
+ if (!Array.isArray(jobs))
986
+ return [];
987
+ const out = [];
988
+ for (const j of jobs) {
989
+ if (typeof j !== "object" || j === null)
990
+ continue;
991
+ const r = j;
992
+ if (r.enabled !== true)
993
+ continue;
994
+ if (r.provider !== oldProvider)
995
+ continue;
996
+ if (typeof r.id !== "string" || r.id === "")
997
+ continue;
998
+ out.push({
999
+ jobId: r.id,
1000
+ prevProvider: oldProvider,
1001
+ prevModel: typeof r.model === "string" && r.model !== "" ? r.model : null,
1002
+ });
1003
+ }
1004
+ return out;
1005
+ }
1006
+ /**
1007
+ * Parse the provider registry (`hermes-providers.json`). Corrupt/missing
1008
+ * content is best-effort → empty registry; entries lacking id/keyEnv are
1009
+ * dropped.
1010
+ */
1011
+ export function parseHermesRegistry(text) {
1012
+ try {
1013
+ const raw = JSON.parse(text);
1014
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1015
+ return {};
1016
+ }
1017
+ const out = {};
1018
+ for (const [k, v] of Object.entries(raw)) {
1019
+ if (typeof v !== "object" || v === null)
1020
+ continue;
1021
+ const r = v;
1022
+ if (typeof r.id !== "string" || r.id === "")
1023
+ continue;
1024
+ if (typeof r.keyEnv !== "string" || r.keyEnv === "")
1025
+ continue;
1026
+ const entry = { id: r.id, keyEnv: r.keyEnv };
1027
+ if (typeof r.modelOverride === "string" && r.modelOverride !== "") {
1028
+ entry.modelOverride = r.modelOverride;
1029
+ }
1030
+ out[k] = entry;
1031
+ }
1032
+ return out;
1033
+ }
1034
+ catch {
1035
+ return {};
1036
+ }
1037
+ }
1038
+ export function serializeHermesRegistry(reg) {
1039
+ return `${JSON.stringify(reg, null, 2)}\n`;
1040
+ }
1041
+ function parseHermesModelPoint(v) {
1042
+ if (typeof v !== "object" || v === null)
1043
+ return undefined;
1044
+ const r = v;
1045
+ if (typeof r.id !== "string" ||
1046
+ typeof r.model !== "string" ||
1047
+ typeof r.base_url !== "string") {
1048
+ return undefined;
1049
+ }
1050
+ return { id: r.id, model: r.model, base_url: r.base_url };
1051
+ }
1052
+ /**
1053
+ * Parse `hermes-state.json` STRICTLY (畸形 → {error},不猜): it carries the
1054
+ * previous token and the rollback plan, so a half-broken state must not be
1055
+ * acted on.
1056
+ */
1057
+ export function parseHermesStateFile(text) {
1058
+ let raw;
1059
+ try {
1060
+ raw = JSON.parse(text);
1061
+ }
1062
+ catch {
1063
+ return { error: "state 文件不是合法 JSON" };
1064
+ }
1065
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1066
+ return { error: "state 文件根不是对象" };
1067
+ }
1068
+ const ls = raw.lastSwitch;
1069
+ if (typeof ls !== "object" || ls === null || Array.isArray(ls)) {
1070
+ return { error: "state 缺少 lastSwitch 对象" };
1071
+ }
1072
+ const r = ls;
1073
+ const to = parseHermesModelPoint(r.to);
1074
+ const from = parseHermesModelPoint(r.from);
1075
+ if (to === undefined || from === undefined) {
1076
+ return { error: "state lastSwitch.to/from 畸形" };
1077
+ }
1078
+ if (typeof r.ts !== "number" ||
1079
+ typeof r.ccName !== "string" ||
1080
+ typeof r.configBackup !== "string") {
1081
+ return { error: "state lastSwitch 标量字段畸形" };
1082
+ }
1083
+ const env = r.env;
1084
+ if (typeof env !== "object" || env === null || Array.isArray(env)) {
1085
+ return { error: "state env 畸形" };
1086
+ }
1087
+ const e = env;
1088
+ if (typeof e.key !== "string" ||
1089
+ (e.prevValue !== null && typeof e.prevValue !== "string")) {
1090
+ return { error: "state env 畸形" };
1091
+ }
1092
+ const cr = r.cronRepinned;
1093
+ if (!Array.isArray(cr)) {
1094
+ return { error: "state cronRepinned 畸形" };
1095
+ }
1096
+ const cronRepinned = [];
1097
+ for (const c of cr) {
1098
+ if (typeof c !== "object" || c === null) {
1099
+ return { error: "state cronRepinned 条目畸形" };
1100
+ }
1101
+ const x = c;
1102
+ if (typeof x.jobId !== "string" ||
1103
+ typeof x.prevProvider !== "string" ||
1104
+ (x.prevModel !== null && typeof x.prevModel !== "string")) {
1105
+ return { error: "state cronRepinned 条目畸形" };
1106
+ }
1107
+ cronRepinned.push({
1108
+ jobId: x.jobId,
1109
+ prevProvider: x.prevProvider,
1110
+ prevModel: x.prevModel,
1111
+ });
1112
+ }
1113
+ return {
1114
+ state: {
1115
+ lastSwitch: {
1116
+ ts: r.ts,
1117
+ ccName: r.ccName,
1118
+ to,
1119
+ from,
1120
+ configBackup: r.configBackup,
1121
+ env: { key: e.key, prevValue: e.prevValue },
1122
+ cronRepinned,
1123
+ },
1124
+ },
1125
+ };
1126
+ }
1127
+ export function serializeHermesStateFile(s) {
1128
+ return `${JSON.stringify(s, null, 2)}\n`;
1129
+ }
542
1130
  function isOk(r) {
543
1131
  return !("error" in r);
544
1132
  }
@@ -654,6 +1242,8 @@ export function parseApiArgs(argv) {
654
1242
  provider: { type: "string" },
655
1243
  "max-tokens": { type: "string" },
656
1244
  timeout: { type: "string" },
1245
+ thinking: { type: "string" },
1246
+ retry: { type: "string" },
657
1247
  stream: { type: "boolean" },
658
1248
  version: { type: "boolean" },
659
1249
  help: { type: "boolean" },
@@ -682,6 +1272,35 @@ export function parseApiArgs(argv) {
682
1272
  }
683
1273
  timeoutMs = n;
684
1274
  }
1275
+ // thinking: auto (default, omit field — endpoint default, k3 keeps its
1276
+ // quality source) | off (disabled; avoids thinking-only responses on
1277
+ // budget-starved requests) | on (enabled, budget = max_tokens/2, hence
1278
+ // the >= 2048 floor).
1279
+ let thinking = "auto";
1280
+ if (values.thinking !== undefined) {
1281
+ const t = values.thinking;
1282
+ if (t !== "auto" && t !== "off" && t !== "on") {
1283
+ return {
1284
+ error: `--thinking must be auto|off|on, got "${t}"`,
1285
+ };
1286
+ }
1287
+ thinking = t;
1288
+ }
1289
+ if (thinking === "on" && maxTokens < 2048) {
1290
+ return {
1291
+ error: `--thinking on needs --max-tokens >= 2048 (thinking budget = max_tokens/2 = ${Math.floor(maxTokens / 2)}), got ${maxTokens}`,
1292
+ };
1293
+ }
1294
+ let retries = 1;
1295
+ if (values.retry !== undefined) {
1296
+ const n = Number(values.retry);
1297
+ if (!Number.isInteger(n) || n < 0 || n > 5) {
1298
+ return {
1299
+ error: `--retry must be an integer in [0, 5], got "${values.retry}"`,
1300
+ };
1301
+ }
1302
+ retries = n;
1303
+ }
685
1304
  return {
686
1305
  prompt: typeof values.prompt === "string" ? values.prompt : undefined,
687
1306
  model: typeof values.model === "string" ? values.model : undefined,
@@ -690,6 +1309,8 @@ export function parseApiArgs(argv) {
690
1309
  timeoutMs,
691
1310
  // default stream=true; --no-stream (allowNegative) → false
692
1311
  stream: values.stream !== false,
1312
+ thinking,
1313
+ retries,
693
1314
  version: values.version === true,
694
1315
  help: values.help === true,
695
1316
  cwd: typeof values.cwd === "string" ? values.cwd : undefined,
@@ -832,30 +1453,57 @@ export function runAgyInteractive(args, cwd) {
832
1453
  export function runClaudeInteractive(args, cwd) {
833
1454
  return spawnInteractive(CLAUDE_BIN, args, cwd);
834
1455
  }
835
- // ---------------------------------------------------------------------------
836
- // api backend — production HTTP implementation (zero deps: fetch + TextDecoder)
837
- // ---------------------------------------------------------------------------
1456
+ const API_RETRY_BASE_DELAY_MS = 400;
1457
+ const API_RETRY_MAX_DELAY_MS = 4000;
838
1458
  /**
839
1459
  * Production deps.runApi: POST an anthropic-compatible /v1/messages request
840
1460
  * and return a normalized RunOutcome.
841
1461
  *
842
1462
  * - stream=true: reads the SSE body chunk-by-chunk, decodes UTF-8, splits on
843
1463
  * newlines, and aggregates `text_delta` payloads into stdout. Two clocks
844
- * guard against hangs: an idle timer (reset on every text-bearing chunk)
845
- * and an absolute timer (timeoutMs). Either firing aborts the fetch via
846
- * AbortController → exit 1 with a timeout message.
1464
+ * guard against hangs: an idle timer (reset on EVERY received chunk —
1465
+ * thinking models can emit long non-text stretches) and an absolute timer
1466
+ * (timeoutMs). Either firing aborts the fetch via AbortController → exit 1
1467
+ * with a timeout message.
847
1468
  * - stream=false: awaits the full JSON body and extracts `content[].text`,
848
1469
  * then applies the 50k-char truncation.
849
1470
  *
1471
+ * TRANSIENT failures are retried automatically (default 1 retry, `--retry N`):
1472
+ * network-level fetch errors, HTTP 408/429/5xx, malformed JSON, empty-content
1473
+ * 200 bodies, and streams that die or deliver no SSE events at all. Each retry
1474
+ * note lands on stderr (stdout stays pipe-clean), so a recovered attempt still
1475
+ * leaves a trace. Deterministic failures are NOT retried — timeouts, 4xx, and
1476
+ * thinking-only responses (retrying cannot fix a max_tokens budget starved by
1477
+ * endpoint-default thinking); those errors carry a diagnosis instead.
1478
+ *
850
1479
  * HTTP errors (non-2xx, network failure, abort) → exit 1 with diagnostics on
851
1480
  * stderr; stdout stays empty so the caller's empty-output guard still works.
852
1481
  */
853
1482
  export async function runApi(req) {
1483
+ const maxAttempts = Math.max(1, (req.retries ?? 0) + 1);
1484
+ const retryNotes = [];
1485
+ for (let attempt = 1;; attempt++) {
1486
+ const res = await runApiAttempt(req);
1487
+ if (res.retryable === false || attempt >= maxAttempts) {
1488
+ if (retryNotes.length === 0)
1489
+ return res.outcome;
1490
+ return {
1491
+ ...res.outcome,
1492
+ stderr: [...retryNotes, res.outcome.stderr].join("\n"),
1493
+ };
1494
+ }
1495
+ const delayMs = Math.min(API_RETRY_MAX_DELAY_MS, API_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
1496
+ retryNotes.push(`gcli: api attempt ${attempt}/${maxAttempts} failed (${res.retryable}); retrying in ${delayMs}ms`);
1497
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1498
+ }
1499
+ }
1500
+ /** A single fetch + body-consumption pass (fresh AbortController + timers). */
1501
+ async function runApiAttempt(req) {
854
1502
  const controller = new AbortController();
855
1503
  const { signal } = controller;
856
1504
  // Two timers: absolute + idle. timeoutMs is both the hard ceiling and the
857
1505
  // default idle budget — SSE streams that go quiet for that long are treated
858
- // as hung. We reset the idle clock whenever we receive text.
1506
+ // as hung. We reset the idle clock whenever we receive any chunk.
859
1507
  let idleTimer;
860
1508
  let absoluteTimer;
861
1509
  let timedOut = false;
@@ -878,6 +1526,11 @@ export async function runApi(req) {
878
1526
  if (absoluteTimer)
879
1527
  clearTimeout(absoluteTimer);
880
1528
  };
1529
+ const timeoutOutcome = () => ({
1530
+ exitCode: 1,
1531
+ stdout: "",
1532
+ stderr: `gcli: api timed out after ${req.timeoutMs}ms`,
1533
+ });
881
1534
  const headers = {
882
1535
  Authorization: `Bearer ${req.token}`,
883
1536
  "anthropic-version": "2023-06-01",
@@ -895,18 +1548,16 @@ export async function runApi(req) {
895
1548
  }
896
1549
  catch (err) {
897
1550
  clearTimers();
898
- if (timedOut) {
899
- return {
900
- exitCode: 1,
901
- stdout: "",
902
- stderr: `gcli: api timed out after ${req.timeoutMs}ms`,
903
- };
904
- }
1551
+ if (timedOut)
1552
+ return { outcome: timeoutOutcome(), retryable: false };
905
1553
  const msg = err instanceof Error ? err.message : String(err);
906
1554
  return {
907
- exitCode: 1,
908
- stdout: "",
909
- stderr: `gcli: api request failed: ${msg}`,
1555
+ outcome: {
1556
+ exitCode: 1,
1557
+ stdout: "",
1558
+ stderr: `gcli: api request failed: ${msg}`,
1559
+ },
1560
+ retryable: `fetch failed: ${msg}`.slice(0, 120),
910
1561
  };
911
1562
  }
912
1563
  if (!response.ok) {
@@ -920,9 +1571,14 @@ export async function runApi(req) {
920
1571
  }
921
1572
  const trimmed = detail.trim().slice(0, 1000);
922
1573
  return {
923
- exitCode: 1,
924
- stdout: "",
925
- stderr: `gcli: api returned HTTP ${response.status}${trimmed ? `: ${trimmed}` : ""}`,
1574
+ outcome: {
1575
+ exitCode: 1,
1576
+ stdout: "",
1577
+ stderr: `gcli: api returned HTTP ${response.status}${trimmed ? `: ${trimmed}` : ""}`,
1578
+ },
1579
+ retryable: API_RETRYABLE_STATUS.has(response.status)
1580
+ ? `HTTP ${response.status}`
1581
+ : false,
926
1582
  };
927
1583
  }
928
1584
  if (!req.stream) {
@@ -934,58 +1590,118 @@ export async function runApi(req) {
934
1590
  catch (err) {
935
1591
  const msg = err instanceof Error ? err.message : String(err);
936
1592
  return {
937
- exitCode: 1,
938
- stdout: "",
939
- stderr: `gcli: api returned malformed JSON: ${msg}`,
1593
+ outcome: {
1594
+ exitCode: 1,
1595
+ stdout: "",
1596
+ stderr: `gcli: api returned malformed JSON: ${msg}`,
1597
+ },
1598
+ retryable: "malformed JSON",
1599
+ };
1600
+ }
1601
+ // Some gateways answer 200 with an error envelope instead of a message.
1602
+ const errEnvelope = body;
1603
+ if (errEnvelope.type === "error" || errEnvelope.error != null) {
1604
+ return {
1605
+ outcome: {
1606
+ exitCode: 1,
1607
+ stdout: "",
1608
+ stderr: `gcli: api returned an error body: ${JSON.stringify(body).slice(0, 500)}`,
1609
+ },
1610
+ retryable: false,
940
1611
  };
941
1612
  }
942
1613
  const text = extractNonStreamText(body);
943
1614
  if (!text) {
1615
+ const info = describeNoTextBody(body);
1616
+ if (!info.blocks) {
1617
+ // 200 with nothing usable in content — gateway hiccup, worth a retry.
1618
+ return {
1619
+ outcome: {
1620
+ exitCode: 1,
1621
+ stdout: "",
1622
+ stderr: "gcli: api returned no text content (empty content[])",
1623
+ },
1624
+ retryable: "empty content[]",
1625
+ };
1626
+ }
944
1627
  return {
945
- exitCode: 1,
946
- stdout: "",
947
- stderr: "gcli: api returned no text content",
1628
+ outcome: {
1629
+ exitCode: 1,
1630
+ stdout: "",
1631
+ stderr: `gcli: api returned no text content (blocks=[${info.blocks}]` +
1632
+ `${info.stopReason ? `, stop_reason=${info.stopReason}` : ""})` +
1633
+ (info.sawThinking
1634
+ ? " — thinking consumed the whole max_tokens budget; raise --max-tokens or pass --thinking off"
1635
+ : ""),
1636
+ },
1637
+ // thinking-only is a deterministic budget outcome, not a flake
1638
+ retryable: false,
948
1639
  };
949
1640
  }
950
- return { exitCode: 0, stdout: truncate(text), stderr: "" };
1641
+ return {
1642
+ outcome: { exitCode: 0, stdout: truncate(text), stderr: "" },
1643
+ retryable: false,
1644
+ };
951
1645
  }
952
1646
  // Streaming: aggregate text_delta chunks. response.body is a web stream;
953
1647
  // TextDecoder handles multi-byte UTF-8 split across chunk boundaries, and a
954
1648
  // leftover buffer carries the partial final line until the next newline.
955
1649
  if (response.body === null) {
956
1650
  clearTimers();
957
- return { exitCode: 1, stdout: "", stderr: "gcli: api stream had no body" };
1651
+ return {
1652
+ outcome: {
1653
+ exitCode: 1,
1654
+ stdout: "",
1655
+ stderr: "gcli: api stream had no body",
1656
+ },
1657
+ retryable: "stream had no body",
1658
+ };
958
1659
  }
959
1660
  const reader = response.body.getReader();
960
1661
  const decoder = new TextDecoder("utf-8");
961
1662
  let aggregated = "";
962
1663
  let leftover = "";
1664
+ let sawEvent = false;
1665
+ let thinkingChars = 0;
1666
+ let stopReason;
963
1667
  try {
964
1668
  // eslint-disable-next-line no-constant-condition
965
1669
  while (true) {
966
1670
  const { done, value } = await reader.read();
967
1671
  if (done)
968
1672
  break;
1673
+ // Any traffic proves the stream is alive — reset the idle clock on every
1674
+ // chunk, not only text-bearing ones (thinking models can emit long
1675
+ // non-text stretches before the first text_delta).
1676
+ resetIdle();
969
1677
  leftover += decoder.decode(value, { stream: true });
970
1678
  // SSE events are separated by newlines; process every complete line and
971
1679
  // keep the trailing partial in `leftover`.
972
1680
  const lines = leftover.split(/\r?\n/);
973
1681
  leftover = lines.pop() ?? "";
974
1682
  for (const line of lines) {
975
- const delta = extractTextDelta(line);
976
- if (delta !== null) {
977
- aggregated += delta;
978
- resetIdle();
979
- }
1683
+ const meta = extractSseLineMeta(line);
1684
+ if (meta.parsed)
1685
+ sawEvent = true;
1686
+ if (meta.text !== null)
1687
+ aggregated += meta.text;
1688
+ thinkingChars += meta.thinkingChars;
1689
+ if (meta.stopReason !== undefined)
1690
+ stopReason = meta.stopReason;
980
1691
  }
981
1692
  }
982
1693
  // Flush any trailing line (some servers omit the final newline).
983
1694
  const tail = decoder.decode();
984
1695
  leftover += tail;
985
1696
  if (leftover.length > 0) {
986
- const delta = extractTextDelta(leftover);
987
- if (delta !== null)
988
- aggregated += delta;
1697
+ const meta = extractSseLineMeta(leftover);
1698
+ if (meta.parsed)
1699
+ sawEvent = true;
1700
+ if (meta.text !== null)
1701
+ aggregated += meta.text;
1702
+ thinkingChars += meta.thinkingChars;
1703
+ if (meta.stopReason !== undefined)
1704
+ stopReason = meta.stopReason;
989
1705
  }
990
1706
  }
991
1707
  catch (err) {
@@ -993,31 +1709,54 @@ export async function runApi(req) {
993
1709
  if (timedOut) {
994
1710
  // Timeout is a backend error (exit 1) per the contract, even when some
995
1711
  // text was already received — callers must not treat a timed-out
996
- // response as success.
997
- return {
998
- exitCode: 1,
999
- stdout: "",
1000
- stderr: `gcli: api timed out after ${req.timeoutMs}ms`,
1001
- };
1712
+ // response as success. Nothing has been emitted yet (stream output is
1713
+ // aggregated before printing), so a retry would also be safe — but a
1714
+ // request that already burned its whole time budget won't get one.
1715
+ return { outcome: timeoutOutcome(), retryable: false };
1002
1716
  }
1003
1717
  const msg = err instanceof Error ? err.message : String(err);
1004
1718
  return {
1005
- exitCode: 1,
1006
- stdout: "",
1007
- stderr: `gcli: api stream read failed: ${msg}`,
1719
+ outcome: {
1720
+ exitCode: 1,
1721
+ stdout: "",
1722
+ stderr: `gcli: api stream read failed: ${msg}`,
1723
+ },
1724
+ retryable: `stream read failed: ${msg}`.slice(0, 120),
1008
1725
  };
1009
1726
  }
1010
1727
  clearTimers();
1011
1728
  if (!aggregated) {
1729
+ if (!sawEvent) {
1730
+ // Connection delivered no SSE events at all — transport-level flake.
1731
+ return {
1732
+ outcome: {
1733
+ exitCode: 1,
1734
+ stdout: "",
1735
+ stderr: "gcli: api stream produced no text (stream closed with no SSE events)",
1736
+ },
1737
+ retryable: "no SSE events",
1738
+ };
1739
+ }
1740
+ const detail = thinkingChars > 0
1741
+ ? ` (thinking ~${thinkingChars}ch${stopReason ? `, stop_reason=${stopReason}` : ""}) — thinking consumed the whole max_tokens budget; raise --max-tokens or pass --thinking off`
1742
+ : stopReason
1743
+ ? ` (stop_reason=${stopReason})`
1744
+ : "";
1012
1745
  return {
1013
- exitCode: 1,
1014
- stdout: "",
1015
- stderr: "gcli: api stream produced no text",
1746
+ outcome: {
1747
+ exitCode: 1,
1748
+ stdout: "",
1749
+ stderr: `gcli: api stream produced no text${detail}`,
1750
+ },
1751
+ retryable: false,
1016
1752
  };
1017
1753
  }
1018
1754
  // Streaming output is not truncated (real-time, contract §A); only the
1019
1755
  // non-stream path truncates.
1020
- return { exitCode: 0, stdout: aggregated, stderr: "" };
1756
+ return {
1757
+ outcome: { exitCode: 0, stdout: aggregated, stderr: "" },
1758
+ retryable: false,
1759
+ };
1021
1760
  }
1022
1761
  // ---------------------------------------------------------------------------
1023
1762
  // cc-switch provider lookup
@@ -1387,12 +2126,161 @@ async function fetchProviderQuotasHttp(items) {
1387
2126
  return out;
1388
2127
  }
1389
2128
  // ---------------------------------------------------------------------------
2129
+ // hermes backend — production deps implementations
2130
+ // ---------------------------------------------------------------------------
2131
+ /**
2132
+ * Production deps.runHermes: spawn `hermes <args>` with the same IO/timeout
2133
+ * contract as runClaude (pipe stdout/stderr, SIGTERM on timeout, no stdin
2134
+ * inheritance needed — cron edit / gateway / -z ping never read stdin).
2135
+ */
2136
+ function runHermesProcess(args, timeoutMs) {
2137
+ return new Promise((resolveFn) => {
2138
+ const child = spawn(HERMES_BIN, args, {
2139
+ env: { ...process.env },
2140
+ stdio: ["ignore", "pipe", "pipe"],
2141
+ });
2142
+ let stdout = "";
2143
+ let stderr = "";
2144
+ let timedOut = false;
2145
+ child.stdout.on("data", (chunk) => {
2146
+ stdout += chunk.toString();
2147
+ });
2148
+ child.stderr.on("data", (chunk) => {
2149
+ stderr += chunk.toString();
2150
+ });
2151
+ const timer = setTimeout(() => {
2152
+ timedOut = true;
2153
+ child.kill("SIGTERM");
2154
+ }, timeoutMs);
2155
+ child.on("error", () => {
2156
+ clearTimeout(timer);
2157
+ resolveFn({ stdout, stderr, exitCode: null, timedOut: false });
2158
+ });
2159
+ child.on("close", (code, signal) => {
2160
+ clearTimeout(timer);
2161
+ resolveFn({
2162
+ stdout,
2163
+ stderr,
2164
+ exitCode: code,
2165
+ signal: signal ?? null,
2166
+ timedOut,
2167
+ });
2168
+ });
2169
+ });
2170
+ }
2171
+ /** Production deps.readTextFile: UTF-8 read; missing/unreadable → undefined. */
2172
+ function readTextFileFromDisk(path) {
2173
+ return new Promise((resolvePromise) => {
2174
+ try {
2175
+ resolvePromise(readFileSync(path, "utf8"));
2176
+ }
2177
+ catch {
2178
+ resolvePromise(undefined);
2179
+ }
2180
+ });
2181
+ }
2182
+ /**
2183
+ * Production deps.writeTextFileAtomic: tmp + rename (atomic on POSIX).
2184
+ * mode 缺省时沿用目标文件现有权限,目标不存在则 0o600。Any failure →
2185
+ * {ok:false} (never throws); the tmp file is cleaned up best-effort.
2186
+ */
2187
+ function writeTextFileAtomicToDisk(path, text, mode) {
2188
+ return new Promise((resolvePromise) => {
2189
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
2190
+ try {
2191
+ let m = mode;
2192
+ if (m === undefined) {
2193
+ try {
2194
+ m = statSync(path).mode & 0o777;
2195
+ }
2196
+ catch {
2197
+ m = 0o600;
2198
+ }
2199
+ }
2200
+ mkdirSync(dirname(path), { recursive: true });
2201
+ writeFileSync(tmp, text, { encoding: "utf8", mode: m });
2202
+ renameSync(tmp, path);
2203
+ resolvePromise({ ok: true });
2204
+ }
2205
+ catch (err) {
2206
+ try {
2207
+ rmSync(tmp, { force: true });
2208
+ }
2209
+ catch {
2210
+ // best-effort cleanup
2211
+ }
2212
+ resolvePromise({
2213
+ ok: false,
2214
+ error: err instanceof Error ? err.message : String(err),
2215
+ });
2216
+ }
2217
+ });
2218
+ }
2219
+ /** Production deps.copyFile: full-file copy (backup / whole-file restore). */
2220
+ function copyFileOnDisk(src, dest) {
2221
+ return new Promise((resolvePromise) => {
2222
+ try {
2223
+ mkdirSync(dirname(dest), { recursive: true });
2224
+ copyFileSync(src, dest);
2225
+ resolvePromise({ ok: true });
2226
+ }
2227
+ catch (err) {
2228
+ resolvePromise({
2229
+ ok: false,
2230
+ error: err instanceof Error ? err.message : String(err),
2231
+ });
2232
+ }
2233
+ });
2234
+ }
2235
+ /**
2236
+ * Production deps.queryLastSessionModel: sqlite3 -readonly -json against
2237
+ * HERMES_STATE_DB_PATH for the newest session_model_usage row (best-effort
2238
+ * verification; any failure → undefined). Same spawn skeleton as
2239
+ * readCcSwitchProvider.
2240
+ */
2241
+ function queryLastSessionModelFromDb() {
2242
+ return new Promise((resolvePromise) => {
2243
+ const sql = "SELECT model, billing_provider AS provider FROM session_model_usage ORDER BY last_seen DESC LIMIT 1";
2244
+ const child = spawn("sqlite3", ["-readonly", "-json", HERMES_STATE_DB_PATH, sql], {
2245
+ stdio: ["ignore", "pipe", "pipe"],
2246
+ });
2247
+ let stdout = "";
2248
+ child.stdout.on("data", (chunk) => {
2249
+ stdout += chunk.toString();
2250
+ });
2251
+ child.on("error", () => resolvePromise(undefined));
2252
+ child.on("close", (code) => {
2253
+ if (code !== 0) {
2254
+ resolvePromise(undefined);
2255
+ return;
2256
+ }
2257
+ try {
2258
+ const parsed = JSON.parse(stdout.trim() || "[]");
2259
+ if (!Array.isArray(parsed) || parsed.length === 0) {
2260
+ resolvePromise(undefined);
2261
+ return;
2262
+ }
2263
+ const row = parsed[0];
2264
+ if (typeof row.model !== "string" || typeof row.provider !== "string") {
2265
+ resolvePromise(undefined);
2266
+ return;
2267
+ }
2268
+ resolvePromise({ model: row.model, provider: row.provider });
2269
+ }
2270
+ catch {
2271
+ resolvePromise(undefined);
2272
+ }
2273
+ });
2274
+ });
2275
+ }
2276
+ // ---------------------------------------------------------------------------
1390
2277
  // Main
1391
2278
  // ---------------------------------------------------------------------------
1392
2279
  const HELP = `Usage:
1393
2280
  gcli [claude] [options] [-- <args>] wrap the claude CLI (default backend)
1394
2281
  gcli agy [options] [-- <args>] wrap the agy CLI (explicit subcommand)
1395
2282
  gcli api [options] call an anthropic-compatible messages API
2283
+ gcli hermes <provider|status|rollback> [options] hermes agent 主模型一键切换
1396
2284
 
1397
2285
  gcli sits in front of three backends and gives skills a stable entry point:
1398
2286
  50k-char output truncation (agy/claude; api only when non-stream), a hard
@@ -1461,6 +2349,13 @@ api backend (\`gcli api ...\`) — pure HTTP, no agent, no subprocess:
1461
2349
  --max-tokens <n> Output token budget (default 80000)
1462
2350
  --timeout <ms> Idle + absolute timeout in ms (default 300000, max 1800000)
1463
2351
  --stream|--no-stream Stream SSE and aggregate live (default stream)
2352
+ --thinking <mode> auto|off|on (default auto = omit the thinking field,
2353
+ endpoint default applies; off sends disabled; on
2354
+ sends enabled with budget = max_tokens/2, needs
2355
+ --max-tokens >= 2048)
2356
+ --retry <n> Auto retries for transient failures: network errors,
2357
+ HTTP 408/429/5xx, malformed JSON, empty content
2358
+ (default 1, max 5; exponential backoff 400ms..4s)
1464
2359
  --version Print the api backend identity
1465
2360
  --help Show this help
1466
2361
 
@@ -1468,8 +2363,38 @@ api backend (\`gcli api ...\`) — pure HTTP, no agent, no subprocess:
1468
2363
  - No --cwd (no file operations; supplied --cwd is warned + ignored).
1469
2364
  - --yolo/--sandbox are rejected (they are agent flags; api has no agent).
1470
2365
  - Unknown flags exit 2 (strict; nothing to forward to).
1471
- - thinking is left ENABLED (k3 quality source); use a sufficient
1472
- --max-tokens budget (default 80000) to cover thinking + text.
2366
+ - thinking auto keeps the endpoint's own default. Beware bigmodel GLM
2367
+ endpoints: thinking defaults ON there and shares the max_tokens budget
2368
+ with text, so a small explicit budget can yield a thinking-only response
2369
+ ("no text content ... blocks=[thinking], stop_reason=max_tokens").
2370
+ Fix: raise --max-tokens or pass --thinking off (k3 quality workflows:
2371
+ keep auto/on + a generous budget). Deterministic failures are never
2372
+ retried; transient ones (network/408/429/5xx/empty body) are, with each
2373
+ attempt noted on stderr.
2374
+
2375
+ hermes backend (\`gcli hermes ...\`) — hermes agent 主模型/provider 一键切换:
2376
+ <provider> cc-switch provider 名(exact/case/substring 三层匹配);
2377
+ TTY 下省略则弹出选择菜单;非 TTY 省略 → exit 2 零交互
2378
+ status 当前 model 段 + .env key 存在性 + 上次切换记录
2379
+ rollback 切回上一个 provider(from/to 互换写回,支持来回 toggle)
2380
+ --model <name> 覆盖模型名(自动剥 [1M] 后缀),并持久化进 registry
2381
+ --dry-run 只输出计划:零写入零 spawn(.env 行显示 KEY=<redacted>)
2382
+ --no-verify 跳过切换后的 hermes -z ping 验证
2383
+ --keep-on-fail 验证/网关失败时不自动回滚(默认自动回滚)
2384
+
2385
+ Notes:
2386
+ - 切换流程 = 备份 config.yaml → 改 model/providers 段(原子写)→ .env
2387
+ upsert key(0o600)→ cron 重 pin(仅 enabled 且 pin 在旧 provider 的
2388
+ job,单条失败 warn 继续)→ 重启网关 → hermes -z ping 验证(60s 硬门槛)
2389
+ + state.db session_model_usage best-effort 比对。
2390
+ - 验证或网关失败默认自动回滚整链路(config/.env/cron pin/网关)。
2391
+ - 备份命名 config.yaml.bak-before-<id>-<epoch>,写入均 tmp+rename 原子写。
2392
+ - registry: ~/.config/gcli/hermes-providers.json(cc-switch 名 → id/keyEnv
2393
+ /modelOverride);state: ~/.config/gcli/hermes-state.json(0o600,含旧
2394
+ token 的 prevValue 供 rollback)。
2395
+ - 输出红线:任何 stdout/stderr/dry-run 计划只打印 key 名,永不打印 token 值。
2396
+ - 绝不修改 ~/.claude/settings.json 与 cc-switch.db(后者只读)。
2397
+ - config.yaml 结构不认识(缺 model:/providers: 段、意外嵌套)→ 报错零写盘。
1473
2398
 
1474
2399
  Exit codes: 0 success | 1 backend error / timeout / empty output | 2 bad args
1475
2400
  | N (interactive mode: child's exit code passed through unchanged)`;
@@ -1870,7 +2795,7 @@ async function resolveApiProviderEnv(providerName, model, deps) {
1870
2795
  envResult.env.ANTHROPIC_MODEL ??
1871
2796
  envResult.env.ANTHROPIC_DEFAULT_SONNET_MODEL ??
1872
2797
  envResult.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
1873
- const resolvedModel = rawModel?.replace(/\[[^\]]*\]$/, "");
2798
+ const resolvedModel = rawModel === undefined ? undefined : stripContextSuffix(rawModel);
1874
2799
  if (!baseUrl) {
1875
2800
  return err(1, "gcli: provider env is missing ANTHROPIC_BASE_URL");
1876
2801
  }
@@ -1938,6 +2863,9 @@ async function runApiBackend(parsed, deps) {
1938
2863
  prompt,
1939
2864
  stream: parsed.stream,
1940
2865
  timeoutMs: parsed.timeoutMs,
2866
+ // auto = omit the thinking field entirely (endpoint default)
2867
+ thinking: parsed.thinking === "auto" ? undefined : parsed.thinking,
2868
+ retries: parsed.retries,
1941
2869
  };
1942
2870
  const outcome = await deps.runApi(req);
1943
2871
  // Empty output is a backend error (exit 1), consistent with the other
@@ -1951,6 +2879,494 @@ async function runApiBackend(parsed, deps) {
1951
2879
  }
1952
2880
  return wrap(outcome);
1953
2881
  }
2882
+ // ---------------------------------------------------------------------------
2883
+ // hermes backend — orchestration (status / rollback / switch)
2884
+ // ---------------------------------------------------------------------------
2885
+ const HERMES_USAGE = "gcli hermes <provider|status|rollback> [--model <m>] [--dry-run] [--no-verify] [--keep-on-fail]";
2886
+ function hermesFail(exitCode, stderr) {
2887
+ return { exitCode, stdout: "", stderr };
2888
+ }
2889
+ /** Read the active (uncommented) value of KEY from a .env text, or null. */
2890
+ function envValueOf(envText, key) {
2891
+ const prefix = `${key}=`;
2892
+ for (const line of envText.split("\n")) {
2893
+ if (line.startsWith(prefix))
2894
+ return line.slice(prefix.length);
2895
+ }
2896
+ return null;
2897
+ }
2898
+ /**
2899
+ * `gcli hermes status`:当前 model 段 + .env key 存在性 + 上次切换记录。
2900
+ * 无切换历史时优雅显示(exit 0)。config 不可读/不可解析 → exit 1。
2901
+ */
2902
+ async function hermesStatus(deps) {
2903
+ const configText = await deps.readTextFile(HERMES_CONFIG_PATH);
2904
+ if (configText === undefined) {
2905
+ return hermesFail(1, "gcli: hermes: 无法读取 ~/.hermes/config.yaml");
2906
+ }
2907
+ const info = parseHermesConfig(configText);
2908
+ if ("error" in info) {
2909
+ return hermesFail(1, `gcli: hermes: ${info.error}`);
2910
+ }
2911
+ const provider = info.info.model.provider ?? "(unset)";
2912
+ const model = info.info.model.default ?? "(unset)";
2913
+ const baseUrl = info.info.model.base_url ?? "(unset)";
2914
+ // .env key 存在性:按当前 provider id 反查 registry/seed 的 keyEnv
2915
+ const regText = await deps.readTextFile(HERMES_PROVIDERS_REGISTRY_PATH);
2916
+ const registry = regText === undefined ? {} : parseHermesRegistry(regText);
2917
+ const known = [
2918
+ ...Object.values(registry),
2919
+ ...Object.values(HERMES_PROVIDER_SEEDS),
2920
+ ];
2921
+ const entry = known.find((e) => e.id === provider);
2922
+ let envLine = "env_key: unknown(provider 不在 registry/seed 中)";
2923
+ if (entry !== undefined) {
2924
+ const envText = await deps.readTextFile(HERMES_ENV_PATH);
2925
+ const present = envText !== undefined && envValueOf(envText, entry.keyEnv) !== null;
2926
+ envLine = `env_key: ${entry.keyEnv} ${present ? "present" : "missing"}`;
2927
+ }
2928
+ let lastLine = "last_switch: none";
2929
+ const stateText = await deps.readTextFile(HERMES_STATE_PATH);
2930
+ if (stateText !== undefined) {
2931
+ const st = parseHermesStateFile(stateText);
2932
+ if ("state" in st) {
2933
+ const s = st.state.lastSwitch;
2934
+ lastLine = `last_switch: ${s.from.id} -> ${s.to.id}(${s.from.model} -> ${s.to.model})@ ${new Date(s.ts).toISOString()}`;
2935
+ }
2936
+ else {
2937
+ lastLine = "last_switch: (state file malformed)";
2938
+ }
2939
+ }
2940
+ return {
2941
+ exitCode: 0,
2942
+ stdout: [
2943
+ `provider: ${provider}`,
2944
+ `model: ${model}`,
2945
+ `base_url: ${baseUrl}`,
2946
+ envLine,
2947
+ lastLine,
2948
+ ].join("\n"),
2949
+ stderr: "",
2950
+ };
2951
+ }
2952
+ /**
2953
+ * 回滚执行体(rollback 命令与切换失败自动回滚共用):
2954
+ * 快照现状 config → copyFile 整文件恢复 → .env 回写/删行 → cron 逐条回放旧
2955
+ * pin(单条失败 warn 继续)→ 网关重启(stop 失败仅 warn,start 失败=失败)→
2956
+ * ping 验证(可选)。全程不 throw。
2957
+ */
2958
+ async function performHermesRollback(s, deps, verify, logs) {
2959
+ let ok = true;
2960
+ let snapshotPath;
2961
+ const snap = `${HERMES_CONFIG_PATH}.bak-before-${s.from.id}-${Math.floor(Date.now() / 1000)}`;
2962
+ const snapR = await deps.copyFile(HERMES_CONFIG_PATH, snap);
2963
+ if (snapR.ok) {
2964
+ snapshotPath = snap;
2965
+ }
2966
+ else {
2967
+ logs.push(`gcli: hermes: rollback: warn: 现状快照失败(toggle 将不可用): ${snapR.error}`);
2968
+ }
2969
+ const envText = (await deps.readTextFile(HERMES_ENV_PATH)) ?? "";
2970
+ const currentEnvValue = envValueOf(envText, s.env.key);
2971
+ const rc = await deps.copyFile(s.configBackup, HERMES_CONFIG_PATH);
2972
+ if (!rc.ok) {
2973
+ logs.push(`gcli: hermes: rollback: 备份恢复失败(${s.configBackup}): ${rc.error}`);
2974
+ return { ok: false, snapshotPath, currentEnvValue };
2975
+ }
2976
+ logs.push("gcli: hermes: rollback: config.yaml 已整文件恢复");
2977
+ const we = await deps.writeTextFileAtomic(HERMES_ENV_PATH, upsertEnvLines(envText, s.env.key, s.env.prevValue), 0o600);
2978
+ if (!we.ok) {
2979
+ logs.push(`gcli: hermes: rollback: .env 回写失败: ${we.error}`);
2980
+ ok = false;
2981
+ }
2982
+ else {
2983
+ logs.push(`gcli: hermes: rollback: .env 已回写 ${s.env.key}`);
2984
+ }
2985
+ for (const c of s.cronRepinned) {
2986
+ const args = ["cron", "edit", c.jobId, "--provider", c.prevProvider];
2987
+ if (c.prevModel !== null)
2988
+ args.push("--model", c.prevModel);
2989
+ const r = await deps.runHermes(args, DEFAULT_TIMEOUT_MS);
2990
+ if (r.exitCode !== 0) {
2991
+ logs.push(`gcli: hermes: rollback: warn: cron edit ${c.jobId} 回放失败(继续): ${(r.stderr || r.stdout).trim().slice(0, 200)}`);
2992
+ }
2993
+ else {
2994
+ logs.push(`gcli: hermes: rollback: cron ${c.jobId} 已回 pin → ${c.prevProvider}`);
2995
+ }
2996
+ }
2997
+ const gst = await deps.runHermes(["gateway", "stop"], DEFAULT_TIMEOUT_MS);
2998
+ if (gst.exitCode !== 0) {
2999
+ logs.push("gcli: hermes: rollback: warn: gateway stop 非零退出(网关可能本就没跑),继续 start");
3000
+ }
3001
+ const gsa = await deps.runHermes(["gateway", "start"], DEFAULT_TIMEOUT_MS);
3002
+ if (gsa.exitCode !== 0) {
3003
+ logs.push(`gcli: hermes: rollback: gateway start 失败: ${(gsa.stderr || gsa.stdout).trim().slice(0, 300)}`);
3004
+ ok = false;
3005
+ }
3006
+ else {
3007
+ logs.push("gcli: hermes: rollback: 网关已重启");
3008
+ }
3009
+ if (verify) {
3010
+ const ping = await deps.runHermes(["-z", "ping"], HERMES_VERIFY_TIMEOUT_MS);
3011
+ if (ping.timedOut === true || ping.exitCode !== 0) {
3012
+ logs.push("gcli: hermes: rollback: 回滚后验证 ping 未通过");
3013
+ ok = false;
3014
+ }
3015
+ else {
3016
+ logs.push("gcli: hermes: rollback: 验证 ping 通过");
3017
+ }
3018
+ }
3019
+ return { ok, snapshotPath, currentEnvValue };
3020
+ }
3021
+ /** `gcli hermes rollback`:无 state → exit 1;畸形 → exit 1(不猜)。 */
3022
+ async function hermesRollback(parsed, deps) {
3023
+ const stateText = await deps.readTextFile(HERMES_STATE_PATH);
3024
+ if (stateText === undefined) {
3025
+ return hermesFail(1, "gcli: hermes: 无可回滚的切换历史 / no switch history to roll back");
3026
+ }
3027
+ const st = parseHermesStateFile(stateText);
3028
+ if ("error" in st) {
3029
+ return hermesFail(1, `gcli: hermes: state 文件畸形,拒绝回滚(不猜): ${st.error}`);
3030
+ }
3031
+ const s = st.state.lastSwitch;
3032
+ const logs = [
3033
+ `gcli: hermes: 回滚 ${s.to.id}(${s.to.model})-> ${s.from.id}(${s.from.model})…`,
3034
+ ];
3035
+ const r = await performHermesRollback(s, deps, parsed.verify, logs);
3036
+ if (!r.ok || r.snapshotPath === undefined) {
3037
+ logs.push("gcli: hermes: 回滚未完全成功(详见上方日志)");
3038
+ return { exitCode: 1, stdout: "", stderr: logs.join("\n") };
3039
+ }
3040
+ // toggle:from/to 互换写回;configBackup 指向本次快照(= 旧 to 的配置)。
3041
+ const swapped = {
3042
+ lastSwitch: {
3043
+ ts: Date.now(),
3044
+ ccName: s.ccName,
3045
+ to: s.from,
3046
+ from: s.to,
3047
+ configBackup: r.snapshotPath,
3048
+ env: { key: s.env.key, prevValue: r.currentEnvValue },
3049
+ cronRepinned: s.cronRepinned,
3050
+ },
3051
+ };
3052
+ const ws = await deps.writeTextFileAtomic(HERMES_STATE_PATH, serializeHermesStateFile(swapped), 0o600);
3053
+ if (!ws.ok) {
3054
+ logs.push(`gcli: hermes: warn: state 写回失败(toggle 将不可用): ${ws.error}`);
3055
+ }
3056
+ logs.push(`gcli: hermes: 回滚完成,当前 ${s.from.id} / ${s.from.model}`);
3057
+ return { exitCode: 0, stdout: "", stderr: logs.join("\n") };
3058
+ }
3059
+ /**
3060
+ * `gcli hermes <provider>` 一键切换(设计文档流程 0-9)。所有失败 resolve
3061
+ * RunOutcome,禁止 throw。进度/诊断全走 stderr;stdout 仅 dry-run 计划。
3062
+ * token 红线:任何输出只打印 key 名,token 值永不出现。
3063
+ */
3064
+ async function hermesSwitch(parsed, deps) {
3065
+ const logs = [];
3066
+ // 0. 目标 provider 名:位置参数,或 TTY picker;非 TTY 缺参 → exit 2 零交互
3067
+ let ccQuery = parsed.provider;
3068
+ if (ccQuery === undefined) {
3069
+ if (!deps.isInteractive()) {
3070
+ return hermesFail(2, `gcli: hermes 需要 provider 位置参数(或保留字 status/rollback);非 TTY 下零交互。\nusage: ${HERMES_USAGE}`);
3071
+ }
3072
+ const lookup0 = await deps.readCcSwitchProvider();
3073
+ if (!lookup0.ok) {
3074
+ return hermesFail(1, `gcli: hermes: ${lookup0.message}`);
3075
+ }
3076
+ if (lookup0.providers.length === 0) {
3077
+ return hermesFail(1, "gcli: hermes: no cc-switch providers configured");
3078
+ }
3079
+ const picked = await deps.pickProvider(lookup0.providers.map((p) => ({ name: p.name })), 0);
3080
+ if (picked.kind === "skip") {
3081
+ return {
3082
+ exitCode: 0,
3083
+ stdout: "",
3084
+ stderr: "gcli: hermes: 已取消,未切换",
3085
+ };
3086
+ }
3087
+ ccQuery = picked.entry.name;
3088
+ }
3089
+ // 1. cc-switch 取 provider(复用三层匹配)
3090
+ const lookup = await deps.readCcSwitchProvider();
3091
+ if (!lookup.ok) {
3092
+ return hermesFail(1, `gcli: hermes: ${lookup.message}`);
3093
+ }
3094
+ const match = matchProviderName(ccQuery, lookup.providers.map((p) => p.name));
3095
+ if ("none" in match) {
3096
+ return hermesFail(2, `gcli: hermes: provider not found / 未知 provider: ${ccQuery}`);
3097
+ }
3098
+ if ("ambiguous" in match) {
3099
+ const candidates = match.ambiguous
3100
+ .map((n) => `"${n}"`)
3101
+ .sort()
3102
+ .join(", ");
3103
+ return hermesFail(2, `gcli: hermes: ambiguous provider: ${candidates}`);
3104
+ }
3105
+ const ccName = match.matched;
3106
+ const target = lookup.providers.find((p) => p.name === ccName);
3107
+ if (target === undefined) {
3108
+ return hermesFail(2, `gcli: hermes: provider not found: ${ccQuery}`);
3109
+ }
3110
+ const cfgJson = typeof target.settingsConfig === "string"
3111
+ ? target.settingsConfig
3112
+ : JSON.stringify(target.settingsConfig);
3113
+ const envResult = extractProviderEnv(cfgJson);
3114
+ if ("error" in envResult) {
3115
+ return hermesFail(1, `gcli: hermes: ${envResult.error}`);
3116
+ }
3117
+ const penv = envResult.env;
3118
+ const baseUrl = penv.ANTHROPIC_BASE_URL;
3119
+ const token = penv.ANTHROPIC_AUTH_TOKEN ?? penv.ANTHROPIC_API_KEY;
3120
+ if (baseUrl === undefined || baseUrl === "") {
3121
+ return hermesFail(1, "gcli: hermes: provider env is missing ANTHROPIC_BASE_URL");
3122
+ }
3123
+ if (token === undefined || token === "") {
3124
+ return hermesFail(1, "gcli: hermes: provider env is missing ANTHROPIC_AUTH_TOKEN(无 token)");
3125
+ }
3126
+ // 换行消毒:cc-switch 值将写入行级文件(.env/config.yaml),裸换行会破坏行结构
3127
+ for (const [label, value] of [
3128
+ ["name", ccName],
3129
+ ["ANTHROPIC_BASE_URL", baseUrl],
3130
+ ["token", token],
3131
+ ]) {
3132
+ if (containsLineBreak(value)) {
3133
+ return hermesFail(1, `gcli: hermes: cc-switch provider ${label} 含裸换行,拒绝写入(宁报错不猜)`);
3134
+ }
3135
+ }
3136
+ // 2. 读现状(= rollback 的 from)并验证结构
3137
+ const configText = await deps.readTextFile(HERMES_CONFIG_PATH);
3138
+ if (configText === undefined) {
3139
+ return hermesFail(1, "gcli: hermes: 无法读取 ~/.hermes/config.yaml");
3140
+ }
3141
+ const info = parseHermesConfig(configText);
3142
+ if ("error" in info) {
3143
+ return hermesFail(1, `gcli: hermes: ${info.error} —— 无法安全解析 config.yaml,拒绝写入`);
3144
+ }
3145
+ const fromId = info.info.model.provider ?? "";
3146
+ const fromModel = info.info.model.default ?? "";
3147
+ if (fromId === "" || fromModel === "") {
3148
+ return hermesFail(1, "gcli: hermes: config.yaml model 段缺 provider/default,拒绝写入");
3149
+ }
3150
+ const from = {
3151
+ id: fromId,
3152
+ model: fromModel,
3153
+ base_url: info.info.model.base_url ?? "",
3154
+ };
3155
+ // 3. registry 解析 hermes 字段(文件 → seed → 推导+写回)
3156
+ const regText = await deps.readTextFile(HERMES_PROVIDERS_REGISTRY_PATH);
3157
+ const registry = regText === undefined ? {} : parseHermesRegistry(regText);
3158
+ let entry = registry[ccName] ?? HERMES_PROVIDER_SEEDS[ccName];
3159
+ let registryDirty = false;
3160
+ if (entry === undefined) {
3161
+ const id = deriveHermesId(ccName);
3162
+ if (id === "") {
3163
+ return hermesFail(1, `gcli: hermes: 无法从 "${ccName}" 推导 provider id`);
3164
+ }
3165
+ if (info.info.providerIds.includes(id)) {
3166
+ return hermesFail(1, `gcli: hermes: 推导 id "${id}" 与 config.yaml 已有 providers 条目冲突,拒绝覆盖;请在 ${HERMES_PROVIDERS_REGISTRY_PATH} 手工登记`);
3167
+ }
3168
+ entry = { id, keyEnv: deriveKeyEnv(id) };
3169
+ registry[ccName] = entry;
3170
+ registryDirty = true;
3171
+ }
3172
+ // 4. 模型名推导:--model > registry modelOverride > cc-switch(剥 [1M] 后缀)
3173
+ const ccModel = penv.ANTHROPIC_MODEL !== undefined
3174
+ ? stripContextSuffix(penv.ANTHROPIC_MODEL)
3175
+ : undefined;
3176
+ if (parsed.model !== undefined) {
3177
+ // --model 持久化进 registry 的 modelOverride
3178
+ entry = { ...entry, modelOverride: parsed.model };
3179
+ registry[ccName] = entry;
3180
+ registryDirty = true;
3181
+ }
3182
+ const model = parsed.model ?? entry.modelOverride ?? ccModel;
3183
+ if (model === undefined || model === "") {
3184
+ return hermesFail(1, "gcli: hermes: no model resolved(cc-switch 无 ANTHROPIC_MODEL,可用 --model 指定)");
3185
+ }
3186
+ if (containsLineBreak(model)) {
3187
+ return hermesFail(1, "gcli: hermes: 模型名含裸换行,拒绝写入(宁报错不猜)");
3188
+ }
3189
+ const to = { id: entry.id, model, base_url: baseUrl };
3190
+ // 5. 构造编辑(纯函数;结构异常 → 报错零写盘)
3191
+ const edit = {
3192
+ model: { default: model, provider: to.id, base_url: baseUrl },
3193
+ provider: {
3194
+ id: to.id,
3195
+ name: ccName,
3196
+ base_url: baseUrl,
3197
+ transport: HERMES_TRANSPORT,
3198
+ key_env: entry.keyEnv,
3199
+ default_model: model,
3200
+ },
3201
+ };
3202
+ const edited = editHermesConfig(configText, edit);
3203
+ if ("error" in edited) {
3204
+ return hermesFail(1, `gcli: hermes: ${edited.error} —— 无法安全识别 config.yaml 结构,拒绝写入 / refusing to write`);
3205
+ }
3206
+ // cron 重 pin 计划(读 jobs.json;读不到/畸形 → warn 跳过,不阻塞)
3207
+ let cronPlan = [];
3208
+ let cronWarn;
3209
+ const jobsText = await deps.readTextFile(HERMES_CRON_JOBS_PATH);
3210
+ if (jobsText === undefined) {
3211
+ cronWarn = "无法读取 cron jobs.json,跳过重 pin";
3212
+ }
3213
+ else {
3214
+ try {
3215
+ cronPlan = buildCronRepinPlan(JSON.parse(jobsText), from.id);
3216
+ }
3217
+ catch {
3218
+ cronWarn = "cron jobs.json 不是合法 JSON,跳过重 pin";
3219
+ }
3220
+ }
3221
+ // 6. dry-run:输出计划,零写入零 spawn(备份路径仅为预告)
3222
+ const backupPath = `${HERMES_CONFIG_PATH}.bak-before-${to.id}-${Math.floor(Date.now() / 1000)}`;
3223
+ if (parsed.dryRun) {
3224
+ const planLines = [
3225
+ "gcli hermes 切换计划(dry-run,未做任何写入):",
3226
+ ` provider: ${to.id}(cc-switch: ${ccName})`,
3227
+ ` model: ${model}`,
3228
+ ` base_url: ${baseUrl}`,
3229
+ ` backup: ${backupPath}`,
3230
+ ` config.yaml: model.default=${model} model.provider=${to.id} model.base_url=${baseUrl}; providers upsert ${to.id}`,
3231
+ ` .env: upsert ${entry.keyEnv}=<redacted>`,
3232
+ ` cron: repin ${cronPlan.length} job(s) ${from.id} -> ${to.id}${cronPlan.length > 0 ? `(${cronPlan.map((c) => c.jobId).join(", ")})` : ""}`,
3233
+ " gateway: hermes gateway stop && hermes gateway start",
3234
+ ` verify: ${parsed.verify ? `hermes -z ping(${HERMES_VERIFY_TIMEOUT_MS}ms 超时)+ state.db best-effort 比对` : "已跳过(--no-verify)"}`,
3235
+ ];
3236
+ if (cronWarn !== undefined)
3237
+ planLines.push(` warn: ${cronWarn}`);
3238
+ return { exitCode: 0, stdout: planLines.join("\n"), stderr: "" };
3239
+ }
3240
+ // 7. registry 写回(推导/modelOverride 持久化;best-effort)
3241
+ if (registryDirty) {
3242
+ const w = await deps.writeTextFileAtomic(HERMES_PROVIDERS_REGISTRY_PATH, serializeHermesRegistry(registry));
3243
+ if (!w.ok) {
3244
+ logs.push(`gcli: hermes: warn: registry 写回失败(不影响本次切换): ${w.error}`);
3245
+ }
3246
+ }
3247
+ // 8. 备份先行 → 原子写 config
3248
+ const bak = await deps.copyFile(HERMES_CONFIG_PATH, backupPath);
3249
+ if (!bak.ok) {
3250
+ return hermesFail(1, `gcli: hermes: 备份失败,中止切换(未写入任何内容): ${bak.error}`);
3251
+ }
3252
+ logs.push(`gcli: hermes: 已备份 config.yaml → ${backupPath}`);
3253
+ const wc = await deps.writeTextFileAtomic(HERMES_CONFIG_PATH, edited.text);
3254
+ if (!wc.ok) {
3255
+ return hermesFail(1, `gcli: hermes: config.yaml 写入失败(备份在 ${backupPath}): ${wc.error}`);
3256
+ }
3257
+ logs.push(`gcli: hermes: config.yaml 已切换到 ${to.id} / ${model}`);
3258
+ // 9. .env upsert(保持 0o600;失败则从备份恢复 config 避免半切换态)
3259
+ const envText = (await deps.readTextFile(HERMES_ENV_PATH)) ?? "";
3260
+ const prevValue = envValueOf(envText, entry.keyEnv);
3261
+ const we = await deps.writeTextFileAtomic(HERMES_ENV_PATH, upsertEnvLines(envText, entry.keyEnv, token), 0o600);
3262
+ if (!we.ok) {
3263
+ await deps.copyFile(backupPath, HERMES_CONFIG_PATH);
3264
+ return hermesFail(1, `gcli: hermes: .env 写入失败,已从备份恢复 config.yaml: ${we.error}`);
3265
+ }
3266
+ logs.push(`gcli: hermes: .env 已 upsert ${entry.keyEnv}=<redacted>`);
3267
+ // 10. cron 重 pin(单条失败 warn 继续,不触发回滚)
3268
+ const cronRepinned = [];
3269
+ if (cronWarn !== undefined) {
3270
+ logs.push(`gcli: hermes: warn: ${cronWarn}`);
3271
+ }
3272
+ for (const c of cronPlan) {
3273
+ cronRepinned.push(c);
3274
+ const r = await deps.runHermes(["cron", "edit", c.jobId, "--provider", to.id, "--model", model], DEFAULT_TIMEOUT_MS);
3275
+ if (r.exitCode !== 0) {
3276
+ logs.push(`gcli: hermes: warn: cron edit ${c.jobId} 失败(继续,不触发回滚): ${(r.stderr || r.stdout).trim().slice(0, 200)}`);
3277
+ }
3278
+ else {
3279
+ logs.push(`gcli: hermes: cron ${c.jobId} 重 pin → ${to.id}`);
3280
+ }
3281
+ }
3282
+ // 11. 网关重启(stop 失败仅 warn;start 失败 = 网关失败)
3283
+ let gatewayFailed;
3284
+ const gst = await deps.runHermes(["gateway", "stop"], DEFAULT_TIMEOUT_MS);
3285
+ if (gst.exitCode !== 0) {
3286
+ logs.push("gcli: hermes: warn: gateway stop 非零退出(网关可能本就没跑),继续 start");
3287
+ }
3288
+ const gsa = await deps.runHermes(["gateway", "start"], DEFAULT_TIMEOUT_MS);
3289
+ if (gsa.exitCode !== 0) {
3290
+ gatewayFailed = `hermes gateway start 失败: ${(gsa.stderr || gsa.stdout).trim().slice(0, 300)}`;
3291
+ logs.push(`gcli: hermes: ${gatewayFailed}`);
3292
+ }
3293
+ else {
3294
+ logs.push("gcli: hermes: 网关已重启");
3295
+ }
3296
+ // 12. 验证:(a) ping 硬门槛;(b) state.db 比对 best-effort(不一致仅 warn)
3297
+ let verifyFailed;
3298
+ if (parsed.verify) {
3299
+ const ping = await deps.runHermes(["-z", "ping"], HERMES_VERIFY_TIMEOUT_MS);
3300
+ if (ping.timedOut === true) {
3301
+ verifyFailed = `hermes -z ping 超时(${HERMES_VERIFY_TIMEOUT_MS}ms)`;
3302
+ }
3303
+ else if (ping.exitCode !== 0) {
3304
+ verifyFailed = `hermes -z ping 退出码 ${ping.exitCode}: ${(ping.stderr || ping.stdout).trim().slice(0, 300)}`;
3305
+ }
3306
+ else {
3307
+ logs.push("gcli: hermes: 验证 ping 通过");
3308
+ }
3309
+ if (verifyFailed === undefined) {
3310
+ const last = await deps.queryLastSessionModel();
3311
+ if (last === undefined) {
3312
+ logs.push("gcli: hermes: warn: state.db 比对不可用(best-effort,跳过)");
3313
+ }
3314
+ else if (last.provider !== to.id) {
3315
+ logs.push(`gcli: hermes: warn: state.db 最新 session 的 billing_provider=${last.provider}(期望 ${to.id})——请人工复核`);
3316
+ }
3317
+ }
3318
+ }
3319
+ else {
3320
+ logs.push("gcli: hermes: 已跳过验证(--no-verify)");
3321
+ }
3322
+ const stateRecord = {
3323
+ lastSwitch: {
3324
+ ts: Date.now(),
3325
+ ccName,
3326
+ to,
3327
+ from,
3328
+ configBackup: backupPath,
3329
+ env: { key: entry.keyEnv, prevValue },
3330
+ cronRepinned,
3331
+ },
3332
+ };
3333
+ // 13. 失败 → 默认自动回滚(--keep-on-fail 抑制,但仍写 state 以便手动 rollback)
3334
+ const failedReason = gatewayFailed ?? verifyFailed;
3335
+ if (failedReason !== undefined) {
3336
+ logs.push(`gcli: hermes: 切换验证失败 / verification failed: ${failedReason}`);
3337
+ const ws = await deps.writeTextFileAtomic(HERMES_STATE_PATH, serializeHermesStateFile(stateRecord), 0o600);
3338
+ if (!ws.ok) {
3339
+ logs.push(`gcli: hermes: warn: state 文件写入失败: ${ws.error}`);
3340
+ }
3341
+ if (parsed.keepOnFail) {
3342
+ logs.push("gcli: hermes: --keep-on-fail 生效,保留现场(可 gcli hermes rollback 手动回滚)");
3343
+ return { exitCode: 1, stdout: "", stderr: logs.join("\n") };
3344
+ }
3345
+ logs.push("gcli: hermes: 开始自动回滚…");
3346
+ const rb = await performHermesRollback(stateRecord.lastSwitch, deps, parsed.verify, logs);
3347
+ logs.push(rb.ok
3348
+ ? "gcli: hermes: 验证失败,已自动回滚 / rolled back"
3349
+ : "gcli: hermes: 自动回滚未完全成功,请检查现场(备份文件仍在)");
3350
+ return { exitCode: 1, stdout: "", stderr: logs.join("\n") };
3351
+ }
3352
+ // 14. 成功 → 写 state(best-effort;失败仅 warn,切换本身已成功)
3353
+ const ws = await deps.writeTextFileAtomic(HERMES_STATE_PATH, serializeHermesStateFile(stateRecord), 0o600);
3354
+ if (!ws.ok) {
3355
+ logs.push(`gcli: hermes: warn: state 文件写入失败(rollback 将不可用): ${ws.error}`);
3356
+ }
3357
+ logs.push(`gcli: hermes: 切换完成 ${from.id} -> ${to.id}(${model})`);
3358
+ return { exitCode: 0, stdout: "", stderr: logs.join("\n") };
3359
+ }
3360
+ /** hermes 子命令入口:status / rollback / switch 三分支。 */
3361
+ async function runHermesBackend(parsed, deps) {
3362
+ if (parsed.provider !== undefined &&
3363
+ HERMES_RESERVED_WORDS.has(parsed.provider)) {
3364
+ return parsed.provider === "status"
3365
+ ? hermesStatus(deps)
3366
+ : hermesRollback(parsed, deps);
3367
+ }
3368
+ return hermesSwitch(parsed, deps);
3369
+ }
1954
3370
  /**
1955
3371
  * Route argv to the agy / claude / api backend via injectable deps (C1/C2).
1956
3372
  * Returns a {exitCode, stdout, stderr} outcome; main() owns process.exit.
@@ -1970,6 +3386,16 @@ export async function run(argv, deps) {
1970
3386
  }
1971
3387
  return runApiBackend(parsed, deps);
1972
3388
  }
3389
+ if (sub.subcommand === "hermes") {
3390
+ const parsed = parseHermesArgs(sub.rest);
3391
+ if ("error" in parsed) {
3392
+ return { exitCode: 2, stdout: "", stderr: `gcli: ${parsed.error}` };
3393
+ }
3394
+ if (parsed.help) {
3395
+ return { exitCode: 0, stdout: HELP, stderr: "" };
3396
+ }
3397
+ return runHermesBackend(parsed, deps);
3398
+ }
1973
3399
  const parsed = parseCliArgs(sub.rest);
1974
3400
  if (!isOk(parsed)) {
1975
3401
  return { exitCode: 2, stdout: "", stderr: `gcli: ${parsed.error}` };
@@ -1996,6 +3422,11 @@ async function main() {
1996
3422
  fetchProviderQuotas: (items) => fetchProviderQuotasHttp(items),
1997
3423
  readLastProvider: () => readLastProviderFromDisk(),
1998
3424
  writeLastProvider: (name) => writeLastProviderToDisk(name),
3425
+ runHermes: (args, timeoutMs) => runHermesProcess(args, timeoutMs ?? DEFAULT_TIMEOUT_MS),
3426
+ readTextFile: (path) => readTextFileFromDisk(path),
3427
+ writeTextFileAtomic: (path, text, mode) => writeTextFileAtomicToDisk(path, text, mode),
3428
+ copyFile: (src, dest) => copyFileOnDisk(src, dest),
3429
+ queryLastSessionModel: () => queryLastSessionModelFromDb(),
1999
3430
  };
2000
3431
  const r = await run(process.argv.slice(2), deps);
2001
3432
  if (r.stdout)