@signalridge/pi-goal 1.4.2 → 1.4.4

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/src/lifecycle.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { currentTokenTotal } from "./accounting.js";
2
+ import { assistantTokensAfterGoalState, currentTokenTotal, rebaseGoalUsage } from "./accounting.js";
3
3
  import type { GoalCommandController } from "./commands.js";
4
4
  import { notifyTerminal } from "./errors.js";
5
5
  import { type ActiveGoal, loadGoalStateFromSession } from "./persistence.js";
6
- import { buildGoalPrompt, buildGoalSystemPrompt } from "./prompts.js";
6
+ import {
7
+ buildGoalBindingUpdate,
8
+ buildGoalPrompt,
9
+ buildGoalSystemPrompt,
10
+ GOAL_BINDING_UPDATE_HEADER,
11
+ } from "./prompts.js";
7
12
  import { activateQueuedGoal } from "./queue.js";
8
13
  import type { GoalRunController } from "./run-protocol.js";
9
14
  import {
@@ -13,6 +18,7 @@ import {
13
18
  findFinalAssistantMessage,
14
19
  formatError,
15
20
  type GoalRuntime,
21
+ hasPendingMessages,
16
22
  incrementGoal,
17
23
  isGoalContextOverflow,
18
24
  isRetryableGoalInterruption,
@@ -39,13 +45,50 @@ export function registerGoalLifecycle(
39
45
  runController: GoalRunController,
40
46
  options: GoalLifecycleOptions = {},
41
47
  ) {
48
+ function afterTreeNavigationSettles(ctx: StatusContext, ownsWork: () => boolean, work: () => Promise<unknown>) {
49
+ const generation = runtime.menuGeneration;
50
+ const signal = runtime.menuController.signal;
51
+ let timer: ReturnType<typeof setTimeout>;
52
+ const cancel = () => {
53
+ clearTimeout(timer);
54
+ signal.removeEventListener("abort", cancel);
55
+ };
56
+ const attempt = () => {
57
+ if (signal.aborted || generation !== runtime.menuGeneration || !ownsWork()) {
58
+ cancel();
59
+ return;
60
+ }
61
+ if (ctx.isIdle?.() !== true || hasPendingMessages(ctx)) {
62
+ timer = setTimeout(attempt, 1_000);
63
+ return;
64
+ }
65
+ cancel();
66
+ void work().catch((error) => {
67
+ if (!signal.aborted && generation === runtime.menuGeneration) {
68
+ notifyTerminal(ctx.ui, `Cannot restore /goal after tree navigation: ${formatError(error)}`, "error");
69
+ }
70
+ });
71
+ };
72
+ signal.addEventListener("abort", cancel, { once: true });
73
+ timer = setTimeout(attempt, 0);
74
+ }
75
+
42
76
  pi.on("session_start", async (_event, ctx) => {
43
77
  runtime.replaceMenuSession();
78
+ runtime.inputWakeGoalId = undefined;
79
+ runtime.inputWakeNeedsPrompt = false;
80
+ runtime.pendingDirectInputs = [];
81
+ runtime.ambiguousWaitInputGoalId = undefined;
82
+ runtime.acceptedRunPrompt = undefined;
83
+ runtime.pendingInputWake = undefined;
84
+ runtime.clearGoalWaitWake();
44
85
  runtime.clearCompletionStatusTimer();
45
86
  runtime.clearContinuationTracking();
46
87
  runtime.clearPendingGoalPrompts();
47
88
  runtime.clearAgentRun();
48
89
  runtime.guardAbortGoalId = undefined;
90
+ runtime.pendingStoppedUsageGoalId = undefined;
91
+ runtime.pendingReplacementUsage = undefined;
49
92
  runtime.clearGoalRecovery();
50
93
  runtime.clearBudgetWrapUp();
51
94
  runtime.clearStaleGoalToolCallBlock();
@@ -100,6 +143,12 @@ export function registerGoalLifecycle(
100
143
  runtime.activeGoal = resetGoalSafetyEpoch(runtime.activeGoal);
101
144
  }
102
145
  if (runtime.activeGoal.status === "active") {
146
+ if (runtime.activeGoal.usageBaselinePending) {
147
+ runtime.activeGoal = rebaseGoalUsage(
148
+ { ...runtime.activeGoal, usageBaselinePending: undefined },
149
+ currentTokenTotal(ctx),
150
+ );
151
+ }
103
152
  runtime.recordGoalUsage(runtime.activeGoal, ctx);
104
153
  if (runtime.limitActiveGoalForBudget(ctx, false)) return;
105
154
  if (runtime.enforceAutomaticTurnLimit(ctx, false) || runtime.enforceNoProgressLimit(ctx)) return;
@@ -113,6 +162,7 @@ export function registerGoalLifecycle(
113
162
  }
114
163
  runtime.persistGoal(runtime.activeGoal);
115
164
  runtime.updateStatus(ctx, runtime.activeGoal);
165
+ runtime.scheduleGoalWaitWake(ctx);
116
166
  if (startRestoredQueuedGoal) {
117
167
  const restoredGoal = runtime.activeGoal;
118
168
  const sent = await runtime.sendOwnedGoalPrompt(
@@ -136,9 +186,123 @@ export function registerGoalLifecycle(
136
186
  }
137
187
  });
138
188
 
189
+ pi.on("session_before_tree", (_event, ctx) => {
190
+ const goal = runtime.activeGoal;
191
+ if (runtime.queueFrozen || goal?.status !== "active") return;
192
+ if (!runtime.recordGoalUsage(goal, ctx)) return;
193
+ runtime.persistGoal(goal);
194
+ runtime.updateStatus(ctx, goal);
195
+ });
196
+
197
+ pi.on("session_tree", (_event, ctx) => {
198
+ runtime.replaceMenuSession();
199
+ runtime.clearCompletionStatusTimer();
200
+ runtime.cancelContinuationWork();
201
+ runtime.clearContinuationTracking();
202
+ runtime.clearPendingGoalPrompts();
203
+ runtime.clearAgentRun();
204
+ runtime.pendingNonGoalInputs = [];
205
+ runtime.pendingDirectInputs = [];
206
+ runtime.ambiguousWaitInputGoalId = undefined;
207
+ runtime.acceptedRunPrompt = undefined;
208
+ runtime.inputWakeGoalId = undefined;
209
+ runtime.inputWakeNeedsPrompt = false;
210
+ runtime.guardAbortGoalId = undefined;
211
+ runtime.pendingStoppedUsageGoalId = undefined;
212
+ runtime.pendingReplacementUsage = undefined;
213
+ runtime.clearGoalRecovery();
214
+ runtime.clearBudgetWrapUp();
215
+ runtime.clearStaleGoalToolCallBlock();
216
+ runtime.clearTerminalDetails();
217
+
218
+ const loaded = loadGoalStateFromSession(ctx);
219
+ runtime.activeGoal = loaded.goal;
220
+ runtime.queuedGoals = loaded.queue;
221
+ runtime.pendingQueueAction = loaded.pendingAction;
222
+ runtime.queueFrozen = loaded.hasExperimentalQueueState && !runtime.settings.experimental.goals;
223
+ if (runtime.queueFrozen) {
224
+ runController.handleTreeNavigation(undefined);
225
+ ctx.ui.setStatus(STATUS_KEY, "queue off");
226
+ return;
227
+ }
228
+ if (!runtime.activeGoal) {
229
+ runController.handleTreeNavigation(undefined);
230
+ runtime.toolPolicy.reconcileRestoredState(runtime.settings.toolVisibility, false);
231
+ ctx.ui.setStatus(STATUS_KEY, undefined);
232
+ return;
233
+ }
234
+ let startQueuedHead = false;
235
+ if (runtime.activeGoal.status === "queued" && !runtime.pendingQueueAction) {
236
+ runtime.activeGoal = activateQueuedGoal(runtime.activeGoal, currentTokenTotal(ctx));
237
+ startQueuedHead = runtime.activeGoal.status === "active";
238
+ }
239
+ runController.handleTreeNavigation(runtime.activeGoal.id);
240
+ runtime.toolPolicy.reconcileRestoredState(runtime.settings.toolVisibility, true);
241
+ const pending = runtime.pendingQueueAction;
242
+ if (pending) {
243
+ afterTreeNavigationSettles(
244
+ ctx,
245
+ () => runtime.pendingQueueAction === pending,
246
+ () => commands.dispatchPendingQueueActionIfSettled(ctx),
247
+ );
248
+ }
249
+ if (runtime.activeGoal.status === "active") {
250
+ const usageIsFinalized =
251
+ runtime.pendingQueueAction?.kind === "prioritize" &&
252
+ runtime.pendingQueueAction.displacedUsageFinalized === true;
253
+ const unaccountedTokens =
254
+ usageIsFinalized || startQueuedHead || runtime.activeGoal.usageBaselinePending
255
+ ? 0
256
+ : assistantTokensAfterGoalState(ctx, loaded.source === "legacy-goals" ? "goals-state" : "goal-state");
257
+ const goalWithUsage = {
258
+ ...runtime.activeGoal,
259
+ tokensUsed: Math.min(Number.MAX_SAFE_INTEGER, runtime.activeGoal.tokensUsed + unaccountedTokens),
260
+ };
261
+ runtime.activeGoal = rebaseGoalUsage(
262
+ { ...goalWithUsage, usageBaselinePending: undefined },
263
+ currentTokenTotal(ctx),
264
+ );
265
+ if (runtime.activeGoal.safetyResetPending) runtime.activeGoal = resetGoalSafetyEpoch(runtime.activeGoal);
266
+ if (runtime.limitActiveGoalForBudget(ctx, false)) return;
267
+ if (runtime.enforceAutomaticTurnLimit(ctx, false) || runtime.enforceNoProgressLimit(ctx)) return;
268
+ if (!runtime.toolPolicy.toolsAvailable()) {
269
+ runtime.pauseGoalForUnavailableTools(ctx, false, false);
270
+ return;
271
+ }
272
+ }
273
+ runtime.persistGoal(runtime.activeGoal);
274
+ runtime.updateStatus(ctx, runtime.activeGoal);
275
+ runtime.scheduleGoalWaitWake(ctx);
276
+ if (startQueuedHead) {
277
+ const queuedHead = runtime.activeGoal;
278
+ afterTreeNavigationSettles(
279
+ ctx,
280
+ () => runtime.activeGoal?.id === queuedHead.id && runtime.activeGoal.status === "active",
281
+ async () => {
282
+ const sent = await runtime.sendOwnedGoalPrompt(ctx, queuedHead.id, buildGoalPrompt(queuedHead), false);
283
+ if (!sent && runtime.activeGoal?.id === queuedHead.id) {
284
+ runtime.stopActiveGoal(ctx, {
285
+ kind: "activation_rollback",
286
+ expectedGoalId: queuedHead.id,
287
+ restoreGoal: queuedHead,
288
+ abortTurn: false,
289
+ });
290
+ }
291
+ },
292
+ );
293
+ }
294
+ });
295
+
139
296
  pi.on("session_shutdown", (_event, ctx) => {
140
297
  runController.unbindSession();
141
298
  runtime.closeMenuSession();
299
+ runtime.inputWakeGoalId = undefined;
300
+ runtime.inputWakeNeedsPrompt = false;
301
+ runtime.pendingDirectInputs = [];
302
+ runtime.ambiguousWaitInputGoalId = undefined;
303
+ runtime.acceptedRunPrompt = undefined;
304
+ runtime.pendingInputWake = undefined;
305
+ runtime.clearGoalWaitWake();
142
306
  if (runtime.activeGoal) {
143
307
  if (!runtime.queueFrozen && runtime.activeGoal.status === "active") {
144
308
  runtime.recordGoalUsage(runtime.activeGoal, ctx, false);
@@ -149,6 +313,8 @@ export function registerGoalLifecycle(
149
313
  runtime.clearPendingGoalPrompts();
150
314
  runtime.clearAgentRun();
151
315
  runtime.guardAbortGoalId = undefined;
316
+ runtime.pendingStoppedUsageGoalId = undefined;
317
+ runtime.pendingReplacementUsage = undefined;
152
318
  runtime.clearGoalRecovery();
153
319
  runtime.clearBudgetWrapUp();
154
320
  runtime.clearStaleGoalToolCallBlock();
@@ -182,6 +348,7 @@ export function registerGoalLifecycle(
182
348
  if (runtime.activeGoal?.status !== "active") {
183
349
  runtime.clearGoalRecovery();
184
350
  if (runtime.pendingQueueAction) await commands.dispatchPendingQueueActionIfSettled(ctx);
351
+ runtime.scheduleGoalWaitWake(ctx);
185
352
  return;
186
353
  }
187
354
 
@@ -217,7 +384,11 @@ export function registerGoalLifecycle(
217
384
  runtime.scheduleContinuationDispatch(ctx, runtime.activeGoal.id);
218
385
  });
219
386
 
220
- pi.on("input", (event, ctx) => {
387
+ pi.on("session_compact_failed", (_event, ctx) => {
388
+ runtime.scheduleGoalWaitWake(ctx);
389
+ });
390
+
391
+ pi.on("input", (event, _ctx) => {
221
392
  if (event.source === "extension") {
222
393
  if (runtime.consumeCancelledContinuationPrompt(event.text) || runtime.consumeStaleOwnedGoalPrompt(event.text)) {
223
394
  return { action: "handled" as const };
@@ -229,8 +400,9 @@ export function registerGoalLifecycle(
229
400
  if (runtime.hasPendingOwnedGoalPrompt(event.text)) return;
230
401
  if (event.streamingBehavior === "steer" || event.streamingBehavior === "followUp") {
231
402
  runtime.noteQueuedNonGoalInput(event.text, event.streamingBehavior);
403
+ } else {
404
+ runtime.noteDirectInput(event.text, false);
232
405
  }
233
- runtime.clearGoalRecovery();
234
406
  return;
235
407
  }
236
408
  if (runtime.queueFrozen) return;
@@ -240,12 +412,10 @@ export function registerGoalLifecycle(
240
412
  return;
241
413
  }
242
414
  if (event.streamingBehavior === "steer") {
243
- runtime.noteQueuedNonGoalInput(event.text, "steer");
415
+ runtime.noteQueuedNonGoalInput(event.text, "steer", true);
416
+ } else {
417
+ runtime.noteDirectInput(event.text, true);
244
418
  }
245
- runtime.clearGoalRecovery();
246
- runtime.clearBudgetWrapUp();
247
- runtime.clearStaleGoalToolCallBlock();
248
- runtime.resetActiveSafetyEpoch(ctx);
249
419
  });
250
420
 
251
421
  pi.on("message_start", (event, ctx) => {
@@ -259,7 +429,11 @@ export function registerGoalLifecycle(
259
429
  return;
260
430
  }
261
431
  if (message.role === "custom") {
262
- if (runtime.isActiveBudgetWrapUpMessage(message)) return;
432
+ if (runtime.isActiveBudgetWrapUpMessage(message)) {
433
+ runtime.beginAgentRun(runtime.activeGoal?.id, "manual");
434
+ return;
435
+ }
436
+ runtime.finalizeStoppedRunUsage(ctx);
263
437
  if (runtime.guardAbortGoalId === runtime.activeGoal?.id) {
264
438
  runtime.guardAbortGoalId = undefined;
265
439
  }
@@ -278,18 +452,77 @@ export function registerGoalLifecycle(
278
452
  : "";
279
453
  const ownedPrompt = runtime.consumeOwnedGoalPrompt(prompt);
280
454
  const ownedPromptBoundary = runtime.hasOwnedPromptBoundary(prompt);
281
- const queuedNonGoalInput = runtime.consumeQueuedNonGoalInput(prompt, !ownedPromptBoundary);
455
+ const acceptedRunPrompt = runtime.acceptedRunPrompt;
456
+ const initialRunMessage =
457
+ acceptedRunPrompt !== undefined &&
458
+ (prompt === acceptedRunPrompt || prompt.startsWith(`${acceptedRunPrompt}\n\n`));
459
+ runtime.acceptedRunPrompt = undefined;
460
+ if (initialRunMessage && runtime.pendingInputWake) {
461
+ const ticket = runtime.pendingInputWake;
462
+ runtime.pendingInputWake = undefined;
463
+ if (!commands.commitWaitingGoalOnInput(ctx, ticket)) {
464
+ runtime.beginAgentRun(null, undefined);
465
+ abortCurrentTurn(ctx);
466
+ }
467
+ return;
468
+ }
469
+ const classifyInput = !ownedPromptBoundary && !initialRunMessage;
470
+ const collidingCandidates =
471
+ classifyInput && runtime.hasDirectInputCandidate(prompt) && runtime.hasQueuedInputCandidate(prompt);
472
+ const exactDirectInput = classifyInput ? runtime.consumeDirectInput(prompt, false) : undefined;
473
+ const exactQueuedInput = classifyInput ? runtime.consumeQueuedNonGoalInput(prompt, false) : undefined;
474
+ const agreeingRealInputs = Boolean(exactDirectInput?.realInput && exactQueuedInput?.realInput);
475
+ if (agreeingRealInputs && exactQueuedInput) runtime.pendingNonGoalInputs.unshift(exactQueuedInput);
476
+ const ambiguousInput =
477
+ !agreeingRealInputs && (collidingCandidates || Boolean(exactDirectInput && exactQueuedInput));
478
+ const deliveredDirectInput = ambiguousInput
479
+ ? exactDirectInput
480
+ ? { ...exactDirectInput, realInput: false }
481
+ : undefined
482
+ : (exactDirectInput ??
483
+ (exactQueuedInput ? undefined : classifyInput ? runtime.consumeDirectInput(prompt) : undefined));
484
+ const queuedNonGoalInput = ambiguousInput
485
+ ? exactQueuedInput
486
+ ? { ...exactQueuedInput, realInput: false }
487
+ : undefined
488
+ : !classifyInput || deliveredDirectInput
489
+ ? undefined
490
+ : (exactQueuedInput ?? runtime.consumeQueuedNonGoalInput(prompt));
491
+ if (!ownedPrompt && ownedPromptBoundary) return;
282
492
  if (!ownedPrompt) {
283
- if (queuedNonGoalInput?.behavior === "followUp") {
284
- beginNonGoalFollowUp(ctx, queuedNonGoalInput.resetSafetyEpoch);
493
+ if (initialRunMessage || deliveredDirectInput || queuedNonGoalInput) {
494
+ runtime.clearGoalRecovery();
495
+ if (deliveredDirectInput?.realInput || queuedNonGoalInput?.realInput) runtime.clearBudgetWrapUp();
496
+ runtime.clearStaleGoalToolCallBlock();
497
+ if (runtime.guardAbortGoalId === runtime.activeGoal?.id) runtime.guardAbortGoalId = undefined;
498
+ }
499
+ if (deliveredDirectInput?.realInput && deliveredDirectInput.waiting) {
500
+ if (commands.resumeWaitingGoalOnInput(ctx, true)) runtime.guardAbortGoalId = undefined;
501
+ } else if (deliveredDirectInput?.realInput) {
502
+ runtime.resetActiveSafetyEpoch(ctx);
503
+ } else if (queuedNonGoalInput?.behavior === "followUp") {
504
+ beginNonGoalFollowUp(ctx, queuedNonGoalInput.realInput, true);
505
+ } else if (queuedNonGoalInput?.realInput) {
506
+ if (commands.resumeWaitingGoalOnInput(ctx, true)) {
507
+ runtime.guardAbortGoalId = undefined;
508
+ } else if (queuedNonGoalInput.realInput) {
509
+ runtime.resetActiveSafetyEpoch(ctx);
510
+ }
285
511
  }
512
+ if (runtime.activeGoal?.status !== "active") runtime.beginAgentRun(null, undefined);
286
513
  return;
287
514
  }
288
515
  if (runtime.activeGoal?.id !== ownedPrompt.goalId || runtime.activeGoal.status !== "active") {
289
516
  return;
290
517
  }
291
- if (runtime.agentRunGoalId !== undefined && runtime.agentRunGoalId !== ownedPrompt.goalId) {
292
- runtime.activeGoal.baselineTokens = Math.max(0, currentTokenTotal(ctx) - runtime.activeGoal.tokensUsed);
518
+ if (
519
+ runtime.activeGoal.usageBaselinePending ||
520
+ (runtime.agentRunGoalId !== undefined && runtime.agentRunGoalId !== ownedPrompt.goalId)
521
+ ) {
522
+ runtime.activeGoal = rebaseGoalUsage(
523
+ { ...runtime.activeGoal, usageBaselinePending: undefined },
524
+ currentTokenTotal(ctx),
525
+ );
293
526
  }
294
527
  runtime.beginAgentRun(ownedPrompt.goalId, "manual");
295
528
  if (ownedPrompt.resetSafetyEpoch) {
@@ -306,6 +539,22 @@ export function registerGoalLifecycle(
306
539
  // context transformation aborts before the provider adapter receives the signal.
307
540
  abortCurrentTurn(ctx);
308
541
  }
542
+ const goal = runtime.activeGoal;
543
+ if (!runtime.queueFrozen && goal?.status === "active" && runtime.inputWakeGoalId === goal.id) {
544
+ // Native queued input reaches message_start without before_agent_start.
545
+ // Keep the rotated guard visible for every remaining response in this run,
546
+ // without enqueueing another user turn or changing persisted user input.
547
+ messages.push({
548
+ role: "custom",
549
+ customType: "goal-input-wake",
550
+ content: runtime.inputWakeNeedsPrompt
551
+ ? `${GOAL_BINDING_UPDATE_HEADER}\n\n${buildGoalSystemPrompt(goal)}`
552
+ : buildGoalBindingUpdate(goal),
553
+ display: false,
554
+ timestamp: Date.now(),
555
+ });
556
+ return { messages };
557
+ }
309
558
  if (messages.length !== event.messages.length) return { messages };
310
559
  });
311
560
 
@@ -372,6 +621,10 @@ export function registerGoalLifecycle(
372
621
  });
373
622
 
374
623
  pi.on("before_agent_start", (event, ctx) => {
624
+ runtime.pendingInputWake = undefined;
625
+ runtime.acceptedRunPrompt = typeof event.prompt === "string" ? event.prompt : undefined;
626
+ runtime.inputWakeGoalId = undefined;
627
+ runtime.inputWakeNeedsPrompt = false;
375
628
  runtime.clearAgentRun();
376
629
  if (runtime.queueFrozen) return;
377
630
  // Pi-owned retries emit agent_start directly. Reaching a normal prompt
@@ -384,15 +637,36 @@ export function registerGoalLifecycle(
384
637
  const ownedPromptGoalId = goalPromptGoalId ?? continuationGoalId;
385
638
  const ownedPromptBoundary = runtime.hasOwnedPromptBoundary(event.prompt);
386
639
  const activeBudgetWrapUp = runtime.hasActiveBudgetWrapUp();
640
+ const recoveryBeforeInput = runtime.hasActiveGoalRecovery();
641
+ const acceptedDirectInput =
642
+ ownedPromptBoundary || ownedPromptGoalId !== undefined ? undefined : runtime.consumeDirectInput(event.prompt);
643
+ const queuedNonGoalInput =
644
+ activeBudgetWrapUp || acceptedDirectInput
645
+ ? undefined
646
+ : runtime.consumeQueuedNonGoalInput(
647
+ event.prompt,
648
+ !recoveryBeforeInput && ownedPromptGoalId === undefined && !ownedPromptBoundary,
649
+ );
650
+ if (acceptedDirectInput || queuedNonGoalInput) runtime.clearGoalRecovery();
387
651
  const activeGoalRecovery = runtime.hasActiveGoalRecovery();
388
- const queuedNonGoalInput = activeBudgetWrapUp
389
- ? undefined
390
- : runtime.consumeQueuedNonGoalInput(
391
- event.prompt,
392
- !activeGoalRecovery && ownedPromptGoalId === undefined && !ownedPromptBoundary,
393
- );
652
+ if (acceptedDirectInput?.realInput || queuedNonGoalInput?.realInput) {
653
+ const ticket = commands.prepareWaitingGoalOnInput(ctx);
654
+ if (ticket) {
655
+ runtime.pendingInputWake = ticket;
656
+ runtime.beginAgentRun(null, undefined);
657
+ return { systemPrompt: `${event.systemPrompt}\n\n${buildGoalSystemPrompt(ticket.resumedGoal)}` };
658
+ }
659
+ }
660
+ if (!ownedPromptBoundary && ownedPromptGoalId === undefined && acceptedDirectInput?.realInput) {
661
+ if (acceptedDirectInput.waiting) commands.resumeWaitingGoalOnInput(ctx);
662
+ else runtime.resetActiveSafetyEpoch(ctx);
663
+ }
394
664
  if (queuedNonGoalInput?.behavior === "followUp") {
395
- beginNonGoalFollowUp(ctx, queuedNonGoalInput.resetSafetyEpoch);
665
+ beginNonGoalFollowUp(ctx, queuedNonGoalInput.realInput, false);
666
+ } else if (queuedNonGoalInput?.realInput) {
667
+ if (!commands.resumeWaitingGoalOnInput(ctx) && queuedNonGoalInput.realInput) {
668
+ runtime.resetActiveSafetyEpoch(ctx);
669
+ }
396
670
  }
397
671
  const runOrigin = continuationGoalId
398
672
  ? "automatic"
@@ -443,6 +717,12 @@ export function registerGoalLifecycle(
443
717
  runtime.pauseGoalForUnavailableTools(ctx, ownedPromptGoalId !== undefined);
444
718
  return;
445
719
  }
720
+ if (goalPromptGoalId === runtime.activeGoal.id && runtime.activeGoal.usageBaselinePending) {
721
+ runtime.activeGoal = rebaseGoalUsage(
722
+ { ...runtime.activeGoal, usageBaselinePending: undefined },
723
+ currentTokenTotal(ctx),
724
+ );
725
+ }
446
726
  if (goalPrompt?.resetSafetyEpoch && goalPromptGoalId === runtime.activeGoal.id) {
447
727
  runtime.activeGoal = resetGoalSafetyEpoch(runtime.activeGoal);
448
728
  runtime.persistGoal(runtime.activeGoal);
@@ -477,6 +757,21 @@ export function registerGoalLifecycle(
477
757
 
478
758
  pi.on("agent_end", (event, ctx) => {
479
759
  const run = runtime.finishAgentRun();
760
+ const replacement = runtime.pendingReplacementUsage;
761
+ if (replacement && replacement.runGoalId === run.goalId) {
762
+ runtime.pendingReplacementUsage = undefined;
763
+ const successor = runtime.activeGoal;
764
+ if (successor?.id === replacement.replacementGoalId) {
765
+ const rebasedSuccessor = rebaseGoalUsage(
766
+ { ...successor, usageBaselinePending: undefined },
767
+ currentTokenTotal(ctx),
768
+ );
769
+ runtime.activeGoal = rebasedSuccessor;
770
+ runtime.persistGoal(rebasedSuccessor);
771
+ if (runtime.activeGoal === rebasedSuccessor) runtime.updateStatus(ctx, rebasedSuccessor);
772
+ }
773
+ }
774
+ if (runtime.finalizeStoppedRunUsage(ctx, run.goalId)) return;
480
775
  if (runtime.queueFrozen || run.goalId === null) return;
481
776
  if (!runtime.canRecordGoalUsage() && !runtime.hasActiveBudgetWrapUp()) return;
482
777
  if (run.goalId && run.goalId !== runtime.activeGoal?.id) return;
@@ -582,15 +877,22 @@ export function registerGoalLifecycle(
582
877
  }
583
878
  if (!dispatchedQueueAction) runtime.dispatchContinuationIfSettled(ctx);
584
879
  runtime.clearSettledSafetyTracking();
880
+ runtime.acceptedRunPrompt = undefined;
881
+ runtime.pendingInputWake = undefined;
882
+ runtime.inputWakeGoalId = undefined;
883
+ runtime.inputWakeNeedsPrompt = false;
884
+ runtime.scheduleGoalWaitWake(ctx);
585
885
  });
586
886
 
587
- function beginNonGoalFollowUp(ctx: StatusContext, resetSafetyEpoch: boolean) {
887
+ function beginNonGoalFollowUp(ctx: StatusContext, realInput = false, inRun = false) {
888
+ const resumed = realInput && commands.resumeWaitingGoalOnInput(ctx, inRun);
889
+ if (resumed) runtime.guardAbortGoalId = undefined;
588
890
  runtime.clearGoalRecovery();
589
891
  runtime.clearStaleGoalToolCallBlock();
590
- if (resetSafetyEpoch) runtime.clearBudgetWrapUp();
892
+ if (realInput) runtime.clearBudgetWrapUp();
591
893
  const activeGoalId = runtime.activeGoal?.status === "active" ? runtime.activeGoal.id : undefined;
592
894
  runtime.beginAgentRun(activeGoalId ?? null, activeGoalId ? "manual" : undefined);
593
- if (resetSafetyEpoch && activeGoalId) runtime.resetActiveSafetyEpoch(ctx);
895
+ if (realInput && activeGoalId && !resumed) runtime.resetActiveSafetyEpoch(ctx);
594
896
  }
595
897
 
596
898
  function stopGoalAfterAgentEnd(
package/src/menu.ts CHANGED
@@ -597,7 +597,7 @@ function goalMainMenuItem(label: string): ActionMenuItem<GoalMenuScreen, GoalMen
597
597
 
598
598
  function refreshGoalMenuState(runtime: GoalMenuRuntimeView, ctx: ExtensionCommandContext) {
599
599
  const goal = runtime.activeGoal;
600
- if (!goal || runtime.queueFrozen) return;
600
+ if (!goal || runtime.queueFrozen || goal.status !== "active") return;
601
601
  runtime.recordGoalUsage?.(goal, ctx);
602
602
  runtime.persistGoal?.(goal);
603
603
  runtime.updateStatus?.(ctx, goal);
@@ -4,6 +4,7 @@ import { basename, dirname, join } from "node:path";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { isNonNegativeFiniteNumber, nonNegativeFiniteNumber, normalizeTokenBudget } from "./accounting.js";
6
6
  import type { GoalStatus } from "./prompts.js";
7
+ import { type GoalWait, normalizeGoalWait } from "./wait.js";
7
8
 
8
9
  const GOAL_STATE_ENTRY_TYPE = "goal-state";
9
10
  const LEGACY_GOALS_STATE_ENTRY_TYPE = "goals-state";
@@ -22,12 +23,17 @@ export interface ActiveGoal {
22
23
  tokensUsed: number;
23
24
  timeUsedSeconds: number;
24
25
  baselineTokens: number;
26
+ usageOffset?: number;
27
+ /** A preceding run still owns usage after this activation checkpoint. */
28
+ usageBaselinePending?: boolean;
25
29
  activeStartedAt?: number;
26
30
  automaticModelTurns: number;
27
31
  toolFreeRepeatCount: number;
28
32
  lastToolFreeOutputFingerprint?: string;
29
33
  safetyPauseCause?: SafetyPauseCause;
30
34
  safetyResetPending?: boolean;
35
+ /** Only a paused goal with this record may wake without /goal resume. */
36
+ wait?: GoalWait;
31
37
  }
32
38
 
33
39
  export type PendingQueueAction =
@@ -218,12 +224,18 @@ export function normalizeLoadedGoal(goal: ActiveGoal): ActiveGoal {
218
224
  tokensUsed: nonNegativeFiniteNumber(goal.tokensUsed),
219
225
  timeUsedSeconds: nonNegativeFiniteNumber(goal.timeUsedSeconds),
220
226
  baselineTokens: nonNegativeFiniteNumber(goal.baselineTokens),
227
+ usageOffset:
228
+ isNonNegativeFiniteNumber(goal.usageOffset) && goal.usageOffset <= nonNegativeFiniteNumber(goal.tokensUsed)
229
+ ? goal.usageOffset
230
+ : undefined,
231
+ usageBaselinePending: goal.usageBaselinePending === true ? true : undefined,
221
232
  activeStartedAt: goal.status === "active" ? now : undefined,
222
233
  automaticModelTurns: normalizeSafetyCounter(goal.automaticModelTurns),
223
234
  toolFreeRepeatCount: normalizeSafetyCounter(goal.toolFreeRepeatCount),
224
235
  lastToolFreeOutputFingerprint: normalizeOutputFingerprint(goal.lastToolFreeOutputFingerprint),
225
236
  safetyPauseCause: normalizeSafetyPauseCause(goal.safetyPauseCause),
226
237
  safetyResetPending: goal.safetyResetPending === true ? true : undefined,
238
+ wait: goal.status === "paused" && !goal.safetyPauseCause ? normalizeGoalWait(goal.wait) : undefined,
227
239
  };
228
240
  }
229
241
 
package/src/prompts.ts CHANGED
@@ -34,7 +34,14 @@ export function buildResumePrompt(goal: GoalPromptContext, stoppedStatus: GoalSt
34
34
  export function buildGoalSystemPrompt(goal: GoalPromptContext) {
35
35
  const budgetLine =
36
36
  goal.tokenBudget === undefined ? "" : `\n- Respect the goal token budget (${formatBudget(goal)} used).`;
37
- return `Active /goal:\n${goalContextBlock(goal)}\n\n${goalModeRules("the active goal")}${budgetLine}`;
37
+ return `Active /goal:\n${goalContextBlock(goal)}\n\nA later Goal runtime binding update can replace this goal_id after real input resumes a wait. Use its current goal_id, not the earlier binding.\n\n${goalModeRules("the active goal")}${budgetLine}`;
38
+ }
39
+
40
+ export const GOAL_BINDING_UPDATE_HEADER =
41
+ "Goal runtime binding update: real input resumed the waiting goal. This current goal_id supersedes the earlier binding.";
42
+
43
+ export function buildGoalBindingUpdate(goal: GoalPromptContext) {
44
+ return `${GOAL_BINDING_UPDATE_HEADER}\n\n${goalCompletionGuardBlock(goal)}`;
38
45
  }
39
46
 
40
47
  export function buildContinuePrompt(goal: GoalPromptContext, marker: string) {
package/src/queue.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { checkpointGoalActiveTime } from "./accounting.js";
2
+ import { checkpointGoalActiveTime, rebaseGoalUsage } from "./accounting.js";
3
3
  import type { ActiveGoal, PendingQueueAction } from "./persistence.js";
4
4
  import { resetGoalSafetyEpoch } from "./safety.js";
5
5
 
@@ -83,8 +83,7 @@ export function shelveGoal(goal: ActiveGoal, now = Date.now()): ActiveGoal {
83
83
 
84
84
  export function activateQueuedGoal(goal: ActiveGoal, currentTokenTotal: number, now = Date.now()): ActiveGoal {
85
85
  const rebased = {
86
- ...goal,
87
- baselineTokens: Math.max(0, currentTokenTotal - goal.tokensUsed),
86
+ ...rebaseGoalUsage(goal, currentTokenTotal),
88
87
  activeStartedAt: undefined,
89
88
  updatedAt: now,
90
89
  };
@@ -169,6 +169,21 @@ export class GoalRunController {
169
169
  this.usedRunIds.clear();
170
170
  }
171
171
 
172
+ handleTreeNavigation(goalId: string | undefined) {
173
+ const run = this.run;
174
+ if (!run || run.closed || run.goalId === goalId) return;
175
+ if (run.goalId) {
176
+ this.publishStateEvent(run, {
177
+ goalId: run.goalId,
178
+ status: "cleared",
179
+ reason: "managed Goal left by session tree navigation",
180
+ });
181
+ return;
182
+ }
183
+ this.closeCurrentRun();
184
+ this.emitError(run.runId, "start", "SUPERSEDED", "Goal run superseded by session tree navigation.");
185
+ }
186
+
172
187
  private async handleStart(data: unknown) {
173
188
  const runId = parseRunId(data);
174
189
  if (!runId) return;