pi-herdr-agents 3.1.0 → 3.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.
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pi-herdr-agents
3
- description: Operate the pi-herdr-agents host. Use when asked to launch, delegate to, supervise, interrupt, cancel, stop, resume, or clean up Pi subagents; to use worktrees, persistent specialists, or model routing; to edit the host's config.json or task-model preferences; or when a child looks hung, stalled, or unwanted.
3
+ description: Operate the pi-herdr-agents host. Use when asked to launch, delegate to, supervise, interrupt, cancel, stop, resume, or clean up Pi subagents; to use worktrees, persistent specialists, or model routing; to edit the host's config.json or task-model preferences; to install, configure, troubleshoot, or uninstall the optional Herdr sidebar plugin; or when a child looks hung, stalled, or unwanted.
4
4
  ---
5
5
 
6
6
  # pi-herdr-agents operating guide
@@ -236,6 +236,7 @@ changes. Without a real file, only non-model settings fall back to the example.
236
236
  | Key | Purpose |
237
237
  | --- | --- |
238
238
  | `status.enabled` | Widget status |
239
+ | `sidebar.enabled` | Opt-in delegated-child markers for the optional Herdr sidebar plugin; off by default |
239
240
  | `models.default` | Model for children that specify none |
240
241
  | `models.agents` | Per-role model, keyed by role name |
241
242
  | `models.tasks` | Ordered exact-ID candidates per category for `task:<category>` |
@@ -265,6 +266,44 @@ retry blindly. Every write holds an advisory `config.json.lock`; a held lock
265
266
  fails immediately with `Task model config writer busy`, and it is never broken
266
267
  automatically. Remove it only after confirming no writer is active.
267
268
 
269
+ ## Optional Herdr sidebar plugin
270
+
271
+ For installation or troubleshooting, first read the
272
+ [sidebar plugin guide](../../plugins/sidebar/README.md). Resolve that path from
273
+ this skill's directory, not the user's working directory. It is the canonical
274
+ reference for requirements, installation, Focus/All, limits, and uninstall.
275
+
276
+ Setup has two separate parts: **install the Herdr plugin** and **enable markers
277
+ in the Pi extension**. Neither alone enables sidebar filtering.
278
+
279
+ 1. Check the installed extension supports `sidebar.enabled`, and check the
280
+ guide's Herdr compatibility requirements. Markers require Linux; the Herdr
281
+ server needs `node` on its `PATH`. Use `pi list` to check package sources;
282
+ do not load two copies of the extension.
283
+ 2. Review the plugin source, then follow the guide to install
284
+ `giuseppecrj/pi-herdr-agents/plugins/sidebar` with `herdr plugin install`,
285
+ or link an existing compatible directory with `herdr plugin link`.
286
+ Installing the Pi package does not install the Herdr plugin.
287
+ 3. Merge `"sidebar": { "enabled": true }` into the durable config described
288
+ above, preserving `status`, `models`, and all other settings. If the file
289
+ does not exist, first copy the installed package's `config.json.example`;
290
+ a sidebar-only config is invalid because `status` is required.
291
+ 4. Run `/reload` in each parent Pi session inside Herdr, then launch new
292
+ children. Children already running stay unmarked.
293
+ 5. Invoke Focus explicitly; installation and enablement never activate it:
294
+
295
+ ```bash
296
+ herdr plugin action invoke pi-herdr-agents.sidebar.focus
297
+ ```
298
+
299
+ Focus changes the server-wide Agents view, replacing any existing view. It
300
+ hides marked children except those blocked or with unknown status; it never
301
+ stops children or suppresses notifications. To restore rows, invoke
302
+ `herdr plugin action invoke pi-herdr-agents.sidebar.all`. All clears only this
303
+ plugin's view and leaves another tool's view untouched. If filtering does not
304
+ work, inspect `herdr plugin log list --plugin pi-herdr-agents.sidebar` and
305
+ follow the guide's troubleshooting steps.
306
+
268
307
  ## Denied tools
269
308
 
270
309
  Role `spawning: false` and `deny-tools` restrict what a child may call; the