@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 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`) creates a temporary herdr workspace, sends a
86
- rate-limit banner to its root pane, runs the daemon against it, and asserts the daemon detects the
87
- banner. It skips gracefully if no herdr socket is found.
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 clients (separate sockets: one for RPC, one for event subscription)
105
+ // Construct client
106
106
  const client = new HerdrClient({ socketPath });
107
- const subscribeClient = new HerdrClient({ socketPath });
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, subscribeClient = 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;
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 = subscribeClient.subscribe(subs, innerSignal);
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
- log(`${paneId ?? 'unknown'} — pane created, resubscribing`);
223
- innerAc.abort();
224
- break;
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
- * Connects to the herdr UNIX socket, sends JSON-RPC-style requests,
5
- * and exposes subscription streams as async iterators.
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
- onReconnect?: () => void;
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
- * The iterator ends when `signal` is aborted or the client is destroyed.
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
- * Connects to the herdr UNIX socket, sends JSON-RPC-style requests,
5
- * and exposes subscription streams as async iterators.
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
- onReconnect;
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
- // Connection management
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
- this.reconnectDelay = 1000;
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.socket?.destroy();
130
- this.socket = null;
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.pendingRequests.clear();
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 (!this.socket || this.socket.destroyed) {
142
- reject(new Error('Not connected'));
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 line = JSON.stringify({ id, method, params }) + '\n';
147
- this.pendingRequests.set(id, {
148
- resolve: (v) => resolve(v),
149
- reject,
150
- });
151
- this.socket.write(line, (err) => {
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
- * The iterator ends when `signal` is aborted or the client is destroyed.
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 (!this.socket || this.socket.destroyed) {
203
- throw new Error('Not connected');
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 line = JSON.stringify({ id, method: 'events.subscribe', params: { subscriptions } }) + '\n';
207
- // Wait for subscription_started
208
- const started = new Promise((resolve, reject) => {
209
- this.pendingRequests.set(id, {
210
- resolve: () => resolve(),
211
- reject,
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
- this.socket.write(line);
215
- await started;
216
- // Now yield events until aborted
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
- const listener = (ev) => {
221
- queue.push(ev);
222
- notify?.();
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
- this.eventListeners = this.eventListeners.filter((l) => l !== listener);
296
+ sock.removeListener('data', onStreamData);
228
297
  notify?.();
229
298
  };
230
- signal?.addEventListener('abort', cleanup, { once: true });
231
- // Capture the socket active at subscribe time. If it closes (reconnect),
232
- // terminate this generator so the caller can resubscribe on the new socket.
233
- const subscribedSocket = this.socket;
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
- subscribedSocket?.removeListener('close', cleanup);
254
- signal?.removeEventListener('abort', cleanup);
320
+ sock.destroy();
321
+ signal.removeEventListener('abort', cleanup);
255
322
  }
256
323
  }
257
324
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tigorhutasuhut/herdr-claude-retry",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Herdr daemon — monitor Claude CLI sessions and auto-retry on rate-limit and errors",
5
5
  "type": "module",
6
6
  "license": "MIT",