@pontia/pi-client-plugin 0.5.2 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pontia/pi-client-plugin",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Pi extension that connects coding-agent sessions to Pontia.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,225 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ SessionBeforeCompactEvent,
4
+ SessionCompactEvent,
5
+ SessionCompactFailedEvent,
6
+ CompactionResult,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { InternalEvent } from "./events.js";
9
+ import type { SessionContext } from "./session.js";
10
+ import { RpcError } from "./rpc-error.js";
11
+
12
+ type Trigger = "manual" | "threshold" | "overflow" | "unknown";
13
+ interface Operation {
14
+ id: string;
15
+ trigger: Trigger;
16
+ started?: InternalEvent;
17
+ terminal?: InternalEvent;
18
+ acknowledged: Set<string>;
19
+ reporting: Promise<void>;
20
+ awaitingNativeResult: boolean;
21
+ failureKey?: string;
22
+ }
23
+ interface State {
24
+ report: (event: InternalEvent) => Promise<boolean>;
25
+ active?: Operation;
26
+ operations: Map<string, Operation>;
27
+ signals: WeakMap<AbortSignal, Operation>;
28
+ failures: WeakSet<object>;
29
+ completed: Map<string, Operation>;
30
+ }
31
+
32
+ // Pi reloads extensions in the same process. Keep only control metadata across
33
+ // reload, so a replaced hook or reconnect cannot allocate another operation.
34
+ const stateKey = Symbol.for("pontia.pi.compaction");
35
+ const processState = globalThis as typeof globalThis & { [stateKey]?: Map<string, State> };
36
+ const states = (processState[stateKey] ??= new Map());
37
+
38
+ export class PiCompaction {
39
+ private readonly state: State;
40
+
41
+ constructor(
42
+ home: string,
43
+ private readonly context: SessionContext,
44
+ report: (event: InternalEvent) => Promise<boolean>,
45
+ ) {
46
+ const key = JSON.stringify([
47
+ home,
48
+ context.sessionId,
49
+ context.runtimeId,
50
+ context.clientSessionKey,
51
+ ]);
52
+ let state = states.get(key);
53
+ if (!state) {
54
+ state = {
55
+ report,
56
+ operations: new Map(),
57
+ signals: new WeakMap(),
58
+ failures: new WeakSet(),
59
+ completed: new Map(),
60
+ };
61
+ states.set(key, state);
62
+ }
63
+ state.report = report;
64
+ this.state = state;
65
+ }
66
+
67
+ get running(): boolean {
68
+ return !!this.state.active && !this.state.active.terminal;
69
+ }
70
+
71
+ async reconnect(): Promise<void> {
72
+ for (const operation of this.state.operations.values()) {
73
+ if (operation.started) await this.publish(operation, !operation.awaitingNativeResult);
74
+ }
75
+ }
76
+
77
+ request(operationId: string, compact: () => void): void {
78
+ if (this.state.operations.has(operationId)) return;
79
+ if (this.running) throw new RpcError(-32006, "Pi compaction is already running");
80
+ const operation = this.create("manual", operationId);
81
+ operation.awaitingNativeResult = true;
82
+ try {
83
+ compact();
84
+ } catch (error) {
85
+ this.state.operations.delete(operationId);
86
+ this.state.active = undefined;
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ async before(event: SessionBeforeCompactEvent): Promise<void> {
92
+ let operation = this.state.signals.get(event.signal);
93
+ if (!operation) {
94
+ operation = this.running ? this.state.active! : this.create(trigger(event.reason));
95
+ this.state.signals.set(event.signal, operation);
96
+ }
97
+ await this.publish(operation);
98
+ }
99
+
100
+ async completed(event: SessionCompactEvent): Promise<void> {
101
+ const entry = event.compactionEntry;
102
+ const operation = this.state.completed.get(entry.id) ?? this.current(event.reason);
103
+ this.state.completed.set(entry.id, operation);
104
+ operation.terminal ??= this.fact("session.compaction_completed", operation, {
105
+ native_compaction_id: entry.id ?? null,
106
+ tokens_before: tokens(entry.tokensBefore),
107
+ // Pi's extension event does not expose a post-compaction token count.
108
+ tokens_after: null,
109
+ });
110
+ // The manual context callback supplies Pi's estimatedTokensAfter result.
111
+ // Automatic compaction exposes only the extension event, so it stays null.
112
+ await this.publish(operation, !operation.awaitingNativeResult);
113
+ }
114
+
115
+ async failed(event: SessionCompactFailedEvent, leaf: string | null): Promise<void> {
116
+ if (this.state.failures.has(event)) return;
117
+ this.state.failures.add(event);
118
+ const failureKey = JSON.stringify([
119
+ event.reason,
120
+ event.aborted,
121
+ event.errorMessage ?? null,
122
+ leaf,
123
+ ]);
124
+ const previous = this.state.active;
125
+ const operation =
126
+ this.running || previous?.failureKey === failureKey
127
+ ? previous!
128
+ : this.create(trigger(event.reason));
129
+ operation.failureKey = failureKey;
130
+ operation.terminal ??= this.fact("session.compaction_failed", operation, {
131
+ aborted: event.aborted,
132
+ error_message: event.errorMessage ?? null,
133
+ });
134
+ operation.awaitingNativeResult = false;
135
+ await this.publish(operation);
136
+ }
137
+
138
+ // The callback covers native failures occurring before session_before_compact
139
+ // (for example, a session too small to compact). Hooks remain authoritative
140
+ // for cancellation and supply richer diagnostics when present.
141
+ async callbackError(operationId: string, error: Error): Promise<void> {
142
+ const operation = this.state.operations.get(operationId);
143
+ if (!operation) return;
144
+ operation.awaitingNativeResult = false;
145
+ operation.terminal ??= this.fact("session.compaction_failed", operation, {
146
+ aborted: false,
147
+ error_message: error.message,
148
+ });
149
+ await this.publish(operation);
150
+ }
151
+
152
+ async callbackComplete(operationId: string, result: CompactionResult): Promise<void> {
153
+ const operation = this.state.operations.get(operationId);
154
+ if (!operation) return;
155
+ operation.awaitingNativeResult = false;
156
+ operation.terminal ??= this.fact("session.compaction_completed", operation, {
157
+ native_compaction_id: null,
158
+ tokens_before: tokens(result.tokensBefore),
159
+ tokens_after: null,
160
+ });
161
+ if (
162
+ !operation.acknowledged.has(operation.terminal.type) &&
163
+ operation.terminal.type === "session.compaction_completed"
164
+ ) {
165
+ operation.terminal.data.tokens_after = tokens(result.estimatedTokensAfter);
166
+ }
167
+ await this.publish(operation);
168
+ }
169
+
170
+ private current(reason: unknown): Operation {
171
+ return this.running ? this.state.active! : this.create(trigger(reason));
172
+ }
173
+
174
+ private create(reason: Trigger, id = `compact_${randomUUID()}`): Operation {
175
+ const operation: Operation = {
176
+ id,
177
+ trigger: reason,
178
+ acknowledged: new Set(),
179
+ reporting: Promise.resolve(),
180
+ awaitingNativeResult: false,
181
+ };
182
+ this.state.operations.set(id, operation);
183
+ this.state.active = operation;
184
+ return operation;
185
+ }
186
+
187
+ private fact(
188
+ type: InternalEvent["type"],
189
+ operation: Operation,
190
+ metadata: Record<string, unknown> = {},
191
+ ): InternalEvent {
192
+ return {
193
+ session_id: this.context.sessionId,
194
+ type,
195
+ data: { operation_id: operation.id, trigger: operation.trigger, ...metadata },
196
+ };
197
+ }
198
+
199
+ private publish(operation: Operation, includeTerminal = true): Promise<void> {
200
+ operation.started ??= this.fact("session.compaction_started", operation);
201
+ operation.reporting = operation.reporting
202
+ .catch(() => {})
203
+ .then(async () => {
204
+ for (const event of [
205
+ operation.started!,
206
+ includeTerminal ? operation.terminal : undefined,
207
+ ]) {
208
+ if (!event || operation.acknowledged.has(event.type)) continue;
209
+ if (!(await this.state.report(event))) return;
210
+ operation.acknowledged.add(event.type);
211
+ }
212
+ });
213
+ return operation.reporting;
214
+ }
215
+ }
216
+
217
+ function trigger(reason: unknown): Trigger {
218
+ return reason === "manual" || reason === "threshold" || reason === "overflow"
219
+ ? reason
220
+ : "unknown";
221
+ }
222
+
223
+ function tokens(value: unknown): number | null {
224
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
225
+ }
@@ -4,7 +4,7 @@ import { readHistory, type HistoryContext } from "./history.js";
4
4
  import { createConnection, type Socket } from "node:net";
5
5
  import { isAbsolute, join } from "node:path";
6
6
 
7
- export const CONTROL_VERSION = 7;
7
+ export const CONTROL_VERSION = 8;
8
8
  export const MAX_CONTROL_FRAME_BYTES = 64 * 1024;
9
9
  // Match the former HTTP event body limit, including the RPC envelope.
10
10
  export const MAX_RPC_FRAME_BYTES = 2 * 1024 * 1024 + 1024;
@@ -33,6 +33,9 @@ export interface LifecycleControl {
33
33
  interrupt(): void;
34
34
  shutdown(): void;
35
35
  }
36
+ export interface CompactionControl {
37
+ compact(operationId: string): void;
38
+ }
36
39
  export interface PiConnection {
37
40
  request(method: string, params: object): Promise<unknown>;
38
41
  registered(identity: ControlIdentity): void;
@@ -65,6 +68,7 @@ class RpcSocket {
65
68
  private models?: ModelControl;
66
69
  private lifecycle?: LifecycleControl;
67
70
  private history?: () => HistoryContext;
71
+ private compaction?: CompactionControl;
68
72
  private onReplay?: (inboxMessageId: string) => void;
69
73
 
70
74
  constructor(
@@ -74,6 +78,7 @@ class RpcSocket {
74
78
  onReplay?: (inboxMessageId: string) => void,
75
79
  lifecycle?: LifecycleControl,
76
80
  history?: () => HistoryContext,
81
+ compaction?: CompactionControl,
77
82
  ) {
78
83
  this.socket = socket;
79
84
  this.onSubmit = onSubmit;
@@ -81,6 +86,7 @@ class RpcSocket {
81
86
  this.onReplay = onReplay;
82
87
  this.lifecycle = lifecycle;
83
88
  this.history = history;
89
+ this.compaction = compaction;
84
90
  socket.on("data", (chunk: Buffer) => this.receive(chunk));
85
91
  socket.on("error", (error) => this.fail(error));
86
92
  socket.on("close", () =>
@@ -195,6 +201,26 @@ class RpcSocket {
195
201
  respond({ result: { pong: true } });
196
202
  return;
197
203
  }
204
+ if (message.method === "compact" && this.compaction) {
205
+ if (
206
+ Object.keys(params).some((key) => key !== "operation_id") ||
207
+ typeof params.operation_id !== "string" ||
208
+ !params.operation_id.trim()
209
+ ) {
210
+ error(-32602, "compact requires an operation_id");
211
+ return;
212
+ }
213
+ try {
214
+ this.compaction.compact(params.operation_id);
215
+ respond({ result: { accepted: true } });
216
+ } catch (failure) {
217
+ error(
218
+ failure instanceof RpcError ? failure.code : -32006,
219
+ failure instanceof Error ? failure.message : String(failure),
220
+ );
221
+ }
222
+ return;
223
+ }
198
224
  if (this.lifecycle && (message.method === "interrupt" || message.method === "shutdown")) {
199
225
  try {
200
226
  this.lifecycle[message.method as "interrupt" | "shutdown"]();
@@ -310,6 +336,7 @@ export async function connectPi(
310
336
  onReplay?: (inboxMessageId: string) => void,
311
337
  lifecycle?: LifecycleControl,
312
338
  history?: () => HistoryContext,
339
+ compaction?: CompactionControl,
313
340
  ): Promise<PiConnection> {
314
341
  const path = piSocketPath(pontiaHome);
315
342
  let identity: ControlIdentity | undefined;
@@ -381,6 +408,7 @@ export async function connectPi(
381
408
  reportingOnly ? undefined : onReplay,
382
409
  reportingOnly ? undefined : lifecycle,
383
410
  reportingOnly ? undefined : history,
411
+ reportingOnly ? undefined : compaction,
384
412
  );
385
413
  peers.add(peer);
386
414
  socket.once("close", () => {
@@ -434,16 +462,6 @@ export async function connectPi(
434
462
  current = await open();
435
463
  return {
436
464
  async request(method, params) {
437
- if (method === "turn.startFailure") {
438
- if (!identity?.clientSessionKey || stopped)
439
- throw new Error("Pi reporting identity is unavailable");
440
- await ready();
441
- if (!current || stopped) throw new Error("Pi connection is unavailable");
442
- return current.request(method, {
443
- ...params,
444
- client_session_key: identity.clientSessionKey,
445
- });
446
- }
447
465
  await ready();
448
466
  if (!current || stopped) return Promise.reject(new Error("Pi connection is unavailable"));
449
467
  return current.request(method, params);
package/src/events.ts CHANGED
@@ -9,6 +9,9 @@ export type InternalEventType =
9
9
  | "session.exited"
10
10
  | "session.model_updated"
11
11
  | "session.context_usage_updated"
12
+ | "session.compaction_started"
13
+ | "session.compaction_completed"
14
+ | "session.compaction_failed"
12
15
  | "turn.started"
13
16
  | "turn.output"
14
17
  | "turn.completed"
@@ -58,6 +61,7 @@ export interface PiTopologyContext {
58
61
  export interface InternalEvent {
59
62
  session_id: string;
60
63
  turn_id?: string;
64
+ report_key?: string;
61
65
  type: InternalEventType;
62
66
  data: Record<string, unknown>;
63
67
  [legacyField: string]: any;
@@ -311,6 +315,7 @@ export function contextUsageFromPiHook(
311
315
 
312
316
  export function buildTurnStartedEvent(
313
317
  context: TurnContext,
318
+ reportKey: string,
314
319
  previousLeafId: string | null = null,
315
320
  topologyContext?: PiTopologyContext,
316
321
  ): InternalEvent {
@@ -324,7 +329,12 @@ export function buildTurnStartedEvent(
324
329
  };
325
330
  if (context.inboxMessageId) data.inbox_message_id = context.inboxMessageId;
326
331
  if (topologyContext) data.topology_context = topologyContext;
327
- return { session_id: context.sessionId, type: "turn.started", data };
332
+ return {
333
+ session_id: context.sessionId,
334
+ report_key: reportKey,
335
+ type: "turn.started",
336
+ data,
337
+ };
328
338
  }
329
339
 
330
340
  export function buildTurnOutputEvent(context: ActiveTurnContext, output: string): InternalEvent {
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { HistoryContext } from "./history.js";
4
+ import { PiCompaction } from "./compaction.js";
4
5
  import { connectPi, RpcError, type PiConnection, type ControlInput } from "./control-socket.js";
5
6
  import { defaultHookLogFile, type EnvLike, type TurnContext } from "./context.js";
6
7
  import { appendDiagnostic, type DiagnosticEntry } from "./diagnostics.js";
@@ -187,6 +188,13 @@ export function createPontiaPiExtension(
187
188
  let controlGeneration = 0;
188
189
  let piContext: ExtensionContext | undefined;
189
190
  let changingModel = false;
191
+ const compaction = () => {
192
+ const context = boundSessionContext;
193
+ if (!context || reportingDisabled || !readyReported) return undefined;
194
+ return new PiCompaction(currentPontiaHome(), context, async (event) =>
195
+ reportAccepted(await makeReporter(currentHookLogFile()).report(context, event)),
196
+ );
197
+ };
190
198
  const directInput = new AsyncLocalStorage<{
191
199
  submission: ControlInput;
192
200
  sessionContext: SessionContext;
@@ -317,6 +325,7 @@ export function createPontiaPiExtension(
317
325
  if (generation !== controlGeneration)
318
326
  throw new Error("Pi session changed during reconnect");
319
327
  await reportModel();
328
+ await compaction()?.reconnect();
320
329
  },
321
330
  },
322
331
  (inboxMessageId) => {
@@ -345,6 +354,26 @@ export function createPontiaPiExtension(
345
354
  throw new Error("Pi history native Session identity changed");
346
355
  return { generation, sessionManager: manager } satisfies HistoryContext;
347
356
  },
357
+ {
358
+ compact(operationId) {
359
+ const ctx = lifecycleContext();
360
+ if (ctx.sessionManager.getSessionId() !== boundSessionContext?.clientSessionKey)
361
+ throw new RpcError(-32006, "Pi compaction native Session identity changed");
362
+ const observation = compaction()!;
363
+ if (!ctx.isIdle() || ctx.hasPendingMessages() || queuedControlInput || changingModel)
364
+ throw new RpcError(-32006, "Pi is busy; compaction was not requested");
365
+ observation.request(operationId, () =>
366
+ ctx.compact({
367
+ onComplete: (result) => {
368
+ void observation.callbackComplete(operationId, result).catch(controlError);
369
+ },
370
+ onError: (error) => {
371
+ void observation.callbackError(operationId, error).catch(controlError);
372
+ },
373
+ }),
374
+ );
375
+ },
376
+ },
348
377
  );
349
378
  if (generation !== controlGeneration) {
350
379
  await socket.close();
@@ -519,6 +548,18 @@ export function createPontiaPiExtension(
519
548
  await reportModel().catch(controlError);
520
549
  });
521
550
 
551
+ pi.on("session_before_compact", async (event) => {
552
+ await compaction()?.before(event).catch(controlError);
553
+ });
554
+ pi.on("session_compact", async (event) => {
555
+ await compaction()?.completed(event).catch(controlError);
556
+ });
557
+ pi.on("session_compact_failed", async (event, ctx) => {
558
+ await compaction()
559
+ ?.failed(event, leafIdFromHookContext(ctx ?? piContext))
560
+ .catch(controlError);
561
+ });
562
+
522
563
  pi.on("session_start", async (event, ctx) => {
523
564
  if (reportingDisabled) return;
524
565
  const reason = (event as unknown as Record<string, unknown> | undefined)?.reason;
@@ -658,6 +699,7 @@ export function createPontiaPiExtension(
658
699
  await makeReporter(logFile).report(context, buildSessionReadyEvent(context)),
659
700
  );
660
701
  await reportModel().catch(controlError);
702
+ await compaction()?.reconnect().catch(controlError);
661
703
  } catch (error) {
662
704
  await closeControlSocket();
663
705
  const logFile = currentHookLogFile();
@@ -698,7 +740,7 @@ export function createPontiaPiExtension(
698
740
 
699
741
  pi.on("agent_start", async (_event, ctx) => {
700
742
  const topologyContext = topologyContextFromHookContext(ctx);
701
- const previousLeafId = leafIdFromHookContext(ctx);
743
+ const reportKey = leafIdFromHookContext(ctx);
702
744
  if (reportingDisabled) return;
703
745
  try {
704
746
  const dispatch = directInput.getStore();
@@ -708,6 +750,16 @@ export function createPontiaPiExtension(
708
750
  : undefined;
709
751
  if (dispatch) dispatch.consumed = true;
710
752
  const logFile = currentHookLogFile();
753
+ if (!reportKey) {
754
+ activeTurn = undefined;
755
+ pendingPrompt = undefined;
756
+ await logDiagnostic(logFile, {
757
+ level: "error",
758
+ code: "turn_start_entry_id_missing",
759
+ message: "pi agent_start has no native session entry id; turn.started was not reported",
760
+ });
761
+ return;
762
+ }
711
763
  if (!boundSessionContext) {
712
764
  const hookSessionDetails = piSessionDetailsFromHookContext(ctx);
713
765
  const sessionDetails = hookSessionDetails.clientSessionKey
@@ -774,7 +826,7 @@ export function createPontiaPiExtension(
774
826
  lastContextUsageJson = undefined;
775
827
  const started = await reporter.report(
776
828
  turnContext,
777
- buildTurnStartedEvent(turnContext, previousLeafId, topologyContext),
829
+ buildTurnStartedEvent(turnContext, reportKey, reportKey, topologyContext),
778
830
  );
779
831
  const canonicalTurnId = typeof started === "boolean" ? turnContext.turnId : started.turnId;
780
832
  if (!reportAccepted(started) || !canonicalTurnId) {
package/src/reporter.ts CHANGED
@@ -23,72 +23,52 @@ export class EventReporter {
23
23
  }
24
24
 
25
25
  async report(context: { runtimeId: string }, event: InternalEvent): Promise<EventReportResult> {
26
- try {
27
- const body = await this.connection.request("event.report", {
28
- runtime_id: context.runtimeId,
29
- event,
30
- });
31
- const record =
32
- body && typeof body === "object" ? (body as Record<string, unknown>) : undefined;
33
- if (
34
- event.type === "turn.started" &&
35
- (record?.accepted !== true || typeof record?.turn_id !== "string" || !record.turn_id)
36
- ) {
37
- await this.reportStartFailure(event, "missing_turn_id");
38
- return { accepted: false };
39
- }
40
- if (record?.accepted !== true)
41
- throw new Error("Pi event RPC returned an invalid acknowledgement");
42
- return {
43
- accepted: true,
44
- eventId: typeof record.event_id === "string" ? record.event_id : undefined,
45
- turnId: typeof record.turn_id === "string" ? record.turn_id : undefined,
46
- };
47
- } catch (error) {
48
- await appendDiagnostic(this.logFile, {
49
- level: "error",
50
- code: "pi_event_report_failed",
51
- message: `Pi event RPC failed for ${event.type}`,
52
- details: {
53
- event_type: event.type,
54
- error: error instanceof Error ? error.message : String(error),
55
- },
56
- });
57
- await this.reportStartFailure(
58
- event,
59
- error instanceof RpcError ? "event_rejected" : "transport_failed",
60
- );
61
- return { accepted: false };
62
- }
63
- }
26
+ const attempts = event.type === "turn.started" ? 3 : 1;
27
+ let lastError: unknown;
64
28
 
65
- private async reportStartFailure(
66
- event: InternalEvent,
67
- reason: "event_rejected" | "transport_failed" | "missing_turn_id",
68
- ): Promise<void> {
69
- if (event.type !== "turn.started" || typeof event.data.runtime_id !== "string") return;
70
- // A lost turn.started response is ambiguous: retrying it could create a
71
- // second Turn. Only this fenced, idempotent failure notification is retried.
72
- for (let attempt = 0; attempt < 3; attempt++) {
29
+ for (let attempt = 0; attempt < attempts; attempt++) {
73
30
  if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
74
31
  try {
75
- const result = (await this.connection.request("turn.startFailure", {
76
- session_id: event.session_id,
77
- runtime_id: event.data.runtime_id,
78
- reason,
79
- })) as { accepted?: unknown } | null;
80
- if (result?.accepted === true) return;
32
+ const body = await this.connection.request("event.report", {
33
+ runtime_id: context.runtimeId,
34
+ event,
35
+ });
36
+ const record =
37
+ body && typeof body === "object" ? (body as Record<string, unknown>) : undefined;
38
+ if (record?.accepted !== true) {
39
+ if (record?.accepted === false) {
40
+ lastError = new Error("Pi event report was rejected");
41
+ break;
42
+ }
43
+ throw new Error("Pi event RPC returned an invalid acknowledgement");
44
+ }
45
+ if (
46
+ event.type === "turn.started" &&
47
+ (typeof record.turn_id !== "string" || !record.turn_id)
48
+ ) {
49
+ throw new Error("Pi turn.started acknowledgement has no canonical turn_id");
50
+ }
51
+ return {
52
+ accepted: true,
53
+ eventId: typeof record.event_id === "string" ? record.event_id : undefined,
54
+ turnId: typeof record.turn_id === "string" ? record.turn_id : undefined,
55
+ };
81
56
  } catch (error) {
57
+ lastError = error;
82
58
  if (error instanceof RpcError && error.code !== -32603) break;
83
- // Exhaustion is visible locally; an unreachable server cannot persist
84
- // a state transition. Never claim that this notification succeeded.
85
59
  }
86
60
  }
61
+
87
62
  await appendDiagnostic(this.logFile, {
88
63
  level: "error",
89
- code: "turn_start_failure_report_failed",
90
- message: "could not persist turn.started reporting failure; workflow state is unconfirmed",
91
- details: { session_id: event.session_id, reason },
64
+ code: "pi_event_report_failed",
65
+ message: `Pi event RPC failed for ${event.type}`,
66
+ details: {
67
+ event_type: event.type,
68
+ report_key: event.report_key,
69
+ error: lastError instanceof Error ? lastError.message : String(lastError),
70
+ },
92
71
  });
72
+ return { accepted: false };
93
73
  }
94
74
  }