pi-web-ui 0.85.0 → 0.86.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  * 从 agent-service.ts 抽出,行为保持不变。
8
8
  */
9
9
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
- import { dirname } from "node:path";
10
+ import { dirname, isAbsolute, resolve } from "node:path";
11
11
  import { deriveLegacy, legacyToDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
12
12
  /** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
13
13
  export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
@@ -18,6 +18,47 @@ export function normalizeRetryMaxAttempts(v) {
18
18
  return DEFAULT_RETRY_MAX_ATTEMPTS;
19
19
  return Math.min(100, Math.max(0, n));
20
20
  }
21
+ /**
22
+ * 归一化 UI 布局偏好(UiLayoutPrefs):只收字符串数组 / 字符串字典,去重 + 长度上限。
23
+ * 脏数据(数字、对象、超长 key、嵌套)一律丢弃而不是整份回落 —— 用户手动调过的那部分
24
+ * 不该因为插件写坏了一个字段就全丢。
25
+ */
26
+ export function normalizeUiLayout(v) {
27
+ if (!v || typeof v !== "object" || Array.isArray(v))
28
+ return {};
29
+ const o = v;
30
+ const arr = (x, max) => {
31
+ if (!Array.isArray(x))
32
+ return undefined;
33
+ const out = [
34
+ ...new Set(x.filter((s) => typeof s === "string" && s.length > 0 && s.length <= 96)),
35
+ ].slice(0, max);
36
+ return out.length ? out : undefined;
37
+ };
38
+ const dict = (x, max) => {
39
+ if (!x || typeof x !== "object" || Array.isArray(x))
40
+ return undefined;
41
+ const out = {};
42
+ for (const [k, val] of Object.entries(x).slice(0, max)) {
43
+ if (k.length > 0 && k.length <= 96 && typeof val === "string" && val.length > 0 && val.length <= 120) {
44
+ out[k] = val;
45
+ }
46
+ }
47
+ return Object.keys(out).length ? out : undefined;
48
+ };
49
+ const hidden = arr(o.hidden, 200);
50
+ const shown = arr(o.shown, 200);
51
+ const order = arr(o.order, 200);
52
+ const groups = dict(o.groups, 200);
53
+ const labels = dict(o.labels, 200);
54
+ return {
55
+ ...(hidden ? { hidden } : {}),
56
+ ...(shown ? { shown } : {}),
57
+ ...(order ? { order } : {}),
58
+ ...(groups ? { groups } : {}),
59
+ ...(labels ? { labels } : {}),
60
+ };
61
+ }
21
62
  /** 归一化技能名单:字符串数组原样过滤;其他(含旧 bool 开关)回落空数组。 */
22
63
  export function normalizeSkillList(v) {
23
64
  return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
@@ -69,6 +110,36 @@ export function isExtensionEnabled(e, enabled) {
69
110
  const keys = extensionKeyCandidates(e);
70
111
  return enabled.some((d) => keys.includes(d));
71
112
  }
113
+ /** 额外工作区根(宿主侧多根,issue #146)上限:右栏文件树可切换的根数量。 */
114
+ export const MAX_WORKSPACE_ROOTS = 8;
115
+ /**
116
+ * 归一化「额外工作区根」列表:只收**绝对路径**(相对路径在服务端没有任何可靠基准)、
117
+ * 去重(win32 折大小写)、上限 MAX_WORKSPACE_ROOTS,并 resolve 成规范形式以便与工作区
118
+ * 做前缀比较。脏数据(数字/空串/对象)逐个丢弃而不是整份回落 —— 用户加过的根不该因为
119
+ * 前端传坏了一个元素就全丢。
120
+ */
121
+ export function normalizeWorkspaceRoots(v) {
122
+ if (!Array.isArray(v))
123
+ return [];
124
+ const out = [];
125
+ const seen = new Set();
126
+ for (const raw of v) {
127
+ if (typeof raw !== "string")
128
+ continue;
129
+ const p = raw.trim();
130
+ if (!p || !isAbsolute(p))
131
+ continue;
132
+ const abs = resolve(p);
133
+ const key = process.platform === "win32" ? abs.toLowerCase() : abs;
134
+ if (seen.has(key))
135
+ continue;
136
+ seen.add(key);
137
+ out.push(abs);
138
+ if (out.length >= MAX_WORKSPACE_ROOTS)
139
+ break;
140
+ }
141
+ return out;
142
+ }
72
143
  /**
73
144
  * Persists which workspace each browser client last used + which workspaces it
74
145
  * has opened, so a server restart / page reload restores the same project and
@@ -165,6 +236,27 @@ export class ClientStateStore {
165
236
  locked: s.goalPrefs.locked ?? true,
166
237
  };
167
238
  }
239
+ /** 某项目当前的额外工作区根(空数组 = 单根)。 */
240
+ getWorkspaceRoots(clientId, cwd) {
241
+ return this.load()[clientId]?.workspaceRoots?.[cwd] ?? [];
242
+ }
243
+ /** 记下某项目的额外工作区根(空数组 = 清掉该项目的键,不留空壳)。 */
244
+ saveWorkspaceRoots(clientId, cwd, roots) {
245
+ const all = this.load();
246
+ const state = (all[clientId] ??= { projects: [] });
247
+ const next = normalizeWorkspaceRoots(roots);
248
+ if (next.length === 0) {
249
+ if (state.workspaceRoots) {
250
+ delete state.workspaceRoots[cwd];
251
+ if (Object.keys(state.workspaceRoots).length === 0)
252
+ delete state.workspaceRoots;
253
+ }
254
+ this.save();
255
+ return;
256
+ }
257
+ (state.workspaceRoots ??= {})[cwd] = next;
258
+ this.save();
259
+ }
168
260
  /** Persist the client's UI locale code (hello/set_locale; best-effort). */
169
261
  saveLocale(clientId, locale) {
170
262
  const code = locale.trim().slice(0, 16);
@@ -249,6 +341,8 @@ export class ClientStateStore {
249
341
  : (stored?.questionnaireEnabled ?? true),
250
342
  goalModeEnabled: stored?.goalModeEnabled ?? true,
251
343
  thinkingWrap: stored?.thinkingWrap ?? false,
344
+ devNoCache: stored?.devNoCache,
345
+ autoReload: stored?.autoReload,
252
346
  toolsWrap: stored?.toolsWrap ?? true,
253
347
  skillsFullText: normalizeSkillList(stored?.skillsFullText),
254
348
  visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
@@ -262,6 +356,7 @@ export class ClientStateStore {
262
356
  reviewPrompt: stored?.reviewPrompt ?? "",
263
357
  reviewDisabledSkills: stored?.reviewDisabledSkills ?? [],
264
358
  disabledPlugins: stored?.disabledPlugins ?? [],
359
+ uiLayout: normalizeUiLayout(stored?.uiLayout),
265
360
  };
266
361
  }
267
362
  /** Persist the settings-panel state (partial merge) — global shared config. */
@@ -284,6 +379,8 @@ export class ClientStateStore {
284
379
  questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
285
380
  goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
286
381
  thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
382
+ devNoCache: settings.devNoCache ?? cur.devNoCache,
383
+ autoReload: settings.autoReload ?? cur.autoReload,
287
384
  toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
288
385
  skillsFullText: normalizeSkillList(settings.skillsFullText ?? cur.skillsFullText),
289
386
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
@@ -295,6 +392,7 @@ export class ClientStateStore {
295
392
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
296
393
  reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
297
394
  disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
395
+ uiLayout: normalizeUiLayout(settings.uiLayout ?? cur.uiLayout),
298
396
  quickPhrases: settings.quickPhrases ?? cur.quickPhrases ?? [],
299
397
  quickPhrasesEnabled: settings.quickPhrasesEnabled ?? cur.quickPhrasesEnabled ?? true,
300
398
  };
@@ -30,13 +30,14 @@ import { randomUUID } from "node:crypto";
30
30
  import { homedir } from "node:os";
31
31
  import { BgServerTracker } from "../bg-servers.js";
32
32
  import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS } from "../client-state.js";
33
+ import { normalizeUiLayout } from "../client-state.js";
33
34
  import { FilesService, workspacePath } from "../files-service.js";
34
35
  import { QuiesceRejectedError } from "../agent-service.js";
35
36
  import { NATIVE_COMMANDS, parseSlash } from "../slash-commands.js";
36
37
  import { bilingual, pick, resolveServerLang } from "../i18n.js";
37
38
  import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js";
38
39
  import { saveUpload } from "../uploads.js";
39
- import { checkAll as checkAllUpdates, collectTargets } from "../update-check.js";
40
+ import { checkAll as checkAllUpdates, collectTargets, resolveNpmRegistry } from "../update-check.js";
40
41
  import { previewKind } from "../text-sniff.js";
41
42
  import { launchOrigin, toServiceInfo } from "../launch-origin.js";
42
43
  import { DshRuntime, loadDeepSeekKey } from "./dsh-client.js";
@@ -116,6 +117,7 @@ const DEFAULT_SETTINGS = {
116
117
  thinkingWrap: false,
117
118
  toolsWrap: true,
118
119
  disabledPlugins: [],
120
+ uiLayout: {},
119
121
  reviewPrompt: "",
120
122
  quickPhrases: [],
121
123
  quickPhrasesEnabled: true,
@@ -126,6 +128,9 @@ const DEFAULT_SETTINGS = {
126
128
  export class DshClientSession {
127
129
  clientId;
128
130
  cwd;
131
+ /** 当前项目的额外工作区根(宿主侧多根,见 protocol 的 set_workspace_roots)——
132
+ * 按 cwd 存在 client-state 里,这里只存一份内存缓存给快照热路径读。 */
133
+ roots = [];
129
134
  stateStore;
130
135
  sessionRoot;
131
136
  dataDir;
@@ -179,10 +184,20 @@ export class DshClientSession {
179
184
  onQuit;
180
185
  isQuiesced;
181
186
  onCwdChanged;
187
+ /** issue #145 跨客户端感知(DshAgentService.attach 接线,与 pi 引擎同语义;
188
+ * DSH 的会话身份是 sessionId = JSONL 目录名)。 */
189
+ findSessionOwnerById = undefined;
190
+ listProjectRunners = undefined;
191
+ listExternalRunning = undefined;
192
+ notifyExternalClients = undefined;
193
+ onRunningChanged = undefined;
194
+ /** issue #145:上次 emit 时流式对话签名(变化才 poke 其他客户端,防循环)。 */
195
+ lastRunningSig = "";
182
196
  constructor(clientId, cwd, stateStore, dataDir, agentDir) {
183
197
  this.clientId = clientId;
184
198
  this.cwd = cwd;
185
199
  this.stateStore = stateStore;
200
+ this.roots = stateStore.getWorkspaceRoots(clientId, cwd);
186
201
  this.dataDir = dataDir;
187
202
  this.agentDir = agentDir;
188
203
  this.sessionRoot = dshSessionRoot(dataDir);
@@ -234,6 +249,7 @@ export class DshClientSession {
234
249
  thinkingWrap: savedSettings.thinkingWrap,
235
250
  toolsWrap: savedSettings.toolsWrap,
236
251
  disabledPlugins: savedSettings.disabledPlugins ?? [],
252
+ uiLayout: normalizeUiLayout(savedSettings.uiLayout),
237
253
  reviewPrompt: savedSettings.reviewPrompt,
238
254
  quickPhrases: savedSettings.quickPhrases ?? [],
239
255
  quickPhrasesEnabled: savedSettings.quickPhrasesEnabled ?? true,
@@ -1042,6 +1058,7 @@ export class DshClientSession {
1042
1058
  return {
1043
1059
  clientId: this.clientId,
1044
1060
  cwd: this.cwd,
1061
+ workspaceRoots: this.roots,
1045
1062
  sessionId: conv.sessionId,
1046
1063
  conversationId: this.activeId,
1047
1064
  rev,
@@ -1121,6 +1138,43 @@ export class DshClientSession {
1121
1138
  pendingMessages() {
1122
1139
  return 0;
1123
1140
  }
1141
+ /** issue #145:本实例连接的 socket 数(0 = 标签页全关了,会话残留)。 */
1142
+ sinkCount() {
1143
+ return this.sinks.size;
1144
+ }
1145
+ /** issue #145:按 sessionId 找本实例持有的对话(跨客户端查重的本机一半)。 */
1146
+ findConversationBySessionId(sessionId) {
1147
+ for (const conv of this.convs.values())
1148
+ if (conv.sessionId === sessionId)
1149
+ return conv;
1150
+ return undefined;
1151
+ }
1152
+ /** issue #145:本实例在某 cwd 下正在跑的对话摘要(同项目并行感知用)。 */
1153
+ streamingInCwd(cwd) {
1154
+ const out = [];
1155
+ for (const conv of this.convs.values()) {
1156
+ if (conv.cwd !== cwd || !conv.isStreaming)
1157
+ continue;
1158
+ out.push({ convId: conv.id, title: conv.title, sessionId: conv.sessionId });
1159
+ }
1160
+ return out;
1161
+ }
1162
+ /** issue #145:本实例所有正在跑的对话摘要(elsewhere 列表的本机一半)。 */
1163
+ streamingSummariesAll() {
1164
+ const out = [];
1165
+ for (const conv of this.convs.values()) {
1166
+ if (!conv.isStreaming)
1167
+ continue;
1168
+ out.push({ title: conv.title, cwd: conv.cwd, isStreaming: true });
1169
+ }
1170
+ return out;
1171
+ }
1172
+ /** issue #145:其他客户端重推 conversations 用(elsewhere 刷新)。 */
1173
+ refreshExternalRunning() {
1174
+ if (this.disposed)
1175
+ return;
1176
+ this.emitConversations();
1177
+ }
1124
1178
  /** 左栏展示口径(issue #140,与 pi 引擎同义):listed 之外,当前对话只要有
1125
1179
  * 内容(DSH 的对话消息全在内存里,直接数 messages)也在列表里;空白新对话
1126
1180
  * 不入列。只影响展示,不动 listed 的语义。 */
@@ -1143,7 +1197,23 @@ export class DshClientSession {
1143
1197
  isSubagent: false,
1144
1198
  });
1145
1199
  }
1146
- this.emit({ type: "conversations", conversations: list, activeId: this.activeId });
1200
+ // issue #145:流式集合签名变化 通知其他客户端重推(左栏「另一处正在运行」近实时)
1201
+ const sig = JSON.stringify([...this.convs.values()]
1202
+ .filter((c) => c.isStreaming)
1203
+ .map((c) => c.id)
1204
+ .sort());
1205
+ if (sig !== this.lastRunningSig) {
1206
+ this.lastRunningSig = sig;
1207
+ this.onRunningChanged?.();
1208
+ }
1209
+ const elsewhere = this.listExternalRunning?.() ?? [];
1210
+ this.emit({
1211
+ type: "conversations",
1212
+ conversations: list,
1213
+ activeId: this.activeId,
1214
+ // 为空时缺省(老快照字节一致)
1215
+ ...(elsewhere.length > 0 ? { elsewhere } : {}),
1216
+ });
1147
1217
  }
1148
1218
  /** 语义同 pi 引擎的 newChat:true = 当前活动对话是可接收首条的空白新对话
1149
1219
  * (/new <prompt> 靠它决定要不要把首条提示发出去)。 */
@@ -1239,6 +1309,51 @@ export class DshClientSession {
1239
1309
  }
1240
1310
  }
1241
1311
  let conv = this.conv;
1312
+ // issue #145:同文件守卫 —— 别处正在跑同一 sessionId 时拒绝发送(不造第二个 writer)。
1313
+ const fileOwner = this.findSessionOwnerById?.(conv.sessionId);
1314
+ if (fileOwner && fileOwner.isStreaming) {
1315
+ this.emit({
1316
+ type: "notice",
1317
+ level: "warning",
1318
+ text: `发送已拦截:该对话正在另一处运行中(「${fileOwner.title}」)。请等它结束后再发,或回到原窗口继续 —— 否则两个 agent 会同时写同一份记录,其中一支事后不可见。`,
1319
+ textEn: `Prompt blocked: this conversation is running in another window ("${fileOwner.title}"). Wait for it to finish or continue there — two writers on one transcript would leave one run permanently invisible.`,
1320
+ });
1321
+ this.flushSnapshot();
1322
+ return;
1323
+ }
1324
+ // issue #145:同项目并行感知(与 pi 引擎同语义;DSH 无 display:false 的
1325
+ // custom 消息通道,提醒以前置系统文本随本轮发给运行时,用户气泡保持原文)。
1326
+ let sysPrefix = "";
1327
+ if (!conv.isStreaming) {
1328
+ const localTitles = [...this.convs.values()]
1329
+ .filter((c) => c.id !== conv.id && c.cwd === conv.cwd && c.isStreaming)
1330
+ .map((c) => `本窗口「${c.title}」`);
1331
+ const externalRunners = (this.listProjectRunners?.(conv.cwd) ?? []).filter((r) => r.sessionFile === undefined || basename(dirname(resolve(r.sessionFile))) !== conv.sessionId);
1332
+ const runnerTitles = [...localTitles, ...externalRunners.map((r) => `另一处「${r.title}」`)];
1333
+ if (runnerTitles.length > 0) {
1334
+ const shown = runnerTitles.slice(0, 3).join("、");
1335
+ const more = runnerTitles.length > 3 ? `等 ${runnerTitles.length} 处` : "";
1336
+ this.emit({
1337
+ type: "notice",
1338
+ level: "info",
1339
+ text: `同项目并行提醒:${shown}${more}正在同一项目运行。你可以继续(适合改不同文件),改动同一文件前请先确认;拿不准就等它跑完。`,
1340
+ textEn: `Parallel-work notice: ${shown}${more ? " and more" : ""} running in the same project. You may continue (fine for different files); confirm before touching the same files, or wait for it to finish when unsure.`,
1341
+ });
1342
+ sysPrefix =
1343
+ `(System reminder: ${runnerTitles.length} other run(s) [${runnerTitles.join("; ").slice(0, 600)}] are currently running in the same project directory. ` +
1344
+ `You may work in parallel on different files, but before reading/writing files or running commands, assess the conflict probability with the other run(s). ` +
1345
+ `If a conflict is likely or you are unsure, use ask_user_question to let the user choose: continue in parallel / wait / watch read-only.)\n` +
1346
+ `(系统提醒:同一项目另有 ${runnerTitles.length} 处运行(${shown}${more})。改不同文件可并行;读写文件或跑命令前先评估冲突概率,拿不准就用 ask_user_question 让用户选择:并行 / 等它跑完 / 只读围观。)\n\n`;
1347
+ if (externalRunners.length > 0) {
1348
+ this.notifyExternalClients?.({
1349
+ type: "notice",
1350
+ level: "info",
1351
+ text: `同项目并行提醒:另一处在「${conv.cwd}」开始了对话(「${conv.title}」),可能与你正在跑的任务并行改动同一项目。`,
1352
+ textEn: `Parallel-work notice: another window started a conversation ("${conv.title}") in "${conv.cwd}", possibly editing the same project in parallel with your running task.`,
1353
+ });
1354
+ }
1355
+ }
1356
+ }
1242
1357
  // 磁盘回放会话(switch_session)没有 live runtime session —— DSH 的
1243
1358
  // JSON-RPC 面不支持恢复(id collision),自动 fork 新会话继续:把历史
1244
1359
  // 作为上下文注入首条 prompt,前端提示。
@@ -1256,7 +1371,7 @@ export class DshClientSession {
1256
1371
  conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
1257
1372
  this.emitConversations();
1258
1373
  }
1259
- await this.promptConv(conv, text, attachments, queue);
1374
+ await this.promptConv(conv, text, attachments, queue, sysPrefix);
1260
1375
  }
1261
1376
  /**
1262
1377
  * Remove ONE queued prompt text (the ✕ on a pending bubble). DSH queues are
@@ -1315,13 +1430,15 @@ export class DshClientSession {
1315
1430
  /* 补图失败保持占位 */
1316
1431
  }
1317
1432
  }
1318
- async promptConv(conv, text, attachments, _queue = false) {
1433
+ async promptConv(conv, text, attachments, _queue = false,
1434
+ // issue #145 同项目并行提醒:只进运行时上下文,不进用户气泡。
1435
+ sysPrefix = "") {
1319
1436
  try {
1320
1437
  if (this.quiesceBlocked())
1321
1438
  return;
1322
1439
  conv.promptedSinceActive = true;
1323
1440
  conv.lastEventAt = Date.now();
1324
- const blocks = await this.buildContentBlocks(text, attachments);
1441
+ const blocks = await this.buildContentBlocks(sysPrefix ? `${sysPrefix}${text}` : text, attachments);
1325
1442
  // 乐观落地用户消息(id 用暂定值;user/message 事件到达时按内容去重)。
1326
1443
  const optimistic = {
1327
1444
  id: `u-pending-${Date.now()}-${conv.deltaSeq++}`,
@@ -1869,6 +1986,29 @@ export class DshClientSession {
1869
1986
  return;
1870
1987
  }
1871
1988
  }
1989
+ // issue #145:同 pi 引擎 —— 别处正在跑同一 sessionId 时不建第二个持有者。
1990
+ const owner = this.findSessionOwnerById?.(sessionId);
1991
+ if (owner && owner.isStreaming) {
1992
+ this.emit({
1993
+ type: "notice",
1994
+ level: "warning",
1995
+ text: `该对话正在另一处运行中(「${owner.title}」),为避免两个 agent 同时写同一份记录,已停止打开。请等它结束后再试,或回到原窗口继续。`,
1996
+ textEn: `This conversation is running in another window ("${owner.title}"). Opening it here would create a second writer for the same transcript, so it was blocked. Wait for it to finish, or continue in the original window.`,
1997
+ });
1998
+ this.flushSnapshot(true);
1999
+ return;
2000
+ }
2001
+ if (owner) {
2002
+ // 对端已断开(标签页关了)只剩残留会话 —— 不打扰,直接开。
2003
+ if (owner.connected) {
2004
+ this.emit({
2005
+ type: "notice",
2006
+ level: "info",
2007
+ text: `提醒:该对话在另一处也开着(「${owner.title}」,当前空闲)。请只留一处发送消息,否则两边轮流发送会让历史分叉、其中一支事后不可见。`,
2008
+ textEn: `Note: this conversation is also open in another window ("${owner.title}", currently idle). Send new messages from only one place — alternating between two writers forks the history and hides one branch.`,
2009
+ });
2010
+ }
2011
+ }
1872
2012
  const prev = this.conv;
1873
2013
  prev.listed = prev.isStreaming || prev.terminals.list().length > 0 || prev.promptedSinceActive;
1874
2014
  const conv = this.addConversation(sessionId, this.cwd, true);
@@ -2252,6 +2392,7 @@ export class DshClientSession {
2252
2392
  reviewPrompt: this.settings.reviewPrompt,
2253
2393
  reviewDisabledSkills: [],
2254
2394
  disabledPlugins: this.settings.disabledPlugins,
2395
+ uiLayout: normalizeUiLayout(this.settings.uiLayout),
2255
2396
  promptTemplate: "",
2256
2397
  promptOverrides: {},
2257
2398
  effectiveSystemPrompt: this.settings.customSystemPrompt,
@@ -2306,6 +2447,8 @@ export class DshClientSession {
2306
2447
  this.settings.toolsWrap = partial.toolsWrap;
2307
2448
  if (partial.disabledPlugins !== undefined)
2308
2449
  this.settings.disabledPlugins = partial.disabledPlugins;
2450
+ if (partial.uiLayout !== undefined)
2451
+ this.settings.uiLayout = normalizeUiLayout(partial.uiLayout);
2309
2452
  if (partial.reviewPrompt !== undefined)
2310
2453
  this.settings.reviewPrompt = partial.reviewPrompt;
2311
2454
  if (partial.quickPhrases !== undefined) {
@@ -2334,6 +2477,7 @@ export class DshClientSession {
2334
2477
  thinkingWrap: this.settings.thinkingWrap,
2335
2478
  toolsWrap: this.settings.toolsWrap,
2336
2479
  disabledPlugins: this.settings.disabledPlugins,
2480
+ uiLayout: normalizeUiLayout(this.settings.uiLayout),
2337
2481
  reviewPrompt: this.settings.reviewPrompt,
2338
2482
  quickPhrases: this.settings.quickPhrases,
2339
2483
  quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
@@ -2442,6 +2586,7 @@ export class DshClientSession {
2442
2586
  thinkingWrap: this.settings.thinkingWrap,
2443
2587
  toolsWrap: this.settings.toolsWrap,
2444
2588
  disabledPlugins: this.settings.disabledPlugins,
2589
+ uiLayout: normalizeUiLayout(this.settings.uiLayout),
2445
2590
  reviewPrompt: this.settings.reviewPrompt,
2446
2591
  });
2447
2592
  await this.applyPersona();
@@ -2983,7 +3128,9 @@ export class DshClientSession {
2983
3128
  }
2984
3129
  async checkUpdate() {
2985
3130
  try {
2986
- const latest = await checkAllUpdates([{ name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" }], undefined, () => this.getLang());
3131
+ // registry 遵从 ~/.pi/agent/npm/.npmrc(与 `pi update` npm 的行为一致,issue #151)
3132
+ const npmRegistry = resolveNpmRegistry(join(homedir(), ".pi", "agent"));
3133
+ const latest = await checkAllUpdates([{ name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" }], undefined, () => this.getLang(), npmRegistry);
2987
3134
  const item = latest[0];
2988
3135
  this.emit({
2989
3136
  type: "update_status",
@@ -3008,7 +3155,7 @@ export class DshClientSession {
3008
3155
  async checkUpdatesAll(force = false) {
3009
3156
  try {
3010
3157
  const targets = collectTargets(join(homedir(), ".pi", "agent"), DshClientSession.currentAppVersion());
3011
- const items = await checkAllUpdates(targets, undefined, () => this.getLang());
3158
+ const items = await checkAllUpdates(targets, undefined, () => this.getLang(), resolveNpmRegistry(join(homedir(), ".pi", "agent")));
3012
3159
  if (force) {
3013
3160
  // 强制模式:忽略缓存(默认 Fetcher 带 TTL,直接再查一次即可)。
3014
3161
  void items;
@@ -3263,6 +3410,19 @@ export class DshClientSession {
3263
3410
  return;
3264
3411
  this.stateStore.saveLocale(this.clientId, code);
3265
3412
  }
3413
+ /** 设置当前项目的额外工作区根(宿主侧多根,宿主 API v2 的宿主侧能力)。
3414
+ * DSH 引擎没有插件宿主,多根只落到「宿主知道 / 快照下发」这一层:右栏文件树
3415
+ * (前端)据此多出可切换的根。
3416
+ * 与 pi 引擎同口径:归一化交给 ClientStateStore(只收绝对路径 / 去重 / 上限 8)。 */
3417
+ async setWorkspaceRoots(roots) {
3418
+ const before = this.stateStore.getWorkspaceRoots(this.clientId, this.cwd);
3419
+ this.stateStore.saveWorkspaceRoots(this.clientId, this.cwd, roots ?? []);
3420
+ const saved = this.stateStore.getWorkspaceRoots(this.clientId, this.cwd);
3421
+ if (saved.length === before.length && saved.every((p, i) => p === before[i]))
3422
+ return;
3423
+ this.roots = saved;
3424
+ this.flushSnapshot();
3425
+ }
3266
3426
  async setCwd(newCwd) {
3267
3427
  try {
3268
3428
  const abs = resolve(newCwd);
@@ -3278,6 +3438,7 @@ export class DshClientSession {
3278
3438
  if (abs === this.cwd)
3279
3439
  return;
3280
3440
  this.cwd = abs;
3441
+ this.roots = this.stateStore.getWorkspaceRoots(this.clientId, abs);
3281
3442
  this.stateStore.remember(this.clientId, abs);
3282
3443
  // 换项目 = 重启运行时(initialize 固定 cwd)。
3283
3444
  try {
@@ -3440,6 +3601,71 @@ export class DshAgentService {
3440
3601
  service: toServiceInfo(launchOrigin()),
3441
3602
  };
3442
3603
  }
3604
+ /** issue #145:跨客户端同会话查重(sessionId 口径,与 pi 引擎同语义)。 */
3605
+ findSessionOwner(sessionId, excludeClientId) {
3606
+ for (const [clientId, cs] of this.clients) {
3607
+ if (clientId === excludeClientId)
3608
+ continue;
3609
+ const conv = cs.findConversationBySessionId(sessionId);
3610
+ if (conv)
3611
+ return {
3612
+ clientId,
3613
+ title: conv.title,
3614
+ cwd: conv.cwd,
3615
+ isStreaming: conv.isStreaming,
3616
+ connected: cs.sinkCount() > 0,
3617
+ };
3618
+ }
3619
+ return null;
3620
+ }
3621
+ /** issue #145:别处在某 cwd 下正在跑的对话(同项目并行感知用)。 */
3622
+ listProjectRunners(cwd, excludeClientId) {
3623
+ const out = [];
3624
+ for (const [clientId, cs] of this.clients) {
3625
+ if (clientId === excludeClientId)
3626
+ continue;
3627
+ for (const r of cs.streamingInCwd(cwd))
3628
+ out.push({ clientId, title: r.title });
3629
+ }
3630
+ return out;
3631
+ }
3632
+ /** issue #145:别处所有正在跑的对话(左栏 elsewhere 只读感知用)。 */
3633
+ listExternalRunning(excludeClientId) {
3634
+ const out = [];
3635
+ for (const [clientId, cs] of this.clients) {
3636
+ if (clientId === excludeClientId)
3637
+ continue;
3638
+ for (const r of cs.streamingSummariesAll())
3639
+ out.push(r);
3640
+ }
3641
+ return out;
3642
+ }
3643
+ /** issue #145:某客户端流式集合变化 → 其他客户端重推 conversations。 */
3644
+ pokeExternalRunning(excludeClientId) {
3645
+ for (const [clientId, cs] of this.clients) {
3646
+ if (clientId === excludeClientId)
3647
+ continue;
3648
+ try {
3649
+ cs.refreshExternalRunning();
3650
+ }
3651
+ catch {
3652
+ // 单客户端坏了不影响其他
3653
+ }
3654
+ }
3655
+ }
3656
+ /** issue #145:向除请求方外的所有客户端发一条 notice(并行通告用)。 */
3657
+ notifyClientsExcept(excludeClientId, msg) {
3658
+ for (const [clientId, cs] of this.clients) {
3659
+ if (clientId === excludeClientId)
3660
+ continue;
3661
+ try {
3662
+ cs.emitNotice(msg.level, msg.text, msg.textEn);
3663
+ }
3664
+ catch {
3665
+ // 单客户端坏了不影响其他
3666
+ }
3667
+ }
3668
+ }
3443
3669
  async attach(clientId, send) {
3444
3670
  let cs = this.clients.get(clientId);
3445
3671
  if (!cs) {
@@ -3478,6 +3704,12 @@ export class DshAgentService {
3478
3704
  cs.pluginBgTasksProvider = this.pluginBgTasksProvider;
3479
3705
  cs.pluginStopBgTask = this.pluginStopBgTask;
3480
3706
  cs.isQuiesced = () => this.quiesced;
3707
+ // issue #145 跨客户端感知接线(同会话查重 / 同项目并行 / elsewhere 列表)。
3708
+ cs.findSessionOwnerById = (sessionId) => this.findSessionOwner(sessionId, clientId);
3709
+ cs.listProjectRunners = (cwd) => this.listProjectRunners(cwd, clientId);
3710
+ cs.listExternalRunning = () => this.listExternalRunning(clientId);
3711
+ cs.notifyExternalClients = (msg) => this.notifyClientsExcept(clientId, msg);
3712
+ cs.onRunningChanged = () => this.pokeExternalRunning(clientId);
3481
3713
  cs.onCwdChanged = (abs) => this.onClientCwdChanged?.(abs);
3482
3714
  this.onClientCwdChanged?.(cs.cwd);
3483
3715
  return cs;