page-harness 0.1.1 → 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.
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/cli/index.ts
4
4
  import { existsSync as existsSync10, readFileSync as readFileSync9, realpathSync as realpathSync2, statSync as statSync6 } from "fs";
5
5
  import net from "net";
6
- import path15 from "path";
6
+ import path16 from "path";
7
7
  import { spawn as spawn2 } from "child_process";
8
8
  import { fileURLToPath as fileURLToPath2 } from "url";
9
9
  import { parseArgs } from "util";
@@ -198,7 +198,10 @@ function stringifyOutput(v) {
198
198
  return JSON.stringify(v, null, 2);
199
199
  }
200
200
  function displayToolName(name) {
201
- return name.replace(/^mcp__page[-_]harness__/, "").replace(/^page[-_]harness[.:/]/, "");
201
+ return name.replace(/^mcp__page[-_]harness__/, "").replace(/^page[-_]harness[.:/_]/, "");
202
+ }
203
+ function stripAnsi(s) {
204
+ return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
202
205
  }
203
206
 
204
207
  // src/agents/cursor.ts
@@ -444,8 +447,8 @@ function createCursorRunner(opts = {}) {
444
447
 
445
448
  // src/server/harness.ts
446
449
  import { existsSync as existsSync9, mkdirSync as mkdirSync6, writeFileSync as writeFileSync7 } from "fs";
447
- import os3 from "os";
448
- import path14 from "path";
450
+ import os5 from "os";
451
+ import path15 from "path";
449
452
 
450
453
  // src/agents/claude.ts
451
454
  import { readFileSync as readFileSync2 } from "fs";
@@ -645,7 +648,164 @@ function createClaudeRunner(opts = {}) {
645
648
  };
646
649
  }
647
650
 
651
+ // src/agents/codebuddy.ts
652
+ import { mkdtemp as mkdtemp2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
653
+ import os2 from "os";
654
+ import path4 from "path";
655
+ var INTERACTIVE_TOOLS = ["AskUserQuestion", "AskUserForStructuredInput"];
656
+ var CodeBuddyParser = class extends ClaudeParser {
657
+ sawSession = false;
658
+ feed(o) {
659
+ if (!o || typeof o !== "object") return [];
660
+ if (o.type === "system" && o.subtype === "init") {
661
+ if (this.sawSession) return [];
662
+ this.sawSession = !!o.session_id;
663
+ }
664
+ if (o.type === "assistant" && isAuthNotice(o.message?.content)) return [];
665
+ if (o.type === "error") return [{ type: "error", message: friendlyError(String(o.error ?? o.message ?? "CodeBuddy reported an error")), sessionId: this.sessionId }];
666
+ if (o.type === "result" && (o.is_error || o.subtype && o.subtype !== "success") && !o.result) {
667
+ const errors = Array.isArray(o.errors) ? o.errors.filter(Boolean).map(String) : [];
668
+ if (errors.length) o = { ...o, result: errors.join("\n") };
669
+ }
670
+ return super.feed(o).map((ev) => ev.type === "error" ? { ...ev, message: friendlyError(ev.message) } : ev);
671
+ }
672
+ };
673
+ function isAuthNotice(content) {
674
+ return Array.isArray(content) && content.length > 0 && content.every((b) => b?.type === "text" && /^authentication required\b/i.test(String(b.text ?? "")));
675
+ }
676
+ function friendlyError(msg) {
677
+ if (/authentication required/i.test(msg)) return "CodeBuddy Code is not signed in. Run `codebuddy` in a terminal once and sign in with /login, then retry.";
678
+ return msg;
679
+ }
680
+ function codebuddyArgs(input, mcpConfigPath, extra = []) {
681
+ const args = [
682
+ "-p",
683
+ "--input-format",
684
+ "stream-json",
685
+ "--output-format",
686
+ "stream-json",
687
+ "--include-partial-messages",
688
+ "--verbose",
689
+ "--permission-mode",
690
+ "bypassPermissions",
691
+ "--mcp-config",
692
+ mcpConfigPath,
693
+ "--strict-mcp-config",
694
+ "--disallowedTools",
695
+ INTERACTIVE_TOOLS.join(",")
696
+ ];
697
+ if (input.system && process.platform !== "win32") args.push("--append-system-prompt", input.system);
698
+ if (input.model) args.push("--model", input.model);
699
+ if (input.resumeId) args.push("--resume", input.resumeId);
700
+ return [...args, ...extra];
701
+ }
702
+ function parseCodeBuddyHelp(help) {
703
+ const flat = help.replace(/\s+/g, " ");
704
+ const models = /--model <model>.*?Currently supported: \(([^)]*)\)/.exec(flat)?.[1];
705
+ if (!models) return [];
706
+ const ids = [...new Set(models.split(",").map((m) => m.trim()).filter((m) => /^[\w.:\-/]+$/.test(m)))];
707
+ return ids.map((id) => ({
708
+ id,
709
+ label: aliasLabel(id),
710
+ // A session started without --model reports `default-model`.
711
+ ...id === "default-model" ? { isDefault: true } : {}
712
+ }));
713
+ }
714
+ function aliasLabel(id) {
715
+ const m = /^(default|fast|balanced|primary|deep)-model$/.exec(id);
716
+ return m ? `${m[1][0].toUpperCase()}${m[1].slice(1)} model` : id;
717
+ }
718
+ function createCodeBuddyRunner(opts = {}) {
719
+ const bin = () => opts.bin ?? findExecutable(["codebuddy", "cbc"]);
720
+ let models;
721
+ return {
722
+ id: "codebuddy",
723
+ label: "CodeBuddy",
724
+ images: true,
725
+ async detect() {
726
+ const p = bin();
727
+ if (!p) return { ok: false, error: "codebuddy not found in PATH" };
728
+ const r = await runCapture(p, ["--version"], 15e3);
729
+ if (r.code !== 0) return { ok: false, path: p, error: r.stderr.trim() || "codebuddy --version failed" };
730
+ return { ok: true, path: p, version: r.stdout.trim().split("\n")[0] };
731
+ },
732
+ async listModels() {
733
+ if (models) return models;
734
+ const p = bin();
735
+ if (!p) return [];
736
+ const r = await runCapture(p, ["--help"], 15e3);
737
+ const found = r.code === 0 ? parseCodeBuddyHelp(r.stdout) : [];
738
+ if (found.length) models = found;
739
+ return found;
740
+ },
741
+ run(input, signal) {
742
+ const q = new EventQueue();
743
+ void (async () => {
744
+ const p = bin();
745
+ if (!p) {
746
+ q.push({ type: "error", message: "codebuddy not found in PATH" });
747
+ return q.end();
748
+ }
749
+ const dir = await mkdtemp2(path4.join(os2.tmpdir(), "ph-codebuddy-"));
750
+ let proc;
751
+ const onAbort = () => proc?.stop();
752
+ signal.addEventListener("abort", onAbort, { once: true });
753
+ const parser = new CodeBuddyParser();
754
+ try {
755
+ const cfg = path4.join(dir, "mcp.json");
756
+ await writeFile2(
757
+ cfg,
758
+ JSON.stringify({ mcpServers: { [input.mcp.name]: { type: "http", url: input.mcp.url, headers: { Authorization: `Bearer ${input.mcp.token}` } } } }),
759
+ { mode: 384 }
760
+ );
761
+ if (signal.aborted) {
762
+ q.push({ type: "error", message: "cancelled" });
763
+ return;
764
+ }
765
+ proc = spawnAgent(p, codebuddyArgs(input, cfg, opts.extraArgs), {
766
+ cwd: input.cwd,
767
+ stdin: true,
768
+ env: { MCP_TOOL_TIMEOUT: "1800000", MCP_TIMEOUT: "30000" }
769
+ });
770
+ const child = proc.child;
771
+ child.stdin?.on("error", () => {
772
+ });
773
+ child.stdin?.write(claudeUserMessage(input));
774
+ let finished = false;
775
+ for await (const line of proc.lines) {
776
+ for (const ev of parser.feed(parseJson(line))) {
777
+ q.push(ev);
778
+ if (ev.type === "done" || ev.type === "error") {
779
+ finished = true;
780
+ child.stdin?.end();
781
+ }
782
+ }
783
+ }
784
+ const code = await proc.exited;
785
+ if (!finished) {
786
+ if (signal.aborted) q.push({ type: "error", message: "cancelled", sessionId: parser.sessionId });
787
+ else q.push({ type: "error", message: proc.stderrTail() || `codebuddy exited with code ${code}`, sessionId: parser.sessionId });
788
+ }
789
+ } catch (e) {
790
+ proc?.stop();
791
+ q.push({ type: "error", message: String(e?.message ?? e), sessionId: parser.sessionId });
792
+ } finally {
793
+ signal.removeEventListener("abort", onAbort);
794
+ await rm2(dir, { recursive: true, force: true }).catch(() => {
795
+ });
796
+ q.end();
797
+ }
798
+ })().catch((e) => {
799
+ q.push({ type: "error", message: String(e?.message ?? e) });
800
+ q.end();
801
+ });
802
+ return q;
803
+ }
804
+ };
805
+ }
806
+
648
807
  // src/agents/codex.ts
808
+ import os3 from "os";
649
809
  var CodexParser = class {
650
810
  deltaItems = /* @__PURE__ */ new Set();
651
811
  usage;
@@ -806,8 +966,69 @@ function answerServerRequest(method) {
806
966
  return { error: `unsupported request ${method}` };
807
967
  }
808
968
  }
969
+ function parseCodexModels(result) {
970
+ return (result?.data ?? []).filter((m) => m?.id && !m.hidden).map((m) => {
971
+ const efforts = (m.supportedReasoningEfforts ?? []).map((e) => typeof e === "string" ? e : e?.reasoningEffort).filter((e) => typeof e === "string" && e);
972
+ const tier = (m.serviceTiers ?? []).find((s) => s?.id && /fast/i.test(String(s.name ?? s.id)));
973
+ return {
974
+ id: String(m.id),
975
+ label: String(m.displayName || m.id),
976
+ ...m.isDefault ? { isDefault: true } : {},
977
+ ...efforts.length ? { efforts } : {},
978
+ ...m.defaultReasoningEffort ? { defaultEffort: String(m.defaultReasoningEffort) } : {},
979
+ ...tier ? { fastTier: { id: String(tier.id), ...tier.description ? { description: String(tier.description) } : {} } } : {}
980
+ };
981
+ });
982
+ }
983
+ function codexErrorMessage(msg) {
984
+ try {
985
+ const j = JSON.parse(msg);
986
+ const inner = j?.error?.message ?? j?.message;
987
+ return typeof inner === "string" && inner ? inner : msg;
988
+ } catch {
989
+ return msg;
990
+ }
991
+ }
992
+ var INIT_PARAMS = { clientInfo: { name: "page-harness", title: "Page Harness", version: "0.1.0" }, capabilities: { experimentalApi: false, requestAttestation: false } };
993
+ async function fetchCodexModels(bin, timeoutMs = 15e3) {
994
+ const proc = spawnAgent(bin, ["app-server", "--listen", "stdio://"], { cwd: os3.tmpdir(), stdin: true });
995
+ proc.child.stdin?.on("error", () => {
996
+ });
997
+ const rpc = new RpcClient((s) => proc.child.stdin?.write(s));
998
+ void (async () => {
999
+ for await (const line of proc.lines) {
1000
+ const m = parseJson(line);
1001
+ if (!m || rpc.handleResponse(m)) continue;
1002
+ if (m.id !== void 0 && m.method) rpc.respondError(m.id, `unsupported request ${m.method}`);
1003
+ }
1004
+ })();
1005
+ void proc.exited.then(() => rpc.failAll(new Error("codex exited")));
1006
+ let timer;
1007
+ const timeout = new Promise((_, reject) => {
1008
+ timer = setTimeout(() => reject(new Error("codex model/list timed out")), timeoutMs);
1009
+ });
1010
+ try {
1011
+ return await Promise.race([timeout, (async () => {
1012
+ await rpc.request("initialize", INIT_PARAMS);
1013
+ rpc.notify("initialized");
1014
+ const models = [];
1015
+ let cursor = null;
1016
+ do {
1017
+ const res = await rpc.request("model/list", cursor ? { cursor } : {});
1018
+ models.push(...parseCodexModels(res));
1019
+ cursor = res?.nextCursor ?? null;
1020
+ } while (cursor);
1021
+ return models;
1022
+ })()]);
1023
+ } finally {
1024
+ clearTimeout(timer);
1025
+ proc.child.stdin?.end();
1026
+ proc.stop(1e3);
1027
+ }
1028
+ }
809
1029
  function createCodexRunner(opts = {}) {
810
1030
  const bin = () => opts.bin ?? findExecutable(["codex"]);
1031
+ let models;
811
1032
  return {
812
1033
  id: "codex",
813
1034
  label: "Codex",
@@ -820,11 +1041,12 @@ function createCodexRunner(opts = {}) {
820
1041
  return { ok: true, path: p, version: r.stdout.trim().split("\n")[0] };
821
1042
  },
822
1043
  async listModels() {
823
- return [
824
- { id: "gpt-5.5", label: "GPT-5.5" },
825
- { id: "gpt-5.5-codex", label: "GPT-5.5 Codex" },
826
- { id: "gpt-5.4-mini", label: "GPT-5.4 mini" }
827
- ];
1044
+ if (models) return models;
1045
+ const p = bin();
1046
+ const found = p ? await fetchCodexModels(p).catch(() => []) : [];
1047
+ if (!found.length) return [{ id: "gpt-5.5", label: "GPT-5.5" }];
1048
+ models = found;
1049
+ return models;
828
1050
  },
829
1051
  run(input, signal) {
830
1052
  const q = new EventQueue();
@@ -872,18 +1094,18 @@ function createCodexRunner(opts = {}) {
872
1094
  for (const ev of parser.feed(m.method, params)) q.push(ev);
873
1095
  if (m.method === "turn/started" && params.turn?.id) turnId = params.turn.id;
874
1096
  if (m.method === "error" && params.willRetry === false) {
875
- finish({ type: "error", message: params.error?.message ?? "codex error", sessionId: threadId });
1097
+ finish({ type: "error", message: codexErrorMessage(params.error?.message ?? "codex error"), sessionId: threadId });
876
1098
  }
877
1099
  if (m.method === "turn/completed") {
878
1100
  const t = params.turn ?? {};
879
1101
  if (t.status === "completed") finish({ type: "done", sessionId: threadId, usage: parser.usage });
880
1102
  else if (t.status === "interrupted") finish({ type: "error", message: "cancelled", sessionId: threadId });
881
- else finish({ type: "error", message: t.error?.message ?? `turn ${t.status}`, sessionId: threadId });
1103
+ else finish({ type: "error", message: codexErrorMessage(t.error?.message ?? `turn ${t.status}`), sessionId: threadId });
882
1104
  }
883
1105
  }
884
1106
  })();
885
1107
  try {
886
- await rpc.request("initialize", { clientInfo: { name: "page-harness", title: "Page Harness", version: "0.1.0" }, capabilities: { experimentalApi: false, requestAttestation: false } });
1108
+ await rpc.request("initialize", INIT_PARAMS);
887
1109
  rpc.notify("initialized");
888
1110
  const threadParams = {
889
1111
  cwd: input.cwd,
@@ -906,7 +1128,10 @@ function createCodexRunner(opts = {}) {
906
1128
  if (threadId) q.push({ type: "session", sessionId: threadId });
907
1129
  const items = [{ type: "text", text: input.prompt, text_elements: [] }];
908
1130
  for (const img of input.images) items.push({ type: "localImage", path: img.path });
909
- const started = await rpc.request("turn/start", { threadId, input: items });
1131
+ const model = models?.find((m) => input.model ? m.id === input.model : m.isDefault);
1132
+ const effort = input.effort ?? model?.defaultEffort ?? null;
1133
+ const serviceTier = input.fast && model?.fastTier ? model.fastTier.id : "default";
1134
+ const started = await rpc.request("turn/start", { threadId, input: items, effort, serviceTier });
910
1135
  turnId = started?.turn?.id ?? turnId;
911
1136
  } catch (e) {
912
1137
  finish({ type: "error", message: proc.stderrTail() || String(e?.message ?? e), sessionId: threadId });
@@ -928,7 +1153,7 @@ function createCodexRunner(opts = {}) {
928
1153
 
929
1154
  // src/agents/fake.ts
930
1155
  import { readdirSync as readdirSync3, readFileSync as readFileSync5, statSync as statSync2, writeFileSync as writeFileSync4, existsSync as existsSync4, mkdirSync as mkdirSync3 } from "fs";
931
- import path6 from "path";
1156
+ import path7 from "path";
932
1157
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
933
1158
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
934
1159
 
@@ -936,7 +1161,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
936
1161
  import { EventEmitter } from "events";
937
1162
  import { execFile } from "child_process";
938
1163
  import { existsSync as existsSync3, readFileSync as readFileSync4, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3, chmodSync } from "fs";
939
- import path5 from "path";
1164
+ import path6 from "path";
940
1165
  import { promisify } from "util";
941
1166
 
942
1167
  // src/shared/protocol.ts
@@ -1018,9 +1243,9 @@ function truncate(s, n) {
1018
1243
 
1019
1244
  // src/server/store.ts
1020
1245
  import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, renameSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
1021
- import path4 from "path";
1246
+ import path5 from "path";
1022
1247
  function writeJsonAtomic(file, data) {
1023
- mkdirSync2(path4.dirname(file), { recursive: true });
1248
+ mkdirSync2(path5.dirname(file), { recursive: true });
1024
1249
  const tmp = `${file}.${process.pid}.tmp`;
1025
1250
  writeFileSync2(tmp, JSON.stringify(data, null, 2));
1026
1251
  renameSync(tmp, file);
@@ -1035,31 +1260,31 @@ function readJson(file, fallback) {
1035
1260
  var ChatStore = class {
1036
1261
  root;
1037
1262
  constructor(dataDir) {
1038
- this.root = path4.join(dataDir, "chats");
1263
+ this.root = path5.join(dataDir, "chats");
1039
1264
  mkdirSync2(this.root, { recursive: true });
1040
1265
  }
1041
1266
  dir(id) {
1042
1267
  if (!/^[\w-]+$/.test(id)) throw new Error("bad chat id");
1043
- return path4.join(this.root, id);
1268
+ return path5.join(this.root, id);
1044
1269
  }
1045
1270
  listMetas() {
1046
1271
  const out = [];
1047
1272
  for (const name of readdirSync2(this.root)) {
1048
- const m = readJson(path4.join(this.root, name, "meta.json"), null);
1273
+ const m = readJson(path5.join(this.root, name, "meta.json"), null);
1049
1274
  if (m && m.id === name) out.push(m);
1050
1275
  }
1051
1276
  return out.sort((a, b) => b.updatedAt - a.updatedAt);
1052
1277
  }
1053
1278
  saveMeta(meta) {
1054
- writeJsonAtomic(path4.join(this.dir(meta.id), "meta.json"), meta);
1279
+ writeJsonAtomic(path5.join(this.dir(meta.id), "meta.json"), meta);
1055
1280
  }
1056
1281
  append(chatId, ev) {
1057
1282
  const d = this.dir(chatId);
1058
1283
  mkdirSync2(d, { recursive: true });
1059
- appendFileSync(path4.join(d, "events.jsonl"), JSON.stringify(ev) + "\n");
1284
+ appendFileSync(path5.join(d, "events.jsonl"), JSON.stringify(ev) + "\n");
1060
1285
  }
1061
1286
  readEvents(chatId) {
1062
- const file = path4.join(this.dir(chatId), "events.jsonl");
1287
+ const file = path5.join(this.dir(chatId), "events.jsonl");
1063
1288
  if (!existsSync2(file)) return [];
1064
1289
  const out = [];
1065
1290
  for (const line of readFileSync3(file, "utf8").split("\n")) {
@@ -1072,10 +1297,10 @@ var ChatStore = class {
1072
1297
  return out;
1073
1298
  }
1074
1299
  saveQueue(chatId, queue) {
1075
- writeJsonAtomic(path4.join(this.dir(chatId), "queue.json"), queue);
1300
+ writeJsonAtomic(path5.join(this.dir(chatId), "queue.json"), queue);
1076
1301
  }
1077
1302
  loadQueue(chatId) {
1078
- return readJson(path4.join(this.dir(chatId), "queue.json"), []);
1303
+ return readJson(path5.join(this.dir(chatId), "queue.json"), []);
1079
1304
  }
1080
1305
  delete(chatId) {
1081
1306
  rmSync(this.dir(chatId), { recursive: true, force: true });
@@ -1092,7 +1317,7 @@ var LiveManager = class extends EventEmitter {
1092
1317
  constructor(dataDir, patch = false) {
1093
1318
  super();
1094
1319
  this.patch = patch;
1095
- this.file = path5.join(dataDir, "live.json");
1320
+ this.file = path6.join(dataDir, "live.json");
1096
1321
  for (const s of readJson(this.file, [])) {
1097
1322
  if (TRANSIENT.includes(s.status)) {
1098
1323
  s.status = "failed";
@@ -1414,11 +1639,11 @@ var MarkerGuard = class {
1414
1639
  return found.filter((f) => (counts.get(f.file) ?? 0) > (this.baseline?.get(f.file) ?? 0));
1415
1640
  }
1416
1641
  hookPath() {
1417
- return path5.join(this.cwd, ".git", "hooks", "pre-commit");
1642
+ return path6.join(this.cwd, ".git", "hooks", "pre-commit");
1418
1643
  }
1419
1644
  /** Optional pre-commit hook that rejects staged Live markers. */
1420
1645
  async installHook() {
1421
- if (!await this.isGit() || !existsSync3(path5.join(this.cwd, ".git", "hooks"))) return false;
1646
+ if (!await this.isGit() || !existsSync3(path6.join(this.cwd, ".git", "hooks"))) return false;
1422
1647
  const hook = this.hookPath();
1423
1648
  const orig = hook + ".page-harness-orig";
1424
1649
  if (existsSync3(hook) && !readFileSync4(hook, "utf8").includes("page-harness live guard")) renameSync2(hook, orig);
@@ -1478,7 +1703,7 @@ function findFiles(dir, depth = 3) {
1478
1703
  }
1479
1704
  for (const name of entries) {
1480
1705
  if (name.startsWith(".") || name === "node_modules" || name === "dist") continue;
1481
- const p = path6.join(dir, name);
1706
+ const p = path7.join(dir, name);
1482
1707
  const st = statSync2(p);
1483
1708
  if (st.isDirectory() && depth > 0) out.push(...findFiles(p, depth - 1));
1484
1709
  else if (/\.(html|vue|jsx|tsx|svelte)$/.test(name)) out.push(p);
@@ -1487,7 +1712,7 @@ function findFiles(dir, depth = 3) {
1487
1712
  }
1488
1713
  function liveEdit(cwd, req) {
1489
1714
  const sid = req.sid;
1490
- const stash = path6.join(cwd, ".page-harness", `fake-live-${sid}.json`);
1715
+ const stash = path7.join(cwd, ".page-harness", `fake-live-${sid}.json`);
1491
1716
  if (req.op === "generate" || req.op === "mount_failed") {
1492
1717
  if (req.op === "mount_failed" && existsSync4(stash)) {
1493
1718
  const { file: file3, original: original3, wrapper: wrapper3 } = JSON.parse(readFileSync5(stash, "utf8"));
@@ -1502,7 +1727,7 @@ function liveEdit(cwd, req) {
1502
1727
  const hide = (html, attrs) => html.replace(/^<([a-zA-Z0-9-]+)/, `<$1 ${attrs}`);
1503
1728
  const wrapper2 = `<div data-ph-live="${sid}" style="display:contents">` + hide(original2, 'data-ph-variant="0" hidden') + hide(original2, 'data-ph-variant="1" data-ph-variant-label="Bold" style="font-weight:800"') + hide(original2, `data-ph-variant="2" data-ph-variant-label="Soft" style="opacity:.7" hidden data-ph-params='[{"name":"gap","type":"range","min":0,"max":40,"step":4,"default":8,"unit":"px","label":"Gap"}]'`) + `</div>`;
1504
1729
  writeFileSync4(file2, readFileSync5(file2, "utf8").replace(original2, wrapper2));
1505
- mkdirSync3(path6.dirname(stash), { recursive: true });
1730
+ mkdirSync3(path7.dirname(stash), { recursive: true });
1506
1731
  writeFileSync4(stash, JSON.stringify({ file: file2, original: original2, wrapper: wrapper2 }));
1507
1732
  return { ok: true, variants: [{ index: 1, label: "Bold" }, { index: 2, label: "Soft" }] };
1508
1733
  }
@@ -1534,7 +1759,7 @@ function createFakeRunner() {
1534
1759
  return [
1535
1760
  { id: "fake-auto", label: "Auto", isDefault: true },
1536
1761
  { id: "fake-small", label: "Fake Small" },
1537
- { id: "fake-large", label: "Fake Large" },
1762
+ { id: "fake-large", label: "Fake Large", efforts: ["low", "high"], defaultEffort: "low", fastTier: { id: "priority", description: "1.5x speed, increased usage" } },
1538
1763
  { id: "claude-fake-4", label: "Claude Fake 4" },
1539
1764
  { id: "gpt-fake-5", label: "GPT Fake 5" }
1540
1765
  ];
@@ -1670,8 +1895,199 @@ function createFakeRunner() {
1670
1895
  };
1671
1896
  }
1672
1897
 
1898
+ // src/agents/opencode.ts
1899
+ var OpenCodeParser = class {
1900
+ sessionId;
1901
+ error;
1902
+ wrote = false;
1903
+ usage = {};
1904
+ feed(o) {
1905
+ if (!o || typeof o !== "object") return [];
1906
+ const out = [];
1907
+ if (o.sessionID && !this.sessionId) {
1908
+ this.sessionId = String(o.sessionID);
1909
+ out.push({ type: "session", sessionId: this.sessionId });
1910
+ }
1911
+ const part = o.part ?? {};
1912
+ switch (o.type) {
1913
+ // Text and reasoning parts arrive whole, once they end.
1914
+ case "text":
1915
+ if (part.text) {
1916
+ out.push({ type: "text", delta: (this.wrote ? "\n\n" : "") + part.text });
1917
+ this.wrote = true;
1918
+ }
1919
+ break;
1920
+ case "reasoning":
1921
+ if (part.text) out.push({ type: "thinking", delta: part.text });
1922
+ break;
1923
+ // Tools are reported only when they finish, so a card opens and closes in one go.
1924
+ case "tool_use": {
1925
+ const st = part.state ?? {};
1926
+ const id = String(part.callID ?? part.id ?? Math.random());
1927
+ const name = displayToolName(String(part.tool ?? "tool"));
1928
+ const files = changedFiles(String(part.tool ?? ""), st);
1929
+ out.push({ type: "tool", id, name, input: st.input, status: "start", files });
1930
+ out.push({ type: "tool", id, name, status: st.status === "error" ? "error" : "done", output: clip(String(st.status === "error" ? st.error ?? "failed" : st.output ?? "")) });
1931
+ break;
1932
+ }
1933
+ case "step_finish": {
1934
+ const t = part.tokens ?? {};
1935
+ const add = (a, b) => typeof b === "number" ? (a ?? 0) + b : a;
1936
+ this.usage = {
1937
+ // Counted like Claude's input: fresh + cache reads + cache writes.
1938
+ inputTokens: add(add(add(this.usage.inputTokens, t.input), t.cache?.read), t.cache?.write),
1939
+ outputTokens: add(add(this.usage.outputTokens, t.output), t.reasoning),
1940
+ cachedTokens: add(this.usage.cachedTokens, t.cache?.read),
1941
+ costUsd: add(this.usage.costUsd, part.cost)
1942
+ };
1943
+ break;
1944
+ }
1945
+ case "error": {
1946
+ const e = o.error ?? {};
1947
+ const msg = String(e.data?.message ?? e.message ?? e.name ?? "opencode reported an error");
1948
+ this.error = this.error ? `${this.error}
1949
+ ${msg}` : msg;
1950
+ break;
1951
+ }
1952
+ }
1953
+ return out;
1954
+ }
1955
+ /** The CLI exits once the session is idle; there is no closing event. */
1956
+ finish(code, stderr) {
1957
+ if (this.error) return { type: "error", message: this.error, sessionId: this.sessionId };
1958
+ if (code === 0) return { type: "done", sessionId: this.sessionId, usage: this.usage };
1959
+ const tail = stripAnsi(stderr).trim().split("\n").filter((l) => !/^\s*(timestamp=|INFO|DEBUG)/.test(l)).slice(-6).join("\n");
1960
+ return { type: "error", message: tail.replace(/^Error:\s*/, "") || `opencode exited with code ${code}`, sessionId: this.sessionId };
1961
+ }
1962
+ };
1963
+ function changedFiles(tool, st) {
1964
+ if (st.status === "error") return void 0;
1965
+ if ((tool === "edit" || tool === "write") && st.input?.filePath) return [String(st.input.filePath)];
1966
+ if (tool === "apply_patch" && Array.isArray(st.metadata?.files)) {
1967
+ const files = st.metadata.files.map((f) => String(f.movePath ?? f.filePath ?? "")).filter(Boolean);
1968
+ return files.length ? files : void 0;
1969
+ }
1970
+ return void 0;
1971
+ }
1972
+ function opencodeArgs(input, extra = []) {
1973
+ const args = ["run", "--format", "json", "--thinking", "--auto"];
1974
+ if (input.model) args.push("--model", input.model);
1975
+ if (input.effort) args.push("--variant", input.effort);
1976
+ if (input.resumeId) args.push("--session", input.resumeId);
1977
+ const files = input.images.flatMap((img) => ["--file", img.path]);
1978
+ return [...args, ...extra, ...files];
1979
+ }
1980
+ function opencodeConfig(input, existing = process.env.OPENCODE_CONFIG_CONTENT) {
1981
+ let base = {};
1982
+ try {
1983
+ base = existing ? JSON.parse(existing) : {};
1984
+ } catch {
1985
+ }
1986
+ if (!base || typeof base !== "object") base = {};
1987
+ return JSON.stringify({
1988
+ ...base,
1989
+ mcp: {
1990
+ ...base.mcp ?? {},
1991
+ [input.mcp.name]: {
1992
+ type: "remote",
1993
+ url: input.mcp.url,
1994
+ headers: { Authorization: "Bearer {env:PH_MCP_TOKEN}" },
1995
+ oauth: false,
1996
+ // Applies to tool calls too; page tools can wait on the user for a long time.
1997
+ timeout: 18e5
1998
+ }
1999
+ }
2000
+ });
2001
+ }
2002
+ function opencodePrompt(input) {
2003
+ return [input.system ? `<instructions>
2004
+ ${input.system}
2005
+ </instructions>` : "", input.prompt].filter(Boolean).join("\n\n");
2006
+ }
2007
+ function parseOpenCodeModels(out) {
2008
+ const list = [];
2009
+ const lines = stripAnsi(out).split("\n");
2010
+ for (let i = 0; i < lines.length; i++) {
2011
+ const id = lines[i].trim();
2012
+ if (!/^[\w.@-]+\/\S+$/.test(id)) continue;
2013
+ let label = id;
2014
+ let efforts = [];
2015
+ if (lines[i + 1]?.trim() === "{") {
2016
+ let j = i + 1;
2017
+ while (j < lines.length && lines[j] !== "}") j++;
2018
+ try {
2019
+ const m = JSON.parse(lines.slice(i + 1, j + 1).join("\n"));
2020
+ if (m.name) label = String(m.name);
2021
+ efforts = Object.keys(m.variants ?? {});
2022
+ } catch {
2023
+ }
2024
+ i = j;
2025
+ }
2026
+ list.push({ id, label, ...efforts.length ? { efforts } : {} });
2027
+ }
2028
+ return list;
2029
+ }
2030
+ function createOpenCodeRunner(opts = {}) {
2031
+ const bin = () => opts.bin ?? findExecutable(["opencode"]);
2032
+ let models;
2033
+ return {
2034
+ id: "opencode",
2035
+ label: "OpenCode",
2036
+ images: true,
2037
+ async detect() {
2038
+ const p = bin();
2039
+ if (!p) return { ok: false, error: "opencode not found in PATH" };
2040
+ const r = await runCapture(p, ["--version"], 15e3);
2041
+ if (r.code !== 0) return { ok: false, path: p, error: r.stderr.trim() || "opencode --version failed" };
2042
+ return { ok: true, path: p, version: r.stdout.trim().split("\n")[0] };
2043
+ },
2044
+ async listModels() {
2045
+ if (models) return models;
2046
+ const p = bin();
2047
+ if (!p) return [];
2048
+ const r = await runCapture(p, ["models", "--verbose"], 2e4);
2049
+ const found = r.code === 0 ? parseOpenCodeModels(r.stdout) : [];
2050
+ if (found.length) models = found;
2051
+ return found;
2052
+ },
2053
+ run(input, signal) {
2054
+ const q = new EventQueue();
2055
+ void (async () => {
2056
+ const p = bin();
2057
+ if (!p) {
2058
+ q.push({ type: "error", message: "opencode not found in PATH" });
2059
+ return q.end();
2060
+ }
2061
+ const proc = spawnAgent(p, opencodeArgs(input, opts.extraArgs), {
2062
+ cwd: input.cwd,
2063
+ stdin: true,
2064
+ env: { PH_MCP_TOKEN: input.mcp.token, OPENCODE_CONFIG_CONTENT: opencodeConfig(input), OPENCODE_DISABLE_AUTOUPDATE: "1" }
2065
+ });
2066
+ proc.child.stdin?.on("error", () => {
2067
+ });
2068
+ proc.child.stdin?.end(opencodePrompt(input));
2069
+ const onAbort = () => proc.stop();
2070
+ signal.addEventListener("abort", onAbort, { once: true });
2071
+ const parser = new OpenCodeParser();
2072
+ try {
2073
+ for await (const line of proc.lines) for (const ev of parser.feed(parseJson(line))) q.push(ev);
2074
+ const code = await proc.exited;
2075
+ q.push(signal.aborted ? { type: "error", message: "cancelled", sessionId: parser.sessionId } : parser.finish(code, proc.stderrTail()));
2076
+ } finally {
2077
+ signal.removeEventListener("abort", onAbort);
2078
+ q.end();
2079
+ }
2080
+ })().catch((e) => {
2081
+ q.push({ type: "error", message: String(e?.message ?? e) });
2082
+ q.end();
2083
+ });
2084
+ return q;
2085
+ }
2086
+ };
2087
+ }
2088
+
1673
2089
  // src/agents/index.ts
1674
- var AGENT_ORDER = ["claude", "codex", "cursor"];
2090
+ var AGENT_ORDER = ["claude", "codex", "cursor", "codebuddy", "opencode"];
1675
2091
  var AgentRegistry = class {
1676
2092
  runners = /* @__PURE__ */ new Map();
1677
2093
  info = /* @__PURE__ */ new Map();
@@ -1680,6 +2096,8 @@ var AgentRegistry = class {
1680
2096
  this.runners.set("claude", createClaudeRunner(o("claude")));
1681
2097
  this.runners.set("codex", createCodexRunner(o("codex")));
1682
2098
  this.runners.set("cursor", createCursorRunner(o("cursor")));
2099
+ this.runners.set("codebuddy", createCodeBuddyRunner(o("codebuddy")));
2100
+ this.runners.set("opencode", createOpenCodeRunner(o("opencode")));
1683
2101
  if (process.env.PH_FAKE_AGENT === "1") this.runners.set("fake", createFakeRunner());
1684
2102
  }
1685
2103
  async detect() {
@@ -1718,7 +2136,7 @@ var AgentRegistry = class {
1718
2136
  // src/server/app.ts
1719
2137
  import { existsSync as existsSync8, readFileSync as readFileSync8, statSync as statSync5, createReadStream } from "fs";
1720
2138
  import http2 from "http";
1721
- import path11 from "path";
2139
+ import path12 from "path";
1722
2140
  import { fileURLToPath } from "url";
1723
2141
  import { WebSocketServer } from "ws";
1724
2142
 
@@ -1858,7 +2276,7 @@ var log = {
1858
2276
 
1859
2277
  // src/server/mcp.ts
1860
2278
  import { existsSync as existsSync6, readFileSync as readFileSync7, realpathSync, statSync as statSync3 } from "fs";
1861
- import path9 from "path";
2279
+ import path10 from "path";
1862
2280
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1863
2281
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1864
2282
  import { z } from "zod";
@@ -1866,7 +2284,7 @@ import { z } from "zod";
1866
2284
  // src/server/artifacts.ts
1867
2285
  import { EventEmitter as EventEmitter2 } from "events";
1868
2286
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
1869
- import path7 from "path";
2287
+ import path8 from "path";
1870
2288
  var MAX_ARTIFACT_BYTES = 20 * 1024 * 1024;
1871
2289
  function validArtifactName(name) {
1872
2290
  return typeof name === "string" && name.length > 0 && name.length <= 120 && /^[\p{L}\p{N}][\p{L}\p{N} ._()\-+@]*$/u.test(name) && !name.includes("..") && !name.endsWith(".") && !name.endsWith(" ");
@@ -1877,8 +2295,8 @@ var ArtifactStore = class extends EventEmitter2 {
1877
2295
  index;
1878
2296
  constructor(dataDir) {
1879
2297
  super();
1880
- this.root = path7.join(dataDir, "artifacts");
1881
- this.indexFile = path7.join(this.root, "index.json");
2298
+ this.root = path8.join(dataDir, "artifacts");
2299
+ this.indexFile = path8.join(this.root, "index.json");
1882
2300
  mkdirSync4(this.root, { recursive: true });
1883
2301
  this.index = readJson(this.indexFile, { artifacts: [] });
1884
2302
  }
@@ -1901,9 +2319,9 @@ var ArtifactStore = class extends EventEmitter2 {
1901
2319
  this.index.artifacts.push(info);
1902
2320
  }
1903
2321
  const v = info.latest + 1;
1904
- const dir = path7.join(this.root, name);
2322
+ const dir = path8.join(this.root, name);
1905
2323
  mkdirSync4(dir, { recursive: true });
1906
- writeFileSync5(path7.join(dir, `v${v}`), content);
2324
+ writeFileSync5(path8.join(dir, `v${v}`), content);
1907
2325
  info.latest = v;
1908
2326
  info.updatedAt = now;
1909
2327
  info.size = content.length;
@@ -1916,7 +2334,7 @@ var ArtifactStore = class extends EventEmitter2 {
1916
2334
  const info = this.get(name);
1917
2335
  if (!info) return void 0;
1918
2336
  const v = version2 && info.versions.some((x) => x.v === version2) ? version2 : info.latest;
1919
- const file = path7.join(this.root, name, `v${v}`);
2337
+ const file = path8.join(this.root, name, `v${v}`);
1920
2338
  if (!existsSync5(file)) return void 0;
1921
2339
  return { info, data: readFileSync6(file), version: v };
1922
2340
  }
@@ -1992,7 +2410,7 @@ function formatAnswer(spec, a) {
1992
2410
  // src/server/review.ts
1993
2411
  import { EventEmitter as EventEmitter3 } from "events";
1994
2412
  import { isIP } from "net";
1995
- import path8 from "path";
2413
+ import path9 from "path";
1996
2414
 
1997
2415
  // src/shared/paths.ts
1998
2416
  var TRACKING = /^(utm_[a-z]+|gclid|fbclid|msclkid|spm|from|ref|_ga|mc_[a-z]+)$/i;
@@ -2003,10 +2421,10 @@ function normalizePath(url) {
2003
2421
  } catch {
2004
2422
  return "/";
2005
2423
  }
2006
- const path16 = u.pathname.length > 1 ? u.pathname.replace(/\/+$/, "") : u.pathname;
2424
+ const path17 = u.pathname.length > 1 ? u.pathname.replace(/\/+$/, "") : u.pathname;
2007
2425
  const params = [...u.searchParams].filter(([k]) => !TRACKING.test(k) && !k.startsWith("ph-")).sort(([a], [b]) => a.localeCompare(b));
2008
2426
  const search = new URLSearchParams(params).toString();
2009
- return (path16 || "/") + (search ? `?${search}` : "");
2427
+ return (path17 || "/") + (search ? `?${search}` : "");
2010
2428
  }
2011
2429
 
2012
2430
  // src/shared/review-read.ts
@@ -2216,8 +2634,8 @@ function rampGroups(patches) {
2216
2634
  function readerEntries(items) {
2217
2635
  const paths = [...new Set(items.map((i) => i.path))];
2218
2636
  const out = [];
2219
- for (const path16 of paths) {
2220
- const here = items.filter((i) => i.path === path16);
2637
+ for (const path17 of paths) {
2638
+ const here = items.filter((i) => i.path === path17);
2221
2639
  const notes = here.filter((i) => i.kind !== "patch" || !i.patch);
2222
2640
  const patches = here.filter((i) => i.kind === "patch" && i.patch);
2223
2641
  const ramps = rampGroups(patches);
@@ -2389,7 +2807,7 @@ var ReviewStore = class extends EventEmitter3 {
2389
2807
  nextN;
2390
2808
  constructor(dataDir) {
2391
2809
  super();
2392
- this.file = path8.join(dataDir, "review.json");
2810
+ this.file = path9.join(dataDir, "review.json");
2393
2811
  this.state = migrate(readJson(this.file, []));
2394
2812
  this.nextN = this.state.items.reduce((m, i) => Math.max(m, i.n), 0) + 1;
2395
2813
  }
@@ -2543,12 +2961,12 @@ ${s.content}`);
2543
2961
  }
2544
2962
  function projectFile(project, p) {
2545
2963
  const root = realpathSync(project);
2546
- const abs = path9.resolve(root, p);
2964
+ const abs = path10.resolve(root, p);
2547
2965
  if (!existsSync6(abs)) throw new Error(`file not found: ${p}`);
2548
2966
  const real = realpathSync(abs);
2549
- if (real !== root && !real.startsWith(root + path9.sep)) throw new Error("file must be inside the project directory");
2550
- const data = path9.join(root, ".page-harness") + path9.sep;
2551
- if (real.startsWith(data) && !real.startsWith(path9.join(data, "uploads") + path9.sep)) throw new Error("files under .page-harness/ are internal (only chat uploads can be used)");
2967
+ if (real !== root && !real.startsWith(root + path10.sep)) throw new Error("file must be inside the project directory");
2968
+ const data = path10.join(root, ".page-harness") + path10.sep;
2969
+ if (real.startsWith(data) && !real.startsWith(path10.join(data, "uploads") + path10.sep)) throw new Error("files under .page-harness/ are internal (only chat uploads can be used)");
2552
2970
  if (!statSync3(real).isFile()) throw new Error(`not a file: ${p}`);
2553
2971
  return real;
2554
2972
  }
@@ -2778,9 +3196,9 @@ function registerReviewTools(server, host, scope, guard, mode) {
2778
3196
  const probe = await host.probe(scope, input.selector);
2779
3197
  if (probe && probe.count < 0) return fail(`"${input.selector}" is not a valid CSS selector.`);
2780
3198
  if (probe && probe.count === 0) return fail(`No element on the user's page (${probe.url}) matches "${input.selector}". Use a selector from <picked-elements> or page_state.`);
2781
- const path16 = normalizePath(probe?.url || host.pageUrl(scope) || "/");
2782
- const item = host.review.add({ ...input, path: path16, label: probe?.label, anchor: probe?.anchor, author: AGENT_AUTHOR, chatId: scope.chatId, turnId: scope.turnId });
2783
- const where = probe ? `on ${path16}${probe.count > 1 ? ` (selector matches ${probe.count} elements; the first one is used)` : ""}` : `for ${path16} (the page did not answer, so the selector is unchecked)`;
3199
+ const path17 = normalizePath(probe?.url || host.pageUrl(scope) || "/");
3200
+ const item = host.review.add({ ...input, path: path17, label: probe?.label, anchor: probe?.anchor, author: AGENT_AUTHOR, chatId: scope.chatId, turnId: scope.turnId });
3201
+ const where = probe ? `on ${path17}${probe.count > 1 ? ` (selector matches ${probe.count} elements; the first one is used)` : ""}` : `for ${path17} (the page did not answer, so the selector is unchecked)`;
2784
3202
  return ok(`${item.kind === "patch" ? "Change" : "Note"} #${item.n} is ${item.kind === "patch" ? "applied" : "pinned"} ${where}.`);
2785
3203
  };
2786
3204
  const selector = z.string().describe("CSS selector of the element, preferably from <picked-elements> or page_state");
@@ -2885,9 +3303,6 @@ var HOP_HEADERS = /* @__PURE__ */ new Set([
2885
3303
  ]);
2886
3304
  var RELAY_PREFIX = "/__ph/relay/";
2887
3305
  var MAX_HTML_BYTES = 20 << 20;
2888
- function appOrigin(req) {
2889
- return `http://${req.headers.host}`;
2890
- }
2891
3306
  var SECOND_LEVEL = /^(com|net|org|gov|edu|co|ac|ne|or)$/;
2892
3307
  function relaySite(target) {
2893
3308
  const host = target.hostname;
@@ -2961,6 +3376,8 @@ function decompress(body, encoding) {
2961
3376
  }
2962
3377
  function createProxy(opts) {
2963
3378
  const { target } = opts;
3379
+ const scheme = opts.scheme ?? "http";
3380
+ const appOrigin = (req) => `${scheme}://${req.headers.host}`;
2964
3381
  const secure = target.protocol === "https:";
2965
3382
  const agent = secure ? new https.Agent({ keepAlive: true }) : new http.Agent({ keepAlive: true });
2966
3383
  const warned = /* @__PURE__ */ new Set();
@@ -2988,7 +3405,7 @@ function createProxy(opts) {
2988
3405
  for (const k of Object.keys(headers)) if (k.startsWith("x-forwarded-") || k === "forwarded") delete headers[k];
2989
3406
  if (local && !relay2) {
2990
3407
  headers["x-forwarded-host"] = req.headers.host ?? "";
2991
- headers["x-forwarded-proto"] = "http";
3408
+ headers["x-forwarded-proto"] = scheme;
2992
3409
  }
2993
3410
  return headers;
2994
3411
  }
@@ -3003,7 +3420,7 @@ function createProxy(opts) {
3003
3420
  const out = {};
3004
3421
  for (const [k, v] of Object.entries(res.headers)) {
3005
3422
  if (v === void 0 || HOP_HEADERS.has(k)) continue;
3006
- if (k === "set-cookie") out[k] = v.map((c2) => rewriteSetCookie(c2, secure || relay2?.protocol === "https:"));
3423
+ if (k === "set-cookie") out[k] = v.map((c2) => rewriteSetCookie(c2, scheme === "http" && (secure || relay2?.protocol === "https:")));
3007
3424
  else if (k === "location") out[k] = rewriteLocation(String(v), req, relay2);
3008
3425
  else if (k === "strict-transport-security") continue;
3009
3426
  else out[k] = v;
@@ -3155,33 +3572,33 @@ function escapeHtml(s) {
3155
3572
 
3156
3573
  // src/server/uploads.ts
3157
3574
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync4, statSync as statSync4, writeFileSync as writeFileSync6 } from "fs";
3158
- import path10 from "path";
3575
+ import path11 from "path";
3159
3576
  var MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
3160
3577
  function safeName(name) {
3161
- const base = path10.basename(name || "file").replace(/[^\p{L}\p{N}._\-() ]+/gu, "_").replace(/^\.+/, "");
3578
+ const base = path11.basename(name || "file").replace(/[^\p{L}\p{N}._\-() ]+/gu, "_").replace(/^\.+/, "");
3162
3579
  return base.slice(-100) || "file";
3163
3580
  }
3164
3581
  var Uploads = class {
3165
3582
  root;
3166
3583
  constructor(dataDir) {
3167
- this.root = path10.join(dataDir, "uploads");
3584
+ this.root = path11.join(dataDir, "uploads");
3168
3585
  mkdirSync5(this.root, { recursive: true });
3169
3586
  }
3170
3587
  save(name, mime, data) {
3171
3588
  if (data.length > MAX_UPLOAD_BYTES) throw new Error("file too large (max 25 MB)");
3172
3589
  const id = newId("u_");
3173
3590
  const file = safeName(name);
3174
- mkdirSync5(path10.join(this.root, id), { recursive: true });
3175
- writeFileSync6(path10.join(this.root, id, file), data);
3591
+ mkdirSync5(path11.join(this.root, id), { recursive: true });
3592
+ writeFileSync6(path11.join(this.root, id, file), data);
3176
3593
  const cleanMime = /^[\w.+-]+\/[\w.+-]+$/.test(mime) ? mime : "application/octet-stream";
3177
3594
  return { id, name: file, mime: cleanMime, size: data.length, kind: cleanMime.startsWith("image/") ? "image" : "file" };
3178
3595
  }
3179
3596
  path(id) {
3180
3597
  if (!/^u_[a-z0-9]+$/i.test(id)) return void 0;
3181
- const dir = path10.join(this.root, id);
3598
+ const dir = path11.join(this.root, id);
3182
3599
  if (!existsSync7(dir)) return void 0;
3183
- const name = readdirSync4(dir).find((n) => statSync4(path10.join(dir, n)).isFile());
3184
- return name ? path10.join(dir, name) : void 0;
3600
+ const name = readdirSync4(dir).find((n) => statSync4(path11.join(dir, n)).isFile());
3601
+ return name ? path11.join(dir, name) : void 0;
3185
3602
  }
3186
3603
  };
3187
3604
 
@@ -3208,9 +3625,9 @@ var MIME = {
3208
3625
  ".map": "application/json"
3209
3626
  };
3210
3627
  function clientDir() {
3211
- const here = path11.dirname(fileURLToPath(import.meta.url));
3212
- const candidates = [path11.join(here, "client"), path11.join(here, "..", "client"), path11.join(here, "..", "..", "dist", "client")];
3213
- return candidates.find((d) => existsSync8(path11.join(d, "page", "overlay.js"))) ?? candidates[0];
3628
+ const here = path12.dirname(fileURLToPath(import.meta.url));
3629
+ const candidates = [path12.join(here, "client"), path12.join(here, "..", "client"), path12.join(here, "..", "..", "dist", "client")];
3630
+ return candidates.find((d) => existsSync8(path12.join(d, "page", "overlay.js"))) ?? candidates[0];
3214
3631
  }
3215
3632
  var HttpError = class extends Error {
3216
3633
  constructor(status, message) {
@@ -3260,7 +3677,7 @@ async function readJsonBody(req, limit = 1 << 20) {
3260
3677
  function serveFile(res, file, extra = {}, cache = "no-cache") {
3261
3678
  if (!existsSync8(file) || !statSync5(file).isFile()) return send(res, 404, "not found");
3262
3679
  res.writeHead(200, {
3263
- "Content-Type": MIME[path11.extname(file)] ?? "application/octet-stream",
3680
+ "Content-Type": MIME[path12.extname(file)] ?? "application/octet-stream",
3264
3681
  "Cache-Control": cache,
3265
3682
  "X-Content-Type-Options": "nosniff",
3266
3683
  ...extra
@@ -3269,11 +3686,11 @@ function serveFile(res, file, extra = {}, cache = "no-cache") {
3269
3686
  }
3270
3687
  async function startServers(o) {
3271
3688
  const dir = clientDir();
3272
- const pageDir = path11.join(dir, "page");
3273
- const appDir = path11.join(dir, "app");
3274
- const controlOrigin = (req) => `http://${hostname(req)}:${o.controlPort}`;
3275
- const appOrigin2 = (req) => `http://${hostname(req)}:${o.appPort}`;
3276
- const proxy = createProxy({ target: o.target, cookieName: o.auth.cookieName, controlOrigin, keepHost: o.keepHost });
3689
+ const pageDir = path12.join(dir, "page");
3690
+ const appDir = path12.join(dir, "app");
3691
+ const controlOrigin = (req) => o.auth.origin(hostname(req), o.controlPort);
3692
+ const appOrigin = (req) => o.auth.origin(hostname(req), o.appPort);
3693
+ const proxy = createProxy({ target: o.target, cookieName: o.auth.cookieName, controlOrigin, keepHost: o.keepHost, scheme: o.auth.scheme });
3277
3694
  const pageScripts = {
3278
3695
  [OVERLAY_PATH]: "overlay.js",
3279
3696
  "/__ph/page-control.js": "page-control.js",
@@ -3299,7 +3716,7 @@ async function startServers(o) {
3299
3716
  }
3300
3717
  const file = pageScripts[url.pathname];
3301
3718
  if (!file || req.method !== "GET") return send(res, 404, "not found");
3302
- const p = path11.join(pageDir, file);
3719
+ const p = path12.join(pageDir, file);
3303
3720
  if (!existsSync8(p)) return send(res, 503, `/* page-harness: ${file} missing, run npm run build */`, "text/javascript");
3304
3721
  let body = readFileSync8(p, "utf8");
3305
3722
  if (file === "overlay.js") body = `window.__PH_CONFIG=${JSON.stringify({ controlOrigin: controlOrigin(req), appPort: o.appPort, target: o.target.host, relay: proxy.site })};
@@ -3325,7 +3742,7 @@ async function startServers(o) {
3325
3742
  "style-src 'self' 'unsafe-inline'",
3326
3743
  "img-src 'self' data: blob:",
3327
3744
  "font-src 'self' data:",
3328
- `connect-src 'self' ws://${req.headers.host}`,
3745
+ `connect-src 'self' ${o.auth.scheme === "https" ? "wss" : "ws"}://${req.headers.host}`,
3329
3746
  "frame-src 'self' blob: data:",
3330
3747
  "worker-src 'self' blob:",
3331
3748
  "object-src 'none'",
@@ -3337,14 +3754,14 @@ async function startServers(o) {
3337
3754
  "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'"
3338
3755
  });
3339
3756
  const servePage = (req, res, page) => {
3340
- const file = path11.join(appDir, `${page}.html`);
3757
+ const file = path12.join(appDir, `${page}.html`);
3341
3758
  const who = o.auth.issuePageSession(req, res);
3342
3759
  if (!who) return noAccess(res, "Open this page with the invite link from the person running Page Harness.<br>\u8BF7\u4F7F\u7528 Page Harness \u53D1\u8D77\u4EBA\u5206\u4EAB\u7684\u9080\u8BF7\u94FE\u63A5\u6253\u5F00\u3002");
3343
3760
  if (page === "history" && who.access !== "owner") return noAccess(res, "Chat history is only available to the person running Page Harness.<br>\u5BF9\u8BDD\u8BB0\u5F55\u4EC5\u53D1\u8D77\u4EBA\u53EF\u89C1\u3002");
3344
3761
  if (!existsSync8(file)) return send(res, 503, "page-harness: client build missing, run npm run build");
3345
- const html = readFileSync8(file, "utf8").replace("<html", `<html data-app-origin="${appOrigin2(req)}"`);
3762
+ const html = readFileSync8(file, "utf8").replace("<html", `<html data-app-origin="${appOrigin(req)}"`);
3346
3763
  send(res, 200, html, "text/html; charset=utf-8", {
3347
- "Content-Security-Policy": panelCsp(req, page === "panel" ? appOrigin2(req) : "'none'"),
3764
+ "Content-Security-Policy": panelCsp(req, page === "panel" ? appOrigin(req) : "'none'"),
3348
3765
  "Referrer-Policy": "no-referrer",
3349
3766
  "Cross-Origin-Opener-Policy": page === "history" ? "same-origin" : "unsafe-none"
3350
3767
  });
@@ -3368,15 +3785,15 @@ async function startServers(o) {
3368
3785
  if (p === "/__ph/join" && req.method === "GET") {
3369
3786
  const who = o.auth.joinWithInvite(req, res, url.searchParams.get("t") ?? "");
3370
3787
  if (!who) return noAccess(res, "This invite link is not valid (sharing may be off).<br>\u9080\u8BF7\u94FE\u63A5\u65E0\u6548\uFF0C\u53EF\u80FD\u5BF9\u65B9\u5DF2\u5173\u95ED\u5206\u4EAB\u3002");
3371
- res.writeHead(302, { Location: `${appOrigin2(req)}/`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
3788
+ res.writeHead(302, { Location: `${appOrigin(req)}/`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
3372
3789
  return res.end();
3373
3790
  }
3374
3791
  if (p === "/__ph/ui" && req.method === "GET") return servePage(req, res, "panel");
3375
3792
  if (p === "/__ph/history" && req.method === "GET") return servePage(req, res, "history");
3376
3793
  if (p.startsWith("/__ph/app/") && req.method === "GET") {
3377
3794
  const rel = decodeURIComponent(p.slice("/__ph/app/".length));
3378
- const file = path11.resolve(appDir, rel);
3379
- if (!file.startsWith(appDir + path11.sep) || rel.endsWith(".html")) return send(res, 404, "not found");
3795
+ const file = path12.resolve(appDir, rel);
3796
+ if (!file.startsWith(appDir + path12.sep) || rel.endsWith(".html")) return send(res, 404, "not found");
3380
3797
  return serveFile(res, file, {}, rel.startsWith("assets/") ? "public, max-age=31536000, immutable" : "no-cache");
3381
3798
  }
3382
3799
  if (p.startsWith("/__ph/api/")) {
@@ -3554,7 +3971,7 @@ function createApi(o) {
3554
3971
 
3555
3972
  // src/server/auth.ts
3556
3973
  import { randomBytes, timingSafeEqual } from "crypto";
3557
- import os2 from "os";
3974
+ import os4 from "os";
3558
3975
  function randomToken(bytes = 32) {
3559
3976
  return randomBytes(bytes).toString("base64url");
3560
3977
  }
@@ -3586,13 +4003,14 @@ function isLoopback(req) {
3586
4003
  var Auth = class {
3587
4004
  constructor(opts) {
3588
4005
  this.opts = opts;
4006
+ this.scheme = opts.publicScheme ?? "http";
3589
4007
  this.cookieName = `__ph_${opts.controlPort}`;
3590
4008
  this.appCookieName = `${this.cookieName}_app`;
3591
4009
  const names = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
3592
4010
  if (opts.host && opts.host !== "0.0.0.0" && opts.host !== "::") names.add(opts.host);
3593
4011
  if (opts.host === "0.0.0.0" || opts.host === "::") {
3594
- names.add(os2.hostname().toLowerCase());
3595
- for (const list of Object.values(os2.networkInterfaces())) {
4012
+ names.add(os4.hostname().toLowerCase());
4013
+ for (const list of Object.values(os4.networkInterfaces())) {
3596
4014
  for (const a of list ?? []) names.add(a.family === "IPv6" ? `[${a.address}]` : a.address);
3597
4015
  }
3598
4016
  }
@@ -3605,20 +4023,28 @@ var Auth = class {
3605
4023
  cookieName;
3606
4024
  /** Reviewer pass for the app port, so other machines only see the page through the invite. */
3607
4025
  appCookieName;
4026
+ scheme;
3608
4027
  hostnames;
3609
4028
  /** Host header must name this machine and the port the request arrived on. */
3610
4029
  checkHost(req, port) {
3611
4030
  const host = (req.headers.host ?? "").toLowerCase();
3612
4031
  const m = /^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/.exec(host);
3613
4032
  if (!m) return false;
3614
- const p = m[2] ? Number(m[2]) : 80;
4033
+ const p = m[2] ? Number(m[2]) : this.scheme === "https" ? 443 : 80;
3615
4034
  return this.hostnames.has(m[1]) && p === port;
3616
4035
  }
4036
+ /** Origin of a port as the browser sees it. */
4037
+ origin(hostname2, port) {
4038
+ return `${this.scheme}://${hostname2}:${port}`;
4039
+ }
4040
+ get secureAttr() {
4041
+ return this.scheme === "https" ? "; Secure" : "";
4042
+ }
3617
4043
  get sharing() {
3618
4044
  return !!this.opts.team?.enabled;
3619
4045
  }
3620
4046
  setCookie(res, value) {
3621
- res.appendHeader("Set-Cookie", `${this.cookieName}=${value}; Path=/__ph; HttpOnly; SameSite=Strict${value === this.uiSecret ? "" : "; Max-Age=31536000"}`);
4047
+ res.appendHeader("Set-Cookie", `${this.cookieName}=${value}; Path=/__ph; HttpOnly; SameSite=Strict${this.secureAttr}${value === this.uiSecret ? "" : "; Max-Age=31536000"}`);
3622
4048
  }
3623
4049
  /**
3624
4050
  * Called when a panel page is served. Keeps an existing session; otherwise local requests get
@@ -3639,7 +4065,7 @@ var Auth = class {
3639
4065
  if (cur?.access === "owner") return cur;
3640
4066
  const id = cur?.access === "reviewer" && cur.id ? cur.id : team.join();
3641
4067
  if (id !== cur?.id) this.setCookie(res, team.sign(id));
3642
- res.appendHeader("Set-Cookie", `${this.appCookieName}=${team.sign(id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`);
4068
+ res.appendHeader("Set-Cookie", `${this.appCookieName}=${team.sign(id)}; Path=/; HttpOnly; SameSite=Lax${this.secureAttr}; Max-Age=31536000`);
3643
4069
  return { access: "reviewer", id };
3644
4070
  }
3645
4071
  /** App port: open locally; while sharing, other machines need the pass from the invite link. */
@@ -3657,7 +4083,7 @@ var Auth = class {
3657
4083
  }
3658
4084
  sameOrigin(req) {
3659
4085
  const origin = req.headers.origin;
3660
- return typeof origin === "string" && origin === `http://${req.headers.host}`;
4086
+ return typeof origin === "string" && origin === `${this.scheme}://${req.headers.host}`;
3661
4087
  }
3662
4088
  /** Browser API calls on the control origin. Returns an error string, or null when allowed. */
3663
4089
  checkUi(req) {
@@ -3888,7 +4314,7 @@ function turnText(turn) {
3888
4314
  }
3889
4315
 
3890
4316
  // src/server/prompt.ts
3891
- import path12 from "path";
4317
+ import path13 from "path";
3892
4318
  var esc = (s) => s.replace(/</g, "&lt;");
3893
4319
  function buildPrompt(msg, opts) {
3894
4320
  const parts = [];
@@ -3917,7 +4343,7 @@ ${lines.join("\n")}
3917
4343
  for (const a of msg.attachments) {
3918
4344
  const p = opts.uploads.path(a.id);
3919
4345
  if (!p) continue;
3920
- const shown = path12.relative(opts.project, p).startsWith("..") ? p : path12.relative(opts.project, p);
4346
+ const shown = path13.relative(opts.project, p).startsWith("..") ? p : path13.relative(opts.project, p);
3921
4347
  if (a.kind === "image" && opts.nativeImages) {
3922
4348
  images.push({ path: p, mime: a.mime });
3923
4349
  lines.push(`- ${a.name} (image, attached; also at ${shown})`);
@@ -4045,6 +4471,15 @@ var Gateway = class {
4045
4471
  this.chats.set(meta.id, new Chat(this.store, meta, false).load());
4046
4472
  return meta;
4047
4473
  }
4474
+ /**
4475
+ * The effort to send, only when the chat's model lists it among two or more levels. Agents and models without
4476
+ * selectable effort (Claude, Cursor, CodeBuddy, most OpenCode models) never get the flag, even if a stale
4477
+ * value is stored on the chat.
4478
+ */
4479
+ async effortFor(agent, model, effort) {
4480
+ if (!effort) return void 0;
4481
+ return supportedEffort(await this.o.registry.models(agent).catch(() => []), model, effort);
4482
+ }
4048
4483
  updateChat(id, patch) {
4049
4484
  const c2 = this.chat(id);
4050
4485
  if (!c2) throw new Error("no such chat");
@@ -4054,11 +4489,18 @@ var Gateway = class {
4054
4489
  c2.meta.agent = patch.agent;
4055
4490
  c2.meta.agentSessionId = void 0;
4056
4491
  c2.meta.model = void 0;
4492
+ c2.meta.effort = void 0;
4493
+ c2.meta.fast = void 0;
4057
4494
  }
4058
4495
  if (patch.model !== void 0) {
4059
- if ((patch.model ?? void 0) !== c2.meta.model) c2.meta.agentSessionId = c2.meta.agent === "codex" ? c2.meta.agentSessionId : void 0;
4496
+ if ((patch.model ?? void 0) !== c2.meta.model) {
4497
+ c2.meta.agentSessionId = c2.meta.agent === "codex" || c2.meta.agent === "opencode" ? c2.meta.agentSessionId : void 0;
4498
+ c2.meta.effort = void 0;
4499
+ }
4060
4500
  c2.meta.model = patch.model || void 0;
4061
4501
  }
4502
+ if (patch.effort !== void 0) c2.meta.effort = patch.effort || void 0;
4503
+ if (patch.fast !== void 0) c2.meta.fast = patch.fast === true || void 0;
4062
4504
  if (c2.persisted) c2.persist();
4063
4505
  this.broadcastState(c2);
4064
4506
  this.broadcastChats();
@@ -4325,6 +4767,8 @@ var Gateway = class {
4325
4767
  images: built.images,
4326
4768
  cwd: this.o.project,
4327
4769
  model: c2.meta.model,
4770
+ effort: await this.effortFor(c2.meta.agent, c2.meta.model, c2.meta.effort),
4771
+ fast: c2.meta.fast,
4328
4772
  resumeId: c2.meta.agentSessionId,
4329
4773
  mcp: { name: MCP_SERVER_NAME, url: this.o.mcpUrl, token }
4330
4774
  };
@@ -4584,6 +5028,11 @@ var Gateway = class {
4584
5028
  for (const c2 of this.chats.values()) c2.flush();
4585
5029
  }
4586
5030
  };
5031
+ function supportedEffort(models, model, effort) {
5032
+ if (!effort) return void 0;
5033
+ const m = model ? models.find((x) => x.id === model) : models.find((x) => x.isDefault);
5034
+ return m?.efforts && m.efforts.length > 1 && m.efforts.includes(effort) ? effort : void 0;
5035
+ }
4587
5036
 
4588
5037
  // src/server/pageBridge.ts
4589
5038
  var PAGE_MESSAGES = {
@@ -4779,11 +5228,11 @@ var PageBridge = class {
4779
5228
  // src/server/team.ts
4780
5229
  import { createHmac } from "crypto";
4781
5230
  import { chmodSync as chmodSync2 } from "fs";
4782
- import path13 from "path";
5231
+ import path14 from "path";
4783
5232
  var Team = class {
4784
5233
  constructor(dataDir, enabled) {
4785
5234
  this.enabled = enabled;
4786
- this.file = path13.join(dataDir, "team.json");
5235
+ this.file = path14.join(dataDir, "team.json");
4787
5236
  const d = readJson(this.file, {});
4788
5237
  this.data = { invite: d.invite || randomToken(18), key: d.key || randomToken(), ownerName: d.ownerName, reviewers: d.reviewers ?? {} };
4789
5238
  if (!d.invite || !d.key) this.save();
@@ -4835,25 +5284,25 @@ var Team = class {
4835
5284
 
4836
5285
  // src/server/harness.ts
4837
5286
  async function startHarness(o) {
4838
- const dataDir = path14.join(o.project, ".page-harness");
5287
+ const dataDir = path15.join(o.project, ".page-harness");
4839
5288
  mkdirSync6(dataDir, { recursive: true });
4840
- if (!existsSync9(path14.join(dataDir, ".gitignore"))) writeFileSync7(path14.join(dataDir, ".gitignore"), "*\n");
5289
+ if (!existsSync9(path15.join(dataDir, ".gitignore"))) writeFileSync7(path15.join(dataDir, ".gitignore"), "*\n");
4841
5290
  if (recoverCursorMcp(o.project)) log.warn("restored .cursor/mcp.json left behind by a previous run");
4842
5291
  const registry = new AgentRegistry({ paths: o.agentPaths });
4843
5292
  const agents = await registry.detect();
4844
5293
  let defaultAgent = o.agent;
4845
5294
  if (defaultAgent) {
4846
- if (!registry.get(defaultAgent)) throw new Error(`unknown agent "${defaultAgent}"; use claude, codex or cursor`);
5295
+ if (!registry.get(defaultAgent)) throw new Error(`unknown agent "${defaultAgent}"; use ${AGENT_ORDER.join(", ")}`);
4847
5296
  if (!registry.available(defaultAgent)) log.warn(`${defaultAgent} CLI not found; chats will fail until it is installed (or pass --${defaultAgent}-path)`);
4848
5297
  } else {
4849
5298
  defaultAgent = registry.firstAvailable();
4850
5299
  if (!defaultAgent) {
4851
- log.warn("no agent CLI found (claude, codex or cursor-agent). The panel will open, but chats cannot run until one is installed.");
5300
+ log.warn("no agent CLI found (claude, codex, cursor-agent, codebuddy or opencode). The panel will open, but chats cannot run until one is installed.");
4852
5301
  defaultAgent = "claude";
4853
5302
  }
4854
5303
  }
4855
5304
  const team = new Team(dataDir, !!o.share);
4856
- const auth = new Auth({ host: o.host, appPort: o.appPort, controlPort: o.controlPort, extraHosts: o.allowHosts, team });
5305
+ const auth = new Auth({ host: o.host, appPort: o.appPort, controlPort: o.controlPort, extraHosts: o.allowHosts, team, publicScheme: o.publicScheme });
4857
5306
  const artifacts = new ArtifactStore(dataDir);
4858
5307
  const uploads = new Uploads(dataDir);
4859
5308
  const mode = o.mode ?? detectMode(o.target);
@@ -4862,9 +5311,9 @@ async function startHarness(o) {
4862
5311
  const bridge = new PageBridge();
4863
5312
  const review = new ReviewStore(dataDir);
4864
5313
  const shownHost = o.host === "0.0.0.0" || o.host === "::" || o.host === "127.0.0.1" ? "localhost" : o.host;
4865
- const appUrl = `http://${shownHost}:${o.appPort}`;
5314
+ const appUrl = auth.origin(shownHost, o.appPort);
4866
5315
  const shareHost = o.host === "0.0.0.0" || o.host === "::" ? lanAddress() ?? shownHost : shownHost;
4867
- const shareUrl = o.share ? `http://${shareHost}:${o.controlPort}/__ph/join?t=${team.invite}` : void 0;
5316
+ const shareUrl = o.share ? `${auth.origin(shareHost, o.controlPort)}/__ph/join?t=${team.invite}` : void 0;
4868
5317
  const gateway = new Gateway({
4869
5318
  project: o.project,
4870
5319
  dataDir,
@@ -4903,7 +5352,7 @@ async function startHarness(o) {
4903
5352
  let closed = false;
4904
5353
  return {
4905
5354
  appUrl,
4906
- controlUrl: `http://${shownHost}:${o.controlPort}`,
5355
+ controlUrl: auth.origin(shownHost, o.controlPort),
4907
5356
  agents,
4908
5357
  defaultAgent,
4909
5358
  mode,
@@ -4927,7 +5376,7 @@ function mcpHost(host) {
4927
5376
  return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
4928
5377
  }
4929
5378
  function lanAddress() {
4930
- for (const list of Object.values(os3.networkInterfaces()))
5379
+ for (const list of Object.values(os5.networkInterfaces()))
4931
5380
  for (const a of list ?? []) if (a.family === "IPv4" && !a.internal) return a.address;
4932
5381
  return void 0;
4933
5382
  }
@@ -4944,15 +5393,17 @@ Examples
4944
5393
 
4945
5394
  Options
4946
5395
  --project <dir> Project the agent works in (default: current directory)
4947
- --agent <id> claude | codex | cursor (default: first one installed)
5396
+ --agent <id> claude | codex | cursor | codebuddy | opencode (default: first one installed)
4948
5397
  --model <name> Default model for new chats
4949
5398
  --port <n> App port: the proxied frontend (default 17990)
4950
5399
  --control-port <n> Panel / API port (default: app port + 1)
4951
5400
  --host <addr> Listen address (default 127.0.0.1)
4952
5401
  --allow-host <name> Extra Host header to accept (repeatable), e.g. a LAN name
4953
5402
  --keep-host Forward the browser's Host header to the target
5403
+ --public-scheme <s> http | https: scheme browsers use to reach both ports (default http).
5404
+ Use https behind a load balancer that terminates TLS on the same ports
4954
5405
  --open Open the browser
4955
- --claude-path <bin> Path to the claude CLI (also --codex-path, --cursor-path)
5406
+ --claude-path <bin> Path to the claude CLI (also --codex-path, --cursor-path, --codebuddy-path, --opencode-path)
4956
5407
  --live-guard-hook Install a git pre-commit hook that rejects Live candidate markers
4957
5408
  --review No source for this page: the agent leaves notes and page changes instead of editing files
4958
5409
  (default for public sites; --source forces source mode)
@@ -4963,8 +5414,8 @@ Options
4963
5414
  -h, --help
4964
5415
  `;
4965
5416
  function version() {
4966
- const here = path15.dirname(fileURLToPath2(import.meta.url));
4967
- for (const p of [path15.join(here, "..", "package.json"), path15.join(here, "..", "..", "package.json")]) {
5417
+ const here = path16.dirname(fileURLToPath2(import.meta.url));
5418
+ for (const p of [path16.join(here, "..", "package.json"), path16.join(here, "..", "..", "package.json")]) {
4968
5419
  try {
4969
5420
  const pkg = JSON.parse(readFileSync9(p, "utf8"));
4970
5421
  if (pkg.name === "page-harness") return pkg.version;
@@ -5024,10 +5475,13 @@ async function main(argv = process.argv.slice(2)) {
5024
5475
  host: { type: "string" },
5025
5476
  "allow-host": { type: "string", multiple: true },
5026
5477
  "keep-host": { type: "boolean" },
5478
+ "public-scheme": { type: "string" },
5027
5479
  open: { type: "boolean" },
5028
5480
  "claude-path": { type: "string" },
5029
5481
  "codex-path": { type: "string" },
5030
5482
  "cursor-path": { type: "string" },
5483
+ "codebuddy-path": { type: "string" },
5484
+ "opencode-path": { type: "string" },
5031
5485
  "live-guard-hook": { type: "boolean" },
5032
5486
  review: { type: "boolean" },
5033
5487
  source: { type: "boolean" },
@@ -5050,12 +5504,14 @@ async function main(argv = process.argv.slice(2)) {
5050
5504
  if (values.verbose) log.setVerbose(true);
5051
5505
  if (values.quiet) log.setQuiet(true);
5052
5506
  const target = normalizeTarget(positionals[0]);
5053
- const project = path15.resolve(values.project ?? process.cwd());
5507
+ const project = path16.resolve(values.project ?? process.cwd());
5054
5508
  if (!existsSync10(project) || !statSync6(project).isDirectory()) throw new Error(`project directory not found: ${project}`);
5055
5509
  const host = values.host ?? (values.share ? "0.0.0.0" : "127.0.0.1");
5056
5510
  const [appPort, controlPort] = await pickPorts(host, intOpt(values.port, "port"), intOpt(values["control-port"], "control-port"));
5057
5511
  if (appPort === controlPort) throw new Error("--port and --control-port must differ");
5058
5512
  if (values.review && values.source) throw new Error("use either --review or --source");
5513
+ const publicScheme = values["public-scheme"] ?? "http";
5514
+ if (publicScheme !== "http" && publicScheme !== "https") throw new Error("--public-scheme must be http or https");
5059
5515
  const h = await startHarness({
5060
5516
  target,
5061
5517
  project,
@@ -5066,7 +5522,14 @@ async function main(argv = process.argv.slice(2)) {
5066
5522
  model: values.model,
5067
5523
  allowHosts: values["allow-host"],
5068
5524
  keepHost: values["keep-host"],
5069
- agentPaths: { claude: values["claude-path"], codex: values["codex-path"], cursor: values["cursor-path"] },
5525
+ publicScheme,
5526
+ agentPaths: {
5527
+ claude: values["claude-path"],
5528
+ codex: values["codex-path"],
5529
+ cursor: values["cursor-path"],
5530
+ codebuddy: values["codebuddy-path"],
5531
+ opencode: values["opencode-path"]
5532
+ },
5070
5533
  liveGuardHook: values["live-guard-hook"],
5071
5534
  mode: values.review ? "review" : values.source ? "source" : void 0,
5072
5535
  share: values.share