@liguoshuai/pi-web-chat 1.5.0 → 1.7.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
@@ -8,17 +8,18 @@
8
8
 
9
9
  ---
10
10
 
11
- ## 📸 功能
12
-
13
- - 💬 **流式对话** —— 文本逐字流式渲染,带打字光标
14
- - 🗂️ **会话历史** —— 自动读取 `~/.pi/agent/sessions/` 下所有历史会话,按首条用户消息做标题
15
- - 🆕 **新建 / 切换会话** —— 通过左侧栏点击或生成中新建
16
- - ⚙️ **工具调用折叠块** —— bash / read / write / edit 等以可折叠卡片显示,实时输出
17
- - 💭 **思考过程折叠块** —— 模型 thinking 以独立可折叠卡片显示
18
- - 🧩 **模型切换** —— 顶栏模型 pill,覆盖所有 pi 已配置的模型
19
- - ⏹️ **中止生成** —— 生成中点击发送按钮即可中断
20
- - ⌨️ **Markdown 渲染** —— 代码块、表格、列表、链接、引用、标题
21
- - 🖥️ **工作目录绑定** —— 每个 ws 连接由 cwd 决定会话范围(默认 `~`)
11
+ ## 📸 功能特性
12
+
13
+ - 💬 **流式对话** —— 文本逐字流式渲染,带打字光标与 Markdown 高亮
14
+ - **后台任务持久化** —— 关闭网页/标签页不中断正在生成的任务,重新进入自动回放(Backfill)追平进度
15
+ - 🌐 **多端/多设备同步协同** —— 多个浏览器标签页或多设备(手机/电脑)可同时连入同一会话,实时双向同步
16
+ - 🧭 **运行时实时插入指令 (Steer)** —— 生成过程中可随时插入转向指令指导 AI 调整后续行动
17
+ - 🗂️ **会话历史与管理** —— 自动读取并显示 `~/.pi/agent/sessions/` 下所有历史会话,实时自动落盘
18
+ - ⚙️ **工具与思考过程折叠** —— bash / read / write / edit 等工具调用与 thinking 思考卡片实时展开/收起
19
+ - 🧩 **动态模型切换** —— 顶栏模型胶囊,零延迟覆盖所有 pi 已配置的模型
20
+ - 🔄 **断线自动重连与心跳** —— 带指数退避自动重连、双向心跳 Ping/Pong 检测
21
+ - 📋 **快捷复制** —— 支持代码块、工具指令、回答全文一键复制
22
+ - 📱 **移动端响应式** —— Viewport `100dvh` 优化、顶栏常驻固定、滚动置顶/置底快捷悬浮按钮(FAB)
22
23
 
23
24
  ---
24
25
 
@@ -28,19 +29,26 @@
28
29
  cd pi-web-chat
29
30
  npm install
30
31
 
31
- # 确保 pi 已安装并已配置好至少一个 provider/model
32
+ # 确保 pi 已安装并已配置好至少一个 provider:
32
33
  # pi (交互模式 → 运行 /login 选择 provider,或设置 API key)
33
34
  npm start
34
35
  # → http://localhost:3000
35
36
  ```
36
37
 
37
- ### 环境变量
38
+ ---
39
+
40
+ ## ⚙️ 环境变量配置
38
41
 
39
- | 变量 | 默认值 | 说明 |
40
- | -------------------- | --------------------------------------- | --------------------------- |
41
- | `PORT` | `3000` | Web 服务监听端口 |
42
- | `PI_BIN` | 自动探测(`~/.npm-global/bin/pi` 等) | 显式指定 pi 可执行文件路径 |
43
- | `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | pi 的 session 存储目录 |
42
+ | 变量 | 默认值 | 说明 |
43
+ | ---- | ------ | ---- |
44
+ | `PORT` | `3000` | Web 服务监听端口 |
45
+ | `PI_BIN` | 自动探测(`~/.npm-global/bin/pi` 等) | 显式指定 pi 可执行文件绝对路径 |
46
+ | `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | pi 的 session 存储目录 |
47
+ | `IDLE_TIMEOUT_MS` | `300000` (5分钟) | 真正空闲(无连接+非流式)后的进程回收超时(0 为禁用回收) |
48
+ | `MAX_AGENT_LIFETIME_MS` | `1800000` (30分钟) | 单个 Agent 进程后台生存硬上限(0 为无上限) |
49
+ | `EVENT_BUFFER_SIZE` | `2000` | 离线环形 Buffer 允许缓存的最大事件条数 |
50
+ | `MAX_CONCURRENT_AGENTS` | `0` (无限制) | 进程池最大并发 Agent 进程数量 |
51
+ | `IDLE_DROP_HEAP` | `false` | 进入空闲时是否给 V8 引擎 GC 提示(需 `--expose-gc`) |
44
52
 
45
53
  ---
46
54
 
@@ -49,31 +57,38 @@ npm start
49
57
  ```
50
58
  pi-web-chat/
51
59
  ├── README.md 本文件
52
- ├── DESIGN.md 设计文档(架构、数据流、决策)
53
- ├── ISSUES.md 历次问题排查与修复
54
- ├── CHANGELOG.md 版本变更日志
55
60
  ├── package.json
56
61
  ├── package-lock.json
57
- ├── server.js Express + WebSocket,桥接 pi RPC
58
- ├── .gitignore
59
- └── public/
60
- ├── index.html 单页 UI
61
- ├── app.js 前端逻辑
62
- └── style.css ChatGPT/Gemini 风格样式
62
+ ├── server.js Express + WebSocket,桥接 pi RPC 与进程池管理
63
+ ├── bin/
64
+ └── pi-web-chat.js CLI 可执行入口
65
+ ├── public/
66
+ ├── index.html 单页 UI
67
+ │ ├── app.js 前端逻辑与状态机
68
+ │ └── style.css ChatGPT/Gemini 风格样式
69
+ └── docs/ 项目文档库
70
+ ├── ARCHITECTURE.md 架构设计文档
71
+ ├── DESIGN.md 详细设计与决策文档
72
+ ├── ISSUES.md 历次问题排查与修补记录
73
+ └── CHANGELOG.md 版本变更日志
63
74
  ```
64
75
 
65
76
  ---
66
77
 
67
- ## 🏗️ 架构
78
+ ## 🏗️ 架构概览
68
79
 
69
80
  ```
70
- 浏览器 ──WebSocket(/ws?cwd=...&session=...)──► Node server.js ──stdin/stdout(JSONL)──► pi --mode rpc
71
- (一个 ws 连接 spawn 一个 pi 子进程)
72
-
73
- ├──REST /api/sessions ──► 直读 JSONL 列历史
74
- └──REST /api/session?file=... ─► 重建根→叶路径
81
+ 浏览器 (多 Tab / 多设备) ──WebSocket(/ws?cwd=...&session=...)──► Node server.js
82
+ (Session Key 进程池 activeAgents)
83
+
84
+ ├──► pi --mode rpc (后台持久化 Worker)
85
+ ├──► REST /api/sessions ──► 列会话历史
86
+ ├──► REST /api/session ────► 构建对话链
87
+ └──► REST /api/agents ─────► 实时监控看板
75
88
  ```
76
89
 
90
+ 详细架构说明与设计决策请参阅 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) 与 [docs/DESIGN.md](docs/DESIGN.md)。
91
+
77
92
  ---
78
93
 
79
94
  ## 📝 License
@@ -94,11 +109,6 @@ MIT
94
109
  ./scripts/install-service.sh 8080
95
110
  ```
96
111
 
97
- 脚本会:
98
- 1. 在 `~/.config/systemd/user/pi-web-chat.service` 生成 unit
99
- 2. `systemctl --user daemon-reload && systemctl --user enable --now pi-web-chat`
100
- 3. 设置 `Restart=on-failure` 自动重启
101
-
102
112
  查看状态 / 日志:
103
113
  ```bash
104
114
  systemctl --user status pi-web-chat
@@ -110,7 +120,7 @@ journalctl --user -u pi-web-chat -f
110
120
  > sudo loginctl enable-linger $USER
111
121
  > ```
112
122
 
113
- 卸载:
123
+ 卸载服务:
114
124
  ```bash
115
125
  systemctl --user disable --now pi-web-chat
116
126
  rm ~/.config/systemd/user/pi-web-chat.service
@@ -0,0 +1,179 @@
1
+ # 架构设计文档
2
+
3
+ > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.6.0 版本)。
4
+
5
+ ---
6
+
7
+ ## 1. 高层架构
8
+
9
+ ```
10
+ ┌─────────────────┐ WebSocket (JSON) ┌──────────────────────┐
11
+ │ Browser Tab A │ ◄──────────────────────────────────────► │ │
12
+ └─────────────────┘ │ │
13
+ ┌─────────────────┐ │ Node.js server │
14
+ │ Browser Tab B │ ◄──────────────────────────────────────► │ (server.js) │
15
+ └─────────────────┘ │ │
16
+ │ ┌────────────────┐ │
17
+ │ │ PiAgent Pool │ │
18
+ │ │ (activeAgents) │ │
19
+ │ └───────┬────────┘ │
20
+ └──────────┼───────────┘
21
+
22
+ spawn child_process │ (1 agent per session key)
23
+
24
+ ┌──────────────────────┐
25
+ │ pi --mode rpc │
26
+ │ (后台 Agent 子进程) │
27
+ │ stdin/stdout │
28
+ └──────────┬───────────┘
29
+
30
+ ┌──────────────────┼──────────────────┐
31
+ ▼ ▼ ▼
32
+ ~/.pi/agent/sessions/ ~/.pi/agent/ 配置/扩展/
33
+ --home-zrlgs--/ extensions/ skills/
34
+
35
+
36
+ *.jsonl (append-only tree)
37
+ ```
38
+
39
+ **核心设计原则**:
40
+ - **按 Session Key 池化进程**:以 `${cwd}:${resolvedSessionPath}` 作为唯一 Key,多标签页或多设备连接同一个 Session 时共享同一个后台 `PiAgent` 进程。
41
+ - **后台持久化运行(Headless Persistence)**:网页断开(如关闭标签页)不会立即终止进程。只要 Agent 处于流式生成或有未响应请求状态,后台进程会坚持跑完当前任务。
42
+ - **离线事件环形缓存与回放(Ring Buffer & Backfill)**:无客户端连接时,pi 输出的事件自动存入环形 Buffer(默认 2000 条)。客户端重连时通过 `backfill_start` → 离线事件 → `backfill_end` 进行增量补齐。
43
+ - **真正空闲回收(True-Idle Timeout)**:仅在“无 WebSocket 连接”且“进程彻底进入 idle(非 streaming、无挂起 RPC)”时,才启动 `IDLE_TIMEOUT_MS` 空闲回收倒计时。
44
+ - **磁盘 JSONL 为唯一真理(Single Source of Truth)**:会话历史与树状分支全由 pi 本身维护在磁盘 `.jsonl` 文件中,Web 端通过 REST 接口与 RPC 消息与文件保持同步。
45
+
46
+ ---
47
+
48
+ ## 2. 关键数据流
49
+
50
+ ### 2.1 打开已有会话与增量回放(Backfill)
51
+
52
+ ```
53
+ Browser server.js PiAgent (RPC)
54
+ │ │ │
55
+ ├─ GET /api/session?file=X ──────►│ 读 JSONL 主干消息 │
56
+ │◄─── { header, entries[] }──────┤ │
57
+ │ │ │
58
+ ├─ 渲染历史消息 │ │
59
+ │ │ │
60
+ ├─ WS /ws?cwd=...&session=X ─────►│ 查找/创建 activeAgents[Key] │
61
+ │ ├─► attachWs(ws) │
62
+ │ ├─► 发现有离线缓存事件 │
63
+ │◄─── { type: "backfill_start" }─┤ │
64
+ │◄─── N 条离线事件 (text_delta…) │ (将离线事件批量回放给当前 ws) │
65
+ │◄─── { type: "backfill_end" }───┤ │
66
+ │ │ │
67
+ ├─ 矫正当前 streaming 与 Composer 状态 │
68
+ ```
69
+
70
+ ### 2.2 新建会话与发送消息
71
+
72
+ ```
73
+ Browser server.js pi RPC 子进程
74
+ │ │ │
75
+ ├─ WS /ws?cwd=... (无 session) │ spawn pi --mode rpc │
76
+ │ │◄─ pi 创建新 session jsonl │
77
+ │◄─── ws open (状态记为已连接) │ │
78
+ │ │ │
79
+ ├─ sendWs({type:"prompt", ...})─►│────────────────────────────────►│
80
+ │ │ ├─ agent_start (state=streaming)
81
+ │◄─── message_update/text_delta ─┼◄────────────────────────────────┤
82
+ │◄─── agent_settled │◄────────────────────────────────┤ (state=idle)
83
+ │ │ ├─ session 文件实时追加
84
+ ```
85
+
86
+ ### 2.3 中途插话 / 转向(Mid-turn Steering)
87
+
88
+ ```
89
+ Browser server.js pi RPC 子进程
90
+ │ │ │
91
+ ├─ Agent 处于 streaming 状态 ────┼─────────────────────────────────┤ (正在执行长任务/工具)
92
+ ├─ sendWs({type:"steer", ...})──►│────────────────────────────────►│
93
+ │ │ ├─ 注入 steer 命令
94
+ │◄─── agent 收到并调整后续思考 ────┼◄────────────────────────────────┤
95
+ ```
96
+
97
+ ---
98
+
99
+ ## 3. 关键模块职责
100
+
101
+ ### 3.1 `server.js`
102
+
103
+ | 模块/函数 | 职责 |
104
+ |-----------|------|
105
+ | `PiAgent` 类 | 管理单个 `pi --mode rpc` 子进程:维护状态(`idle`/`streaming`)、离线事件 Buffer、挂起请求表、真·空闲回收定时器与硬生存上限定时器。 |
106
+ | `activeAgents` Map | 以 `${cwd}:${sessionFile}` 为 Key 的进程池,实现多端/多标签页共享。 |
107
+ | `GET /api/sessions` | 扫描 `~/.pi/agent/sessions/**/*.jsonl`,读取 header 做 cwd 过滤,按更新时间排序返回。 |
108
+ | `GET /api/session` | 读取单个 `.jsonl` 文件,按 parentId 关系重构叶子到根节点的标准对话链。 |
109
+ | `GET /api/agents` | 暴露后台活跃 Agent 的状态信息(运行状态、客户端数量、存活时长、离线 Buffer 消息数、最近提示词等)。 |
110
+ | `WebSocketServer` | 处理连接建立与断开,分发 `prompt`、`steer`、`abort`、`ping` 等指令,并在 `SIGINT`/`SIGTERM` 时进行优雅关闭。 |
111
+
112
+ ### 3.2 `public/app.js`
113
+
114
+ | 模块/函数 | 职责 |
115
+ |-----------|------|
116
+ | `state` | 全局状态对象:维护 `wsConnected`、`streaming`、`isBackfilling`、`activeToolCalls`、`sessionId`、`wsGen` 等。 |
117
+ | `connectWs(opts)` | 管理 WebSocket 生命周期:携带代次 `wsGen` 防止旧消息污染,处理重连逻辑与心跳。 |
118
+ | `handlePiMessage(obj)` | 核心事件分发器:处理 `backfill_start`/`backfill_end`、16 种 pi 事件以及状态同步。 |
119
+ | `ensureStreamingMsg / refreshStreamingContent` | 增量渲染/更新正在流式的文本、思考过程(Thinking)与工具卡片。 |
120
+ | `renderMarkdown` | 自研轻量 Markdown 渲染器(代码块抠出保护、Html Escape、行内语法转换)。 |
121
+
122
+ ### 3.3 `public/style.css`
123
+
124
+ - 暗色与明色响应式样式变量。
125
+ - 左侧 260px 侧边栏与右侧主聊天区自适应布局。
126
+ - 移动端 Fixed 顶栏、Viewport height 100dvh 适配与悬浮置顶按钮(FAB)。
127
+
128
+ ---
129
+
130
+ ## 4. 关键设计决策
131
+
132
+ | 决策点 | 选择 | 理由 |
133
+ |--------|------|------|
134
+ | **进程池粒度** | **Session Key 池化** | 允许同一会话在多设备/多 Tab 间同步,同时保证进程资源不重复浪费。 |
135
+ | **断连处理** | **后台继续跑 + 环形 Buffer 回放** | 解决关闭标签页导致未完成任务断掉的问题;用环形 Buffer 防止长时间离线膨胀内存。 |
136
+ | **回收策略** | **真·空闲倒计时(True-Idle Timeout)** | 区分“无连接”与“真正的空闲”,保障长时间后台生成任务不被中断。 |
137
+ | **通讯协议** | **直接透传 pi 原生 JSONL** | 不做二次包装,保持与 pi RPC 协议同频更新。 |
138
+ | **持久化** | **复用 pi 本身 .jsonl** | 零额外存储依赖,Web 端与终端命令完全同源共享会话。 |
139
+
140
+ ---
141
+
142
+ ## 5. 配置与环境变量
143
+
144
+ | 环境变量 | 默认值 | 说明 |
145
+ |----------|--------|------|
146
+ | `PORT` | `3000` | HTTP 与 WebSocket 监听端口 |
147
+ | `PI_BIN` | 自动探测 | pi 可执行文件绝对路径 |
148
+ | `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | 会话 JSONL 文件存取目录 |
149
+ | `IDLE_TIMEOUT_MS` | `300000` (5分钟) | 真正空闲(无连接+非流式)后的回收超时(0 为禁用回收) |
150
+ | `MAX_AGENT_LIFETIME_MS` | `1800000` (30分钟) | 单个 Agent 进程后台生存硬上限(0 为无上限) |
151
+ | `EVENT_BUFFER_SIZE` | `2000` | 离线环形 Buffer 允许缓存的最大事件条数 |
152
+ | `MAX_CONCURRENT_AGENTS` | `0` (无限制) | 进程池最大并发 Agent 进程数量 |
153
+ | `IDLE_DROP_HEAP` | `false` | 进入空闲时是否给 V8 引擎 GC 提示(需 `--expose-gc`) |
154
+
155
+ ---
156
+
157
+ ## 6. 目录结构
158
+
159
+ ```
160
+ pi-web-chat/
161
+ ├── README.md # 主说明文档
162
+ ├── package.json
163
+ ├── package-lock.json
164
+ ├── server.js # Express + WebSocket 服务器与 PiAgent 管理
165
+ ├── bin/
166
+ │ └── pi-web-chat.js # 可执行入口
167
+ ├── public/
168
+ │ ├── index.html # 单页 UI
169
+ │ ├── app.js # 前端逻辑与状态机
170
+ │ └── style.css # CSS 样式
171
+ ├── docs/ # 所有文档统一收纳
172
+ │ ├── ARCHITECTURE.md # 架构设计文档(本文件)
173
+ │ ├── DESIGN.md # 详细设计与决策文档
174
+ │ ├── ISSUES.md # 排查与修补记录
175
+ │ └── CHANGELOG.md # 版本变更日志
176
+ └── scripts/ # 服务安装/卸载脚本
177
+ ├── install-service.sh
178
+ └── pi-web-chat.service
179
+ ```
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.7.0] - 2026-07-26
11
+
12
+ ### Added
13
+ - **前端流式渲染防抖节流 (rAF UI Throttling)**:使用 `requestAnimationFrame` 节流高频 Token/Delta 事件,消除倒水式输出时的 DOM 频繁销毁与全量重塑,大幅提升高频打字时的流畅帧率,显著降低 CPU 和发热消耗。
14
+ - **后端 Session 列表 mtime 内存缓存 (Session Metadata Caching)**:`/api/sessions` 引入基于文件修改时间(`mtimeMs`)的元数据内存缓存。对无改动的历史 Session 文件跳过磁盘读取与全量 JSON 逐行解析,大幅提升侧边栏列表加载响应速度。
15
+ - **离线事件 Buffer 算法优化 (Ring Buffer O(1) Push)**:将 `PiAgent.bufferEvent` 从 $O(N)$ 复杂度的 `Array.shift()` 改为 $O(1)$ 的指针环形队列,消除无客户端连接时的数组平移消耗。
16
+ - **WebSocket 垃圾连接自动回收 (Dead Socket Cleanup)**:在 `wsSend` 广播消息时动态检测并自动剔除已处于关闭状态(`CLOSING`/`CLOSED`)的垃圾 Socket 引用,避免泄露。
17
+
18
+ ---
19
+
20
+ ## [1.6.0] - 2026-07-26
21
+
22
+ ### Added
23
+ - **后台任务继续运行 (Background Task Persistence)**:
24
+ - 关闭浏览器/标签页不再强制中止正在生成的 pi 任务:后台 pi RPC 子进程保持**跑完当前这一轮**;重连后可看到完整结果。
25
+ - `PiAgent` 跟踪 `state`(`idle`/`streaming`)与 `pending` 请求。只有在“**无 WebSocket 且真正空闲**”(无 streaming 也无未响应请求)时才启动空闲回收计时。
26
+ - 时长型缓冲区:在后台期间渲染器产出的事件会被**离线缓存**到 `EVENT_BUFFER_SIZE`(默认 2000 条)的环形 buffer。新连接上来时自动 **回放**为 `backfill_start` → N 条原始事件 → `backfill_end` 三个阶段包裹的消息,便于前端精确“追到哪儿”。
27
+ - 新增 REST 端点 `GET /api/agents`:查看所有存活后台 pi 代理(`state`, `alive`, `busy`, `hasClients`, `uptimeMs`, `bufferedEvents`, 最近一条用户提示等)。常驻 npm 下载、上传、调试或后台 面板都可以利用。
28
+ - 新增环境变量:`MAX_AGENT_LIFETIME_MS`(默认 1800000 = 30分钟,硬上限超出强制 `SIGTERM`;设为 0 禁用)。防止后台代理失控常驻。
29
+
30
+ ### Changed
31
+ - **`IDLE_TIMEOUT_MS` 语义重要变更**:从“**断开后多久**杀进程”变为“**真正空闲后多久**才回收”(默认还是 5min)。如果断开后还在 streaming 或有余未完成的 RPC 请求,定时器不会触发,任务不会被中断。
32
+ - 首页顶栏重连后会同步服务器状态:收到 `backfill_end` 后会自动滚动到底,并恢复 `streaming` 状态(如仍在后台继续)。
33
+ - 首页增加优雅关闭:`SIGINT`/`SIGTERM` 会“逐个停止所有后台 pi 进程”后再退出,防止 server 重启时留下 zombie 进程。
34
+ - 不再支持“`IDLE_TIMEOUT_MS=0` 断开即杀”告诉——该环境变量现在表示“**禁用空闲回收**”。需要立刻释放内存,请改为正数(如 `5000`)或重启 server。
35
+
36
+ ### 技术说明
37
+ - `PiAgent` 生命周期事件:`agent_start → state=streaming`;`agent_end/agent_settled → state=idle`;`pi_exit → state=idle`。这是“背景继续跑完”的关键开关。
38
+
39
+ ---
40
+
10
41
  ## [1.5.0] - 2026-07-26
11
42
 
12
43
  ### Added
@@ -132,14 +163,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
132
163
  - 多模型切换(pi 配置的所有 provider/model)
133
164
  - 响应式深色主题(ChatGPT/Gemini 风格)
134
165
 
135
- ### Technical Debt (已知)
136
- - 图片上传未接
137
- - 多 cwd / 项目切换器未做
138
- - fork / tree / clone 浏览未接
139
- - 浅/深主题切换未做
140
- - 双端实时同步未做
141
- - 鉴权未做
142
-
143
166
  ---
144
167
 
145
168
  ## Legend
package/docs/DESIGN.md ADDED
@@ -0,0 +1,88 @@
1
+ # 详细设计与决策文档
2
+
3
+ ## 目标
4
+
5
+ 将 [pi](https://pi.dev) 终端编码代理包装为一个**类 ChatGPT / Gemini 的 Web 界面**,使用户能够:
6
+
7
+ - 在浏览器中与 pi 展开对话,享受流式文本输出、思考过程卡片、Markdown 渲染与工具调用可视化;
8
+ - 与终端共享会话历史(一份存储、两端可见);
9
+ - 支持后台持久化运行:关闭标签页或切换设备不打断正在生成的任务,重新进入时自动追进度。
10
+
11
+ 非目标:
12
+ - 引入复杂的前端构建链(保持单页原生 JS/CSS,无打包工具);
13
+ - 替代 pi 本身的文件存储层或重新实现数据库。
14
+
15
+ ---
16
+
17
+ ## 核心设计与进程模型
18
+
19
+ ### 进程池化与后台持久化运行
20
+
21
+ ```
22
+ Browser Tab 1 ──┐
23
+ ├── WebSocket ──► server.js (activeAgents[Key]) ──stdio (JSONL)──► pi (rpc mode)
24
+ Browser Tab 2 ──┘
25
+ ```
26
+
27
+ 与早期“1 个 WebSocket = 1 个子进程”不同,当前设计以 **Session Key**(`${cwd}:${resolvedSessionPath}`)作为唯一标识:
28
+
29
+ 1. **多端/多标签页共享**:多个浏览器标签页或多个设备打开同一个 Session 时,附加到同一个 `PiAgent` 实例。
30
+ 2. **后台任务持久化**:关闭网页仅表示断开 WebSocket 连接,`PiAgent` 会继续运行直到当前的流式任务结束。
31
+ 3. **离线 Buffer 与回放**:在无客户端连接期间,pi 抛出的事件将缓存在 `eventBuffer` 环形队列中。新客户端连接后,服务端通过 `backfill_start` → 增量事件 → `backfill_end` 进行追回。
32
+ 4. **真·空闲回收(True-Idle Timeout)**:仅在“无 WebSocket 连接”且“真正 Idle”(非 streaming,无挂起 RPC 请求)时启动 `IDLE_TIMEOUT_MS`(默认 5 分钟)倒计时回收进程。
33
+
34
+ ---
35
+
36
+ ## 数据流设计
37
+
38
+ ### 1. 消息发送与流式渲染
39
+
40
+ ```
41
+ 1. 浏览器发送 JSON: {"type": "prompt", "message": "..."}
42
+ 2. 服务端转给对应 Session 的 PiAgent
43
+ 3. PiAgent 更新状态为 streaming,写入 pi 的 stdin
44
+ 4. pi 往 stdout 输出事件流 (agent_start / text_delta / toolcall_start / agent_end / agent_settled)
45
+ 5. 服务端广播给所有连入该 Session 的 WebSocket,并写入离线 Buffer(若无连接)
46
+ 6. 浏览器 handlePiMessage 增量更新 DOM(文本、思考块、工具卡片)
47
+ ```
48
+
49
+ ### 2. 中途中断与转向(Steering)
50
+
51
+ 在代理正在执行长任务或工具调用时,用户可以输入补充指令:
52
+ - **Abort**:发送 `{"type": "abort"}` 中断当前生成。
53
+ - **Steer**:发送 `{"type": "steer", "message": "..."}` 插入中途指令,指导代理调整后续行动。
54
+
55
+ ---
56
+
57
+ ## 关键设计决策与权衡
58
+
59
+ ### 1. 渲染策略:重构 vs 增量 DOM
60
+
61
+ 针对流式输出中出现的文本、思考块(Thinking)、工具卡片交错问题:
62
+ - 选择**每次更新时重构当前 assistant 消息的内容节点**。因为单次回复文本长度通常有限,原生 DOM 节点重写开销在 5ms 以内,极大地简化了复杂的节点插入与折叠逻辑。
63
+
64
+ ### 2. 代次标记(`wsGen`)
65
+
66
+ 解决浏览器在极短时间内快速切换 WebSocket 会话时的竞态:
67
+ - 全局分配单调递增的 `wsGen` 代次。每次建立新 WebSocket 时递增。在接收消息前比对 `ws._gen === wsGen`,避免旧连接迟到的消息破坏新会话的状态。
68
+
69
+ ### 3. Markdown 渲染:Escape-First 零依赖
70
+
71
+ 不使用第三方大型 Markdown 渲染库,采用原生轻量处理:
72
+ - 在渲染入口统一对文本做 HTML Escape 转义(防止 XSS 攻击);
73
+ - 先将围栏代码块(Fenced Code Blocks)抠出暂存,再做行内语法转换,最后还原代码块。
74
+
75
+ ---
76
+
77
+ ## 消息事件与 UI 控件对照表
78
+
79
+ | pi RPC 事件 | 触发动作 / 前端 UI 表现 |
80
+ |-------------|-------------------------|
81
+ | `agent_start` | 切换发送按钮为“停止/中止”状态,标志进入流式生成状态 |
82
+ | `message_start` | 在聊天区域末尾追加新的 Assistant 消息卡片 |
83
+ | `message_update` / `text_delta` | 累加到文本缓冲并重新渲染 Markdown |
84
+ | `message_update` / `thinking_delta` | 累加到 Thinking 折叠卡片并实时显示 |
85
+ | `message_update` / `toolcall_start` | 创建可视化工具调用卡片(可折叠) |
86
+ | `tool_execution_start / update / end` | 动态更新工具卡片的输入参数、标准输出与执行状态 |
87
+ | `agent_end` / `agent_settled` | 恢复发送按钮,标记流式生成结束,触发侧边栏会话列表刷新 |
88
+ | `backfill_start` / `backfill_end` | 前端标记处于历史回放状态,回放期间暂停自动滚动,回放结束后一次性同步状态并滚动到底部 |
package/docs/ISSUES.md ADDED
@@ -0,0 +1,66 @@
1
+ # 问题排查与修补记录
2
+
3
+ 项目开发与演进过程中遇到并修复的技术问题与设计优化记录。
4
+
5
+ ---
6
+
7
+ ## 1. 侧边栏 `cwd` 路径编码错误
8
+
9
+ **症状**:API `GET /api/sessions?cwd=/home/zrlgs` 拿不到任何结果。
10
+
11
+ **根因**:第一版用了 `cwd.replace(/\//g, "-")` 把 `/home/zrlgs` 编码成 `-home-zrlgs`,但 pi 实际上用的是 `--home-zrlgs--`(前后都带 `-`)。
12
+
13
+ **修复**:弃用编码猜测,扫描 sessions 根目录下所有子目录,读每个 jsonl 的 header 里 `cwd` 字段,再按 `cwd` 严格比对过滤。
14
+
15
+ ---
16
+
17
+ ## 2. 关闭网页标签页导致正在生成的 pi 任务中断
18
+
19
+ **症状**:在用户提交了一个复杂 Prompt(或正在跑 Bash 工具)后关闭网页,AI 回答直接中断,下次连入时拿不到完整结果。
20
+
21
+ **根因**:原先 WebSocket 断开会直接触发 5 分钟倒计时杀进程;即使倒计时未到,没有客户端连入时生成的事件也被直接抛弃,未在内存中保留。
22
+
23
+ **修复**:
24
+ - 引入 **PiAgent 进程池化** 与 **真·空闲回收(True-Idle Timeout)**:只有在“无 WS 连接”且“真正 Idle”(非 streaming、无挂起 RPC)时才倒计时。
25
+ - 引入 **离线事件环形 Buffer(Event Ring Buffer)**:无客户端时缓存在 Buffer,新连接重连时发送 `backfill_start` → 增量事件 → `backfill_end` 进行追回并一次性同步状态。
26
+
27
+ ---
28
+
29
+ ## 3. 局域网 / 移动端 Safari 100vh 动态导航栏遮挡底部输入框
30
+
31
+ **症状**:移动端 Safari 浏览器打开 Web 界面时,底部输入框被 iOS 动态地址栏遮挡,且顶栏在滚动时会跟着滑走。
32
+
33
+ **修复**:
34
+ - 在 CSS 中使用 `100dvh` (Dynamic Viewport Height) 替代 `100vh`。
35
+ - 将顶栏改为 `sticky` / `fixed` 布局,保障移动端交互体验。
36
+ - 增加悬浮置顶/置底快捷按钮(FAB)。
37
+
38
+ ---
39
+
40
+ ## 4. WebSocket 代次竞态 —— 旧 socket 残留消息污染新会话
41
+
42
+ **症状**:点“新对话”快速发消息时,前一次会话延迟到达的 `agent_end` / `agent_settled` 事件跑进新会话,导致新会话 `streaming` 状态被错误覆盖。
43
+
44
+ **修复**:
45
+ - 引入全局 `wsGen` 单调递增计数器,给每个 WebSocket 分配 `_gen` 编号。
46
+ - 接收消息前比对代次,丢弃非当前代次发来的旧事件。
47
+
48
+ ---
49
+
50
+ ## 5. `/api/session` 中局部变量遮蔽 Node `path` 模块
51
+
52
+ **症状**:在调用 `/api/session` 接口获取历史对话时,出现 `TypeError: path.basename is not a function` 报错。
53
+
54
+ **根因**:函数内部在处理 jsonl 包含的文件条目时,使用了 `const path = ...` 局部变量名,屏蔽了 Node.js 顶层的 `import path from "node:path"` 模块。
55
+
56
+ **修复**:重命名局部变量为 `filePath` / `resolvedPath`,消除变量同名遮蔽。
57
+
58
+ ---
59
+
60
+ ## 6. 多设备 / 多 Tab 共享会话不同步
61
+
62
+ **症状**:同一个 Session 在手机和电脑上同时打开时,手机发送的消息电脑看不到,状态互相覆盖。
63
+
64
+ **修复**:
65
+ - 以 Session Key 为粒度管理 `PiAgent` 实例。
66
+ - 任何一个客户端发送 `prompt` 或 `steer` 指令时,通过 `agent.wsSend(..., senderWs)` 广播同步给连入该 Session 的其他客户端。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
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",
@@ -11,8 +11,8 @@
11
11
  "server.js",
12
12
  "bin/",
13
13
  "public/",
14
+ "docs/",
14
15
  "README.md",
15
- "CHANGELOG.md",
16
16
  "LICENSE"
17
17
  ],
18
18
  "scripts": {
package/public/app.js CHANGED
@@ -40,6 +40,7 @@ const state = {
40
40
  currentModel: null,
41
41
  thinkingLevel: "medium",
42
42
  sessionId: null,
43
+ isBackfilling: false,
43
44
  };
44
45
 
45
46
  let toastTimer = null;
@@ -431,6 +432,26 @@ function closeSidebar() {
431
432
  $(".app").classList.remove("sidebar-open");
432
433
  }
433
434
 
435
+ function initMobileToolbarFab() {
436
+ const chat = $("#chat");
437
+ const fab = $("#mobileToolbarFab");
438
+ if (!chat || !fab) return;
439
+
440
+ const onScroll = () => {
441
+ if (chat.scrollTop > 250) {
442
+ fab.classList.add("visible");
443
+ } else {
444
+ fab.classList.remove("visible");
445
+ }
446
+ };
447
+
448
+ chat.addEventListener("scroll", onScroll);
449
+ fab.addEventListener("click", () => {
450
+ chat.scrollTo({ top: 0, behavior: "smooth" });
451
+ fab.classList.remove("visible");
452
+ });
453
+ }
454
+
434
455
  async function loadSession(file) {
435
456
  state.currentSessionFile = file;
436
457
  try {
@@ -708,6 +729,8 @@ function summaryArgs(name, args) {
708
729
  }
709
730
 
710
731
  function scrollBottom() {
732
+ // Don't fight the user during a background-event replay (backfill).
733
+ if (state.isBackfilling) return;
711
734
  const chat = $("#chat");
712
735
  chat.scrollTop = chat.scrollHeight;
713
736
  }
@@ -750,6 +773,16 @@ function ensureStreamingMsg() {
750
773
  return node;
751
774
  }
752
775
 
776
+ let isRefreshScheduled = false;
777
+ function refreshStreamingContentDebounced() {
778
+ if (isRefreshScheduled) return;
779
+ isRefreshScheduled = true;
780
+ requestAnimationFrame(() => {
781
+ isRefreshScheduled = false;
782
+ refreshStreamingContent();
783
+ });
784
+ }
785
+
753
786
  function refreshStreamingContent() {
754
787
  const node = state.streamingMsg;
755
788
  if (!node) return;
@@ -996,6 +1029,25 @@ function sendWs(obj) {
996
1029
  }
997
1030
 
998
1031
  function handlePiMessage(obj) {
1032
+ // Backfill markers emitted by the server when it replays buffered events
1033
+ // that happened in the background while no browser was attached.
1034
+ if (obj.type === "backfill_start") {
1035
+ state.isBackfilling = true;
1036
+ return;
1037
+ }
1038
+ if (obj.type === "backfill_end") {
1039
+ state.isBackfilling = false;
1040
+ // After replay, sync the composer / streaming state to what the server thinks.
1041
+ if (obj.streaming) {
1042
+ state.streaming = true;
1043
+ setComposerAborting(true);
1044
+ ensureStreamingMsg();
1045
+ refreshStreamingContent();
1046
+ }
1047
+ // jump to the latest content once the replay is done
1048
+ requestAnimationFrame(scrollBottom);
1049
+ return;
1050
+ }
999
1051
  // Responses to commands we issued (get_state etc.) come back with success+data.
1000
1052
  if (obj.type === "response") {
1001
1053
  if (obj.command === "get_state" && obj.success) updateState(obj.data);
@@ -1100,7 +1152,7 @@ function handlePiMessage(obj) {
1100
1152
  if (!ev) break;
1101
1153
  if (ev.type === "text_delta") {
1102
1154
  state.streamingText += ev.delta;
1103
- refreshStreamingContent();
1155
+ refreshStreamingContentDebounced();
1104
1156
  } else if (ev.type === "text_end") {
1105
1157
  // Authoritative final text for this content slot. Overwrite any
1106
1158
  // accumulated/delta text so we display exactly what the model
@@ -1114,7 +1166,7 @@ function handlePiMessage(obj) {
1114
1166
  if (ev.type === "thinking_delta") {
1115
1167
  state.streamingThinking += ev.delta || "";
1116
1168
  }
1117
- refreshStreamingContent();
1169
+ refreshStreamingContentDebounced();
1118
1170
  } else if (ev.type === "toolcall_start") {
1119
1171
  ensureStreamingMsg();
1120
1172
  const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
@@ -1180,7 +1232,7 @@ function handlePiMessage(obj) {
1180
1232
  function ensureToolBlock(toolCallId, name, args) {
1181
1233
  if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
1182
1234
  makeToolBlockFromCall({ id: toolCallId, name, arguments: args });
1183
- refreshStreamingContent();
1235
+ refreshStreamingContentDebounced();
1184
1236
  return state.activeToolCalls.get(toolCallId);
1185
1237
  }
1186
1238
 
@@ -1593,6 +1645,9 @@ function init() {
1593
1645
  }
1594
1646
  });
1595
1647
 
1648
+ // Mobile: floating button to jump back to the toolbar after long scrolls
1649
+ initMobileToolbarFab();
1650
+
1596
1651
  refreshSessions();
1597
1652
  // start in the disconnected state; connectWs will flip to green on open.
1598
1653
  const initDot = $("#connDot");
package/public/index.html CHANGED
@@ -101,6 +101,14 @@
101
101
  </div>
102
102
  </div>
103
103
 
104
+ <!-- Mobile toolbar reveal / scroll-to-top FAB -->
105
+ <button class="mobile-toolbar-fab" id="mobileToolbarFab" title="回到顶部">
106
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
107
+ <line x1="12" y1="19" x2="12" y2="5"></line>
108
+ <polyline points="5 12 12 5 19 12"></polyline>
109
+ </svg>
110
+ </button>
111
+
104
112
  <!-- Toast notification -->
105
113
  <div class="toast" id="toast"></div>
106
114
  <script src="/app.js"></script>
package/public/style.css CHANGED
@@ -746,6 +746,37 @@ body {
746
746
  transform: translateX(-50%) translateY(0);
747
747
  }
748
748
 
749
+ /* Mobile toolbar reveal / scroll-to-top FAB */
750
+ .mobile-toolbar-fab {
751
+ position: fixed;
752
+ right: 16px;
753
+ bottom: 90px;
754
+ width: 44px;
755
+ height: 44px;
756
+ border-radius: 50%;
757
+ border: 1px solid var(--border);
758
+ background: var(--bg-input);
759
+ color: var(--text);
760
+ display: none;
761
+ align-items: center;
762
+ justify-content: center;
763
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4);
764
+ cursor: pointer;
765
+ z-index: 50;
766
+ opacity: 0;
767
+ transform: translateY(10px);
768
+ pointer-events: none;
769
+ transition: opacity 0.2s ease, transform 0.2s ease, background 0.15s ease;
770
+ }
771
+ .mobile-toolbar-fab.visible {
772
+ opacity: 1;
773
+ transform: translateY(0);
774
+ pointer-events: auto;
775
+ }
776
+ .mobile-toolbar-fab:active {
777
+ background: var(--bg-hover);
778
+ }
779
+
749
780
  /* Model selector dropdown */
750
781
  .model-menu {
751
782
  position: absolute; top: 52px; right: 16px;
@@ -807,33 +838,52 @@ body {
807
838
  opacity: 1;
808
839
  }
809
840
 
810
- /* Topbar Mobile Adjustments */
841
+ /* Topbar Mobile Adjustments — fixed, taller, shadowed so it is always findable */
811
842
  .topbar {
812
- height: 50px;
813
- padding: 0 8px;
814
- gap: 6px;
815
- flex-shrink: 0;
816
- position: sticky;
843
+ height: 54px;
844
+ padding: 0 12px;
845
+ gap: 8px;
846
+ position: fixed;
817
847
  top: 0;
818
- z-index: 20;
848
+ left: 0;
849
+ right: 0;
850
+ z-index: 101;
819
851
  background: var(--bg);
852
+ border-bottom: 1px solid var(--border);
853
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
854
+ }
855
+
856
+ .main {
857
+ padding-top: 54px;
820
858
  }
821
859
 
822
860
  .topbar .session-name {
823
- font-size: 13px;
861
+ font-size: 14px;
824
862
  font-weight: 500;
825
863
  min-width: 0;
826
864
  flex: 1;
827
865
  overflow: hidden;
828
866
  text-overflow: ellipsis;
829
867
  white-space: nowrap;
830
- padding: 0 2px;
868
+ padding: 0 4px;
869
+ }
870
+
871
+ .btn-toggle-sidebar {
872
+ width: 40px;
873
+ height: 40px;
874
+ padding: 0;
875
+ color: var(--text);
876
+ background: var(--bg-hover);
877
+ }
878
+ .btn-toggle-sidebar svg {
879
+ width: 22px;
880
+ height: 22px;
831
881
  }
832
882
 
833
883
  #cwdPillWrap {
834
- padding: 4px 8px;
884
+ padding: 5px 9px;
835
885
  font-size: 11px;
836
- max-width: 95px;
886
+ max-width: 90px;
837
887
  overflow: hidden;
838
888
  text-overflow: ellipsis;
839
889
  white-space: nowrap;
@@ -841,22 +891,28 @@ body {
841
891
  }
842
892
 
843
893
  .model-pill {
844
- padding: 4px 8px;
894
+ padding: 5px 10px;
845
895
  font-size: 11px;
846
- max-width: 105px;
896
+ max-width: 100px;
847
897
  overflow: hidden;
848
898
  text-overflow: ellipsis;
849
899
  white-space: nowrap;
850
900
  flex-shrink: 0;
851
901
  }
852
902
 
853
- /* Model Dropdown Menu Mobile Adjustments */
903
+ /* Model Dropdown Menu Mobile Adjustments — fixed so it never detaches from topbar */
854
904
  .model-menu {
855
- width: calc(100vw - 16px);
856
- right: 8px;
905
+ position: fixed;
906
+ top: 54px;
857
907
  left: 8px;
858
- top: 46px;
859
- max-height: 280px;
908
+ right: 8px;
909
+ width: auto;
910
+ max-height: calc(100dvh - 70px);
911
+ border-radius: 12px;
912
+ }
913
+
914
+ .mobile-toolbar-fab {
915
+ display: flex;
860
916
  }
861
917
 
862
918
  /* Chat area mobile adjustments */
package/server.js CHANGED
@@ -42,9 +42,13 @@ function normalizeCwd(dir) {
42
42
  const SESSIONS_DIR = process.env.PI_SESSIONS_DIR || path.join(home(), ".pi", "agent", "sessions");
43
43
  const PORT = process.env.PORT || 3000;
44
44
 
45
- // Idle timeout (ms) before a detached (browser closed) pi RPC subprocess is killed.
46
- // Replaces the previous hardcoded 5-minute timeout. Lower values free memory faster
47
- // on memory-constrained hosts; set IDLE_TIMEOUT_MS=0 to disable cleanup entirely.
45
+ // Idle timeout (ms). When a pi agent has NO WebSocket attached AND is truly
46
+ // idle (no streaming/pending tasks), after this many ms the subprocess is
47
+ // killed to reclaim memory. Browser-close alone does NOT trigger this only
48
+ // true idleness does — so a task that is mid-flight keeps running in the
49
+ // background after you close the tab, and survives until it finishes.
50
+ // Set IDLE_TIMEOUT_MS=0 to disable idle reclamation entirely (agents live
51
+ // until MAX_AGENT_LIFETIME_MS or server shutdown).
48
52
  const IDLE_TIMEOUT_MS = (() => {
49
53
  const raw = process.env.IDLE_TIMEOUT_MS;
50
54
  if (raw === undefined || raw === "") return 5 * 60 * 1000;
@@ -56,6 +60,30 @@ const IDLE_TIMEOUT_MS = (() => {
56
60
  return n;
57
61
  })();
58
62
 
63
+ // Hard ceiling on how long a background pi agent may live, even if still busy.
64
+ // Protects against runaway agents that never terminate. 0 = unlimited.
65
+ const MAX_AGENT_LIFETIME_MS = (() => {
66
+ const raw = process.env.MAX_AGENT_LIFETIME_MS;
67
+ if (raw === undefined || raw === "") return 30 * 60 * 1000; // 30 min
68
+ const n = Number(raw);
69
+ if (!Number.isFinite(n) || n < 0) {
70
+ console.warn(`[pi-web-chat] Invalid MAX_AGENT_LIFETIME_MS="${raw}", falling back to 1800000`);
71
+ return 30 * 60 * 1000;
72
+ }
73
+ return n;
74
+ })();
75
+
76
+ // How many pi->browser events to buffer while no WebSocket is attached, so a
77
+ // reconnecting client can replay what happened in the background after they
78
+ // closed the tab. Ring buffer; oldest events are dropped on overflow.
79
+ const EVENT_BUFFER_SIZE = (() => {
80
+ const raw = process.env.EVENT_BUFFER_SIZE;
81
+ if (raw === undefined || raw === "") return 2000;
82
+ const n = Number(raw);
83
+ if (!Number.isInteger(n) || n < 0) return 2000;
84
+ return n;
85
+ })();
86
+
59
87
  // Maximum number of concurrently-pooled pi RPC subprocesses. New WebSocket
60
88
  // connections beyond the cap are rejected with a clear message instead of
61
89
  // silently exhausting memory. Set MAX_CONCURRENT_AGENTS=0 to disable.
@@ -77,6 +105,8 @@ const MAX_CONCURRENT_AGENTS = (() => {
77
105
  // idle timer, reducing pressure on small-memory hosts. Default: false.
78
106
  const IDLE_DROP_HEAP = process.env.IDLE_DROP_HEAP === "1" || process.env.IDLE_DROP_HEAP === "true";
79
107
 
108
+ const nowMs = () => Date.now();
109
+
80
110
  // Active pi RPC processes pooled by session key (`${cwd}:${resolvedSessionPath}`)
81
111
  const activeAgents = new Map();
82
112
 
@@ -90,27 +120,42 @@ class PiAgent {
90
120
  this.proc = null;
91
121
  this.buffer = "";
92
122
  this.alive = false;
93
- this.cleanupTimer = null;
123
+ // lifecycle / background-task state
124
+ this.state = "idle"; // "idle" | "streaming"
125
+ this.idleTimer = null; // true-idle reclamation timer
126
+ this.lifetimeTimer = null; // hard max-lifetime kill
127
+ this.startedAt = 0;
128
+ this.lastActivityAt = 0;
129
+ // ring buffer of pi->browser events while no socket is attached, so a
130
+ // reconnecting client can replay what happened in the background.
131
+ this.eventBuffer = [];
132
+ this.bufferHead = 0;
133
+ // lightweight summary of the task currently running in background, for
134
+ // the /api/agents dashboard and reconnecting clients.
135
+ this.lastUserPrompt = null;
94
136
  }
95
137
 
138
+ get hasWs() { return this.sockets.size > 0; }
139
+ get isBusy() { return this.state === "streaming" || this.pending.size > 0; }
140
+
96
141
  attachWs(ws) {
97
142
  this.sockets.add(ws);
98
- this.cancelCleanup();
143
+ this.cancelIdleKill();
144
+ // Replay buffered background events to the newly attached client so it can
145
+ // catch up on whatever pi produced while no one was watching.
146
+ this.replayBuffered(ws);
99
147
  }
100
148
 
101
149
  detachWs(ws) {
102
150
  this.sockets.delete(ws);
103
151
  if (this.sockets.size === 0) {
104
- // Keep process alive for IDLE_TIMEOUT_MS so a browser refresh/reconnect
105
- // can re-attach to the same pi RPC subprocess. If IDLE_TIMEOUT_MS is 0,
106
- // scheduleCleanup still runs but stops the process immediately.
107
- const idleMs = IDLE_TIMEOUT_MS === 0 ? 0 : IDLE_TIMEOUT_MS;
108
- this.scheduleCleanup(idleMs);
109
- if (IDLE_DROP_HEAP && idleMs > 0) {
110
- // Give the OS a hint that this process is a candidate for early
111
- // reclamation before the idle timer fires. Cheaper than swap pressure.
152
+ // Browser closed. We do NOT kill the subprocess here: a background task
153
+ // keeps running. We only arm the idle-kill, which fires once the agent
154
+ // is truly idle (no streaming, no pending requests) for IDLE_TIMEOUT_MS.
155
+ if (IDLE_DROP_HEAP) {
112
156
  try { if (typeof global.gc === "function") global.gc(); } catch {}
113
157
  }
158
+ this.maybeScheduleIdleKill();
114
159
  }
115
160
  }
116
161
 
@@ -125,32 +170,57 @@ class PiAgent {
125
170
  activeAgents.set(key, this);
126
171
  }
127
172
 
128
- scheduleCleanup(delayMs = 300000) {
129
- this.cancelCleanup();
130
- // delayMs === 0 means "kill the subprocess right now" — used when an
131
- // explicit teardown is requested without bypassing the cleanup pipeline.
132
- if (delayMs === 0) {
133
- console.log(`Cleaning up pi agent immediately (key=${this.sessionKey || "unkeyed"})`);
134
- this.stop();
135
- return;
136
- }
137
- this.cleanupTimer = setTimeout(() => {
138
- console.log(`Cleaning up inactive pi agent after ${Math.round(delayMs / 1000)}s idle (key=${this.sessionKey || "unkeyed"})`);
139
- // One final heap drop just before we kill the subprocess.
173
+ markActivity() {
174
+ this.lastActivityAt = nowMs();
175
+ // any activity cancels a pending idle-kill; it will be re-armed when idle.
176
+ this.cancelIdleKill();
177
+ if (!this.hasWs && !this.isBusy) this.maybeScheduleIdleKill();
178
+ }
179
+
180
+ setStreaming(streaming) {
181
+ this.state = streaming ? "streaming" : "idle";
182
+ this.markActivity();
183
+ }
184
+
185
+ maybeScheduleIdleKill() {
186
+ // Only arm the reclamation timer when: no client attached AND truly idle.
187
+ // A background task that is still running must never be killed here.
188
+ if (this.hasWs || this.isBusy) return;
189
+ if (IDLE_TIMEOUT_MS === 0) return; // disabled
190
+ this.cancelIdleKill();
191
+ const ms = IDLE_TIMEOUT_MS;
192
+ this.idleTimer = setTimeout(() => {
193
+ this.idleTimer = null;
194
+ // re-check at fire time — a reconnect or new task may have started.
195
+ if (this.hasWs || this.isBusy) return;
196
+ console.log(`Reclaiming truly-idle pi agent after ${Math.round(ms / 1000)}s (key=${this.sessionKey || "unkeyed"})`);
140
197
  if (IDLE_DROP_HEAP) {
141
198
  try { if (typeof global.gc === "function") global.gc(); } catch {}
142
199
  }
143
200
  this.stop();
144
- }, delayMs);
201
+ }, ms);
145
202
  }
146
203
 
147
- cancelCleanup() {
148
- if (this.cleanupTimer) {
149
- clearTimeout(this.cleanupTimer);
150
- this.cleanupTimer = null;
204
+ cancelIdleKill() {
205
+ if (this.idleTimer) {
206
+ clearTimeout(this.idleTimer);
207
+ this.idleTimer = null;
151
208
  }
152
209
  }
153
210
 
211
+ // Back-compat shim for any caller that used the old name.
212
+ scheduleCleanup(delayMs = 300000) {
213
+ this.cancelIdleKill();
214
+ if (delayMs === 0) { this.stop(); return; }
215
+ // treat as immediate idle-kill after the given delay only if truly idle
216
+ this.idleTimer = setTimeout(() => {
217
+ this.idleTimer = null;
218
+ if (this.hasWs || this.isBusy) return;
219
+ this.stop();
220
+ }, delayMs);
221
+ }
222
+ cancelCleanup() { this.cancelIdleKill(); }
223
+
154
224
  start() {
155
225
  const args = [PI_BIN, "--mode", "rpc", "--session-dir", SESSIONS_DIR];
156
226
  this.proc = spawn(args[0], args.slice(1), {
@@ -158,6 +228,14 @@ class PiAgent {
158
228
  env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" },
159
229
  });
160
230
  this.alive = true;
231
+ this.startedAt = nowMs();
232
+ this.lastActivityAt = this.startedAt;
233
+ if (MAX_AGENT_LIFETIME_MS > 0) {
234
+ this.lifetimeTimer = setTimeout(() => {
235
+ console.warn(`pi agent hit MAX_AGENT_LIFETIME_MS (${Math.round(MAX_AGENT_LIFETIME_MS / 1000)}s), force-stopping (key=${this.sessionKey || "unkeyed"})`);
236
+ this.stop();
237
+ }, MAX_AGENT_LIFETIME_MS);
238
+ }
161
239
  this.proc.on("error", (err) => {
162
240
  this.alive = false;
163
241
  console.error(`[pi spawn error]`, err);
@@ -176,6 +254,8 @@ class PiAgent {
176
254
  if (this.sessionKey) activeAgents.delete(this.sessionKey);
177
255
  for (const s of this.sockets) { try { s.close(); } catch {} }
178
256
  this.sockets.clear();
257
+ this.cancelIdleKill();
258
+ if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
179
259
  });
180
260
  }
181
261
 
@@ -199,13 +279,52 @@ class PiAgent {
199
279
  if (obj.data?.sessionFile) {
200
280
  this.setSessionKey(this.cwd, obj.data.sessionFile);
201
281
  }
282
+ // Track streaming lifecycle so background tasks are not killed mid-flight.
283
+ switch (obj.type) {
284
+ case "agent_start": this.setStreaming(true); break;
285
+ case "agent_end": this.setStreaming(false); break;
286
+ case "agent_settled": this.setStreaming(false); break;
287
+ case "pi_exit": this.state = "idle"; break;
288
+ }
289
+ // Capture the most recent user prompt for the background-task dashboard.
290
+ if (obj.type === "remote_user_prompt" || obj.type === "remote_user_steer") {
291
+ this.lastUserPrompt = { text: obj.message, isSteer: !!obj.isSteer, at: nowMs() };
292
+ }
202
293
  // RPC responses carry `id`; events do not.
203
294
  if (obj.type === "response" && obj.id) {
204
295
  const res = this.pending.get(obj.id);
205
296
  if (res) { this.pending.delete(obj.id); res(obj); }
297
+ this.markActivity();
206
298
  }
207
- // Forward every event / response to the browser as-is.
299
+ // Forward every event / response to connected browsers as-is.
208
300
  this.wsSend(obj);
301
+ // If nobody is listening, remember it so a reconnect can replay.
302
+ if (!this.hasWs) this.bufferEvent(obj);
303
+ }
304
+
305
+ bufferEvent(obj) {
306
+ if (EVENT_BUFFER_SIZE <= 0) return;
307
+ if (this.eventBuffer.length < EVENT_BUFFER_SIZE) {
308
+ this.eventBuffer.push(obj);
309
+ } else {
310
+ this.eventBuffer[this.bufferHead] = obj;
311
+ this.bufferHead = (this.bufferHead + 1) % EVENT_BUFFER_SIZE;
312
+ }
313
+ }
314
+
315
+ replayBuffered(ws) {
316
+ if (this.eventBuffer.length === 0) return;
317
+ if (ws.readyState !== 1) return;
318
+ const count = this.eventBuffer.length;
319
+ // Send a marker so the client knows the next burst is backfill, not live.
320
+ try { ws.send(JSON.stringify({ type: "backfill_start", count })); } catch {}
321
+ const start = count === EVENT_BUFFER_SIZE ? this.bufferHead : 0;
322
+ for (let i = 0; i < count; i++) {
323
+ const idx = (start + i) % EVENT_BUFFER_SIZE;
324
+ const ev = this.eventBuffer[idx];
325
+ try { ws.send(JSON.stringify(ev)); } catch {}
326
+ }
327
+ try { ws.send(JSON.stringify({ type: "backfill_end", streaming: this.isBusy, state: this.state })); } catch {}
209
328
  }
210
329
 
211
330
  send(cmd) {
@@ -214,11 +333,13 @@ class PiAgent {
214
333
  const id = String(++this.reqId);
215
334
  const payload = { ...cmd, id };
216
335
  this.pending.set(id, resolve);
336
+ this.markActivity();
217
337
  this.proc.stdin.write(JSON.stringify(payload) + "\n");
218
338
  // Safety: timeout so a dropped response doesn't leak the promise.
219
339
  setTimeout(() => {
220
340
  if (this.pending.has(id)) {
221
341
  this.pending.delete(id);
342
+ this.markActivity();
222
343
  resolve({ type: "response", id, success: false, error: "timeout" });
223
344
  }
224
345
  }, 60000);
@@ -227,25 +348,50 @@ class PiAgent {
227
348
 
228
349
  sendNoReply(cmd) {
229
350
  if (!this.alive) throw new Error("pi process not alive");
351
+ this.markActivity();
230
352
  this.proc.stdin.write(JSON.stringify(cmd) + "\n");
231
353
  }
232
354
 
233
355
  wsSend(obj, excludeSocket = null) {
234
356
  const payload = typeof obj === "string" ? obj : JSON.stringify(obj);
235
- for (const ws of this.sockets) {
357
+ for (const ws of [...this.sockets]) {
358
+ if (ws.readyState > 1) { // CLOSING or CLOSED
359
+ this.sockets.delete(ws);
360
+ continue;
361
+ }
236
362
  if (ws !== excludeSocket && ws.readyState === 1) {
237
363
  try {
238
364
  ws.send(payload);
239
365
  } catch (e) {
240
366
  console.error("wsSend error", e);
367
+ this.sockets.delete(ws);
241
368
  }
242
369
  }
243
370
  }
244
371
  }
245
372
 
373
+ status() {
374
+ return {
375
+ cwd: this.cwd,
376
+ sessionKey: this.sessionKey,
377
+ alive: this.alive,
378
+ state: this.state,
379
+ busy: this.isBusy,
380
+ hasClients: this.hasWs,
381
+ clientCount: this.sockets.size,
382
+ pendingRequests: this.pending.size,
383
+ startedAt: this.startedAt || null,
384
+ lastActivityAt: this.lastActivityAt || null,
385
+ uptimeMs: this.startedAt ? nowMs() - this.startedAt : 0,
386
+ bufferedEvents: this.eventBuffer.length,
387
+ lastUserPrompt: this.lastUserPrompt,
388
+ };
389
+ }
390
+
246
391
  stop() {
247
392
  this.alive = false;
248
- this.cancelCleanup();
393
+ this.cancelIdleKill();
394
+ if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
249
395
  if (this.sessionKey) {
250
396
  activeAgents.delete(this.sessionKey);
251
397
  }
@@ -291,6 +437,17 @@ app.get("/api/validate-dir", async (req, res) => {
291
437
  }
292
438
  });
293
439
 
440
+ // Endpoint listing all live background pi agents (for dashboards / debug).
441
+ // Shows which sessions are still running headlessly after the browser closed.
442
+ app.get("/api/agents", (req, res) => {
443
+ const agents = [];
444
+ for (const [key, a] of activeAgents.entries()) {
445
+ if (!a.alive) continue;
446
+ agents.push({ key, ...a.status() });
447
+ }
448
+ res.json({ count: agents.length, idleTimeoutMs: IDLE_TIMEOUT_MS, maxLifetimeMs: MAX_AGENT_LIFETIME_MS, agents });
449
+ });
450
+
294
451
  // Scan SESSIONS_DIR for .jsonl files in BOTH the root AND every subdirectory.
295
452
  // Why both? Because pi stores sessions under a cwd-encoded subdir (e.g.
296
453
  // `--home-zrlgs--`) when left to its own device, but our server passes
@@ -317,6 +474,47 @@ async function listAllSessionFiles() {
317
474
  return files;
318
475
  }
319
476
 
477
+ // Memory metadata cache keyed by file path -> { mtimeMs, cwd, sessionInfo }
478
+ const sessionMetadataCache = new Map();
479
+
480
+ async function getSessionMetadata(file) {
481
+ const fileStat = await stat(file);
482
+ const cached = sessionMetadataCache.get(file);
483
+ if (cached && cached.mtimeMs === fileStat.mtimeMs) {
484
+ return cached;
485
+ }
486
+
487
+ const content = await readFile(file, "utf8");
488
+ const lines = content.split("\n").filter(Boolean);
489
+ let header = null, title = null, msgCount = 0;
490
+ for (const line of lines) {
491
+ let o;
492
+ try { o = JSON.parse(line); } catch { continue; }
493
+ if (o.type === "session") header = o;
494
+ if (o.type === "message" && o.message && o.message.role === "user" && !title) {
495
+ title = extractText(o.message.content).slice(0, 80);
496
+ }
497
+ if (o.type === "message") msgCount++;
498
+ }
499
+
500
+ if (!header) return null;
501
+
502
+ const result = {
503
+ mtimeMs: fileStat.mtimeMs,
504
+ cwd: normalizeCwd(header.cwd),
505
+ sessionInfo: {
506
+ file,
507
+ name: path.basename(file),
508
+ id: header.id,
509
+ timestamp: header.timestamp,
510
+ firstUser: title,
511
+ messageCount: msgCount,
512
+ },
513
+ };
514
+ sessionMetadataCache.set(file, result);
515
+ return result;
516
+ }
517
+
320
518
  app.get("/api/sessions", async (req, res) => {
321
519
  try {
322
520
  const cwd = normalizeCwd(req.query.cwd);
@@ -324,27 +522,10 @@ app.get("/api/sessions", async (req, res) => {
324
522
  const sessions = [];
325
523
  for (const full of all) {
326
524
  try {
327
- const content = await readFile(full, "utf8");
328
- const lines = content.split("\n").filter(Boolean);
329
- let header = null, title = null, msgCount = 0;
330
- for (const line of lines) {
331
- let o;
332
- try { o = JSON.parse(line); } catch { continue; }
333
- if (o.type === "session") header = o;
334
- if (o.type === "message" && o.message && o.message.role === "user" && !title) {
335
- title = extractText(o.message.content).slice(0, 80);
336
- }
337
- if (o.type === "message") msgCount++;
525
+ const meta = await getSessionMetadata(full);
526
+ if (meta && meta.cwd === cwd) {
527
+ sessions.push(meta.sessionInfo);
338
528
  }
339
- if (!header || normalizeCwd(header.cwd) !== cwd) continue;
340
- sessions.push({
341
- file: full,
342
- name: path.basename(full),
343
- id: header.id,
344
- timestamp: header.timestamp,
345
- firstUser: title,
346
- messageCount: msgCount,
347
- });
348
529
  } catch {}
349
530
  }
350
531
  sessions.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
@@ -526,6 +707,7 @@ wss.on("connection", (ws, req) => {
526
707
  case "prompt":
527
708
  // Sync prompt to other connected clients in the same session
528
709
  agent.wsSend({ type: "remote_user_prompt", message: msg.message, images: msg.images }, ws);
710
+ agent.lastUserPrompt = { text: msg.message, isSteer: false, at: nowMs() };
529
711
  agent.send({ type: "prompt", message: msg.message, images: msg.images });
530
712
  break;
531
713
  case "abort":
@@ -541,6 +723,7 @@ wss.on("connection", (ws, req) => {
541
723
  case "steer":
542
724
  // Sync steer instruction to other connected clients in the same session
543
725
  agent.wsSend({ type: "remote_user_prompt", message: msg.message, isSteer: true }, ws);
726
+ agent.lastUserPrompt = { text: msg.message, isSteer: true, at: nowMs() };
544
727
  agent.send({ type: "steer", message: msg.message });
545
728
  break;
546
729
  case "set_session_name":
@@ -576,3 +759,22 @@ wss.on("connection", (ws, req) => {
576
759
  }
577
760
  });
578
761
  });
762
+
763
+ // ---- Graceful shutdown: stop all background pi agents on exit ----
764
+ function shutdownAllAgents(reason) {
765
+ console.log(`\n[pi-web-chat] ${reason}: stopping ${activeAgents.size} background pi agent(s)…`);
766
+ for (const a of [...activeAgents.values()]) {
767
+ try { a.stop(); } catch {}
768
+ }
769
+ clearInterval(heartbeatInterval);
770
+ try { wss.close(); } catch {}
771
+ try { httpServer.close(); } catch {}
772
+ }
773
+ process.on("SIGINT", () => { shutdownAllAgents("SIGINT"); process.exit(0); });
774
+ process.on("SIGTERM", () => { shutdownAllAgents("SIGTERM"); process.exit(0); });
775
+ process.on("exit", () => {
776
+ // best-effort: kill any still-living children synchronously on hard exit
777
+ for (const a of activeAgents.values()) {
778
+ try { a.proc && a.proc.kill("SIGKILL"); } catch {}
779
+ }
780
+ });