@oversk7/pi-web-dsh-style 0.1.0-alpha.1
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 +90 -0
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/abort-session.ts +150 -0
- package/attachment-store.ts +161 -0
- package/file-preview.ts +94 -0
- package/index.ts +177 -0
- package/local-session.ts +190 -0
- package/package.json +70 -0
- package/pi-rpc.ts +253 -0
- package/server.ts +3060 -0
- package/sse-channel.ts +72 -0
- package/state-store.ts +64 -0
- package/transcript.ts +328 -0
- package/web/app.css +535 -0
- package/web/app.js +4228 -0
- package/web/assets/fonts/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/assets/fonts/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/assets/fonts/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/assets/fonts/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/assets/fonts/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/assets/fonts/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/assets/fonts/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/assets/fonts/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/assets/fonts/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/assets/fonts/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/assets/fonts/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/assets/fonts/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/assets/fonts/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/assets/fonts/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/assets/fonts/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/assets/fonts/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/assets/fonts/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/assets/fonts/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/assets/fonts/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/assets/fonts/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/assets/fonts/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/assets/fonts/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/favicon.svg +4 -0
- package/web/index.html +25 -0
- package/web/manifest.webmanifest +9 -0
- package/windows-clipboard.ts +81 -0
package/index.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getPackageDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/compat";
|
|
5
|
+
|
|
6
|
+
interface MaintenanceCommandResult {
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
code: number;
|
|
10
|
+
killed: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface WebServerModule {
|
|
14
|
+
startWebServer(options?: {
|
|
15
|
+
defaultCwd?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
open?: boolean;
|
|
18
|
+
initialModelInfo?: {
|
|
19
|
+
models: unknown[];
|
|
20
|
+
thinkingLevels: string[];
|
|
21
|
+
defaultModel?: unknown;
|
|
22
|
+
thinkingLevel?: string;
|
|
23
|
+
};
|
|
24
|
+
initialCommands?: unknown[];
|
|
25
|
+
updateExtensions?: () => Promise<MaintenanceCommandResult>;
|
|
26
|
+
reloadRuntime?: () => Promise<void>;
|
|
27
|
+
}): Promise<string>;
|
|
28
|
+
stopWebServer(): Promise<void>;
|
|
29
|
+
getWebUrl(): string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ReloadState {
|
|
33
|
+
resumeWebAfterReload: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RELOAD_STATE_KEY = Symbol.for("@oversk7/pi-web-dsh-style/reload-state");
|
|
37
|
+
const processState = globalThis as typeof globalThis & { [key: symbol]: unknown };
|
|
38
|
+
const reloadState = (processState[RELOAD_STATE_KEY] ??= { resumeWebAfterReload: false }) as ReloadState;
|
|
39
|
+
|
|
40
|
+
let serverPromise: Promise<WebServerModule> | undefined;
|
|
41
|
+
|
|
42
|
+
function piCliEntry(): string {
|
|
43
|
+
if (process.env.PI_WEB_PI_CLI) return resolve(process.env.PI_WEB_PI_CLI);
|
|
44
|
+
const packageDir = getPackageDir();
|
|
45
|
+
const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")) as {
|
|
46
|
+
bin?: string | Record<string, string>;
|
|
47
|
+
};
|
|
48
|
+
const target = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.pi;
|
|
49
|
+
if (!target) throw new Error("Pi package does not declare a CLI entry");
|
|
50
|
+
return resolve(packageDir, target);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseArgs(args?: string): Record<string, string | boolean> {
|
|
54
|
+
const out: Record<string, string | boolean> = {};
|
|
55
|
+
const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
56
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
57
|
+
const token = tokens[i];
|
|
58
|
+
if (token === "--stop") out.stop = true;
|
|
59
|
+
else if (token === "--no-open") out.open = "false";
|
|
60
|
+
else if (token === "--port" && tokens[i + 1]) {
|
|
61
|
+
out.port = tokens[++i];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function startServer(
|
|
68
|
+
pi: ExtensionAPI,
|
|
69
|
+
ctx: ExtensionContext,
|
|
70
|
+
options: Record<string, string | boolean> = {},
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
serverPromise ??= import("./server.ts") as Promise<WebServerModule>;
|
|
74
|
+
const mod = await serverPromise;
|
|
75
|
+
const availableModels = ctx.modelRegistry.getAvailable();
|
|
76
|
+
const initialModels = availableModels.length > 0 ? availableModels : ctx.model ? [ctx.model] : [];
|
|
77
|
+
const url = await mod.startWebServer({
|
|
78
|
+
defaultCwd: ctx.cwd,
|
|
79
|
+
port: typeof options.port === "string" ? Number(options.port) : undefined,
|
|
80
|
+
open: options.open !== "false",
|
|
81
|
+
initialModelInfo: {
|
|
82
|
+
models: initialModels,
|
|
83
|
+
thinkingLevels: ctx.model ? getSupportedThinkingLevels(ctx.model) : [],
|
|
84
|
+
defaultModel: ctx.model,
|
|
85
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
86
|
+
},
|
|
87
|
+
initialCommands: pi.getCommands(),
|
|
88
|
+
updateExtensions: () => pi.exec(process.execPath, [piCliEntry(), "update", "--extensions"], {
|
|
89
|
+
cwd: ctx.cwd,
|
|
90
|
+
timeout: 10 * 60_000,
|
|
91
|
+
}),
|
|
92
|
+
reloadRuntime: async () => {
|
|
93
|
+
reloadState.resumeWebAfterReload = true;
|
|
94
|
+
try {
|
|
95
|
+
pi.sendUserMessage("/pi-web-reload-runtime", {
|
|
96
|
+
deliverAs: "followUp",
|
|
97
|
+
expandPromptTemplates: true,
|
|
98
|
+
});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
reloadState.resumeWebAfterReload = false;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
ctx.ui.notify(`pi-web: ${url}`, "info");
|
|
106
|
+
} catch (error) {
|
|
107
|
+
serverPromise = undefined;
|
|
108
|
+
ctx.ui.notify(`pi-web failed: ${String(error)}`, "error");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default function (pi: ExtensionAPI) {
|
|
113
|
+
pi.registerCommand("pi-web-reload-runtime", {
|
|
114
|
+
description: "Internal command used by pi-web to reload the Pi runtime",
|
|
115
|
+
handler: async (_args, ctx) => {
|
|
116
|
+
try {
|
|
117
|
+
await ctx.reload();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
reloadState.resumeWebAfterReload = false;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.registerCommand("pi-web-navigate-tree", {
|
|
126
|
+
description: "Internal command used by pi-web for same-session tree navigation",
|
|
127
|
+
handler: async (args, ctx) => {
|
|
128
|
+
const targetId = args.trim();
|
|
129
|
+
if (!targetId || !ctx.sessionManager.getEntry(targetId)) throw new Error("会话树节点不存在");
|
|
130
|
+
await ctx.waitForIdle();
|
|
131
|
+
const result = await ctx.navigateTree(targetId, { summarize: false });
|
|
132
|
+
if (result.cancelled) throw new Error("会话回溯已取消");
|
|
133
|
+
pi.appendEntry("pi-web-tree-navigation", { targetId });
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
pi.registerFlag("web", {
|
|
138
|
+
description: "Start the pi agent web UI and open it in the browser",
|
|
139
|
+
type: "boolean",
|
|
140
|
+
default: false,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
144
|
+
const resumeAfterReload = reloadState.resumeWebAfterReload;
|
|
145
|
+
reloadState.resumeWebAfterReload = false;
|
|
146
|
+
if (pi.getFlag("web") === true || resumeAfterReload) {
|
|
147
|
+
await startServer(pi, ctx, resumeAfterReload ? { open: "false" } : {});
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_shutdown", async () => {
|
|
152
|
+
if (!serverPromise) return;
|
|
153
|
+
const mod = await serverPromise;
|
|
154
|
+
await mod.stopWebServer();
|
|
155
|
+
serverPromise = undefined;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
pi.registerCommand("web", {
|
|
159
|
+
description: "Open the pi agent web UI (DSH-style multi-workspace browser surface)",
|
|
160
|
+
handler: async (args, ctx) => {
|
|
161
|
+
const options = parseArgs(args);
|
|
162
|
+
if (options.stop) {
|
|
163
|
+
if (!serverPromise) {
|
|
164
|
+
ctx.ui.notify("pi-web: not running", "info");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const mod = await serverPromise;
|
|
168
|
+
await mod.stopWebServer();
|
|
169
|
+
serverPromise = undefined;
|
|
170
|
+
ctx.ui.notify("pi-web: stopped", "info");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await startServer(pi, ctx, options);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
package/local-session.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { calculateContextTokens, estimateTokens, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { renderSessionEntries, type RenderedMessage } from "./transcript.ts";
|
|
4
|
+
|
|
5
|
+
export type Json = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
export interface LocalSessionSnapshot {
|
|
8
|
+
messages: RenderedMessage[];
|
|
9
|
+
state: Json;
|
|
10
|
+
stats: Json | null;
|
|
11
|
+
title?: string;
|
|
12
|
+
sessionFile?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SessionEntry {
|
|
16
|
+
type: string;
|
|
17
|
+
id?: string;
|
|
18
|
+
parentId?: string | null;
|
|
19
|
+
timestamp?: string;
|
|
20
|
+
message?: Json;
|
|
21
|
+
provider?: string;
|
|
22
|
+
modelId?: string;
|
|
23
|
+
thinkingLevel?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function localContextTokens(manager: SessionManager): number | null {
|
|
28
|
+
const branch = manager.getBranch();
|
|
29
|
+
let latestCompactionIndex = -1;
|
|
30
|
+
for (let i = branch.length - 1; i >= 0; i -= 1) {
|
|
31
|
+
if (branch[i]?.type === "compaction") {
|
|
32
|
+
latestCompactionIndex = i;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (latestCompactionIndex >= 0) {
|
|
37
|
+
let hasPostCompactionUsage = false;
|
|
38
|
+
for (let i = branch.length - 1; i > latestCompactionIndex; i -= 1) {
|
|
39
|
+
const entry = branch[i];
|
|
40
|
+
if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
|
|
41
|
+
if (entry.message.stopReason === "aborted" || entry.message.stopReason === "error" || !entry.message.usage) continue;
|
|
42
|
+
if (calculateContextTokens(entry.message.usage) > 0) {
|
|
43
|
+
hasPostCompactionUsage = true;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!hasPostCompactionUsage) return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const messages = manager.buildSessionContext().messages;
|
|
51
|
+
let lastUsageIndex = -1;
|
|
52
|
+
let contextTokens = 0;
|
|
53
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
54
|
+
const message = messages[i];
|
|
55
|
+
if (message.role !== "assistant") continue;
|
|
56
|
+
if (message.stopReason === "aborted" || message.stopReason === "error" || !message.usage) continue;
|
|
57
|
+
const tokens = calculateContextTokens(message.usage);
|
|
58
|
+
if (tokens <= 0) continue;
|
|
59
|
+
lastUsageIndex = i;
|
|
60
|
+
contextTokens = tokens;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
const trailingStart = lastUsageIndex >= 0 ? lastUsageIndex + 1 : 0;
|
|
64
|
+
for (let i = trailingStart; i < messages.length; i += 1) {
|
|
65
|
+
contextTokens += estimateTokens(messages[i]);
|
|
66
|
+
}
|
|
67
|
+
return contextTokens;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read a Pi session JSONL file directly (no pi process) and produce the same
|
|
72
|
+
* shape the web UI uses for switching/display. This makes switching to a
|
|
73
|
+
* not-yet-opened session instant, like DSH's in-memory harness sessions.
|
|
74
|
+
*/
|
|
75
|
+
export function loadLocalSession(sessionFile: string, fallbackCwd?: string): LocalSessionSnapshot {
|
|
76
|
+
if (!existsSync(sessionFile) || statSync(sessionFile).size === 0) {
|
|
77
|
+
return {
|
|
78
|
+
messages: [],
|
|
79
|
+
state: { sessionFile, isStreaming: false },
|
|
80
|
+
stats: null,
|
|
81
|
+
sessionFile,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const manager = SessionManager.open(sessionFile, undefined, fallbackCwd);
|
|
86
|
+
const entries = manager.getEntries() as SessionEntry[];
|
|
87
|
+
const leafId = manager.getLeafId();
|
|
88
|
+
|
|
89
|
+
const byId = new Map<string, SessionEntry>();
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry.id) byId.set(entry.id, entry);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Walk from the current leaf to the root so forked branches only show the
|
|
95
|
+
// active path, mirroring what the pi session exposes to the UI.
|
|
96
|
+
let leaf = leafId ? byId.get(leafId) : undefined;
|
|
97
|
+
if (!leaf) leaf = entries[entries.length - 1];
|
|
98
|
+
const path: SessionEntry[] = [];
|
|
99
|
+
let current: SessionEntry | undefined = leaf;
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
while (current) {
|
|
102
|
+
if (current.id && seen.has(current.id)) break;
|
|
103
|
+
if (current.id) seen.add(current.id);
|
|
104
|
+
path.push(current);
|
|
105
|
+
current = current.parentId && current.id !== current.parentId ? byId.get(current.parentId) : undefined;
|
|
106
|
+
}
|
|
107
|
+
path.reverse();
|
|
108
|
+
|
|
109
|
+
const rawMessages: Json[] = [];
|
|
110
|
+
let model: Json | null = null;
|
|
111
|
+
let thinkingLevel = "off";
|
|
112
|
+
let sessionName = "";
|
|
113
|
+
for (const entry of path) {
|
|
114
|
+
if (entry.type === "message" && entry.message && typeof entry.message === "object") {
|
|
115
|
+
rawMessages.push(entry.message);
|
|
116
|
+
if (entry.message.role === "assistant") {
|
|
117
|
+
if (typeof entry.message.provider === "string" && typeof entry.message.model === "string") {
|
|
118
|
+
model = {
|
|
119
|
+
provider: entry.message.provider,
|
|
120
|
+
id: entry.message.model,
|
|
121
|
+
name: entry.message.model,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} else if (entry.type === "model_change") {
|
|
126
|
+
if (typeof entry.provider === "string" && typeof entry.modelId === "string") {
|
|
127
|
+
model = { provider: entry.provider, id: entry.modelId, name: entry.modelId };
|
|
128
|
+
}
|
|
129
|
+
} else if (entry.type === "thinking_level_change" && typeof entry.thinkingLevel === "string") {
|
|
130
|
+
thinkingLevel = entry.thinkingLevel;
|
|
131
|
+
} else if (entry.type === "session_info" && typeof entry.name === "string" && entry.name.trim()) {
|
|
132
|
+
sessionName = entry.name.trim();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const messages = renderSessionEntries(entries, leafId);
|
|
137
|
+
const contextTokens = localContextTokens(manager);
|
|
138
|
+
|
|
139
|
+
// Cheap local stats until a warm process can provide the authoritative ones.
|
|
140
|
+
let userMessages = 0;
|
|
141
|
+
let assistantMessages = 0;
|
|
142
|
+
let toolCalls = 0;
|
|
143
|
+
let input = 0;
|
|
144
|
+
let output = 0;
|
|
145
|
+
let cost = 0;
|
|
146
|
+
for (const raw of rawMessages) {
|
|
147
|
+
const message = raw as Json;
|
|
148
|
+
const role = message.role;
|
|
149
|
+
if (role === "user") userMessages += 1;
|
|
150
|
+
else if (role === "assistant") assistantMessages += 1;
|
|
151
|
+
if (role === "assistant" && Array.isArray(message.content)) {
|
|
152
|
+
for (const part of message.content as Json[]) {
|
|
153
|
+
if (part && part.type === "toolCall") toolCalls += 1;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (message.usage && typeof message.usage === "object") {
|
|
157
|
+
const usage = message.usage as Json;
|
|
158
|
+
input += Number(usage.input ?? 0);
|
|
159
|
+
output += Number(usage.output ?? 0);
|
|
160
|
+
}
|
|
161
|
+
if (typeof message.cost === "number") cost += message.cost;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const state: Json = {
|
|
165
|
+
model,
|
|
166
|
+
thinkingLevel,
|
|
167
|
+
isStreaming: false,
|
|
168
|
+
isCompacting: false,
|
|
169
|
+
sessionFile: manager.getSessionFile(),
|
|
170
|
+
sessionId: manager.getSessionId(),
|
|
171
|
+
sessionName,
|
|
172
|
+
messageCount: rawMessages.length,
|
|
173
|
+
pendingMessageCount: 0,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
messages,
|
|
178
|
+
state,
|
|
179
|
+
stats: {
|
|
180
|
+
userMessages,
|
|
181
|
+
assistantMessages,
|
|
182
|
+
toolCalls,
|
|
183
|
+
tokens: { input, output },
|
|
184
|
+
contextUsage: { tokens: contextTokens },
|
|
185
|
+
cost: Number(cost.toFixed(6)),
|
|
186
|
+
},
|
|
187
|
+
title: sessionName || undefined,
|
|
188
|
+
sessionFile: manager.getSessionFile(),
|
|
189
|
+
};
|
|
190
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oversk7/pi-web-dsh-style",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "An unofficial DSH-styled local Windows browser UI extension for pi coding agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "oversk7",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"web-ui",
|
|
12
|
+
"dsh",
|
|
13
|
+
"deepseek-harness",
|
|
14
|
+
"windows"
|
|
15
|
+
],
|
|
16
|
+
"os": [
|
|
17
|
+
"win32"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.19.0"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"abort-session.ts",
|
|
24
|
+
"attachment-store.ts",
|
|
25
|
+
"file-preview.ts",
|
|
26
|
+
"index.ts",
|
|
27
|
+
"local-session.ts",
|
|
28
|
+
"pi-rpc.ts",
|
|
29
|
+
"sse-channel.ts",
|
|
30
|
+
"server.ts",
|
|
31
|
+
"state-store.ts",
|
|
32
|
+
"transcript.ts",
|
|
33
|
+
"windows-clipboard.ts",
|
|
34
|
+
"web/",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"THIRD_PARTY_NOTICES.md"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"test": "node --test tests/*.test.ts",
|
|
42
|
+
"check:package": "node scripts/check-package.mjs",
|
|
43
|
+
"verify": "npm run typecheck && npm test && npm run check:package",
|
|
44
|
+
"prepack": "npm run verify"
|
|
45
|
+
},
|
|
46
|
+
"pi": {
|
|
47
|
+
"extensions": [
|
|
48
|
+
"./index.ts"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@earendil-works/pi-ai": "*",
|
|
53
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@earendil-works/pi-ai": "0.85.0",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
58
|
+
"@earendil-works/pi-server": "0.85.0",
|
|
59
|
+
"@types/node": "^22.19.19",
|
|
60
|
+
"typescript": "^5.9.3"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public",
|
|
64
|
+
"registry": "https://registry.npmjs.org",
|
|
65
|
+
"tag": "alpha"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"highlight.js": "^11.12.0"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/pi-rpc.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
export type Json = Record<string, any>;
|
|
8
|
+
|
|
9
|
+
export interface PiRpc {
|
|
10
|
+
send<T = unknown>(command: Json, timeoutMs?: number): Promise<T>;
|
|
11
|
+
write(message: Json): void;
|
|
12
|
+
close(timeoutMs?: number): Promise<void>;
|
|
13
|
+
onEvent(handler: (event: Json) => void): () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const REQUEST_TIMEOUT = 120_000;
|
|
17
|
+
|
|
18
|
+
function safeJsonParse(line: string): Json | undefined {
|
|
19
|
+
try {
|
|
20
|
+
const value = JSON.parse(line);
|
|
21
|
+
return value && typeof value === "object" ? (value as Json) : undefined;
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PiRpcLaunch {
|
|
28
|
+
entry: string;
|
|
29
|
+
argsPrefix: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function exportedRpcEntry(packageDir: string): string {
|
|
33
|
+
const manifestPath = join(packageDir, "package.json");
|
|
34
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
|
|
35
|
+
exports?: Record<string, string | { import?: string; default?: string }>;
|
|
36
|
+
};
|
|
37
|
+
const rpcExport = manifest.exports?.["./rpc-entry"];
|
|
38
|
+
const target = typeof rpcExport === "string" ? rpcExport : rpcExport?.import ?? rpcExport?.default;
|
|
39
|
+
if (!target) throw new Error("@earendil-works/pi-coding-agent does not export ./rpc-entry");
|
|
40
|
+
const entry = resolve(packageDir, target);
|
|
41
|
+
if (!existsSync(entry)) throw new Error(`pi rpc entry does not exist: ${entry}`);
|
|
42
|
+
return entry;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolvePiRpcLaunch(): PiRpcLaunch {
|
|
46
|
+
const explicitRpcEntry = process.env.PI_WEB_RPC_ENTRY;
|
|
47
|
+
if (explicitRpcEntry) {
|
|
48
|
+
const entry = resolve(explicitRpcEntry);
|
|
49
|
+
if (!existsSync(entry)) throw new Error(`PI_WEB_RPC_ENTRY does not exist: ${entry}`);
|
|
50
|
+
return { entry, argsPrefix: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const legacyCli = process.env.PI_WEB_PI_CLI;
|
|
54
|
+
if (legacyCli) {
|
|
55
|
+
const entry = resolve(legacyCli);
|
|
56
|
+
if (!existsSync(entry)) throw new Error(`PI_WEB_PI_CLI does not exist: ${entry}`);
|
|
57
|
+
return { entry, argsPrefix: ["--mode", "rpc"] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { entry: exportedRpcEntry(getPackageDir()), argsPrefix: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PiRpcSpawnOptions {
|
|
64
|
+
cwd: string;
|
|
65
|
+
sessionId?: string;
|
|
66
|
+
sessionFile?: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
noSession?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function spawnPiRpc(options: PiRpcSpawnOptions): PiRpc {
|
|
72
|
+
const launch = resolvePiRpcLaunch();
|
|
73
|
+
const args = [...launch.argsPrefix];
|
|
74
|
+
if (options.noSession) {
|
|
75
|
+
args.push("--no-session");
|
|
76
|
+
} else if (options.sessionFile) {
|
|
77
|
+
args.push("--session", options.sessionFile);
|
|
78
|
+
} else if (options.sessionId) {
|
|
79
|
+
args.push("--session-id", options.sessionId);
|
|
80
|
+
}
|
|
81
|
+
if (options.name) args.push("--name", options.name);
|
|
82
|
+
const child = spawn(process.execPath, [launch.entry, ...args], {
|
|
83
|
+
cwd: options.cwd,
|
|
84
|
+
env: { ...process.env, NO_COLOR: "1", PI_TELEMETRY: "0" },
|
|
85
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const handlers = new Set<(event: Json) => void>();
|
|
90
|
+
const pending = new Map<string, { resolve: (v: Json) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
91
|
+
let buffer = "";
|
|
92
|
+
let closed = false;
|
|
93
|
+
let spawnError: Error | undefined;
|
|
94
|
+
let resolveExit: (() => void) | undefined;
|
|
95
|
+
const exitPromise = new Promise<void>((resolve) => {
|
|
96
|
+
resolveExit = resolve;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const forceClose = (reason: string): void => {
|
|
100
|
+
if (closed) return;
|
|
101
|
+
closed = true;
|
|
102
|
+
try {
|
|
103
|
+
child.kill();
|
|
104
|
+
} catch {
|
|
105
|
+
// ignore
|
|
106
|
+
}
|
|
107
|
+
const error = new Error(reason);
|
|
108
|
+
for (const [, p] of pending) {
|
|
109
|
+
clearTimeout(p.timer);
|
|
110
|
+
p.reject(error);
|
|
111
|
+
}
|
|
112
|
+
pending.clear();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
child.on("error", (error) => {
|
|
116
|
+
spawnError = error;
|
|
117
|
+
closed = true;
|
|
118
|
+
resolveExit?.();
|
|
119
|
+
for (const [, p] of pending) {
|
|
120
|
+
clearTimeout(p.timer);
|
|
121
|
+
p.reject(error);
|
|
122
|
+
}
|
|
123
|
+
pending.clear();
|
|
124
|
+
for (const handler of handlers) handler({ type: "spawn_error", error: String(error) });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const expectedNewSessionWarning = options.sessionId
|
|
128
|
+
? `Warning: No project session found with id '${options.sessionId}'; creating a new session with that id.`
|
|
129
|
+
: undefined;
|
|
130
|
+
let stderrBuffer = "";
|
|
131
|
+
const logStderrLine = (line: string) => {
|
|
132
|
+
const text = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
133
|
+
if (!text || text.includes("pi@") || text === expectedNewSessionWarning) return;
|
|
134
|
+
console.error(`[pi-web rpc stderr] ${text}`);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
138
|
+
stderrBuffer += chunk.toString("utf8");
|
|
139
|
+
let idx: number;
|
|
140
|
+
while ((idx = stderrBuffer.indexOf("\n")) >= 0) {
|
|
141
|
+
logStderrLine(stderrBuffer.slice(0, idx));
|
|
142
|
+
stderrBuffer = stderrBuffer.slice(idx + 1);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
child.stderr?.on("end", () => {
|
|
146
|
+
if (stderrBuffer) logStderrLine(stderrBuffer);
|
|
147
|
+
stderrBuffer = "";
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
151
|
+
buffer += chunk.toString("utf8");
|
|
152
|
+
let idx: number;
|
|
153
|
+
while ((idx = buffer.indexOf("\n")) >= 0) {
|
|
154
|
+
let line = buffer.slice(0, idx);
|
|
155
|
+
buffer = buffer.slice(idx + 1);
|
|
156
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
157
|
+
const event = safeJsonParse(line);
|
|
158
|
+
if (!event) continue;
|
|
159
|
+
if (event.type === "response" && typeof event.id === "string") {
|
|
160
|
+
const p = pending.get(event.id);
|
|
161
|
+
if (p) {
|
|
162
|
+
pending.delete(event.id);
|
|
163
|
+
clearTimeout(p.timer);
|
|
164
|
+
if (event.success === false) {
|
|
165
|
+
p.reject(new Error(typeof event.error === "string" ? event.error : JSON.stringify(event.error)));
|
|
166
|
+
} else {
|
|
167
|
+
p.resolve(event);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
for (const handler of handlers) {
|
|
173
|
+
try {
|
|
174
|
+
handler(event);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error("[pi-web] event handler failed:", error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
child.on("exit", (code, signal) => {
|
|
183
|
+
closed = true;
|
|
184
|
+
resolveExit?.();
|
|
185
|
+
for (const [, p] of pending) {
|
|
186
|
+
clearTimeout(p.timer);
|
|
187
|
+
p.reject(new Error(`pi rpc exited (${code ?? signal ?? "unknown"})`));
|
|
188
|
+
}
|
|
189
|
+
pending.clear();
|
|
190
|
+
for (const handler of handlers) handler({ type: "process_exit", code: code ?? undefined, signal: signal ?? undefined });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
send<T = unknown>(command: Json, timeoutMs = REQUEST_TIMEOUT): Promise<T> {
|
|
195
|
+
if (spawnError) return Promise.reject(spawnError);
|
|
196
|
+
if (closed) return Promise.reject(new Error("pi rpc process is not running"));
|
|
197
|
+
const id = randomUUID();
|
|
198
|
+
const payload = { ...command, id };
|
|
199
|
+
return new Promise<T>((resolve, reject) => {
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
pending.delete(id);
|
|
202
|
+
reject(new Error(`pi rpc command timed out: ${String(command.type)}`));
|
|
203
|
+
}, timeoutMs);
|
|
204
|
+
pending.set(id, { resolve: resolve as (v: Json) => void, reject, timer });
|
|
205
|
+
child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
|
|
206
|
+
if (error) {
|
|
207
|
+
pending.delete(id);
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
reject(error);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
write(message: Json): void {
|
|
215
|
+
if (spawnError) throw spawnError;
|
|
216
|
+
if (closed) throw new Error("pi rpc process is not running");
|
|
217
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
218
|
+
},
|
|
219
|
+
onEvent(handler) {
|
|
220
|
+
handlers.add(handler);
|
|
221
|
+
return () => handlers.delete(handler);
|
|
222
|
+
},
|
|
223
|
+
async close(timeoutMs = 3000) {
|
|
224
|
+
if (closed) return exitPromise;
|
|
225
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
226
|
+
let forced = false;
|
|
227
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
228
|
+
timeout = setTimeout(() => {
|
|
229
|
+
forced = true;
|
|
230
|
+
forceClose("pi rpc close timed out");
|
|
231
|
+
resolve();
|
|
232
|
+
}, timeoutMs);
|
|
233
|
+
timeout.unref();
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
child.stdin.end();
|
|
237
|
+
} catch {
|
|
238
|
+
// ignore
|
|
239
|
+
}
|
|
240
|
+
await Promise.race([exitPromise, timeoutPromise]);
|
|
241
|
+
if (timeout) clearTimeout(timeout);
|
|
242
|
+
if (forced) {
|
|
243
|
+
await Promise.race([
|
|
244
|
+
exitPromise,
|
|
245
|
+
new Promise<void>((resolve) => {
|
|
246
|
+
const timer = setTimeout(resolve, 1000);
|
|
247
|
+
timer.unref();
|
|
248
|
+
}),
|
|
249
|
+
]);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|