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