pi-web-ui 0.86.1 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -22
- package/README.md +1 -1
- package/bin/pi-web-ui.mjs +312 -40
- package/dist/server/agent-service.js +297 -6
- package/dist/server/client-state.js +8 -0
- package/dist/server/composer-drafts.js +138 -0
- package/dist/server/dsh/dsh-agent-service.js +531 -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 +219 -3
- 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/package.json +1 -1
- package/plugins/catalog.json +9 -0
- package/themes/dark-teal.css +63 -22
- package/web/dist/assets/index-DePnXpq-.js +374 -0
- package/web/dist/assets/index-F86qWlJy.css +41 -0
- package/web/dist/index.html +3 -2
- package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
- package/web/dist/assets/TerminalPanel-fC8DLbDz.js +0 -6
- package/web/dist/assets/index-CCrF953V.js +0 -361
- package/web/dist/assets/index-CKoVyDkP.css +0 -10
|
@@ -18,6 +18,7 @@ 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";
|
|
@@ -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.
|
|
@@ -1838,6 +1854,7 @@ export class ClientSession {
|
|
|
1838
1854
|
// Reconnect: push the built-in provider key list (multi-key grouping in the
|
|
1839
1855
|
// model picker needs it even before the client asks).
|
|
1840
1856
|
this.modelAdmin.listProviderKeys();
|
|
1857
|
+
this.modelAdmin.listProviderOAuthFlows();
|
|
1841
1858
|
// PTYs are conversation-owned and survive a socket reconnect.
|
|
1842
1859
|
this.pushTerminals();
|
|
1843
1860
|
}
|
|
@@ -2027,6 +2044,153 @@ export class ClientSession {
|
|
|
2027
2044
|
return null;
|
|
2028
2045
|
}
|
|
2029
2046
|
}
|
|
2047
|
+
/** 插件扩展点 v2(只读组装,供 index.ts 注入给 PluginManager 的 conversationLister)。
|
|
2048
|
+
* 本客户端运行中对话(kind:"running")+ 当前项目历史会话摘要(kind:"history",最多 50 条)。
|
|
2049
|
+
* 纯数据组装,不 emit、不改任何状态;历史会话读失败时只回运行中部分。 */
|
|
2050
|
+
listRunningForPlugins() {
|
|
2051
|
+
const out = [];
|
|
2052
|
+
for (const conv of this.convs.values()) {
|
|
2053
|
+
let isStreaming = false;
|
|
2054
|
+
try {
|
|
2055
|
+
isStreaming = conv.session.isStreaming;
|
|
2056
|
+
}
|
|
2057
|
+
catch {
|
|
2058
|
+
// 会话替换中——按未跑处理
|
|
2059
|
+
}
|
|
2060
|
+
out.push({ id: conv.id, title: conv.title, cwd: conv.cwd, kind: "running", isStreaming });
|
|
2061
|
+
}
|
|
2062
|
+
return out;
|
|
2063
|
+
}
|
|
2064
|
+
/** 插件扩展点 v2(conversationLister 的历史一半):当前项目历史会话摘要,最多 50 条。
|
|
2065
|
+
* 复用 refreshSessions/searchSessions 共用的 loadSessionInfos 缓存(3s TTL,不扫两遍盘)。 */
|
|
2066
|
+
async listHistoryForPlugins(limit = 50) {
|
|
2067
|
+
try {
|
|
2068
|
+
const infos = await this.loadSessionInfos();
|
|
2069
|
+
return infos.slice(0, Math.max(0, limit)).map((s) => ({
|
|
2070
|
+
id: s.path,
|
|
2071
|
+
title: (s.name?.trim() || s.firstMessage.trim() || basename(s.path)).slice(0, 60),
|
|
2072
|
+
cwd: this.cwd,
|
|
2073
|
+
kind: "history",
|
|
2074
|
+
isStreaming: false,
|
|
2075
|
+
}));
|
|
2076
|
+
}
|
|
2077
|
+
catch {
|
|
2078
|
+
return [];
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
/** 插件扩展点 v2(供 conversationSearcher):运行中对话标题 + 历史会话全文匹配,返回前 N 个 {id,title}。
|
|
2082
|
+
* 复用 searchSessions 的 sessionMatchesSearch 判定(含转录全文),只读不 emit。 */
|
|
2083
|
+
async searchForPlugins(query, limit = 20) {
|
|
2084
|
+
const q = query.trim().toLowerCase();
|
|
2085
|
+
if (!q)
|
|
2086
|
+
return [];
|
|
2087
|
+
const out = [];
|
|
2088
|
+
for (const conv of this.convs.values()) {
|
|
2089
|
+
if (conv.title.toLowerCase().includes(q))
|
|
2090
|
+
out.push({ id: conv.id, title: conv.title });
|
|
2091
|
+
if (out.length >= limit)
|
|
2092
|
+
return out;
|
|
2093
|
+
}
|
|
2094
|
+
try {
|
|
2095
|
+
const infos = await this.loadSessionInfos();
|
|
2096
|
+
for (const s of infos) {
|
|
2097
|
+
if (!sessionMatchesSearch(q, s))
|
|
2098
|
+
continue;
|
|
2099
|
+
out.push({
|
|
2100
|
+
id: s.path,
|
|
2101
|
+
title: (s.name?.trim() || s.firstMessage.trim() || basename(s.path)).slice(0, 60),
|
|
2102
|
+
});
|
|
2103
|
+
if (out.length >= limit)
|
|
2104
|
+
break;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
catch {
|
|
2108
|
+
// 读盘失败只回运行中部分
|
|
2109
|
+
}
|
|
2110
|
+
return out;
|
|
2111
|
+
}
|
|
2112
|
+
/** 插件扩展点 v2(供 conversationWriter):向指定对话投递 prompt,复用现有 prompt 投递路径。
|
|
2113
|
+
* 目标非当前对话时先 switchConversation(复用跨项目切换副作用),再走 this.prompt
|
|
2114
|
+
* (斜杠拦截/排空门禁/并行提醒/首条命名全在里面);找不到对话返回 {ok:false,error}。 */
|
|
2115
|
+
async writeForPlugins(id, text) {
|
|
2116
|
+
try {
|
|
2117
|
+
const conv = this.convs.get(id);
|
|
2118
|
+
if (!conv)
|
|
2119
|
+
return { ok: false, error: `未知对话:${id}` };
|
|
2120
|
+
if (!text.trim())
|
|
2121
|
+
return { ok: false, error: "投递文本为空" };
|
|
2122
|
+
if (id !== this.activeId)
|
|
2123
|
+
await this.switchConversation(id);
|
|
2124
|
+
await this.prompt(text);
|
|
2125
|
+
return { ok: true };
|
|
2126
|
+
}
|
|
2127
|
+
catch (err) {
|
|
2128
|
+
return { ok: false, error: err.message };
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
/** 插件扩展点 v2(供 runAborter):中止指定对话的运行,复用 abort 的 interruptRun 路径
|
|
2132
|
+
* (abort 卡住/空转时的强制重置语义一并继承)。未在跑时直接 {ok:true}(幂等)。 */
|
|
2133
|
+
async abortForPlugins(id) {
|
|
2134
|
+
try {
|
|
2135
|
+
const conv = this.convs.get(id);
|
|
2136
|
+
if (!conv)
|
|
2137
|
+
return { ok: false, error: `未知对话:${id}` };
|
|
2138
|
+
if (this.conversationStreaming(conv)) {
|
|
2139
|
+
await this.interruptRun(conv, "插件已中止运行");
|
|
2140
|
+
this.flushSnapshot();
|
|
2141
|
+
}
|
|
2142
|
+
return { ok: true };
|
|
2143
|
+
}
|
|
2144
|
+
catch (err) {
|
|
2145
|
+
return { ok: false, error: err.message };
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
/** 插件扩展点(供 runSteerer 跨客户端兜底复用):只在本客户端 conversations 里找对话,
|
|
2149
|
+
* 持有则执行 steer 并返回结果,未持有回 undefined(不碰钩子,无递归)。 */
|
|
2150
|
+
async steerOwnConversation(id, text) {
|
|
2151
|
+
const conv = this.convs.get(id);
|
|
2152
|
+
if (!conv?.session)
|
|
2153
|
+
return undefined;
|
|
2154
|
+
await conv.session.sendUserMessage(text, conv.session.isStreaming ? { deliverAs: "steer" } : undefined);
|
|
2155
|
+
return { ok: true };
|
|
2156
|
+
}
|
|
2157
|
+
/** 插件扩展点(供 runSteerer):向指定对话插队一条用户消息,复用子代理 steer 的
|
|
2158
|
+
* sendUserMessage + deliverAs:'steer' 路径(运行时插队;未跑时按普通消息投递)。
|
|
2159
|
+
* 先找本客户端 conversations,找不到再经 steerConversationElsewhere 问其他客户端;
|
|
2160
|
+
* 空文本回 {ok:false};异常 catch 透传 message。 */
|
|
2161
|
+
async steerForPlugins(conversationId, text) {
|
|
2162
|
+
try {
|
|
2163
|
+
if (!text.trim())
|
|
2164
|
+
return { ok: false, error: "空消息" };
|
|
2165
|
+
const own = await this.steerOwnConversation(conversationId, text);
|
|
2166
|
+
if (own)
|
|
2167
|
+
return own;
|
|
2168
|
+
if (typeof this.steerConversationElsewhere === "function") {
|
|
2169
|
+
const r = await this.steerConversationElsewhere(conversationId, text);
|
|
2170
|
+
if (r)
|
|
2171
|
+
return r;
|
|
2172
|
+
}
|
|
2173
|
+
return { ok: false, error: `未知对话:${conversationId}` };
|
|
2174
|
+
}
|
|
2175
|
+
catch (err) {
|
|
2176
|
+
return { ok: false, error: err.message };
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
/** 插件扩展点 v2(供 modelLister):复用 listModels 的模型列表映射 {id,provider,vision}。
|
|
2180
|
+
* 与 listModels 唯一差别:不做网络 refresh(插件列表走缓存目录,15s 超时也不等),只读不 emit。 */
|
|
2181
|
+
async listModelsForPlugins() {
|
|
2182
|
+
try {
|
|
2183
|
+
const available = await this.runtime.services.modelRuntime.getAvailable();
|
|
2184
|
+
return available.map((m) => ({
|
|
2185
|
+
id: `${m.provider}/${m.id}`,
|
|
2186
|
+
provider: m.provider,
|
|
2187
|
+
vision: m.input?.includes("image") ?? false,
|
|
2188
|
+
}));
|
|
2189
|
+
}
|
|
2190
|
+
catch {
|
|
2191
|
+
return [];
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2030
2194
|
onEvent(conv, event) {
|
|
2031
2195
|
// Any SDK event proves the run is alive — feeds the stall watchdog below.
|
|
2032
2196
|
conv.lastSdkEventAt = Date.now();
|
|
@@ -2535,7 +2699,7 @@ export class ClientSession {
|
|
|
2535
2699
|
return messages;
|
|
2536
2700
|
}
|
|
2537
2701
|
/** Build every UiState field EXCEPT messages (the expensive part). */
|
|
2538
|
-
buildLightState(rev) {
|
|
2702
|
+
buildLightState(rev, withDraft = false) {
|
|
2539
2703
|
const conv = this.conv;
|
|
2540
2704
|
const state = conv.session.agent.state;
|
|
2541
2705
|
const model = state.model;
|
|
@@ -2587,6 +2751,9 @@ export class ClientSession {
|
|
|
2587
2751
|
cwd: this.cwd,
|
|
2588
2752
|
// 判重:直接读缓存字段,不在快照热路径上重读 client-state。
|
|
2589
2753
|
workspaceRoots: this.roots,
|
|
2754
|
+
// 用户主目录(右栏 🏠):进程内不变,模块级求值一次,不在热路径调 homedir()。
|
|
2755
|
+
homeDir: HOME_WIRE,
|
|
2756
|
+
desktopDir: DESKTOP_WIRE,
|
|
2590
2757
|
sessionId: this.session.sessionId,
|
|
2591
2758
|
sessionFile: this.session.sessionFile,
|
|
2592
2759
|
conversationId: this.activeId,
|
|
@@ -2610,6 +2777,10 @@ export class ClientSession {
|
|
|
2610
2777
|
retry: conv.retryState ?? null,
|
|
2611
2778
|
compaction: conv.compactionState ?? null,
|
|
2612
2779
|
pendingQuestion: this.pendingQuestionForSnapshot(),
|
|
2780
|
+
// 未发送草稿只跟全量快照走(切会话/new_chat/get_state):增量 delta 里
|
|
2781
|
+
// 这个 key 必须整个缺席(不能是显式的 undefined——前端 delta 合并是
|
|
2782
|
+
// {...ui, ...d.state} 整批覆盖,显式 undefined 会把上次全量带回的草稿洗掉)。
|
|
2783
|
+
...(withDraft ? { draft: this.draftForSnapshot() } : {}),
|
|
2613
2784
|
tools: state.tools.map((t) => t.name),
|
|
2614
2785
|
version: ++this.version,
|
|
2615
2786
|
piConfigured: this.isPiConfigured(),
|
|
@@ -2653,7 +2824,7 @@ export class ClientSession {
|
|
|
2653
2824
|
rev,
|
|
2654
2825
|
baseRev,
|
|
2655
2826
|
appended: cur.slice(prev.length),
|
|
2656
|
-
state: this.buildLightState(rev),
|
|
2827
|
+
state: this.buildLightState(rev, false),
|
|
2657
2828
|
});
|
|
2658
2829
|
}
|
|
2659
2830
|
else {
|
|
@@ -2662,7 +2833,9 @@ export class ClientSession {
|
|
|
2662
2833
|
this.emittedRev = rev;
|
|
2663
2834
|
this.emit({
|
|
2664
2835
|
type: "snapshot",
|
|
2665
|
-
|
|
2836
|
+
// 全量快照一律带草稿(切会话/new_chat/改写分支/重连 get_state 全走这里);
|
|
2837
|
+
// 60ms 热帧是上面的 snapshot_delta,本来就不带。
|
|
2838
|
+
state: { ...this.buildLightState(rev, true), messages: cur },
|
|
2666
2839
|
});
|
|
2667
2840
|
}
|
|
2668
2841
|
}
|
|
@@ -2981,6 +3154,9 @@ export class ClientSession {
|
|
|
2981
3154
|
* - onRunningChanged:本实例流式集合变化时触发,AgentService 借此让其他
|
|
2982
3155
|
* 客户端重推 conversations(elsewhere 列表近实时)。 */
|
|
2983
3156
|
findSessionOwner = undefined;
|
|
3157
|
+
/** 插件 steer 跨客户端兜底钩子:attach 时由 AgentService 接线(见 steerElsewhere),
|
|
3158
|
+
* 在其他客户端的 conversations 里找对话并由持有方执行 steer,未持有回 undefined。 */
|
|
3159
|
+
steerConversationElsewhere = undefined;
|
|
2984
3160
|
/** issue #145:除本客户端外是否有人在跑(扫目录查重前置的无 I/O 判断)。 */
|
|
2985
3161
|
hasStreamingElsewhere = undefined;
|
|
2986
3162
|
listProjectRunners = undefined;
|
|
@@ -3201,10 +3377,27 @@ export class ClientSession {
|
|
|
3201
3377
|
return this.modelAdmin.setProviderApiKey(provider, apiKey);
|
|
3202
3378
|
}
|
|
3203
3379
|
async clearProviderApiKey(provider) {
|
|
3380
|
+
const usingOAuth = this.runtime.services.modelRuntime.isUsingOAuth(provider.trim());
|
|
3204
3381
|
await this.modelAdmin.clearProviderApiKey(provider);
|
|
3205
3382
|
// The provider is back to unconfigured — drop its key preference in
|
|
3206
3383
|
// EVERY project, otherwise each project switch re-tries a restore.
|
|
3207
|
-
|
|
3384
|
+
if (!usingOAuth)
|
|
3385
|
+
this.stateStore.deleteProviderEverywhere(provider.trim());
|
|
3386
|
+
}
|
|
3387
|
+
startProviderOAuth(provider) {
|
|
3388
|
+
this.modelAdmin.startProviderOAuth(provider);
|
|
3389
|
+
}
|
|
3390
|
+
replyProviderOAuth(flowId, promptId, value) {
|
|
3391
|
+
this.modelAdmin.replyProviderOAuth(flowId, promptId, value);
|
|
3392
|
+
}
|
|
3393
|
+
cancelProviderOAuth(flowId) {
|
|
3394
|
+
this.modelAdmin.cancelProviderOAuth(flowId);
|
|
3395
|
+
}
|
|
3396
|
+
listProviderOAuthFlows() {
|
|
3397
|
+
this.modelAdmin.listProviderOAuthFlows();
|
|
3398
|
+
}
|
|
3399
|
+
logoutProviderOAuth(provider) {
|
|
3400
|
+
return this.modelAdmin.logoutProviderOAuth(provider);
|
|
3208
3401
|
}
|
|
3209
3402
|
listProviders() {
|
|
3210
3403
|
return this.modelAdmin.listProviders();
|
|
@@ -3634,6 +3827,14 @@ export class ClientSession {
|
|
|
3634
3827
|
// switch/new_chat while prompt() is in flight must never target a
|
|
3635
3828
|
// different conversation.
|
|
3636
3829
|
const conv = this.conv;
|
|
3830
|
+
// 输入框内容被消费(发送/斜杠执行)→ 清掉该会话存过的草稿(best-effort)。
|
|
3831
|
+
// 快捷短语发送(不碰输入框)同样清:客户端发送成功后会把当前草稿重存回来。
|
|
3832
|
+
try {
|
|
3833
|
+
this.drafts.clear(conv.session.sessionId);
|
|
3834
|
+
}
|
|
3835
|
+
catch {
|
|
3836
|
+
// ignore
|
|
3837
|
+
}
|
|
3637
3838
|
try {
|
|
3638
3839
|
const s = this.session;
|
|
3639
3840
|
// Native slash commands (see NATIVE_COMMANDS) are executed here and
|
|
@@ -3954,6 +4155,32 @@ export class ClientSession {
|
|
|
3954
4155
|
}
|
|
3955
4156
|
this.flushSnapshot();
|
|
3956
4157
|
}
|
|
4158
|
+
// -----------------------------------------------------------------------
|
|
4159
|
+
// 未发送输入框草稿(issue #166,单中心文件方案,见 server/composer-drafts.ts)
|
|
4160
|
+
// -----------------------------------------------------------------------
|
|
4161
|
+
/** 存指定会话的未发送草稿(`draft_update` 入口,经 DispatchSession.saveDraft)。
|
|
4162
|
+
* 按消息自带的 sessionId 落键(不按 active 会话:切会话时的「离开刷盘」
|
|
4163
|
+
* 晚于服务端的切换到达)。空白新会话的转录还没落盘,但 id 内存里已有。
|
|
4164
|
+
* 存完不推快照:同页的草稿本来就是自己打的;恢复走全量快照的 draft 字段。 */
|
|
4165
|
+
saveDraft(sessionId, text, ts) {
|
|
4166
|
+
try {
|
|
4167
|
+
if (!sessionId)
|
|
4168
|
+
return;
|
|
4169
|
+
this.drafts.save(sessionId, text, ts);
|
|
4170
|
+
}
|
|
4171
|
+
catch {
|
|
4172
|
+
// best-effort:草稿丢了可以重打
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
/** 当前活跃对话的草稿(全量快照用;增量 snapshot_delta 传 withDraft=false 不带)。 */
|
|
4176
|
+
draftForSnapshot() {
|
|
4177
|
+
try {
|
|
4178
|
+
return this.drafts.get(this.conv.session.sessionId) ?? null;
|
|
4179
|
+
}
|
|
4180
|
+
catch {
|
|
4181
|
+
return null;
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
3957
4184
|
/** Re-push the current list on request (panel opened); prunes dead entries first. */
|
|
3958
4185
|
async listBgServers() {
|
|
3959
4186
|
await this.bg.listAndPush();
|
|
@@ -4117,7 +4344,8 @@ export class ClientSession {
|
|
|
4117
4344
|
* 空白新对话」——/new <prompt> 只在 true 时投递首条提示;false 表示没能进入
|
|
4118
4345
|
* 新对话(准入关闭 / 同项目对话数达上限 / runtime 创建失败),此时照发会把
|
|
4119
4346
|
* 首条提示投进用户原本正在用的那个对话里。 */
|
|
4120
|
-
async newChat() {
|
|
4347
|
+
async newChat(_preset) {
|
|
4348
|
+
// _preset: DSH Agent 预设(pi 引擎无此概念,忽略;wire 统一见 protocol new_chat)。
|
|
4121
4349
|
if (this.quiesceBlocked())
|
|
4122
4350
|
return false;
|
|
4123
4351
|
// Reuse an already-open blank conversation instead of piling up new ones
|
|
@@ -4687,6 +4915,13 @@ export class ClientSession {
|
|
|
4687
4915
|
}
|
|
4688
4916
|
}
|
|
4689
4917
|
rmSync(abs, { force: true });
|
|
4918
|
+
// 转录删了,未发送草稿再留着就是孤儿,一起清掉。
|
|
4919
|
+
try {
|
|
4920
|
+
this.drafts.pruneSessionFile(abs);
|
|
4921
|
+
}
|
|
4922
|
+
catch {
|
|
4923
|
+
// ignore
|
|
4924
|
+
}
|
|
4690
4925
|
// Bust the brief session-info fridge: refreshSessions() below usually
|
|
4691
4926
|
// lands inside its 3s TTL and would otherwise re-serve a listing that
|
|
4692
4927
|
// still contains the deleted transcript.
|
|
@@ -5506,6 +5741,22 @@ export class ClientSession {
|
|
|
5506
5741
|
async uploadFile(relDir, name, data) {
|
|
5507
5742
|
return this.files.uploadFile(relDir, name, data);
|
|
5508
5743
|
}
|
|
5744
|
+
/** 文件树右键菜单:新建(空文件/空文件夹)。 */
|
|
5745
|
+
async createEntry(dir, name, kind) {
|
|
5746
|
+
return this.files.createEntry(dir, name, kind);
|
|
5747
|
+
}
|
|
5748
|
+
/** 文件树右键菜单:同目录内重命名。 */
|
|
5749
|
+
async renameEntry(path, newName) {
|
|
5750
|
+
return this.files.renameEntry(path, newName);
|
|
5751
|
+
}
|
|
5752
|
+
/** 文件树右键菜单:删除文件/目录。 */
|
|
5753
|
+
async deleteEntry(path) {
|
|
5754
|
+
return this.files.deleteEntry(path);
|
|
5755
|
+
}
|
|
5756
|
+
/** 文件树右键菜单:复制/移动(move=true 即剪切粘贴)。 */
|
|
5757
|
+
async copyEntry(src, destDir, move) {
|
|
5758
|
+
return this.files.copyEntry(src, destDir, move);
|
|
5759
|
+
}
|
|
5509
5760
|
async makeDir(relPath) {
|
|
5510
5761
|
return this.files.makeDir(relPath);
|
|
5511
5762
|
}
|
|
@@ -5862,6 +6113,7 @@ export class ClientSession {
|
|
|
5862
6113
|
}
|
|
5863
6114
|
async dispose() {
|
|
5864
6115
|
this.disposed = true;
|
|
6116
|
+
this.modelAdmin.dispose();
|
|
5865
6117
|
for (const conv of this.convs.values())
|
|
5866
6118
|
conv.terminals.killAll();
|
|
5867
6119
|
if (this.snapshotTimer) {
|
|
@@ -5991,6 +6243,24 @@ export class AgentService {
|
|
|
5991
6243
|
}
|
|
5992
6244
|
return null;
|
|
5993
6245
|
}
|
|
6246
|
+
/** 插件 steer 跨客户端兜底:除请求方外逐个问其他客户端的 conversations,
|
|
6247
|
+
* 找到持有方由其执行 steer(只调 steerOwnConversation,不碰钩子,无递归);
|
|
6248
|
+
* 都找不到回 undefined,调用方回未知对话。单客户端异常跳过,不影响其他。 */
|
|
6249
|
+
async steerElsewhere(excludeClientId, id, text) {
|
|
6250
|
+
for (const [clientId, cs] of this.clients) {
|
|
6251
|
+
if (clientId === excludeClientId)
|
|
6252
|
+
continue;
|
|
6253
|
+
try {
|
|
6254
|
+
const r = await cs.steerOwnConversation(id, text);
|
|
6255
|
+
if (r)
|
|
6256
|
+
return r;
|
|
6257
|
+
}
|
|
6258
|
+
catch {
|
|
6259
|
+
// 单客户端坏了继续找下一个
|
|
6260
|
+
}
|
|
6261
|
+
}
|
|
6262
|
+
return undefined;
|
|
6263
|
+
}
|
|
5994
6264
|
/** issue #145:别处在某 cwd 下正在跑的对话(同项目并行感知用,不含请求方)。 */
|
|
5995
6265
|
listProjectRunners(cwd, excludeClientId) {
|
|
5996
6266
|
const out = [];
|
|
@@ -6169,6 +6439,7 @@ export class AgentService {
|
|
|
6169
6439
|
cs.listExternalRunning = () => this.listExternalRunning(clientId);
|
|
6170
6440
|
cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
|
|
6171
6441
|
cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
|
|
6442
|
+
cs.steerConversationElsewhere = (id, text) => this.steerElsewhere(clientId, id, text);
|
|
6172
6443
|
// Make sure the restored/default workspace appears in the project list.
|
|
6173
6444
|
this.stateStore.remember(clientId, cwd);
|
|
6174
6445
|
if (cwd !== this.cwd) {
|
|
@@ -6203,6 +6474,7 @@ export class AgentService {
|
|
|
6203
6474
|
cs.listExternalRunning = () => this.listExternalRunning(clientId);
|
|
6204
6475
|
cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
|
|
6205
6476
|
cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
|
|
6477
|
+
cs.steerConversationElsewhere = (id, text) => this.steerElsewhere(clientId, id, text);
|
|
6206
6478
|
// 插件宿主工作区跟随:初次接入也同步一次(恢复的 lastCwd 可能≠服务启动目录),
|
|
6207
6479
|
// notifyCwd 幂等去重;此后 set_cwd 成功时由 cs.onCwdChanged 继续驱动。
|
|
6208
6480
|
cs.onCwdChanged = (abs, roots) => this.onClientCwdChanged?.(abs, roots);
|
|
@@ -6250,6 +6522,25 @@ export class AgentService {
|
|
|
6250
6522
|
get(clientId) {
|
|
6251
6523
|
return this.clients.get(clientId);
|
|
6252
6524
|
}
|
|
6525
|
+
/** 插件扩展点 v2:挑一个最合适的客户端会话供无浏览器调用的插件 API 用
|
|
6526
|
+
* (conversationLister/Searcher/Writer、modelLister、runAborter)。
|
|
6527
|
+
* 有运行中对话的优先,否则任意残留客户端;一个没有时返回 undefined,
|
|
6528
|
+
* 调用方(index.ts 注入)回退空列表 / {ok:false},绝不抛错。 */
|
|
6529
|
+
pluginClient() {
|
|
6530
|
+
let fallback;
|
|
6531
|
+
for (const cs of this.clients.values()) {
|
|
6532
|
+
if (!fallback)
|
|
6533
|
+
fallback = cs;
|
|
6534
|
+
try {
|
|
6535
|
+
if (cs.activeConversations() > 0)
|
|
6536
|
+
return cs;
|
|
6537
|
+
}
|
|
6538
|
+
catch {
|
|
6539
|
+
// 单客户端坏了不影响挑选
|
|
6540
|
+
}
|
|
6541
|
+
}
|
|
6542
|
+
return fallback;
|
|
6543
|
+
}
|
|
6253
6544
|
async disposeAll() {
|
|
6254
6545
|
// Record still-streaming conversations BEFORE tearing anything down, so
|
|
6255
6546
|
// the next attach can tell the user what was lost (SIGTERM / update).
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* composer-drafts.ts — 未发送输入框草稿的单中心文件存储(全局 <dataDir>/composer-drafts.json)。
|
|
3
|
+
*
|
|
4
|
+
* 设计(issue #166 单中心文件方案):不进 session JSONL、不按会话建 sidecar,
|
|
5
|
+
* 全会话的草稿集中在一个文件里,按 **sessionId**(uuidv7,重启稳定)键入。
|
|
6
|
+
* conversationId 每次重启都变,不能做 key(见 agent-service 的 compaction 注释)。
|
|
7
|
+
*
|
|
8
|
+
* - 空白新会话也能存:sessionId 在 SessionManager.create() 时内存里就有了,
|
|
9
|
+
* 不依赖转录文件落盘(SDK 的 _persist 门控要等首轮 assistant 才写盘)。
|
|
10
|
+
* - 每会话只留最新一条(last-write-wins,按 ts,比 marker-store 的扫描模式更轻)。
|
|
11
|
+
* - 与 per-client 的 client-state.json 不同:按 sessionId 全局存,跨标签页可见。
|
|
12
|
+
* - 文件 I/O 一律 best-effort:持久化故障绝不能弄崩 server(同 subagent-templates)。
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { basename, dirname } from "node:path";
|
|
16
|
+
/** 单条草稿上限字符数:超出截断再存(快照全量下发时携带,太大浪费流量)。 */
|
|
17
|
+
export const DRAFT_TEXT_MAX = 20_000;
|
|
18
|
+
/** 草稿保留期:超过这么久没更新,下次加载时清扫(兜底;正常路径靠 clear/prune)。 */
|
|
19
|
+
export const DRAFT_TTL_MS = 30 * 24 * 3600 * 1000;
|
|
20
|
+
/** 转录文件名 `<timestamp>_<sessionId>.jsonl` → sessionId。
|
|
21
|
+
* 时间戳里的 `:`/`.` 已被 SDK 换成 `-`(无下划线),取首个 `_` 之后即 sessionId
|
|
22
|
+
* (uuidv7 本身也不含下划线)。对不上形状返回 undefined(调用方跳过剪枝)。 */
|
|
23
|
+
export function sessionIdFromTranscriptPath(p) {
|
|
24
|
+
const base = basename(p);
|
|
25
|
+
if (!base.endsWith(".jsonl"))
|
|
26
|
+
return undefined;
|
|
27
|
+
const noExt = base.slice(0, -".jsonl".length);
|
|
28
|
+
const i = noExt.indexOf("_");
|
|
29
|
+
if (i < 0 || i + 1 >= noExt.length)
|
|
30
|
+
return undefined;
|
|
31
|
+
return noExt.slice(i + 1);
|
|
32
|
+
}
|
|
33
|
+
/** 归一化待存文本:超长截断;纯空白 → undefined(调用方按「删除」处理,不存空条目)。 */
|
|
34
|
+
export function normalizeDraftText(text) {
|
|
35
|
+
const t = (text ?? "").slice(0, DRAFT_TEXT_MAX);
|
|
36
|
+
return t.trim() ? t : undefined;
|
|
37
|
+
}
|
|
38
|
+
function isValidDraft(d) {
|
|
39
|
+
if (!d || typeof d !== "object")
|
|
40
|
+
return false;
|
|
41
|
+
const o = d;
|
|
42
|
+
return typeof o.text === "string" && typeof o.ts === "number" && typeof o.updatedAt === "number";
|
|
43
|
+
}
|
|
44
|
+
export class ComposerDraftsStore {
|
|
45
|
+
filePath;
|
|
46
|
+
cache = null;
|
|
47
|
+
constructor(filePath) {
|
|
48
|
+
this.filePath = filePath;
|
|
49
|
+
}
|
|
50
|
+
load() {
|
|
51
|
+
if (this.cache)
|
|
52
|
+
return this.cache;
|
|
53
|
+
let raw = {};
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
raw = {};
|
|
59
|
+
}
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const kept = {};
|
|
62
|
+
let pruned = false;
|
|
63
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
64
|
+
if (!isValidDraft(v)) {
|
|
65
|
+
pruned = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (now - v.updatedAt > DRAFT_TTL_MS) {
|
|
69
|
+
pruned = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
kept[k] = v;
|
|
73
|
+
}
|
|
74
|
+
this.cache = kept;
|
|
75
|
+
// 读到过期/脏条目才回写:正常加载不碰磁盘。
|
|
76
|
+
if (pruned)
|
|
77
|
+
this.persist();
|
|
78
|
+
return this.cache;
|
|
79
|
+
}
|
|
80
|
+
persist() {
|
|
81
|
+
if (!this.cache)
|
|
82
|
+
return;
|
|
83
|
+
try {
|
|
84
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
85
|
+
const tmp = `${this.filePath}.tmp-${process.pid}`;
|
|
86
|
+
writeFileSync(tmp, JSON.stringify(this.cache, null, 2) + "\n");
|
|
87
|
+
renameSync(tmp, this.filePath);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// best-effort:草稿丢了可以重打,server 绝不能因此崩。
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** 读一条草稿({text, ts};没有返回 undefined)。 */
|
|
94
|
+
get(sessionId) {
|
|
95
|
+
if (!sessionId)
|
|
96
|
+
return undefined;
|
|
97
|
+
const d = this.load()[sessionId];
|
|
98
|
+
return d ? { text: d.text, ts: d.ts } : undefined;
|
|
99
|
+
}
|
|
100
|
+
/** 存一条草稿:空文本 = 删除;同 key 上 ts 更大的才覆盖(last-write-wins,
|
|
101
|
+
* `>=` 让同 ts 的重发幂等)。只有实际变化才写盘。 */
|
|
102
|
+
save(sessionId, text, ts) {
|
|
103
|
+
if (!sessionId)
|
|
104
|
+
return;
|
|
105
|
+
const map = this.load();
|
|
106
|
+
const norm = normalizeDraftText(text);
|
|
107
|
+
if (norm === undefined) {
|
|
108
|
+
if (map[sessionId] !== undefined) {
|
|
109
|
+
delete map[sessionId];
|
|
110
|
+
this.persist();
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const prev = map[sessionId];
|
|
115
|
+
if (prev && prev.ts > ts)
|
|
116
|
+
return;
|
|
117
|
+
if (prev && prev.ts === ts && prev.text === norm)
|
|
118
|
+
return;
|
|
119
|
+
map[sessionId] = { text: norm, ts, updatedAt: Date.now() };
|
|
120
|
+
this.persist();
|
|
121
|
+
}
|
|
122
|
+
/** 发送成功 / 会话删除后清掉(有 key 才写盘)。 */
|
|
123
|
+
clear(sessionId) {
|
|
124
|
+
if (!sessionId)
|
|
125
|
+
return;
|
|
126
|
+
const map = this.load();
|
|
127
|
+
if (map[sessionId] !== undefined) {
|
|
128
|
+
delete map[sessionId];
|
|
129
|
+
this.persist();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** delete_session 用:按转录文件路径反解 sessionId 并清掉(形状对不上就跳过)。 */
|
|
133
|
+
pruneSessionFile(sessionPath) {
|
|
134
|
+
const id = sessionIdFromTranscriptPath(sessionPath);
|
|
135
|
+
if (id)
|
|
136
|
+
this.clear(id);
|
|
137
|
+
}
|
|
138
|
+
}
|