@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/runtime.ts CHANGED
@@ -17,7 +17,7 @@ export { queueGoalSafetyReset, resetGoalSafetyEpoch } from "./safety.js";
17
17
 
18
18
  import { DEFAULT_GOAL_SETTINGS, type GoalSettings, type GoalSettingsLoadIssue } from "./settings.js";
19
19
  import { GoalToolPolicy, type GoalToolVisibilitySnapshot } from "./tool-policy.js";
20
- import { GoalWaitTimer } from "./wait.js";
20
+ import { type GoalWait, GoalWaitTimer } from "./wait.js";
21
21
 
22
22
  export { GOAL_BLOCKED_TOOL, GOAL_COMPLETE_TOOL, GOAL_TOOL_NAMES, GOAL_WAIT_TOOL } from "./tool-policy.js";
23
23
 
@@ -69,7 +69,7 @@ export type GoalStopRequest =
69
69
  * same stale-call block — but lands on `paused`, which is already the state
70
70
  * every resume path knows how to bring back.
71
71
  */
72
- | { kind: "wait"; expectedGoalId: string; reason: string }
72
+ | { kind: "wait"; expectedGoalId: string; wait: GoalWait }
73
73
  | {
74
74
  kind: "tools_unavailable";
75
75
  expectedGoalId: string;
@@ -152,6 +152,9 @@ export interface GoalSettingsRuntimeSnapshot {
152
152
  goalRecovery?: GoalRecovery;
153
153
  budgetWrapUp?: BudgetWrapUp;
154
154
  guardAbortGoalId?: string;
155
+ pendingStoppedUsageGoalId?: string;
156
+ pendingReplacementUsage?: { runGoalId: string | null; replacementGoalId: string };
157
+ ambiguousWaitInputGoalId?: string;
155
158
  staleGoalToolCallsBlocked: boolean;
156
159
  cancelledContinuationMarkers: string[];
157
160
  terminalDetails?: GoalTerminalDetails;
@@ -166,12 +169,26 @@ interface PendingGoalPrompt {
166
169
  interface PendingNonGoalInput {
167
170
  behavior: "steer" | "followUp";
168
171
  fingerprint: string;
169
- resetSafetyEpoch: boolean;
172
+ realInput: boolean;
173
+ }
174
+
175
+ interface PendingDirectInput {
176
+ goalId: string;
177
+ fingerprint: string;
178
+ realInput: boolean;
179
+ waiting: boolean;
180
+ }
181
+
182
+ export interface WaitingInputWake {
183
+ waitingGoal: ActiveGoal;
184
+ resumedGoal: ActiveGoal;
185
+ generation: number;
170
186
  }
171
187
 
172
188
  const MAX_CANCELLED_CONTINUATION_PROMPTS = 20;
173
189
  const MAX_PENDING_GOAL_PROMPTS = 20;
174
190
  const MAX_PENDING_NON_GOAL_INPUTS = 20;
191
+ const WAIT_WAKE_RECHECK_MS = 60_000;
175
192
  const BUDGET_WRAP_UP_MESSAGE_TYPE = "goal-budget-wrap-up";
176
193
  const BUDGET_WRAP_UP_PROMPT =
177
194
  "The active /goal token budget is exhausted. Stop substantive work and do not call substantive tools. Summarize progress, verified results, remaining work, and blockers concisely. Treat completion as unproven. Do not call goal_complete unless authoritative, requirement-by-requirement evidence already proves every requirement is complete. Weak, indirect, or missing evidence is not enough. Budget exhaustion is not completion.";
@@ -210,8 +227,22 @@ export class GoalRuntime {
210
227
  /** `null` marks a run that must not be charged to the active goal. */
211
228
  agentRunGoalId?: string | null;
212
229
  agentRunOrigin?: GoalRunOrigin;
230
+ /** Input wakes inside an existing run cannot refresh its system prompt. */
231
+ inputWakeGoalId?: string;
232
+ inputWakeNeedsPrompt = false;
233
+ /** Accepted idle inputs, not their early input hooks, own wait wakes. */
234
+ pendingDirectInputs: PendingDirectInput[] = [];
235
+ /** A mixed-source delivery cannot prove which waiting input remains pending. */
236
+ ambiguousWaitInputGoalId?: string;
237
+ /** The accepted prompt whose initial user message must not claim queued input. */
238
+ acceptedRunPrompt?: string;
239
+ pendingInputWake?: WaitingInputWake;
213
240
  agentRunToolAttempted = false;
214
241
  guardAbortGoalId?: string;
242
+ /** A stopped goal still owns its aborted run's final assistant usage. */
243
+ pendingStoppedUsageGoalId?: string;
244
+ /** Exclude a replaced run's late response from its successor's baseline. */
245
+ pendingReplacementUsage?: { runGoalId: string | null; replacementGoalId: string };
215
246
  staleGoalToolCallsBlocked = false;
216
247
  readonly toolPolicy: GoalToolPolicy;
217
248
  pendingGoalPromptMarkers = new Map<string, PendingGoalPrompt>();
@@ -319,6 +350,17 @@ export class GoalRuntime {
319
350
  return true;
320
351
  }
321
352
 
353
+ finalizeStoppedRunUsage(ctx: StatusContext, runGoalId = this.agentRunGoalId) {
354
+ if (!runGoalId || this.pendingStoppedUsageGoalId !== runGoalId) return false;
355
+ this.pendingStoppedUsageGoalId = undefined;
356
+ const goal = this.activeGoal;
357
+ if (goal?.id !== runGoalId || goal.status === "active") return false;
358
+ updateGoalUsage(goal, ctx, false);
359
+ this.persistGoal(goal);
360
+ if (this.activeGoal === goal) this.updateStatus(ctx, goal);
361
+ return true;
362
+ }
363
+
322
364
  requestContinuation(goal: ActiveGoal) {
323
365
  if (this.hasContinuationWorkForGoal(goal.id)) return false;
324
366
  const marker = continuationMarker(goal);
@@ -383,12 +425,19 @@ export class GoalRuntime {
383
425
  let terminalReason: string | undefined;
384
426
  switch (request.kind) {
385
427
  case "explicit_pause":
386
- this.recordGoalUsage(goal, ctx);
428
+ if (goal.status === "active") this.recordGoalUsage(goal, ctx);
387
429
  this.cancelContinuationWork();
388
430
  this.clearGoalRecoveryForGoal(goal.id);
389
431
  this.clearBudgetWrapUp();
390
- this.blockStaleGoalToolCalls();
391
- abortCurrentTurn(ctx);
432
+ if (goal.status === "active") {
433
+ if (this.agentRunGoalId === goal.id) this.pendingStoppedUsageGoalId = goal.id;
434
+ this.blockStaleGoalToolCalls();
435
+ abortCurrentTurn(ctx);
436
+ } else if (this.agentRunGoalId === goal.id && ctx.isIdle?.() === false) {
437
+ this.blockStaleGoalToolCalls();
438
+ } else {
439
+ this.clearStaleGoalToolCallBlock();
440
+ }
392
441
  status = "paused";
393
442
  break;
394
443
  case "budget_limit":
@@ -404,6 +453,7 @@ export class GoalRuntime {
404
453
  this.clearBudgetWrapUp();
405
454
  this.blockStaleGoalToolCalls();
406
455
  if (request.abortTurn) {
456
+ if (this.agentRunGoalId === goal.id) this.pendingStoppedUsageGoalId = goal.id;
407
457
  this.guardAbortGoalId = goal.id;
408
458
  abortCurrentTurn(ctx);
409
459
  }
@@ -418,7 +468,7 @@ export class GoalRuntime {
418
468
  this.clearBudgetWrapUp();
419
469
  this.blockStaleGoalToolCalls();
420
470
  status = "paused";
421
- terminalReason = request.reason;
471
+ terminalReason = request.wait.reason;
422
472
  break;
423
473
  case "retry_exhausted":
424
474
  this.clearGoalRecoveryForGoal(goal.id);
@@ -434,6 +484,7 @@ export class GoalRuntime {
434
484
  this.clearGoalRecoveryForGoal(goal.id);
435
485
  this.clearBudgetWrapUp();
436
486
  if (request.abortTurn) {
487
+ if (this.agentRunGoalId === goal.id) this.pendingStoppedUsageGoalId = goal.id;
437
488
  this.blockStaleGoalToolCalls();
438
489
  abortCurrentTurn(ctx);
439
490
  } else {
@@ -467,6 +518,10 @@ export class GoalRuntime {
467
518
  }
468
519
 
469
520
  this.activeGoal = transitionGoal(goal, status);
521
+ if (request.kind === "wait") {
522
+ this.activeGoal.wait = request.wait;
523
+ this.activeGoal.safetyPauseCause = undefined;
524
+ }
470
525
  if (terminalReason !== undefined) this.setTerminalReason(this.activeGoal.id, terminalReason);
471
526
  const stoppedGoal = this.activeGoal;
472
527
  this.persistGoal(stoppedGoal);
@@ -675,6 +730,8 @@ export class GoalRuntime {
675
730
 
676
731
  clearSettledSafetyTracking() {
677
732
  this.guardAbortGoalId = undefined;
733
+ this.pendingStoppedUsageGoalId = undefined;
734
+ this.pendingReplacementUsage = undefined;
678
735
  this.pendingNonGoalInputs = [];
679
736
  this.claimedGoalPromptMarkers.clear();
680
737
  this.claimedContinuationMarkers.clear();
@@ -736,21 +793,55 @@ export class GoalRuntime {
736
793
  */
737
794
  onGoalWaitElapsed?: (ctx: StatusContext, goalId: string) => void;
738
795
 
739
- scheduleGoalWaitWake(ctx: StatusContext, goalId: string, resumeAt: number): void {
740
- this.goalWaitTimer.schedule(resumeAt, () => {
741
- // Re-checked at fire time, not at schedule time: the goal may have been
742
- // completed, cleared, or replaced during the wait, and waking a goal that
743
- // is no longer the active one would resume the wrong work.
744
- if (this.activeGoal?.id !== goalId || this.activeGoal.status !== "paused") return;
745
- this.onGoalWaitElapsed?.(ctx, goalId);
796
+ scheduleGoalWaitWake(ctx: StatusContext, recheckAt?: number): void {
797
+ this.clearGoalWaitWake();
798
+ const goal = this.activeGoal;
799
+ if (this.queueFrozen || this.pendingQueueAction || goal?.status !== "paused" || !goal.wait) return;
800
+ const resumeAt = goal.wait.resumeAt;
801
+ if (resumeAt === undefined) return;
802
+ const generation = this.menuGeneration;
803
+ this.goalWaitTimer.schedule(recheckAt ?? resumeAt, () => {
804
+ if (
805
+ generation !== this.menuGeneration ||
806
+ this.activeGoal?.id !== goal.id ||
807
+ this.activeGoal.wait?.resumeAt !== resumeAt
808
+ )
809
+ return;
810
+ this.dispatchGoalWaitIfDue(ctx);
746
811
  });
747
812
  }
748
813
 
814
+ dispatchGoalWaitIfDue(ctx: StatusContext): void {
815
+ const goal = this.activeGoal;
816
+ if (
817
+ this.queueFrozen ||
818
+ this.pendingQueueAction ||
819
+ goal?.status !== "paused" ||
820
+ goal.wait?.resumeAt === undefined ||
821
+ goal.wait.resumeAt > Date.now()
822
+ )
823
+ return;
824
+ const pendingRealInput =
825
+ this.ambiguousWaitInputGoalId === goal.id ||
826
+ this.pendingInputWake?.waitingGoal === goal ||
827
+ this.pendingDirectInputs.some((pending) => pending.realInput && pending.waiting && pending.goalId === goal.id);
828
+ if (pendingRealInput || !this.toolPolicy.toolsAvailable() || ctx.isIdle?.() !== true || hasPendingMessages(ctx)) {
829
+ // Input preflight and tool-loadout changes need not emit agent_settled.
830
+ // Never infer rejection from elapsed time: a slow accepted input could
831
+ // otherwise race a second prompt. Explicit /goal resume remains available.
832
+ this.scheduleGoalWaitWake(ctx, Date.now() + WAIT_WAKE_RECHECK_MS);
833
+ return;
834
+ }
835
+ this.clearGoalWaitWake();
836
+ this.onGoalWaitElapsed?.(ctx, goal.id);
837
+ }
838
+
749
839
  clearGoalWaitWake(): void {
750
840
  this.goalWaitTimer.clear();
751
841
  }
752
842
 
753
843
  cancelContinuationWork() {
844
+ this.pendingInputWake = undefined;
754
845
  this.clearGoalWaitWake();
755
846
  this.clearContinuationDispatchTimer();
756
847
  if (this.continuationDelivery) {
@@ -821,11 +912,110 @@ export class GoalRuntime {
821
912
  return true;
822
913
  }
823
914
 
824
- noteQueuedNonGoalInput(prompt: string, behavior: "steer" | "followUp", resetSafetyEpoch = false) {
915
+ noteDirectInput(prompt: string, realInput: boolean) {
916
+ const goal = this.activeGoal;
917
+ if (this.queueFrozen || !goal || (goal.status !== "active" && (goal.status !== "paused" || !goal.wait))) return;
918
+ // Only the latest real idle attempt can be awaiting delivery; an earlier
919
+ // handled or rejected prompt must not make a transformed successor ambiguous.
920
+ if (realInput) {
921
+ this.pendingInputWake = undefined;
922
+ this.pendingDirectInputs = this.pendingDirectInputs.filter((pending) => !pending.realInput);
923
+ }
924
+ this.pendingDirectInputs.push({
925
+ goalId: goal.id,
926
+ fingerprint: inputFingerprint(prompt),
927
+ realInput,
928
+ waiting: goal.status === "paused",
929
+ });
930
+ if (this.pendingDirectInputs.length > MAX_PENDING_NON_GOAL_INPUTS) this.pendingDirectInputs.shift();
931
+ }
932
+
933
+ hasDirectInputCandidate(prompt: string) {
934
+ if (this.pendingDirectInputs.length === 0) return false;
935
+ const fingerprint = inputFingerprint(prompt);
936
+ return this.pendingDirectInputs.some((pending) => pending.fingerprint === fingerprint);
937
+ }
938
+
939
+ hasQueuedInputCandidate(prompt: string) {
940
+ if (this.pendingNonGoalInputs.length === 0) return false;
941
+ const fingerprint = inputFingerprint(prompt);
942
+ return this.pendingNonGoalInputs.some((pending) => pending.fingerprint === fingerprint);
943
+ }
944
+
945
+ consumeDirectInput(prompt: unknown, allowFallback = true) {
946
+ if (typeof prompt !== "string" || this.pendingDirectInputs.length === 0) return undefined;
947
+ const fingerprint = inputFingerprint(prompt);
948
+ const matches = this.pendingDirectInputs.filter((pending) => pending.fingerprint === fingerprint);
949
+ if (matches.length > 0) {
950
+ // Identical real and extension prompts have no source at this boundary.
951
+ // Drop ambiguous candidates rather than claiming another extension's turn.
952
+ if (matches.some((pending) => pending.realInput) && matches.some((pending) => !pending.realInput)) {
953
+ this.markAmbiguousWaitInput();
954
+ this.pendingDirectInputs = this.pendingDirectInputs.filter((pending) => pending.fingerprint !== fingerprint);
955
+ return { ...matches[0], realInput: false };
956
+ }
957
+ const index = this.pendingDirectInputs.findIndex((pending) => pending.fingerprint === fingerprint);
958
+ const [pending] = this.pendingDirectInputs.splice(index, 1);
959
+ return this.currentDirectInput(pending);
960
+ }
961
+ // Pi can expand templates or another input handler can transform the text.
962
+ // Without an exact match, only one pending candidate proves provenance.
963
+ if (!allowFallback || this.pendingDirectInputs.length !== 1) return undefined;
964
+ return this.currentDirectInput(this.pendingDirectInputs.shift());
965
+ }
966
+
967
+ private currentDirectInput(pending: PendingDirectInput | undefined) {
968
+ const goal = this.activeGoal;
969
+ if (
970
+ !pending ||
971
+ goal?.id !== pending.goalId ||
972
+ (goal.status !== "active" && !(goal.status === "paused" && goal.wait))
973
+ ) {
974
+ return undefined;
975
+ }
976
+ pending = { ...pending, waiting: goal.status === "paused" };
977
+ // A different extension input may be transformed to the same delivered text.
978
+ if (pending.realInput && this.hasUnresolvedExtensionInput()) {
979
+ this.markAmbiguousWaitInput();
980
+ return { ...pending, realInput: false };
981
+ }
982
+ return pending;
983
+ }
984
+
985
+ private markAmbiguousWaitInput() {
986
+ const goal = this.activeGoal;
987
+ if (goal?.status === "paused" && goal.wait) this.ambiguousWaitInputGoalId = goal.id;
988
+ }
989
+
990
+ private hasUnresolvedRealInput() {
991
+ return (
992
+ this.pendingDirectInputs.some((pending) => pending.realInput) ||
993
+ this.pendingNonGoalInputs.some((pending) => pending.realInput)
994
+ );
995
+ }
996
+
997
+ private hasUnresolvedExtensionInput() {
998
+ return (
999
+ this.pendingDirectInputs.some((pending) => !pending.realInput) ||
1000
+ this.pendingNonGoalInputs.some((pending) => !pending.realInput)
1001
+ );
1002
+ }
1003
+
1004
+ noteQueuedNonGoalInput(prompt: string, behavior: "steer" | "followUp", realInput = false) {
1005
+ const fingerprint = inputFingerprint(prompt);
1006
+ if (!realInput) {
1007
+ // Pi does not attach input provenance to message_start. An extension
1008
+ // message with identical text must not inherit a prior real-input wake.
1009
+ for (const pending of this.pendingNonGoalInputs) {
1010
+ if (pending.behavior === behavior && pending.fingerprint === fingerprint) {
1011
+ pending.realInput = false;
1012
+ }
1013
+ }
1014
+ }
825
1015
  this.pendingNonGoalInputs.push({
826
1016
  behavior,
827
- fingerprint: inputFingerprint(prompt),
828
- resetSafetyEpoch,
1017
+ fingerprint,
1018
+ realInput,
829
1019
  });
830
1020
  if (this.pendingNonGoalInputs.length > MAX_PENDING_NON_GOAL_INPUTS) {
831
1021
  this.pendingNonGoalInputs.shift();
@@ -833,10 +1023,18 @@ export class GoalRuntime {
833
1023
  }
834
1024
 
835
1025
  consumeQueuedNonGoalInput(prompt: string, allowDeliveryFallback = true) {
836
- if (typeof prompt !== "string") return undefined;
1026
+ if (typeof prompt !== "string" || this.pendingNonGoalInputs.length === 0) return undefined;
837
1027
  const fingerprint = inputFingerprint(prompt);
838
- // Pi delivers steers before follow-ups. Prefer a matching steer even when an
839
- // identical follow-up was queued first so it cannot steal follow-up ownership.
1028
+ const matching = this.pendingNonGoalInputs.filter((pending) => pending.fingerprint === fingerprint);
1029
+ if (matching.some((pending) => pending.realInput) && matching.some((pending) => !pending.realInput)) {
1030
+ // A follow-up may already be draining when a same-text steer arrives.
1031
+ // Without delivery identity, either lane can own this message.
1032
+ this.markAmbiguousWaitInput();
1033
+ this.pendingNonGoalInputs = this.pendingNonGoalInputs.filter((pending) => pending.fingerprint !== fingerprint);
1034
+ return { ...matching[0], realInput: false };
1035
+ }
1036
+ // Pi normally delivers steers before follow-ups. Prefer a matching steer
1037
+ // only when the candidates agree on real-input provenance.
840
1038
  const steerIndex = this.pendingNonGoalInputs.findIndex(
841
1039
  (pending) => pending.behavior === "steer" && pending.fingerprint === fingerprint,
842
1040
  );
@@ -846,7 +1044,15 @@ export class GoalRuntime {
846
1044
  : this.pendingNonGoalInputs.findIndex(
847
1045
  (pending) => pending.behavior === "followUp" && pending.fingerprint === fingerprint,
848
1046
  );
849
- if (exactIndex >= 0) return this.pendingNonGoalInputs.splice(exactIndex, 1)[0];
1047
+ if (exactIndex >= 0) {
1048
+ const [pending] = this.pendingNonGoalInputs.splice(exactIndex, 1);
1049
+ if (pending?.realInput && this.hasUnresolvedExtensionInput()) {
1050
+ this.markAmbiguousWaitInput();
1051
+ return { ...pending, realInput: false };
1052
+ }
1053
+ if (pending && !pending.realInput && this.hasUnresolvedRealInput()) this.markAmbiguousWaitInput();
1054
+ return pending;
1055
+ }
850
1056
  if (!allowDeliveryFallback) return undefined;
851
1057
 
852
1058
  // Skills, templates, and later input handlers can transform the raw text after
@@ -858,7 +1064,16 @@ export class GoalRuntime {
858
1064
  ? fallbackSteerIndex
859
1065
  : this.pendingNonGoalInputs.findIndex((pending) => pending.behavior === "followUp");
860
1066
  if (fallbackIndex < 0) return undefined;
861
- return this.pendingNonGoalInputs.splice(fallbackIndex, 1)[0];
1067
+ const fallback = this.pendingNonGoalInputs.splice(fallbackIndex, 1)[0];
1068
+ // A transformed input can still own its wake when it is the only pending
1069
+ // candidate. With multiple inputs Pi exposes no source at delivery, so do
1070
+ // not attribute an unmatched message to a real user.
1071
+ const unambiguousRealInput = Boolean(
1072
+ fallback?.realInput &&
1073
+ this.pendingNonGoalInputs.every((pending) => pending.realInput) &&
1074
+ this.pendingDirectInputs.every((pending) => pending.realInput),
1075
+ );
1076
+ return fallback ? { ...fallback, realInput: unambiguousRealInput } : undefined;
862
1077
  }
863
1078
 
864
1079
  consumeQueuedNonGoalFollowUpForAgentStart() {
@@ -866,7 +1081,7 @@ export class GoalRuntime {
866
1081
  // follow-up suppress cleanup until all earlier-priority steers have started.
867
1082
  if (this.pendingNonGoalInputs.some((pending) => pending.behavior === "steer")) return false;
868
1083
  const index = this.pendingNonGoalInputs.findIndex((pending) => pending.behavior === "followUp");
869
- if (index < 0) return false;
1084
+ if (index < 0 || (this.activeGoal?.wait && this.pendingNonGoalInputs[index]?.realInput)) return false;
870
1085
  this.pendingNonGoalInputs.splice(index, 1);
871
1086
  return true;
872
1087
  }
@@ -874,6 +1089,9 @@ export class GoalRuntime {
874
1089
  markContinuationStarted(prompt: string) {
875
1090
  const marker = extractContinuationMarker(prompt);
876
1091
  if (!marker) {
1092
+ // A paused wait is not continuation work. An unrelated prompt can fail
1093
+ // before agent_settled, so its deadline must remain armed.
1094
+ if (this.activeGoal?.status === "paused" && this.activeGoal.wait) return undefined;
877
1095
  // A user, retry, or another extension started newer work. Cancel both an
878
1096
  // unsent intent and a delivery that may have lost the non-atomic idle race;
879
1097
  // the newer work's agent_end will record a fresh intent.
@@ -910,6 +1128,9 @@ export class GoalRuntime {
910
1128
 
911
1129
  clearActiveGoal(ctx: StatusContext, reason = "goal cleared") {
912
1130
  const clearedGoal = this.activeGoal;
1131
+ this.ambiguousWaitInputGoalId = undefined;
1132
+ this.pendingStoppedUsageGoalId = undefined;
1133
+ this.pendingReplacementUsage = undefined;
913
1134
  this.cancelContinuationWork();
914
1135
  this.clearGoalRecovery();
915
1136
  this.clearBudgetWrapUp();
@@ -936,6 +1157,9 @@ export class GoalRuntime {
936
1157
  goalRecovery: this.goalRecovery ? structuredClone(this.goalRecovery) : undefined,
937
1158
  budgetWrapUp: this.budgetWrapUp ? structuredClone(this.budgetWrapUp) : undefined,
938
1159
  guardAbortGoalId: this.guardAbortGoalId,
1160
+ pendingStoppedUsageGoalId: this.pendingStoppedUsageGoalId,
1161
+ pendingReplacementUsage: this.pendingReplacementUsage ? { ...this.pendingReplacementUsage } : undefined,
1162
+ ambiguousWaitInputGoalId: this.ambiguousWaitInputGoalId,
939
1163
  staleGoalToolCallsBlocked: this.staleGoalToolCallsBlocked,
940
1164
  cancelledContinuationMarkers: [...this.cancelledContinuationMarkers],
941
1165
  terminalDetails: this.terminalDetails ? structuredClone(this.terminalDetails) : undefined,
@@ -955,6 +1179,11 @@ export class GoalRuntime {
955
1179
  this.goalRecovery = snapshot.goalRecovery ? structuredClone(snapshot.goalRecovery) : undefined;
956
1180
  this.budgetWrapUp = snapshot.budgetWrapUp ? structuredClone(snapshot.budgetWrapUp) : undefined;
957
1181
  this.guardAbortGoalId = snapshot.guardAbortGoalId;
1182
+ this.pendingStoppedUsageGoalId = snapshot.pendingStoppedUsageGoalId;
1183
+ this.pendingReplacementUsage = snapshot.pendingReplacementUsage
1184
+ ? { ...snapshot.pendingReplacementUsage }
1185
+ : undefined;
1186
+ this.ambiguousWaitInputGoalId = snapshot.ambiguousWaitInputGoalId;
958
1187
  this.staleGoalToolCallsBlocked = snapshot.staleGoalToolCallsBlocked;
959
1188
  this.cancelledContinuationMarkers = new Set(snapshot.cancelledContinuationMarkers);
960
1189
  this.terminalDetails = snapshot.terminalDetails ? structuredClone(snapshot.terminalDetails) : undefined;
@@ -1069,7 +1298,7 @@ export function transitionGoal(goal: ActiveGoal, requestedStatus: GoalStatus): A
1069
1298
  requestedStatus === "active" && goal.tokenBudget !== undefined && goal.tokensUsed >= goal.tokenBudget
1070
1299
  ? "budget_limited"
1071
1300
  : requestedStatus;
1072
- const next = { ...goal, status, updatedAt: now };
1301
+ const next = { ...goal, status, updatedAt: now, wait: undefined };
1073
1302
  checkpointGoalActiveTime(next, now, status === "active");
1074
1303
  return next;
1075
1304
  }
@@ -1105,6 +1334,7 @@ export function formatStatus(
1105
1334
  }
1106
1335
  return `paused · automatic limit ${goal.automaticModelTurns}/${automaticTurnLimit}`;
1107
1336
  }
1337
+ if (goal.status === "paused" && goal.wait) return `waiting · ${automatic}`;
1108
1338
  if (goal.status === "paused") return `paused · ${automatic}`;
1109
1339
  if (goal.status === "blocked") return `blocked · ${automatic}`;
1110
1340
  if (goal.status === "usage_limited") return `usage · ${automatic}`;
@@ -1135,6 +1365,10 @@ export function goalSummary(
1135
1365
  `Active elapsed: ${formatDuration(goal.timeUsedSeconds)}`,
1136
1366
  `Tokens: ${goal.tokenBudget === undefined ? formatTokenCount(goal.tokensUsed) : formatBudget(goal)}`,
1137
1367
  ];
1368
+ if (goal.wait) {
1369
+ summary.push(`Waiting: ${goal.wait.reason}`);
1370
+ if (goal.wait.resumeAt !== undefined) summary.push(`Wake deadline: ${new Date(goal.wait.resumeAt).toISOString()}`);
1371
+ }
1138
1372
  if (goal.safetyPauseCause) {
1139
1373
  summary.push(
1140
1374
  goal.safetyPauseCause === "continuation_limit"
package/src/tools.ts CHANGED
@@ -20,7 +20,13 @@ import {
20
20
  transitionGoal,
21
21
  truncateNotification,
22
22
  } from "./runtime.js";
23
- import { createGoalWait, MAX_GOAL_WAIT_REASON_LENGTH, MIN_GOAL_WAIT_DELAY_MS, resolveGoalWaitDelay } from "./wait.js";
23
+ import {
24
+ createGoalWait,
25
+ MAX_GOAL_WAIT_REASON_LENGTH,
26
+ MIN_GOAL_WAIT_DELAY_MS,
27
+ normalizeGoalWait,
28
+ resolveGoalWaitDelay,
29
+ } from "./wait.js";
24
30
 
25
31
  interface GoalCompleteDetails {
26
32
  goal: string;
@@ -366,17 +372,23 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: GoalRuntime) {
366
372
  if (goal.status !== "active") return reject(`goal is ${goal.status}, not active`);
367
373
  if (!reason) return reject("reason is empty");
368
374
 
369
- const wait = createGoalWait(reason, requestedResumeAfterMs);
375
+ if (
376
+ requestedResumeAfterMs !== undefined &&
377
+ (!Number.isSafeInteger(requestedResumeAfterMs) || requestedResumeAfterMs < 1)
378
+ )
379
+ return reject("resume_after_ms must be a positive safe integer");
380
+ const wait = normalizeGoalWait(createGoalWait(reason, requestedResumeAfterMs));
381
+ if (!wait) return reject("invalid wait reason or deadline");
370
382
  const stoppedGoal = runtime.stopActiveGoal(ctx, {
371
383
  kind: "wait",
372
384
  expectedGoalId: goal.id,
373
- reason,
385
+ wait,
374
386
  });
375
387
  if (!stoppedGoal) return reject("active goal changed before the wait took effect");
376
388
 
377
389
  // Armed after the stop, so a wake can never race a goal that is still
378
390
  // being transitioned out of `active`.
379
- if (wait.resumeAt !== undefined) runtime.scheduleGoalWaitWake(ctx, stoppedGoal.id, wait.resumeAt);
391
+ if (runtime.activeGoal === stoppedGoal) runtime.scheduleGoalWaitWake(ctx);
380
392
 
381
393
  const { requestedMs, effectiveMs } = resolveGoalWaitDelay(requestedResumeAfterMs);
382
394
  const clamped = requestedMs !== undefined && effectiveMs !== undefined && effectiveMs !== requestedMs;
package/src/wait.ts CHANGED
@@ -100,12 +100,16 @@ export class GoalWaitTimer {
100
100
  schedule(resumeAt: number, onDue: () => void): void {
101
101
  this.clear();
102
102
  const generation = this.generation;
103
- // A deadline already past fires on the next tick rather than never, and one
104
- // beyond the timer range is capped instead of overflowing to immediate.
103
+ // Long waits use bounded timer segments, never an early wake. Overdue
104
+ // deadlines run on the next tick so restore can finish before resuming.
105
105
  const delay = Math.max(0, Math.min(MAX_GOAL_WAIT_DELAY_MS, resumeAt - Date.now()));
106
106
  this.timer = setTimeout(() => {
107
107
  if (generation !== this.generation) return;
108
108
  this.timer = undefined;
109
+ if (resumeAt > Date.now()) {
110
+ this.schedule(resumeAt, onDue);
111
+ return;
112
+ }
109
113
  onDue();
110
114
  }, delay);
111
115
  }