@youweichen/pi-web-ui 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/bin/pi-web-ui.mjs +1818 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
- package/deploy/nginx-subpath.conf +88 -0
- package/deploy/pi-web-ui-task.xml +71 -0
- package/deploy/pi-web-ui.service +31 -0
- package/dist/server/agent-service.js +2975 -0
- package/dist/server/attachments.js +672 -0
- package/dist/server/bg-servers.js +150 -0
- package/dist/server/client-state.js +234 -0
- package/dist/server/control-socket.js +177 -0
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/files-service.js +709 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +879 -0
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/model-admin.js +821 -0
- package/dist/server/patch-node-pty.js +80 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +893 -0
- package/dist/server/process-utils.js +112 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/serialize.js +172 -0
- package/dist/server/settings-service.js +395 -0
- package/dist/server/slash-commands.js +263 -0
- package/dist/server/terminals.js +1372 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/vision-bridge.js +131 -0
- package/dist/server/webui-context.js +222 -0
- package/extensions/webui.ts +192 -0
- package/package.json +99 -0
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
- package/web/dist/assets/arc-pkuxEN8A.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
- package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
- package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
- package/web/dist/assets/channel-0Y__6sq2.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
- package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
- package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
- package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
- package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
- package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
- package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
- package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
- package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
- package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
- package/web/dist/assets/index-CniHcFxT.js +22 -0
- package/web/dist/assets/index-DYz2iP_B.css +10 -0
- package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
- package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
- package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
- package/web/dist/assets/katex-C5jXJg4s.js +257 -0
- package/web/dist/assets/layout-zq_9DigZ.js +1 -0
- package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
- package/web/dist/assets/markdown-CUPbftfV.js +51 -0
- package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
- package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
- package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
- package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
- package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
- package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
- package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
- package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
- package/web/dist/favicon.svg +8 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/dist/icon.ico +0 -0
- package/web/dist/index.html +17 -0
- package/web/public/favicon.svg +8 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/public/icon.ico +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* process-utils — 跨平台进程工具:监听端口快照、进程树查杀、进程名查询。
|
|
3
|
+
* 后台任务面板(bgServers)用它们检测/停止 agent 在后台拉起的服务。
|
|
4
|
+
* 从 agent-service.ts 抽出,行为保持不变。全平台 best-effort:失败静默。
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Snapshot currently LISTENING TCP ports → owning pid. Windows: netstat;
|
|
8
|
+
* POSIX: lsof. Used to detect servers the agent started in the background
|
|
9
|
+
* (the bash tool itself exits, leaving e.g. `npm run dev &` listening).
|
|
10
|
+
*/
|
|
11
|
+
export async function snapshotListeningPorts() {
|
|
12
|
+
const m = new Map();
|
|
13
|
+
try {
|
|
14
|
+
const { execFile } = await import("node:child_process");
|
|
15
|
+
if (process.platform === "win32") {
|
|
16
|
+
const out = await new Promise((resolve, reject) => execFile("netstat", ["-ano", "-p", "tcp"], { windowsHide: true, timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
17
|
+
for (const line of out.split(/\r?\n/)) {
|
|
18
|
+
const p = line.trim().split(/\s+/);
|
|
19
|
+
// TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 12345
|
|
20
|
+
if (p.length >= 5 && p[0] === "TCP" && p[3] === "LISTENING") {
|
|
21
|
+
const port = Number(p[1].split(":").pop());
|
|
22
|
+
const pid = Number(p[4]);
|
|
23
|
+
if (Number.isFinite(port) && Number.isFinite(pid))
|
|
24
|
+
m.set(port, pid);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const out = await new Promise((resolve, reject) => execFile("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n"], { timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
30
|
+
for (const line of out.split(/\r?\n/).slice(1)) {
|
|
31
|
+
const p = line.trim().split(/\s+/);
|
|
32
|
+
if (p.length >= 9) {
|
|
33
|
+
// NAME column tail: "*:5173 (LISTEN)" or "[::1]:5173 (LISTEN)"
|
|
34
|
+
const mm = (p[p.length - 1] ?? "").match(/(\d+)\)?\s*$/);
|
|
35
|
+
const port = mm ? Number(mm[1]) : NaN;
|
|
36
|
+
const pid = Number(p[1]);
|
|
37
|
+
if (Number.isFinite(port) && Number.isFinite(pid))
|
|
38
|
+
m.set(port, pid);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// best effort — snapshot failure just means no tracking this round
|
|
45
|
+
}
|
|
46
|
+
return m;
|
|
47
|
+
}
|
|
48
|
+
/** Kill a pid and its whole process tree (cross-platform). */
|
|
49
|
+
export function killPidTree(pid) {
|
|
50
|
+
try {
|
|
51
|
+
if (process.platform === "win32") {
|
|
52
|
+
void import("node:child_process").then(({ spawn }) => {
|
|
53
|
+
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
54
|
+
stdio: "ignore",
|
|
55
|
+
detached: true,
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
}).unref();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
process.kill(-pid, "SIGKILL");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// already dead
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Best-effort full command line of a pid (PowerShell CIM on win32 — wmic is
|
|
69
|
+
* gone on recent Win11 builds; ps -o command= on POSIX). Returns undefined
|
|
70
|
+
* when the process is gone or the lookup fails. */
|
|
71
|
+
export async function lookupProcessCommandLine(pid) {
|
|
72
|
+
try {
|
|
73
|
+
const { execFile } = await import("node:child_process");
|
|
74
|
+
if (process.platform === "win32") {
|
|
75
|
+
// CIM query is slower (~1s) than tasklist but this is a one-shot
|
|
76
|
+
// best-effort probe fired once per detected background server.
|
|
77
|
+
const out = await new Promise((resolve, reject) => execFile("powershell.exe", [
|
|
78
|
+
"-NoProfile",
|
|
79
|
+
"-NonInteractive",
|
|
80
|
+
"-Command",
|
|
81
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
|
|
82
|
+
], { windowsHide: true, timeout: 10000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
83
|
+
const line = out.trim();
|
|
84
|
+
return line || undefined;
|
|
85
|
+
}
|
|
86
|
+
const out = await new Promise((resolve, reject) => execFile("ps", ["-o", "command=", "-p", String(pid)], { timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
87
|
+
const line = out.trim();
|
|
88
|
+
return line || undefined;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Best-effort process name for a pid (tasklist on win32, ps on POSIX).
|
|
95
|
+
* Returns undefined when the process is gone or the lookup fails. */
|
|
96
|
+
export async function lookupProcessName(pid) {
|
|
97
|
+
try {
|
|
98
|
+
const { execFile } = await import("node:child_process");
|
|
99
|
+
if (process.platform === "win32") {
|
|
100
|
+
const out = await new Promise((resolve, reject) => execFile("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { windowsHide: true, timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
101
|
+
// CSV: "node.exe","12345",...
|
|
102
|
+
const m = out.match(/"([^"]+)"/);
|
|
103
|
+
return m ? m[1] : undefined;
|
|
104
|
+
}
|
|
105
|
+
const out = await new Promise((resolve, reject) => execFile("ps", ["-o", "comm=", "-p", String(pid)], { timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
106
|
+
const name = out.trim();
|
|
107
|
+
return name || undefined;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-protocol version. Bump whenever a protocol change would make an old
|
|
3
|
+
* browser tab talk to a newer server (or vice versa) in a broken way — the
|
|
4
|
+
* classic symptom is "UI is new, WS handling is old" after an in-place app
|
|
5
|
+
* update before the auto-restart completes.
|
|
6
|
+
*
|
|
7
|
+
* Lives OUTSIDE protocol.ts (which must stay pure types). The frontend keeps
|
|
8
|
+
* its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
|
|
9
|
+
* verifies the two never drift.
|
|
10
|
+
*/
|
|
11
|
+
export const PROTOCOL_VERSION = 10;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only git queries backing the source-control panel.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the old hidden-PTY + text-scraping approach: every query is a
|
|
5
|
+
* plain `execFile("git", …)` — no shell, no prompts, no echo, no sentinel
|
|
6
|
+
* parsing. Output is parsed into structured JSON on the server; the client
|
|
7
|
+
* just renders it.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
/** Per-command timeout / output cap — a stuck repo must not hang the panel. */
|
|
13
|
+
const GIT_TIMEOUT_MS = 15_000;
|
|
14
|
+
const MAX_GIT_OUTPUT = 16 * 1024 * 1024;
|
|
15
|
+
/** Run one git command; throws Error with a readable message on failure. */
|
|
16
|
+
async function git(cwd, args) {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await exec("git", ["-c", "core.quotepath=false", ...args], {
|
|
19
|
+
cwd,
|
|
20
|
+
timeout: GIT_TIMEOUT_MS,
|
|
21
|
+
maxBuffer: MAX_GIT_OUTPUT,
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
});
|
|
24
|
+
return stdout;
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
const e = err;
|
|
28
|
+
if (e.code === "ENOENT")
|
|
29
|
+
throw new Error("未找到 git 命令——请确认已安装 Git 并在 PATH 中");
|
|
30
|
+
if (e.killed)
|
|
31
|
+
throw new Error("git 命令超时");
|
|
32
|
+
const detail = (e.stderr ?? e.message ?? "").trim().split("\n")[0];
|
|
33
|
+
throw new Error(detail || "git 命令失败");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Absolute path of the repo's git dir (handles worktrees/submodules), or
|
|
37
|
+
* null when cwd isn't inside a repository. */
|
|
38
|
+
export async function gitDirOf(cwd) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await exec("git", ["rev-parse", "--absolute-git-dir"], {
|
|
41
|
+
cwd,
|
|
42
|
+
timeout: 5_000,
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
});
|
|
45
|
+
return stdout.trim() || null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** True when the error is "this directory isn't a git repository". */
|
|
52
|
+
export function isNotRepoError(err) {
|
|
53
|
+
return /not a git repository/i.test(err instanceof Error ? err.message : String(err));
|
|
54
|
+
}
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
/* parsers */
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
/** Undo git's C-style quoting for unusual file names ("a\tb" → a<TAB>b).
|
|
59
|
+
* core.quotepath=false already handles non-ASCII; this covers control chars. */
|
|
60
|
+
function unquotePath(s) {
|
|
61
|
+
if (!s.startsWith('"'))
|
|
62
|
+
return s;
|
|
63
|
+
const inner = s.endsWith('"') ? s.slice(1, -1) : s.slice(1);
|
|
64
|
+
return inner.replace(/\\(.)/g, (_m, c) => {
|
|
65
|
+
switch (c) {
|
|
66
|
+
case "n": return "\n";
|
|
67
|
+
case "t": return "\t";
|
|
68
|
+
case "r": return "\r";
|
|
69
|
+
case "b": return "\b";
|
|
70
|
+
case "a": return "\a";
|
|
71
|
+
case "f": return "\f";
|
|
72
|
+
case "v": return "\v";
|
|
73
|
+
case "\\": return "\\";
|
|
74
|
+
case '"': return '"';
|
|
75
|
+
default: return c;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function parseStatusHeader(rest) {
|
|
80
|
+
const out = {
|
|
81
|
+
branch: "HEAD",
|
|
82
|
+
detached: false,
|
|
83
|
+
upstream: null,
|
|
84
|
+
ahead: 0,
|
|
85
|
+
behind: 0,
|
|
86
|
+
upstreamGone: false,
|
|
87
|
+
};
|
|
88
|
+
let branchPart = rest;
|
|
89
|
+
let flags = "";
|
|
90
|
+
const bi = rest.indexOf(" [");
|
|
91
|
+
if (bi >= 0) {
|
|
92
|
+
branchPart = rest.slice(0, bi);
|
|
93
|
+
flags = rest.slice(bi + 2);
|
|
94
|
+
if (flags.endsWith("]"))
|
|
95
|
+
flags = flags.slice(0, -1);
|
|
96
|
+
}
|
|
97
|
+
if (branchPart === "HEAD (no branch)" || branchPart === "HEAD") {
|
|
98
|
+
out.detached = true;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (branchPart.startsWith("No commits yet on "))
|
|
102
|
+
branchPart = branchPart.slice("No commits yet on ".length);
|
|
103
|
+
const up = branchPart.indexOf("...");
|
|
104
|
+
if (up >= 0) {
|
|
105
|
+
out.branch = branchPart.slice(0, up);
|
|
106
|
+
out.upstream = branchPart.slice(up + 3);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
out.branch = branchPart;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (flags) {
|
|
113
|
+
for (const part of flags.split(",")) {
|
|
114
|
+
const p = part.trim();
|
|
115
|
+
const m = p.match(/^(ahead|behind) (\d+)$/);
|
|
116
|
+
if (m) {
|
|
117
|
+
if (m[1] === "ahead")
|
|
118
|
+
out.ahead = Number(m[2]);
|
|
119
|
+
else
|
|
120
|
+
out.behind = Number(m[2]);
|
|
121
|
+
}
|
|
122
|
+
else if (p === "gone") {
|
|
123
|
+
out.upstreamGone = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
function parseStatusFiles(text) {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const rawLine of text.split("\n")) {
|
|
132
|
+
const line = rawLine.trimEnd();
|
|
133
|
+
if (!line || line.startsWith("## "))
|
|
134
|
+
continue;
|
|
135
|
+
if (line.length >= 3) {
|
|
136
|
+
let path = line.slice(3);
|
|
137
|
+
const arrow = path.indexOf(" -> "); // rename: "R old -> new"
|
|
138
|
+
if (arrow >= 0)
|
|
139
|
+
path = path.slice(arrow + 4);
|
|
140
|
+
out.push({ path: unquotePath(path), x: line[0], y: line[1] });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
function parseBranches(text) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const line of text.split("\n")) {
|
|
148
|
+
const parts = line.split("\t");
|
|
149
|
+
if (parts.length < 2)
|
|
150
|
+
continue;
|
|
151
|
+
const ref = parts[0];
|
|
152
|
+
const isHead = parts[1] === "*";
|
|
153
|
+
if (ref.startsWith("refs/heads/")) {
|
|
154
|
+
out.push({ name: ref.slice("refs/heads/".length), current: isHead });
|
|
155
|
+
}
|
|
156
|
+
else if (ref.startsWith("refs/remotes/")) {
|
|
157
|
+
const short = ref.slice("refs/remotes/".length);
|
|
158
|
+
// Skip the "origin/HEAD -> origin/main" symlink.
|
|
159
|
+
if (short.endsWith("/HEAD"))
|
|
160
|
+
continue;
|
|
161
|
+
const slash = short.indexOf("/");
|
|
162
|
+
out.push({
|
|
163
|
+
name: short,
|
|
164
|
+
current: false,
|
|
165
|
+
remote: slash > 0 ? short.slice(0, slash) : true,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/** Parse `git log --graph --pretty=format:%H%x09…` preserving graph prefixes. */
|
|
172
|
+
function parseCommitHistory(text) {
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const line of text.split("\n")) {
|
|
175
|
+
const tab = line.indexOf("\t");
|
|
176
|
+
if (tab < 0)
|
|
177
|
+
continue; // connector-only graph line
|
|
178
|
+
const prefixAndHash = line.slice(0, tab);
|
|
179
|
+
const match = prefixAndHash.match(/([0-9a-f]{7,40})$/i);
|
|
180
|
+
if (!match || match.index === undefined)
|
|
181
|
+
continue;
|
|
182
|
+
const fields = line.slice(tab + 1).split("\t");
|
|
183
|
+
// An empty decoration field removes the final tab — four fields is a
|
|
184
|
+
// valid undecorated commit.
|
|
185
|
+
if (fields.length < 4)
|
|
186
|
+
continue;
|
|
187
|
+
out.push({
|
|
188
|
+
hash: match[1],
|
|
189
|
+
shortHash: fields[0],
|
|
190
|
+
author: fields[1],
|
|
191
|
+
date: fields[2],
|
|
192
|
+
subject: fields[3],
|
|
193
|
+
decorations: fields[4] ?? "",
|
|
194
|
+
graph: prefixAndHash.slice(0, match.index),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
/** Parse `git diff --numstat` output: "12\t3\tpath" → { path: [add, del] }. */
|
|
200
|
+
function parseNumStat(text) {
|
|
201
|
+
const stats = {};
|
|
202
|
+
for (const line of text.split("\n")) {
|
|
203
|
+
if (!line.trim())
|
|
204
|
+
continue;
|
|
205
|
+
const tab1 = line.indexOf("\t");
|
|
206
|
+
const tab2 = tab1 < 0 ? -1 : line.indexOf("\t", tab1 + 1);
|
|
207
|
+
if (tab2 < 0)
|
|
208
|
+
continue;
|
|
209
|
+
let add = Number(line.slice(0, tab1));
|
|
210
|
+
let del = Number(line.slice(tab1 + 1, tab2));
|
|
211
|
+
if (!Number.isFinite(add))
|
|
212
|
+
add = 0; // binary file → "-"
|
|
213
|
+
if (!Number.isFinite(del))
|
|
214
|
+
del = 0;
|
|
215
|
+
const path = unquotePath(line.slice(tab2 + 1).trim());
|
|
216
|
+
const prev = stats[path];
|
|
217
|
+
stats[path] = [
|
|
218
|
+
(prev?.[0] ?? 0) + add,
|
|
219
|
+
(prev?.[1] ?? 0) + del,
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
return stats;
|
|
223
|
+
}
|
|
224
|
+
/* ------------------------------------------------------------------ */
|
|
225
|
+
/* queries */
|
|
226
|
+
/* ------------------------------------------------------------------ */
|
|
227
|
+
const HISTORY_ARGS = [
|
|
228
|
+
"log",
|
|
229
|
+
"--all",
|
|
230
|
+
"--graph",
|
|
231
|
+
"--decorate=short",
|
|
232
|
+
"--date=short",
|
|
233
|
+
"--pretty=format:%H%x09%h%x09%an%x09%ad%x09%s%x09%D",
|
|
234
|
+
"-n",
|
|
235
|
+
"120",
|
|
236
|
+
];
|
|
237
|
+
/** Commit graph for the history tab — fetched lazily so the common
|
|
238
|
+
* "changes" view never pays for it on huge repos. */
|
|
239
|
+
export async function scmHistory(cwd) {
|
|
240
|
+
return parseCommitHistory(await git(cwd, HISTORY_ARGS));
|
|
241
|
+
}
|
|
242
|
+
/** Status refresh payload (status + branches + numstat) — parallel. */
|
|
243
|
+
export async function scmStatus(cwd) {
|
|
244
|
+
const [statusText, branchText, statText, cachedStatText] = await Promise.all([
|
|
245
|
+
git(cwd, ["status", "--porcelain=v1", "-b", "--find-renames"]),
|
|
246
|
+
git(cwd, [
|
|
247
|
+
"for-each-ref",
|
|
248
|
+
"refs/heads",
|
|
249
|
+
"refs/remotes",
|
|
250
|
+
"--format=%(refname)%09%(HEAD)",
|
|
251
|
+
]),
|
|
252
|
+
git(cwd, ["diff", "--numstat"]),
|
|
253
|
+
git(cwd, ["diff", "--cached", "--numstat"]),
|
|
254
|
+
]);
|
|
255
|
+
const header = parseStatusHeader(statusText.split("\n").find((l) => l.startsWith("## "))?.slice(3) ?? "");
|
|
256
|
+
// Worktree + staged line counts, merged per path.
|
|
257
|
+
const merged = {};
|
|
258
|
+
for (const [path, pair] of Object.entries(parseNumStat(statText))) {
|
|
259
|
+
merged[path] = pair;
|
|
260
|
+
}
|
|
261
|
+
for (const [path, pair] of Object.entries(parseNumStat(cachedStatText))) {
|
|
262
|
+
const prev = merged[path];
|
|
263
|
+
merged[path] = [(prev?.[0] ?? 0) + pair[0], (prev?.[1] ?? 0) + pair[1]];
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
notRepo: false,
|
|
267
|
+
branch: header.branch,
|
|
268
|
+
detached: header.detached,
|
|
269
|
+
upstream: header.upstream,
|
|
270
|
+
ahead: header.ahead,
|
|
271
|
+
behind: header.behind,
|
|
272
|
+
upstreamGone: header.upstreamGone,
|
|
273
|
+
files: parseStatusFiles(statusText),
|
|
274
|
+
branches: parseBranches(branchText),
|
|
275
|
+
stats: merged,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Staged + worktree diffs for one file (empty strings when no diff). */
|
|
279
|
+
export async function scmFileDiff(cwd, path) {
|
|
280
|
+
const [staged, worktree] = await Promise.all([
|
|
281
|
+
git(cwd, ["diff", "--cached", "--no-color", "--no-ext-diff", "--", path]).catch(() => ""),
|
|
282
|
+
git(cwd, ["diff", "--no-color", "--no-ext-diff", "--", path]).catch(() => ""),
|
|
283
|
+
]);
|
|
284
|
+
return { staged, worktree };
|
|
285
|
+
}
|
|
286
|
+
/** Full patch of one commit (`git show`). */
|
|
287
|
+
export async function scmCommitDetail(cwd, hash) {
|
|
288
|
+
return git(cwd, [
|
|
289
|
+
"show",
|
|
290
|
+
"--no-color",
|
|
291
|
+
"--no-ext-diff",
|
|
292
|
+
"--find-renames",
|
|
293
|
+
"--format=fuller",
|
|
294
|
+
"--stat",
|
|
295
|
+
"--patch",
|
|
296
|
+
hash,
|
|
297
|
+
]);
|
|
298
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const TEXT_CAP = 200_000;
|
|
2
|
+
const TOOL_OUTPUT_CAP = 100_000;
|
|
3
|
+
const ARGS_CAP = 20_000;
|
|
4
|
+
function truncate(s, cap) {
|
|
5
|
+
if (s.length <= cap)
|
|
6
|
+
return { text: s, truncated: false };
|
|
7
|
+
return { text: `${s.slice(0, cap)}\n\n… [truncated]`, truncated: true };
|
|
8
|
+
}
|
|
9
|
+
function serializeUserContent(content) {
|
|
10
|
+
if (typeof content === "string")
|
|
11
|
+
return [{ type: "text", text: content }];
|
|
12
|
+
return content.map((b) => {
|
|
13
|
+
if (b.type === "image") {
|
|
14
|
+
const img = b;
|
|
15
|
+
// Canonical ImageContent shape is { type, data, mimeType }; tolerate the
|
|
16
|
+
// legacy { source } wrapper too.
|
|
17
|
+
if (typeof img.data === "string" && img.data.length > 0) {
|
|
18
|
+
return {
|
|
19
|
+
type: "image",
|
|
20
|
+
dataUrl: `data:${img.mimeType ?? "image/png"};base64,${img.data}`,
|
|
21
|
+
mimeType: img.mimeType,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const src = img.source;
|
|
25
|
+
if (src?.type === "base64" && src.data) {
|
|
26
|
+
return {
|
|
27
|
+
type: "image",
|
|
28
|
+
dataUrl: `data:${src.mediaType ?? "image/png"};base64,${src.data}`,
|
|
29
|
+
mimeType: src.mediaType,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { type: "image", dataUrl: src?.url };
|
|
33
|
+
}
|
|
34
|
+
return { type: "text", text: String(b.text ?? "") };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function serializeAssistantContent(content) {
|
|
38
|
+
return content.map((b) => {
|
|
39
|
+
if (b.type === "text") {
|
|
40
|
+
const { text, truncated } = truncate(b.text, TEXT_CAP);
|
|
41
|
+
return { type: "text", text, truncated };
|
|
42
|
+
}
|
|
43
|
+
if (b.type === "thinking") {
|
|
44
|
+
return { type: "thinking", thinking: b.thinking };
|
|
45
|
+
}
|
|
46
|
+
if (b.type === "toolCall") {
|
|
47
|
+
if (b.arguments === undefined) {
|
|
48
|
+
return { type: "toolCall", id: b.id, name: b.name };
|
|
49
|
+
}
|
|
50
|
+
const { text, truncated } = truncate(JSON.stringify(b.arguments), ARGS_CAP);
|
|
51
|
+
return {
|
|
52
|
+
type: "toolCall",
|
|
53
|
+
id: b.id,
|
|
54
|
+
name: b.name,
|
|
55
|
+
argumentsText: text,
|
|
56
|
+
argumentsTruncated: truncated,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { type: "unknown", ...b };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function serializeMessage(m, seq) {
|
|
63
|
+
switch (m.role) {
|
|
64
|
+
case "user":
|
|
65
|
+
return {
|
|
66
|
+
id: `u-${m.timestamp}-${seq}`,
|
|
67
|
+
role: "user",
|
|
68
|
+
content: serializeUserContent(m.content),
|
|
69
|
+
timestamp: m.timestamp,
|
|
70
|
+
};
|
|
71
|
+
case "assistant":
|
|
72
|
+
return {
|
|
73
|
+
id: `a-${m.timestamp}-${seq}`,
|
|
74
|
+
role: "assistant",
|
|
75
|
+
content: serializeAssistantContent(m.content),
|
|
76
|
+
timestamp: m.timestamp,
|
|
77
|
+
model: m.model,
|
|
78
|
+
provider: m.provider,
|
|
79
|
+
stopReason: m.stopReason,
|
|
80
|
+
errorMessage: m.errorMessage,
|
|
81
|
+
};
|
|
82
|
+
case "toolResult": {
|
|
83
|
+
const raw = m.content
|
|
84
|
+
.map((c) => (c.type === "text" ? c.text : "[image result]"))
|
|
85
|
+
.join("\n");
|
|
86
|
+
const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
|
|
87
|
+
return {
|
|
88
|
+
id: `t-${m.toolCallId}`,
|
|
89
|
+
role: "toolResult",
|
|
90
|
+
content: [{ type: "text", text, truncated }],
|
|
91
|
+
toolCallId: m.toolCallId,
|
|
92
|
+
toolName: m.toolName,
|
|
93
|
+
isError: m.isError,
|
|
94
|
+
timestamp: m.timestamp,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
case "bashExecution": {
|
|
98
|
+
const { text, truncated } = truncate(m.output, TOOL_OUTPUT_CAP);
|
|
99
|
+
return {
|
|
100
|
+
id: `b-${m.timestamp}-${seq}`,
|
|
101
|
+
role: "bashExecution",
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: "bash",
|
|
105
|
+
command: m.command,
|
|
106
|
+
output: text,
|
|
107
|
+
exitCode: m.exitCode,
|
|
108
|
+
cancelled: m.cancelled,
|
|
109
|
+
truncated,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
timestamp: m.timestamp,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
case "custom": {
|
|
116
|
+
// Third-party extension messages with display:false are UI-hidden
|
|
117
|
+
// (they still go into LLM context — the SDK handles that).
|
|
118
|
+
if (m.display === false) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const content = serializeUserContent(m.content);
|
|
122
|
+
return {
|
|
123
|
+
id: `c-${m.timestamp}-${seq}`,
|
|
124
|
+
role: "custom",
|
|
125
|
+
content,
|
|
126
|
+
customType: m.customType,
|
|
127
|
+
details: m.details,
|
|
128
|
+
timestamp: m.timestamp,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
case "branchSummary": {
|
|
132
|
+
const { text, truncated } = truncate(m.summary, TEXT_CAP);
|
|
133
|
+
return {
|
|
134
|
+
id: `bs-${m.timestamp}-${seq}`,
|
|
135
|
+
role: "branchSummary",
|
|
136
|
+
content: [{ type: "text", text, truncated }],
|
|
137
|
+
timestamp: m.timestamp,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
case "compactionSummary": {
|
|
141
|
+
const { text, truncated } = truncate(m.summary, TEXT_CAP);
|
|
142
|
+
return {
|
|
143
|
+
id: `cs-${m.timestamp}-${seq}`,
|
|
144
|
+
role: "compactionSummary",
|
|
145
|
+
content: [{ type: "text", text, truncated }],
|
|
146
|
+
timestamp: m.timestamp,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
default:
|
|
150
|
+
return {
|
|
151
|
+
id: `x-${seq}`,
|
|
152
|
+
role: String(m.role ?? "unknown"),
|
|
153
|
+
content: [],
|
|
154
|
+
timestamp: m.timestamp,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Serialize the in-progress assistant message (agent.state.streamingMessage).
|
|
160
|
+
*
|
|
161
|
+
* Unlike persisted messages, the id must be STABLE across snapshots: the SDK
|
|
162
|
+
* replaces the partial object on every stream event, so a seq-based id would
|
|
163
|
+
* remount the React component (and collapse open thinking/tool blocks) every
|
|
164
|
+
* 60ms. The timestamp is fixed at message creation, so `stream-<ts>` stays
|
|
165
|
+
* constant for the whole stream.
|
|
166
|
+
*/
|
|
167
|
+
export function serializeStreamingMessage(m) {
|
|
168
|
+
const msg = serializeMessage(m, 0);
|
|
169
|
+
if (!msg)
|
|
170
|
+
return null;
|
|
171
|
+
return { ...msg, id: `stream-${m.timestamp ?? 0}` };
|
|
172
|
+
}
|