@tigorhutasuhut/herdr-claude-retry 0.1.1 → 0.1.2
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/README.md +3 -3
- package/dist/cli.js +2 -22
- package/dist/daemon.d.ts +0 -11
- package/dist/daemon.js +37 -14
- package/dist/herdr.d.ts +24 -14
- package/dist/herdr.js +194 -127
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -82,9 +82,9 @@ npm run verify # typecheck + test + build (publish gate)
|
|
|
82
82
|
npm run e2e # acceptance test — needs live herdr
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
The e2e test (`test/e2e/blocked-pane.e2e.ts`)
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
The e2e test (`test/e2e/blocked-pane.e2e.ts`) verifies live connectivity to the herdr socket:
|
|
86
|
+
connects, lists agents, reads all live panes (the core one-shot protocol fix), and runs a daemon
|
|
87
|
+
reconcile sweep asserting zero paneRead failures. Skips gracefully if no herdr socket is found.
|
|
88
88
|
|
|
89
89
|
## Publishing
|
|
90
90
|
|
package/dist/cli.js
CHANGED
|
@@ -102,17 +102,9 @@ async function main() {
|
|
|
102
102
|
const ac = new AbortController();
|
|
103
103
|
process.on('SIGINT', () => ac.abort());
|
|
104
104
|
process.on('SIGTERM', () => ac.abort());
|
|
105
|
-
// Construct
|
|
105
|
+
// Construct client
|
|
106
106
|
const client = new HerdrClient({ socketPath });
|
|
107
|
-
|
|
108
|
-
// Wire reconnect log events
|
|
109
|
-
client.onReconnect = () => {
|
|
110
|
-
logFn({ event: 'socket.connected' });
|
|
111
|
-
};
|
|
112
|
-
subscribeClient.onReconnect = () => {
|
|
113
|
-
logFn({ event: 'socket.connected', role: 'subscribe' });
|
|
114
|
-
};
|
|
115
|
-
// Connect
|
|
107
|
+
// Connect (connectivity check — open+close a socket)
|
|
116
108
|
logFn({ event: 'daemon.start', version: VERSION, socket_path: socketPath });
|
|
117
109
|
try {
|
|
118
110
|
await client.connect();
|
|
@@ -123,21 +115,10 @@ async function main() {
|
|
|
123
115
|
process.stderr.write(`fatal: could not connect to ${socketPath}: ${err}\n`);
|
|
124
116
|
process.exit(1);
|
|
125
117
|
}
|
|
126
|
-
try {
|
|
127
|
-
await subscribeClient.connect();
|
|
128
|
-
logFn({ event: 'socket.connected', role: 'subscribe' });
|
|
129
|
-
}
|
|
130
|
-
catch (err) {
|
|
131
|
-
logFn({ level: 'error', event: 'socket.dead', role: 'subscribe' });
|
|
132
|
-
client.destroy();
|
|
133
|
-
process.stderr.write(`fatal: could not connect subscribe socket to ${socketPath}: ${err}\n`);
|
|
134
|
-
process.exit(1);
|
|
135
|
-
}
|
|
136
118
|
// Run daemon — map daemon's string logger to structured events
|
|
137
119
|
try {
|
|
138
120
|
await runDaemon({
|
|
139
121
|
client,
|
|
140
|
-
subscribeClient,
|
|
141
122
|
marginSeconds,
|
|
142
123
|
sweepIntervalMs: sweepIntervalSeconds * 1000,
|
|
143
124
|
signal: ac.signal,
|
|
@@ -146,7 +127,6 @@ async function main() {
|
|
|
146
127
|
}
|
|
147
128
|
finally {
|
|
148
129
|
client.destroy();
|
|
149
|
-
subscribeClient.destroy();
|
|
150
130
|
logFn({ event: 'daemon.stop', reason: ac.signal.aborted ? 'signal' : 'exit' });
|
|
151
131
|
}
|
|
152
132
|
}
|
package/dist/daemon.d.ts
CHANGED
|
@@ -10,17 +10,6 @@ import { readAccessToken, fetchUsage } from './usage.ts';
|
|
|
10
10
|
import type { Logger } from './monitor.ts';
|
|
11
11
|
export interface DaemonOpts {
|
|
12
12
|
client: HerdrClient;
|
|
13
|
-
/**
|
|
14
|
-
* Separate client used exclusively for event subscriptions.
|
|
15
|
-
*
|
|
16
|
-
* herdr closes any connection that sends non-subscribe requests after
|
|
17
|
-
* `events.subscribe` — so requests (pane.read, agent.list, inject) and the
|
|
18
|
-
* subscription stream must use different sockets. When provided, `client` is
|
|
19
|
-
* used only for regular requests and `subscribeClient` is used only for
|
|
20
|
-
* `events.subscribe`. Both must already be connected. Defaults to `client`
|
|
21
|
-
* when omitted (fine for unit tests with a mock that handles both).
|
|
22
|
-
*/
|
|
23
|
-
subscribeClient?: HerdrClient;
|
|
24
13
|
/** Override account dir discovery. */
|
|
25
14
|
accountDirs?: string[];
|
|
26
15
|
/** Extra seconds after resetsAt before injecting. Default 60. */
|
package/dist/daemon.js
CHANGED
|
@@ -52,7 +52,7 @@ async function resolveUsage(paneId, client, accountDirs, opts) {
|
|
|
52
52
|
// Main daemon
|
|
53
53
|
// ---------------------------------------------------------------------------
|
|
54
54
|
export async function runDaemon(opts) {
|
|
55
|
-
const { client,
|
|
55
|
+
const { client, marginSeconds = 60, sweepIntervalMs = 5 * 60 * 1000, signal, log = (msg) => process.stderr.write(`[herdr] ${msg}\n`), now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), fetchUsageFn = fetchUsage, readTokenFn = readAccessToken, discoverDirsFn = discoverAccountDirs, resolveAccountDirFn = resolveAccountDir, } = opts;
|
|
56
56
|
// Discover account dirs (or use override)
|
|
57
57
|
const accountDirs = opts.accountDirs ?? await discoverDirsFn().catch(() => []);
|
|
58
58
|
log(`daemon starting — ${accountDirs.length} account dir(s)`);
|
|
@@ -164,6 +164,10 @@ export async function runDaemon(opts) {
|
|
|
164
164
|
// Event subscription loop (restart-capable, with per-pane subscriptions)
|
|
165
165
|
// -------------------------------------------------------------------------
|
|
166
166
|
let currentPaneIds = [];
|
|
167
|
+
// Tracks every pane id ever seen via pane_created — persists across resubscribes.
|
|
168
|
+
// Used to detect historical replays: first sighting of a pane id may trigger a
|
|
169
|
+
// resubscribe (new pane joined), subsequent sightings are always replays → ignored.
|
|
170
|
+
const knownCreatedPaneIds = new Set();
|
|
167
171
|
async function buildSubscriptions() {
|
|
168
172
|
let agents = [];
|
|
169
173
|
try {
|
|
@@ -171,6 +175,9 @@ export async function runDaemon(opts) {
|
|
|
171
175
|
}
|
|
172
176
|
catch { /* sweep will catch it */ }
|
|
173
177
|
currentPaneIds = agents.map((a) => a.pane_id);
|
|
178
|
+
// Seed knownCreatedPaneIds from live panes so initial replays are ignored.
|
|
179
|
+
for (const id of currentPaneIds)
|
|
180
|
+
knownCreatedPaneIds.add(id);
|
|
174
181
|
const subs = [
|
|
175
182
|
...currentPaneIds.map((paneId) => ({
|
|
176
183
|
type: 'pane.output_matched',
|
|
@@ -199,7 +206,7 @@ export async function runDaemon(opts) {
|
|
|
199
206
|
// Any other generator exit (socket close, pane.created) → restart.
|
|
200
207
|
let shouldStop = false;
|
|
201
208
|
try {
|
|
202
|
-
const events =
|
|
209
|
+
const events = client.subscribe(subs, innerSignal);
|
|
203
210
|
for await (const ev of events) {
|
|
204
211
|
if (signal?.aborted) {
|
|
205
212
|
shouldStop = true;
|
|
@@ -219,9 +226,34 @@ export async function runDaemon(opts) {
|
|
|
219
226
|
}
|
|
220
227
|
else if (ev.event === 'pane_created') {
|
|
221
228
|
const paneId = ev.data.pane?.pane_id;
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
229
|
+
if (paneId && knownCreatedPaneIds.has(paneId)) {
|
|
230
|
+
// Already seen this pane_created — historical replay, ignore.
|
|
231
|
+
// No log to avoid spam from long history backlogs.
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// First time seeing this pane_created. Check if pane is actually live
|
|
235
|
+
// before resubscribing — historical replays for long-dead panes would
|
|
236
|
+
// otherwise trigger N resubscribes on startup.
|
|
237
|
+
if (paneId)
|
|
238
|
+
knownCreatedPaneIds.add(paneId);
|
|
239
|
+
let isLive = false;
|
|
240
|
+
try {
|
|
241
|
+
const live = await client.agentList();
|
|
242
|
+
isLive = live.some((a) => a.pane_id === paneId);
|
|
243
|
+
// Also seed known set from current live panes to prevent future replays.
|
|
244
|
+
for (const a of live)
|
|
245
|
+
knownCreatedPaneIds.add(a.pane_id);
|
|
246
|
+
}
|
|
247
|
+
catch { /* treat as not live */ }
|
|
248
|
+
if (isLive) {
|
|
249
|
+
log(`${paneId} — pane created, resubscribing`);
|
|
250
|
+
innerAc.abort();
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
log(`${paneId ?? 'unknown'} — pane_created for dead/historical pane, ignoring`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
225
257
|
}
|
|
226
258
|
else if (ev.event === 'pane_closed') {
|
|
227
259
|
const paneId = ev.data.pane_id;
|
|
@@ -245,15 +277,6 @@ export async function runDaemon(opts) {
|
|
|
245
277
|
}
|
|
246
278
|
}
|
|
247
279
|
// -------------------------------------------------------------------------
|
|
248
|
-
// Reconnect-aware sweep: run sweep on connect/reconnect
|
|
249
|
-
// -------------------------------------------------------------------------
|
|
250
|
-
const prevReconnect = client.onReconnect;
|
|
251
|
-
client.onReconnect = () => {
|
|
252
|
-
prevReconnect?.();
|
|
253
|
-
log('reconnected — triggering immediate reconcile sweep');
|
|
254
|
-
reconcileSweep().catch((e) => log(`sweep error after reconnect: ${e}`));
|
|
255
|
-
};
|
|
256
|
-
// -------------------------------------------------------------------------
|
|
257
280
|
// Periodic sweep timer
|
|
258
281
|
// -------------------------------------------------------------------------
|
|
259
282
|
async function runSweepLoop() {
|
package/dist/herdr.d.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* herdr.ts — NDJSON socket client for the herdr daemon.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Protocol reality (herdr 0.7.1): every connection is one-shot — the server
|
|
5
|
+
* closes after ONE request-response. Subscribe connections stay open for the
|
|
6
|
+
* event stream but must NOT send a second request (kills the connection).
|
|
7
|
+
*
|
|
8
|
+
* Design:
|
|
9
|
+
* request() — opens a fresh socket per call, reads one response, destroys.
|
|
10
|
+
* subscribe() — opens a dedicated socket per call, sends events.subscribe,
|
|
11
|
+
* awaits subscription_started, then yields events until the
|
|
12
|
+
* socket closes or the abort signal fires.
|
|
13
|
+
* connect() — connectivity check (open + close a socket).
|
|
14
|
+
* destroy() — aborts all live subscribe streams; prevents further use.
|
|
6
15
|
*/
|
|
7
16
|
import { type Socket } from 'node:net';
|
|
8
17
|
export type AgentStatus = 'idle' | 'working' | 'blocked' | 'done' | 'unknown';
|
|
@@ -95,23 +104,18 @@ export type ConnectFn = (path: string) => Socket;
|
|
|
95
104
|
export declare class HerdrClient {
|
|
96
105
|
private readonly socketPath;
|
|
97
106
|
private readonly connectFn;
|
|
98
|
-
private socket;
|
|
99
|
-
private buffer;
|
|
100
|
-
private pendingRequests;
|
|
101
|
-
private eventListeners;
|
|
102
|
-
private reconnectDelay;
|
|
103
|
-
private readonly maxDelay;
|
|
104
107
|
private destroyed;
|
|
105
|
-
|
|
108
|
+
/** AbortControllers for active subscribe streams — destroy() aborts all. */
|
|
109
|
+
private readonly subscribeAborts;
|
|
106
110
|
constructor(opts?: {
|
|
107
111
|
socketPath?: string;
|
|
108
112
|
connectFn?: ConnectFn;
|
|
109
|
-
onReconnect?: () => void;
|
|
110
113
|
});
|
|
114
|
+
/**
|
|
115
|
+
* Open a connection and immediately close it. Rejects if the socket is
|
|
116
|
+
* unreachable. Used by cli.ts startup validation.
|
|
117
|
+
*/
|
|
111
118
|
connect(): Promise<void>;
|
|
112
|
-
private processBuffer;
|
|
113
|
-
private handleMessage;
|
|
114
|
-
private scheduleReconnect;
|
|
115
119
|
destroy(): void;
|
|
116
120
|
private request;
|
|
117
121
|
paneRead(pane_id: string, source?: 'visible' | 'recent' | 'recent_unwrapped' | 'detection', lines?: number): Promise<PaneRead>;
|
|
@@ -126,9 +130,15 @@ export declare class HerdrClient {
|
|
|
126
130
|
inject(pane_id: string): Promise<void>;
|
|
127
131
|
/**
|
|
128
132
|
* Subscribe to a set of event types and return an async iterator of typed events.
|
|
129
|
-
*
|
|
133
|
+
*
|
|
134
|
+
* Opens a dedicated socket, sends events.subscribe, awaits subscription_started,
|
|
135
|
+
* then yields events. The generator ends when:
|
|
136
|
+
* - the socket closes (server side)
|
|
137
|
+
* - `signal` is aborted
|
|
138
|
+
* - destroy() is called on this client
|
|
130
139
|
*/
|
|
131
140
|
subscribe(subscriptions: SubscriptionSpec[], signal?: AbortSignal): AsyncGenerator<HerdrEvent>;
|
|
141
|
+
private _subscribeOnSocket;
|
|
132
142
|
/**
|
|
133
143
|
* Subscribe to `pane.output_matched` events for a specific pane.
|
|
134
144
|
*/
|
package/dist/herdr.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* herdr.ts — NDJSON socket client for the herdr daemon.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Protocol reality (herdr 0.7.1): every connection is one-shot — the server
|
|
5
|
+
* closes after ONE request-response. Subscribe connections stay open for the
|
|
6
|
+
* event stream but must NOT send a second request (kills the connection).
|
|
7
|
+
*
|
|
8
|
+
* Design:
|
|
9
|
+
* request() — opens a fresh socket per call, reads one response, destroys.
|
|
10
|
+
* subscribe() — opens a dedicated socket per call, sends events.subscribe,
|
|
11
|
+
* awaits subscription_started, then yields events until the
|
|
12
|
+
* socket closes or the abort signal fires.
|
|
13
|
+
* connect() — connectivity check (open + close a socket).
|
|
14
|
+
* destroy() — aborts all live subscribe streams; prevents further use.
|
|
6
15
|
*/
|
|
7
16
|
import { createConnection } from 'node:net';
|
|
8
17
|
import { randomUUID } from 'node:crypto';
|
|
@@ -20,139 +29,115 @@ function defaultSocketPath() {
|
|
|
20
29
|
// ---------------------------------------------------------------------------
|
|
21
30
|
// HerdrClient
|
|
22
31
|
// ---------------------------------------------------------------------------
|
|
32
|
+
/** Default per-request timeout in milliseconds. */
|
|
33
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
|
23
34
|
export class HerdrClient {
|
|
24
35
|
socketPath;
|
|
25
36
|
connectFn;
|
|
26
|
-
socket = null;
|
|
27
|
-
buffer = '';
|
|
28
|
-
pendingRequests = new Map();
|
|
29
|
-
eventListeners = [];
|
|
30
|
-
reconnectDelay = 1000;
|
|
31
|
-
maxDelay = 60_000;
|
|
32
37
|
destroyed = false;
|
|
33
|
-
|
|
38
|
+
/** AbortControllers for active subscribe streams — destroy() aborts all. */
|
|
39
|
+
subscribeAborts = new Set();
|
|
34
40
|
constructor(opts) {
|
|
35
41
|
this.socketPath = opts?.socketPath ?? defaultSocketPath();
|
|
36
42
|
this.connectFn = opts?.connectFn ?? ((p) => createConnection(p));
|
|
37
|
-
if (opts?.onReconnect)
|
|
38
|
-
this.onReconnect = opts.onReconnect;
|
|
39
43
|
}
|
|
40
44
|
// -------------------------------------------------------------------------
|
|
41
|
-
//
|
|
45
|
+
// Connectivity check
|
|
42
46
|
// -------------------------------------------------------------------------
|
|
47
|
+
/**
|
|
48
|
+
* Open a connection and immediately close it. Rejects if the socket is
|
|
49
|
+
* unreachable. Used by cli.ts startup validation.
|
|
50
|
+
*/
|
|
43
51
|
connect() {
|
|
44
52
|
return new Promise((resolve, reject) => {
|
|
53
|
+
if (this.destroyed) {
|
|
54
|
+
reject(new Error('Client destroyed'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
45
57
|
const sock = this.connectFn(this.socketPath);
|
|
46
|
-
this.socket = sock;
|
|
47
|
-
this.buffer = '';
|
|
48
58
|
sock.setEncoding('utf8');
|
|
49
59
|
sock.once('connect', () => {
|
|
50
|
-
|
|
60
|
+
sock.destroy();
|
|
51
61
|
resolve();
|
|
52
62
|
});
|
|
53
63
|
sock.once('error', (err) => {
|
|
54
64
|
reject(err);
|
|
55
65
|
});
|
|
56
|
-
sock.on('data', (chunk) => {
|
|
57
|
-
this.buffer += chunk;
|
|
58
|
-
this.processBuffer();
|
|
59
|
-
});
|
|
60
|
-
sock.on('close', () => {
|
|
61
|
-
if (!this.destroyed) {
|
|
62
|
-
this.scheduleReconnect();
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
66
|
});
|
|
66
67
|
}
|
|
67
|
-
processBuffer() {
|
|
68
|
-
const lines = this.buffer.split('\n');
|
|
69
|
-
// Last element may be incomplete — keep in buffer
|
|
70
|
-
this.buffer = lines.pop() ?? '';
|
|
71
|
-
for (const line of lines) {
|
|
72
|
-
const trimmed = line.trim();
|
|
73
|
-
if (!trimmed)
|
|
74
|
-
continue;
|
|
75
|
-
try {
|
|
76
|
-
const msg = JSON.parse(trimmed);
|
|
77
|
-
this.handleMessage(msg);
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
// Malformed line — skip
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
handleMessage(msg) {
|
|
85
|
-
if ('event' in msg) {
|
|
86
|
-
// Push to all event listeners
|
|
87
|
-
const ev = msg;
|
|
88
|
-
for (const listener of this.eventListeners) {
|
|
89
|
-
listener(ev);
|
|
90
|
-
}
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if ('id' in msg) {
|
|
94
|
-
const resp = msg;
|
|
95
|
-
const pending = this.pendingRequests.get(resp.id);
|
|
96
|
-
if (!pending)
|
|
97
|
-
return;
|
|
98
|
-
this.pendingRequests.delete(resp.id);
|
|
99
|
-
if (resp.error) {
|
|
100
|
-
pending.reject(new HerdrError(resp.error.code, resp.error.message));
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
pending.resolve(resp.result);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
scheduleReconnect() {
|
|
108
|
-
// Reject all pending requests on disconnect
|
|
109
|
-
for (const [, { reject }] of this.pendingRequests) {
|
|
110
|
-
reject(new Error('Socket disconnected'));
|
|
111
|
-
}
|
|
112
|
-
this.pendingRequests.clear();
|
|
113
|
-
const delay = this.reconnectDelay;
|
|
114
|
-
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay);
|
|
115
|
-
setTimeout(() => {
|
|
116
|
-
if (this.destroyed)
|
|
117
|
-
return;
|
|
118
|
-
this.connect()
|
|
119
|
-
.then(() => {
|
|
120
|
-
this.onReconnect?.();
|
|
121
|
-
})
|
|
122
|
-
.catch(() => {
|
|
123
|
-
// scheduleReconnect will be triggered again by close event
|
|
124
|
-
});
|
|
125
|
-
}, delay);
|
|
126
|
-
}
|
|
127
68
|
destroy() {
|
|
128
69
|
this.destroyed = true;
|
|
129
|
-
this.
|
|
130
|
-
|
|
131
|
-
for (const [, { reject }] of this.pendingRequests) {
|
|
132
|
-
reject(new Error('Client destroyed'));
|
|
70
|
+
for (const ac of this.subscribeAborts) {
|
|
71
|
+
ac.abort();
|
|
133
72
|
}
|
|
134
|
-
this.
|
|
73
|
+
this.subscribeAborts.clear();
|
|
135
74
|
}
|
|
136
75
|
// -------------------------------------------------------------------------
|
|
137
|
-
// Raw request
|
|
76
|
+
// Raw request — one fresh socket per call
|
|
138
77
|
// -------------------------------------------------------------------------
|
|
139
|
-
request(method, params) {
|
|
78
|
+
request(method, params, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
140
79
|
return new Promise((resolve, reject) => {
|
|
141
|
-
if (
|
|
142
|
-
reject(new Error('
|
|
80
|
+
if (this.destroyed) {
|
|
81
|
+
reject(new Error('Client destroyed'));
|
|
143
82
|
return;
|
|
144
83
|
}
|
|
84
|
+
const sock = this.connectFn(this.socketPath);
|
|
85
|
+
sock.setEncoding('utf8');
|
|
86
|
+
let settled = false;
|
|
87
|
+
let buffer = '';
|
|
145
88
|
const id = randomUUID();
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
89
|
+
const done = (err, value) => {
|
|
90
|
+
if (settled)
|
|
91
|
+
return;
|
|
92
|
+
settled = true;
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
sock.destroy();
|
|
152
95
|
if (err) {
|
|
153
|
-
this.pendingRequests.delete(id);
|
|
154
96
|
reject(err);
|
|
155
97
|
}
|
|
98
|
+
else {
|
|
99
|
+
resolve(value);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
done(new Error(`Request timed out after ${timeoutMs}ms`));
|
|
104
|
+
}, timeoutMs);
|
|
105
|
+
sock.once('error', (err) => done(err));
|
|
106
|
+
sock.on('close', () => {
|
|
107
|
+
done(new Error('Socket closed before response'));
|
|
108
|
+
});
|
|
109
|
+
sock.on('data', (chunk) => {
|
|
110
|
+
buffer += chunk;
|
|
111
|
+
const lines = buffer.split('\n');
|
|
112
|
+
buffer = lines.pop() ?? '';
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed)
|
|
116
|
+
continue;
|
|
117
|
+
let msg;
|
|
118
|
+
try {
|
|
119
|
+
msg = JSON.parse(trimmed);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if ('id' in msg && msg['id'] === id) {
|
|
125
|
+
const resp = msg;
|
|
126
|
+
if (resp.error) {
|
|
127
|
+
done(new HerdrError(resp.error.code, resp.error.message));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
done(null, resp.result);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
sock.once('connect', () => {
|
|
136
|
+
const line = JSON.stringify({ id, method, params }) + '\n';
|
|
137
|
+
sock.write(line, (err) => {
|
|
138
|
+
if (err)
|
|
139
|
+
done(err);
|
|
140
|
+
});
|
|
156
141
|
});
|
|
157
142
|
});
|
|
158
143
|
}
|
|
@@ -192,46 +177,129 @@ export class HerdrClient {
|
|
|
192
177
|
await this.paneSendText(pane_id, 'continue\n');
|
|
193
178
|
}
|
|
194
179
|
// -------------------------------------------------------------------------
|
|
195
|
-
// Subscription streaming
|
|
180
|
+
// Subscription streaming — dedicated fresh socket per call
|
|
196
181
|
// -------------------------------------------------------------------------
|
|
197
182
|
/**
|
|
198
183
|
* Subscribe to a set of event types and return an async iterator of typed events.
|
|
199
|
-
*
|
|
184
|
+
*
|
|
185
|
+
* Opens a dedicated socket, sends events.subscribe, awaits subscription_started,
|
|
186
|
+
* then yields events. The generator ends when:
|
|
187
|
+
* - the socket closes (server side)
|
|
188
|
+
* - `signal` is aborted
|
|
189
|
+
* - destroy() is called on this client
|
|
200
190
|
*/
|
|
201
191
|
async *subscribe(subscriptions, signal) {
|
|
202
|
-
if (
|
|
203
|
-
throw new Error('
|
|
192
|
+
if (this.destroyed) {
|
|
193
|
+
throw new Error('Client destroyed');
|
|
204
194
|
}
|
|
195
|
+
const internalAc = new AbortController();
|
|
196
|
+
this.subscribeAborts.add(internalAc);
|
|
197
|
+
const effectiveSignal = signal
|
|
198
|
+
? AbortSignal.any([signal, internalAc.signal])
|
|
199
|
+
: internalAc.signal;
|
|
200
|
+
try {
|
|
201
|
+
yield* this._subscribeOnSocket(subscriptions, effectiveSignal);
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
this.subscribeAborts.delete(internalAc);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async *_subscribeOnSocket(subscriptions, signal) {
|
|
208
|
+
if (signal.aborted)
|
|
209
|
+
return;
|
|
210
|
+
const sock = this.connectFn(this.socketPath);
|
|
211
|
+
sock.setEncoding('utf8');
|
|
212
|
+
// Wait for socket connect
|
|
213
|
+
await new Promise((resolve, reject) => {
|
|
214
|
+
if (signal.aborted) {
|
|
215
|
+
sock.destroy();
|
|
216
|
+
reject(new Error('Aborted before connect'));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
sock.once('connect', resolve);
|
|
220
|
+
sock.once('error', reject);
|
|
221
|
+
});
|
|
222
|
+
if (signal.aborted) {
|
|
223
|
+
sock.destroy();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Send subscribe request and wait for subscription_started
|
|
205
227
|
const id = randomUUID();
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
228
|
+
const reqLine = JSON.stringify({ id, method: 'events.subscribe', params: { subscriptions } }) + '\n';
|
|
229
|
+
await new Promise((resolve, reject) => {
|
|
230
|
+
let startedBuffer = '';
|
|
231
|
+
const onData = (chunk) => {
|
|
232
|
+
startedBuffer += chunk;
|
|
233
|
+
const lines = startedBuffer.split('\n');
|
|
234
|
+
startedBuffer = lines.pop() ?? '';
|
|
235
|
+
for (const l of lines) {
|
|
236
|
+
const trimmed = l.trim();
|
|
237
|
+
if (!trimmed)
|
|
238
|
+
continue;
|
|
239
|
+
let msg;
|
|
240
|
+
try {
|
|
241
|
+
msg = JSON.parse(trimmed);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if ('id' in msg && msg['id'] === id) {
|
|
247
|
+
sock.removeListener('data', onData);
|
|
248
|
+
const resp = msg;
|
|
249
|
+
if (resp.error) {
|
|
250
|
+
reject(new HerdrError(resp.error.code, resp.error.message));
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
resolve();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
sock.on('data', onData);
|
|
259
|
+
sock.once('error', reject);
|
|
260
|
+
sock.write(reqLine);
|
|
213
261
|
});
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
262
|
+
if (signal.aborted) {
|
|
263
|
+
sock.destroy();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// Yield events until socket closes or signal aborts
|
|
217
267
|
const queue = [];
|
|
218
268
|
let notify = null;
|
|
219
269
|
let done = false;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
270
|
+
let streamBuf = '';
|
|
271
|
+
const onStreamData = (chunk) => {
|
|
272
|
+
streamBuf += chunk;
|
|
273
|
+
const lines = streamBuf.split('\n');
|
|
274
|
+
streamBuf = lines.pop() ?? '';
|
|
275
|
+
for (const l of lines) {
|
|
276
|
+
const trimmed = l.trim();
|
|
277
|
+
if (!trimmed)
|
|
278
|
+
continue;
|
|
279
|
+
let msg;
|
|
280
|
+
try {
|
|
281
|
+
msg = JSON.parse(trimmed);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if ('event' in msg) {
|
|
287
|
+
queue.push(msg);
|
|
288
|
+
notify?.();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
223
291
|
};
|
|
224
|
-
this.eventListeners.push(listener);
|
|
225
292
|
const cleanup = () => {
|
|
293
|
+
if (done)
|
|
294
|
+
return;
|
|
226
295
|
done = true;
|
|
227
|
-
|
|
296
|
+
sock.removeListener('data', onStreamData);
|
|
228
297
|
notify?.();
|
|
229
298
|
};
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
subscribedSocket?.once('close', cleanup);
|
|
299
|
+
sock.on('data', onStreamData);
|
|
300
|
+
sock.once('close', cleanup);
|
|
301
|
+
sock.once('error', cleanup);
|
|
302
|
+
signal.addEventListener('abort', cleanup, { once: true });
|
|
235
303
|
try {
|
|
236
304
|
while (!done) {
|
|
237
305
|
while (queue.length > 0) {
|
|
@@ -241,7 +309,6 @@ export class HerdrClient {
|
|
|
241
309
|
break;
|
|
242
310
|
await new Promise((res) => {
|
|
243
311
|
notify = res;
|
|
244
|
-
// If items arrived while we were setting notify, flush immediately
|
|
245
312
|
if (queue.length > 0 || done)
|
|
246
313
|
res();
|
|
247
314
|
});
|
|
@@ -250,8 +317,8 @@ export class HerdrClient {
|
|
|
250
317
|
}
|
|
251
318
|
finally {
|
|
252
319
|
cleanup();
|
|
253
|
-
|
|
254
|
-
signal
|
|
320
|
+
sock.destroy();
|
|
321
|
+
signal.removeEventListener('abort', cleanup);
|
|
255
322
|
}
|
|
256
323
|
}
|
|
257
324
|
/**
|