@muzi3/herdr 0.1.3 → 0.2.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/LICENSE.MIT +21 -0
- package/README.md +77 -30
- package/extensions/herdr-agent-state/index.ts +409 -0
- package/extensions/pane-agent-tools/index.ts +763 -0
- package/package.json +15 -8
- package/extensions/index.ts +0 -257
- package/extensions/session-notify.ts +0 -190
- package/extensions/tab-task-label.ts +0 -91
- /package/extensions/{pi-hot-restart.ts → pi-hot-restart/index.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muzi3/herdr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Herdr for pi:
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Herdr for pi: three independent extensions (agent + blocked state reporting, /pi-hot-restart, and the herdr_layout / herdr_pane / herdr_agent pane control tools forked from @ogulcancelik/pi-herdr) plus the herdr-guide skill.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi",
|
|
@@ -15,22 +15,29 @@
|
|
|
15
15
|
"troubleshooting",
|
|
16
16
|
"agent-state",
|
|
17
17
|
"pane",
|
|
18
|
-
"
|
|
18
|
+
"pane-control",
|
|
19
|
+
"layout",
|
|
20
|
+
"fork"
|
|
19
21
|
],
|
|
20
|
-
"license": "Apache-2.0",
|
|
22
|
+
"license": "Apache-2.0 AND MIT",
|
|
23
|
+
"type": "module",
|
|
21
24
|
"pi": {
|
|
22
25
|
"extensions": [
|
|
23
|
-
"./extensions/index.ts",
|
|
24
|
-
"./extensions/
|
|
25
|
-
"./extensions/
|
|
26
|
+
"./extensions/herdr-agent-state/index.ts",
|
|
27
|
+
"./extensions/pane-agent-tools/index.ts",
|
|
28
|
+
"./extensions/pi-hot-restart/index.ts"
|
|
26
29
|
],
|
|
27
30
|
"skills": [
|
|
28
31
|
"./skills"
|
|
29
32
|
]
|
|
30
33
|
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test tests/"
|
|
36
|
+
},
|
|
31
37
|
"files": [
|
|
32
38
|
"extensions",
|
|
33
|
-
"skills"
|
|
39
|
+
"skills",
|
|
40
|
+
"LICENSE.MIT"
|
|
34
41
|
],
|
|
35
42
|
"repository": {
|
|
36
43
|
"type": "git",
|
package/extensions/index.ts
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
// installed by herdr
|
|
2
|
-
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
|
3
|
-
// add custom hooks/plugins beside this file instead of editing it.
|
|
4
|
-
// HERDR_INTEGRATION_ID=pi
|
|
5
|
-
// HERDR_INTEGRATION_VERSION=8
|
|
6
|
-
// @ts-nocheck
|
|
7
|
-
|
|
8
|
-
import net from "node:net";
|
|
9
|
-
|
|
10
|
-
const HERDR_ENV = process.env.HERDR_ENV;
|
|
11
|
-
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
12
|
-
const socketEndpoint =
|
|
13
|
-
process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
|
14
|
-
const paneId = process.env.HERDR_PANE_ID;
|
|
15
|
-
const source = "herdr:pi";
|
|
16
|
-
|
|
17
|
-
function enabled() {
|
|
18
|
-
return HERDR_ENV === "1" && !!socketPath && !!paneId;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
|
|
22
|
-
if (!enabled()) {
|
|
23
|
-
return Promise.resolve(true);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return new Promise((resolve) => {
|
|
27
|
-
let done = false;
|
|
28
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
29
|
-
const finish = (delivered: boolean) => {
|
|
30
|
-
if (done) return;
|
|
31
|
-
done = true;
|
|
32
|
-
if (timeout) {
|
|
33
|
-
clearTimeout(timeout);
|
|
34
|
-
}
|
|
35
|
-
socket.destroy();
|
|
36
|
-
resolve(delivered);
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const socket = net.createConnection(socketEndpoint!);
|
|
40
|
-
socket.on("error", () => finish(false));
|
|
41
|
-
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
|
42
|
-
socket.on("data", () => finish(true));
|
|
43
|
-
socket.on("end", () => finish(false));
|
|
44
|
-
timeout = setTimeout(() => finish(false), timeoutMs);
|
|
45
|
-
timeout.unref?.();
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function sendRequest(request: unknown): Promise<void> {
|
|
50
|
-
if (await sendRequestAttempt(request, 500)) {
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
await sendRequestAttempt(request, 1500);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
type AgentState = "working" | "blocked" | "idle";
|
|
57
|
-
|
|
58
|
-
type QueuedState = {
|
|
59
|
-
state: AgentState;
|
|
60
|
-
message?: string;
|
|
61
|
-
seq: number;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
let reportSeq = Date.now() * 1000;
|
|
65
|
-
let currentAgentSessionId: string | undefined;
|
|
66
|
-
let currentAgentSessionPath: string | undefined;
|
|
67
|
-
|
|
68
|
-
function nextReportSeq(): number {
|
|
69
|
-
reportSeq += 1;
|
|
70
|
-
return reportSeq;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function updateSessionRef(ctx: any): void {
|
|
74
|
-
try {
|
|
75
|
-
const file = ctx?.sessionManager?.getSessionFile?.();
|
|
76
|
-
currentAgentSessionPath =
|
|
77
|
-
typeof file === "string" && file.startsWith("/") ? file : undefined;
|
|
78
|
-
} catch {
|
|
79
|
-
currentAgentSessionPath = undefined;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
const id = ctx?.sessionManager?.getSessionId?.();
|
|
84
|
-
currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
|
|
85
|
-
} catch {
|
|
86
|
-
currentAgentSessionId = undefined;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
|
|
91
|
-
if (currentAgentSessionPath) {
|
|
92
|
-
return { ...params, agent_session_path: currentAgentSessionPath };
|
|
93
|
-
}
|
|
94
|
-
if (currentAgentSessionId) {
|
|
95
|
-
return { ...params, agent_session_id: currentAgentSessionId };
|
|
96
|
-
}
|
|
97
|
-
return params;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function currentSessionRef(): Record<string, unknown> | undefined {
|
|
101
|
-
if (currentAgentSessionPath) {
|
|
102
|
-
return { agent_session_path: currentAgentSessionPath };
|
|
103
|
-
}
|
|
104
|
-
if (currentAgentSessionId) {
|
|
105
|
-
return { agent_session_id: currentAgentSessionId };
|
|
106
|
-
}
|
|
107
|
-
return undefined;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function reportSession(sessionStartSource?: string): Promise<void> {
|
|
111
|
-
const sessionRef = currentSessionRef();
|
|
112
|
-
if (!sessionRef) {
|
|
113
|
-
return Promise.resolve();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return sendRequest({
|
|
117
|
-
id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
|
118
|
-
method: "pane.report_agent_session",
|
|
119
|
-
params: {
|
|
120
|
-
pane_id: paneId,
|
|
121
|
-
source,
|
|
122
|
-
agent: "pi",
|
|
123
|
-
seq: nextReportSeq(),
|
|
124
|
-
session_start_source: sessionStartSource,
|
|
125
|
-
...sessionRef,
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
|
|
131
|
-
return sendRequest({
|
|
132
|
-
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
|
133
|
-
method: "pane.report_agent",
|
|
134
|
-
params: withSessionRef({
|
|
135
|
-
pane_id: paneId,
|
|
136
|
-
source,
|
|
137
|
-
agent: "pi",
|
|
138
|
-
state,
|
|
139
|
-
message,
|
|
140
|
-
seq,
|
|
141
|
-
}),
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
let sendInFlight = false;
|
|
146
|
-
let queuedState: QueuedState | undefined;
|
|
147
|
-
|
|
148
|
-
function queueState(state: AgentState, message?: string): void {
|
|
149
|
-
queuedState = { state, message, seq: nextReportSeq() };
|
|
150
|
-
if (!sendInFlight) {
|
|
151
|
-
void drainStateQueue();
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function drainStateQueue(): Promise<void> {
|
|
156
|
-
if (sendInFlight) {
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
sendInFlight = true;
|
|
161
|
-
try {
|
|
162
|
-
while (queuedState) {
|
|
163
|
-
const next = queuedState;
|
|
164
|
-
queuedState = undefined;
|
|
165
|
-
await sendState(next.state, next.message, next.seq);
|
|
166
|
-
}
|
|
167
|
-
} finally {
|
|
168
|
-
sendInFlight = false;
|
|
169
|
-
if (queuedState) {
|
|
170
|
-
void drainStateQueue();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export default function (pi) {
|
|
176
|
-
if (!enabled()) {
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
let agentActive = false;
|
|
181
|
-
let blockedCount = 0;
|
|
182
|
-
let blockedMessage: string | undefined;
|
|
183
|
-
let lastState: AgentState | undefined;
|
|
184
|
-
let lastMessage: string | undefined;
|
|
185
|
-
let rootSession = false;
|
|
186
|
-
|
|
187
|
-
function desiredState() {
|
|
188
|
-
if (blockedCount > 0) {
|
|
189
|
-
return { state: "blocked" as const, message: blockedMessage };
|
|
190
|
-
}
|
|
191
|
-
if (agentActive) {
|
|
192
|
-
return { state: "working" as const, message: undefined };
|
|
193
|
-
}
|
|
194
|
-
return { state: "idle" as const, message: undefined };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function publishState(force = false) {
|
|
198
|
-
const next = desiredState();
|
|
199
|
-
if (!force && next.state === lastState && next.message === lastMessage) {
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
lastState = next.state;
|
|
203
|
-
lastMessage = next.message;
|
|
204
|
-
queueState(next.state, next.message);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
pi.events.on("herdr:blocked", (data) => {
|
|
208
|
-
if (!rootSession) {
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
if (!data?.active) {
|
|
212
|
-
blockedCount = Math.max(0, blockedCount - 1);
|
|
213
|
-
if (blockedCount === 0) {
|
|
214
|
-
blockedMessage = undefined;
|
|
215
|
-
}
|
|
216
|
-
publishState();
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
blockedCount += 1;
|
|
221
|
-
blockedMessage = data.label;
|
|
222
|
-
publishState();
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
pi.on("session_start", async (event, ctx) => {
|
|
226
|
-
// TUI only: RPC/JSON/print modes are headless (no PTY herdr can display),
|
|
227
|
-
// and RPC still reports hasUI=true, so mode is the reliable gate.
|
|
228
|
-
if (ctx?.mode !== "tui") {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
rootSession = true;
|
|
232
|
-
updateSessionRef(ctx);
|
|
233
|
-
await reportSession(event?.reason);
|
|
234
|
-
// A reload can replace this extension mid-run without emitting another agent_start.
|
|
235
|
-
agentActive = ctx?.isIdle?.() === false;
|
|
236
|
-
publishState(true);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
pi.on("agent_start", (_event, ctx) => {
|
|
240
|
-
if (!rootSession) {
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
updateSessionRef(ctx);
|
|
244
|
-
void reportSession();
|
|
245
|
-
agentActive = true;
|
|
246
|
-
publishState();
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
250
|
-
if (!rootSession || ctx?.isIdle?.() !== true) {
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
agentActive = false;
|
|
255
|
-
publishState();
|
|
256
|
-
});
|
|
257
|
-
}
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* session-notify
|
|
3
|
-
*
|
|
4
|
-
* 两件相关的事,共用同一份「pi 现在在等什么 / 刚做完什么」的判断:
|
|
5
|
-
*
|
|
6
|
-
* 1) blocked 状态桥:把 pi 的阻塞式交互提示转发成 herdr 的 blocked 状态。
|
|
7
|
-
* herdr 的 pi 集成只监听 `herdr:blocked` 事件再上报 `state: "blocked"`;而内置的
|
|
8
|
-
* pi 终端识别规则只有一条 working_literal(匹配 "Working..."),没有任何 blocked
|
|
9
|
-
* 规则 —— 没有这个桥,ask_user_question 期间 pane 一直显示 working。
|
|
10
|
-
* 信号来源是 pi core 的 `ui_prompt_start` / `ui_prompt_end`(runner 统一包装
|
|
11
|
-
* select/confirm/input/editor/custom,只在外层提示触发),所以覆盖所有阻塞弹窗;
|
|
12
|
-
* `custom` 不带 title,额外借 `rpiv:ask-user:prompt` 的问题内容补 label。
|
|
13
|
-
* herdr 的 blockedCount 是计数式的:切 label 前必须先发 `{active:false}` 降自己那
|
|
14
|
-
* 一份再发 `{active:true}`。本扩展严格配对 raise/lower,只占一个计数。
|
|
15
|
-
*
|
|
16
|
-
* 2) macOS 桌面横幅:herdr 自己的弹窗正文固定为「工作区 · 编号 · tab 名」,不含 pane
|
|
17
|
-
* 与问题原文,且按 tab 粒度抑制通知(同 tab 多 pane 会一起静音)。所以横幅自己发:
|
|
18
|
-
* 标题 = `herdr · <pane> · <本轮任务>`,正文 = 问题原文 / 完成用时。herdr 侧
|
|
19
|
-
* ui.toast.delivery 保持 off,避免同一状态弹两份。
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { execFile } from "node:child_process";
|
|
23
|
-
import { existsSync } from "node:fs";
|
|
24
|
-
import { homedir } from "node:os";
|
|
25
|
-
import { join } from "node:path";
|
|
26
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
|
-
|
|
28
|
-
/** herdr 集成的入站事件通道。 */
|
|
29
|
-
const HERDR_BLOCKED_EVENT = "herdr:blocked";
|
|
30
|
-
/** @juicesharp/rpiv-ask-user-question 的公开事件通道。 */
|
|
31
|
-
const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt";
|
|
32
|
-
|
|
33
|
-
const PANE_ID = process.env.HERDR_PANE_ID ?? "";
|
|
34
|
-
const MAX_LABEL_CHARS = 60;
|
|
35
|
-
const MAX_TASK_CHARS = 24;
|
|
36
|
-
const MAX_BODY_CHARS = 120;
|
|
37
|
-
const TIMEOUT_MS = 5000;
|
|
38
|
-
|
|
39
|
-
/** 各类交互提示的中文动词;未知类型回落到「等待用户输入」。 */
|
|
40
|
-
const KIND_VERBS: Record<string, string> = {
|
|
41
|
-
select: "等待选择",
|
|
42
|
-
confirm: "等待确认",
|
|
43
|
-
input: "等待输入",
|
|
44
|
-
editor: "等待编辑",
|
|
45
|
-
custom: "等待回答",
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/** terminal-notifier 支持 -group,同一 pane 的旧横幅会被新横幅替换;osascript 没有。 */
|
|
49
|
-
const TERMINAL_NOTIFIER = [
|
|
50
|
-
"/opt/homebrew/bin/terminal-notifier",
|
|
51
|
-
"/usr/local/bin/terminal-notifier",
|
|
52
|
-
join(
|
|
53
|
-
homedir(),
|
|
54
|
-
".pi/agent/npm/node_modules/node-notifier/vendor/mac.noindex/terminal-notifier.app/Contents/MacOS/terminal-notifier",
|
|
55
|
-
),
|
|
56
|
-
].find((path) => existsSync(path));
|
|
57
|
-
|
|
58
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
-
return typeof value === "object" && value !== null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function truncate(text: string, max: number): string {
|
|
63
|
-
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** 折叠空白:AppleScript 字符串是一行,换行会把正文截断。 */
|
|
67
|
-
function flatten(text: string): string {
|
|
68
|
-
return text.replace(/\s+/g, " ").trim();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function applescript(value: string): string {
|
|
72
|
-
return `"${flatten(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function notify(title: string, body: string): void {
|
|
76
|
-
if (process.platform !== "darwin") {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
const [command, args] = TERMINAL_NOTIFIER
|
|
80
|
-
? [
|
|
81
|
-
TERMINAL_NOTIFIER,
|
|
82
|
-
["-title", title, "-message", body, "-group", `herdr-${PANE_ID}`],
|
|
83
|
-
]
|
|
84
|
-
: ["osascript", ["-e", `display notification ${applescript(body)} with title ${applescript(title)}`]];
|
|
85
|
-
// 纯提示层,尽力而为:失败/超时都不该影响 pi 本体。
|
|
86
|
-
execFile(command, args, { timeout: TIMEOUT_MS }, () => {});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** 取第一条问题的 header(短)与原文(长)。 */
|
|
90
|
-
function readQuestion(data: unknown): { header?: string; question?: string } | undefined {
|
|
91
|
-
if (!isRecord(data) || !Array.isArray(data.questions)) {
|
|
92
|
-
return undefined;
|
|
93
|
-
}
|
|
94
|
-
const first = data.questions.find(isRecord);
|
|
95
|
-
if (!first) {
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
|
-
const header = typeof first.header === "string" ? flatten(first.header) : "";
|
|
99
|
-
const question = typeof first.question === "string" ? flatten(first.question) : "";
|
|
100
|
-
if (!header && !question) {
|
|
101
|
-
return undefined;
|
|
102
|
-
}
|
|
103
|
-
return { header: header || undefined, question: question || undefined };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export default function (pi: ExtensionAPI) {
|
|
107
|
-
// 与 herdr 集成保持一致:不在 herdr 集成环境里就完全静默。
|
|
108
|
-
if (process.env.HERDR_ENV !== "1" || !PANE_ID) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
let rootSession = false;
|
|
113
|
-
let raised = false;
|
|
114
|
-
let raisedLabel: string | undefined;
|
|
115
|
-
let askUser: { header?: string; question?: string } | undefined;
|
|
116
|
-
let task: string | undefined;
|
|
117
|
-
let startedAt = 0;
|
|
118
|
-
|
|
119
|
-
function bannerTitle(): string {
|
|
120
|
-
return task ? `herdr · ${PANE_ID} · ${task}` : `herdr · ${PANE_ID}`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function lower(): void {
|
|
124
|
-
if (!raised) return;
|
|
125
|
-
raised = false;
|
|
126
|
-
raisedLabel = undefined;
|
|
127
|
-
pi.events.emit(HERDR_BLOCKED_EVENT, { active: false });
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function raise(kind: string, detail: string | undefined, bannerText?: string): void {
|
|
131
|
-
const verb = KIND_VERBS[kind] ?? "等待用户输入";
|
|
132
|
-
const label = detail ? `${verb}:${detail}` : "等待用户输入";
|
|
133
|
-
if (raised && raisedLabel === label) return;
|
|
134
|
-
lower(); // 先降自己那一份再抬高,保证 label 切换时计数不残留
|
|
135
|
-
raised = true;
|
|
136
|
-
raisedLabel = label;
|
|
137
|
-
pi.events.emit(HERDR_BLOCKED_EVENT, { active: true, label });
|
|
138
|
-
const body = bannerText ?? detail;
|
|
139
|
-
notify(bannerTitle(), body ? `${verb}:${truncate(body, MAX_BODY_CHARS)}` : verb);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
pi.events.on(ASK_USER_PROMPT_EVENT, (data) => {
|
|
143
|
-
askUser = readQuestion(data);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
pi.on("before_agent_start", (event) => {
|
|
147
|
-
const line = flatten(event.prompt ?? "");
|
|
148
|
-
task = line ? truncate(line, MAX_TASK_CHARS) : undefined;
|
|
149
|
-
startedAt = Date.now();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
pi.on("ui_prompt_start", (event) => {
|
|
153
|
-
if (!rootSession) return;
|
|
154
|
-
if (event.kind === "custom") {
|
|
155
|
-
const detail = askUser?.header ?? askUser?.question;
|
|
156
|
-
raise("custom", detail ? truncate(detail, MAX_LABEL_CHARS) : undefined, askUser?.question);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
raise(event.kind, event.title);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
pi.on("ui_prompt_end", () => {
|
|
163
|
-
askUser = undefined;
|
|
164
|
-
if (!rootSession) return;
|
|
165
|
-
lower();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
pi.on("agent_settled", (_event, ctx) => {
|
|
169
|
-
if (!rootSession || ctx?.isIdle?.() !== true) return;
|
|
170
|
-
const seconds = startedAt ? Math.round((Date.now() - startedAt) / 1000) : undefined;
|
|
171
|
-
notify(bannerTitle(), seconds === undefined ? "完成" : `完成 · 用时 ${seconds}s`);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
pi.on("session_start", (_event, ctx) => {
|
|
175
|
-
// 与 herdr 集成同一门槛:RPC/print 没有可展示的 PTY;
|
|
176
|
-
// reload 会同时重载集成与本扩展,两侧计数一起归零。
|
|
177
|
-
if (ctx?.mode !== "tui") return;
|
|
178
|
-
rootSession = true;
|
|
179
|
-
raised = false;
|
|
180
|
-
raisedLabel = undefined;
|
|
181
|
-
askUser = undefined;
|
|
182
|
-
startedAt = 0;
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
pi.on("session_shutdown", () => {
|
|
186
|
-
lower();
|
|
187
|
-
rootSession = false;
|
|
188
|
-
askUser = undefined;
|
|
189
|
-
});
|
|
190
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tab-task-label
|
|
3
|
-
*
|
|
4
|
-
* 让 herdr 的完成/阻塞弹窗带上「这一轮在做什么」。
|
|
5
|
-
*
|
|
6
|
-
* 背景:herdr 0.8.2 的 agent 通知内容固定为
|
|
7
|
-
* title = "<agent> finished" / "<agent> needs attention"
|
|
8
|
-
* body = "<工作区名> · <工作区编号> · <tab 名>"
|
|
9
|
-
* 实测结论(见 packages/herdr/README.md):pane metadata(--title / --display-agent /
|
|
10
|
-
* --state-label)不会进入弹窗,弹窗里唯一可定制的一段就是 tab 名。所以这里在每轮
|
|
11
|
-
* 用户提问时把 tab 重命名为该轮任务摘要(提问首行、折叠空白、截断),这样无论是完成
|
|
12
|
-
* 还是阻塞待回答,弹窗正文都会带上任务信息。
|
|
13
|
-
*
|
|
14
|
-
* 取舍:会覆盖用户自己起的 tab 名(herdr 没有「临时显示名」这类旁路字段)。会话正常
|
|
15
|
-
* 退出(reason=quit)时,若 tab 名仍是我们写的那一份就还原成原名字;用户中途手动改过
|
|
16
|
-
* 则不动。同一 tab 多个 pi 会话会互相覆盖,以最后提问者为准。
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { execFile } from "node:child_process";
|
|
20
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
-
|
|
22
|
-
const HERDR_BIN = process.env.HERDR_BIN_PATH || "herdr";
|
|
23
|
-
const TAB_ID = process.env.HERDR_TAB_ID ?? "";
|
|
24
|
-
const MAX_LABEL_CHARS = 40;
|
|
25
|
-
const CLI_TIMEOUT_MS = 5000;
|
|
26
|
-
|
|
27
|
-
function run(args: string[]): Promise<string> {
|
|
28
|
-
return new Promise((resolve, reject) => {
|
|
29
|
-
execFile(HERDR_BIN, args, { timeout: CLI_TIMEOUT_MS }, (err, stdout) => {
|
|
30
|
-
if (err) reject(err);
|
|
31
|
-
else resolve(stdout);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** 读本 pane 所在 tab 当前的显示名。 */
|
|
37
|
-
async function currentLabel(): Promise<string | undefined> {
|
|
38
|
-
const out = await run(["tab", "list"]);
|
|
39
|
-
const parsed = JSON.parse(out) as {
|
|
40
|
-
result?: { tabs?: Array<{ tab_id?: string; label?: string }> };
|
|
41
|
-
};
|
|
42
|
-
const tab = parsed.result?.tabs?.find((t) => t.tab_id === TAB_ID);
|
|
43
|
-
return tab?.label;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** 提问首行 + 折叠空白 + 截断,作为 tab 名。 */
|
|
47
|
-
function summarize(prompt: string): string {
|
|
48
|
-
const line = prompt.replace(/\s+/g, " ").trim();
|
|
49
|
-
return line.length > MAX_LABEL_CHARS ? `${line.slice(0, MAX_LABEL_CHARS - 1)}…` : line;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export default function (pi: ExtensionAPI) {
|
|
53
|
-
// 与 herdr 集成保持一致:不在 herdr 集成环境里就完全静默。
|
|
54
|
-
if (process.env.HERDR_ENV !== "1" || !TAB_ID) {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
let originalLabel: string | undefined;
|
|
59
|
-
let originalCaptured = false;
|
|
60
|
-
let written: string | undefined;
|
|
61
|
-
|
|
62
|
-
pi.on("before_agent_start", (event) => {
|
|
63
|
-
const label = summarize(event.prompt ?? "");
|
|
64
|
-
if (!label || label === written) return;
|
|
65
|
-
// 纯展示层,尽力而为且不阻塞 agent 启动:CLI 失败/超时都不该影响 pi 本体。
|
|
66
|
-
void (async () => {
|
|
67
|
-
try {
|
|
68
|
-
if (!originalCaptured) {
|
|
69
|
-
originalLabel = await currentLabel();
|
|
70
|
-
originalCaptured = true;
|
|
71
|
-
}
|
|
72
|
-
await run(["tab", "rename", TAB_ID, label]);
|
|
73
|
-
written = label;
|
|
74
|
-
} catch {
|
|
75
|
-
// 忽略:herdr CLI 不可用、socket 断开、tab 已关闭等
|
|
76
|
-
}
|
|
77
|
-
})();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
pi.on("session_shutdown", async (event) => {
|
|
81
|
-
if (event.reason !== "quit" || !written || !originalLabel) return;
|
|
82
|
-
try {
|
|
83
|
-
// 用户在会话期间手动改过 tab 名,就不覆盖他的。
|
|
84
|
-
if ((await currentLabel()) !== written) return;
|
|
85
|
-
await run(["tab", "rename", TAB_ID, originalLabel]);
|
|
86
|
-
written = undefined;
|
|
87
|
-
} catch {
|
|
88
|
-
// 忽略
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
}
|
|
File without changes
|