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.
- package/extensions/loops/goal.ts +73 -17
- package/package.json +1 -1
package/extensions/loops/goal.ts
CHANGED
|
@@ -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; //
|
|
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:
|
|
6454
|
-
//
|
|
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.
|
|
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",
|