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.
Files changed (36) hide show
  1. package/CHANGELOG.md +67 -22
  2. package/README.md +1 -1
  3. package/bin/pi-web-ui.mjs +312 -40
  4. package/dist/server/agent-service.js +297 -6
  5. package/dist/server/client-state.js +8 -0
  6. package/dist/server/composer-drafts.js +138 -0
  7. package/dist/server/dsh/dsh-agent-service.js +531 -54
  8. package/dist/server/dsh/dsh-client.js +28 -0
  9. package/dist/server/dsh/dsh-sessions.js +28 -0
  10. package/dist/server/dsh/dsh-usage.js +82 -0
  11. package/dist/server/dsh/preset-clones.js +260 -0
  12. package/dist/server/dsh/runtime/custom-prompt.mjs +33 -0
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +406 -22
  14. package/dist/server/dsh/runtime/launcher.mjs +17 -0
  15. package/dist/server/dsh/runtime/override.patch.yml +19 -1
  16. package/dist/server/files-service.js +259 -1
  17. package/dist/server/index.js +219 -3
  18. package/dist/server/mcp-bridge.js +126 -22
  19. package/dist/server/mcp-hot-reload.js +117 -0
  20. package/dist/server/model-admin.js +93 -3
  21. package/dist/server/plugin-dom.js +83 -0
  22. package/dist/server/plugin-facilities.js +15 -1
  23. package/dist/server/plugin-installer.js +6 -0
  24. package/dist/server/plugins.js +781 -74
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/provider-oauth-flow.js +157 -0
  27. package/package.json +1 -1
  28. package/plugins/catalog.json +9 -0
  29. package/themes/dark-teal.css +63 -22
  30. package/web/dist/assets/index-DePnXpq-.js +374 -0
  31. package/web/dist/assets/index-F86qWlJy.css +41 -0
  32. package/web/dist/index.html +3 -2
  33. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +0 -32
  34. package/web/dist/assets/TerminalPanel-fC8DLbDz.js +0 -6
  35. package/web/dist/assets/index-CCrF953V.js +0 -361
  36. 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
- state: { ...this.buildLightState(rev), messages: cur },
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
- this.stateStore.deleteProviderEverywhere(provider.trim());
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
+ }