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.
- package/dist/server/agent-service.js +201 -27
- package/dist/server/bg-servers.js +11 -2
- package/dist/server/client-state.js +22 -0
- package/dist/server/files-service.js +76 -0
- package/dist/server/index.js +7 -2
- package/dist/server/process-utils.js +26 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/terminals.js +40 -5
- package/package.json +96 -96
- package/themes/light.css +431 -0
- package/web/dist/assets/{TerminalPanel-B-bsYqea.js → TerminalPanel-D7lQ3g5k.js} +1 -1
- package/web/dist/assets/index-BiJTtKxQ.js +18 -0
- package/web/dist/assets/index-DxO8_hl1.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BsrqFaSZ.js +0 -13
- package/web/dist/assets/index-Dsb8Bak1.css +0 -10
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|