pi-oracle 0.6.8 → 0.6.9

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/CHANGELOG.md CHANGED
@@ -2,13 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- ## 0.6.9 - 2026-04-22
5
+ ## 0.6.9 - 2026-04-23
6
6
 
7
7
  ### Changed
8
8
  - updated the local pi development baseline to `@mariozechner/pi-ai` / `@mariozechner/pi-coding-agent` `0.69.0`
9
9
  - migrated published TypeBox integration metadata and source imports from `@sinclair/typebox` to `typebox` for pi `0.69.0` compatibility
10
10
  - updated the local oracle verification flow to externalize `typebox` in the extension bundle check and regenerated the npm lockfile against the current stable dependency graph
11
11
 
12
+ ### Fixed
13
+ - stopped background poller scans from touching stale pi extension contexts after session replacement or reload
14
+ - avoided consuming wake-up retry attempts when a stopped poller exits before sending its best-effort reminder
15
+
12
16
  ### Compatibility
13
17
  - reviewed the pi `0.69.0` changelog and confirmed the extension already uses explicit session-scoped objects in the relevant flows while now matching the required TypeBox 1.x package name
14
18
 
@@ -27,7 +27,24 @@ import {
27
27
  import { promoteQueuedJobs } from "./queue.js";
28
28
  import { getProjectId, getSessionId } from "./runtime.js";
29
29
 
30
- const activePollers = new Map<string, NodeJS.Timeout>();
30
+ interface OraclePollerContextSnapshot {
31
+ cwd: string;
32
+ sessionFile: string | undefined;
33
+ hasUI: boolean;
34
+ ui: ExtensionContext["ui"];
35
+ }
36
+
37
+ interface OracleActivePoller {
38
+ active: boolean;
39
+ sessionKey: string;
40
+ timer?: NodeJS.Timeout;
41
+ }
42
+
43
+ interface OraclePollerLifecycle {
44
+ isActive?: () => boolean;
45
+ }
46
+
47
+ const activePollers = new Map<string, OracleActivePoller>();
31
48
  const scansInFlight = new Set<string>();
32
49
  const POLLER_LOCK_TIMEOUT_MS = 50;
33
50
  const WAKEUP_TARGET_LEASE_KIND = "wakeup-target";
@@ -121,13 +138,21 @@ function jobCanNotifyContext(
121
138
  return job.projectId === getProjectId(cwd) && !jobHasLiveWakeupTarget(job, liveWakeupTargets);
122
139
  }
123
140
 
124
- function getJobCounts(ctx: ExtensionContext): { active: number; queued: number } {
125
- const currentSessionFile = getSessionFile(ctx);
126
- if (!currentSessionFile) return { active: 0, queued: 0 };
141
+ function snapshotPollerContext(ctx: ExtensionContext): OraclePollerContextSnapshot {
142
+ return {
143
+ cwd: ctx.cwd,
144
+ sessionFile: getSessionFile(ctx),
145
+ hasUI: ctx.hasUI,
146
+ ui: ctx.ui,
147
+ };
148
+ }
149
+
150
+ function getJobCountsForSession(sessionFile: string | undefined, cwd: string): { active: number; queued: number } {
151
+ if (!sessionFile) return { active: 0, queued: 0 };
127
152
  return listOracleJobDirs()
128
153
  .map((jobDir) => readJob(jobDir))
129
154
  .filter((job): job is NonNullable<typeof job> => Boolean(job))
130
- .filter((job) => jobMatchesContext(job, currentSessionFile, ctx.cwd))
155
+ .filter((job) => jobMatchesContext(job, sessionFile, cwd))
131
156
  .reduce(
132
157
  (counts, job) => {
133
158
  if (job.status === "queued") counts.queued += 1;
@@ -138,15 +163,19 @@ function getJobCounts(ctx: ExtensionContext): { active: number; queued: number }
138
163
  );
139
164
  }
140
165
 
141
- export function refreshOracleStatus(ctx: ExtensionContext): void {
142
- if (!getSessionFile(ctx)) {
143
- ctx.ui.setStatus("oracle", ctx.ui.theme.fg("accent", "oracle: unavailable"));
166
+ function refreshOracleStatusSnapshot(snapshot: OraclePollerContextSnapshot): void {
167
+ if (!snapshot.sessionFile) {
168
+ snapshot.ui.setStatus("oracle", snapshot.ui.theme.fg("accent", "oracle: unavailable"));
144
169
  return;
145
170
  }
146
- const counts = getJobCounts(ctx);
171
+ const counts = getJobCountsForSession(snapshot.sessionFile, snapshot.cwd);
147
172
  const statusText = buildOracleStatusText(counts);
148
173
  const tone = counts.active > 0 ? "success" : "accent";
149
- ctx.ui.setStatus("oracle", ctx.ui.theme.fg(tone, statusText));
174
+ snapshot.ui.setStatus("oracle", snapshot.ui.theme.fg(tone, statusText));
175
+ }
176
+
177
+ export function refreshOracleStatus(ctx: ExtensionContext): void {
178
+ refreshOracleStatusSnapshot(snapshotPollerContext(ctx));
150
179
  }
151
180
 
152
181
  function requestWakeupTurn(pi: ExtensionAPI, job: OraclePollerJob): void {
@@ -165,12 +194,27 @@ function requestWakeupTurn(pi: ExtensionAPI, job: OraclePollerJob): void {
165
194
  );
166
195
  }
167
196
 
168
- async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string, hooks: OraclePollerHooks = {}): Promise<void> {
169
- const currentSessionFile = getSessionFile(ctx);
170
- const pollerKey = getPollerSessionKey(currentSessionFile, ctx.cwd);
197
+ async function releaseWakeupLeaseIfInactive(leaseKey: string, lifecycle: OraclePollerLifecycle): Promise<boolean> {
198
+ if (lifecycle.isActive?.() === false) {
199
+ await releaseLease(WAKEUP_TARGET_LEASE_KIND, leaseKey).catch(() => undefined);
200
+ return true;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ async function scan(
206
+ pi: ExtensionAPI,
207
+ snapshot: OraclePollerContextSnapshot,
208
+ workerPath: string,
209
+ hooks: OraclePollerHooks = {},
210
+ lifecycle: OraclePollerLifecycle = {},
211
+ ): Promise<void> {
212
+ if (lifecycle.isActive?.() === false) return;
213
+ const currentSessionFile = snapshot.sessionFile;
214
+ const pollerKey = getPollerSessionKey(currentSessionFile, snapshot.cwd);
171
215
  const notificationClaimant = `${pollerKey}:${process.pid}`;
172
216
 
173
- const projectId = getProjectId(ctx.cwd);
217
+ const projectId = getProjectId(snapshot.cwd);
174
218
  const sessionId = getSessionId(currentSessionFile, projectId);
175
219
  const processStartedAt = readProcessStartedAt(process.pid);
176
220
  const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(pollerKey, process.pid, processStartedAt || "unknown");
@@ -183,11 +227,14 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string,
183
227
  processStartedAt,
184
228
  updatedAt: new Date().toISOString(),
185
229
  }).catch(() => undefined);
230
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
231
+
186
232
  const liveWakeupTargets = await resolveLiveWakeupTargets();
233
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
187
234
 
188
235
  try {
189
236
  await withGlobalReconcileLock(
190
- { processPid: process.pid, cwd: ctx.cwd, sessionFile: currentSessionFile, source: "poller" },
237
+ { processPid: process.pid, cwd: snapshot.cwd, sessionFile: currentSessionFile, source: "poller" },
191
238
  async () => {
192
239
  await reconcileStaleOracleJobs();
193
240
  },
@@ -196,8 +243,10 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string,
196
243
  } catch (error) {
197
244
  if (!isLockTimeoutError(error, "reconcile", "global")) throw error;
198
245
  }
246
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
199
247
 
200
248
  await promoteQueuedJobs({ workerPath, source: "poller" });
249
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
201
250
 
202
251
  const terminalJobs = listOracleJobDirs()
203
252
  .map((jobDir) => readJob(jobDir))
@@ -207,7 +256,7 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string,
207
256
  const now = Date.now();
208
257
  const candidateJobIds = terminalJobs
209
258
  .filter((job) => {
210
- if (!jobCanNotifyContext(job, currentSessionFile, ctx.cwd, liveWakeupTargets)) return false;
259
+ if (!jobCanNotifyContext(job, currentSessionFile, snapshot.cwd, liveWakeupTargets)) return false;
211
260
  if (job.notifiedAt) return false;
212
261
  if (shouldPruneTerminalJob(job, now)) return false;
213
262
  return shouldRequestWakeup(job, now);
@@ -215,27 +264,53 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string,
215
264
  .map((job) => job.id);
216
265
 
217
266
  for (const jobId of candidateJobIds) {
267
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
218
268
  await hooks.beforeNotificationClaim?.(jobId);
269
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) return;
219
270
  const claimed = await tryClaimNotification(jobId, notificationClaimant);
220
271
  if (!claimed) continue;
221
272
 
222
- await hooks.afterNotificationClaim?.(claimed);
223
- const preNotifyLiveWakeupTargets = await resolveLiveWakeupTargets();
224
- if (!jobCanNotifyContext(claimed, currentSessionFile, ctx.cwd, preNotifyLiveWakeupTargets)) {
225
- await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
226
- continue;
227
- }
228
-
229
273
  try {
274
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) {
275
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
276
+ return;
277
+ }
278
+ await hooks.afterNotificationClaim?.(claimed);
279
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) {
280
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
281
+ return;
282
+ }
283
+ const preNotifyLiveWakeupTargets = await resolveLiveWakeupTargets();
284
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) {
285
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
286
+ return;
287
+ }
288
+ if (!jobCanNotifyContext(claimed, currentSessionFile, snapshot.cwd, preNotifyLiveWakeupTargets)) {
289
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
290
+ continue;
291
+ }
292
+
230
293
  if (currentSessionFile) {
231
294
  await recordNotificationTarget(jobId, notificationClaimant, {
232
295
  notificationSessionKey: pollerKey,
233
296
  notificationSessionFile: currentSessionFile,
234
297
  });
235
298
  }
299
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) {
300
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
301
+ return;
302
+ }
236
303
  await hooks.beforeNotificationPersist?.(claimed);
304
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) {
305
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
306
+ return;
307
+ }
237
308
  const preWakeupLiveWakeupTargets = await resolveLiveWakeupTargets();
238
- if (!jobCanNotifyContext(claimed, currentSessionFile, ctx.cwd, preWakeupLiveWakeupTargets)) {
309
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) {
310
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
311
+ return;
312
+ }
313
+ if (!jobCanNotifyContext(claimed, currentSessionFile, snapshot.cwd, preWakeupLiveWakeupTargets)) {
239
314
  await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
240
315
  continue;
241
316
  }
@@ -245,16 +320,22 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string,
245
320
  continue;
246
321
  }
247
322
 
323
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) {
324
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
325
+ return;
326
+ }
327
+ requestWakeupTurn(pi, deliverable);
248
328
  const notedWakeup = await noteWakeupRequested(jobId);
249
- const deliverableAfterNote = notedWakeup ?? readJob(jobId);
250
- if (!deliverableAfterNote || shouldPruneTerminalJob(deliverableAfterNote, Date.now())) {
329
+ if (!notedWakeup) {
251
330
  await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
252
331
  continue;
253
332
  }
254
-
255
- requestWakeupTurn(pi, deliverableAfterNote);
256
- if (ctx.hasUI) {
257
- ctx.ui.notify(`Oracle job ${claimed.id} is ${claimed.status}.`, "info");
333
+ if (await releaseWakeupLeaseIfInactive(wakeupTargetLeaseKey, lifecycle)) {
334
+ await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
335
+ return;
336
+ }
337
+ if (snapshot.hasUI) {
338
+ snapshot.ui.notify(`Oracle job ${claimed.id} is ${claimed.status}.`, "info");
258
339
  }
259
340
  await releaseNotificationClaim(jobId, notificationClaimant).catch(() => undefined);
260
341
  } catch (error) {
@@ -265,40 +346,58 @@ async function scan(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string,
265
346
  }
266
347
 
267
348
  export async function scanOracleJobsOnce(pi: ExtensionAPI, ctx: ExtensionContext, workerPath: string, options: OraclePollerOptions = {}): Promise<void> {
268
- await scan(pi, ctx, workerPath, options.hooks);
349
+ await scan(pi, snapshotPollerContext(ctx), workerPath, options.hooks);
269
350
  }
270
351
 
271
352
  export function startPoller(pi: ExtensionAPI, ctx: ExtensionContext, intervalMs: number, workerPath: string, options: OraclePollerOptions = {}): void {
272
- const sessionKey = getPollerSessionKey(getSessionFile(ctx), ctx.cwd);
353
+ const snapshot = snapshotPollerContext(ctx);
354
+ const sessionKey = getPollerSessionKey(snapshot.sessionFile, snapshot.cwd);
273
355
  const existing = activePollers.get(sessionKey);
274
- if (existing) clearInterval(existing);
356
+ if (existing) {
357
+ existing.active = false;
358
+ if (existing.timer) clearInterval(existing.timer);
359
+ }
360
+
361
+ const handle: OracleActivePoller = {
362
+ active: true,
363
+ sessionKey,
364
+ };
365
+ activePollers.set(sessionKey, handle);
366
+
367
+ const isCurrentPollerActive = () => handle.active && activePollers.get(sessionKey) === handle;
275
368
 
276
369
  const runScan = async () => {
370
+ if (!isCurrentPollerActive()) return;
277
371
  if (scansInFlight.has(sessionKey)) return;
278
372
  scansInFlight.add(sessionKey);
279
373
  try {
280
- await scanOracleJobsOnce(pi, ctx, workerPath, options);
374
+ await scan(pi, snapshot, workerPath, options.hooks, { isActive: isCurrentPollerActive });
281
375
  } catch (error) {
282
- console.error(`Oracle poller scan failed (${sessionKey}):`, error);
376
+ if (isCurrentPollerActive()) {
377
+ console.error(`Oracle poller scan failed (${sessionKey}):`, error);
378
+ }
283
379
  } finally {
284
380
  scansInFlight.delete(sessionKey);
285
- refreshOracleStatus(ctx);
381
+ if (isCurrentPollerActive()) {
382
+ refreshOracleStatusSnapshot(snapshot);
383
+ }
286
384
  }
287
385
  };
288
386
 
289
- refreshOracleStatus(ctx);
387
+ refreshOracleStatusSnapshot(snapshot);
290
388
  void runScan();
291
389
  const timer = setInterval(() => {
292
390
  void runScan();
293
391
  }, intervalMs);
294
- activePollers.set(sessionKey, timer);
392
+ handle.timer = timer;
295
393
  }
296
394
 
297
395
  export function stopPollerForSession(sessionFile: string | undefined, cwd: string): void {
298
396
  const sessionKey = getPollerSessionKey(sessionFile, cwd);
299
- const timer = activePollers.get(sessionKey);
300
- if (timer) {
301
- clearInterval(timer);
397
+ const handle = activePollers.get(sessionKey);
398
+ if (handle) {
399
+ handle.active = false;
400
+ if (handle.timer) clearInterval(handle.timer);
302
401
  activePollers.delete(sessionKey);
303
402
  }
304
403
  const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
@@ -306,13 +405,14 @@ export function stopPollerForSession(sessionFile: string | undefined, cwd: strin
306
405
  }
307
406
 
308
407
  export async function stopAllPollers(): Promise<void> {
309
- const sessionKeys = [...activePollers.keys()];
310
- for (const timer of activePollers.values()) {
311
- clearInterval(timer);
408
+ const handles = [...activePollers.values()];
409
+ for (const handle of handles) {
410
+ handle.active = false;
411
+ if (handle.timer) clearInterval(handle.timer);
312
412
  }
313
413
  activePollers.clear();
314
- await Promise.all(sessionKeys.map(async (sessionKey) => {
315
- const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(sessionKey);
414
+ await Promise.all(handles.map(async (handle) => {
415
+ const wakeupTargetLeaseKey = getWakeupTargetLeaseKey(handle.sessionKey);
316
416
  await releaseLease(WAKEUP_TARGET_LEASE_KIND, wakeupTargetLeaseKey).catch(() => undefined);
317
417
  }));
318
418
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",