@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 CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  采用 DeepSeek Harness(DSH)视觉风格、面向 Windows 的非官方 Pi Coding Agent 本地浏览器界面扩展。后端使用 Pi 的公开 RPC 模式,每个会话对应独立的 RPC 进程;历史会话可直接从 Pi 会话文件恢复和渲染。
4
4
 
5
- > 当前版本为 alpha,仅承诺 Windows 10/11。服务只监听 `127.0.0.1`,不应通过端口转发或反向代理暴露到局域网或公网。
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.84.4 或更高版本。
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@alpha
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
- - 会话历史、分支树、对话回溯及 `pi-rewind` 文件恢复。
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
- 发布 alpha:
90
+ 发布正式版:
82
91
 
83
92
  ```powershell
84
93
  npm login --registry=https://registry.npmjs.org
85
- npm publish --access public --tag alpha
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": "0.1.0-alpha.1",
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": "alpha"
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 child = spawn(process.execPath, [launch.entry, ...args], {
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: { ...process.env, NO_COLOR: "1", PI_TELEMETRY: "0" },
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
- child.kill();
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
- return {
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
- async close(timeoutMs = 3000) {
251
+ close(timeoutMs = 3000) {
252
+ if (closing) return closing;
224
253
  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
- });
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<void>((resolve) => {
246
- const timer = setTimeout(resolve, 1000);
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: active && summary.role === "user",
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 undefined;
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
- async function shutdown(): Promise<void> {
2961
- if (shuttingDown) return;
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
- for (const record of sessions.values()) {
2973
- await stopSessionProcess(record);
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 visibleHistoryTreeNodes(nodes) {
2136
- return nodes.filter((node) => (
2137
- node.role === "user"
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.filter((node) => node.rewindable).reverse();
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))} · 从这里创建新分支并重新编辑</span></span>
2164
- </button>`).join("") : `<div class="pi-historyState">当前分支还没有可回溯的用户消息</div>`;
2165
- const selected = prompts.find((node) => node.id === dialog.selectedEntryId);
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">回溯不会删除记录,离开的内容仍保留在分支树中</div></div></div>
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 (role === "user" && S.draft.trim() && !confirm("当前输入框草稿将被历史消息替换,继续回溯?")) return;
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
+ }