pi-better-goal 0.4.1 → 0.6.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.
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/index.ts +259 -97
- package/src/types.ts +6 -4
package/README.md
CHANGED
|
@@ -13,11 +13,11 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
13
13
|
## Core Features
|
|
14
14
|
|
|
15
15
|
- `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
|
|
16
|
-
- `escape` pauses the active goal and
|
|
16
|
+
- `escape` pauses the active goal, and it stays paused while you talk to the agent. Say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows `goal paused · say "go" or /goal resume`, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
|
|
17
17
|
- Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
|
|
18
18
|
- A compact goal widget that does not replace Pi's footer.
|
|
19
19
|
- Background activity tracking for subagents and other registered providers.
|
|
20
|
-
- A progress-aware follow-up loop
|
|
20
|
+
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (30s through 300s by default), followed by a hold recoverable with `/goal resume`. Delays saturate at Node's timer limit, and observed background drains reset progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
21
21
|
- An observable-progress stall state for active goals.
|
|
22
22
|
|
|
23
23
|
## Skill-Owned Workflows
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-goal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Pi extension for goal tracking with background-aware continuation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"pretest": "node ../../scripts/sync-shared-log-utils.mjs",
|
|
39
39
|
"prepack": "node ../../scripts/sync-shared-log-utils.mjs",
|
|
40
|
-
"test": "node --import tsx --test test/*.test.ts",
|
|
40
|
+
"test": "node --import tsx --import ../../scripts/isolate-registry.mjs --test test/*.test.ts",
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
42
|
"verify": "npm run typecheck && npm test"
|
|
43
43
|
},
|
package/src/index.ts
CHANGED
|
@@ -51,7 +51,8 @@ import {
|
|
|
51
51
|
|
|
52
52
|
const POLL_INTERVAL_MS = 2_000;
|
|
53
53
|
const DEFAULT_IDLE_CONTINUATION_DELAY_MS = 30_000;
|
|
54
|
-
const DEFAULT_MAX_NO_PROGRESS_RETRIES =
|
|
54
|
+
const DEFAULT_MAX_NO_PROGRESS_RETRIES = 10;
|
|
55
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
55
56
|
const WAKE_DISABLED =
|
|
56
57
|
process.env.PI_BETTER_GOAL_DISABLE_WAKE === "1" ||
|
|
57
58
|
process.env.PI_BETTER_EXTENSION_DISABLE_WAKE === "1";
|
|
@@ -70,6 +71,34 @@ const MAX_NO_PROGRESS_RETRIES = parseRetryLimit(
|
|
|
70
71
|
* steering after the whole tool batch, and callback batches are follow-ups
|
|
71
72
|
* that wait for the entire run), so the goal harvests them explicitly.
|
|
72
73
|
*/
|
|
74
|
+
/** Model-callable resume, active only while an escape-paused goal waits. */
|
|
75
|
+
export const GOAL_RESUME_TOOL = "goal_resume";
|
|
76
|
+
/** Hotkey that resumes any paused goal, like `/goal resume`. */
|
|
77
|
+
export const GOAL_RESUME_SHORTCUT = "alt+g";
|
|
78
|
+
|
|
79
|
+
/** When the agent may call `goal_resume`; shared by the tool description and the paused prompt. */
|
|
80
|
+
const GOAL_RESUME_RULE =
|
|
81
|
+
"Call goal_resume only when the user's latest message clearly says to proceed (for example \"go\", \"continue\", \"ok do it\", \"approved, proceed\"), " +
|
|
82
|
+
"or answers a decision you explicitly asked for in your previous message with a choice that means proceed. Never call it for questions, \"why...\", \"what about...\", \"let me think\", or discussion.";
|
|
83
|
+
|
|
84
|
+
/** True when the agent may resume this goal with `goal_resume`. */
|
|
85
|
+
export function agentResumable(goal: GoalSnapshot | null): boolean {
|
|
86
|
+
return goal?.status === "paused" && goal.pauseReason === "interrupt";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Footer status for a paused goal, telling the user how to resume it. */
|
|
90
|
+
export function pausedGoalStatus(goal: GoalSnapshot): string {
|
|
91
|
+
return agentResumable(goal) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pausedGoalPrompt(goal: GoalSnapshot): string {
|
|
95
|
+
return [
|
|
96
|
+
`Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
|
|
97
|
+
"Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
|
|
98
|
+
GOAL_RESUME_RULE,
|
|
99
|
+
].join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
73
102
|
const BLOCKING_QUESTION_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
|
|
74
103
|
|
|
75
104
|
const GOAL_ACTIONS: readonly AutocompleteItem[] = [
|
|
@@ -93,6 +122,14 @@ function parseDurationEnv(raw: string | undefined, fallback: number): number {
|
|
|
93
122
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
94
123
|
}
|
|
95
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Linear backoff for no-progress retries: each identical outcome waits one more
|
|
127
|
+
* grace period than the last (30s, 60s, 90s, ... by default).
|
|
128
|
+
*/
|
|
129
|
+
export function idleContinuationDelay(noProgressRetries: number, baseMs = IDLE_CONTINUATION_DELAY_MS): number {
|
|
130
|
+
return Math.min(MAX_TIMER_DELAY_MS, baseMs * (1 + Math.max(0, noProgressRetries)));
|
|
131
|
+
}
|
|
132
|
+
|
|
96
133
|
function parseRetryLimit(raw: string | undefined, fallback: number): number {
|
|
97
134
|
if (raw === undefined || raw.trim() === "") {
|
|
98
135
|
return fallback;
|
|
@@ -195,6 +232,11 @@ function formatGoal(
|
|
|
195
232
|
`Elapsed time: ${timing.elapsedSeconds}s`,
|
|
196
233
|
`Observable progress: ${stall?.state ?? "unknown"}`,
|
|
197
234
|
continuationStatus,
|
|
235
|
+
...(goal.status === "paused"
|
|
236
|
+
? [agentResumable(goal)
|
|
237
|
+
? 'Resume: say "go" (the agent calls goal_resume), /goal resume, or alt+g'
|
|
238
|
+
: "Resume: /goal resume or alt+g"]
|
|
239
|
+
: []),
|
|
198
240
|
].join("\n");
|
|
199
241
|
}
|
|
200
242
|
|
|
@@ -224,13 +266,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
224
266
|
});
|
|
225
267
|
|
|
226
268
|
let currentCtx: ExtensionContext | undefined;
|
|
269
|
+
let executionGeneration = 0;
|
|
270
|
+
let agentGeneration = -1;
|
|
271
|
+
let sessionGeneration = 0;
|
|
272
|
+
let wakeGeneration = 0;
|
|
273
|
+
let snapshotSequence = 0;
|
|
274
|
+
let lastAppliedSnapshotSequence = 0;
|
|
227
275
|
let foregroundRunning = false;
|
|
228
276
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
229
277
|
let collecting = false;
|
|
230
278
|
let collectionPending = false;
|
|
231
279
|
let latestSnapshot: ActivitySnapshot | null = null;
|
|
232
280
|
let backgroundDrainTracker: BackgroundDrainTracker | null = null;
|
|
233
|
-
let lastWakeSignature = "";
|
|
234
281
|
let lastAttentionSignature = "";
|
|
235
282
|
let continuationQueuedFor: string | null = null;
|
|
236
283
|
let idleContinuationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -297,6 +344,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
297
344
|
};
|
|
298
345
|
|
|
299
346
|
const setGoal = (goal: GoalSnapshot, ctx: ExtensionContext, source: GoalEntrySource): void => {
|
|
347
|
+
executionGeneration += 1;
|
|
348
|
+
lastAgentEvidence = null;
|
|
300
349
|
const previous = getGoal(ctx);
|
|
301
350
|
const wasVisible = previous !== null && isGoalClockVisible(previous);
|
|
302
351
|
pi.appendEntry(EXTENSION_NAME, goalSetEntry(goal, source));
|
|
@@ -304,11 +353,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
304
353
|
backgroundDrainTracker = null;
|
|
305
354
|
clearIdleContinuation();
|
|
306
355
|
syncPollingState();
|
|
356
|
+
syncResumeTool(goal);
|
|
357
|
+
applyStatus(ctx);
|
|
307
358
|
// Force a full redraw when the dock height changes (absent ↔ visible clock).
|
|
308
359
|
refreshGoalWidget?.(!wasVisible || !isGoalClockVisible(goal));
|
|
309
360
|
};
|
|
310
361
|
|
|
311
362
|
const clearGoal = (ctx: ExtensionContext, source: GoalEntrySource): void => {
|
|
363
|
+
executionGeneration += 1;
|
|
364
|
+
lastAgentEvidence = null;
|
|
312
365
|
const current = getGoal(ctx);
|
|
313
366
|
const wasVisible = current !== null && isGoalClockVisible(current);
|
|
314
367
|
pi.appendEntry(EXTENSION_NAME, goalClearEntry(current?.goalId ?? null, source));
|
|
@@ -317,14 +370,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
317
370
|
backgroundDrainTracker = null;
|
|
318
371
|
clearIdleContinuation();
|
|
319
372
|
syncPollingState();
|
|
373
|
+
syncResumeTool(null);
|
|
374
|
+
applyStatus(ctx);
|
|
320
375
|
refreshGoalWidget?.(wasVisible);
|
|
321
376
|
};
|
|
322
377
|
|
|
323
378
|
/**
|
|
324
|
-
* An interrupt (escape, or anything else that aborts the running turn)
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
379
|
+
* An interrupt (escape, or anything else that aborts the running turn) pauses
|
|
380
|
+
* the goal. It stays paused while the user talks: messages are ordinary
|
|
381
|
+
* conversation. It resumes through `/goal resume`, the hotkey, or the agent's
|
|
382
|
+
* `goal_resume` once the user clearly says to proceed.
|
|
328
383
|
*/
|
|
329
384
|
const pauseGoalOnInterrupt = (ctx: ExtensionContext): void => {
|
|
330
385
|
const goal = getGoal(ctx);
|
|
@@ -332,7 +387,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
332
387
|
return;
|
|
333
388
|
}
|
|
334
389
|
setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
|
|
335
|
-
notifyGoal(ctx,
|
|
390
|
+
notifyGoal(ctx, 'Goal paused. Say "go" to resume it, or use /goal resume.');
|
|
336
391
|
};
|
|
337
392
|
|
|
338
393
|
/** Why a paused goal cannot become active again, or null when it can. */
|
|
@@ -346,6 +401,29 @@ export default function (pi: ExtensionAPI): void {
|
|
|
346
401
|
return null;
|
|
347
402
|
};
|
|
348
403
|
|
|
404
|
+
/**
|
|
405
|
+
* The one resume path shared by `/goal resume`, the hotkey, and `goal_resume`:
|
|
406
|
+
* reactivate a paused goal or reopen an active no-progress hold.
|
|
407
|
+
*/
|
|
408
|
+
const resumeGoal = (
|
|
409
|
+
ctx: ExtensionContext,
|
|
410
|
+
source: GoalEntrySource,
|
|
411
|
+
): { ok: true; goal: GoalSnapshot } | { ok: false; message: string } => {
|
|
412
|
+
const current = getGoal(ctx);
|
|
413
|
+
const held = current?.status === "active" && currentContinuationState(ctx, current.goalId)?.blocked === true;
|
|
414
|
+
if (!current || (current.status !== "paused" && !held)) {
|
|
415
|
+
return { ok: false, message: "Only paused or no-progress-held goals can be resumed." };
|
|
416
|
+
}
|
|
417
|
+
const blocker = resumeBlocker(current);
|
|
418
|
+
if (blocker) {
|
|
419
|
+
return { ok: false, message: blocker };
|
|
420
|
+
}
|
|
421
|
+
const goal = held ? current : goalWithStatus(current, "active");
|
|
422
|
+
setGoal(goal, ctx, source);
|
|
423
|
+
queueGoalContinuation(goal, ctx);
|
|
424
|
+
return { ok: true, goal };
|
|
425
|
+
};
|
|
426
|
+
|
|
349
427
|
const boundCommandReady = (goal: GoalSnapshot, ctx: ExtensionContext): boolean => {
|
|
350
428
|
if (!goal.command || commandAvailable(pi, goal.command)) return true;
|
|
351
429
|
setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
|
|
@@ -378,6 +456,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
378
456
|
};
|
|
379
457
|
|
|
380
458
|
function clearIdleContinuation(): void {
|
|
459
|
+
wakeGeneration += 1;
|
|
381
460
|
if (idleContinuationTimer) {
|
|
382
461
|
clearTimeout(idleContinuationTimer);
|
|
383
462
|
idleContinuationTimer = undefined;
|
|
@@ -398,7 +477,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
398
477
|
return;
|
|
399
478
|
}
|
|
400
479
|
const signature = `${goal.goalId}:${kind}`;
|
|
401
|
-
if (idleContinuationTimer && idleContinuationSignature === signature
|
|
480
|
+
if (idleContinuationTimer && (idleContinuationSignature === signature ||
|
|
481
|
+
(kind === "continuation" && idleContinuationSignature === `${goal.goalId}:background-drained`))) {
|
|
402
482
|
return;
|
|
403
483
|
}
|
|
404
484
|
clearIdleContinuation();
|
|
@@ -407,7 +487,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
407
487
|
idleContinuationTimer = undefined;
|
|
408
488
|
idleContinuationSignature = "";
|
|
409
489
|
void sendIdleContinuationAfterAudit(goal.goalId, ctx, kind, snapshot);
|
|
410
|
-
},
|
|
490
|
+
}, idleContinuationDelay(kind === "continuation" ? currentContinuationState(ctx, goal.goalId)?.noProgressRetries ?? 0 : 0));
|
|
411
491
|
idleContinuationTimer.unref?.();
|
|
412
492
|
};
|
|
413
493
|
|
|
@@ -417,22 +497,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
417
497
|
kind: "continuation" | "background-drained",
|
|
418
498
|
priorSnapshot?: ActivitySnapshot,
|
|
419
499
|
): Promise<void> => {
|
|
500
|
+
const execution = executionGeneration;
|
|
501
|
+
const wake = wakeGeneration;
|
|
502
|
+
const auditIsCurrent = (): boolean => {
|
|
503
|
+
if (execution !== executionGeneration || wake !== wakeGeneration) return false;
|
|
504
|
+
const current = currentGoalSnapshot(ctx);
|
|
505
|
+
return isPokeable(current) && current.goalId === goalId &&
|
|
506
|
+
continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
|
|
507
|
+
};
|
|
420
508
|
const goal = currentGoalSnapshot(ctx);
|
|
421
|
-
if (!
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
509
|
+
if (!auditIsCurrent() || !isPokeable(goal)) return;
|
|
424
510
|
if (!boundCommandReady(goal, ctx)) return;
|
|
425
|
-
const snapshot = await
|
|
426
|
-
|
|
427
|
-
pi.events.emit(EVENT_ACTIVITY, snapshot);
|
|
428
|
-
if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
511
|
+
const snapshot = await publishSnapshot(ctx);
|
|
512
|
+
if (!snapshot || !auditIsCurrent() || snapshot.foregroundRunning || snapshot.backgroundRunning) return;
|
|
431
513
|
|
|
432
|
-
// A background drain is a meaningful external state transition and reopens a held goal.
|
|
433
|
-
if (kind === "background-drained") {
|
|
434
|
-
resetContinuationState(goal);
|
|
435
|
-
}
|
|
436
514
|
continuationQueuedFor = goal.goalId;
|
|
437
515
|
const content = kind === "background-drained"
|
|
438
516
|
? "Background activity for the active goal is no longer running. Inspect any subagent callbacks or final results, then continue the completion audit before marking the goal complete.\n\n" +
|
|
@@ -475,64 +553,94 @@ export default function (pi: ExtensionAPI): void {
|
|
|
475
553
|
return goal;
|
|
476
554
|
};
|
|
477
555
|
|
|
478
|
-
const publishSnapshot = async (ctx: ExtensionContext): Promise<ActivitySnapshot> => {
|
|
556
|
+
const publishSnapshot = async (ctx: ExtensionContext): Promise<ActivitySnapshot | null> => {
|
|
557
|
+
const execution = executionGeneration;
|
|
558
|
+
const sequence = ++snapshotSequence;
|
|
479
559
|
const snapshot = await collectActivitySnapshot(ctx, providers.values(), foregroundRunning);
|
|
560
|
+
if (execution !== executionGeneration) return null;
|
|
561
|
+
if (sequence < lastAppliedSnapshotSequence) return latestSnapshot;
|
|
562
|
+
lastAppliedSnapshotSequence = sequence;
|
|
480
563
|
latestSnapshot = snapshot;
|
|
481
|
-
pi.events.emit(EVENT_ACTIVITY, snapshot);
|
|
482
564
|
|
|
565
|
+
const goal = currentGoalSnapshot(ctx);
|
|
566
|
+
const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
|
|
567
|
+
backgroundDrainTracker = wakePlan.nextTracker;
|
|
568
|
+
if (isPokeable(goal) && wakePlan.wakeSignature) {
|
|
569
|
+
// Persist external progress now: a foreground/callback turn may cancel its delayed wake.
|
|
570
|
+
clearIdleContinuation();
|
|
571
|
+
resetContinuationState(goal);
|
|
572
|
+
lastAgentEvidence = null;
|
|
573
|
+
scheduleIdleContinuation(goal, ctx, "background-drained", snapshot);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
|
|
577
|
+
clearIdleContinuation();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
pi.events.emit(EVENT_ACTIVITY, snapshot);
|
|
581
|
+
if (execution !== executionGeneration) return null;
|
|
483
582
|
const attention = terminalAttentionSignature(snapshot);
|
|
484
583
|
if (attention && attention !== lastAttentionSignature) {
|
|
485
584
|
lastAttentionSignature = attention;
|
|
486
585
|
pi.events.emit(EVENT_TERMINAL_ATTENTION, snapshot);
|
|
586
|
+
if (execution !== executionGeneration) return null;
|
|
487
587
|
}
|
|
588
|
+
applyStatus(ctx);
|
|
488
589
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
|
|
492
|
-
backgroundDrainTracker = wakePlan.nextTracker;
|
|
493
|
-
if (isPokeable(goal) && wakePlan.wakeSignature) {
|
|
494
|
-
const wakeSignature = wakePlan.wakeSignature;
|
|
495
|
-
if (wakeSignature !== lastWakeSignature) {
|
|
496
|
-
lastWakeSignature = wakeSignature;
|
|
497
|
-
scheduleIdleContinuation(goal, ctx, "background-drained", snapshot);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (snapshot.foregroundRunning || snapshot.backgroundRunning) {
|
|
503
|
-
clearIdleContinuation();
|
|
504
|
-
}
|
|
590
|
+
return snapshot;
|
|
591
|
+
};
|
|
505
592
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
|
|
512
|
-
});
|
|
513
|
-
let waitingOnAnswer = 0;
|
|
593
|
+
/** Footer status from the goal state and the latest activity snapshot. */
|
|
594
|
+
const statusText = (ctx: ExtensionContext, snapshot: ActivitySnapshot | null): string | undefined => {
|
|
595
|
+
const goal = getGoal(ctx);
|
|
596
|
+
let waitingOnAnswer = 0;
|
|
597
|
+
if (snapshot) {
|
|
514
598
|
for (const activeAtStart of pendingQuestions.values()) {
|
|
515
599
|
waitingOnAnswer += finishedSinceQuestion(activeAtStart, snapshot).length;
|
|
516
600
|
}
|
|
517
|
-
const status = waitingOnAnswer > 0
|
|
518
|
-
? `${waitingOnAnswer} background done; waiting on your answer`
|
|
519
|
-
: snapshot.backgroundRunning
|
|
520
|
-
? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
|
|
521
|
-
: continuation?.blocked
|
|
522
|
-
? "waiting: no progress"
|
|
523
|
-
: goalStall?.state === "stalled"
|
|
524
|
-
? "goal stalled"
|
|
525
|
-
: undefined;
|
|
526
|
-
try {
|
|
527
|
-
ctx.ui.setStatus(EXTENSION_NAME, status);
|
|
528
|
-
} catch {
|
|
529
|
-
// UI status is best-effort only.
|
|
530
|
-
}
|
|
531
601
|
}
|
|
532
|
-
|
|
533
|
-
|
|
602
|
+
if (waitingOnAnswer > 0) return `${waitingOnAnswer} background done; waiting on your answer`;
|
|
603
|
+
const background = snapshot?.backgroundRunning
|
|
604
|
+
? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
|
|
605
|
+
: undefined;
|
|
606
|
+
if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal)} · ${background}` : pausedGoalStatus(goal);
|
|
607
|
+
if (background) return background;
|
|
608
|
+
const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
|
|
609
|
+
if (continuation?.blocked) return "waiting: no progress";
|
|
610
|
+
const goalStall = observeGoalStall(goal, continuation, {
|
|
611
|
+
foregroundRunning,
|
|
612
|
+
backgroundRunning: snapshot?.backgroundRunning ?? false,
|
|
613
|
+
});
|
|
614
|
+
return goalStall?.state === "stalled" ? "goal stalled" : undefined;
|
|
534
615
|
};
|
|
535
616
|
|
|
617
|
+
function applyStatus(ctx: ExtensionContext): void {
|
|
618
|
+
if (!ctx.hasUI) return;
|
|
619
|
+
try {
|
|
620
|
+
ctx.ui.setStatus(EXTENSION_NAME, statusText(ctx, latestSnapshot));
|
|
621
|
+
} catch {
|
|
622
|
+
// UI status is best-effort only.
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* `goal_resume` is in the model's tool list only while an escape-paused goal
|
|
628
|
+
* waits. Pi activates newly registered tools by default, so this also removes
|
|
629
|
+
* it at session start. Only this one tool is toggled; other extensions' tool
|
|
630
|
+
* choices are left as they are.
|
|
631
|
+
*/
|
|
632
|
+
function syncResumeTool(goal: GoalSnapshot | null): void {
|
|
633
|
+
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
634
|
+
try {
|
|
635
|
+
const active = pi.getActiveTools();
|
|
636
|
+
const wanted = agentResumable(goal);
|
|
637
|
+
if (active.includes(GOAL_RESUME_TOOL) === wanted) return;
|
|
638
|
+
pi.setActiveTools(wanted ? [...active, GOAL_RESUME_TOOL] : active.filter((name) => name !== GOAL_RESUME_TOOL));
|
|
639
|
+
} catch {
|
|
640
|
+
// Tool activation is unavailable before Pi binds the session; the tool also refuses on its own.
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
536
644
|
const collectIfPossible = (): void => {
|
|
537
645
|
const ctx = currentCtx;
|
|
538
646
|
if (!ctx) {
|
|
@@ -543,7 +651,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
543
651
|
return;
|
|
544
652
|
}
|
|
545
653
|
collecting = true;
|
|
654
|
+
const session = sessionGeneration;
|
|
546
655
|
void publishSnapshot(ctx).finally(() => {
|
|
656
|
+
if (session !== sessionGeneration) return;
|
|
547
657
|
collecting = false;
|
|
548
658
|
if (collectionPending) {
|
|
549
659
|
collectionPending = false;
|
|
@@ -675,18 +785,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
675
785
|
}
|
|
676
786
|
|
|
677
787
|
if (trimmed === "resume") {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
}
|
|
682
|
-
const blocker = resumeBlocker(current);
|
|
683
|
-
if (blocker) {
|
|
684
|
-
notifyGoal(ctx, blocker, "error");
|
|
788
|
+
const result = resumeGoal(ctx, "command");
|
|
789
|
+
if (!result.ok) {
|
|
790
|
+
notifyGoal(ctx, result.message, current?.status === "paused" ? "error" : "warning");
|
|
685
791
|
return;
|
|
686
792
|
}
|
|
687
|
-
const goal = goalWithStatus(current, "active");
|
|
688
|
-
setGoal(goal, ctx, "command");
|
|
689
|
-
queueGoalContinuation(goal, ctx);
|
|
690
793
|
notifyGoal(ctx, "Goal resumed.");
|
|
691
794
|
return;
|
|
692
795
|
}
|
|
@@ -789,12 +892,51 @@ export default function (pi: ExtensionAPI): void {
|
|
|
789
892
|
},
|
|
790
893
|
});
|
|
791
894
|
|
|
895
|
+
pi.registerTool({
|
|
896
|
+
name: GOAL_RESUME_TOOL,
|
|
897
|
+
label: "Resume Goal",
|
|
898
|
+
description:
|
|
899
|
+
"Resume the goal the user paused with escape, exactly like /goal resume. " + GOAL_RESUME_RULE,
|
|
900
|
+
promptSnippet: "Resume the escape-paused goal, only on the user's clear go-ahead.",
|
|
901
|
+
parameters: Type.Object({
|
|
902
|
+
reason: Type.Optional(Type.String({ description: "Short quote or summary of the user's go-ahead." })),
|
|
903
|
+
}),
|
|
904
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
905
|
+
const reason = (params as { reason?: string }).reason;
|
|
906
|
+
const current = getGoal(ctx);
|
|
907
|
+
if (!agentResumable(current)) {
|
|
908
|
+
const text = current?.status === "paused"
|
|
909
|
+
? "This goal was paused with /goal pause or by the runtime. Only the user can resume it, with /goal resume."
|
|
910
|
+
: "No goal is paused, so there is nothing to resume.";
|
|
911
|
+
return { content: [{ type: "text", text }], details: { ok: false, goal: current } };
|
|
912
|
+
}
|
|
913
|
+
const result = resumeGoal(ctx, "tool");
|
|
914
|
+
if (!result.ok) {
|
|
915
|
+
return { content: [{ type: "text", text: `Goal stays paused. ${result.message}` }], details: { ok: false, goal: getGoal(ctx) } };
|
|
916
|
+
}
|
|
917
|
+
return {
|
|
918
|
+
content: [{ type: "text", text: "Goal resumed. Its continuation runs after this turn." }],
|
|
919
|
+
details: { ok: true, goal: result.goal, ...(reason ? { reason } : {}) },
|
|
920
|
+
};
|
|
921
|
+
},
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
pi.registerShortcut?.(GOAL_RESUME_SHORTCUT, {
|
|
925
|
+
description: "Resume the paused goal",
|
|
926
|
+
handler: (ctx) => {
|
|
927
|
+
currentCtx = ctx;
|
|
928
|
+
const result = resumeGoal(ctx, "command");
|
|
929
|
+
notifyGoal(ctx, result.ok ? "Goal resumed." : result.message, result.ok ? "info" : "warning");
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
|
|
792
933
|
pi.registerCommand("better-activity", {
|
|
793
934
|
description: "Show foreground/background activity known to pi-better-goal",
|
|
794
935
|
handler: async (_args, ctx) => {
|
|
795
936
|
currentCtx = ctx;
|
|
796
937
|
const snapshot = await publishSnapshot(ctx);
|
|
797
|
-
|
|
938
|
+
if (!snapshot) return;
|
|
939
|
+
ctx.ui.notify(formatSnapshot(snapshot), "info");
|
|
798
940
|
},
|
|
799
941
|
});
|
|
800
942
|
|
|
@@ -833,6 +975,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
833
975
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
834
976
|
currentCtx = ctx;
|
|
835
977
|
const snapshot = await publishSnapshot(ctx);
|
|
978
|
+
if (!snapshot) {
|
|
979
|
+
return { content: [{ type: "text", text: "Activity changed during collection; inspect again." }], details: { stale: true } };
|
|
980
|
+
}
|
|
836
981
|
return {
|
|
837
982
|
content: [{ type: "text", text: formatSnapshot(snapshot) }],
|
|
838
983
|
details: snapshot,
|
|
@@ -841,6 +986,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
841
986
|
});
|
|
842
987
|
|
|
843
988
|
pi.on("session_start", async (_event, ctx) => {
|
|
989
|
+
executionGeneration += 1;
|
|
990
|
+
sessionGeneration += 1;
|
|
991
|
+
clearIdleContinuation();
|
|
992
|
+
continuationQueuedFor = null;
|
|
993
|
+
backgroundDrainTracker = null;
|
|
994
|
+
lastAgentEvidence = null;
|
|
995
|
+
collecting = false;
|
|
996
|
+
collectionPending = false;
|
|
844
997
|
currentCtx = ctx;
|
|
845
998
|
foregroundRunning = !ctx.isIdle();
|
|
846
999
|
const restoredGoal = getGoal(ctx);
|
|
@@ -849,10 +1002,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
849
1002
|
notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
|
|
850
1003
|
}
|
|
851
1004
|
pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
|
|
1005
|
+
syncResumeTool(getGoal(ctx));
|
|
852
1006
|
installGoalWidget(ctx);
|
|
853
|
-
|
|
1007
|
+
const snapshot = await publishSnapshot(ctx);
|
|
1008
|
+
if (!snapshot) return;
|
|
854
1009
|
syncPollingState();
|
|
855
|
-
if (restoredGoal?.status === "active" && restoredGoal.command && boundCommandReady(restoredGoal, ctx) && !foregroundRunning && !
|
|
1010
|
+
if (restoredGoal?.status === "active" && restoredGoal.command && boundCommandReady(restoredGoal, ctx) && !foregroundRunning && !snapshot.backgroundRunning) {
|
|
856
1011
|
scheduleIdleContinuation(restoredGoal, ctx, "continuation");
|
|
857
1012
|
}
|
|
858
1013
|
});
|
|
@@ -874,24 +1029,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
874
1029
|
}
|
|
875
1030
|
}
|
|
876
1031
|
}
|
|
1032
|
+
// A paused goal stays paused while the user talks: the message is ordinary
|
|
1033
|
+
// conversation. Only /goal resume, the hotkey, or goal_resume resume it.
|
|
877
1034
|
const goal = getGoal(ctx);
|
|
878
|
-
if (goal?.status === "paused" && goal.pauseReason === "interrupt") {
|
|
879
|
-
// Conversational input after an interrupt means the user has taken the
|
|
880
|
-
// wheel, not stopped the goal: reactivate it so continuation resumes once
|
|
881
|
-
// this exchange settles. Pi's built-in commands (/settings, /model, ...)
|
|
882
|
-
// and extension commands never reach this handler.
|
|
883
|
-
const blocker = resumeBlocker(goal);
|
|
884
|
-
if (blocker) {
|
|
885
|
-
notifyGoal(ctx, `Goal stays paused. ${blocker}`, "warning");
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
|
-
const resumed = goalWithStatus(goal, "active");
|
|
889
|
-
setGoal(resumed, ctx, "runtime");
|
|
890
|
-
resetContinuationState(resumed);
|
|
891
|
-
notifyGoal(ctx, "Goal resumed; it continues after this exchange.");
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
1035
|
if (goal?.status === "active") {
|
|
1036
|
+
executionGeneration += 1;
|
|
1037
|
+
clearIdleContinuation();
|
|
1038
|
+
lastAgentEvidence = null;
|
|
895
1039
|
resetContinuationState(goal);
|
|
896
1040
|
}
|
|
897
1041
|
});
|
|
@@ -901,8 +1045,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
901
1045
|
const goal = currentGoalSnapshot(ctx);
|
|
902
1046
|
const owner = getWorkflow(ctx);
|
|
903
1047
|
const snapshot = await publishSnapshot(ctx);
|
|
1048
|
+
if (!snapshot) return;
|
|
1049
|
+
const pausedInstruction = agentResumable(goal) ? pausedGoalPrompt(goal!) : "";
|
|
904
1050
|
if (!isPokeable(goal) && !owner) {
|
|
905
|
-
return;
|
|
1051
|
+
return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
|
|
906
1052
|
}
|
|
907
1053
|
|
|
908
1054
|
const questionInstruction = snapshot.backgroundRunning
|
|
@@ -923,7 +1069,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
923
1069
|
return { systemPrompt: `${event.systemPrompt}\n\nWorkflow ${owner.name} is unavailable. Stop work and ask the user to restore or reinvoke the skill.` };
|
|
924
1070
|
}
|
|
925
1071
|
return {
|
|
926
|
-
systemPrompt: `${event.systemPrompt}\n\
|
|
1072
|
+
systemPrompt: `${event.systemPrompt}\n\n` +
|
|
1073
|
+
(pausedInstruction
|
|
1074
|
+
? `${pausedInstruction}\nWhile the goal is paused, this overrides the workflow instructions below: do not advance the workflow until the goal is resumed.\n\n`
|
|
1075
|
+
: "") +
|
|
1076
|
+
`Active workflow: ${owner.name} (${owner.path}). Its task plan owns planning and the parent is a coordinator, not a product-code implementer. Follow the workflow instructions below, including on resumed turns:\n\n${instructions}` +
|
|
927
1077
|
(isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
|
|
928
1078
|
(questionInstruction ? `\n\n${questionInstruction.trim()}` : ""),
|
|
929
1079
|
};
|
|
@@ -941,6 +1091,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
941
1091
|
});
|
|
942
1092
|
|
|
943
1093
|
pi.on("agent_start", async (_event, ctx) => {
|
|
1094
|
+
executionGeneration += 1;
|
|
1095
|
+
agentGeneration = executionGeneration;
|
|
944
1096
|
currentCtx = ctx;
|
|
945
1097
|
foregroundRunning = true;
|
|
946
1098
|
clearIdleContinuation();
|
|
@@ -958,7 +1110,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
958
1110
|
});
|
|
959
1111
|
|
|
960
1112
|
pi.on("agent_end", async (event, ctx) => {
|
|
961
|
-
lastAgentEvidence = continuationEvidence(event.messages);
|
|
1113
|
+
lastAgentEvidence = agentGeneration === executionGeneration ? continuationEvidence(event.messages) : null;
|
|
962
1114
|
// `escape` is a pi-reserved built-in shortcut (`app.interrupt`), so extensions
|
|
963
1115
|
// cannot register it. Observe the interrupt instead: when a running turn is
|
|
964
1116
|
// aborted (escape / ctrl+c while streaming), pause the active goal so it does
|
|
@@ -972,6 +1124,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
972
1124
|
if (!BLOCKING_QUESTION_TOOLS.has(event.toolName)) return;
|
|
973
1125
|
currentCtx = ctx;
|
|
974
1126
|
const snapshot = await publishSnapshot(ctx);
|
|
1127
|
+
if (!snapshot) return;
|
|
975
1128
|
const active = activeItemsByKey(snapshot);
|
|
976
1129
|
if (active.size > 0) pendingQuestions.set(event.toolCallId, active);
|
|
977
1130
|
});
|
|
@@ -982,6 +1135,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
982
1135
|
pendingQuestions.delete(event.toolCallId);
|
|
983
1136
|
currentCtx = ctx;
|
|
984
1137
|
const snapshot = await publishSnapshot(ctx);
|
|
1138
|
+
if (!snapshot) return;
|
|
985
1139
|
const finished = finishedSinceQuestion(activeAtStart, snapshot);
|
|
986
1140
|
if (finished.length === 0) return;
|
|
987
1141
|
// Steering is drained right after this tool batch, so the model sees the
|
|
@@ -995,15 +1149,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
995
1149
|
});
|
|
996
1150
|
|
|
997
1151
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
1152
|
+
executionGeneration += 1;
|
|
998
1153
|
currentCtx = ctx;
|
|
999
1154
|
foregroundRunning = false;
|
|
1000
1155
|
const snapshot = await publishSnapshot(ctx);
|
|
1156
|
+
if (!snapshot) return;
|
|
1001
1157
|
const goal = getGoal(ctx);
|
|
1002
1158
|
if (!isPokeable(goal) || snapshot.backgroundRunning) {
|
|
1003
1159
|
return;
|
|
1004
1160
|
}
|
|
1161
|
+
const evidence = lastAgentEvidence;
|
|
1162
|
+
if (!evidence) {
|
|
1163
|
+
scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1005
1166
|
const previous = currentContinuationState(ctx, goal.goalId) ?? createContinuationState(goal.goalId);
|
|
1006
|
-
const evidence = lastAgentEvidence ?? continuationEvidence([]);
|
|
1007
1167
|
const repeated = previous.lastEvidenceSignature === evidence.signature;
|
|
1008
1168
|
const noProgressRetries = repeated ? previous.noProgressRetries + 1 : 0;
|
|
1009
1169
|
const blocked = repeated && noProgressRetries >= MAX_NO_PROGRESS_RETRIES;
|
|
@@ -1034,6 +1194,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1034
1194
|
});
|
|
1035
1195
|
|
|
1036
1196
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1197
|
+
executionGeneration += 1;
|
|
1198
|
+
sessionGeneration += 1;
|
|
1037
1199
|
stopPolling();
|
|
1038
1200
|
foregroundRunning = false;
|
|
1039
1201
|
pendingQuestions.clear();
|
package/src/types.ts
CHANGED
|
@@ -66,10 +66,12 @@ export interface ActivitySnapshot {
|
|
|
66
66
|
export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Why a paused goal is paused. `interrupt` marks a
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* Why a paused goal is paused. `interrupt` marks a pause from escape (or any
|
|
70
|
+
* other abort of the running turn). Every pause is sticky: user messages never
|
|
71
|
+
* resume a goal on their own. An `interrupt` pause may be resumed by the agent
|
|
72
|
+
* with `goal_resume` when the user clearly says to proceed. A paused goal
|
|
73
|
+
* without a reason (`/goal pause`, an unavailable command or workflow) resumes
|
|
74
|
+
* only through `/goal resume` or the resume hotkey.
|
|
73
75
|
*/
|
|
74
76
|
export type GoalPauseReason = "interrupt";
|
|
75
77
|
|