pi-web-ui 0.92.0 → 0.94.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 (57) hide show
  1. package/CHANGELOG.md +74 -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 +577 -127
  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 +125 -8
  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 +79 -27
  13. package/dist/server/http-proxy.js +72 -0
  14. package/dist/server/index.js +84 -17
  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/soft-cap.js +38 -4
  25. package/dist/server/subagents.js +30 -13
  26. package/dist/server/terminals.js +38 -18
  27. package/dist/server/themes.js +15 -0
  28. package/dist/server/tool-info.js +89 -0
  29. package/dist/server/tool-manager.js +107 -14
  30. package/package.json +2 -2
  31. package/plugins/catalog.json +10 -0
  32. package/themes/ayu-light.css +136 -0
  33. package/themes/catppuccin-latte.css +4 -0
  34. package/themes/catppuccin.css +4 -0
  35. package/themes/codex.css +136 -0
  36. package/themes/cyberpunk.css +4 -0
  37. package/themes/dazzle.css +4 -0
  38. package/themes/everforest-light.css +136 -0
  39. package/themes/geist.css +136 -0
  40. package/themes/gruvbox-light.css +136 -0
  41. package/themes/kanagawa-lotus.css +136 -0
  42. package/themes/md-preview.css +4 -0
  43. package/themes/mist.css +4 -0
  44. package/themes/nord.css +4 -0
  45. package/themes/one-dark.css +4 -0
  46. package/themes/paper.css +4 -0
  47. package/themes/rose-pine-dawn.css +136 -0
  48. package/themes/sakura.css +4 -0
  49. package/themes/solarized-light.css +4 -0
  50. package/themes/tokyo-night.css +4 -0
  51. package/themes/white.css +4 -0
  52. package/web/dist/assets/{TerminalPanel-RACpk6AX.js → TerminalPanel-B-CMQuzm.js} +1 -1
  53. package/web/dist/assets/index-18jK6al2.js +364 -0
  54. package/web/dist/assets/index-B3S9MxnN.css +1 -0
  55. package/web/dist/index.html +2 -2
  56. package/web/dist/assets/index-BgrnP9Jz.js +0 -364
  57. 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,
@@ -532,11 +591,28 @@ export class ClientStateStore {
532
591
  }
533
592
  /** Get a single provider's saved key for a project. */
534
593
  getProjectProviderKey(clientId, cwd, provider) {
535
- return this.load()[clientId]?.projectProviderKeys?.[cwd]?.[provider];
594
+ const all = this.load();
595
+ const globalHit = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.projectProviderKeys?.[cwd]?.[provider];
596
+ if (globalHit)
597
+ return globalHit;
598
+ const direct = all[clientId]?.projectProviderKeys?.[cwd]?.[provider];
599
+ if (direct)
600
+ return direct;
601
+ for (const [id, state] of Object.entries(all)) {
602
+ if (id === clientId || id === ClientStateStore.GLOBAL_SETTINGS_KEY)
603
+ continue;
604
+ const hit = state.projectProviderKeys?.[cwd]?.[provider];
605
+ if (hit)
606
+ return hit;
607
+ }
608
+ return undefined;
536
609
  }
537
610
  /** Remember which key was last used for a provider in a project. */
538
611
  saveProjectProviderKey(clientId, cwd, provider, keyName) {
539
612
  const all = this.load();
613
+ const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
614
+ const gMap = (globalState.projectProviderKeys ??= {});
615
+ (gMap[cwd] ??= {})[provider] = keyName;
540
616
  const state = (all[clientId] ??= { projects: [] });
541
617
  const map = (state.projectProviderKeys ??= {});
542
618
  const inner = (map[cwd] ??= {});
@@ -546,6 +622,13 @@ export class ClientStateStore {
546
622
  /** Delete a per-project provider key (e.g. when the key is removed). */
547
623
  deleteProjectProviderKey(clientId, cwd, provider) {
548
624
  const all = this.load();
625
+ const gInner = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.projectProviderKeys?.[cwd];
626
+ if (gInner && provider in gInner) {
627
+ delete gInner[provider];
628
+ if (Object.keys(gInner).length === 0) {
629
+ delete all[ClientStateStore.GLOBAL_SETTINGS_KEY].projectProviderKeys[cwd];
630
+ }
631
+ }
549
632
  const inner = all[clientId]?.projectProviderKeys?.[cwd];
550
633
  if (!inner || !(provider in inner))
551
634
  return;
@@ -613,11 +696,39 @@ export class ClientStateStore {
613
696
  }
614
697
  /** Get the model the user last selected in a project, or undefined. */
615
698
  getProjectModel(clientId, cwd) {
616
- return this.load()[clientId]?.projectModels?.[cwd];
699
+ const all = this.load();
700
+ // 1. 全局项目模型记忆(跨标签页、跨客户端、重启浏览器共享,优先级最高)
701
+ const globalHit = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.projectModels?.[cwd];
702
+ if (globalHit)
703
+ return globalHit;
704
+ // 2. 本客户端记忆
705
+ const direct = all[clientId]?.projectModels?.[cwd];
706
+ if (direct)
707
+ return direct;
708
+ // 3. 其他客户端按最近活跃时间倒序查找兜底
709
+ const candidates = [];
710
+ for (const [id, state] of Object.entries(all)) {
711
+ if (id === clientId || id === ClientStateStore.GLOBAL_SETTINGS_KEY)
712
+ continue;
713
+ const m = state.projectModels?.[cwd];
714
+ if (m) {
715
+ const p = state.projects?.find((proj) => proj.path === cwd);
716
+ candidates.push({ model: m, lastUsed: p?.lastUsed ?? 0 });
717
+ }
718
+ }
719
+ if (candidates.length > 0) {
720
+ candidates.sort((a, b) => b.lastUsed - a.lastUsed);
721
+ return candidates[0].model;
722
+ }
723
+ return undefined;
617
724
  }
618
725
  /** Remember the model last selected in a project (immediate, not after a turn). */
619
726
  saveProjectModel(clientId, cwd, modelId) {
620
727
  const all = this.load();
728
+ // 写入全局项目记忆(保证新标签页/重启浏览器时确定可用)
729
+ const globalState = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
730
+ (globalState.projectModels ??= {})[cwd] = modelId;
731
+ // 同时写入本客户端
621
732
  const state = (all[clientId] ??= { projects: [] });
622
733
  (state.projectModels ??= {})[cwd] = modelId;
623
734
  this.save();
@@ -626,6 +737,12 @@ export class ClientStateStore {
626
737
  * removed from the catalog). */
627
738
  deleteProjectModel(clientId, cwd) {
628
739
  const all = this.load();
740
+ const gMap = all[ClientStateStore.GLOBAL_SETTINGS_KEY]?.projectModels;
741
+ if (gMap && cwd in gMap) {
742
+ delete gMap[cwd];
743
+ if (Object.keys(gMap).length === 0)
744
+ delete all[ClientStateStore.GLOBAL_SETTINGS_KEY].projectModels;
745
+ }
629
746
  const map = all[clientId]?.projectModels;
630
747
  if (!map || !(cwd in map))
631
748
  return;
@@ -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,