@muzi3/herdr 0.2.3 → 0.3.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/README.md +55 -26
- package/extensions/pane-agent-tools/index.ts +752 -651
- package/package.json +3 -3
|
@@ -1,33 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Herdr
|
|
2
|
+
* Herdr 管理工具面:八个动词工具,逐个操作对齐 dsh 的
|
|
3
|
+
* `@muzi3/dsh-plugin-herdr-tools` manager 面(`src/manager.js`):
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* herdr_overview · herdr_read · herdr_wait · herdr_prompt
|
|
6
|
+
* herdr_run · herdr_create · herdr_agent_start · herdr_close
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* —— 即使命令早已成功下发到目标 pane。
|
|
8
|
+
* 与 dsh 的默认面(`herdr_layout` / `herdr_pane` / `herdr_agent` 三个 action 风格
|
|
9
|
+
* 工具)不同,这里一个动词一个工具。pi 早期版本用的是 action 面,已整体替换:
|
|
10
|
+
* 两套不共存,否则同名能力会有两种调用方式,模型选哪套全凭运气。
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* 复用了 dsh 侧的全部判据语义:
|
|
13
|
+
* - 统一信封 `{ ok, exitCode, timedOut?, output, note?, via? }`。exitCode:0 成功 /
|
|
14
|
+
* 1 herdr 错误 / 2 参数或前置校验失败 / -1 本地超时。`note` 是判据提示,渲染在正文前。
|
|
15
|
+
* - 每次 herdr 调用都有本地截止时间(默认 120s);本地超时是「没观测到」,不是失败:
|
|
16
|
+
* 返回 timedOut 而不是把错误抛给调用方。
|
|
17
|
+
* - herdr 的 `timeout` / `agent_prompt_stalled` 都是「没观测到」,不是失败。stalled 尤其
|
|
18
|
+
* 危险 —— prompt 已经下发,重发就是重复下发。
|
|
19
|
+
* - `agent prompt` 只有 herdr 自己命名并激活的 agent 才收;自报状态的 agent(dsh-tui)
|
|
20
|
+
* 回 `agent_not_ready`,此时回退成 `pane run` 直接键入,`via` 说明实际走了哪条路。
|
|
21
|
+
* - `herdr_read` 默认 digest:只回「前段命中的审批/报错行 + 尾部 30 行」,要看整段传
|
|
22
|
+
* `mode: "raw"`。失败从不压缩 —— 错误原文本身就是证据。
|
|
23
|
+
* - 下发类工具(prompt / run / close)拒绝以自己所在的 pane 为目标:那等于给自己喂输入,
|
|
24
|
+
* 会打断正在进行的这一轮。prompt 另外拒绝插队正在 working 的 agent(除非 force)。
|
|
16
25
|
*
|
|
17
|
-
*
|
|
18
|
-
* - herdr
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* 字面量语义下 `a|b` / `log \$` 永远不可能命中,只会白等满 timeout。
|
|
24
|
-
* - 命中行包含本进程刚下发的命令文本时标 echoedInput —— 那多半是命令回显行,而不是
|
|
25
|
-
* 命令产生的输出(回显在命令还没跑完时就已经存在)。
|
|
26
|
-
* - agent_blocked 单独识别:目标卡在审批/提问弹窗时 herdr 会在发送任何输入前就拒绝,
|
|
27
|
-
* 因此不能像 stalled 那样判为“已下发”,也不能靠重试绕过(详见 isPromptBlocked)。
|
|
26
|
+
* pi 侧保留的两处加固(dsh manager 面没有,见 README):
|
|
27
|
+
* - `agent_blocked`:目标卡在审批/提问弹窗时 herdr 在发送任何输入**之前**就拒绝,所以判为
|
|
28
|
+
* 未下发(exitCode 2)并指向「先看清阻塞界面」,而不是 `agent_prompt_stalled` 那样判为
|
|
29
|
+
* 已下发。
|
|
30
|
+
* - `agent start --kind` 用枚举约束,必须与 `herdr agent start --help` 一致(0.9.5 共 24 个),
|
|
31
|
+
* tests/ 里有对账用例。
|
|
28
32
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
* 环境变量(pi 侧用 PI_ 前缀,语义同 dsh 的 DSH_HERDR_*):`PI_HERDR_PROMPT_GUARD`(默认 on,
|
|
34
|
+
* 置 off 关闭插队保护)、`PI_HERDR_READ_MODE`(默认 digest)、`PI_HERDR_READ_TAIL_LINES`(默认 30)。
|
|
31
35
|
*/
|
|
32
36
|
|
|
33
37
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -37,10 +41,8 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
37
41
|
import { Type } from "typebox";
|
|
38
42
|
|
|
39
43
|
type AgentStatus = "idle" | "working" | "blocked" | "done" | "unknown";
|
|
40
|
-
type ReadSource = "visible" | "recent" | "recent-unwrapped" | "detection";
|
|
41
|
-
type WaitOutputSource = Exclude<ReadSource, "detection">;
|
|
42
44
|
type SplitDirection = "right" | "down";
|
|
43
|
-
type
|
|
45
|
+
type ReadMode = "digest" | "raw";
|
|
44
46
|
|
|
45
47
|
interface WorkspaceInfo {
|
|
46
48
|
workspace_id: string;
|
|
@@ -49,14 +51,6 @@ interface WorkspaceInfo {
|
|
|
49
51
|
agent_status: AgentStatus;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
interface TabInfo {
|
|
53
|
-
tab_id: string;
|
|
54
|
-
workspace_id: string;
|
|
55
|
-
label: string;
|
|
56
|
-
focused: boolean;
|
|
57
|
-
agent_status: AgentStatus;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
54
|
interface PaneInfo {
|
|
61
55
|
pane_id: string;
|
|
62
56
|
workspace_id: string;
|
|
@@ -106,26 +100,53 @@ interface HerdrJsonEnvelope {
|
|
|
106
100
|
};
|
|
107
101
|
}
|
|
108
102
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
103
|
+
/** 八个工具共用的结果信封:渲染成正文,同时作为 `details` 交给渲染层。 */
|
|
104
|
+
interface Envelope {
|
|
105
|
+
ok: boolean;
|
|
106
|
+
exitCode: number;
|
|
107
|
+
timedOut?: boolean;
|
|
108
|
+
output: string;
|
|
109
|
+
note?: string;
|
|
110
|
+
via?: string;
|
|
113
111
|
}
|
|
114
112
|
|
|
113
|
+
/** 一次 herdr 调用的结局:成功 / 本地截止时间到期 / 真失败。 */
|
|
114
|
+
type Failure =
|
|
115
|
+
| { ok: false; timedOut: false; error: unknown }
|
|
116
|
+
| { ok: false; timedOut: true; error?: undefined };
|
|
117
|
+
type Attempt<T> = { ok: true; value: T } | Failure;
|
|
118
|
+
|
|
115
119
|
const StatusEnum = StringEnum(["idle", "working", "blocked", "done", "unknown"] as const, {
|
|
116
|
-
description: "
|
|
120
|
+
description: "要等的 agent 生命周期状态",
|
|
117
121
|
});
|
|
118
122
|
|
|
119
123
|
const ReadSourceEnum = StringEnum(["visible", "recent", "recent-unwrapped", "detection"] as const, {
|
|
120
|
-
description: "
|
|
124
|
+
description: "终端快照来源,默认 recent-unwrapped(日志与长文本首选)",
|
|
121
125
|
});
|
|
122
126
|
|
|
123
|
-
const
|
|
124
|
-
description: "
|
|
127
|
+
const ReadModeEnum = StringEnum(["digest", "raw"] as const, {
|
|
128
|
+
description: "默认 digest:压成「前段信号行 + 尾部」;要整段原文传 raw",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 八个工具共用的结构化结果:codemode 脚本拿它做过滤,模型仍看 content 里的渲染文本。
|
|
133
|
+
* 与 Envelope 同形,免得多出一套真值。
|
|
134
|
+
*/
|
|
135
|
+
const envelopeOutputSchema = Type.Object({
|
|
136
|
+
ok: Type.Boolean({ description: "这条 herdr 命令是否成功" }),
|
|
137
|
+
exitCode: Type.Number({ description: "退出码;-1 表示本地截止时间到期" }),
|
|
138
|
+
timedOut: Type.Optional(Type.Boolean({ description: "本地截止时间是否到期" })),
|
|
139
|
+
output: Type.String({ description: "正文:digest 压缩后的信号行 + 尾部,或 raw 全文" }),
|
|
140
|
+
note: Type.Optional(Type.String({ description: "判据提示,或失败时的说明" })),
|
|
141
|
+
via: Type.Optional(Type.String({ description: "实际走了 agent 还是 pane 视图" })),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const CreateKindEnum = StringEnum(["workspace", "tab", "pane"] as const, {
|
|
145
|
+
description: "建 workspace、建 tab,还是切一个兄弟 pane",
|
|
125
146
|
});
|
|
126
147
|
|
|
127
148
|
const DirectionEnum = StringEnum(["right", "down"] as const, {
|
|
128
|
-
description: "
|
|
149
|
+
description: "pane 切分方向,默认从几何信息判断",
|
|
129
150
|
});
|
|
130
151
|
|
|
131
152
|
const AgentKindEnum = StringEnum(
|
|
@@ -155,14 +176,53 @@ const AgentKindEnum = StringEnum(
|
|
|
155
176
|
"maki",
|
|
156
177
|
"muse",
|
|
157
178
|
] as const,
|
|
158
|
-
{ description: "
|
|
179
|
+
{ description: "agent 种类(如 claude / codex / pi)。必须与 `herdr agent start --help` 一致" },
|
|
159
180
|
);
|
|
160
181
|
|
|
182
|
+
/** 每次 herdr 调用的本地截止时间:卡死的 herdr 不能把 manager 一起挂住。 */
|
|
183
|
+
const DEFAULT_LOCAL_TIMEOUT = 120_000;
|
|
184
|
+
|
|
161
185
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
186
|
+
* herdr_wait 的本地截止时间比它向 herdr 要的等待略长,让 herdr 自己的 timeout 先到;
|
|
187
|
+
* 本地那条只兜「herdr 卡死」。
|
|
164
188
|
*/
|
|
165
|
-
const
|
|
189
|
+
const WAIT_LOCAL_MARGIN = 30_000;
|
|
190
|
+
const WAIT_DEFAULT_TIMEOUT = 60_000;
|
|
191
|
+
|
|
192
|
+
/** 插队保护:lancer 正在 working 的 agent 时默认拒绝下发。 */
|
|
193
|
+
const PROMPT_GUARD_ON = (process.env.PI_HERDR_PROMPT_GUARD ?? "on") !== "off";
|
|
194
|
+
|
|
195
|
+
const readTailFromEnv = Number(process.env.PI_HERDR_READ_TAIL_LINES ?? 30);
|
|
196
|
+
const READ_TAIL_LINES = Number.isFinite(readTailFromEnv) && readTailFromEnv > 0 ? readTailFromEnv : 30;
|
|
197
|
+
const READ_MODE_DEFAULT: ReadMode = process.env.PI_HERDR_READ_MODE === "raw" ? "raw" : "digest";
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* digest 从前段捞出来的信号行:审批、报错、超时。判据和 dsh 侧一致 —— manager 的
|
|
201
|
+
* 「有没有东西在等人」判断是按这批词训出来的。
|
|
202
|
+
*/
|
|
203
|
+
const SIGNAL_RE =
|
|
204
|
+
/(\[y\/N\]|\(y\/n\)|是否|确认|同意|approve|allow|permission|do you want|waiting for|error|exception|traceback|failed|失败|报错|超时|?|\?$)/i;
|
|
205
|
+
|
|
206
|
+
/** herdr_overview 每个列表默认返回多少条,与 dsh 侧一致。 */
|
|
207
|
+
const OVERVIEW_LIST_LIMIT = 50;
|
|
208
|
+
|
|
209
|
+
/** agent 名字的约束(herdr CLI 自己也是这条)。 */
|
|
210
|
+
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
211
|
+
|
|
212
|
+
/** herdr_agent_start 的 timeout 边界,dsh 侧同样在 body 里判。 */
|
|
213
|
+
const START_TIMEOUT_MIN = 3_000;
|
|
214
|
+
const START_TIMEOUT_MAX = 300_000;
|
|
215
|
+
|
|
216
|
+
/** exitCode 的语义,让 note 读起来和 dsh 侧一样。 */
|
|
217
|
+
const EXIT_NOTE: Record<number, string> = {
|
|
218
|
+
1: "herdr 侧错误(消息来自它的结构化错误)",
|
|
219
|
+
2: "参数或前置校验失败(没有下发给 herdr)",
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const LOCAL_TIMEOUT_NOTE = "本地等待超时,未观测到结果(不等于命令失败);用 herdr_wait / herdr_read 复查再决定。";
|
|
223
|
+
|
|
224
|
+
const SELF_PANE_REFUSAL =
|
|
225
|
+
"拒绝对自己所在的 pane 下发:那等于给自己喂输入,会打断你正在进行的这一轮。要操作别的 pane,先从 herdr_overview 拿完整 pane id。";
|
|
166
226
|
|
|
167
227
|
interface HerdrErrorDetail {
|
|
168
228
|
code?: string;
|
|
@@ -182,7 +242,7 @@ function parseHerdrError(output: string): HerdrErrorDetail | null {
|
|
|
182
242
|
}
|
|
183
243
|
}
|
|
184
244
|
|
|
185
|
-
/** 构造带 herdr code
|
|
245
|
+
/** 构造带 herdr code 的错误,让调用方能区分「检测未达成」和真正的失败。 */
|
|
186
246
|
function herdrError(message: string, code?: string): Error {
|
|
187
247
|
const error = new Error(message) as Error & { herdrCode?: string };
|
|
188
248
|
if (code) error.herdrCode = code;
|
|
@@ -193,17 +253,22 @@ function herdrErrorCode(error: unknown): string | undefined {
|
|
|
193
253
|
return error instanceof Error ? (error as Error & { herdrCode?: string }).herdrCode : undefined;
|
|
194
254
|
}
|
|
195
255
|
|
|
256
|
+
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
|
257
|
+
|
|
196
258
|
/**
|
|
197
|
-
* herdr 的 timeout
|
|
198
|
-
*
|
|
199
|
-
* stalled 的文案见过两种:旧版 `no observed state change ... state_change_seq remained N`,
|
|
200
|
-
* 0.9.0 起 `no observed working or blocked state within 5000 ms; current status is idle`
|
|
201
|
-
* (0.9.5 二进制里仍是这段措辞)。
|
|
259
|
+
* herdr 的 timeout 表示「没观测到」。code 是主判据,文案匹配作为兜底
|
|
260
|
+
* (个别版本可能不带结构化 code)。
|
|
202
261
|
*/
|
|
203
262
|
function isHerdrTimeout(error: unknown): boolean {
|
|
204
263
|
return herdrErrorCode(error) === "timeout" || (error instanceof Error && /timed out waiting for/i.test(error.message));
|
|
205
264
|
}
|
|
206
265
|
|
|
266
|
+
/**
|
|
267
|
+
* `agent_prompt_stalled` 是「submission accepted」之后 5s 内没观测到 working/blocked,
|
|
268
|
+
* prompt 已经下发 —— 这是启发式,不能当成下发失败。文案见过两种:旧版
|
|
269
|
+
* `no observed state change ... state_change_seq remained N`,0.9.0 起
|
|
270
|
+
* `no observed working or blocked state within 5000 ms; current status is idle`。
|
|
271
|
+
*/
|
|
207
272
|
function isPromptStalled(error: unknown): boolean {
|
|
208
273
|
return (
|
|
209
274
|
herdrErrorCode(error) === "agent_prompt_stalled" ||
|
|
@@ -214,7 +279,7 @@ function isPromptStalled(error: unknown): boolean {
|
|
|
214
279
|
/**
|
|
215
280
|
* 目标正卡在审批/提问弹窗时,herdr 在**发送任何输入之前**就拒绝 prompt,返回 agent_blocked
|
|
216
281
|
* (见 0.9.5 的 `herdr agent prompt --help` 与 api/agents.rs 的 validate_agent_prompt)。
|
|
217
|
-
* 这和 stalled
|
|
282
|
+
* 这和 stalled 相反:它确定没下发,也不该自动重试 —— 必须先看清阻塞的界面再由人决定怎么答。
|
|
218
283
|
*/
|
|
219
284
|
function isPromptBlocked(error: unknown): boolean {
|
|
220
285
|
return herdrErrorCode(error) === "agent_blocked";
|
|
@@ -233,27 +298,15 @@ function formatOutput(output: string): string {
|
|
|
233
298
|
return `[Showing last ${truncation.outputLines} of ${truncation.totalLines} lines]\n${truncation.content}`;
|
|
234
299
|
}
|
|
235
300
|
|
|
236
|
-
function chooseSplitDirection(
|
|
301
|
+
function chooseSplitDirection(
|
|
302
|
+
layout: { panes: Array<{ pane_id: string; rect: PaneLayoutRect }> },
|
|
303
|
+
paneId: string,
|
|
304
|
+
): SplitDirection {
|
|
237
305
|
const pane = layout.panes.find((candidate) => candidate.pane_id === paneId);
|
|
238
306
|
if (!pane) return "right";
|
|
239
307
|
return pane.rect.width >= 80 && pane.rect.width >= pane.rect.height * 2 ? "right" : "down";
|
|
240
308
|
}
|
|
241
309
|
|
|
242
|
-
function statusDot(theme: any, status: AgentStatus): string {
|
|
243
|
-
switch (status) {
|
|
244
|
-
case "blocked":
|
|
245
|
-
return theme.fg("warning", "●");
|
|
246
|
-
case "working":
|
|
247
|
-
return theme.fg("accent", "●");
|
|
248
|
-
case "done":
|
|
249
|
-
return theme.fg("success", "●");
|
|
250
|
-
case "idle":
|
|
251
|
-
return theme.fg("muted", "○");
|
|
252
|
-
default:
|
|
253
|
-
return theme.fg("dim", "·");
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
310
|
function agentDisplayName(agent: AgentInfo): string {
|
|
258
311
|
return agent.name || agent.display_agent || agent.agent || agent.pane_id;
|
|
259
312
|
}
|
|
@@ -275,13 +328,6 @@ function summarizePane(pane: PaneInfo, currentPaneId?: string): string {
|
|
|
275
328
|
return `${pane.label || pane.pane_id}: [${pane.pane_id}]${flags ? ` (${flags})` : ""}${cwd ? ` ${cwd}` : ""}`;
|
|
276
329
|
}
|
|
277
330
|
|
|
278
|
-
function summarizeTab(tab: TabInfo): string {
|
|
279
|
-
const flags = [tab.focused ? "focused" : null, tab.agent_status !== "unknown" ? tab.agent_status : null]
|
|
280
|
-
.filter(Boolean)
|
|
281
|
-
.join(", ");
|
|
282
|
-
return `${tab.label}: [${tab.tab_id}]${flags ? ` (${flags})` : ""}`;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
331
|
function summarizeWorkspace(workspace: WorkspaceInfo): string {
|
|
286
332
|
const flags = [
|
|
287
333
|
workspace.focused ? "focused" : null,
|
|
@@ -292,75 +338,149 @@ function summarizeWorkspace(workspace: WorkspaceInfo): string {
|
|
|
292
338
|
return `${workspace.label}: [${workspace.workspace_id}]${flags ? ` (${flags})` : ""}`;
|
|
293
339
|
}
|
|
294
340
|
|
|
295
|
-
|
|
341
|
+
/**
|
|
342
|
+
* 截断一个 overview 列表。静默截断会被读成「全机就这些」—— 对 manager 恰是错的结论,
|
|
343
|
+
* 所以永远写出被隐藏的条数,让调用方自己决定抬 limit。
|
|
344
|
+
*/
|
|
345
|
+
function capList<T>(items: T[], limit: number, kind: string, summarize: (item: T) => string): string {
|
|
346
|
+
if (!items.length) return `No ${kind}.`;
|
|
347
|
+
const shown = items.slice(0, limit);
|
|
348
|
+
const body = shown.map(summarize).join("\n");
|
|
349
|
+
return shown.length < items.length ? `${body}\n[Showing ${shown.length} of ${items.length} ${kind} — raise with limit]` : body;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 把读屏压成「前段命中的信号行 + 原样尾部」。
|
|
354
|
+
*
|
|
355
|
+
* 为什么不是单纯截断:manager 既要判断又要引用这段文字,尾部必须原样留着(那是证据),
|
|
356
|
+
* 而更早的审批/失败行值得提上来。与 dsh 侧同一份实现,保证两边看到同一幅画面。
|
|
357
|
+
*/
|
|
358
|
+
function digestRead(output: string, tailLines: number): string {
|
|
359
|
+
const lines = output.split("\n");
|
|
360
|
+
let end = lines.length;
|
|
361
|
+
while (end > 0 && lines[end - 1].trim() === "") end -= 1;
|
|
362
|
+
const body = lines.slice(0, end);
|
|
363
|
+
if (body.length <= tailLines) return output;
|
|
364
|
+
const tail = body.slice(-tailLines);
|
|
365
|
+
const head = [`(读屏已压缩:原始 ${body.length} 行 → 尾部 ${tail.length} 行;要全量传 mode="raw")`];
|
|
366
|
+
const signals: string[] = [];
|
|
367
|
+
for (let i = 0; i < body.length - tail.length; i += 1) {
|
|
368
|
+
if (SIGNAL_RE.test(body[i])) signals.push(`[${i + 1}] ${body[i]}`);
|
|
369
|
+
}
|
|
370
|
+
if (signals.length > 0) head.push("## 前段命中的信号行(审批/报错)", ...signals.slice(-20));
|
|
371
|
+
head.push("## 尾部(原样)", ...tail);
|
|
372
|
+
return head.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** 信封渲染:note 是判据提示,所以排在最前面。 */
|
|
376
|
+
function renderEnvelope(value: Envelope): string {
|
|
377
|
+
const head = value.note === undefined ? "" : `${value.note}\n`;
|
|
378
|
+
const body = value.output.length > 0 ? value.output : "(无输出)";
|
|
379
|
+
return (head + body).trim();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function envelopeResult(value: Envelope) {
|
|
383
|
+
return {
|
|
384
|
+
content: [{ type: "text" as const, text: renderEnvelope(value) }],
|
|
385
|
+
details: value,
|
|
386
|
+
// 声明了 outputSchema 的工具:脚本收结构化值,模型仍收上面的渲染文本。
|
|
387
|
+
structuredContent: value,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 自己拒绝的决定,发生在任何 herdr 调用之前。 */
|
|
392
|
+
const refusal = (note: string): Envelope => ({ ok: false, exitCode: 2, output: "", note });
|
|
393
|
+
|
|
394
|
+
/** 来自 herdr 侧前置条件的拒绝。 */
|
|
395
|
+
const precondition = (message: string): Envelope => ({
|
|
396
|
+
ok: false,
|
|
397
|
+
exitCode: 2,
|
|
398
|
+
output: message,
|
|
399
|
+
note: EXIT_NOTE[2],
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
/** 失败结局映射成信封。成功时不会调用它。 */
|
|
403
|
+
function failureEnvelope(outcome: Failure, extra: Partial<Envelope> = {}): Envelope {
|
|
404
|
+
if (outcome.timedOut) {
|
|
405
|
+
return { ok: true, exitCode: -1, timedOut: true, output: "", note: LOCAL_TIMEOUT_NOTE, ...extra };
|
|
406
|
+
}
|
|
407
|
+
return { ok: false, exitCode: 1, output: errorMessage(outcome.error), note: EXIT_NOTE[1], ...extra };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 失败结局的一行摘要,用于 via 里说明回退原因。 */
|
|
411
|
+
function failureLine(outcome: Failure): string {
|
|
412
|
+
return outcome.timedOut ? "本地超时" : errorMessage(outcome.error).split("\n")[0];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 在本地截止时间下跑一次 herdr 调用。
|
|
417
|
+
*
|
|
418
|
+
* 截止时间是组合 signal,而不是第二条调用路径:execHerdr 本来就跟随 signal 中断,
|
|
419
|
+
* 所以这里只需把「调用方取消」(重抛)和「我们的截止时间到了」(timedOut)分开。
|
|
420
|
+
*/
|
|
421
|
+
async function attempt<T>(
|
|
422
|
+
signal: AbortSignal | undefined,
|
|
423
|
+
timeoutMs: number | undefined,
|
|
424
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
425
|
+
): Promise<Attempt<T>> {
|
|
426
|
+
const controller = new AbortController();
|
|
427
|
+
const timer = timeoutMs == null ? null : setTimeout(() => controller.abort(), timeoutMs);
|
|
428
|
+
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
429
|
+
try {
|
|
430
|
+
return { ok: true, value: await operation(combined) };
|
|
431
|
+
} catch (error) {
|
|
432
|
+
if (signal?.aborted === true) throw error;
|
|
433
|
+
if (controller.signal.aborted) return { ok: false, timedOut: true };
|
|
434
|
+
return { ok: false, timedOut: false, error };
|
|
435
|
+
} finally {
|
|
436
|
+
if (timer !== null) clearTimeout(timer);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function renderVerbCall(tool: string, args: Record<string, any>, theme: any, context: any) {
|
|
296
441
|
const component = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
297
442
|
let text = theme.fg("toolTitle", theme.bold(`${tool} `));
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
if (args.
|
|
302
|
-
if (args.
|
|
443
|
+
const target = args.target || args.pane || args.kind || args.workspace;
|
|
444
|
+
if (target) text += theme.fg("accent", target);
|
|
445
|
+
if (args.label) text += theme.fg("muted", ` ${args.label}`);
|
|
446
|
+
if (args.scope) text += theme.fg("dim", ` › ${args.scope}`);
|
|
447
|
+
if (args.mode) text += theme.fg("dim", ` › ${args.mode}`);
|
|
303
448
|
if (args.direction) text += theme.fg("dim", ` › ${args.direction}`);
|
|
304
449
|
if (args.command) text += theme.fg("dim", ` › ${args.command}`);
|
|
305
450
|
if (args.prompt) text += theme.fg("dim", ` › ${args.prompt}`);
|
|
306
|
-
if (args.
|
|
451
|
+
if (args.dryRun === false) text += theme.fg("warning", " › close");
|
|
307
452
|
component.setText(text);
|
|
308
453
|
return component;
|
|
309
454
|
}
|
|
310
455
|
|
|
311
|
-
function
|
|
312
|
-
if (options.isPartial) return new Text(theme.fg("warning", "◌
|
|
313
|
-
const details = result.details as Record<string, any> | undefined;
|
|
456
|
+
function renderVerbResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: any) {
|
|
457
|
+
if (options.isPartial) return new Text(theme.fg("warning", "◌ running"), 0, 0);
|
|
314
458
|
const content = result.content?.[0];
|
|
315
459
|
const rawText = content?.type === "text" ? content.text : "";
|
|
460
|
+
const details = result.details as Envelope | undefined;
|
|
316
461
|
if (!details) return new Text(rawText, 0, 0);
|
|
317
462
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
return new Text(
|
|
329
|
-
agents.length
|
|
330
|
-
? agents
|
|
331
|
-
.map(
|
|
332
|
-
(agent) =>
|
|
333
|
-
`${statusDot(theme, agent.agent_status)} ${theme.fg(agent.focused ? "accent" : "muted", agentDisplayName(agent))} ${theme.fg("dim", agent.agent_status)}`,
|
|
334
|
-
)
|
|
335
|
-
.join("\n")
|
|
336
|
-
: theme.fg("dim", "no agents"),
|
|
337
|
-
0,
|
|
338
|
-
0,
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
if (details.read) {
|
|
342
|
-
let text = theme.fg("accent", `▤ ${details.target || details.pane}`);
|
|
343
|
-
if (options.expanded && rawText) text += `\n${rawText.split("\n").slice(0, 40).map((line: string) => theme.fg("dim", line)).join("\n")}`;
|
|
344
|
-
return new Text(text, 0, 0);
|
|
345
|
-
}
|
|
346
|
-
return new Text(theme.fg("success", `✓ ${details.action || "done"}`), 0, 0);
|
|
463
|
+
const mark = details.ok
|
|
464
|
+
? theme.fg("success", "✓")
|
|
465
|
+
: details.exitCode === -1
|
|
466
|
+
? theme.fg("warning", "◷")
|
|
467
|
+
: theme.fg("error", "✗");
|
|
468
|
+
const code = details.exitCode === 0 ? "" : theme.fg("dim", ` exit=${details.exitCode}`);
|
|
469
|
+
const lines = rawText.split("\n").filter((line: string) => line.trim() !== "");
|
|
470
|
+
const head = `${mark}${code} ${theme.fg("muted", lines[0] ?? "")}`;
|
|
471
|
+
if (!options.expanded) return new Text(head, 0, 0);
|
|
472
|
+
return new Text([head, ...lines.slice(1).map((line: string) => theme.fg("dim", line))].join("\n"), 0, 0);
|
|
347
473
|
}
|
|
348
474
|
|
|
349
475
|
export default function (pi: ExtensionAPI) {
|
|
350
476
|
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) return;
|
|
351
477
|
|
|
352
|
-
/**
|
|
353
|
-
* 本进程最近一次下发到某个 pane 的文本(`run` 的命令、`send_text` 的字面量)。
|
|
354
|
-
* wait_output 用它识别“命中行其实就是刚敲下去的命令回显”,而不是命令产生的输出。
|
|
355
|
-
*/
|
|
356
|
-
const lastSubmitted = new Map<string, string>();
|
|
357
|
-
|
|
358
478
|
async function execHerdr(args: string[], signal?: AbortSignal) {
|
|
359
479
|
const result = await pi.exec("herdr", args, { signal });
|
|
360
480
|
if (signal?.aborted || result.killed) throw new Error("Aborted");
|
|
361
481
|
if (result.code !== 0) {
|
|
362
482
|
// herdr 把结构化错误写在 stderr(退出码 1)。带上 code,调用方才能区分
|
|
363
|
-
// timeout / agent_prompt_stalled
|
|
483
|
+
// timeout / agent_prompt_stalled 这类「没观测到」和真正的失败。
|
|
364
484
|
const detail = parseHerdrError(result.stderr) || parseHerdrError(result.stdout);
|
|
365
485
|
throw herdrError(
|
|
366
486
|
detail?.message || `herdr ${args.join(" ")} failed with exit code ${result.code}`,
|
|
@@ -390,24 +510,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
390
510
|
return (await execHerdr(args, signal)).stdout;
|
|
391
511
|
}
|
|
392
512
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
source: WaitOutputSource,
|
|
399
|
-
lines: number | undefined,
|
|
400
|
-
signal?: AbortSignal,
|
|
401
|
-
): Promise<string | null> {
|
|
402
|
-
const args = ["pane", "read", paneId, "--source", source];
|
|
403
|
-
if (lines != null) args.push("--lines", String(lines));
|
|
404
|
-
try {
|
|
405
|
-
return await execHerdrText(args, signal);
|
|
406
|
-
} catch (error) {
|
|
407
|
-
if (isAbortError(error, signal)) throw error;
|
|
408
|
-
return null;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
513
|
+
const textCall = (args: string[], signal?: AbortSignal, timeoutMs?: number) =>
|
|
514
|
+
attempt(signal, timeoutMs ?? DEFAULT_LOCAL_TIMEOUT, (s) => execHerdrText(args, s));
|
|
515
|
+
|
|
516
|
+
const jsonCall = <T>(args: string[], signal?: AbortSignal, timeoutMs?: number) =>
|
|
517
|
+
attempt(signal, timeoutMs ?? DEFAULT_LOCAL_TIMEOUT, (s) => execHerdrJson<T>(args, s));
|
|
411
518
|
|
|
412
519
|
async function getCurrentPane(signal?: AbortSignal): Promise<PaneInfo> {
|
|
413
520
|
const response = await execHerdrJson<{ result: { pane: PaneInfo } }>(["pane", "current", "--current"], signal);
|
|
@@ -427,560 +534,554 @@ export default function (pi: ExtensionAPI) {
|
|
|
427
534
|
return response.result.layout;
|
|
428
535
|
}
|
|
429
536
|
|
|
537
|
+
/**
|
|
538
|
+
* 把 agent target 解析成 `pane run` 回退需要的完整 pane id。agent 名不是 pane id,
|
|
539
|
+
* 侧栏短名也不是,猜一下就会把 prompt 打进错的终端。`agent get` 对活着的名字和承载它
|
|
540
|
+
* 的 pane id 都答;全机列表是第二索引,因为自报 agent 可能查不到直接的那一条。
|
|
541
|
+
*/
|
|
542
|
+
async function resolveAgentPaneId(target: string, signal?: AbortSignal): Promise<string> {
|
|
543
|
+
try {
|
|
544
|
+
const response = await execHerdrJson<{
|
|
545
|
+
result?: { pane?: { pane_id?: string }; agent?: { pane_id?: string } };
|
|
546
|
+
}>(["agent", "get", target], signal);
|
|
547
|
+
const paneId = response.result?.pane?.pane_id || response.result?.agent?.pane_id;
|
|
548
|
+
if (paneId) return paneId;
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (isAbortError(error, signal)) throw error;
|
|
551
|
+
// 落到下面的列表扫描:它才是权威索引。
|
|
552
|
+
}
|
|
553
|
+
const response = await execHerdrJson<{ result: { agents: AgentInfo[] } }>(["agent", "list"], signal);
|
|
554
|
+
const match = (response.result.agents || []).find(
|
|
555
|
+
(agent) => agent.pane_id === target || agent.name === target || agentDisplayName(agent) === target,
|
|
556
|
+
);
|
|
557
|
+
if (match?.pane_id) return match.pane_id;
|
|
558
|
+
throw new Error(
|
|
559
|
+
`'${target}' 没有解析成完整 pane id(agent get 与 agent list 都查不到),` +
|
|
560
|
+
"无法确定该往哪个 pane 键入 —— 不猜。先用 herdr_overview 拿到完整 pane id 再发。",
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
430
564
|
pi.registerTool({
|
|
431
|
-
name: "
|
|
432
|
-
label: "Herdr
|
|
565
|
+
name: "herdr_overview",
|
|
566
|
+
label: "Herdr Overview",
|
|
567
|
+
outputSchema: envelopeOutputSchema,
|
|
433
568
|
description:
|
|
434
|
-
"
|
|
435
|
-
promptSnippet: "
|
|
569
|
+
"盘点:一次拿到自己的位置 + 全机 workspace 拓扑 + 全机 agent 状态(名称/状态/cwd/pane id)。管理其他 agent 的第一步几乎总是它 —— 先看有哪些 agent、谁在忙、谁空着。只读。",
|
|
570
|
+
promptSnippet: "一次看清 herdr 里有哪些 agent、谁在忙、各自在哪个 pane",
|
|
436
571
|
promptGuidelines: [
|
|
437
|
-
"
|
|
438
|
-
"
|
|
439
|
-
"
|
|
572
|
+
"要管别的 agent 之前先调 herdr_overview,用结果里的完整 pane id 当后续 target,不要自己拼。",
|
|
573
|
+
"列表被 limit 截断时会写明隐藏条数 —— 别把截断结果当成「全机就这些」。",
|
|
574
|
+
"herdr_overview 只读:不切焦点、不下发任何东西。",
|
|
440
575
|
],
|
|
441
576
|
parameters: Type.Object({
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
"workspace_list",
|
|
446
|
-
"workspace_create",
|
|
447
|
-
"workspace_focus",
|
|
448
|
-
"tab_list",
|
|
449
|
-
"tab_create",
|
|
450
|
-
"tab_focus",
|
|
451
|
-
"pane_list",
|
|
452
|
-
"pane_layout",
|
|
453
|
-
"pane_split",
|
|
454
|
-
] as const,
|
|
455
|
-
{ description: "Layout action" },
|
|
577
|
+
limit: Type.Optional(Type.Integer({ description: `每个列表最多返回多少条,默认 ${OVERVIEW_LIST_LIMIT}` })),
|
|
578
|
+
workspace: Type.Optional(
|
|
579
|
+
Type.String({ description: "只看某个 workspace 的拓扑(如 wC)。agent 列表也一并收窄到该 workspace" }),
|
|
456
580
|
),
|
|
457
|
-
workspace: Type.Optional(Type.String({ description: "Opaque workspace ID" })),
|
|
458
|
-
tab: Type.Optional(Type.String({ description: "Opaque tab ID" })),
|
|
459
|
-
pane: Type.Optional(
|
|
460
|
-
Type.String({ description: "Opaque source pane ID. Omit for current, pane_layout, or pane_split to use the caller's pane." }),
|
|
461
|
-
),
|
|
462
|
-
label: Type.Optional(Type.String({ description: "Label for a new workspace or tab" })),
|
|
463
|
-
direction: Type.Optional(DirectionEnum),
|
|
464
|
-
cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the caller pane's foreground cwd." })),
|
|
465
|
-
focus: Type.Optional(Type.Boolean({ description: "Change UI focus after creation. Defaults to false." })),
|
|
466
581
|
}),
|
|
467
|
-
async execute(_toolCallId, params, signal
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
return {
|
|
518
|
-
content: [{ type: "text", text: tabs.length ? tabs.map(summarizeTab).join("\n") : "No tabs." }],
|
|
519
|
-
details: { action: "tab_list", tabs },
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
case "tab_create": {
|
|
523
|
-
const current = await getCurrentPane(signal);
|
|
524
|
-
const args = ["tab", "create", "--workspace", params.workspace || current.workspace_id];
|
|
525
|
-
args.push("--cwd", params.cwd || current.foreground_cwd || current.cwd || process.cwd());
|
|
526
|
-
if (params.label) args.push("--label", params.label);
|
|
527
|
-
args.push(params.focus === true ? "--focus" : "--no-focus");
|
|
528
|
-
const response = await execHerdrJson<{ result: { tab: TabInfo; root_pane: PaneInfo } }>(args, signal);
|
|
529
|
-
const { tab, root_pane: rootPane } = response.result;
|
|
530
|
-
return {
|
|
531
|
-
content: [{ type: "text", text: `Created tab ${tab.tab_id}, root pane ${rootPane.pane_id}` }],
|
|
532
|
-
details: { action: "tab_create", tab, pane: rootPane },
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
case "tab_focus": {
|
|
536
|
-
if (!params.tab) throw new Error("'tab' is required for tab_focus");
|
|
537
|
-
const response = await execHerdrJson<{ result: { tab: TabInfo } }>(["tab", "focus", params.tab], signal);
|
|
538
|
-
return {
|
|
539
|
-
content: [{ type: "text", text: `Focused tab ${response.result.tab.tab_id}` }],
|
|
540
|
-
details: { action: "tab_focus", tab: response.result.tab },
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
case "pane_list": {
|
|
544
|
-
const current = await getCurrentPane(signal);
|
|
545
|
-
const workspaceId = params.workspace || current.workspace_id;
|
|
546
|
-
const response = await execHerdrJson<{ result: { panes: PaneInfo[] } }>(
|
|
547
|
-
["pane", "list", "--workspace", workspaceId],
|
|
548
|
-
signal,
|
|
549
|
-
);
|
|
550
|
-
const panes = response.result.panes || [];
|
|
551
|
-
return {
|
|
552
|
-
content: [{ type: "text", text: panes.length ? panes.map((pane) => summarizePane(pane, current.pane_id)).join("\n") : "No panes." }],
|
|
553
|
-
details: { action: "pane_list", panes, workspaceId },
|
|
554
|
-
};
|
|
555
|
-
}
|
|
556
|
-
case "pane_layout": {
|
|
557
|
-
const paneId = params.pane || (await getCurrentPane(signal)).pane_id;
|
|
558
|
-
const layout = await getPaneLayout(paneId, signal);
|
|
559
|
-
return {
|
|
560
|
-
content: [{ type: "text", text: JSON.stringify(layout, null, 2) }],
|
|
561
|
-
details: { action: "pane_layout", layout },
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
case "pane_split": {
|
|
565
|
-
const current = await getCurrentPane(signal);
|
|
566
|
-
const source = params.pane ? await getPane(params.pane, signal) : current;
|
|
567
|
-
const direction = params.direction || chooseSplitDirection(await getPaneLayout(source.pane_id, signal), source.pane_id);
|
|
568
|
-
const cwd = params.cwd || source.foreground_cwd || source.cwd || current.foreground_cwd || current.cwd || process.cwd();
|
|
569
|
-
const args = ["pane", "split", source.pane_id, "--direction", direction, "--cwd", cwd];
|
|
570
|
-
args.push(params.focus === true ? "--focus" : "--no-focus");
|
|
571
|
-
const response = await execHerdrJson<{ result: { pane: PaneInfo } }>(args, signal);
|
|
572
|
-
const pane = response.result.pane;
|
|
573
|
-
return {
|
|
574
|
-
content: [{ type: "text", text: `Created pane ${pane.pane_id} by splitting ${source.pane_id} ${direction}` }],
|
|
575
|
-
details: { action: "pane_split", pane, sourcePaneId: source.pane_id, direction },
|
|
576
|
-
};
|
|
577
|
-
}
|
|
582
|
+
async execute(_toolCallId, params, signal) {
|
|
583
|
+
const limit = params.limit ?? OVERVIEW_LIST_LIMIT;
|
|
584
|
+
// dsh 的参数 spec 没有 minimum,所以这条校验落在 body 里;pi 保持一致好让信封统一。
|
|
585
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
586
|
+
return envelopeResult(precondition(`--limit must be >= 1 (default ${OVERVIEW_LIST_LIMIT}).`));
|
|
587
|
+
}
|
|
588
|
+
// 三次独立读,并行付一次 RTT 而不是三次。
|
|
589
|
+
const [current, workspaces, agents] = await Promise.all([
|
|
590
|
+
jsonCall<{ result: { pane: PaneInfo } }>(["pane", "current", "--current"], signal),
|
|
591
|
+
jsonCall<{ result: { workspaces: WorkspaceInfo[] } }>(["workspace", "list"], signal),
|
|
592
|
+
jsonCall<{ result: { agents: AgentInfo[] } }>(["agent", "list"], signal),
|
|
593
|
+
]);
|
|
594
|
+
const failed: Failure | undefined = [current, workspaces, agents].find(
|
|
595
|
+
(part): part is Failure => part.ok !== true,
|
|
596
|
+
);
|
|
597
|
+
const allWorkspaces = workspaces.ok === true ? workspaces.value.result.workspaces || [] : [];
|
|
598
|
+
const allAgents = agents.ok === true ? agents.value.result.agents || [] : [];
|
|
599
|
+
// `agent list` 不接受 --workspace,但每条 agent 记录都带 workspace_id,
|
|
600
|
+
// 所以收窄是精确的客户端过滤,不需要第二次查询。
|
|
601
|
+
const scopedWorkspaces = params.workspace
|
|
602
|
+
? allWorkspaces.filter((workspace) => workspace.workspace_id === params.workspace)
|
|
603
|
+
: allWorkspaces;
|
|
604
|
+
const scopedAgents = params.workspace
|
|
605
|
+
? allAgents.filter((agent) => agent.workspace_id === params.workspace)
|
|
606
|
+
: allAgents;
|
|
607
|
+
const output = [
|
|
608
|
+
"## 我所在的位置",
|
|
609
|
+
current.ok === true
|
|
610
|
+
? summarizePane(current.value.result.pane, current.value.result.pane.pane_id)
|
|
611
|
+
: failureEnvelope(current).output,
|
|
612
|
+
"## workspace 拓扑",
|
|
613
|
+
workspaces.ok === true
|
|
614
|
+
? capList(scopedWorkspaces, limit, "workspaces", summarizeWorkspace)
|
|
615
|
+
: failureEnvelope(workspaces).output,
|
|
616
|
+
params.workspace ? `## agent(workspace ${params.workspace})` : "## 全机 agent",
|
|
617
|
+
agents.ok === true
|
|
618
|
+
? capList(scopedAgents, limit, "agents", summarizeAgent)
|
|
619
|
+
: failureEnvelope(agents).output,
|
|
620
|
+
].join("\n");
|
|
621
|
+
// 本地超时不是 herdr 错误,但确实少了一段;把盘点报成 ok 会让 manager
|
|
622
|
+
// 以为自己看全了整支舰队。
|
|
623
|
+
if (failed === undefined) return envelopeResult({ ok: true, exitCode: 0, output });
|
|
624
|
+
if (failed.timedOut) {
|
|
625
|
+
return envelopeResult({
|
|
626
|
+
ok: false,
|
|
627
|
+
exitCode: -1,
|
|
628
|
+
timedOut: true,
|
|
629
|
+
output,
|
|
630
|
+
note: "盘点有部分超时:本地等待超时只说明没观测到,不等于 herdr 出错。重跑一次,或缩小 limit 再试。",
|
|
631
|
+
});
|
|
578
632
|
}
|
|
633
|
+
return envelopeResult({ ok: false, exitCode: 1, output, note: EXIT_NOTE[1] });
|
|
579
634
|
},
|
|
580
635
|
renderCall(args, theme, context) {
|
|
581
|
-
return
|
|
636
|
+
return renderVerbCall("herdr_overview", args, theme, context);
|
|
582
637
|
},
|
|
583
638
|
renderResult(result, options, theme) {
|
|
584
|
-
return
|
|
639
|
+
return renderVerbResult(result, options, theme);
|
|
585
640
|
},
|
|
586
641
|
});
|
|
587
642
|
|
|
588
643
|
pi.registerTool({
|
|
589
|
-
name: "
|
|
590
|
-
label: "Herdr
|
|
644
|
+
name: "herdr_read",
|
|
645
|
+
label: "Herdr Read",
|
|
646
|
+
outputSchema: envelopeOutputSchema,
|
|
591
647
|
description:
|
|
592
|
-
"
|
|
593
|
-
promptSnippet: "
|
|
648
|
+
"读屏:看某个 pane(或它里面的 agent)当前输出。盯梢一个 agent 在干什么、卡在哪,用它。target 用完整限定 pane id(如 wC:p77);scope=agent 走 agent 语义视图,pane 走裸终端视图。只读。默认 digest 模式:只回「前段命中的审批/报错行 + 尾部 30 行」,省上下文;要看整段传 mode=\"raw\"。",
|
|
649
|
+
promptSnippet: "读取某个 pane 或 agent 的当前输出(默认压缩成信号行 + 尾部)",
|
|
594
650
|
promptGuidelines: [
|
|
595
|
-
"
|
|
596
|
-
"
|
|
597
|
-
"
|
|
598
|
-
"Pick a wait_output pattern that only appears in the command output. Patterns that also occur in the command you sent (script names, function names, marker constants) match the echoed input line and can report success before the command produces anything; echoedInput flags such a hit.",
|
|
599
|
-
"Never use a shell prompt (❯, $, \\$) as a wait_output pattern; it is always present and therefore meaningless.",
|
|
600
|
-
"Do not close a Herdr pane you did not create unless the user explicitly asks. herdr_pane always refuses to close the pane running the current pi process.",
|
|
651
|
+
"读屏先走默认 scope=agent;pane 里没有 herdr 可识别的 agent 时会自动回退到 pane 视图,via 会说明走了哪条。",
|
|
652
|
+
"默认 digest 已经够判断「在干什么/卡在哪」;确信需要逐行引用原文时才传 mode=\"raw\"。",
|
|
653
|
+
"失败不会被压缩:错误原文本身就是证据。",
|
|
601
654
|
],
|
|
602
655
|
parameters: Type.Object({
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
command: Type.Optional(Type.String({ description: "Shell command to submit atomically with Enter for run" })),
|
|
608
|
-
text: Type.Optional(Type.String({ description: "Literal text to send without Enter for send_text" })),
|
|
609
|
-
keys: Type.Optional(
|
|
610
|
-
Type.Array(Type.String(), { description: "Logical terminal keys for send_keys, such as esc, enter, up, or ctrl+c" }),
|
|
611
|
-
),
|
|
612
|
-
match: Type.Optional(
|
|
613
|
-
Type.String({
|
|
614
|
-
description:
|
|
615
|
-
"Pattern for wait_output. Patterns containing regex metacharacters (| ( ) [ ] { } ^ $ * + ? \\) are treated as Rust regular expressions unless regex is explicitly false",
|
|
656
|
+
target: Type.String({ description: "完整限定 pane id(w1B:pF),或唯一 agent 名" }),
|
|
657
|
+
scope: Type.Optional(
|
|
658
|
+
StringEnum(["agent", "pane"] as const, {
|
|
659
|
+
description: "默认 agent;pane 里没有可识别 agent 时用 pane",
|
|
616
660
|
}),
|
|
617
661
|
),
|
|
618
|
-
regex: Type.Optional(
|
|
619
|
-
Type.Boolean({ description: "Force match to be a literal string (false) or a Rust regular expression (true)" }),
|
|
620
|
-
),
|
|
621
662
|
source: Type.Optional(ReadSourceEnum),
|
|
622
|
-
lines: Type.Optional(Type.Integer({ minimum: 1, description: "
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
663
|
+
lines: Type.Optional(Type.Integer({ minimum: 1, description: "只取尾部 N 行(默认 2000 行 / 50KB 封顶)" })),
|
|
664
|
+
mode: Type.Optional(ReadModeEnum),
|
|
665
|
+
tail: Type.Optional(Type.Integer({ minimum: 1, description: `digest 保留多少行尾部,默认 ${READ_TAIL_LINES}` })),
|
|
666
|
+
}),
|
|
667
|
+
async execute(_toolCallId, params, signal) {
|
|
668
|
+
const scope = params.scope ?? "agent";
|
|
669
|
+
const source = params.source ?? "recent-unwrapped";
|
|
670
|
+
const lines = params.lines === undefined ? [] : ["--lines", String(params.lines)];
|
|
671
|
+
const build = (view: "agent" | "pane") =>
|
|
672
|
+
view === "agent"
|
|
673
|
+
? ["agent", "read", params.target, "--source", source, ...lines]
|
|
674
|
+
: ["pane", "read", params.target, "--source", source, ...lines];
|
|
675
|
+
const first = await textCall(build(scope), signal);
|
|
676
|
+
let result: Attempt<string> = first;
|
|
677
|
+
let via: string = scope;
|
|
678
|
+
if (first.ok !== true && scope === "agent") {
|
|
679
|
+
// 这个 pane 里没有 herdr 认的 agent:回退到裸终端视图,并说清哪条路给出了文字。
|
|
680
|
+
result = await textCall(build("pane"), signal);
|
|
681
|
+
via = `pane(agent 视图不可用:${failureLine(first)})`;
|
|
682
|
+
}
|
|
683
|
+
const mode = params.mode ?? READ_MODE_DEFAULT;
|
|
684
|
+
// 失败从不压缩:错误文本本身就是证据。
|
|
685
|
+
if (result.ok !== true) return envelopeResult(failureEnvelope(result, { via }));
|
|
686
|
+
const output = formatOutput(result.value).trim();
|
|
687
|
+
if (mode === "raw") return envelopeResult({ ok: true, exitCode: 0, output, via });
|
|
688
|
+
return envelopeResult({
|
|
689
|
+
ok: true,
|
|
690
|
+
exitCode: 0,
|
|
691
|
+
output: digestRead(output, params.tail ?? READ_TAIL_LINES),
|
|
692
|
+
via: `${via} · digest`,
|
|
693
|
+
});
|
|
694
|
+
},
|
|
695
|
+
renderCall(args, theme, context) {
|
|
696
|
+
return renderVerbCall("herdr_read", args, theme, context);
|
|
697
|
+
},
|
|
698
|
+
renderResult(result, options, theme) {
|
|
699
|
+
return renderVerbResult(result, options, theme);
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
pi.registerTool({
|
|
704
|
+
name: "herdr_wait",
|
|
705
|
+
label: "Herdr Wait",
|
|
706
|
+
outputSchema: envelopeOutputSchema,
|
|
707
|
+
description:
|
|
708
|
+
"等某个 agent 进入指定状态(默认 idle/done/blocked)。盯梢的关键原语:下发任务后等它落定。注意超时只表示「没观测到」,不是失败 —— 不要据此重发任务,先 herdr_read 看实际情况。",
|
|
709
|
+
promptSnippet: "等待某个 agent 进入 idle/done/blocked",
|
|
710
|
+
promptGuidelines: [
|
|
711
|
+
"下发任务以后用 herdr_wait 等它落定,而不是反复 herdr_read 轮询。",
|
|
712
|
+
"等待超时只说明给定时间内没观测到那个状态,不等于对方失败,也不等于任务没送到。",
|
|
713
|
+
],
|
|
714
|
+
parameters: Type.Object({
|
|
715
|
+
target: Type.String({ description: "完整限定 pane id,或唯一 agent 名" }),
|
|
716
|
+
until: Type.Optional(Type.Array(StatusEnum, { description: "要等的状态,默认 idle/done/blocked" })),
|
|
717
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "herdr 侧等待上限,默认由 herdr 决定" })),
|
|
718
|
+
}),
|
|
719
|
+
async execute(_toolCallId, params, signal) {
|
|
720
|
+
const until = (params.until ?? []).flatMap((state) => ["--until", state]);
|
|
721
|
+
const timeout = params.timeoutMs === undefined ? [] : ["--timeout", String(params.timeoutMs)];
|
|
722
|
+
const local = (params.timeoutMs ?? WAIT_DEFAULT_TIMEOUT) + WAIT_LOCAL_MARGIN;
|
|
723
|
+
const result = await jsonCall<{ result: { agent: AgentInfo } }>(
|
|
724
|
+
["agent", "wait", params.target, ...until, ...timeout],
|
|
725
|
+
signal,
|
|
726
|
+
local,
|
|
727
|
+
);
|
|
728
|
+
if (result.ok === true) {
|
|
729
|
+
return envelopeResult({
|
|
730
|
+
ok: true,
|
|
731
|
+
exitCode: 0,
|
|
732
|
+
output: `Agent settled: ${summarizeAgent(result.value.result.agent)}`,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (result.timedOut) return envelopeResult(failureEnvelope(result));
|
|
736
|
+
if (!isHerdrTimeout(result.error)) return envelopeResult(failureEnvelope(result));
|
|
737
|
+
// herdr 把等待超时也报成结构化错误(非零退出)。它的意思是「没观测到」,
|
|
738
|
+
// 所以保留失败退出码,但把这个「不意味着什么」说清楚。
|
|
739
|
+
return envelopeResult({
|
|
740
|
+
ok: false,
|
|
741
|
+
exitCode: 1,
|
|
742
|
+
output: failureEnvelope(result).output,
|
|
743
|
+
note:
|
|
744
|
+
"等待超时:给定时间内没观测到目标状态。这不等于对方失败、也不等于任务没送到 —— " +
|
|
745
|
+
"先 herdr_read 看它实际在做什么,再决定要不要重发。",
|
|
746
|
+
});
|
|
747
|
+
},
|
|
748
|
+
renderCall(args, theme, context) {
|
|
749
|
+
return renderVerbCall("herdr_wait", args, theme, context);
|
|
750
|
+
},
|
|
751
|
+
renderResult(result, options, theme) {
|
|
752
|
+
return renderVerbResult(result, options, theme);
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
pi.registerTool({
|
|
757
|
+
name: "herdr_prompt",
|
|
758
|
+
label: "Herdr Prompt",
|
|
759
|
+
outputSchema: envelopeOutputSchema,
|
|
760
|
+
description:
|
|
761
|
+
"给另一个 agent 下发一段 prompt(调度)。先试 agent prompt(语义路径);若对方是自报状态的 dsh-tui,herdr 会回 agent_not_ready,此时自动回退成 pane run 直接键入。返回的 via 会说明走了哪条路。「已送出」不等于「已开始干活」—— 要确认请配 herdr_wait 或 herdr_read。",
|
|
762
|
+
promptSnippet: "给另一个 agent 下发任务(自动回退到直接键入)",
|
|
763
|
+
promptGuidelines: [
|
|
764
|
+
"herdr_prompt 默认发完就走(不等落定);要等它进入稳定状态请传 wait=true,或另外调 herdr_wait。",
|
|
765
|
+
"目标正在 working 时默认拒绝下发(那是插队,会打断它手上的任务):先 herdr_wait 等它落定;确实要插队才传 force=true。",
|
|
766
|
+
"如果失败信封说 agent_blocked,说明 herdr 在发送任何输入之前就拒绝了:先 herdr_read 看清弹窗内容并问用户,不要重复下发。",
|
|
767
|
+
"「已送出」不等于「已开始干活」—— 用 herdr_wait / herdr_read 确认。",
|
|
768
|
+
],
|
|
769
|
+
parameters: Type.Object({
|
|
770
|
+
target: Type.String({ description: "完整限定 pane id,或 herdr 命名并激活的 agent 名" }),
|
|
771
|
+
prompt: Type.String({ description: "要下发的完整任务文本" }),
|
|
772
|
+
wait: Type.Optional(Type.Boolean({ description: "true 时等它进入稳定状态(agent prompt 语义);默认发了就走" })),
|
|
773
|
+
force: Type.Optional(
|
|
774
|
+
Type.Boolean({ description: "目标正在 working 时,默认拒绝下发(插队会打断它);确实要插队传 true" }),
|
|
626
775
|
),
|
|
627
|
-
timeout: Type.Optional(Type.Integer({ minimum: 1, description: "Wait timeout in milliseconds; omitted means indefinite" })),
|
|
628
776
|
}),
|
|
629
|
-
async execute(_toolCallId, params, signal
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
onUpdate?.({
|
|
681
|
-
content: [{ type: "text", text: `Waiting for output in ${params.pane}...` }],
|
|
682
|
-
details: { action: "wait_output", pane: params.pane, waiting: true },
|
|
683
|
-
});
|
|
684
|
-
const args = ["pane", "wait-output", params.pane, matchMode === "regex" ? "--regex" : "--match", params.match];
|
|
685
|
-
args.push("--source", source);
|
|
686
|
-
if (params.lines != null) args.push("--lines", String(params.lines));
|
|
687
|
-
if (params.timeout != null) args.push("--timeout", String(params.timeout));
|
|
688
|
-
if (params.raw) args.push("--raw");
|
|
689
|
-
let response: { result: WaitOutputResult } | null;
|
|
690
|
-
try {
|
|
691
|
-
response = await execHerdrJson<{ result: WaitOutputResult }>(args, signal);
|
|
692
|
-
} catch (error) {
|
|
693
|
-
if (!isHerdrTimeout(error)) throw error;
|
|
694
|
-
response = null;
|
|
695
|
-
}
|
|
696
|
-
if (response === null) {
|
|
697
|
-
// 超时是“没观测到”,不是失败:返回观测事实(含 pane 尾部输出)让调用方自己判断。
|
|
698
|
-
const tail = await readPaneSnapshot(params.pane, source, params.lines, signal);
|
|
699
|
-
const waitedMs = Date.now() - startedAt;
|
|
700
|
-
return {
|
|
701
|
-
content: [
|
|
702
|
-
{
|
|
703
|
-
type: "text",
|
|
704
|
-
text: [
|
|
705
|
-
`未匹配:${JSON.stringify(params.match)}(模式=${matchMode}, source=${source})在 ${waitedMs}ms 内未出现。`,
|
|
706
|
-
"这既不表示命令失败,也不表示命令完成,只表示这个 pattern 没有出现。",
|
|
707
|
-
...notes,
|
|
708
|
-
tail ? `当前 pane 尾部输出:\n${formatOutput(tail)}` : "(未能读取 pane 尾部输出)",
|
|
709
|
-
].join("\n"),
|
|
710
|
-
},
|
|
711
|
-
],
|
|
712
|
-
details: {
|
|
713
|
-
action: "wait_output",
|
|
714
|
-
pane: params.pane,
|
|
715
|
-
matched: false,
|
|
716
|
-
waitedMs,
|
|
717
|
-
matchMode,
|
|
718
|
-
source,
|
|
719
|
-
tail,
|
|
720
|
-
},
|
|
721
|
-
};
|
|
722
|
-
}
|
|
723
|
-
const matched = response.result;
|
|
724
|
-
const output = matched.read?.text || matched.matched_line;
|
|
725
|
-
// 命中行可能来自刚下发的命令回显(`❯ <命令>`)而不是命令产生的输出,
|
|
726
|
-
// 回显在命令还没跑完时就已经存在。本进程自己记录的下发内容是最可靠的事实依据。
|
|
727
|
-
const submitted = lastSubmitted.get(params.pane);
|
|
728
|
-
const echoedInput = !!submitted && matched.matched_line.includes(submitted);
|
|
729
|
-
if (echoedInput) {
|
|
730
|
-
notes.push(
|
|
731
|
-
"注意:命中的是刚下发的命令回显行(❯ 后面那段),不是命令产生的输出;命令可能还没跑完。要等真正的输出,请改用只会出现在输出里的 pattern。",
|
|
732
|
-
);
|
|
733
|
-
}
|
|
734
|
-
return {
|
|
735
|
-
content: [
|
|
736
|
-
{
|
|
737
|
-
type: "text",
|
|
738
|
-
text: [...notes, `Matched: ${matched.matched_line}`, "", formatOutput(output)].join("\n"),
|
|
739
|
-
},
|
|
740
|
-
],
|
|
741
|
-
details: {
|
|
742
|
-
action: "wait_output",
|
|
743
|
-
pane: params.pane,
|
|
744
|
-
matched: true,
|
|
745
|
-
matchedLine: matched.matched_line,
|
|
746
|
-
matchMode,
|
|
747
|
-
source,
|
|
748
|
-
echoedInput,
|
|
749
|
-
elapsedMs: Date.now() - startedAt,
|
|
750
|
-
},
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
case "send_text": {
|
|
754
|
-
if (!params.text) throw new Error("'text' is required for send_text");
|
|
755
|
-
await execHerdr(["pane", "send-text", params.pane, params.text], signal);
|
|
756
|
-
lastSubmitted.set(params.pane, params.text);
|
|
757
|
-
return {
|
|
758
|
-
content: [{ type: "text", text: `Sent literal text to pane ${params.pane}` }],
|
|
759
|
-
details: { action: "send_text", pane: params.pane },
|
|
760
|
-
};
|
|
761
|
-
}
|
|
762
|
-
case "send_keys": {
|
|
763
|
-
if (!params.keys?.length) throw new Error("'keys' is required for send_keys");
|
|
764
|
-
await execHerdr(["pane", "send-keys", params.pane, ...params.keys], signal);
|
|
765
|
-
return {
|
|
766
|
-
content: [{ type: "text", text: `Sent ${params.keys.join(" ")} to pane ${params.pane}` }],
|
|
767
|
-
details: { action: "send_keys", pane: params.pane, keys: params.keys },
|
|
768
|
-
};
|
|
769
|
-
}
|
|
770
|
-
case "close": {
|
|
771
|
-
const current = await getCurrentPane(signal);
|
|
772
|
-
if (params.pane === current.pane_id) throw new Error("Refusing to close the pane pi is running in.");
|
|
773
|
-
await execHerdrJson(["pane", "close", params.pane], signal);
|
|
774
|
-
return {
|
|
775
|
-
content: [{ type: "text", text: `Closed pane ${params.pane}` }],
|
|
776
|
-
details: { action: "close", pane: params.pane },
|
|
777
|
-
};
|
|
778
|
-
}
|
|
777
|
+
async execute(_toolCallId, params, signal) {
|
|
778
|
+
const self = process.env.HERDR_PANE_ID;
|
|
779
|
+
// 先探一下:状态决定插队保护,pane id 让自身检查能抓到「名字与自己的 pane 不同」的目标。
|
|
780
|
+
const probe = await jsonCall<{ result: { agent: AgentInfo } }>(["agent", "get", params.target], signal);
|
|
781
|
+
const agent = probe.ok === true ? probe.value.result.agent : undefined;
|
|
782
|
+
const pane = agent?.pane_id;
|
|
783
|
+
const status = agent?.agent_status;
|
|
784
|
+
const unresolved =
|
|
785
|
+
probe.ok === true && pane !== undefined
|
|
786
|
+
? undefined
|
|
787
|
+
: "无法把 target 解析成完整 pane id(agent get 失败或输出未识别),下面的回退将不可用。";
|
|
788
|
+
// 往自己的 pane 里键入等于给自己喂输入,会打断正在进行的这一轮。
|
|
789
|
+
if (self !== undefined && (params.target === self || pane === self)) return envelopeResult(refusal(SELF_PANE_REFUSAL));
|
|
790
|
+
// 插队正在 working 的 agent:managing-agents 的纪律,这里做成机械约束。
|
|
791
|
+
if (PROMPT_GUARD_ON && status === "working" && params.force !== true) {
|
|
792
|
+
return envelopeResult({
|
|
793
|
+
ok: false,
|
|
794
|
+
exitCode: 2,
|
|
795
|
+
output: `目标 ${pane} 当前状态:working。`,
|
|
796
|
+
note:
|
|
797
|
+
"它正在干活,现在下发等于插队,会打断它手上的任务。先 herdr_wait 等它落定再发;" +
|
|
798
|
+
"确实要插队就显式传 force=true。",
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
// herdr CLI 只有 --wait,没有 --no-wait。
|
|
802
|
+
const waitFlag = params.wait === true ? ["--wait"] : [];
|
|
803
|
+
const semantic = await textCall(["agent", "prompt", params.target, params.prompt, ...waitFlag], signal);
|
|
804
|
+
if (semantic.ok === true) {
|
|
805
|
+
return envelopeResult({
|
|
806
|
+
ok: true,
|
|
807
|
+
exitCode: 0,
|
|
808
|
+
output: semantic.value.trim(),
|
|
809
|
+
via: "agent prompt",
|
|
810
|
+
...(unresolved === undefined ? {} : { note: unresolved }),
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
// 本地超时:不知道有没有送达,但也不是 herdr 拒绝。
|
|
814
|
+
if (semantic.timedOut) return envelopeResult(failureEnvelope(semantic, { via: "agent prompt" }));
|
|
815
|
+
// 目标卡在审批/提问弹窗:herdr 在发送任何输入之前就拒绝了,确定没下发。
|
|
816
|
+
// 这与 stalled 相反 —— 重试必然同样失败,必须先看清界面。
|
|
817
|
+
if (isPromptBlocked(semantic.error)) {
|
|
818
|
+
return envelopeResult({
|
|
819
|
+
ok: false,
|
|
820
|
+
exitCode: 2,
|
|
821
|
+
output: errorMessage(semantic.error),
|
|
822
|
+
via: "agent prompt",
|
|
823
|
+
note:
|
|
824
|
+
`${params.target} 处于 blocked(等待审批或回答),herdr 在发送任何输入前就拒绝了本次 prompt。` +
|
|
825
|
+
"先用 herdr_read 看清阻塞的界面,向用户确认怎么答,再用 herdr_prompt / herdr_run 回应;不要重复下发。",
|
|
826
|
+
});
|
|
779
827
|
}
|
|
828
|
+
// submission accepted 之后没观测到 working/blocked:prompt 已经下发了,
|
|
829
|
+
// 这是启发式,不能当成下发失败。
|
|
830
|
+
if (isPromptStalled(semantic.error)) {
|
|
831
|
+
const observed = /status is (\w+)/.exec(errorMessage(semantic.error))?.[1];
|
|
832
|
+
return envelopeResult({
|
|
833
|
+
ok: true,
|
|
834
|
+
exitCode: 0,
|
|
835
|
+
output:
|
|
836
|
+
`Prompt 已下发到 ${params.target}(submission accepted),但 5s 内未观测到进入 working/blocked` +
|
|
837
|
+
(observed ? `(当前状态 ${observed})` : "") +
|
|
838
|
+
"。",
|
|
839
|
+
via: "agent prompt",
|
|
840
|
+
note: "不要再下发一次;用 herdr_read 确认它是否已开始工作。",
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
const semanticMessage = errorMessage(semantic.error);
|
|
844
|
+
// herdr 只接受 agent prompt 给「它自己命名并激活」的 agent;自报状态的 agent
|
|
845
|
+
// (dsh-tui)会回 agent_not_ready。文案匹配兜住不带结构化 code 的版本。
|
|
846
|
+
if (!/agent_not_ready|not an active named agent/i.test(semanticMessage)) {
|
|
847
|
+
return envelopeResult(failureEnvelope(semantic, { via: "agent prompt" }));
|
|
848
|
+
}
|
|
849
|
+
// pane run 不收裸 agent 名,而往猜出来的 pane 里键入比直接失败更糟。
|
|
850
|
+
const resolved = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => resolveAgentPaneId(params.target, s));
|
|
851
|
+
if (resolved.ok !== true) {
|
|
852
|
+
return envelopeResult({
|
|
853
|
+
ok: false,
|
|
854
|
+
exitCode: 2,
|
|
855
|
+
output: resolved.timedOut ? semanticMessage : errorMessage(resolved.error),
|
|
856
|
+
note:
|
|
857
|
+
"agent prompt 不可用(对方是自报 agent),而 target 又不是完整 pane id," +
|
|
858
|
+
"无法确定该往哪个 pane 键入 —— 不猜。先用 herdr_overview 或 herdr_read 拿到完整 pane id 再发。",
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
const paneId = resolved.value;
|
|
862
|
+
const fallbackVia = `pane run ${paneId}(agent prompt 不可用:对方是自报 agent,非 herdr 命名 agent)`;
|
|
863
|
+
const typed = await textCall(["pane", "run", paneId, params.prompt], signal);
|
|
864
|
+
if (typed.ok !== true) return envelopeResult(failureEnvelope(typed, { via: fallbackVia }));
|
|
865
|
+
return envelopeResult({
|
|
866
|
+
ok: true,
|
|
867
|
+
exitCode: 0,
|
|
868
|
+
output: `Submitted prompt to pane ${paneId}`,
|
|
869
|
+
via: fallbackVia,
|
|
870
|
+
});
|
|
780
871
|
},
|
|
781
872
|
renderCall(args, theme, context) {
|
|
782
|
-
return
|
|
873
|
+
return renderVerbCall("herdr_prompt", args, theme, context);
|
|
783
874
|
},
|
|
784
875
|
renderResult(result, options, theme) {
|
|
785
|
-
return
|
|
876
|
+
return renderVerbResult(result, options, theme);
|
|
786
877
|
},
|
|
787
878
|
});
|
|
788
879
|
|
|
789
880
|
pi.registerTool({
|
|
790
|
-
name: "
|
|
791
|
-
label: "Herdr
|
|
881
|
+
name: "herdr_run",
|
|
882
|
+
label: "Herdr Run",
|
|
883
|
+
outputSchema: envelopeOutputSchema,
|
|
792
884
|
description:
|
|
793
|
-
"
|
|
794
|
-
promptSnippet: "
|
|
885
|
+
"在指定 pane 里跑一条命令(裸终端,原子下发:文本 + Enter)。用来在别的 pane 起服务、跑脚本。成功 = 退出 0 且 stdout 为空,别期待回显信封;要确认结果用 herdr_read 或 herdr_wait。",
|
|
886
|
+
promptSnippet: "在另一个 pane 里原子下发一条命令",
|
|
795
887
|
promptGuidelines: [
|
|
796
|
-
"
|
|
797
|
-
"
|
|
798
|
-
"
|
|
799
|
-
"When a prompt is rejected with agent_blocked, nothing was sent and retrying will fail the same way: inspect the blocking UI with get and read, and ask the operator before answering it.",
|
|
800
|
-
"If herdr_agent read cannot recover a full alternate-screen response after increasing lines, ask the agent to write its complete response to a temporary Markdown file and return the path, then read that file directly.",
|
|
888
|
+
"herdr_run 只负责把命令送进 pane,不负责等它跑完;确认结果用 herdr_read 或 herdr_wait。",
|
|
889
|
+
"不要用 herdr_run 往自己所在的 pane 里下发命令:那会打断你正在进行的这一轮。",
|
|
890
|
+
"要驱动的是 coding agent 而不是普通 shell 时,用 herdr_prompt。",
|
|
801
891
|
],
|
|
802
892
|
parameters: Type.Object({
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
893
|
+
pane: Type.String({ description: "完整限定 pane id(w1B:pF)" }),
|
|
894
|
+
command: Type.String({ description: "要执行的命令" }),
|
|
895
|
+
}),
|
|
896
|
+
async execute(_toolCallId, params, signal) {
|
|
897
|
+
const self = process.env.HERDR_PANE_ID;
|
|
898
|
+
if (self !== undefined && params.pane === self) return envelopeResult(refusal(SELF_PANE_REFUSAL));
|
|
899
|
+
const result = await textCall(["pane", "run", params.pane, params.command], signal);
|
|
900
|
+
// 这里的成功是退出 0 且 stdout 为空;要求信封会把已经送达的命令报成失败。
|
|
901
|
+
if (result.ok !== true) return envelopeResult(failureEnvelope(result));
|
|
902
|
+
return envelopeResult({ ok: true, exitCode: 0, output: `Submitted command to pane ${params.pane}` });
|
|
903
|
+
},
|
|
904
|
+
renderCall(args, theme, context) {
|
|
905
|
+
return renderVerbCall("herdr_run", args, theme, context);
|
|
906
|
+
},
|
|
907
|
+
renderResult(result, options, theme) {
|
|
908
|
+
return renderVerbResult(result, options, theme);
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
pi.registerTool({
|
|
913
|
+
name: "herdr_create",
|
|
914
|
+
label: "Herdr Create",
|
|
915
|
+
outputSchema: envelopeOutputSchema,
|
|
916
|
+
description:
|
|
917
|
+
"开新位置:workspace / tab / 兄弟 pane。默认不动焦点(不加 --focus),避免把用户正在看的上下文切走。返回的新 pane id 才是后续操作要用的句柄。",
|
|
918
|
+
promptSnippet: "创建 workspace / tab / 兄弟 pane,并拿回新 pane id",
|
|
919
|
+
promptGuidelines: [
|
|
920
|
+
"开新位置用 herdr_create;返回的 pane id 是后续 herdr_run / herdr_prompt 的句柄,别自己拼。",
|
|
921
|
+
"默认不切焦点:用户正在看的上下文保持不变。确实要切才传 focus=true。",
|
|
922
|
+
"pane 是相对调用者自己的 pane 切分;要在别的 pane 里切,先切焦点或改用 workspace/tab。",
|
|
923
|
+
],
|
|
924
|
+
parameters: Type.Object({
|
|
925
|
+
kind: CreateKindEnum,
|
|
926
|
+
workspace: Type.Optional(Type.String({ description: "tab 的目标 workspace(默认当前)。kind=workspace/pane 时忽略" })),
|
|
927
|
+
label: Type.Optional(Type.String({ description: "workspace/tab 的标签" })),
|
|
928
|
+
cwd: Type.Optional(Type.String({ description: "新位置的工作目录" })),
|
|
929
|
+
direction: Type.Optional(DirectionEnum),
|
|
930
|
+
focus: Type.Optional(Type.Boolean({ description: "是否把焦点切过去;默认 false" })),
|
|
931
|
+
}),
|
|
932
|
+
async execute(_toolCallId, params, signal) {
|
|
933
|
+
const focusFlag = params.focus === true ? "--focus" : "--no-focus";
|
|
934
|
+
const current = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => getCurrentPane(s));
|
|
935
|
+
if (current.ok !== true) return envelopeResult(failureEnvelope(current));
|
|
936
|
+
const here = current.value;
|
|
937
|
+
const cwd = params.cwd || here.foreground_cwd || here.cwd || process.cwd();
|
|
938
|
+
if (params.kind === "workspace") {
|
|
939
|
+
const args = ["workspace", "create", "--cwd", cwd];
|
|
940
|
+
if (params.label !== undefined) args.push("--label", params.label);
|
|
941
|
+
args.push(focusFlag);
|
|
942
|
+
const created = await jsonCall<{
|
|
943
|
+
result: { workspace: WorkspaceInfo; tab: { tab_id: string }; root_pane: PaneInfo };
|
|
944
|
+
}>(args, signal);
|
|
945
|
+
if (created.ok !== true) return envelopeResult(failureEnvelope(created));
|
|
946
|
+
const { workspace, tab, root_pane: rootPane } = created.value.result;
|
|
947
|
+
return envelopeResult({
|
|
948
|
+
ok: true,
|
|
949
|
+
exitCode: 0,
|
|
950
|
+
output: `Created workspace ${workspace.workspace_id}, tab ${tab.tab_id}, root pane ${rootPane.pane_id}`,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
if (params.kind === "tab") {
|
|
954
|
+
const args = ["tab", "create", "--workspace", params.workspace || here.workspace_id, "--cwd", cwd];
|
|
955
|
+
if (params.label !== undefined) args.push("--label", params.label);
|
|
956
|
+
args.push(focusFlag);
|
|
957
|
+
const created = await jsonCall<{ result: { tab: { tab_id: string }; root_pane: PaneInfo } }>(args, signal);
|
|
958
|
+
if (created.ok !== true) return envelopeResult(failureEnvelope(created));
|
|
959
|
+
const { tab, root_pane: rootPane } = created.value.result;
|
|
960
|
+
return envelopeResult({
|
|
961
|
+
ok: true,
|
|
962
|
+
exitCode: 0,
|
|
963
|
+
output: `Created tab ${tab.tab_id}, root pane ${rootPane.pane_id}`,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
// 切分是相对调用者自己的 pane:--workspace 在这里没有意义。
|
|
967
|
+
const layout = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => getPaneLayout(here.pane_id, s));
|
|
968
|
+
const direction =
|
|
969
|
+
params.direction || chooseSplitDirection(layout.ok === true ? layout.value : { panes: [] }, here.pane_id);
|
|
970
|
+
const args = ["pane", "split", here.pane_id, "--direction", direction, "--cwd", cwd, focusFlag];
|
|
971
|
+
const created = await jsonCall<{ result: { pane: PaneInfo } }>(args, signal);
|
|
972
|
+
if (created.ok !== true) return envelopeResult(failureEnvelope(created));
|
|
973
|
+
return envelopeResult({
|
|
974
|
+
ok: true,
|
|
975
|
+
exitCode: 0,
|
|
976
|
+
output: `Created pane ${created.value.result.pane.pane_id} by splitting ${here.pane_id} ${direction}`,
|
|
977
|
+
});
|
|
978
|
+
},
|
|
979
|
+
renderCall(args, theme, context) {
|
|
980
|
+
return renderVerbCall("herdr_create", args, theme, context);
|
|
981
|
+
},
|
|
982
|
+
renderResult(result, options, theme) {
|
|
983
|
+
return renderVerbResult(result, options, theme);
|
|
984
|
+
},
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
pi.registerTool({
|
|
988
|
+
name: "herdr_agent_start",
|
|
989
|
+
label: "Herdr Agent Start",
|
|
990
|
+
outputSchema: envelopeOutputSchema,
|
|
991
|
+
description:
|
|
992
|
+
"在一个已经停在交互提示符的 shell pane 里启动 coding agent。它不创建 pane、不移动布局 —— 要新位置先用 herdr_create。注意 kind 列表里没有 dsh-tui(dsh-tui 靠自报被识别,不由 agent start 起出来)。",
|
|
993
|
+
promptSnippet: "在空闲 shell pane 里启动一个 coding agent",
|
|
994
|
+
promptGuidelines: [
|
|
995
|
+
"先 herdr_create 拿到一个停在提示符的 shell pane,再 herdr_agent_start —— 它不会创建 pane,也不会移动布局。",
|
|
996
|
+
"name 必须是唯一的小写名字(^[a-z][a-z0-9_-]{0,31}$);后续 prompt / wait / read 都用这个名字或它的 pane id。",
|
|
997
|
+
"kind 只接受 herdr 认识的那几种;启动失败时先看 kind 是否在枚举里。",
|
|
998
|
+
],
|
|
999
|
+
parameters: Type.Object({
|
|
1000
|
+
name: Type.String({ description: "给这个 agent 的唯一名字(^[a-z][a-z0-9_-]{0,31}$)" }),
|
|
1001
|
+
kind: AgentKindEnum,
|
|
1002
|
+
pane: Type.String({ description: "承载它的完整限定 pane id,必须是停在提示符的 shell pane" }),
|
|
1003
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "启动等待上限" })),
|
|
1004
|
+
agentArgs: Type.Optional(
|
|
1005
|
+
Type.Array(Type.String(), { description: "原样传给 agent 的额外参数(放在 -- 之后)。dsh manager 面没有这个参数,pi 侧保留" }),
|
|
813
1006
|
),
|
|
814
|
-
kind: Type.Optional(AgentKindEnum),
|
|
815
|
-
agentArgs: Type.Optional(Type.Array(Type.String(), { description: "Native agent arguments passed unchanged after -- for start" })),
|
|
816
|
-
prompt: Type.Optional(Type.String({ description: "Prompt text submitted atomically with Enter" })),
|
|
817
|
-
wait: Type.Optional(Type.Boolean({ description: "Wait for lifecycle settlement after prompt. Defaults to true." })),
|
|
818
|
-
until: Type.Optional(Type.Array(StatusEnum, { description: "Accepted lifecycle states for prompt with wait or wait; defaults to idle, done, or blocked" })),
|
|
819
|
-
timeout: Type.Optional(Type.Integer({ minimum: 1, description: "Timeout in milliseconds; omitted means indefinite" })),
|
|
820
|
-
source: Type.Optional(ReadSourceEnum),
|
|
821
|
-
lines: Type.Optional(Type.Integer({ minimum: 1, description: "Rendered terminal rows to read" })),
|
|
822
|
-
format: Type.Optional(OutputFormatEnum),
|
|
823
|
-
keys: Type.Optional(Type.Array(Type.String(), { description: "Logical UI keys such as esc, enter, up, or ctrl+c" })),
|
|
824
|
-
clearName: Type.Optional(Type.Boolean({ description: "Clear the current agent name for rename" })),
|
|
825
1007
|
}),
|
|
826
|
-
async execute(_toolCallId, params, signal
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
stalledState = /status is (\w+)/.exec(error instanceof Error ? error.message : "")?.[1];
|
|
894
|
-
response = null;
|
|
895
|
-
}
|
|
896
|
-
if (response === null) {
|
|
897
|
-
// herdr 在 submission accepted 后要求 5s 内观测到 working/blocked,观测不到就报
|
|
898
|
-
// agent_prompt_stalled —— 这是启发式,prompt 已经下发,不能当成“下发失败”。
|
|
899
|
-
return {
|
|
900
|
-
content: [
|
|
901
|
-
{
|
|
902
|
-
type: "text",
|
|
903
|
-
text:
|
|
904
|
-
`Prompt 已下发到 ${params.target}(submission accepted),但 5s 内未观测到进入 working/blocked` +
|
|
905
|
-
(stalledState ? `(当前状态 ${stalledState})` : "") +
|
|
906
|
-
"。\n不要再下发一次;用 herdr_agent read 确认它是否已开始工作。",
|
|
907
|
-
},
|
|
908
|
-
],
|
|
909
|
-
details: {
|
|
910
|
-
action: "prompt",
|
|
911
|
-
target: params.target,
|
|
912
|
-
submitted: true,
|
|
913
|
-
stalled: true,
|
|
914
|
-
observedState: stalledState,
|
|
915
|
-
},
|
|
916
|
-
};
|
|
917
|
-
}
|
|
918
|
-
return {
|
|
919
|
-
content: [{ type: "text", text: `${shouldWait ? "Prompt settled" : "Prompt submitted"}: ${summarizeAgent(response.result.agent)}` }],
|
|
920
|
-
details: { action: "prompt", agent: response.result.agent },
|
|
921
|
-
};
|
|
922
|
-
}
|
|
923
|
-
case "wait": {
|
|
924
|
-
if (!params.target) throw new Error("'target' is required for wait");
|
|
925
|
-
const args = ["agent", "wait", params.target];
|
|
926
|
-
for (const status of params.until || []) args.push("--until", status);
|
|
927
|
-
if (params.timeout != null) args.push("--timeout", String(params.timeout));
|
|
928
|
-
onUpdate?.({
|
|
929
|
-
content: [{ type: "text", text: `Waiting for agent ${params.target}...` }],
|
|
930
|
-
details: { action: "wait", target: params.target, waiting: true },
|
|
931
|
-
});
|
|
932
|
-
const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
|
|
933
|
-
return {
|
|
934
|
-
content: [{ type: "text", text: `Agent settled: ${summarizeAgent(response.result.agent)}` }],
|
|
935
|
-
details: { action: "wait", agent: response.result.agent },
|
|
936
|
-
};
|
|
937
|
-
}
|
|
938
|
-
case "read": {
|
|
939
|
-
if (!params.target) throw new Error("'target' is required for read");
|
|
940
|
-
const args = ["agent", "read", params.target, "--source", params.source || "recent-unwrapped"];
|
|
941
|
-
if (params.lines != null) args.push("--lines", String(params.lines));
|
|
942
|
-
if (params.format) args.push("--format", params.format as OutputFormat);
|
|
943
|
-
const output = await execHerdrText(args, signal);
|
|
944
|
-
return {
|
|
945
|
-
content: [{ type: "text", text: formatOutput(output) }],
|
|
946
|
-
details: { action: "read", target: params.target, read: true, source: params.source || "recent-unwrapped" },
|
|
947
|
-
};
|
|
948
|
-
}
|
|
949
|
-
case "send_keys": {
|
|
950
|
-
if (!params.target) throw new Error("'target' is required for send_keys");
|
|
951
|
-
if (!params.keys?.length) throw new Error("'keys' is required for send_keys");
|
|
952
|
-
await execHerdrJson(["agent", "send-keys", params.target, ...params.keys], signal);
|
|
953
|
-
return {
|
|
954
|
-
content: [{ type: "text", text: `Sent ${params.keys.join(" ")} to ${params.target}` }],
|
|
955
|
-
details: { action: "send_keys", target: params.target, keys: params.keys },
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
case "focus": {
|
|
959
|
-
if (!params.target) throw new Error("'target' is required for focus");
|
|
960
|
-
const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(["agent", "focus", params.target], signal);
|
|
961
|
-
return {
|
|
962
|
-
content: [{ type: "text", text: `Focused ${agentDisplayName(response.result.agent)}` }],
|
|
963
|
-
details: { action: "focus", agent: response.result.agent },
|
|
964
|
-
};
|
|
965
|
-
}
|
|
966
|
-
case "rename": {
|
|
967
|
-
if (!params.target) throw new Error("'target' is required for rename");
|
|
968
|
-
if (!params.clearName && !params.name) throw new Error("'name' or 'clearName' is required for rename");
|
|
969
|
-
const args = ["agent", "rename", params.target];
|
|
970
|
-
args.push(params.clearName ? "--clear" : params.name!);
|
|
971
|
-
const response = await execHerdrJson<{ result: { agent: AgentInfo } }>(args, signal);
|
|
972
|
-
return {
|
|
973
|
-
content: [{ type: "text", text: params.clearName ? `Cleared agent name for ${params.target}` : `Renamed agent to ${params.name}` }],
|
|
974
|
-
details: { action: "rename", agent: response.result.agent },
|
|
975
|
-
};
|
|
976
|
-
}
|
|
1008
|
+
async execute(_toolCallId, params, signal) {
|
|
1009
|
+
// herdr CLI 与 dsh 的 skill 脚本都在这一层校验;校验放在 body 里而不是 schema 的
|
|
1010
|
+
// pattern 上,让八个工具的失败都走同一套信封(同一约束只保留一处真值)。
|
|
1011
|
+
if (!AGENT_NAME_PATTERN.test(params.name ?? "")) {
|
|
1012
|
+
return envelopeResult(
|
|
1013
|
+
precondition("'name' must match ^[a-z][a-z0-9_-]{0,31}$(小写字母开头,可含数字/_/-,最长 32 字符)。"),
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
if (params.timeoutMs != null && (params.timeoutMs <= START_TIMEOUT_MIN || params.timeoutMs > START_TIMEOUT_MAX)) {
|
|
1017
|
+
return envelopeResult(
|
|
1018
|
+
precondition(`start timeout must be > ${START_TIMEOUT_MIN}ms and <= ${START_TIMEOUT_MAX}ms(单位毫秒)。`),
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
const args = ["agent", "start", params.name, "--kind", params.kind, "--pane", params.pane];
|
|
1022
|
+
if (params.timeoutMs != null) args.push("--timeout", String(params.timeoutMs));
|
|
1023
|
+
if (params.agentArgs?.length) args.push("--", ...params.agentArgs);
|
|
1024
|
+
const started = await jsonCall<{ result: { agent: AgentInfo } }>(args, signal);
|
|
1025
|
+
if (started.ok !== true) return envelopeResult(failureEnvelope(started));
|
|
1026
|
+
return envelopeResult({
|
|
1027
|
+
ok: true,
|
|
1028
|
+
exitCode: 0,
|
|
1029
|
+
output: `Started ${summarizeAgent(started.value.result.agent)}`,
|
|
1030
|
+
});
|
|
1031
|
+
},
|
|
1032
|
+
renderCall(args, theme, context) {
|
|
1033
|
+
return renderVerbCall("herdr_agent_start", args, theme, context);
|
|
1034
|
+
},
|
|
1035
|
+
renderResult(result, options, theme) {
|
|
1036
|
+
return renderVerbResult(result, options, theme);
|
|
1037
|
+
},
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
pi.registerTool({
|
|
1041
|
+
name: "herdr_close",
|
|
1042
|
+
label: "Herdr Close",
|
|
1043
|
+
outputSchema: envelopeOutputSchema,
|
|
1044
|
+
description:
|
|
1045
|
+
"关 pane(会杀掉里面的进程)。默认 dryRun=true 只预览目标、不真关;确认目标无误后再显式传 dryRun=false。工具拒绝关闭自己所在的 pane,也绝不要关不是你创建的 pane。",
|
|
1046
|
+
promptSnippet: "预览或关闭一个 pane(默认只预览)",
|
|
1047
|
+
promptGuidelines: [
|
|
1048
|
+
"herdr_close 默认只预览:先看清要关的是哪个 pane,再显式传 dryRun=false 真关。",
|
|
1049
|
+
"关 pane 会杀掉里面的进程且不可撤销;不要关不是你创建的 pane,除非用户明确要求。",
|
|
1050
|
+
"工具永远拒绝关闭当前进程自己所在的 pane。",
|
|
1051
|
+
],
|
|
1052
|
+
parameters: Type.Object({
|
|
1053
|
+
pane: Type.String({ description: "完整限定 pane id" }),
|
|
1054
|
+
dryRun: Type.Optional(Type.Boolean({ description: "默认 true:只预览。要真关必须显式传 false" })),
|
|
1055
|
+
}),
|
|
1056
|
+
async execute(_toolCallId, params, signal) {
|
|
1057
|
+
const current = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => getCurrentPane(s));
|
|
1058
|
+
if (current.ok !== true) return envelopeResult(failureEnvelope(current));
|
|
1059
|
+
if (params.pane === current.value.pane_id) {
|
|
1060
|
+
return envelopeResult(
|
|
1061
|
+
precondition(
|
|
1062
|
+
"Refusing to close the pane pi is running in.\n 先跑 herdr_overview 确认自己的 pane;要关的是别的 pane。",
|
|
1063
|
+
),
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
// 关 pane 会杀掉进程且不可撤销:默认预览,让目标先落在屏幕上再动手。
|
|
1067
|
+
if (params.dryRun !== false) {
|
|
1068
|
+
const target = await attempt(signal, DEFAULT_LOCAL_TIMEOUT, (s) => getPane(params.pane, s));
|
|
1069
|
+
if (target.ok !== true) return envelopeResult(failureEnvelope(target));
|
|
1070
|
+
return envelopeResult({
|
|
1071
|
+
ok: true,
|
|
1072
|
+
exitCode: 0,
|
|
1073
|
+
output: `dry-run: would close ${summarizePane(target.value)}. 再调一次并传 dryRun=false 才会真的关。`,
|
|
1074
|
+
});
|
|
977
1075
|
}
|
|
1076
|
+
const closed = await jsonCall(["pane", "close", params.pane], signal);
|
|
1077
|
+
if (closed.ok !== true) return envelopeResult(failureEnvelope(closed));
|
|
1078
|
+
return envelopeResult({ ok: true, exitCode: 0, output: `Closed pane ${params.pane}` });
|
|
978
1079
|
},
|
|
979
1080
|
renderCall(args, theme, context) {
|
|
980
|
-
return
|
|
1081
|
+
return renderVerbCall("herdr_close", args, theme, context);
|
|
981
1082
|
},
|
|
982
1083
|
renderResult(result, options, theme) {
|
|
983
|
-
return
|
|
1084
|
+
return renderVerbResult(result, options, theme);
|
|
984
1085
|
},
|
|
985
1086
|
});
|
|
986
1087
|
}
|