pi-herdr-agents 3.0.3 → 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.
@@ -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
+ }
@@ -245,12 +245,19 @@ changes. Without a real file, only non-model settings fall back to the example.
245
245
  | `roles.bundled` | Deprecated no-op; remove it |
246
246
 
247
247
  Model values must be exact authenticated IDs. `/subagents-init [preferences]`
248
- drafts task preferences from the live model registry and saves them through
249
- `subagents_write_task_models` (parent-only; validates, atomically replaces
250
- `models.tasks` and `tasksMeta`, preserves other settings). Payload: `tasks`
248
+ drafts task preferences from the live model registry and the saved config's
249
+ `configRevision`, then saves them through `subagents_write_task_models`
250
+ (parent-only; validates, atomically replaces `models.tasks` and `tasksMeta`,
251
+ preserves other settings). When one loaded extension offers to approve the
252
+ write, the init prompt names that extension's tool instead; follow the prompt
253
+ and do not call the writer around it. Payload: `tasks`
251
254
  (partial but nonempty categories; empty `tasks: {}` and duplicate refs within a
252
255
  category are rejected) and required `tasksMeta`. Warning: categories you omit
253
256
  are removed from the saved config, so send every category you want to keep.
257
+ Optional `basis` states what the ranking rests on and is not saved: use
258
+ `{"kind":"registry-only"}` unless sources consulted in this run informed it,
259
+ then `{"kind":"research","sources":[{"url":"https://...","influence":"how it changed the ranking"}],"uncertainty":"what remains unknown"}`.
260
+ Its kind must equal `tasksMeta.method`.
254
261
  Optional `expectedConfigRevision` (`sha256:` plus 64 lowercase hex digits of the exact file bytes,
255
262
  or `missing`) makes the write conditional; a stale revision fails with
256
263
  `Stale task model config revision`: re-read, re-propose, re-approve, do not