@liguoshuai/pi-web-chat 1.5.0 → 1.6.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/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/public/app.js +45 -0
- package/public/index.html +8 -0
- package/public/style.css +74 -18
- package/server.js +198 -32
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.6.0] - 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **后台任务继续运行 (Background Task Persistence)**:
|
|
14
|
+
- 关闭浏览器/标签页不再强制中止正在生成的 pi 任务:后台 pi RPC 子进程保持**跑完当前这一轮**;重连后可看到完整结果。
|
|
15
|
+
- `PiAgent` 跟踪 `state`(`idle`/`streaming`)与 `pending` 请求。只有在“**无 WebSocket 且真正空闲**”(无 streaming 也无未响应请求)时才启动空闲回收计时。
|
|
16
|
+
- 时长型缓冲区:在后台期间渲染器产出的事件会被**离线缓存**到 `EVENT_BUFFER_SIZE`(默认 2000 条)的环形 buffer。新连接上来时自动 **回放**为 `backfill_start` → N 条原始事件 → `backfill_end` 三个阶段包裹的消息,便于前端精确“追到哪儿”。
|
|
17
|
+
- 新增 REST 端点 `GET /api/agents`:查看所有存活后台 pi 代理(`state`, `alive`, `busy`, `hasClients`, `uptimeMs`, `bufferedEvents`, 最近一条用户提示等)。常驻 npm 下载、上传、调试或后台 面板都可以利用。
|
|
18
|
+
- 新增环境变量:`MAX_AGENT_LIFETIME_MS`(默认 1800000 = 30分钟,硬上限超出强制 `SIGTERM`;设为 0 禁用)。防止后台代理失控常驻。
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- **`IDLE_TIMEOUT_MS` 语义重要变更**:从“**断开后多久**杀进程”变为“**真正空闲后多久**才回收”(默认还是 5min)。如果断开后还在 streaming 或有余未完成的 RPC 请求,定时器不会触发,任务不会被中断。
|
|
22
|
+
- 首页顶栏重连后会同步服务器状态:收到 `backfill_end` 后会自动滚动到底,并恢复 `streaming` 状态(如仍在后台继续)。
|
|
23
|
+
- 首页增加优雅关闭:`SIGINT`/`SIGTERM` 会“逐个停止所有后台 pi 进程”后再退出,防止 server 重启时留下 zombie 进程。
|
|
24
|
+
- 不再支持“`IDLE_TIMEOUT_MS=0` 断开即杀”告诉——该环境变量现在表示“**禁用空闲回收**”。需要立刻释放内存,请改为正数(如 `5000`)或重启 server。
|
|
25
|
+
|
|
26
|
+
### 技术说明
|
|
27
|
+
- `PiAgent` 生命周期事件:`agent_start → state=streaming`;`agent_end/agent_settled → state=idle`;`pi_exit → state=idle`。这是“背景继续跑完”的关键开关。
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
10
31
|
## [1.5.0] - 2026-07-26
|
|
11
32
|
|
|
12
33
|
### Added
|
package/package.json
CHANGED
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
|
}
|
|
@@ -996,6 +1019,25 @@ function sendWs(obj) {
|
|
|
996
1019
|
}
|
|
997
1020
|
|
|
998
1021
|
function handlePiMessage(obj) {
|
|
1022
|
+
// Backfill markers emitted by the server when it replays buffered events
|
|
1023
|
+
// that happened in the background while no browser was attached.
|
|
1024
|
+
if (obj.type === "backfill_start") {
|
|
1025
|
+
state.isBackfilling = true;
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (obj.type === "backfill_end") {
|
|
1029
|
+
state.isBackfilling = false;
|
|
1030
|
+
// After replay, sync the composer / streaming state to what the server thinks.
|
|
1031
|
+
if (obj.streaming) {
|
|
1032
|
+
state.streaming = true;
|
|
1033
|
+
setComposerAborting(true);
|
|
1034
|
+
ensureStreamingMsg();
|
|
1035
|
+
refreshStreamingContent();
|
|
1036
|
+
}
|
|
1037
|
+
// jump to the latest content once the replay is done
|
|
1038
|
+
requestAnimationFrame(scrollBottom);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
999
1041
|
// Responses to commands we issued (get_state etc.) come back with success+data.
|
|
1000
1042
|
if (obj.type === "response") {
|
|
1001
1043
|
if (obj.command === "get_state" && obj.success) updateState(obj.data);
|
|
@@ -1593,6 +1635,9 @@ function init() {
|
|
|
1593
1635
|
}
|
|
1594
1636
|
});
|
|
1595
1637
|
|
|
1638
|
+
// Mobile: floating button to jump back to the toolbar after long scrolls
|
|
1639
|
+
initMobileToolbarFab();
|
|
1640
|
+
|
|
1596
1641
|
refreshSessions();
|
|
1597
1642
|
// start in the disconnected state; connectWs will flip to green on open.
|
|
1598
1643
|
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:
|
|
813
|
-
padding: 0
|
|
814
|
-
gap:
|
|
815
|
-
|
|
816
|
-
position: sticky;
|
|
843
|
+
height: 54px;
|
|
844
|
+
padding: 0 12px;
|
|
845
|
+
gap: 8px;
|
|
846
|
+
position: fixed;
|
|
817
847
|
top: 0;
|
|
818
|
-
|
|
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:
|
|
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
|
|
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:
|
|
884
|
+
padding: 5px 9px;
|
|
835
885
|
font-size: 11px;
|
|
836
|
-
max-width:
|
|
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:
|
|
894
|
+
padding: 5px 10px;
|
|
845
895
|
font-size: 11px;
|
|
846
|
-
max-width:
|
|
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
|
-
|
|
856
|
-
|
|
905
|
+
position: fixed;
|
|
906
|
+
top: 54px;
|
|
857
907
|
left: 8px;
|
|
858
|
-
|
|
859
|
-
|
|
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)
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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,41 @@ class PiAgent {
|
|
|
90
120
|
this.proc = null;
|
|
91
121
|
this.buffer = "";
|
|
92
122
|
this.alive = false;
|
|
93
|
-
|
|
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
|
+
// lightweight summary of the task currently running in background, for
|
|
133
|
+
// the /api/agents dashboard and reconnecting clients.
|
|
134
|
+
this.lastUserPrompt = null;
|
|
94
135
|
}
|
|
95
136
|
|
|
137
|
+
get hasWs() { return this.sockets.size > 0; }
|
|
138
|
+
get isBusy() { return this.state === "streaming" || this.pending.size > 0; }
|
|
139
|
+
|
|
96
140
|
attachWs(ws) {
|
|
97
141
|
this.sockets.add(ws);
|
|
98
|
-
this.
|
|
142
|
+
this.cancelIdleKill();
|
|
143
|
+
// Replay buffered background events to the newly attached client so it can
|
|
144
|
+
// catch up on whatever pi produced while no one was watching.
|
|
145
|
+
this.replayBuffered(ws);
|
|
99
146
|
}
|
|
100
147
|
|
|
101
148
|
detachWs(ws) {
|
|
102
149
|
this.sockets.delete(ws);
|
|
103
150
|
if (this.sockets.size === 0) {
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
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.
|
|
151
|
+
// Browser closed. We do NOT kill the subprocess here: a background task
|
|
152
|
+
// keeps running. We only arm the idle-kill, which fires once the agent
|
|
153
|
+
// is truly idle (no streaming, no pending requests) for IDLE_TIMEOUT_MS.
|
|
154
|
+
if (IDLE_DROP_HEAP) {
|
|
112
155
|
try { if (typeof global.gc === "function") global.gc(); } catch {}
|
|
113
156
|
}
|
|
157
|
+
this.maybeScheduleIdleKill();
|
|
114
158
|
}
|
|
115
159
|
}
|
|
116
160
|
|
|
@@ -125,32 +169,57 @@ class PiAgent {
|
|
|
125
169
|
activeAgents.set(key, this);
|
|
126
170
|
}
|
|
127
171
|
|
|
128
|
-
|
|
129
|
-
this.
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
this.
|
|
138
|
-
|
|
139
|
-
|
|
172
|
+
markActivity() {
|
|
173
|
+
this.lastActivityAt = nowMs();
|
|
174
|
+
// any activity cancels a pending idle-kill; it will be re-armed when idle.
|
|
175
|
+
this.cancelIdleKill();
|
|
176
|
+
if (!this.hasWs && !this.isBusy) this.maybeScheduleIdleKill();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setStreaming(streaming) {
|
|
180
|
+
this.state = streaming ? "streaming" : "idle";
|
|
181
|
+
this.markActivity();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
maybeScheduleIdleKill() {
|
|
185
|
+
// Only arm the reclamation timer when: no client attached AND truly idle.
|
|
186
|
+
// A background task that is still running must never be killed here.
|
|
187
|
+
if (this.hasWs || this.isBusy) return;
|
|
188
|
+
if (IDLE_TIMEOUT_MS === 0) return; // disabled
|
|
189
|
+
this.cancelIdleKill();
|
|
190
|
+
const ms = IDLE_TIMEOUT_MS;
|
|
191
|
+
this.idleTimer = setTimeout(() => {
|
|
192
|
+
this.idleTimer = null;
|
|
193
|
+
// re-check at fire time — a reconnect or new task may have started.
|
|
194
|
+
if (this.hasWs || this.isBusy) return;
|
|
195
|
+
console.log(`Reclaiming truly-idle pi agent after ${Math.round(ms / 1000)}s (key=${this.sessionKey || "unkeyed"})`);
|
|
140
196
|
if (IDLE_DROP_HEAP) {
|
|
141
197
|
try { if (typeof global.gc === "function") global.gc(); } catch {}
|
|
142
198
|
}
|
|
143
199
|
this.stop();
|
|
144
|
-
},
|
|
200
|
+
}, ms);
|
|
145
201
|
}
|
|
146
202
|
|
|
147
|
-
|
|
148
|
-
if (this.
|
|
149
|
-
clearTimeout(this.
|
|
150
|
-
this.
|
|
203
|
+
cancelIdleKill() {
|
|
204
|
+
if (this.idleTimer) {
|
|
205
|
+
clearTimeout(this.idleTimer);
|
|
206
|
+
this.idleTimer = null;
|
|
151
207
|
}
|
|
152
208
|
}
|
|
153
209
|
|
|
210
|
+
// Back-compat shim for any caller that used the old name.
|
|
211
|
+
scheduleCleanup(delayMs = 300000) {
|
|
212
|
+
this.cancelIdleKill();
|
|
213
|
+
if (delayMs === 0) { this.stop(); return; }
|
|
214
|
+
// treat as immediate idle-kill after the given delay only if truly idle
|
|
215
|
+
this.idleTimer = setTimeout(() => {
|
|
216
|
+
this.idleTimer = null;
|
|
217
|
+
if (this.hasWs || this.isBusy) return;
|
|
218
|
+
this.stop();
|
|
219
|
+
}, delayMs);
|
|
220
|
+
}
|
|
221
|
+
cancelCleanup() { this.cancelIdleKill(); }
|
|
222
|
+
|
|
154
223
|
start() {
|
|
155
224
|
const args = [PI_BIN, "--mode", "rpc", "--session-dir", SESSIONS_DIR];
|
|
156
225
|
this.proc = spawn(args[0], args.slice(1), {
|
|
@@ -158,6 +227,14 @@ class PiAgent {
|
|
|
158
227
|
env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" },
|
|
159
228
|
});
|
|
160
229
|
this.alive = true;
|
|
230
|
+
this.startedAt = nowMs();
|
|
231
|
+
this.lastActivityAt = this.startedAt;
|
|
232
|
+
if (MAX_AGENT_LIFETIME_MS > 0) {
|
|
233
|
+
this.lifetimeTimer = setTimeout(() => {
|
|
234
|
+
console.warn(`pi agent hit MAX_AGENT_LIFETIME_MS (${Math.round(MAX_AGENT_LIFETIME_MS / 1000)}s), force-stopping (key=${this.sessionKey || "unkeyed"})`);
|
|
235
|
+
this.stop();
|
|
236
|
+
}, MAX_AGENT_LIFETIME_MS);
|
|
237
|
+
}
|
|
161
238
|
this.proc.on("error", (err) => {
|
|
162
239
|
this.alive = false;
|
|
163
240
|
console.error(`[pi spawn error]`, err);
|
|
@@ -176,6 +253,8 @@ class PiAgent {
|
|
|
176
253
|
if (this.sessionKey) activeAgents.delete(this.sessionKey);
|
|
177
254
|
for (const s of this.sockets) { try { s.close(); } catch {} }
|
|
178
255
|
this.sockets.clear();
|
|
256
|
+
this.cancelIdleKill();
|
|
257
|
+
if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
|
|
179
258
|
});
|
|
180
259
|
}
|
|
181
260
|
|
|
@@ -199,13 +278,46 @@ class PiAgent {
|
|
|
199
278
|
if (obj.data?.sessionFile) {
|
|
200
279
|
this.setSessionKey(this.cwd, obj.data.sessionFile);
|
|
201
280
|
}
|
|
281
|
+
// Track streaming lifecycle so background tasks are not killed mid-flight.
|
|
282
|
+
switch (obj.type) {
|
|
283
|
+
case "agent_start": this.setStreaming(true); break;
|
|
284
|
+
case "agent_end": this.setStreaming(false); break;
|
|
285
|
+
case "agent_settled": this.setStreaming(false); break;
|
|
286
|
+
case "pi_exit": this.state = "idle"; break;
|
|
287
|
+
}
|
|
288
|
+
// Capture the most recent user prompt for the background-task dashboard.
|
|
289
|
+
if (obj.type === "remote_user_prompt" || obj.type === "remote_user_steer") {
|
|
290
|
+
this.lastUserPrompt = { text: obj.message, isSteer: !!obj.isSteer, at: nowMs() };
|
|
291
|
+
}
|
|
202
292
|
// RPC responses carry `id`; events do not.
|
|
203
293
|
if (obj.type === "response" && obj.id) {
|
|
204
294
|
const res = this.pending.get(obj.id);
|
|
205
295
|
if (res) { this.pending.delete(obj.id); res(obj); }
|
|
296
|
+
this.markActivity();
|
|
206
297
|
}
|
|
207
|
-
// Forward every event / response to
|
|
298
|
+
// Forward every event / response to connected browsers as-is.
|
|
208
299
|
this.wsSend(obj);
|
|
300
|
+
// If nobody is listening, remember it so a reconnect can replay.
|
|
301
|
+
if (!this.hasWs) this.bufferEvent(obj);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
bufferEvent(obj) {
|
|
305
|
+
if (EVENT_BUFFER_SIZE <= 0) return;
|
|
306
|
+
this.eventBuffer.push(obj);
|
|
307
|
+
if (this.eventBuffer.length > EVENT_BUFFER_SIZE) {
|
|
308
|
+
this.eventBuffer.shift();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
replayBuffered(ws) {
|
|
313
|
+
if (this.eventBuffer.length === 0) return;
|
|
314
|
+
if (ws.readyState !== 1) return;
|
|
315
|
+
// Send a marker so the client knows the next burst is backfill, not live.
|
|
316
|
+
try { ws.send(JSON.stringify({ type: "backfill_start", count: this.eventBuffer.length })); } catch {}
|
|
317
|
+
for (const ev of this.eventBuffer) {
|
|
318
|
+
try { ws.send(JSON.stringify(ev)); } catch {}
|
|
319
|
+
}
|
|
320
|
+
try { ws.send(JSON.stringify({ type: "backfill_end", streaming: this.isBusy, state: this.state })); } catch {}
|
|
209
321
|
}
|
|
210
322
|
|
|
211
323
|
send(cmd) {
|
|
@@ -214,11 +326,13 @@ class PiAgent {
|
|
|
214
326
|
const id = String(++this.reqId);
|
|
215
327
|
const payload = { ...cmd, id };
|
|
216
328
|
this.pending.set(id, resolve);
|
|
329
|
+
this.markActivity();
|
|
217
330
|
this.proc.stdin.write(JSON.stringify(payload) + "\n");
|
|
218
331
|
// Safety: timeout so a dropped response doesn't leak the promise.
|
|
219
332
|
setTimeout(() => {
|
|
220
333
|
if (this.pending.has(id)) {
|
|
221
334
|
this.pending.delete(id);
|
|
335
|
+
this.markActivity();
|
|
222
336
|
resolve({ type: "response", id, success: false, error: "timeout" });
|
|
223
337
|
}
|
|
224
338
|
}, 60000);
|
|
@@ -227,6 +341,7 @@ class PiAgent {
|
|
|
227
341
|
|
|
228
342
|
sendNoReply(cmd) {
|
|
229
343
|
if (!this.alive) throw new Error("pi process not alive");
|
|
344
|
+
this.markActivity();
|
|
230
345
|
this.proc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
231
346
|
}
|
|
232
347
|
|
|
@@ -243,9 +358,28 @@ class PiAgent {
|
|
|
243
358
|
}
|
|
244
359
|
}
|
|
245
360
|
|
|
361
|
+
status() {
|
|
362
|
+
return {
|
|
363
|
+
cwd: this.cwd,
|
|
364
|
+
sessionKey: this.sessionKey,
|
|
365
|
+
alive: this.alive,
|
|
366
|
+
state: this.state,
|
|
367
|
+
busy: this.isBusy,
|
|
368
|
+
hasClients: this.hasWs,
|
|
369
|
+
clientCount: this.sockets.size,
|
|
370
|
+
pendingRequests: this.pending.size,
|
|
371
|
+
startedAt: this.startedAt || null,
|
|
372
|
+
lastActivityAt: this.lastActivityAt || null,
|
|
373
|
+
uptimeMs: this.startedAt ? nowMs() - this.startedAt : 0,
|
|
374
|
+
bufferedEvents: this.eventBuffer.length,
|
|
375
|
+
lastUserPrompt: this.lastUserPrompt,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
246
379
|
stop() {
|
|
247
380
|
this.alive = false;
|
|
248
|
-
this.
|
|
381
|
+
this.cancelIdleKill();
|
|
382
|
+
if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
|
|
249
383
|
if (this.sessionKey) {
|
|
250
384
|
activeAgents.delete(this.sessionKey);
|
|
251
385
|
}
|
|
@@ -291,6 +425,17 @@ app.get("/api/validate-dir", async (req, res) => {
|
|
|
291
425
|
}
|
|
292
426
|
});
|
|
293
427
|
|
|
428
|
+
// Endpoint listing all live background pi agents (for dashboards / debug).
|
|
429
|
+
// Shows which sessions are still running headlessly after the browser closed.
|
|
430
|
+
app.get("/api/agents", (req, res) => {
|
|
431
|
+
const agents = [];
|
|
432
|
+
for (const [key, a] of activeAgents.entries()) {
|
|
433
|
+
if (!a.alive) continue;
|
|
434
|
+
agents.push({ key, ...a.status() });
|
|
435
|
+
}
|
|
436
|
+
res.json({ count: agents.length, idleTimeoutMs: IDLE_TIMEOUT_MS, maxLifetimeMs: MAX_AGENT_LIFETIME_MS, agents });
|
|
437
|
+
});
|
|
438
|
+
|
|
294
439
|
// Scan SESSIONS_DIR for .jsonl files in BOTH the root AND every subdirectory.
|
|
295
440
|
// Why both? Because pi stores sessions under a cwd-encoded subdir (e.g.
|
|
296
441
|
// `--home-zrlgs--`) when left to its own device, but our server passes
|
|
@@ -526,6 +671,7 @@ wss.on("connection", (ws, req) => {
|
|
|
526
671
|
case "prompt":
|
|
527
672
|
// Sync prompt to other connected clients in the same session
|
|
528
673
|
agent.wsSend({ type: "remote_user_prompt", message: msg.message, images: msg.images }, ws);
|
|
674
|
+
agent.lastUserPrompt = { text: msg.message, isSteer: false, at: nowMs() };
|
|
529
675
|
agent.send({ type: "prompt", message: msg.message, images: msg.images });
|
|
530
676
|
break;
|
|
531
677
|
case "abort":
|
|
@@ -541,6 +687,7 @@ wss.on("connection", (ws, req) => {
|
|
|
541
687
|
case "steer":
|
|
542
688
|
// Sync steer instruction to other connected clients in the same session
|
|
543
689
|
agent.wsSend({ type: "remote_user_prompt", message: msg.message, isSteer: true }, ws);
|
|
690
|
+
agent.lastUserPrompt = { text: msg.message, isSteer: true, at: nowMs() };
|
|
544
691
|
agent.send({ type: "steer", message: msg.message });
|
|
545
692
|
break;
|
|
546
693
|
case "set_session_name":
|
|
@@ -576,3 +723,22 @@ wss.on("connection", (ws, req) => {
|
|
|
576
723
|
}
|
|
577
724
|
});
|
|
578
725
|
});
|
|
726
|
+
|
|
727
|
+
// ---- Graceful shutdown: stop all background pi agents on exit ----
|
|
728
|
+
function shutdownAllAgents(reason) {
|
|
729
|
+
console.log(`\n[pi-web-chat] ${reason}: stopping ${activeAgents.size} background pi agent(s)…`);
|
|
730
|
+
for (const a of [...activeAgents.values()]) {
|
|
731
|
+
try { a.stop(); } catch {}
|
|
732
|
+
}
|
|
733
|
+
clearInterval(heartbeatInterval);
|
|
734
|
+
try { wss.close(); } catch {}
|
|
735
|
+
try { httpServer.close(); } catch {}
|
|
736
|
+
}
|
|
737
|
+
process.on("SIGINT", () => { shutdownAllAgents("SIGINT"); process.exit(0); });
|
|
738
|
+
process.on("SIGTERM", () => { shutdownAllAgents("SIGTERM"); process.exit(0); });
|
|
739
|
+
process.on("exit", () => {
|
|
740
|
+
// best-effort: kill any still-living children synchronously on hard exit
|
|
741
|
+
for (const a of activeAgents.values()) {
|
|
742
|
+
try { a.proc && a.proc.kill("SIGKILL"); } catch {}
|
|
743
|
+
}
|
|
744
|
+
});
|