@oversk7/pi-web-dsh-style 0.1.0-alpha.1 → 1.0.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/README.md +15 -6
- package/index.ts +2 -1
- package/package.json +3 -2
- package/pi-rpc.ts +67 -29
- package/server.ts +23 -9
- package/web/app.css +5 -1
- package/web/app.js +32 -34
- package/windows-rpc-job.ps1 +184 -0
package/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
采用 DeepSeek Harness(DSH)视觉风格、面向 Windows 的非官方 Pi Coding Agent 本地浏览器界面扩展。后端使用 Pi 的公开 RPC 模式,每个会话对应独立的 RPC 进程;历史会话可直接从 Pi 会话文件恢复和渲染。
|
|
4
4
|
|
|
5
|
-
>
|
|
5
|
+
> 当前正式版本为 1.0.0,仅承诺 Windows 10/11。服务只监听 `127.0.0.1`,不应通过端口转发或反向代理暴露到局域网或公网。
|
|
6
6
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
|
-
需要 Node.js 22.19 或更高版本,以及 Pi Coding Agent 0.
|
|
9
|
+
需要 Node.js 22.19 或更高版本,以及 Pi Coding Agent 0.85.0 或更高版本。
|
|
10
10
|
|
|
11
11
|
```powershell
|
|
12
|
-
pi install npm:@oversk7/pi-web-dsh-style
|
|
12
|
+
pi install npm:@oversk7/pi-web-dsh-style
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
也可以从源码目录安装:
|
|
@@ -41,14 +41,23 @@ pi --web
|
|
|
41
41
|
|
|
42
42
|
- 多工作区、多会话和后台会话并行运行。
|
|
43
43
|
- 按需启动 Pi RPC 进程,最多保留一个空闲进程。
|
|
44
|
+
- Windows 会话使用 Job Object 托管进程树:停止 Web 或宿主退出时,清理 RPC 及其启动的后台服务、子进程和孙进程。
|
|
44
45
|
- 模型、思考等级、中断、压缩和扩展交互对话框。
|
|
45
46
|
- 运行中逐条显示待处理消息,可单条撤回并重新编辑;`Alt+Q` 可将全部队列消息恢复到输入框。
|
|
46
|
-
-
|
|
47
|
+
- 会话历史保留完整分支与检查点,支持回溯后返回切换前的位置,以及 `pi-rewind` 文件恢复。
|
|
47
48
|
- Windows 系统剪贴板图片和文本粘贴。
|
|
48
49
|
- 会话中的本地文件链接可在右侧分栏预览,支持语法高亮、行定位和拖拽调整宽度。
|
|
49
50
|
- 设置面板内更新扩展并热重载 Pi 运行时,无需退出 Web UI。
|
|
50
51
|
- 会话 HTML 导出。
|
|
51
52
|
|
|
53
|
+
## 进程生命周期
|
|
54
|
+
|
|
55
|
+
`/web --stop`、宿主 Pi 退出或运行时重载会关闭 Web 会话进程及其后代,包括 detached 后台进程;即使宿主被强制结束,Windows 托管进程也会检测到宿主退出并清理整棵进程树。不会扫描或结束工作区外独立启动的进程,也不会结束其他 Pi 实例。
|
|
56
|
+
|
|
57
|
+
单个 RPC 进程退出、异常终止或被空闲进程回收时,它启动的后台进程也会一起结束。需要独立于会话长期运行的服务,应从 Web 会话之外的终端启动。通过系统服务管理器、计划任务等外部机制启动的进程不属于此进程树。
|
|
58
|
+
|
|
59
|
+
只关闭浏览器标签页不会停止 Web 服务或后台会话;要结束它们,请执行 `/web --stop` 或退出宿主 Pi。进程托管依赖 Windows 10/11 自带的 Windows PowerShell 和 Win32 Job Object;如果无法建立托管,RPC 启动会失败,不会回退为无托管进程。
|
|
60
|
+
|
|
52
61
|
## 数据与升级
|
|
53
62
|
|
|
54
63
|
运行状态保存在:
|
|
@@ -78,11 +87,11 @@ npm run verify
|
|
|
78
87
|
npm pack --dry-run
|
|
79
88
|
```
|
|
80
89
|
|
|
81
|
-
|
|
90
|
+
发布正式版:
|
|
82
91
|
|
|
83
92
|
```powershell
|
|
84
93
|
npm login --registry=https://registry.npmjs.org
|
|
85
|
-
npm publish --access public --tag
|
|
94
|
+
npm publish --access public --tag latest
|
|
86
95
|
```
|
|
87
96
|
|
|
88
97
|
## 许可证
|
package/index.ts
CHANGED
|
@@ -128,9 +128,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
128
128
|
const targetId = args.trim();
|
|
129
129
|
if (!targetId || !ctx.sessionManager.getEntry(targetId)) throw new Error("会话树节点不存在");
|
|
130
130
|
await ctx.waitForIdle();
|
|
131
|
+
const fromId = ctx.sessionManager.getLeafId();
|
|
131
132
|
const result = await ctx.navigateTree(targetId, { summarize: false });
|
|
132
133
|
if (result.cancelled) throw new Error("会话回溯已取消");
|
|
133
|
-
pi.appendEntry("pi-web-tree-navigation", { targetId });
|
|
134
|
+
pi.appendEntry("pi-web-tree-navigation", { targetId, fromId });
|
|
134
135
|
},
|
|
135
136
|
});
|
|
136
137
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oversk7/pi-web-dsh-style",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "An unofficial DSH-styled local Windows browser UI extension for pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"state-store.ts",
|
|
32
32
|
"transcript.ts",
|
|
33
33
|
"windows-clipboard.ts",
|
|
34
|
+
"windows-rpc-job.ps1",
|
|
34
35
|
"web/",
|
|
35
36
|
"README.md",
|
|
36
37
|
"LICENSE",
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
"publishConfig": {
|
|
63
64
|
"access": "public",
|
|
64
65
|
"registry": "https://registry.npmjs.org",
|
|
65
|
-
"tag": "
|
|
66
|
+
"tag": "latest"
|
|
66
67
|
},
|
|
67
68
|
"dependencies": {
|
|
68
69
|
"highlight.js": "^11.12.0"
|
package/pi-rpc.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
6
7
|
|
|
7
8
|
export type Json = Record<string, any>;
|
|
@@ -14,6 +15,11 @@ export interface PiRpc {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const REQUEST_TIMEOUT = 120_000;
|
|
18
|
+
const activeRpcs = new Set<PiRpc>();
|
|
19
|
+
|
|
20
|
+
export async function closeAllPiRpcs(): Promise<void> {
|
|
21
|
+
await Promise.all([...activeRpcs].map((rpc) => rpc.close()));
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
function safeJsonParse(line: string): Json | undefined {
|
|
19
25
|
try {
|
|
@@ -79,28 +85,47 @@ export function spawnPiRpc(options: PiRpcSpawnOptions): PiRpc {
|
|
|
79
85
|
args.push("--session-id", options.sessionId);
|
|
80
86
|
}
|
|
81
87
|
if (options.name) args.push("--name", options.name);
|
|
82
|
-
const
|
|
88
|
+
const windows = process.platform === "win32";
|
|
89
|
+
const executable = windows
|
|
90
|
+
? join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
91
|
+
: process.execPath;
|
|
92
|
+
const childArgs = windows
|
|
93
|
+
? ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", fileURLToPath(new URL("./windows-rpc-job.ps1", import.meta.url))]
|
|
94
|
+
: [launch.entry, ...args];
|
|
95
|
+
const child = spawn(executable, childArgs, {
|
|
83
96
|
cwd: options.cwd,
|
|
84
|
-
env: {
|
|
97
|
+
env: {
|
|
98
|
+
...process.env, NO_COLOR: "1", PI_TELEMETRY: "0",
|
|
99
|
+
...(windows ? { PI_WEB_RPC_JOB: JSON.stringify({ ownerPid: process.pid, executable: process.execPath, args: [launch.entry, ...args], cwd: options.cwd }) } : {}),
|
|
100
|
+
},
|
|
85
101
|
stdio: ["pipe", "pipe", "pipe"],
|
|
86
102
|
windowsHide: true,
|
|
103
|
+
detached: !windows,
|
|
87
104
|
});
|
|
88
105
|
|
|
89
106
|
const handlers = new Set<(event: Json) => void>();
|
|
90
107
|
const pending = new Map<string, { resolve: (v: Json) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
91
108
|
let buffer = "";
|
|
92
109
|
let closed = false;
|
|
110
|
+
let closing: Promise<void> | undefined;
|
|
93
111
|
let spawnError: Error | undefined;
|
|
94
112
|
let resolveExit: (() => void) | undefined;
|
|
95
113
|
const exitPromise = new Promise<void>((resolve) => {
|
|
96
114
|
resolveExit = resolve;
|
|
97
115
|
});
|
|
98
116
|
|
|
117
|
+
const killProcessTree = (): void => {
|
|
118
|
+
if (!windows && child.pid) {
|
|
119
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch { /* The process group may already be gone. */ }
|
|
120
|
+
}
|
|
121
|
+
child.kill();
|
|
122
|
+
};
|
|
123
|
+
|
|
99
124
|
const forceClose = (reason: string): void => {
|
|
100
125
|
if (closed) return;
|
|
101
126
|
closed = true;
|
|
102
127
|
try {
|
|
103
|
-
|
|
128
|
+
killProcessTree();
|
|
104
129
|
} catch {
|
|
105
130
|
// ignore
|
|
106
131
|
}
|
|
@@ -114,6 +139,7 @@ export function spawnPiRpc(options: PiRpcSpawnOptions): PiRpc {
|
|
|
114
139
|
|
|
115
140
|
child.on("error", (error) => {
|
|
116
141
|
spawnError = error;
|
|
142
|
+
activeRpcs.delete(rpc);
|
|
117
143
|
closed = true;
|
|
118
144
|
resolveExit?.();
|
|
119
145
|
for (const [, p] of pending) {
|
|
@@ -180,6 +206,8 @@ export function spawnPiRpc(options: PiRpcSpawnOptions): PiRpc {
|
|
|
180
206
|
});
|
|
181
207
|
|
|
182
208
|
child.on("exit", (code, signal) => {
|
|
209
|
+
activeRpcs.delete(rpc);
|
|
210
|
+
if (!windows) killProcessTree();
|
|
183
211
|
closed = true;
|
|
184
212
|
resolveExit?.();
|
|
185
213
|
for (const [, p] of pending) {
|
|
@@ -190,10 +218,10 @@ export function spawnPiRpc(options: PiRpcSpawnOptions): PiRpc {
|
|
|
190
218
|
for (const handler of handlers) handler({ type: "process_exit", code: code ?? undefined, signal: signal ?? undefined });
|
|
191
219
|
});
|
|
192
220
|
|
|
193
|
-
|
|
221
|
+
const rpc: PiRpc = {
|
|
194
222
|
send<T = unknown>(command: Json, timeoutMs = REQUEST_TIMEOUT): Promise<T> {
|
|
195
223
|
if (spawnError) return Promise.reject(spawnError);
|
|
196
|
-
if (closed) return Promise.reject(new Error("pi rpc process is not running"));
|
|
224
|
+
if (closed || closing) return Promise.reject(new Error("pi rpc process is not running"));
|
|
197
225
|
const id = randomUUID();
|
|
198
226
|
const payload = { ...command, id };
|
|
199
227
|
return new Promise<T>((resolve, reject) => {
|
|
@@ -213,41 +241,51 @@ export function spawnPiRpc(options: PiRpcSpawnOptions): PiRpc {
|
|
|
213
241
|
},
|
|
214
242
|
write(message: Json): void {
|
|
215
243
|
if (spawnError) throw spawnError;
|
|
216
|
-
if (closed) throw new Error("pi rpc process is not running");
|
|
244
|
+
if (closed || closing) throw new Error("pi rpc process is not running");
|
|
217
245
|
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
218
246
|
},
|
|
219
247
|
onEvent(handler) {
|
|
220
248
|
handlers.add(handler);
|
|
221
249
|
return () => handlers.delete(handler);
|
|
222
250
|
},
|
|
223
|
-
|
|
251
|
+
close(timeoutMs = 3000) {
|
|
252
|
+
if (closing) return closing;
|
|
224
253
|
if (closed) return exitPromise;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
254
|
+
closing = closeProcess(timeoutMs);
|
|
255
|
+
return closing;
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
activeRpcs.add(rpc);
|
|
259
|
+
return rpc;
|
|
260
|
+
|
|
261
|
+
async function closeProcess(timeoutMs: number): Promise<void> {
|
|
262
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
263
|
+
let forced = false;
|
|
264
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
265
|
+
timeout = setTimeout(() => {
|
|
266
|
+
forced = true;
|
|
267
|
+
forceClose("pi rpc close timed out");
|
|
268
|
+
resolve();
|
|
269
|
+
}, timeoutMs);
|
|
270
|
+
});
|
|
271
|
+
try {
|
|
272
|
+
child.stdin.end();
|
|
273
|
+
} catch {
|
|
274
|
+
// The RPC may have already closed its input.
|
|
275
|
+
}
|
|
276
|
+
await Promise.race([exitPromise, timeoutPromise]);
|
|
277
|
+
if (timeout) clearTimeout(timeout);
|
|
278
|
+
if (forced) {
|
|
235
279
|
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
280
|
await Promise.race([
|
|
244
281
|
exitPromise,
|
|
245
|
-
new Promise<
|
|
246
|
-
|
|
247
|
-
timer.unref();
|
|
282
|
+
new Promise<never>((_resolve, reject) => {
|
|
283
|
+
timeout = setTimeout(() => reject(new Error("pi rpc process tree did not exit after forced shutdown")), 1000);
|
|
248
284
|
}),
|
|
249
285
|
]);
|
|
286
|
+
} finally {
|
|
287
|
+
if (timeout) clearTimeout(timeout);
|
|
250
288
|
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
253
291
|
}
|
package/server.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "./abort-session.ts";
|
|
19
19
|
import { ImageAttachmentStore } from "./attachment-store.ts";
|
|
20
20
|
import { closeSseClient, createSseClient, sendSseEvent, type SseClient } from "./sse-channel.ts";
|
|
21
|
-
import { spawnPiRpc, type Json, type PiRpc } from "./pi-rpc.ts";
|
|
21
|
+
import { closeAllPiRpcs, spawnPiRpc, type Json, type PiRpc } from "./pi-rpc.ts";
|
|
22
22
|
import { loadLocalSession } from "./local-session.ts";
|
|
23
23
|
import { loadJsonWithLegacyMigration, writeJsonAtomically } from "./state-store.ts";
|
|
24
24
|
import { buildFilePreview } from "./file-preview.ts";
|
|
@@ -136,6 +136,7 @@ interface HistoryNode {
|
|
|
136
136
|
role?: string;
|
|
137
137
|
customType?: string;
|
|
138
138
|
navigationTargetId?: string;
|
|
139
|
+
navigationFromId?: string;
|
|
139
140
|
text: string;
|
|
140
141
|
timestamp?: string;
|
|
141
142
|
label?: string;
|
|
@@ -212,6 +213,7 @@ let sseClients = new Set<SseClient>();
|
|
|
212
213
|
let state: StateFile = { version: 1, workspaces: [], sessions: [] };
|
|
213
214
|
let stateSaveChain: Promise<void> = Promise.resolve();
|
|
214
215
|
let shuttingDown = false;
|
|
216
|
+
let shutdownPromise: Promise<void> | undefined;
|
|
215
217
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
216
218
|
|
|
217
219
|
const MAX_IDLE_PROCESSES = 1;
|
|
@@ -302,7 +304,7 @@ function historyEntrySummary(entry: Json): { role?: string; customType?: string;
|
|
|
302
304
|
return { text: String(entry.type ?? "会话节点") };
|
|
303
305
|
}
|
|
304
306
|
|
|
305
|
-
function flattenHistoryTree(treeValue: unknown, leafValue: unknown): { nodes: HistoryNode[]; leafId: string | null } {
|
|
307
|
+
export function flattenHistoryTree(treeValue: unknown, leafValue: unknown): { nodes: HistoryNode[]; leafId: string | null } {
|
|
306
308
|
const roots = Array.isArray(treeValue) ? treeValue as Json[] : [];
|
|
307
309
|
const leafId = typeof leafValue === "string" ? leafValue : null;
|
|
308
310
|
const entries = new Map<string, Json>();
|
|
@@ -340,12 +342,15 @@ function flattenHistoryTree(treeValue: unknown, leafValue: unknown): { nodes: Hi
|
|
|
340
342
|
navigationTargetId: entry.type === "custom" && entry.data && typeof entry.data === "object" && typeof (entry.data as Json).targetId === "string"
|
|
341
343
|
? (entry.data as Json).targetId as string
|
|
342
344
|
: undefined,
|
|
345
|
+
navigationFromId: entry.type === "custom" && entry.customType === "pi-web-tree-navigation" && entry.data && typeof entry.data === "object" && typeof (entry.data as Json).fromId === "string"
|
|
346
|
+
? (entry.data as Json).fromId as string
|
|
347
|
+
: undefined,
|
|
343
348
|
text: summary.text,
|
|
344
349
|
timestamp: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
|
|
345
350
|
label: typeof item.node.label === "string" ? item.node.label : undefined,
|
|
346
351
|
active,
|
|
347
352
|
current: entry.id === leafId,
|
|
348
|
-
rewindable:
|
|
353
|
+
rewindable: summary.role === "user",
|
|
349
354
|
navigable: entry.id !== leafId,
|
|
350
355
|
branchDepth: item.branchDepth,
|
|
351
356
|
branchStart: item.branchStart,
|
|
@@ -386,7 +391,7 @@ function historyEditorText(record: RuntimeSession, entryId: string): string | un
|
|
|
386
391
|
if (message.role === "user") return rawContentText(message.content);
|
|
387
392
|
}
|
|
388
393
|
if (entry.type === "custom_message") return rawContentText(entry.content);
|
|
389
|
-
return
|
|
394
|
+
return "";
|
|
390
395
|
}
|
|
391
396
|
|
|
392
397
|
async function pasteSystemClipboard(): Promise<Json> {
|
|
@@ -854,6 +859,7 @@ function sameModelCatalog(left: unknown[], right: unknown[]): boolean {
|
|
|
854
859
|
}
|
|
855
860
|
|
|
856
861
|
async function discoverFreshModelCatalog(cwd: string): Promise<ModelCatalog> {
|
|
862
|
+
if (shuttingDown) throw new Error("pi-web 正在关闭,无法启动模型探测进程");
|
|
857
863
|
const key = normalizePath(cwd);
|
|
858
864
|
const existing = modelCatalogRefreshes.get(key);
|
|
859
865
|
if (existing) return existing;
|
|
@@ -1343,6 +1349,7 @@ async function applyPendingSettings(record: RuntimeSession): Promise<void> {
|
|
|
1343
1349
|
}
|
|
1344
1350
|
|
|
1345
1351
|
async function ensureProcess(record: RuntimeSession): Promise<void> {
|
|
1352
|
+
if (shuttingDown) throw new Error("pi-web 正在关闭,无法启动会话进程");
|
|
1346
1353
|
record.lastUsed = Date.now();
|
|
1347
1354
|
if (record.proc) return;
|
|
1348
1355
|
if (record.starting) return record.starting;
|
|
@@ -2957,9 +2964,14 @@ function sigintHook(): void {
|
|
|
2957
2964
|
void shutdown();
|
|
2958
2965
|
}
|
|
2959
2966
|
|
|
2960
|
-
|
|
2961
|
-
if (
|
|
2967
|
+
function shutdown(): Promise<void> {
|
|
2968
|
+
if (shutdownPromise) return shutdownPromise;
|
|
2962
2969
|
shuttingDown = true;
|
|
2970
|
+
shutdownPromise = shutdownServer();
|
|
2971
|
+
return shutdownPromise;
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
async function shutdownServer(): Promise<void> {
|
|
2963
2975
|
for (const client of [...sseClients]) {
|
|
2964
2976
|
closeSseClient(client);
|
|
2965
2977
|
try {
|
|
@@ -2969,9 +2981,8 @@ async function shutdown(): Promise<void> {
|
|
|
2969
2981
|
}
|
|
2970
2982
|
}
|
|
2971
2983
|
sseClients.clear();
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
}
|
|
2984
|
+
await Promise.all([...sessions.values()].map(stopSessionProcess));
|
|
2985
|
+
await closeAllPiRpcs();
|
|
2975
2986
|
for (const download of exportDownloads.values()) unlink(download.path).catch(() => {});
|
|
2976
2987
|
exportDownloads.clear();
|
|
2977
2988
|
await imageAttachments.close();
|
|
@@ -3004,6 +3015,7 @@ export async function startWebServer(options: {
|
|
|
3004
3015
|
updateExtensions?: () => Promise<MaintenanceCommandResult>;
|
|
3005
3016
|
reloadRuntime?: () => Promise<void>;
|
|
3006
3017
|
} = {}): Promise<string> {
|
|
3018
|
+
if (shutdownPromise) await shutdownPromise;
|
|
3007
3019
|
const initialCwd = normalizePath(options.defaultCwd ?? process.cwd());
|
|
3008
3020
|
updateExtensions = options.updateExtensions;
|
|
3009
3021
|
reloadRuntime = options.reloadRuntime;
|
|
@@ -3012,6 +3024,7 @@ export async function startWebServer(options: {
|
|
|
3012
3024
|
if (options.open !== false) openBrowser(serverUrl);
|
|
3013
3025
|
return serverUrl;
|
|
3014
3026
|
}
|
|
3027
|
+
shutdownPromise = undefined;
|
|
3015
3028
|
shuttingDown = false;
|
|
3016
3029
|
await loadState();
|
|
3017
3030
|
if (options.initialModelInfo?.models.length) {
|
|
@@ -3027,6 +3040,7 @@ export async function startWebServer(options: {
|
|
|
3027
3040
|
const requestedPort = options.port && Number.isFinite(options.port) ? options.port : DEFAULT_PORT;
|
|
3028
3041
|
const app = createServer(async (req, res) => {
|
|
3029
3042
|
try {
|
|
3043
|
+
if (shuttingDown) return sendJson(res, 503, { error: "pi-web 正在关闭" });
|
|
3030
3044
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? `${HOST}:${requestedPort}`}`);
|
|
3031
3045
|
if (url.pathname.startsWith("/api/")) {
|
|
3032
3046
|
const handled = await handleApi(req, res, url);
|
package/web/app.css
CHANGED
|
@@ -297,7 +297,11 @@ body[data-workspace-dragging] .YDXeBa_projectRow[draggable=true]{cursor:grabbing
|
|
|
297
297
|
.pi-queuedMessageWithdraw:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
|
|
298
298
|
.pi-queuedMessageWithdraw:disabled{cursor:wait;opacity:.45}
|
|
299
299
|
.pi-historyView{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:var(--dsw-alias-bg-base)}
|
|
300
|
-
.pi-historyHeader{box-sizing:border-box;width:min(100%,860px);flex:none;display:flex;align-items:flex-start;margin:0 auto;padding:24px 22px 14px}
|
|
300
|
+
.pi-historyHeader{box-sizing:border-box;width:min(100%,860px);flex:none;display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:12px;margin:0 auto;padding:24px 22px 14px}
|
|
301
|
+
.pi-historyReturn{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:6px 0;color:var(--dsw-alias-state-business-primary);background:transparent;border:0;cursor:pointer;font:500 12px/20px var(--dsw-font-family)}
|
|
302
|
+
.pi-historyReturn svg{flex:none}
|
|
303
|
+
.pi-historyReturn:hover:not(:disabled){text-decoration:underline}
|
|
304
|
+
.pi-historyReturn:disabled{cursor:wait;opacity:.58}
|
|
301
305
|
.pi-historyTitle{color:var(--dsw-alias-label-primary);font-size:16px;font-weight:600;line-height:24px}
|
|
302
306
|
.pi-historySubtitle{margin-top:3px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
|
|
303
307
|
.pi-historyTabs{box-sizing:border-box;width:min(100%,860px);flex:none;display:flex;gap:24px;margin:0 auto;padding:0 22px;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
package/web/app.js
CHANGED
|
@@ -2129,21 +2129,14 @@ function historyNodeLabel(node) {
|
|
|
2129
2129
|
thinking_level_change: "思考",
|
|
2130
2130
|
session_info: "会话",
|
|
2131
2131
|
custom_message: "扩展消息",
|
|
2132
|
+
custom: "扩展状态",
|
|
2133
|
+
label: "标签",
|
|
2132
2134
|
}[node.type] || "节点";
|
|
2133
2135
|
}
|
|
2134
2136
|
|
|
2135
|
-
function
|
|
2136
|
-
return nodes.filter((node) =>
|
|
2137
|
-
|
|
2138
|
-
|| node.role === "assistant"
|
|
2139
|
-
|| node.type === "compaction"
|
|
2140
|
-
|| node.type === "branch_summary"
|
|
2141
|
-
|| node.type === "model_change"
|
|
2142
|
-
|| node.type === "thinking_level_change"
|
|
2143
|
-
|| node.customType === "pi-web-tree-navigation"
|
|
2144
|
-
|| node.label
|
|
2145
|
-
|| node.current
|
|
2146
|
-
));
|
|
2137
|
+
function historyRewindNodes(nodes) {
|
|
2138
|
+
return nodes.filter((node) => node.rewindable)
|
|
2139
|
+
.sort((a, b) => (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0));
|
|
2147
2140
|
}
|
|
2148
2141
|
|
|
2149
2142
|
function renderHistoryView(view) {
|
|
@@ -2156,13 +2149,26 @@ function renderHistoryView(view) {
|
|
|
2156
2149
|
} else if (dialog.error && nodes.length === 0) {
|
|
2157
2150
|
body = `<div class="pi-historyState pi-historyError">${esc(dialog.error)}</div>`;
|
|
2158
2151
|
} else if (dialog.mode === "rewind") {
|
|
2159
|
-
const prompts = nodes
|
|
2152
|
+
const prompts = historyRewindNodes(nodes);
|
|
2160
2153
|
body = prompts.length > 0 ? prompts.map((node, index) => `
|
|
2161
|
-
<button type="button" class="pi-historyPrompt ${dialog.selectedEntryId === node.id ? "pi-historyPromptSelected" : ""}" data-action="history-choose-rewind" data-entry="${esc(node.id)}" data-role="${esc(node.role || "")}" aria-pressed="${dialog.selectedEntryId === node.id}" ${dialog.busyEntryId ? "disabled" : ""}>
|
|
2154
|
+
<button type="button" class="pi-historyPrompt ${dialog.selectedEntryId === node.id ? "pi-historyPromptSelected" : ""}" data-action="history-choose-rewind" data-entry="${esc(node.id)}" data-role="${esc(node.role || "")}" aria-pressed="${dialog.selectedEntryId === node.id}" ${!node.navigable || dialog.busyEntryId ? "disabled" : ""}>
|
|
2162
2155
|
<span class="pi-historyPromptIndex">${index === 0 ? "最近" : `-${index}`}</span>
|
|
2163
|
-
<span class="pi-historyPromptCopy"><span class="pi-historyPromptText">${esc(node.text || "(空消息)")}</span><span class="pi-historyPromptMeta">${esc(clockLabel(node.timestamp))} ·
|
|
2164
|
-
</button>`).join("") : `<div class="pi-historyState"
|
|
2165
|
-
|
|
2156
|
+
<span class="pi-historyPromptCopy"><span class="pi-historyPromptText">${esc(node.text || "(空消息)")}</span><span class="pi-historyPromptMeta">${esc(clockLabel(node.timestamp))} · ${node.active ? "当前路径" : "历史分支"}</span></span>
|
|
2157
|
+
</button>`).join("") : `<div class="pi-historyState">当前会话还没有可回溯的用户消息</div>`;
|
|
2158
|
+
} else {
|
|
2159
|
+
body = nodes.length > 0 ? nodes.map((node) => {
|
|
2160
|
+
const depth = Math.min(7, Math.max(0, Number(node.branchDepth) || 0));
|
|
2161
|
+
const disabled = !node.navigable || dialog.busyEntryId;
|
|
2162
|
+
return `<button type="button" class="pi-historyTreeRow ${node.active ? "pi-historyTreeActive" : ""} ${node.current ? "pi-historyTreeCurrent" : ""} ${dialog.selectedEntryId === node.id ? "pi-historyPromptSelected" : ""}" style="--history-indent:${12 + depth * 18}px" data-action="history-choose-rewind" data-entry="${esc(node.id)}" data-role="${esc(node.role || "")}" aria-pressed="${dialog.selectedEntryId === node.id}" ${disabled ? "disabled" : ""}>
|
|
2163
|
+
<span class="pi-historyRail"><span class="pi-historyDot"></span></span>
|
|
2164
|
+
<span class="pi-historyKind">${esc(historyNodeLabel(node))}</span>
|
|
2165
|
+
<span class="pi-historyTreeCopy"><span class="pi-historyTreeText">${esc(node.text || historyNodeLabel(node))}</span><span class="pi-historyTreeMeta">${esc(clockLabel(node.timestamp))}${node.branchCount > 1 ? ` · ${node.branchCount} 个分支` : ""}${node.current ? " · 当前位置" : node.active ? " · 当前路径" : " · 历史分支"}</span></span>
|
|
2166
|
+
${node.label ? `<span class="pi-historyLabel">${esc(node.label)}</span>` : ""}
|
|
2167
|
+
</button>`;
|
|
2168
|
+
}).join("") : `<div class="pi-historyState">当前会话树为空</div>`;
|
|
2169
|
+
}
|
|
2170
|
+
if (!dialog.loading) {
|
|
2171
|
+
const selected = nodes.find((node) => node.id === dialog.selectedEntryId && node.navigable);
|
|
2166
2172
|
if (selected) {
|
|
2167
2173
|
decision = `<div class="pi-historyDecisionWrap"><div class="pi-historyDecision">
|
|
2168
2174
|
<div class="pi-historyDecisionCopy"><div class="pi-historyDecisionTitle">选择回溯范围</div><div class="pi-historyDecisionText">${esc(selected.text || "(空消息)")}</div></div>
|
|
@@ -2173,23 +2179,14 @@ function renderHistoryView(view) {
|
|
|
2173
2179
|
</div>
|
|
2174
2180
|
</div></div>`;
|
|
2175
2181
|
}
|
|
2176
|
-
} else {
|
|
2177
|
-
const treeNodes = visibleHistoryTreeNodes(nodes);
|
|
2178
|
-
body = treeNodes.length > 0 ? treeNodes.map((node) => {
|
|
2179
|
-
const depth = Math.min(7, Math.max(0, Number(node.branchDepth) || 0));
|
|
2180
|
-
const disabled = !node.navigable || dialog.busyEntryId;
|
|
2181
|
-
return `<button type="button" class="pi-historyTreeRow ${node.active ? "pi-historyTreeActive" : ""} ${node.current ? "pi-historyTreeCurrent" : ""}" style="--history-indent:${12 + depth * 18}px" data-action="history-navigate" data-entry="${esc(node.id)}" data-role="${esc(node.role || "")}" ${disabled ? "disabled" : ""}>
|
|
2182
|
-
<span class="pi-historyRail"><span class="pi-historyDot"></span></span>
|
|
2183
|
-
<span class="pi-historyKind">${esc(historyNodeLabel(node))}</span>
|
|
2184
|
-
<span class="pi-historyTreeCopy"><span class="pi-historyTreeText">${esc(node.text || historyNodeLabel(node))}</span><span class="pi-historyTreeMeta">${esc(clockLabel(node.timestamp))}${node.branchCount > 1 ? ` · ${node.branchCount} 个分支` : ""}${node.current ? " · 当前位置" : node.active ? " · 当前路径" : ""}</span></span>
|
|
2185
|
-
${node.label ? `<span class="pi-historyLabel">${esc(node.label)}</span>` : ""}
|
|
2186
|
-
</button>`;
|
|
2187
|
-
}).join("") : `<div class="pi-historyState">当前会话树为空</div>`;
|
|
2188
2182
|
}
|
|
2183
|
+
const current = nodes.find((node) => node.current);
|
|
2184
|
+
const previous = nodes.find((node) => node.id === current?.navigationFromId && node.navigable);
|
|
2185
|
+
const returnButton = previous ? `<button type="button" class="pi-historyReturn" data-action="history-choose-rewind" data-entry="${esc(previous.id)}" data-role="${esc(previous.role || "")}" ${dialog.busyEntryId ? "disabled" : ""}>${ICONS.history}<span>返回切换前的位置</span></button>` : "";
|
|
2189
2186
|
const errorBanner = dialog.error && nodes.length > 0 ? `<div class="pi-historyErrorBanner" role="alert">${esc(dialog.error)}</div>` : "";
|
|
2190
2187
|
const busyText = dialog.busyRestoreMode === "all" ? "正在回溯对话和文件…" : "正在切换会话分支…";
|
|
2191
2188
|
return `<section class="pi-historyView" role="tabpanel" aria-label="会话历史">
|
|
2192
|
-
<div class="pi-historyHeader"><div><div class="pi-historyTitle">会话历史</div><div class="pi-historySubtitle"
|
|
2189
|
+
<div class="pi-historyHeader"><div><div class="pi-historyTitle">会话历史</div><div class="pi-historySubtitle">${nodes.filter((node) => node.rewindable).length} 条用户消息 · ${nodes.filter((node) => !node.active).length} 个历史分支节点</div></div>${returnButton}</div>
|
|
2193
2190
|
<div class="pi-historyTabs" role="tablist">
|
|
2194
2191
|
<button type="button" role="tab" aria-selected="${dialog.mode === "rewind"}" class="pi-historyTab ${dialog.mode === "rewind" ? "pi-historyTabActive" : ""}" data-action="history-mode" data-mode="rewind">回溯</button>
|
|
2195
2192
|
<button type="button" role="tab" aria-selected="${dialog.mode === "tree"}" class="pi-historyTab ${dialog.mode === "tree" ? "pi-historyTabActive" : ""}" data-action="history-mode" data-mode="tree">会话树</button>
|
|
@@ -2231,7 +2228,10 @@ async function navigateHistory(entryId, role, restoreMode) {
|
|
|
2231
2228
|
const view = S.historyView;
|
|
2232
2229
|
const sessionId = view?.sessionId;
|
|
2233
2230
|
if (!view || !sessionId || view.busyEntryId) return;
|
|
2234
|
-
if (
|
|
2231
|
+
if (S.draft.trim()) {
|
|
2232
|
+
const replacesDraft = role === "user" || role === "custom";
|
|
2233
|
+
if (!confirm(replacesDraft ? "当前输入框草稿将被历史消息替换,继续回溯?" : "切换会话节点将清空当前输入框草稿,继续?")) return;
|
|
2234
|
+
}
|
|
2235
2235
|
S.historyView = { ...view, busyEntryId: entryId, busyRestoreMode: restoreMode || null, error: null };
|
|
2236
2236
|
renderConversation();
|
|
2237
2237
|
try {
|
|
@@ -2249,7 +2249,7 @@ async function navigateHistory(entryId, role, restoreMode) {
|
|
|
2249
2249
|
input?.focus({ preventScroll: true });
|
|
2250
2250
|
input?.setSelectionRange(input.value.length, input.value.length);
|
|
2251
2251
|
});
|
|
2252
|
-
if (restoreMode === "all") showToast("已回溯对话和文件,可编辑后重新发送");
|
|
2252
|
+
if (restoreMode === "all") showToast(role === "user" ? "已回溯对话和文件,可编辑后重新发送" : "已切换会话节点并恢复文件");
|
|
2253
2253
|
else if (restoreMode === "conversation") showToast("已回溯对话,工作区文件保持不变");
|
|
2254
2254
|
else showToast(role === "user" ? "已回溯到所选消息,可编辑后重新发送" : "已切换到所选会话节点");
|
|
2255
2255
|
} catch (error) {
|
|
@@ -3710,8 +3710,6 @@ async function onClick(ev) {
|
|
|
3710
3710
|
S.historyView = { ...S.historyView, selectedEntryId: null, error: null };
|
|
3711
3711
|
renderConversation();
|
|
3712
3712
|
}
|
|
3713
|
-
} else if (action === "history-navigate") {
|
|
3714
|
-
await navigateHistory(target.dataset.entry, target.dataset.role);
|
|
3715
3713
|
} else if (action === "abort") {
|
|
3716
3714
|
if (sessionId) {
|
|
3717
3715
|
target.disabled = true;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
$ErrorActionPreference = 'Stop'
|
|
2
|
+
|
|
3
|
+
Add-Type -TypeDefinition @'
|
|
4
|
+
using System;
|
|
5
|
+
using System.ComponentModel;
|
|
6
|
+
using System.Runtime.InteropServices;
|
|
7
|
+
using System.Text;
|
|
8
|
+
|
|
9
|
+
public static class PiWebRpcJob
|
|
10
|
+
{
|
|
11
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
12
|
+
struct BasicLimitInformation
|
|
13
|
+
{
|
|
14
|
+
public long PerProcessUserTimeLimit;
|
|
15
|
+
public long PerJobUserTimeLimit;
|
|
16
|
+
public uint LimitFlags;
|
|
17
|
+
public UIntPtr MinimumWorkingSetSize;
|
|
18
|
+
public UIntPtr MaximumWorkingSetSize;
|
|
19
|
+
public uint ActiveProcessLimit;
|
|
20
|
+
public UIntPtr Affinity;
|
|
21
|
+
public uint PriorityClass;
|
|
22
|
+
public uint SchedulingClass;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
26
|
+
struct IoCounters
|
|
27
|
+
{
|
|
28
|
+
public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount;
|
|
29
|
+
public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
33
|
+
struct ExtendedLimitInformation
|
|
34
|
+
{
|
|
35
|
+
public BasicLimitInformation BasicLimitInformation;
|
|
36
|
+
public IoCounters IoInfo;
|
|
37
|
+
public UIntPtr ProcessMemoryLimit, JobMemoryLimit, PeakProcessMemoryUsed, PeakJobMemoryUsed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
41
|
+
struct StartupInfo
|
|
42
|
+
{
|
|
43
|
+
public uint cb;
|
|
44
|
+
public string lpReserved, lpDesktop, lpTitle;
|
|
45
|
+
public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
|
|
46
|
+
public ushort wShowWindow, cbReserved2;
|
|
47
|
+
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
51
|
+
struct ExtendedStartupInfo
|
|
52
|
+
{
|
|
53
|
+
public StartupInfo StartupInfo;
|
|
54
|
+
public IntPtr AttributeList;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
58
|
+
struct ProcessInformation
|
|
59
|
+
{
|
|
60
|
+
public IntPtr hProcess, hThread;
|
|
61
|
+
public uint dwProcessId, dwThreadId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
65
|
+
static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
|
|
66
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
67
|
+
static extern IntPtr CreateJobObject(IntPtr attributes, string name);
|
|
68
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
69
|
+
static extern bool SetInformationJobObject(IntPtr job, int infoClass, ref ExtendedLimitInformation info, uint size);
|
|
70
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
71
|
+
static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, uint flags, ref IntPtr size);
|
|
72
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
73
|
+
static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, UIntPtr attribute,
|
|
74
|
+
IntPtr value, UIntPtr size, IntPtr previous, IntPtr returnedSize);
|
|
75
|
+
[DllImport("kernel32.dll")]
|
|
76
|
+
static extern void DeleteProcThreadAttributeList(IntPtr list);
|
|
77
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
78
|
+
static extern bool CreateProcess(string application, StringBuilder commandLine, IntPtr processAttributes,
|
|
79
|
+
IntPtr threadAttributes, bool inheritHandles, uint flags, IntPtr environment,
|
|
80
|
+
string cwd, ref ExtendedStartupInfo startupInfo, out ProcessInformation processInformation);
|
|
81
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
82
|
+
static extern uint ResumeThread(IntPtr thread);
|
|
83
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
84
|
+
static extern uint WaitForMultipleObjects(uint count, IntPtr[] handles, bool all, uint milliseconds);
|
|
85
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
86
|
+
static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
|
|
87
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
88
|
+
static extern bool GetExitCodeProcess(IntPtr process, out uint code);
|
|
89
|
+
[DllImport("kernel32.dll")]
|
|
90
|
+
static extern bool CloseHandle(IntPtr handle);
|
|
91
|
+
[DllImport("kernel32.dll")]
|
|
92
|
+
static extern IntPtr GetStdHandle(int which);
|
|
93
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
94
|
+
static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags);
|
|
95
|
+
|
|
96
|
+
static void Check(bool success)
|
|
97
|
+
{
|
|
98
|
+
if (!success) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static string Quote(string value)
|
|
102
|
+
{
|
|
103
|
+
var result = new StringBuilder("\"");
|
|
104
|
+
int slashes = 0;
|
|
105
|
+
foreach (char c in value)
|
|
106
|
+
{
|
|
107
|
+
if (c == '\\') { slashes++; continue; }
|
|
108
|
+
result.Append('\\', c == '"' ? slashes * 2 + 1 : slashes);
|
|
109
|
+
result.Append(c);
|
|
110
|
+
slashes = 0;
|
|
111
|
+
}
|
|
112
|
+
return result.Append('\\', slashes * 2).Append('"').ToString();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public static int Run(uint ownerPid, string executable, string[] args, string cwd)
|
|
116
|
+
{
|
|
117
|
+
IntPtr owner = OpenProcess(0x00100000, false, ownerPid);
|
|
118
|
+
if (owner == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
|
|
119
|
+
IntPtr job = IntPtr.Zero;
|
|
120
|
+
var child = new ProcessInformation();
|
|
121
|
+
IntPtr attributes = IntPtr.Zero, jobValue = IntPtr.Zero;
|
|
122
|
+
bool attributesInitialized = false;
|
|
123
|
+
try
|
|
124
|
+
{
|
|
125
|
+
// Acquire the owner's handle before launching, so PID reuse cannot keep this job alive.
|
|
126
|
+
if (WaitForSingleObject(owner, 0) != 258) return 1;
|
|
127
|
+
job = CreateJobObject(IntPtr.Zero, null);
|
|
128
|
+
Check(job != IntPtr.Zero);
|
|
129
|
+
var limits = new ExtendedLimitInformation();
|
|
130
|
+
limits.BasicLimitInformation.LimitFlags = 0x00002000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
131
|
+
Check(SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf(limits)));
|
|
132
|
+
IntPtr size = IntPtr.Zero;
|
|
133
|
+
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size);
|
|
134
|
+
attributes = Marshal.AllocHGlobal(size);
|
|
135
|
+
Check(InitializeProcThreadAttributeList(attributes, 1, 0, ref size));
|
|
136
|
+
attributesInitialized = true;
|
|
137
|
+
jobValue = Marshal.AllocHGlobal(IntPtr.Size);
|
|
138
|
+
Marshal.WriteIntPtr(jobValue, job);
|
|
139
|
+
Check(UpdateProcThreadAttribute(attributes, 0, new UIntPtr(0x0002000D),
|
|
140
|
+
jobValue, new UIntPtr((uint)IntPtr.Size), IntPtr.Zero, IntPtr.Zero)); // PROC_THREAD_ATTRIBUTE_JOB_LIST
|
|
141
|
+
var startup = new ExtendedStartupInfo();
|
|
142
|
+
startup.AttributeList = attributes;
|
|
143
|
+
startup.StartupInfo.cb = (uint)Marshal.SizeOf(startup);
|
|
144
|
+
startup.StartupInfo.dwFlags = 0x00000100; // STARTF_USESTDHANDLES
|
|
145
|
+
startup.StartupInfo.hStdInput = GetStdHandle(-10);
|
|
146
|
+
startup.StartupInfo.hStdOutput = GetStdHandle(-11);
|
|
147
|
+
startup.StartupInfo.hStdError = GetStdHandle(-12);
|
|
148
|
+
foreach (IntPtr handle in new[] { startup.StartupInfo.hStdInput, startup.StartupInfo.hStdOutput, startup.StartupInfo.hStdError })
|
|
149
|
+
Check(SetHandleInformation(handle, 1, 1));
|
|
150
|
+
var commandLine = new StringBuilder(Quote(executable));
|
|
151
|
+
foreach (string arg in args) commandLine.Append(' ').Append(Quote(arg));
|
|
152
|
+
// Atomic job assignment also covers the launcher dying during process creation.
|
|
153
|
+
Check(CreateProcess(executable, commandLine, IntPtr.Zero, IntPtr.Zero, true,
|
|
154
|
+
0x08080004, IntPtr.Zero, cwd, ref startup, out child)); // NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT | SUSPENDED
|
|
155
|
+
Check(ResumeThread(child.hThread) != UInt32.MaxValue);
|
|
156
|
+
uint completed = WaitForMultipleObjects(2, new[] { owner, child.hProcess }, false, UInt32.MaxValue);
|
|
157
|
+
Check(completed != UInt32.MaxValue);
|
|
158
|
+
uint code = 1;
|
|
159
|
+
if (completed == 1) Check(GetExitCodeProcess(child.hProcess, out code));
|
|
160
|
+
return unchecked((int)code);
|
|
161
|
+
}
|
|
162
|
+
finally
|
|
163
|
+
{
|
|
164
|
+
if (job != IntPtr.Zero) CloseHandle(job);
|
|
165
|
+
if (attributesInitialized) DeleteProcThreadAttributeList(attributes);
|
|
166
|
+
if (attributes != IntPtr.Zero) Marshal.FreeHGlobal(attributes);
|
|
167
|
+
if (jobValue != IntPtr.Zero) Marshal.FreeHGlobal(jobValue);
|
|
168
|
+
if (child.hThread != IntPtr.Zero) CloseHandle(child.hThread);
|
|
169
|
+
if (child.hProcess != IntPtr.Zero) CloseHandle(child.hProcess);
|
|
170
|
+
CloseHandle(owner);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
'@
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
$launch = $env:PI_WEB_RPC_JOB | ConvertFrom-Json
|
|
178
|
+
Remove-Item Env:PI_WEB_RPC_JOB
|
|
179
|
+
$code = [PiWebRpcJob]::Run([uint32]$launch.ownerPid, [string]$launch.executable, [string[]]$launch.args, [string]$launch.cwd)
|
|
180
|
+
exit $code
|
|
181
|
+
} catch {
|
|
182
|
+
[Console]::Error.WriteLine("pi-web RPC job failed: " + $_.Exception.Message)
|
|
183
|
+
exit 1
|
|
184
|
+
}
|