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.
@@ -16,19 +16,27 @@
16
16
  * 的 send 函数),插件本身不接触 ws。
17
17
  * - activate 抛错只标记 error 字段并记日志,绝不影响主进程。
18
18
  */
19
- import { readdir, readFile, stat } from "node:fs/promises";
19
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
20
20
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
21
- import { join, resolve, sep } from "node:path";
21
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
22
  import { pathToFileURL } from "node:url";
23
23
  import { pick } from "./i18n.js";
24
24
  import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
25
25
  import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog.js";
26
+ import { PluginGrantsStore, normalizeGrantPath } from "./plugin-grants.js";
27
+ // 工作区根的归一化与 client-state 共用一份(同一份语义:只收绝对路径 / 去重 / 上限)。
28
+ import { normalizeWorkspaceRoots } from "./client-state.js";
29
+ import { createProject } from "./plugin-project.js";
26
30
  import { createHash } from "node:crypto";
27
31
  /** 合法插件 id:字母/数字/下划线/连字符,防路径穿越(同 themes.ts 的做法)。 */
28
32
  const ID_RE = /^[A-Za-z0-9_-]+$/;
29
33
  /** 宿主提供的插件设施版本——manifest 声明的 apiVersion 高于此值则拒绝激活,
30
- * 插件能拿到明确的「请升级 pi-web-ui」而不是在新接口上莫名 undefined。 */
31
- export const PLUGIN_API_VERSION = 1;
34
+ * 插件能拿到明确的「请升级 pi-web-ui」而不是在新接口上莫名 undefined。
35
+ * 1 = 初始:storage/secrets/命令/HTTP 路由/工具注册/受限 fs/后台任务。
36
+ * 2 = issue #146:UI 扩展点(manifest "ui" + host.ui.*)、跨目录 fs(requestAccess /
37
+ * *Path 族)、项目组装(host.project.create)、多根工作区(set_workspace_roots)。
38
+ * 同时把「未声明 permissions」从旧全权模式改为**默认拒绝**(versionGuard 前移)。 */
39
+ export const PLUGIN_API_VERSION = 2;
32
40
  /** 消息处理器超时:仅作为不再等待的日志阈值(响应由 handler 自己发出)。 */
33
41
  const MESSAGE_TIMEOUT_MS = 30_000;
34
42
  /** host.fs 被能力门控拒绝时的共享 rejected promise(类型对齐用)。 */
@@ -38,6 +46,171 @@ NO_FS_PROMISE.catch(() => { }); // 避免未处理 rejection 噪音;调用方
38
46
  // 声明式设置 schema(manifest "settings")
39
47
  // ---------------------------------------------------------------------------
40
48
  const SETTING_TYPES = new Set(["text", "password", "number", "boolean", "select"]);
49
+ /**
50
+ * 解析 manifest "ui" 的某个 slot 数组 → 规范化条目(issue #146 完整版)。
51
+ *
52
+ * 宽容但不放任:坏字段跳过、id 非法或重复跳过、children 只收一层、文本截断;
53
+ * 不认识的 slot / kind / when 直接丢弃(旧宿主读到新字段也不会崩,新宿主读到旧字段同理)。
54
+ * 归属由宿主决定:全局 id = `<pluginId>:<itemId>`。
55
+ */
56
+ const UI_SLOTS = new Set([
57
+ "topbar.primary",
58
+ "topbar.overflow",
59
+ "bottombar",
60
+ "composer.actions",
61
+ "message.actions",
62
+ "rightpanel.tabs",
63
+ "contextmenu.topbar",
64
+ "contextmenu.message",
65
+ "contextmenu.session",
66
+ "contextmenu.file",
67
+ "settings.pages",
68
+ ]);
69
+ /** manifest 里可以写更自然的简写(作者少踩坑):解析时映射到完整 slot 名。 */
70
+ const UI_SLOT_ALIASES = {
71
+ topbar: "topbar.primary",
72
+ "topbar.more": "topbar.overflow",
73
+ composer: "composer.actions",
74
+ message: "message.actions",
75
+ rightpanel: "rightpanel.tabs",
76
+ settings: "settings.pages",
77
+ };
78
+ /** 合法的条目种类(缺省 action;settings.pages 缺省 page)。 */
79
+ const UI_KINDS = new Set(["view", "action", "badge", "menu", "page", "organizer", "divider"]);
80
+ function trimStr(v, max = 60) {
81
+ return typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
82
+ }
83
+ /** 规范化一个条目;非法返回 null(slot 由调用方给)。 */
84
+ export function parseUiItem(raw, slot) {
85
+ if (!raw || typeof raw !== "object")
86
+ return null;
87
+ const o = raw;
88
+ const id = trimStr(o.id, 64);
89
+ // id 必须匹配插件 id 字符集(它与 pluginId 拼成全局 key,直接进 DOM 的 data 属性)
90
+ if (!id || !ID_RE.test(id))
91
+ return null;
92
+ const label = trimStr(o.label, 60);
93
+ if (!label)
94
+ return null;
95
+ const kindRaw = trimStr(o.kind, 16);
96
+ let kind = kindRaw && UI_KINDS.has(kindRaw) ? kindRaw : undefined;
97
+ if (!kind)
98
+ kind = slot === "settings.pages" ? "page" : "action";
99
+ const children = [];
100
+ if (Array.isArray(o.children)) {
101
+ for (const c of o.children.slice(0, 16)) {
102
+ const child = parseUiItem(c, slot);
103
+ // 子项不再递归(一层够用):清掉它自己的 children 防嵌套刷栈
104
+ if (child)
105
+ children.push({ ...child, children: undefined });
106
+ }
107
+ }
108
+ const when = Array.isArray(o.when)
109
+ ? o.when.filter((x) => typeof x === "string" && x.trim().length > 0).slice(0, 8)
110
+ : undefined;
111
+ const num = Number(o.order);
112
+ return {
113
+ id,
114
+ slot: slot,
115
+ label,
116
+ ...(trimStr(o.labelEn, 60) ? { labelEn: trimStr(o.labelEn, 60) } : {}),
117
+ ...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
118
+ ...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
119
+ ...(trimStr(o.hintEn, 200) ? { hintEn: trimStr(o.hintEn, 200) } : {}),
120
+ kind,
121
+ ...(children.length ? { children } : {}),
122
+ ...(Number.isFinite(num) ? { order: num } : {}),
123
+ ...(trimStr(o.group, 40) ? { group: trimStr(o.group, 40) } : {}),
124
+ ...(o.hidden === true ? { hidden: true } : {}),
125
+ ...(trimStr(o.action, 64) ? { action: trimStr(o.action, 64) } : {}),
126
+ ...(trimStr(o.view, 64) ? { view: trimStr(o.view, 64) } : {}),
127
+ ...(when?.length ? { when } : {}),
128
+ ...(trimStr(o.badge, 24) ? { badge: trimStr(o.badge, 24) } : {}),
129
+ };
130
+ }
131
+ /**
132
+ * 解析 manifest "ui" → 规范化贡献。
133
+ *
134
+ * 形状两种都收(都是为了少让插件作者踩坑):
135
+ * "ui": { "topbar": [...] } // 按 slot 分组(推荐)
136
+ * "ui": { "items": [{ slot, ... }, ...] } // 平铺(运行时注册同形,便于两边复用)
137
+ * 单条目上限 32、arrange 上限 64 —— 防一份 manifest 把前端顶爆。
138
+ */
139
+ export function parseUiContributions(raw) {
140
+ if (!raw || typeof raw !== "object")
141
+ return undefined;
142
+ const o = raw;
143
+ const items = [];
144
+ const push = (it) => {
145
+ if (it && items.length < 32)
146
+ items.push(it);
147
+ };
148
+ if (Array.isArray(o.items)) {
149
+ for (const it of o.items.slice(0, 32)) {
150
+ const raw = trimStr(it?.slot, 32);
151
+ const slot = raw ? (UI_SLOT_ALIASES[raw] ?? raw) : "";
152
+ if (!slot || !UI_SLOTS.has(slot))
153
+ continue;
154
+ push(parseUiItem(it, slot));
155
+ }
156
+ }
157
+ for (const [rawKey, val] of Object.entries(o)) {
158
+ if (rawKey === "items" || rawKey === "arrange")
159
+ continue;
160
+ const key = UI_SLOT_ALIASES[rawKey] ?? rawKey;
161
+ if (!UI_SLOTS.has(key) || !Array.isArray(val))
162
+ continue;
163
+ for (const it of val.slice(0, 32))
164
+ push(parseUiItem(it, key));
165
+ }
166
+ const arrange = parseUiArrange(o.arrange);
167
+ if (!items.length && !arrange.length)
168
+ return undefined;
169
+ return { items, arrange };
170
+ }
171
+ /** 规范化整理意图(对内置/其它插件的条目)。非法/越界形状丢弃。 */
172
+ export function parseUiArrange(raw) {
173
+ if (!Array.isArray(raw))
174
+ return [];
175
+ const out = [];
176
+ for (const it of raw.slice(0, 64)) {
177
+ if (!it || typeof it !== "object")
178
+ continue;
179
+ const o = it;
180
+ // 目标 id:`host:<name>` 或 `<pluginId>:<itemId>`
181
+ const id = trimStr(o.id, 96);
182
+ if (!id || !/^[A-Za-z0-9_-]+:[A-Za-z0-9_.:-]+$/.test(id))
183
+ continue;
184
+ const slotRaw = trimStr(o.slot, 32);
185
+ const num = Number(o.order);
186
+ out.push({
187
+ id,
188
+ ...(slotRaw && UI_SLOTS.has(slotRaw) ? { slot: slotRaw } : {}),
189
+ ...(o.hide === true ? { hide: true } : {}),
190
+ ...(o.hide === false ? { hide: false } : {}),
191
+ ...(trimStr(o.group, 40) ? { group: trimStr(o.group, 40) } : {}),
192
+ ...(Number.isFinite(num) ? { order: num } : {}),
193
+ ...(trimStr(o.label, 60) ? { label: trimStr(o.label, 60) } : {}),
194
+ ...(trimStr(o.hint, 200) ? { hint: trimStr(o.hint, 200) } : {}),
195
+ ...(trimStr(o.icon, 16) ? { icon: trimStr(o.icon, 16) } : {}),
196
+ });
197
+ }
198
+ return out;
199
+ }
200
+ /** 合并 manifest 基线与运行时注册:运行时同 id 覆盖,removed 里的删除;arrange 追加。 */
201
+ function mergeUiPluginUi(base, rt) {
202
+ const items = new Map();
203
+ for (const it of base?.items ?? [])
204
+ items.set(it.id, it);
205
+ for (const it of rt?.items.values() ?? [])
206
+ items.set(it.id, it);
207
+ for (const id of rt?.removed ?? [])
208
+ items.delete(id);
209
+ const arrange = [...(base?.arrange ?? []), ...(rt?.arrange ?? [])];
210
+ if (!items.size && !arrange.length)
211
+ return undefined;
212
+ return { items: [...items.values()], arrange };
213
+ }
41
214
  /** 解析 manifest.settings → 合法 schema(坏字段跳过,最多 32 个)。 */
42
215
  function parseSettingsSchema(raw) {
43
216
  if (!Array.isArray(raw))
@@ -166,12 +339,28 @@ export class PluginManager {
166
339
  catalogEpoch = 0;
167
340
  /** 当前全局工作区(host.cwd 的背后存储)——随 notifyCwd 更新。 */
168
341
  cwdValue;
342
+ /** 当前项目的**额外工作区根**(宿主侧多根,见 protocol 的 set_workspace_roots)——
343
+ * 由 index.ts 在 set_cwd / set_workspace_roots 后调 notifyWorkspaceRoots 同步。
344
+ * 它们只影响「哪些路径算工作区内」(免授权的受支持路径),不改变 cwd 本身。 */
345
+ workspaceRoots = [];
346
+ /** 插件目录授权表(<dataDir>/plugin-grants.json,issue #146)。 */
347
+ grants;
348
+ /** 由 index.ts 注入:向浏览器请求「插件要访问这个目录」的用户确认。 */
349
+ pathAccessRequester = undefined;
350
+ /** 由 index.ts 注入:授权表**变了**(新授权落表)时触发 —— 设置面板的「已授权目录」
351
+ * 靠它即时刷新(以前只在 attach / 撤销时推,「点了允许但列表里还没出现」很难不被当成 bug)。 */
352
+ onGrantsChanged = undefined;
353
+ /** 插件运行时注册的 UI 贡献(host.ui.register/arrange),随 plugins 清单推送。 */
354
+ uiRuntime = new Map();
355
+ /** manifest "ui" 基线(每次 scan 刷新;host.ui.list 与合并都读它)。 */
356
+ uiBase = new Map();
169
357
  constructor(dataDir, cwd,
170
358
  /** 随包发布的默认插件列表(<pkgRoot>/plugins/catalog.json)。缺省 = 无内置列表。 */
171
359
  builtinCatalogPath) {
172
360
  this.dataDir = dataDir;
173
361
  this.builtinCatalogPath = builtinCatalogPath;
174
362
  this.cwdValue = resolve(cwd);
363
+ this.grants = new PluginGrantsStore(dataDir);
175
364
  }
176
365
  /** index.ts 在客户端 set_cwd 成功后调用:更新全局工作区并扇出给
177
366
  * 所有已激活插件的 onCwdChange 钩子(异常隔离,不炸主进程)。 */
@@ -191,6 +380,15 @@ export class PluginManager {
191
380
  }
192
381
  }
193
382
  }
383
+ /** index.ts 在客户端改动「额外工作区根」后调用(新增/移除/切项目都算):归一化后存下,
384
+ * 同一份就是 no-op。刻意**不发** onCwdChange 钩子:工作区根变化不动 cwd,那个钩子的
385
+ * 语义就是「当前目录变了」(插件该切根的时机)。 */
386
+ notifyWorkspaceRoots(roots) {
387
+ const next = normalizeWorkspaceRoots(roots ?? []);
388
+ if (next.length === this.workspaceRoots.length && next.every((p, i) => p === this.workspaceRoots[i]))
389
+ return;
390
+ this.workspaceRoots = next;
391
+ }
194
392
  get pluginsDir() {
195
393
  return join(this.dataDir, "plugins");
196
394
  }
@@ -622,6 +820,34 @@ export class PluginManager {
622
820
  }
623
821
  };
624
822
  }
823
+ /** 取(或建)某插件的运行时 UI 状态。 */
824
+ uiRuntimeFor(pluginId) {
825
+ let rt = this.uiRuntime.get(pluginId);
826
+ if (!rt)
827
+ this.uiRuntime.set(pluginId, (rt = { items: new Map(), removed: new Set(), arrange: [] }));
828
+ return rt;
829
+ }
830
+ /** 移除一个条目(运行时注册的或 manifest 声明的都记进 removed,保证合并时不复活)。 */
831
+ removeUiItem(pluginId, itemId) {
832
+ const rt = this.uiRuntimeFor(pluginId);
833
+ rt.items.delete(itemId);
834
+ rt.removed.add(itemId);
835
+ }
836
+ /** 该绝对路径是否落在当前工作区(或其额外根)内:工作区内的路径本来就能访问,
837
+ * 不必走授权。多根语义见 protocol 的 set_workspace_roots —— 用户把一个目录加成
838
+ * 工作区根,就是「我认它是我工作区的一部分」,插件读它无需再问。 */
839
+ isInsideWorkspace(abs) {
840
+ for (const root of [this.cwdValue, ...this.workspaceRoots]) {
841
+ const rel = relative(root, abs);
842
+ if (rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)))
843
+ return true;
844
+ }
845
+ return false;
846
+ }
847
+ /** 某插件当前生效的 UI 贡献 = manifest 基线 + 运行时注册(同 id 覆盖、removed 删除)。 */
848
+ uiOf(pluginId) {
849
+ return mergeUiPluginUi(this.uiBase.get(pluginId), this.uiRuntime.get(pluginId));
850
+ }
625
851
  deliverAll(msg) {
626
852
  for (const s of this.senders) {
627
853
  try {
@@ -684,6 +910,9 @@ export class PluginManager {
684
910
  this.loaded.delete(id);
685
911
  this.messageHandlers.delete(id);
686
912
  this.attempted.delete(id);
913
+ // 运行时 UI 注册随插件一起消失(manifest 基线留着,重扫时会重算)。
914
+ this.uiRuntime.delete(id);
915
+ this.uiBase.delete(id);
687
916
  // 重新激活时会 import 磁盘上的 index.mjs:Node 的 ESM 缓存按 URL(含 ?e=)
688
917
  // 命中,epoch 不变就会拿到旧模块(更新插件后还是旧代码)——所以这里也 +1,
689
918
  // 顺带让浏览器端 ?e= 变化、重拉插件的 client bundle。
@@ -759,6 +988,26 @@ export class PluginManager {
759
988
  : undefined,
760
989
  // 是否有独立视图 tab(manifest "view",缺省 true);纯 renderer 插件写 false
761
990
  view: typeof m.view === "boolean" ? m.view : true,
991
+ // 插件对宿主 UI 的贡献(manifest "ui":slot 框架 + 整理意图,issue #146)。
992
+ // 权限:与 activate 的 can("ui") **同一口径**(严格模式 = 声明了 permissions
993
+ // 或 apiVersion>=2):严格模式下必须含 "ui" 族,否则整份忽略;旧全权格式放行。
994
+ ui: (() => {
995
+ const perms = Array.isArray(m.permissions)
996
+ ? m.permissions.filter((x) => typeof x === "string")
997
+ : [];
998
+ const apiVersion = Number(m.apiVersion ?? 1) || 1;
999
+ const strict = perms.length > 0 || apiVersion >= 2;
1000
+ if (strict && !perms.some((x) => x.split(":")[0] === "ui")) {
1001
+ this.uiBase.delete(name);
1002
+ return undefined;
1003
+ }
1004
+ const base = parseUiContributions(m.ui);
1005
+ if (base)
1006
+ this.uiBase.set(name, base);
1007
+ else
1008
+ this.uiBase.delete(name);
1009
+ return this.uiOf(name);
1010
+ })(),
762
1011
  // 安装来源(pi-web-ui install 写入的 .pi-source.json)——
763
1012
  // 设置面板据此显示「更新」按钮;手工拷入的插件没有此文件。
764
1013
  source: await readFile(join(dir, ".pi-source.json"), "utf8")
@@ -842,6 +1091,39 @@ export class PluginManager {
842
1091
  const secrets = new PluginSecrets(this.dataDir, dir);
843
1092
  // 受限工作区文件访问(能力 "fs" 门控;根随 set_cwd 活值移动)。
844
1093
  const workspaceFs = new WorkspaceFS(() => self.cwdValue);
1094
+ /** 跨目录读写(issue #146):每次操作都要求路径已在授权表里(或落在工作区内)。
1095
+ * 与 workspaceFs 的分工:那个锚定当前工作区、越界拒绝;这个锚定「用户点过头的目录」。
1096
+ * 两者都不允许插件无告知地碰任意路径 —— 这就是「受支持路径」与裸 node:fs 的差别。 */
1097
+ const allowAbs = (p) => {
1098
+ const abs = normalizeGrantPath(p);
1099
+ if (!abs)
1100
+ throw new Error("路径必须是绝对路径");
1101
+ if (self.isInsideWorkspace(abs) || self.grants.has(info.id, abs))
1102
+ return abs;
1103
+ throw new Error(`目录未授权:先 await host.fs.requestAccess(dir)(${abs})`);
1104
+ };
1105
+ const crossDirFs = {
1106
+ list: async (absDir) => {
1107
+ const abs = allowAbs(absDir);
1108
+ const ents = await readdir(abs, { withFileTypes: true });
1109
+ return ents.slice(0, 2000).map((e) => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" }));
1110
+ },
1111
+ read: async (absPath) => readFile(allowAbs(absPath)),
1112
+ readText: async (absPath, maxBytes) => {
1113
+ const buf = await readFile(allowAbs(absPath));
1114
+ const cap = Math.max(1024, Math.min(Number(maxBytes ?? 2 * 1024 * 1024), 8 * 1024 * 1024));
1115
+ return buf.subarray(0, cap).toString("utf8");
1116
+ },
1117
+ write: async (absPath, data) => {
1118
+ const abs = allowAbs(absPath);
1119
+ await mkdir(dirname(abs), { recursive: true });
1120
+ await writeFile(abs, data);
1121
+ },
1122
+ remove: async (absPath) => {
1123
+ const abs = allowAbs(absPath);
1124
+ await rm(abs, { recursive: true, force: true });
1125
+ },
1126
+ };
845
1127
  /** 能力门控:严格模式下查声明族;旧模式放行但每个激活期只警告一次。
846
1128
  * 返回 false = 已记日志,调用方应拒绝。 */
847
1129
  const can = (family) => {
@@ -952,6 +1234,51 @@ export class PluginManager {
952
1234
  readText: (p, max) => (can("fs") ? workspaceFs.readText(p, max) : NO_FS_PROMISE),
953
1235
  write: (p, data) => (can("fs") ? workspaceFs.write(p, data) : NO_FS_PROMISE),
954
1236
  remove: (p) => (can("fs") ? workspaceFs.remove(p) : NO_FS_PROMISE),
1237
+ requestAccess: async (dir, reason) => {
1238
+ if (!can("fs"))
1239
+ return false;
1240
+ const abs = normalizeGrantPath(String(dir ?? ""));
1241
+ if (!abs)
1242
+ return false;
1243
+ // 工作区内的路径本来就能用,不必打扰用户。
1244
+ if (self.isInsideWorkspace(abs))
1245
+ return true;
1246
+ if (self.grants.has(info.id, abs))
1247
+ return true;
1248
+ if (!self.pathAccessRequester)
1249
+ return false;
1250
+ const ok = await self.pathAccessRequester(info.id, abs, reason);
1251
+ if (ok) {
1252
+ self.grants.grant(info.id, abs);
1253
+ // 授权表变了 → 通知宿主重推(设置面板即时可见)。 throws 不能拖塔授权本身。
1254
+ try {
1255
+ self.onGrantsChanged?.();
1256
+ }
1257
+ catch {
1258
+ /* 推送失败不影响已完成的授权 */
1259
+ }
1260
+ }
1261
+ return ok;
1262
+ },
1263
+ authorizedDirs: () => (can("fs") ? self.grants.get(info.id) : []),
1264
+ listPath: (absDir) => (can("fs") ? crossDirFs.list(absDir) : NO_FS_PROMISE),
1265
+ readPath: (absPath) => (can("fs") ? crossDirFs.read(absPath) : NO_FS_PROMISE),
1266
+ readTextPath: (absPath, max) => (can("fs") ? crossDirFs.readText(absPath, max) : NO_FS_PROMISE),
1267
+ writePath: (absPath, data) => (can("fs") ? crossDirFs.write(absPath, data) : NO_FS_PROMISE),
1268
+ removePath: (absPath) => (can("fs") ? crossDirFs.remove(absPath) : NO_FS_PROMISE),
1269
+ },
1270
+ project: {
1271
+ create: async (spec) => {
1272
+ if (!can("fs"))
1273
+ return { ok: false, error: "未声明能力 fs(manifest.permissions)", log: [], dir: "" };
1274
+ const dir = normalizeGrantPath(String(spec?.dir ?? ""));
1275
+ if (!dir)
1276
+ return { ok: false, error: "项目目录必须是绝对路径", log: [], dir: "" };
1277
+ if (!self.isInsideWorkspace(dir) && !self.grants.has(info.id, dir)) {
1278
+ return { ok: false, dir, log: [], error: `项目目录未授权:先 await host.fs.requestAccess("${dir}")` };
1279
+ }
1280
+ return createProject(spec, { onProgress: (line) => self.notifyAll("info", line) });
1281
+ },
955
1282
  },
956
1283
  registerBackgroundTask: (task) => {
957
1284
  const id = String(task?.id ?? "").trim();
@@ -996,6 +1323,65 @@ export class PluginManager {
996
1323
  },
997
1324
  };
998
1325
  },
1326
+ ui: {
1327
+ register: (items) => {
1328
+ if (!can("ui"))
1329
+ return () => { };
1330
+ const list = Array.isArray(items) ? items : [items];
1331
+ const added = [];
1332
+ const rt = self.uiRuntimeFor(info.id);
1333
+ for (const raw of list.slice(0, 32)) {
1334
+ const slotRaw = typeof raw?.slot === "string" ? String(raw.slot) : "";
1335
+ // 与 manifest 解析同口径:先查别名(topbar → topbar.primary)、再校枚举。
1336
+ // 运行时注册不校验的话,插件给个别名(或写错)会得到一个前端不认识的 slot
1337
+ // —— buildUiSlots 会静默丢掉它,表现为「注册了但界面上没有」,最难排。
1338
+ const slot = UI_SLOT_ALIASES[slotRaw] ?? slotRaw;
1339
+ const parsed = slot && UI_SLOTS.has(slot) ? parseUiItem(raw, slot) : null;
1340
+ if (!parsed)
1341
+ continue;
1342
+ rt.items.set(parsed.id, parsed);
1343
+ rt.removed.delete(parsed.id);
1344
+ added.push(parsed.id);
1345
+ }
1346
+ if (added.length)
1347
+ void self.pushToAll().catch(() => { });
1348
+ return () => {
1349
+ if (!added.length)
1350
+ return;
1351
+ for (const id of added)
1352
+ self.removeUiItem(info.id, id);
1353
+ void self.pushToAll().catch(() => { });
1354
+ };
1355
+ },
1356
+ update: (id, patch) => {
1357
+ if (!can("ui"))
1358
+ return;
1359
+ // 只能更新"当前生效"的条目:manifest 声明的与运行时注册的都算,
1360
+ // 不存在的一律忽略(避免插件凭空造条目绕过声明审查)。
1361
+ const base = self.uiOf(info.id)?.items.find((x) => x.id === id);
1362
+ if (!base)
1363
+ return;
1364
+ const merged = { ...base, ...patch, id, slot: base.slot };
1365
+ self.uiRuntimeFor(info.id).items.set(id, merged);
1366
+ void self.pushToAll().catch(() => { });
1367
+ },
1368
+ remove: (id) => {
1369
+ if (!can("ui"))
1370
+ return;
1371
+ self.removeUiItem(info.id, id);
1372
+ void self.pushToAll().catch(() => { });
1373
+ },
1374
+ arrange: (ops) => {
1375
+ if (!can("ui"))
1376
+ return;
1377
+ const list = parseUiArrange(Array.isArray(ops) ? ops : [ops]);
1378
+ if (!list.length)
1379
+ return;
1380
+ self.uiRuntimeFor(info.id).arrange.push(...list);
1381
+ void self.pushToAll().catch(() => { });
1382
+ },
1383
+ list: () => self.uiOf(info.id) ?? { items: [], arrange: [] },
1384
+ },
999
1385
  getSettings: () => storedSettingsValues(dir, info.settingsSchema ?? []),
1000
1386
  onSettingsChanged: (h) => {
1001
1387
  settingsHandlers.add(h);
@@ -8,7 +8,8 @@
8
8
  */
9
9
  import { existsSync, readdirSync } from "node:fs";
10
10
  import { basename, dirname, join } from "node:path";
11
- import { extensionKey, normalizeRetryMaxAttempts, normalizeSkillList, } from "./client-state.js";
11
+ import { fileURLToPath } from "node:url";
12
+ import { extensionKey, normalizeRetryMaxAttempts, normalizeSkillList, normalizeUiLayout, } from "./client-state.js";
12
13
  import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
13
14
  import { DEFAULT_TEMPLATES } from "./subagent-templates.js";
14
15
  import { deriveLegacy, foldLegacyIntoDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
@@ -32,6 +33,21 @@ export class SettingsService {
32
33
  get current() {
33
34
  return this.settings;
34
35
  }
36
+ /** Effective dev-no-cache: explicit setting wins, else source-tree default
37
+ * (ON from source, OFF for installs) — same rule as the index.html route.
38
+ * PI_WEB_DEV_CACHE=0/1 overrides either way. */
39
+ defaultDevNoCache() {
40
+ const env = process.env.PI_WEB_DEV_CACHE;
41
+ if (env !== undefined)
42
+ return env !== "0";
43
+ let dir = dirname(fileURLToPath(import.meta.url));
44
+ for (let i = 0; i < 4; i++) {
45
+ if (existsSync(join(dir, ".git")))
46
+ return true;
47
+ dir = dirname(dir);
48
+ }
49
+ return false;
50
+ }
35
51
  get reviewPrefs() {
36
52
  return {
37
53
  reviewPrompt: this.settings.reviewPrompt,
@@ -207,6 +223,8 @@ export class SettingsService {
207
223
  editSoftEnabled: legacyTools.editSoftEnabled,
208
224
  questionnaireEnabled: legacyTools.questionnaireEnabled,
209
225
  goalModeEnabled: this.settings.goalModeEnabled,
226
+ devNoCache: this.settings.devNoCache ?? this.defaultDevNoCache(),
227
+ autoReload: this.settings.autoReload ?? this.defaultDevNoCache(),
210
228
  thinkingWrap: this.settings.thinkingWrap,
211
229
  toolsWrap: this.settings.toolsWrap,
212
230
  visionBridgeEnabled: this.settings.visionBridgeEnabled,
@@ -216,6 +234,7 @@ export class SettingsService {
216
234
  reviewPrompt: this.settings.reviewPrompt,
217
235
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
218
236
  disabledPlugins: [...(this.settings.disabledPlugins ?? [])],
237
+ uiLayout: normalizeUiLayout(this.settings.uiLayout),
219
238
  skillsFullText: [...normalizeSkillList(this.settings.skillsFullText)],
220
239
  // The composed system prompt actually in effect (read-only view).
221
240
  effectiveSystemPrompt: promptSnap.full,
@@ -330,6 +349,10 @@ export class SettingsService {
330
349
  if (partial.disabledPlugins !== undefined) {
331
350
  this.settings.disabledPlugins = partial.disabledPlugins;
332
351
  }
352
+ // UI 布局偏好只是渲染层的事(顶栏/底栏/右键菜单由前端拼),同样不需 reload。
353
+ if (partial.uiLayout !== undefined) {
354
+ this.settings.uiLayout = normalizeUiLayout(partial.uiLayout);
355
+ }
333
356
  // 统一工具开关:新字段优先;只给遗留单开关时折回新字段。两边写完再由
334
357
  // deriveLegacy 回填遗留别名,保证内存/推送/落盘三处一致。
335
358
  if (partial.disabledAgentTools !== undefined) {
@@ -360,6 +383,12 @@ export class SettingsService {
360
383
  if (partial.goalModeEnabled !== undefined) {
361
384
  this.settings.goalModeEnabled = partial.goalModeEnabled;
362
385
  }
386
+ if (partial.devNoCache !== undefined) {
387
+ this.settings.devNoCache = partial.devNoCache;
388
+ }
389
+ if (partial.autoReload !== undefined) {
390
+ this.settings.autoReload = partial.autoReload;
391
+ }
363
392
  if (partial.thinkingWrap !== undefined) {
364
393
  this.settings.thinkingWrap = partial.thinkingWrap;
365
394
  }
@@ -501,8 +530,12 @@ export class SettingsService {
501
530
  // 全文注入名单随预设走;旧预设缺字段时保留当前值。
502
531
  skillsFullText: normalizeSkillList(p.skillsFullText ?? this.settings.skillsFullText),
503
532
  // 纯 UI 偏好不进预设——保留当前值。
533
+ devNoCache: this.settings.devNoCache,
534
+ autoReload: this.settings.autoReload,
504
535
  thinkingWrap: this.settings.thinkingWrap,
505
536
  toolsWrap: this.settings.toolsWrap,
537
+ // UI 布局偏好也不进预设——保留当前值。
538
+ uiLayout: normalizeUiLayout(this.settings.uiLayout),
506
539
  // Presets don't capture vision-bridge prefs — keep the current ones.
507
540
  visionBridgeEnabled: this.settings.visionBridgeEnabled,
508
541
  visionBridgeModel: this.settings.visionBridgeModel,
@@ -673,7 +673,12 @@ export class TerminalManager {
673
673
  // a NEW live PTY needs a free slot under the cap. Reusing an exited name
674
674
  // starts a fresh PTY and discards its old history — but only after the
675
675
  // slot check, so a rejected request keeps its retained output.
676
- if (!this.ensureSpawnAllowed(id, opts?.agentBash))
676
+ // 重建已退出终端时继承其原有身份:前端刷新/重挂载会为 history 里的每条
677
+ // 记录重发 terminal_create,旧消息体不带 agentBash——不继承的话 AI 终端
678
+ // 会被降级为用户终端并占满 16 个名额(issue #147)。history.delete 位于
679
+ // 检查之后,故此处仍可取到旧 entry。
680
+ const priorAgentBash = opts?.agentBash ?? this.history.get(id)?.agentBash ?? false;
681
+ if (!this.ensureSpawnAllowed(id, priorAgentBash))
677
682
  return null;
678
683
  this.history.delete(id);
679
684
  const safeCwd = this.safeCwd(cwd || fallbackCwd);
@@ -681,7 +686,7 @@ export class TerminalManager {
681
686
  this.fail(id, pick(this.lang?.() ?? "en", "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace", "terminals.cwd.outside.workspace"), "Terminal cwd must be inside the current workspace");
682
687
  return null;
683
688
  }
684
- if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash, opts?.locale)) {
689
+ if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, priorAgentBash, opts?.locale)) {
685
690
  this.maybeEmitTccHint(id);
686
691
  this.emitList();
687
692
  return this.info(this.terms.get(id));