@dorokuma/herdsman-pi 0.6.3 → 0.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorokuma/herdsman-pi",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Pi extension bridge for Herdsman agent history. Forked from @ryonakae/shepherd.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,4 +1,5 @@
1
1
  import { createConnection, type Socket } from "node:net";
2
+ import { JsonLineDecoder } from "@/shared/json-lines.js";
2
3
 
3
4
  export type AgentEventWireRecord = {
4
5
  agentId?: string | null;
@@ -99,8 +100,9 @@ export class ReconnectingDaemonClient {
99
100
  readonly #pending = new Map<string, PendingRequest>();
100
101
  readonly #reconnectDelaysMs: readonly number[];
101
102
  readonly #socketPath: string;
102
- #buffer = "";
103
+ #decoder = new JsonLineDecoder();
103
104
  #generation = 0;
105
+ #incompatible = false;
104
106
  #nextId = 1;
105
107
  #reconnectAttempt = 0;
106
108
  #reconnectTimer: ReturnType<typeof setTimeout> | undefined;
@@ -116,6 +118,13 @@ export class ReconnectingDaemonClient {
116
118
  queueMicrotask(() => this.#connect());
117
119
  }
118
120
 
121
+ resetForSession(): void {
122
+ if (this.#state === "closed") return;
123
+ this.#incompatible = false;
124
+ this.#reconnectAttempt = 0;
125
+ this.#connect();
126
+ }
127
+
119
128
  close(): void {
120
129
  if (this.#state === "closed") return;
121
130
  this.#state = "closed";
@@ -143,11 +152,11 @@ export class ReconnectingDaemonClient {
143
152
  }
144
153
 
145
154
  #connect(): void {
146
- if (this.#state === "closed" || this.#state === "connecting") return;
155
+ if (this.#state === "closed" || this.#incompatible) return;
147
156
  this.#state = "connecting";
148
157
  this.#generation += 1;
149
158
  const generation = this.#generation;
150
- this.#buffer = "";
159
+ this.#decoder = new JsonLineDecoder();
151
160
  const socket = createConnection(this.#socketPath);
152
161
  this.#socket = socket;
153
162
  let disconnected = false;
@@ -177,11 +186,12 @@ export class ReconnectingDaemonClient {
177
186
  }
178
187
 
179
188
  #handleDisconnect(error: Error, generation: number): void {
180
- if (generation !== this.#generation || this.#state === "closed") return;
189
+ if (this.#state === "closed" || this.#incompatible) return;
181
190
  this.#state = "idle";
182
191
  this.#socket = undefined;
183
192
  this.#rejectAll(error);
184
193
  this.onDisconnected?.(error);
194
+ if (this.#incompatible) return;
185
195
  const delay =
186
196
  this.#reconnectDelaysMs[
187
197
  Math.min(this.#reconnectAttempt, this.#reconnectDelaysMs.length - 1)
@@ -194,31 +204,39 @@ export class ReconnectingDaemonClient {
194
204
  }
195
205
 
196
206
  #handleData(chunk: string): void {
197
- this.#buffer += chunk;
198
- let newline = this.#buffer.indexOf("\n");
199
- while (newline >= 0) {
200
- const line = this.#buffer.slice(0, newline).trim();
201
- this.#buffer = this.#buffer.slice(newline + 1);
202
- newline = this.#buffer.indexOf("\n");
203
- if (!line) continue;
204
- const message = JSON.parse(line) as RpcMessage;
205
- if (
206
- message.method === "agent.event" ||
207
- message.method === "agent.context.changed" ||
208
- message.method === "agent.orchestrator.changed"
209
- ) {
210
- this.onStreamMessage?.(message as DaemonStreamMessage);
211
- continue;
212
- }
213
- if (message.id === undefined) continue;
214
- const pending = this.#pending.get(String(message.id));
215
- if (!pending) continue;
216
- this.#pending.delete(String(message.id));
217
- if (message.error) pending.reject(new Error(message.error.message ?? "Herdsman RPC failed"));
218
- else pending.resolve(message.result);
207
+ for (const message of this.#decoder.push(chunk)) {
208
+ this.#handleMessage(message as RpcMessage);
219
209
  }
220
210
  }
221
211
 
212
+ #handleMessage(message: RpcMessage): void {
213
+ if (
214
+ message.method === "agent.event" ||
215
+ message.method === "agent.context.changed" ||
216
+ message.method === "agent.orchestrator.changed"
217
+ ) {
218
+ this.onStreamMessage?.(message as DaemonStreamMessage);
219
+ return;
220
+ }
221
+ if (message.id === undefined) return;
222
+ const pending = this.#pending.get(String(message.id));
223
+ if (!pending) return;
224
+ this.#pending.delete(String(message.id));
225
+ if (message.error) {
226
+ const error = new Error(message.error.message ?? "Herdsman RPC failed");
227
+ pending.reject(error);
228
+ if (/unknown|not found|unsupported|method/i.test(error.message)) {
229
+ const incompatible = new Error("Herdsman daemon version is incompatible; restart the session");
230
+ this.#incompatible = true;
231
+ this.#state = "idle";
232
+ this.#socket?.destroy();
233
+ this.#socket = undefined;
234
+ this.#rejectAll(incompatible);
235
+ this.onDisconnected?.(incompatible);
236
+ }
237
+ } else pending.resolve(message.result);
238
+ }
239
+
222
240
  #rejectAll(error: Error): void {
223
241
  for (const pending of this.#pending.values()) pending.reject(error);
224
242
  this.#pending.clear();
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ export type HerdsmanDaemonClient = {
56
56
  onConnected: (() => Promise<void> | void) | undefined;
57
57
  onDisconnected: ((error: Error) => void) | undefined;
58
58
  onStreamMessage: ((message: DaemonStreamMessage) => void) | undefined;
59
+ resetForSession?(): void;
59
60
  request(method: string, params: unknown): Promise<unknown>;
60
61
  };
61
62
 
@@ -352,11 +353,10 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
352
353
  const loseRole = (ctx: PiContext | undefined, options: { abort?: boolean } = {}) => {
353
354
  if (state.deliveredBatch) {
354
355
  state.deliveredBatch.invalidated = true;
355
- const lastEventId = state.deliveredBatch.events.at(-1)?.id;
356
- if (lastEventId !== undefined) {
356
+ if (options.abort) {
357
357
  state.failedWakeThroughEventId = Math.max(
358
358
  state.failedWakeThroughEventId,
359
- lastEventId,
359
+ ...state.deliveredBatch.events.map((event) => event.id),
360
360
  );
361
361
  }
362
362
  if (
@@ -571,6 +571,11 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
571
571
  })
572
572
  .catch((error) => {
573
573
  state.connected = false;
574
+ const incompatibleMessage =
575
+ error instanceof Error && /incompatible/i.test(error.message)
576
+ ? error.message
577
+ : undefined;
578
+ if (incompatibleMessage) ctx.ui.notify?.(incompatibleMessage, "error");
574
579
  markDisconnected(ctx);
575
580
  throw error;
576
581
  })
@@ -645,6 +650,7 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
645
650
  }
646
651
  state.client?.close();
647
652
  const client = options.clientFactory?.() ?? new ReconnectingDaemonClient({ socketPath: defaultSocketPath() });
653
+ client.resetForSession?.();
648
654
  state.client = client;
649
655
  client.onConnected = () => registerPresence(ctx);
650
656
  client.onDisconnected = () => {
@@ -731,13 +737,6 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
731
737
  const stillOwner =
732
738
  state.isOrchestrator && state.currentScope?.terminalId === batch.ownerTerminalId;
733
739
  const failBatch = () => {
734
- const lastEventId = batch.events.at(-1)?.id;
735
- if (lastEventId !== undefined) {
736
- state.failedWakeThroughEventId = Math.max(
737
- state.failedWakeThroughEventId,
738
- lastEventId,
739
- );
740
- }
741
740
  ctx.ui.notify?.(
742
741
  "Herdsman couldn’t acknowledge agent updates · updates remain pending",
743
742
  "warning",
@@ -773,11 +772,9 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
773
772
  "Herdsman couldn’t acknowledge agent updates · updates remain pending",
774
773
  "warning",
775
774
  );
776
- const failedEventId = event.id;
777
- state.failedWakeThroughEventId = Math.max(
778
- state.failedWakeThroughEventId,
779
- failedEventId,
780
- );
775
+ // Keep the event pending so a later delivery can retry it. A server-side
776
+ // rejection may mean the pane was invalidated; advancing the wake cursor
777
+ // here would make a reconnect skip a still-pending event.
781
778
  break;
782
779
  }
783
780
  }