pi-goal-list-loop-audit 0.34.17 → 0.34.19

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.
@@ -310,17 +310,24 @@ function heldLoopLines(l: LoopState, now: number, theme?: DisplayTheme, width?:
310
310
  function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | undefined, now: number, theme?: DisplayTheme, width?: number, extras?: WidgetExtras): string[] {
311
311
  // Head glyph is ● (not ◆): U+25C6 renders as a color-emoji diamond in some
312
312
  // terminal fonts and ignores ANSI color; ● takes the paint everywhere.
313
+ const interrupted = g.status === "active" && !!g.interruptedAt;
313
314
  const icon =
314
- g.status === "paused"
315
- ? paint(theme, pauseIsError(g) ? "error" : "warning", "⏸")
316
- : g.status === "auditing"
317
- ? paint(theme, "accent", "⟡")
318
- : paint(theme, "success", "●");
315
+ interrupted
316
+ ? paint(theme, "error", "⚠")
317
+ : g.status === "paused"
318
+ ? paint(theme, pauseIsError(g) ? "error" : "warning", "⏸")
319
+ : g.status === "auditing"
320
+ ? paint(theme, "accent", "⟡")
321
+ : paint(theme, "success", "●");
319
322
  // v0.24.7: a list item is named as such and points at /list — before,
320
323
  // the widget called it "active" and hinted "/goal status", reading as if
321
324
  // queue work were a standalone goal.
322
325
  const isList = g.policy === "list";
323
- const statusWord = g.status === "active" ? paint(theme, "success", "active") : g.status;
326
+ const statusWord = interrupted
327
+ ? paint(theme, "error", "interrupted")
328
+ : g.status === "active"
329
+ ? paint(theme, "success", "active")
330
+ : g.status;
324
331
  // v0.33.0: slim card — status folds INTO the head line as middot segments
325
332
  // (filter(Boolean).join, the universal CLI idiom). Line 2 is the live
326
333
  // "last action · next task" line; the footer stays the hint line.
@@ -351,6 +358,12 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
351
358
  const objBudget = width && width > 0 ? Math.max(16, width - 1 - 2 - 3 - visibleLen(segsText)) : 48;
352
359
  const head = `${icon} ${truncate(g.objective.replace(/\s+/g, " "), objBudget)} ${paint(theme, "dim", "·")} ${segsText}`;
353
360
  const lines = [head];
361
+ if (interrupted) {
362
+ const resumeCmd = isList ? "/list resume" : "/goal resume";
363
+ lines.push(`├─ ${paint(theme, "error", "host session lost — waiting for fresh session_start")}`);
364
+ lines.push(`└─ ${paint(theme, "warning", `/reload to rebind · ${resumeCmd} if it does not resume`)}`);
365
+ return lines;
366
+ }
354
367
  if (g.status === "auditing") {
355
368
  lines.push(`├─ auditor: ${audit?.label ?? "running"}${audit?.currentTool ? ` · ${truncate(audit.currentTool, 30)}` : ""}`);
356
369
  // v0.25.4: auditor-quiet stall — progress events stopped arriving
@@ -35,6 +35,48 @@ export interface LengthContinueTick {
35
35
  consecutive: number;
36
36
  }
37
37
 
38
+ export interface ContextUsageLike {
39
+ tokens?: number | null;
40
+ contextWindow?: number;
41
+ percent?: number | null;
42
+ }
43
+
44
+ export interface AssistantLengthMessageLike {
45
+ stopReason?: string;
46
+ usage?: {
47
+ output?: number;
48
+ };
49
+ }
50
+
51
+ export const LENGTH_CONTINUE_CONTEXT_STARVED_PERCENT = 90;
52
+ export const LENGTH_CONTINUE_CONTEXT_STARVED_MAX_OUTPUT = 8;
53
+
54
+ /**
55
+ * v0.34.19: distinguish a REAL overlong assistant response from pi's
56
+ * context-safety clamp. Near the configured context ceiling,
57
+ * pi-ai's clampMaxTokensToContext() can reduce max_tokens to 1; MiniMax then
58
+ * returns stopReason "length" with ~1 output token. That is context
59
+ * starvation: auto-compaction must own recovery. Sending LENGTH_CONTINUE_TEXT
60
+ * here queues another 1-token request before pi's post-agent_end compaction
61
+ * check and delays the actual cure (field: darklord 2026-08-02, 198,116 /
62
+ * 198,179 total tokens of a 200,000 window, output=1 twice).
63
+ */
64
+ export function isContextStarvedLengthStop(
65
+ message: AssistantLengthMessageLike | null | undefined,
66
+ contextUsage: ContextUsageLike | null | undefined,
67
+ ): boolean {
68
+ if (message?.stopReason !== "length") return false;
69
+ const output = message.usage?.output;
70
+ if (typeof output !== "number" || !Number.isFinite(output)) return false;
71
+ if (output > LENGTH_CONTINUE_CONTEXT_STARVED_MAX_OUTPUT) return false;
72
+ const percent = typeof contextUsage?.percent === "number"
73
+ ? contextUsage.percent
74
+ : typeof contextUsage?.tokens === "number" && typeof contextUsage?.contextWindow === "number" && contextUsage.contextWindow > 0
75
+ ? (contextUsage.tokens / contextUsage.contextWindow) * 100
76
+ : null;
77
+ return percent !== null && Number.isFinite(percent) && percent >= LENGTH_CONTINUE_CONTEXT_STARVED_PERCENT;
78
+ }
79
+
38
80
  export function makeLengthContinueTracker(max: number = LENGTH_CONTINUE_MAX) {
39
81
  let consecutive = 0;
40
82
  let gaveUp = false;
@@ -92,6 +92,7 @@ import {
92
92
  import {
93
93
  LENGTH_CONTINUE_MAX,
94
94
  LENGTH_CONTINUE_TEXT,
95
+ isContextStarvedLengthStop,
95
96
  resetLengthContinue,
96
97
  tickLengthContinue,
97
98
  } from "../length-continue.js";
@@ -231,6 +232,11 @@ let extensionApiStale = false;
231
232
  // path must still ledger the stale handle, stop stale work, and preserve the
232
233
  // interrupt marker so a later fresh lifecycle can restore it.
233
234
  let staleTerminalDone = false;
235
+ // v0.34.19: delayed session-owned callbacks capture this generation. A
236
+ // clearTimeout can race a callback already queued by Node; without a
237
+ // generation check, an old compaction/refire callback can run after /reload
238
+ // and schedule work against the fresh session.
239
+ let sessionGeneration = 0;
234
240
 
235
241
  /** v0.26.7: a stale api is terminal for this process — go loudly with
236
242
  * restart guidance instead of retrying sends that can never land.
@@ -309,6 +315,10 @@ function goStaleTerminal(ctx: ExtensionContext, where: string): void {
309
315
  } else if (state.goal && state.goal.status === "active") {
310
316
  updateGoal({ interruptedAt: nowIso(), interruptedReason: `extension api stale (${where})` }, ctx);
311
317
  }
318
+ // The stale process loses its ticker immediately, so paint the durable
319
+ // interrupted state synchronously while the old UI handle can still accept
320
+ // updates. The next session_start paints it again from disk.
321
+ refreshUI(ctx);
312
322
  ctx.ui.notify(`glla: ${guidance}`, "warning");
313
323
  notifyExternal(ctx, `glla: extension api stale — waiting for a fresh session_start; restart pi normally only if no replacement arrives. (${where})`);
314
324
  }
@@ -583,6 +593,11 @@ let lastCtx: ExtensionContext | null = null;
583
593
  // timer or late event that reaches the old module must stand down until the
584
594
  // fresh session_start rebinds it.
585
595
  let sessionHandoffPending = false;
596
+ // v0.34.18: pi's initial `session_start` fires before interactive mode
597
+ // renders the selected transcript. A plain `pi` startup is also a fresh,
598
+ // empty session; do not let project-scoped autoResume launch work into that
599
+ // placeholder before the user has loaded a real session.
600
+ let initialSessionLoadPending = false;
586
601
  const sessionTimeouts = new Set<NodeJS.Timeout>();
587
602
  // v0.23.8: the session that OWNS the loop (its sessionManager). Subagent
588
603
  // sessions (pi-subagents binds extensions there too) fire our handlers
@@ -590,7 +605,37 @@ const sessionTimeouts = new Set<NodeJS.Timeout>();
590
605
  // subagent ctx would silently kill the heartbeat/wedge machinery).
591
606
  let ownerSession: unknown = null;
592
607
 
608
+ function sessionHasConversation(ctx: ExtensionContext): boolean | undefined {
609
+ try {
610
+ const manager = ctx.sessionManager as unknown as {
611
+ buildSessionContext?: () => { messages?: unknown[] };
612
+ };
613
+ if (typeof manager.buildSessionContext !== "function") return undefined;
614
+ return (manager.buildSessionContext().messages?.length ?? 0) > 0;
615
+ } catch {
616
+ // Older pi contexts and test doubles may not expose the session reader;
617
+ // preserve their existing behavior rather than guessing that they are blank.
618
+ return undefined;
619
+ }
620
+ }
621
+
622
+ function isBlankInitialStartup(ctx: ExtensionContext, reason: string): boolean {
623
+ if (reason !== "startup" && reason !== "unknown") return false;
624
+ return sessionHasConversation(ctx) === false;
625
+ }
626
+
627
+ function releaseInitialSessionLoadBarrier(): void {
628
+ initialSessionLoadPending = false;
629
+ // The factory starts the heartbeat, but a future pi/context may not. This
630
+ // is idempotent and makes an explicit /goal resume or /loop start usable.
631
+ startHeartbeat();
632
+ }
633
+
593
634
  function rememberCtx(ctx: ExtensionContext): void {
635
+ // Late events from a disposed session must never reclaim lastCtx after the
636
+ // lifecycle handoff has been declared. Only session_start clears these
637
+ // gates and may bind a fresh context.
638
+ if (sessionHandoffPending || staleTerminalDone || zombieStoodDown) return;
594
639
  let ownerLive = false;
595
640
  if (ownerSession && lastCtx) {
596
641
  try { lastCtx.isIdle(); ownerLive = true; } catch { /* owner went stale (session replaced) */ }
@@ -927,7 +972,7 @@ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
927
972
  }
928
973
 
929
974
  function heartbeatTick(): void {
930
- if (zombieStoodDown) return; // v0.30.0: a superseded instance stays silent forever
975
+ if (zombieStoodDown || initialSessionLoadPending) return; // blank startup waits for pi to bind a real session
931
976
  const ctx = freshCtx();
932
977
  if (!ctx) return;
933
978
  let idle = false;
@@ -1179,9 +1224,19 @@ function clearContinuationTimer(): void {
1179
1224
  }
1180
1225
 
1181
1226
  function scheduleSessionTimeout(callback: () => void, delayMs: number): NodeJS.Timeout {
1227
+ const generation = sessionGeneration;
1182
1228
  let timer: NodeJS.Timeout;
1183
1229
  timer = setTimeout(() => {
1184
1230
  sessionTimeouts.delete(timer);
1231
+ // clearTimeout is not enough when the callback is already queued. Do not
1232
+ // let an old session's callback re-arm work after stale/shutdown/reload.
1233
+ if (
1234
+ generation !== sessionGeneration ||
1235
+ sessionHandoffPending ||
1236
+ extensionApiStale ||
1237
+ staleTerminalDone ||
1238
+ zombieStoodDown
1239
+ ) return;
1185
1240
  callback();
1186
1241
  }, delayMs);
1187
1242
  sessionTimeouts.add(timer);
@@ -1191,6 +1246,8 @@ function scheduleSessionTimeout(callback: () => void, delayMs: number): NodeJS.T
1191
1246
 
1192
1247
  function clearSessionOwnedTimers(): void {
1193
1248
  sessionHandoffPending = true;
1249
+ sessionGeneration++;
1250
+ initialSessionLoadPending = false;
1194
1251
  clearContinuationTimer();
1195
1252
  clearLoopTimer();
1196
1253
  if (queueStuckProbe) { clearTimeout(queueStuckProbe); queueStuckProbe = null; }
@@ -1208,7 +1265,7 @@ function isActionableGoal(): boolean {
1208
1265
  }
1209
1266
 
1210
1267
  function freshCtx(): ExtensionContext | null {
1211
- if (sessionHandoffPending) return null;
1268
+ if (sessionHandoffPending || initialSessionLoadPending) return null;
1212
1269
  // A captured ctx throws "stale" after session replacement. Probe cheaply;
1213
1270
  // on stale, drop it and wait for the next event to hand us a fresh one.
1214
1271
  if (!lastCtx) return null;
@@ -1253,7 +1310,7 @@ function armQueueStuckProbe(sentAt: number): void {
1253
1310
  }
1254
1311
 
1255
1312
  function scheduleContinuation(ctx: ExtensionContext, force = false, delayMs?: number): void {
1256
- if (sessionHandoffPending) return;
1313
+ if (sessionHandoffPending || initialSessionLoadPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
1257
1314
  abortedStandDown = false; // v0.29.5: any explicit schedule ends the stand-down
1258
1315
  if (!isActionableGoal()) return;
1259
1316
  rememberCtx(ctx);
@@ -1271,7 +1328,7 @@ function scheduleContinuation(ctx: ExtensionContext, force = false, delayMs?: nu
1271
1328
  }
1272
1329
 
1273
1330
  function sendContinuation(goalId: string): void {
1274
- if (sessionHandoffPending) return;
1331
+ if (sessionHandoffPending || initialSessionLoadPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
1275
1332
  continuationTimer = null;
1276
1333
  continuationScheduledFor = null;
1277
1334
  if (!isActionableGoal()) return;
@@ -1321,7 +1378,7 @@ function sendContinuation(goalId: string): void {
1321
1378
  // what closes the turn: complete_goal if done, pause_goal if blocked, a tool
1322
1379
  // call otherwise. display: true — the user should see the warning too.
1323
1380
  function sendStallEscalation(ctx: ExtensionContext, nudges: number): void {
1324
- if (sessionHandoffPending || !extensionApi || extensionApiStale) return;
1381
+ if (sessionHandoffPending || initialSessionLoadPending || !extensionApi || extensionApiStale) return;
1325
1382
  const remaining = HEARTBEAT_MAX_NUDGES - nudges;
1326
1383
  const text = [
1327
1384
  `[STALL WARNING ${nudges}/${HEARTBEAT_MAX_NUDGES}] The last turn produced no tool calls.`,
@@ -1343,7 +1400,7 @@ function sendStallEscalation(ctx: ExtensionContext, nudges: number): void {
1343
1400
  // sendContinuation (stale api = terminal), independent of goal state —
1344
1401
  // plain sessions truncate too.
1345
1402
  function sendLengthContinue(ctx: ExtensionContext, consecutive: number): void {
1346
- if (sessionHandoffPending || !extensionApi || extensionApiStale) return;
1403
+ if (sessionHandoffPending || initialSessionLoadPending || !extensionApi || extensionApiStale) return;
1347
1404
  try {
1348
1405
  extensionApi.sendMessage({
1349
1406
  customType: GOAL_EVENT_ENTRY,
@@ -1913,6 +1970,9 @@ function listQueue(): NonNullable<State["list"]> {
1913
1970
  }
1914
1971
 
1915
1972
  function activateNextListItem(ctx: ExtensionContext, n = 1): boolean {
1973
+ // An explicit list activation is user consent even when pi initially
1974
+ // reported a blank startup context; automatic restore never reaches here.
1975
+ releaseInitialSessionLoadBarrier();
1916
1976
  // v0.28.14: one-active-thing choke point — NO call site (session_start,
1917
1977
  // completion cascade, /list next, list_activate, list-draft auto-activate)
1918
1978
  // may activate a list item over a live loop, present or future.
@@ -2100,6 +2160,7 @@ async function cmdGoal(args: string, ctx: ExtensionContext): Promise<void> {
2100
2160
  // =================================================================
2101
2161
 
2102
2162
  async function cmdSet(args: string, ctx: ExtensionContext, skipDraft = false): Promise<void> {
2163
+ releaseInitialSessionLoadBarrier();
2103
2164
  // v0.28.1 (S3): probe at the creation entry — no "created — starting now"
2104
2165
  // lie in a doomed process. (The draft path's seed send has its own loud
2105
2166
  // stale handling — E6.)
@@ -2180,6 +2241,7 @@ async function cmdPause(ctx: ExtensionContext): Promise<void> {
2180
2241
  }
2181
2242
 
2182
2243
  async function cmdResume(ctx: ExtensionContext): Promise<void> {
2244
+ releaseInitialSessionLoadBarrier();
2183
2245
  // v0.34.3: /goal resume on an ACTIVE-but-idle goal re-kicks its
2184
2246
  // continuation (was: silent return — the user got NOTHING while the
2185
2247
  // widget said "active"). One-active-thing still holds: an active loop
@@ -2873,7 +2935,7 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
2873
2935
  }
2874
2936
 
2875
2937
  function scheduleLoopTick(ctx: ExtensionContext): void {
2876
- if (sessionHandoffPending || !isLoopActive()) return;
2938
+ if (sessionHandoffPending || initialSessionLoadPending || extensionApiStale || staleTerminalDone || zombieStoodDown || !isLoopActive()) return;
2877
2939
  rememberCtx(ctx);
2878
2940
  clearLoopTimer();
2879
2941
  let delay = 0;
@@ -2886,7 +2948,7 @@ function scheduleLoopTick(ctx: ExtensionContext): void {
2886
2948
  }
2887
2949
 
2888
2950
  function sendLoopTurn(): void {
2889
- if (sessionHandoffPending) return;
2951
+ if (sessionHandoffPending || initialSessionLoadPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
2890
2952
  loopTimer = null;
2891
2953
  if (!isLoopActive() || !extensionApi) return;
2892
2954
  const ctx = freshCtx();
@@ -3255,6 +3317,7 @@ interface LoopConfig {
3255
3317
 
3256
3318
  /** Shared loop-start path: /loop start AND propose_loop_draft (after Confirm). */
3257
3319
  async function startLoopFromConfig(ctx: ExtensionContext, cfg: LoopConfig): Promise<boolean> {
3320
+ releaseInitialSessionLoadBarrier();
3258
3321
  // branch=1 mode: scratch branch ONLY. Refuse on non-git or dirty tree —
3259
3322
  // we never mix uncommitted user work into the loop's branch.
3260
3323
  let branchName: string | undefined;
@@ -3343,6 +3406,7 @@ async function cmdLoop(args: string, ctx: ExtensionContext): Promise<void> {
3343
3406
  const rest = args.trim().slice(sub.length).trim();
3344
3407
 
3345
3408
  if (!sub || sub === "resume") {
3409
+ releaseInitialSessionLoadBarrier();
3346
3410
  // /loop with no args (or /loop resume, v0.28.22) → resume a held loop
3347
3411
  // if one is waiting; otherwise draft the loop config (metric design is
3348
3412
  // the whole game for a long-running loop; never start one blind).
@@ -4282,6 +4346,9 @@ function registerAgentTools(pi: any, ctx: ExtensionContext): void {
4282
4346
  const confirmedTarget = draftingTarget;
4283
4347
  draftingTarget = null;
4284
4348
  const full = p.objective.trim() + (normContract ? `\nDone when:\n${normContract}` : "");
4349
+ // The user has just confirmed this activation; release the blank-start
4350
+ // barrier before the direct goal path schedules its first continuation.
4351
+ releaseInitialSessionLoadBarrier();
4285
4352
  // v0.28.14: one-active-thing — no goal/list activation over a live loop.
4286
4353
  if (isLoopActive()) {
4287
4354
  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: {} };
@@ -5488,6 +5555,7 @@ async function cmdGllaResume(ctx: ExtensionContext): Promise<void> {
5488
5555
  // used to answer "Nothing to resume" — the resume path must name the
5489
5556
  // real recovery (/reload rebuilds extensions in place), not mislead.
5490
5557
  if (warnIfStaleAtEntry(ctx, "/glla resume")) return;
5558
+ releaseInitialSessionLoadBarrier();
5491
5559
  const g = state.goal;
5492
5560
  const goalResumable = g && g.status === "paused";
5493
5561
  const loopResumable = state.loop && !state.loop.active && state.loop.stopReason === HELD_ON_RESTORE;
@@ -6191,6 +6259,9 @@ export default function (pi: ExtensionAPI): void {
6191
6259
  // 60s heartbeat notices. Re-arm it as soon as pi settles post-compact.
6192
6260
  pi.on("session_compact", async (_event: any, ctx: ExtensionContext) => {
6193
6261
  if (isForeignCtx(ctx)) return;
6262
+ // A late compact event can arrive after pi has already invalidated this
6263
+ // extension. It must not reclaim the old ctx or schedule settle refires.
6264
+ if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
6194
6265
  rememberCtx(ctx);
6195
6266
  if (!isSupervising()) return;
6196
6267
  appendLedger(ctx.cwd, "session_compact", {});
@@ -6256,6 +6327,7 @@ export default function (pi: ExtensionAPI): void {
6256
6327
  // v0.15.1: ask_user_question answers arrive as tool results, not chat
6257
6328
  // messages — count answered (non-cancelled) questionnaires as replies too.
6258
6329
  pi.on("tool_result", async (event: any) => {
6330
+ if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
6259
6331
  noteToolResult(event); // v0.33.0: slim widget "last action" feed
6260
6332
  // v0.24.0: roll loop tool-result fingerprints (same-tool-same-result
6261
6333
  // detection) — recorded for ANY tool result while a loop is active.
@@ -6328,6 +6400,18 @@ export default function (pi: ExtensionAPI): void {
6328
6400
  if (isForeignCtx(ctx)) return;
6329
6401
  extensionApi = pi;
6330
6402
  sessionHandoffPending = false;
6403
+ // Reset terminal ownership before rememberCtx: this is the only event
6404
+ // allowed to bind a context after a stale/shutdown handoff.
6405
+ staleTerminalDone = false; // v0.33.1: a rebound session can go terminal again
6406
+ zombieStoodDown = false;
6407
+ sessionGeneration++;
6408
+ // Ephemeral watchdog counters belong to the old session, not the
6409
+ // persisted goal. Reset them so a stale boundary cannot make the next
6410
+ // fresh session inherit a false stall count.
6411
+ heartbeatNudges = 0;
6412
+ consecutiveStalls = 0;
6413
+ const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
6414
+ initialSessionLoadPending = isBlankInitialStartup(ctx, startReason);
6331
6415
  rememberCtx(ctx);
6332
6416
  startHeartbeat();
6333
6417
  startUITicker();
@@ -6339,11 +6423,8 @@ export default function (pi: ExtensionAPI): void {
6339
6423
  // confirm the new handle actually works.
6340
6424
  writeOwnerFile(ctx.cwd);
6341
6425
  sessionReplacementUntil = 0;
6342
- zombieStoodDown = false;
6343
- staleTerminalDone = false; // v0.33.1: a rebound session must be able to go terminal AGAIN (was: one-shot for the process lifetime)
6344
6426
  postCompactResumeOwed = false; // v0.33.1: a compact from a previous session must not resync THIS one
6345
6427
  postCompactResyncPending = false;
6346
- const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
6347
6428
  appendLedger(ctx.cwd, "session_rebound", { reason: startReason });
6348
6429
  if (extensionApiStale) {
6349
6430
  extensionApiStale = false; // fresh ctx delivered — re-probe
@@ -6397,6 +6478,21 @@ export default function (pi: ExtensionAPI): void {
6397
6478
  } catch (err) {
6398
6479
  ctx.ui.notify(`glla subagent override sync failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
6399
6480
  }
6481
+ // Consume ownership markers before the startup barrier so a later loaded
6482
+ // session cannot mistake this placeholder runtime for a rebind.
6483
+ const recoveryResume = consumeRecoveryResume(ctx.cwd);
6484
+ const rebindResume = claimSessionOwnerAndDetectRebind(ctx.cwd);
6485
+ if (rebindResume) appendLedger(ctx.cwd, "rebind_resume", { pid: process.pid });
6486
+ const explicitRecovery = handoffResume || recoveryResume || rebindResume;
6487
+ if (initialSessionLoadPending && !explicitRecovery) {
6488
+ appendLedger(ctx.cwd, "session_waiting_for_load", { reason: startReason });
6489
+ 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");
6490
+ return;
6491
+ }
6492
+ // An explicit lifecycle handoff/rebind is continuation consent even if
6493
+ // pi reported a blank startup context. Release the barrier before any
6494
+ // scheduling path below can observe it.
6495
+ initialSessionLoadPending = false;
6400
6496
  // v0.29.6: stacked-state auto-arbitration FIRST — one live artifact
6401
6497
  // survives before the restore gate decides hold-vs-resume for it.
6402
6498
  autoArbitrateStackedState(ctx);
@@ -6453,14 +6549,8 @@ export default function (pi: ExtensionAPI): void {
6453
6549
  persistState(ctx);
6454
6550
  appendLedger(ctx.cwd, "audit_loop_target_migrated", { from: "audit-every-iteration", to: "fix-first" });
6455
6551
  }
6456
- // v0.34.15 compatibility: consume one legacy recovery marker if an older
6457
- // process wrote it before this lifecycle-first build landed.
6458
- const recoveryResume = consumeRecoveryResume(ctx.cwd);
6459
- // v0.34.16: a same-process lifecycle handoff is explicit continuation
6460
- // debt, so it resumes independently of the cold-boot autoResume setting.
6461
- // A same-pid owner rebind is the second same-process signal.
6462
- const rebindResume = claimSessionOwnerAndDetectRebind(ctx.cwd);
6463
- if (rebindResume) appendLedger(ctx.cwd, "rebind_resume", { pid: process.pid });
6552
+ // v0.34.15 compatibility: the legacy recovery marker and v0.34.16
6553
+ // rebind marker were consumed before the startup barrier above.
6464
6554
  if (isLoopActive()) {
6465
6555
  const l = state.loop!;
6466
6556
  if (autoResume || recoveryResume || rebindResume || handoffResume) {
@@ -6552,6 +6642,10 @@ export default function (pi: ExtensionAPI): void {
6552
6642
  });
6553
6643
 
6554
6644
  pi.on("agent_end", async (event: any, ctx: ExtensionContext) => {
6645
+ // A late agent_end from the disposed session is not a fresh turn. Do not
6646
+ // account it, run length continuation, or schedule another send after a
6647
+ // stale terminal/handoff.
6648
+ if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
6555
6649
  rememberCtx(ctx);
6556
6650
  // v0.23.8: a subagent finishing must not drive the main session's
6557
6651
  // continuation loop.
@@ -6572,7 +6666,30 @@ export default function (pi: ExtensionAPI): void {
6572
6666
  const rawPriorA = assistants.length >= 2 ? assistants[assistants.length - 2] : null;
6573
6667
  const extractText = (m: any): string => (m && Array.isArray(m.content)) ? m.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
6574
6668
  const lastA = rawLastA ? { stopReason: rawLastA.stopReason, text: extractText(rawLastA), priorText: extractText(rawPriorA) } : null;
6575
- const lc = tickLengthContinue(lastA?.stopReason === "length");
6669
+ // v0.34.19: pi-ai clamps max_tokens to the remaining context before the
6670
+ // provider call. At ~99% context that clamp can be 1 token, which the
6671
+ // provider reports as stopReason "length" — but this is NOT an overlong
6672
+ // assistant response. Extension agent_end runs BEFORE pi's own
6673
+ // auto-compaction check (agent-session.js _handlePostAgentRun), so sending
6674
+ // LENGTH_CONTINUE_TEXT here queues another 1-token request and delays the
6675
+ // real cure. Defer to pi compaction; session_compact's resume debt owns
6676
+ // the next continuation. Older pi/test doubles without getContextUsage()
6677
+ // fail open to the legacy true-length path.
6678
+ const contextUsage = (() => {
6679
+ try { return typeof ctx.getContextUsage === "function" ? ctx.getContextUsage() : undefined; } catch { return undefined; }
6680
+ })();
6681
+ const contextStarvedLength = isContextStarvedLengthStop(rawLastA, contextUsage);
6682
+ const lc = tickLengthContinue(lastA?.stopReason === "length" && !contextStarvedLength);
6683
+ if (contextStarvedLength) {
6684
+ appendLedger(ctx.cwd, "length_continue_deferred_context_full", {
6685
+ outputTokens: rawLastA?.usage?.output,
6686
+ contextTokens: contextUsage?.tokens ?? null,
6687
+ contextWindow: contextUsage?.contextWindow ?? null,
6688
+ contextPercent: contextUsage?.percent ?? null,
6689
+ });
6690
+ ctx.ui.notify("glla: output-token stop was context starvation (tiny output at a nearly full context) — yielding to pi auto-compaction instead of re-sending.", "info");
6691
+ return;
6692
+ }
6576
6693
  if (lc.giveUpNow) {
6577
6694
  ctx.ui.notify(`glla: response hit the output-token cap ${LENGTH_CONTINUE_MAX}× in a row — stepping aside. Ask the model to split the work into smaller pieces.`, "warning");
6578
6695
  notifyExternal(ctx, "Response truncated 3× in a row — giving up auto-continue.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.34.17",
3
+ "version": "0.34.19",
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",