pi-web-ui 0.31.0 → 0.34.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.
@@ -11,12 +11,13 @@
11
11
  * so reconnects just re-request a snapshot.
12
12
  */
13
13
  import { spawn } from "node:child_process";
14
- import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
14
+ import { existsSync, readFileSync, rmSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
15
15
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
18
18
  import { Type } from "typebox";
19
19
  import { BgServerTracker } from "./bg-servers.js";
20
+ import { syncPluginToolsIntoSession } from "./plugins.js";
20
21
  import { SettingsService } from "./settings-service.js";
21
22
  import { GoalService } from "./goal-service.js";
22
23
  import { SlashCommandsService, parseSlash } from "./slash-commands.js";
@@ -24,7 +25,7 @@ import { ModelAdminService } from "./model-admin.js";
24
25
  import { FilesService, workspacePath } from "./files-service.js";
25
26
  import { isExtensionDisabled, ClientStateStore, } from "./client-state.js";
26
27
  import { saveUpload } from "./uploads.js";
27
- import { makePersistentTerminalTools, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
28
+ import { makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
28
29
  import { WebUIContext } from "./webui-context.js";
29
30
  import { buildAttachmentMessages, parseModelSpec, } from "./attachments.js";
30
31
  import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
@@ -126,6 +127,48 @@ function makeKillableBashTool(cwd, kills) {
126
127
  execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
127
128
  };
128
129
  }
130
+ /**
131
+ * 动态分流 bash:调用时按设置决定走哪套实现——「终端接管 bash」开关因此
132
+ * 即时生效(customTools 在 runtime 创建时固定,不能在创建时二选一)。
133
+ */
134
+ function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
135
+ return {
136
+ ...killable,
137
+ execute: (id, params, signal, onUpdate, ctx) => (useTerminal() ? terminalBacked : killable).execute(id, params, signal, onUpdate, ctx),
138
+ };
139
+ }
140
+ /**
141
+ * 插件结构化工具 → SDK ToolDefinition。
142
+ * execute 返回值宽容处理:{content,details} 原样收编;字符串/对象包成文本块。
143
+ */
144
+ function pluginToolToDefinition(tool) {
145
+ const normalize = (result) => {
146
+ if (result &&
147
+ typeof result === "object" &&
148
+ Array.isArray(result.content)) {
149
+ return result;
150
+ }
151
+ const text = typeof result === "string" ? result : JSON.stringify(result ?? null, null, 2);
152
+ return { content: [{ type: "text", text }] };
153
+ };
154
+ return {
155
+ name: tool.name,
156
+ label: tool.label ?? tool.name,
157
+ description: tool.description,
158
+ promptSnippet: tool.promptSnippet,
159
+ promptGuidelines: tool.promptGuidelines,
160
+ parameters: (tool.parameters ?? {
161
+ type: "object",
162
+ properties: {},
163
+ }),
164
+ execute: async (toolCallId, params, signal, onUpdate) => {
165
+ const raw = await tool.execute(toolCallId, params, signal, onUpdate
166
+ ? (partial) => onUpdate(normalize(partial))
167
+ : undefined);
168
+ return normalize(raw);
169
+ },
170
+ };
171
+ }
129
172
  /**
130
173
  * Cheap per-message discriminator for the serialization cache key. Persisted
131
174
  * message content never changes, so this is stable across snapshots, while
@@ -272,6 +315,13 @@ export class ClientSession {
272
315
  flushSnapshot: () => this.flushSnapshot(),
273
316
  isDisposed: () => this.disposed,
274
317
  });
318
+ /** index.ts 注入(经 AgentService 拷贝到每个新会话):把 SDK 工具执行事件转发给
319
+ * 插件(PluginManager.emitToolEvent)。未设置时不做任何事。 */
320
+ onToolEvent = undefined;
321
+ /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
322
+ pluginToolsProvider = undefined;
323
+ /** 上一轮注入会话的插件工具名集合(用于检测注销/移除)。 */
324
+ appliedPluginToolNames = new Set();
275
325
  /** The active conversation (all session operations target it). */
276
326
  get conv() {
277
327
  const conv = this.convs.get(this.activeId);
@@ -299,7 +349,68 @@ export class ClientSession {
299
349
  return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
300
350
  }
301
351
  makeTerminalManager(conversationId, cwd) {
302
- return new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
352
+ const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
353
+ // 终端活力检测:AI 触碰过的终端静默 ≥ 阈值(PI_WEB_TERMINAL_IDLE_MS,
354
+ // 默认 15s)且该对话正在运行时,注入一条 steer 消息唤醒 AI 去检查。
355
+ mgr.onAgentIdle = (terminalId, idleMs, title) => this.notifyTerminalIdle(conversationId, terminalId, idleMs, title);
356
+ return mgr;
357
+ }
358
+ /** 终端活力提醒:仅在该对话正在流式运行时注入(sendUserMessage 在流式中
359
+ * 即 steer 语义——当前回合结算后送达,agent 立即响应);空闲时不打扰。
360
+ * 一次性语义由 TerminalManager 保证(触发后解除武装,agent 再次触碰才
361
+ * 重新计时),不会反复刷屏。 */
362
+ notifyTerminalIdle(conversationId, terminalId, idleMs, title) {
363
+ const conv = this.convs.get(conversationId);
364
+ if (!conv || this.disposed)
365
+ return;
366
+ if (!conv.runtime.session.isStreaming)
367
+ return;
368
+ const seconds = Math.max(1, Math.round(idleMs / 1000));
369
+ void conv.runtime.session
370
+ .sendUserMessage(`(系统自动提醒:你启动的终端「${title}」已连续 ${seconds} 秒没有任何新输出。` +
371
+ `进程可能在等待输入、卡住或已挂起。请用 terminal_read 查看它的当前状态;` +
372
+ `若在等交互就用 terminal_input / terminal_key 回应;确认不再需要就 terminal_close 关掉它。)`)
373
+ .catch(() => {
374
+ // best effort —— 注入失败不影响终端本身
375
+ });
376
+ }
377
+ /**
378
+ * 终端接管的 bash 静默转后台后的完成通知:命令真正结束时主动告诉 AI。
379
+ * 流式中 → sendUserMessage(steer,立即唤醒处理);空闲时 → sendCustomMessage
380
+ * nextTurn 排队(不唤醒 agent、不耗 token,下次对话自动带上)。
381
+ */
382
+ notifyTerminalBashDone(terminals, info) {
383
+ const conv = [...this.convs.values()].find((c) => c.terminals === terminals);
384
+ if (!conv || this.disposed)
385
+ return;
386
+ let tail = "";
387
+ try {
388
+ const end = terminals.endCursor(info.terminalId);
389
+ if (end !== null) {
390
+ tail = terminals.read(info.terminalId, Math.max(0, end - 4000))?.data ?? "";
391
+ }
392
+ }
393
+ catch {
394
+ // 终端可能已被关闭
395
+ }
396
+ const exitText = info.exitCode === null ? "终端已关闭" : `退出码 ${info.exitCode}`;
397
+ const cmdShort = info.command.length > 120 ? `${info.command.slice(0, 120)}…` : info.command;
398
+ const text = `(系统:你之前在终端 ${info.terminalId} 后台运行的命令已结束(${exitText}):${cmdShort}\n` +
399
+ `最后输出:\n${stripAnsi(tail).trim() || "(无输出)"})`;
400
+ const session = conv.runtime.session;
401
+ if (session.isStreaming) {
402
+ void session.sendUserMessage(text).catch(() => { });
403
+ }
404
+ else {
405
+ // 空闲时不唤醒 agent——排队为 nextTurn 上下文,下次对话自动可见。
406
+ void session
407
+ .sendCustomMessage({
408
+ customType: "terminal-bash-done",
409
+ content: [{ type: "text", text }],
410
+ display: true,
411
+ })
412
+ .catch(() => { });
413
+ }
303
414
  }
304
415
  emitTerminal(conversationId, msg) {
305
416
  // Background conversations keep collecting output in their own PTY buffer.
@@ -356,6 +467,20 @@ export class ClientSession {
356
467
  }
357
468
  return "";
358
469
  }
470
+ /** The FULL system prompt actually in effect right now (AgentSession getter,
471
+ * includes the append/replace override + auto-appended sections like
472
+ * project context, skills and tool guidance). Read-only view source for
473
+ * the settings panel. */
474
+ effectiveSystemPrompt() {
475
+ try {
476
+ const sp = this.session.systemPrompt;
477
+ return typeof sp === "string" ? sp : "";
478
+ }
479
+ catch {
480
+ // Session not ready yet.
481
+ return "";
482
+ }
483
+ }
359
484
  /** Web-facing extension UI context (widgets, notifications). */
360
485
  webUi = new WebUIContext((msg) => this.emit(msg));
361
486
  widgetsTimer = null;
@@ -375,6 +500,11 @@ export class ClientSession {
375
500
  /** Messages array as of the last emitted snapshot/delta — identity-walked
376
501
  * against the current array to detect append-only growth. */
377
502
  emittedMessages = null;
503
+ /** Conversation whose messages emittedMessages belongs to. A conversation
504
+ * switch (set_cwd / new_chat / switch_*) must fall back to a FULL snapshot:
505
+ * two empty conversations have identical (empty) arrays, so the identity
506
+ * walk alone would misread the switch as "nothing changed" → delta. */
507
+ emittedConvId = null;
378
508
  /** snapRev value at which emittedMessages was captured. */
379
509
  emittedRev = 0;
380
510
  /**
@@ -420,6 +550,7 @@ export class ClientSession {
420
550
  await this.pushSlashCommands();
421
551
  },
422
552
  effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
553
+ effectiveSystemPrompt: () => this.effectiveSystemPrompt(),
423
554
  });
424
555
  this.goalSvc = new GoalService({
425
556
  clientId,
@@ -550,8 +681,21 @@ export class ClientSession {
550
681
  // 覆盖),执行时把自己的 AbortController 注册进客户端集合——
551
682
  // abortBash() 只杀这些命令,agent run 与对话继续。
552
683
  customTools: [
553
- makeKillableBashTool(effectiveCwd, this.bashKills),
684
+ // bash 双实现动态分流:「终端接管」开启时命令跑进持久可见终端
685
+ // (保留 shell 状态、静默自动转后台),关闭时是原生 killable bash。
686
+ makeAdaptiveBashTool(makeKillableBashTool(effectiveCwd, this.bashKills), makeTerminalBashTool(terminals, {
687
+ cwd: effectiveCwd,
688
+ idleMs: () => this.settingsSvc.current.terminalBash
689
+ ? Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) ||
690
+ 0)
691
+ : 0,
692
+ kills: this.bashKills,
693
+ notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
694
+ }), () => this.settingsSvc.current.terminalBash),
554
695
  ...makePersistentTerminalTools(terminals, effectiveCwd),
696
+ // 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
697
+ // refreshPluginTools 动态补入已有会话)。
698
+ ...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
555
699
  ],
556
700
  });
557
701
  // 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
@@ -797,6 +941,8 @@ export class ClientSession {
797
941
  this.bg.snapshotBefore();
798
942
  }
799
943
  this.armToolWatchdog(conv, event.toolCallId);
944
+ // 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
945
+ this.onToolEvent?.({ phase: "start", toolName: event.toolName, conversationId: conv.id });
800
946
  break;
801
947
  }
802
948
  case "tool_execution_end": {
@@ -808,6 +954,14 @@ export class ClientSession {
808
954
  if (event.toolName === "bash")
809
955
  void this.bg.trackAfterBash();
810
956
  const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
957
+ // 插件扩展点:工具结束执行(带耗时与错误标志)。
958
+ this.onToolEvent?.({
959
+ phase: "end",
960
+ toolName: event.toolName,
961
+ conversationId: conv.id,
962
+ ...(durationMs !== undefined ? { durationMs } : {}),
963
+ isError: event.isError,
964
+ });
811
965
  // The bash tool does not put its exit code in result.details — on
812
966
  // failure it throws "Command exited with code N" and the agent
813
967
  // wraps that into the error result text. Try details first (future
@@ -1107,7 +1261,10 @@ export class ClientSession {
1107
1261
  return;
1108
1262
  const cur = this.currentMessages();
1109
1263
  const prev = this.emittedMessages;
1110
- let incremental = !forceFull && prev !== null && prev.length <= cur.length;
1264
+ let incremental = !forceFull &&
1265
+ prev !== null &&
1266
+ this.emittedConvId === this.activeId &&
1267
+ prev.length <= cur.length;
1111
1268
  if (incremental && prev) {
1112
1269
  for (let i = 0; i < prev.length; i++) {
1113
1270
  if (prev[i] !== cur[i]) {
@@ -1120,6 +1277,7 @@ export class ClientSession {
1120
1277
  if (incremental && prev) {
1121
1278
  const baseRev = this.emittedRev;
1122
1279
  this.emittedMessages = cur;
1280
+ this.emittedConvId = this.activeId;
1123
1281
  this.emittedRev = rev;
1124
1282
  this.emit({
1125
1283
  type: "snapshot_delta",
@@ -1132,6 +1290,7 @@ export class ClientSession {
1132
1290
  }
1133
1291
  else {
1134
1292
  this.emittedMessages = cur;
1293
+ this.emittedConvId = this.activeId;
1135
1294
  this.emittedRev = rev;
1136
1295
  this.emit({
1137
1296
  type: "snapshot",
@@ -1238,13 +1397,6 @@ export class ClientSession {
1238
1397
  }
1239
1398
  return 0;
1240
1399
  }
1241
- /** True once updateApp succeeded — the process must restart to run new code. */
1242
- pendingRestart = false;
1243
- /**
1244
- * Set by index.ts: called after a successful self-update; returns whether
1245
- * the process is going to restart itself (so the notice can say so).
1246
- */
1247
- onUpdateReady = undefined;
1248
1400
  /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
1249
1401
  onQuit = undefined;
1250
1402
  /** Ask the npm registry for the latest pi-web-ui version and report it. */
@@ -1269,7 +1421,6 @@ export class ClientSession {
1269
1421
  latest,
1270
1422
  latestPublishedAt,
1271
1423
  upToDate,
1272
- pendingRestart: this.pendingRestart,
1273
1424
  });
1274
1425
  }
1275
1426
  catch (err) {
@@ -1279,115 +1430,10 @@ export class ClientSession {
1279
1430
  latest: null,
1280
1431
  latestPublishedAt: null,
1281
1432
  upToDate: false,
1282
- pendingRestart: this.pendingRestart,
1283
1433
  error: `检查更新失败:${err.message}`,
1284
1434
  });
1285
1435
  }
1286
1436
  }
1287
- /**
1288
- * After `npm i -g`, confirm the on-disk package this process serves from
1289
- * actually changed to the new version and is complete. Windows npm updates
1290
- * can fail partway (locked files / Defender / npm rollback) and leave the
1291
- * global install without its bin links — restarting into that is a silent
1292
- * crash (web/dist missing + `pi-web-ui` no longer on PATH). Returns null
1293
- * when OK, else a human-readable problem description.
1294
- */
1295
- static verifyGlobalInstall() {
1296
- try {
1297
- const here = dirname(fileURLToPath(import.meta.url));
1298
- const pkgRoot = resolve(here, "..", "..");
1299
- const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
1300
- if (!pkg.version || pkg.version === ClientSession.currentAppVersion()) {
1301
- return `安装目录版本未变化(${pkg.version ?? "未知"})`;
1302
- }
1303
- if (!existsSync(join(pkgRoot, "web", "dist", "index.html"))) {
1304
- return "web/dist/index.html 缺失(前端产物未安装完整)";
1305
- }
1306
- if (!existsSync(join(pkgRoot, "bin", "pi-web-ui.mjs"))) {
1307
- return "bin/pi-web-ui.mjs 缺失";
1308
- }
1309
- if (process.platform === "win32") {
1310
- const prefix = dirname(process.execPath);
1311
- const hasShim = existsSync(join(prefix, "pi-web-ui.cmd")) ||
1312
- existsSync(join(prefix, "pi-web-ui.ps1"));
1313
- if (!hasShim)
1314
- return "pi-web-ui 命令入口(bin 链接)未生成";
1315
- }
1316
- return null;
1317
- }
1318
- catch (err) {
1319
- return `读取安装目录失败:${err.message}`;
1320
- }
1321
- }
1322
- /** npm i -g pi-web-ui@latest — the new code only takes effect after a restart. */
1323
- async updateApp() {
1324
- try {
1325
- this.emit({
1326
- type: "notice",
1327
- level: "info",
1328
- text: "正在更新 pi-web-ui(npm i -g pi-web-ui@latest)…",
1329
- });
1330
- const { code, out } = await this.runAsync("npm", ["i", "-g", "pi-web-ui@latest"], 180_000);
1331
- if (code !== 0) {
1332
- this.emit({
1333
- type: "update_result",
1334
- ok: false,
1335
- detail: `npm i 失败(${code ?? "timeout"}):${out.slice(0, 400)}`,
1336
- });
1337
- this.emit({
1338
- type: "notice",
1339
- level: "error",
1340
- text: `更新 pi-web-ui 失败(${code ?? "timeout"}):${out.slice(0, 300)}`,
1341
- });
1342
- return;
1343
- }
1344
- // npm reported success, but on Windows the replacement can be partial
1345
- // (locked files, rollback) — restarting into a broken install is a
1346
- // crash with no hint. Verify before handing over.
1347
- const problem = ClientSession.verifyGlobalInstall();
1348
- if (problem) {
1349
- this.emit({
1350
- type: "update_result",
1351
- ok: false,
1352
- detail: `npm i 成功但安装不完整(${problem})。请手动执行 npm i -g pi-web-ui@latest 修复后再重启服务。`,
1353
- });
1354
- this.emit({
1355
- type: "notice",
1356
- level: "error",
1357
- text: `更新未完整生效(${problem})。请手动执行 npm i -g pi-web-ui@latest 修复`,
1358
- });
1359
- return;
1360
- }
1361
- this.pendingRestart = true;
1362
- this.emit({
1363
- type: "update_result",
1364
- ok: true,
1365
- detail: out.slice(0, 400),
1366
- });
1367
- const autoRestart = this.onUpdateReady?.() ?? false;
1368
- this.emit({
1369
- type: "notice",
1370
- level: "info",
1371
- text: autoRestart
1372
- ? "✅ 已更新 pi-web-ui,正在自动重启…"
1373
- : "✅ 已更新 pi-web-ui,重启服务后生效(pi-web-ui server restart)",
1374
- });
1375
- }
1376
- catch (err) {
1377
- this.emit({
1378
- type: "update_result",
1379
- ok: false,
1380
- detail: String(err),
1381
- });
1382
- this.emit({
1383
- type: "notice",
1384
- level: "error",
1385
- text: `更新 pi-web-ui 失败:${err.message}`,
1386
- });
1387
- }
1388
- // Re-check so the UI reflects the new state (pendingRestart included).
1389
- void this.checkUpdate();
1390
- }
1391
1437
  async installPiAgent() {
1392
1438
  try {
1393
1439
  mkdirSync(this.agentDir, { recursive: true });
@@ -1491,6 +1537,12 @@ export class ClientSession {
1491
1537
  refreshProviderModels(providerId, reqId) {
1492
1538
  return this.modelAdmin.refreshProviderModels(providerId, reqId);
1493
1539
  }
1540
+ /** Copy a built-in provider into an editable custom-provider draft
1541
+ * (clone_provider_result) — lets the user run a second API key without
1542
+ * overwriting the built-in one. */
1543
+ cloneProvider(providerId, reqId) {
1544
+ return this.modelAdmin.cloneProvider(providerId, reqId);
1545
+ }
1494
1546
  saveModelConfig(providerId, config) {
1495
1547
  return this.modelAdmin.saveModelConfig(providerId, config);
1496
1548
  }
@@ -1551,6 +1603,24 @@ export class ClientSession {
1551
1603
  // Session 未就绪——下次创建/reload 会再应用。
1552
1604
  }
1553
1605
  }
1606
+ /** 把插件 AI 工具同步进一个已存在的会话(新增/更新/移除)。
1607
+ * 实际 diff 逻辑在 plugins.ts 的 syncPluginToolsIntoSession(可单测)。 */
1608
+ syncPluginTools(session) {
1609
+ try {
1610
+ const defs = (this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition);
1611
+ const next = syncPluginToolsIntoSession(session, defs, this.appliedPluginToolNames);
1612
+ if (next)
1613
+ this.appliedPluginToolNames = new Set(next);
1614
+ }
1615
+ catch (err) {
1616
+ console.error("[plugins] sync tools to session failed:", err);
1617
+ }
1618
+ }
1619
+ /** index.ts 经 pluginMgr.onAgentToolsChanged 触发:把插件 AI 工具推入全部会话。 */
1620
+ refreshPluginTools() {
1621
+ for (const conv of this.convs.values())
1622
+ this.syncPluginTools(conv.session);
1623
+ }
1554
1624
  async applySettingsReload() {
1555
1625
  // 兼容旧入口:reload + 刷目录在宿主回调里完成
1556
1626
  return this.settingsSvc.applyRuntime();
@@ -2040,6 +2110,48 @@ export class ClientSession {
2040
2110
  this.emit({ type: "sessions", sessions: [] });
2041
2111
  }
2042
2112
  }
2113
+ /** Remove an entry from the client's recent-project list (UI state only). */
2114
+ async removeProject(path) {
2115
+ this.stateStore.removeProject(this.clientId, path);
2116
+ await this.pushProjects();
2117
+ }
2118
+ /** Permanently delete a persisted session transcript file (history list ✕). */
2119
+ async deleteSession(path) {
2120
+ try {
2121
+ const abs = resolve(path);
2122
+ // Guardrail: only transcripts under the shared sessions root
2123
+ // (<agentDir>/sessions/) may be deleted — never arbitrary files.
2124
+ const sessionsRoot = resolve(this.agentDir, "sessions");
2125
+ if (!abs.startsWith(sessionsRoot + sep)) {
2126
+ this.emit({
2127
+ type: "notice",
2128
+ level: "error",
2129
+ text: "只能删除会话目录中的对话记录",
2130
+ });
2131
+ return;
2132
+ }
2133
+ // Refuse to pull the file out from under a live conversation.
2134
+ for (const conv of this.convs.values()) {
2135
+ if (conv.session.sessionFile === abs) {
2136
+ this.emit({
2137
+ type: "notice",
2138
+ level: "warning",
2139
+ text: "该对话正在使用中,请先切换到其他对话再删除",
2140
+ });
2141
+ return;
2142
+ }
2143
+ }
2144
+ rmSync(abs, { force: true });
2145
+ await this.refreshSessions();
2146
+ }
2147
+ catch (err) {
2148
+ this.emit({
2149
+ type: "notice",
2150
+ level: "error",
2151
+ text: `删除会话失败:${err.message}`,
2152
+ });
2153
+ }
2154
+ }
2043
2155
  /** Switch the active session to a persisted one (from listSessions). */
2044
2156
  async switchSession(path) {
2045
2157
  if (this.quiesceBlocked())
@@ -2160,6 +2272,7 @@ export class ClientSession {
2160
2272
  async pushProjects() {
2161
2273
  try {
2162
2274
  const saved = this.stateStore.get(this.clientId);
2275
+ const removedProjects = new Set(this.stateStore.getRemovedProjects(this.clientId));
2163
2276
  const map = new Map();
2164
2277
  for (const p of saved.projects)
2165
2278
  map.set(p.path, p.lastUsed);
@@ -2173,9 +2286,10 @@ export class ClientSession {
2173
2286
  }
2174
2287
  }
2175
2288
  // Only keep directories that still exist — a deleted/unmounted workspace
2176
- // is useless in the picker.
2289
+ // is useless in the picker. Tombstoned entries (explicitly removed by
2290
+ // the user) stay hidden even though session files still mention them.
2177
2291
  const projects = [...map.entries()]
2178
- .filter(([path]) => existsSync(path))
2292
+ .filter(([path]) => !removedProjects.has(path) && existsSync(path))
2179
2293
  .map(([path, lastUsed]) => ({ path, lastUsed }))
2180
2294
  .sort((a, b) => b.lastUsed - a.lastUsed)
2181
2295
  .slice(0, 20);
@@ -2473,6 +2587,10 @@ export class ClientSession {
2473
2587
  }
2474
2588
  export class AgentService {
2475
2589
  cwd;
2590
+ /** index.ts 注入:SDK 工具执行事件的插件转发钩子,attach 时拷贝到每个新会话。 */
2591
+ onToolEvent = undefined;
2592
+ /** index.ts 注入:读取插件当前注册的 AI 工具(attach 时拷贝到每个新会话)。 */
2593
+ pluginToolsProvider = undefined;
2476
2594
  clients = new Map();
2477
2595
  /** Quiesce (draining) state — the service refuses NEW work (prompts, forks,
2478
2596
  * session resumes, new clients) so a deploy/upgrade/backup can stop cleanly
@@ -2485,11 +2603,6 @@ export class AgentService {
2485
2603
  socketCount = 0;
2486
2604
  pending = new Map();
2487
2605
  stateStore;
2488
- /**
2489
- * Set by index.ts: called by a client session after a successful
2490
- * self-update; returns whether the process will restart itself.
2491
- */
2492
- onUpdateReady = undefined;
2493
2606
  /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
2494
2607
  onQuit = undefined;
2495
2608
  constructor(cwd, stateFile) {
@@ -2603,11 +2716,17 @@ export class AgentService {
2603
2716
  cs.notifyInterrupted(this.stateStore.takeInterrupted(clientId));
2604
2717
  cs.attachSink(send);
2605
2718
  // Forward hooks (set once by index.ts) to every session.
2606
- cs.onUpdateReady = this.onUpdateReady;
2607
2719
  cs.onQuit = this.onQuit;
2720
+ cs.onToolEvent = this.onToolEvent;
2721
+ cs.pluginToolsProvider = this.pluginToolsProvider;
2608
2722
  cs.isQuiesced = () => this.quiesced;
2609
2723
  return cs;
2610
2724
  }
2725
+ /** 插件 AI 工具集合变化(注册/注销)时由 index.ts 触发:推送到所有客户端的全部会话。 */
2726
+ applyPluginAgentTools() {
2727
+ for (const cs of this.clients.values())
2728
+ cs.refreshPluginTools();
2729
+ }
2611
2730
  /** Remove a socket from a client's broadcast set (called on socket close). */
2612
2731
  detach(clientId, send) {
2613
2732
  this.clients.get(clientId)?.detachSink(send);
@@ -97,8 +97,31 @@ export class ClientStateStore {
97
97
  { path: cwd, lastUsed: now },
98
98
  ...state.projects.filter((p) => p.path !== cwd),
99
99
  ].slice(0, 30);
100
+ // Opening the workspace again clears its removal tombstone.
101
+ if (state.removedProjects?.length) {
102
+ state.removedProjects = state.removedProjects.filter((p) => p !== cwd);
103
+ }
104
+ this.save();
105
+ }
106
+ /** Drop one workspace from the recent-project list (user-requested removal).
107
+ * Records a tombstone too: pushProjects() re-discovers cwds from session
108
+ * files on every listing, so without it the entry would instantly reappear. */
109
+ removeProject(clientId, cwd) {
110
+ const all = this.load();
111
+ const state = (all[clientId] ??= { projects: [] });
112
+ state.projects = state.projects.filter((p) => p.path !== cwd);
113
+ if (state.lastCwd === cwd)
114
+ delete state.lastCwd;
115
+ const removed = new Set(state.removedProjects ?? []);
116
+ removed.add(cwd);
117
+ state.removedProjects = [...removed];
100
118
  this.save();
101
119
  }
120
+ /** Tombstoned projects (explicitly removed by the user) for filtering the
121
+ * merged recent-project list. */
122
+ getRemovedProjects(clientId) {
123
+ return this.load()[clientId]?.removedProjects ?? [];
124
+ }
102
125
  /** Last-used goal/review prefs for a client, or undefined if never set. */
103
126
  getGoalPrefs(clientId) {
104
127
  const s = this.load()[clientId];
@@ -152,12 +175,15 @@ export class ClientStateStore {
152
175
  disabledSkills: s?.settings?.disabledSkills ?? [],
153
176
  disabledExtensions: s?.settings?.disabledExtensions ?? [],
154
177
  terminalToolsEnabled: s?.settings?.terminalToolsEnabled ?? true,
178
+ terminalBash: s?.settings?.terminalBash ?? false,
179
+ terminalBashIdleMs: s?.settings?.terminalBashIdleMs ?? 15_000,
155
180
  visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
156
181
  visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
157
182
  visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
158
183
  visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
159
184
  reviewPrompt: s?.settings?.reviewPrompt ?? "",
160
185
  reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
186
+ disabledPlugins: s?.settings?.disabledPlugins ?? [],
161
187
  };
162
188
  }
163
189
  /** Persist the client's settings-panel state (partial merge). */
@@ -171,6 +197,8 @@ export class ClientStateStore {
171
197
  disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
172
198
  disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
173
199
  terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
200
+ terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
201
+ terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
174
202
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
175
203
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
176
204
  visionBridgePromptMode: settings.visionBridgePromptMode ??
@@ -179,6 +207,7 @@ export class ClientStateStore {
179
207
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
180
208
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
181
209
  reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
210
+ disabledPlugins: settings.disabledPlugins ?? cur.disabledPlugins ?? [],
182
211
  };
183
212
  this.save();
184
213
  }