pi-web-ui 0.86.2 → 0.87.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -23
- package/README.md +1 -1
- package/bin/pi-web-ui.mjs +312 -40
- package/dist/server/agent-service.js +377 -25
- package/dist/server/client-state.js +8 -0
- package/dist/server/composer-drafts.js +138 -0
- package/dist/server/dsh/dsh-agent-service.js +535 -54
- package/dist/server/dsh/dsh-client.js +28 -0
- package/dist/server/dsh/dsh-sessions.js +28 -0
- package/dist/server/dsh/dsh-usage.js +82 -0
- package/dist/server/dsh/preset-clones.js +260 -0
- package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
- package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
- package/dist/server/dsh/runtime/launcher.mjs +17 -0
- package/dist/server/dsh/runtime/override.patch.yml +19 -1
- package/dist/server/files-service.js +259 -1
- package/dist/server/index.js +244 -5
- package/dist/server/mcp-bridge.js +126 -22
- package/dist/server/mcp-hot-reload.js +117 -0
- package/dist/server/model-admin.js +93 -3
- package/dist/server/plugin-dom.js +83 -0
- package/dist/server/plugin-facilities.js +15 -1
- package/dist/server/plugin-installer.js +6 -0
- package/dist/server/plugins.js +781 -74
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/provider-oauth-flow.js +157 -0
- package/dist/server/update-check.js +22 -0
- package/package.json +1 -1
- package/plugins/catalog.json +9 -0
- package/themes/dark-teal.css +65 -22
- package/web/dist/assets/index-8xCnPMZP.js +374 -0
- package/web/dist/assets/index-F86qWlJy.css +41 -0
- package/web/dist/index.html +3 -2
- package/web/dist/assets/TerminalPanel-BytY8dx7.js +0 -6
- package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
- package/web/dist/assets/index-CKoVyDkP.css +0 -10
- package/web/dist/assets/index-Dmwji4Cr.js +0 -361
|
@@ -18,12 +18,13 @@ import { spawn } from "node:child_process";
|
|
|
18
18
|
import { randomUUID } from "node:crypto";
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
import { appendFileSync, existsSync, readFileSync, rmSync, statSync, mkdirSync, watch, writeFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
21
22
|
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
22
23
|
import { fileURLToPath } from "node:url";
|
|
23
24
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
24
25
|
import { Type } from "typebox";
|
|
25
26
|
import { BgServerTracker } from "./bg-servers.js";
|
|
26
|
-
import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, } from "./update-check.js";
|
|
27
|
+
import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, resolveNpmRegistry, sortUpdateItems, } from "./update-check.js";
|
|
27
28
|
import { hasActiveSubagentRun, hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
|
|
28
29
|
import { removeFirstOccurrence } from "./queue-utils.js";
|
|
29
30
|
import { syncPluginToolsIntoSession } from "./plugins.js";
|
|
@@ -32,10 +33,11 @@ import { GoalService } from "./goal-service.js";
|
|
|
32
33
|
import { MarkerService } from "./marker-service.js";
|
|
33
34
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
34
35
|
import { ModelAdminService } from "./model-admin.js";
|
|
35
|
-
import { FilesService, MACHINE_ROOT, workspacePath } from "./files-service.js";
|
|
36
|
+
import { FilesService, MACHINE_ROOT, desktopDirWire, workspacePath } from "./files-service.js";
|
|
36
37
|
import { isExtensionDisabled, isExtensionEnabled, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
|
|
37
38
|
import { bilingual, pick, resolveServerLang } from "./i18n.js";
|
|
38
39
|
import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
|
|
40
|
+
import { ComposerDraftsStore } from "./composer-drafts.js";
|
|
39
41
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, } from "./terminals.js";
|
|
40
42
|
import { applyAgentToolsGating, ASK_USER_QUESTION_TOOL_NAME, BROWSER_PAGE_TOOL_NAME, effectiveDisabledAgentTools, isTerminalGuidanceOn, MARKERS_LIST_TOOL_NAME, } from "./tool-manager.js";
|
|
41
43
|
import { WebUIContext } from "./webui-context.js";
|
|
@@ -50,6 +52,12 @@ import { launchOrigin, toServiceInfo } from "./launch-origin.js";
|
|
|
50
52
|
import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
51
53
|
import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
|
|
52
54
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
55
|
+
/** 服务进程所在机器的用户主目录(wire 格式):进程内不变,模块加载时求值一次,
|
|
56
|
+
* 快照热路径直接引用(右栏 🏠 一键直达,见 protocol.ts 的 UiState.homeDir)。 */
|
|
57
|
+
const HOME_WIRE = homedir().replace(/\\/g, "/");
|
|
58
|
+
/** 桌面目录(wire 格式):进程内不变(见 files-service.ts 的 desktopDirWire),
|
|
59
|
+
* 不存在则空串 → 前端不渲染 🖥️。 */
|
|
60
|
+
const DESKTOP_WIRE = desktopDirWire(HOME_WIRE);
|
|
53
61
|
/** While assistant deltas are flowing, live rendering is carried by
|
|
54
62
|
* message_delta — full snapshots become pure reconciliation checkpoints, so
|
|
55
63
|
* send them on a slow event-driven cadence (see flushSnapshot call-sites:
|
|
@@ -962,6 +970,10 @@ export class ClientSession {
|
|
|
962
970
|
conv.listed = true;
|
|
963
971
|
conv.title = subagentTitle(prompt);
|
|
964
972
|
this.convs.set(conv.id, conv);
|
|
973
|
+
// 子代理会话同样订阅 SDK 事件:否则 onEvent 永不触发,点开查看时没有
|
|
974
|
+
// message_delta 流式增量、快照也不刷新,只能靠切走切回时的 flushSnapshot
|
|
975
|
+
// 看到新内容(dismiss/释放流程本来就会 unsubscribe,不泄漏)。
|
|
976
|
+
conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
|
|
965
977
|
// 子代理不走 bindSession——这里同样注入面板的重试次数覆盖。
|
|
966
978
|
this.applyRetryOverrides();
|
|
967
979
|
// 扩展绑定(rpc 模式);用 headless 的 Web UI context:
|
|
@@ -1368,6 +1380,8 @@ export class ClientSession {
|
|
|
1368
1380
|
watchTimer = null;
|
|
1369
1381
|
/** 子代理模板库(全局共享,<dataDir>/subagent-templates.json)。 */
|
|
1370
1382
|
subagentTemplates;
|
|
1383
|
+
/** 未发送输入框草稿(全局共享,<dataDir>/composer-drafts.json,按 sessionId 键入,见 server/composer-drafts.ts)。 */
|
|
1384
|
+
drafts;
|
|
1371
1385
|
/** 内置标记服务(todo/notify/svc/rename 等,可全局/分组开关)。 */
|
|
1372
1386
|
markerSvc;
|
|
1373
1387
|
// -----------------------------------------------------------------------
|
|
@@ -1401,6 +1415,7 @@ export class ClientSession {
|
|
|
1401
1415
|
this.stateStore = stateStore;
|
|
1402
1416
|
this.roots = stateStore.getWorkspaceRoots(clientId, cwd);
|
|
1403
1417
|
this.subagentTemplates = new SubagentTemplatesStore(join(stateStore.dataDir, "subagent-templates.json"));
|
|
1418
|
+
this.drafts = new ComposerDraftsStore(join(stateStore.dataDir, "composer-drafts.json"));
|
|
1404
1419
|
this.markerSvc = new MarkerService({
|
|
1405
1420
|
clientId,
|
|
1406
1421
|
stateStore,
|
|
@@ -1484,6 +1499,7 @@ export class ClientSession {
|
|
|
1484
1499
|
this.piCheckCache = null;
|
|
1485
1500
|
},
|
|
1486
1501
|
pushModels: async () => this.listModels(),
|
|
1502
|
+
onOAuthActivated: (provider) => this.stateStore.deleteProviderEverywhere(provider),
|
|
1487
1503
|
});
|
|
1488
1504
|
// Prune dead background tasks every 30s (only spawns netstat/lsof while
|
|
1489
1505
|
// the list is non-empty). unref: must not keep the process alive.
|
|
@@ -1784,26 +1800,78 @@ export class ClientSession {
|
|
|
1784
1800
|
};
|
|
1785
1801
|
}
|
|
1786
1802
|
/** Summaries of conversations currently streaming — captured at shutdown
|
|
1787
|
-
* so the next attach can
|
|
1803
|
+
* so the next attach can reopen them and continue their runs. */
|
|
1788
1804
|
streamingSummaries() {
|
|
1789
1805
|
const out = [];
|
|
1790
1806
|
for (const conv of this.convs.values()) {
|
|
1791
|
-
if (conv.session.isStreaming)
|
|
1792
|
-
|
|
1807
|
+
if (!conv.session.isStreaming)
|
|
1808
|
+
continue;
|
|
1809
|
+
let sessionFile;
|
|
1810
|
+
try {
|
|
1811
|
+
sessionFile = conv.session.sessionFile ?? undefined;
|
|
1812
|
+
}
|
|
1813
|
+
catch {
|
|
1814
|
+
sessionFile = undefined;
|
|
1815
|
+
}
|
|
1816
|
+
out.push({ title: conv.title, cwd: conv.cwd, sessionFile });
|
|
1793
1817
|
}
|
|
1794
1818
|
return out;
|
|
1795
1819
|
}
|
|
1796
|
-
/**
|
|
1797
|
-
|
|
1820
|
+
/** Reopen sessions interrupted by the last restart and continue them.
|
|
1821
|
+
*
|
|
1822
|
+
* For each record WITH a session file: reopen it (listed, keeps running
|
|
1823
|
+
* when displaced) and send a short Continue prompt so the run resumes
|
|
1824
|
+
* from the persisted context. Records WITHOUT a file fall back to the
|
|
1825
|
+
* warning notice. The conversation active before the resume is restored
|
|
1826
|
+
* at the end, so the user lands where they were and clicks whichever
|
|
1827
|
+
* resumed run they want — nothing steals the view.
|
|
1828
|
+
*
|
|
1829
|
+
* Called once on the first attach after a restart (fire-and-forget from
|
|
1830
|
+
* the attach path — each step is internally guarded and never throws). */
|
|
1831
|
+
async resumeInterrupted(list) {
|
|
1798
1832
|
if (!list || list.length === 0)
|
|
1799
1833
|
return;
|
|
1800
|
-
const
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1834
|
+
const resumable = list.filter((r) => r.sessionFile);
|
|
1835
|
+
const orphaned = list.filter((r) => !r.sessionFile);
|
|
1836
|
+
if (orphaned.length > 0) {
|
|
1837
|
+
const names = orphaned.map((r) => `「${r.title}」(${r.cwd})`).join("、");
|
|
1838
|
+
this.emit({
|
|
1839
|
+
type: "notice",
|
|
1840
|
+
level: "warning",
|
|
1841
|
+
text: `上次服务重启时有 ${orphaned.length} 个进行中的对话被中断且无法自动恢复:${names}。可在历史对话中手动恢复继续。`,
|
|
1842
|
+
textEn: `${orphaned.length} running conversation(s) were interrupted by the last restart and could not be resumed automatically: ${names}. Resume them manually from History.`,
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
if (resumable.length === 0)
|
|
1846
|
+
return;
|
|
1847
|
+
const continueText = this.getLang() === "zh" ? "继续" : "Continue";
|
|
1848
|
+
const restoreId = this.activeId;
|
|
1849
|
+
for (const r of resumable) {
|
|
1850
|
+
try {
|
|
1851
|
+
this.emit({
|
|
1852
|
+
type: "notice",
|
|
1853
|
+
level: "info",
|
|
1854
|
+
text: `上次服务重启中断了「${r.title}」,正在自动恢复并继续。`,
|
|
1855
|
+
textEn: `Restart interrupted "${r.title}" — reopening it and continuing automatically.`,
|
|
1856
|
+
});
|
|
1857
|
+
await this.switchSession(r.sessionFile);
|
|
1858
|
+
await this.prompt(continueText);
|
|
1859
|
+
}
|
|
1860
|
+
catch {
|
|
1861
|
+
// switchSession/prompt already surface failures as notices;
|
|
1862
|
+
// one bad session must not block the rest.
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
// Hand the view back: the user lands where they were, resumed runs
|
|
1866
|
+
// keep going in the background list.
|
|
1867
|
+
try {
|
|
1868
|
+
if (restoreId && this.convs.has(restoreId) && restoreId !== this.activeId) {
|
|
1869
|
+
await this.switchConversation(restoreId);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
catch {
|
|
1873
|
+
/* staying on the last resumed session is a fine fallback */
|
|
1874
|
+
}
|
|
1807
1875
|
}
|
|
1808
1876
|
/** Add a socket to this client's broadcast set; flushes buffered startup notices. */
|
|
1809
1877
|
attachSink(send) {
|
|
@@ -1838,6 +1906,7 @@ export class ClientSession {
|
|
|
1838
1906
|
// Reconnect: push the built-in provider key list (multi-key grouping in the
|
|
1839
1907
|
// model picker needs it even before the client asks).
|
|
1840
1908
|
this.modelAdmin.listProviderKeys();
|
|
1909
|
+
this.modelAdmin.listProviderOAuthFlows();
|
|
1841
1910
|
// PTYs are conversation-owned and survive a socket reconnect.
|
|
1842
1911
|
this.pushTerminals();
|
|
1843
1912
|
}
|
|
@@ -2027,6 +2096,153 @@ export class ClientSession {
|
|
|
2027
2096
|
return null;
|
|
2028
2097
|
}
|
|
2029
2098
|
}
|
|
2099
|
+
/** 插件扩展点 v2(只读组装,供 index.ts 注入给 PluginManager 的 conversationLister)。
|
|
2100
|
+
* 本客户端运行中对话(kind:"running")+ 当前项目历史会话摘要(kind:"history",最多 50 条)。
|
|
2101
|
+
* 纯数据组装,不 emit、不改任何状态;历史会话读失败时只回运行中部分。 */
|
|
2102
|
+
listRunningForPlugins() {
|
|
2103
|
+
const out = [];
|
|
2104
|
+
for (const conv of this.convs.values()) {
|
|
2105
|
+
let isStreaming = false;
|
|
2106
|
+
try {
|
|
2107
|
+
isStreaming = conv.session.isStreaming;
|
|
2108
|
+
}
|
|
2109
|
+
catch {
|
|
2110
|
+
// 会话替换中——按未跑处理
|
|
2111
|
+
}
|
|
2112
|
+
out.push({ id: conv.id, title: conv.title, cwd: conv.cwd, kind: "running", isStreaming });
|
|
2113
|
+
}
|
|
2114
|
+
return out;
|
|
2115
|
+
}
|
|
2116
|
+
/** 插件扩展点 v2(conversationLister 的历史一半):当前项目历史会话摘要,最多 50 条。
|
|
2117
|
+
* 复用 refreshSessions/searchSessions 共用的 loadSessionInfos 缓存(3s TTL,不扫两遍盘)。 */
|
|
2118
|
+
async listHistoryForPlugins(limit = 50) {
|
|
2119
|
+
try {
|
|
2120
|
+
const infos = await this.loadSessionInfos();
|
|
2121
|
+
return infos.slice(0, Math.max(0, limit)).map((s) => ({
|
|
2122
|
+
id: s.path,
|
|
2123
|
+
title: (s.name?.trim() || s.firstMessage.trim() || basename(s.path)).slice(0, 60),
|
|
2124
|
+
cwd: this.cwd,
|
|
2125
|
+
kind: "history",
|
|
2126
|
+
isStreaming: false,
|
|
2127
|
+
}));
|
|
2128
|
+
}
|
|
2129
|
+
catch {
|
|
2130
|
+
return [];
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
/** 插件扩展点 v2(供 conversationSearcher):运行中对话标题 + 历史会话全文匹配,返回前 N 个 {id,title}。
|
|
2134
|
+
* 复用 searchSessions 的 sessionMatchesSearch 判定(含转录全文),只读不 emit。 */
|
|
2135
|
+
async searchForPlugins(query, limit = 20) {
|
|
2136
|
+
const q = query.trim().toLowerCase();
|
|
2137
|
+
if (!q)
|
|
2138
|
+
return [];
|
|
2139
|
+
const out = [];
|
|
2140
|
+
for (const conv of this.convs.values()) {
|
|
2141
|
+
if (conv.title.toLowerCase().includes(q))
|
|
2142
|
+
out.push({ id: conv.id, title: conv.title });
|
|
2143
|
+
if (out.length >= limit)
|
|
2144
|
+
return out;
|
|
2145
|
+
}
|
|
2146
|
+
try {
|
|
2147
|
+
const infos = await this.loadSessionInfos();
|
|
2148
|
+
for (const s of infos) {
|
|
2149
|
+
if (!sessionMatchesSearch(q, s))
|
|
2150
|
+
continue;
|
|
2151
|
+
out.push({
|
|
2152
|
+
id: s.path,
|
|
2153
|
+
title: (s.name?.trim() || s.firstMessage.trim() || basename(s.path)).slice(0, 60),
|
|
2154
|
+
});
|
|
2155
|
+
if (out.length >= limit)
|
|
2156
|
+
break;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
catch {
|
|
2160
|
+
// 读盘失败只回运行中部分
|
|
2161
|
+
}
|
|
2162
|
+
return out;
|
|
2163
|
+
}
|
|
2164
|
+
/** 插件扩展点 v2(供 conversationWriter):向指定对话投递 prompt,复用现有 prompt 投递路径。
|
|
2165
|
+
* 目标非当前对话时先 switchConversation(复用跨项目切换副作用),再走 this.prompt
|
|
2166
|
+
* (斜杠拦截/排空门禁/并行提醒/首条命名全在里面);找不到对话返回 {ok:false,error}。 */
|
|
2167
|
+
async writeForPlugins(id, text) {
|
|
2168
|
+
try {
|
|
2169
|
+
const conv = this.convs.get(id);
|
|
2170
|
+
if (!conv)
|
|
2171
|
+
return { ok: false, error: `未知对话:${id}` };
|
|
2172
|
+
if (!text.trim())
|
|
2173
|
+
return { ok: false, error: "投递文本为空" };
|
|
2174
|
+
if (id !== this.activeId)
|
|
2175
|
+
await this.switchConversation(id);
|
|
2176
|
+
await this.prompt(text);
|
|
2177
|
+
return { ok: true };
|
|
2178
|
+
}
|
|
2179
|
+
catch (err) {
|
|
2180
|
+
return { ok: false, error: err.message };
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
/** 插件扩展点 v2(供 runAborter):中止指定对话的运行,复用 abort 的 interruptRun 路径
|
|
2184
|
+
* (abort 卡住/空转时的强制重置语义一并继承)。未在跑时直接 {ok:true}(幂等)。 */
|
|
2185
|
+
async abortForPlugins(id) {
|
|
2186
|
+
try {
|
|
2187
|
+
const conv = this.convs.get(id);
|
|
2188
|
+
if (!conv)
|
|
2189
|
+
return { ok: false, error: `未知对话:${id}` };
|
|
2190
|
+
if (this.conversationStreaming(conv)) {
|
|
2191
|
+
await this.interruptRun(conv, "插件已中止运行");
|
|
2192
|
+
this.flushSnapshot();
|
|
2193
|
+
}
|
|
2194
|
+
return { ok: true };
|
|
2195
|
+
}
|
|
2196
|
+
catch (err) {
|
|
2197
|
+
return { ok: false, error: err.message };
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
/** 插件扩展点(供 runSteerer 跨客户端兜底复用):只在本客户端 conversations 里找对话,
|
|
2201
|
+
* 持有则执行 steer 并返回结果,未持有回 undefined(不碰钩子,无递归)。 */
|
|
2202
|
+
async steerOwnConversation(id, text) {
|
|
2203
|
+
const conv = this.convs.get(id);
|
|
2204
|
+
if (!conv?.session)
|
|
2205
|
+
return undefined;
|
|
2206
|
+
await conv.session.sendUserMessage(text, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
|
|
2207
|
+
return { ok: true };
|
|
2208
|
+
}
|
|
2209
|
+
/** 插件扩展点(供 runSteerer):向指定对话插队一条用户消息,复用子代理 steer 的
|
|
2210
|
+
* sendUserMessage + deliverAs:'steer' 路径(运行时插队;未跑时按普通消息投递)。
|
|
2211
|
+
* 先找本客户端 conversations,找不到再经 steerConversationElsewhere 问其他客户端;
|
|
2212
|
+
* 空文本回 {ok:false};异常 catch 透传 message。 */
|
|
2213
|
+
async steerForPlugins(conversationId, text) {
|
|
2214
|
+
try {
|
|
2215
|
+
if (!text.trim())
|
|
2216
|
+
return { ok: false, error: "空消息" };
|
|
2217
|
+
const own = await this.steerOwnConversation(conversationId, text);
|
|
2218
|
+
if (own)
|
|
2219
|
+
return own;
|
|
2220
|
+
if (typeof this.steerConversationElsewhere === "function") {
|
|
2221
|
+
const r = await this.steerConversationElsewhere(conversationId, text);
|
|
2222
|
+
if (r)
|
|
2223
|
+
return r;
|
|
2224
|
+
}
|
|
2225
|
+
return { ok: false, error: `未知对话:${conversationId}` };
|
|
2226
|
+
}
|
|
2227
|
+
catch (err) {
|
|
2228
|
+
return { ok: false, error: err.message };
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
/** 插件扩展点 v2(供 modelLister):复用 listModels 的模型列表映射 {id,provider,vision}。
|
|
2232
|
+
* 与 listModels 唯一差别:不做网络 refresh(插件列表走缓存目录,15s 超时也不等),只读不 emit。 */
|
|
2233
|
+
async listModelsForPlugins() {
|
|
2234
|
+
try {
|
|
2235
|
+
const available = await this.runtime.services.modelRuntime.getAvailable();
|
|
2236
|
+
return available.map((m) => ({
|
|
2237
|
+
id: `${m.provider}/${m.id}`,
|
|
2238
|
+
provider: m.provider,
|
|
2239
|
+
vision: m.input?.includes("image") ?? false,
|
|
2240
|
+
}));
|
|
2241
|
+
}
|
|
2242
|
+
catch {
|
|
2243
|
+
return [];
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2030
2246
|
onEvent(conv, event) {
|
|
2031
2247
|
// Any SDK event proves the run is alive — feeds the stall watchdog below.
|
|
2032
2248
|
conv.lastSdkEventAt = Date.now();
|
|
@@ -2535,7 +2751,7 @@ export class ClientSession {
|
|
|
2535
2751
|
return messages;
|
|
2536
2752
|
}
|
|
2537
2753
|
/** Build every UiState field EXCEPT messages (the expensive part). */
|
|
2538
|
-
buildLightState(rev) {
|
|
2754
|
+
buildLightState(rev, withDraft = false) {
|
|
2539
2755
|
const conv = this.conv;
|
|
2540
2756
|
const state = conv.session.agent.state;
|
|
2541
2757
|
const model = state.model;
|
|
@@ -2587,6 +2803,9 @@ export class ClientSession {
|
|
|
2587
2803
|
cwd: this.cwd,
|
|
2588
2804
|
// 判重:直接读缓存字段,不在快照热路径上重读 client-state。
|
|
2589
2805
|
workspaceRoots: this.roots,
|
|
2806
|
+
// 用户主目录(右栏 🏠):进程内不变,模块级求值一次,不在热路径调 homedir()。
|
|
2807
|
+
homeDir: HOME_WIRE,
|
|
2808
|
+
desktopDir: DESKTOP_WIRE,
|
|
2590
2809
|
sessionId: this.session.sessionId,
|
|
2591
2810
|
sessionFile: this.session.sessionFile,
|
|
2592
2811
|
conversationId: this.activeId,
|
|
@@ -2610,6 +2829,10 @@ export class ClientSession {
|
|
|
2610
2829
|
retry: conv.retryState ?? null,
|
|
2611
2830
|
compaction: conv.compactionState ?? null,
|
|
2612
2831
|
pendingQuestion: this.pendingQuestionForSnapshot(),
|
|
2832
|
+
// 未发送草稿只跟全量快照走(切会话/new_chat/get_state):增量 delta 里
|
|
2833
|
+
// 这个 key 必须整个缺席(不能是显式的 undefined——前端 delta 合并是
|
|
2834
|
+
// {...ui, ...d.state} 整批覆盖,显式 undefined 会把上次全量带回的草稿洗掉)。
|
|
2835
|
+
...(withDraft ? { draft: this.draftForSnapshot() } : {}),
|
|
2613
2836
|
tools: state.tools.map((t) => t.name),
|
|
2614
2837
|
version: ++this.version,
|
|
2615
2838
|
piConfigured: this.isPiConfigured(),
|
|
@@ -2653,7 +2876,7 @@ export class ClientSession {
|
|
|
2653
2876
|
rev,
|
|
2654
2877
|
baseRev,
|
|
2655
2878
|
appended: cur.slice(prev.length),
|
|
2656
|
-
state: this.buildLightState(rev),
|
|
2879
|
+
state: this.buildLightState(rev, false),
|
|
2657
2880
|
});
|
|
2658
2881
|
}
|
|
2659
2882
|
else {
|
|
@@ -2662,7 +2885,9 @@ export class ClientSession {
|
|
|
2662
2885
|
this.emittedRev = rev;
|
|
2663
2886
|
this.emit({
|
|
2664
2887
|
type: "snapshot",
|
|
2665
|
-
|
|
2888
|
+
// 全量快照一律带草稿(切会话/new_chat/改写分支/重连 get_state 全走这里);
|
|
2889
|
+
// 60ms 热帧是上面的 snapshot_delta,本来就不带。
|
|
2890
|
+
state: { ...this.buildLightState(rev, true), messages: cur },
|
|
2666
2891
|
});
|
|
2667
2892
|
}
|
|
2668
2893
|
}
|
|
@@ -2981,6 +3206,9 @@ export class ClientSession {
|
|
|
2981
3206
|
* - onRunningChanged:本实例流式集合变化时触发,AgentService 借此让其他
|
|
2982
3207
|
* 客户端重推 conversations(elsewhere 列表近实时)。 */
|
|
2983
3208
|
findSessionOwner = undefined;
|
|
3209
|
+
/** 插件 steer 跨客户端兜底钩子:attach 时由 AgentService 接线(见 steerElsewhere),
|
|
3210
|
+
* 在其他客户端的 conversations 里找对话并由持有方执行 steer,未持有回 undefined。 */
|
|
3211
|
+
steerConversationElsewhere = undefined;
|
|
2984
3212
|
/** issue #145:除本客户端外是否有人在跑(扫目录查重前置的无 I/O 判断)。 */
|
|
2985
3213
|
hasStreamingElsewhere = undefined;
|
|
2986
3214
|
listProjectRunners = undefined;
|
|
@@ -3044,7 +3272,7 @@ export class ClientSession {
|
|
|
3044
3272
|
}
|
|
3045
3273
|
try {
|
|
3046
3274
|
const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion());
|
|
3047
|
-
const items = await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir));
|
|
3275
|
+
const items = sortUpdateItems(await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(this.agentDir)));
|
|
3048
3276
|
this.updatesAllCache = { at: Date.now(), items };
|
|
3049
3277
|
this.emit({ type: "update_status_all", items });
|
|
3050
3278
|
}
|
|
@@ -3201,10 +3429,27 @@ export class ClientSession {
|
|
|
3201
3429
|
return this.modelAdmin.setProviderApiKey(provider, apiKey);
|
|
3202
3430
|
}
|
|
3203
3431
|
async clearProviderApiKey(provider) {
|
|
3432
|
+
const usingOAuth = this.runtime.services.modelRuntime.isUsingOAuth(provider.trim());
|
|
3204
3433
|
await this.modelAdmin.clearProviderApiKey(provider);
|
|
3205
3434
|
// The provider is back to unconfigured — drop its key preference in
|
|
3206
3435
|
// EVERY project, otherwise each project switch re-tries a restore.
|
|
3207
|
-
|
|
3436
|
+
if (!usingOAuth)
|
|
3437
|
+
this.stateStore.deleteProviderEverywhere(provider.trim());
|
|
3438
|
+
}
|
|
3439
|
+
startProviderOAuth(provider) {
|
|
3440
|
+
this.modelAdmin.startProviderOAuth(provider);
|
|
3441
|
+
}
|
|
3442
|
+
replyProviderOAuth(flowId, promptId, value) {
|
|
3443
|
+
this.modelAdmin.replyProviderOAuth(flowId, promptId, value);
|
|
3444
|
+
}
|
|
3445
|
+
cancelProviderOAuth(flowId) {
|
|
3446
|
+
this.modelAdmin.cancelProviderOAuth(flowId);
|
|
3447
|
+
}
|
|
3448
|
+
listProviderOAuthFlows() {
|
|
3449
|
+
this.modelAdmin.listProviderOAuthFlows();
|
|
3450
|
+
}
|
|
3451
|
+
logoutProviderOAuth(provider) {
|
|
3452
|
+
return this.modelAdmin.logoutProviderOAuth(provider);
|
|
3208
3453
|
}
|
|
3209
3454
|
listProviders() {
|
|
3210
3455
|
return this.modelAdmin.listProviders();
|
|
@@ -3634,6 +3879,14 @@ export class ClientSession {
|
|
|
3634
3879
|
// switch/new_chat while prompt() is in flight must never target a
|
|
3635
3880
|
// different conversation.
|
|
3636
3881
|
const conv = this.conv;
|
|
3882
|
+
// 输入框内容被消费(发送/斜杠执行)→ 清掉该会话存过的草稿(best-effort)。
|
|
3883
|
+
// 快捷短语发送(不碰输入框)同样清:客户端发送成功后会把当前草稿重存回来。
|
|
3884
|
+
try {
|
|
3885
|
+
this.drafts.clear(conv.session.sessionId);
|
|
3886
|
+
}
|
|
3887
|
+
catch {
|
|
3888
|
+
// ignore
|
|
3889
|
+
}
|
|
3637
3890
|
try {
|
|
3638
3891
|
const s = this.session;
|
|
3639
3892
|
// Native slash commands (see NATIVE_COMMANDS) are executed here and
|
|
@@ -3954,6 +4207,32 @@ export class ClientSession {
|
|
|
3954
4207
|
}
|
|
3955
4208
|
this.flushSnapshot();
|
|
3956
4209
|
}
|
|
4210
|
+
// -----------------------------------------------------------------------
|
|
4211
|
+
// 未发送输入框草稿(issue #166,单中心文件方案,见 server/composer-drafts.ts)
|
|
4212
|
+
// -----------------------------------------------------------------------
|
|
4213
|
+
/** 存指定会话的未发送草稿(`draft_update` 入口,经 DispatchSession.saveDraft)。
|
|
4214
|
+
* 按消息自带的 sessionId 落键(不按 active 会话:切会话时的「离开刷盘」
|
|
4215
|
+
* 晚于服务端的切换到达)。空白新会话的转录还没落盘,但 id 内存里已有。
|
|
4216
|
+
* 存完不推快照:同页的草稿本来就是自己打的;恢复走全量快照的 draft 字段。 */
|
|
4217
|
+
saveDraft(sessionId, text, ts) {
|
|
4218
|
+
try {
|
|
4219
|
+
if (!sessionId)
|
|
4220
|
+
return;
|
|
4221
|
+
this.drafts.save(sessionId, text, ts);
|
|
4222
|
+
}
|
|
4223
|
+
catch {
|
|
4224
|
+
// best-effort:草稿丢了可以重打
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
/** 当前活跃对话的草稿(全量快照用;增量 snapshot_delta 传 withDraft=false 不带)。 */
|
|
4228
|
+
draftForSnapshot() {
|
|
4229
|
+
try {
|
|
4230
|
+
return this.drafts.get(this.conv.session.sessionId) ?? null;
|
|
4231
|
+
}
|
|
4232
|
+
catch {
|
|
4233
|
+
return null;
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
3957
4236
|
/** Re-push the current list on request (panel opened); prunes dead entries first. */
|
|
3958
4237
|
async listBgServers() {
|
|
3959
4238
|
await this.bg.listAndPush();
|
|
@@ -4117,7 +4396,8 @@ export class ClientSession {
|
|
|
4117
4396
|
* 空白新对话」——/new <prompt> 只在 true 时投递首条提示;false 表示没能进入
|
|
4118
4397
|
* 新对话(准入关闭 / 同项目对话数达上限 / runtime 创建失败),此时照发会把
|
|
4119
4398
|
* 首条提示投进用户原本正在用的那个对话里。 */
|
|
4120
|
-
async newChat() {
|
|
4399
|
+
async newChat(_preset) {
|
|
4400
|
+
// _preset: DSH Agent 预设(pi 引擎无此概念,忽略;wire 统一见 protocol new_chat)。
|
|
4121
4401
|
if (this.quiesceBlocked())
|
|
4122
4402
|
return false;
|
|
4123
4403
|
// Reuse an already-open blank conversation instead of piling up new ones
|
|
@@ -4687,6 +4967,13 @@ export class ClientSession {
|
|
|
4687
4967
|
}
|
|
4688
4968
|
}
|
|
4689
4969
|
rmSync(abs, { force: true });
|
|
4970
|
+
// 转录删了,未发送草稿再留着就是孤儿,一起清掉。
|
|
4971
|
+
try {
|
|
4972
|
+
this.drafts.pruneSessionFile(abs);
|
|
4973
|
+
}
|
|
4974
|
+
catch {
|
|
4975
|
+
// ignore
|
|
4976
|
+
}
|
|
4690
4977
|
// Bust the brief session-info fridge: refreshSessions() below usually
|
|
4691
4978
|
// lands inside its 3s TTL and would otherwise re-serve a listing that
|
|
4692
4979
|
// still contains the deleted transcript.
|
|
@@ -5506,6 +5793,22 @@ export class ClientSession {
|
|
|
5506
5793
|
async uploadFile(relDir, name, data) {
|
|
5507
5794
|
return this.files.uploadFile(relDir, name, data);
|
|
5508
5795
|
}
|
|
5796
|
+
/** 文件树右键菜单:新建(空文件/空文件夹)。 */
|
|
5797
|
+
async createEntry(dir, name, kind) {
|
|
5798
|
+
return this.files.createEntry(dir, name, kind);
|
|
5799
|
+
}
|
|
5800
|
+
/** 文件树右键菜单:同目录内重命名。 */
|
|
5801
|
+
async renameEntry(path, newName) {
|
|
5802
|
+
return this.files.renameEntry(path, newName);
|
|
5803
|
+
}
|
|
5804
|
+
/** 文件树右键菜单:删除文件/目录。 */
|
|
5805
|
+
async deleteEntry(path) {
|
|
5806
|
+
return this.files.deleteEntry(path);
|
|
5807
|
+
}
|
|
5808
|
+
/** 文件树右键菜单:复制/移动(move=true 即剪切粘贴)。 */
|
|
5809
|
+
async copyEntry(src, destDir, move) {
|
|
5810
|
+
return this.files.copyEntry(src, destDir, move);
|
|
5811
|
+
}
|
|
5509
5812
|
async makeDir(relPath) {
|
|
5510
5813
|
return this.files.makeDir(relPath);
|
|
5511
5814
|
}
|
|
@@ -5862,6 +6165,7 @@ export class ClientSession {
|
|
|
5862
6165
|
}
|
|
5863
6166
|
async dispose() {
|
|
5864
6167
|
this.disposed = true;
|
|
6168
|
+
this.modelAdmin.dispose();
|
|
5865
6169
|
for (const conv of this.convs.values())
|
|
5866
6170
|
conv.terminals.killAll();
|
|
5867
6171
|
if (this.snapshotTimer) {
|
|
@@ -5991,6 +6295,24 @@ export class AgentService {
|
|
|
5991
6295
|
}
|
|
5992
6296
|
return null;
|
|
5993
6297
|
}
|
|
6298
|
+
/** 插件 steer 跨客户端兜底:除请求方外逐个问其他客户端的 conversations,
|
|
6299
|
+
* 找到持有方由其执行 steer(只调 steerOwnConversation,不碰钩子,无递归);
|
|
6300
|
+
* 都找不到回 undefined,调用方回未知对话。单客户端异常跳过,不影响其他。 */
|
|
6301
|
+
async steerElsewhere(excludeClientId, id, text) {
|
|
6302
|
+
for (const [clientId, cs] of this.clients) {
|
|
6303
|
+
if (clientId === excludeClientId)
|
|
6304
|
+
continue;
|
|
6305
|
+
try {
|
|
6306
|
+
const r = await cs.steerOwnConversation(id, text);
|
|
6307
|
+
if (r)
|
|
6308
|
+
return r;
|
|
6309
|
+
}
|
|
6310
|
+
catch {
|
|
6311
|
+
// 单客户端坏了继续找下一个
|
|
6312
|
+
}
|
|
6313
|
+
}
|
|
6314
|
+
return undefined;
|
|
6315
|
+
}
|
|
5994
6316
|
/** issue #145:别处在某 cwd 下正在跑的对话(同项目并行感知用,不含请求方)。 */
|
|
5995
6317
|
listProjectRunners(cwd, excludeClientId) {
|
|
5996
6318
|
const out = [];
|
|
@@ -6169,6 +6491,7 @@ export class AgentService {
|
|
|
6169
6491
|
cs.listExternalRunning = () => this.listExternalRunning(clientId);
|
|
6170
6492
|
cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
|
|
6171
6493
|
cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
|
|
6494
|
+
cs.steerConversationElsewhere = (id, text) => this.steerElsewhere(clientId, id, text);
|
|
6172
6495
|
// Make sure the restored/default workspace appears in the project list.
|
|
6173
6496
|
this.stateStore.remember(clientId, cwd);
|
|
6174
6497
|
if (cwd !== this.cwd) {
|
|
@@ -6181,10 +6504,11 @@ export class AgentService {
|
|
|
6181
6504
|
}
|
|
6182
6505
|
}
|
|
6183
6506
|
}
|
|
6184
|
-
// First attach after a restart:
|
|
6185
|
-
// the previous process shut down
|
|
6186
|
-
//
|
|
6187
|
-
|
|
6507
|
+
// First attach after a restart: reopen sessions that were streaming
|
|
6508
|
+
// when the previous process shut down and continue them (consumed
|
|
6509
|
+
// once, then cleared). Fire-and-forget AFTER attachSink + hooks:
|
|
6510
|
+
// resume emits directly to sinks and needs the owner guards.
|
|
6511
|
+
// Progress arrives over the socket as usual.
|
|
6188
6512
|
cs.attachSink(send);
|
|
6189
6513
|
// Forward hooks (set once by index.ts) to every session.
|
|
6190
6514
|
cs.onQuit = this.onQuit;
|
|
@@ -6203,10 +6527,12 @@ export class AgentService {
|
|
|
6203
6527
|
cs.listExternalRunning = () => this.listExternalRunning(clientId);
|
|
6204
6528
|
cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
|
|
6205
6529
|
cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
|
|
6530
|
+
cs.steerConversationElsewhere = (id, text) => this.steerElsewhere(clientId, id, text);
|
|
6206
6531
|
// 插件宿主工作区跟随:初次接入也同步一次(恢复的 lastCwd 可能≠服务启动目录),
|
|
6207
6532
|
// notifyCwd 幂等去重;此后 set_cwd 成功时由 cs.onCwdChanged 继续驱动。
|
|
6208
6533
|
cs.onCwdChanged = (abs, roots) => this.onClientCwdChanged?.(abs, roots);
|
|
6209
6534
|
this.onClientCwdChanged?.(cs.cwd, cs.workspaceRoots);
|
|
6535
|
+
void cs.resumeInterrupted(this.stateStore.takeInterrupted(clientId));
|
|
6210
6536
|
return cs;
|
|
6211
6537
|
}
|
|
6212
6538
|
/** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */
|
|
@@ -6250,7 +6576,30 @@ export class AgentService {
|
|
|
6250
6576
|
get(clientId) {
|
|
6251
6577
|
return this.clients.get(clientId);
|
|
6252
6578
|
}
|
|
6253
|
-
|
|
6579
|
+
/** 插件扩展点 v2:挑一个最合适的客户端会话供无浏览器调用的插件 API 用
|
|
6580
|
+
* (conversationLister/Searcher/Writer、modelLister、runAborter)。
|
|
6581
|
+
* 有运行中对话的优先,否则任意残留客户端;一个没有时返回 undefined,
|
|
6582
|
+
* 调用方(index.ts 注入)回退空列表 / {ok:false},绝不抛错。 */
|
|
6583
|
+
pluginClient() {
|
|
6584
|
+
let fallback;
|
|
6585
|
+
for (const cs of this.clients.values()) {
|
|
6586
|
+
if (!fallback)
|
|
6587
|
+
fallback = cs;
|
|
6588
|
+
try {
|
|
6589
|
+
if (cs.activeConversations() > 0)
|
|
6590
|
+
return cs;
|
|
6591
|
+
}
|
|
6592
|
+
catch {
|
|
6593
|
+
// 单客户端坏了不影响挑选
|
|
6594
|
+
}
|
|
6595
|
+
}
|
|
6596
|
+
return fallback;
|
|
6597
|
+
}
|
|
6598
|
+
/** Snapshot still-streaming conversations for post-restart resume.
|
|
6599
|
+
* Called during graceful shutdown AND from the restart_service handler
|
|
6600
|
+
* (which exits without shutdown under systemd — without this, the
|
|
6601
|
+
* interrupted-run record would silently never be written there). */
|
|
6602
|
+
recordInterruptedRuns() {
|
|
6254
6603
|
// Record still-streaming conversations BEFORE tearing anything down, so
|
|
6255
6604
|
// the next attach can tell the user what was lost (SIGTERM / update).
|
|
6256
6605
|
// eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
|
|
@@ -6265,6 +6614,9 @@ export class AgentService {
|
|
|
6265
6614
|
// best effort — never block shutdown on bookkeeping
|
|
6266
6615
|
}
|
|
6267
6616
|
}
|
|
6617
|
+
}
|
|
6618
|
+
async disposeAll() {
|
|
6619
|
+
this.recordInterruptedRuns();
|
|
6268
6620
|
const all = [...this.clients.values()];
|
|
6269
6621
|
this.clients.clear();
|
|
6270
6622
|
await Promise.all(all.map((cs) => cs.dispose()));
|
|
@@ -357,6 +357,12 @@ export class ClientStateStore {
|
|
|
357
357
|
reviewDisabledSkills: stored?.reviewDisabledSkills ?? [],
|
|
358
358
|
disabledPlugins: stored?.disabledPlugins ?? [],
|
|
359
359
|
uiLayout: normalizeUiLayout(stored?.uiLayout),
|
|
360
|
+
defaultAgentPreset: typeof stored?.defaultAgentPreset === "string" && stored.defaultAgentPreset
|
|
361
|
+
? stored.defaultAgentPreset
|
|
362
|
+
: "standard",
|
|
363
|
+
defaultPermissionPreset: typeof stored?.defaultPermissionPreset === "string" && stored.defaultPermissionPreset
|
|
364
|
+
? stored.defaultPermissionPreset
|
|
365
|
+
: "workspace-write-never",
|
|
360
366
|
};
|
|
361
367
|
}
|
|
362
368
|
/** Persist the settings-panel state (partial merge) — global shared config. */
|
|
@@ -395,6 +401,8 @@ export class ClientStateStore {
|
|
|
395
401
|
uiLayout: normalizeUiLayout(settings.uiLayout ?? cur.uiLayout),
|
|
396
402
|
quickPhrases: settings.quickPhrases ?? cur.quickPhrases ?? [],
|
|
397
403
|
quickPhrasesEnabled: settings.quickPhrasesEnabled ?? cur.quickPhrasesEnabled ?? true,
|
|
404
|
+
defaultAgentPreset: settings.defaultAgentPreset ?? cur.defaultAgentPreset ?? "standard",
|
|
405
|
+
defaultPermissionPreset: settings.defaultPermissionPreset ?? cur.defaultPermissionPreset ?? "workspace-write-never",
|
|
398
406
|
};
|
|
399
407
|
this.save();
|
|
400
408
|
}
|