pi-web-ui 0.29.0 → 0.29.2

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.
@@ -39,6 +39,19 @@ const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
39
39
  /** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
40
40
  const DELTA_ACTIVE_WINDOW_MS = 1500;
41
41
  const WIDGET_REFRESH_MS = 2000;
42
+ /** Model-stall watchdog: warn (don't abort — deep thinking can be legitimately
43
+ * quiet for minutes) when a streaming run produced NO SDK events for this long.
44
+ * Covers the failure class the per-tool watchdog cannot see: half-open API
45
+ * connections / hung proxies where no tool is running and no error is thrown.
46
+ * Override: PI_WEB_STALL_NOTIFY_MS (milliseconds; 0 disables). */
47
+ const STALL_NOTIFY_MS = (() => {
48
+ const v = Number(process.env.PI_WEB_STALL_NOTIFY_MS);
49
+ return Number.isFinite(v) && v >= 0 ? v : 180_000;
50
+ })();
51
+ /** Serialization-cache cap per conversation (see serializeCached): cached
52
+ * UiMessage objects are pure-function results, so eviction only costs a
53
+ * recompute on next access. Bounds memory for marathon sessions. */
54
+ const UI_MESSAGE_CACHE_CAP = 4096;
42
55
  /** Preview panel cap: only the first 512KB of a file is ever read/sent. */
43
56
  /** Thrown when the service is quiesced (draining) and the request is NEW work
44
57
  * the admission controller refuses: a brand-new client attach, a prompt,
@@ -66,14 +79,14 @@ export class QuiesceRejectedError extends Error {
66
79
  * programs wait for input that never comes. Legacy Chinese files are often
67
80
  * GBK/GB2312 — read them with the right encoding, never paste mojibake into
68
81
  * reasoning/answers. */
69
- const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
70
-
71
-
72
-
73
- - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
74
- - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
75
- - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
76
-
82
+ const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
83
+
84
+
85
+
86
+ - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
87
+ - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
88
+ - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
89
+
77
90
  Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
78
91
  /**
79
92
  * Killable bash tool: wraps the SDK bash tool with operations that register
@@ -346,6 +359,8 @@ export class ClientSession {
346
359
  /** Web-facing extension UI context (widgets, notifications). */
347
360
  webUi = new WebUIContext((msg) => this.emit(msg));
348
361
  widgetsTimer = null;
362
+ /** Model-stall watchdog interval (see startStallTimer). */
363
+ stallTimer = null;
349
364
  /** Connected sockets for this client (multiple tabs share the session). */
350
365
  sinks = new Set();
351
366
  pendingNotices = [];
@@ -355,6 +370,13 @@ export class ClientSession {
355
370
  lastDeltaAt = 0;
356
371
  sessionsTimer = null;
357
372
  version = 0;
373
+ /** Snapshot revision counter (see emitSnapshotNow / protocol snapshot_delta). */
374
+ snapRev = 0;
375
+ /** Messages array as of the last emitted snapshot/delta — identity-walked
376
+ * against the current array to detect append-only growth. */
377
+ emittedMessages = null;
378
+ /** snapRev value at which emittedMessages was captured. */
379
+ emittedRev = 0;
358
380
  /**
359
381
  * Per-conversation serialization caches (stable message ids, UiMessage
360
382
  * object cache, message-array signature, queue counts) live inside each
@@ -552,6 +574,8 @@ export class ClientSession {
552
574
  listed: false,
553
575
  promptedSinceActive: false,
554
576
  lastActiveAt: Date.now(),
577
+ lastSdkEventAt: Date.now(),
578
+ stallNoticed: false,
555
579
  goal: this.makeGoalStatus(),
556
580
  goalGeneration: 0,
557
581
  goalReviewGeneration: 0,
@@ -570,6 +594,29 @@ export class ClientSession {
570
594
  toolWatchdogs: new Map(),
571
595
  };
572
596
  }
597
+ /** Summaries of conversations currently streaming — captured at shutdown
598
+ * so the next attach can tell the user their run was interrupted. */
599
+ streamingSummaries() {
600
+ const out = [];
601
+ for (const conv of this.convs.values()) {
602
+ if (conv.session.isStreaming)
603
+ out.push({ title: conv.title, cwd: conv.cwd });
604
+ }
605
+ return out;
606
+ }
607
+ /** Tell the user about runs lost to the last server restart (once). */
608
+ notifyInterrupted(list) {
609
+ if (!list || list.length === 0)
610
+ return;
611
+ const names = list
612
+ .map((r) => `「${r.title}」(${r.cwd})`)
613
+ .join("、");
614
+ this.pendingNotices.push({
615
+ type: "notice",
616
+ level: "warning",
617
+ text: `上次服务重启时有 ${list.length} 个进行中的对话被中断:${names}。可在历史对话中恢复继续。`,
618
+ });
619
+ }
573
620
  /** Add a socket to this client's broadcast set; flushes buffered startup notices. */
574
621
  attachSink(send) {
575
622
  this.sinks.add(send);
@@ -635,6 +682,7 @@ export class ClientSession {
635
682
  this.scheduleSnapshot();
636
683
  this.webUi.refresh();
637
684
  this.startWidgetsTimer();
685
+ this.startStallTimer();
638
686
  }
639
687
  /** Poll extension widgets so TUI-only overlays (e.g. rpiv-todo) stay live. */
640
688
  startWidgetsTimer() {
@@ -645,6 +693,32 @@ export class ClientSession {
645
693
  this.webUi.refresh();
646
694
  }, WIDGET_REFRESH_MS);
647
695
  }
696
+ /** Model-stall watchdog: warn when a streaming run went completely silent
697
+ * (no SDK events at all) for STALL_NOTIFY_MS. Deliberately does NOT abort:
698
+ * deep-thinking models can legitimately be quiet for minutes — the notice
699
+ * just tells the user the run looks stuck so they can Stop it themselves. */
700
+ startStallTimer() {
701
+ if (this.stallTimer || STALL_NOTIFY_MS === 0)
702
+ return;
703
+ this.stallTimer = setInterval(() => {
704
+ if (this.disposed)
705
+ return;
706
+ const now = Date.now();
707
+ for (const conv of this.convs.values()) {
708
+ if (!conv.stallNoticed &&
709
+ conv.session.isStreaming &&
710
+ now - conv.lastSdkEventAt > STALL_NOTIFY_MS) {
711
+ conv.stallNoticed = true;
712
+ const mins = Math.round((now - conv.lastSdkEventAt) / 60_000);
713
+ this.emit({
714
+ type: "notice",
715
+ level: "warning",
716
+ text: `对话「${conv.title}」已 ${mins} 分钟无任何响应,可能已失联(网络中断或服务端挂起)。可点击停止后重试。`,
717
+ });
718
+ }
719
+ }
720
+ }, 30_000);
721
+ }
648
722
  /** Arm the hang-guard for a tool call: if it is still running after
649
723
  * TOOL_WATCHDOG_TIMEOUT_MS, abort the session instead of letting the
650
724
  * conversation hang forever (the SDK bash tool has no default timeout). */
@@ -684,6 +758,9 @@ export class ClientSession {
684
758
  conv.toolWatchdogs.clear();
685
759
  }
686
760
  onEvent(conv, event) {
761
+ // Any SDK event proves the run is alive — feeds the stall watchdog below.
762
+ conv.lastSdkEventAt = Date.now();
763
+ conv.stallNoticed = false;
687
764
  switch (event.type) {
688
765
  case "bash_execution_update": {
689
766
  if (event.id) {
@@ -905,11 +982,42 @@ export class ClientSession {
905
982
  conv.userSeqByTs.set(ts, seq);
906
983
  }
907
984
  const msg = serializeMessage(m, seq);
908
- if (msg)
985
+ if (msg) {
909
986
  conv.uiMessageCache.set(cacheKey, msg);
987
+ // Bound the cache (marathon sessions otherwise grow without limit;
988
+ // single messages can reach TEXT_CAP = 200K chars). Map iteration is
989
+ // insertion order, so dropping from the front evicts the oldest —
990
+ // recent messages (the ones every snapshot touches) always survive.
991
+ // Safe: a miss just recomputes an identical object on next access.
992
+ let excess = conv.uiMessageCache.size - UI_MESSAGE_CACHE_CAP;
993
+ while (excess-- > 0) {
994
+ const oldest = conv.uiMessageCache.keys().next().value;
995
+ if (oldest === undefined)
996
+ break;
997
+ conv.uiMessageCache.delete(oldest);
998
+ }
999
+ }
910
1000
  return msg;
911
1001
  }
912
- snapshot() {
1002
+ /** Current messages array (with the existing sig-reuse optimization).
1003
+ * Element objects are reference-stable (serializeCached cache), which is
1004
+ * what lets emitSnapshotNow detect append-only growth via identity walk. */
1005
+ currentMessages() {
1006
+ const conv = this.conv;
1007
+ const rawMessages = conv.session.agent.state.messages
1008
+ .map((m) => this.serializeCached(m))
1009
+ .filter((m) => m !== null);
1010
+ // Reuse the previous array when nothing changed: the element objects are
1011
+ // cached (reference-stable) anyway, and a stable array reference lets the
1012
+ // frontend memoize derived maps instead of rebuilding them every 60ms.
1013
+ const sig = rawMessages.map((m) => m.id).join("\u0001");
1014
+ const messages = conv.lastMessagesSig === sig ? conv.lastMessagesArray : rawMessages;
1015
+ conv.lastMessagesSig = sig;
1016
+ conv.lastMessagesArray = rawMessages;
1017
+ return messages;
1018
+ }
1019
+ /** Build every UiState field EXCEPT messages (the expensive part). */
1020
+ buildLightState(rev) {
913
1021
  const conv = this.conv;
914
1022
  const state = conv.session.agent.state;
915
1023
  const model = state.model;
@@ -937,23 +1045,13 @@ export class ClientSession {
937
1045
  catch {
938
1046
  // stats are best-effort
939
1047
  }
940
- const rawMessages = state.messages
941
- .map((m) => this.serializeCached(m))
942
- .filter((m) => m !== null);
943
- // Reuse the previous array when nothing changed: the element objects are
944
- // cached (reference-stable) anyway, and a stable array reference lets the
945
- // frontend memoize derived maps instead of rebuilding them every 60ms.
946
- const sig = rawMessages.map((m) => m.id).join("\u0001");
947
- const messages = conv.lastMessagesSig === sig ? conv.lastMessagesArray : rawMessages;
948
- conv.lastMessagesSig = sig;
949
- conv.lastMessagesArray = rawMessages;
950
1048
  return {
951
1049
  clientId: this.clientId,
952
1050
  cwd: this.cwd,
953
1051
  sessionId: this.session.sessionId,
954
1052
  sessionFile: this.session.sessionFile,
955
1053
  conversationId: this.activeId,
956
- messages,
1054
+ rev,
957
1055
  // The in-progress assistant message lives in state.streamingMessage
958
1056
  // (the SDK only pushes it into state.messages at message_end). Surfacing
959
1057
  // it here is what makes thinking + text stream into the browser at
@@ -982,6 +1080,53 @@ export class ClientSession {
982
1080
  stats,
983
1081
  };
984
1082
  }
1083
+ /** Emit one snapshot update — incremental when possible, full otherwise.
1084
+ *
1085
+ * Persisted messages are content-immutable with reference-stable objects
1086
+ * (serializeCached), so an IDENTITY WALK over the previous array detects
1087
+ * append-only growth in O(n) pointer compares. Appends travel as
1088
+ * snapshot_delta carrying only the new tail + light fields; any mid-array
1089
+ * change/truncation (switch session, edit fork, compaction) or a forced
1090
+ * resync falls back to a full snapshot. The 10MB-stringify-per-checkpoint
1091
+ * cost of big sessions collapses to a few hundred bytes for the common
1092
+ * "nothing but stats/version changed" checkpoint. */
1093
+ emitSnapshotNow(forceFull = false) {
1094
+ if (this.disposed)
1095
+ return;
1096
+ const cur = this.currentMessages();
1097
+ const prev = this.emittedMessages;
1098
+ let incremental = !forceFull && prev !== null && prev.length <= cur.length;
1099
+ if (incremental && prev) {
1100
+ for (let i = 0; i < prev.length; i++) {
1101
+ if (prev[i] !== cur[i]) {
1102
+ incremental = false;
1103
+ break;
1104
+ }
1105
+ }
1106
+ }
1107
+ const rev = ++this.snapRev;
1108
+ if (incremental && prev) {
1109
+ const baseRev = this.emittedRev;
1110
+ this.emittedMessages = cur;
1111
+ this.emittedRev = rev;
1112
+ this.emit({
1113
+ type: "snapshot_delta",
1114
+ conversationId: this.activeId,
1115
+ rev,
1116
+ baseRev,
1117
+ appended: cur.slice(prev.length),
1118
+ state: this.buildLightState(rev),
1119
+ });
1120
+ }
1121
+ else {
1122
+ this.emittedMessages = cur;
1123
+ this.emittedRev = rev;
1124
+ this.emit({
1125
+ type: "snapshot",
1126
+ state: { ...this.buildLightState(rev), messages: cur },
1127
+ });
1128
+ }
1129
+ }
985
1130
  /** Resolve a browser-bridged dialog (select/confirm/input) for this session. */
986
1131
  resolveDialog(id, value) {
987
1132
  this.webUi.resolveDialog(id, value);
@@ -1270,14 +1415,16 @@ export class ClientSession {
1270
1415
  }
1271
1416
  this.flushSnapshot();
1272
1417
  }
1273
- /** Send a snapshot immediately (cancels any pending throttled one). */
1274
- flushSnapshot() {
1418
+ /** Send a snapshot immediately (cancels any pending throttled one).
1419
+ * forceFull skips the incremental path — used by get_state so a (re)
1420
+ * connecting or desynced client always receives an authoritative full
1421
+ * state it can rebuild from. */
1422
+ flushSnapshot(forceFull = false) {
1275
1423
  if (this.snapshotTimer) {
1276
1424
  clearTimeout(this.snapshotTimer);
1277
1425
  this.snapshotTimer = null;
1278
1426
  }
1279
- if (!this.disposed)
1280
- this.emit({ type: "snapshot", state: this.snapshot() });
1427
+ this.emitSnapshotNow(forceFull);
1281
1428
  }
1282
1429
  scheduleSnapshot() {
1283
1430
  if (this.snapshotTimer || this.disposed)
@@ -1290,8 +1437,7 @@ export class ClientSession {
1290
1437
  : SNAPSHOT_INTERVAL_MS;
1291
1438
  this.snapshotTimer = setTimeout(() => {
1292
1439
  this.snapshotTimer = null;
1293
- if (!this.disposed)
1294
- this.emit({ type: "snapshot", state: this.snapshot() });
1440
+ this.emitSnapshotNow();
1295
1441
  }, interval);
1296
1442
  }
1297
1443
  /** Slash-command catalog + native command execution — 自包含模块,见
@@ -1485,6 +1631,9 @@ export class ClientSession {
1485
1631
  // per-project "most recently active" order used by set_cwd.)
1486
1632
  conv.promptedSinceActive = true;
1487
1633
  conv.lastActiveAt = Date.now();
1634
+ // Fresh run — restart the stall watchdog window.
1635
+ conv.lastSdkEventAt = Date.now();
1636
+ conv.stallNoticed = false;
1488
1637
  this.flushSnapshot();
1489
1638
  }
1490
1639
  /**
@@ -2002,6 +2151,10 @@ export class ClientSession {
2002
2151
  async listFiles(relPath) {
2003
2152
  return this.files.listFiles(relPath);
2004
2153
  }
2154
+ /** 全局搜索:递归文件名匹配(结果经 search_files_result 回推,reqId 匹配)。 */
2155
+ async searchFiles(query, reqId) {
2156
+ return this.files.searchFiles(query, reqId);
2157
+ }
2005
2158
  /** SCM 只读查询(结构化 JSON,reqId 匹配)。 */
2006
2159
  async scmQuery(kind, reqId, arg) {
2007
2160
  return this.files.scmQuery(kind, reqId, arg);
@@ -2260,6 +2413,10 @@ export class ClientSession {
2260
2413
  clearInterval(this.widgetsTimer);
2261
2414
  this.widgetsTimer = null;
2262
2415
  }
2416
+ if (this.stallTimer) {
2417
+ clearInterval(this.stallTimer);
2418
+ this.stallTimer = null;
2419
+ }
2263
2420
  this.files.unwatchDir();
2264
2421
  this.files.unwatchGit();
2265
2422
  this.webUi.dispose();
@@ -2402,6 +2559,10 @@ export class AgentService {
2402
2559
  }
2403
2560
  }
2404
2561
  }
2562
+ // First attach after a restart: report runs that were interrupted when
2563
+ // the previous process shut down (consumed once, then cleared). Queue
2564
+ // BEFORE attachSink so the notice rides the initial pending-notice flush.
2565
+ cs.notifyInterrupted(this.stateStore.takeInterrupted(clientId));
2405
2566
  cs.attachSink(send);
2406
2567
  // Forward hooks (set once by index.ts) to every session.
2407
2568
  cs.onUpdateReady = this.onUpdateReady;
@@ -2417,6 +2578,19 @@ export class AgentService {
2417
2578
  return this.clients.get(clientId);
2418
2579
  }
2419
2580
  async disposeAll() {
2581
+ // Record still-streaming conversations BEFORE tearing anything down, so
2582
+ // the next attach can tell the user what was lost (SIGTERM / update).
2583
+ for (const [clientId, cs] of [...this.clients]) {
2584
+ try {
2585
+ const running = cs.streamingSummaries();
2586
+ if (running.length > 0) {
2587
+ this.stateStore.saveInterrupted(clientId, running.map((r) => ({ ...r, at: Date.now() })));
2588
+ }
2589
+ }
2590
+ catch {
2591
+ // best effort — never block shutdown on bookkeeping
2592
+ }
2593
+ }
2420
2594
  const all = [...this.clients.values()];
2421
2595
  this.clients.clear();
2422
2596
  await Promise.all(all.map((cs) => cs.dispose()));
@@ -1,4 +1,4 @@
1
- import { killPidTree, lookupProcessName, snapshotListeningPorts } from "./process-utils.js";
1
+ import { killPidTree, lookupProcessName, lookupProcessCommandLine, snapshotListeningPorts, } from "./process-utils.js";
2
2
  const BG_REFRESH_INTERVAL_MS = 30_000;
3
3
  /** bash 结束后等这么久再拍「后」快照——给后台服务绑定端口的时间。 */
4
4
  const BG_BIND_WAIT_MS = 1500;
@@ -43,7 +43,8 @@ export class BgServerTracker {
43
43
  if (!before.has(port) && !this.servers.has(port)) {
44
44
  this.servers.set(port, { pid, since: Date.now() });
45
45
  added = true;
46
- // Best-effort process name so the panel shows something readable.
46
+ // Best-effort process name + full command line so the panel shows
47
+ // something readable (name) AND what is actually running (command).
47
48
  void lookupProcessName(pid).then((name) => {
48
49
  const cur = this.servers.get(port);
49
50
  if (cur && cur.pid === pid && name) {
@@ -51,6 +52,13 @@ export class BgServerTracker {
51
52
  this.push();
52
53
  }
53
54
  });
55
+ void lookupProcessCommandLine(pid).then((command) => {
56
+ const cur = this.servers.get(port);
57
+ if (cur && cur.pid === pid && command) {
58
+ cur.command = command;
59
+ this.push();
60
+ }
61
+ });
54
62
  this.opts.emit({
55
63
  type: "notice",
56
64
  level: "info",
@@ -69,6 +77,7 @@ export class BgServerTracker {
69
77
  pid: v.pid,
70
78
  since: v.since,
71
79
  ...(v.name ? { name: v.name } : {}),
80
+ ...(v.command ? { command: v.command } : {}),
72
81
  }))
73
82
  .sort((a, b) => a.since - b.since);
74
83
  }
@@ -92,6 +92,28 @@ export class ClientStateStore {
92
92
  };
93
93
  this.save();
94
94
  }
95
+ /** Remember conversations that were still streaming at shutdown (best-
96
+ * effort; called during the graceful-shutdown path). */
97
+ saveInterrupted(clientId, list) {
98
+ if (list.length === 0)
99
+ return;
100
+ const all = this.load();
101
+ const state = (all[clientId] ??= { projects: [] });
102
+ state.interrupted = list.slice(0, 8);
103
+ this.save();
104
+ }
105
+ /** Consume the interrupted-conversation record (returns and clears it) —
106
+ * called once on the client's first attach after a restart. */
107
+ takeInterrupted(clientId) {
108
+ const all = this.load();
109
+ const state = all[clientId];
110
+ const list = state?.interrupted;
111
+ if (list?.length && state) {
112
+ delete state.interrupted;
113
+ this.save();
114
+ }
115
+ return list;
116
+ }
95
117
  /** Last-used settings-panel state for a client, or defaults. */
96
118
  getSettings(clientId) {
97
119
  const s = this.load()[clientId];
@@ -191,6 +191,82 @@ export class FilesService {
191
191
  truncated,
192
192
  });
193
193
  }
194
+ /**
195
+ * Global search: recursive filename match across the active workspace.
196
+ * Best-effort bounded walk — ignored dirs (node_modules/.git/…) are
197
+ * skipped, unreadable dirs silently passed, and hard caps on results /
198
+ * visited entries / elapsed time keep big repos responsive. Always answers
199
+ * with a search_files_result echoing reqId so the client's request never
200
+ * stalls (ok:false on unexpected failure).
201
+ */
202
+ async searchFiles(query, reqId) {
203
+ const { join } = await import("node:path");
204
+ const fsp = await import("node:fs/promises");
205
+ const q = query.trim().toLowerCase();
206
+ if (!q) {
207
+ this.host.emit({ type: "search_files_result", reqId, ok: true, results: [] });
208
+ return;
209
+ }
210
+ const root = resolve(this.host.getActiveCwd());
211
+ const ignored = ignoredEntries();
212
+ const MAX_RESULTS = 50;
213
+ const MAX_VISITED = 20000;
214
+ const MAX_MS = 4000;
215
+ const start = Date.now();
216
+ const results = [];
217
+ let visited = 0;
218
+ let truncated = false;
219
+ const budgetLeft = () => results.length < MAX_RESULTS &&
220
+ visited < MAX_VISITED &&
221
+ Date.now() - start < MAX_MS;
222
+ // Breadth-first-ish iterative stack; depth cap is a symlink-cycle guard.
223
+ const walk = async (abs, rel, depth) => {
224
+ if (!budgetLeft() || depth > 24) {
225
+ truncated = true;
226
+ return;
227
+ }
228
+ let dirents;
229
+ try {
230
+ dirents = await fsp.readdir(abs, { withFileTypes: true });
231
+ }
232
+ catch {
233
+ return; // unreadable dir (ACL/permissions) — skip silently
234
+ }
235
+ for (const d of dirents) {
236
+ visited++;
237
+ if (!budgetLeft()) {
238
+ truncated = true;
239
+ break;
240
+ }
241
+ if (ignored.has(d.name))
242
+ continue;
243
+ const childRel = rel ? `${rel}/${d.name}` : d.name;
244
+ if (d.name.toLowerCase().includes(q)) {
245
+ results.push({
246
+ path: childRel,
247
+ name: d.name,
248
+ type: d.isDirectory() ? "dir" : "file",
249
+ });
250
+ }
251
+ if (d.isDirectory()) {
252
+ await walk(join(abs, d.name), childRel, depth + 1);
253
+ }
254
+ }
255
+ };
256
+ try {
257
+ await walk(root, "", 0);
258
+ this.host.emit({
259
+ type: "search_files_result",
260
+ reqId,
261
+ ok: true,
262
+ results,
263
+ ...(truncated ? { truncated: true } : {}),
264
+ });
265
+ }
266
+ catch {
267
+ this.host.emit({ type: "search_files_result", reqId, ok: false, results: [] });
268
+ }
269
+ }
194
270
  /**
195
271
  * Source-control panel: read-only git queries via server-side execFile
196
272
  * (no shell, no prompts). Always responds with an scm_data message echoing
@@ -450,7 +450,7 @@ wss.on("connection", (ws) => {
450
450
  // 每份 ~10MB 的全量 snapshot 字符串,低内存主机直接 OOM。snapshot 是全量
451
451
  // 幂等的且 60ms 后必有更新的一份,可以安全丢弃——在序列化之前丢,连
452
452
  // stringify 的分配都省掉。ready/notice/error/tool_delta 等消息必须送达。
453
- if (msg.type === "snapshot" &&
453
+ if ((msg.type === "snapshot" || msg.type === "snapshot_delta") &&
454
454
  ws.bufferedAmount > SNAPSHOT_BACKPRESSURE_BYTES) {
455
455
  return;
456
456
  }
@@ -499,7 +499,9 @@ wss.on("connection", (ws) => {
499
499
  cs.cycleThinking();
500
500
  break;
501
501
  case "get_state":
502
- cs.flushSnapshot();
502
+ // Always a FULL snapshot: the client is (re)connecting or detected
503
+ // a rev/seq gap — it needs an authoritative state to rebuild from.
504
+ cs.flushSnapshot(true);
503
505
  break;
504
506
  case "get_commands":
505
507
  void cs.pushSlashCommands();
@@ -519,6 +521,9 @@ wss.on("connection", (ws) => {
519
521
  case "list_files":
520
522
  void cs.listFiles(msg.path);
521
523
  break;
524
+ case "search_files":
525
+ void cs.searchFiles(msg.query, msg.reqId);
526
+ break;
522
527
  case "scm_status":
523
528
  void cs.scmQuery("status", msg.reqId);
524
529
  break;
@@ -65,6 +65,32 @@ export function killPidTree(pid) {
65
65
  // already dead
66
66
  }
67
67
  }
68
+ /** Best-effort full command line of a pid (PowerShell CIM on win32 — wmic is
69
+ * gone on recent Win11 builds; ps -o command= on POSIX). Returns undefined
70
+ * when the process is gone or the lookup fails. */
71
+ export async function lookupProcessCommandLine(pid) {
72
+ try {
73
+ const { execFile } = await import("node:child_process");
74
+ if (process.platform === "win32") {
75
+ // CIM query is slower (~1s) than tasklist but this is a one-shot
76
+ // best-effort probe fired once per detected background server.
77
+ const out = await new Promise((resolve, reject) => execFile("powershell.exe", [
78
+ "-NoProfile",
79
+ "-NonInteractive",
80
+ "-Command",
81
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
82
+ ], { windowsHide: true, timeout: 10000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
83
+ const line = out.trim();
84
+ return line || undefined;
85
+ }
86
+ const out = await new Promise((resolve, reject) => execFile("ps", ["-o", "command=", "-p", String(pid)], { timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
87
+ const line = out.trim();
88
+ return line || undefined;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
68
94
  /** Best-effort process name for a pid (tasklist on win32, ps on POSIX).
69
95
  * Returns undefined when the process is gone or the lookup fails. */
70
96
  export async function lookupProcessName(pid) {
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 1;
11
+ export const PROTOCOL_VERSION = 3;