pi-goal-list-loop-audit 0.29.23 → 0.30.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.
@@ -225,6 +225,72 @@ let extensionApiStale = false;
225
225
  * here stranded goals until manual /goal resume (hegemon/sraaal shape).
226
226
  * sendContinuation's extensionApiStale guard already stops further sends
227
227
  * in this doomed process; the next fresh session auto-resumes. */
228
+ /** v0.30.0: rebind-first session-replacement survival. pi's sanctioned
229
+ * pattern (docs/extensions.md lifecycle + the stale error text itself):
230
+ * session_shutdown → cleanup, session_start → re-establish with the NEW
231
+ * ctx. glla used to treat every stale handle as terminal ("run /reload"),
232
+ * but three replacement shapes need three responses:
233
+ * (a) switch (resume/new/fork): pi rebinds THIS module to the new
234
+ * session — session_start delivers a fresh ctx. No user action, no
235
+ * warning; reset the stale flag via a re-probe and continue.
236
+ * (b) /reload: pi re-imports the extension modules — a SUCCESSOR
237
+ * instance owns this cwd in the same process. The old module stands
238
+ * down silently (owner-file check) instead of screaming + injecting
239
+ * /reload (v0.29.22's injection is right for orphans, wrong here).
240
+ * (c) orphan: the session died with NO replacement (hegemon 2026-07-31:
241
+ * handle dead ~06:03, zero ledger events for 5h). Only a rebuild
242
+ * revives extension function — goStaleTerminal's warning + self-heal
243
+ * stays for this case ONLY.
244
+ * session_shutdown is now ledgered with pi's reason, so the next
245
+ * unexplained disposal is attributable from the ledger alone. */
246
+ const SESSION_REBIND_GRACE_MS = 60_000;
247
+ let sessionReplacementUntil = 0;
248
+ const instanceStartedAt = Date.now();
249
+ const instanceId = `${process.pid}:${instanceStartedAt}`;
250
+ let zombieStoodDown = false;
251
+
252
+ function ownerFilePath(cwd: string): string {
253
+ return path.join(cwd, ".pi-glla", "owner.json");
254
+ }
255
+
256
+ function writeOwnerFile(cwd: string): void {
257
+ try {
258
+ fs.mkdirSync(path.join(cwd, ".pi-glla"), { recursive: true });
259
+ fs.writeFileSync(ownerFilePath(cwd), JSON.stringify({ instanceId, pid: process.pid, at: Date.now() }));
260
+ } catch {
261
+ /* owner file is advisory — never block activation on it */
262
+ }
263
+ }
264
+
265
+ function readOwnerFile(cwd: string): { instanceId?: string; pid?: number; at?: number } | null {
266
+ try {
267
+ return JSON.parse(fs.readFileSync(ownerFilePath(cwd), "utf8")) as { instanceId?: string; pid?: number; at?: number };
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ /** A stale probe is terminal only for ORPHANS. Returns true when the
274
+ * stale sighting was absorbed (a rebind window is open, or a successor
275
+ * instance owns this cwd and we stand down silently), false when the
276
+ * caller should go terminal (orphan — no replacement came). */
277
+ function absorbStaleIfSuperseded(ctx: ExtensionContext): boolean {
278
+ if (Date.now() < sessionReplacementUntil) {
279
+ appendLedger(ctx.cwd, "stale_awaiting_rebind", {});
280
+ return true;
281
+ }
282
+ const owner = readOwnerFile(ctx.cwd);
283
+ if (owner && owner.pid === process.pid && typeof owner.instanceId === "string" && owner.instanceId !== instanceId) {
284
+ appendLedger(ctx.cwd, "zombie_stood_down", { owner: owner.instanceId });
285
+ zombieStoodDown = true;
286
+ extensionApiStale = true; // silence the send paths WITHOUT the terminal theatre
287
+ clearLoopTimer();
288
+ if (continuationTimer) { clearTimeout(continuationTimer); continuationTimer = null; }
289
+ return true;
290
+ }
291
+ return false;
292
+ }
293
+
228
294
  function goStaleTerminal(ctx: ExtensionContext, where: string): void {
229
295
  if (extensionApiStale) return; // already terminal — don't re-spam
230
296
  extensionApiStale = true;
@@ -325,6 +391,13 @@ function probeExtensionApiStale(): boolean {
325
391
  * and must NOT claim work started (S3's "created — starting now" lie). */
326
392
  function warnIfStaleAtEntry(ctx: ExtensionContext, what: string): boolean {
327
393
  if (!probeExtensionApiStale()) return false;
394
+ // v0.30.0: a successor may already own this session (e.g. /reload
395
+ // re-imported the modules) — the user's command belongs to the fresh
396
+ // instance; say so softly instead of demanding a reload.
397
+ if (absorbStaleIfSuperseded(ctx)) {
398
+ ctx.ui.notify(`glla: a refreshed instance owns this session — ${what} is handled there; nothing to do.`, "info");
399
+ return true;
400
+ }
328
401
  appendLedger(ctx.cwd, "extension_api_stale", { where: `entry probe (${what})` });
329
402
  ctx.ui.notify(
330
403
  `glla: this session's extension handle is stale (pi session replacement) — ${what} can't send continuations in this process. State is safe in .pi-glla/ — run /reload (extensions rebuild in place), then /glla resume. Restart pi only if /reload fails.`,
@@ -693,6 +766,7 @@ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
693
766
  }
694
767
 
695
768
  function heartbeatTick(): void {
769
+ if (zombieStoodDown) return; // v0.30.0: a superseded instance stays silent forever
696
770
  const ctx = freshCtx();
697
771
  if (!ctx) return;
698
772
  let idle = false;
@@ -717,7 +791,12 @@ function heartbeatTick(): void {
717
791
  // on: refiring into a dead process is misleading, and worse, the stall
718
792
  // escalation would PAUSE the goal — silently cancelling the
719
793
  // interruptedAt → hold-on-restart promise the footer shows.
720
- if (probeExtensionApiStale()) { goStaleTerminal(ctx, "heartbeat probe"); return; }
794
+ if (probeExtensionApiStale()) {
795
+ // v0.30.0: stale ≠ terminal — absorb the rebind-window and
796
+ // successor-instance cases; only orphans go terminal.
797
+ if (!absorbStaleIfSuperseded(ctx)) goStaleTerminal(ctx, "heartbeat probe");
798
+ return;
799
+ }
721
800
  // v0.29.16: zombie-run watchdog. pi reports BUSY (a run is "active") but
722
801
  // zero stream events for 20 min = the provider stream hung silently —
723
802
  // queued continuations can't land, and every other watchdog stays quiet
@@ -5493,12 +5572,43 @@ export default function (pi: ExtensionAPI): void {
5493
5572
  }
5494
5573
  });
5495
5574
 
5575
+ pi.on("session_shutdown", async (event: any, ctx: ExtensionContext) => {
5576
+ if (isForeignCtx(ctx)) return;
5577
+ // v0.30.0: attribution + rebind window. pi announces WHY the session
5578
+ // is being replaced (reload/resume/new/fork/quit) — the ledger can
5579
+ // now answer "what killed the handle?" without guesswork (hegemon's
5580
+ // 5-hour orphan silence 2026-07-31 was unattributable). The window
5581
+ // tells the stale probe that a rebind (session_start) is imminent.
5582
+ const shutdownReason = typeof event?.reason === "string" ? event.reason : "unknown";
5583
+ appendLedger(ctx.cwd, "session_shutdown", { reason: shutdownReason });
5584
+ sessionReplacementUntil = Date.now() + SESSION_REBIND_GRACE_MS;
5585
+ });
5586
+
5496
5587
  pi.on("session_start", async (event: any, ctx: ExtensionContext) => {
5497
5588
  rememberCtx(ctx);
5498
5589
  // v0.23.8: subagent sessions (pi-subagents binds extensions there too)
5499
5590
  // are workers — never run the restore gate or reschedule the loop from
5500
5591
  // a foreign session.
5501
5592
  if (isForeignCtx(ctx)) return;
5593
+ // v0.30.0: rebind bookkeeping — claim ownership, close any replacement
5594
+ // window, and reset a stale flag left over from the PREVIOUS session's
5595
+ // invalidation. pi rebinds THIS module to the new session (switch) or
5596
+ // re-imports it (/reload — fresh module, flag already false); either
5597
+ // way the fresh ctx makes the old poison flag wrong. Re-probe to
5598
+ // confirm the new handle actually works.
5599
+ writeOwnerFile(ctx.cwd);
5600
+ sessionReplacementUntil = 0;
5601
+ zombieStoodDown = false;
5602
+ const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
5603
+ appendLedger(ctx.cwd, "session_rebound", { reason: startReason });
5604
+ if (extensionApiStale) {
5605
+ extensionApiStale = false; // fresh ctx delivered — re-probe
5606
+ const stillStale = probeExtensionApiStale();
5607
+ appendLedger(ctx.cwd, "stale_flag_reset_on_rebind", { reason: startReason, stillStale });
5608
+ if (stillStale) {
5609
+ ctx.ui.notify("glla: session rebound but the extension handle is still stale — run /reload (extensions rebuild in place), then /glla resume.", "warning");
5610
+ }
5611
+ }
5502
5612
  state = readState(ctx.cwd);
5503
5613
  // v0.28.14: snapshot carryover BEFORE any restore logic mutates state —
5504
5614
  // a paused goal, waiting list items, or a loop that was live/held when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.29.23",
3
+ "version": "0.30.0",
4
4
  "description": "Goal. Loop. Audit. Done. \u2014 a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor \u2014 only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",