pi-web-ui 0.71.0 → 0.73.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,6 +10,10 @@
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
+ // MUST be the first import: rewrites the SDK's installed remote-catalog
14
+ // provider so built-in model lists follow the official pi.dev catalog
15
+ // wholesale (no union merge / no stale built-in leftovers).
16
+ import "./patch-remote-catalog.js";
13
17
  import { spawn } from "node:child_process";
14
18
  import { randomUUID } from "node:crypto";
15
19
  import { createRequire } from "node:module";
@@ -34,7 +38,7 @@ import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates
34
38
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
35
39
  import { WebUIContext, mockThemeProxy } from "./webui-context.js";
36
40
  import { makeEditSoftTool, SOFT_EDIT_TOOL_NAME } from "./edit-soft-tool.js";
37
- import { makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
41
+ import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
38
42
  import { buildAttachmentMessages } from "./attachments.js";
39
43
  import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
40
44
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
@@ -400,8 +404,9 @@ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
400
404
  const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
401
405
  return Number.isFinite(v) && v > 0 ? v : 20 * 60_000;
402
406
  })();
403
- /** Cap on simultaneously open conversations of ONE project (each keeps a full
404
- * runtime alive; conversations of other projects keep their own lists). */
407
+ /** Cap on simultaneously open NON-subagent conversations of ONE project (each keeps a full
408
+ * runtime alive; conversations of other projects keep their own lists).
409
+ * 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
405
410
  const MAX_OPEN_CONVERSATIONS = 8;
406
411
  const DEFAULT_CONV_TITLE = "新对话";
407
412
  /** First user text in a session, truncated for the conversation list. */
@@ -724,6 +729,8 @@ export class ClientSession {
724
729
  conv.listed = true;
725
730
  conv.title = subagentTitle(prompt);
726
731
  this.convs.set(conv.id, conv);
732
+ // 子代理不走 bindSession——这里同样注入面板的重试次数覆盖。
733
+ this.applyRetryOverrides();
727
734
  // 扩展绑定(rpc 模式;uiContext 只给无害的 mock theme/status 槽,避免与主对话
728
735
  // 的 widget 冲突。无 uiContext 时扩展的 ctx.ui.theme.fg 会打到 TUI 真 theme
729
736
  // 代理上抛 "Theme not initialized",每个扩展一条 error toast。)
@@ -1382,9 +1389,11 @@ export class ClientSession {
1382
1389
  // 父对话 = 真正调用 spawn 的那个会话(本 runtime 所属会话),而不是
1383
1390
  // 派发瞬间的 active——后台对话继续产出时用户可能已切到别的项目,用
1384
1391
  // activeId 会把孩子记到无关会话名下、沉到别的组/底部(issue #95)。
1385
- // ownerId 即本 runtime 所属会话(创建时就已知,见各调用点)。
1392
+ // ownerId 即本 runtime 所属会话(创建时就已知,见各调用点),一身二任:
1393
+ // spawn 的 parentId(子代理记到真正的派发会话名下)+ wait_all 的
1394
+ // selfConvId(调用者自身永不计入等待,防 self-wait deadlock)。
1386
1395
  ...(ownerId
1387
- ? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId))
1396
+ ? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId), undefined, ownerId)
1388
1397
  : makeSubagentTools(this.subagentHost)),
1389
1398
  // 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
1390
1399
  makeMarkersListTool(() => this.activeId, this.markerSvc),
@@ -1535,6 +1544,10 @@ export class ClientSession {
1535
1544
  },
1536
1545
  });
1537
1546
  conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
1547
+ // 新会话 / 切换会话 / 强杀重建的必经之路:刚创建的 runtime 用的是 SDK
1548
+ // 默认重试 3 次——这里把面板的 retryMaxAttempts 覆盖注入,否则“设了 6
1549
+ // 次还是按 3 次重试”。已存在会话重复注入是幂等的(同值覆盖)。
1550
+ this.applyRetryOverrides();
1538
1551
  this.scheduleSnapshot();
1539
1552
  this.webUi.refresh();
1540
1553
  this.startWidgetsTimer();
@@ -2671,8 +2684,11 @@ export class ClientSession {
2671
2684
  setProviderApiKey(provider, apiKey) {
2672
2685
  return this.modelAdmin.setProviderApiKey(provider, apiKey);
2673
2686
  }
2674
- clearProviderApiKey(provider) {
2675
- return this.modelAdmin.clearProviderApiKey(provider);
2687
+ async clearProviderApiKey(provider) {
2688
+ await this.modelAdmin.clearProviderApiKey(provider);
2689
+ // The provider is back to unconfigured — drop its key preference in
2690
+ // EVERY project, otherwise each project switch re-tries a restore.
2691
+ this.stateStore.deleteProviderEverywhere(provider.trim());
2676
2692
  }
2677
2693
  listProviders() {
2678
2694
  return this.modelAdmin.listProviders();
@@ -2680,6 +2696,9 @@ export class ClientSession {
2680
2696
  listModelsConfig() {
2681
2697
  return this.modelAdmin.listModelsConfig();
2682
2698
  }
2699
+ reloadModelsConfig() {
2700
+ return this.modelAdmin.reloadModelsConfig();
2701
+ }
2683
2702
  fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
2684
2703
  return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
2685
2704
  }
@@ -2708,23 +2727,27 @@ export class ClientSession {
2708
2727
  this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, active);
2709
2728
  }
2710
2729
  async activateProviderKey(provider, keyName) {
2711
- await this.modelAdmin.activateProviderKey(provider, keyName);
2712
- this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, keyName);
2730
+ const ok = await this.modelAdmin.activateProviderKey(provider, keyName);
2731
+ // Only remember existing keys — a failed switch (deleted key) must not
2732
+ // plant a stale reference that errors on every later project switch.
2733
+ if (ok)
2734
+ this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, keyName);
2735
+ else
2736
+ this.stateStore.deleteProjectProviderKey(this.clientId, this.cwd, provider);
2713
2737
  }
2714
2738
  async removeProviderKey(provider, keyName) {
2715
2739
  await this.modelAdmin.removeProviderKey(provider, keyName);
2716
- const saved = this.stateStore.getProjectProviderKey(this.clientId, this.cwd, provider);
2717
- if (saved === keyName) {
2718
- const active = this.modelAdmin.getActiveKeyName(provider);
2719
- if (active)
2720
- this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, active);
2721
- else
2722
- this.stateStore.deleteProjectProviderKey(this.clientId, this.cwd, provider);
2723
- }
2740
+ // The deletion may have been made from another project: every project
2741
+ // still pinned to the deleted key must follow the key that took over
2742
+ // (or drop the pin when no keys remain), not just the current one.
2743
+ const active = this.modelAdmin.getActiveKeyName(provider);
2744
+ this.stateStore.repointDeletedKeyEverywhere(provider, keyName, active);
2724
2745
  }
2725
2746
  /** Restore per-project provider keys when entering a project. For each
2726
2747
  * provider that has a saved key for `cwd`, activate it if it differs from
2727
- * the current global active. Silent no notice spam on project switch. */
2748
+ * the current global active. Silent + self-healing: a saved key deleted
2749
+ * elsewhere is dropped without notifying (a noisy error here is what
2750
+ * haunted project switches after a key deletion). */
2728
2751
  async restoreProjectProviderKeysForCwd(cwd) {
2729
2752
  const saved = this.stateStore.getProjectProviderKeys(this.clientId, cwd);
2730
2753
  if (!saved)
@@ -2733,15 +2756,17 @@ export class ClientSession {
2733
2756
  const cur = this.modelAdmin.getActiveKeyName(provider);
2734
2757
  if (cur === keyName)
2735
2758
  continue;
2736
- try {
2737
- await this.modelAdmin.activateProviderKey(provider, keyName);
2738
- }
2739
- catch {
2740
- // saved key may have been deleted — ignore
2759
+ if (!this.modelAdmin.hasProviderKey(provider, keyName)) {
2760
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
2761
+ continue;
2741
2762
  }
2763
+ const ok = await this.modelAdmin.activateProviderKey(provider, keyName, { silent: true });
2764
+ if (!ok)
2765
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
2742
2766
  }
2743
2767
  }
2744
- /** When a model is set, ensure its provider's per-project key is restored. */
2768
+ /** When a model is set, ensure its provider's per-project key is restored.
2769
+ * Silent + self-healing like the bulk restore above. */
2745
2770
  async restoreKeyForModel(modelId, cwd) {
2746
2771
  const slash = modelId.indexOf("/");
2747
2772
  if (slash <= 0)
@@ -2753,10 +2778,13 @@ export class ClientSession {
2753
2778
  const cur = this.modelAdmin.getActiveKeyName(provider);
2754
2779
  if (cur === saved)
2755
2780
  return;
2756
- try {
2757
- await this.modelAdmin.activateProviderKey(provider, saved);
2781
+ if (!this.modelAdmin.hasProviderKey(provider, saved)) {
2782
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
2783
+ return;
2758
2784
  }
2759
- catch { }
2785
+ const ok = await this.modelAdmin.activateProviderKey(provider, saved, { silent: true });
2786
+ if (!ok)
2787
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
2760
2788
  }
2761
2789
  /** Remember the just-selected model (and the key that was active for its
2762
2790
  * provider) for the current project. Called IMMEDIATELY on model selection —
@@ -3129,6 +3157,87 @@ export class ClientSession {
3129
3157
  await this.interruptRun(this.conv, "已停止");
3130
3158
  this.flushSnapshot();
3131
3159
  }
3160
+ /** 手动重试上次失败的模型调用:自动重试次数(retryMaxAttempts)用完后
3161
+ * 本轮已停止并标红,用户点「重试」再触发一轮 LLM 调用。不新增用户气泡——
3162
+ * 用 display:false 的 custom 消息 triggerTurn 续跑,模型基于完整上下文
3163
+ * (含上次报错)继续生成。流式中 / 无可重试失败时只发 notice 拒绝。 */
3164
+ async retryLast() {
3165
+ const conv = this.conv;
3166
+ try {
3167
+ if (this.quiesceBlocked())
3168
+ return;
3169
+ const s = this.session;
3170
+ if (s.isStreaming) {
3171
+ this.emit({
3172
+ type: "notice",
3173
+ level: "info",
3174
+ text: "对话正在生成中,无需重试",
3175
+ textEn: "The conversation is still generating — no need to retry",
3176
+ });
3177
+ return;
3178
+ }
3179
+ if (conv.retryState) {
3180
+ this.emit({
3181
+ type: "notice",
3182
+ level: "info",
3183
+ text: "正在自动重试中,稍候即可",
3184
+ textEn: "Auto-retry is in progress — please wait",
3185
+ });
3186
+ return;
3187
+ }
3188
+ // 最后一轮失败的证据:末尾 stopReason=error 的 assistant 消息。
3189
+ let failed = null;
3190
+ try {
3191
+ const msgs = s.agent.state.messages;
3192
+ for (let i = msgs.length - 1; i >= 0; i--) {
3193
+ const m = msgs[i];
3194
+ if (m.role !== "assistant")
3195
+ continue;
3196
+ if ((typeof m.errorMessage === "string" && m.errorMessage.trim()) || m.stopReason === "error") {
3197
+ failed = m;
3198
+ }
3199
+ break;
3200
+ }
3201
+ }
3202
+ catch {
3203
+ // 会话替换中——按无可重试处理
3204
+ }
3205
+ if (!failed) {
3206
+ this.emit({
3207
+ type: "notice",
3208
+ level: "info",
3209
+ text: "没有可重试的失败:上一轮没有报错结束",
3210
+ textEn: "Nothing to retry: the last turn did not end with an error",
3211
+ });
3212
+ return;
3213
+ }
3214
+ // 轨迹用:下一轮 agent_start 消费(否则插件回退为「继续执行」)。
3215
+ conv.pendingTask = "手动重试上次失败的模型请求";
3216
+ await s.sendCustomMessage({
3217
+ customType: "manual-retry",
3218
+ content: [
3219
+ {
3220
+ type: "text",
3221
+ text: "(系统:用户点击了「重试」。请基于完整上下文重新发起上一次失败的模型请求,继续完成用户的任务。)",
3222
+ },
3223
+ ],
3224
+ display: false,
3225
+ }, { triggerTurn: true });
3226
+ conv.promptedSinceActive = true;
3227
+ conv.lastActiveAt = Date.now();
3228
+ conv.lastSdkEventAt = Date.now();
3229
+ conv.stallNoticed = false;
3230
+ }
3231
+ catch (err) {
3232
+ this.emit({
3233
+ type: "notice",
3234
+ level: "error",
3235
+ text: `手动重试失败:${err.message}`,
3236
+ textEn: `Manual retry failed: ${err.message}`,
3237
+ });
3238
+ }
3239
+ this.flushSnapshot();
3240
+ }
3132
3241
  /**
3133
3242
  * Remove ONE queued prompt text (the ✕ on a pending bubble) so it is neither
3134
3243
  * shown nor eventually delivered. The pi SDK has no per-item queue API, so we
@@ -3379,8 +3488,9 @@ export class ClientSession {
3379
3488
  }
3380
3489
  }
3381
3490
  // Cap is per project — conversations of other projects keep their own
3382
- // lists and don't consume this project's slots.
3383
- const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd).length;
3491
+ // lists and don't consume this project's slots. Subagents don't count
3492
+ // (inMemory 后台任务,不占位)。
3493
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd && !c.isSubagent).length;
3384
3494
  if (openInProject >= MAX_OPEN_CONVERSATIONS) {
3385
3495
  this.emit({
3386
3496
  type: "notice",
@@ -3450,6 +3560,9 @@ export class ClientSession {
3450
3560
  * The active conversation is being left (new_chat / switch_conversation /
3451
3561
  * set_cwd). Runs the running-list lifecycle:
3452
3562
  *
3563
+ * - 子代理豁免:活动的是子代理时永远保留(listed=true,返回 null)——点开
3564
+ * 查看后切走也不释放 runtime,后台任务继续跑、随时可点开看;清理走
3565
+ * dismiss_conversation / dismiss_finished_subagents(用户显式动作)。
3453
3566
  * - still streaming → it becomes a background run: ensure it is listed;
3454
3567
  * - idle + listed + continued → keep it (the user did continue it);
3455
3568
  * - any retained terminal state → keep it listed until the terminals are closed;
@@ -3459,6 +3572,11 @@ export class ClientSession {
3459
3572
  */
3460
3573
  displaceActive() {
3461
3574
  const conv = this.conv;
3575
+ // 子代理不受切换关闭影响(见上)。
3576
+ if (conv.isSubagent) {
3577
+ conv.listed = true;
3578
+ return null;
3579
+ }
3462
3580
  // An isolated reviewer can keep working while the main session is idle;
3463
3581
  // retain that conversation so its review is not disposed when the user
3464
3582
  // switches away without sending another prompt.
@@ -3844,11 +3962,40 @@ export class ClientSession {
3844
3962
  if (changed)
3845
3963
  this.emitConversations();
3846
3964
  }
3965
+ /** Dismiss 口径的「已结束子代理」:非 streaming 且无保留态(存活终端/
3966
+ * 审查/后台唤醒等),与 dismissFinishedSubagents 的候选口径一致。 */
3967
+ isDismissableFinishedSubagent(conv) {
3968
+ let streaming = true;
3969
+ try {
3970
+ streaming = conv.session.isStreaming;
3971
+ }
3972
+ catch {
3973
+ // 会话替换中——按运行中处理,绝不误删。
3974
+ }
3975
+ if (streaming)
3976
+ return false;
3977
+ return !shouldRetainActive({
3978
+ reviewing: conv.goal.reviewing,
3979
+ wizardRunning: conv.wizardRunning,
3980
+ streaming: false,
3981
+ openTerminals: conv.terminals.countLive(),
3982
+ listed: false,
3983
+ promptedSinceActive: false,
3984
+ hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
3985
+ hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
3986
+ });
3987
+ }
3847
3988
  /** Dismiss a running conversation from the left-panel list without deleting its
3848
3989
  * transcript file. Only idle (non-streaming) conversations that are not
3849
3990
  * retained by terminal/wake/review state can be dismissed. The session stays
3850
- * in history and can be reopened. */
3851
- async dismissConversation(id) {
3991
+ * in history and can be reopened.
3992
+ *
3993
+ * withFinishedSubagents=true 时连带关闭该对话下已结束的子代理(传递后代,
3994
+ * 与 dismissFinishedSubagents 同口径;active 的子代理跳过)——只关不运行的:
3995
+ * 运行中的后代不受影响;关完后若还有后代剩下(运行中/保留中/active),父级
3996
+ * 暂留并提示。只有运行中的后代(无可关的)时拒绝。不传 + 存在已结束子代理
3997
+ * 后代时拒绝并提示(由前端确认框先问用户,避免静默 orphan)。 */
3998
+ async dismissConversation(id, withFinishedSubagents, force) {
3852
3999
  const conv = this.convs.get(id);
3853
4000
  if (!conv) {
3854
4001
  this.emit({
@@ -3859,72 +4006,363 @@ export class ClientSession {
3859
4006
  });
3860
4007
  return;
3861
4008
  }
3862
- if (id === this.activeId) {
4009
+ if (!conv.listed) {
4010
+ // Not in list anyway — nothing to do.
4011
+ this.emitConversations();
4012
+ return;
4013
+ }
4014
+ // Streaming / retained conversations refuse dismissal — mirrors displaceActive retention.
4015
+ // 运行中的子代理后代也阻止关闭(绝不连带 abort);已结束的子代理后代:
4016
+ // withFinishedSubagents 才连带,否则拒绝并提示(前端确认框先问用户)。
4017
+ const isStreaming = (c) => {
4018
+ try {
4019
+ return c.session.isStreaming;
4020
+ }
4021
+ catch {
4022
+ return true;
4023
+ }
4024
+ };
4025
+ const descendants = collectSubagentDescendantIds([...this.convs.values()].map((c) => ({ id: c.id, parentId: c.parentId, isSubagent: c.isSubagent })), id)
4026
+ .map((did) => this.convs.get(did))
4027
+ .filter((c) => !!c);
4028
+ if (force) {
4029
+ await this.forceDismissConversation(conv, descendants, isStreaming);
4030
+ return;
4031
+ }
4032
+ const runningKids = descendants.filter((c) => isStreaming(c));
4033
+ // 父对话自身的保留态(流式/终端/审查/后台唤醒)——子代理后代另算。
4034
+ const selfStreaming = (() => {
4035
+ try {
4036
+ return conv.session.isStreaming;
4037
+ }
4038
+ catch {
4039
+ return true;
4040
+ }
4041
+ })();
4042
+ if (selfStreaming) {
3863
4043
  this.emit({
3864
4044
  type: "notice",
3865
4045
  level: "warning",
3866
- text: "当前对话不能直接移出,请先切换到其他对话",
3867
- textEn: "The active conversation cannot be removed directly switch to another conversation first",
4046
+ text: `对话「${conv.title}」仍在运行中,请先等待结束或点击停止后再移出`,
4047
+ textEn: `Conversation "${conv.title}" is still running wait for it to finish or press Stop before removing`,
3868
4048
  });
3869
4049
  return;
3870
4050
  }
3871
- if (!conv.listed) {
3872
- // Not in list anyway — nothing to do.
3873
- this.emitConversations();
4051
+ if (conv.terminals.countLive() > 0) {
4052
+ this.emit({
4053
+ type: "notice",
4054
+ level: "warning",
4055
+ text: `对话「${conv.title}」还有未关闭的终端,请先关闭终端后再移出`,
4056
+ textEn: `Conversation "${conv.title}" still has open terminals — close them before removing`,
4057
+ });
3874
4058
  return;
3875
4059
  }
3876
- // Streaming / retained conversations refuse dismissal — mirrors displaceActive retention.
3877
- // A parent with live children also refuses: dropping it orphans the child rows.
3878
- const hasLiveChild = [...this.convs.values()].some((child) => child.parentId === id);
3879
- const retained = hasLiveChild ||
3880
- shouldRetainActive({
3881
- reviewing: conv.goal.reviewing,
3882
- wizardRunning: conv.wizardRunning,
3883
- streaming: conv.session.isStreaming,
3884
- openTerminals: conv.terminals.countLive(),
3885
- listed: conv.listed,
3886
- promptedSinceActive: conv.promptedSinceActive,
3887
- hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
3888
- hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
4060
+ if (shouldRetainActive({
4061
+ reviewing: conv.goal.reviewing,
4062
+ wizardRunning: conv.wizardRunning,
4063
+ streaming: false,
4064
+ openTerminals: 0,
4065
+ listed: conv.listed,
4066
+ promptedSinceActive: conv.promptedSinceActive,
4067
+ hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
4068
+ hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
4069
+ })) {
4070
+ this.emit({
4071
+ type: "notice",
4072
+ level: "warning",
4073
+ text: `对话「${conv.title}」暂时无法移出(存在待处理的后台任务/审查)`,
4074
+ textEn: `Conversation "${conv.title}" cannot be removed right now (pending background task/review)`,
3889
4075
  });
3890
- if (retained) {
3891
- if (conv.session.isStreaming) {
4076
+ return;
4077
+ }
4078
+ const finishedKids = descendants.filter((c) => c.listed && c.id !== this.activeId && this.isDismissableFinishedSubagent(c));
4079
+ if (runningKids.length > 0 && finishedKids.length === 0) {
4080
+ // 只有运行中的后代:连 flag 也变不出可关的,拒绝(绝不连带 abort)。
4081
+ this.emit({
4082
+ type: "notice",
4083
+ level: "warning",
4084
+ text: `对话「${conv.title}」还有 ${runningKids.length} 个运行中的子代理,请先等待结束或停止后再移出`,
4085
+ textEn: `Conversation "${conv.title}" still has ${runningKids.length} running subagent(s) — wait for them to finish or stop them before removing`,
4086
+ });
4087
+ return;
4088
+ }
4089
+ if (finishedKids.length > 0 && !withFinishedSubagents) {
4090
+ this.emit({
4091
+ type: "notice",
4092
+ level: "warning",
4093
+ text: `对话「${conv.title}」下还有 ${finishedKids.length} 个已结束的子代理:连带关闭请确认,仅关闭父级请先在右键菜单清理子代理`,
4094
+ textEn: `Conversation "${conv.title}" still has ${finishedKids.length} finished subagent(s): confirm to dismiss them together, or clear the subagents first (right-click menu) to dismiss only the parent`,
4095
+ });
4096
+ return;
4097
+ }
4098
+ // 只关不运行的:运行中的后代绝不连带 abort;关完后若还有后代剩下
4099
+ // (运行中/保留中/active),父级暂留并提示。
4100
+ let removedKids = 0;
4101
+ for (const kid of finishedKids) {
4102
+ if (kid.id === this.activeId)
4103
+ continue;
4104
+ if (this.convs.get(kid.id) !== kid)
4105
+ continue;
4106
+ this.removeConversation(kid.id);
4107
+ removedKids++;
4108
+ }
4109
+ if (withFinishedSubagents && removedKids > 0) {
4110
+ const remaining = descendants.filter((c) => this.convs.get(c.id) === c);
4111
+ if (remaining.length > 0) {
4112
+ const stillRunning = remaining.filter((c) => isStreaming(c)).length;
3892
4113
  this.emit({
3893
4114
  type: "notice",
3894
- level: "warning",
3895
- text: `对话「${conv.title}」仍在运行中,请先等待结束或点击停止后再移出`,
3896
- textEn: `Conversation "${conv.title}" is still running wait for it to finish or press Stop before removing`,
4115
+ level: "info",
4116
+ text: `已关闭 ${removedKids} 个已结束的子代理,还有 ${remaining.length} 个子代理未关闭${stillRunning > 0 ? `(${stillRunning} 个运行中)` : ""},父对话暂留`,
4117
+ textEn: `Dismissed ${removedKids} finished subagent(s); ${remaining.length} subagent(s) remain${stillRunning > 0 ? ` (${stillRunning} running)` : ""}, keeping the parent`,
3897
4118
  });
4119
+ this.emitConversations();
4120
+ this.flushSnapshot();
4121
+ return;
3898
4122
  }
3899
- else if (conv.terminals.countLive() > 0) {
4123
+ }
4124
+ // Dismissing the ACTIVE conversation: move active elsewhere first
4125
+ // (another listed conversation, else a fresh chat), then remove.
4126
+ if (id === this.activeId) {
4127
+ const vacated = await this.vacateActive(id);
4128
+ if (!vacated) {
3900
4129
  this.emit({
3901
4130
  type: "notice",
3902
4131
  level: "warning",
3903
- text: `对话「${conv.title}」还有未关闭的终端,请先关闭终端后再移出`,
3904
- textEn: `Conversation "${conv.title}" still has open terminals close them before removing`,
4132
+ text: `当前对话「${conv.title}」暂时无法移出(无法创建接替对话)`,
4133
+ textEn: `Cannot dismiss the active conversation "${conv.title}" right now (no replacement chat available)`,
3905
4134
  });
4135
+ this.emitConversations();
4136
+ this.flushSnapshot();
4137
+ return;
3906
4138
  }
3907
- else if (hasLiveChild) {
3908
- this.emit({
3909
- type: "notice",
3910
- level: "warning",
3911
- text: `对话「${conv.title}」还有运行中的子代理,请先移出子代理后再移出`,
3912
- textEn: `Conversation "${conv.title}" still has running subagents remove them first`,
3913
- });
4139
+ }
4140
+ this.removeConversation(id);
4141
+ this.emitConversations();
4142
+ this.flushSnapshot();
4143
+ }
4144
+ /** Move the active marker away from id so that conversation can be removed.
4145
+ * Prefers another listed conversation; falls back to creating a fresh chat.
4146
+ * Returns true when id is no longer active. */
4147
+ async vacateActive(id) {
4148
+ if (id !== this.activeId)
4149
+ return true;
4150
+ const other = [...this.convs.values()].find((c) => c.id !== id && c.listed);
4151
+ if (other) {
4152
+ await this.switchConversation(other.id);
4153
+ }
4154
+ else {
4155
+ await this.newChat();
4156
+ }
4157
+ return this.activeId !== id;
4158
+ }
4159
+ /** 强行关闭:中止自身运行(如在跑)与全部子代理后代(运行中的也停),
4160
+ * 再整体移出;终端/审查/后台唤醒等保留态一并放行。active 的目标先让出
4161
+ * active(vacateActive),active 的后代跳过、让出后再补移。 */
4162
+ async forceDismissConversation(conv, descendants, isStreaming) {
4163
+ const title = conv.title;
4164
+ let stopped = 0;
4165
+ for (const d of descendants) {
4166
+ if (d.id === conv.id)
4167
+ continue;
4168
+ if (this.convs.get(d.id) !== d)
4169
+ continue;
4170
+ if (isStreaming(d)) {
4171
+ try {
4172
+ await this.subagentHost.stopSubagent(d.id);
4173
+ stopped++;
4174
+ }
4175
+ catch {
4176
+ // best effort — removal below disposes the runtime anyway.
4177
+ }
3914
4178
  }
3915
- else {
4179
+ }
4180
+ let selfAborted = false;
4181
+ if (isStreaming(conv)) {
4182
+ selfAborted = true;
4183
+ await this.interruptRun(conv, "已强行关闭");
4184
+ }
4185
+ let removedKids = 0;
4186
+ const deferred = [];
4187
+ for (const d of descendants) {
4188
+ const cur = this.convs.get(d.id);
4189
+ if (!cur || cur.id === conv.id)
4190
+ continue;
4191
+ if (cur.id === this.activeId) {
4192
+ deferred.push(cur);
4193
+ continue;
4194
+ }
4195
+ this.removeConversation(cur.id);
4196
+ removedKids++;
4197
+ }
4198
+ if (conv.id === this.activeId) {
4199
+ const vacated = await this.vacateActive(conv.id);
4200
+ if (!vacated) {
3916
4201
  this.emit({
3917
4202
  type: "notice",
3918
4203
  level: "warning",
3919
- text: `对话「${conv.title}」暂时无法移出(存在待处理的后台任务/审查)`,
3920
- textEn: `Conversation "${conv.title}" cannot be removed right now (pending background task/review)`,
4204
+ text: `对话「${title}」暂时无法强行关闭(无法创建接替对话)`,
4205
+ textEn: `Cannot force-dismiss conversation "${title}" right now (no replacement chat available)`,
3921
4206
  });
4207
+ this.emitConversations();
4208
+ this.flushSnapshot();
4209
+ return;
4210
+ }
4211
+ }
4212
+ for (const d of deferred) {
4213
+ if (this.convs.get(d.id) === d && d.id !== this.activeId) {
4214
+ this.removeConversation(d.id);
4215
+ removedKids++;
4216
+ }
4217
+ }
4218
+ if (this.convs.get(conv.id) === conv && conv.id !== this.activeId) {
4219
+ this.removeConversation(conv.id);
4220
+ }
4221
+ this.emitConversations();
4222
+ this.flushSnapshot();
4223
+ const remaining = descendants.filter((c) => this.convs.get(c.id) === c).length;
4224
+ this.emit({
4225
+ type: "notice",
4226
+ level: "info",
4227
+ text: `已强行关闭对话「${title}」${removedKids > 0 ? `(含 ${removedKids} 个子代理)` : ""}${selfAborted ? ",本轮运行已中止" : ""}${stopped > 0 ? `,${stopped} 个运行中的子代理已中止` : ""}${remaining > 0 ? `;还有 ${remaining} 个子代理未关闭(已切为当前对话)` : ""}`,
4228
+ textEn: `Force-dismissed conversation "${title}"${removedKids > 0 ? ` (incl. ${removedKids} subagent(s))` : ""}${selfAborted ? ", its run was aborted" : ""}${stopped > 0 ? `, ${stopped} running subagent(s) stopped` : ""}${remaining > 0 ? `; ${remaining} subagent(s) remain (now active)` : ""}`,
4229
+ });
4230
+ }
4231
+ /** Bulk-dismiss finished subagents (left-panel right-click menu).
4232
+ *
4233
+ * parentId omitted = every finished subagent in the running list;
4234
+ * given = the transitive subagent descendants of that conversation
4235
+ * (children, grandchildren, … — parentId chain followed recursively),
4236
+ * plus the conversation itself when IT is a finished subagent.
4237
+ * Finished = idle (not streaming, no retained terminal/review/wake
4238
+ * state). Running ones are skipped, never aborted. Children are removed
4239
+ * before parents so the "parent with live children refuses" guard in
4240
+ * dismissConversation never blocks the batch. The active conversation is
4241
+ * never removed. */
4242
+ async dismissFinishedSubagents(parentId) {
4243
+ const root = parentId?.trim() ? parentId.trim() : undefined;
4244
+ if (root && !this.convs.has(root)) {
4245
+ this.emit({
4246
+ type: "notice",
4247
+ level: "warning",
4248
+ text: "该对话不存在或已关闭",
4249
+ textEn: "This conversation does not exist or is already closed",
4250
+ });
4251
+ return;
4252
+ }
4253
+ // Collect the subtree: every conversation whose parentId chain leads to
4254
+ // root (or every subagent when root is omitted). Child-before-parent
4255
+ // order via depth so parents become dismissable as children leave.
4256
+ const depthOf = (id) => {
4257
+ let d = 0;
4258
+ let cur = this.convs.get(id);
4259
+ const seen = new Set([id]);
4260
+ while (cur?.parentId) {
4261
+ if (seen.has(cur.parentId))
4262
+ break;
4263
+ seen.add(cur.parentId);
4264
+ d++;
4265
+ cur = this.convs.get(cur.parentId);
4266
+ if (!cur)
4267
+ break;
3922
4268
  }
4269
+ return d;
4270
+ };
4271
+ const inScope = (conv) => {
4272
+ if (!conv.isSubagent)
4273
+ return false;
4274
+ if (conv.id === this.activeId)
4275
+ return false;
4276
+ if (!conv.listed)
4277
+ return false;
4278
+ if (!root)
4279
+ return true;
4280
+ if (conv.id === root)
4281
+ return true;
4282
+ let cur = conv;
4283
+ const seen = new Set();
4284
+ while (cur?.parentId) {
4285
+ if (cur.parentId === root)
4286
+ return true;
4287
+ if (seen.has(cur.parentId))
4288
+ return false;
4289
+ seen.add(cur.parentId);
4290
+ cur = this.convs.get(cur.parentId);
4291
+ if (!cur)
4292
+ return false;
4293
+ }
4294
+ return false;
4295
+ };
4296
+ const isStreaming = (conv) => {
4297
+ try {
4298
+ return conv.session.isStreaming;
4299
+ }
4300
+ catch {
4301
+ return true;
4302
+ }
4303
+ };
4304
+ const candidates = [...this.convs.values()]
4305
+ .filter(inScope)
4306
+ // Running first would be pointless — drop streaming/retained up front.
4307
+ .filter((conv) => !isStreaming(conv))
4308
+ .filter((conv) => !shouldRetainActive({
4309
+ reviewing: conv.goal.reviewing,
4310
+ wizardRunning: conv.wizardRunning,
4311
+ streaming: false,
4312
+ openTerminals: conv.terminals.countLive(),
4313
+ listed: false,
4314
+ promptedSinceActive: false,
4315
+ hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
4316
+ hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
4317
+ }))
4318
+ .sort((a, b) => depthOf(b.id) - depthOf(a.id));
4319
+ if (candidates.length === 0) {
4320
+ this.emit({
4321
+ type: "notice",
4322
+ level: "info",
4323
+ text: "没有可关闭的已结束子代理",
4324
+ textEn: "No finished subagents to dismiss",
4325
+ });
3923
4326
  return;
3924
4327
  }
3925
- this.removeConversation(id);
4328
+ let removed = 0;
4329
+ let skippedRunning = 0;
4330
+ for (const conv of candidates) {
4331
+ const cur = this.convs.get(conv.id);
4332
+ if (!cur || cur.id === this.activeId)
4333
+ continue;
4334
+ if (isStreaming(cur)) {
4335
+ skippedRunning++;
4336
+ continue;
4337
+ }
4338
+ // Re-check live children: earlier removals in this same batch may
4339
+ // have cleared the guard; still-running children block the parent.
4340
+ const liveChild = [...this.convs.values()].some((child) => child.parentId === cur.id && isStreaming(child));
4341
+ if (liveChild) {
4342
+ skippedRunning++;
4343
+ continue;
4344
+ }
4345
+ this.removeConversation(cur.id);
4346
+ removed++;
4347
+ }
3926
4348
  this.emitConversations();
3927
4349
  this.flushSnapshot();
4350
+ if (removed > 0) {
4351
+ this.emit({
4352
+ type: "notice",
4353
+ level: "info",
4354
+ text: `已关闭 ${removed} 个已结束的子代理${skippedRunning > 0 ? `(${skippedRunning} 个仍在运行,已跳过)` : ""}`,
4355
+ textEn: `Dismissed ${removed} finished subagent(s)${skippedRunning > 0 ? ` (${skippedRunning} still running, skipped)` : ""}`,
4356
+ });
4357
+ }
4358
+ else {
4359
+ this.emit({
4360
+ type: "notice",
4361
+ level: "info",
4362
+ text: "没有可关闭的已结束子代理(剩余的仍在运行)",
4363
+ textEn: "No finished subagents to dismiss (the rest are still running)",
4364
+ });
4365
+ }
3928
4366
  }
3929
4367
  /** Open a persisted session as the active conversation (from listSessions).
3930
4368
  *
@@ -3963,7 +4401,9 @@ export class ClientSession {
3963
4401
  // is known-good. This keeps a failed history open entirely non-destructive.
3964
4402
  const oldListed = this.conv.listed;
3965
4403
  const displaced = this.displaceActive();
3966
- const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length + 1 - (displaced?.cwd === targetCwd ? 1 : 0);
4404
+ const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd && !c.isSubagent).length +
4405
+ 1 -
4406
+ (displaced?.cwd === targetCwd && !displaced?.isSubagent ? 1 : 0);
3967
4407
  if (openInProject > MAX_OPEN_CONVERSATIONS) {
3968
4408
  // displaceActive() may have promoted a streaming conversation into the
3969
4409
  // running list. Roll that presentation-only mutation back because no
@@ -4367,14 +4807,29 @@ export class ClientSession {
4367
4807
  }
4368
4808
  this.flushSnapshot();
4369
4809
  }
4810
+ /** Strip the "(New)" freshness marker some catalogs append to display names
4811
+ * (pi.dev data, e.g. "DeepSeek V4 Pro (New)") — display-only; the model id
4812
+ * is untouched so switching still uses the exact official id. */
4813
+ cleanModelDisplayName(name) {
4814
+ return name.replace(/\s*\(new\)$/i, "").trim();
4815
+ }
4370
4816
  /** List models that have valid authentication configured. */
4371
4817
  async listModels() {
4372
4818
  try {
4373
4819
  const mr = this.runtime.services.modelRuntime;
4820
+ // Reconcile built-in provider catalogs with the official pi.dev
4821
+ // endpoint before listing: within the SDK's 4h freshness window this
4822
+ // is a fast 304; past it the newest catalog is downloaded WHOLESALE
4823
+ // (patch-remote-catalog.ts) — no union merge, no stale built-in
4824
+ // leftovers, no "新增 N 个模型" noise. Network failure falls back to
4825
+ // the cached catalog silently.
4826
+ await mr.refresh({ allowNetwork: true, signal: AbortSignal.timeout(15_000) }).catch(() => {
4827
+ // list must never fail because the catalog sync did
4828
+ });
4374
4829
  const available = await mr.getAvailable();
4375
4830
  const models = available.map((m) => ({
4376
4831
  id: `${m.provider}/${m.id}`,
4377
- name: m.name,
4832
+ name: this.cleanModelDisplayName(m.name),
4378
4833
  provider: m.provider,
4379
4834
  reasoning: m.reasoning,
4380
4835
  vision: m.input?.includes("image") ?? false,