@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.5.0",
3
+ "version": "1.6.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",
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: 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,41 @@ 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
+ // 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.cancelCleanup();
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
- // 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.
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
- 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.
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
- }, delayMs);
200
+ }, ms);
145
201
  }
146
202
 
147
- cancelCleanup() {
148
- if (this.cleanupTimer) {
149
- clearTimeout(this.cleanupTimer);
150
- this.cleanupTimer = null;
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 the browser as-is.
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.cancelCleanup();
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
+ });