pi-web-ui 0.95.0 → 0.96.1
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 +63 -1
- package/bin/pi-web-ui.mjs +159 -31
- package/dist/server/agent-service.js +880 -240
- package/dist/server/approval-rules.js +56 -10
- package/dist/server/attachment-store.js +21 -4
- package/dist/server/attachments.js +30 -3
- 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 +63 -14
- package/dist/server/compact-context-tool.js +6 -5
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +72 -5
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +19 -6
- package/dist/server/edit-soft-tool.js +14 -11
- package/dist/server/eval-tool.js +67 -14
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +30 -11
- package/dist/server/goal-service.js +141 -19
- package/dist/server/hashline-engine.js +59 -21
- package/dist/server/host-guard.js +94 -0
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +331 -78
- package/dist/server/lsp-tool.js +752 -60
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +94 -34
- package/dist/server/office-parse.js +96 -30
- package/dist/server/patch-tool.js +9 -15
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +11 -1
- 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-installer.js +30 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-project.js +70 -2
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +205 -44
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +4 -0
- 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/sdk-origin.js +18 -2
- package/dist/server/serialize.js +108 -21
- package/dist/server/settings-service.js +4 -2
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +48 -72
- package/dist/server/terminals.js +181 -69
- package/dist/server/tool-manager.js +24 -7
- 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/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +4 -2
- package/plugins/catalog.json +20 -0
- package/themes/aetheris.css +457 -0
- package/themes/zhupi-dark.css +718 -0
- package/themes/zhupi.css +771 -0
- package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-O3GyFIGG.js} +1 -1
- package/web/dist/assets/index-B7Zp23Vc.css +1 -0
- package/web/dist/assets/index-ByfoaDwp.js +366 -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-BXnCQL9p.js +0 -364
- package/web/dist/assets/index-RpIGFyi_.css +0 -1
|
@@ -19,6 +19,28 @@
|
|
|
19
19
|
import { createServer, createConnection } from "node:net";
|
|
20
20
|
import { chmodSync, existsSync, rmSync } from "node:fs";
|
|
21
21
|
import { join } from "node:path";
|
|
22
|
+
/** 探测既有 socket 的超时:活实例对 connect 的响应是内核级的,300ms 足够。 */
|
|
23
|
+
const CONTROL_PROBE_TIMEOUT_MS = 300;
|
|
24
|
+
/** 控制路径探测:连得上(有活实例在 listen)→ null;失败 → errno
|
|
25
|
+
* (ECONNREFUSED/ENOENT = 崩溃残留,可安全删除)。超时按「可能是活实例」
|
|
26
|
+
* 保守处理,返回超时码 —— 宁可不删残留也别摘掉正在服务的 socket。 */
|
|
27
|
+
function probeControlPath(path) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const sock = createConnection(path);
|
|
30
|
+
let settled = false;
|
|
31
|
+
const finish = (result) => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
sock.destroy();
|
|
37
|
+
resolve(result);
|
|
38
|
+
};
|
|
39
|
+
const timer = setTimeout(() => finish("ETIMEDOUT"), CONTROL_PROBE_TIMEOUT_MS);
|
|
40
|
+
sock.once("connect", () => finish(null));
|
|
41
|
+
sock.once("error", (err) => finish(err.code ?? "EUNKNOWN"));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
22
44
|
/** How long a control connection may sit idle before the server closes it. */
|
|
23
45
|
const CONTROL_IDLE_TIMEOUT_MS = 5_000;
|
|
24
46
|
/** How long the CLI waits for a reply before giving up. */
|
|
@@ -32,21 +54,45 @@ export function startControlServer(opts) {
|
|
|
32
54
|
const { service, dataDir, port } = opts;
|
|
33
55
|
const path = controlPath(dataDir, port);
|
|
34
56
|
let server;
|
|
57
|
+
// 只有本实例真正 bind 成功才允许 stop 时删 socket 文件:探测发现活实例而
|
|
58
|
+
// listen 失败时,path 上是对方的 socket,删了会让后续客户端全部失联。
|
|
59
|
+
let bound = false;
|
|
35
60
|
if (process.platform === "win32") {
|
|
61
|
+
// Windows named pipe 不落盘(最后一个句柄关闭即消失),不存在崩溃残留
|
|
62
|
+
// 文件可清理;撞上活实例时 listen 直接 EADDRINUSE,走下方统一 error
|
|
63
|
+
// 处理。无需探测。
|
|
36
64
|
server = createServer(handleConnection);
|
|
65
|
+
server.listen(path, () => {
|
|
66
|
+
bound = true;
|
|
67
|
+
console.log(` control : ${path}`);
|
|
68
|
+
});
|
|
37
69
|
}
|
|
38
70
|
else {
|
|
39
|
-
// Remove a stale socket left by a previous crash (only if it's ours —
|
|
40
|
-
// an existing socket file that refuses connections is stale).
|
|
41
|
-
if (existsSync(path)) {
|
|
42
|
-
try {
|
|
43
|
-
rmSync(path);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
/* best-effort */
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
71
|
server = createServer(handleConnection);
|
|
72
|
+
// POSIX socket 文件崩溃后会残留,但先探测再删:无条件 rmSync 会把正在
|
|
73
|
+
// 服务的实例(比如被新的 pi-web 抢占前仍在跑的旧实例)的 socket 文件摘
|
|
74
|
+
// 走,新旧实例互相踩。只有连不上的残留(ECONNREFUSED/ENOENT)才删;
|
|
75
|
+
// 探测超时保守当作活实例,让 listen 自己以 EADDRINUSE 失败告警。
|
|
76
|
+
void probeControlPath(path).then((probeErr) => {
|
|
77
|
+
if (probeErr && existsSync(path)) {
|
|
78
|
+
try {
|
|
79
|
+
rmSync(path);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* best-effort */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
server.listen(path, () => {
|
|
86
|
+
bound = true;
|
|
87
|
+
try {
|
|
88
|
+
chmodSync(path, 0o600);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* best-effort */
|
|
92
|
+
}
|
|
93
|
+
console.log(` control : ${path}`);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
50
96
|
}
|
|
51
97
|
// A second instance on the same data dir / port would fail to bind — don't
|
|
52
98
|
// crash the server over it, just log and run without a control socket.
|
|
@@ -105,27 +151,10 @@ export function startControlServer(opts) {
|
|
|
105
151
|
});
|
|
106
152
|
sock.on("close", () => clearTimeout(timer));
|
|
107
153
|
}
|
|
108
|
-
if (process.platform === "win32") {
|
|
109
|
-
// net.Server on a named pipe: listen on the pipe name directly.
|
|
110
|
-
server.listen(path, () => {
|
|
111
|
-
console.log(` control : ${path}`);
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
115
|
-
server.listen(path, () => {
|
|
116
|
-
try {
|
|
117
|
-
chmodSync(path, 0o600);
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
/* best-effort */
|
|
121
|
-
}
|
|
122
|
-
console.log(` control : ${path}`);
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
154
|
return () => {
|
|
126
155
|
server.close();
|
|
127
156
|
try {
|
|
128
|
-
if (process.platform !== "win32" && existsSync(path))
|
|
157
|
+
if (process.platform !== "win32" && bound && existsSync(path))
|
|
129
158
|
rmSync(path);
|
|
130
159
|
}
|
|
131
160
|
catch {
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
// 长对话分多次 offset 翻页。跨标签页的实时运行读不到——以落盘历史为准,
|
|
17
17
|
// description 里会告诉模型这一点。
|
|
18
18
|
//
|
|
19
|
-
//
|
|
19
|
+
// 文案约定:工具 definition(description/promptSnippet/promptGuidelines)为纯英文;per-call
|
|
20
20
|
// 返回文本按 lang 取 pick(lang, zh, en, key, vars),缺表回落英文内联。
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { Type } from "typebox";
|
|
24
|
-
import {
|
|
24
|
+
import { pick } from "./i18n.js";
|
|
25
25
|
import { CONVERSATION_READ_TOOL_NAME } from "./tool-manager.js";
|
|
26
26
|
import { extractTouches, formatTouchEntry, formatTouchesCompact, toolCallRefsOfContent, unionTouchLists, } from "./conversation-touches.js";
|
|
27
27
|
/** 由 content 块提 toolCalls 字段(additive;格式化输出不受影响,见接口注释)。 */
|
|
@@ -369,21 +369,11 @@ export function makeConversationReadTool(host, lang, extras) {
|
|
|
369
369
|
return defineTool({
|
|
370
370
|
name: CONVERSATION_READ_TOOL_NAME,
|
|
371
371
|
label: "Read another conversation",
|
|
372
|
-
description:
|
|
373
|
-
|
|
374
|
-
"
|
|
375
|
-
|
|
376
|
-
"
|
|
377
|
-
"action=status gives a short summary: last tool call, last assistant text, recently touched files, whether it is waiting for a question answer. " +
|
|
378
|
-
"Only sessions from the session list can be read (arbitrary file paths are refused). " +
|
|
379
|
-
"Live runs in OTHER browser tabs are not readable here — they appear in history once persisted.", "读取**另一个对话**:本客户端的运行中对话(含子代理——用 action=list 查 conversation id,如 c3)或已落盘的历史会话转录(用它的文件 path)。" +
|
|
380
|
-
"用户引用了别的对话(引用 chip、粘过来的对话 id、“看看之前那个关于 X 的对话”)时用它。" +
|
|
381
|
-
"action=list 列运行中对话与历史会话(kind=running|history|all,query 过滤,limit;历史默认 15 条)。" +
|
|
382
|
-
"action=read 取某一份转录:view=chat(默认:只给 user/assistant 文本)或 full(含工具调用与结果);last=N 取最新 N 条(不用猜 offset);query 在对话内搜索(命中 ±1 条上下文);每条消息独立截断(约 600 字符,超出标 “… +N chars”)。" +
|
|
383
|
-
"action=files 列该对话创建/修改过的文件(含次数)。" +
|
|
384
|
-
"action=status 给一两行摘要:最后一次工具调用、最后一句 assistant、最近动过的文件、是否在等用户回答问卷。" +
|
|
385
|
-
"只能读会话列表里的转录(任意文件路径会被拒绝)。" +
|
|
386
|
-
"其他浏览器标签页里的实时运行在这里读不到——它们落盘后会出现在历史里。"),
|
|
372
|
+
description: "Read ANOTHER conversation: a running conversation of this client (incl. subagents — id from action=list) or a persisted history session (file path). " +
|
|
373
|
+
"Use when the user references another chat. list: running + history sessions (query filter, limit). " +
|
|
374
|
+
"read: one transcript — view=chat (default, user/assistant text) or full (incl. tool calls); last=N latest; query searches within (±1 context); messages capped ~600 chars. " +
|
|
375
|
+
"files: files it created/modified. status: last tool call, last assistant text, pending question. " +
|
|
376
|
+
"Only listed sessions are readable; other tabs' live runs appear in history once persisted.",
|
|
387
377
|
promptSnippet: "read another conversation: list/find chats, read transcript (chat view / tail / search), touched files, status summary",
|
|
388
378
|
parameters: Type.Object({
|
|
389
379
|
action: Type.Optional(Type.String({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 悬空 toolCall 检测与修复(issue #280)。
|
|
2
|
+
* 悬空 toolCall 检测与修复(issue #280、issue #332)。
|
|
3
3
|
*
|
|
4
4
|
* 背景:流式中模型卡死 → 工具看门狗 abort 无效 → forceResetConversation
|
|
5
5
|
* dispose 在飞运行时并从磁盘重建。内存里未落盘的工具结果静默蒸发,
|
|
@@ -7,9 +7,20 @@
|
|
|
7
7
|
* prompt 会把非法转录链喂给 provider——请求有发起迹象但零落盘、零报错,
|
|
8
8
|
* 用户在往黑洞里打字。
|
|
9
9
|
*
|
|
10
|
+
* 缺陷与收紧(issue #332):
|
|
11
|
+
* 不能将全文件所有找不到 toolResult 的调用一律视为悬空并追加合成结果:
|
|
12
|
+
* 1. 上线检查:若 assistant 的 stopReason 是 "error" 或 "aborted",
|
|
13
|
+
* pi 在发送给 provider 前会丢弃该 assistant(transformMessages)。
|
|
14
|
+
* 该调用属于永不上线的幽灵调用,补合成结果反而会构造出没有前置 tool_calls
|
|
15
|
+
* 的孤儿 role: "tool",导致 provider 400(DeepSeek/OpenAI Responses 等);
|
|
16
|
+
* 2. 分支检查:会话文件由树状结构追加存储,老分支被遗弃的 toolCall 不在当前分支上;
|
|
17
|
+
* 若在文件尾(当前活跃分支末尾)追加老分支调用的合成结果,也会制造孤儿 toolResult。
|
|
18
|
+
* 因此落盘修复必须沿当前活跃分支(lastId 开始回溯)定位尾部生效 assistant。
|
|
19
|
+
*
|
|
10
20
|
* 本模块:
|
|
11
21
|
* - findDanglingToolCalls:纯函数,消息/条目数组里找「有调用、无后继结果」的 toolCall;
|
|
12
|
-
* -
|
|
22
|
+
* - tailAssistantToolCallIds:沿当前分支回溯,定位尾部会上线的 assistant 的 toolCallId 集合;
|
|
23
|
+
* - healDanglingToolCallFile:落盘版,仅针对当前分支尾部生效的悬空调用追加合成 toolResult;
|
|
13
24
|
* - 合成结果文案:DANGLING_TOOL_RESULT_TEXT(中英各一,toolResult content 只带一条文本)。
|
|
14
25
|
*/
|
|
15
26
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
@@ -22,6 +33,8 @@ function toolCallsOfMessage(msg) {
|
|
|
22
33
|
const m = msg;
|
|
23
34
|
if (m.role !== "assistant" || !Array.isArray(m.content))
|
|
24
35
|
return [];
|
|
36
|
+
if (m.stopReason === "error" || m.stopReason === "aborted")
|
|
37
|
+
return [];
|
|
25
38
|
const out = [];
|
|
26
39
|
for (const b of m.content) {
|
|
27
40
|
if (typeof b !== "object" || b === null || b.type !== "toolCall")
|
|
@@ -96,7 +109,58 @@ export function findDanglingToolCalls(messagesOrEntries) {
|
|
|
96
109
|
return out;
|
|
97
110
|
}
|
|
98
111
|
/**
|
|
99
|
-
*
|
|
112
|
+
* 沿当前分支从 lastId 往前回溯,定位当前分支尾部生效 assistant 的 toolCallId 集合(issue #332)。
|
|
113
|
+
*
|
|
114
|
+
* 两个守卫条件:
|
|
115
|
+
* 1. 分支检查:严格沿 parentId 链回溯,老分支(不在当前分支路径上)的条目绝不纳入,
|
|
116
|
+
* 避免在当前分支尾部追加针对老分支调用的合成结果而制造孤儿 toolResult;
|
|
117
|
+
* 2. 上线检查:若遇到的 assistant 是 stopReason: "error" | "aborted",
|
|
118
|
+
* 说明该 assistant 已被中断/报错,pi 在发送给模型前会直接跳过丢弃它(transformMessages),
|
|
119
|
+
* 其 toolCall 属于绝不上线的幽灵调用,必须跳过且绝不能为其补结果。
|
|
120
|
+
*
|
|
121
|
+
* 此外,若在回溯中遇到了 user 消息,说明当前尾部已经退出该回合(例如最新是 user 消息),
|
|
122
|
+
* 绝不能跨过 user 消息去为更早的 assistant 补 toolResult,应立即停止。
|
|
123
|
+
*/
|
|
124
|
+
export function tailAssistantToolCallIds(entries, lastId) {
|
|
125
|
+
if (!lastId)
|
|
126
|
+
return new Set();
|
|
127
|
+
const byId = new Map();
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
if (typeof e === "object" && e !== null && typeof e.id === "string") {
|
|
130
|
+
byId.set(e.id, e);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
let cursor = lastId;
|
|
135
|
+
while (cursor && !seen.has(cursor)) {
|
|
136
|
+
seen.add(cursor);
|
|
137
|
+
const entry = byId.get(cursor);
|
|
138
|
+
if (!entry || typeof entry !== "object")
|
|
139
|
+
break;
|
|
140
|
+
const msg = "message" in entry && typeof entry.message === "object"
|
|
141
|
+
? entry.message
|
|
142
|
+
: null;
|
|
143
|
+
if (msg && typeof msg === "object" && "role" in msg) {
|
|
144
|
+
const m = msg;
|
|
145
|
+
if (m.role === "user") {
|
|
146
|
+
// 跨入更早的用户回合,尾部无正在等待结果的 assistant
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (m.role === "assistant") {
|
|
150
|
+
if (m.stopReason !== "error" && m.stopReason !== "aborted") {
|
|
151
|
+
return new Set(toolCallsOfMessage(m).map((c) => c.toolCallId));
|
|
152
|
+
}
|
|
153
|
+
// 尾部最新 assistant 处于 aborted/error 状态,幽灵调用不上线;且不可越过它去修更早回合
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
cursor =
|
|
158
|
+
typeof entry.parentId === "string" ? entry.parentId : null;
|
|
159
|
+
}
|
|
160
|
+
return new Set();
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 落盘修复:向会话文件尾追加合成 toolResult(仅针对当前分支尾部生效的悬空调用),
|
|
100
164
|
* parentId 链式接在当前尾行之后。append-only——历史字节不动,无需备份。
|
|
101
165
|
* 返回追加条数(0 = 健康,无需处理;-1 = 文件不可读/不可写)。
|
|
102
166
|
*/
|
|
@@ -125,9 +189,12 @@ export function healDanglingToolCallFile(filePath) {
|
|
|
125
189
|
// 脏行:SDK 加载时同样跳过,这里忽略。
|
|
126
190
|
}
|
|
127
191
|
}
|
|
128
|
-
if (entries.length === 0)
|
|
192
|
+
if (entries.length === 0 || !lastId)
|
|
193
|
+
return 0;
|
|
194
|
+
const tailIds = tailAssistantToolCallIds(entries, lastId);
|
|
195
|
+
if (tailIds.size === 0)
|
|
129
196
|
return 0;
|
|
130
|
-
const dangling = findDanglingToolCalls(entries);
|
|
197
|
+
const dangling = findDanglingToolCalls(entries).filter((d) => tailIds.has(d.toolCallId));
|
|
131
198
|
if (dangling.length === 0)
|
|
132
199
|
return 0;
|
|
133
200
|
try {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { Type } from "typebox";
|
|
18
18
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import {
|
|
19
|
+
import { pick } from "./i18n.js";
|
|
20
20
|
import { DELEGATE_TASK_TOOL_NAME } from "./tool-manager.js";
|
|
21
21
|
import { subagentTitle } from "./subagents.js";
|
|
22
22
|
/** 工具名(唯一值在 tool-manager.ts;本别名保留:前端派单卡片与旧导入沿用它)。 */
|
|
@@ -101,33 +101,35 @@ export function buildDelegationPrompt(input, lang = "en") {
|
|
|
101
101
|
}
|
|
102
102
|
const delegateSchema = Type.Object({
|
|
103
103
|
agent: Type.String({
|
|
104
|
-
description:
|
|
104
|
+
description: "Specialist template to delegate to (e.g. oracle, metis, momus, explore, librarian, sisyphus-junior, multimodal-looker, review). " +
|
|
105
|
+
"Must be an enabled template — use subagent_templates to see descriptions and pick the domain match.",
|
|
105
106
|
}),
|
|
106
107
|
task: Type.String({
|
|
107
|
-
description:
|
|
108
|
+
description: "Atomic, specific goal: ONE action per delegation (minimum 20 chars). Vague tasks are rejected.",
|
|
108
109
|
}),
|
|
109
110
|
expected_outcome: Type.String({
|
|
110
|
-
description:
|
|
111
|
+
description: "Concrete deliverables with done criteria: what does success look like (minimum 10 chars).",
|
|
111
112
|
}),
|
|
112
113
|
required_tools: Type.String({
|
|
113
|
-
description:
|
|
114
|
+
description: "Explicit tool whitelist for the subagent (prevents tool sprawl).",
|
|
114
115
|
}),
|
|
115
116
|
must_do: Type.String({
|
|
116
|
-
description:
|
|
117
|
+
description: "Exhaustive requirements — leave NOTHING implicit.",
|
|
117
118
|
}),
|
|
118
119
|
must_not_do: Type.String({
|
|
119
|
-
description:
|
|
120
|
+
description: "Forbidden actions — anticipate and block rogue behavior.",
|
|
120
121
|
}),
|
|
121
122
|
context: Type.String({
|
|
122
|
-
description:
|
|
123
|
+
description: "File paths, existing patterns, constraints the subagent must know.",
|
|
123
124
|
}),
|
|
124
125
|
model: Type.Optional(Type.String({
|
|
125
|
-
description:
|
|
126
|
+
description: 'Optional model "provider/id" for this delegation; ' +
|
|
127
|
+
"omit = template model → panel default → follow the main conversation model.",
|
|
126
128
|
})),
|
|
127
129
|
persist: Type.Optional(Type.Boolean({
|
|
128
|
-
description:
|
|
129
|
-
"Default false (lightweight in-memory subagent).
|
|
130
|
-
"
|
|
130
|
+
description: "Optional: persist this conversation to disk as a regular session (saved in history, resumable). " +
|
|
131
|
+
"Default false (lightweight in-memory subagent). " +
|
|
132
|
+
"Use true for tasks that need long-term retention.",
|
|
131
133
|
})),
|
|
132
134
|
});
|
|
133
135
|
/** 结构化派单工具:校验六段 → 拼装标准 prompt → 走 host.spawnSubagent 真子代理。 */
|
|
@@ -140,23 +142,18 @@ export function makeDelegateTaskTool(host, lang) {
|
|
|
140
142
|
return defineTool({
|
|
141
143
|
name: DELEGATE_TOOL_NAME,
|
|
142
144
|
label: "Delegate task",
|
|
143
|
-
description:
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
|
|
149
|
-
"subagent session instead of delegating again.", "把一个定义清楚的任务派给 specialist 子代理模板,派单文本是结构化六段 " +
|
|
150
|
-
"(TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT)。六段在服务端校验:" +
|
|
151
|
-
"缺段或含糊直接报错打回,所以每段都要写实在。任务适合 specialist 模板时优先用它而不是 subagent_spawn。" +
|
|
152
|
-
"派单会启动真实子代理会话(左栏运行列表可见);用 subagent_wait_all / subagent_get_result 收结果、" +
|
|
153
|
-
"subagent_steer 改向、subagent_stop 停止。追问要在同一子代理会话里继续,不要重复派单。"),
|
|
154
|
-
promptSnippet: bilingual("Delegate a well-defined task to a specialist template with a validated six-section brief", "把定义清楚的任务派给 specialist 模板,六段派单文本带服务端校验"),
|
|
145
|
+
description: "Delegate ONE well-defined task to a specialist subagent template via a structured six-section brief (TASK / EXPECTED OUTCOME / REQUIRED TOOLS / MUST DO / MUST NOT DO / CONTEXT). " +
|
|
146
|
+
"The brief is validated server-side — missing or vague sections are rejected, so fill every section concretely. " +
|
|
147
|
+
"Prefer over subagent_spawn when the work fits a specialist template. Spawns a real subagent conversation; " +
|
|
148
|
+
"collect with subagent_wait_all / subagent_get_result, redirect with subagent_steer, stop with subagent_stop. " +
|
|
149
|
+
"For follow-ups continue the SAME subagent session instead of delegating again.",
|
|
150
|
+
promptSnippet: "Delegate a well-defined task to a specialist template with a validated six-section brief",
|
|
155
151
|
promptGuidelines: [
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
152
|
+
"Prefer delegate_task over subagent_spawn when the work matches a specialist template's domain",
|
|
153
|
+
"Before delegating, declare which template you chose and WHY its description matches the task",
|
|
154
|
+
"After delegation ALWAYS verify the result: " +
|
|
155
|
+
"does it work, does it follow codebase patterns, did it respect MUST DO / MUST NOT DO",
|
|
156
|
+
"Never start implementing work that a pending delegated result was asked to decide",
|
|
160
157
|
],
|
|
161
158
|
parameters: delegateSchema,
|
|
162
159
|
execute: async (_id, params, _signal, _onUpdate, ctx) => {
|
|
@@ -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";
|
|
@@ -368,6 +368,7 @@ export class DshClientSession {
|
|
|
368
368
|
// 直播帧能力跟着运行时进程走:重启后重新探测(换运行时版本也能回落到持久 assistant/chunk)。
|
|
369
369
|
conv.liveChunks = false;
|
|
370
370
|
}
|
|
371
|
+
this.emitConversations();
|
|
371
372
|
const now = Date.now();
|
|
372
373
|
if (now - this.runtimeRestart.windowStart > DshClientSession.RUNTIME_RESTART_WINDOW_MS) {
|
|
373
374
|
this.runtimeRestart.windowStart = now;
|
|
@@ -1135,6 +1136,7 @@ export class DshClientSession {
|
|
|
1135
1136
|
conv.streaming = null;
|
|
1136
1137
|
this.refreshConversationTitle(conv);
|
|
1137
1138
|
this.scheduleSessionsRefresh();
|
|
1139
|
+
this.emitConversations();
|
|
1138
1140
|
}
|
|
1139
1141
|
this.flushSnapshot();
|
|
1140
1142
|
}
|
|
@@ -1416,8 +1418,10 @@ export class DshClientSession {
|
|
|
1416
1418
|
if (active.messages.length === 0 && active.terminals.list().length === 0) {
|
|
1417
1419
|
if (preset)
|
|
1418
1420
|
await this.selectAgentPreset(preset);
|
|
1419
|
-
else
|
|
1421
|
+
else {
|
|
1422
|
+
this.pushSettings();
|
|
1420
1423
|
this.flushSnapshot();
|
|
1424
|
+
}
|
|
1421
1425
|
return true;
|
|
1422
1426
|
}
|
|
1423
1427
|
for (const conv of this.convs.values()) {
|
|
@@ -1451,6 +1455,7 @@ export class DshClientSession {
|
|
|
1451
1455
|
this.emitConversations();
|
|
1452
1456
|
this.emitGoalStatus();
|
|
1453
1457
|
this.pushTerminals();
|
|
1458
|
+
this.pushSettings();
|
|
1454
1459
|
this.flushSnapshot();
|
|
1455
1460
|
void this.refreshActivePermission().catch(() => { });
|
|
1456
1461
|
return true;
|
|
@@ -1485,6 +1490,7 @@ export class DshClientSession {
|
|
|
1485
1490
|
this.emitConversations();
|
|
1486
1491
|
this.emitGoalStatus();
|
|
1487
1492
|
this.pushTerminals();
|
|
1493
|
+
this.pushSettings();
|
|
1488
1494
|
this.flushSnapshot(true);
|
|
1489
1495
|
// 切会话带上权限值(cwd 变化走运行时重启,onStarted 会重拉)。
|
|
1490
1496
|
void this.refreshActivePermission().catch(() => { });
|
|
@@ -1865,6 +1871,7 @@ export class DshClientSession {
|
|
|
1865
1871
|
const conv = this.conv;
|
|
1866
1872
|
conv.isStreaming = false;
|
|
1867
1873
|
conv.streaming = null;
|
|
1874
|
+
this.emitConversations();
|
|
1868
1875
|
// 手动停止 → 清当前会话的 DSH 原生目标(半成品运行不该继续被轮次驱动)。
|
|
1869
1876
|
// 旧进程还活着,先 goal/clear 落盘,再重启运行时。
|
|
1870
1877
|
if (conv.dsGoal || conv.goal.goal) {
|
|
@@ -2214,6 +2221,7 @@ export class DshClientSession {
|
|
|
2214
2221
|
this.activeId = conv.id;
|
|
2215
2222
|
this.emitConversations();
|
|
2216
2223
|
this.pushTerminals();
|
|
2224
|
+
this.pushSettings();
|
|
2217
2225
|
this.flushSnapshot(true);
|
|
2218
2226
|
}
|
|
2219
2227
|
catch (err) {
|
|
@@ -2286,9 +2294,10 @@ export class DshClientSession {
|
|
|
2286
2294
|
// -----------------------------------------------------------------------
|
|
2287
2295
|
async pushProjects() {
|
|
2288
2296
|
const saved = this.stateStore.get(this.clientId);
|
|
2297
|
+
const removedKeys = new Set(this.stateStore.getRemovedProjects(this.clientId).map(normalizePathKey));
|
|
2289
2298
|
const projects = new Map();
|
|
2290
2299
|
for (const p of saved.projects ?? []) {
|
|
2291
|
-
if (!
|
|
2300
|
+
if (!removedKeys.has(normalizePathKey(p.path))) {
|
|
2292
2301
|
projects.set(p.path, p.lastUsed);
|
|
2293
2302
|
}
|
|
2294
2303
|
}
|
|
@@ -2300,7 +2309,7 @@ export class DshClientSession {
|
|
|
2300
2309
|
for (const e of entries) {
|
|
2301
2310
|
if (e.isDirectory()) {
|
|
2302
2311
|
const cwd = this.decodeProjectKey(e.name);
|
|
2303
|
-
if (cwd && !projects.has(cwd))
|
|
2312
|
+
if (cwd && !projects.has(cwd) && !removedKeys.has(normalizePathKey(cwd)))
|
|
2304
2313
|
cwdProjects.add(cwd);
|
|
2305
2314
|
}
|
|
2306
2315
|
}
|
|
@@ -2310,7 +2319,9 @@ export class DshClientSession {
|
|
|
2310
2319
|
}
|
|
2311
2320
|
for (const cwd of cwdProjects)
|
|
2312
2321
|
projects.set(cwd, Date.now());
|
|
2313
|
-
|
|
2322
|
+
if (!removedKeys.has(normalizePathKey(this.cwd))) {
|
|
2323
|
+
projects.set(this.cwd, Date.now());
|
|
2324
|
+
}
|
|
2314
2325
|
const list = [...projects.entries()]
|
|
2315
2326
|
.map(([path, lastUsed]) => ({ path, lastUsed }))
|
|
2316
2327
|
.sort((a, b) => b.lastUsed - a.lastUsed)
|
|
@@ -2888,6 +2899,7 @@ export class DshClientSession {
|
|
|
2888
2899
|
}
|
|
2889
2900
|
conv.agentPreset = res.preset ?? target;
|
|
2890
2901
|
this.emitConversations();
|
|
2902
|
+
this.pushSettings();
|
|
2891
2903
|
this.flushSnapshot();
|
|
2892
2904
|
}
|
|
2893
2905
|
catch (err) {
|
|
@@ -3447,7 +3459,7 @@ export class DshClientSession {
|
|
|
3447
3459
|
`# User's raw requirement`,
|
|
3448
3460
|
draft,
|
|
3449
3461
|
``,
|
|
3450
|
-
`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.`,
|
|
3451
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):`,
|
|
3452
3464
|
`GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`,
|
|
3453
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.`,
|
|
@@ -4068,6 +4080,7 @@ export class DshClientSession {
|
|
|
4068
4080
|
// 文件树跟随新项目(服务端原生 watcher 自动重挂)。
|
|
4069
4081
|
void this.listFiles(undefined);
|
|
4070
4082
|
this.pushTerminals();
|
|
4083
|
+
this.pushSettings();
|
|
4071
4084
|
this.flushSnapshot(true);
|
|
4072
4085
|
}
|
|
4073
4086
|
catch (err) {
|
|
@@ -26,24 +26,26 @@ import { homedir } from "node:os";
|
|
|
26
26
|
import { isAbsolute, join, resolve as nodeResolve } from "node:path";
|
|
27
27
|
import { Type } from "typebox";
|
|
28
28
|
import { defineTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, } from "@earendil-works/pi-coding-agent";
|
|
29
|
-
import {
|
|
29
|
+
import { pick } from "./i18n.js";
|
|
30
30
|
import { EDIT_SOFT_TOOL_NAME } from "./tool-manager.js";
|
|
31
31
|
/** 独立宽松编辑工具名(唯一登记见 tool-manager.ts;此处别名保兼容)。 */
|
|
32
32
|
export const SOFT_EDIT_TOOL_NAME = EDIT_SOFT_TOOL_NAME;
|
|
33
33
|
const replaceEditSchema = Type.Object({
|
|
34
34
|
oldText: Type.String({
|
|
35
|
-
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.",
|
|
36
37
|
}),
|
|
37
38
|
newText: Type.String({
|
|
38
|
-
description:
|
|
39
|
+
description: "Replacement text (written verbatim; the indentation you provide is final).",
|
|
39
40
|
}),
|
|
40
41
|
}, {});
|
|
41
42
|
const editSoftSchema = Type.Object({
|
|
42
43
|
path: Type.String({
|
|
43
|
-
description:
|
|
44
|
+
description: "Path of the file to edit (relative or absolute).",
|
|
44
45
|
}),
|
|
45
46
|
edits: Type.Array(replaceEditSchema, {
|
|
46
|
-
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.",
|
|
47
49
|
}),
|
|
48
50
|
}, {});
|
|
49
51
|
function isSingleEditInput(value) {
|
|
@@ -302,13 +304,14 @@ export function makeEditSoftTool(fallbackCwd, getLang) {
|
|
|
302
304
|
return defineTool({
|
|
303
305
|
name: SOFT_EDIT_TOOL_NAME,
|
|
304
306
|
label: "Edit (indentation-insensitive)",
|
|
305
|
-
description:
|
|
306
|
-
|
|
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)",
|
|
307
310
|
promptGuidelines: [
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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",
|
|
312
315
|
],
|
|
313
316
|
parameters: editSoftSchema,
|
|
314
317
|
prepareArguments: prepareSoftEditArguments,
|