@liguoshuai/pi-web-chat 1.8.4 → 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/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/public/app.js +78 -24
- package/public/style.css +12 -3
- package/scripts/install-service.sh +2 -1
- package/server.js +69 -21
package/docs/ARCHITECTURE.md
CHANGED
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ 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
|
+
|
|
10
23
|
## [1.8.4] - 2026-07-26
|
|
11
24
|
|
|
12
25
|
### Added
|
package/package.json
CHANGED
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;
|
|
@@ -576,8 +577,12 @@ async function syncSessionHistory(file, force = false) {
|
|
|
576
577
|
}
|
|
577
578
|
|
|
578
579
|
async function loadSession(file) {
|
|
580
|
+
if (state.streaming) {
|
|
581
|
+
abortGeneration();
|
|
582
|
+
}
|
|
579
583
|
state.currentSessionFile = file;
|
|
580
584
|
state.streaming = false;
|
|
585
|
+
state.aborting = false;
|
|
581
586
|
state.streamingItems = [];
|
|
582
587
|
state.streamingMsg = null;
|
|
583
588
|
state.activeToolCalls.clear();
|
|
@@ -1295,6 +1300,9 @@ function handlePiMessage(obj) {
|
|
|
1295
1300
|
renderThinkingPill();
|
|
1296
1301
|
updateEmptyStateModelInfo();
|
|
1297
1302
|
}
|
|
1303
|
+
} else if (obj.command === "abort") {
|
|
1304
|
+
state.aborting = false;
|
|
1305
|
+
setComposerAborting(false);
|
|
1298
1306
|
}
|
|
1299
1307
|
else if (obj.command === "switch_session" && obj.success) {
|
|
1300
1308
|
// ask pi for current state so we can get session id, name
|
|
@@ -1329,6 +1337,7 @@ function handlePiMessage(obj) {
|
|
|
1329
1337
|
break;
|
|
1330
1338
|
case "agent_start":
|
|
1331
1339
|
state.streaming = true;
|
|
1340
|
+
state.aborting = false;
|
|
1332
1341
|
setComposerAborting(true);
|
|
1333
1342
|
ensureStreamingMsg();
|
|
1334
1343
|
refreshStreamingContent();
|
|
@@ -1340,6 +1349,7 @@ function handlePiMessage(obj) {
|
|
|
1340
1349
|
case "agent_settled":
|
|
1341
1350
|
finalizeStreamingMsg();
|
|
1342
1351
|
state.streaming = false;
|
|
1352
|
+
state.aborting = false;
|
|
1343
1353
|
setComposerAborting(false);
|
|
1344
1354
|
sendWs({ type: "get_state" });
|
|
1345
1355
|
refreshSessions(); // titles may have changed
|
|
@@ -1465,6 +1475,7 @@ function handlePiMessage(obj) {
|
|
|
1465
1475
|
case "pi_exit":
|
|
1466
1476
|
finalizeStreamingMsg();
|
|
1467
1477
|
state.streaming = false;
|
|
1478
|
+
state.aborting = false;
|
|
1468
1479
|
setComposerAborting(false);
|
|
1469
1480
|
$("#connDot").style.color = "var(--danger)";
|
|
1470
1481
|
break;
|
|
@@ -1560,6 +1571,7 @@ function updateState(d) {
|
|
|
1560
1571
|
} else if (state.streaming) {
|
|
1561
1572
|
finalizeStreamingMsg();
|
|
1562
1573
|
state.streaming = false;
|
|
1574
|
+
state.aborting = false;
|
|
1563
1575
|
setComposerAborting(false);
|
|
1564
1576
|
if (state.currentSessionFile) {
|
|
1565
1577
|
syncSessionHistory(state.currentSessionFile, true);
|
|
@@ -1947,14 +1959,12 @@ function renderModelList(listContainer) {
|
|
|
1947
1959
|
const opt = el("div", {
|
|
1948
1960
|
class: "opt" + (active ? " active" : ""),
|
|
1949
1961
|
onclick: () => {
|
|
1950
|
-
const prev = state.currentModel;
|
|
1951
1962
|
$("#modelPillName").textContent = "切换中…";
|
|
1952
1963
|
sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
|
|
1953
1964
|
saveRecentModel(m);
|
|
1954
1965
|
$("#modelMenu").classList.remove("open");
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
}
|
|
1966
|
+
// 成功后的“已切换模型至 …”提示统一由 set_model 的 response 处理逻辑弹出,
|
|
1967
|
+
// 避免在这里乐观提示一次、响应到达后 pi 再提示一次(重复提示)。
|
|
1958
1968
|
},
|
|
1959
1969
|
}, [
|
|
1960
1970
|
el("span", { class: "check", html: active ? "✓" : "" }),
|
|
@@ -2064,8 +2074,24 @@ function updateComposerUI() {
|
|
|
2064
2074
|
const text = ta ? ta.value.trim() : "";
|
|
2065
2075
|
const sendBtn = $("#sendBtn");
|
|
2066
2076
|
const steerBtn = $("#steerBtn");
|
|
2077
|
+
const inner = $("#composerInner");
|
|
2067
2078
|
|
|
2068
|
-
if (
|
|
2079
|
+
if (inner) {
|
|
2080
|
+
inner.classList.toggle("aborting", !!state.aborting);
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
if (state.aborting) {
|
|
2084
|
+
if (sendBtn) {
|
|
2085
|
+
sendBtn.classList.add("stop");
|
|
2086
|
+
sendBtn.disabled = true;
|
|
2087
|
+
sendBtn.textContent = "⏳";
|
|
2088
|
+
sendBtn.title = "中止中…";
|
|
2089
|
+
}
|
|
2090
|
+
if (steerBtn) {
|
|
2091
|
+
steerBtn.style.display = "none";
|
|
2092
|
+
}
|
|
2093
|
+
if (ta) ta.placeholder = "正在中止当前任务…";
|
|
2094
|
+
} else if (state.streaming) {
|
|
2069
2095
|
if (sendBtn) {
|
|
2070
2096
|
sendBtn.classList.add("stop");
|
|
2071
2097
|
sendBtn.disabled = !state.wsConnected;
|
|
@@ -2096,10 +2122,34 @@ function updateComposerUI() {
|
|
|
2096
2122
|
}
|
|
2097
2123
|
|
|
2098
2124
|
function setComposerAborting(yes) {
|
|
2125
|
+
if (!yes) {
|
|
2126
|
+
state.aborting = false;
|
|
2127
|
+
const inner = $("#composerInner");
|
|
2128
|
+
if (inner) inner.classList.remove("aborting");
|
|
2129
|
+
const hint = $(".composer-hint");
|
|
2130
|
+
if (hint && hint.textContent.includes("中止")) {
|
|
2131
|
+
hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。";
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2099
2134
|
updateComposerUI();
|
|
2100
2135
|
renderModelPill();
|
|
2101
2136
|
}
|
|
2102
2137
|
|
|
2138
|
+
function abortGeneration() {
|
|
2139
|
+
if (!state.streaming && !state.aborting) return;
|
|
2140
|
+
if (!state.wsConnected) {
|
|
2141
|
+
const hint = $(".composer-hint");
|
|
2142
|
+
if (hint) hint.textContent = "操作失败:WebSocket 未连接。正在尝试重连…";
|
|
2143
|
+
scheduleReconnect(0);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
state.aborting = true;
|
|
2147
|
+
updateComposerUI();
|
|
2148
|
+
const hint = $(".composer-hint");
|
|
2149
|
+
if (hint) hint.textContent = "中止当前任务中…";
|
|
2150
|
+
sendWs({ type: "abort" });
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2103
2153
|
function submitSteer() {
|
|
2104
2154
|
const ta = $("#composer");
|
|
2105
2155
|
const text = ta.value.trim();
|
|
@@ -2126,8 +2176,19 @@ function submitSteer() {
|
|
|
2126
2176
|
}
|
|
2127
2177
|
|
|
2128
2178
|
function submitPrompt() {
|
|
2179
|
+
if (state.streaming) {
|
|
2180
|
+
const ta = $("#composer");
|
|
2181
|
+
const text = ta ? ta.value.trim() : "";
|
|
2182
|
+
if (text) {
|
|
2183
|
+
submitSteer();
|
|
2184
|
+
} else {
|
|
2185
|
+
abortGeneration();
|
|
2186
|
+
}
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2129
2190
|
const ta = $("#composer");
|
|
2130
|
-
const text = ta.value.trim();
|
|
2191
|
+
const text = ta ? ta.value.trim() : "";
|
|
2131
2192
|
const hint = $(".composer-hint");
|
|
2132
2193
|
if (!text) return;
|
|
2133
2194
|
if (!state.wsConnected) {
|
|
@@ -2137,16 +2198,7 @@ function submitPrompt() {
|
|
|
2137
2198
|
if (box) { box.style.boxShadow = "0 0 0 2px var(--danger)"; setTimeout(() => { box.style.boxShadow = ""; }, 350); }
|
|
2138
2199
|
return;
|
|
2139
2200
|
}
|
|
2140
|
-
|
|
2141
|
-
if (text) {
|
|
2142
|
-
submitSteer();
|
|
2143
|
-
return;
|
|
2144
|
-
}
|
|
2145
|
-
if (hint) hint.textContent = "中止当前生成中…";
|
|
2146
|
-
sendWs({ type: "abort" });
|
|
2147
|
-
return;
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2201
|
+
|
|
2150
2202
|
if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
|
|
2151
2203
|
// Render the user's message locally for instant feedback.
|
|
2152
2204
|
appendMessageNode("user", { text });
|
|
@@ -2154,6 +2206,7 @@ function submitPrompt() {
|
|
|
2154
2206
|
autoResize();
|
|
2155
2207
|
|
|
2156
2208
|
state.streaming = true;
|
|
2209
|
+
state.aborting = false;
|
|
2157
2210
|
setComposerAborting(true);
|
|
2158
2211
|
ensureStreamingMsg();
|
|
2159
2212
|
refreshStreamingContent();
|
|
@@ -2183,7 +2236,7 @@ async function init() {
|
|
|
2183
2236
|
$("#btnNew").addEventListener("click", () => {
|
|
2184
2237
|
if (state.streaming) {
|
|
2185
2238
|
if (!confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
|
|
2186
|
-
|
|
2239
|
+
abortGeneration();
|
|
2187
2240
|
}
|
|
2188
2241
|
clearChat();
|
|
2189
2242
|
showEmptyState(true);
|
|
@@ -2200,12 +2253,7 @@ async function init() {
|
|
|
2200
2253
|
|
|
2201
2254
|
$("#sendBtn").addEventListener("click", () => {
|
|
2202
2255
|
if (state.streaming) {
|
|
2203
|
-
|
|
2204
|
-
if (ta && ta.value.trim()) {
|
|
2205
|
-
submitSteer();
|
|
2206
|
-
} else {
|
|
2207
|
-
submitPrompt();
|
|
2208
|
-
}
|
|
2256
|
+
abortGeneration();
|
|
2209
2257
|
} else {
|
|
2210
2258
|
submitPrompt();
|
|
2211
2259
|
}
|
|
@@ -2259,11 +2307,17 @@ async function init() {
|
|
|
2259
2307
|
});
|
|
2260
2308
|
}
|
|
2261
2309
|
|
|
2262
|
-
// Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector
|
|
2310
|
+
// Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector, Escape to abort if streaming
|
|
2263
2311
|
window.addEventListener("keydown", (e) => {
|
|
2264
2312
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
|
|
2265
2313
|
e.preventDefault();
|
|
2266
2314
|
toggleModelMenu();
|
|
2315
|
+
} else if (e.key === "Escape" && state.streaming) {
|
|
2316
|
+
const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
|
|
2317
|
+
const isModalOpen = $("#cwdModal")?.classList.contains("open");
|
|
2318
|
+
if (!isMenuOpen && !isModalOpen) {
|
|
2319
|
+
abortGeneration();
|
|
2320
|
+
}
|
|
2267
2321
|
}
|
|
2268
2322
|
});
|
|
2269
2323
|
|
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 {
|
|
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);
|
|
@@ -708,7 +712,10 @@ body {
|
|
|
708
712
|
transition: border-color .15s;
|
|
709
713
|
}
|
|
710
714
|
.composer-inner:focus-within { border-color: var(--text-dim); }
|
|
711
|
-
.composer-inner.aborting {
|
|
715
|
+
.composer-inner.aborting {
|
|
716
|
+
border-color: var(--danger);
|
|
717
|
+
box-shadow: 0 0 0 1px var(--danger);
|
|
718
|
+
}
|
|
712
719
|
.composer textarea {
|
|
713
720
|
flex: 1; background: transparent; border: none; outline: none; resize: none;
|
|
714
721
|
color: var(--text); font-family: inherit; font-size: 15px; line-height: 1.5;
|
|
@@ -717,10 +724,12 @@ body {
|
|
|
717
724
|
.composer .send-btn {
|
|
718
725
|
width: 36px; height: 32px; border-radius: 18px; border: none; cursor: pointer;
|
|
719
726
|
background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center;
|
|
720
|
-
flex-shrink: 0; transition: background .15s, opacity .15s;
|
|
727
|
+
flex-shrink: 0; transition: background .15s, opacity .15s, transform .1s;
|
|
721
728
|
}
|
|
722
729
|
.composer .send-btn:disabled { background: #3a3a3a; color: #6f6f6f; cursor: not-allowed; }
|
|
723
730
|
.composer .send-btn.stop { background: var(--danger); }
|
|
731
|
+
.composer .send-btn.stop:hover:not(:disabled) { background: #f87171; }
|
|
732
|
+
.composer .send-btn.stop:active:not(:disabled) { transform: scale(0.95); }
|
|
724
733
|
.composer .steer-btn {
|
|
725
734
|
display: inline-flex;
|
|
726
735
|
align-items: center;
|
|
@@ -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="
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
}
|
|
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
|
-
|
|
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
|
|
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)
|
|
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":
|