pi-web-ui 0.62.0 → 0.63.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/LICENSE +21 -21
- package/README.md +504 -497
- package/README.zh-CN.md +427 -422
- package/bin/pi-web-ui.mjs +1809 -1881
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +54 -54
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +318 -88
- package/dist/server/attachments.js +37 -28
- package/dist/server/bg-servers.js +6 -1
- package/dist/server/client-state.js +15 -7
- package/dist/server/control-socket.js +1 -5
- package/dist/server/dsh/dsh-agent-service.js +454 -118
- package/dist/server/dsh/dsh-client.js +1 -3
- package/dist/server/dsh/dsh-sessions.js +4 -14
- package/dist/server/dsh/runtime/cordis.yml +1 -1
- package/dist/server/dsh/runtime/goal-rpc.mjs +645 -662
- package/dist/server/dsh/runtime/launcher.mjs +8 -18
- package/dist/server/dsh/runtime/override.patch.yml +9 -9
- package/dist/server/dsh/runtime/runtime-root.mjs +7 -11
- package/dist/server/ensure-bash.js +2 -5
- package/dist/server/files-service.js +42 -36
- package/dist/server/goal-service.js +59 -30
- package/dist/server/index.js +17 -10
- package/dist/server/mcp-bridge.js +11 -5
- package/dist/server/model-admin.js +78 -72
- package/dist/server/plugin-facilities.js +2 -4
- package/dist/server/plugin-updater.js +1 -1
- package/dist/server/plugins.js +26 -8
- package/dist/server/process-utils.js +4 -4
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/scm.js +27 -21
- package/dist/server/serialize.js +1 -3
- package/dist/server/settings-service.js +63 -21
- package/dist/server/slash-commands.js +29 -3
- package/dist/server/subagent-templates.js +249 -0
- package/dist/server/subagents.js +150 -0
- package/dist/server/terminals.js +101 -40
- package/dist/server/text-sniff.js +4 -24
- package/dist/server/update-check.js +2 -4
- package/dist/server/uploads.js +1 -3
- package/dist/server/vision-bridge.js +2 -7
- package/dist/server/wait-subscription-scan.js +102 -21
- package/dist/server/webui-context.js +1 -1
- package/extensions/webui.ts +190 -192
- package/package.json +109 -103
- package/web/dist/assets/TerminalPanel-B9vcI7-_.js +2 -0
- package/web/dist/assets/index-B8Wv29p5.css +10 -0
- package/web/dist/assets/index-CWUWD0rq.js +323 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/TerminalPanel-BkCWDsIG.js +0 -2
- package/web/dist/assets/index-BRVKed7W.js +0 -321
- package/web/dist/assets/index-ORTpR6ZQ.css +0 -10
|
@@ -11,28 +11,29 @@
|
|
|
11
11
|
* so reconnects just re-request a snapshot.
|
|
12
12
|
*/
|
|
13
13
|
import { spawn, spawnSync } from "node:child_process";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
|
|
16
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
16
17
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations,
|
|
18
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
18
19
|
import { Type } from "typebox";
|
|
19
20
|
import { BgServerTracker } from "./bg-servers.js";
|
|
20
21
|
import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, } from "./update-check.js";
|
|
21
|
-
import { hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
|
|
22
|
+
import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
|
|
22
23
|
import { syncPluginToolsIntoSession } from "./plugins.js";
|
|
23
24
|
import { SettingsService } from "./settings-service.js";
|
|
24
25
|
import { GoalService } from "./goal-service.js";
|
|
25
26
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
26
27
|
import { ModelAdminService } from "./model-admin.js";
|
|
27
28
|
import { FilesService, workspacePath } from "./files-service.js";
|
|
28
|
-
import { isExtensionDisabled,
|
|
29
|
-
import {
|
|
29
|
+
import { isExtensionDisabled, isExtensionEnabled, ClientStateStore } from "./client-state.js";
|
|
30
|
+
import { SubagentTemplatesStore } from "./subagent-templates.js";
|
|
30
31
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
|
|
31
32
|
import { WebUIContext } from "./webui-context.js";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
33
|
+
import { makeSubagentTools, subagentTitle, } from "./subagents.js";
|
|
34
|
+
import { buildAttachmentMessages } from "./attachments.js";
|
|
35
|
+
import { serializeMessage, serializeStreamingMessage } from "./serialize.js";
|
|
36
|
+
import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
|
|
36
37
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
37
38
|
/** While assistant deltas are flowing, live rendering is carried by
|
|
38
39
|
* message_delta — full snapshots become pure reconciliation checkpoints, so
|
|
@@ -82,14 +83,14 @@ export class QuiesceRejectedError extends Error {
|
|
|
82
83
|
* programs wait for input that never comes. Legacy Chinese files are often
|
|
83
84
|
* GBK/GB2312 — read them with the right encoding, never paste mojibake into
|
|
84
85
|
* reasoning/answers. */
|
|
85
|
-
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:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
- 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.
|
|
90
|
-
- 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.
|
|
91
|
-
- 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\`).
|
|
92
|
-
|
|
86
|
+
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:
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
- 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.
|
|
91
|
+
- 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.
|
|
92
|
+
- 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\`).
|
|
93
|
+
|
|
93
94
|
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.`;
|
|
94
95
|
/** bash 工具输出限制/过滤管道引导:模型习惯套 `| tail/-n`、`| head`、`| grep`、`| less`
|
|
95
96
|
* 等限输出。这些管道在持久终端里会①缓冲(可见终端全程哑火、看不到实时进度)②把退出码
|
|
@@ -181,9 +182,7 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
|
|
|
181
182
|
*/
|
|
182
183
|
function pluginToolToDefinition(tool) {
|
|
183
184
|
const normalize = (result) => {
|
|
184
|
-
if (result &&
|
|
185
|
-
typeof result === "object" &&
|
|
186
|
-
Array.isArray(result.content)) {
|
|
185
|
+
if (result && typeof result === "object" && Array.isArray(result.content)) {
|
|
187
186
|
return result;
|
|
188
187
|
}
|
|
189
188
|
const text = typeof result === "string" ? result : JSON.stringify(result ?? null, null, 2);
|
|
@@ -200,9 +199,7 @@ function pluginToolToDefinition(tool) {
|
|
|
200
199
|
properties: {},
|
|
201
200
|
}),
|
|
202
201
|
execute: async (toolCallId, params, signal, onUpdate) => {
|
|
203
|
-
const raw = await tool.execute(toolCallId, params, signal, onUpdate
|
|
204
|
-
? (partial) => onUpdate(normalize(partial))
|
|
205
|
-
: undefined);
|
|
202
|
+
const raw = await tool.execute(toolCallId, params, signal, onUpdate ? (partial) => onUpdate(normalize(partial)) : undefined);
|
|
206
203
|
return normalize(raw);
|
|
207
204
|
},
|
|
208
205
|
};
|
|
@@ -240,13 +237,10 @@ function contentFingerprint(m) {
|
|
|
240
237
|
// with a mock theme to plain text lines, and push them to the client.
|
|
241
238
|
// ---------------------------------------------------------------------------
|
|
242
239
|
function extractPartialText(partial) {
|
|
243
|
-
const content = partial
|
|
244
|
-
?.content;
|
|
240
|
+
const content = partial?.content;
|
|
245
241
|
if (Array.isArray(content)) {
|
|
246
242
|
const text = content
|
|
247
|
-
.map((c) => c?.type === "text"
|
|
248
|
-
? c.text
|
|
249
|
-
: "")
|
|
243
|
+
.map((c) => (c?.type === "text" ? c.text : ""))
|
|
250
244
|
.join("");
|
|
251
245
|
return text.length > 0 ? text : null;
|
|
252
246
|
}
|
|
@@ -531,6 +525,77 @@ export class ClientSession {
|
|
|
531
525
|
.catch(() => { });
|
|
532
526
|
}
|
|
533
527
|
}
|
|
528
|
+
/** 创建子代理 conversation(inMemory runtime + 独立 terminals),listed 入左栏,
|
|
529
|
+
* 并在其上触发一次完整回合。返回 convId(= 工具 runId)。 */
|
|
530
|
+
async spawnSubagentConversation(prompt, type, cwd, apply) {
|
|
531
|
+
const conversationId = `sa-${randomUUID().slice(0, 8)}`;
|
|
532
|
+
const terminals = this.makeTerminalManager(conversationId, cwd);
|
|
533
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply), {
|
|
534
|
+
cwd,
|
|
535
|
+
agentDir: this.agentDir,
|
|
536
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
537
|
+
});
|
|
538
|
+
const conv = this.makeConversation(runtime, conversationId, terminals);
|
|
539
|
+
conv.isSubagent = true;
|
|
540
|
+
conv.subagentType = type;
|
|
541
|
+
conv.listed = true;
|
|
542
|
+
conv.title = subagentTitle(prompt);
|
|
543
|
+
this.convs.set(conv.id, conv);
|
|
544
|
+
// 扩展绑定(rpc 模式;不给 uiContext,避免与主对话的 widget 冲突)。
|
|
545
|
+
try {
|
|
546
|
+
await conv.session.bindExtensions({
|
|
547
|
+
mode: "rpc",
|
|
548
|
+
onError: (err) => this.emit({ type: "notice", level: "error", text: err.error }),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// 绑定失败不阻断运行。
|
|
553
|
+
}
|
|
554
|
+
// 触发回合(后台执行;失败转识为通知)。
|
|
555
|
+
void conv.session.sendUserMessage(prompt).catch((err) => {
|
|
556
|
+
this.emit({
|
|
557
|
+
type: "notice",
|
|
558
|
+
level: "error",
|
|
559
|
+
text: `子代理 ${conversationId} 启动失败: ${err instanceof Error ? err.message : String(err)}`,
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
this.emitConversations();
|
|
563
|
+
return conv.id;
|
|
564
|
+
}
|
|
565
|
+
getSubagentSnapshot(convId) {
|
|
566
|
+
const conv = this.convs.get(convId);
|
|
567
|
+
if (!conv?.isSubagent || !conv.session)
|
|
568
|
+
return undefined;
|
|
569
|
+
return this.toSubagentSnapshot(conv);
|
|
570
|
+
}
|
|
571
|
+
listSubagentSnapshots() {
|
|
572
|
+
return [...this.convs.values()]
|
|
573
|
+
.filter((c) => c.isSubagent)
|
|
574
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
575
|
+
.map((c) => this.toSubagentSnapshot(c));
|
|
576
|
+
}
|
|
577
|
+
toSubagentSnapshot(conv) {
|
|
578
|
+
const streaming = conv.session.isStreaming;
|
|
579
|
+
const state = streaming ? "running" : "done";
|
|
580
|
+
let messageCount = 0;
|
|
581
|
+
try {
|
|
582
|
+
messageCount = conv.session.getSessionStats().totalMessages;
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
// session being replaced — report defaults
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
convId: conv.id,
|
|
589
|
+
type: conv.subagentType ?? "general",
|
|
590
|
+
title: conv.title,
|
|
591
|
+
prompt: "",
|
|
592
|
+
state,
|
|
593
|
+
streaming,
|
|
594
|
+
messageCount,
|
|
595
|
+
model: conv.session.model?.id,
|
|
596
|
+
output: conv.session.getLastAssistantText() ?? "",
|
|
597
|
+
};
|
|
598
|
+
}
|
|
534
599
|
emitTerminal(conversationId, msg) {
|
|
535
600
|
// Background conversations keep collecting output in their own PTY buffer.
|
|
536
601
|
// Do not stream it into the active xterm; push the retained window on switch.
|
|
@@ -602,6 +667,45 @@ export class ClientSession {
|
|
|
602
667
|
}
|
|
603
668
|
/** Web-facing extension UI context (widgets, notifications). */
|
|
604
669
|
webUi = new WebUIContext((msg) => this.emit(msg));
|
|
670
|
+
/**
|
|
671
|
+
* 第一方子代理 host(见 subagents.ts 设计头注)。子代理 = 一个标记
|
|
672
|
+
* isSubagent 的普通 Conversation:inMemory runtime(不落盘、不进
|
|
673
|
+
* 历史/resume 列表)、listed=true 出现在左栏「运行的对话」并向用户可见——
|
|
674
|
+
* 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
|
|
675
|
+
*/
|
|
676
|
+
subagentHost = {
|
|
677
|
+
spawnSubagent: (prompt, type, cwd, templateName) => {
|
|
678
|
+
// 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
|
|
679
|
+
const tpl = templateName ? this.subagentTemplates.get(templateName) : undefined;
|
|
680
|
+
if (templateName && (!tpl || !tpl.enabled)) {
|
|
681
|
+
throw new Error(`子代理模板不可用:${templateName}(不存在或已停用)`);
|
|
682
|
+
}
|
|
683
|
+
return this.spawnSubagentConversation(prompt, type, cwd, tpl);
|
|
684
|
+
},
|
|
685
|
+
getSubagent: (convId) => this.getSubagentSnapshot(convId),
|
|
686
|
+
listSubagents: () => this.listSubagentSnapshots(),
|
|
687
|
+
steerSubagent: async (convId, message) => {
|
|
688
|
+
const conv = this.convs.get(convId);
|
|
689
|
+
if (!conv?.session)
|
|
690
|
+
return;
|
|
691
|
+
await conv.session.sendUserMessage(message, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
|
|
692
|
+
},
|
|
693
|
+
stopSubagent: async (convId) => {
|
|
694
|
+
const conv = this.convs.get(convId);
|
|
695
|
+
if (conv && (conv.session.isStreaming || !conv.session.isIdle)) {
|
|
696
|
+
await this.interruptRun(conv, "用户停止子代理");
|
|
697
|
+
}
|
|
698
|
+
},
|
|
699
|
+
// 只向 AI 暴露 enabled 的模板(停用的对 AI 不可见)。
|
|
700
|
+
listTemplates: () => this.subagentTemplates
|
|
701
|
+
.list()
|
|
702
|
+
.filter((t) => t.enabled)
|
|
703
|
+
.map((t) => ({ name: t.name, description: t.description })),
|
|
704
|
+
isTemplateUsable: (name) => {
|
|
705
|
+
const t = this.subagentTemplates.get(name);
|
|
706
|
+
return !!t && t.enabled;
|
|
707
|
+
},
|
|
708
|
+
};
|
|
605
709
|
widgetsTimer = null;
|
|
606
710
|
/** Model-stall watchdog interval (see startStallTimer). */
|
|
607
711
|
stallTimer = null;
|
|
@@ -649,11 +753,14 @@ export class ClientSession {
|
|
|
649
753
|
gitWatchCwd = null;
|
|
650
754
|
gitDirtyTimer = null;
|
|
651
755
|
watchTimer = null;
|
|
756
|
+
/** 子代理模板库(全局共享,<dataDir>/subagent-templates.json)。 */
|
|
757
|
+
subagentTemplates;
|
|
652
758
|
constructor(clientId, cwd, agentDir, stateStore) {
|
|
653
759
|
this.clientId = clientId;
|
|
654
760
|
this.cwd = cwd;
|
|
655
761
|
this.agentDir = agentDir;
|
|
656
762
|
this.stateStore = stateStore;
|
|
763
|
+
this.subagentTemplates = new SubagentTemplatesStore(join(stateStore.dataDir, "subagent-templates.json"));
|
|
657
764
|
this.settingsSvc = new SettingsService({
|
|
658
765
|
clientId,
|
|
659
766
|
stateStore,
|
|
@@ -672,7 +779,7 @@ export class ClientSession {
|
|
|
672
779
|
},
|
|
673
780
|
effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
|
|
674
781
|
effectiveSystemPrompt: () => this.effectiveSystemPrompt(),
|
|
675
|
-
});
|
|
782
|
+
}, this.subagentTemplates);
|
|
676
783
|
this.goalSvc = new GoalService({
|
|
677
784
|
clientId,
|
|
678
785
|
agentDir,
|
|
@@ -741,8 +848,12 @@ export class ClientSession {
|
|
|
741
848
|
* Factory for cwd-bound runtimes. All conversations share ONE ModelRuntime
|
|
742
849
|
* (the model choice is client-wide), so later conversations reuse the
|
|
743
850
|
* instance created with the first one.
|
|
851
|
+
*
|
|
852
|
+
* `apply`(可选):子代理模板 —— 会话的 system prompt / 技能 / 扩展按模板
|
|
853
|
+
* 应用(prompt replace/append + 白名单),其余(终端接管、Windows persona
|
|
854
|
+
* 等)仍跟随主会话设置。undefined = 按主会话设置(普通对话/不选模板的子代理)。
|
|
744
855
|
*/
|
|
745
|
-
makeRuntimeFactory(terminals) {
|
|
856
|
+
makeRuntimeFactory(terminals, apply) {
|
|
746
857
|
return async ({ cwd: effectiveCwd, sessionManager }) => {
|
|
747
858
|
const services = await createAgentSessionServices({
|
|
748
859
|
cwd: effectiveCwd,
|
|
@@ -751,6 +862,10 @@ export class ClientSession {
|
|
|
751
862
|
// 在每次 resourceLoader.reload() 时重放,且读取 this.settings 的当前
|
|
752
863
|
// 值——因此 session.reload() 即可让系统提示词 / 技能 / 插件开关生效,
|
|
753
864
|
// 新对话(新 runtime)也会自动带上当前设置。
|
|
865
|
+
// 子代理带模板(apply)时:prompt/skills/extensions 改读模板视图——
|
|
866
|
+
// replace 模式整体替换为模板提示词;append 模式把模板提示词追加到
|
|
867
|
+
// 末尾(此时主会话的自定义 prompt 不再叠加,角色由模板定义);非空
|
|
868
|
+
// 白名单取代主会话开关(只启用这些),空白名单 = 跟随主会话。
|
|
754
869
|
resourceLoaderOptions: {
|
|
755
870
|
// 系统提示词:replace 模式整体替换;append 模式追加到提示词末尾。
|
|
756
871
|
systemPromptOverride: (base) => {
|
|
@@ -759,6 +874,9 @@ export class ClientSession {
|
|
|
759
874
|
if (typeof base === "string" && base) {
|
|
760
875
|
this.lastBaseSystemPrompt = base;
|
|
761
876
|
}
|
|
877
|
+
if (apply && apply.promptMode === "replace" && apply.systemPrompt.trim()) {
|
|
878
|
+
return apply.systemPrompt;
|
|
879
|
+
}
|
|
762
880
|
return this.settingsSvc.current.promptMode === "replace" &&
|
|
763
881
|
this.settingsSvc.current.customSystemPrompt.trim()
|
|
764
882
|
? this.settingsSvc.current.customSystemPrompt
|
|
@@ -766,9 +884,14 @@ export class ClientSession {
|
|
|
766
884
|
},
|
|
767
885
|
appendSystemPromptOverride: (base) => {
|
|
768
886
|
const out = [...base];
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
887
|
+
if (apply && apply.promptMode === "append" && apply.systemPrompt.trim()) {
|
|
888
|
+
out.push(apply.systemPrompt);
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
const custom = this.settingsSvc.current.customSystemPrompt.trim();
|
|
892
|
+
if (this.settingsSvc.current.promptMode === "append" && custom) {
|
|
893
|
+
out.push(custom);
|
|
894
|
+
}
|
|
772
895
|
}
|
|
773
896
|
if (process.platform === "win32") {
|
|
774
897
|
// Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端
|
|
@@ -788,18 +911,30 @@ export class ClientSession {
|
|
|
788
911
|
out.push(PIPELESS_BASH_GUIDANCE);
|
|
789
912
|
return out;
|
|
790
913
|
},
|
|
791
|
-
//
|
|
792
|
-
skillsOverride: (res) =>
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
914
|
+
// 技能:模板非空白名单时只启用白名单里的;否则按主会话禁用集过滤。
|
|
915
|
+
skillsOverride: (res) => {
|
|
916
|
+
if (apply && apply.enabledSkills.length > 0) {
|
|
917
|
+
const set = new Set(apply.enabledSkills);
|
|
918
|
+
return { ...res, skills: res.skills.filter((s) => set.has(s.name)) };
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
...res,
|
|
922
|
+
skills: res.skills.filter((s) => !this.settingsSvc.current.disabledSkills.includes(s.name)),
|
|
923
|
+
};
|
|
924
|
+
},
|
|
925
|
+
// 插件:模板非空扩展白名单时只加载白名单里的;否则按主会话禁用集过滤。
|
|
797
926
|
// 注意 SDK 在 extensionsOverride 之后才补 sourceInfo,包扩展此处只能靠路径
|
|
798
|
-
// 匹配 —— isExtensionDisabled 同时比对 npm:<pkg> 候选键。
|
|
799
|
-
extensionsOverride: (res) =>
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
927
|
+
// 匹配 —— isExtensionDisabled / isExtensionEnabled 同时比对 npm:<pkg> 候选键。
|
|
928
|
+
extensionsOverride: (res) => {
|
|
929
|
+
if (apply && apply.enabledExtensions.length > 0) {
|
|
930
|
+
const set = new Set(apply.enabledExtensions);
|
|
931
|
+
return { ...res, extensions: res.extensions.filter((e) => isExtensionEnabled(e, [...set])) };
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
...res,
|
|
935
|
+
extensions: res.extensions.filter((e) => !isExtensionDisabled(e, this.settingsSvc.current.disabledExtensions)),
|
|
936
|
+
};
|
|
937
|
+
},
|
|
803
938
|
},
|
|
804
939
|
});
|
|
805
940
|
const created = await createAgentSessionFromServices({
|
|
@@ -823,6 +958,9 @@ export class ClientSession {
|
|
|
823
958
|
// 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
|
|
824
959
|
// refreshPluginTools 动态补入已有会话)。
|
|
825
960
|
...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
|
|
961
|
+
// 第一方子代理工具(spawn/get_result/steer/list/stop)。子代理会话
|
|
962
|
+
// 也注册了它们,因此可自然嵌套派发。
|
|
963
|
+
...makeSubagentTools(this.subagentHost),
|
|
826
964
|
],
|
|
827
965
|
});
|
|
828
966
|
// 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
@@ -848,6 +986,7 @@ export class ClientSession {
|
|
|
848
986
|
return {
|
|
849
987
|
id,
|
|
850
988
|
title: conversationTitle(runtime.session),
|
|
989
|
+
isSubagent: false,
|
|
851
990
|
runtime,
|
|
852
991
|
session: runtime.session,
|
|
853
992
|
cwd: runtime.cwd,
|
|
@@ -891,13 +1030,12 @@ export class ClientSession {
|
|
|
891
1030
|
notifyInterrupted(list) {
|
|
892
1031
|
if (!list || list.length === 0)
|
|
893
1032
|
return;
|
|
894
|
-
const names = list
|
|
895
|
-
.map((r) => `「${r.title}」(${r.cwd})`)
|
|
896
|
-
.join("、");
|
|
1033
|
+
const names = list.map((r) => `「${r.title}」(${r.cwd})`).join("、");
|
|
897
1034
|
this.pendingNotices.push({
|
|
898
1035
|
type: "notice",
|
|
899
1036
|
level: "warning",
|
|
900
1037
|
text: `上次服务重启时有 ${list.length} 个进行中的对话被中断:${names}。可在历史对话中恢复继续。`,
|
|
1038
|
+
textEn: `${list.length} running conversation(s) were interrupted by the last restart: ${names}. Resume them from History.`,
|
|
901
1039
|
});
|
|
902
1040
|
}
|
|
903
1041
|
/** Add a socket to this client's broadcast set; flushes buffered startup notices. */
|
|
@@ -949,6 +1087,7 @@ export class ClientSession {
|
|
|
949
1087
|
emit(msg) {
|
|
950
1088
|
if (this.disposed)
|
|
951
1089
|
return;
|
|
1090
|
+
// eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
|
|
952
1091
|
for (const sink of [...this.sinks])
|
|
953
1092
|
sink(msg);
|
|
954
1093
|
}
|
|
@@ -991,15 +1130,14 @@ export class ClientSession {
|
|
|
991
1130
|
return;
|
|
992
1131
|
const now = Date.now();
|
|
993
1132
|
for (const conv of this.convs.values()) {
|
|
994
|
-
if (!conv.stallNoticed &&
|
|
995
|
-
conv.session.isStreaming &&
|
|
996
|
-
now - conv.lastSdkEventAt > STALL_NOTIFY_MS) {
|
|
1133
|
+
if (!conv.stallNoticed && conv.session.isStreaming && now - conv.lastSdkEventAt > STALL_NOTIFY_MS) {
|
|
997
1134
|
conv.stallNoticed = true;
|
|
998
1135
|
const mins = Math.round((now - conv.lastSdkEventAt) / 60_000);
|
|
999
1136
|
this.emit({
|
|
1000
1137
|
type: "notice",
|
|
1001
1138
|
level: "warning",
|
|
1002
1139
|
text: `对话「${conv.title}」已 ${mins} 分钟无任何响应,可能已失联(网络中断或服务端挂起)。可点击停止后重试。`,
|
|
1140
|
+
textEn: `Conversation "${conv.title}" has been silent for ${mins} min — possibly disconnected (network or hung server). Stop it and retry.`,
|
|
1003
1141
|
});
|
|
1004
1142
|
}
|
|
1005
1143
|
}
|
|
@@ -1018,6 +1156,7 @@ export class ClientSession {
|
|
|
1018
1156
|
type: "notice",
|
|
1019
1157
|
level: "warning",
|
|
1020
1158
|
text: `工具执行超过 ${Math.round(TOOL_WATCHDOG_TIMEOUT_MS / 60_000)} 分钟,已自动终止(防止挂死)。可调整超时:环境变量 PI_WEB_TOOL_TIMEOUT_MS(毫秒)。`,
|
|
1159
|
+
textEn: `Tool ran over ${Math.round(TOOL_WATCHDOG_TIMEOUT_MS / 60_000)} min and was auto-terminated (hang guard). Tune via PI_WEB_TOOL_TIMEOUT_MS (ms).`,
|
|
1021
1160
|
});
|
|
1022
1161
|
conv.toolStartTimes.delete(toolCallId);
|
|
1023
1162
|
// Abort the run (kills the process tree via the SDK's abort signal);
|
|
@@ -1107,9 +1246,7 @@ export class ClientSession {
|
|
|
1107
1246
|
const content = event.result?.content;
|
|
1108
1247
|
const text = Array.isArray(content)
|
|
1109
1248
|
? content
|
|
1110
|
-
.map((c) =>
|
|
1111
|
-
c !== null &&
|
|
1112
|
-
c.type === "text")
|
|
1249
|
+
.map((c) => typeof c === "object" && c !== null && c.type === "text"
|
|
1113
1250
|
? (c.text ?? "")
|
|
1114
1251
|
: "")
|
|
1115
1252
|
.join("\n")
|
|
@@ -1153,6 +1290,7 @@ export class ClientSession {
|
|
|
1153
1290
|
type: "notice",
|
|
1154
1291
|
level: "info",
|
|
1155
1292
|
text: "正在压缩上下文…(压缩摘要将显示在消息区)",
|
|
1293
|
+
textEn: "Compacting context… (the summary will appear in the message list)",
|
|
1156
1294
|
});
|
|
1157
1295
|
break;
|
|
1158
1296
|
}
|
|
@@ -1162,6 +1300,7 @@ export class ClientSession {
|
|
|
1162
1300
|
type: "notice",
|
|
1163
1301
|
level: "error",
|
|
1164
1302
|
text: `压缩上下文失败:${event.errorMessage}`,
|
|
1303
|
+
textEn: `Context compaction failed: ${event.errorMessage}`,
|
|
1165
1304
|
});
|
|
1166
1305
|
}
|
|
1167
1306
|
else if (event.aborted) {
|
|
@@ -1169,6 +1308,7 @@ export class ClientSession {
|
|
|
1169
1308
|
type: "notice",
|
|
1170
1309
|
level: "warning",
|
|
1171
1310
|
text: "压缩上下文已取消",
|
|
1311
|
+
textEn: "Context compaction cancelled",
|
|
1172
1312
|
});
|
|
1173
1313
|
}
|
|
1174
1314
|
else if (event.result) {
|
|
@@ -1178,6 +1318,7 @@ export class ClientSession {
|
|
|
1178
1318
|
type: "notice",
|
|
1179
1319
|
level: "info",
|
|
1180
1320
|
text: `上下文压缩完成:${tokensBefore.toLocaleString()} → ${after.toLocaleString()} tokens(摘要已插入消息区)`,
|
|
1321
|
+
textEn: `Context compacted: ${tokensBefore.toLocaleString()} → ${after.toLocaleString()} tokens (summary inserted into the message list)`,
|
|
1181
1322
|
});
|
|
1182
1323
|
}
|
|
1183
1324
|
break;
|
|
@@ -1263,9 +1404,7 @@ export class ClientSession {
|
|
|
1263
1404
|
// Snapshot checkpoint policy: deltas carry live rendering during streaming;
|
|
1264
1405
|
// full snapshots are reconciliation checkpoints taken immediately at
|
|
1265
1406
|
// run/tool boundaries and on a slow timer otherwise.
|
|
1266
|
-
if (event.type === "agent_end" ||
|
|
1267
|
-
event.type === "tool_execution_end" ||
|
|
1268
|
-
event.type === "compaction_end") {
|
|
1407
|
+
if (event.type === "agent_end" || event.type === "tool_execution_end" || event.type === "compaction_end") {
|
|
1269
1408
|
this.flushSnapshot();
|
|
1270
1409
|
}
|
|
1271
1410
|
else {
|
|
@@ -1309,9 +1448,7 @@ export class ClientSession {
|
|
|
1309
1448
|
// timestamp alone collides in the cache and only the first one renders
|
|
1310
1449
|
// — append a cheap content fingerprint to keep them distinct while
|
|
1311
1450
|
// staying stable across snapshots (content never changes once persisted).
|
|
1312
|
-
const key = m.role === "toolResult"
|
|
1313
|
-
? `t:${m.toolCallId}`
|
|
1314
|
-
: `${m.role}:${m.timestamp}:${contentFingerprint(m)}`;
|
|
1451
|
+
const key = m.role === "toolResult" ? `t:${m.toolCallId}` : `${m.role}:${m.timestamp}:${contentFingerprint(m)}`;
|
|
1315
1452
|
let n = conv.msgIds.get(key);
|
|
1316
1453
|
if (n === undefined) {
|
|
1317
1454
|
n = conv.nextMsgId++;
|
|
@@ -1407,9 +1544,7 @@ export class ClientSession {
|
|
|
1407
1544
|
// (the SDK only pushes it into state.messages at message_end). Surfacing
|
|
1408
1545
|
// it here is what makes thinking + text stream into the browser at
|
|
1409
1546
|
// ~60ms granularity instead of appearing only when the turn finishes.
|
|
1410
|
-
streamingMessage: state.streamingMessage
|
|
1411
|
-
? serializeStreamingMessage(state.streamingMessage)
|
|
1412
|
-
: null,
|
|
1547
|
+
streamingMessage: state.streamingMessage ? serializeStreamingMessage(state.streamingMessage) : null,
|
|
1413
1548
|
isStreaming: this.session.isStreaming,
|
|
1414
1549
|
model: model
|
|
1415
1550
|
? {
|
|
@@ -1447,10 +1582,7 @@ export class ClientSession {
|
|
|
1447
1582
|
return;
|
|
1448
1583
|
const cur = this.currentMessages();
|
|
1449
1584
|
const prev = this.emittedMessages;
|
|
1450
|
-
let incremental = !forceFull &&
|
|
1451
|
-
prev !== null &&
|
|
1452
|
-
this.emittedConvId === this.activeId &&
|
|
1453
|
-
prev.length <= cur.length;
|
|
1585
|
+
let incremental = !forceFull && prev !== null && this.emittedConvId === this.activeId && prev.length <= cur.length;
|
|
1454
1586
|
if (incremental && prev) {
|
|
1455
1587
|
for (let i = 0; i < prev.length; i++) {
|
|
1456
1588
|
if (prev[i] !== cur[i]) {
|
|
@@ -1646,10 +1778,7 @@ export class ClientSession {
|
|
|
1646
1778
|
* within UPDATE_ALL_CACHE_MS; pass force=true (explicit refresh) to bypass.
|
|
1647
1779
|
*/
|
|
1648
1780
|
async checkUpdatesAll(force = false) {
|
|
1649
|
-
if (!force &&
|
|
1650
|
-
this.updatesAllCache &&
|
|
1651
|
-
Date.now() - this.updatesAllCache.at <
|
|
1652
|
-
ClientSession.UPDATE_ALL_CACHE_MS) {
|
|
1781
|
+
if (!force && this.updatesAllCache && Date.now() - this.updatesAllCache.at < ClientSession.UPDATE_ALL_CACHE_MS) {
|
|
1653
1782
|
this.emit({
|
|
1654
1783
|
type: "update_status_all",
|
|
1655
1784
|
items: this.updatesAllCache.items,
|
|
@@ -1686,6 +1815,7 @@ export class ClientSession {
|
|
|
1686
1815
|
type: "notice",
|
|
1687
1816
|
level: "info",
|
|
1688
1817
|
text: "正在安装 pi agent CLI(npm i -g @earendil-works/pi-coding-agent)…",
|
|
1818
|
+
textEn: "Installing pi agent CLI (npm i -g @earendil-works/pi-coding-agent)…",
|
|
1689
1819
|
});
|
|
1690
1820
|
const { code, out } = await this.runAsync("npm", ["i", "-g", "@earendil-works/pi-coding-agent"], 180_000);
|
|
1691
1821
|
if (code === 0) {
|
|
@@ -1693,6 +1823,7 @@ export class ClientSession {
|
|
|
1693
1823
|
type: "notice",
|
|
1694
1824
|
level: "info",
|
|
1695
1825
|
text: "✅ pi agent CLI 安装完成。填入 API 密钥即可开始,或在终端运行 pi 完成登录。",
|
|
1826
|
+
textEn: "✅ pi agent CLI installed. Enter an API key to start, or run pi in a terminal to log in.",
|
|
1696
1827
|
});
|
|
1697
1828
|
this.emit({ type: "install_result", ok: true, detail: "" });
|
|
1698
1829
|
}
|
|
@@ -1701,6 +1832,7 @@ export class ClientSession {
|
|
|
1701
1832
|
type: "notice",
|
|
1702
1833
|
level: "error",
|
|
1703
1834
|
text: `pi agent 安装失败(${code ?? "timeout"}):${out.slice(0, 400)}`,
|
|
1835
|
+
textEn: `pi agent install failed (${code ?? "timeout"}): ${out.slice(0, 400)}`,
|
|
1704
1836
|
});
|
|
1705
1837
|
this.emit({
|
|
1706
1838
|
type: "install_result",
|
|
@@ -1714,6 +1846,7 @@ export class ClientSession {
|
|
|
1714
1846
|
type: "notice",
|
|
1715
1847
|
level: "error",
|
|
1716
1848
|
text: `pi agent 安装失败:${err.message}`,
|
|
1849
|
+
textEn: `pi agent install failed: ${err.message}`,
|
|
1717
1850
|
});
|
|
1718
1851
|
}
|
|
1719
1852
|
// The CLI may just have landed on PATH (or the install may have failed) —
|
|
@@ -1738,9 +1871,7 @@ export class ClientSession {
|
|
|
1738
1871
|
// During active streaming the deltas carry live rendering — full snapshots
|
|
1739
1872
|
// are just a periodic reconciliation checkpoint, so send them far less
|
|
1740
1873
|
// often (they serialize the whole session; big sessions made this path OOM).
|
|
1741
|
-
const interval = Date.now() - this.lastDeltaAt < DELTA_ACTIVE_WINDOW_MS
|
|
1742
|
-
? STREAMING_SNAPSHOT_INTERVAL_MS
|
|
1743
|
-
: SNAPSHOT_INTERVAL_MS;
|
|
1874
|
+
const interval = Date.now() - this.lastDeltaAt < DELTA_ACTIVE_WINDOW_MS ? STREAMING_SNAPSHOT_INTERVAL_MS : SNAPSHOT_INTERVAL_MS;
|
|
1744
1875
|
this.snapshotTimer = setTimeout(() => {
|
|
1745
1876
|
this.snapshotTimer = null;
|
|
1746
1877
|
this.emitSnapshotNow();
|
|
@@ -1775,6 +1906,7 @@ export class ClientSession {
|
|
|
1775
1906
|
type: "notice",
|
|
1776
1907
|
level: "error",
|
|
1777
1908
|
text: `插件命令 /${name} 执行失败:${err.message}`,
|
|
1909
|
+
textEn: `Plugin command /${name} failed: ${err.message}`,
|
|
1778
1910
|
});
|
|
1779
1911
|
}
|
|
1780
1912
|
return true;
|
|
@@ -1963,6 +2095,14 @@ export class ClientSession {
|
|
|
1963
2095
|
async deletePreset(name) {
|
|
1964
2096
|
return this.settingsSvc.deletePreset(name);
|
|
1965
2097
|
}
|
|
2098
|
+
/** Upsert 一个子代理模板(全局共享)。 */
|
|
2099
|
+
async saveSubagentTemplate(template) {
|
|
2100
|
+
return this.settingsSvc.saveTemplate(template);
|
|
2101
|
+
}
|
|
2102
|
+
/** 删除一个子代理模板。 */
|
|
2103
|
+
async deleteSubagentTemplate(name) {
|
|
2104
|
+
return this.settingsSvc.deleteTemplate(name);
|
|
2105
|
+
}
|
|
1966
2106
|
/** Make settings effective in the running runtime(流式中则延迟到 agent_end)。 */
|
|
1967
2107
|
async applyRuntimeSettings() {
|
|
1968
2108
|
return this.settingsSvc.applyRuntime();
|
|
@@ -2023,6 +2163,7 @@ export class ClientSession {
|
|
|
2023
2163
|
type: "notice",
|
|
2024
2164
|
level: "error",
|
|
2025
2165
|
text: "服务器正在排空存量工作(quiesce),已拒绝新的对话/消息/编辑。存量运行会继续跑完;用 pi-web-ui server unquiesce 可恢复。",
|
|
2166
|
+
textEn: "Server is draining (quiesce) and rejected the new chat/message/edit. Existing runs continue; resume with pi-web-ui server unquiesce.",
|
|
2026
2167
|
});
|
|
2027
2168
|
this.flushSnapshot();
|
|
2028
2169
|
return true;
|
|
@@ -2086,8 +2227,7 @@ export class ClientSession {
|
|
|
2086
2227
|
// re-derives it from the persisted transcript when needed.
|
|
2087
2228
|
if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
|
|
2088
2229
|
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
2089
|
-
conv.title =
|
|
2090
|
-
trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
2230
|
+
conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
2091
2231
|
this.emitConversations();
|
|
2092
2232
|
}
|
|
2093
2233
|
// Attach files as independent nextTurn context messages (asides) so the
|
|
@@ -2126,6 +2266,7 @@ export class ClientSession {
|
|
|
2126
2266
|
type: "notice",
|
|
2127
2267
|
level: "error",
|
|
2128
2268
|
text: `提示发送失败:${err.message}`,
|
|
2269
|
+
textEn: `Failed to send prompt: ${err.message}`,
|
|
2129
2270
|
});
|
|
2130
2271
|
}
|
|
2131
2272
|
// The active conversation (captured at prompt start — see above) has been
|
|
@@ -2206,6 +2347,7 @@ export class ClientSession {
|
|
|
2206
2347
|
type: "notice",
|
|
2207
2348
|
level: "error",
|
|
2208
2349
|
text: `重新入队插队消息失败:${err.message}`,
|
|
2350
|
+
textEn: `Failed to re-queue the steer message: ${err.message}`,
|
|
2209
2351
|
});
|
|
2210
2352
|
}
|
|
2211
2353
|
}
|
|
@@ -2218,6 +2360,7 @@ export class ClientSession {
|
|
|
2218
2360
|
type: "notice",
|
|
2219
2361
|
level: "error",
|
|
2220
2362
|
text: `重新入队排队消息失败:${err.message}`,
|
|
2363
|
+
textEn: `Failed to re-queue the queued message: ${err.message}`,
|
|
2221
2364
|
});
|
|
2222
2365
|
}
|
|
2223
2366
|
}
|
|
@@ -2246,6 +2389,7 @@ export class ClientSession {
|
|
|
2246
2389
|
type: "notice",
|
|
2247
2390
|
level: "info",
|
|
2248
2391
|
text: `后台任务「${taskId}」不存在或已结束`,
|
|
2392
|
+
textEn: `Background task "${taskId}" does not exist or has ended`,
|
|
2249
2393
|
});
|
|
2250
2394
|
}
|
|
2251
2395
|
this.bg.push();
|
|
@@ -2270,16 +2414,19 @@ export class ClientSession {
|
|
|
2270
2414
|
type: "notice",
|
|
2271
2415
|
level: "info",
|
|
2272
2416
|
text: "当前没有正在运行的 bash 命令",
|
|
2417
|
+
textEn: "No bash command is running",
|
|
2273
2418
|
});
|
|
2274
2419
|
this.flushSnapshot();
|
|
2275
2420
|
return;
|
|
2276
2421
|
}
|
|
2422
|
+
// eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
|
|
2277
2423
|
for (const ac of [...this.bashKills])
|
|
2278
2424
|
ac.abort();
|
|
2279
2425
|
this.emit({
|
|
2280
2426
|
type: "notice",
|
|
2281
2427
|
level: "info",
|
|
2282
2428
|
text: "已停止 bash 命令(对话继续)",
|
|
2429
|
+
textEn: "Bash command stopped (conversation continues)",
|
|
2283
2430
|
});
|
|
2284
2431
|
// 让 AI 明确知道是用户手动停止:sendUserMessage 触发下一轮,agent
|
|
2285
2432
|
// 会看到「命令被用户中止」而不是普通失败,并据此继续(不会困惑于
|
|
@@ -2328,6 +2475,7 @@ export class ClientSession {
|
|
|
2328
2475
|
type: "notice",
|
|
2329
2476
|
level: "error",
|
|
2330
2477
|
text: `中止失败:${err.message}`,
|
|
2478
|
+
textEn: `Abort failed: ${err.message}`,
|
|
2331
2479
|
});
|
|
2332
2480
|
}
|
|
2333
2481
|
// 3) abort returned but no agent_end within the settle window → the
|
|
@@ -2368,6 +2516,7 @@ export class ClientSession {
|
|
|
2368
2516
|
type: "notice",
|
|
2369
2517
|
level: "error",
|
|
2370
2518
|
text: `强制中断失败:${err.message}`,
|
|
2519
|
+
textEn: `Force-stop failed: ${err.message}`,
|
|
2371
2520
|
});
|
|
2372
2521
|
}
|
|
2373
2522
|
}
|
|
@@ -2410,6 +2559,7 @@ export class ClientSession {
|
|
|
2410
2559
|
type: "notice",
|
|
2411
2560
|
level: "warning",
|
|
2412
2561
|
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
|
|
2562
|
+
textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
|
|
2413
2563
|
});
|
|
2414
2564
|
return;
|
|
2415
2565
|
}
|
|
@@ -2462,6 +2612,7 @@ export class ClientSession {
|
|
|
2462
2612
|
type: "notice",
|
|
2463
2613
|
level: "error",
|
|
2464
2614
|
text: `新建对话失败:${err.message}`,
|
|
2615
|
+
textEn: `Failed to create chat: ${err.message}`,
|
|
2465
2616
|
});
|
|
2466
2617
|
}
|
|
2467
2618
|
this.flushSnapshot();
|
|
@@ -2489,6 +2640,12 @@ export class ClientSession {
|
|
|
2489
2640
|
// Also retain when a non-expired pi-subagents wait-subscription record
|
|
2490
2641
|
// exists on disk for this session: a finished background subagent run
|
|
2491
2642
|
// still owes this conversation a wake-up turn.
|
|
2643
|
+
// 更靠前的阶段:run 本身还在 queued/running(workflow 编排中)时释放
|
|
2644
|
+
// runtime 同样杀死扩展宿主并 abort 所有 live workflow controller,比
|
|
2645
|
+
// wake 订阅早一步——磁盘 .active-runs marker + status.json 探测(pi-web-ui #52)。
|
|
2646
|
+
// Retain while the session has active (queued/running) pi-subagents async
|
|
2647
|
+
// runs on disk — the extension host would otherwise be torn down and its
|
|
2648
|
+
// workflow controllers aborted mid-flight.
|
|
2492
2649
|
const retained = shouldRetainActive({
|
|
2493
2650
|
reviewing: conv.goal.reviewing,
|
|
2494
2651
|
wizardRunning: conv.wizardRunning,
|
|
@@ -2496,6 +2653,7 @@ export class ClientSession {
|
|
|
2496
2653
|
openTerminals: conv.terminals.list().length,
|
|
2497
2654
|
listed: conv.listed,
|
|
2498
2655
|
promptedSinceActive: conv.promptedSinceActive,
|
|
2656
|
+
hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
|
|
2499
2657
|
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
2500
2658
|
});
|
|
2501
2659
|
if (retained) {
|
|
@@ -2585,6 +2743,7 @@ export class ClientSession {
|
|
|
2585
2743
|
cwd: conv.cwd,
|
|
2586
2744
|
messageCount,
|
|
2587
2745
|
isStreaming,
|
|
2746
|
+
isSubagent: !!conv.isSubagent,
|
|
2588
2747
|
});
|
|
2589
2748
|
}
|
|
2590
2749
|
this.emit({
|
|
@@ -2650,9 +2809,7 @@ export class ClientSession {
|
|
|
2650
2809
|
source: "web",
|
|
2651
2810
|
});
|
|
2652
2811
|
}
|
|
2653
|
-
const sorted = [...sessions.values()]
|
|
2654
|
-
.sort((a, b) => b.modified - a.modified)
|
|
2655
|
-
.slice(0, 200); // newest first — the panel shows recent history
|
|
2812
|
+
const sorted = [...sessions.values()].sort((a, b) => b.modified - a.modified).slice(0, 200); // newest first — the panel shows recent history
|
|
2656
2813
|
this.emit({ type: "sessions", sessions: sorted });
|
|
2657
2814
|
}
|
|
2658
2815
|
catch {
|
|
@@ -2685,6 +2842,7 @@ export class ClientSession {
|
|
|
2685
2842
|
type: "notice",
|
|
2686
2843
|
level: "error",
|
|
2687
2844
|
text: "只能删除会话目录中的对话记录",
|
|
2845
|
+
textEn: "Only transcripts inside the session directory can be deleted",
|
|
2688
2846
|
});
|
|
2689
2847
|
return;
|
|
2690
2848
|
}
|
|
@@ -2703,6 +2861,7 @@ export class ClientSession {
|
|
|
2703
2861
|
type: "notice",
|
|
2704
2862
|
level: "warning",
|
|
2705
2863
|
text: "该对话正在后台运行,请先停止或关闭该对话再删除",
|
|
2864
|
+
textEn: "This conversation is still running — stop or close it before deleting",
|
|
2706
2865
|
});
|
|
2707
2866
|
return;
|
|
2708
2867
|
}
|
|
@@ -2755,9 +2914,68 @@ export class ClientSession {
|
|
|
2755
2914
|
type: "notice",
|
|
2756
2915
|
level: "error",
|
|
2757
2916
|
text: `删除会话失败:${err.message}`,
|
|
2917
|
+
textEn: `Failed to delete session: ${err.message}`,
|
|
2758
2918
|
});
|
|
2759
2919
|
}
|
|
2760
2920
|
}
|
|
2921
|
+
/** Dismiss a running conversation from the left-panel list without deleting its
|
|
2922
|
+
* transcript file. Only idle (non-streaming) conversations that are not
|
|
2923
|
+
* retained by terminal/wake/review state can be dismissed. The session stays
|
|
2924
|
+
* in history and can be reopened. */
|
|
2925
|
+
async dismissConversation(id) {
|
|
2926
|
+
const conv = this.convs.get(id);
|
|
2927
|
+
if (!conv) {
|
|
2928
|
+
this.emit({ type: "notice", level: "warning", text: "该对话不存在或已关闭" });
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
if (id === this.activeId) {
|
|
2932
|
+
this.emit({ type: "notice", level: "warning", text: "当前对话不能直接移出,请先切换到其他对话" });
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
if (!conv.listed) {
|
|
2936
|
+
// Not in list anyway — nothing to do.
|
|
2937
|
+
this.emitConversations();
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
// Streaming / retained conversations refuse dismissal — mirrors displaceActive retention.
|
|
2941
|
+
const retained = shouldRetainActive({
|
|
2942
|
+
reviewing: conv.goal.reviewing,
|
|
2943
|
+
wizardRunning: conv.wizardRunning,
|
|
2944
|
+
streaming: conv.session.isStreaming,
|
|
2945
|
+
openTerminals: conv.terminals.list().length,
|
|
2946
|
+
listed: conv.listed,
|
|
2947
|
+
promptedSinceActive: conv.promptedSinceActive,
|
|
2948
|
+
hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
|
|
2949
|
+
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
2950
|
+
});
|
|
2951
|
+
if (retained) {
|
|
2952
|
+
if (conv.session.isStreaming) {
|
|
2953
|
+
this.emit({
|
|
2954
|
+
type: "notice",
|
|
2955
|
+
level: "warning",
|
|
2956
|
+
text: `对话「${conv.title}」仍在运行中,请先等待结束或点击停止后再移出`,
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
else if (conv.terminals.list().length > 0) {
|
|
2960
|
+
this.emit({
|
|
2961
|
+
type: "notice",
|
|
2962
|
+
level: "warning",
|
|
2963
|
+
text: `对话「${conv.title}」还有未关闭的终端,请先关闭终端后再移出`,
|
|
2964
|
+
});
|
|
2965
|
+
}
|
|
2966
|
+
else {
|
|
2967
|
+
this.emit({
|
|
2968
|
+
type: "notice",
|
|
2969
|
+
level: "warning",
|
|
2970
|
+
text: `对话「${conv.title}」暂时无法移出(存在待处理的后台任务/审查)`,
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
this.removeConversation(id);
|
|
2976
|
+
this.emitConversations();
|
|
2977
|
+
this.flushSnapshot();
|
|
2978
|
+
}
|
|
2761
2979
|
/** Open a persisted session as the active conversation (from listSessions).
|
|
2762
2980
|
*
|
|
2763
2981
|
* A persisted-session click must follow the same ownership rule as
|
|
@@ -2795,9 +3013,7 @@ export class ClientSession {
|
|
|
2795
3013
|
// is known-good. This keeps a failed history open entirely non-destructive.
|
|
2796
3014
|
const oldListed = this.conv.listed;
|
|
2797
3015
|
const displaced = this.displaceActive();
|
|
2798
|
-
const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length +
|
|
2799
|
-
1 -
|
|
2800
|
-
(displaced?.cwd === targetCwd ? 1 : 0);
|
|
3016
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length + 1 - (displaced?.cwd === targetCwd ? 1 : 0);
|
|
2801
3017
|
if (openInProject > MAX_OPEN_CONVERSATIONS) {
|
|
2802
3018
|
// displaceActive() may have promoted a streaming conversation into the
|
|
2803
3019
|
// running list. Roll that presentation-only mutation back because no
|
|
@@ -2811,6 +3027,7 @@ export class ClientSession {
|
|
|
2811
3027
|
type: "notice",
|
|
2812
3028
|
level: "warning",
|
|
2813
3029
|
text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
|
|
3030
|
+
textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
|
|
2814
3031
|
});
|
|
2815
3032
|
return;
|
|
2816
3033
|
}
|
|
@@ -2844,6 +3061,7 @@ export class ClientSession {
|
|
|
2844
3061
|
type: "notice",
|
|
2845
3062
|
level: "error",
|
|
2846
3063
|
text: `切换会话失败:${err.message}`,
|
|
3064
|
+
textEn: `Failed to switch session: ${err.message}`,
|
|
2847
3065
|
});
|
|
2848
3066
|
}
|
|
2849
3067
|
this.flushSnapshot();
|
|
@@ -2896,6 +3114,7 @@ export class ClientSession {
|
|
|
2896
3114
|
type: "notice",
|
|
2897
3115
|
level: "warning",
|
|
2898
3116
|
text: "编辑内容为空,已取消",
|
|
3117
|
+
textEn: "Edited content is empty — cancelled",
|
|
2899
3118
|
});
|
|
2900
3119
|
this.flushSnapshot();
|
|
2901
3120
|
return;
|
|
@@ -2906,6 +3125,7 @@ export class ClientSession {
|
|
|
2906
3125
|
type: "notice",
|
|
2907
3126
|
level: "error",
|
|
2908
3127
|
text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
|
|
3128
|
+
textEn: "Message to edit not found (may have been compacted or is on another branch)",
|
|
2909
3129
|
});
|
|
2910
3130
|
this.flushSnapshot();
|
|
2911
3131
|
return;
|
|
@@ -2920,6 +3140,7 @@ export class ClientSession {
|
|
|
2920
3140
|
type: "notice",
|
|
2921
3141
|
level: "info",
|
|
2922
3142
|
text: "已取消编辑重问",
|
|
3143
|
+
textEn: "Edit-and-reask cancelled",
|
|
2923
3144
|
});
|
|
2924
3145
|
this.flushSnapshot();
|
|
2925
3146
|
return;
|
|
@@ -2941,6 +3162,7 @@ export class ClientSession {
|
|
|
2941
3162
|
type: "notice",
|
|
2942
3163
|
level: "info",
|
|
2943
3164
|
text: "已从该问题重新提问(原对话保留在会话列表中)",
|
|
3165
|
+
textEn: "Re-asked from that question (the original stays in the session list)",
|
|
2944
3166
|
});
|
|
2945
3167
|
}
|
|
2946
3168
|
catch (err) {
|
|
@@ -2948,6 +3170,7 @@ export class ClientSession {
|
|
|
2948
3170
|
type: "notice",
|
|
2949
3171
|
level: "error",
|
|
2950
3172
|
text: `编辑重问失败:${err.message}`,
|
|
3173
|
+
textEn: `Edit-and-reask failed: ${err.message}`,
|
|
2951
3174
|
});
|
|
2952
3175
|
}
|
|
2953
3176
|
this.flushSnapshot();
|
|
@@ -3059,6 +3282,7 @@ export class ClientSession {
|
|
|
3059
3282
|
type: "notice",
|
|
3060
3283
|
level: "error",
|
|
3061
3284
|
text: `切换模型失败:${err.message}`,
|
|
3285
|
+
textEn: `Failed to switch model: ${err.message}`,
|
|
3062
3286
|
});
|
|
3063
3287
|
}
|
|
3064
3288
|
this.flushSnapshot();
|
|
@@ -3085,6 +3309,7 @@ export class ClientSession {
|
|
|
3085
3309
|
type: "notice",
|
|
3086
3310
|
level: "info",
|
|
3087
3311
|
text: `已在工作目录:${abs}`,
|
|
3312
|
+
textEn: `Already in directory: ${abs}`,
|
|
3088
3313
|
});
|
|
3089
3314
|
this.flushSnapshot();
|
|
3090
3315
|
return;
|
|
@@ -3098,8 +3323,7 @@ export class ClientSession {
|
|
|
3098
3323
|
// project has none open yet.
|
|
3099
3324
|
let target;
|
|
3100
3325
|
for (const c of this.convs.values()) {
|
|
3101
|
-
if (c.cwd === abs &&
|
|
3102
|
-
(!target || c.lastActiveAt > target.lastActiveAt)) {
|
|
3326
|
+
if (c.cwd === abs && (!target || c.lastActiveAt > target.lastActiveAt)) {
|
|
3103
3327
|
target = c;
|
|
3104
3328
|
}
|
|
3105
3329
|
}
|
|
@@ -3154,6 +3378,7 @@ export class ClientSession {
|
|
|
3154
3378
|
type: "notice",
|
|
3155
3379
|
level: "info",
|
|
3156
3380
|
text: `已切换到工作目录:${abs}`,
|
|
3381
|
+
textEn: `Switched to directory: ${abs}`,
|
|
3157
3382
|
});
|
|
3158
3383
|
void this.refreshSessions();
|
|
3159
3384
|
void this.listFiles(undefined);
|
|
@@ -3165,6 +3390,7 @@ export class ClientSession {
|
|
|
3165
3390
|
type: "notice",
|
|
3166
3391
|
level: "error",
|
|
3167
3392
|
text: `切换工作目录失败:${err.message}`,
|
|
3393
|
+
textEn: `Failed to switch directory: ${err.message}`,
|
|
3168
3394
|
});
|
|
3169
3395
|
}
|
|
3170
3396
|
this.flushSnapshot();
|
|
@@ -3188,6 +3414,7 @@ export class ClientSession {
|
|
|
3188
3414
|
type: "notice",
|
|
3189
3415
|
level: "error",
|
|
3190
3416
|
text: `获取模型列表失败:${err.message}`,
|
|
3417
|
+
textEn: `Failed to fetch model list: ${err.message}`,
|
|
3191
3418
|
});
|
|
3192
3419
|
}
|
|
3193
3420
|
}
|
|
@@ -3245,6 +3472,7 @@ export class ClientSession {
|
|
|
3245
3472
|
type: "notice",
|
|
3246
3473
|
level: "error",
|
|
3247
3474
|
text: `切换模型失败:${err.message}`,
|
|
3475
|
+
textEn: `Failed to switch model: ${err.message}`,
|
|
3248
3476
|
});
|
|
3249
3477
|
}
|
|
3250
3478
|
this.flushSnapshot();
|
|
@@ -3259,6 +3487,7 @@ export class ClientSession {
|
|
|
3259
3487
|
type: "notice",
|
|
3260
3488
|
level: "error",
|
|
3261
3489
|
text: `切换思考强度失败:${err.message}`,
|
|
3490
|
+
textEn: `Failed to switch thinking level: ${err.message}`,
|
|
3262
3491
|
});
|
|
3263
3492
|
}
|
|
3264
3493
|
this.flushSnapshot();
|
|
@@ -3272,6 +3501,7 @@ export class ClientSession {
|
|
|
3272
3501
|
type: "notice",
|
|
3273
3502
|
level: "error",
|
|
3274
3503
|
text: `切换思考强度失败:${err.message}`,
|
|
3504
|
+
textEn: `Failed to switch thinking level: ${err.message}`,
|
|
3275
3505
|
});
|
|
3276
3506
|
}
|
|
3277
3507
|
this.flushSnapshot();
|
|
@@ -3292,7 +3522,7 @@ export class ClientSession {
|
|
|
3292
3522
|
return;
|
|
3293
3523
|
}
|
|
3294
3524
|
this.emit({ type: "commands", commands, path });
|
|
3295
|
-
this.emit({ type: "notice", level: "info", text: `命令已保存:${path}` });
|
|
3525
|
+
this.emit({ type: "notice", level: "info", text: `命令已保存:${path}`, textEn: `Command saved: ${path}` });
|
|
3296
3526
|
}
|
|
3297
3527
|
async dispose() {
|
|
3298
3528
|
this.disposed = true;
|
|
@@ -3380,9 +3610,7 @@ export class AgentService {
|
|
|
3380
3610
|
}
|
|
3381
3611
|
/** Snapshot for the control socket / status command. */
|
|
3382
3612
|
quiesceInfo() {
|
|
3383
|
-
return this.quiesced
|
|
3384
|
-
? { quiesced: true, quiescedSince: this.quiescedAt }
|
|
3385
|
-
: { quiesced: false };
|
|
3613
|
+
return this.quiesced ? { quiesced: true, quiescedSince: this.quiescedAt } : { quiesced: false };
|
|
3386
3614
|
}
|
|
3387
3615
|
/** Aggregate across every client session: conversations with in-flight runs. */
|
|
3388
3616
|
activeConversations() {
|
|
@@ -3460,6 +3688,7 @@ export class AgentService {
|
|
|
3460
3688
|
type: "notice",
|
|
3461
3689
|
level: "info",
|
|
3462
3690
|
text: `已恢复上次的工作目录:${cwd}`,
|
|
3691
|
+
textEn: `Restored the last working directory: ${cwd}`,
|
|
3463
3692
|
});
|
|
3464
3693
|
}
|
|
3465
3694
|
}
|
|
@@ -3508,6 +3737,7 @@ export class AgentService {
|
|
|
3508
3737
|
async disposeAll() {
|
|
3509
3738
|
// Record still-streaming conversations BEFORE tearing anything down, so
|
|
3510
3739
|
// the next attach can tell the user what was lost (SIGTERM / update).
|
|
3740
|
+
// eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
|
|
3511
3741
|
for (const [clientId, cs] of [...this.clients]) {
|
|
3512
3742
|
try {
|
|
3513
3743
|
const running = cs.streamingSummaries();
|