@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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @signalridge/pi-goal
2
2
 
3
+ ## 1.4.4
4
+ ### Patch Changes
5
+
6
+ - ef0158d: Persist goal wait reasons and deadlines, restore scheduled wakes, and resume recorded waits on real input without an extra prompt. Keep deliberate pauses stopped and cancel stale wait timers.
7
+ - 4d300c7: Wake waiting goals only after real input is accepted, exclude unrelated usage from their budgets, preserve cumulative usage across compaction and session-tree navigation, and restore deadline timers across pause, resume, and queue transitions. Recheck overdue waits without overriding restrictive tool policies, reset safety only for delivered input, and reduce repeated goal-binding context.
8
+
9
+ ## 1.4.3
10
+ ### Patch Changes
11
+
12
+ - 8190516: Extend the tested Pi host compatibility range through 0.87.x while retaining supported older hosts. Validate the published extensions against Pi 0.87.1 and adapt changed provider, session-context, and lifecycle contracts where necessary.
13
+ - 8190516: Add Pi 0.99.1 to the supported host peer ranges while retaining Pi 0.84–0.87 compatibility. Align Pi development dependency pins with 0.99.1 for validation against the new host.
14
+ - Updated dependencies [8190516]
15
+ - Updated dependencies [8190516]
16
+ - @signalridge/pi-ui@1.3.2
17
+
3
18
  ## 1.4.2
4
19
  ### Patch Changes
5
20
 
package/README.md CHANGED
@@ -38,7 +38,7 @@ Goal mode uses Codex-like persistence instructions and sends guarded continuatio
38
38
 
39
39
  ## Install
40
40
 
41
- Supports Pi `0.84.x` and `0.85.x`; tested with `0.85.1` — see the `peerDependencies` range. The `agent_settled` lifecycle event this extension depends on landed in `0.80.6`.
41
+ The declared Pi peer range covers `0.84.x`–`0.87.x` and `0.99.1+` within the 0.99 minor; the current development host is `0.99.1`. See the [compatibility review](https://github.com/signalridge/pi-extensions/blob/main/docs/pi-0.99-compatibility.md) for verification details. The `agent_settled` lifecycle event this extension depends on landed in `0.80.6`.
42
42
 
43
43
  ```bash
44
44
  pi install npm:@signalridge/pi-goal
@@ -141,11 +141,11 @@ Tool visibility is a baseline, not ownership of Pi's global active-tool list. Pl
141
141
  navigate, Enter selects or submits, Escape goes Back, and Ctrl+C closes the full flow.
142
142
  - In RPC mode, bare `/goal` and `/goal status` report the current summary through an observable notification without opening terminal UI. Pi exposes no extension-command output channel in print or JSON mode, so those routes reject with an explicit unsupported-mode error instead of misreporting stderr as status output.
143
143
  - Menu-driven Replace, Clear, Prioritize, Skip, and Drop last actions preview the exact affected goals and require confirmation. Existing direct routes remain immediate for compatibility and automation.
144
- - `/goal <goal_to_complete>` starts goal mode. If another unfinished goal exists, Pi asks for confirmation before replacing it with a new active goal and resetting its usage counters. Failed kickoff delivery clears a new goal or restores the prior goal; a previously active goal is restored as paused.
144
+ - `/goal <goal_to_complete>` starts goal mode. If another unfinished goal exists, Pi asks for confirmation before replacing it with a new active goal and resetting its usage counters. A synchronous kickoff-submission failure clears a new goal or restores the prior goal; a previously active goal is restored as paused.
145
145
  - `/goal --tokens 100k <goal_to_complete>` starts or replaces goal mode with a token budget. `k` and `m` suffixes are accepted, for example `100k` or `1.5m`.
146
- - `/goal edit <goal_to_complete>` updates the existing goal objective without resetting usage counters. A successful active edit rotates the stale-turn guard and starts a fresh safety epoch. Paused, blocked, and usage-limited goals stay stopped and retain their safety state until resume. A budget-limited goal reactivates only when `edit --tokens` raises its budget above current usage. Failed prompt delivery restores the exact previous safety counters/cause; it restores a budget-limited goal or restores and pauses a previously active goal.
147
- - `/goal pause` stops prompt injection and auto-continuation, aborts the current turn, and keeps the goal for later resume. Only active goals can be paused.
148
- - `/goal resume` resumes a paused, blocked, usage-limited, or budget-limited goal when its token budget allows it, rotates the stale-turn guard id, resets the automatic-response/repeat safety epoch when the queued resume prompt starts, clears a safety-pause cause, and reports the new finite epoch or explicit Unlimited state. Objective, cumulative usage, elapsed time, and queue are preserved. If prompt delivery fails, the original stopped state, guard id, counters, fingerprint, and cause are restored.
146
+ - `/goal edit <goal_to_complete>` updates the existing goal objective without resetting usage counters. A successful active edit rotates the stale-turn guard and starts a fresh safety epoch. Paused, blocked, and usage-limited goals stay stopped and retain their safety state until resume. A budget-limited goal reactivates only when `edit --tokens` raises its budget above current usage. A synchronous prompt-submission failure restores the exact previous safety counters/cause; it restores a budget-limited goal or restores and pauses a previously active goal.
147
+ - `/goal pause` stops prompt injection and auto-continuation and keeps the goal for later resume. It aborts an active goal's current turn; pausing an already-waiting goal does not interrupt unrelated work. Pausing a waiting goal cancels its deadline and input wake; only `/goal resume` can restart that deliberate pause.
148
+ - `/goal resume` resumes a paused, blocked, usage-limited, or budget-limited goal when its token budget allows it, rotates the stale-turn guard id, resets the automatic-response/repeat safety epoch when the queued resume prompt starts, clears a safety-pause cause, and reports the new finite epoch or explicit Unlimited state. Objective, cumulative usage, elapsed time, and queue are preserved. If prompt submission throws synchronously, the original stopped state, guard id, counters, fingerprint, and cause are restored.
149
149
  - `/goal clear` clears the current goal or the entire ordered queue, status, pending continuation/transition, and legacy persisted state for the current working directory without aborting unrelated in-flight work.
150
150
 
151
151
  With `experimental.goals: true`:
@@ -163,7 +163,11 @@ Goal objectives are limited to 4,000 characters. Put longer instructions in a fi
163
163
 
164
164
  ## Session and reload behavior
165
165
 
166
- Goal state is stored as Pi session state, similar to Codex's thread-owned goals. `/reload` and reopening the same Pi session can restore that session's unfinished goal. An active restored goal already at or above its finite automatic-work limit pauses before another provider request and reports that progress is saved; use `/goal` to review and continue. With `"after-first-goal"`, an unfinished restore marks the tools unlocked in the new extension runtime, but it does not widen an active-tool set already restricted by an earlier lifecycle handler; an active goal instead restores as paused when either terminal tool is missing. If no unfinished goal remains, a fresh runtime starts locked again. Active elapsed time is checkpointed before shutdown and restarted after reload, so offline and stopped wall-clock time is excluded. Automatic-response counts, repeat fingerprints, and safety-pause causes persist across reload and compaction. A direct non-`/goal` user/RPC input resets the safety epoch only while the goal is active and reclassifies the in-flight run as manual; extension input and messages sent while stopped do not reset it. Starting a new Pi session in the same working directory does not inherit the old goal.
166
+ Goal state is stored as Pi session state, similar to Codex's thread-owned goals. `/reload` and reopening the same Pi session can restore that session's unfinished goal. An active restored goal already at or above its finite automatic-work limit pauses before another provider request and reports that progress is saved; use `/goal` to review and continue. With `"after-first-goal"`, an unfinished restore marks the tools unlocked in the new extension runtime, but it does not widen an active-tool set already restricted by an earlier lifecycle handler; an active goal instead restores as paused when either terminal tool is missing. If no unfinished goal remains, a fresh runtime starts locked again. Active elapsed time is checkpointed before shutdown and restarted after reload, so offline and stopped wall-clock time is excluded. Automatic-response counts, repeat fingerprints, and safety-pause causes persist across reload and compaction. An accepted non-`/goal` user/RPC input resets the safety epoch only while the goal is active and reclassifies the in-flight run as manual; input consumed by another extension or rejected before a turn starts does not reset it. Navigating the session tree checkpoints the source goal's active time, restores the destination branch's own Goal state, and accounts only for assistant work after that branch's last Goal-state checkpoint. Pending queue transitions resume after navigation settles; leaving a managed Goal branch closes that run. Starting a new Pi session in the same working directory does not inherit the old goal.
167
+
168
+ A `goal_wait` pause stores its reason and optional absolute wake deadline in the same session entry. On reload or session resume, a future deadline is re-armed; an overdue deadline gets one resume attempt after Pi is idle and pending messages have drained. No timer runs while Pi is closed. A wait without a deadline stays quiet until real interactive or RPC input is accepted for delivery, or `/goal resume` is used. Input consumed by another extension or rejected before the turn starts leaves the wait intact. Accepted real input resumes only a recorded wait and supplies the turn itself, without an extra resume prompt. A queued follow-up wakes the wait when delivered, not when queued. If delivery stays inside an existing agent run, the context hook supplies the rotated goal ID before its next response; it does not add another user turn. Extension-generated input and `/goal` commands do not count as real-input wake triggers. When Pi cannot distinguish real and extension input at delivery (for example, identical queued text or cross-transformed messages), the goal stays waiting rather than accepting a possible extension wake. That ambiguity remains attached to the waiting goal, including its deadline, until explicit `/goal resume` or reload; later input is not used to guess which earlier message was delivered. Automatic wakes do not override a restrictive active-tool policy; an overdue deadline rechecks readiness at most once per minute until Goal tools are available, while a settled boundary may wake sooner. An input whose delivery has not been confirmed also defers the deadline rather than racing a second prompt; if that input was rejected, resolve the failure and use `/goal resume` explicitly.
169
+
170
+ Ordinary pauses, safety pauses, blockers, usage limits, exhausted budgets, and frozen queues do not auto-resume. Older paused entries without wait metadata remain paused; the extension does not infer a wait from transcript text. `/goal pause`, edit, clear, and replacement cancel the old wait. A synchronous deadline prompt-submission failure restores the stopped goal and wait metadata without a timer retry loop; use `/goal resume` or reload after resolving the problem. Pi's extension API does not acknowledge asynchronous prompt acceptance, so a later authentication or model failure cannot be rolled back by the extension; if no turn starts, inspect `/goal status` and explicitly pause/resume or reload after fixing the failure. Objective, cumulative usage, and the configured finite or Unlimited automatic-work setting are retained.
167
171
 
168
172
  Ordered queues use the same canonical `goal-state` session entry as single goals. Every item owns independent usage and safety state. Shelving, priority displacement, automatic advancement, and later reactivation preserve that item's epoch rather than granting more automatic work. The legacy `{ goal }` shape remains valid, and missing safety fields normalize to zero/defaults. Queue fields are written only when needed. Sessions created by the former standalone `pi-goals` experiment can migrate their last `goals-state` array and pending `unshift` intent when the branch has never written a canonical `goal-state`; any canonical entry, including an explicit clear, takes precedence so old plural state cannot be resurrected.
169
173
 
@@ -180,6 +184,7 @@ Older versions wrote unfinished goals to `~/.pi/agent/pi-goal-state.json` keyed
180
184
  - `active 3m · automatic Unlimited` — explicit Unlimited automatic work.
181
185
  - `paused · automatic limit 25/25` — the automatic-work limit paused the goal; `/goal` opens the recovery preview.
182
186
  - `paused · automatic 12/25` — another pause reason stopped work while preserving the finite epoch.
187
+ - `waiting · automatic Unlimited` — a recorded `goal_wait` pause; `/goal status` shows its reason and any deadline.
183
188
  - `blocked · automatic 12/25` — progress requires user or external action, or a terminal non-usage error stopped work.
184
189
  - `usage · automatic 12/25` — the provider or account usage limit stopped work.
185
190
  - `budget 100k/100k · automatic 12/25` — the user-configured token budget was reached; auto-continuation stops.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-goal",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Pi extension for autonomous /goal completion with opt-in experimental ordered queues.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,22 +36,22 @@
36
36
  "lint": "biome check ."
37
37
  },
38
38
  "peerDependencies": {
39
- "@earendil-works/pi-ai": "^0.84.0 || ^0.85.0",
40
- "@earendil-works/pi-coding-agent": "^0.84.0 || ^0.85.0",
41
- "@earendil-works/pi-tui": "^0.84.0 || ^0.85.0",
39
+ "@earendil-works/pi-ai": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1",
40
+ "@earendil-works/pi-coding-agent": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1",
41
+ "@earendil-works/pi-tui": "^0.84.0 || ^0.85.0 || ^0.86.0 || ^0.87.0 || ^0.99.1",
42
42
  "typebox": "^1.3.11"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@biomejs/biome": "2.5.7",
46
- "@earendil-works/pi-ai": "0.85.1",
47
- "@earendil-works/pi-coding-agent": "0.85.1",
48
- "@earendil-works/pi-tui": "0.85.1",
46
+ "@earendil-works/pi-ai": "0.99.1",
47
+ "@earendil-works/pi-coding-agent": "0.99.1",
48
+ "@earendil-works/pi-tui": "0.99.1",
49
49
  "typebox": "1.3.11",
50
50
  "typescript": "7.0.2"
51
51
  },
52
52
  "dependencies": {
53
53
  "@narumitw/pi-tui-kit": "^0.54.0",
54
- "@signalridge/pi-ui": "^1.3.1"
54
+ "@signalridge/pi-ui": "^1.3.2"
55
55
  },
56
56
  "repository": {
57
57
  "type": "git",
package/src/accounting.ts CHANGED
@@ -3,6 +3,8 @@ import type { Usage } from "@earendil-works/pi-ai";
3
3
  export interface GoalAccountingState {
4
4
  status: string;
5
5
  baselineTokens: number;
6
+ /** Usage retained from before the current session-token baseline. */
7
+ usageOffset?: number;
6
8
  tokensUsed: number;
7
9
  timeUsedSeconds: number;
8
10
  activeStartedAt?: number;
@@ -29,15 +31,36 @@ export function checkpointGoalActiveTime(goal: GoalAccountingState, now: number,
29
31
  goal.activeStartedAt = continueClock ? now : undefined;
30
32
  }
31
33
 
34
+ export function rebaseGoalUsage<T extends GoalAccountingState>(goal: T, totalTokens: number): T {
35
+ const total = nonNegativeFiniteNumber(totalTokens);
36
+ const used = nonNegativeFiniteNumber(goal.tokensUsed);
37
+ return {
38
+ ...goal,
39
+ baselineTokens: Math.max(0, total - used),
40
+ usageOffset: total < used ? used - total : undefined,
41
+ };
42
+ }
43
+
32
44
  export function updateGoalUsage(
33
45
  goal: GoalAccountingState,
34
46
  ctx: UsageContext,
35
47
  continueClock = goal.status === "active",
36
48
  ) {
37
49
  const now = Date.now();
38
- const baselineTokens = nonNegativeFiniteNumber(goal.baselineTokens);
39
- goal.baselineTokens = baselineTokens;
40
- goal.tokensUsed = Math.max(0, currentTokenTotal(ctx) - baselineTokens);
50
+ const total = currentTokenTotal(ctx);
51
+ goal.baselineTokens = nonNegativeFiniteNumber(goal.baselineTokens);
52
+ goal.tokensUsed = nonNegativeFiniteNumber(goal.tokensUsed);
53
+ goal.usageOffset = Math.min(goal.tokensUsed, nonNegativeFiniteNumber(goal.usageOffset)) || undefined;
54
+ const previousTotal = goal.baselineTokens + goal.tokensUsed - (goal.usageOffset ?? 0);
55
+ if (total < previousTotal) {
56
+ const rebased = rebaseGoalUsage(goal, total);
57
+ goal.baselineTokens = rebased.baselineTokens;
58
+ goal.usageOffset = rebased.usageOffset;
59
+ }
60
+ goal.tokensUsed = Math.min(
61
+ Number.MAX_SAFE_INTEGER,
62
+ (goal.usageOffset ?? 0) + Math.max(0, total - goal.baselineTokens),
63
+ );
41
64
  checkpointGoalActiveTime(goal, now, continueClock);
42
65
  goal.updatedAt = now;
43
66
  }
@@ -100,3 +123,14 @@ export function currentTokenTotal(ctx: UsageContext): number {
100
123
  const sessionManager = ctx.sessionManager as { getBranch?: () => unknown[] } | undefined;
101
124
  return cumulativeAssistantTokens(sessionManager?.getBranch?.() ?? []);
102
125
  }
126
+
127
+ export function assistantTokensAfterGoalState(ctx: UsageContext, customType = "goal-state") {
128
+ const sessionManager = ctx.sessionManager as { getBranch?: () => unknown[] } | undefined;
129
+ const branch = sessionManager?.getBranch?.() ?? [];
130
+ let lastStateIndex = -1;
131
+ for (let index = 0; index < branch.length; index++) {
132
+ const entry = branch[index] as { type?: unknown; customType?: unknown };
133
+ if (entry?.type === "custom" && entry.customType === customType) lastStateIndex = index;
134
+ }
135
+ return lastStateIndex < 0 ? 0 : cumulativeAssistantTokens(branch.slice(lastStateIndex + 1));
136
+ }
package/src/commands.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { checkpointGoalActiveTime, currentTokenTotal, formatTokenCount } from "./accounting.js";
1
+ import { checkpointGoalActiveTime, currentTokenTotal, formatTokenCount, rebaseGoalUsage } from "./accounting.js";
2
2
  import { validateObjective } from "./command.js";
3
3
  import { notifyTerminal, safeGoalMenuText } from "./errors.js";
4
4
  import type { ActiveGoal } from "./persistence.js";
@@ -24,10 +24,12 @@ import {
24
24
  isResumableGoalStatus,
25
25
  nextGoalInstance,
26
26
  queueGoalSafetyReset,
27
+ resetGoalSafetyEpoch,
27
28
  STATUS_KEY,
28
29
  type StatusContext,
29
30
  stoppedStatusLabel,
30
31
  transitionGoal,
32
+ type WaitingInputWake,
31
33
  } from "./runtime.js";
32
34
 
33
35
  // User-command mutations are kept separate from Pi event wiring. Every controller
@@ -56,6 +58,7 @@ export class GoalCommandController {
56
58
 
57
59
  const existingGoal = this.runtime.activeGoal?.status !== "complete" ? this.runtime.activeGoal : undefined;
58
60
  const existingQueuedGoals = [...this.runtime.queuedGoals];
61
+ const existingReplacementUsage = this.runtime.pendingReplacementUsage;
59
62
  const existingQueueIdentity = goalQueueIdentity(
60
63
  this.runtime.activeGoal,
61
64
  this.runtime.queuedGoals,
@@ -106,6 +109,14 @@ export class GoalCommandController {
106
109
  this.runtime.pendingQueueAction = undefined;
107
110
  this.runtime.activeGoal = createGoal(objective, tokenBudget, currentTokenTotal(ctx));
108
111
  const startedGoal = this.runtime.activeGoal;
112
+ this.runtime.pendingReplacementUsage =
113
+ this.runtime.agentRunGoalId !== undefined
114
+ ? { runGoalId: this.runtime.agentRunGoalId, replacementGoalId: startedGoal.id }
115
+ : undefined;
116
+ if (this.runtime.pendingReplacementUsage) startedGoal.usageBaselinePending = true;
117
+ this.runtime.ambiguousWaitInputGoalId = undefined;
118
+ this.runtime.pendingDirectInputs = [];
119
+ this.runtime.pendingNonGoalInputs = [];
109
120
  onActivated?.(startedGoal);
110
121
  this.runtime.persistGoal(startedGoal);
111
122
  if (this.runtime.activeGoal?.id !== startedGoal.id || this.runtime.activeGoal.status !== "active") {
@@ -126,7 +137,7 @@ export class GoalCommandController {
126
137
  rolledBackStartedGoal = true;
127
138
  if (existingGoal) {
128
139
  this.runtime.queuedGoals = existingQueuedGoals;
129
- this.runtime.recordGoalUsage(existingGoal, ctx);
140
+ if (existingGoal.status === "active") this.runtime.recordGoalUsage(existingGoal, ctx);
130
141
  if (existingGoal.status === "active") {
131
142
  this.runtime.stopActiveGoal(ctx, {
132
143
  kind: "activation_rollback",
@@ -149,7 +160,9 @@ export class GoalCommandController {
149
160
  }
150
161
  }
151
162
  if (rolledBackStartedGoal) {
163
+ this.runtime.pendingReplacementUsage = existingReplacementUsage;
152
164
  this.runtime.toolPolicy.restore(goalToolVisibilityBeforeActivation);
165
+ this.runtime.scheduleGoalWaitWake(ctx);
153
166
  }
154
167
  return;
155
168
  }
@@ -274,6 +287,7 @@ export class GoalCommandController {
274
287
  return this.dispatchPendingQueueActionIfSettled(ctx);
275
288
  }
276
289
  const goal = this.runtime.activeGoal;
290
+ this.runtime.scheduleGoalWaitWake(ctx);
277
291
  if (goal?.status !== "active") return false;
278
292
  this.runtime.requestContinuation(goal);
279
293
  return this.runtime.dispatchContinuationIfSettled(ctx);
@@ -330,6 +344,7 @@ export class GoalCommandController {
330
344
  if (blocksStaleGoalToolCalls(this.runtime.activeGoal.status)) {
331
345
  this.runtime.blockStaleGoalToolCalls();
332
346
  }
347
+ this.runtime.scheduleGoalWaitWake(ctx);
333
348
  notifyTerminal(
334
349
  ctx.ui,
335
350
  `${reason === "complete" ? "Goal complete" : "Goal skipped"}: ${previousText}. Next goal remains ${this.runtime.activeGoal.status}: ${this.runtime.activeGoal.text}`,
@@ -388,8 +403,12 @@ export class GoalCommandController {
388
403
  notifyTerminal(ctx.ui, "No active goal.", "info");
389
404
  return;
390
405
  }
391
- if (this.runtime.activeGoal.status !== "active") {
392
- notifyTerminal(ctx.ui, `Goal is ${this.runtime.activeGoal.status}; only active goals can be paused.`, "warning");
406
+ if (this.runtime.activeGoal.status !== "active" && !this.runtime.activeGoal.wait) {
407
+ notifyTerminal(
408
+ ctx.ui,
409
+ `Goal is ${this.runtime.activeGoal.status}; only active or waiting goals can be paused.`,
410
+ "warning",
411
+ );
393
412
  return;
394
413
  }
395
414
  const stoppedGoal = this.runtime.stopActiveGoal(ctx, {
@@ -399,7 +418,74 @@ export class GoalCommandController {
399
418
  if (stoppedGoal) notifyTerminal(ctx.ui, `Goal paused: ${stoppedGoal.text}`, "info");
400
419
  }
401
420
 
402
- async resumeGoal(ctx: StatusContext) {
421
+ private canWakeWaitingGoal() {
422
+ const goal = this.runtime.activeGoal;
423
+ return Boolean(
424
+ !this.runtime.queueFrozen &&
425
+ !this.runtime.pendingQueueAction &&
426
+ goal?.status === "paused" &&
427
+ goal.wait &&
428
+ this.runtime.ambiguousWaitInputGoalId !== goal.id &&
429
+ this.runtime.toolPolicy.toolsAvailable() &&
430
+ (goal.tokenBudget === undefined || goal.tokensUsed < goal.tokenBudget),
431
+ );
432
+ }
433
+
434
+ prepareWaitingGoalOnInput(ctx: StatusContext): WaitingInputWake | undefined {
435
+ const goal = this.runtime.activeGoal;
436
+ if (!goal || !this.canWakeWaitingGoal()) return undefined;
437
+ // Reserve the prompt binding without changing the persisted wait. Pi can
438
+ // still reject images after before_agent_start, before any message starts.
439
+ const resumedGoal = resetGoalSafetyEpoch(
440
+ transitionGoal(nextGoalInstance(rebaseGoalUsage(goal, currentTokenTotal(ctx))), "active"),
441
+ );
442
+ return { waitingGoal: goal, resumedGoal, generation: this.runtime.menuGeneration };
443
+ }
444
+
445
+ commitWaitingGoalOnInput(ctx: StatusContext, ticket: WaitingInputWake, inRun = false) {
446
+ if (
447
+ ticket.generation !== this.runtime.menuGeneration ||
448
+ this.runtime.activeGoal !== ticket.waitingGoal ||
449
+ !this.canWakeWaitingGoal()
450
+ )
451
+ return false;
452
+ const previousRunGoalId = this.runtime.agentRunGoalId;
453
+ const resumedGoal = resetGoalSafetyEpoch(
454
+ transitionGoal(
455
+ {
456
+ ...rebaseGoalUsage(ticket.waitingGoal, currentTokenTotal(ctx)),
457
+ id: ticket.resumedGoal.id,
458
+ },
459
+ "active",
460
+ ),
461
+ );
462
+ this.runtime.pendingInputWake = undefined;
463
+ this.runtime.cancelContinuationWork();
464
+ this.runtime.clearGoalRecovery();
465
+ this.runtime.clearBudgetWrapUp();
466
+ this.runtime.clearStaleGoalToolCallBlock();
467
+ this.runtime.pendingDirectInputs = this.runtime.pendingDirectInputs
468
+ .filter((pending) => pending.realInput && pending.goalId === ticket.waitingGoal.id)
469
+ .map((pending) => ({ ...pending, goalId: resumedGoal.id }));
470
+ this.runtime.pendingNonGoalInputs = this.runtime.pendingNonGoalInputs.filter((pending) => pending.realInput);
471
+ this.runtime.activeGoal = resumedGoal;
472
+ if (inRun) {
473
+ this.runtime.inputWakeGoalId = resumedGoal.id;
474
+ this.runtime.inputWakeNeedsPrompt = previousRunGoalId !== ticket.waitingGoal.id;
475
+ }
476
+ this.runtime.beginAgentRun(resumedGoal.id, "manual");
477
+ this.runtime.persistGoal(resumedGoal);
478
+ if (ticket.generation !== this.runtime.menuGeneration || this.runtime.activeGoal !== resumedGoal) return false;
479
+ this.runtime.updateStatus(ctx, resumedGoal);
480
+ return true;
481
+ }
482
+
483
+ resumeWaitingGoalOnInput(ctx: StatusContext, inRun = false) {
484
+ const ticket = this.prepareWaitingGoalOnInput(ctx);
485
+ return ticket ? this.commitWaitingGoalOnInput(ctx, ticket, inRun) : false;
486
+ }
487
+
488
+ private prepareGoalResume(ctx: StatusContext, resetOnPrompt: boolean) {
403
489
  if (!this.runtime.activeGoal) {
404
490
  notifyTerminal(ctx.ui, "No active goal.", "info");
405
491
  return;
@@ -427,19 +513,29 @@ export class GoalCommandController {
427
513
  return;
428
514
  }
429
515
  const stoppedGoal = this.runtime.activeGoal;
430
- const stoppedStatus = stoppedGoal.status;
516
+ this.runtime.ambiguousWaitInputGoalId = undefined;
517
+ this.runtime.pendingDirectInputs = [];
518
+ this.runtime.pendingNonGoalInputs = [];
431
519
  this.runtime.cancelContinuationWork();
432
520
  this.runtime.clearGoalRecovery();
433
521
  this.runtime.clearBudgetWrapUp();
434
522
  this.runtime.clearStaleGoalToolCallBlock();
435
- this.runtime.activeGoal = queueGoalSafetyReset(transitionGoal(nextGoalInstance(this.runtime.activeGoal), "active"));
436
- this.runtime.persistGoal(this.runtime.activeGoal);
437
- this.runtime.updateStatus(ctx, this.runtime.activeGoal);
438
- if (this.runtime.activeGoal.status !== "active") {
439
- notifyTerminal(ctx.ui, `Goal token budget is still reached: ${formatBudget(this.runtime.activeGoal)}`, "warning");
440
- return;
441
- }
442
- const resumedGoal = this.runtime.activeGoal;
523
+ const rebased = rebaseGoalUsage({ ...stoppedGoal, usageBaselinePending: undefined }, currentTokenTotal(ctx));
524
+ const active = transitionGoal(nextGoalInstance(rebased), "active");
525
+ const resumedGoal = resetOnPrompt ? queueGoalSafetyReset(active) : resetGoalSafetyEpoch(active);
526
+ this.runtime.activeGoal = resumedGoal;
527
+ this.runtime.persistGoal(resumedGoal);
528
+ // Canonical state publication can synchronously pause or replace the goal.
529
+ if (this.runtime.activeGoal !== resumedGoal || resumedGoal.status !== "active") return;
530
+ this.runtime.updateStatus(ctx, resumedGoal);
531
+ return { stoppedGoal, resumedGoal, goalToolVisibilityBeforeActivation };
532
+ }
533
+
534
+ async resumeGoal(ctx: StatusContext, source: "explicit" | "deadline" = "explicit") {
535
+ const prepared = this.prepareGoalResume(ctx, true);
536
+ if (!prepared) return;
537
+ const { stoppedGoal, resumedGoal, goalToolVisibilityBeforeActivation } = prepared;
538
+ const stoppedStatus = stoppedGoal.status;
443
539
  const sent = await this.runtime.sendOwnedGoalPrompt(
444
540
  ctx,
445
541
  resumedGoal.id,
@@ -454,6 +550,9 @@ export class GoalCommandController {
454
550
  this.runtime.blockStaleGoalToolCalls();
455
551
  }
456
552
  this.runtime.toolPolicy.restore(goalToolVisibilityBeforeActivation);
553
+ if (source === "explicit" && stoppedGoal.wait?.resumeAt !== undefined) {
554
+ this.runtime.scheduleGoalWaitWake(ctx);
555
+ }
457
556
  }
458
557
  return;
459
558
  }
@@ -497,7 +596,7 @@ export class GoalCommandController {
497
596
  return;
498
597
  }
499
598
 
500
- this.runtime.recordGoalUsage(this.runtime.activeGoal, ctx);
599
+ if (this.runtime.activeGoal.status === "active") this.runtime.recordGoalUsage(this.runtime.activeGoal, ctx);
501
600
  const previousGoal = { ...this.runtime.activeGoal };
502
601
  this.runtime.cancelContinuationWork();
503
602
  this.runtime.clearGoalRecovery();
@@ -512,7 +611,12 @@ export class GoalCommandController {
512
611
  },
513
612
  editedGoalStatus(previousStatus),
514
613
  );
515
- const nextGoal = transitionedGoal.status === "active" ? queueGoalSafetyReset(transitionedGoal) : transitionedGoal;
614
+ const nextGoal =
615
+ transitionedGoal.status === "active"
616
+ ? queueGoalSafetyReset(
617
+ previousStatus === "active" ? transitionedGoal : rebaseGoalUsage(transitionedGoal, currentTokenTotal(ctx)),
618
+ )
619
+ : transitionedGoal;
516
620
  const goalToolVisibilityBeforeActivation =
517
621
  nextGoal.status === "active" ? this.runtime.toolPolicy.snapshot() : undefined;
518
622
  if (nextGoal.status === "active") {
@@ -574,7 +678,7 @@ export class GoalCommandController {
574
678
  this.reportGoalStatus(ctx, message);
575
679
  return;
576
680
  }
577
- if (!this.runtime.queueFrozen) {
681
+ if (!this.runtime.queueFrozen && this.runtime.activeGoal.status === "active") {
578
682
  this.runtime.recordGoalUsage(this.runtime.activeGoal, ctx);
579
683
  this.runtime.persistGoal(this.runtime.activeGoal);
580
684
  this.runtime.updateStatus(ctx, this.runtime.activeGoal);
@@ -640,6 +744,7 @@ export class GoalCommandController {
640
744
  this.runtime.pauseGoalForUnavailableTools(ctx, true, !displacedUsageFinalized);
641
745
  } else {
642
746
  this.runtime.persistGoal(currentGoal);
747
+ this.runtime.scheduleGoalWaitWake(ctx);
643
748
  }
644
749
  }
645
750
  return false;
@@ -685,6 +790,7 @@ export class GoalCommandController {
685
790
  this.runtime.updateStatus(ctx, this.runtime.activeGoal);
686
791
  }
687
792
  this.runtime.toolPolicy.restore(visibilityBeforeActivation);
793
+ this.runtime.scheduleGoalWaitWake(ctx);
688
794
  return false;
689
795
  }
690
796
  notifyTerminal(ctx.ui, `Goal prioritized: ${objective}`, "info");
package/src/goal.ts CHANGED
@@ -21,7 +21,7 @@ function registerGoalRuntime(pi: ExtensionAPI, options: GoalOptions = {}) {
21
21
  // the real one. Wired here because the controller owns it and the runtime,
22
22
  // which arms the timer, deliberately does not depend on the controller.
23
23
  runtime.onGoalWaitElapsed = (ctx) => {
24
- void commands.resumeGoal(ctx);
24
+ void commands.resumeGoal(ctx, "deadline");
25
25
  };
26
26
 
27
27
  // Keep registration order explicit: managed-run bus listeners exist before tools,