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 +33 -4
- package/dist/server/agent-service.js +9 -3
- package/dist/server/eval-tool.js +22 -1
- package/dist/server/goal-service.js +30 -7
- package/dist/server/model-enrich.js +2 -0
- package/dist/server/patch-tool.js +65 -30
- package/dist/server/plan-manager.js +71 -30
- package/dist/server/terminals.js +13 -6
- package/dist/server/update-check.js +0 -15
- package/package.json +1 -1
- package/web/dist/assets/{TerminalPanel-C88r3r3Q.js → TerminalPanel-NerrJlTS.js} +1 -1
- package/web/dist/assets/index-BdMaGCcs.css +1 -0
- package/web/dist/assets/{index-BjdK7s1M.js → index-ByCBN4Kt.js} +32 -32
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +1 -1
- package/web/dist/assets/index-2jDtjHvW.css +0 -1
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
|
-
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 进程组,父进程退出不会自动带走它们。
|
package/dist/server/eval-tool.js
CHANGED
|
@@ -523,7 +523,28 @@ export function makeEvalTool(opts) {
|
|
|
523
523
|
}),
|
|
524
524
|
execute: async (_id, params) => {
|
|
525
525
|
const lang = getLang();
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
83
|
+
// 绑定(或重新绑定)session 时,先清除当前 conversationId 上次残留的内存状态
|
|
84
|
+
this.plansByConv.delete(conversationId);
|
|
85
|
+
const existing = this.plansBySession.get(sessionId);
|
|
64
86
|
if (existing) {
|
|
65
|
-
this.
|
|
87
|
+
this.plansByConv.set(conversationId, existing);
|
|
66
88
|
return existing;
|
|
67
89
|
}
|
|
68
|
-
|
|
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(
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
return
|
|
81
|
-
const sessionId = this.convToSession.get(
|
|
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.
|
|
105
|
+
const bySession = this.plansBySession.get(sessionId);
|
|
84
106
|
if (bySession) {
|
|
85
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
194
|
+
this.plansByConv.set(conversationId, nextState);
|
|
164
195
|
const sessId = this.convToSession.get(conversationId);
|
|
165
196
|
if (sessId) {
|
|
166
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
273
|
+
this.plansByConv.delete(conversationId);
|
|
233
274
|
const sessId = this.convToSession.get(conversationId);
|
|
234
275
|
if (sessId) {
|
|
235
|
-
this.
|
|
276
|
+
this.plansBySession.delete(sessId);
|
|
236
277
|
this.convToSession.delete(conversationId);
|
|
237
278
|
}
|
|
238
279
|
this.save();
|
package/dist/server/terminals.js
CHANGED
|
@@ -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 (
|
|
1593
|
-
const n = data.length -
|
|
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,
|
|
1604
|
+
data = data.slice(0, headN);
|
|
1598
1605
|
}
|
|
1599
|
-
if (
|
|
1600
|
-
const n = data.length -
|
|
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(-
|
|
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