@liguoshuai/pi-web-chat 1.8.3 → 1.8.5

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.
@@ -1,6 +1,6 @@
1
1
  # 架构设计文档
2
2
 
3
- > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.8.3 版本)。
3
+ > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.8.5 版本)。
4
4
 
5
5
  ---
6
6
 
@@ -176,5 +176,6 @@ pi-web-chat/
176
176
  │ └── CHANGELOG.md # 版本变更日志
177
177
  └── scripts/ # 服务安装/卸载脚本
178
178
  ├── install-service.sh
179
+ ├── uninstall-service.sh
179
180
  └── pi-web-chat.service
180
181
  ```
package/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.8.5] - 2026-08-23
11
+
12
+ ### Fixed
13
+ - **中止/终止生成按钮逻辑修复 (Abort Button Logic & UX Fix)**:
14
+ - 修复生成过程中点击终止按钮(`■`)时,因输入框为空触发提前返回(`if (!text) return`)导致无法发出 `abort` 信号的问题。
15
+ - 抽离独立的 `abortGeneration()` 统一处理中断生成流程,确保点击停止按钮无条件触发任务终止。
16
+ - 修复输入框有草稿时点击停止按钮误触发 `steer`(插入指令)的问题,明确区分停止按钮与插入指令按钮职责。
17
+ - 新增中止中即时视觉反馈(按钮变为等待状态 `⏳`,输入框边缘高亮变红,提示文字变更为“中止当前任务中…”),并在底层 `agent_settled` 或 `abort` 响应后安全恢复。
18
+ - 支持在生成过程中按 `Escape` 键快速中止当前任务(模态框或菜单打开时除外)。
19
+ - 在新建会话及切换会话时,若当前任务正在运行中,自动先行中断当前流式任务以避免后台状态错乱。
20
+
21
+ ---
22
+
23
+ ## [1.8.4] - 2026-07-26
24
+
25
+ ### Added
26
+ - **Markdown 渲染能力增强 (Markdown Enhancements: HR, Strikethrough & Task Checkboxes)**:
27
+ - 支持标准 Markdown 水平分割线(`---`、`***`、`___`)渲染为 `<hr>` 标签。
28
+ - 支持删除线语法(`~~strikethrough~~`)渲染为 `<del>` 标签。
29
+ - 支持任务列表复选框(`- [ ]` 与 `- [x]`)及加号无序列表(`+ item`)。
30
+ - 为 `h4`、`h5`、`h6`、`hr`、`del` 及任务复选框补充了精细的暗色主题 CSS 样式。
31
+
32
+ ### Fixed
33
+ - **移动端息屏/切出恢复后历史消息自动同步 (Mobile Reconnect Session Auto-Sync)**:
34
+ - 修复手机端在后台挂起或息屏后切回页面时,未能及时同步后台已生成完毕的最新回复内容的问题。
35
+ - 增加 `syncSessionHistory` 历史同步机制,并在 `ws.onopen`、`visibilitychange` 与 `pageshow` 事件中自动拉取磁盘最新消息渲染至对话区。
36
+ - 优化 `backfill_start` / `backfill_end` 流式状态重置,保障后台生成途中切回时无缝追平实时生成内容。
37
+ - **未绑定会话孤儿子进程即时回收 (Immediate Cleanup of Unkeyed Idle Agents)**:当客户端打开新建会话页面但未发送消息即关闭或切换时,后端未分配 Session Key 的孤立 `PiAgent` 进程在连接断开后立即释放,避免占用 5 分钟闲置内存。
38
+ - **切换与加载历史会话时工作目录自动同步 (Session CWD Auto-Sync)**:点击侧边栏或通过 URL 参数(`?session=...`)打开会话时,自动将 `state.cwd` 同步为会话真实的 `data.header.cwd`,确保 Agent 执行环境与会话一致。
39
+ - **工作目录变更后项目级默认配置实时重载 (Project Settings Live Reload on CWD Change)**:切换工作目录时重新获取 `/api/config?cwd=...`,确保项目根目录下的 `.pi/settings.json` 模型配置即时生效。
40
+ - **波浪号路径解析规范化 (Tilde `~` Path Resolution Normalization)**:服务端引入统一的 `normalizePath`,确保会话参数与路径校验中包含的 `~` 家目录前缀能被正确展开并绝对化。
41
+ - **历史错误消息展示保护 (Error Notice on Historical Failed Messages)**:修复历史会话中若生成中途报错中断时未能渲染失败提示条的问题。
42
+ - **Package.json 发布字段补充 (Scripts Directory Inclusion)**:在 `package.json` 的 `files` 字段中加入 `scripts/` 目录,确保 Systemd 服务脚本正常打包发布。
43
+
44
+ ---
45
+
10
46
  ## [1.8.3] - 2026-07-26
11
47
 
12
48
  ### Fixed
package/docs/ISSUES.md CHANGED
@@ -190,6 +190,19 @@
190
190
 
191
191
  ---
192
192
 
193
+ ## 18. 移动端顶栏空间挤压与思考胶囊截断
194
+
195
+ **症状**:在手机端竖屏打开页面时,顶栏右侧的“深度思考 / 推理级别”(`🧠 High`)胶囊被挤压,只能看到一半的脑花图标,文本完全不可见。
196
+
197
+ **根因**:移动端媒体查询中为 `.model-pill-container` 设置了 `max-width: 130px` 的硬限制。由于模型胶囊占据了大部分宽度,容器剩余宽度不足以完整容纳思考胶囊,导致其被裁剪。
198
+
199
+ **修复**:
200
+ - 移除移动端 `.model-pill-container` 的 `max-width: 130px` 限制,改为弹性无约束容器。
201
+ - 优化顶栏各按钮尺寸与间距:缩小菜单按钮为 36px,工作目录胶囊限制最大 75px,模型胶囊适度缩略模型名并隐藏手机端冗余的“默认”徽章,为思考胶囊留出充足显示空间。
202
+ - 追加 `<= 360px` 极窄屏响应式规则,确保在 iPhone SE 等小屏设备上也能完整显示。
203
+
204
+ ---
205
+
193
206
  ## 19. 移动端切换工作目录弹窗按钮超出边框
194
207
 
195
208
  **症状**:在手机端点击顶栏切换工作目录(CWD Modal)时,右侧的“确定切换”按钮超出了弹窗白色/暗色卡片右边框。
@@ -203,14 +216,53 @@
203
216
  - 在移动端媒体查询中调整弹窗内边距为 16px,优化输入框与按钮的 padding。
204
217
  - 在 `<= 380px` 超窄屏设备上自适应将输入框与操作按钮上下垂直排列(`flex-direction: column`),确保彻底杜绝横向溢出。
205
218
 
206
- **症状**:在手机端竖屏打开页面时,顶栏右侧的“深度思考 / 推理级别”(`🧠 High`)胶囊被挤压,只能看到一半的脑花图标,文本完全不可见。
219
+ ---
207
220
 
208
- **根因**:移动端媒体查询中为 `.model-pill-container` 设置了 `max-width: 130px` 的硬限制。由于模型胶囊占据了大部分宽度,容器剩余宽度不足以完整容纳思考胶囊,导致其被裁剪。
221
+ ## 20. 未绑定会话孤儿子进程即时回收
222
+
223
+ **症状**:客户端打开新建会话页面但未发送任何 Prompt 就关闭页面或切换至其他会话时,后端生成的未分配 Session Key 的孤立 `PiAgent` 子进程会持续占用内存 5 分钟后才被回收。
224
+
225
+ **根因**:原先 `detachWs` 统一对所有断连进程开启 5 分钟 `IDLE_TIMEOUT_MS` 倒计时。然而未绑定会话 Key 的进程由于没有 Session Key,后续没有任何客户端可以重新挂载接入该进程。
226
+
227
+ **修复**:在 `detachWs` 中增加判断,若 Agent 未分配 Session Key(`!this.sessionKey`)且处于完全空闲状态(`!this.isBusy`),则在最后一个连接断开时立即调用 `stop()` 释放进程资源,避免无用进程驻留内存。
228
+
229
+ ---
230
+
231
+ ## 21. 切换与直接加载历史会话时工作目录 (CWD) 自动同步
232
+
233
+ **症状**:通过 URL 参数(如 `?session=...`)或点击侧边栏加载属于其他工作目录的历史会话时,前端工作目录(CWD)未能联动更新,导致后续发送消息时 Agent 在错误的工作目录下执行命令与操作文件。
234
+
235
+ **根因**:`loadSession` 在从 `/api/session` 接口获取会话详情后,仅解析了 `model` 和消息历史,未读取 `header.cwd` 同步更新 `state.cwd`。
236
+
237
+ **修复**:在 `loadSession` 中自动比对并应用 `data.header.cwd`,同步更新 `state.cwd`、`localStorage` 及顶栏工作目录显示,确保后续 WebSocket 连接与 Agent 子进程的工作目录与会话原始目录精确一致。
238
+
239
+ ---
240
+
241
+ ## 22. 波浪号 (`~`) 路径解析规范化与 Markdown 渲染增强
242
+
243
+ **症状**:
244
+ 1. 若 WebSocket 会话参数或切换会话传入带有 `~` 的路径,Node 原生 `path.resolve` 无法自动展开用户家目录,导致路径解析不一致。
245
+ 2. 模型输出的 Markdown 水平分割线(`---` / `***`)、删除线(`~~strikethrough~~`)、任务复选框(`- [ ]` / `- [x]`)未能正确渲染。
209
246
 
210
247
  **修复**:
211
- - 移除移动端 `.model-pill-container` `max-width: 130px` 限制,改为弹性无约束容器。
212
- - 优化顶栏各按钮尺寸与间距:缩小菜单按钮为 36px,工作目录胶囊限制最大 75px,模型胶囊适度缩略模型名并隐藏手机端冗余的“默认”徽章,为思考胶囊留出充足显示空间。
213
- - 追加 `<= 360px` 极窄屏响应式规则,确保在 iPhone SE 等小屏设备上也能完整显示。
248
+ - 服务端增加 `normalizePath` 统一处理 `~` 家目录展开与绝对路径规范化。
249
+ - 前端 Markdown 渲染器增加对水平分割线 `<hr>`、删除线 `<del>`、任务列表复选框以及加号无序列表(`+ item`)的完整语法支持与对应样式。
250
+
251
+ ---
252
+
253
+ ## 23. 移动端切出/息屏恢复后会话未同步后台最新对话内容
254
+
255
+ **症状**:在手机端发送消息或查看对话时,若切出应用或息屏锁定,等待一段时间后再返回页面(WebSocket 提示“网络连接已恢复”),但聊天窗口中仍停留在息屏前的旧状态,未能展示后台已生成完毕的完整回复。
256
+
257
+ **根因**:
258
+ 1. 手机端在息屏或后台挂起时,浏览器暂停 JS 执行并断开 WebSocket。
259
+ 2. 任务在服务端后台运行完毕后,pi 将完整消息写入磁盘 `.jsonl` 并触发 `agent_settled`,随后服务端清空了针对该轮次的事件环形 Buffer。
260
+ 3. 手机端唤醒并重新建立 WebSocket 连接后,前端仅请求了 `get_state`,未主动从服务端 `/api/session` 重新拉取最新的会话历史消息,导致 DOM 中残留断线前的未完成状态。
261
+
262
+ **修复**:
263
+ - 抽取通用的 `syncSessionHistory(file)` 历史同步机制。
264
+ - 在 `ws.onopen`(重连成功时)、`visibilitychange`(页面可见性恢复时)以及 `pageshow`(移动端切回页面时)自动触发 `syncSessionHistory`,重新拉取并渲染磁盘最新消息。
265
+ - 完善 `backfill_start` / `backfill_end` 流式状态重置,确保如果在后台仍在流式生成途中切回,能清空旧数据并无缝追平实时流式内容。
214
266
 
215
267
 
216
268
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.8.3",
3
+ "version": "1.8.5",
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",
@@ -12,6 +12,7 @@
12
12
  "bin/",
13
13
  "public/",
14
14
  "docs/",
15
+ "scripts/",
15
16
  "README.md",
16
17
  "LICENSE"
17
18
  ],
package/public/app.js CHANGED
@@ -42,6 +42,7 @@ const state = {
42
42
  thinkingLevel: "medium",
43
43
  sessionId: null,
44
44
  isBackfilling: false,
45
+ aborting: false,
45
46
  };
46
47
 
47
48
  let toastTimer = null;
@@ -71,9 +72,10 @@ function updateCwdDisplay() {
71
72
  if (wrap) wrap.title = `工作目录: ${state.cwd || "~"}`;
72
73
  }
73
74
 
74
- function setCwd(newCwd) {
75
+ async function setCwd(newCwd) {
75
76
  state.cwd = newCwd;
76
77
  localStorage.setItem("pi_cwd", newCwd);
78
+ await loadServerConfig();
77
79
  updateCwdDisplay();
78
80
  clearChat();
79
81
  showEmptyState(true);
@@ -112,12 +114,18 @@ function saveRecentModel(model) {
112
114
  }
113
115
 
114
116
  async function loadServerConfig() {
117
+ if (!state.cwd) {
118
+ state.cwd = localStorage.getItem("pi_cwd") || document.body.dataset.cwd || "";
119
+ }
115
120
  try {
116
121
  const cwdParam = state.cwd ? `?cwd=${encodeURIComponent(state.cwd)}` : "";
117
122
  const res = await fetch(`${API}/api/config${cwdParam}`);
118
123
  const data = await res.json();
119
124
  if (data.home) state.homeDir = data.home;
120
125
  if (data.serverCwd) state.serverCwd = data.serverCwd;
126
+ if (!state.cwd) {
127
+ state.cwd = state.serverCwd || state.homeDir || "";
128
+ }
121
129
  if (data.version) {
122
130
  state.version = data.version;
123
131
  const verEl = $("#appVersion");
@@ -131,7 +139,7 @@ async function loadServerConfig() {
131
139
  }
132
140
  } catch {}
133
141
  if (!state.cwd) {
134
- state.cwd = localStorage.getItem("pi_cwd") || state.serverCwd || state.homeDir || "";
142
+ state.cwd = state.serverCwd || state.homeDir || "";
135
143
  }
136
144
  loadRecentModels();
137
145
  updateCwdDisplay();
@@ -358,9 +366,14 @@ function renderInlineMd(text) {
358
366
  flushList();
359
367
  outHtml += mdTable(seg.lines);
360
368
  } else if (seg.kind === "line") {
361
- // headings
362
- const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
363
- if (m) {
369
+ // horizontal rule
370
+ if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
371
+ flushPara();
372
+ flushList();
373
+ outHtml += "<hr>";
374
+ } else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
375
+ // headings
376
+ const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
364
377
  flushPara();
365
378
  flushList();
366
379
  const level = m[1].length;
@@ -373,7 +386,7 @@ function renderInlineMd(text) {
373
386
  flushPara();
374
387
  flushList();
375
388
  outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
376
- } else if (/^\s*[-*]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
389
+ } else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
377
390
  // list item — group consecutive into ul/ol
378
391
  // simple inline handling: wrap each list item line.
379
392
  const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
@@ -384,7 +397,16 @@ function renderInlineMd(text) {
384
397
  linkListOrdered = isOrdered;
385
398
  outHtml += "<" + linkListOpen + ">";
386
399
  }
387
- outHtml += `<li>${mdInlineBlock(seg.text.replace(/^\s*([-*]|\d+\.)\s+/, ""))}</li>`;
400
+ let itemText = seg.text.replace(/^\s*([-*+]|\d+\.)\s+/, "");
401
+ let taskPrefix = "";
402
+ if (/^\[ \]\s+/.test(itemText)) {
403
+ taskPrefix = '<input type="checkbox" disabled class="task-list-item-checkbox"> ';
404
+ itemText = itemText.replace(/^\[ \]\s+/, "");
405
+ } else if (/^\[[xX]\]\s+/.test(itemText)) {
406
+ taskPrefix = '<input type="checkbox" checked disabled class="task-list-item-checkbox"> ';
407
+ itemText = itemText.replace(/^\[[xX]\]\s+/, "");
408
+ }
409
+ outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
388
410
  } else {
389
411
  flushList();
390
412
  para.push(seg.text);
@@ -405,6 +427,8 @@ function mdInlineBlock(text) {
405
427
  // bold
406
428
  s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
407
429
  s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
430
+ // strikethrough
431
+ s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
408
432
  // italic
409
433
  s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
410
434
  s = s.replace(/(^|[^_])_([^_]+)_/g, "$1<em>$2</em>");
@@ -514,9 +538,51 @@ function initMobileToolbarFab() {
514
538
  });
515
539
  }
516
540
 
541
+ async function syncSessionHistory(file, force = false) {
542
+ if (!file) return;
543
+ try {
544
+ const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
545
+ if (!res.ok) return;
546
+ const data = await res.json();
547
+ if (!data || data.error) return;
548
+
549
+ if (data.header?.cwd && data.header.cwd !== state.cwd) {
550
+ state.cwd = data.header.cwd;
551
+ localStorage.setItem("pi_cwd", state.cwd);
552
+ updateCwdDisplay();
553
+ }
554
+
555
+ if (data.model) {
556
+ state.currentModel = data.model;
557
+ renderModelPill();
558
+ }
559
+
560
+ const topName = data.sessionName || (data.header?.id ? baseName(file) : "新对话");
561
+ $("#topSessionName").textContent = topName;
562
+
563
+ // Only overwrite chat if force is true or we're not actively streaming mid-turn live
564
+ if (force || !state.streaming) {
565
+ clearChat();
566
+ const msgs = reconstructFromEntries(data.entries || []);
567
+ showEmptyState(msgs.length === 0);
568
+ for (const m of msgs) {
569
+ appendMessageNode(m.role, m);
570
+ }
571
+ scrollBottom();
572
+ refreshSessions();
573
+ }
574
+ } catch (e) {
575
+ console.warn("syncSessionHistory error:", e);
576
+ }
577
+ }
578
+
517
579
  async function loadSession(file) {
580
+ if (state.streaming) {
581
+ abortGeneration();
582
+ }
518
583
  state.currentSessionFile = file;
519
584
  state.streaming = false;
585
+ state.aborting = false;
520
586
  state.streamingItems = [];
521
587
  state.streamingMsg = null;
522
588
  state.activeToolCalls.clear();
@@ -529,38 +595,9 @@ async function loadSession(file) {
529
595
  if (window.innerWidth <= 768) {
530
596
  closeSidebar();
531
597
  }
532
- // pull transcript from REST then connect a fresh WS pointed at this session
533
- const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
534
- const data = await res.json();
535
-
536
- if (!res.ok || data.error) {
537
- showToast(`加载会话失败: ${data.error || "无法读取会话文件"}`);
538
- showEmptyState(true);
539
- state.currentSessionFile = null;
540
- return;
541
- }
542
-
543
- if (data.model) {
544
- state.currentModel = data.model;
545
- renderModelPill();
546
- }
547
-
548
- const topName = data.sessionName || (data.header?.id ? baseName(file) : "新对话");
549
- $("#topSessionName").textContent = topName;
550
-
551
- clearChat();
552
- document.querySelector("#emptyState").style.display = "none";
553
- const chat = $("#chat-inner");
554
- // Walk through path entries to render messages in order.
555
- // We reconstruct assistant/user/toolResult blocks.
556
- const msgs = reconstructFromEntries(data.entries || []);
557
- for (const m of msgs) {
558
- appendMessageNode(m.role, m);
559
- }
598
+ await syncSessionHistory(file, true);
560
599
  // Reconnect websocket pointed at this session so new prompts continue history.
561
600
  connectWs({ session: file });
562
- // Update sidebar active highlight
563
- refreshSessions();
564
601
  }
565
602
 
566
603
  function reconstructFromEntries(entries) {
@@ -592,7 +629,7 @@ function reconstructFromEntries(entries) {
592
629
  }
593
630
  return part;
594
631
  });
595
- if (m.stopReason === "error" && content.length === 0) {
632
+ if (m.stopReason === "error") {
596
633
  let errMsg = m.errorMessage || "生成失败(模型返回错误)";
597
634
  try {
598
635
  const parsed = JSON.parse(errMsg);
@@ -613,7 +650,8 @@ function extractContentText(content) {
613
650
  return content
614
651
  .filter(c => c && (c.type === "text" || typeof c === "string"))
615
652
  .map(c => typeof c === "string" ? c : (c.text || ""))
616
- .join("");
653
+ .filter(Boolean)
654
+ .join("\n\n");
617
655
  }
618
656
 
619
657
  function appendSystemNotice(text) {
@@ -846,14 +884,22 @@ function makeToolBlockFromCall(call) {
846
884
 
847
885
  function summaryArgs(name, args) {
848
886
  if (!args) return "";
887
+ let obj = args;
888
+ if (typeof args === "string") {
889
+ try { obj = JSON.parse(args); } catch { return args; }
890
+ }
849
891
  try {
850
- if (name === "bash" && args.command) return args.command;
851
- if (name === "read" && args.path) return args.path;
852
- if (name === "write" && args.path) return args.path;
853
- if (name === "edit" && args.path) return args.path;
854
- if (name === "ls" && args.path) return args.path;
855
- if (name === "grep") return args.pattern || "";
856
- if (name === "find") return args.pattern || args.path || "";
892
+ if (name === "bash" && obj.command) return obj.command;
893
+ if (name === "read" && obj.path) return obj.path;
894
+ if (name === "write" && obj.path) return obj.path;
895
+ if (name === "edit" && obj.path) return obj.path;
896
+ if (name === "ls" && obj.path) return obj.path;
897
+ if (name === "grep") return obj.pattern || "";
898
+ if (name === "find") return obj.pattern || obj.path || "";
899
+ if (typeof obj === "object" && obj !== null) {
900
+ const keys = Object.keys(obj);
901
+ if (keys.length === 1 && typeof obj[keys[0]] === "string") return obj[keys[0]];
902
+ }
857
903
  return "";
858
904
  } catch { return ""; }
859
905
  }
@@ -1121,14 +1167,18 @@ function connectWs(opts = {}) {
1121
1167
  state.ws = ws;
1122
1168
  ws._gen = myGen;
1123
1169
 
1124
- ws.onopen = () => {
1170
+ ws.onopen = async () => {
1125
1171
  if (ws._gen !== wsGen) return;
1126
1172
  isConnecting = false;
1127
1173
  setConnStatus("connected");
1128
1174
  startPingInterval();
1129
1175
 
1130
- if (wasDisconnected || reconnectAttempts > 0) {
1176
+ const isReconnecting = wasDisconnected || reconnectAttempts > 0 || opts.isReconnect;
1177
+ if (isReconnecting) {
1131
1178
  showToast("网络连接已恢复");
1179
+ if (targetSession) {
1180
+ await syncSessionHistory(targetSession, true);
1181
+ }
1132
1182
  }
1133
1183
  wasDisconnected = false;
1134
1184
  reconnectAttempts = 0;
@@ -1189,6 +1239,11 @@ function handlePiMessage(obj) {
1189
1239
  // that happened in the background while no browser was attached.
1190
1240
  if (obj.type === "backfill_start") {
1191
1241
  state.isBackfilling = true;
1242
+ state.streaming = true;
1243
+ setComposerAborting(true);
1244
+ state.streamingItems = [];
1245
+ state.streamingMsg = null;
1246
+ state.activeToolCalls.clear();
1192
1247
  return;
1193
1248
  }
1194
1249
  if (obj.type === "backfill_end") {
@@ -1199,6 +1254,11 @@ function handlePiMessage(obj) {
1199
1254
  setComposerAborting(true);
1200
1255
  ensureStreamingMsg();
1201
1256
  refreshStreamingContent();
1257
+ } else {
1258
+ finalizeStreamingMsg();
1259
+ state.streaming = false;
1260
+ setComposerAborting(false);
1261
+ refreshSessions();
1202
1262
  }
1203
1263
  // jump to the latest content once the replay is done
1204
1264
  requestAnimationFrame(scrollBottom);
@@ -1240,6 +1300,9 @@ function handlePiMessage(obj) {
1240
1300
  renderThinkingPill();
1241
1301
  updateEmptyStateModelInfo();
1242
1302
  }
1303
+ } else if (obj.command === "abort") {
1304
+ state.aborting = false;
1305
+ setComposerAborting(false);
1243
1306
  }
1244
1307
  else if (obj.command === "switch_session" && obj.success) {
1245
1308
  // ask pi for current state so we can get session id, name
@@ -1274,6 +1337,7 @@ function handlePiMessage(obj) {
1274
1337
  break;
1275
1338
  case "agent_start":
1276
1339
  state.streaming = true;
1340
+ state.aborting = false;
1277
1341
  setComposerAborting(true);
1278
1342
  ensureStreamingMsg();
1279
1343
  refreshStreamingContent();
@@ -1285,6 +1349,7 @@ function handlePiMessage(obj) {
1285
1349
  case "agent_settled":
1286
1350
  finalizeStreamingMsg();
1287
1351
  state.streaming = false;
1352
+ state.aborting = false;
1288
1353
  setComposerAborting(false);
1289
1354
  sendWs({ type: "get_state" });
1290
1355
  refreshSessions(); // titles may have changed
@@ -1389,8 +1454,7 @@ function handlePiMessage(obj) {
1389
1454
  case "tool_execution_update": {
1390
1455
  const tc = state.activeToolCalls.get(obj.toolCallId);
1391
1456
  if (tc) {
1392
- const pr = obj.partialResult;
1393
- const text = pr && pr.content ? (Array.isArray(pr.content) ? pr.content.map(c => c.text || "").join("") : "") : "";
1457
+ const text = extractContentText(obj.partialResult?.content);
1394
1458
  tc.body.innerHTML = escapeHtml(text) || "(执行中…)";
1395
1459
  }
1396
1460
  break;
@@ -1398,8 +1462,7 @@ function handlePiMessage(obj) {
1398
1462
  case "tool_execution_end": {
1399
1463
  const tc = state.activeToolCalls.get(obj.toolCallId);
1400
1464
  if (tc) {
1401
- const res = obj.result;
1402
- const text = res && res.content ? (Array.isArray(res.content) ? res.content.map(c => c.text || "").join("") : "") : "";
1465
+ const text = extractContentText(obj.result?.content);
1403
1466
  tc.body.innerHTML = escapeHtml(text) || "(无输出)";
1404
1467
  tc.head.querySelector(".state").textContent = obj.isError ? "错误" : "完成";
1405
1468
  tc.head.querySelector(".state").classList.toggle("error", !!obj.isError);
@@ -1412,6 +1475,7 @@ function handlePiMessage(obj) {
1412
1475
  case "pi_exit":
1413
1476
  finalizeStreamingMsg();
1414
1477
  state.streaming = false;
1478
+ state.aborting = false;
1415
1479
  setComposerAborting(false);
1416
1480
  $("#connDot").style.color = "var(--danger)";
1417
1481
  break;
@@ -1507,8 +1571,13 @@ function updateState(d) {
1507
1571
  } else if (state.streaming) {
1508
1572
  finalizeStreamingMsg();
1509
1573
  state.streaming = false;
1574
+ state.aborting = false;
1510
1575
  setComposerAborting(false);
1511
- refreshSessions();
1576
+ if (state.currentSessionFile) {
1577
+ syncSessionHistory(state.currentSessionFile, true);
1578
+ } else {
1579
+ refreshSessions();
1580
+ }
1512
1581
  }
1513
1582
  }
1514
1583
  }
@@ -2007,8 +2076,24 @@ function updateComposerUI() {
2007
2076
  const text = ta ? ta.value.trim() : "";
2008
2077
  const sendBtn = $("#sendBtn");
2009
2078
  const steerBtn = $("#steerBtn");
2079
+ const inner = $("#composerInner");
2010
2080
 
2011
- if (state.streaming) {
2081
+ if (inner) {
2082
+ inner.classList.toggle("aborting", !!state.aborting);
2083
+ }
2084
+
2085
+ if (state.aborting) {
2086
+ if (sendBtn) {
2087
+ sendBtn.classList.add("stop");
2088
+ sendBtn.disabled = true;
2089
+ sendBtn.textContent = "⏳";
2090
+ sendBtn.title = "中止中…";
2091
+ }
2092
+ if (steerBtn) {
2093
+ steerBtn.style.display = "none";
2094
+ }
2095
+ if (ta) ta.placeholder = "正在中止当前任务…";
2096
+ } else if (state.streaming) {
2012
2097
  if (sendBtn) {
2013
2098
  sendBtn.classList.add("stop");
2014
2099
  sendBtn.disabled = !state.wsConnected;
@@ -2039,10 +2124,34 @@ function updateComposerUI() {
2039
2124
  }
2040
2125
 
2041
2126
  function setComposerAborting(yes) {
2127
+ if (!yes) {
2128
+ state.aborting = false;
2129
+ const inner = $("#composerInner");
2130
+ if (inner) inner.classList.remove("aborting");
2131
+ const hint = $(".composer-hint");
2132
+ if (hint && hint.textContent.includes("中止")) {
2133
+ hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。";
2134
+ }
2135
+ }
2042
2136
  updateComposerUI();
2043
2137
  renderModelPill();
2044
2138
  }
2045
2139
 
2140
+ function abortGeneration() {
2141
+ if (!state.streaming && !state.aborting) return;
2142
+ if (!state.wsConnected) {
2143
+ const hint = $(".composer-hint");
2144
+ if (hint) hint.textContent = "操作失败:WebSocket 未连接。正在尝试重连…";
2145
+ scheduleReconnect(0);
2146
+ return;
2147
+ }
2148
+ state.aborting = true;
2149
+ updateComposerUI();
2150
+ const hint = $(".composer-hint");
2151
+ if (hint) hint.textContent = "中止当前任务中…";
2152
+ sendWs({ type: "abort" });
2153
+ }
2154
+
2046
2155
  function submitSteer() {
2047
2156
  const ta = $("#composer");
2048
2157
  const text = ta.value.trim();
@@ -2069,8 +2178,19 @@ function submitSteer() {
2069
2178
  }
2070
2179
 
2071
2180
  function submitPrompt() {
2181
+ if (state.streaming) {
2182
+ const ta = $("#composer");
2183
+ const text = ta ? ta.value.trim() : "";
2184
+ if (text) {
2185
+ submitSteer();
2186
+ } else {
2187
+ abortGeneration();
2188
+ }
2189
+ return;
2190
+ }
2191
+
2072
2192
  const ta = $("#composer");
2073
- const text = ta.value.trim();
2193
+ const text = ta ? ta.value.trim() : "";
2074
2194
  const hint = $(".composer-hint");
2075
2195
  if (!text) return;
2076
2196
  if (!state.wsConnected) {
@@ -2080,16 +2200,7 @@ function submitPrompt() {
2080
2200
  if (box) { box.style.boxShadow = "0 0 0 2px var(--danger)"; setTimeout(() => { box.style.boxShadow = ""; }, 350); }
2081
2201
  return;
2082
2202
  }
2083
- if (state.streaming) {
2084
- if (text) {
2085
- submitSteer();
2086
- return;
2087
- }
2088
- if (hint) hint.textContent = "中止当前生成中…";
2089
- sendWs({ type: "abort" });
2090
- return;
2091
- }
2092
-
2203
+
2093
2204
  if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
2094
2205
  // Render the user's message locally for instant feedback.
2095
2206
  appendMessageNode("user", { text });
@@ -2097,6 +2208,7 @@ function submitPrompt() {
2097
2208
  autoResize();
2098
2209
 
2099
2210
  state.streaming = true;
2211
+ state.aborting = false;
2100
2212
  setComposerAborting(true);
2101
2213
  ensureStreamingMsg();
2102
2214
  refreshStreamingContent();
@@ -2126,7 +2238,7 @@ async function init() {
2126
2238
  $("#btnNew").addEventListener("click", () => {
2127
2239
  if (state.streaming) {
2128
2240
  if (!confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
2129
- sendWs({ type: "abort" });
2241
+ abortGeneration();
2130
2242
  }
2131
2243
  clearChat();
2132
2244
  showEmptyState(true);
@@ -2143,12 +2255,7 @@ async function init() {
2143
2255
 
2144
2256
  $("#sendBtn").addEventListener("click", () => {
2145
2257
  if (state.streaming) {
2146
- const ta = $("#composer");
2147
- if (ta && ta.value.trim()) {
2148
- submitSteer();
2149
- } else {
2150
- submitPrompt();
2151
- }
2258
+ abortGeneration();
2152
2259
  } else {
2153
2260
  submitPrompt();
2154
2261
  }
@@ -2202,11 +2309,17 @@ async function init() {
2202
2309
  });
2203
2310
  }
2204
2311
 
2205
- // Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector
2312
+ // Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector, Escape to abort if streaming
2206
2313
  window.addEventListener("keydown", (e) => {
2207
2314
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
2208
2315
  e.preventDefault();
2209
2316
  toggleModelMenu();
2317
+ } else if (e.key === "Escape" && state.streaming) {
2318
+ const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
2319
+ const isModalOpen = $("#cwdModal")?.style.display === "flex";
2320
+ if (!isMenuOpen && !isModalOpen) {
2321
+ abortGeneration();
2322
+ }
2210
2323
  }
2211
2324
  });
2212
2325
 
@@ -2323,10 +2436,22 @@ async function init() {
2323
2436
  scheduleReconnect(0);
2324
2437
  } else {
2325
2438
  try { state.ws.send(JSON.stringify({ type: "ping" })); } catch {}
2439
+ if (state.currentSessionFile && !state.streaming) {
2440
+ syncSessionHistory(state.currentSessionFile, true);
2441
+ }
2326
2442
  }
2327
2443
  }
2328
2444
  });
2329
2445
 
2446
+ window.addEventListener("pageshow", () => {
2447
+ if (!state.wsConnected) {
2448
+ reconnectAttempts = 0;
2449
+ scheduleReconnect(0);
2450
+ } else if (state.currentSessionFile && !state.streaming) {
2451
+ syncSessionHistory(state.currentSessionFile, true);
2452
+ }
2453
+ });
2454
+
2330
2455
  // Global event delegation for code block copy buttons
2331
2456
  document.addEventListener("click", async (e) => {
2332
2457
  const btn = e.target.closest(".btn-copy-code");
package/public/style.css CHANGED
@@ -439,12 +439,30 @@ body {
439
439
  }
440
440
  .msg.assistant .content ul, .msg.assistant .content ol { margin: 8px 0 12px 24px; }
441
441
  .msg.assistant .content li { margin: 4px 0; }
442
- .msg.assistant .content h1, .msg.assistant .content h2, .msg.assistant .content h3 {
442
+ .msg.assistant .content h1, .msg.assistant .content h2, .msg.assistant .content h3,
443
+ .msg.assistant .content h4, .msg.assistant .content h5, .msg.assistant .content h6 {
443
444
  margin: 16px 0 10px; line-height: 1.3;
444
445
  }
445
446
  .msg.assistant .content h1 { font-size: 20px; }
446
447
  .msg.assistant .content h2 { font-size: 18px; }
447
448
  .msg.assistant .content h3 { font-size: 16px; }
449
+ .msg.assistant .content h4 { font-size: 14.5px; }
450
+ .msg.assistant .content h5 { font-size: 13.5px; }
451
+ .msg.assistant .content h6 { font-size: 12.5px; }
452
+ .msg.assistant .content hr {
453
+ border: none;
454
+ border-top: 1px solid var(--border);
455
+ margin: 16px 0;
456
+ }
457
+ .msg.assistant .content del {
458
+ text-decoration: line-through;
459
+ opacity: 0.7;
460
+ }
461
+ .msg.assistant .content .task-list-item-checkbox {
462
+ margin-right: 6px;
463
+ vertical-align: -2px;
464
+ accent-color: var(--accent);
465
+ }
448
466
  .msg.assistant .content a { color: #4ea1ff; text-decoration: none; }
449
467
  .msg.assistant .content a:hover { text-decoration: underline; }
450
468
  .msg.assistant .content table {
@@ -690,7 +708,10 @@ body {
690
708
  transition: border-color .15s;
691
709
  }
692
710
  .composer-inner:focus-within { border-color: var(--text-dim); }
693
- .composer-inner.aborting { border-color: var(--danger); }
711
+ .composer-inner.aborting {
712
+ border-color: var(--danger);
713
+ box-shadow: 0 0 0 1px var(--danger);
714
+ }
694
715
  .composer textarea {
695
716
  flex: 1; background: transparent; border: none; outline: none; resize: none;
696
717
  color: var(--text); font-family: inherit; font-size: 15px; line-height: 1.5;
@@ -699,10 +720,12 @@ body {
699
720
  .composer .send-btn {
700
721
  width: 36px; height: 32px; border-radius: 18px; border: none; cursor: pointer;
701
722
  background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center;
702
- flex-shrink: 0; transition: background .15s, opacity .15s;
723
+ flex-shrink: 0; transition: background .15s, opacity .15s, transform .1s;
703
724
  }
704
725
  .composer .send-btn:disabled { background: #3a3a3a; color: #6f6f6f; cursor: not-allowed; }
705
726
  .composer .send-btn.stop { background: var(--danger); }
727
+ .composer .send-btn.stop:hover:not(:disabled) { background: #f87171; }
728
+ .composer .send-btn.stop:active:not(:disabled) { transform: scale(0.95); }
706
729
  .composer .steer-btn {
707
730
  display: inline-flex;
708
731
  align-items: center;
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # Install pi-web-chat as a user-level systemd service.
3
+ #
4
+ # Why user-level? pi reads/writes ~/.pi and cwd files under $HOME, and it
5
+ # launches `pi` from ~/.npm-global/bin (or wherever $PI_BIN points). Running
6
+ # as a system unit would either need root or your home dir in a bind mount;
7
+ # user-level systemd (systemd --user) is the obvious fit and needs no sudo.
8
+ #
9
+ # Usage:
10
+ # ./scripts/install-service.sh [PORT] [--restart]
11
+ #
12
+ # After install:
13
+ # journalctl --user -u pi-web-chat -f
14
+ # systemctl --user status pi-web-chat
15
+ # systemctl --user restart pi-web-chat
16
+
17
+ set -euo pipefail
18
+
19
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ ROOT="$(cd "$HERE/.." && pwd)"
21
+ SRC="$HERE/pi-web-chat.service"
22
+ DEST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
23
+ DEST="$DEST_DIR/pi-web-chat.service"
24
+
25
+ PORT="${1:-3000}"
26
+ RESTART_NOW="no"
27
+ for arg in "$@"; do
28
+ case "$arg" in
29
+ --restart|--now) RESTART_NOW="yes" ;;
30
+ esac
31
+ done
32
+
33
+ # Sanity checks.
34
+ if [[ ! -f "$SRC" ]]; then
35
+ echo "ERR: unit file not found at $SRC" >&2; exit 1
36
+ fi
37
+ if ! command -v node >/dev/null 2>&1; then
38
+ echo "ERR: 'node' not on PATH" >&2; exit 1
39
+ fi
40
+ if ! command -v systemctl >/dev/null 2>&1; then
41
+ echo "ERR: 'systemctl' not on PATH — this installer only supports systemd" >&2; exit 1
42
+ fi
43
+ # User systemd needs linger for services to outlive the login session.
44
+ if ! loginctl show-user "$USER" 2>/dev/null | grep -q '^Linger=yes'; then
45
+ echo "⚠ Linger is not enabled for $USER — service will stop at logout."
46
+ echo " To keep it running after logout, run (one-off, requires sudo):"
47
+ echo " sudo loginctl enable-linger $USER"
48
+ fi
49
+
50
+ mkdir -p "$DEST_DIR"
51
+
52
+ # Concrete paths. The shipped unit uses %h for portability, but %h is only
53
+ # resolved when the user systemd instance starts — and we want a file that
54
+ # 'systemctl --user status' shows an exact path on, so substitute now.
55
+ USER_HOME="$HOME"
56
+ PROJECT_DIR="$ROOT"
57
+ NODE_BIN="$(command -v node)"
58
+
59
+ # Read the template and substitute.
60
+ sed \
61
+ -e "s|^WorkingDirectory=.*|WorkingDirectory=$PROJECT_DIR|" \
62
+ -e "s|^ExecStart=.*|ExecStart=$NODE_BIN $PROJECT_DIR/server.js|" \
63
+ -e "s|^Environment=PORT=.*|Environment=PORT=$PORT|" \
64
+ -e "s|%h/projects/pi-web-chat|$PROJECT_DIR|g" \
65
+ -e "s|%h/|$USER_HOME/|g" \
66
+ "$SRC" > "$DEST"
67
+
68
+ echo "✔ wrote $DEST"
69
+
70
+ # Reload + (re)enable + start.
71
+ systemctl --user daemon-reload
72
+ systemctl --user enable pi-web-chat.service
73
+ if [[ "$RESTART_NOW" == "yes" ]] || systemctl --user is-active --quiet pi-web-chat.service; then
74
+ systemctl --user restart pi-web-chat.service
75
+ else
76
+ systemctl --user start pi-web-chat.service || true
77
+ fi
78
+
79
+ echo
80
+ echo "Status:"
81
+ systemctl --user --no-pager status pi-web-chat.service || true
82
+ echo
83
+ echo "Logs:"
84
+ echo " journalctl --user -u pi-web-chat -f"
@@ -0,0 +1,41 @@
1
+ [Unit]
2
+ Description=pi-web-chat (pi RPC web UI)
3
+ Documentation=https://github.com/liguoshuai-1990/pi-web-chat
4
+ # Run after the user session / network is available (no-op for user systemd
5
+ # units that run after the user logs in).
6
+ After=network-online.target
7
+ Wants=network-online.target
8
+
9
+ [Service]
10
+ Type=simple
11
+ # Project lives in $HOME/projects/pi-web-chat — override with `WorkingDirectory=`
12
+ # in an override unit or in install-service.sh if you keep it elsewhere.
13
+ # We don't hardcode the path here so the file is portable across machines /
14
+ # forks; the install script substitutes it at install time.
15
+ WorkingDirectory=%h/projects/pi-web-chat
16
+
17
+ # Same reasoning for the Node binary — prefer what Node.js EnvFile says if
18
+ # the user has nvm/fnm; otherwise rely on /usr/bin/node being on PATH.
19
+ ExecStart=/usr/bin/env node server.js
20
+
21
+ # Allow overriding via environment files / drop-ins without editing this
22
+ # unit. PORT, PI_BIN, PI_SESSIONS_DIR etc. are honored by server.js.
23
+ Environment=PORT=3000
24
+ Environment=NODE_ENV=production
25
+ Environment=PI_SKIP_VERSION_CHECK=1
26
+
27
+ # Logging: stdout/stderr go to journald. `journalctl --user -u pi-web-chat -f`.
28
+ StandardOutput=journal
29
+ StandardError=journal
30
+ SyslogIdentifier=pi-web-chat
31
+
32
+ # Restart on any failure; back off so we don't tight-loop if node is broken.
33
+ Restart=on-failure
34
+ RestartSec=3
35
+
36
+ # Hardening.
37
+ NoNewPrivileges=true
38
+ PrivateTmp=true
39
+
40
+ [Install]
41
+ WantedBy=default.target
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # Remove the pi-web-chat user systemd service.
3
+ # Does NOT delete the project directory or sessions.
4
+
5
+ set -euo pipefail
6
+
7
+ UNIT_NAME="pi-web-chat.service"
8
+ DEST="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$UNIT_NAME"
9
+
10
+ if ! command -v systemctl >/dev/null 2>&1; then
11
+ echo "ERR: 'systemctl' not on PATH" >&2; exit 1
12
+ fi
13
+
14
+ if systemctl --user list-unit-files "$UNIT_NAME" 2>/dev/null | grep -q "$UNIT_NAME"; then
15
+ systemctl --user disable --now "$UNIT_NAME" || true
16
+ echo "✔ disabled $UNIT_NAME"
17
+ fi
18
+
19
+ if [[ -f "$DEST" ]]; then
20
+ rm "$DEST"
21
+ systemctl --user daemon-reload
22
+ echo "✔ removed $DEST"
23
+ else
24
+ echo "no unit file at $DEST"
25
+ fi
package/server.js CHANGED
@@ -36,15 +36,20 @@ function resolvePiBin() {
36
36
  const PI_BIN = resolvePiBin();
37
37
  function home() { return os.homedir(); }
38
38
 
39
- function normalizeCwd(dir) {
40
- if (!dir) return process.cwd() || home();
41
- let resolved = dir;
42
- if (dir.startsWith("~")) {
43
- resolved = path.join(home(), dir.slice(1));
39
+ function normalizePath(p) {
40
+ if (!p) return "";
41
+ let resolved = p;
42
+ if (p.startsWith("~")) {
43
+ resolved = path.join(home(), p.slice(1));
44
44
  }
45
45
  return path.resolve(resolved);
46
46
  }
47
47
 
48
+ function normalizeCwd(dir) {
49
+ if (!dir) return process.cwd() || home();
50
+ return normalizePath(dir);
51
+ }
52
+
48
53
  // Where pi stores sessions, organized by cwd-encoded subdirectory.
49
54
  const SESSIONS_DIR = process.env.PI_SESSIONS_DIR || path.join(home(), ".pi", "agent", "sessions");
50
55
  const PORT = process.env.PORT || 3000;
@@ -172,13 +177,19 @@ class PiAgent {
172
177
  if (IDLE_DROP_HEAP) {
173
178
  try { if (typeof global.gc === "function") global.gc(); } catch {}
174
179
  }
180
+ // If unkeyed AND truly idle (never had a session assigned and not busy),
181
+ // no client can ever re-attach to it. Stop immediately to reclaim memory.
182
+ if (!this.sessionKey && !this.isBusy) {
183
+ this.stop();
184
+ return;
185
+ }
175
186
  this.maybeScheduleIdleKill();
176
187
  }
177
188
  }
178
189
 
179
190
  setSessionKey(cwd, sessionPath) {
180
191
  if (!sessionPath) return;
181
- const resolved = path.resolve(sessionPath);
192
+ const resolved = normalizePath(sessionPath);
182
193
  const key = `${cwd}:${resolved}`;
183
194
  if (this.sessionKey && this.sessionKey !== key) {
184
195
  activeAgents.delete(this.sessionKey);
@@ -623,7 +634,7 @@ async function listAllSessionFiles() {
623
634
  }
624
635
 
625
636
  // Subdirectory .jsonl files (created by pi itself when cwd is encoded).
626
- const subdirs = top.filter(d => d.isDirectory() || d.isSymbolicLink());
637
+ const subdirs = top.filter(d => d.isDirectory() || (d.isSymbolicLink() && !d.name.endsWith(".jsonl")));
627
638
  for (const d of subdirs) {
628
639
  const dp = path.join(SESSIONS_DIR, d.name);
629
640
  const names = (await readdir(dp).catch(() => [])).filter(f => f.endsWith(".jsonl"));
@@ -717,12 +728,8 @@ app.get("/api/session", async (req, res) => {
717
728
  if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
718
729
 
719
730
  // Security check: ensure the file path is within SESSIONS_DIR
720
- let resolvedFile = file;
721
- if (resolvedFile.startsWith("~")) {
722
- resolvedFile = path.join(home(), resolvedFile.slice(1));
723
- }
724
- resolvedFile = path.resolve(resolvedFile);
725
- const resolvedSessionsDir = path.resolve(SESSIONS_DIR);
731
+ let resolvedFile = normalizePath(file);
732
+ const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
726
733
  const relPath = path.relative(resolvedSessionsDir, resolvedFile);
727
734
  if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
728
735
  return res.status(403).json({ error: "Access denied" });
@@ -834,7 +841,7 @@ wss.on("connection", (ws, req) => {
834
841
  const url = new URL(req.url, "http://x");
835
842
  const cwd = normalizeCwd(url.searchParams.get("cwd"));
836
843
  const session = url.searchParams.get("session") || null;
837
- const key = session ? `${cwd}:${path.resolve(session)}` : null;
844
+ const key = session ? `${cwd}:${normalizePath(session)}` : null;
838
845
 
839
846
  let agent = null;
840
847
  if (key && activeAgents.has(key) && activeAgents.get(key).alive) {