@liguoshuai/pi-web-chat 1.8.5 → 1.8.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.8.5",
3
+ "version": "1.8.7",
4
4
  "description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/public/app.js CHANGED
@@ -1959,14 +1959,12 @@ function renderModelList(listContainer) {
1959
1959
  const opt = el("div", {
1960
1960
  class: "opt" + (active ? " active" : ""),
1961
1961
  onclick: () => {
1962
- const prev = state.currentModel;
1963
1962
  $("#modelPillName").textContent = "切换中…";
1964
1963
  sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
1965
1964
  saveRecentModel(m);
1966
1965
  $("#modelMenu").classList.remove("open");
1967
- if (prev && (prev.id !== m.id || prev.provider !== m.provider)) {
1968
- appendSystemNotice(`已切换模型至 ${m.provider ? m.provider + " / " : ""}${m.name || m.id}`);
1969
- }
1966
+ // 成功后的“已切换模型至 …”提示统一由 set_model response 处理逻辑弹出,
1967
+ // 避免在这里乐观提示一次、响应到达后 pi 再提示一次(重复提示)。
1970
1968
  },
1971
1969
  }, [
1972
1970
  el("span", { class: "check", html: active ? "✓" : "" }),
@@ -2316,7 +2314,7 @@ async function init() {
2316
2314
  toggleModelMenu();
2317
2315
  } else if (e.key === "Escape" && state.streaming) {
2318
2316
  const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
2319
- const isModalOpen = $("#cwdModal")?.style.display === "flex";
2317
+ const isModalOpen = $("#cwdModal")?.classList.contains("open");
2320
2318
  if (!isMenuOpen && !isModalOpen) {
2321
2319
  abortGeneration();
2322
2320
  }
package/public/style.css CHANGED
@@ -356,7 +356,11 @@ body {
356
356
  font-size: 15px; line-height: 1.65; color: var(--text);
357
357
  max-width: 100%; min-width: 0;
358
358
  }
359
- .msg.assistant .content p { margin-bottom: 12px; }
359
+ .msg.assistant .content p {
360
+ margin-bottom: 12px;
361
+ white-space: pre-wrap;
362
+ word-break: break-word;
363
+ }
360
364
  .msg.assistant .content p:last-child { margin-bottom: 0; }
361
365
  .msg.assistant .content pre {
362
366
  background: #0d0d0d; border: 1px solid var(--border);
@@ -22,11 +22,12 @@ SRC="$HERE/pi-web-chat.service"
22
22
  DEST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
23
23
  DEST="$DEST_DIR/pi-web-chat.service"
24
24
 
25
- PORT="${1:-3000}"
25
+ PORT="3000"
26
26
  RESTART_NOW="no"
27
27
  for arg in "$@"; do
28
28
  case "$arg" in
29
29
  --restart|--now) RESTART_NOW="yes" ;;
30
+ [0-9]*) PORT="$arg" ;;
30
31
  esac
31
32
  done
32
33
 
package/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // for listing sessions and reading session history from the JSONL store.
4
4
  import { spawn } from "child_process";
5
5
  import { randomUUID } from "crypto";
6
- import { readFile, readdir, stat, writeFile, mkdir } from "fs/promises";
6
+ import { readFile, readdir, stat, writeFile, mkdir, realpath as fsRealpath } from "fs/promises";
7
7
  import { readFileSync, existsSync } from "fs";
8
8
  import { StringDecoder } from "string_decoder";
9
9
  import express from "express";
@@ -213,14 +213,14 @@ class PiAgent {
213
213
  maybeScheduleIdleKill() {
214
214
  // Only arm the reclamation timer when: no client attached AND truly idle.
215
215
  // A background task that is still running must never be killed here.
216
- if (this.hasWs || this.isBusy) return;
216
+ if (!this.alive || this.hasWs || this.isBusy) return;
217
217
  if (IDLE_TIMEOUT_MS === 0) return; // disabled
218
218
  this.cancelIdleKill();
219
219
  const ms = IDLE_TIMEOUT_MS;
220
220
  this.idleTimer = setTimeout(() => {
221
221
  this.idleTimer = null;
222
222
  // re-check at fire time — a reconnect or new task may have started.
223
- if (this.hasWs || this.isBusy) return;
223
+ if (!this.alive || this.hasWs || this.isBusy) return;
224
224
  console.log(`Reclaiming truly-idle pi agent after ${Math.round(ms / 1000)}s (key=${this.sessionKey || "unkeyed"})`);
225
225
  if (IDLE_DROP_HEAP) {
226
226
  try { if (typeof global.gc === "function") global.gc(); } catch {}
@@ -271,6 +271,10 @@ class PiAgent {
271
271
  if (this.sessionKey) activeAgents.delete(this.sessionKey);
272
272
  this.cancelIdleKill();
273
273
  if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
274
+ for (const [id, resolve] of this.pending) {
275
+ resolve({ type: "response", id, success: false, error: err.message });
276
+ }
277
+ this.pending.clear();
274
278
  console.error(`[pi spawn error]`, err);
275
279
  this.wsSend({ type: "pi_exit", error: err.message });
276
280
  for (const s of this.sockets) { try { s.close(); } catch {} }
@@ -291,6 +295,10 @@ class PiAgent {
291
295
  console.log(`pi exited (code=${code})`);
292
296
  this.wsSend({ type: "pi_exit", code });
293
297
  if (this.sessionKey) activeAgents.delete(this.sessionKey);
298
+ for (const [id, resolve] of this.pending) {
299
+ resolve({ type: "response", id, success: false, error: "pi exited" });
300
+ }
301
+ this.pending.clear();
294
302
  for (const s of this.sockets) { try { s.close(); } catch {} }
295
303
  this.sockets.clear();
296
304
  this.cancelIdleKill();
@@ -326,6 +334,10 @@ class PiAgent {
326
334
  this.setStreaming(false);
327
335
  this.eventBuffer = [];
328
336
  this.bufferHead = 0;
337
+ // If unkeyed, actively query state from pi so sessionKey is recorded
338
+ if (!this.sessionKey) {
339
+ this.send({ type: "get_state" });
340
+ }
329
341
  break;
330
342
  case "pi_exit": this.state = "idle"; break;
331
343
  }
@@ -386,7 +398,22 @@ class PiAgent {
386
398
  }
387
399
  const id = String(++this.reqId);
388
400
  const payload = { ...cmd, id };
389
- this.pending.set(id, resolve);
401
+ // Long-running commands (prompt/steer) are tracked for their whole
402
+ // lifetime: pi's response may legitimately take many minutes, and the
403
+ // pending entry doubles as the isBusy guard that protects a working
404
+ // agent from idle-kill. Fire-and-forget queries (get_state etc.) keep a
405
+ // safety timeout so a dropped response doesn't leak the promise.
406
+ const longRunning = cmd.type === "prompt" || cmd.type === "steer";
407
+ let timeoutId = null;
408
+ const settled = { done: false };
409
+ const complete = (obj) => {
410
+ if (settled.done) return;
411
+ settled.done = true;
412
+ this.pending.delete(id);
413
+ if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
414
+ resolve(obj);
415
+ };
416
+ this.pending.set(id, complete);
390
417
  this.markActivity();
391
418
  try {
392
419
  this.proc.stdin.write(JSON.stringify(payload) + "\n");
@@ -394,14 +421,14 @@ class PiAgent {
394
421
  this.pending.delete(id);
395
422
  return resolve({ type: "response", id, success: false, error: err.message });
396
423
  }
397
- // Safety: timeout so a dropped response doesn't leak the promise.
398
- setTimeout(() => {
399
- if (this.pending.has(id)) {
400
- this.pending.delete(id);
401
- this.markActivity();
402
- resolve({ type: "response", id, success: false, error: "timeout" });
403
- }
404
- }, 60000);
424
+ if (!longRunning) {
425
+ timeoutId = setTimeout(() => {
426
+ if (this.pending.has(id)) {
427
+ this.pending.delete(id);
428
+ resolve({ type: "response", id, success: false, error: "timeout" });
429
+ }
430
+ }, 60000);
431
+ }
405
432
  });
406
433
  }
407
434
 
@@ -459,6 +486,10 @@ class PiAgent {
459
486
  if (this.sessionKey) {
460
487
  activeAgents.delete(this.sessionKey);
461
488
  }
489
+ for (const [id, resolve] of this.pending) {
490
+ resolve({ type: "response", id, success: false, error: "pi process stopped" });
491
+ }
492
+ this.pending.clear();
462
493
  for (const s of this.sockets) { try { s.close(); } catch {} }
463
494
  this.sockets.clear();
464
495
  try { this.proc && this.proc.kill("SIGTERM"); } catch {}
@@ -727,13 +758,34 @@ app.get("/api/session", async (req, res) => {
727
758
  const file = req.query.file;
728
759
  if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
729
760
 
730
- // Security check: ensure the file path is within SESSIONS_DIR
731
- let resolvedFile = normalizePath(file);
761
+ // Security check: ensure the file path is within SESSIONS_DIR. We resolve the
762
+ // canonical (real) path rather than just the lexical one, otherwise a
763
+ // symlink placed inside SESSIONS_DIR could point outside and let a caller
764
+ // read arbitrary .jsonl files via the traversal check above.
732
765
  const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
733
- const relPath = path.relative(resolvedSessionsDir, resolvedFile);
766
+ const requestedPath = normalizePath(file);
767
+ const relPath = path.relative(resolvedSessionsDir, requestedPath);
734
768
  if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
735
769
  return res.status(403).json({ error: "Access denied" });
736
770
  }
771
+ // Canonicalize both target file and sessions directory to verify the real
772
+ // target still lives under the real SESSIONS_DIR even when symlinks exist.
773
+ let resolvedFile;
774
+ try {
775
+ resolvedFile = await fsRealpath(requestedPath);
776
+ } catch {
777
+ // Fall back to the lexical path if realpath fails (e.g. missing file);
778
+ // the read below will surface the actual error.
779
+ resolvedFile = requestedPath;
780
+ }
781
+ let canonicalSessionsDir = resolvedSessionsDir;
782
+ try {
783
+ canonicalSessionsDir = await fsRealpath(resolvedSessionsDir);
784
+ } catch {}
785
+ const realRel = path.relative(canonicalSessionsDir, resolvedFile);
786
+ if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
787
+ return res.status(403).json({ error: "Access denied" });
788
+ }
737
789
 
738
790
  const content = await readFile(resolvedFile, "utf8");
739
791
  const lines = content.split("\n").filter(Boolean);
@@ -751,11 +803,6 @@ app.get("/api/session", async (req, res) => {
751
803
  // Build a map and reconstruct the active path from root -> leaf.
752
804
  const byId = new Map();
753
805
  for (const e of entries) if (e.id) byId.set(e.id, e);
754
- let leaf = null;
755
- for (const e of entries) {
756
- // a leaf is one that nobody else has as parentId (and isn't a non-message like header)
757
- if (e.type === "message" || e.type === "message_summary") leaf = e.id;
758
- }
759
806
  // find true leaf = last entry with no children
760
807
  const childCount = new Map();
761
808
  for (const e of entries) {
@@ -905,7 +952,8 @@ wss.on("connection", (ws, req) => {
905
952
  agent.send({ type: "new_session" });
906
953
  break;
907
954
  case "switch_session":
908
- if (msg.sessionPath) agent.setSessionKey(cwd, msg.sessionPath);
955
+ if (!msg.sessionPath) break; // refuse malformed request; never forward an undefined path to pi
956
+ agent.setSessionKey(cwd, msg.sessionPath);
909
957
  agent.send({ type: "switch_session", sessionPath: msg.sessionPath });
910
958
  break;
911
959
  case "steer":