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/README.md +23 -5
- package/README.zh-CN.md +23 -5
- package/THIRD_PARTY_LICENSES.md +31 -0
- package/dist/cli.js +574 -111
- package/dist/cli.js.map +1 -1
- package/dist/client/app/assets/history-Clt0CHe-.js +1 -0
- package/dist/client/app/assets/history-SejuExAu.css +1 -0
- package/dist/client/app/assets/{index.es-pLGXQA_f.js → index.es-BK9-mTsa.js} +1 -1
- package/dist/client/app/assets/{jspdf.es.min-OsUFBIXu.js → jspdf.es.min-Qzb6sJ_Q.js} +4 -4
- package/dist/client/app/assets/panel-B47-AaSQ.js +2 -0
- package/dist/client/app/assets/panel-CSA_nlud.css +1 -0
- package/dist/client/app/assets/{ws-B8OO397O.js → ws-DAWfQL6l.js} +21 -21
- package/dist/client/app/assets/{ws-BhXqMf5F.css → ws-DtkfUYSX.css} +1 -1
- package/dist/client/app/history.html +4 -4
- package/dist/client/app/panel.html +4 -4
- package/dist/client/page/live-overlay.js +6 -3
- package/dist/client/page/overlay.js +16 -13
- package/package.json +1 -1
- package/dist/client/app/assets/history-DTQ4t3U_.js +0 -1
- package/dist/client/app/assets/history-DfMi5ceT.css +0 -1
- package/dist/client/app/assets/panel-5OkTcFqg.css +0 -1
- package/dist/client/app/assets/panel-dt4wG7is.js +0 -2
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
|
|
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
|
|
448
|
-
import
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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}
|
|
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",
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1246
|
+
import path5 from "path";
|
|
1022
1247
|
function writeJsonAtomic(file, data) {
|
|
1023
|
-
mkdirSync2(
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
1284
|
+
appendFileSync(path5.join(d, "events.jsonl"), JSON.stringify(ev) + "\n");
|
|
1060
1285
|
}
|
|
1061
1286
|
readEvents(chatId) {
|
|
1062
|
-
const file =
|
|
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(
|
|
1300
|
+
writeJsonAtomic(path5.join(this.dir(chatId), "queue.json"), queue);
|
|
1076
1301
|
}
|
|
1077
1302
|
loadQueue(chatId) {
|
|
1078
|
-
return readJson(
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
1881
|
-
this.indexFile =
|
|
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 =
|
|
2322
|
+
const dir = path8.join(this.root, name);
|
|
1905
2323
|
mkdirSync4(dir, { recursive: true });
|
|
1906
|
-
writeFileSync5(
|
|
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 =
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
2220
|
-
const here = items.filter((i) => i.path ===
|
|
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 =
|
|
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 =
|
|
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 +
|
|
2550
|
-
const data =
|
|
2551
|
-
if (real.startsWith(data) && !real.startsWith(
|
|
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
|
|
2782
|
-
const item = host.review.add({ ...input, path:
|
|
2783
|
-
const where = probe ? `on ${
|
|
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"] =
|
|
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
|
|
3575
|
+
import path11 from "path";
|
|
3159
3576
|
var MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
3160
3577
|
function safeName(name) {
|
|
3161
|
-
const base =
|
|
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 =
|
|
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(
|
|
3175
|
-
writeFileSync6(
|
|
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 =
|
|
3598
|
+
const dir = path11.join(this.root, id);
|
|
3182
3599
|
if (!existsSync7(dir)) return void 0;
|
|
3183
|
-
const name = readdirSync4(dir).find((n) => statSync4(
|
|
3184
|
-
return name ?
|
|
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 =
|
|
3212
|
-
const candidates = [
|
|
3213
|
-
return candidates.find((d) => existsSync8(
|
|
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[
|
|
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 =
|
|
3273
|
-
const appDir =
|
|
3274
|
-
const controlOrigin = (req) =>
|
|
3275
|
-
const
|
|
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 =
|
|
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 =
|
|
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="${
|
|
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" ?
|
|
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: `${
|
|
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 =
|
|
3379
|
-
if (!file.startsWith(appDir +
|
|
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
|
|
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(
|
|
3595
|
-
for (const list of Object.values(
|
|
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 ===
|
|
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
|
|
4317
|
+
import path13 from "path";
|
|
3892
4318
|
var esc = (s) => s.replace(/</g, "<");
|
|
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 =
|
|
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)
|
|
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
|
|
5231
|
+
import path14 from "path";
|
|
4783
5232
|
var Team = class {
|
|
4784
5233
|
constructor(dataDir, enabled) {
|
|
4785
5234
|
this.enabled = enabled;
|
|
4786
|
-
this.file =
|
|
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 =
|
|
5287
|
+
const dataDir = path15.join(o.project, ".page-harness");
|
|
4839
5288
|
mkdirSync6(dataDir, { recursive: true });
|
|
4840
|
-
if (!existsSync9(
|
|
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
|
|
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
|
|
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 =
|
|
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 ?
|
|
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:
|
|
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(
|
|
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 =
|
|
4967
|
-
for (const p of [
|
|
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 =
|
|
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
|
-
|
|
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
|