pi-web-ui 0.44.2 → 0.46.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.
@@ -10,7 +10,7 @@
10
10
  * snapshots. The frontend is snapshot-driven (server is the source of truth),
11
11
  * so reconnects just re-request a snapshot.
12
12
  */
13
- import { spawn } from "node:child_process";
13
+ import { spawn, spawnSync } from "node:child_process";
14
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";
@@ -80,14 +80,14 @@ export class QuiesceRejectedError extends Error {
80
80
  * programs wait for input that never comes. Legacy Chinese files are often
81
81
  * GBK/GB2312 — read them with the right encoding, never paste mojibake into
82
82
  * reasoning/answers. */
83
- const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
84
-
85
-
86
-
87
- - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
88
- - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
89
- - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
90
-
83
+ const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
84
+
85
+
86
+
87
+ - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
88
+ - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
89
+ - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
90
+
91
91
  Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
92
92
  /**
93
93
  * Killable bash tool: wraps the SDK bash tool with operations that register
@@ -550,6 +550,8 @@ export class ClientSession {
550
550
  flushSnapshot: () => this.flushSnapshot(),
551
551
  isDisposed: () => this.disposed,
552
552
  getSession: () => this.session,
553
+ cwd: () => this.cwd,
554
+ agentDir: () => this.agentDir,
553
555
  isStreaming: () => this.session.isStreaming,
554
556
  reloadSession: async () => {
555
557
  await this.session.reload();
@@ -1028,6 +1030,7 @@ export class ClientSession {
1028
1030
  // (new chat + first message, completed turns, compaction, etc.).
1029
1031
  case "agent_end": {
1030
1032
  this.scheduleSessionsRefresh();
1033
+ this.refreshConversationTitle(conv);
1031
1034
  // Manual interrupt (Stop button / abort): the last assistant message
1032
1035
  // carries stopReason "aborted". A half-finished run should NOT be
1033
1036
  // reviewed (it would fail and inject a revision, only to be stopped
@@ -1057,6 +1060,7 @@ export class ClientSession {
1057
1060
  }
1058
1061
  case "entry_appended":
1059
1062
  this.scheduleSessionsRefresh();
1063
+ this.refreshConversationTitle(conv);
1060
1064
  break;
1061
1065
  case "message_update": {
1062
1066
  // Live assistant-message increment, deliberately OUTSIDE the snapshot
@@ -1123,6 +1127,21 @@ export class ClientSession {
1123
1127
  }, 800);
1124
1128
  // pushSessions no-ops unless the client opted in via list_sessions.
1125
1129
  }
1130
+ /** Refresh a conversation's title from its persisted first user message
1131
+ * while it is still unnamed. Runs off the event stream (entry_appended /
1132
+ * agent_end) rather than the prompt() call site, so ANY entry path that
1133
+ * lands a message names the chat the moment it is persisted — a rename
1134
+ * skipped by the prompt-start fast path (e.g. a concurrent switch) is
1135
+ * recovered here instead of leaving a permanent “新对话”. */
1136
+ refreshConversationTitle(conv) {
1137
+ if (conv.title !== DEFAULT_CONV_TITLE)
1138
+ return;
1139
+ const title = conversationTitle(conv.session);
1140
+ if (title === DEFAULT_CONV_TITLE)
1141
+ return;
1142
+ conv.title = title;
1143
+ this.emitConversations();
1144
+ }
1126
1145
  /** Serialize a persisted message with a STABLE id + cached object reference. */
1127
1146
  serializeCached(m) {
1128
1147
  const conv = this.conv;
@@ -1251,6 +1270,7 @@ export class ClientSession {
1251
1270
  tools: state.tools.map((t) => t.name),
1252
1271
  version: ++this.version,
1253
1272
  piConfigured: this.isPiConfigured(),
1273
+ piAgentInstalled: this.isPiCliInstalled(),
1254
1274
  stats,
1255
1275
  };
1256
1276
  }
@@ -1336,6 +1356,38 @@ export class ClientSession {
1336
1356
  this.piCheckCache = { at: now, configured };
1337
1357
  return configured;
1338
1358
  }
1359
+ /**
1360
+ * Whether the pi CLI binary is installed and runnable (`pi --version`
1361
+ * probe). Cached machine-wide (same binary for every client) for 10s —
1362
+ * the check is only rerun after install or when the cache expires.
1363
+ */
1364
+ static piCliProbe = null;
1365
+ static PI_CLI_PROBE_TTL_MS = 10_000;
1366
+ isPiCliInstalled() {
1367
+ const now = Date.now();
1368
+ const cached = ClientSession.piCliProbe;
1369
+ if (cached && now - cached.at < ClientSession.PI_CLI_PROBE_TTL_MS)
1370
+ return cached.installed;
1371
+ let installed = false;
1372
+ try {
1373
+ const res = spawnSync("pi", ["--version"], {
1374
+ timeout: 5000,
1375
+ stdio: "ignore",
1376
+ // Windows: `pi` resolves to a pi.cmd shim — spawnSync can only
1377
+ // exec those through a shell (else ENOENT).
1378
+ shell: process.platform === "win32",
1379
+ });
1380
+ installed = !res.error && res.status === 0;
1381
+ }
1382
+ catch {
1383
+ installed = false;
1384
+ }
1385
+ ClientSession.piCliProbe = { at: now, installed };
1386
+ return installed;
1387
+ }
1388
+ static invalidatePiCliProbe() {
1389
+ ClientSession.piCliProbe = null;
1390
+ }
1339
1391
  /**
1340
1392
  * Run a command async, collecting stdout+stderr; kills on timeout.
1341
1393
  * Never throws / never crashes the server: spawn errors (ENOENT etc.)
@@ -1483,6 +1535,9 @@ export class ClientSession {
1483
1535
  text: `pi agent 安装失败:${err.message}`,
1484
1536
  });
1485
1537
  }
1538
+ // The CLI may just have landed on PATH (or the install may have failed) —
1539
+ // drop the probe cache so the next snapshot re-checks.
1540
+ ClientSession.invalidatePiCliProbe();
1486
1541
  this.flushSnapshot();
1487
1542
  }
1488
1543
  /** Send a snapshot immediately (cancels any pending throttled one).
@@ -1707,6 +1762,11 @@ export class ClientSession {
1707
1762
  * after the current turn settles, skipping remaining planned tool calls.
1708
1763
  */
1709
1764
  queue = false) {
1765
+ // Captured at the START (before any await): the conversation being
1766
+ // addressed by this prompt. See the naming block below — a concurrent
1767
+ // switch/new_chat while prompt() is in flight must never target a
1768
+ // different conversation.
1769
+ const conv = this.conv;
1710
1770
  try {
1711
1771
  const s = this.session;
1712
1772
  // Native slash commands (see NATIVE_COMMANDS) are executed here and
@@ -1722,6 +1782,19 @@ export class ClientSession {
1722
1782
  // is refused until admission reopens.
1723
1783
  if (this.quiesceBlocked())
1724
1784
  return;
1785
+ // Name the conversation from its FIRST prompt immediately, before any
1786
+ // await: the typed text IS the name. The `conv` reference was captured
1787
+ // before the try block, so a concurrent switch/new_chat while prompt()
1788
+ // is in flight can never rename a DIFFERENT conversation — or miss the
1789
+ // rename entirely. A failed send still leaves the name, which matches
1790
+ // what the user typed intent-wise; the entry_appended fallback below
1791
+ // re-derives it from the persisted transcript when needed.
1792
+ if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
1793
+ const trimmed = text.trim().replace(/\s+/g, " ");
1794
+ conv.title =
1795
+ trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
1796
+ this.emitConversations();
1797
+ }
1725
1798
  // Attach files as independent nextTurn context messages (asides) so the
1726
1799
  // user message stays clean; they render as separate attachment cards.
1727
1800
  const asides = await buildAttachmentMessages({
@@ -1760,16 +1833,10 @@ export class ClientSession {
1760
1833
  text: `提示发送失败:${err.message}`,
1761
1834
  });
1762
1835
  }
1763
- // Name the conversation after its first user prompt.
1764
- const conv = this.conv;
1765
- if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
1766
- const trimmed = text.trim().replace(/\s+/g, " ");
1767
- conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
1768
- this.emitConversations();
1769
- }
1770
- // The active conversation has been continued since it was opened — it
1771
- // must not be dismissed when the user switches away. (Also bumps the
1772
- // per-project "most recently active" order used by set_cwd.)
1836
+ // The active conversation (captured at prompt start — see above) has been
1837
+ // continued since it was opened — it must not be dismissed when the user
1838
+ // switches away. (Also bumps the per-project "most recently active"
1839
+ // order used by set_cwd.)
1773
1840
  conv.promptedSinceActive = true;
1774
1841
  conv.lastActiveAt = Date.now();
1775
1842
  // Fresh run — restart the stall watchdog window.
@@ -2095,9 +2162,12 @@ export class ClientSession {
2095
2162
  return;
2096
2163
  const displaced = this.displaceActive();
2097
2164
  this.activeId = id;
2098
- this.cwd = this.conv.cwd;
2099
- // All listed conversations share the current project's cwd, so this is
2100
- // normally a no-op kept defensive for stale clients.
2165
+ const newCwd = this.conv.cwd;
2166
+ // A listed conversation may belong to ANOTHER project (cross-project
2167
+ // running list). Switching to it must also switch the active workspace
2168
+ // — otherwise the file tree / session history / recent-projects order
2169
+ // would keep showing the OLD project while the chat shows the new one.
2170
+ const cwdChanged = newCwd !== this.cwd;
2101
2171
  if (displaced)
2102
2172
  this.removeConversation(displaced.id);
2103
2173
  this.conv.promptedSinceActive = false;
@@ -2108,15 +2178,33 @@ export class ClientSession {
2108
2178
  this.pushTerminals();
2109
2179
  // The switched-to conversation has its own runtime (own resource cache).
2110
2180
  void this.pushSlashCommands();
2181
+ if (cwdChanged) {
2182
+ this.cwd = newCwd;
2183
+ // Mirror set_cwd's project-switch side-effects so the whole UI follows
2184
+ // the new workspace, not just the chat pane.
2185
+ try {
2186
+ this.onCwdChanged?.(newCwd);
2187
+ }
2188
+ catch {
2189
+ /* hook failure must not break the switch */
2190
+ }
2191
+ this.stateStore.remember(this.clientId, newCwd);
2192
+ void this.pushProjects();
2193
+ void this.refreshSessions();
2194
+ void this.listFiles(undefined);
2195
+ void this.listCommands();
2196
+ }
2111
2197
  this.flushSnapshot();
2112
2198
  }
2113
- /** Push the current project's running-conversation list to the client. */
2199
+ /** Push every running conversation across ALL projects to the client. The
2200
+ * running-conversation list is global so a background run from another
2201
+ * workspace stays visible; clicking one switches both the conversation and
2202
+ * its project (see switchConversation). The client groups the list by cwd. */
2114
2203
  emitConversations() {
2115
2204
  const conversations = [];
2116
2205
  for (const conv of this.convs.values()) {
2117
- // The running-conversation list is per project and only contains
2118
- // conversations that were displaced to the background while running.
2119
- if (conv.cwd !== this.cwd || !conv.listed)
2206
+ // Only conversations that were displaced to the background while running.
2207
+ if (!conv.listed)
2120
2208
  continue;
2121
2209
  let messageCount = 0;
2122
2210
  let isStreaming = false;
@@ -2223,26 +2311,86 @@ export class ClientSession {
2223
2311
  });
2224
2312
  }
2225
2313
  }
2226
- /** Switch the active session to a persisted one (from listSessions). */
2314
+ /** Open a persisted session as the active conversation (from listSessions).
2315
+ *
2316
+ * A persisted-session click must follow the same ownership rule as
2317
+ * new_chat/switch_conversation: every open conversation keeps its own
2318
+ * runtime. AgentSessionRuntime.switchSession() tears down (and aborts) the
2319
+ * current runtime, which would otherwise stop a response merely because the
2320
+ * user opened history while it was streaming.
2321
+ */
2227
2322
  async switchSession(path) {
2228
2323
  if (this.quiesceBlocked())
2229
2324
  return;
2325
+ let openedRuntime = null;
2326
+ let openedTerminals = null;
2230
2327
  try {
2231
- await this.runtime.switchSession(path);
2232
- await this.bindSession();
2233
- // The resumed session carries its own cwd sync it into the ACTIVE
2234
- // conversation (other open conversations are untouched).
2235
- this.conv.cwd = this.runtime.cwd;
2236
- this.cwd = this.runtime.cwd;
2237
- this.conv.title = conversationTitle(this.runtime.session);
2328
+ const targetPath = resolve(path);
2329
+ // A session may already be open in the running-conversation map. Reuse it
2330
+ // instead of creating a second writer for the same JSONL transcript.
2331
+ for (const conv of this.convs.values()) {
2332
+ const sessionFile = conv.session.sessionFile;
2333
+ if (sessionFile && resolve(sessionFile) === targetPath) {
2334
+ await this.switchConversation(conv.id);
2335
+ return;
2336
+ }
2337
+ }
2338
+ const sessionManager = SessionManager.open(targetPath);
2339
+ const targetCwd = sessionManager.getCwd();
2340
+ const conversationId = this.nextConversationId();
2341
+ openedTerminals = this.makeTerminalManager(conversationId, targetCwd);
2342
+ openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals), {
2343
+ cwd: targetCwd,
2344
+ agentDir: this.agentDir,
2345
+ sessionManager,
2346
+ });
2347
+ // Only displace the old active conversation after the replacement runtime
2348
+ // is known-good. This keeps a failed history open entirely non-destructive.
2349
+ const oldListed = this.conv.listed;
2350
+ const displaced = this.displaceActive();
2351
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length +
2352
+ 1 -
2353
+ (displaced?.cwd === targetCwd ? 1 : 0);
2354
+ if (openInProject > MAX_OPEN_CONVERSATIONS) {
2355
+ // displaceActive() may have promoted a streaming conversation into the
2356
+ // running list. Roll that presentation-only mutation back because no
2357
+ // switch will take place.
2358
+ this.conv.listed = oldListed;
2359
+ openedTerminals.killAll();
2360
+ await openedRuntime.dispose();
2361
+ openedRuntime = null;
2362
+ openedTerminals = null;
2363
+ this.emit({
2364
+ type: "notice",
2365
+ level: "warning",
2366
+ text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
2367
+ });
2368
+ return;
2369
+ }
2370
+ const conv = this.makeConversation(openedRuntime, conversationId, openedTerminals);
2238
2371
  // Deliberately resumed — must not be dismissed when the user later
2239
2372
  // switches away without sending a new message.
2240
- this.conv.promptedSinceActive = true;
2373
+ conv.promptedSinceActive = true;
2374
+ this.convs.set(conv.id, conv);
2375
+ this.activeId = conv.id;
2376
+ openedRuntime = null;
2377
+ openedTerminals = null;
2378
+ if (displaced)
2379
+ this.removeConversation(displaced.id);
2380
+ await this.bindSession();
2381
+ this.cwd = targetCwd;
2382
+ this.conv.lastActiveAt = Date.now();
2383
+ this.webUi.refresh();
2241
2384
  this.emitConversations();
2242
- // switchSession replaced the runtime — its resource cache is fresh.
2385
+ this.goalSvc.emitGoalStatus();
2386
+ this.pushTerminals();
2387
+ // The restored conversation has a fresh project-bound resource cache.
2243
2388
  void this.pushSlashCommands();
2244
2389
  }
2245
2390
  catch (err) {
2391
+ openedTerminals?.killAll();
2392
+ if (openedRuntime)
2393
+ await openedRuntime.dispose().catch(() => { });
2246
2394
  this.emit({
2247
2395
  type: "notice",
2248
2396
  level: "error",
@@ -448,9 +448,9 @@ export async function buildAttachmentMessages(ctx, attachments) {
448
448
  content: [
449
449
  {
450
450
  type: "text",
451
- text: `
452
- <vision-bridge>
453
- ${transcript}
451
+ text: `
452
+ <vision-bridge>
453
+ ${transcript}
454
454
  </vision-bridge>`,
455
455
  },
456
456
  ...(pathImg
@@ -178,6 +178,7 @@ export class ClientStateStore {
178
178
  terminalBash: s?.settings?.terminalBash ?? false,
179
179
  terminalBashIdleMs: s?.settings?.terminalBashIdleMs ?? 15_000,
180
180
  thinkingWrap: s?.settings?.thinkingWrap ?? false,
181
+ toolsWrap: s?.settings?.toolsWrap ?? true,
181
182
  visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
182
183
  visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
183
184
  visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
@@ -201,6 +202,7 @@ export class ClientStateStore {
201
202
  terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
202
203
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
203
204
  thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
205
+ toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
204
206
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
205
207
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
206
208
  visionBridgePromptMode: settings.visionBridgePromptMode ??
@@ -179,6 +179,11 @@ const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/dist/server or <p
179
179
  // Resolve the package root robustly: dev runs from <repo>/server (tsx), prod
180
180
  // from <pkg>/dist/server — the ancestor that actually has package.json wins.
181
181
  function resolvePkgRoot() {
182
+ // PI_WEB_PKG_ROOT: Electron 桌面版打包后,server 子进程从 extraResources 目录
183
+ //(process.resourcesPath)加载 web/dist 和 themes。通过这个 env var 告诉
184
+ // server 去哪里找 pkgRoot,避免 resolvePkgRoot 的候选路径找不到 package.json。
185
+ if (process.env.PI_WEB_PKG_ROOT)
186
+ return process.env.PI_WEB_PKG_ROOT;
182
187
  const candidates = [
183
188
  resolve(here, ".."),
184
189
  resolve(here, "..", ".."),
@@ -717,6 +722,7 @@ wss.on("connection", (ws) => {
717
722
  terminalBash: msg.terminalBash,
718
723
  terminalBashIdleMs: msg.terminalBashIdleMs,
719
724
  thinkingWrap: msg.thinkingWrap,
725
+ toolsWrap: msg.toolsWrap,
720
726
  visionBridgeEnabled: msg.visionBridgeEnabled,
721
727
  visionBridgeModel: msg.visionBridgeModel,
722
728
  visionBridgePromptMode: msg.visionBridgePromptMode,
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 9;
11
+ export const PROTOCOL_VERSION = 10;
@@ -6,7 +6,8 @@
6
6
  * 预设存取 + 何时需要 reload」,真正动 runtime 的 session.reload() 走宿主回调
7
7
  * (reloadSession 里还会刷新斜杠命令目录)。
8
8
  */
9
- import { basename } from "node:path";
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { basename, dirname, join } from "node:path";
10
11
  import { extensionKey } from "./client-state.js";
11
12
  import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
12
13
  export class SettingsService {
@@ -39,20 +40,88 @@ export class SettingsService {
39
40
  this.pendingReload = false;
40
41
  return v;
41
42
  }
43
+ /** 判断某个 skill 名是否仍存在于磁盘任何来源(agent 区 / 项目 .pi / 祖先
44
+ * .agents/skills / npm 包内 skills)。被禁用且文件已删除的名字不应再
45
+ * 出现在设置面板,也不应留在持久化记录里。 */
46
+ skillStillOnDisk(name) {
47
+ const cwd = this.host.cwd();
48
+ const agentDir = this.host.agentDir();
49
+ const check = (base) => existsSync(join(base, name)) || existsSync(join(base, `${name}.md`));
50
+ // ① 用户区 <agentDir>/skills ② 项目 .pi/skills
51
+ if (check(join(agentDir, "skills")))
52
+ return true;
53
+ if (check(join(cwd, ".pi", "skills")))
54
+ return true;
55
+ // ③ 祖先链 .agents/skills(SDK collectAncestorAgentsSkillDirs 语义,最多上溯 6 层)
56
+ let dir = cwd;
57
+ for (let i = 0; i < 6 && dir !== dirname(dir); i++, dir = dirname(dir)) {
58
+ if (check(join(dir, ".agents", "skills")))
59
+ return true;
60
+ }
61
+ // ④ npm 包内 skills(agent 级 + 项目级,含 @scope 两级子包)
62
+ for (const npmRoot of [
63
+ join(agentDir, "npm", "node_modules"),
64
+ join(cwd, ".pi", "npm", "node_modules"),
65
+ ]) {
66
+ try {
67
+ for (const entry of readdirSync(npmRoot, { withFileTypes: true })) {
68
+ if (!entry.isDirectory())
69
+ continue;
70
+ if (!entry.name.startsWith("@")) {
71
+ if (check(join(npmRoot, entry.name, "skills")))
72
+ return true;
73
+ }
74
+ else {
75
+ for (const sub of readdirSync(join(npmRoot, entry.name), { withFileTypes: true })) {
76
+ if (sub.isDirectory() && check(join(npmRoot, entry.name, sub.name, "skills"))) {
77
+ return true;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // npm 目录不存在/不可读 → 不是来源
85
+ }
86
+ }
87
+ return false;
88
+ }
42
89
  push() {
43
90
  const disabledSkills = new Set(this.settings.disabledSkills);
44
91
  const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
45
92
  const disabledExts = new Set(this.settings.disabledExtensions);
93
+ let loadedSkillNames = null;
46
94
  try {
47
- // Refresh the cache with the CURRENTLY loaded set (post-filter).
48
- for (const s of this.host.getSession().resourceLoader.getSkills().skills) {
95
+ const loadedSkills = this.host.getSession().resourceLoader.getSkills().skills;
96
+ const loadedExts = this.host.getSession().resourceLoader.getExtensions().extensions;
97
+ loadedSkillNames = new Set(loadedSkills.map((s) => s.name));
98
+ // Prune entries that no longer exist on disk AND aren't disabled
99
+ // (e.g. a skill/extension file was deleted). Disabled entries are
100
+ // kept so they can be re-enabled even when filtered out of the loader.
101
+ const keepSkills = new Set([
102
+ ...loadedSkills.map((s) => s.name),
103
+ ...this.settings.disabledSkills,
104
+ ]);
105
+ const keepExts = new Set([
106
+ ...loadedExts.map((e) => extensionKey(e)),
107
+ ...this.settings.disabledExtensions,
108
+ ]);
109
+ for (const name of [...this.knownSkills.keys()]) {
110
+ if (!keepSkills.has(name))
111
+ this.knownSkills.delete(name);
112
+ }
113
+ for (const id of [...this.knownExtensions.keys()]) {
114
+ if (!keepExts.has(id))
115
+ this.knownExtensions.delete(id);
116
+ }
117
+ for (const s of loadedSkills) {
49
118
  this.knownSkills.set(s.name, {
50
119
  name: s.name,
51
120
  description: s.description,
52
121
  enabled: true,
53
122
  });
54
123
  }
55
- for (const e of this.host.getSession().resourceLoader.getExtensions().extensions) {
124
+ for (const e of loadedExts) {
56
125
  const id = extensionKey(e);
57
126
  const p = e.sourceInfo?.path ?? e.path;
58
127
  this.knownExtensions.set(id, {
@@ -68,13 +137,33 @@ export class SettingsService {
68
137
  catch {
69
138
  // Session not ready yet — keep whatever we already know.
70
139
  }
71
- // Disabled entries are filtered out of the loader — keep them in the
72
- // panel (with the last-known description) so they can be re-enabled.
73
- for (const name of this.settings.disabledSkills) {
74
- if (!this.knownSkills.has(name)) {
75
- this.knownSkills.set(name, { name, description: "", enabled: false });
140
+ // 清理“源文件已删除”的禁用残留记录:磁盘上已不存在的技能名从
141
+ // disabledSkills / reviewDisabledSkills 持久化记录中移除——否则每次
142
+ // 推送都会把已删除的 skill 以灰条形式永恒地补回面板(“关闭过的
143
+ // skill 被一直记录”)。session 未就绪时保守跳过。
144
+ if (loadedSkillNames !== null) {
145
+ const stale = [
146
+ ...new Set([...this.settings.disabledSkills, ...this.settings.reviewDisabledSkills]),
147
+ ].filter((name) => !loadedSkillNames.has(name) && !this.skillStillOnDisk(name));
148
+ if (stale.length > 0) {
149
+ this.settings.disabledSkills = this.settings.disabledSkills.filter((n) => !stale.includes(n));
150
+ this.settings.reviewDisabledSkills = this.settings.reviewDisabledSkills.filter((n) => !stale.includes(n));
151
+ this.host.stateStore.saveSettings(this.host.clientId, {
152
+ disabledSkills: this.settings.disabledSkills,
153
+ reviewDisabledSkills: this.settings.reviewDisabledSkills,
154
+ });
76
155
  }
77
156
  }
157
+ // Disabled entries that still exist on disk are re-added (with the
158
+ // last-known description) so they can be re-enabled; entries whose
159
+ // source file was deleted are dropped instead of being resurrected.
160
+ for (const name of this.settings.disabledSkills) {
161
+ if (this.knownSkills.has(name))
162
+ continue;
163
+ if (!this.skillStillOnDisk(name))
164
+ continue;
165
+ this.knownSkills.set(name, { name, description: "", enabled: false });
166
+ }
78
167
  for (const id of this.settings.disabledExtensions) {
79
168
  if (!this.knownExtensions.has(id)) {
80
169
  this.knownExtensions.set(id, {
@@ -103,6 +192,7 @@ export class SettingsService {
103
192
  terminalBash: this.settings.terminalBash,
104
193
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
105
194
  thinkingWrap: this.settings.thinkingWrap,
195
+ toolsWrap: this.settings.toolsWrap,
106
196
  visionBridgeEnabled: this.settings.visionBridgeEnabled,
107
197
  visionBridgeModel: this.settings.visionBridgeModel,
108
198
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
@@ -174,6 +264,9 @@ export class SettingsService {
174
264
  if (partial.thinkingWrap !== undefined) {
175
265
  this.settings.thinkingWrap = partial.thinkingWrap;
176
266
  }
267
+ if (partial.toolsWrap !== undefined) {
268
+ this.settings.toolsWrap = partial.toolsWrap;
269
+ }
177
270
  if (partial.visionBridgeEnabled !== undefined) {
178
271
  this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
179
272
  }
@@ -245,10 +338,11 @@ export class SettingsService {
245
338
  reviewDisabledSkills: [
246
339
  ...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
247
340
  ],
248
- // Presets don't capture vision-bridge prefs — keep the current ones.
249
- visionBridgeEnabled: this.settings.visionBridgeEnabled,
250
341
  // 纯 UI 偏好不进预设——保留当前值。
251
342
  thinkingWrap: this.settings.thinkingWrap,
343
+ toolsWrap: this.settings.toolsWrap,
344
+ // Presets don't capture vision-bridge prefs — keep the current ones.
345
+ visionBridgeEnabled: this.settings.visionBridgeEnabled,
252
346
  visionBridgeModel: this.settings.visionBridgeModel,
253
347
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
254
348
  visionBridgePrompt: this.settings.visionBridgePrompt,
@@ -1230,11 +1230,11 @@ export const TERMINAL_TOOL_NAMES = [
1230
1230
  /** System-prompt guidance teaching the model WHEN to prefer the terminal tools
1231
1231
  * over one-shot bash. Without it models almost never pick them — bash returns
1232
1232
  * complete output in a single call, so it always wins on convenience. */
1233
- export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
1234
- - The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
1235
- - You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
1236
- - The user explicitly asks you to work in the visible terminal panel.
1237
- Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
1233
+ export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
1234
+ - The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
1235
+ - You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
1236
+ - The user explicitly asks you to work in the visible terminal panel.
1237
+ Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.
1238
1238
  Do NOT use them for simple one-shot commands; bash remains cheaper and simpler there.`;
1239
1239
  /** Build the agent-facing persistent terminal tools for one conversation. */
1240
1240
  export function makePersistentTerminalTools(terminals, cwd) {