pi-web-ui 0.98.0 → 0.99.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.
package/CHANGELOG.md CHANGED
@@ -12,13 +12,41 @@
12
12
 
13
13
  暂无未发布内容。
14
14
 
15
+ ## [0.99.0] — 2026-10-03
16
+
17
+ ### Added
18
+
19
+ - **手机端边缘横滑抽屉手势** —— 从屏幕左/右边缘向内横滑拉出左侧会话面板或右侧工具面板,手势支持全行程实时跟手跟随、常驻遮罩层透明度渐显,并具备横向主导轴向识别与代码块/宽表格横向滚动元素避让。回归:`swipe-drawer.test.ts`、`swipe-drawer-ui-test`。
20
+ - **侧边图标停靠栏竖轴对齐(#443)** —— 侧边停靠栏(`SideDock`)支持「靠上 / 居中 / 靠下」三档竖轴对齐模式,三段贴边药丸自适应内容高度,并在设置页「界面布局」对齐选项及拖拽式图标编辑模式中打通。回归:`ui-layout-edit.test.ts`、`side-dock-align-ui-test`。
21
+
22
+ ### Fixed
23
+
24
+ - **任务计划跨会话隔离防串台** —— `PlanManager` 架构重构:按持久化 `sessionId` 独立落盘至 `<dataDir>/plans.json`,而运行时按 `conversationId` 存入只活在进程内存的临时缓存,启动与载入时严格过滤易变短 ID(`c\d+`),绑定新会话时主动清理旧内存缓存,杜绝服务重启或短 ID 复用导致新会话被上一会话的任务串台附体。回归:`plan-state.test.ts`。
25
+ - **工具契约与运行时脱节修复(#462、#537)** —— 落地三处未对齐的契约细节:`eval` 将 `python` 别名统一归一为 `py`,非法语言显式抛错提示候选;`patch` 真正接入 schema 声明的 `timeout`(1-300s)超时控制并通过 Promise.race 竞速;`bash` 的 `head`/`tail` 参数严密钳制在 schema 上限 5000 以内,防止异常大数冲垮工具结果缓冲与转录。
26
+ - **目标模式 `stopDelegated` 串行化与时序窗口(#464、#536)** —— `stopDelegated` 改为按会话串行化队列执行,`setGoal`/`clearGoal`/`stopAllGoals` 增加 `await` 落定,解决满员边缘重设目标时旧执行者尚未完全移出、同步名额检查误报「名额已满」的问题。
27
+
28
+ ### Changed
29
+
30
+ - **抽出 CollapsibleHead 公共壳组件(#476、#539)** —— 提取统一的 `CollapsibleHead` 组件,将 `ThinkingBlock`、`ToolCallBlock` 及 `Message`(附件卡/压缩摘要/技能卡)五处内联的可折叠区块头骨架代码收敛统一,键控行为与 ARIA 规范保持一致。
31
+ - **清理 memoizeWithTtl 死码并为手写 TTL 补充注释(#470、#538)** —— 移除全仓零消费的 `memoizeWithTtl` 死码,并为 `agent-service` 的 `piCliProbe`(安装后即时失效)与 `model-enrich` 的 `catalogCache`(双键异步 fetch)补充语义注释。
32
+
33
+ <!-- auto-i18n:start -->
34
+
35
+ ### i18n
36
+
37
+ - 前端新增 key(3):`uiLayoutAlignTop`、`uiLayoutAlignMiddle`、`uiLayoutAlignBottom`
38
+
39
+ <!-- auto-i18n:end -->
40
+
15
41
  ## [0.98.0] — 2026-10-03
16
42
 
17
43
  ### Added
18
44
 
19
45
  - **聊天内容引用(#430)** —— 选中消息里的文字或代码,点浮动按钮即可把原文作为引用卡片加进输入框:支持多条引用、展开查看、逐条移除与重复去重,发送后能在历史消息里回看,编辑重问时保留。标准引擎与 DSH 引擎的消息序列化、草稿恢复、流式提问都已适配。回归:`quote-selection.test.ts`、`text-quote.test.ts`、`dsh-edit-quotes.test.ts`、`quote-selection-ui-test.mjs`。
20
- - **侧边图标停靠栏改为布局内槽位 + 拖拽式图标编辑模式(#443)** —— `SideDock` 不再是 `position:fixed` 贴边浮层,而是 `.layout` 里的 flex 子项:面板与主区主动向它让出宽度,不再压住面板里的按钮;该侧没有图标时整条不渲染(连宽度都不占)。想要旧行为可在「设置 → 界面布局 → 侧边图标悬浮显示」(`uiLayout.sideDockFloat`)打开。新增**图标编辑模式**(顶栏「⋯」→「编辑图标」):用 pointer 事件把顶栏、底栏、左右侧边栏四个栏与「待放回」托盘里的条目直接拖进同一份 `uiLayout`(order/align/slots/hidden),与设置页布局互通;「⋯」溢出菜单因此改为顶栏有任意条目时常驻(否则进不去编辑面板)。回归:`ui-layout-edit.test.ts`、`host-icon.test.ts`、`ui-layout-ui-test`(真 Chrome)。
21
- - **顶栏/底栏条目可移动到上下左右 + 全屏预览增强** —— 条目右键菜单新增「移至顶部 / 底部 / 左侧 / 右侧」,左右侧边停靠栏可折叠展开,偏好持久化;文件全屏预览同步增强。
46
+ - **图标编辑模式:拖拽调整四个栏的布局(#443)** —— 顶栏「⋯」→「编辑图标」进入。顶栏 / 底栏 / 左侧边栏 / 右侧边栏四个栏与「待放回」托盘同处一面板,把条目从一个栏拖到另一个栏、或拖回托盘,即直接改写同一份 `uiLayout`(order / align / slots / hidden),与设置页的布局选项实时互通;内置条目的图标名登记在 `host-icon.tsx`(否则编辑面板里只剩文字)。拖拽用 pointer 事件而非 HTML5 DnD(DnD 在触屏上不触发)。「⋯」溢出菜单因此改为顶栏有任意条目时常驻——否则用户根本进不来。回归:`ui-layout-edit.test.ts`、`host-icon.test.ts`、`ui-layout-ui-test`(真 Chrome)。
47
+ - **顶栏/底栏条目可移动到上下左右(右键路径)** —— 条目右键菜单新增「移至顶部 / 底部 / 左侧 / 右侧」,与上面的拖拽是同一份 `uiLayout` 的两个入口;左右侧边停靠栏可折叠展开,偏好持久化。
48
+ - **侧边图标停靠栏改为布局内槽位(#443)** —— `SideDock` 不再是 `position:fixed` 贴边浮层,而是 `.layout` 里的 flex 子项:面板与主区主动向它让出宽度,不再压住面板里的按钮;该侧没有图标时整条不渲染(连宽度都不占)。想要旧行为可在「设置 → 界面布局 → 侧边图标悬浮显示」(`uiLayout.sideDockFloat`)打开。
49
+ - **全屏预览增强** —— 预览头双击(避开按钮 / 链接 / 输入框)切换全屏;图片预览点击图片即关闭(`clickToCloseImage`);HTML 预览工具条新增「在新标签页打开」外链(`openInNewTab`);不需要编辑栏时页脚不再占位。
22
50
  - **子代理 `wait_all` 阻塞上限可配(#449)** —— 上限从硬编码 60 秒提升到 0.8×看门狗(默认约 16 分钟),可用 `PI_WEB_SUBAGENT_WAIT_CAP_SECONDS` 覆盖:长子代理任务不再被迫每 60 秒重发一次 `wait_all` 烧轮次。
23
51
  - **任务计划跨重启/跨刷新持久化** —— 计划不再只活在内存:按 `sessionId` 落盘到 `<dataDir>/plans.json`,并随会话转录(`plan/update` custom entry)一起走;重建会话时按「落盘文件 → 转录 → 历史 `plan_update` 工具调用」三重兼容顺序回放,旧会话也能找回看板。回归:`plan-state.test.ts`。
24
52
  - **任务看板折叠态精简为单行状态条** —— 折叠时只留「标题 + 进度徽标 + 当前步骤」,进度以底边 2px 细线呈现(零额外高度);「净室执行 / 复制 Markdown / 清空」收进展开态,「开始实施」在两种状态都可达。
@@ -94,6 +122,7 @@
94
122
  - **`release-notes.mjs --write-changelog` 替换串改函数形式(#498, #515)** —— i18n 文案含 `$&`、`` $` `` 等替换模式时不再静默写坏 `CHANGELOG.md`。
95
123
 
96
124
  <!-- auto-i18n:start -->
125
+
97
126
  ### i18n
98
127
 
99
128
  - 前端新增 key(26):`openInNewTab`、`clickToCloseImage`、`compactedHistoryNotFound`、`uiLayoutSideDockFloat`、`uiIconEdit`、`uiIconEditTitle`、`uiIconEditHint`、`uiIconEditTray`、`uiIconEditDropHere`、`uiLayoutSidebarLeft`、`uiLayoutSidebarRight`、`uiLayoutPosition`、`uiLayoutPosTop`、`uiLayoutPosBottom`、`uiLayoutPosLeft`、`uiLayoutPosRight`、`moveToTop`、`moveToBottom`、`moveToLeft`、`moveToRight`、`sideDockCollapse`、`sideDockExpand`、`quoteSelection`、`quoteText`、`quoteSource`、`removeQuote`
@@ -104,7 +133,6 @@
104
133
  - 服务端文案变更(2):`markers.todo.dep.blocks.updated`、`subagents.wait.pending`
105
134
  - 服务端删除 key(1):`markers.todo.list.empty`
106
135
 
107
-
108
136
  ## [0.97.0] — 2026-09-26
109
137
 
110
138
  ### Added
@@ -1453,7 +1481,8 @@ when?, children?}`,也收 `topbar` / `settings` 这类简写别名);宿主
1453
1481
  - 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
1454
1482
  - 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
1455
1483
 
1456
- [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.98.0...main
1484
+ [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.99.0...main
1485
+ [0.99.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.99.0
1457
1486
  [0.98.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.98.0
1458
1487
  [0.97.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.97.0
1459
1488
  [0.96.1]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.96.1
@@ -5352,7 +5352,9 @@ export class ClientSession {
5352
5352
  compaction: conv.compactionState ?? null,
5353
5353
  pendingQuestion: this.pendingQuestionForSnapshot(),
5354
5354
  pendingApproval: this.pendingApprovalForSnapshot(),
5355
- plan: this.planManager.getPlan(this.activeId),
5355
+ // 任务计划看板状态同样是会话级:快照恒给 PlanState 或 null(不用 undefined),
5356
+ // 否则 snapshot_delta 里 key 缺席 → 前端 spread 浅合并会残留上一对话的 plan。
5357
+ plan: this.planManager.getPlan(this.activeId) ?? null,
5356
5358
  // 计划模式是**会话级**开关:快照恒给布尔(不用 undefined),否则
5357
5359
  // snapshot_delta 里 key 缺席 → 前端 spread 合并会残留上一对话的 true。
5358
5360
  planMode: conv?.planMode === true,
@@ -5853,7 +5855,7 @@ export class ClientSession {
5853
5855
  const sourceGoal = sourceConv?.goal?.goal;
5854
5856
  await this.newChat();
5855
5857
  const targetConv = this.conv;
5856
- this.planManager.setPlan(targetConv.id, steps);
5858
+ this.planManager.setPlan(targetConv.id, steps, undefined, targetConv?.session?.sessionId);
5857
5859
  if (sourceGoal) {
5858
5860
  try {
5859
5861
  await this.goalSvc.setGoal(sourceGoal, { targetConvId: targetConv.id, autoStart: false });
@@ -6061,6 +6063,8 @@ export class ClientSession {
6061
6063
  * the forked child deadlocks between fork and exec. This applies to
6062
6064
  * asynchronous spawns too — the previous async probe reproduced the hang.
6063
6065
  */
6066
+ // 手写 TTL 缓存而非通用 memoize:安装 pi 后 invalidatePiCliProbe() 要立即
6067
+ // 失效重探,通用 TTL memoize 不带失效通道(issue #470 处置说明)。
6064
6068
  static piCliProbe = null;
6065
6069
  static PI_CLI_PROBE_TTL_MS = 10_000;
6066
6070
  isPiCliInstalled() {
@@ -8741,7 +8745,8 @@ export class ClientSession {
8741
8745
  // this branch normally can't exist — kept as a safety net).
8742
8746
  const isBlank = (c) => {
8743
8747
  try {
8744
- return c.session.getSessionStats().totalMessages === 0 && c.terminals.list().length === 0;
8748
+ const hasPlan = (this.planManager.getPlan(c.id)?.steps.length ?? 0) > 0;
8749
+ return c.session.getSessionStats().totalMessages === 0 && c.terminals.list().length === 0 && !hasPlan;
8745
8750
  }
8746
8751
  catch {
8747
8752
  // session being replaced — treat as used so we don't switch onto it
@@ -9208,6 +9213,7 @@ export class ClientSession {
9208
9213
  // ignore
9209
9214
  }
9210
9215
  this.convs.delete(id);
9216
+ this.planManager.unbindConversation(id);
9211
9217
  this.clearAllToolWatchdogs(conv);
9212
9218
  // 关对话 → 连它的 eval 内核(Python/Node 子进程 + 临时沙箱目录)一起回收:
9213
9219
  // 这些进程是 detached 进程组,父进程退出不会自动带走它们。
@@ -523,7 +523,28 @@ export function makeEvalTool(opts) {
523
523
  }),
524
524
  execute: async (_id, params) => {
525
525
  const lang = getLang();
526
- const language = params.language ?? "py";
526
+ let language = params.language ?? "py";
527
+ if (language === "python")
528
+ language = "py"; // 常见笔误别名(issue #462)
529
+ if (language !== "py" && language !== "js" && language !== "ts") {
530
+ // schema 已声明 enum(["py","js","ts"])(#417),但那只影响提示不拦运行时;
531
+ // getKernel 的 normLang 只映射 ts,其余任意值会原样建出名为它的 Node 内核,
532
+ // Python 代码被 node 执行报 JS 语法错且模型无从知道是键写错。
533
+ const text = pick(lang, `错误:language 仅支持 py / js / ts,收到「${language}」。`, `Error: language must be one of py / js / ts; got "${language}".`);
534
+ return {
535
+ content: [{ type: "text", text }],
536
+ details: {
537
+ language,
538
+ durationMs: 0,
539
+ ok: false,
540
+ stdout: "",
541
+ stderr: "",
542
+ result: null,
543
+ error: text,
544
+ reset: params.reset ?? false,
545
+ },
546
+ };
547
+ }
527
548
  const timeoutSec = Math.min(Math.max(1, params.timeout ?? DEFAULT_TIMEOUT_SECONDS), MAX_TIMEOUT_SECONDS);
528
549
  const timeoutMs = timeoutSec * 1000;
529
550
  const startTime = Date.now();
@@ -532,7 +532,9 @@ export class GoalService {
532
532
  conv.goalGeneration += 1;
533
533
  // 上一轮目标可能还留着常驻执行者子代理(委托执行):先停掉再开新的,
534
534
  // 否则旧循环会在新目标上继续派活(代次已作废,但角色对话得收)。
535
- this.stopDelegated(conv);
535
+ // await 到 dismiss 完全落定:后面的 spawn 名额检查才不会数到还在收尾的
536
+ // 旧执行者而误报上限(issue #464;stopDelegated 自身已按会话串行化)。
537
+ await this.stopDelegated(conv);
536
538
  const goal = conv.goal;
537
539
  goal.reviewing = false;
538
540
  goal.conversationId = goalConversationId;
@@ -1125,7 +1127,8 @@ export class GoalService {
1125
1127
  const conv = this.host.activeConv();
1126
1128
  conv.goalGeneration += 1;
1127
1129
  // 委托执行:停掉在飞的执行者(循环靠代次作废 + 代次守卫退出)。
1128
- this.stopDelegated(conv);
1130
+ // await 落定,保证旧执行者已移出会话表,后续重设目标不再误报名额(issue #464)。
1131
+ await this.stopDelegated(conv);
1129
1132
  this.resetLoopCounters(conv);
1130
1133
  this.clearGoalFields(conv);
1131
1134
  this.emitGoalStatus();
@@ -1159,14 +1162,15 @@ export class GoalService {
1159
1162
  */
1160
1163
  async stopAllGoals() {
1161
1164
  let stopped = 0;
1162
- // 直接迭代:stopDelegated 只发 fire-and-forget 的停/收请求,不会同步改这张表
1163
- // (循环退出是异步的,靠代次守卫),无需快照。
1164
- for (const convId of this.delegatedLoops.keys()) {
1165
+ // 快照迭代:await stopDelegated 期间,循环退出的回调可能增删这张表。
1166
+ for (const convId of [...this.delegatedLoops.keys()]) {
1165
1167
  const conv = this.host.getConv(convId);
1166
1168
  if (!conv)
1167
1169
  continue;
1168
1170
  conv.goalGeneration += 1; // 作废在飞回调(循环靠代次守卫退出)
1169
- this.stopDelegated(conv); // 停执行者 + 唤醒 verdict 等待者 + 清 roles/phase
1171
+ // 逐个 await 停/收落定(stopDelegated 已按会话串行化,issue #464):
1172
+ // 停执行者 + 唤醒 verdict 等待者 + 清 roles/phase
1173
+ await this.stopDelegated(conv);
1170
1174
  this.resetLoopCounters(conv);
1171
1175
  this.clearGoalFields(conv);
1172
1176
  stopped++;
@@ -1210,6 +1214,8 @@ export class GoalService {
1210
1214
  }
1211
1215
  conv.goalGeneration += 1;
1212
1216
  // 委托执行:手动停止也要把在飞的执行者停掉(否则它还在后台改工作区)。
1217
+ // 本钩子是同步上下文,fire-and-forget 即可:stopDelegated 已按会话
1218
+ // 串行化(issue #464),重设/清除目标的一方会先等这次 dismiss 落定。
1213
1219
  this.stopDelegated(conv);
1214
1220
  this.resetLoopCounters(conv);
1215
1221
  g.conversationId = null;
@@ -1403,7 +1409,24 @@ export class GoalService {
1403
1409
  conv.goal.roles = {};
1404
1410
  return exec?.convId;
1405
1411
  }
1406
- async stopDelegated(conv) {
1412
+ stopInflight = new Map();
1413
+ /**
1414
+ * 停掉委托执行者(stop + dismiss + 唤醒等待者)。同一会话并发调用时按序串行:
1415
+ * 上一次 stop/dismiss 未落定前,下一次先等它 —— 否则重设目标时旧执行者尚未
1416
+ * 移出会话表,随后 spawn 的名额检查会数到它而误报容量上限(issue #464)。
1417
+ * 同步上下文(onAgentEnd 等钩子)可以继续 fire-and-forget,串行化由这里兜住。
1418
+ */
1419
+ stopDelegated(conv) {
1420
+ const prev = this.stopInflight.get(conv.id);
1421
+ const run = prev ? prev.catch(() => { }).then(() => this.stopDelegatedNow(conv)) : this.stopDelegatedNow(conv);
1422
+ this.stopInflight.set(conv.id, run);
1423
+ void run.finally(() => {
1424
+ if (this.stopInflight.get(conv.id) === run)
1425
+ this.stopInflight.delete(conv.id);
1426
+ });
1427
+ return run;
1428
+ }
1429
+ async stopDelegatedNow(conv) {
1407
1430
  // 排队的启动请求一并作废:setGoal 会在后面按新代次重新排,clearGoal 则不需要。
1408
1431
  this.delegatedPending.delete(conv.id);
1409
1432
  conv.awaitingVerdict = undefined;
@@ -156,6 +156,8 @@ async function fetchJson(fetchFn, url, maxBytes, timeoutMs, signal) {
156
156
  signal.removeEventListener("abort", onAbort);
157
157
  }
158
158
  }
159
+ // 手写 TTL 缓存而非通用 memoize:双键(openrouter/modelsdev)共享一个 Map,
160
+ // 且 compute 是 async fetch——单值同步的 memoizeWithTtl 覆盖不了(issue #470 处置说明)。
159
161
  /** 目录抓取缓存(24h TTL,进程内;首跑后无感)。 */
160
162
  const catalogCache = new Map();
161
163
  const CATALOG_TTL_MS = 24 * 3600 * 1000;
@@ -61,41 +61,76 @@ On divergence the engine attempts a 3-way merge. After success the tool returns
61
61
  details: emptyReport,
62
62
  };
63
63
  }
64
- const report = applyHashlinePatch(patchText, {
65
- cwd,
66
- snapshotStore: globalSnapshotStore,
67
- });
68
- if (!report.ok) {
64
+ // timeout 此前是死参数:schema 声明了 1-300 秒,execute 从未读取,大补丁
65
+ // 卡住只能等 20 分钟看门狗(issue #462)。这里用 Promise.race 实现真实
66
+ // 超时,覆盖 await 长尾(实时 LSP 诊断等);applyHashlinePatch 是同步
67
+ // 本地文件操作,同步段无法被中断,race 只能兜异步部分。
68
+ const timeoutSec = params.timeout ? Math.min(Math.max(1, params.timeout), 300) : null;
69
+ const finish = async () => {
70
+ const report = applyHashlinePatch(patchText, {
71
+ cwd,
72
+ snapshotStore: globalSnapshotStore,
73
+ });
74
+ if (!report.ok) {
75
+ return {
76
+ content: [
77
+ {
78
+ type: "text",
79
+ text: pick(L, `补丁应用失败:\n${report.summary}`, `Patch Failed:\n${report.summary}`),
80
+ },
81
+ ],
82
+ details: report,
83
+ };
84
+ }
85
+ const textOutput = [report.summary];
86
+ // 为成功修改的文件输出最新的头部标签,方便 Agent 连续进行下一步修改
87
+ for (const r of report.results) {
88
+ if (r.op !== "deleted" && r.newHash) {
89
+ const targetPath = r.newPath || r.filePath;
90
+ textOutput.push(pick(L, `\n${targetPath} 的下一处编辑锚点:\`${formatHashlineHeader(targetPath, r.newHash)}\``, `\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``));
91
+ try {
92
+ const fullPath = resolve(cwd, targetPath);
93
+ const diags = await getLiveLspDiagnostics(fullPath, cwd);
94
+ if (diags) {
95
+ textOutput.push(pick(L, `\n${targetPath} 的实时 LSP 诊断:\n${diags}`, `\nLive LSP diagnostics for ${targetPath}:\n${diags}`));
96
+ }
97
+ }
98
+ catch { }
99
+ }
100
+ }
69
101
  return {
70
- content: [
71
- {
72
- type: "text",
73
- text: pick(L, `补丁应用失败:\n${report.summary}`, `Patch Failed:\n${report.summary}`),
74
- },
75
- ],
102
+ content: [{ type: "text", text: textOutput.join("\n") }],
76
103
  details: report,
77
104
  };
105
+ };
106
+ if (!timeoutSec)
107
+ return finish();
108
+ const timeoutReply = {
109
+ content: [
110
+ {
111
+ type: "text",
112
+ text: pick(L, `补丁执行超时(${timeoutSec}s)。补丁可能已部分应用,请检查目标文件后重试。`, `Patch execution timed out (${timeoutSec}s). The patch may have been partially applied; check the target files and retry.`),
113
+ },
114
+ ],
115
+ details: {
116
+ ok: false,
117
+ summary: `Execution timed out after ${timeoutSec}s`,
118
+ results: [],
119
+ error: "timeout",
120
+ },
121
+ };
122
+ let timer;
123
+ try {
124
+ return await Promise.race([
125
+ finish(),
126
+ new Promise((resolveTimeout) => {
127
+ timer = setTimeout(() => resolveTimeout(timeoutReply), timeoutSec * 1000);
128
+ }),
129
+ ]);
78
130
  }
79
- const textOutput = [report.summary];
80
- // 为成功修改的文件输出最新的头部标签,方便 Agent 连续进行下一步修改
81
- for (const r of report.results) {
82
- if (r.op !== "deleted" && r.newHash) {
83
- const targetPath = r.newPath || r.filePath;
84
- textOutput.push(pick(L, `\n${targetPath} 的下一处编辑锚点:\`${formatHashlineHeader(targetPath, r.newHash)}\``, `\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``));
85
- try {
86
- const fullPath = resolve(cwd, targetPath);
87
- const diags = await getLiveLspDiagnostics(fullPath, cwd);
88
- if (diags) {
89
- textOutput.push(pick(L, `\n${targetPath} 的实时 LSP 诊断:\n${diags}`, `\nLive LSP diagnostics for ${targetPath}:\n${diags}`));
90
- }
91
- }
92
- catch { }
93
- }
131
+ finally {
132
+ clearTimeout(timer);
94
133
  }
95
- return {
96
- content: [{ type: "text", text: textOutput.join("\n") }],
97
- details: report,
98
- };
99
134
  },
100
135
  });
101
136
  }
@@ -11,13 +11,26 @@
11
11
  * - 模型可通过 customTool `plan_update` 更新
12
12
  * - 客户端也可通过协议消息 `plan_update` 调整
13
13
  * - 状态自动同步到快照 `UiState.plan`
14
+ *
15
+ * 存储模型与隔离设计(防新会话串台):
16
+ * - 持久化源(plansBySession):以真实唯一的 SDK sessionId 为主键,落盘至 plans.json。
17
+ * - 运行时缓存(plansByConv):以会话槽位 conversationId 为主键,仅存活于进程内存,绝不落盘。
18
+ * - 历史文件清洗:严格过滤形如 /^c\d+$/ 的易变短 ID,防止服务重启后新会话被上一个会话的任务附体。
14
19
  */
15
20
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
21
  import { dirname } from "node:path";
17
22
  const VALID_STATUSES = new Set(["pending", "in_progress", "done", "failed"]);
23
+ /** 判断是否为易变的临时 conversationId(如 c1, c2, c99 等)。此类短 ID 绝不入持久化文件。 */
24
+ function isEphemeralConvId(id) {
25
+ return /^c\d+$/.test(id);
26
+ }
18
27
  export class PlanManager {
19
28
  filePath;
20
- plans = new Map();
29
+ /** 按持久化 sessionId 存储的真实计划状态(落盘至 plans.json)。 */
30
+ plansBySession = new Map();
31
+ /** 按运行时 conversationId 存储的临时计划缓存(进程内存态,不落盘)。 */
32
+ plansByConv = new Map();
33
+ /** conversationId 到 sessionId 的映射。 */
21
34
  convToSession = new Map();
22
35
  constructor(filePath) {
23
36
  this.filePath = filePath;
@@ -31,8 +44,12 @@ export class PlanManager {
31
44
  try {
32
45
  const raw = JSON.parse(readFileSync(this.filePath, "utf8"));
33
46
  for (const [key, val] of Object.entries(raw)) {
47
+ // 严格过滤:形如 c1, c2 的临时 conversationId 绝不作为持久化会话键载入,
48
+ // 防止服务重启或历史残留时污染新分配的相同 ID 对话。
49
+ if (isEphemeralConvId(key))
50
+ continue;
34
51
  if (val && typeof val === "object" && Array.isArray(val.steps)) {
35
- this.plans.set(key, val);
52
+ this.plansBySession.set(key, val);
36
53
  }
37
54
  }
38
55
  }
@@ -46,7 +63,10 @@ export class PlanManager {
46
63
  try {
47
64
  mkdirSync(dirname(this.filePath), { recursive: true });
48
65
  const obj = {};
49
- for (const [k, v] of this.plans.entries()) {
66
+ for (const [k, v] of this.plansBySession.entries()) {
67
+ // 双重防护:临时 conversationId 绝不落盘
68
+ if (isEphemeralConvId(k))
69
+ continue;
50
70
  obj[k] = v;
51
71
  }
52
72
  const tmp = `${this.filePath}.tmp.${Date.now()}`;
@@ -60,32 +80,38 @@ export class PlanManager {
60
80
  /** 绑定 conversationId 与 sessionId。若 sessionId 已有落盘计划,立即恢复给 conversationId。 */
61
81
  bindSession(conversationId, sessionId) {
62
82
  this.convToSession.set(conversationId, sessionId);
63
- const existing = this.plans.get(sessionId);
83
+ // 绑定(或重新绑定)session 时,先清除当前 conversationId 上次残留的内存状态
84
+ this.plansByConv.delete(conversationId);
85
+ const existing = this.plansBySession.get(sessionId);
64
86
  if (existing) {
65
- this.plans.set(conversationId, existing);
87
+ this.plansByConv.set(conversationId, existing);
66
88
  return existing;
67
89
  }
68
- const convExisting = this.plans.get(conversationId);
69
- if (convExisting) {
70
- this.plans.set(sessionId, convExisting);
71
- this.save();
72
- return convExisting;
73
- }
90
+ // 绝不从 conversationId 逆向污染全新 sessionId!全新会话以空状态起步。
74
91
  return null;
75
92
  }
93
+ /** 解绑并清理指定 conversationId 的运行时内存计划与映射(在会话移除/销毁时调用)。 */
94
+ unbindConversation(conversationId) {
95
+ this.plansByConv.delete(conversationId);
96
+ this.convToSession.delete(conversationId);
97
+ }
76
98
  /** 获取指定会话的计划状态。优先使用 conversationId,若无则回退查找绑定的 sessionId。 */
77
- getPlan(conversationId) {
78
- const direct = this.plans.get(conversationId);
79
- if (direct)
80
- return direct;
81
- const sessionId = this.convToSession.get(conversationId);
99
+ getPlan(id) {
100
+ const directConv = this.plansByConv.get(id);
101
+ if (directConv)
102
+ return directConv;
103
+ const sessionId = this.convToSession.get(id);
82
104
  if (sessionId) {
83
- const bySession = this.plans.get(sessionId);
105
+ const bySession = this.plansBySession.get(sessionId);
84
106
  if (bySession) {
85
- this.plans.set(conversationId, bySession);
107
+ this.plansByConv.set(id, bySession);
86
108
  return bySession;
87
109
  }
88
110
  }
111
+ // 兼容以 sessionId 直接查询
112
+ const directSession = this.plansBySession.get(id);
113
+ if (directSession)
114
+ return directSession;
89
115
  return null;
90
116
  }
91
117
  /** 设置/全量更新指定会话的计划(可选绑定 sessionId 进行稳定落盘)。 */
@@ -110,13 +136,18 @@ export class PlanManager {
110
136
  activeStepId: effectiveActiveId,
111
137
  updatedAt: Date.now(),
112
138
  };
113
- this.plans.set(conversationId, state);
139
+ this.plansByConv.set(conversationId, state);
114
140
  const effectiveSessionId = sessionId ?? this.convToSession.get(conversationId);
115
141
  if (effectiveSessionId) {
116
142
  this.convToSession.set(conversationId, effectiveSessionId);
117
- this.plans.set(effectiveSessionId, state);
143
+ if (normalizedSteps.length === 0) {
144
+ this.plansBySession.delete(effectiveSessionId);
145
+ }
146
+ else {
147
+ this.plansBySession.set(effectiveSessionId, state);
148
+ }
149
+ this.save();
118
150
  }
119
- this.save();
120
151
  return state;
121
152
  }
122
153
  /** 增量更新单个步骤的状态或内容。 */
@@ -160,17 +191,17 @@ export class PlanManager {
160
191
  activeStepId: nextActive,
161
192
  updatedAt: Date.now(),
162
193
  };
163
- this.plans.set(conversationId, nextState);
194
+ this.plansByConv.set(conversationId, nextState);
164
195
  const sessId = this.convToSession.get(conversationId);
165
196
  if (sessId) {
166
- this.plans.set(sessId, nextState);
197
+ this.plansBySession.set(sessId, nextState);
198
+ this.save();
167
199
  }
168
- this.save();
169
200
  return nextState;
170
201
  }
171
202
  /** 增量删除单个步骤。 */
172
203
  deleteStep(conversationId, stepId) {
173
- const current = this.plans.get(conversationId);
204
+ const current = this.getPlan(conversationId);
174
205
  if (!current)
175
206
  return null;
176
207
  const idx = current.steps.findIndex((s) => s.id === stepId);
@@ -187,12 +218,17 @@ export class PlanManager {
187
218
  activeStepId: nextActive,
188
219
  updatedAt: Date.now(),
189
220
  };
190
- this.plans.set(conversationId, nextState);
221
+ this.plansByConv.set(conversationId, nextState);
222
+ const sessId = this.convToSession.get(conversationId);
223
+ if (sessId) {
224
+ this.plansBySession.set(sessId, nextState);
225
+ this.save();
226
+ }
191
227
  return nextState;
192
228
  }
193
229
  /** 增量新增步骤。 */
194
230
  addStep(conversationId, step, afterStepId) {
195
- const current = this.plans.get(conversationId);
231
+ const current = this.getPlan(conversationId);
196
232
  const existingSteps = current?.steps ?? [];
197
233
  const normalized = {
198
234
  id: String(step.id ?? `step-${Date.now()}`),
@@ -224,15 +260,20 @@ export class PlanManager {
224
260
  activeStepId: nextActive,
225
261
  updatedAt: Date.now(),
226
262
  };
227
- this.plans.set(conversationId, nextState);
263
+ this.plansByConv.set(conversationId, nextState);
264
+ const sessId = this.convToSession.get(conversationId);
265
+ if (sessId) {
266
+ this.plansBySession.set(sessId, nextState);
267
+ this.save();
268
+ }
228
269
  return nextState;
229
270
  }
230
271
  /** 清除指定会话的计划。 */
231
272
  clearPlan(conversationId) {
232
- this.plans.delete(conversationId);
273
+ this.plansByConv.delete(conversationId);
233
274
  const sessId = this.convToSession.get(conversationId);
234
275
  if (sessId) {
235
- this.plans.delete(sessId);
276
+ this.plansBySession.delete(sessId);
236
277
  this.convToSession.delete(conversationId);
237
278
  }
238
279
  this.save();
@@ -1580,28 +1580,35 @@ function cleanBashOutput(raw) {
1580
1580
  /** 一次性 bash 终端序号:非持久调用每次新建一个终端,进程结束后退出、输出
1581
1581
  * 保留在 history 供查阅——故每调用独立 id,避免复用覆盖旧输出。 */
1582
1582
  let oneShotBashSeq = 0;
1583
+ /** head / tail 的运行时上限,与 bash 工具参数 schema 的 maximum 5000 一致
1584
+ * (agent-service.ts)——schema 只影响提示不拦运行时(issue #462)。 */
1585
+ const MAX_HEAD_TAIL_LINES = 5000;
1583
1586
  /** 应用 head / tail 参数到输出顶层行(替代 `| head` / `| tail` 管道——管道会
1584
1587
  * 缓冲输出、让可见终端全程哑火,还容易白白触发静默解阻)。两者同时给时先
1585
1588
  * 截头再截尾。 */
1586
1589
  export function applyHeadTail(text, head, tail, lang = "en") {
1590
+ // 入口钳到 schema 上限:此前只判 >0,传 1e9 会把整段缓冲灌进单条工具结果
1591
+ // 并持久化进转录。
1592
+ const headN = head && head > 0 ? Math.min(Math.floor(head), MAX_HEAD_TAIL_LINES) : 0;
1593
+ const tailN = tail && tail > 0 ? Math.min(Math.floor(tail), MAX_HEAD_TAIL_LINES) : 0;
1587
1594
  // 只对真实数据行切片;省略提示行单独存,最后再包回输出,避免提示行在
1588
1595
  // head+tail 组合时被当成数据行参与第二次截取(导致尾部少截一行)。
1589
1596
  let data = text.split("\n");
1590
1597
  let headNote = null;
1591
1598
  let tailNote = null;
1592
- if (head && head > 0 && data.length > head) {
1593
- const n = data.length - head;
1599
+ if (headN && data.length > headN) {
1600
+ const n = data.length - headN;
1594
1601
  headNote = pick(lang, `…(后 ${n} 行已省略)`, `…[${n} lines omitted below]…`, "terminals.headtail.omitted.below", {
1595
1602
  n,
1596
1603
  });
1597
- data = data.slice(0, head);
1604
+ data = data.slice(0, headN);
1598
1605
  }
1599
- if (tail && tail > 0 && data.length > tail) {
1600
- const n = data.length - tail;
1606
+ if (tailN && data.length > tailN) {
1607
+ const n = data.length - tailN;
1601
1608
  tailNote = pick(lang, `…(前 ${n} 行已省略)`, `…[${n} lines omitted above]…`, "terminals.headtail.omitted.above", {
1602
1609
  n,
1603
1610
  });
1604
- data = data.slice(-tail);
1611
+ data = data.slice(-tailN);
1605
1612
  }
1606
1613
  const parts = [];
1607
1614
  if (tailNote)
@@ -32,21 +32,6 @@ export function compareVersions(a, b) {
32
32
  }
33
33
  return 0;
34
34
  }
35
- /**
36
- * Cache a zero-arg function's value for ttlMs. Plain value memoization: the
37
- * pi probe returns null on failure instead of throwing, so errors thread
38
- * through as ordinary values and there is nothing to rethrow.
39
- */
40
- export function memoizeWithTtl(fn, ttlMs) {
41
- let entry = null;
42
- return () => {
43
- const now = Date.now();
44
- if (!entry || now - entry.at >= ttlMs) {
45
- entry = { at: now, value: fn() };
46
- }
47
- return entry.value;
48
- };
49
- }
50
35
  /**
51
36
  * Parse `pi --version` stdout into a version string, or null. Two-stage:
52
37
  * prefer a line that is exactly the version (optional leading "v", optional
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.98.0",
3
+ "version": "0.99.0",
4
4
  "description": "Just open your browser to get all your work done — browser cockpit for AI coding agents (chat, code, files, terminal, Git in one tab)",
5
5
  "license": "MIT",
6
6
  "author": {