pitroom 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib.mjs CHANGED
@@ -208,7 +208,8 @@ var DENY_BASH = [
208
208
  "pitroom",
209
209
  "opencode",
210
210
  "claude",
211
- "codex"
211
+ "codex",
212
+ "gemini"
212
213
  ].map((c) => `Bash(${c}:*)`);
213
214
  var SECRETS = ["**/.env", "**/.env.*", "**/*.pem", "**/id_rsa*", "**/id_ed25519*"].map((p) => `Read(${p})`);
214
215
  function toolsFor(mode, web2) {
@@ -261,8 +262,8 @@ function failure(run, stderr, exitCode) {
261
262
  }
262
263
  function defaultModel() {
263
264
  try {
264
- const settings = JSON.parse(fs2.readFileSync(path2.join(os2.homedir(), ".claude", "settings.json"), "utf8"));
265
- return typeof settings.model === "string" ? settings.model : void 0;
265
+ const settings2 = JSON.parse(fs2.readFileSync(path2.join(os2.homedir(), ".claude", "settings.json"), "utf8"));
266
+ return typeof settings2.model === "string" ? settings2.model : void 0;
266
267
  } catch {
267
268
  return void 0;
268
269
  }
@@ -541,13 +542,285 @@ var codex = {
541
542
  doctor: doctor2
542
543
  };
543
544
 
544
- // src/backends/opencode/index.ts
545
+ // src/backends/gemini/index.ts
545
546
  import { spawnSync as spawnSync3 } from "node:child_process";
547
+ import fs4 from "node:fs";
548
+ import os5 from "node:os";
549
+ import path5 from "node:path";
550
+ import { fileURLToPath } from "node:url";
551
+
552
+ // src/core/store.ts
553
+ import os4 from "node:os";
554
+ import path4 from "node:path";
555
+ function home() {
556
+ if (process.env.PITROOM_HOME) return path4.resolve(process.env.PITROOM_HOME);
557
+ if (process.platform === "win32") {
558
+ return path4.join(process.env.LOCALAPPDATA ?? path4.join(os4.homedir(), "AppData", "Local"), "pitroom");
559
+ }
560
+ return path4.join(process.env.XDG_STATE_HOME ?? path4.join(os4.homedir(), ".local", "state"), "pitroom");
561
+ }
562
+
563
+ // src/backends/gemini/events.ts
564
+ var EDIT_TOOLS2 = /* @__PURE__ */ new Set(["replace", "write_file", "edit"]);
565
+ var DENIED2 = /policy|permission|not allowed|denied|blocked|disallowed|refus/i;
566
+ function parseEvents3(jsonl) {
567
+ const usage = {
568
+ input: 0,
569
+ output: 0,
570
+ reasoning: 0,
571
+ cacheRead: 0,
572
+ cacheWrite: 0,
573
+ total: 0,
574
+ steps: 0,
575
+ toolCalls: 0,
576
+ denied: 0
577
+ };
578
+ const tools = {};
579
+ const edits = [];
580
+ const pendingEdits = /* @__PURE__ */ new Map();
581
+ const timeline = [];
582
+ const stepOf = /* @__PURE__ */ new Map();
583
+ let sessionId;
584
+ let model;
585
+ let text = "";
586
+ let lastText = "";
587
+ let lastActivity;
588
+ let error;
589
+ let turns = 0;
590
+ let inTurn = false;
591
+ const flush = () => {
592
+ const said = text.trim();
593
+ text = "";
594
+ if (!said) return;
595
+ lastText = said;
596
+ lastActivity = `says: ${oneLine3(said)}`;
597
+ if (timeline.length < MAX_STEPS) timeline.push({ kind: "say", text: clip3(said, 600) });
598
+ };
599
+ const startTurn = () => {
600
+ if (!inTurn) turns++;
601
+ inTurn = true;
602
+ };
603
+ for (const line of jsonl.split("\n")) {
604
+ if (!line.trim()) continue;
605
+ let e;
606
+ try {
607
+ e = JSON.parse(line);
608
+ } catch {
609
+ continue;
610
+ }
611
+ if (!e || typeof e !== "object") continue;
612
+ if (e.type === "init") {
613
+ sessionId ??= e.session_id;
614
+ model ??= e.model;
615
+ } else if (e.type === "message") {
616
+ if (e.role !== "assistant") continue;
617
+ startTurn();
618
+ if (!e.delta && text) flush();
619
+ text += String(e.content ?? "");
620
+ } else if (e.type === "tool_use") {
621
+ flush();
622
+ lastText = "";
623
+ startTurn();
624
+ const name = String(e.tool_name ?? "tool");
625
+ const p = e.parameters ?? {};
626
+ usage.toolCalls++;
627
+ tools[name] = (tools[name] ?? 0) + 1;
628
+ const target = p.file_path ?? p.absolute_path ?? p.path ?? p.dir_path ?? p.pattern ?? p.command ?? p.query ?? p.url ?? p.prompt;
629
+ lastActivity = `${name} ${target ? oneLine3(String(target), 60) : ""}`.trim();
630
+ const file = p.file_path ?? p.absolute_path ?? p.path;
631
+ if (EDIT_TOOLS2.has(name) && file) pendingEdits.set(String(e.tool_id), String(file));
632
+ if (timeline.length < MAX_STEPS) {
633
+ const step = { kind: EDIT_TOOLS2.has(name) ? "edit" : name === "run_shell_command" ? "shell" : "tool", name, text: clip3(String(target ?? ""), 240) };
634
+ timeline.push(step);
635
+ stepOf.set(String(e.tool_id), step);
636
+ }
637
+ } else if (e.type === "tool_result") {
638
+ inTurn = false;
639
+ const ok = e.status === "success";
640
+ const step = stepOf.get(String(e.tool_id));
641
+ if (step) step.ok = ok;
642
+ if (!ok && DENIED2.test(`${e.error?.type ?? ""} ${e.error?.message ?? ""}`)) usage.denied++;
643
+ if (ok && pendingEdits.has(String(e.tool_id))) edits.push(pendingEdits.get(String(e.tool_id)));
644
+ } else if (e.type === "error") {
645
+ if (e.severity === "error") error ??= String(e.message ?? "Gemini CLI error");
646
+ } else if (e.type === "result") {
647
+ const s = e.stats ?? {};
648
+ const cached = num3(s.cached);
649
+ usage.cacheRead += cached;
650
+ usage.input += s.input !== void 0 ? num3(s.input) : Math.max(0, num3(s.input_tokens) - cached);
651
+ usage.output += num3(s.output_tokens);
652
+ usage.total += num3(s.total_tokens) || num3(s.input_tokens) + num3(s.output_tokens);
653
+ usage.toolCalls = Math.max(usage.toolCalls, num3(s.tool_calls));
654
+ model ??= Object.keys(s.models ?? {})[0];
655
+ if (e.status === "error") error ??= String(e.error?.message ?? e.error?.type ?? "Gemini CLI error");
656
+ }
657
+ }
658
+ flush();
659
+ usage.steps = turns;
660
+ return { sessionId, model, finalText: error ? "" : lastText, usage, tools, edits, lastActivity, timeline, error };
661
+ }
662
+ var clip3 = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
663
+ function num3(v) {
664
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
665
+ }
666
+ function oneLine3(s, max = 80) {
667
+ const t = s.replace(/\s+/g, " ").trim();
668
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
669
+ }
670
+
671
+ // src/backends/gemini/index.ts
672
+ var binary3 = () => findBinary("gemini", "PITROOM_GEMINI_BIN", ["~/.local/bin/gemini"]);
673
+ function gem(args, timeout = 6e4) {
674
+ const { command, prefix } = resolveCommand(binary3());
675
+ const r = spawnSync3(command, [...prefix, ...args], {
676
+ encoding: "utf8",
677
+ timeout,
678
+ stdio: ["ignore", "pipe", "pipe"],
679
+ maxBuffer: 16 * 1024 * 1024
680
+ });
681
+ return { ok: r.status === 0, out: r.stdout ?? "", err: r.stderr ?? "", missing: !!r.error };
682
+ }
683
+ var policyDir = () => path5.resolve(path5.dirname(fileURLToPath(import.meta.url)), "..", "policies", "gemini");
684
+ function workerHome() {
685
+ const root = path5.join(home(), "gemini-home");
686
+ const dir = path5.join(root, ".gemini");
687
+ const base = JSON.parse(fs4.readFileSync(path5.join(policyDir(), "worker-settings.json"), "utf8"));
688
+ const auth = settings().security?.auth;
689
+ const body = `${JSON.stringify(auth ? { ...base, security: { ...base.security, auth } } : base, null, 2)}
690
+ `;
691
+ const file = path5.join(dir, "settings.json");
692
+ try {
693
+ if (fs4.readFileSync(file, "utf8") === body) return root;
694
+ } catch {
695
+ }
696
+ fs4.mkdirSync(dir, { recursive: true });
697
+ fs4.writeFileSync(file, body);
698
+ for (const name of ["oauth_creds.json", "google_accounts.json"]) {
699
+ const from = path5.join(geminiHome(), name);
700
+ const to = path5.join(dir, name);
701
+ try {
702
+ if (fs4.existsSync(from) && !fs4.existsSync(to)) fs4.symlinkSync(from, to);
703
+ } catch {
704
+ }
705
+ }
706
+ return root;
707
+ }
708
+ function invocation3(req) {
709
+ const dir = policyDir();
710
+ const policies = ["base.toml", ...req.web ? [] : ["no-web.toml"], ...req.mode === "read" ? [] : ["shell.toml"]];
711
+ const args = [
712
+ // A prompt that starts with "-" would be read as an option: a leading space keeps it a value.
713
+ "--prompt",
714
+ req.prompt.startsWith("-") ? ` ${req.prompt}` : req.prompt,
715
+ "--output-format",
716
+ "stream-json",
717
+ "--approval-mode",
718
+ req.mode === "read" ? "plan" : "auto_edit",
719
+ "-e",
720
+ "none"
721
+ ];
722
+ for (const p of policies) args.push("--policy", path5.join(dir, p));
723
+ if (process.env.PITROOM_GEMINI_TRUST === "1") args.push("--skip-trust");
724
+ const [model] = (req.model ?? "").split("#");
725
+ if (model) args.push("--model", model);
726
+ const { command, prefix } = resolveCommand(binary3());
727
+ const env = { GEMINI_CLI_HOME: workerHome() };
728
+ const trusted = path5.join(geminiHome(), "trustedFolders.json");
729
+ if (fs4.existsSync(trusted)) env.GEMINI_CLI_TRUSTED_FOLDERS_PATH = trusted;
730
+ return { command, args: [...prefix, ...args], env };
731
+ }
732
+ function classify3(message) {
733
+ if (/IneligibleTier|no longer supported for Gemini|error authenticating|not logged in|please (log ?in|sign in)|credentials|api key|UNAUTHENTICATED|PERMISSION_DENIED|\b40[13]\b|unauthori[sz]ed/i.test(message)) {
734
+ return "auth";
735
+ }
736
+ if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|overloaded|\b503\b|exhausted your capacity|capacity/i.test(message)) return "rate-limited";
737
+ if (/model.{0,80}(not found|not available|no longer available|does not exist|unsupported|invalid)|\b404\b|NOT_FOUND|invalid model|is not supported/i.test(message)) return "model-unavailable";
738
+ return "other";
739
+ }
740
+ var UNTRUSTED = "Gemini CLI does not trust this folder: open `gemini` in it once and trust it, or set PITROOM_GEMINI_TRUST=1 (the folder's own Gemini settings, hooks included, then load)";
741
+ function failure3(run, stderr, exitCode) {
742
+ if (exitCode === 0 && !run.error) return void 0;
743
+ const lines = stderr.trim().split("\n").map((l) => l.trim()).filter(Boolean);
744
+ const detail = lines.find((l) => /error|failed|exceeded|quota|exhausted|unsupported/i.test(l) && !/^at /.test(l)) ?? lines.at(-1);
745
+ const raw = run.error ?? detail ?? `gemini exited with code ${exitCode}`;
746
+ const message = /not running in a trusted directory/i.test(`${stderr} ${raw}`) ? UNTRUSTED : raw;
747
+ return { kind: classify3(message), message };
748
+ }
749
+ function geminiHome() {
750
+ return path5.join(process.env.GEMINI_CLI_HOME ?? os5.homedir(), ".gemini");
751
+ }
752
+ function settings() {
753
+ try {
754
+ return JSON.parse(fs4.readFileSync(path5.join(geminiHome(), "settings.json"), "utf8"));
755
+ } catch {
756
+ return {};
757
+ }
758
+ }
759
+ function defaultModel2() {
760
+ if (process.env.GEMINI_MODEL) return process.env.GEMINI_MODEL;
761
+ const m = settings().model;
762
+ const name = typeof m === "string" ? m : m?.name;
763
+ return typeof name === "string" ? name : void 0;
764
+ }
765
+ function doctor3({ models, hasFallback }) {
766
+ const version = gem(["--version"]);
767
+ if (version.missing || !version.ok) {
768
+ return [{ level: "fail", message: `gemini not runnable (${binary3()}). Install Gemini CLI (npm i -g @google/gemini-cli), or set PITROOM_GEMINI_BIN` }];
769
+ }
770
+ const checks = [{ level: "ok", message: `Gemini CLI ${version.out.trim()} at ${binary3()}` }];
771
+ const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
772
+ const vertex = process.env.GOOGLE_GENAI_USE_VERTEXAI === "true" || process.env.GOOGLE_GENAI_USE_VERTEXAI === "1";
773
+ const signedIn = fs4.existsSync(path5.join(geminiHome(), "oauth_creds.json"));
774
+ const type = settings().security?.auth?.selectedType;
775
+ if (key || vertex) {
776
+ checks.push({ level: "ok", message: `Gemini CLI: ${key ? "an API key is set" : "Vertex AI is set up"}` });
777
+ } else if (signedIn && type === "oauth-personal") {
778
+ checks.push({
779
+ level: "warn",
780
+ message: "Gemini CLI is signed in with a Google account (oauth-personal). If a run fails with IneligibleTierError, Google no longer serves that sign-in to this CLI: set GEMINI_API_KEY (Google AI Studio) instead"
781
+ });
782
+ } else if (signedIn) {
783
+ checks.push({ level: "ok", message: "Gemini CLI: signed in" });
784
+ } else {
785
+ checks.push({ level: "fail", message: "Gemini CLI is not signed in: run `gemini` once to sign in, or set GEMINI_API_KEY (Google AI Studio)" });
786
+ }
787
+ for (const m of models) {
788
+ const model = m ?? defaultModel2();
789
+ const pricey = model && /pro/i.test(model);
790
+ checks.push({
791
+ level: pricey ? "warn" : "ok",
792
+ message: pricey ? `Gemini model: ${model} is the largest tier for a worker; consider -W gemini:gemini-3.8-flash` : model ? `Gemini model: ${model}` : "Gemini model: Gemini CLI's own choice (it may pick a Pro model; a cheaper worker is -W gemini:gemini-3.8-flash)"
793
+ });
794
+ }
795
+ if (!hasFallback) checks.push({ level: "warn", message: "no fallback workers configured for Gemini runs" });
796
+ return checks;
797
+ }
798
+ function catalog3() {
799
+ const def = defaultModel2();
800
+ const models = ["gemini-3.8-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3-flash-preview"].map((id) => ({ id }));
801
+ if (def && !models.some((m) => m.id === def)) models.push({ id: def });
802
+ return { models, source: "Gemini CLI's built-in model names (it cannot list models); the effort suffix is not used" };
803
+ }
804
+ var gemini = {
805
+ id: "gemini",
806
+ name: "Gemini CLI",
807
+ capabilities: { readOnly: "approval-mode", resume: "none", reportsCost: false, attachFiles: false },
808
+ binary: binary3,
809
+ catalog: catalog3,
810
+ invocation: invocation3,
811
+ parse: parseEvents3,
812
+ failure: failure3,
813
+ defaultModel: defaultModel2,
814
+ doctor: doctor3
815
+ };
816
+
817
+ // src/backends/opencode/index.ts
818
+ import { spawnSync as spawnSync4 } from "node:child_process";
546
819
 
547
820
  // src/backends/opencode/events.ts
548
- var EDIT_TOOLS2 = /* @__PURE__ */ new Set(["edit", "write", "patch", "multiedit", "apply_patch"]);
549
- var DENIED2 = /rule which prevents you|permission denied|permission\.rejected/i;
550
- function parseEvents3(ndjson) {
821
+ var EDIT_TOOLS3 = /* @__PURE__ */ new Set(["edit", "write", "patch", "multiedit", "apply_patch"]);
822
+ var DENIED3 = /rule which prevents you|permission denied|permission\.rejected/i;
823
+ function parseEvents4(ndjson) {
551
824
  const usage = {
552
825
  input: 0,
553
826
  output: 0,
@@ -584,8 +857,8 @@ function parseEvents3(ndjson) {
584
857
  const key = String(p.messageID ?? "");
585
858
  if (!textByMessage.has(key)) textByMessage.set(key, []);
586
859
  textByMessage.get(key).push(text);
587
- lastActivity = `says: ${oneLine3(text)}`;
588
- if (timeline.length < MAX_STEPS) timeline.push({ kind: "say", text: clip3(text.trim(), 600), at: stamp(e.timestamp) });
860
+ lastActivity = `says: ${oneLine4(text)}`;
861
+ if (timeline.length < MAX_STEPS) timeline.push({ kind: "say", text: clip4(text.trim(), 600), at: stamp(e.timestamp) });
589
862
  break;
590
863
  }
591
864
  case "tool_use": {
@@ -593,28 +866,28 @@ function parseEvents3(ndjson) {
593
866
  const st = p.state ?? {};
594
867
  usage.toolCalls++;
595
868
  tools[name] = (tools[name] ?? 0) + 1;
596
- if (st.status === "error" && DENIED2.test(String(st.error ?? ""))) usage.denied++;
597
- if (st.status === "completed" && EDIT_TOOLS2.has(name)) {
869
+ if (st.status === "error" && DENIED3.test(String(st.error ?? ""))) usage.denied++;
870
+ if (st.status === "completed" && EDIT_TOOLS3.has(name)) {
598
871
  edits.push(String(st.input?.filePath ?? st.input?.path ?? name));
599
872
  }
600
873
  lastActivity = `${name} ${describe2(st.input)}`.trim();
601
874
  if (timeline.length < MAX_STEPS) {
602
- const kind = EDIT_TOOLS2.has(name) ? "edit" : /^(bash|shell)$/.test(name) ? "shell" : "tool";
875
+ const kind = EDIT_TOOLS3.has(name) ? "edit" : /^(bash|shell)$/.test(name) ? "shell" : "tool";
603
876
  const text = kind === "shell" ? String(st.input?.command ?? "") : String(st.input?.filePath ?? st.input?.path ?? st.input?.pattern ?? st.input?.url ?? "") || describe2(st.input);
604
- timeline.push({ kind, name, text: clip3(text, 240), ok: st.status === "completed" ? true : st.status === "error" ? false : void 0, at: stamp(e.timestamp) });
877
+ timeline.push({ kind, name, text: clip4(text, 240), ok: st.status === "completed" ? true : st.status === "error" ? false : void 0, at: stamp(e.timestamp) });
605
878
  }
606
879
  break;
607
880
  }
608
881
  case "step_finish": {
609
882
  const t = p.tokens ?? {};
610
883
  usage.steps++;
611
- usage.input += num3(t.input);
612
- usage.output += num3(t.output);
613
- usage.reasoning += num3(t.reasoning);
614
- usage.cacheRead += num3(t.cache?.read);
615
- usage.cacheWrite += num3(t.cache?.write);
616
- usage.total += num3(t.total) || num3(t.input) + num3(t.output) + num3(t.reasoning) + num3(t.cache?.read);
617
- usage.cost = (usage.cost ?? 0) + num3(p.cost);
884
+ usage.input += num4(t.input);
885
+ usage.output += num4(t.output);
886
+ usage.reasoning += num4(t.reasoning);
887
+ usage.cacheRead += num4(t.cache?.read);
888
+ usage.cacheWrite += num4(t.cache?.write);
889
+ usage.total += num4(t.total) || num4(t.input) + num4(t.output) + num4(t.reasoning) + num4(t.cache?.read);
890
+ usage.cost = (usage.cost ?? 0) + num4(p.cost);
618
891
  break;
619
892
  }
620
893
  case "error": {
@@ -638,25 +911,25 @@ function describeError(err) {
638
911
  const tags = [err.type, inner, err.status && `HTTP ${err.status}`].filter(Boolean);
639
912
  return tags.length ? `${message} [${tags.join(", ")}]` : message;
640
913
  }
641
- var clip3 = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
914
+ var clip4 = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
642
915
  var stamp = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
643
- function num3(v) {
916
+ function num4(v) {
644
917
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
645
918
  }
646
- function oneLine3(s, max = 80) {
919
+ function oneLine4(s, max = 80) {
647
920
  const t = s.replace(/\s+/g, " ").trim();
648
921
  return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
649
922
  }
650
923
  function describe2(input) {
651
924
  if (!input || typeof input !== "object") return "";
652
925
  const v = input.filePath ?? input.path ?? input.pattern ?? input.command ?? input.url ?? input.query;
653
- return v ? oneLine3(String(v), 60) : "";
926
+ return v ? oneLine4(String(v), 60) : "";
654
927
  }
655
928
 
656
929
  // src/backends/opencode/profiles.ts
657
- import fs4 from "node:fs";
658
- import os4 from "node:os";
659
- import path4 from "node:path";
930
+ import fs5 from "node:fs";
931
+ import os6 from "node:os";
932
+ import path6 from "node:path";
660
933
  var AGENT = { read: "pitroom-read", write: "pitroom-write" };
661
934
  var READ_FILES = {
662
935
  "*": "allow",
@@ -754,18 +1027,18 @@ var FORBIDDEN_BASH = [
754
1027
  "*pitroom*"
755
1028
  ];
756
1029
  function scratchDirs() {
757
- const data = process.env.XDG_DATA_HOME ?? path4.join(os4.homedir(), ".local", "share");
1030
+ const data = process.env.XDG_DATA_HOME ?? path6.join(os6.homedir(), ".local", "share");
758
1031
  const rules = {
759
1032
  "*": "deny",
760
- [path4.join(data, "opencode", "tool-output", "*")]: "allow",
761
- [path4.join(data, "opencode", "shell", "*", "*")]: "allow"
1033
+ [path6.join(data, "opencode", "tool-output", "*")]: "allow",
1034
+ [path6.join(data, "opencode", "shell", "*", "*")]: "allow"
762
1035
  };
763
- for (const tmp of /* @__PURE__ */ new Set([os4.tmpdir(), realpath(os4.tmpdir())])) rules[path4.join(tmp, "opencode", "*")] = "allow";
1036
+ for (const tmp of /* @__PURE__ */ new Set([os6.tmpdir(), realpath(os6.tmpdir())])) rules[path6.join(tmp, "opencode", "*")] = "allow";
764
1037
  return rules;
765
1038
  }
766
1039
  function realpath(p) {
767
1040
  try {
768
- return fs4.realpathSync(p);
1041
+ return fs5.realpathSync(p);
769
1042
  } catch {
770
1043
  return p;
771
1044
  }
@@ -853,10 +1126,10 @@ function isObject(v) {
853
1126
  }
854
1127
 
855
1128
  // src/backends/opencode/index.ts
856
- var binary3 = () => findBinary("opencode", "PITROOM_OPENCODE_BIN", ["~/.opencode/bin/opencode"]);
1129
+ var binary4 = () => findBinary("opencode", "PITROOM_OPENCODE_BIN", ["~/.opencode/bin/opencode"]);
857
1130
  function oc(args, opts = {}) {
858
- const { command, prefix } = resolveCommand(binary3());
859
- const r = spawnSync3(command, [...prefix, ...args], {
1131
+ const { command, prefix } = resolveCommand(binary4());
1132
+ const r = spawnSync4(command, [...prefix, ...args], {
860
1133
  encoding: "utf8",
861
1134
  timeout: opts.timeout ?? 6e4,
862
1135
  env: opts.env ?? process.env,
@@ -866,7 +1139,7 @@ function oc(args, opts = {}) {
866
1139
  return { ok: r.status === 0, out: r.stdout ?? "", err: r.stderr ?? "", missing: !!r.error };
867
1140
  }
868
1141
  var jsonIn = (s) => JSON.parse(s.slice(s.indexOf("{")));
869
- function invocation3(req) {
1142
+ function invocation4(req) {
870
1143
  const args = [
871
1144
  "run",
872
1145
  "--standalone",
@@ -885,7 +1158,7 @@ function invocation3(req) {
885
1158
  if (req.sessionId) args.push("--session", req.sessionId);
886
1159
  for (const f of req.files) args.push("--file", f);
887
1160
  args.push(req.prompt);
888
- const { command, prefix } = resolveCommand(binary3());
1161
+ const { command, prefix } = resolveCommand(binary4());
889
1162
  return {
890
1163
  command,
891
1164
  args: [...prefix, ...args],
@@ -900,7 +1173,7 @@ function majorVersion(output) {
900
1173
  const m = /(\d+)\.\d+\.\d+/.exec(output);
901
1174
  return m ? Number(m[1]) : void 0;
902
1175
  }
903
- function classify3(message) {
1176
+ function classify4(message) {
904
1177
  if (/model not found|ModelNotFound|unknown model|no such model|deprecated/i.test(message)) return "model-unavailable";
905
1178
  if (/rate.?limit|too many requests|\b429\b|quota|overloaded|\b50[23]\b|unavailable|capacity/i.test(message)) return "rate-limited";
906
1179
  if (/provider\.auth|FreeTierError|free tier|unauthori[sz]ed|\b40[13]\b|api.?key|insufficient|credits?\b|billing|payment/i.test(message)) {
@@ -908,19 +1181,19 @@ function classify3(message) {
908
1181
  }
909
1182
  return "other";
910
1183
  }
911
- function failure3(run, stderr, exitCode) {
1184
+ function failure4(run, stderr, exitCode) {
912
1185
  if (exitCode === 0 && !run.error) return void 0;
913
1186
  const detail = stderr.match(/error="([^"]+)"/g)?.pop()?.slice(7, -1);
914
1187
  const raw = run.error ?? `opencode exited with code ${exitCode}`;
915
1188
  const message = detail && !raw.includes(detail) ? `${raw}: ${detail}` : raw;
916
- return { kind: classify3(message), message };
1189
+ return { kind: classify4(message), message };
917
1190
  }
918
1191
  function modelId(m) {
919
1192
  if (typeof m === "string") return m;
920
1193
  const id = m?.model ?? m?.id;
921
1194
  return m?.providerID && id ? `${m.providerID}/${id}` : void 0;
922
1195
  }
923
- function defaultModel2() {
1196
+ function defaultModel3() {
924
1197
  try {
925
1198
  const out = oc(["debug", "config"], { timeout: 3e4 }).out;
926
1199
  const json = JSON.parse(out.slice(out.search(/[[{]/)));
@@ -945,7 +1218,7 @@ function resolveModel2(sessionId) {
945
1218
  return void 0;
946
1219
  }
947
1220
  }
948
- function catalog3() {
1221
+ function catalog4() {
949
1222
  const ids = listModels();
950
1223
  return {
951
1224
  models: (ids.length ? ids : listModels()).map((id) => ({ id })),
@@ -955,20 +1228,20 @@ function catalog3() {
955
1228
  function listModels() {
956
1229
  return oc(["models"]).out.split("\n").map((s) => s.trim()).filter(Boolean);
957
1230
  }
958
- function doctor3({ models, hasFallback }) {
1231
+ function doctor4({ models, hasFallback }) {
959
1232
  const checks = [];
960
1233
  const version = oc(["--version"]);
961
1234
  if (version.missing || !version.ok) {
962
- return [{ level: "fail", message: `opencode not runnable (${binary3()}). Install: https://opencode.ai or set PITROOM_OPENCODE_BIN` }];
1235
+ return [{ level: "fail", message: `opencode not runnable (${binary4()}). Install: https://opencode.ai or set PITROOM_OPENCODE_BIN` }];
963
1236
  }
964
1237
  const v = version.out.trim().replace(/^opencode\s+/i, "");
965
1238
  const major = majorVersion(v);
966
1239
  if (major !== void 0 && major < 2) {
967
1240
  return [{ level: "fail", message: `OpenCode ${v} is too old: Pitroom needs OpenCode v2 or newer (run \`opencode upgrade\`)` }];
968
1241
  }
969
- checks.push({ level: "ok", message: `opencode ${v} at ${binary3()} (private --standalone server per run)` });
1242
+ checks.push({ level: "ok", message: `opencode ${v} at ${binary4()} (private --standalone server per run)` });
970
1243
  const known = new Set(listModels());
971
- const def = defaultModel2();
1244
+ const def = defaultModel3();
972
1245
  for (const m of models) {
973
1246
  const model = m ?? def;
974
1247
  const label = m ? "model" : "default model";
@@ -994,21 +1267,21 @@ var opencode = {
994
1267
  id: "opencode",
995
1268
  name: "OpenCode",
996
1269
  capabilities: { readOnly: "permission-rules", resume: "by-id", reportsCost: true, attachFiles: true },
997
- binary: binary3,
998
- invocation: invocation3,
999
- parse: parseEvents3,
1000
- failure: failure3,
1001
- defaultModel: defaultModel2,
1270
+ binary: binary4,
1271
+ invocation: invocation4,
1272
+ parse: parseEvents4,
1273
+ failure: failure4,
1274
+ defaultModel: defaultModel3,
1002
1275
  resolveModel: resolveModel2,
1003
1276
  listModels,
1004
- catalog: catalog3,
1005
- doctor: doctor3
1277
+ catalog: catalog4,
1278
+ doctor: doctor4
1006
1279
  };
1007
1280
 
1008
1281
  // src/backends/index.ts
1009
- var REGISTRY = new Map([opencode, codex, claude].map((b) => [b.id, b]));
1282
+ var REGISTRY = new Map([opencode, codex, claude, gemini].map((b) => [b.id, b]));
1010
1283
  var DEFAULT_BACKEND = opencode.id;
1011
- var PLANNED = ["gemini"];
1284
+ var PLANNED = [];
1012
1285
  var backendIds = () => [...REGISTRY.keys()];
1013
1286
  var allBackends = () => [...REGISTRY.values()];
1014
1287
  var isBackendId = (id) => REGISTRY.has(id) || PLANNED.includes(id);
@@ -1038,8 +1311,8 @@ var formatTarget = (t) => t.model ? `${t.backend}:${t.model}` : t.backend;
1038
1311
  var describeTarget = (t) => t.model ? formatTarget(t) : `${t.backend} (default model)`;
1039
1312
 
1040
1313
  // src/core/refs.ts
1041
- import fs5 from "node:fs";
1042
- import path5 from "node:path";
1314
+ import fs6 from "node:fs";
1315
+ import path7 from "node:path";
1043
1316
  var FILE_EXTENSIONS = new Set(
1044
1317
  "ts tsx mts cts js jsx mjs cjs json jsonc json5 md mdx txt rst py pyi rb go rs java kt kts swift c h cc cpp cxx hpp hh cs fs php lua r jl sh bash zsh fish ps1 bat yml yaml toml ini cfg conf env html htm css scss sass less vue svelte astro sql graphql gql proto lock xml svg csv tsv gradle tf hcl dart ex exs erl hs ml scala clj vim el mk cmake dockerfile gitignore gitattributes editorconfig npmrc nvmrc".split(" ")
1045
1318
  );
@@ -1090,13 +1363,13 @@ function extractRefs(answer) {
1090
1363
  return [...seen.values()];
1091
1364
  }
1092
1365
  function verifyRefs(refs, dirs) {
1093
- const roots = [...new Set(dirs.filter(Boolean).map((d) => path5.resolve(d)))];
1366
+ const roots = [...new Set(dirs.filter(Boolean).map((d) => path7.resolve(d)))];
1094
1367
  const invalid = [];
1095
1368
  const lineCache = /* @__PURE__ */ new Map();
1096
1369
  const load = (file) => {
1097
1370
  if (!lineCache.has(file)) {
1098
- const size = fs5.statSync(file).size;
1099
- const lines = size > MAX_BYTES ? null : fs5.readFileSync(file, "utf8").split("\n");
1371
+ const size = fs6.statSync(file).size;
1372
+ const lines = size > MAX_BYTES ? null : fs6.readFileSync(file, "utf8").split("\n");
1100
1373
  if (lines && lines[lines.length - 1] === "") lines.pop();
1101
1374
  lineCache.set(file, lines);
1102
1375
  }
@@ -1107,9 +1380,9 @@ function verifyRefs(refs, dirs) {
1107
1380
  const candidates = (ref) => {
1108
1381
  const direct = resolve(ref.file, roots);
1109
1382
  if (direct) return [direct];
1110
- if (path5.isAbsolute(ref.file)) return [];
1383
+ if (path7.isAbsolute(ref.file)) return [];
1111
1384
  const wanted = ref.file.replace(/^(\.{1,2}\/)+/, "");
1112
- return (byName().get(path5.basename(wanted)) ?? []).filter((f) => f.endsWith(`/${wanted}`) || path5.basename(f) === wanted).slice(0, 20);
1385
+ return (byName().get(path7.basename(wanted)) ?? []).filter((f) => f.endsWith(`/${wanted}`) || path7.basename(f) === wanted).slice(0, 20);
1113
1386
  };
1114
1387
  const check = (file, ref) => {
1115
1388
  const lines = load(file);
@@ -1124,7 +1397,7 @@ function verifyRefs(refs, dirs) {
1124
1397
  if (!files.length) {
1125
1398
  if (!ref.file.includes("/") && !isFileName(ref.file)) continue;
1126
1399
  total++;
1127
- invalid.push({ ref: ref.text, reason: path5.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
1400
+ invalid.push({ ref: ref.text, reason: path7.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
1128
1401
  continue;
1129
1402
  }
1130
1403
  total++;
@@ -1145,7 +1418,7 @@ function enclosedBy(lines, ref) {
1145
1418
  return lines.slice(Math.max(0, ref.start - 1 - DEFINITION_LOOKBACK), ref.start).some((l) => def.test(l));
1146
1419
  }
1147
1420
  var isFileName = (name) => {
1148
- const base = path5.basename(name);
1421
+ const base = path7.basename(name);
1149
1422
  return !base.includes(".") || new RegExp(`^(${EXTENSIONLESS})$`).test(base) || FILE_EXTENSIONS.has(base.split(".").pop().toLowerCase());
1150
1423
  };
1151
1424
  function indexFiles(roots) {
@@ -1154,19 +1427,19 @@ function indexFiles(roots) {
1154
1427
  const walk = (dir) => {
1155
1428
  let entries;
1156
1429
  try {
1157
- entries = fs5.readdirSync(dir, { withFileTypes: true });
1430
+ entries = fs6.readdirSync(dir, { withFileTypes: true });
1158
1431
  } catch {
1159
1432
  return;
1160
1433
  }
1161
1434
  for (const e of entries) {
1162
1435
  if (count >= MAX_INDEXED) return;
1163
1436
  if (e.isDirectory()) {
1164
- if (!SKIP_DIRS.has(e.name)) walk(path5.join(dir, e.name));
1437
+ if (!SKIP_DIRS.has(e.name)) walk(path7.join(dir, e.name));
1165
1438
  } else if (e.isFile()) {
1166
1439
  count++;
1167
1440
  const list = index.get(e.name);
1168
- if (list) list.push(path5.join(dir, e.name));
1169
- else index.set(e.name, [path5.join(dir, e.name)]);
1441
+ if (list) list.push(path7.join(dir, e.name));
1442
+ else index.set(e.name, [path7.join(dir, e.name)]);
1170
1443
  }
1171
1444
  }
1172
1445
  };
@@ -1174,10 +1447,10 @@ function indexFiles(roots) {
1174
1447
  return index;
1175
1448
  }
1176
1449
  function resolve(ref, roots) {
1177
- const candidates = path5.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path5.join(r, ref));
1450
+ const candidates = path7.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path7.join(r, ref));
1178
1451
  return candidates.find((c) => {
1179
1452
  try {
1180
- return fs5.statSync(c).isFile() && inside(c, roots);
1453
+ return fs6.statSync(c).isFile() && inside(c, roots);
1181
1454
  } catch {
1182
1455
  return false;
1183
1456
  }
@@ -1185,21 +1458,21 @@ function resolve(ref, roots) {
1185
1458
  }
1186
1459
  function inside(file, roots) {
1187
1460
  return roots.some((r) => {
1188
- const rel = path5.relative(r, path5.resolve(file));
1189
- return rel !== "" && !rel.startsWith("..") && !path5.isAbsolute(rel);
1461
+ const rel = path7.relative(r, path7.resolve(file));
1462
+ return rel !== "" && !rel.startsWith("..") && !path7.isAbsolute(rel);
1190
1463
  });
1191
1464
  }
1192
1465
  var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1193
1466
 
1194
1467
  // src/core/templates.ts
1195
- import fs6 from "node:fs";
1196
- import path7 from "node:path";
1468
+ import fs7 from "node:fs";
1469
+ import path9 from "node:path";
1197
1470
 
1198
1471
  // src/core/install.ts
1199
- import path6 from "node:path";
1200
- import { fileURLToPath } from "node:url";
1472
+ import path8 from "node:path";
1473
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1201
1474
  function packageRoot() {
1202
- return path6.resolve(path6.dirname(fileURLToPath(import.meta.url)), "..");
1475
+ return path8.resolve(path8.dirname(fileURLToPath2(import.meta.url)), "..");
1203
1476
  }
1204
1477
 
1205
1478
  // src/core/templates.ts
@@ -1210,9 +1483,9 @@ var FILES = {
1210
1483
  "code-reviewer": "pitroom-review/code-reviewer.md"
1211
1484
  };
1212
1485
  function loadTemplate(name, root = packageRoot()) {
1213
- const file = path7.join(root, "skills", FILES[name]);
1214
- if (!fs6.existsSync(file)) throw new UserError(`template missing: ${file} (broken install? run pitroom doctor)`, 3);
1215
- return fs6.readFileSync(file, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
1486
+ const file = path9.join(root, "skills", FILES[name]);
1487
+ if (!fs7.existsSync(file)) throw new UserError(`template missing: ${file} (broken install? run pitroom doctor)`, 3);
1488
+ return fs7.readFileSync(file, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
1216
1489
  }
1217
1490
  function fill(template, values) {
1218
1491
  const missing = [...template.matchAll(/\{\{([A-Z_]+)\}\}/g)].map((m) => m[1]).filter((k) => !(k in values));
@@ -1239,10 +1512,17 @@ function parseVerdict(text) {
1239
1512
  minor: count("minor")
1240
1513
  };
1241
1514
  }
1515
+ function parseAudit(text) {
1516
+ const word = /^\s*AUDIT:\s*(AGREE|PARTIAL|DISAGREE)\b/im.exec(text)?.[1]?.toLowerCase();
1517
+ const verdict = word === "agree" || word === "partial" || word === "disagree" ? word : "unclear";
1518
+ const after = /^\s*DISPUTED:\s*$/im.exec(text);
1519
+ const disputed = after ? text.slice(after.index + after[0].length).split("\n").map((l) => l.trim()).filter((l) => /^[-*•]\s+\S/.test(l)).map((l) => l.replace(/^[-*•]\s+/, "")).filter((l) => !/^\(?none\)?\.?$/i.test(l)).slice(0, 8) : [];
1520
+ return { verdict, disputed };
1521
+ }
1242
1522
 
1243
1523
  // src/core/plan.ts
1244
- import fs7 from "node:fs";
1245
- import path8 from "node:path";
1524
+ import fs8 from "node:fs";
1525
+ import path10 from "node:path";
1246
1526
  var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
1247
1527
  var RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
1248
1528
  function structure(lines) {
@@ -1289,9 +1569,9 @@ function parsePlan(text, file = "") {
1289
1569
  return { file, title: titleHeading?.text ?? "", header, constraints, tasks };
1290
1570
  }
1291
1571
  function loadPlan(file) {
1292
- const abs = path8.resolve(file);
1293
- if (!fs7.existsSync(abs) || !fs7.statSync(abs).isFile()) throw new UserError(`plan not found: ${file}`);
1294
- const plan = parsePlan(fs7.readFileSync(abs, "utf8"), fs7.realpathSync(abs));
1572
+ const abs = path10.resolve(file);
1573
+ if (!fs8.existsSync(abs) || !fs8.statSync(abs).isFile()) throw new UserError(`plan not found: ${file}`);
1574
+ const plan = parsePlan(fs8.readFileSync(abs, "utf8"), fs8.realpathSync(abs));
1295
1575
  if (!plan.tasks.length) throw new UserError(`${file} has no "Task N" headings (see pitroom-writing-plans)`);
1296
1576
  return plan;
1297
1577
  }
@@ -1310,7 +1590,7 @@ function brief(plan, task) {
1310
1590
  ].filter(Boolean).join("\n\n")}
1311
1591
  `;
1312
1592
  }
1313
- var planName = (file) => path8.basename(file).replace(/\.md$/i, "");
1593
+ var planName = (file) => path10.basename(file).replace(/\.md$/i, "");
1314
1594
  export {
1315
1595
  DEFAULT_BACKEND,
1316
1596
  allBackends,
@@ -1323,6 +1603,7 @@ export {
1323
1603
  getBackend,
1324
1604
  loadPlan,
1325
1605
  loadTemplate,
1606
+ parseAudit,
1326
1607
  parsePlan,
1327
1608
  parseStatus,
1328
1609
  parseTarget,