pi-goal-list-loop-audit 0.34.16 → 0.34.18

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.
@@ -583,6 +583,11 @@ let lastCtx: ExtensionContext | null = null;
583
583
  // timer or late event that reaches the old module must stand down until the
584
584
  // fresh session_start rebinds it.
585
585
  let sessionHandoffPending = false;
586
+ // v0.34.18: pi's initial `session_start` fires before interactive mode
587
+ // renders the selected transcript. A plain `pi` startup is also a fresh,
588
+ // empty session; do not let project-scoped autoResume launch work into that
589
+ // placeholder before the user has loaded a real session.
590
+ let initialSessionLoadPending = false;
586
591
  const sessionTimeouts = new Set<NodeJS.Timeout>();
587
592
  // v0.23.8: the session that OWNS the loop (its sessionManager). Subagent
588
593
  // sessions (pi-subagents binds extensions there too) fire our handlers
@@ -590,6 +595,32 @@ const sessionTimeouts = new Set<NodeJS.Timeout>();
590
595
  // subagent ctx would silently kill the heartbeat/wedge machinery).
591
596
  let ownerSession: unknown = null;
592
597
 
598
+ function sessionHasConversation(ctx: ExtensionContext): boolean | undefined {
599
+ try {
600
+ const manager = ctx.sessionManager as unknown as {
601
+ buildSessionContext?: () => { messages?: unknown[] };
602
+ };
603
+ if (typeof manager.buildSessionContext !== "function") return undefined;
604
+ return (manager.buildSessionContext().messages?.length ?? 0) > 0;
605
+ } catch {
606
+ // Older pi contexts and test doubles may not expose the session reader;
607
+ // preserve their existing behavior rather than guessing that they are blank.
608
+ return undefined;
609
+ }
610
+ }
611
+
612
+ function isBlankInitialStartup(ctx: ExtensionContext, reason: string): boolean {
613
+ if (reason !== "startup" && reason !== "unknown") return false;
614
+ return sessionHasConversation(ctx) === false;
615
+ }
616
+
617
+ function releaseInitialSessionLoadBarrier(): void {
618
+ initialSessionLoadPending = false;
619
+ // The factory starts the heartbeat, but a future pi/context may not. This
620
+ // is idempotent and makes an explicit /goal resume or /loop start usable.
621
+ startHeartbeat();
622
+ }
623
+
593
624
  function rememberCtx(ctx: ExtensionContext): void {
594
625
  let ownerLive = false;
595
626
  if (ownerSession && lastCtx) {
@@ -927,7 +958,7 @@ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
927
958
  }
928
959
 
929
960
  function heartbeatTick(): void {
930
- if (zombieStoodDown) return; // v0.30.0: a superseded instance stays silent forever
961
+ if (zombieStoodDown || initialSessionLoadPending) return; // blank startup waits for pi to bind a real session
931
962
  const ctx = freshCtx();
932
963
  if (!ctx) return;
933
964
  let idle = false;
@@ -1191,6 +1222,7 @@ function scheduleSessionTimeout(callback: () => void, delayMs: number): NodeJS.T
1191
1222
 
1192
1223
  function clearSessionOwnedTimers(): void {
1193
1224
  sessionHandoffPending = true;
1225
+ initialSessionLoadPending = false;
1194
1226
  clearContinuationTimer();
1195
1227
  clearLoopTimer();
1196
1228
  if (queueStuckProbe) { clearTimeout(queueStuckProbe); queueStuckProbe = null; }
@@ -1208,7 +1240,7 @@ function isActionableGoal(): boolean {
1208
1240
  }
1209
1241
 
1210
1242
  function freshCtx(): ExtensionContext | null {
1211
- if (sessionHandoffPending) return null;
1243
+ if (sessionHandoffPending || initialSessionLoadPending) return null;
1212
1244
  // A captured ctx throws "stale" after session replacement. Probe cheaply;
1213
1245
  // on stale, drop it and wait for the next event to hand us a fresh one.
1214
1246
  if (!lastCtx) return null;
@@ -1253,7 +1285,7 @@ function armQueueStuckProbe(sentAt: number): void {
1253
1285
  }
1254
1286
 
1255
1287
  function scheduleContinuation(ctx: ExtensionContext, force = false, delayMs?: number): void {
1256
- if (sessionHandoffPending) return;
1288
+ if (sessionHandoffPending || initialSessionLoadPending) return;
1257
1289
  abortedStandDown = false; // v0.29.5: any explicit schedule ends the stand-down
1258
1290
  if (!isActionableGoal()) return;
1259
1291
  rememberCtx(ctx);
@@ -1271,7 +1303,7 @@ function scheduleContinuation(ctx: ExtensionContext, force = false, delayMs?: nu
1271
1303
  }
1272
1304
 
1273
1305
  function sendContinuation(goalId: string): void {
1274
- if (sessionHandoffPending) return;
1306
+ if (sessionHandoffPending || initialSessionLoadPending) return;
1275
1307
  continuationTimer = null;
1276
1308
  continuationScheduledFor = null;
1277
1309
  if (!isActionableGoal()) return;
@@ -1321,7 +1353,7 @@ function sendContinuation(goalId: string): void {
1321
1353
  // what closes the turn: complete_goal if done, pause_goal if blocked, a tool
1322
1354
  // call otherwise. display: true — the user should see the warning too.
1323
1355
  function sendStallEscalation(ctx: ExtensionContext, nudges: number): void {
1324
- if (sessionHandoffPending || !extensionApi || extensionApiStale) return;
1356
+ if (sessionHandoffPending || initialSessionLoadPending || !extensionApi || extensionApiStale) return;
1325
1357
  const remaining = HEARTBEAT_MAX_NUDGES - nudges;
1326
1358
  const text = [
1327
1359
  `[STALL WARNING ${nudges}/${HEARTBEAT_MAX_NUDGES}] The last turn produced no tool calls.`,
@@ -1343,7 +1375,7 @@ function sendStallEscalation(ctx: ExtensionContext, nudges: number): void {
1343
1375
  // sendContinuation (stale api = terminal), independent of goal state —
1344
1376
  // plain sessions truncate too.
1345
1377
  function sendLengthContinue(ctx: ExtensionContext, consecutive: number): void {
1346
- if (sessionHandoffPending || !extensionApi || extensionApiStale) return;
1378
+ if (sessionHandoffPending || initialSessionLoadPending || !extensionApi || extensionApiStale) return;
1347
1379
  try {
1348
1380
  extensionApi.sendMessage({
1349
1381
  customType: GOAL_EVENT_ENTRY,
@@ -1913,6 +1945,9 @@ function listQueue(): NonNullable<State["list"]> {
1913
1945
  }
1914
1946
 
1915
1947
  function activateNextListItem(ctx: ExtensionContext, n = 1): boolean {
1948
+ // An explicit list activation is user consent even when pi initially
1949
+ // reported a blank startup context; automatic restore never reaches here.
1950
+ releaseInitialSessionLoadBarrier();
1916
1951
  // v0.28.14: one-active-thing choke point — NO call site (session_start,
1917
1952
  // completion cascade, /list next, list_activate, list-draft auto-activate)
1918
1953
  // may activate a list item over a live loop, present or future.
@@ -2100,6 +2135,7 @@ async function cmdGoal(args: string, ctx: ExtensionContext): Promise<void> {
2100
2135
  // =================================================================
2101
2136
 
2102
2137
  async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): Promise<void> {
2138
+ releaseInitialSessionLoadBarrier();
2103
2139
  // v0.28.1 (S3): probe at the creation entry — no "created — starting now"
2104
2140
  // lie in a doomed process. (The draft path's seed send has its own loud
2105
2141
  // stale handling — E6.)
@@ -2180,6 +2216,7 @@ async function cmdPause(ctx: ExtensionContext): Promise<void> {
2180
2216
  }
2181
2217
 
2182
2218
  async function cmdResume(ctx: ExtensionContext): Promise<void> {
2219
+ releaseInitialSessionLoadBarrier();
2183
2220
  // v0.34.3: /goal resume on an ACTIVE-but-idle goal re-kicks its
2184
2221
  // continuation (was: silent return — the user got NOTHING while the
2185
2222
  // widget said "active"). One-active-thing still holds: an active loop
@@ -2776,6 +2813,9 @@ function probeAutoNotify(ctx: ExtensionContext): void {
2776
2813
 
2777
2814
  function notifyExternal(ctx: ExtensionContext, message: string): void {
2778
2815
  try {
2816
+ // A stale or handoff-bound runtime has no valid pi exec API. The TUI
2817
+ // warning may still be best-effort, but never call the old pi handle.
2818
+ if (sessionHandoffPending || extensionApiStale) return;
2779
2819
  const settings = loadSettings(ctx.cwd);
2780
2820
  if (settings.notifyCmd === "off" || !extensionApi) return;
2781
2821
  const cmd = settings.notifyCmd ?? autoNotifyCmd;
@@ -2870,7 +2910,7 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
2870
2910
  }
2871
2911
 
2872
2912
  function scheduleLoopTick(ctx: ExtensionContext): void {
2873
- if (sessionHandoffPending || !isLoopActive()) return;
2913
+ if (sessionHandoffPending || initialSessionLoadPending || !isLoopActive()) return;
2874
2914
  rememberCtx(ctx);
2875
2915
  clearLoopTimer();
2876
2916
  let delay = 0;
@@ -2883,7 +2923,7 @@ function scheduleLoopTick(ctx: ExtensionContext): void {
2883
2923
  }
2884
2924
 
2885
2925
  function sendLoopTurn(): void {
2886
- if (sessionHandoffPending) return;
2926
+ if (sessionHandoffPending || initialSessionLoadPending) return;
2887
2927
  loopTimer = null;
2888
2928
  if (!isLoopActive() || !extensionApi) return;
2889
2929
  const ctx = freshCtx();
@@ -3252,6 +3292,7 @@ interface LoopConfig {
3252
3292
 
3253
3293
  /** Shared loop-start path: /loop start AND propose_loop_draft (after Confirm). */
3254
3294
  async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Promise<boolean> {
3295
+ releaseInitialSessionLoadBarrier();
3255
3296
  // branch=1 mode: scratch branch ONLY. Refuse on non-git or dirty tree —
3256
3297
  // we never mix uncommitted user work into the loop's branch.
3257
3298
  let branchName: string | undefined;
@@ -3340,6 +3381,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
3340
3381
  const rest = args.trim().slice(sub.length).trim();
3341
3382
 
3342
3383
  if (!sub || sub === "resume") {
3384
+ releaseInitialSessionLoadBarrier();
3343
3385
  // /loop with no args (or /loop resume, v0.28.22) → resume a held loop
3344
3386
  // if one is waiting; otherwise draft the loop config (metric design is
3345
3387
  // the whole game for a long-running loop; never start one blind).
@@ -4279,6 +4321,9 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
4279
4321
  const confirmedTarget = draftingTarget;
4280
4322
  draftingTarget = null;
4281
4323
  const full = p.objective.trim() + (normContract ? `\nDone when:\n${normContract}` : "");
4324
+ // The user has just confirmed this activation; release the blank-start
4325
+ // barrier before the direct goal path schedules its first continuation.
4326
+ releaseInitialSessionLoadBarrier();
4282
4327
  // v0.28.14: one-active-thing — no goal/list activation over a live loop.
4283
4328
  if (isLoopActive()) {
4284
4329
  return { content: [{ type: "text", text: "A loop is active — one active thing at a time. The user must /loop stop it before a goal or list item can activate; do not re-propose until then." }], details: {} };
@@ -5485,6 +5530,7 @@ async function cmdGllaResume(ctx: ExtensionContext): Promise<void> {
5485
5530
  // used to answer "Nothing to resume" — the resume path must name the
5486
5531
  // real recovery (/reload rebuilds extensions in place), not mislead.
5487
5532
  if (warnIfStaleAtEntry(ctx, "/glla resume")) return;
5533
+ releaseInitialSessionLoadBarrier();
5488
5534
  const g = state.goal;
5489
5535
  const goalResumable = g && g.status === "paused";
5490
5536
  const loopResumable = state.loop && !state.loop.active && state.loop.stopReason === HELD_ON_RESTORE;
@@ -6325,6 +6371,8 @@ export default function (pi: ExtensionAPI): void {
6325
6371
  if (isForeignCtx(ctx)) return;
6326
6372
  extensionApi = pi;
6327
6373
  sessionHandoffPending = false;
6374
+ const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
6375
+ initialSessionLoadPending = isBlankInitialStartup(ctx, startReason);
6328
6376
  rememberCtx(ctx);
6329
6377
  startHeartbeat();
6330
6378
  startUITicker();
@@ -6340,7 +6388,6 @@ export default function (pi: ExtensionAPI): void {
6340
6388
  staleTerminalDone = false; // v0.33.1: a rebound session must be able to go terminal AGAIN (was: one-shot for the process lifetime)
6341
6389
  postCompactResumeOwed = false; // v0.33.1: a compact from a previous session must not resync THIS one
6342
6390
  postCompactResyncPending = false;
6343
- const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
6344
6391
  appendLedger(ctx.cwd, "session_rebound", { reason: startReason });
6345
6392
  if (extensionApiStale) {
6346
6393
  extensionApiStale = false; // fresh ctx delivered — re-probe
@@ -6394,6 +6441,21 @@ export default function (pi: ExtensionAPI): void {
6394
6441
  } catch (err) {
6395
6442
  ctx.ui.notify(`glla subagent override sync failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
6396
6443
  }
6444
+ // Consume ownership markers before the startup barrier so a later loaded
6445
+ // session cannot mistake this placeholder runtime for a rebind.
6446
+ const recoveryResume = consumeRecoveryResume(ctx.cwd);
6447
+ const rebindResume = claimSessionOwnerAndDetectRebind(ctx.cwd);
6448
+ if (rebindResume) appendLedger(ctx.cwd, "rebind_resume", { pid: process.pid });
6449
+ const explicitRecovery = handoffResume || recoveryResume || rebindResume;
6450
+ if (initialSessionLoadPending && !explicitRecovery) {
6451
+ appendLedger(ctx.cwd, "session_waiting_for_load", { reason: startReason });
6452
+ ctx.ui.notify("glla: pi has not loaded a conversation yet — waiting before auto-resume. Load/resume the session, or explicitly run /goal resume or /loop start.", "info");
6453
+ return;
6454
+ }
6455
+ // An explicit lifecycle handoff/rebind is continuation consent even if
6456
+ // pi reported a blank startup context. Release the barrier before any
6457
+ // scheduling path below can observe it.
6458
+ initialSessionLoadPending = false;
6397
6459
  // v0.29.6: stacked-state auto-arbitration FIRST — one live artifact
6398
6460
  // survives before the restore gate decides hold-vs-resume for it.
6399
6461
  autoArbitrateStackedState(ctx);
@@ -6450,14 +6512,8 @@ export default function (pi: ExtensionAPI): void {
6450
6512
  persistState(ctx);
6451
6513
  appendLedger(ctx.cwd, "audit_loop_target_migrated", { from: "audit-every-iteration", to: "fix-first" });
6452
6514
  }
6453
- // v0.34.15 compatibility: consume one legacy recovery marker if an older
6454
- // process wrote it before this lifecycle-first build landed.
6455
- const recoveryResume = consumeRecoveryResume(ctx.cwd);
6456
- // v0.34.16: a same-process lifecycle handoff is explicit continuation
6457
- // debt, so it resumes independently of the cold-boot autoResume setting.
6458
- // A same-pid owner rebind is the second same-process signal.
6459
- const rebindResume = claimSessionOwnerAndDetectRebind(ctx.cwd);
6460
- if (rebindResume) appendLedger(ctx.cwd, "rebind_resume", { pid: process.pid });
6515
+ // v0.34.15 compatibility: the legacy recovery marker and v0.34.16
6516
+ // rebind marker were consumed before the startup barrier above.
6461
6517
  if (isLoopActive()) {
6462
6518
  const l = state.loop!;
6463
6519
  if (autoResume || recoveryResume || rebindResume || handoffResume) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.34.16",
3
+ "version": "0.34.18",
4
4
  "description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. An isolated extension-less auditor re-verifies every completion with raw evidence; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",