pi-web-ui 0.72.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. */
@@ -1384,9 +1389,11 @@ export class ClientSession {
1384
1389
  // 父对话 = 真正调用 spawn 的那个会话(本 runtime 所属会话),而不是
1385
1390
  // 派发瞬间的 active——后台对话继续产出时用户可能已切到别的项目,用
1386
1391
  // activeId 会把孩子记到无关会话名下、沉到别的组/底部(issue #95)。
1387
- // ownerId 即本 runtime 所属会话(创建时就已知,见各调用点)。
1392
+ // ownerId 即本 runtime 所属会话(创建时就已知,见各调用点),一身二任:
1393
+ // spawn 的 parentId(子代理记到真正的派发会话名下)+ wait_all 的
1394
+ // selfConvId(调用者自身永不计入等待,防 self-wait deadlock)。
1388
1395
  ...(ownerId
1389
- ? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId))
1396
+ ? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId), undefined, ownerId)
1390
1397
  : makeSubagentTools(this.subagentHost)),
1391
1398
  // 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
1392
1399
  makeMarkersListTool(() => this.activeId, this.markerSvc),
@@ -2689,6 +2696,9 @@ export class ClientSession {
2689
2696
  listModelsConfig() {
2690
2697
  return this.modelAdmin.listModelsConfig();
2691
2698
  }
2699
+ reloadModelsConfig() {
2700
+ return this.modelAdmin.reloadModelsConfig();
2701
+ }
2692
2702
  fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
2693
2703
  return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
2694
2704
  }
@@ -3478,8 +3488,9 @@ export class ClientSession {
3478
3488
  }
3479
3489
  }
3480
3490
  // Cap is per project — conversations of other projects keep their own
3481
- // lists and don't consume this project's slots.
3482
- 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;
3483
3494
  if (openInProject >= MAX_OPEN_CONVERSATIONS) {
3484
3495
  this.emit({
3485
3496
  type: "notice",
@@ -3549,6 +3560,9 @@ export class ClientSession {
3549
3560
  * The active conversation is being left (new_chat / switch_conversation /
3550
3561
  * set_cwd). Runs the running-list lifecycle:
3551
3562
  *
3563
+ * - 子代理豁免:活动的是子代理时永远保留(listed=true,返回 null)——点开
3564
+ * 查看后切走也不释放 runtime,后台任务继续跑、随时可点开看;清理走
3565
+ * dismiss_conversation / dismiss_finished_subagents(用户显式动作)。
3552
3566
  * - still streaming → it becomes a background run: ensure it is listed;
3553
3567
  * - idle + listed + continued → keep it (the user did continue it);
3554
3568
  * - any retained terminal state → keep it listed until the terminals are closed;
@@ -3558,6 +3572,11 @@ export class ClientSession {
3558
3572
  */
3559
3573
  displaceActive() {
3560
3574
  const conv = this.conv;
3575
+ // 子代理不受切换关闭影响(见上)。
3576
+ if (conv.isSubagent) {
3577
+ conv.listed = true;
3578
+ return null;
3579
+ }
3561
3580
  // An isolated reviewer can keep working while the main session is idle;
3562
3581
  // retain that conversation so its review is not disposed when the user
3563
3582
  // switches away without sending another prompt.
@@ -3943,11 +3962,40 @@ export class ClientSession {
3943
3962
  if (changed)
3944
3963
  this.emitConversations();
3945
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
+ }
3946
3988
  /** Dismiss a running conversation from the left-panel list without deleting its
3947
3989
  * transcript file. Only idle (non-streaming) conversations that are not
3948
3990
  * retained by terminal/wake/review state can be dismissed. The session stays
3949
- * in history and can be reopened. */
3950
- 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) {
3951
3999
  const conv = this.convs.get(id);
3952
4000
  if (!conv) {
3953
4001
  this.emit({
@@ -3958,72 +4006,363 @@ export class ClientSession {
3958
4006
  });
3959
4007
  return;
3960
4008
  }
3961
- 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) {
3962
4043
  this.emit({
3963
4044
  type: "notice",
3964
4045
  level: "warning",
3965
- text: "当前对话不能直接移出,请先切换到其他对话",
3966
- 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`,
3967
4048
  });
3968
4049
  return;
3969
4050
  }
3970
- if (!conv.listed) {
3971
- // Not in list anyway — nothing to do.
3972
- 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
+ });
3973
4058
  return;
3974
4059
  }
3975
- // Streaming / retained conversations refuse dismissal — mirrors displaceActive retention.
3976
- // A parent with live children also refuses: dropping it orphans the child rows.
3977
- const hasLiveChild = [...this.convs.values()].some((child) => child.parentId === id);
3978
- const retained = hasLiveChild ||
3979
- shouldRetainActive({
3980
- reviewing: conv.goal.reviewing,
3981
- wizardRunning: conv.wizardRunning,
3982
- streaming: conv.session.isStreaming,
3983
- openTerminals: conv.terminals.countLive(),
3984
- listed: conv.listed,
3985
- promptedSinceActive: conv.promptedSinceActive,
3986
- hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
3987
- 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)`,
3988
4075
  });
3989
- if (retained) {
3990
- 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;
3991
4113
  this.emit({
3992
4114
  type: "notice",
3993
- level: "warning",
3994
- text: `对话「${conv.title}」仍在运行中,请先等待结束或点击停止后再移出`,
3995
- 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`,
3996
4118
  });
4119
+ this.emitConversations();
4120
+ this.flushSnapshot();
4121
+ return;
3997
4122
  }
3998
- 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) {
3999
4129
  this.emit({
4000
4130
  type: "notice",
4001
4131
  level: "warning",
4002
- text: `对话「${conv.title}」还有未关闭的终端,请先关闭终端后再移出`,
4003
- 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)`,
4004
4134
  });
4135
+ this.emitConversations();
4136
+ this.flushSnapshot();
4137
+ return;
4005
4138
  }
4006
- else if (hasLiveChild) {
4007
- this.emit({
4008
- type: "notice",
4009
- level: "warning",
4010
- text: `对话「${conv.title}」还有运行中的子代理,请先移出子代理后再移出`,
4011
- textEn: `Conversation "${conv.title}" still has running subagents remove them first`,
4012
- });
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
+ }
4013
4178
  }
4014
- 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) {
4015
4201
  this.emit({
4016
4202
  type: "notice",
4017
4203
  level: "warning",
4018
- text: `对话「${conv.title}」暂时无法移出(存在待处理的后台任务/审查)`,
4019
- 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)`,
4020
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++;
4021
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
+ });
4022
4251
  return;
4023
4252
  }
4024
- this.removeConversation(id);
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;
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
+ });
4326
+ return;
4327
+ }
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
+ }
4025
4348
  this.emitConversations();
4026
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
+ }
4027
4366
  }
4028
4367
  /** Open a persisted session as the active conversation (from listSessions).
4029
4368
  *
@@ -4062,7 +4401,9 @@ export class ClientSession {
4062
4401
  // is known-good. This keeps a failed history open entirely non-destructive.
4063
4402
  const oldListed = this.conv.listed;
4064
4403
  const displaced = this.displaceActive();
4065
- 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);
4066
4407
  if (openInProject > MAX_OPEN_CONVERSATIONS) {
4067
4408
  // displaceActive() may have promoted a streaming conversation into the
4068
4409
  // running list. Roll that presentation-only mutation back because no
@@ -4466,14 +4807,29 @@ export class ClientSession {
4466
4807
  }
4467
4808
  this.flushSnapshot();
4468
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
+ }
4469
4816
  /** List models that have valid authentication configured. */
4470
4817
  async listModels() {
4471
4818
  try {
4472
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
+ });
4473
4829
  const available = await mr.getAvailable();
4474
4830
  const models = available.map((m) => ({
4475
4831
  id: `${m.provider}/${m.id}`,
4476
- name: m.name,
4832
+ name: this.cleanModelDisplayName(m.name),
4477
4833
  provider: m.provider,
4478
4834
  reasoning: m.reasoning,
4479
4835
  vision: m.input?.includes("image") ?? false,
@@ -1742,7 +1742,9 @@ export class DshClientSession {
1742
1742
  });
1743
1743
  }
1744
1744
  }
1745
- async dismissConversation(id) {
1745
+ async dismissConversation(id, _withFinishedSubagents, force) {
1746
+ // DSH 引擎无第一方子代理:withFinishedSubagents 恒为 no-op(签名与标准引擎对齐)。
1747
+ // force 放行 active/终端限制;运行中仍拒绝(DSH 单 runtime 无法单独 abort 一个对话,请先点停止)。
1746
1748
  const conv = this.convs.get(id);
1747
1749
  if (!conv) {
1748
1750
  this.emit({
@@ -1753,7 +1755,7 @@ export class DshClientSession {
1753
1755
  });
1754
1756
  return;
1755
1757
  }
1756
- if (id === this.activeId) {
1758
+ if (id === this.activeId && !force) {
1757
1759
  this.emit({
1758
1760
  type: "notice",
1759
1761
  level: "warning",
@@ -1775,7 +1777,7 @@ export class DshClientSession {
1775
1777
  });
1776
1778
  return;
1777
1779
  }
1778
- if (conv.terminals.list().length > 0) {
1780
+ if (conv.terminals.list().length > 0 && !force) {
1779
1781
  this.emit({
1780
1782
  type: "notice",
1781
1783
  level: "warning",
@@ -1784,10 +1786,37 @@ export class DshClientSession {
1784
1786
  });
1785
1787
  return;
1786
1788
  }
1789
+ // force + active:先让出 active(切到其他对话或新建),再移除。
1790
+ if (id === this.activeId) {
1791
+ const other = [...this.convs.values()].find((c) => c.id !== id);
1792
+ if (other)
1793
+ await this.switchConversation(other.id);
1794
+ else
1795
+ await this.newChat();
1796
+ if (id === this.activeId) {
1797
+ this.emit({
1798
+ type: "notice",
1799
+ level: "warning",
1800
+ text: `当前对话「${conv.title}」暂时无法移出(无法创建接替对话)`,
1801
+ textEn: `Cannot dismiss the active conversation "${conv.title}" right now (no replacement chat available)`,
1802
+ });
1803
+ return;
1804
+ }
1805
+ }
1787
1806
  this.removeConversation(id);
1788
1807
  this.emitConversations();
1789
1808
  this.flushSnapshot();
1790
1809
  }
1810
+ /** DSH 引擎无第一方子代理(emitConversations 恒 isSubagent:false):批量
1811
+ * 关闭退化为空操作提示,保持与 pi 引擎同一 wire 行为。 */
1812
+ async dismissFinishedSubagents(_parentId) {
1813
+ this.emit({
1814
+ type: "notice",
1815
+ level: "info",
1816
+ text: "没有可关闭的已结束子代理",
1817
+ textEn: "No finished subagents to dismiss",
1818
+ });
1819
+ }
1791
1820
  /** 切换会话:读 JSONL 回放 → 新建 conversation(同一 sessionId 续聊)。 */
1792
1821
  async switchSession(path) {
1793
1822
  try {
@@ -3029,6 +3058,14 @@ export class DshClientSession {
3029
3058
  async listModelsConfig() {
3030
3059
  this.emit({ type: "models_config", providers: [] });
3031
3060
  }
3061
+ async reloadModelsConfig() {
3062
+ this.emit({
3063
+ type: "notice",
3064
+ level: "warning",
3065
+ text: "DSH 引擎不支持自定义 provider",
3066
+ textEn: "The DSH engine does not support custom providers",
3067
+ });
3068
+ }
3032
3069
  async saveModelConfig(_providerId, _config) {
3033
3070
  this.emit({
3034
3071
  type: "notice",