@muzi3/herdr 0.1.4 → 0.2.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/LICENSE.MIT +21 -0
- package/README.md +75 -10
- package/extensions/herdr-agent-state/index.ts +409 -0
- package/extensions/pane-agent-tools/index.ts +955 -0
- package/package.json +15 -8
- package/extensions/blocked-state.ts +0 -118
- package/extensions/index.ts +0 -257
- /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.1
|
|
4
|
-
"description": "Herdr for pi:
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* blocked-state
|
|
3
|
-
*
|
|
4
|
-
* 把 pi 的阻塞式交互提示转发成 herdr 的 blocked 状态。
|
|
5
|
-
*
|
|
6
|
-
* herdr 的 pi 集成(同目录 index.ts)只监听 `herdr:blocked` 事件再上报
|
|
7
|
-
* `state: "blocked"`,而它内置的终端识别规则只有一条 working_literal(匹配
|
|
8
|
-
* "Working..."),没有任何 blocked 规则 —— 没有这个桥,ask_user_question 期间
|
|
9
|
-
* pane 一直显示 working,切到别的 pane 就看不出这个会话正等着人回话。
|
|
10
|
-
*
|
|
11
|
-
* 信号来源是 pi core 的 `ui_prompt_start` / `ui_prompt_end`:runner 统一包装了
|
|
12
|
-
* select/confirm/input/editor/custom,只在外层提示触发,所以覆盖所有阻塞弹窗。
|
|
13
|
-
* `custom` 不带 title,额外借 `rpiv:ask-user:prompt` 的问题内容补 label。
|
|
14
|
-
*
|
|
15
|
-
* herdr 侧的 blockedCount 是计数式的:切 label 前必须先发 `{active:false}` 降自己
|
|
16
|
-
* 那一份再发 `{active:true}`。本扩展严格配对 raise/lower,只占一个计数。
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
-
|
|
21
|
-
/** herdr pi 集成的入站事件通道(由 index.ts 消费)。 */
|
|
22
|
-
const HERDR_BLOCKED_EVENT = "herdr:blocked";
|
|
23
|
-
/** @juicesharp/rpiv-ask-user-question 的公开事件通道。 */
|
|
24
|
-
const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt";
|
|
25
|
-
|
|
26
|
-
/** 与 herdr 集成同一门槛:确认自己确实跑在 herdr 的 pane 里。 */
|
|
27
|
-
const IN_HERDR = process.env.HERDR_ENV === "1" && !!process.env.HERDR_PANE_ID;
|
|
28
|
-
const MAX_LABEL_CHARS = 60;
|
|
29
|
-
const WAITING = "等待回答";
|
|
30
|
-
|
|
31
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
-
return typeof value === "object" && value !== null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** label 是一行状态文字,换行/多空格先折叠掉。 */
|
|
36
|
-
function flatten(text: string): string {
|
|
37
|
-
return text.replace(/\s+/g, " ").trim();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function truncate(text: string, max: number): string {
|
|
41
|
-
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 取第一条问题的 header(短)与原文(长)。 */
|
|
45
|
-
function readQuestion(data: unknown): { header?: string; question?: string } | undefined {
|
|
46
|
-
if (!isRecord(data) || !Array.isArray(data.questions)) {
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
const first = data.questions.find(isRecord);
|
|
50
|
-
if (!first) {
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
const header = typeof first.header === "string" ? flatten(first.header) : "";
|
|
54
|
-
const question = typeof first.question === "string" ? flatten(first.question) : "";
|
|
55
|
-
if (!header && !question) {
|
|
56
|
-
return undefined;
|
|
57
|
-
}
|
|
58
|
-
return { header: header || undefined, question: question || undefined };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export default function (pi: ExtensionAPI) {
|
|
62
|
-
if (!IN_HERDR) {
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
let rootSession = false;
|
|
67
|
-
let raised = false;
|
|
68
|
-
let raisedLabel: string | undefined;
|
|
69
|
-
let askUser: { header?: string; question?: string } | undefined;
|
|
70
|
-
|
|
71
|
-
function lower(): void {
|
|
72
|
-
if (!raised) return;
|
|
73
|
-
raised = false;
|
|
74
|
-
raisedLabel = undefined;
|
|
75
|
-
pi.events.emit(HERDR_BLOCKED_EVENT, { active: false });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function raise(detail: string | undefined): void {
|
|
79
|
-
const label = detail ? `${WAITING}:${truncate(detail, MAX_LABEL_CHARS)}` : WAITING;
|
|
80
|
-
if (raised && raisedLabel === label) return;
|
|
81
|
-
lower(); // 先降自己那一份再抬高,保证 label 切换时计数不残留
|
|
82
|
-
raised = true;
|
|
83
|
-
raisedLabel = label;
|
|
84
|
-
pi.events.emit(HERDR_BLOCKED_EVENT, { active: true, label });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
pi.events.on(ASK_USER_PROMPT_EVENT, (data) => {
|
|
88
|
-
askUser = readQuestion(data);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
pi.on("ui_prompt_start", (event) => {
|
|
92
|
-
if (!rootSession) return;
|
|
93
|
-
// custom 提示(ask_user_question 走这条)的 title 是空的,用问题内容补 label。
|
|
94
|
-
raise(event.kind === "custom" ? (askUser?.header ?? askUser?.question) : event.title);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
pi.on("ui_prompt_end", () => {
|
|
98
|
-
askUser = undefined;
|
|
99
|
-
if (!rootSession) return;
|
|
100
|
-
lower();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
pi.on("session_start", (_event, ctx) => {
|
|
104
|
-
// 与 herdr 集成同一门槛:RPC/print 没有可展示的 PTY;
|
|
105
|
-
// reload 会同时重载集成与本扩展,两侧计数一起归零。
|
|
106
|
-
if (ctx?.mode !== "tui") return;
|
|
107
|
-
rootSession = true;
|
|
108
|
-
raised = false;
|
|
109
|
-
raisedLabel = undefined;
|
|
110
|
-
askUser = undefined;
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
pi.on("session_shutdown", () => {
|
|
114
|
-
lower();
|
|
115
|
-
rootSession = false;
|
|
116
|
-
askUser = undefined;
|
|
117
|
-
});
|
|
118
|
-
}
|
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
|
-
}
|
|
File without changes
|