pi-herdr-agents 3.1.0 → 3.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/AGENTS.md +3 -2
- package/CHANGELOG.md +10 -1
- package/CONTEXT.md +22 -4
- package/README.md +58 -6
- package/RELEASING.md +2 -2
- package/docs/README.md +9 -0
- package/docs/worktree-subagents.md +1 -0
- package/maestro/adapters/pi/launch.ts +26 -4
- package/maestro/adapters/pi/pi-harness-adapter.ts +4 -0
- package/maestro/core/config/sidebar-config.ts +63 -0
- package/maestro/runtime/delegated-marker.ts +206 -0
- package/maestro/runtime/pi-run-session.ts +58 -7
- package/maestro/surfaces/herdr/herdr-socket.ts +165 -0
- package/package.json +6 -6
- package/pi-extension/subagents/index.ts +6 -0
- package/plugins/sidebar/README.md +242 -0
- package/plugins/sidebar/herdr-plugin.toml +21 -0
- package/plugins/sidebar/sidebar.mjs +238 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Pi Herdr Agents Sidebar: the Focus and All actions for Herdr's Agents view.
|
|
2
|
+
// Herdr may install only this directory, so the file uses Node built-ins only.
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
export const PLUGIN_ID = "pi-herdr-agents.sidebar";
|
|
8
|
+
export const VIEW_SOURCE = `plugin:${PLUGIN_ID}`;
|
|
9
|
+
// Written by the pi-herdr-agents extension on a delegated child's pane. "live"
|
|
10
|
+
// means recently classified as delegated, not proven alive at render time.
|
|
11
|
+
export const MARKER_TOKEN = "piha_delegated_v1";
|
|
12
|
+
export const MARKER_VALUE = "live";
|
|
13
|
+
export const REQUEST_TIMEOUT_MS = 3_000;
|
|
14
|
+
export const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
15
|
+
const MAX_REPORTED_CHARS = 200;
|
|
16
|
+
|
|
17
|
+
// Hide a row only when its marker is exactly "live" and its status is idle,
|
|
18
|
+
// working, or done. Blocked, unknown, missing, and other marker values stay.
|
|
19
|
+
export const FOCUS_FILTER = {
|
|
20
|
+
op: "not",
|
|
21
|
+
filter: {
|
|
22
|
+
op: "all",
|
|
23
|
+
filters: [
|
|
24
|
+
{ op: "eq", field: { token: MARKER_TOKEN }, value: MARKER_VALUE },
|
|
25
|
+
{ op: "in", field: "status", values: ["idle", "working", "done"] },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const FOCUS_PARAMS = {
|
|
31
|
+
source: VIEW_SOURCE,
|
|
32
|
+
label: "focus",
|
|
33
|
+
filter: FOCUS_FILTER,
|
|
34
|
+
};
|
|
35
|
+
export const ALL_PARAMS = { source: VIEW_SOURCE };
|
|
36
|
+
|
|
37
|
+
// Mirrors maestro/core/config/type-guards.ts, which this file cannot import.
|
|
38
|
+
function isString(value) {
|
|
39
|
+
return Object.prototype.toString.call(value) === "[object String]";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return (
|
|
44
|
+
value !== null &&
|
|
45
|
+
Object.prototype.toString.call(value) === "[object Object]"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function describe(value) {
|
|
50
|
+
const text = isString(value) ? value : JSON.stringify(value);
|
|
51
|
+
return String(text ?? "")
|
|
52
|
+
.replace(/\p{Cc}+/gu, " ")
|
|
53
|
+
.slice(0, MAX_REPORTED_CHARS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function failure(code, message) {
|
|
57
|
+
return { ok: false, error: { code, message: describe(message) } };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One request on one connection, as Herdr closes ordinary connections after a
|
|
62
|
+
* reply. Settles exactly once: the first newline-terminated reply, a timeout,
|
|
63
|
+
* a reply larger than maxBytes, or a connection that ends first.
|
|
64
|
+
*/
|
|
65
|
+
export function requestHerdr(socketPath, method, params, options = {}) {
|
|
66
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
67
|
+
const maxBytes = options.maxBytes ?? MAX_RESPONSE_BYTES;
|
|
68
|
+
const id = options.id ?? `${PLUGIN_ID}:${process.pid}:${Date.now()}`;
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
let settled = false;
|
|
71
|
+
let received = Buffer.alloc(0);
|
|
72
|
+
const socket = net.connect(socketPath);
|
|
73
|
+
const finish = (outcome) => {
|
|
74
|
+
if (settled) return;
|
|
75
|
+
settled = true;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
socket.destroy();
|
|
78
|
+
resolve(outcome);
|
|
79
|
+
};
|
|
80
|
+
const timer = setTimeout(
|
|
81
|
+
() =>
|
|
82
|
+
finish(failure("timeout", `${method} got no reply in ${timeoutMs}ms`)),
|
|
83
|
+
timeoutMs,
|
|
84
|
+
);
|
|
85
|
+
socket.on("connect", () => {
|
|
86
|
+
socket.write(`${JSON.stringify({ id, method, params })}\n`);
|
|
87
|
+
});
|
|
88
|
+
socket.on("error", (error) => finish(failure("socket", error.message)));
|
|
89
|
+
socket.on("close", () =>
|
|
90
|
+
finish(failure("closed", `${method}: connection closed before a reply`)),
|
|
91
|
+
);
|
|
92
|
+
socket.on("data", (chunk) => {
|
|
93
|
+
received = Buffer.concat([received, chunk]);
|
|
94
|
+
const end = received.indexOf(10);
|
|
95
|
+
if (end < 0) {
|
|
96
|
+
if (received.length > maxBytes)
|
|
97
|
+
finish(
|
|
98
|
+
failure("too_large", `${method} reply exceeds ${maxBytes} bytes`),
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (end > maxBytes) {
|
|
103
|
+
finish(
|
|
104
|
+
failure("too_large", `${method} reply exceeds ${maxBytes} bytes`),
|
|
105
|
+
);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
finish(
|
|
109
|
+
parseReply(received.subarray(0, end).toString("utf8"), id, method),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function parseReply(line, id, method) {
|
|
116
|
+
let reply;
|
|
117
|
+
try {
|
|
118
|
+
reply = JSON.parse(line);
|
|
119
|
+
} catch {
|
|
120
|
+
return failure("malformed", `${method}: reply is not JSON`);
|
|
121
|
+
}
|
|
122
|
+
if (!isRecord(reply))
|
|
123
|
+
return failure("malformed", `${method}: reply is not an object`);
|
|
124
|
+
if (reply.id !== id)
|
|
125
|
+
return failure(
|
|
126
|
+
"wrong_id",
|
|
127
|
+
`${method}: reply id ${describe(reply.id)} is not ${id}`,
|
|
128
|
+
);
|
|
129
|
+
if (isRecord(reply.error))
|
|
130
|
+
return failure(
|
|
131
|
+
describe(reply.error.code ?? "herdr_error"),
|
|
132
|
+
reply.error.message ?? "Herdr reported an error",
|
|
133
|
+
);
|
|
134
|
+
if (!isRecord(reply.result))
|
|
135
|
+
return failure("malformed", `${method}: reply has no result`);
|
|
136
|
+
return { ok: true, result: reply.result };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isAgentView(result) {
|
|
140
|
+
return (
|
|
141
|
+
result.type === "agent_view" &&
|
|
142
|
+
(result.active === true || result.active === false)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const FOCUS_MESSAGE =
|
|
147
|
+
"Focus is on. The Agents view hides delegated Pi children that pi-herdr-agents " +
|
|
148
|
+
"marked in the last 15 seconds, unless they are blocked or their status is unknown. " +
|
|
149
|
+
"Focus replaced whatever agent view was active, including one set by another tool. " +
|
|
150
|
+
"Run All to show every agent again.";
|
|
151
|
+
export const ALL_MESSAGE = "All agents are shown. No agent view is active.";
|
|
152
|
+
|
|
153
|
+
/** Runs one action and returns its exit code; output goes through `out`. */
|
|
154
|
+
export async function runAction(action, options) {
|
|
155
|
+
const { env, out } = options;
|
|
156
|
+
const request = options.request ?? requestHerdr;
|
|
157
|
+
if (action !== "focus" && action !== "all") {
|
|
158
|
+
out.error(`Unknown action ${describe(action)}; expected focus or all.`);
|
|
159
|
+
return 2;
|
|
160
|
+
}
|
|
161
|
+
const socketPath = env.HERDR_SOCKET_PATH;
|
|
162
|
+
if (!socketPath) {
|
|
163
|
+
out.error(
|
|
164
|
+
"HERDR_SOCKET_PATH is not set. Run this action from Herdr: herdr plugin action invoke pi-herdr-agents.sidebar.focus",
|
|
165
|
+
);
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
if (env.HERDR_PLUGIN_ID && env.HERDR_PLUGIN_ID !== PLUGIN_ID) {
|
|
169
|
+
out.error(
|
|
170
|
+
`This script belongs to ${PLUGIN_ID}, but Herdr runs it as ${describe(env.HERDR_PLUGIN_ID)}; refusing to change a view it does not own.`,
|
|
171
|
+
);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
if (action === "focus") {
|
|
175
|
+
const reply = await request(socketPath, "agent.view.set", FOCUS_PARAMS);
|
|
176
|
+
if (!reply.ok) {
|
|
177
|
+
out.error(`Focus failed: ${reply.error.code}: ${reply.error.message}`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
if (
|
|
181
|
+
!isAgentView(reply.result) ||
|
|
182
|
+
reply.result.active !== true ||
|
|
183
|
+
reply.result.source !== VIEW_SOURCE
|
|
184
|
+
) {
|
|
185
|
+
out.error(`Focus failed: unexpected reply ${describe(reply.result)}`);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
out.log(FOCUS_MESSAGE);
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
// Guarded clear: Herdr leaves a view owned by another source in place and
|
|
192
|
+
// reports it, so All never claims that a foreign filter is gone.
|
|
193
|
+
const reply = await request(socketPath, "agent.view.clear", ALL_PARAMS);
|
|
194
|
+
if (!reply.ok) {
|
|
195
|
+
out.error(`All failed: ${reply.error.code}: ${reply.error.message}`);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
if (!isAgentView(reply.result)) {
|
|
199
|
+
out.error(`All failed: unexpected reply ${describe(reply.result)}`);
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
if (!reply.result.active) {
|
|
203
|
+
out.log(ALL_MESSAGE);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
if (reply.result.source === VIEW_SOURCE) {
|
|
207
|
+
out.error("All failed: Herdr still reports this plugin's view as active.");
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const label = reply.result.label
|
|
211
|
+
? `, label ${describe(reply.result.label)}`
|
|
212
|
+
: "";
|
|
213
|
+
out.error(
|
|
214
|
+
`Another agent view is still active (source ${describe(reply.result.source)}${label}). ` +
|
|
215
|
+
"All clears only this plugin's Focus, so that view was left in place and may still hide agents. " +
|
|
216
|
+
"Clear it from the tool that set it.",
|
|
217
|
+
);
|
|
218
|
+
return 3;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isMain() {
|
|
222
|
+
if (!process.argv[1]) return false;
|
|
223
|
+
try {
|
|
224
|
+
return (
|
|
225
|
+
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
|
|
226
|
+
);
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (isMain()) {
|
|
233
|
+
const action = process.argv[2] ?? process.env.HERDR_PLUGIN_ACTION_ID;
|
|
234
|
+
process.exitCode = await runAction(action, {
|
|
235
|
+
env: process.env,
|
|
236
|
+
out: console,
|
|
237
|
+
});
|
|
238
|
+
}
|