pi-web-ui 0.94.1 → 0.96.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 +91 -2
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +910 -237
- package/dist/server/agent-service.js +2655 -231
- package/dist/server/approval-rules.js +596 -0
- package/dist/server/attachment-store.js +129 -0
- package/dist/server/attachments.js +92 -174
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +67 -14
- package/dist/server/compact-context-tool.js +126 -0
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +229 -0
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +69 -66
- package/dist/server/edit-soft-tool.js +84 -16
- package/dist/server/eval-tool.js +588 -0
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +65 -18
- package/dist/server/goal-service.js +371 -77
- package/dist/server/hashline-engine.js +703 -0
- package/dist/server/host-guard.js +94 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +456 -80
- package/dist/server/lsp-tool.js +1371 -0
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +112 -32
- package/dist/server/office-parse.js +375 -0
- package/dist/server/patch-tool.js +85 -0
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +114 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +117 -13
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +950 -205
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +16 -6
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +111 -14
- package/dist/server/settings-service.js +112 -3
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +83 -66
- package/dist/server/terminals.js +248 -81
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +237 -12
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/workspace-snapshot.js +113 -0
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +5 -3
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +40 -0
- package/themes/aetheris.css +457 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
- package/web/dist/assets/index-Do9RgJC3.js +366 -0
- package/web/dist/assets/index-DryOsILO.css +1 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-B3S9MxnN.css +0 -1
- package/web/dist/assets/index-DVLrHI2E.js +0 -364
|
@@ -29,7 +29,7 @@ import { basename, dirname, join, resolve, sep } from "node:path";
|
|
|
29
29
|
import { randomUUID } from "node:crypto";
|
|
30
30
|
import { homedir } from "node:os";
|
|
31
31
|
import { BgServerTracker } from "../bg-servers.js";
|
|
32
|
-
import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS, normalizeToolWatchdogTimeoutMs } from "../client-state.js";
|
|
32
|
+
import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS, normalizePathKey, normalizeToolWatchdogTimeoutMs, } from "../client-state.js";
|
|
33
33
|
import { normalizeUiLayout } from "../client-state.js";
|
|
34
34
|
import { FilesService, workspacePath, desktopDirWire } from "../files-service.js";
|
|
35
35
|
import { QuiesceRejectedError } from "../agent-service.js";
|
|
@@ -124,9 +124,11 @@ const DEFAULT_SETTINGS = {
|
|
|
124
124
|
terminalToolsEnabled: false,
|
|
125
125
|
terminalBash: false,
|
|
126
126
|
terminalBashIdleMs: 15_000,
|
|
127
|
+
terminalBashMaxForegroundMs: 60_000,
|
|
127
128
|
toolWatchdogTimeoutMs: 20 * 60_000,
|
|
128
129
|
editSoftEnabled: false,
|
|
129
130
|
questionnaireEnabled: true,
|
|
131
|
+
toolApprovalEnabled: true,
|
|
130
132
|
goalModeEnabled: true,
|
|
131
133
|
parallelReminderEnabled: true,
|
|
132
134
|
thinkingWrap: false,
|
|
@@ -271,9 +273,11 @@ export class DshClientSession {
|
|
|
271
273
|
terminalToolsEnabled: savedSettings.terminalToolsEnabled,
|
|
272
274
|
terminalBash: savedSettings.terminalBash,
|
|
273
275
|
terminalBashIdleMs: savedSettings.terminalBashIdleMs,
|
|
276
|
+
terminalBashMaxForegroundMs: savedSettings.terminalBashMaxForegroundMs ?? 60_000,
|
|
274
277
|
toolWatchdogTimeoutMs: savedSettings.toolWatchdogTimeoutMs ?? 20 * 60_000,
|
|
275
278
|
editSoftEnabled: savedSettings.editSoftEnabled,
|
|
276
279
|
questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
|
|
280
|
+
toolApprovalEnabled: savedSettings.toolApprovalEnabled ?? true,
|
|
277
281
|
goalModeEnabled: savedSettings.goalModeEnabled ?? true,
|
|
278
282
|
parallelReminderEnabled: savedSettings.parallelReminderEnabled ?? true,
|
|
279
283
|
thinkingWrap: savedSettings.thinkingWrap,
|
|
@@ -364,6 +368,7 @@ export class DshClientSession {
|
|
|
364
368
|
// 直播帧能力跟着运行时进程走:重启后重新探测(换运行时版本也能回落到持久 assistant/chunk)。
|
|
365
369
|
conv.liveChunks = false;
|
|
366
370
|
}
|
|
371
|
+
this.emitConversations();
|
|
367
372
|
const now = Date.now();
|
|
368
373
|
if (now - this.runtimeRestart.windowStart > DshClientSession.RUNTIME_RESTART_WINDOW_MS) {
|
|
369
374
|
this.runtimeRestart.windowStart = now;
|
|
@@ -1131,6 +1136,7 @@ export class DshClientSession {
|
|
|
1131
1136
|
conv.streaming = null;
|
|
1132
1137
|
this.refreshConversationTitle(conv);
|
|
1133
1138
|
this.scheduleSessionsRefresh();
|
|
1139
|
+
this.emitConversations();
|
|
1134
1140
|
}
|
|
1135
1141
|
this.flushSnapshot();
|
|
1136
1142
|
}
|
|
@@ -1412,8 +1418,10 @@ export class DshClientSession {
|
|
|
1412
1418
|
if (active.messages.length === 0 && active.terminals.list().length === 0) {
|
|
1413
1419
|
if (preset)
|
|
1414
1420
|
await this.selectAgentPreset(preset);
|
|
1415
|
-
else
|
|
1421
|
+
else {
|
|
1422
|
+
this.pushSettings();
|
|
1416
1423
|
this.flushSnapshot();
|
|
1424
|
+
}
|
|
1417
1425
|
return true;
|
|
1418
1426
|
}
|
|
1419
1427
|
for (const conv of this.convs.values()) {
|
|
@@ -1447,6 +1455,7 @@ export class DshClientSession {
|
|
|
1447
1455
|
this.emitConversations();
|
|
1448
1456
|
this.emitGoalStatus();
|
|
1449
1457
|
this.pushTerminals();
|
|
1458
|
+
this.pushSettings();
|
|
1450
1459
|
this.flushSnapshot();
|
|
1451
1460
|
void this.refreshActivePermission().catch(() => { });
|
|
1452
1461
|
return true;
|
|
@@ -1481,6 +1490,7 @@ export class DshClientSession {
|
|
|
1481
1490
|
this.emitConversations();
|
|
1482
1491
|
this.emitGoalStatus();
|
|
1483
1492
|
this.pushTerminals();
|
|
1493
|
+
this.pushSettings();
|
|
1484
1494
|
this.flushSnapshot(true);
|
|
1485
1495
|
// 切会话带上权限值(cwd 变化走运行时重启,onStarted 会重拉)。
|
|
1486
1496
|
void this.refreshActivePermission().catch(() => { });
|
|
@@ -1803,62 +1813,46 @@ export class DshClientSession {
|
|
|
1803
1813
|
}
|
|
1804
1814
|
}
|
|
1805
1815
|
else if (resolved) {
|
|
1806
|
-
|
|
1807
|
-
|
|
1816
|
+
// 一律只给路径引用:不再把文件内容注入 prompt(小文件也一样),模型用
|
|
1817
|
+
// 自己的读取工具按需读。行范围模式带上行号提示。
|
|
1818
|
+
// 工作区图片文件仍走 attachment store → 真 image 块(模型可看图)。
|
|
1819
|
+
const refText = pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref", {
|
|
1820
|
+
"resolved.rel": resolved.rel,
|
|
1821
|
+
}) +
|
|
1822
|
+
(a.mode === "lines" && a.lines
|
|
1823
|
+
? pick(lang, `(第 ${a.lines.start}-${a.lines.end} 行)`, ` (lines ${a.lines.start}-${a.lines.end})`)
|
|
1824
|
+
: "");
|
|
1825
|
+
let isImage = false;
|
|
1826
|
+
try {
|
|
1827
|
+
const st = statSync(resolved.abs);
|
|
1828
|
+
isImage = st.isFile() && st.size > 0 && st.size <= 512 * 1024 && previewKind(resolved.abs) === "image";
|
|
1829
|
+
}
|
|
1830
|
+
catch {
|
|
1831
|
+
/* 不存在/不可读 → 照样给路径引用,读取工具会自己报错 */
|
|
1832
|
+
}
|
|
1833
|
+
if (isImage) {
|
|
1808
1834
|
try {
|
|
1809
|
-
const
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
? "image/webp"
|
|
1821
|
-
: ext === "gif"
|
|
1822
|
-
? "image/gif"
|
|
1823
|
-
: "image/png";
|
|
1824
|
-
const saved = await this.runtime.attachmentSave(mediaType, buf.toString("base64"), resolved.rel);
|
|
1825
|
-
blocks.push({ type: "image", attachment: saved.ref });
|
|
1826
|
-
}
|
|
1827
|
-
catch {
|
|
1828
|
-
blocks.push({
|
|
1829
|
-
type: "text",
|
|
1830
|
-
text: pick(lang, `\n[图片附件: ${resolved.rel}]`, `\n[Image attachment: ${resolved.rel}]`, "dsh.attach.image.ref", { "resolved.rel": resolved.rel }),
|
|
1831
|
-
});
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
else {
|
|
1835
|
-
const enc = this.decodeText(buf);
|
|
1836
|
-
const capped = enc.length > 100_000 ? `${enc.slice(0, 100_000)}\n… [truncated]` : enc;
|
|
1837
|
-
blocks.push({
|
|
1838
|
-
type: "text",
|
|
1839
|
-
text: `\n<file path="${resolved.rel}">\n${capped}\n</file>`,
|
|
1840
|
-
});
|
|
1841
|
-
}
|
|
1842
|
-
}
|
|
1843
|
-
else {
|
|
1844
|
-
blocks.push({
|
|
1845
|
-
type: "text",
|
|
1846
|
-
text: pick(lang, `\n[文件引用: ${resolved.rel}(大文件,请用读取工具查看)]`, `\n[File reference: ${resolved.rel} (large file, use the read tool to view it)]`, "dsh.attach.file.large", { "resolved.rel": resolved.rel }),
|
|
1847
|
-
});
|
|
1848
|
-
}
|
|
1835
|
+
const buf = readFileSync(resolved.abs);
|
|
1836
|
+
const ext = (resolved.rel.match(/\.([a-z0-9]+)$/iu)?.[1] ?? "png").toLowerCase();
|
|
1837
|
+
const mediaType = ext === "jpg" || ext === "jpeg"
|
|
1838
|
+
? "image/jpeg"
|
|
1839
|
+
: ext === "webp"
|
|
1840
|
+
? "image/webp"
|
|
1841
|
+
: ext === "gif"
|
|
1842
|
+
? "image/gif"
|
|
1843
|
+
: "image/png";
|
|
1844
|
+
const saved = await this.runtime.attachmentSave(mediaType, buf.toString("base64"), resolved.rel);
|
|
1845
|
+
blocks.push({ type: "image", attachment: saved.ref });
|
|
1849
1846
|
}
|
|
1850
1847
|
catch {
|
|
1851
1848
|
blocks.push({
|
|
1852
1849
|
type: "text",
|
|
1853
|
-
text: pick(lang, `\n[
|
|
1850
|
+
text: pick(lang, `\n[图片附件: ${resolved.rel}]`, `\n[Image attachment: ${resolved.rel}]`, "dsh.attach.image.ref", { "resolved.rel": resolved.rel }),
|
|
1854
1851
|
});
|
|
1855
1852
|
}
|
|
1856
1853
|
}
|
|
1857
1854
|
else {
|
|
1858
|
-
blocks.push({
|
|
1859
|
-
type: "text",
|
|
1860
|
-
text: pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref", { "resolved.rel": resolved.rel }),
|
|
1861
|
-
});
|
|
1855
|
+
blocks.push({ type: "text", text: refText });
|
|
1862
1856
|
}
|
|
1863
1857
|
}
|
|
1864
1858
|
else if (a.name) {
|
|
@@ -1870,19 +1864,6 @@ export class DshClientSession {
|
|
|
1870
1864
|
}
|
|
1871
1865
|
return blocks;
|
|
1872
1866
|
}
|
|
1873
|
-
decodeText(buf) {
|
|
1874
|
-
try {
|
|
1875
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
1876
|
-
}
|
|
1877
|
-
catch {
|
|
1878
|
-
try {
|
|
1879
|
-
return new TextDecoder("gbk").decode(buf);
|
|
1880
|
-
}
|
|
1881
|
-
catch {
|
|
1882
|
-
return buf.toString("latin1");
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
1867
|
/** 中止:kill 运行时进程树(所有 conversation 的运行停止)→ 自动重启保持可用。 */
|
|
1887
1868
|
async abort() {
|
|
1888
1869
|
if (!this.runtime.alive)
|
|
@@ -1890,6 +1871,7 @@ export class DshClientSession {
|
|
|
1890
1871
|
const conv = this.conv;
|
|
1891
1872
|
conv.isStreaming = false;
|
|
1892
1873
|
conv.streaming = null;
|
|
1874
|
+
this.emitConversations();
|
|
1893
1875
|
// 手动停止 → 清当前会话的 DSH 原生目标(半成品运行不该继续被轮次驱动)。
|
|
1894
1876
|
// 旧进程还活着,先 goal/clear 落盘,再重启运行时。
|
|
1895
1877
|
if (conv.dsGoal || conv.goal.goal) {
|
|
@@ -2239,6 +2221,7 @@ export class DshClientSession {
|
|
|
2239
2221
|
this.activeId = conv.id;
|
|
2240
2222
|
this.emitConversations();
|
|
2241
2223
|
this.pushTerminals();
|
|
2224
|
+
this.pushSettings();
|
|
2242
2225
|
this.flushSnapshot(true);
|
|
2243
2226
|
}
|
|
2244
2227
|
catch (err) {
|
|
@@ -2311,9 +2294,10 @@ export class DshClientSession {
|
|
|
2311
2294
|
// -----------------------------------------------------------------------
|
|
2312
2295
|
async pushProjects() {
|
|
2313
2296
|
const saved = this.stateStore.get(this.clientId);
|
|
2297
|
+
const removedKeys = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
|
|
2314
2298
|
const projects = new Map();
|
|
2315
2299
|
for (const p of saved.projects ?? []) {
|
|
2316
|
-
if (!
|
|
2300
|
+
if (!removedKeys.has(normalizePathKey(p.path))) {
|
|
2317
2301
|
projects.set(p.path, p.lastUsed);
|
|
2318
2302
|
}
|
|
2319
2303
|
}
|
|
@@ -2325,7 +2309,7 @@ export class DshClientSession {
|
|
|
2325
2309
|
for (const e of entries) {
|
|
2326
2310
|
if (e.isDirectory()) {
|
|
2327
2311
|
const cwd = this.decodeProjectKey(e.name);
|
|
2328
|
-
if (cwd && !projects.has(cwd))
|
|
2312
|
+
if (cwd && !projects.has(cwd) && !removedKeys.has(normalizePathKey(cwd)))
|
|
2329
2313
|
cwdProjects.add(cwd);
|
|
2330
2314
|
}
|
|
2331
2315
|
}
|
|
@@ -2335,7 +2319,9 @@ export class DshClientSession {
|
|
|
2335
2319
|
}
|
|
2336
2320
|
for (const cwd of cwdProjects)
|
|
2337
2321
|
projects.set(cwd, Date.now());
|
|
2338
|
-
|
|
2322
|
+
if (!removedKeys.has(normalizePathKey(this.cwd))) {
|
|
2323
|
+
projects.set(this.cwd, Date.now());
|
|
2324
|
+
}
|
|
2339
2325
|
const list = [...projects.entries()]
|
|
2340
2326
|
.map(([path, lastUsed]) => ({ path, lastUsed }))
|
|
2341
2327
|
.sort((a, b) => b.lastUsed - a.lastUsed)
|
|
@@ -2624,6 +2610,7 @@ export class DshClientSession {
|
|
|
2624
2610
|
terminalToolsEnabled: this.settings.terminalToolsEnabled,
|
|
2625
2611
|
terminalBash: this.settings.terminalBash,
|
|
2626
2612
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
2613
|
+
terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
|
|
2627
2614
|
toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
|
|
2628
2615
|
// DSH 引擎无 customTool 注册面(工具来自 shipped preset),read 目录覆盖面不存在。
|
|
2629
2616
|
readDirEnabled: true,
|
|
@@ -2634,6 +2621,10 @@ export class DshClientSession {
|
|
|
2634
2621
|
softCapTokens: 0,
|
|
2635
2622
|
softCapByModel: {},
|
|
2636
2623
|
questionnaireEnabled: this.settings.questionnaireEnabled,
|
|
2624
|
+
toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
|
|
2625
|
+
// DSH 无审批弹窗,策略恒为空(保协议完整)。
|
|
2626
|
+
approvalPolicy: { allowAll: false, categories: [] },
|
|
2627
|
+
approvalRules: [],
|
|
2637
2628
|
goalModeEnabled: this.settings.goalModeEnabled,
|
|
2638
2629
|
parallelReminderEnabled: this.settings.parallelReminderEnabled,
|
|
2639
2630
|
thinkingWrap: this.settings.thinkingWrap,
|
|
@@ -2695,12 +2686,16 @@ export class DshClientSession {
|
|
|
2695
2686
|
this.settings.terminalBash = partial.terminalBash;
|
|
2696
2687
|
if (partial.terminalBashIdleMs !== undefined)
|
|
2697
2688
|
this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
|
|
2689
|
+
if (partial.terminalBashMaxForegroundMs !== undefined)
|
|
2690
|
+
this.settings.terminalBashMaxForegroundMs = partial.terminalBashMaxForegroundMs;
|
|
2698
2691
|
if (partial.toolWatchdogTimeoutMs !== undefined)
|
|
2699
2692
|
this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
|
|
2700
2693
|
if (partial.editSoftEnabled !== undefined)
|
|
2701
2694
|
this.settings.editSoftEnabled = partial.editSoftEnabled;
|
|
2702
2695
|
if (partial.questionnaireEnabled !== undefined)
|
|
2703
2696
|
this.settings.questionnaireEnabled = partial.questionnaireEnabled;
|
|
2697
|
+
if (partial.toolApprovalEnabled !== undefined)
|
|
2698
|
+
this.settings.toolApprovalEnabled = partial.toolApprovalEnabled;
|
|
2704
2699
|
if (partial.goalModeEnabled !== undefined)
|
|
2705
2700
|
this.settings.goalModeEnabled = partial.goalModeEnabled;
|
|
2706
2701
|
if (partial.parallelReminderEnabled !== undefined)
|
|
@@ -2735,6 +2730,7 @@ export class DshClientSession {
|
|
|
2735
2730
|
terminalToolsEnabled: this.settings.terminalToolsEnabled,
|
|
2736
2731
|
terminalBash: this.settings.terminalBash,
|
|
2737
2732
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
2733
|
+
terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
|
|
2738
2734
|
editSoftEnabled: this.settings.editSoftEnabled,
|
|
2739
2735
|
// DSH 无独立重试配置(pi 引擎才暴露),保持默认。
|
|
2740
2736
|
retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
|
|
@@ -2742,6 +2738,7 @@ export class DshClientSession {
|
|
|
2742
2738
|
softCapTokens: 0,
|
|
2743
2739
|
softCapByModel: {},
|
|
2744
2740
|
questionnaireEnabled: this.settings.questionnaireEnabled,
|
|
2741
|
+
toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
|
|
2745
2742
|
goalModeEnabled: this.settings.goalModeEnabled,
|
|
2746
2743
|
parallelReminderEnabled: this.settings.parallelReminderEnabled,
|
|
2747
2744
|
thinkingWrap: this.settings.thinkingWrap,
|
|
@@ -2902,6 +2899,7 @@ export class DshClientSession {
|
|
|
2902
2899
|
}
|
|
2903
2900
|
conv.agentPreset = res.preset ?? target;
|
|
2904
2901
|
this.emitConversations();
|
|
2902
|
+
this.pushSettings();
|
|
2905
2903
|
this.flushSnapshot();
|
|
2906
2904
|
}
|
|
2907
2905
|
catch (err) {
|
|
@@ -3059,6 +3057,7 @@ export class DshClientSession {
|
|
|
3059
3057
|
terminalToolsEnabled: this.settings.terminalToolsEnabled,
|
|
3060
3058
|
terminalBash: this.settings.terminalBash,
|
|
3061
3059
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
3060
|
+
terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
|
|
3062
3061
|
editSoftEnabled: this.settings.editSoftEnabled,
|
|
3063
3062
|
// DSH 无独立重试配置,预设沿用默认值。
|
|
3064
3063
|
// DSH 无 skill 全文注入概念,给空保预设类型完整。
|
|
@@ -3099,6 +3098,8 @@ export class DshClientSession {
|
|
|
3099
3098
|
this.settings.terminalToolsEnabled = preset.terminalToolsEnabled;
|
|
3100
3099
|
this.settings.terminalBash = preset.terminalBash;
|
|
3101
3100
|
this.settings.terminalBashIdleMs = preset.terminalBashIdleMs;
|
|
3101
|
+
this.settings.terminalBashMaxForegroundMs =
|
|
3102
|
+
preset.terminalBashMaxForegroundMs ?? this.settings.terminalBashMaxForegroundMs;
|
|
3102
3103
|
this.settings.editSoftEnabled = preset.editSoftEnabled ?? this.settings.editSoftEnabled;
|
|
3103
3104
|
this.settings.reviewPrompt = preset.reviewPrompt ?? "";
|
|
3104
3105
|
this.stateStore.saveSettings(this.clientId, {
|
|
@@ -3109,6 +3110,7 @@ export class DshClientSession {
|
|
|
3109
3110
|
terminalToolsEnabled: this.settings.terminalToolsEnabled,
|
|
3110
3111
|
terminalBash: this.settings.terminalBash,
|
|
3111
3112
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
3113
|
+
terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
|
|
3112
3114
|
editSoftEnabled: this.settings.editSoftEnabled,
|
|
3113
3115
|
thinkingWrap: this.settings.thinkingWrap,
|
|
3114
3116
|
toolsWrap: this.settings.toolsWrap,
|
|
@@ -3457,7 +3459,7 @@ export class DshClientSession {
|
|
|
3457
3459
|
`# User's raw requirement`,
|
|
3458
3460
|
draft,
|
|
3459
3461
|
``,
|
|
3460
|
-
`Use the ask_user_question tool to ask the user focused questions to pin down the essential, ambiguous details. Ask ONE question at a time,
|
|
3462
|
+
`Use the ask_user_question tool to ask the user focused questions to pin down the essential, ambiguous details. Ask ONE question at a time, strictly 1 to 3 questions total: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment). Prefer 2 to 4 mutually exclusive options with the recommended choice placed FIRST, and explain the tradeoff in description.`,
|
|
3461
3463
|
`Once you have enough to write an unambiguous, reviewable goal, STOP asking and reply with EXACTLY this format and nothing else (no preamble, no bullets):`,
|
|
3462
3464
|
`GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`,
|
|
3463
3465
|
`Do NOT call create_goal or update_goal — just output the GOAL: line. If the user cancels or stops answering, still produce a sensible best-effort GOAL from what you already know.`,
|
|
@@ -4078,6 +4080,7 @@ export class DshClientSession {
|
|
|
4078
4080
|
// 文件树跟随新项目(服务端原生 watcher 自动重挂)。
|
|
4079
4081
|
void this.listFiles(undefined);
|
|
4080
4082
|
this.pushTerminals();
|
|
4083
|
+
this.pushSettings();
|
|
4081
4084
|
this.flushSnapshot(true);
|
|
4082
4085
|
}
|
|
4083
4086
|
catch (err) {
|
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* 在文件里找一段连续行,其核心序列与 oldText 完全一致(仅唯一匹配才写)。
|
|
13
13
|
* - 命中后按「整行替换」写入 newText **原样**(AI 给的缩进就是最终缩进),
|
|
14
14
|
* 只做必要的行尾换行平衡。
|
|
15
|
-
* - 若不支持片段(oldText
|
|
15
|
+
* - 若不支持片段(oldText 不是完整行)会返回错误提示:
|
|
16
|
+
* • 多行 oldText 跨行但首/尾未对齐行边界 → 直接拒绝(否则会吃掉行首/行尾残留、
|
|
17
|
+
* 写出粘连内容);
|
|
18
|
+
* • 宽松阶段整块对不上、且首/尾行只是某行的一部分 → 报「片段」错而非笼统的「找不到」;
|
|
19
|
+
* • 单行片段(如 `b();`)不改变行结构,仍照旧支持。
|
|
16
20
|
*
|
|
17
21
|
* 开关:设置面板「编辑」页 `editSoftEnabled`(默认关)。关闭时该工具从活跃集移除。
|
|
18
22
|
*/
|
|
@@ -22,24 +26,26 @@ import { homedir } from "node:os";
|
|
|
22
26
|
import { isAbsolute, join, resolve as nodeResolve } from "node:path";
|
|
23
27
|
import { Type } from "typebox";
|
|
24
28
|
import { defineTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, } from "@earendil-works/pi-coding-agent";
|
|
25
|
-
import {
|
|
29
|
+
import { pick } from "./i18n.js";
|
|
26
30
|
import { EDIT_SOFT_TOOL_NAME } from "./tool-manager.js";
|
|
27
31
|
/** 独立宽松编辑工具名(唯一登记见 tool-manager.ts;此处别名保兼容)。 */
|
|
28
32
|
export const SOFT_EDIT_TOOL_NAME = EDIT_SOFT_TOOL_NAME;
|
|
29
33
|
const replaceEditSchema = Type.Object({
|
|
30
34
|
oldText: Type.String({
|
|
31
|
-
description:
|
|
35
|
+
description: "Text to replace. Loose matching: the content of each non-empty line (trimmed of leading/trailing whitespace) must match the corresponding file lines; " +
|
|
36
|
+
"leading-indentation (spaces/tabs) differences are ignored. Prefer whole lines/blocks.",
|
|
32
37
|
}),
|
|
33
38
|
newText: Type.String({
|
|
34
|
-
description:
|
|
39
|
+
description: "Replacement text (written verbatim; the indentation you provide is final).",
|
|
35
40
|
}),
|
|
36
41
|
}, {});
|
|
37
42
|
const editSoftSchema = Type.Object({
|
|
38
43
|
path: Type.String({
|
|
39
|
-
description:
|
|
44
|
+
description: "Path of the file to edit (relative or absolute).",
|
|
40
45
|
}),
|
|
41
46
|
edits: Type.Array(replaceEditSchema, {
|
|
42
|
-
description:
|
|
47
|
+
description: "One or more targeted replacements. Each edit matches against the original file (not incrementally); " +
|
|
48
|
+
"do not include overlapping/nested edits; merge edits touching the same block or nearby lines into one.",
|
|
43
49
|
}),
|
|
44
50
|
}, {});
|
|
45
51
|
function isSingleEditInput(value) {
|
|
@@ -142,6 +148,50 @@ export function oldTextCores(oldTextLF) {
|
|
|
142
148
|
parts.pop();
|
|
143
149
|
return parts.map((p) => p.trim());
|
|
144
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* 判断 oldText 的首行/末行是否只是「片段」(某文件行的一部分)。
|
|
153
|
+
* 宽松匹配要求每行都是完整行;当整块对不上时,若首/末行的核心确实出现在文件的
|
|
154
|
+
* **行中部**(既不在行首也不在行尾),则极可能是模型漏抄了行首/行尾。
|
|
155
|
+
*
|
|
156
|
+
* 仅在「整块核心序列一次都没命中」后才调用,因此只作诊断,不影响正常匹配。
|
|
157
|
+
*/
|
|
158
|
+
export function fragmentLineEnds(cores, units) {
|
|
159
|
+
const isMidLineFragment = (core) => {
|
|
160
|
+
if (core === "")
|
|
161
|
+
return false;
|
|
162
|
+
// 先看有没有任意一行与它整行相同:有 → 不是片段(只是缩进/上下文不对)。
|
|
163
|
+
if (units.some((u) => u.core === core))
|
|
164
|
+
return false;
|
|
165
|
+
// 再看它是否出现在某行的中部(前后都还有内容)→ 是片段。
|
|
166
|
+
return units.some((u) => {
|
|
167
|
+
const idx = u.raw.indexOf(core);
|
|
168
|
+
return idx > 0 && idx + core.length < u.raw.length;
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
const ends = [];
|
|
172
|
+
if (cores.length > 0 && isMidLineFragment(cores[0]))
|
|
173
|
+
ends.push(0);
|
|
174
|
+
if (cores.length > 1 && isMidLineFragment(cores[cores.length - 1]))
|
|
175
|
+
ends.push(1);
|
|
176
|
+
return ends;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 判断一段**精确命中**是否为「跨行但未对齐整行」的非法片段。
|
|
180
|
+
*
|
|
181
|
+
* 只包含单行的 oldText(如 `const x = 1; `)是安全的:替换不会改动行结构。
|
|
182
|
+
* 但若 oldText 跨越了换行、且首/尾没落在行边界(既不在行首/行尾,也没包含整行),
|
|
183
|
+
* 直接子串替换会吃掉行首/行尾的残留,写出粘连内容——例如在
|
|
184
|
+
* `foo(a);\nfoo(b);` 上把 `a);\nfoo(` 换成 `z();` 会得到 `foo(z();b);`。
|
|
185
|
+
* 这几乎总是模型漏抄行首/行尾所致,因此报错而不静默写坏。
|
|
186
|
+
*/
|
|
187
|
+
export function isMisalignedMultilineFragment(content, start, oldTextLF) {
|
|
188
|
+
if (!oldTextLF.includes("\n"))
|
|
189
|
+
return false; // 单行片段:不影响行结构,允许
|
|
190
|
+
const end = start + oldTextLF.length;
|
|
191
|
+
const startsAtLineStart = start === 0 || content[start - 1] === "\n";
|
|
192
|
+
const endsAtLineBoundary = end === content.length || content[end] === "\n" || content[end - 1] === "\n";
|
|
193
|
+
return !(startsAtLineStart && endsAtLineBoundary);
|
|
194
|
+
}
|
|
145
195
|
/** NOT_FOUND / NOT_UNIQUE 用异常类型区分(与内置 edit 报错风格一致)。 */
|
|
146
196
|
class SoftEditMatchError extends Error {
|
|
147
197
|
constructor(message) {
|
|
@@ -158,7 +208,12 @@ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIn
|
|
|
158
208
|
}
|
|
159
209
|
// 1) 精确子串匹配(等价普通 edit,支持片段)
|
|
160
210
|
const exactIdx = normalizedContent.indexOf(oldTextLF);
|
|
161
|
-
|
|
211
|
+
// 非法片段防御:多行 oldText 若未对齐整行边界,精确子串替换会吃掉行首/行尾
|
|
212
|
+
// 残留、写出粘连内容(如 `foo(a);\nfoo(b);` 把 `a);\nfoo(` 换成 `z();` →
|
|
213
|
+
// `foo(z();b);`)。这种命中一律不走精确路径,改为交给下面的「整行宽松匹配」;
|
|
214
|
+
// 只有当整行也匹配不上时才报错拒绝(避免误伤「首行省略缩进」等合法情况)。
|
|
215
|
+
const exactMisaligned = exactIdx !== -1 && isMisalignedMultilineFragment(normalizedContent, exactIdx, oldTextLF);
|
|
216
|
+
if (exactIdx !== -1 && !exactMisaligned) {
|
|
162
217
|
// 唯一性:精确匹配出现多次 → 报错(模型应提供更多上下文)
|
|
163
218
|
const occurrences = normalizedContent.split(oldTextLF).length - 1;
|
|
164
219
|
if (occurrences > 1) {
|
|
@@ -186,6 +241,14 @@ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIn
|
|
|
186
241
|
starts.push(i);
|
|
187
242
|
}
|
|
188
243
|
if (starts.length === 0) {
|
|
244
|
+
// 非法片段诊断:宽松匹配要求 oldText 的每一行都是**完整行**。若首/尾行只是
|
|
245
|
+
// 某文件行的一部分(模型漏抄了行首/行尾,例如只给了 `a);` 而非 `foo(a);`),
|
|
246
|
+
// 逐行核心永远对不上;这里给出针对性提示,而不是笼统的「找不到」。
|
|
247
|
+
// exactMisaligned 说明精确子串能命中、但跨行且未对齐整行(会写出粘连内容),
|
|
248
|
+
// 而整行匹配又对不上 —— 这种命中绝对不能走精确路径。
|
|
249
|
+
if (exactMisaligned || fragmentLineEnds(cores, units).length > 0) {
|
|
250
|
+
throw new SoftEditMatchError(pick(lang, `在 ${path} 中找不到该文本:edits[${editIndex}].oldText 跨越多行但首/尾没有落在行边界上(首行或末行只是文件中某行的一部分),而本工具按**整行**匹配。请让 oldText 的每一行都是完整行——行首缩进可以省略,但行内容必须完整。`, `Could not find the text in ${path}: edits[${editIndex}].oldText spans multiple lines but its start/end do not fall on line boundaries (its first or last line is only part of a file line), and this tool matches whole lines. Make every line of oldText a complete line — leading indentation may be omitted, but the line content must be complete.`, "editsoft.fragment.not.supported", { path, editIndex }));
|
|
251
|
+
}
|
|
189
252
|
throw new SoftEditMatchError(pick(lang, `在 ${path} 中找不到该文本(精确或忽略缩进均未命中)。oldText 必须与文件的行内容一致;行首空白差异会被忽略,但实际内容必须完全相同。`, `Could not find the text (exact or indentation-insensitive) in ${path}. The oldText must match the file's line content; leading-whitespace differences are ignored, but the actual content must be identical.`, "editsoft.match.not.found", { path }));
|
|
190
253
|
}
|
|
191
254
|
if (starts.length > 1) {
|
|
@@ -217,10 +280,14 @@ export function applySoftEdits(normalizedContent, edits, path, lang = "en") {
|
|
|
217
280
|
throw new SoftEditMatchError(pick(lang, `在 ${path} 中的 edits 重叠。请合并为一个 edit 或定位到不相交的区域。`, `edits overlap in ${path}. Merge them into one edit or target disjoint regions.`, "editsoft.edits.overlap", { path }));
|
|
218
281
|
}
|
|
219
282
|
}
|
|
220
|
-
//
|
|
283
|
+
// 逆序应用,保持左侧偏移稳定。
|
|
284
|
+
// 关键:必须按**位置升序**(前面的 `sorted`)再逆序应用,不能按 edits 的传入
|
|
285
|
+
// 顺序逆序——模型若把靠后的 edit 写在前面,未排序的逆序应用会让后面的替换先
|
|
286
|
+
// 改变长度,前面的偏移随即串位,写出错乱内容(历史 bug:protocol.ts /
|
|
287
|
+
// use-chat.ts / ChatInput.tsx 被写坏)。内置 edit 同样先按 matchIndex 排序,此为对齐语义。
|
|
221
288
|
let result = normalizedContent;
|
|
222
|
-
for (let i =
|
|
223
|
-
const r =
|
|
289
|
+
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
290
|
+
const r = sorted[i];
|
|
224
291
|
result = result.slice(0, r.start) + r.insertText + result.slice(r.end);
|
|
225
292
|
}
|
|
226
293
|
if (result === normalizedContent) {
|
|
@@ -237,13 +304,14 @@ export function makeEditSoftTool(fallbackCwd, getLang) {
|
|
|
237
304
|
return defineTool({
|
|
238
305
|
name: SOFT_EDIT_TOOL_NAME,
|
|
239
306
|
label: "Edit (indentation-insensitive)",
|
|
240
|
-
description:
|
|
241
|
-
|
|
307
|
+
description: "Edit a single file with text replacement tolerant of indentation: oldText is matched by line content, ignoring leading-whitespace differences, so edits don't fail on indentation mismatch. " +
|
|
308
|
+
"Use when the built-in edit rejects oldText due to whitespace (common in JS/JSON). Prefer whole-line/whole-block oldText; newText is written exactly as provided.",
|
|
309
|
+
promptSnippet: "edit a file tolerating indentation differences (whitespace-insensitive match)",
|
|
242
310
|
promptGuidelines: [
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
311
|
+
"Use edit_soft when edit fails because the oldText indentation/spacing differs from the file",
|
|
312
|
+
"Each edits[].oldText is matched to whole lines by trimmed content; leading whitespace is ignored",
|
|
313
|
+
"newText is written verbatim — the indentation you provide is the final indentation in the file",
|
|
314
|
+
"Keep edits[].oldText as small as possible while still being unique; " + "merge nearby changes into one edit",
|
|
247
315
|
],
|
|
248
316
|
parameters: editSoftSchema,
|
|
249
317
|
prepareArguments: prepareSoftEditArguments,
|