pi-web-ui 0.92.0 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +58 -2
  2. package/README.md +69 -14
  3. package/README.zh-CN.md +36 -10
  4. package/bin/pi-web-ui.mjs +18 -6
  5. package/dist/server/agent-service.js +497 -120
  6. package/dist/server/auth-cookie.js +26 -0
  7. package/dist/server/claim-files-tool.js +6 -7
  8. package/dist/server/client-state.js +65 -6
  9. package/dist/server/conversation-read-tool.js +2 -1
  10. package/dist/server/delegate-task.js +10 -3
  11. package/dist/server/dsh/dsh-agent-service.js +43 -1
  12. package/dist/server/files-service.js +78 -26
  13. package/dist/server/http-proxy.js +72 -0
  14. package/dist/server/index.js +62 -7
  15. package/dist/server/patch-node-pty.js +11 -0
  16. package/dist/server/plugins.js +1 -0
  17. package/dist/server/protocol-version.js +1 -1
  18. package/dist/server/read-tool.js +37 -3
  19. package/dist/server/resolve-global-sdk.js +72 -0
  20. package/dist/server/sdk-origin.js +64 -0
  21. package/dist/server/serialize.js +59 -21
  22. package/dist/server/settings-service.js +12 -1
  23. package/dist/server/skill-tool.js +2 -1
  24. package/dist/server/subagents.js +30 -13
  25. package/dist/server/terminals.js +38 -18
  26. package/dist/server/themes.js +15 -0
  27. package/dist/server/tool-info.js +89 -0
  28. package/dist/server/tool-manager.js +107 -14
  29. package/package.json +2 -2
  30. package/plugins/catalog.json +10 -0
  31. package/themes/ayu-light.css +136 -0
  32. package/themes/catppuccin-latte.css +4 -0
  33. package/themes/catppuccin.css +4 -0
  34. package/themes/codex.css +136 -0
  35. package/themes/cyberpunk.css +4 -0
  36. package/themes/dazzle.css +4 -0
  37. package/themes/everforest-light.css +136 -0
  38. package/themes/geist.css +136 -0
  39. package/themes/gruvbox-light.css +136 -0
  40. package/themes/kanagawa-lotus.css +136 -0
  41. package/themes/md-preview.css +4 -0
  42. package/themes/mist.css +4 -0
  43. package/themes/nord.css +4 -0
  44. package/themes/one-dark.css +4 -0
  45. package/themes/paper.css +4 -0
  46. package/themes/rose-pine-dawn.css +136 -0
  47. package/themes/sakura.css +4 -0
  48. package/themes/solarized-light.css +4 -0
  49. package/themes/tokyo-night.css +4 -0
  50. package/themes/white.css +4 -0
  51. package/web/dist/assets/{TerminalPanel-RACpk6AX.js → TerminalPanel-C1A23aI-.js} +1 -1
  52. package/web/dist/assets/index-B48QWf0I.js +364 -0
  53. package/web/dist/assets/index-DGL5OInG.css +1 -0
  54. package/web/dist/index.html +2 -2
  55. package/web/dist/assets/index-BgrnP9Jz.js +0 -364
  56. package/web/dist/assets/index-Cy8dSTZt.css +0 -1
@@ -21,3 +21,29 @@ export function isTlsRequest(req) {
21
21
  export function buildPiWebTokenCookie(encodedValue, maxAge, secure) {
22
22
  return `pi_web_token=${encodedValue}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? "; Secure" : ""}`;
23
23
  }
24
+ /**
25
+ * `pi_web_token` cookie 值 → 原始口令。
26
+ *
27
+ * 下发时按 `encodeURIComponent` 编码(RFC 6265 的 cookie-value 只允许 ASCII,
28
+ * `=` / `+` / 空格 / 非 ASCII 都必须转义),所以**比对前必须先解码** —— 否则
29
+ * 含这些字符的口令永远匹配不上,表现为「`?token=` 那一次 200,之后所有资源与
30
+ * WS 全 401」(issue #261:base64 口令尾巴上的 `=` 被存成 `%3D`,服务端却拿
31
+ * `%3D` 去和 `=` 比)。
32
+ *
33
+ * 浏览器回来的一定是编码值,但手写 cookie / 旧客户端可能是明文(`=` 在值里
34
+ * 合法,我们按首个 `=` 切分),所以调用方应把「原样」与「解码后」两种候选都
35
+ * 试一遍(见 index.ts 的 requestTokens)。
36
+ *
37
+ * 不是合法百分号编码时原样返回:`decodeURIComponent` 会抛 `URIError`,
38
+ * 而一个手写的脏 cookie 绝不该把请求打成 500。
39
+ */
40
+ export function decodeCookieToken(raw) {
41
+ if (!raw)
42
+ return "";
43
+ try {
44
+ return decodeURIComponent(raw);
45
+ }
46
+ catch {
47
+ return raw;
48
+ }
49
+ }
@@ -6,18 +6,17 @@
6
6
  // 强制锁会死锁(见 claim-store.ts 文件头)。先到先得;自己的可刷新,
7
7
  // 别人的动不了;过期自动释放 + 发 prompt 心跳续期 + 对话关闭释放。
8
8
  //
9
- // 常驻工具、不进 AGENT_TOOL_CATALOG(例外,理由见下):目录里的每个工具都
10
- // 必须在设置页有行(settings-tool-rows.test.ts 强制),而 web/src 正被并行
11
- // 任务占用。认领是纯 advisory(关掉只会少提醒),常驻默认开可接受;目录项 +
12
- // 设置行等 web/src 空出来后补(follow-up)。DSH 引擎无 customTool 注册面,
13
- // 只有 pi 引擎对话能认领(提醒是服务端算的,DSH 照样能看到别人的认领)。
9
+ // 开关走统一工具 tab(AGENT_TOOL_CATALOG 目录 + 设置页开关行;ActiveSet 门控,
10
+ // live 生效):认领是纯 advisory(关掉只会少提醒),默认开。DSH 引擎无 customTool
11
+ // 注册面,只有 pi 引擎对话能认领(提醒是服务端算的,DSH 照样能看到别人的认领)。
14
12
  // ---------------------------------------------------------------------------
15
13
  import { defineTool } from "@earendil-works/pi-coding-agent";
16
14
  import { Type } from "typebox";
17
15
  import { bilingual, pick } from "./i18n.js";
16
+ import { CLAIM_FILES_TOOL_NAME } from "./tool-manager.js";
18
17
  import { CLAIM_TTL_MS, resolveClaimPath } from "./claim-store.js";
19
- /** 工具名(改名需同步改 agent-service 注册处注释与单测)。 */
20
- export const CLAIM_FILES_TOOL_NAME = "claim_files";
18
+ /** 工具名(唯一定义在 tool-manager.ts,本模块 re-export 供单测沿用,模式同 present-files-tool.ts)。 */
19
+ export { CLAIM_FILES_TOOL_NAME };
21
20
  function fmtTtl(ms) {
22
21
  const m = Math.round(ms / 60000);
23
22
  return m >= 60 ? `${Math.floor(m / 60)}h${m % 60 ? `${m % 60}m` : ""}` : `${m}m`;
@@ -12,6 +12,40 @@ import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
12
12
  import { deriveLegacy, legacyToDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
13
13
  /** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
14
14
  export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
15
+ /** 工具执行看门狗超时的默认值(默认 20 分钟;环境变量 PI_WEB_TOOL_TIMEOUT_MS 覆盖)。 */
16
+ export const DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS = (() => {
17
+ const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
18
+ return Number.isFinite(v) && v >= 0 ? v : 20 * 60_000;
19
+ })();
20
+ /** 归一化工具看门狗超时(毫秒):0 = 禁用;非数值、负数或空值回落默认值,非负整数保留。 */
21
+ export function normalizeToolWatchdogTimeoutMs(v) {
22
+ if (v === null || v === undefined || v === "")
23
+ return DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS;
24
+ const n = Math.floor(Number(v));
25
+ if (!Number.isFinite(n) || n < 0)
26
+ return DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS;
27
+ return n;
28
+ }
29
+ /** 工具自己声明超时时留给它的余量(毫秒):让工具先自己超时并返回错误,
30
+ * 而不是看门狗先下手把整轮对话 abort 掉。 */
31
+ export const TOOL_WATCHDOG_EXPLICIT_GRACE_MS = 5_000;
32
+ /** 计算单次工具调用的有效看门狗超时(毫秒;≤0 = 不布看门狗)。
33
+ * 基础值来自设置/环境变量(见 DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS);**若工具调用
34
+ * 自己声明了更长的超时**(目前只有 bash 的 `args.timeout`,单位秒),自动顺延到
35
+ * 「工具超时 + 余量」—— 否则 AI 明确要求跑 90 分钟的命令,会被 20 分钟的看门狗
36
+ * 连同整轮对话一起剁掉。纯函数,可单测。 */
37
+ export function effectiveToolWatchdogMs(baseMs, toolName, args) {
38
+ if (!Number.isFinite(baseMs) || baseMs <= 0)
39
+ return 0;
40
+ let ms = baseMs;
41
+ if (toolName === "bash" && args && typeof args === "object") {
42
+ const raw = args.timeout;
43
+ const sec = typeof raw === "number" ? raw : Number(raw);
44
+ if (Number.isFinite(sec) && sec > 0)
45
+ ms = Math.max(ms, sec * 1000 + TOOL_WATCHDOG_EXPLICIT_GRACE_MS);
46
+ }
47
+ return ms;
48
+ }
15
49
  /** 归一化插件 AI 工具禁用名单:只收非空字符串(去重,上限 256 个)。
16
50
  * 与 disabledAgentTools 不同——插件工具名是动态的(注册才知道),不能按
17
51
  * 固定目录校验;未知/已卸载插件的条目刻意保留(重装后仍保持关闭)。
@@ -285,15 +319,20 @@ export class ClientStateStore {
285
319
  state.lastCwd = cwd;
286
320
  const now = Date.now();
287
321
  state.projects = [{ path: cwd, lastUsed: now }, ...state.projects.filter((p) => p.path !== cwd)].slice(0, 30);
288
- // Opening the workspace again clears its removal tombstone.
289
- if (state.removedProjects?.length) {
290
- state.removedProjects = state.removedProjects.filter((p) => p !== cwd);
322
+ // Opening the workspace again clears its removal tombstone across all clients and global settings.
323
+ for (const cState of Object.values(all)) {
324
+ if (cState.removedProjects?.length) {
325
+ cState.removedProjects = cState.removedProjects.filter((p) => p !== cwd);
326
+ }
291
327
  }
292
328
  this.save();
293
329
  }
294
330
  /** Drop one workspace from the recent-project list (user-requested removal).
295
331
  * Records a tombstone too: pushProjects() re-discovers cwds from session
296
- * files on every listing, so without it the entry would instantly reappear. */
332
+ * files on every listing, so without it the entry would instantly reappear.
333
+ * Recorded in both the requesting client and GLOBAL_SETTINGS_KEY so the
334
+ * removal stays across all browser tabs and server restarts until the user
335
+ * explicitly opens that project again. */
297
336
  removeProject(clientId, cwd) {
298
337
  const all = this.load();
299
338
  const state = (all[clientId] ??= { projects: [] });
@@ -303,12 +342,28 @@ export class ClientStateStore {
303
342
  const removed = new Set(state.removedProjects ?? []);
304
343
  removed.add(cwd);
305
344
  state.removedProjects = [...removed];
345
+ const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
346
+ const globalRemoved = new Set(globalState.removedProjects ?? []);
347
+ globalRemoved.add(cwd);
348
+ globalState.removedProjects = [...globalRemoved];
349
+ for (const [id, cState] of Object.entries(all)) {
350
+ if (id !== ClientStateStore.GLOBAL_SETTINGS_KEY && cState.projects) {
351
+ cState.projects = cState.projects.filter((p) => p.path !== cwd);
352
+ }
353
+ }
306
354
  this.save();
307
355
  }
308
356
  /** Tombstoned projects (explicitly removed by the user) for filtering the
309
- * merged recent-project list. */
357
+ * merged recent-project list across clients. */
310
358
  getRemovedProjects(clientId) {
311
- return this.load()[clientId]?.removedProjects ?? [];
359
+ const all = this.load();
360
+ const clientRemoved = all[clientId]?.removedProjects ?? [];
361
+ const globalRemoved = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.removedProjects ?? [];
362
+ if (globalRemoved.length === 0)
363
+ return clientRemoved;
364
+ if (clientRemoved.length === 0)
365
+ return globalRemoved;
366
+ return [...new Set([...clientRemoved, ...globalRemoved])];
312
367
  }
313
368
  /** Last-used goal/review prefs for a client, or undefined if never set. */
314
369
  getGoalPrefs(clientId) {
@@ -419,6 +474,7 @@ export class ClientStateStore {
419
474
  : (stored?.terminalToolsEnabled ?? false),
420
475
  terminalBash: stored?.terminalBash ?? false,
421
476
  terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
477
+ toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(stored?.toolWatchdogTimeoutMs),
422
478
  readDirEnabled: stored?.readDirEnabled ?? true,
423
479
  editSoftEnabled: stored?.disabledAgentTools !== undefined
424
480
  ? deriveLegacy(legacyToDisabled(stored)).editSoftEnabled
@@ -432,6 +488,7 @@ export class ClientStateStore {
432
488
  devNoCache: stored?.devNoCache,
433
489
  autoReload: stored?.autoReload,
434
490
  toolsWrap: stored?.toolsWrap ?? true,
491
+ toolImagesEnabled: stored?.toolImagesEnabled ?? true,
435
492
  skillsFullText: normalizeSkillList(stored?.skillsFullText),
436
493
  visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
437
494
  visionBridgeModel: stored?.visionBridgeModel ?? null,
@@ -474,6 +531,7 @@ export class ClientStateStore {
474
531
  terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? false,
475
532
  terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
476
533
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
534
+ toolWatchdogTimeoutMs: normalizeToolWatchdogTimeoutMs(settings.toolWatchdogTimeoutMs ?? cur.toolWatchdogTimeoutMs ?? DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS),
477
535
  readDirEnabled: settings.readDirEnabled ?? cur.readDirEnabled ?? true,
478
536
  editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
479
537
  questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
@@ -483,6 +541,7 @@ export class ClientStateStore {
483
541
  devNoCache: settings.devNoCache ?? cur.devNoCache,
484
542
  autoReload: settings.autoReload ?? cur.autoReload,
485
543
  toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
544
+ toolImagesEnabled: settings.toolImagesEnabled ?? cur.toolImagesEnabled ?? true,
486
545
  skillsFullText: normalizeSkillList(settings.skillsFullText ?? cur.skillsFullText),
487
546
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
488
547
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
@@ -22,6 +22,7 @@
22
22
  import { defineTool } from "@earendil-works/pi-coding-agent";
23
23
  import { Type } from "typebox";
24
24
  import { bilingual, pick } from "./i18n.js";
25
+ import { CONVERSATION_READ_TOOL_NAME } from "./tool-manager.js";
25
26
  import { extractTouches, formatTouchEntry, formatTouchesCompact, toolCallRefsOfContent, unionTouchLists, } from "./conversation-touches.js";
26
27
  /** 由 content 块提 toolCalls 字段(additive;格式化输出不受影响,见接口注释)。 */
27
28
  function fillToolCalls(target, content) {
@@ -366,7 +367,7 @@ export function makeConversationReadTool(host, lang, extras) {
366
367
  details,
367
368
  });
368
369
  return defineTool({
369
- name: "conversation_read",
370
+ name: CONVERSATION_READ_TOOL_NAME,
370
371
  label: "Read another conversation",
371
372
  description: bilingual('Read ANOTHER conversation: a running conversation of this client (including subagents — use its conversation id like "c3", see action=list) or a persisted history session transcript (use its file path). ' +
372
373
  'Use it when the user references another chat (a quoted conversation, a pasted conversation id, or "see the other chat about X"). ' +
@@ -17,9 +17,10 @@
17
17
  import { Type } from "typebox";
18
18
  import { defineTool } from "@earendil-works/pi-coding-agent";
19
19
  import { bilingual, pick } from "./i18n.js";
20
+ import { DELEGATE_TASK_TOOL_NAME } from "./tool-manager.js";
20
21
  import { subagentTitle } from "./subagents.js";
21
- /** 工具名(前端 ToolCallBlock 派单卡片靠它识别;改名需同步改前端)。 */
22
- export const DELEGATE_TOOL_NAME = "delegate_task";
22
+ /** 工具名(唯一值在 tool-manager.ts;本别名保留:前端派单卡片与旧导入沿用它)。 */
23
+ export const DELEGATE_TOOL_NAME = DELEGATE_TASK_TOOL_NAME;
23
24
  /** 各段最小长度(trim 后字符数):TASK 必须具体,OUTCOME 必须可验收,其余段不许空着。 */
24
25
  const MIN_TASK = 20;
25
26
  const MIN_OUTCOME = 10;
@@ -40,6 +41,7 @@ export function normalizeDelegation(params) {
40
41
  must_not_do: str(p.must_not_do),
41
42
  context: str(p.context),
42
43
  model: str(p.model).trim() || undefined,
44
+ persist: typeof p.persist === "boolean" ? p.persist : undefined,
43
45
  };
44
46
  }
45
47
  /**
@@ -122,6 +124,11 @@ const delegateSchema = Type.Object({
122
124
  model: Type.Optional(Type.String({
123
125
  description: bilingual('Optional model "provider/id" for this delegation; omit = template model → panel default → follow the main conversation model.', "可选:本次派单的子代理模型(provider/id);不传 = 模板模型 → 面板默认 → 跟随主对话模型。"),
124
126
  })),
127
+ persist: Type.Optional(Type.Boolean({
128
+ description: bilingual("Optional: persist this conversation to disk as a regular session (saved in history, resumable). " +
129
+ "Default false (lightweight in-memory subagent). Use true for tasks that need long-term retention.", "可选:是否将该对话持久化落盘为普通对话(保存在历史会话中,可随时回顾与继续)。" +
130
+ "默认 false(轻量内存子代理)。需要长期留存的任务建议设为 true。"),
131
+ })),
125
132
  });
126
133
  /** 结构化派单工具:校验六段 → 拼装标准 prompt → 走 host.spawnSubagent 真子代理。 */
127
134
  export function makeDelegateTaskTool(host, lang) {
@@ -163,7 +170,7 @@ export function makeDelegateTaskTool(host, lang) {
163
170
  // 创建失败都经由 host 抛错),转成返回文本让 AI 能读到原因并修正重试。
164
171
  let convId;
165
172
  try {
166
- convId = await host.spawnSubagent(prompt, "delegate", ctx.cwd, input.agent, input.model);
173
+ convId = await host.spawnSubagent(prompt, "delegate", ctx.cwd, input.agent, input.model, undefined, input.persist);
167
174
  }
168
175
  catch (err) {
169
176
  const msg = err instanceof Error ? err.message : String(err);
@@ -29,12 +29,14 @@ import { basename, dirname, join, resolve, sep } from "node:path";
29
29
  import { randomUUID } from "node:crypto";
30
30
  import { homedir } from "node:os";
31
31
  import { BgServerTracker } from "../bg-servers.js";
32
- import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS } from "../client-state.js";
32
+ import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS, normalizeToolWatchdogTimeoutMs } from "../client-state.js";
33
33
  import { normalizeUiLayout } from "../client-state.js";
34
34
  import { FilesService, workspacePath, desktopDirWire } from "../files-service.js";
35
35
  import { QuiesceRejectedError } from "../agent-service.js";
36
36
  import { NATIVE_COMMANDS, parseSlash } from "../slash-commands.js";
37
37
  import { bilingual, pick, resolveServerLang } from "../i18n.js";
38
+ // 工具定义说明的归一化(工具卡右键 → 「显示工具详细信息」,见 getToolInfo)。
39
+ import { normalizeToolInfo, unsupportedToolInfo } from "../tool-info.js";
38
40
  import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js";
39
41
  import { saveUpload } from "../uploads.js";
40
42
  import { checkAll as checkAllUpdates, collectTargets, resolveNpmRegistry } from "../update-check.js";
@@ -122,12 +124,14 @@ const DEFAULT_SETTINGS = {
122
124
  terminalToolsEnabled: false,
123
125
  terminalBash: false,
124
126
  terminalBashIdleMs: 15_000,
127
+ toolWatchdogTimeoutMs: 20 * 60_000,
125
128
  editSoftEnabled: false,
126
129
  questionnaireEnabled: true,
127
130
  goalModeEnabled: true,
128
131
  parallelReminderEnabled: true,
129
132
  thinkingWrap: false,
130
133
  toolsWrap: true,
134
+ toolImagesEnabled: true,
131
135
  disabledPlugins: [],
132
136
  uiLayout: {},
133
137
  reviewPrompt: "",
@@ -267,12 +271,14 @@ export class DshClientSession {
267
271
  terminalToolsEnabled: savedSettings.terminalToolsEnabled,
268
272
  terminalBash: savedSettings.terminalBash,
269
273
  terminalBashIdleMs: savedSettings.terminalBashIdleMs,
274
+ toolWatchdogTimeoutMs: savedSettings.toolWatchdogTimeoutMs ?? 20 * 60_000,
270
275
  editSoftEnabled: savedSettings.editSoftEnabled,
271
276
  questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
272
277
  goalModeEnabled: savedSettings.goalModeEnabled ?? true,
273
278
  parallelReminderEnabled: savedSettings.parallelReminderEnabled ?? true,
274
279
  thinkingWrap: savedSettings.thinkingWrap,
275
280
  toolsWrap: savedSettings.toolsWrap,
281
+ toolImagesEnabled: savedSettings.toolImagesEnabled ?? true,
276
282
  disabledPlugins: savedSettings.disabledPlugins ?? [],
277
283
  uiLayout: normalizeUiLayout(savedSettings.uiLayout),
278
284
  reviewPrompt: savedSettings.reviewPrompt,
@@ -563,16 +569,22 @@ export class DshClientSession {
563
569
  // 记下待答问卷:`question_pending` 只推给「当时在线」的连接,刷新页面
564
570
  // /WS 重连后靠快照(UiState.pendingQuestion)把对话框恢复出来。
565
571
  // DSH 的提问桥是 runtime 级的(无 conversationId),故不分对话。
572
+ const dshConv = this.convs.get(this.activeId);
573
+ const conversationTitle = dshConv?.title;
566
574
  this.pendingQuestion = {
567
575
  id: params0.id,
568
576
  ...(typeof params0.deadline === "number" ? { deadline: params0.deadline } : {}),
569
577
  questions: mapped,
578
+ ...(this.activeId ? { conversationId: this.activeId } : {}),
579
+ ...(conversationTitle ? { conversationTitle } : {}),
570
580
  };
571
581
  this.emit({
572
582
  type: "question_pending",
573
583
  id: params0.id,
574
584
  ...(typeof params0.deadline === "number" ? { deadline: params0.deadline } : {}),
575
585
  questions: mapped,
586
+ ...(this.activeId ? { conversationId: this.activeId } : {}),
587
+ ...(conversationTitle ? { conversationTitle } : {}),
576
588
  });
577
589
  }
578
590
  else if (method === "tools.call.request") {
@@ -658,6 +670,28 @@ export class DshClientSession {
658
670
  console.error(`[dsh] syncPluginTools 失败 (client=${this.clientId}):`, err);
659
671
  }
660
672
  }
673
+ /**
674
+ * 取一条工具的**定义说明**(工具卡右键 → 「显示工具详细信息」)→ `tool_info`。
675
+ *
676
+ * DSH 侧的定义来自运行时(`tools/list`:DSH 原生工具 + 桥接进来的插件工具)。
677
+ * 拿不到(运行时没起来 / RPC 报错)回 `unsupported`,让前端说「当前引擎不支持」
678
+ * 而不是「未找到工具定义」—— 后者会让用户以为工具名写错了。
679
+ */
680
+ async getToolInfo(name) {
681
+ if (!this.runtime.alive) {
682
+ this.emit({ type: "tool_info", ...unsupportedToolInfo(name) });
683
+ return;
684
+ }
685
+ try {
686
+ const res = await this.runtime.listTools();
687
+ const found = (res.tools ?? []).find((t) => t.name === name);
688
+ this.emit({ type: "tool_info", ...normalizeToolInfo(name, found) });
689
+ }
690
+ catch (err) {
691
+ console.error(`[dsh] listTools 失败 (client=${this.clientId}):`, err);
692
+ this.emit({ type: "tool_info", ...unsupportedToolInfo(name) });
693
+ }
694
+ }
661
695
  /** 从运行时拉取技能清单 → 缓存 → 重推 settings_state(enabled 由 disabledSkills 推导)。 */
662
696
  async refreshSkillsFromRuntime() {
663
697
  if (!this.runtime.alive)
@@ -2590,6 +2624,7 @@ export class DshClientSession {
2590
2624
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2591
2625
  terminalBash: this.settings.terminalBash,
2592
2626
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2627
+ toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
2593
2628
  // DSH 引擎无 customTool 注册面(工具来自 shipped preset),read 目录覆盖面不存在。
2594
2629
  readDirEnabled: true,
2595
2630
  editSoftEnabled: this.settings.editSoftEnabled,
@@ -2603,6 +2638,7 @@ export class DshClientSession {
2603
2638
  parallelReminderEnabled: this.settings.parallelReminderEnabled,
2604
2639
  thinkingWrap: this.settings.thinkingWrap,
2605
2640
  toolsWrap: this.settings.toolsWrap,
2641
+ toolImagesEnabled: this.settings.toolImagesEnabled,
2606
2642
  // DSH 无 skill 全文注入概念,给空保协议完整。
2607
2643
  skillsFullText: [],
2608
2644
  visionBridgeEnabled: false,
@@ -2659,6 +2695,8 @@ export class DshClientSession {
2659
2695
  this.settings.terminalBash = partial.terminalBash;
2660
2696
  if (partial.terminalBashIdleMs !== undefined)
2661
2697
  this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
2698
+ if (partial.toolWatchdogTimeoutMs !== undefined)
2699
+ this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
2662
2700
  if (partial.editSoftEnabled !== undefined)
2663
2701
  this.settings.editSoftEnabled = partial.editSoftEnabled;
2664
2702
  if (partial.questionnaireEnabled !== undefined)
@@ -2671,6 +2709,8 @@ export class DshClientSession {
2671
2709
  this.settings.thinkingWrap = partial.thinkingWrap;
2672
2710
  if (partial.toolsWrap !== undefined)
2673
2711
  this.settings.toolsWrap = partial.toolsWrap;
2712
+ if (partial.toolImagesEnabled !== undefined)
2713
+ this.settings.toolImagesEnabled = partial.toolImagesEnabled;
2674
2714
  if (partial.disabledPlugins !== undefined)
2675
2715
  this.settings.disabledPlugins = partial.disabledPlugins;
2676
2716
  if (partial.uiLayout !== undefined)
@@ -2706,6 +2746,7 @@ export class DshClientSession {
2706
2746
  parallelReminderEnabled: this.settings.parallelReminderEnabled,
2707
2747
  thinkingWrap: this.settings.thinkingWrap,
2708
2748
  toolsWrap: this.settings.toolsWrap,
2749
+ toolImagesEnabled: this.settings.toolImagesEnabled,
2709
2750
  disabledPlugins: this.settings.disabledPlugins,
2710
2751
  uiLayout: normalizeUiLayout(this.settings.uiLayout),
2711
2752
  reviewPrompt: this.settings.reviewPrompt,
@@ -3071,6 +3112,7 @@ export class DshClientSession {
3071
3112
  editSoftEnabled: this.settings.editSoftEnabled,
3072
3113
  thinkingWrap: this.settings.thinkingWrap,
3073
3114
  toolsWrap: this.settings.toolsWrap,
3115
+ toolImagesEnabled: this.settings.toolImagesEnabled,
3074
3116
  disabledPlugins: this.settings.disabledPlugins,
3075
3117
  uiLayout: normalizeUiLayout(this.settings.uiLayout),
3076
3118
  reviewPrompt: this.settings.reviewPrompt,
@@ -5,7 +5,7 @@
5
5
  * 全部为无状态 fs 操作 + 两个自持的 watcher(当前列出目录、git dir),
6
6
  * 经 FilesHost 回调与 ClientSession 解耦。
7
7
  */
8
- import { mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
8
+ import { Dirent, mkdirSync, readFileSync, statSync, writeFileSync, watch } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { resolve, relative, sep } from "node:path";
11
11
  import { pick } from "./i18n.js";
@@ -57,7 +57,7 @@ export function isAbsoluteWirePath(p) {
57
57
  * 不产生结尾斜杠,这里只对补全/直接输入做防御性清理。 */
58
58
  export function normWirePath(p) {
59
59
  if (p.endsWith("/") && p !== "/")
60
- return p.slice(0, -1);
60
+ return p.replace(/\/+$/, "") || "/";
61
61
  return p;
62
62
  }
63
63
  /** wire 绝对路径("C:/Users/x" / "/Users/x" / "C:")→ 原生绝对路径。
@@ -74,7 +74,7 @@ export function wireToAbs(wire) {
74
74
  /** 机器浏览模式下某目录的父级 wire 路径:盘符根("C:")→ 机器根;posix "/" 无父级。 */
75
75
  export function absoluteParent(wire) {
76
76
  const s = normWirePath(wire);
77
- if (s === "" || s === MACHINE_ROOT)
77
+ if (s === "" || s === MACHINE_ROOT || s === "/")
78
78
  return null;
79
79
  const i = s.lastIndexOf("/");
80
80
  if (i < 0)
@@ -150,8 +150,9 @@ export function workspacePath(root, raw) {
150
150
  * system dirs (C:\$Recycle.Bin, Program Files internals, OneDrive placeholders)
151
151
  * throw EPERM/EACCES on open — that must not kill the panel, so it degrades
152
152
  * to an empty listing plus a warning. Directory symlinks/junctions are
153
- * followed so mklink /D folders stay navigable; broken links still show as
154
- * files instead of vanishing. The cap is 4x posix and truncation is reported
153
+ * followed (all platforms — Android/Termux's ~/storage/* entries are symlinks
154
+ * too) so folder links stay navigable; broken links still show as files
155
+ * instead of vanishing. The cap is 4x posix and truncation is reported
155
156
  * via `truncated` instead of happening silently.
156
157
  */
157
158
  async function readDirForUI(abs, rel) {
@@ -178,10 +179,10 @@ async function readDirForUI(abs, rel) {
178
179
  if (ignored.has(d.name))
179
180
  continue;
180
181
  let type;
181
- if (IS_WIN32 && d.isSymbolicLink()) {
182
- // mklink /D symlinks and junctions are reparse points — libuv
183
- // classifies them as links, so isDirectory() is false. Follow the
184
- // target so folder links stay navigable; broken links still show.
182
+ if (d.isSymbolicLink()) {
183
+ // Symlinks (mklink /D reparse points on win32, but also the directory
184
+ // links under Termux's ~/storage) are followed on ALL platforms so
185
+ // folder links stay navigable; broken links still show as files.
185
186
  try {
186
187
  const st = await fs.stat(join(abs, d.name));
187
188
  type = st.isDirectory() ? "dir" : "file";
@@ -210,6 +211,26 @@ async function readDirForUI(abs, rel) {
210
211
  out.length = MAX;
211
212
  return { entries: out, truncated };
212
213
  }
214
+ /** dirent → FileEntry classification: directory symlinks are resolved via
215
+ * stat() on ALL platforms (#262 — Termux's ~/storage/* entries are symlinks;
216
+ * the directory-only cwd picker renders an empty list if they type as files). */
217
+ async function classifyDirent(d, absDir) {
218
+ const fs = await import("node:fs/promises");
219
+ const { join } = await import("node:path");
220
+ let type;
221
+ if (d.isSymbolicLink()) {
222
+ try {
223
+ type = (await fs.stat(join(absDir, d.name))).isDirectory() ? "dir" : "file";
224
+ }
225
+ catch {
226
+ type = "file"; // broken link
227
+ }
228
+ }
229
+ else {
230
+ type = d.isDirectory() ? "dir" : "file";
231
+ }
232
+ return { name: d.name, type };
233
+ }
213
234
  export class FilesService {
214
235
  host;
215
236
  // ---- 当前列出目录的 watcher ----
@@ -248,6 +269,24 @@ export class FilesService {
248
269
  }
249
270
  return out;
250
271
  }
272
+ // Android/Termux: "/" itself is not listable (even `ls /` is denied),
273
+ // so fall back to $HOME + shared storage as the machine-root landing
274
+ // spots — otherwise machine browsing is a dead end on Android.
275
+ try {
276
+ await fsp.readdir("/");
277
+ }
278
+ catch {
279
+ const home = homedir();
280
+ const out = [{ name: home, path: home, type: "dir" }];
281
+ try {
282
+ await fsp.readdir("/storage/emulated/0");
283
+ out.push({ name: "/storage/emulated/0", path: "/storage/emulated/0", type: "dir" });
284
+ }
285
+ catch {
286
+ // no shared storage
287
+ }
288
+ return out;
289
+ }
251
290
  return [{ name: "/", path: "/", type: "dir" }];
252
291
  }
253
292
  /** 单字段错误文本的语言(host 未接线时英文默认)。 */
@@ -286,7 +325,13 @@ export class FilesService {
286
325
  async listFiles(relPath) {
287
326
  const { resolve, sep, relative } = await import("node:path");
288
327
  const root = resolve(this.host.getCwd());
289
- const raw = relPath ?? "";
328
+ let raw = relPath ?? "";
329
+ // Expand a leading "~/" (path-bar input) to the home directory — same
330
+ // rules as completePath/makeDir. The result is absolute, so it lands in
331
+ // the machine-browse branch below; wire paths always use "/".
332
+ if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
333
+ raw = (raw === "~" ? homedir() : homedir() + raw.slice(1)).split(sep).join("/");
334
+ }
290
335
  // ---- 机器根(此电脑:盘符列表)—— 工作区之上的虚拟层 ----
291
336
  if (raw === MACHINE_ROOT || raw === MACHINE_ROOT + "/") {
292
337
  const entries = await this.machineRootEntries();
@@ -342,11 +387,12 @@ export class FilesService {
342
387
  else {
343
388
  this.watchDir(target, rel);
344
389
  }
390
+ const rootWire = normWirePath(root.split(sep).join("/"));
345
391
  this.host.emit({
346
392
  type: "files",
347
393
  path: rel === "" ? "" : rel,
348
- // 工作区根也允许「上一级」→ 机器根(Windows 换盘符 / posix 到 /)。
349
- parent: rel === "" ? MACHINE_ROOT : rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
394
+ // 工作区根也允许「上一级」→ 项目文件夹的上一级(盘符根则到机器根,posix 根无父级)。
395
+ parent: rel === "" ? absoluteParent(rootWire) : rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
350
396
  entries,
351
397
  truncated,
352
398
  });
@@ -399,14 +445,25 @@ export class FilesService {
399
445
  if (ignored.has(d.name))
400
446
  continue;
401
447
  const childRel = rel ? `${rel}/${d.name}` : d.name;
448
+ let isDir = d.isDirectory();
449
+ if (d.isSymbolicLink()) {
450
+ // Follow directory symlinks (Termux's ~/storage/*); the depth
451
+ // cap doubles as a symlink-cycle guard, budgets bound the cost.
452
+ try {
453
+ isDir = (await fsp.stat(join(abs, d.name))).isDirectory();
454
+ }
455
+ catch {
456
+ isDir = false; // broken link — treat as file
457
+ }
458
+ }
402
459
  if (d.name.toLowerCase().includes(q)) {
403
460
  results.push({
404
461
  path: childRel,
405
462
  name: d.name,
406
- type: d.isDirectory() ? "dir" : "file",
463
+ type: isDir ? "dir" : "file",
407
464
  });
408
465
  }
409
- if (d.isDirectory()) {
466
+ if (isDir) {
410
467
  await walk(join(abs, d.name), childRel, depth + 1);
411
468
  }
412
469
  }
@@ -1344,13 +1401,8 @@ export class FilesService {
1344
1401
  empty();
1345
1402
  return;
1346
1403
  }
1347
- const items = dirents
1348
- .filter((d) => !ignoredEntries().has(d.name))
1349
- .map((d) => ({
1350
- name: d.name,
1351
- path: `${drive}/${d.name}`,
1352
- type: (d.isDirectory() ? "dir" : "file"),
1353
- }))
1404
+ const items = (await Promise.all(dirents.filter((d) => !ignoredEntries().has(d.name)).map((d) => classifyDirent(d, `${drive}\\`))))
1405
+ .map((e) => ({ ...e, path: `${drive}/${e.name}` }))
1354
1406
  .sort((a, b) => {
1355
1407
  const aHidden = a.name.startsWith(".");
1356
1408
  const bHidden = b.name.startsWith(".");
@@ -1391,14 +1443,14 @@ export class FilesService {
1391
1443
  return;
1392
1444
  }
1393
1445
  const { join } = await import("node:path");
1394
- const completions = dirents
1446
+ const completions = (await Promise.all(dirents
1395
1447
  .filter((d) => d.name.startsWith(prefix) && !ignoredEntries().has(d.name))
1396
- .map((d) => ({
1397
- name: d.name,
1448
+ .map((d) => classifyDirent(d, dirPart))))
1449
+ .map((e) => ({
1450
+ ...e,
1398
1451
  // Windows users type backslashes — normalize the completion to the
1399
1452
  // wire format ("/") so the picked path round-trips cleanly.
1400
- path: IS_WIN32 ? join(dirPart, d.name).split(sep).join("/") : dirPart + d.name,
1401
- type: (d.isDirectory() ? "dir" : "file"),
1453
+ path: IS_WIN32 ? join(dirPart, e.name).split(sep).join("/") : dirPart + e.name,
1402
1454
  }))
1403
1455
  .sort((a, b) => {
1404
1456
  const aHidden = a.name.startsWith(".");