@335g/pi-herdr-fleet 0.0.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,300 @@
1
+ /**
2
+ * herdr-client: the transport layer over herdr's socket API.
3
+ *
4
+ * Newline-delimited JSON: `{id, method, params}` out, `{id, result}` or
5
+ * `{id, error}` back. Pushed events reuse the same stream once a subscription
6
+ * is acknowledged.
7
+ *
8
+ * Nothing here throws. herdr may be an older build, may be gone, or may be slow;
9
+ * a caller that cannot reach it degrades to "no data" and Pi keeps working.
10
+ */
11
+
12
+ import net from "node:net";
13
+
14
+ export type AgentStatus = "idle" | "working" | "blocked" | "done" | "unknown";
15
+
16
+ export type ReadSource = "visible" | "recent" | "recent_unwrapped" | "detection";
17
+
18
+ /**
19
+ * One entry of an `events.subscribe` request. `pane_id` is required by the
20
+ * pane-scoped events (`pane.agent_status_changed`, `pane.scroll_changed`) and
21
+ * ignored by the lifecycle events, so it is only ever set for the former.
22
+ *
23
+ * Measured against herdr 0.9.0: a lifecycle event carrying a `pane_id` is
24
+ * accepted and the field does nothing. It is not rejected, so nothing may rely
25
+ * on herdr refusing it.
26
+ */
27
+ export interface Subscription {
28
+ type: string;
29
+ pane_id?: string;
30
+ }
31
+
32
+ export interface HerdrEvent {
33
+ event: string;
34
+ data: Record<string, any>;
35
+ }
36
+
37
+ /** The subset of `session.snapshot` this extension reads. */
38
+ export interface Snapshot {
39
+ version: string;
40
+ panes: { pane_id: string; workspace_id: string }[];
41
+ agents: AgentInfo[];
42
+ }
43
+
44
+ export interface AgentInfo {
45
+ pane_id: string;
46
+ workspace_id: string;
47
+ agent?: string | null;
48
+ display_agent?: string | null;
49
+ name?: string | null;
50
+ agent_status: AgentStatus;
51
+ state_labels?: Record<string, string>;
52
+ /** Where the agent's own session lives, when herdr knows. */
53
+ agent_session?: { value?: string | null } | null;
54
+ cwd?: string | null;
55
+ }
56
+
57
+ /**
58
+ * A request either produced a value or degraded. `code` is herdr's own error
59
+ * code when it sent one (`agent_pane_busy`, `pane_not_found`, ...), or `timeout`
60
+ * when this client gave up before herdr answered: the message is for a human, so
61
+ * nothing should branch on its wording.
62
+ */
63
+ export type Outcome<T> = { ok: true; value: T } | { ok: false; error: string; code?: string };
64
+
65
+ export function ok<T>(value: T): Outcome<T> {
66
+ return { ok: true, value };
67
+ }
68
+
69
+ export function err<T = never>(error: string, code?: string): Outcome<T> {
70
+ return code === undefined ? { ok: false, error } : { ok: false, error, code };
71
+ }
72
+
73
+ /** A pushed event, or the signal that the stream has to be rebuilt. */
74
+ export type SubscribeEvent =
75
+ | { kind: "event"; event: HerdrEvent }
76
+ /** `reconnect`: the transport dropped and came back. `refused`: herdr rejected
77
+ * the set itself, so retrying it unchanged would never succeed. */
78
+ | { kind: "resync"; reason: "reconnect" | "refused"; snapshot: Outcome<Snapshot> };
79
+
80
+ export interface SubscriptionHandle {
81
+ close(): void;
82
+ }
83
+
84
+ const REQUEST_TIMEOUT_MS = 5_000;
85
+ const INITIAL_RETRY_MS = 500;
86
+ const MAX_RETRY_MS = 15_000;
87
+
88
+ function endpoint(socketPath: string): string {
89
+ return process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
90
+ }
91
+
92
+ let requestSeq = 0;
93
+
94
+ export class HerdrClient {
95
+ private readonly socketPath: string;
96
+ private readonly paneId: string;
97
+ private readonly workspaceId: string | undefined;
98
+
99
+ constructor(socketPath: string, paneId: string, workspaceId?: string) {
100
+ this.socketPath = socketPath;
101
+ this.paneId = paneId;
102
+ this.workspaceId = workspaceId;
103
+ }
104
+
105
+ /** Undefined unless this process really runs inside a herdr-managed pane. */
106
+ static fromEnv(): HerdrClient | undefined {
107
+ const socketPath = process.env.HERDR_SOCKET_PATH;
108
+ const paneId = process.env.HERDR_PANE_ID;
109
+ if (process.env.HERDR_ENV !== "1" || !socketPath || !paneId) return undefined;
110
+ return new HerdrClient(socketPath, paneId, process.env.HERDR_WORKSPACE_ID);
111
+ }
112
+
113
+ selfPaneId(): string {
114
+ return this.paneId;
115
+ }
116
+
117
+ selfWorkspaceId(): string | undefined {
118
+ return this.workspaceId;
119
+ }
120
+
121
+ /** One request on its own connection, so a lost reply cannot stall later calls. */
122
+ request(method: string, params: unknown, timeoutMs = REQUEST_TIMEOUT_MS): Promise<Outcome<any>> {
123
+ return new Promise((resolve) => {
124
+ let settled = false;
125
+ let buffer = "";
126
+ let timer: ReturnType<typeof setTimeout> | undefined;
127
+ const socket = net.createConnection(endpoint(this.socketPath));
128
+ const finish = (outcome: Outcome<any>) => {
129
+ if (settled) return;
130
+ settled = true;
131
+ if (timer) clearTimeout(timer);
132
+ socket.destroy();
133
+ resolve(outcome);
134
+ };
135
+
136
+ // `timeout` is this client's code, not herdr's: the caller has to be able to
137
+ // tell "herdr said no" from "we stopped waiting", because a slow herdr may
138
+ // still finish the work it was asked for.
139
+ timer = setTimeout(() => finish(err(`${method}: no reply in ${timeoutMs}ms`, "timeout")), timeoutMs);
140
+ timer.unref?.();
141
+ socket.on("error", (error) => finish(err(`${method}: ${error.message}`)));
142
+ socket.on("end", () => finish(err(`${method}: connection closed`)));
143
+ socket.on("connect", () => {
144
+ requestSeq += 1;
145
+ socket.write(`${JSON.stringify({ id: `fleet:${requestSeq}`, method, params })}\n`);
146
+ });
147
+ socket.on("data", (chunk) => {
148
+ buffer += chunk.toString();
149
+ const newline = buffer.indexOf("\n");
150
+ if (newline < 0) return;
151
+ finish(parseResponse(buffer.slice(0, newline), method));
152
+ });
153
+ });
154
+ }
155
+
156
+ async snapshot(): Promise<Outcome<Snapshot>> {
157
+ const response = await this.request("session.snapshot", {});
158
+ if (!response.ok) return response;
159
+ const snapshot = response.value?.snapshot;
160
+ if (!snapshot) return err("session.snapshot: no snapshot in the response");
161
+ return ok(snapshot as Snapshot);
162
+ }
163
+
164
+ /** The pane's rendered text. `detection` is herdr's own view of the prompt UI. */
165
+ async agentRead(target: string, source: ReadSource, lines?: number): Promise<Outcome<string>> {
166
+ const params = lines === undefined ? { target, source } : { target, source, lines };
167
+ const response = await this.request("agent.read", params);
168
+ if (!response.ok) return response;
169
+ const text = response.value?.read?.text;
170
+ if (typeof text !== "string") return err("agent.read: no text in the response");
171
+ return ok(text);
172
+ }
173
+
174
+ /** Raw keystrokes into a pane: `1`, `enter`, `esc`, `up`, `ctrl+c`, ... */
175
+ paneSendKeys(paneId: string, keys: string[]): Promise<Outcome<void>> {
176
+ return this.expectOk("pane.send_keys", { pane_id: paneId, keys });
177
+ }
178
+
179
+ /**
180
+ * Literal text followed by keys, as one ordered submission.
181
+ *
182
+ * The agent-level write methods are the wrong tool for answering a dialog.
183
+ * `agent.prompt` refuses any pane herdr reports as blocked (`agent_blocked`)
184
+ * — which is every pane this extension can answer — and `agent.send_keys`
185
+ * refuses an agent reported through `pane.report_agent` (`agent_not_ready`),
186
+ * which is how hooks and plugins report state. The pane surface has neither
187
+ * check, and answering an approval dialog is intentional raw input.
188
+ */
189
+ paneSendInput(paneId: string, text: string, keys: string[] = ["enter"]): Promise<Outcome<void>> {
190
+ return this.expectOk("pane.send_input", { pane_id: paneId, text, keys });
191
+ }
192
+
193
+ /**
194
+ * Open a long-lived event stream. The subscription set is fixed for the life
195
+ * of one connection, so a caller whose set changes closes this handle and
196
+ * subscribes again.
197
+ *
198
+ * On a drop the stream reconnects with exponential backoff, resends the same
199
+ * set, and reports `resync` with a fresh snapshot so the caller can rebuild
200
+ * the state it derived from events.
201
+ */
202
+ subscribe(subscriptions: Subscription[], onEvent: (event: SubscribeEvent) => void): SubscriptionHandle {
203
+ let closed = false;
204
+ let acknowledged = false;
205
+ let reconnecting = false;
206
+ let refused = false;
207
+ let retryMs = INITIAL_RETRY_MS;
208
+ let buffer = "";
209
+ let socket: net.Socket | undefined;
210
+ let timer: ReturnType<typeof setTimeout> | undefined;
211
+
212
+ const connect = () => {
213
+ if (closed) return;
214
+ acknowledged = false;
215
+ buffer = "";
216
+ socket = net.createConnection(endpoint(this.socketPath));
217
+ socket.on("error", () => {
218
+ // The close handler owns recovery; a connect error always closes.
219
+ });
220
+ socket.on("close", () => {
221
+ // A refused set is a caller bug, not a transient drop: retrying it
222
+ // unchanged would fail forever, so the caller gets one resync instead.
223
+ if (closed || refused) return;
224
+ timer = setTimeout(() => {
225
+ reconnecting = true;
226
+ connect();
227
+ }, retryMs);
228
+ timer.unref?.();
229
+ retryMs = Math.min(retryMs * 2, MAX_RETRY_MS);
230
+ });
231
+ socket.on("connect", () => {
232
+ requestSeq += 1;
233
+ socket?.write(
234
+ `${JSON.stringify({ id: `fleet:sub:${requestSeq}`, method: "events.subscribe", params: { subscriptions } })}\n`,
235
+ );
236
+ });
237
+ socket.on("data", (chunk) => {
238
+ buffer += chunk.toString();
239
+ let newline = buffer.indexOf("\n");
240
+ while (newline >= 0) {
241
+ const line = buffer.slice(0, newline);
242
+ buffer = buffer.slice(newline + 1);
243
+ newline = buffer.indexOf("\n");
244
+ if (line.trim() === "") continue;
245
+ const parsed = parseJson(line);
246
+ if (!parsed) continue;
247
+ if (!acknowledged && parsed.result?.type === "subscription_started") {
248
+ acknowledged = true;
249
+ retryMs = INITIAL_RETRY_MS;
250
+ if (reconnecting) {
251
+ reconnecting = false;
252
+ void this.snapshot().then((snapshot) => onEvent({ kind: "resync", reason: "reconnect", snapshot }));
253
+ }
254
+ continue;
255
+ }
256
+ if (!acknowledged && parsed.error) {
257
+ refused = true;
258
+ void this.snapshot().then((snapshot) => onEvent({ kind: "resync", reason: "refused", snapshot }));
259
+ socket?.destroy();
260
+ continue;
261
+ }
262
+ if (parsed.error || typeof parsed.event !== "string") continue;
263
+ onEvent({ kind: "event", event: { event: parsed.event, data: parsed.data ?? {} } });
264
+ }
265
+ });
266
+ };
267
+
268
+ connect();
269
+ return {
270
+ close: () => {
271
+ closed = true;
272
+ if (timer) clearTimeout(timer);
273
+ socket?.destroy();
274
+ },
275
+ };
276
+ }
277
+
278
+ private async expectOk(method: string, params: unknown): Promise<Outcome<void>> {
279
+ const response = await this.request(method, params);
280
+ return response.ok ? ok(undefined) : response;
281
+ }
282
+ }
283
+
284
+ function parseJson(line: string): any | undefined {
285
+ try {
286
+ return JSON.parse(line);
287
+ } catch {
288
+ return undefined;
289
+ }
290
+ }
291
+
292
+ function parseResponse(line: string, method: string): Outcome<any> {
293
+ const parsed = parseJson(line);
294
+ if (!parsed) return err(`${method}: malformed response`);
295
+ if (parsed.error) {
296
+ const code = typeof parsed.error.code === "string" ? parsed.error.code : undefined;
297
+ return err(`${method}: ${parsed.error.message ?? code ?? "error"}`, code);
298
+ }
299
+ return ok(parsed.result);
300
+ }