pi-goal-x 0.31.3 → 0.31.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/README.md CHANGED
@@ -115,6 +115,7 @@ Open `/goal-settings` to change these options. You can save defaults for all pro
115
115
 
116
116
  | Setting | What it controls |
117
117
  | --- | --- |
118
+ | Autonomous run allowance (`maxAutonomousRuns`) | Positive whole number of extension-started runs per creation or `/goal-resume` period. **Unset disables automatic continuation.** Settings edits change the limit without resetting usage. |
118
119
  | Task tracking (`disableTasks`) | Turn task lists on or off. Set to `true` to disable them. |
119
120
  | Subtask depth (`subtaskDepth`) | Limit how many levels of subtasks the agent can create. |
120
121
  | Completion requirements (`disableContracts`) | Turn explicit goal and task completion requirements on or off. Set to `true` to disable them. |
@@ -122,6 +123,43 @@ Open `/goal-settings` to change these options. You can save defaults for all pro
122
123
  | Auditor provider, model, and thinking level | Choose which model reviews completed work and its reasoning effort. |
123
124
 
124
125
 
126
+ ### Explicit execution and waiting
127
+
128
+ Goals no longer restart merely because they remain unfinished or a tool was used. Before yielding, the agent declares runnable work or an external wait using `update_goal`, or reports complete, paused, or blocked. A missing decision permits one repair prompt within the remaining allowance, then pauses.
129
+
130
+ Set an appropriate allowance in `/goal-settings`, or in `.pi/pi-goal-x-settings.json`:
131
+
132
+ ```json
133
+ { "maxAutonomousRuns": 20 }
134
+ ```
135
+
136
+ Agents may edit this setting. Changing it does not replenish consumed runs; explicit `/goal-resume` renews the period and continues now, including from a waiting goal. It requires a configured allowance. Tool calls within a run are not separate runs. Existing token budgets still apply.
137
+
138
+ ```js
139
+ update_goal({ continuation: { kind: "ready", next_action: "Verify the build artifacts" } })
140
+ update_goal({ continuation: {
141
+ kind: "wait", reason: "Await the remote build",
142
+ deadline: "2026-09-15T12:00:00Z",
143
+ polling: { interval_seconds: 60, max_checks: 3 }
144
+ } })
145
+ ```
146
+
147
+ Use a future deadline appropriate to the task. Omit `polling` for an event-only wait. Successful declarations terminate the execution segment. On a scheduled check, reuse the returned `wait_id` and original deadline, omitting `polling`; remaining checks cannot be reset. A ready decision ends the wait. Time spent waiting is not active execution time.
148
+
149
+ The dashboard, `/goal-status`, and `get_goal` show scheduling state, timing, checks, and allowance consumption. Expired waits and exhausted checks or allowance pause without another model call. Waits survive reopening the same session, without replaying missed checks; Pi must remain open for timers to execute. Another session requires explicit resume to take ownership. An ambiguous interrupted dispatch requires resume instead of automatic replay.
150
+
151
+ ### Background producer integration
152
+
153
+ Budget-controlled producers emit a scheduler signal instead of starting their own model turn:
154
+
155
+ ```js
156
+ pi.events.emit("pi-goal:wake", { goalId, waitToken });
157
+ ```
158
+
159
+ `waitToken` is returned in the wait declaration's tool-result details. Register it before the producer completes, or retain the completion until registration (for example, observe the `update_goal` tool result in the host adapter). The token changes after consumption and re-declaration. A matching signal received before agent settlement is retained; duplicate, stale and wrong-goal tokens are ignored. A signal and timer can claim only one wake.
160
+
161
+ Existing producers that directly send `triggerTurn`/`followUp` messages still run as ordinary host work and supersede old pending decisions. Those independently started turns are **outside this extension's allowance**; use `pi-goal:wake` to put them through its spending gate. The allowance also does not limit Pi's own within-run tool loop or native retries. It bounds the goal extension's kickoff, continuation, check, signal, repair and recovery dispatches.
162
+
125
163
  ## License
126
164
 
127
165
  MIT
@@ -278,6 +278,7 @@ export function registerGoalCommands(core: GoalCore): void {
278
278
  const health = /^\s*health\b/i.test(rawArgs);
279
279
  const ledger = readGoalLedger(ctx);
280
280
  const text = buildGoalStatusText({
281
+ maxAutonomousRuns: loadGoalSettings(ctx.cwd).maxAutonomousRuns,
281
282
  goal: view,
282
283
  focused: view !== null && core.focusedGoalId === view.id,
283
284
  otherOpenGoals: otherCount,
@@ -332,33 +333,10 @@ export function registerGoalCommands(core: GoalCore): void {
332
333
  if (!core.state.goal && otherOpenGoalCount(core.goalsById, null) > 0) {
333
334
  const selected = await chooseOpenGoal(ctx, "Resume or focus open goal");
334
335
  if (!selected) return;
335
- if (selected.status === "active") {
336
- core.armFocusedContinuation(ctx);
337
- ctx.ui.notify(`Goal focused: ${oneLineSummary(selected)}`, "info");
338
- return;
339
- }
340
336
  }
341
- const resumeGate = validateResumeGoal(core.state.goal);
342
- if (!resumeGate.ok) {
343
- const level = resumeGate.message.includes("already running") ? "info" : "warning";
344
- ctx.ui.notify(resumeGate.message, level);
345
- return;
346
- }
347
- if (!core.state.goal) throw new Error("Goal disappeared during resume validation.");
348
- core.setGoal(
349
- {
350
- ...mergeGoalPromptFromDisk(ctx, core.state.goal),
351
- status: "active",
352
- autoContinue: true,
353
- stopReason: undefined,
354
- pauseReason: undefined,
355
- pauseSuggestedAction: undefined,
356
- },
357
- ctx,
358
- );
359
- core.beginAccounting();
360
- ctx.ui.notify("Goal resumed.", "info");
361
- core.queueContinuation(ctx, true);
337
+ if (!core.state.goal) { ctx.ui.notify("No goal is focused.", "warning"); return; }
338
+ if (!core.scheduler.resume(ctx)) return;
339
+ ctx.ui.notify("Goal resumed; autonomous allowance renewed.", "info");
362
340
  // Append ledger event for resumption
363
341
  try {
364
342
  core.goalService.appendEvents(ctx, [{
@@ -392,6 +370,7 @@ export function registerGoalCommands(core: GoalCore): void {
392
370
  { key: "autoSelectSingleGoal", label: "autoSelectSingleGoal", section: "Goal behavior", kind: "boolean" },
393
371
  { key: "hideUnfocusedBanner", label: "hideUnfocusedBanner", section: "Goal behavior", kind: "boolean" },
394
372
  { key: "disableContracts", label: "disableContracts", section: "Goal behavior", kind: "boolean" },
373
+ { key: "maxAutonomousRuns", label: "autonomous run allowance", section: "Goal behavior", kind: "positiveInteger" },
395
374
  { key: "stallTimeoutMinutes", label: "stall timeout (minutes)", section: "Goal behavior", kind: "positiveInteger" },
396
375
  { key: "objectiveMaxChars", label: "max objective length (0 = none)", section: "Goal behavior", kind: "positiveInteger" },
397
376
  { key: "disableTasks", label: "disableTasks", section: "Task tracking", kind: "boolean" },
@@ -414,6 +393,7 @@ export function registerGoalCommands(core: GoalCore): void {
414
393
  return config[key] === true ? "true" : "false";
415
394
  }
416
395
  if (key === "subtaskDepth") return config.subtaskDepth !== undefined ? String(config.subtaskDepth) : "1";
396
+ if (key === "maxAutonomousRuns") return config.maxAutonomousRuns === 0 ? "0 (disabled)" : String(config.maxAutonomousRuns ?? "unlimited (default)");
417
397
  if (key === "stallTimeoutMinutes") return config.stallTimeoutMinutes !== undefined ? String(config.stallTimeoutMinutes) : "0";
418
398
  if (key === "objectiveMaxChars") return config.objectiveMaxChars !== undefined ? String(config.objectiveMaxChars) : "0";
419
399
  if (key === "keybindings") return config.keybindings ? `${config.keybindings.dashboard.toggleExpand}, ${config.keybindings.dashboard.scrollUp}, ${config.keybindings.dashboard.scrollDown}` : "(default)";
@@ -564,7 +544,7 @@ export function registerGoalCommands(core: GoalCore): void {
564
544
  }
565
545
 
566
546
  if (row.kind === "positiveInteger") {
567
- const min = row.path ? 1 : ((row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars") ? 0 : 1);
547
+ const min = row.path ? 1 : ((row.key === "stallTimeoutMinutes" || row.key === "objectiveMaxChars" || row.key === "maxAutonomousRuns") ? 0 : 1);
568
548
  const actions = [`Set ${scope} override...`];
569
549
  if (hasLocalOverride) actions.push(inheritLabel);
570
550
  actions.push("Cancel");
@@ -825,7 +805,7 @@ export function registerGoalCommands(core: GoalCore): void {
825
805
  },
826
806
  });
827
807
  pi.registerCommand("goal-resume", {
828
- description: "Resume a paused or blocked goal.",
808
+ description: "Continue now and renew the configured autonomous-run allowance.",
829
809
  handler: async (_rawArgs, ctx) => {
830
810
  await handleGoalResume(ctx);
831
811
  },
@@ -1,3 +1,4 @@
1
+ import { schedulerSummary, type GoalContinuation } from "./goal-scheduler-state.ts";
1
2
  import { goalDetailPage, type GoalDetailSection } from "./goal-detail.ts";
2
3
  import { taskIndex } from "./goal-task-index.ts";
3
4
  import { StringEnum, Type } from "@earendil-works/pi-ai";
@@ -86,7 +87,7 @@ pi.registerTool(defineTool({
86
87
  }
87
88
  if (params.cursor || params.task_id) return {content: [{type: "text", text: "Use section=objective, tasks, or history for detail retrieval; task_id requires tasks."}], details: goalDetails(view)};
88
89
  if (verbose && !params.section) {
89
- const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}`];
90
+ const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}`, schedulerSummary(view.scheduler, loadGoalSettings(ctx.cwd).maxAutonomousRuns)];
90
91
  lines.push(`Objective: ${view.objective}`, "");
91
92
  lines.push(`Status: ${statusLabel(view)}`);
92
93
  lines.push(`Mode: ${view.sisyphus ? "sisyphus" : "regular"}`);
@@ -129,7 +130,7 @@ pi.registerTool(defineTool({
129
130
  }
130
131
 
131
132
  // Compact state read; full requirements remain available through detail pages.
132
- const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}`];
133
+ const lines: string[] = [`Goal ${view.id}: ${statusLabel(view)}, ${view.sisyphus ? "sisyphus" : "regular"}`, schedulerSummary(view.scheduler, loadGoalSettings(ctx.cwd).maxAutonomousRuns)];
133
134
  lines.push(`Objective: ${truncateText(view.objective, 180)}${view.objective.length > 180 ? " (full: get_goal section=objective)" : ""}`);
134
135
  if (view.taskList) {
135
136
  const { findCurrentTask, firstPendingTask } = conciseTaskPointers(view);
@@ -508,11 +509,15 @@ async function runGoalAgentPauseFlow(ctx: ExtensionContext, reason: string | und
508
509
  pi.registerTool(defineTool({
509
510
  name: "update_goal",
510
511
  label: "Update Goal",
511
- description: "Report complete (independently audited), blocked (three consecutive identical blockers), or paused (immediate). reason is required for blocked/paused; completion_summary is an untrusted claim.",
512
- promptSnippet: "Complete, block, or pause the focused goal.",
512
+ description: "End execution with status OR continuation. Wait: future ISO deadline; reuse wait_id/deadline without polling on recheck. Completion is audited.",
513
+ promptSnippet: "Declare the next execution disposition or complete, block, or pause.",
513
514
  promptGuidelines: [],
514
515
  parameters: Type.Object({
515
- status: StringEnum(["complete", "blocked", "paused"] as const, { description: "Run outcome." }),
516
+ status: Type.Optional(StringEnum(["complete", "blocked", "paused"] as const, { description: "Exclusive with continuation." })),
517
+ continuation: Type.Optional(Type.Union([
518
+ Type.Object({ kind: Type.Literal("ready"), next_action: Type.String({ minLength: 1, maxLength: 2000 }) }, { additionalProperties: false }),
519
+ Type.Object({ kind: Type.Literal("wait"), reason: Type.String({ minLength: 1, maxLength: 2000 }), deadline: Type.String(), wait_id: Type.Optional(Type.String()), polling: Type.Optional(Type.Object({ interval_seconds: Type.Integer({ minimum: 1, maximum: 2147483 }), max_checks: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }) }, { additionalProperties: false })) }, { additionalProperties: false }),
520
+ ])),
516
521
  reason: Type.Optional(Type.String({ description: "Required when status is paused or blocked: describe the concrete blocker." })),
517
522
  attempted_actions: Type.Optional(Type.Array(Type.String({ maxLength: 240 }), { maxItems: 8, description: "Actions attempted against the blocker." })),
518
523
  suggested_action: Type.Optional(Type.String({ description: "Optional suggested next step when status is paused." })),
@@ -523,6 +528,10 @@ pi.registerTool(defineTool({
523
528
  // P1-3: persist any buffered in-turn mutations now so the auditor and
524
529
  // status transitions observe the current task/state, not the stale disk.
525
530
  core.flushGoalTransaction(ctx);
531
+ if (!!params.continuation === !!params.status || (params.continuation && (params.reason !== undefined || params.attempted_actions !== undefined || params.suggested_action !== undefined || params.completion_summary !== undefined))) {
532
+ return { content: [{ type: "text", text: "Provide exactly one lifecycle status or continuation, without mixing their fields." }], details: {}, terminate: false };
533
+ }
534
+ if (params.continuation) return core.scheduler.declare(ctx, params.continuation as GoalContinuation);
526
535
  if (params.status === "blocked") {
527
536
  const attempted = Array.isArray((params as { attempted_actions?: unknown }).attempted_actions)
528
537
  ? ((params as { attempted_actions: unknown[] }).attempted_actions.filter((a): a is string => typeof a === "string"))
@@ -1,3 +1,4 @@
1
+ import { schedulerSummary } from "./goal-scheduler-state.ts";
1
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import {
3
4
  GOAL_EVENT_ENTRY,
@@ -10,7 +11,6 @@ import {
10
11
  isAbortedAssistantMessage,
11
12
  isErrorAssistantMessage,
12
13
  isMeaningfulProgressToolCall,
13
- isToolUseAssistantMessage,
14
14
  } from "./goal-format.ts";
15
15
  import { buildCompactionSummary, buildPostCompactionGoalDelta } from "./goal-compaction.ts";
16
16
  import { latestAuditorResultForGoal, readGoalLedger, goalRuntimeEvents, invalidateGoalLedgerCache } from "./goal-ledger.ts";
@@ -86,19 +86,28 @@ export function registerGoalEvents(core: GoalCore): void {
86
86
  const { pi } = core;
87
87
  let continuationAfterSettleFor: string | null = null;
88
88
  let networkErrorRecoveryAfterSettleFor: string | null = null;
89
- // Run-scoped empty-turn gate: turn_start resets the per-turn flag, so a text-only
90
- // last turn after earlier work must not wipe the run. before_agent_start starts a
91
- // new run. agent_settled must not auto-continue a run that did no goal work, or
92
- // empty replies ("Paused. No action.") loop forever.
93
- let goalWorkThisRun = false;
94
89
 
95
- pi.on("context", async (event) => {
90
+ pi.on("context", async (event, ctx) => {
96
91
  const filtered = filterGoalSessionContext(event.messages);
97
92
  const messages = compactGoalCheckpointContext(filtered ?? event.messages, core.state.goal) ?? filtered;
93
+ if (core.scheduler.needsLiveContext() && core.state.goal) {
94
+ const goal = core.state.goal;
95
+ const settings = loadGoalSettings(ctx.cwd);
96
+ // Custom-message runs bypass before_agent_start. Refresh mutable scheduling
97
+ // state, but do not repeat policy/objective already in the inherited prompt.
98
+ const content = ctx.getSystemPrompt?.().includes(`[PI GOAL ACTIVE goalId=${goal.id}]`)
99
+ ? `[CURRENT EXECUTION STATE goalId=${goal.id}]\nStatus: ${goal.status}; autoContinue: ${goal.autoContinue}.\n${schedulerSummary(goal.scheduler, settings.maxAutonomousRuns)}`
100
+ : goalPrompt(goal, settings);
101
+ const live = { role: "custom" as const, customType: "pi-goal-live-context", content, display: false, timestamp: Date.now() };
102
+ return { messages: [live, ...(messages ?? event.messages)] as typeof event.messages };
103
+ }
98
104
  return messages === null ? undefined : { messages: messages as typeof event.messages };
99
105
  });
100
106
 
107
+ pi.on("agent_start", async (_event, ctx) => { core.scheduler.begin(ctx); });
108
+ pi.on("message_start", async (event, ctx) => { core.scheduler.message(ctx, event.message); });
101
109
  pi.on("turn_start", async (_event, ctx) => {
110
+ core.scheduler.turn(ctx);
102
111
  // Per-turn flag resets (#4 + C9 fix).
103
112
  core.advanceTurnSeq();
104
113
  core.goalWorkToolCalledThisTurn = false;
@@ -111,6 +120,7 @@ export function registerGoalEvents(core: GoalCore): void {
111
120
  // #4 + C9 fix + Phase 5 C3: gate in-turn tool calls based on lifecycle state.
112
121
  pi.on("tool_call", async (event, ctx) => {
113
122
  const stoppedGoalId = core.currentTurnStoppedGoalId();
123
+ if (core.scheduler.isDenied()) return { block: true, reason: "Stale goal dispatch; use /goal-resume." };
114
124
  // Post-stop in-turn block: after update_goal / set_goal_tasks (or a user
115
125
  // lifecycle command) fires in this turn, block all subsequent tool calls
116
126
  // except read-only inspection.
@@ -133,10 +143,9 @@ export function registerGoalEvents(core: GoalCore): void {
133
143
  `End the turn with a brief summary and yield to the user.`,
134
144
  };
135
145
  }
136
- // Track for #4 empty-turn gate (per-turn and per-run).
146
+ // Track Oracle work attempts; this does not authorize scheduling.
137
147
  if (isMeaningfulProgressToolCall(event.toolName, asRecord(event)?.args)) {
138
148
  core.goalWorkToolCalledThisTurn = true;
139
- goalWorkThisRun = true;
140
149
  // Issue #26: record a meaningful work attempt against armed Oracle
141
150
  // advice. get_goal / echo-only reads are excluded upstream by
142
151
  // isMeaningfulProgressToolCall.
@@ -252,17 +261,6 @@ export function registerGoalEvents(core: GoalCore): void {
252
261
  core.updateUI(ctx);
253
262
  }
254
263
 
255
- // If the assistant ended a turn without queuing more tool calls, push a continuation right away.
256
- // #4: only queue if some real work was done this turn — otherwise the model is
257
- // just chatting and we should not keep firing turns on noise.
258
- if (
259
- !isToolUseAssistantMessage(message)
260
- && core.state.goal?.status === "active"
261
- && core.state.goal.autoContinue
262
- && core.goalWorkToolCalledThisTurn
263
- ) {
264
- core.queueContinuation(ctx);
265
- }
266
264
  core.goalService.endTurn(ctx); // P1-3: single flush (lock + write + ledger batch)
267
265
  });
268
266
 
@@ -311,11 +309,11 @@ export function registerGoalEvents(core: GoalCore): void {
311
309
  const current = core.state.goal;
312
310
  const shouldResume = await ctx.ui.confirm("Resume paused goal?", `Goal: ${current.objective}`);
313
311
  if (shouldResume) {
314
- core.setGoal({ ...current, status: "active", autoContinue: true, stopReason: undefined, pauseReason: undefined, pauseSuggestedAction: undefined }, ctx);
312
+ core.scheduler.resume(ctx);
315
313
  }
316
314
  }
317
315
  core.beginAccounting();
318
- core.queueContinuation(ctx, true);
316
+ core.scheduler.restore(ctx);
319
317
  });
320
318
 
321
319
  pi.on("session_before_compact", async (_event, ctx) => {
@@ -341,11 +339,11 @@ export function registerGoalEvents(core: GoalCore): void {
341
339
  rehydrateDraft(core, ctx);
342
340
  syncTerminalInputPause(core, ctx);
343
341
  core.beginAccounting();
344
- core.queueContinuation(ctx, true);
342
+ core.scheduler.restore(ctx);
345
343
  });
346
344
 
347
345
  pi.on("before_agent_start", async (event, ctx) => {
348
- goalWorkThisRun = false;
346
+ core.scheduler.prepare();
349
347
  core.advanceTurnSeq();
350
348
  if (!hasActiveDraft(core)) core.installGoalToolProfile(!loadGoalSettings(ctx.cwd).disableTasks);
351
349
  const currentSystemPrompt = () => ctx.getSystemPrompt?.() || event.systemPrompt;
@@ -531,10 +529,6 @@ export function registerGoalEvents(core: GoalCore): void {
531
529
  core.runtime.clearNetworkErrorBackoff();
532
530
  core.persist(ctx);
533
531
  core.updateUI(ctx);
534
- // Empty-turn gate: agent_end clears any turn_end continuation timer, then
535
- // agent_settled re-queues with force=true. Without this check, a no-tool
536
- // reply (chat, or "Paused. No action." after resume) loops forever.
537
- if (!goalWorkThisRun) return;
538
532
  // agent_end runs before pi finishes retries, compaction, terminating-tool
539
533
  // settlement, and queued messages. Starting the continuation timer here
540
534
  // can poll a stale busy context for minutes on pi 0.84. agent_settled is
@@ -549,10 +543,8 @@ export function registerGoalEvents(core: GoalCore): void {
549
543
  continuationAfterSettleFor = null;
550
544
  const networkErrorGoalId = networkErrorRecoveryAfterSettleFor;
551
545
  networkErrorRecoveryAfterSettleFor = null;
552
- if (goalId && core.isActionableContinuationGoal(goalId)) {
553
- core.queueContinuation(ctx, true);
554
- return;
555
- }
546
+ core.scheduler.settled(ctx, !!goalId && core.isActionableContinuationGoal(goalId));
547
+ if (goalId && core.isActionableContinuationGoal(goalId)) return;
556
548
  if (!networkErrorGoalId || !core.isActionableContinuationGoal(networkErrorGoalId)) return;
557
549
  const recovery = loadGoalSettings(ctx.cwd).networkRecovery;
558
550
  const policy = recovery
@@ -576,6 +568,7 @@ export function registerGoalEvents(core: GoalCore): void {
576
568
 
577
569
  pi.on("session_shutdown", async (_event, ctx) => {
578
570
  core.auditMessages.clear();
571
+ core.scheduler.shutdown();
579
572
  continuationAfterSettleFor = null;
580
573
  networkErrorRecoveryAfterSettleFor = null;
581
574
  core.accountProgress(ctx);
@@ -1,3 +1,4 @@
1
+ import { normalizeGoalScheduler, type GoalSchedulerState } from "./goal-scheduler-state.ts";
1
2
  export type GoalStatus = "active" | "paused" | "blocked" | "budget_limited" | "complete";
2
3
  export type StopReason = "user" | "agent";
3
4
  export type GoalEventKind = "checkpoint" | "stale";
@@ -56,6 +57,7 @@ export interface GoalRecord {
56
57
  revision?: number;
57
58
  /** Optional token budget (whole tokens). When accounted usage reaches it, the runtime marks the goal budget_limited. */
58
59
  tokenBudget?: number;
60
+ scheduler?: GoalSchedulerState;
59
61
  /**
60
62
  * Execution focus: the id of the task (or subtask) the agent is working on.
61
63
  * Optional for backward compatibility; normalized at load (only an existing
@@ -119,7 +121,13 @@ export interface GoalStaleDetails {
119
121
  }
120
122
 
121
123
  /** Everything that can appear in a persisted pi-goal-event entry's details. */
122
- export type GoalEventEntryDetails = GoalCheckpointDetailsV2 | LegacyGoalEventDetails | GoalStaleDetails;
124
+ export interface GoalCheckpointDetailsV3 extends Omit<GoalCheckpointDetailsV2, "version"> {
125
+ version: 3;
126
+ generation: string;
127
+ dispatchId: string;
128
+ }
129
+
130
+ export type GoalEventEntryDetails = GoalCheckpointDetailsV3 | GoalCheckpointDetailsV2 | LegacyGoalEventDetails | GoalStaleDetails;
123
131
 
124
132
  /**
125
133
  * Legacy normalized renderer shape. Kept as the stable contract for
@@ -181,6 +189,7 @@ function cloneGoalTask(task: GoalTask): GoalTask {
181
189
  export function cloneGoal(goal: GoalRecord): GoalRecord {
182
190
  return {
183
191
  ...goal,
192
+ ...(goal.scheduler ? { scheduler: structuredClone(goal.scheduler) } : {}),
184
193
  usage: { ...goal.usage },
185
194
  taskList: goal.taskList
186
195
  ? { ...goal.taskList, tasks: goal.taskList.tasks.map(cloneGoalTask) }
@@ -370,6 +379,7 @@ export function normalizeGoalRecord(value: unknown): GoalRecord | null {
370
379
  skipAuditor: raw.skipAuditor === true ? true : undefined,
371
380
  revision: Number.isSafeInteger(raw.revision) && (raw.revision as number) >= 0 ? (raw.revision as number) : 0,
372
381
  tokenBudget: normalizePositiveSafeInteger(raw.tokenBudget),
382
+ ...(raw.scheduler !== undefined ? { scheduler: normalizeGoalScheduler(raw.scheduler) } : {}),
373
383
  taskList,
374
384
  currentTaskId,
375
385
  verificationContract: typeof raw.verificationContract === "string" ? raw.verificationContract : undefined,
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
- import type { GoalCheckpointDetailsV2, GoalRecord } from "./goal-record.ts";
12
+ import type { GoalCheckpointDetailsV3, GoalRecord } from "./goal-record.ts";
13
13
  import { checkpointTriggerPrompt } from "./prompts/goal-prompts.ts";
14
14
  import { POST_STOP_ALLOWED_TOOLS } from "./goal-tool-names.ts";
15
15
  import { networkErrorBackoffPlan, type NetworkErrorBackoffPlan, type NetworkErrorRecoveryPolicy } from "./network-error-backoff.ts";
@@ -19,6 +19,9 @@ export const CONTINUATION_IDLE_RETRY_MS = 50;
19
19
  const POST_STOP_ALLOWED = new Set<string>(POST_STOP_ALLOWED_TOOLS);
20
20
 
21
21
  export interface GoalRuntimeHooks {
22
+ authorize?(ctx: ExtensionContext, goal: GoalRecord): Record<string, unknown> | null;
23
+ recover?(ctx: ExtensionContext): void;
24
+ dispatchFailed?(ctx: ExtensionContext): void;
22
25
  /** Dispatch a hidden follow-up checkpoint message (pi.sendMessage + triggerTurn). */
23
26
  sendFollowUp(content: string, details: Record<string, unknown>): void;
24
27
  /** Current focused goal (state.goal). */
@@ -125,7 +128,7 @@ export class GoalRuntime {
125
128
  if (!this.hooks.isActionable(goal.id)) return;
126
129
  const currentGoal = this.hooks.getGoal();
127
130
  if (!currentGoal || currentGoal.id !== goal.id) return;
128
- this.queueContinuation(ctx, currentGoal, true);
131
+ this.hooks.recover?.(ctx);
129
132
  }, plan.delayMs);
130
133
  this.networkErrorRetryTimer.unref?.();
131
134
  return plan;
@@ -172,10 +175,14 @@ export class GoalRuntime {
172
175
  if (this.continuationQueuedFor === scheduledGoalId) this.continuationQueuedFor = null;
173
176
  return;
174
177
  }
178
+ const authorization = this.hooks.authorize?.(ctx, goal);
179
+ if (!authorization) return;
175
180
  this.checkpointSeq += 1;
176
181
  this.continuationQueuedFor = goal.id;
177
- const details: GoalCheckpointDetailsV2 = {
178
- version: 2,
182
+ const details: GoalCheckpointDetailsV3 = {
183
+ version: 3,
184
+ generation: String(authorization.generation),
185
+ dispatchId: String(authorization.dispatchId),
179
186
  kind: "checkpoint",
180
187
  goalId: goal.id,
181
188
  status: "active",
@@ -183,7 +190,8 @@ export class GoalRuntime {
183
190
  checkpointSeq: this.checkpointSeq,
184
191
  timestamp: Date.now(),
185
192
  };
186
- this.hooks.sendFollowUp(checkpointTriggerPrompt(goal.id), details as unknown as Record<string, unknown>);
193
+ try { this.hooks.sendFollowUp(checkpointTriggerPrompt(goal.id), details as unknown as Record<string, unknown>); }
194
+ catch { this.hooks.dispatchFailed?.(ctx); }
187
195
  }
188
196
 
189
197
  // ── turn-stop guard ──────────────────────────────────────────────────
@@ -0,0 +1,67 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export type GoalDispatchKind = "ready" | "check" | "wake" | "repair" | "kickoff" | "recovery";
4
+ export interface GoalWait {
5
+ id: string;
6
+ token: string;
7
+ reason: string;
8
+ deadline: number;
9
+ intervalMs?: number;
10
+ remainingChecks?: number;
11
+ nextCheckAt?: number;
12
+ signalled?: boolean;
13
+ }
14
+ export interface GoalSchedulerState {
15
+ version: 1;
16
+ owner: string;
17
+ generation: string;
18
+ used: number;
19
+ phase: "idle" | "ready" | "waiting" | "claimed" | "running" | "interrupted";
20
+ decision?: { kind: "ready"; nextAction: string; purpose: GoalDispatchKind } | { kind: "wait" };
21
+ wait?: GoalWait;
22
+ dispatch?: { id: string; kind: GoalDispatchKind; claimedAt: number };
23
+ repairUsed: boolean;
24
+ }
25
+ export type GoalContinuation =
26
+ | { kind: "ready"; next_action: string }
27
+ | { kind: "wait"; reason: string; deadline: string; wait_id?: string; polling?: { interval_seconds: number; max_checks: number } };
28
+
29
+ export function newGoalScheduler(owner: string): GoalSchedulerState {
30
+ return { version: 1, owner, generation: randomUUID(), used: 0, phase: "idle", repairUsed: false };
31
+ }
32
+
33
+ /** Corrupt scheduling data must not silently reset spent allowance. */
34
+ export function normalizeGoalScheduler(raw: unknown): GoalSchedulerState | undefined {
35
+ if (raw === undefined) return undefined;
36
+ const invalid = (): GoalSchedulerState => ({ ...newGoalScheduler("invalid"), phase: "interrupted", used: Number.MAX_SAFE_INTEGER });
37
+ if (!raw || typeof raw !== "object") return invalid();
38
+ const s = raw as GoalSchedulerState;
39
+ const integer = (n: unknown) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
40
+ const text = (v: unknown) => typeof v === "string" && v.length > 0 && v.length <= 2000;
41
+ if (s.version !== 1 || !text(s.owner) || !text(s.generation) || !integer(s.used) || typeof s.repairUsed !== "boolean" || !["idle", "ready", "waiting", "claimed", "running", "interrupted"].includes(s.phase)) return invalid();
42
+ if (s.decision && (s.decision.kind !== "wait" && (s.decision.kind !== "ready" || !text(s.decision.nextAction) || !["ready", "repair", "kickoff", "recovery"].includes(s.decision.purpose)))) return invalid();
43
+ if (s.wait) {
44
+ const w = s.wait;
45
+ if (!text(w.id) || !text(w.token) || !text(w.reason) || (!integer(w.deadline) || w.deadline > 8_640_000_000_000_000)) return invalid();
46
+ if (w.intervalMs !== undefined && (!integer(w.intervalMs) || w.intervalMs < 1000 || !integer(w.remainingChecks) || !integer(w.nextCheckAt))) return invalid();
47
+ if (w.intervalMs === undefined && (w.remainingChecks !== undefined || w.nextCheckAt !== undefined)) return invalid();
48
+ if (w.signalled !== undefined && typeof w.signalled !== "boolean") return invalid();
49
+ }
50
+ if (s.dispatch && (!text(s.dispatch.id) || !integer(s.dispatch.claimedAt) || !["ready", "check", "wake", "repair", "kickoff", "recovery"].includes(s.dispatch.kind))) return invalid();
51
+ if ((s.phase === "claimed" || s.phase === "running") && !s.dispatch) return invalid();
52
+ if (s.phase === "ready" && s.decision?.kind !== "ready") return invalid();
53
+ if (s.phase === "waiting" && (!s.wait || s.decision?.kind !== "wait")) return invalid();
54
+ return structuredClone(s);
55
+ }
56
+
57
+ export function schedulerSummary(s: GoalSchedulerState | undefined, limit?: number): string {
58
+ const lines = [`Autonomous runs: ${s?.used ?? 0}/${limit ?? "unlimited"}${limit === 0 ? " (automatic continuation disabled)" : ""}.`];
59
+ if (!s) return lines.join("\n");
60
+ if (s.decision?.kind === "ready") lines.push(`Next action: ${s.decision.nextAction}`);
61
+ if (s.wait) {
62
+ lines.push(`Waiting: ${s.wait.reason}; wait_id=${s.wait.id}; deadline=${new Date(s.wait.deadline).toISOString()}.`);
63
+ if (s.wait.nextCheckAt !== undefined) lines.push(`Next check: ${new Date(s.wait.nextCheckAt).toISOString()}; ${s.wait.remainingChecks} checks remaining.`);
64
+ }
65
+ if (s.phase === "interrupted" || s.phase === "claimed") lines.push("Execution requires dispatch admission or explicit /goal-resume after interruption.");
66
+ return lines.join("\n");
67
+ }
@@ -0,0 +1,327 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionContext, AgentToolResult } from "@earendil-works/pi-coding-agent";
3
+ import type { GoalCore } from "./goal-state.ts";
4
+ import { asRecord, type GoalRecord } from "./goal-record.ts";
5
+ import { budgetReached } from "./goal-accounting.ts";
6
+ import { loadGoalSettings, invalidateGoalSettingsCache } from "./goal-settings.ts";
7
+ import { newGoalScheduler, schedulerSummary, type GoalContinuation, type GoalSchedulerState } from "./goal-scheduler-state.ts";
8
+
9
+ /** Scheduling intent is durable; timers only arrange an opportunity to claim it. */
10
+ export class GoalScheduler {
11
+ private timer: ReturnType<typeof setTimeout> | undefined;
12
+ private ctx: ExtensionContext | undefined;
13
+ private inRun = false;
14
+ private prepared = false;
15
+ private liveContext = false;
16
+ private armedGeneration: string | undefined;
17
+ private declared = false;
18
+ private runGoalId: string | undefined;
19
+ private denied = false;
20
+ private unsubscribe: (() => void) | undefined;
21
+ private core: GoalCore;
22
+ constructor(core: GoalCore) { this.core = core; }
23
+
24
+ private owner(ctx: ExtensionContext): string { return ctx.sessionManager.getSessionId() || "unknown-session"; }
25
+ private limit(ctx: ExtensionContext): number | undefined { return loadGoalSettings(ctx.cwd).maxAutonomousRuns; }
26
+ private available(ctx: ExtensionContext, s: GoalSchedulerState): boolean {
27
+ const limit = this.limit(ctx);
28
+ return limit === undefined || s.used < limit;
29
+ }
30
+ private state(ctx: ExtensionContext, goal: GoalRecord): GoalSchedulerState {
31
+ const s = goal.scheduler ?? newGoalScheduler(this.owner(ctx));
32
+ if (s.owner !== this.owner(ctx) || s.phase === "interrupted") throw new Error("Goal scheduling belongs to another session or was interrupted. Use /goal-resume to take ownership.");
33
+ return s;
34
+ }
35
+ private write(ctx: ExtensionContext, change: (g: GoalRecord) => GoalRecord): GoalRecord {
36
+ const error = this.core.goalService.flushForAudit(ctx);
37
+ if (error || this.core.goalService.isTurnBuffered()) throw new Error(error || "Goal changes have not reached disk.");
38
+ const id = this.core.state.goal?.id;
39
+ if (!id) throw new Error("No focused goal.");
40
+ const result = this.core.goalService.apply(ctx, { expectedGoalId: id, mutate: change,
41
+ ledger: g => g.status === "paused" ? [{ type: "goal_paused", goalId: g.id, reason: g.pauseReason ?? "Scheduling paused", source: "agent", at: new Date().toISOString() }] : [],
42
+ });
43
+ if (!result.ok) throw new Error(result.message);
44
+ this.core.updateUI(ctx);
45
+ return result.goal;
46
+ }
47
+ private update(ctx: ExtensionContext, change: (s: GoalSchedulerState, g: GoalRecord) => void): GoalRecord {
48
+ return this.write(ctx, g => { const s = this.state(ctx, g); change(s, g); return { ...g, scheduler: s }; });
49
+ }
50
+ private safe(ctx: ExtensionContext, fn: () => void): void {
51
+ try { fn(); } catch (error) {
52
+ this.cancelTimer();
53
+ this.core.runtime.clearContinuationState();
54
+ ctx.ui.notify(`Goal scheduling stopped: ${error instanceof Error ? error.message : String(error)}`, "warning");
55
+ }
56
+ }
57
+ cancelTimer(): void {
58
+ if (this.timer !== undefined) clearTimeout(this.timer);
59
+ this.timer = undefined;
60
+ this.armedGeneration = undefined;
61
+ }
62
+ private later(ctx: ExtensionContext, delay: number, fn: () => void): void {
63
+ this.cancelTimer();
64
+ this.timer = setTimeout(() => { this.timer = undefined; this.safe(ctx, fn); }, Math.min(2_147_483_647, Math.max(0, delay)));
65
+ this.timer.unref?.();
66
+ }
67
+ private pause(ctx: ExtensionContext, reason: string): void {
68
+ this.cancelTimer();
69
+ this.core.runtime.clearContinuationState();
70
+ this.write(ctx, g => {
71
+ if (g.scheduler && g.scheduler.owner !== this.owner(ctx)) throw new Error("Scheduling ownership changed; no pause was applied.");
72
+ return { ...g, status: "paused", autoContinue: false, stopReason: "agent", pauseReason: reason,
73
+ scheduler: g.scheduler ? { ...g.scheduler, generation: randomUUID(), phase: "idle", decision: undefined, dispatch: undefined, wait: undefined } : undefined };
74
+ });
75
+ this.core.clearActiveAccounting();
76
+ ctx.ui.notify(reason, "warning");
77
+ }
78
+ private allowanceReason(ctx: ExtensionContext): string {
79
+ return this.limit(ctx) === 0 ? "Automatic continuation disabled by maxAutonomousRuns=0. Change the setting in /goal-settings, then use /goal-resume." : "Autonomous-run allowance exhausted. Increase or remove maxAutonomousRuns, or use /goal-resume to renew.";
80
+ }
81
+
82
+ /** Bind once per live session. The event is deliberately not a triggerTurn message. */
83
+ attach(ctx: ExtensionContext): void {
84
+ this.ctx = ctx;
85
+ this.unsubscribe?.();
86
+ this.unsubscribe = this.core.pi.events?.on("pi-goal:wake", (event: unknown) => {
87
+ const raw = asRecord(event);
88
+ if (raw && this.ctx) this.signal(this.ctx, raw.goalId, raw.waitToken);
89
+ });
90
+ }
91
+ shutdown(): void { this.cancelTimer(); this.unsubscribe?.(); this.unsubscribe = undefined; this.ctx = undefined; }
92
+
93
+ restore(ctx: ExtensionContext): void {
94
+ this.attach(ctx);
95
+ this.inRun = false; this.declared = false; this.runGoalId = undefined;
96
+ this.cancelTimer();
97
+ this.safe(ctx, () => {
98
+ this.core.reconcileFocusedGoalFromDisk(ctx);
99
+ const goal = this.core.state.goal;
100
+ if (!goal || goal.status !== "active" || !goal.autoContinue || !goal.scheduler) return;
101
+ const s = goal.scheduler;
102
+ if (s.owner !== this.owner(ctx)) { ctx.ui.notify("Goal owned by another session. Use /goal-resume to take ownership.", "info"); return; }
103
+ if (["claimed", "running", "interrupted"].includes(s.phase)) {
104
+ this.pause(ctx, "Previous autonomous execution was interrupted. Use /goal-resume; its dispatch will not be replayed.");
105
+ return;
106
+ }
107
+ this.schedule(ctx);
108
+ });
109
+ }
110
+
111
+ /** Only explicit user resume resets spent allowance, including while already active/waiting. */
112
+ resume(ctx: ExtensionContext): boolean {
113
+ invalidateGoalSettingsCache();
114
+ if (this.limit(ctx) === 0) { ctx.ui.notify(this.allowanceReason(ctx), "warning"); return false; }
115
+ try {
116
+ this.cancelTimer();
117
+ this.core.runtime.clearContinuationState();
118
+ this.write(ctx, g => {
119
+ if (g.status === "complete" || budgetReached(g)) throw new Error("A completed or token-budget-limited goal cannot resume.");
120
+ return { ...g, status: "active", autoContinue: true, stopReason: undefined, pauseReason: undefined, pauseSuggestedAction: undefined,
121
+ scheduler: { ...newGoalScheduler(this.owner(ctx)), phase: "ready", decision: { kind: "ready", nextAction: "Continue the goal at the user's request.", purpose: "kickoff" } } };
122
+ });
123
+ // A resume during host work waits for settlement; another model turn
124
+ // still invalidates it through turn(), just like a declaration.
125
+ this.declared = this.inRun;
126
+ this.schedule(ctx);
127
+ return true;
128
+ } catch (error) { ctx.ui.notify(String(error), "warning"); return false; }
129
+ }
130
+ kickoff(ctx: ExtensionContext): void {
131
+ if (this.inRun) return; // creating a goal within a user run needs no extra run
132
+ this.safe(ctx, () => {
133
+ const goal = this.core.state.goal;
134
+ if (!goal || goal.status !== "active" || !goal.autoContinue) return;
135
+ if (this.limit(ctx) === 0) { this.update(ctx, () => {}); ctx.ui.notify(this.allowanceReason(ctx), "info"); return; }
136
+ this.update(ctx, s => { s.phase = "ready"; s.decision = { kind: "ready", nextAction: "Begin the requested goal.", purpose: "kickoff" }; });
137
+ this.schedule(ctx);
138
+ });
139
+ }
140
+
141
+ declare(ctx: ExtensionContext, input: GoalContinuation): AgentToolResult<unknown> {
142
+ try {
143
+ if (!this.core.state.goal || this.core.state.goal.status !== "active" || !this.core.state.goal.autoContinue || this.denied) throw new Error("Scheduling requires an active auto-continue goal in this session.");
144
+ invalidateGoalSettingsCache(); // an agent may have just configured the allowance
145
+ const goal = this.update(ctx, s => {
146
+ if (!this.available(ctx, s)) throw new Error(this.allowanceReason(ctx));
147
+ if (input.kind === "ready") {
148
+ if (typeof input.next_action !== "string" || !input.next_action.trim() || input.next_action.length > 2000) throw new Error("ready requires a nonempty next_action (at most 2000 characters).");
149
+ s.phase = "ready"; s.decision = { kind: "ready", nextAction: input.next_action.trim(), purpose: "ready" }; s.wait = undefined;
150
+ } else if (input.kind === "wait") {
151
+ if (typeof input.reason !== "string" || !input.reason.trim() || input.reason.length > 2000) throw new Error("wait requires a nonempty reason (at most 2000 characters).");
152
+ const deadline = Date.parse(input.deadline);
153
+ if (!/T.*(?:Z|[+-]\d\d:\d\d)$/.test(input.deadline) || !Number.isSafeInteger(deadline) || deadline <= Date.now()) throw new Error("wait requires a future ISO deadline with a timezone.");
154
+ if (s.wait) {
155
+ if (input.wait_id !== s.wait.id || deadline !== s.wait.deadline) throw new Error("Re-declare the current wait_id with its original deadline; checks cannot be reset.");
156
+ if (input.polling && (input.polling.interval_seconds * 1000 !== s.wait.intervalMs || input.polling.max_checks !== s.wait.remainingChecks)) throw new Error("Existing wait polling cannot be reset; omit polling when re-declaring wait_id.");
157
+ s.wait.token = randomUUID(); s.wait.signalled = false;
158
+ if (s.wait.intervalMs) s.wait.nextCheckAt = Date.now() + s.wait.intervalMs;
159
+ } else {
160
+ if (input.wait_id) throw new Error("Unknown wait_id.");
161
+ const polling = input.polling;
162
+ if (polling && (!Number.isSafeInteger(polling.interval_seconds) || polling.interval_seconds < 1 || polling.interval_seconds > 2_147_483 || !Number.isSafeInteger(polling.max_checks) || polling.max_checks < 1)) throw new Error("Polling requires positive whole interval_seconds (at most 2147483) and max_checks.");
163
+ s.wait = { id: randomUUID(), token: randomUUID(), reason: input.reason.trim(), deadline,
164
+ ...(polling ? { intervalMs: polling.interval_seconds * 1000, remainingChecks: polling.max_checks, nextCheckAt: Date.now() + polling.interval_seconds * 1000 } : {}) };
165
+ }
166
+ s.phase = "waiting"; s.decision = { kind: "wait" };
167
+ } else throw new Error("Unknown continuation kind.");
168
+ s.dispatch = undefined;
169
+ s.generation = randomUUID();
170
+ });
171
+ this.declared = true;
172
+ this.core.runtime.markTurnStopped(goal.id);
173
+ const wait = goal.scheduler?.wait;
174
+ return { content: [{ type: "text", text: `Scheduling decision saved. Stop this execution.\n${schedulerSummary(goal.scheduler, this.limit(ctx))}${wait ? `\nWake token: ${wait.token}. Register this token with the producer before completion; adapters must retain early results.` : ""}` }], details: { goal, ...(wait ? { wait_id: wait.id, waitToken: wait.token } : {}) }, terminate: true };
175
+ } catch (error) { return { content: [{ type: "text", text: `Scheduling decision NOT saved: ${error instanceof Error ? error.message : String(error)}` }], details: { error: true }, terminate: false }; }
176
+ }
177
+
178
+ prepare(): void { this.prepared = true; }
179
+ needsLiveContext(): boolean { return this.liveContext; }
180
+
181
+ /** One logical run can contain multiple agent_start events during native retry/compaction. */
182
+ begin(ctx: ExtensionContext): void {
183
+ this.ctx = ctx;
184
+ if (this.inRun) { this.core.runningGoalId = this.core.state.goal?.id ?? null; return; }
185
+ this.liveContext = !this.prepared; this.prepared = false;
186
+ this.inRun = true; this.declared = false; this.denied = false;
187
+ this.runGoalId = this.core.state.goal?.id;
188
+ this.cancelTimer(); this.core.runtime.clearContinuationTimer();
189
+ this.core.runningGoalId = this.core.state.goal?.id ?? null;
190
+ }
191
+ turn(ctx: ExtensionContext): void {
192
+ if (!this.declared) return;
193
+ this.declared = false;
194
+ this.safe(ctx, () => this.update(ctx, s => { s.decision = undefined; s.phase = "idle"; s.dispatch = undefined; }));
195
+ }
196
+ message(ctx: ExtensionContext, rawMessage: unknown): void {
197
+ const msg = asRecord(rawMessage);
198
+ if (!msg || !["user", "custom"].includes(String(msg.role))) return;
199
+ if (msg.customType === "pi-goal-audit-event") return;
200
+ if (msg.customType === "pi-goal-event") {
201
+ const d = asRecord(msg.details);
202
+ if (d?.kind !== "checkpoint") return;
203
+ try {
204
+ this.update(ctx, (s, g) => {
205
+ if (d.version !== 3 || s.phase !== "claimed" || g.id !== d.goalId || s.generation !== d.generation || s.dispatch?.id !== d.dispatchId || g.status !== "active" || !g.autoContinue) throw new Error("Stale or unclaimed goal checkpoint.");
206
+ // Retain the admitted action as context; phase prevents reusing its authorization.
207
+ s.phase = "running";
208
+ });
209
+ } catch (error) {
210
+ this.denied = true;
211
+ this.core.runtime.setCheckpoint(String(d?.goalId ?? "invalid"));
212
+ ctx.abort?.();
213
+ ctx.ui.notify(`Goal checkpoint rejected: ${String(error)}`, "warning");
214
+ }
215
+ return;
216
+ }
217
+ // Genuine host work supersedes an old scheduling claim, but never resets used.
218
+ this.takeover(ctx);
219
+ }
220
+ takeover(ctx: ExtensionContext): void {
221
+ this.cancelTimer(); this.core.runtime.clearContinuationState();
222
+ this.declared = false;
223
+ const s = this.core.state.goal?.scheduler;
224
+ if (!s || s.owner !== this.owner(ctx)) return;
225
+ this.safe(ctx, () => this.update(ctx, state => {
226
+ state.generation = randomUUID(); state.phase = "idle"; state.decision = undefined; state.dispatch = undefined; state.wait = undefined;
227
+ state.repairUsed = false;
228
+ }));
229
+ }
230
+ settled(ctx: ExtensionContext, successful = true): void {
231
+ this.inRun = false;
232
+ if (this.denied || (this.runGoalId && this.core.state.goal?.id !== this.runGoalId)) return;
233
+ this.safe(ctx, () => {
234
+ const goal = this.core.state.goal;
235
+ if (!goal || goal.status !== "active" || !goal.autoContinue || !successful) return;
236
+ if (this.declared && goal.scheduler?.decision) { this.schedule(ctx); return; }
237
+ const s = this.state(ctx, goal);
238
+ if (s.repairUsed || !this.available(ctx, s)) {
239
+ this.pause(ctx, !this.available(ctx, s) ? this.allowanceReason(ctx) : "No execution disposition after the contract-repair prompt. Use /goal-resume to continue.");
240
+ return;
241
+ }
242
+ this.update(ctx, state => {
243
+ state.phase = "ready"; state.dispatch = undefined;
244
+ state.decision = { kind: "ready", nextAction: "The prior execution did not declare a disposition. Declare ready with a concrete next action, wait with a deadline, or complete/pause/block. This is the only repair prompt.", purpose: "repair" };
245
+ });
246
+ this.schedule(ctx);
247
+ });
248
+ }
249
+ recover(ctx: ExtensionContext): void {
250
+ this.safe(ctx, () => {
251
+ this.update(ctx, s => { s.phase = "ready"; s.dispatch = undefined; s.decision = { kind: "ready", nextAction: "Retry after the provider error, then declare an execution disposition.", purpose: "recovery" }; });
252
+ this.schedule(ctx);
253
+ });
254
+ }
255
+
256
+ signal(ctx: ExtensionContext, goalId: unknown, token: unknown): void {
257
+ this.safe(ctx, () => {
258
+ this.core.reconcileFocusedGoalFromDisk(ctx);
259
+ const g = this.core.state.goal; const s = g?.scheduler;
260
+ if (!g || g.id !== goalId || g.status !== "active" || !g.autoContinue || s?.owner !== this.owner(ctx) || s.phase !== "waiting" || !s.wait || s.wait.token !== token || s.wait.signalled) return;
261
+ this.update(ctx, current => { if (current.wait?.token !== token || current.phase !== "waiting") throw new Error("Wait changed."); current.wait.signalled = true; });
262
+ if (!this.inRun) this.schedule(ctx);
263
+ });
264
+ }
265
+ schedule(ctx: ExtensionContext): void {
266
+ if (this.inRun) return;
267
+ this.safe(ctx, () => {
268
+ this.core.reconcileFocusedGoalFromDisk(ctx);
269
+ const g = this.core.state.goal;
270
+ if (!g || g.status !== "active" || !g.autoContinue || !g.scheduler) return;
271
+ const s = this.state(ctx, g);
272
+ if (!["ready", "waiting"].includes(s.phase)) return;
273
+ if (budgetReached(g)) { this.pause(ctx, "Goal token budget exhausted."); return; }
274
+ if (!this.available(ctx, s)) { this.pause(ctx, this.allowanceReason(ctx)); return; }
275
+ // Recovery and repair retain the wait while changing phase to ready.
276
+ if (s.wait && Date.now() >= s.wait.deadline) { this.pause(ctx, "Wait deadline reached."); return; }
277
+ if (s.phase === "waiting" && s.wait) {
278
+ this.core.clearActiveAccounting();
279
+ if (!s.wait.signalled && s.wait.remainingChecks === 0) { this.pause(ctx, "Wait check allowance exhausted."); return; }
280
+ if (!s.wait.signalled && (s.wait.nextCheckAt === undefined || s.wait.nextCheckAt > Date.now())) {
281
+ const due = Math.min(s.wait.deadline, s.wait.nextCheckAt ?? Infinity);
282
+ this.later(ctx, due - Date.now(), () => this.schedule(ctx)); return;
283
+ }
284
+ }
285
+ this.cancelTimer();
286
+ // Runtime readiness polling cannot authorize: claim() is called again at delivery.
287
+ this.armedGeneration = s.generation;
288
+ this.core.runtime.queueContinuation(ctx, g, true);
289
+ });
290
+ }
291
+ claim(ctx: ExtensionContext): Record<string, unknown> | null {
292
+ const generation = this.armedGeneration;
293
+ if (!generation) return null;
294
+ this.armedGeneration = undefined;
295
+ invalidateGoalSettingsCache();
296
+ try {
297
+ let result: Record<string, unknown> | null = null;
298
+ this.update(ctx, (s, g) => {
299
+ if (s.generation !== generation) throw new Error("Scheduling generation changed.");
300
+ if (g.status !== "active" || !g.autoContinue || budgetReached(g) || !this.available(ctx, s)) throw new Error(this.allowanceReason(ctx));
301
+ if (s.wait && Date.now() >= s.wait.deadline) throw new Error("Wait deadline reached.");
302
+ let kind = s.phase === "ready" && s.decision?.kind === "ready" ? s.decision.purpose : undefined;
303
+ if (s.phase === "waiting" && s.wait) {
304
+ if (s.wait.signalled) kind = "wake";
305
+ else if (s.wait.nextCheckAt !== undefined && s.wait.nextCheckAt <= Date.now() && (s.wait.remainingChecks ?? 0) > 0) { kind = "check"; s.wait.remainingChecks!--; }
306
+ }
307
+ if (!kind) throw new Error("No scheduling decision authorizes this checkpoint.");
308
+ s.used++; s.phase = "claimed"; s.dispatch = { id: randomUUID(), kind, claimedAt: Date.now() };
309
+ if (kind === "repair") s.repairUsed = true;
310
+ else if (kind !== "recovery") s.repairUsed = false;
311
+ if (s.wait) { s.wait.signalled = false; s.wait.token = randomUUID(); }
312
+ result = { generation: s.generation, dispatchId: s.dispatch.id };
313
+ });
314
+ return result;
315
+ } catch (error) {
316
+ const current = this.core.state.goal?.scheduler;
317
+ if (current && (current.owner !== this.owner(ctx) || current.generation !== generation)) return null;
318
+ this.safe(ctx, () => this.pause(ctx, `Goal wake stopped: ${error instanceof Error ? error.message : String(error)}`));
319
+ return null;
320
+ }
321
+ }
322
+ failedDispatch(ctx: ExtensionContext): void {
323
+ this.safe(ctx, () => this.pause(ctx, "Goal checkpoint delivery failed after claiming allowance. Use /goal-resume; it will not be replayed."));
324
+ }
325
+ isWaiting(): boolean { return !this.inRun && this.core.state.goal?.scheduler?.phase === "waiting"; }
326
+ isDenied(): boolean { return this.denied; }
327
+ }
@@ -68,7 +68,7 @@ function accumulateCheckpointHealth(health: CheckpointHealth, entry: unknown): v
68
68
  if (checkpoint.content.length > health.largestCheckpointChars) {
69
69
  health.largestCheckpointChars = checkpoint.content.length;
70
70
  }
71
- const isV2 = checkpoint.version === 2 && V2_MARKER_PATTERN.test(checkpoint.content);
71
+ const isV2 = (checkpoint.version === 2 || checkpoint.version === 3) && V2_MARKER_PATTERN.test(checkpoint.content);
72
72
  if (isV2) {
73
73
  health.v2Minimal += 1;
74
74
  } else {
@@ -102,6 +102,8 @@ export interface GoalSettingsResolvedShape {
102
102
  auditorProjectResources?: boolean;
103
103
  /** F5: stall detector timeout in minutes (0 = off). */
104
104
  stallTimeoutMinutes?: number;
105
+ /** Optional extension-run limit per creation/resume; zero disables, absent inherits (default unlimited). */
106
+ maxAutonomousRuns?: number;
105
107
  /**
106
108
  * Maximum objective length in characters (0/unset = no limit, the
107
109
  * default; >0 caps objectives in create_goal, propose_goal_draft, and
@@ -328,6 +330,7 @@ const ALLOWED_SETTINGS_KEYS = new Set([
328
330
  "autoSelectSingleGoal",
329
331
  "auditorProjectResources",
330
332
  "stallTimeoutMinutes",
333
+ "maxAutonomousRuns",
331
334
  "objectiveMaxChars",
332
335
  "keybindings",
333
336
  "hideUnfocusedBanner",
@@ -397,6 +400,12 @@ export function parseSettingsLayer(
397
400
  else layer.subtaskDepth = parsed;
398
401
  break;
399
402
  }
403
+ case "maxAutonomousRuns": {
404
+ const n = typeof value === "number" ? value : typeof value === "string" && /^[0-9]+$/.test(value.trim()) ? Number(value) : NaN;
405
+ if (!Number.isSafeInteger(n) || n < 0) diagnostics.push(diagnostic("invalid_value", "maxAutonomousRuns must be a nonnegative safe integer (0 disables automatic continuation)", key));
406
+ else layer.maxAutonomousRuns = n;
407
+ break;
408
+ }
400
409
  case "stallTimeoutMinutes":
401
410
  case "objectiveMaxChars": {
402
411
  const parsed = asNonNegativeInt(value);
@@ -781,6 +790,11 @@ function resolvedSettingsSnapshot(cwd: string, env: NodeJS.ProcessEnv): Settings
781
790
  globalValue: global.layer.oracle?.maxFailedAttemptsPerBlocker,
782
791
  defaultValue: 2,
783
792
  }));
793
+ const maxAutonomousRuns = track("maxAutonomousRuns", resolveLeaf<number | undefined>({
794
+ projectValue: project.layer.maxAutonomousRuns,
795
+ globalValue: global.layer.maxAutonomousRuns,
796
+ defaultValue: undefined,
797
+ }));
784
798
  const stallTimeoutMinutes = track("stallTimeoutMinutes", resolveLeaf<number>({
785
799
  projectValue: project.layer.stallTimeoutMinutes,
786
800
  globalValue: global.layer.stallTimeoutMinutes,
@@ -840,6 +854,7 @@ function resolvedSettingsSnapshot(cwd: string, env: NodeJS.ProcessEnv): Settings
840
854
  auditorProjectResources,
841
855
  hideUnfocusedBanner,
842
856
  stallTimeoutMinutes,
857
+ maxAutonomousRuns,
843
858
  objectiveMaxChars,
844
859
  keybindings,
845
860
  networkRecovery: {
@@ -1189,6 +1204,7 @@ function buildPersistedLayer(settings: GoalSettings): Record<string, unknown> {
1189
1204
  if (so.maxFailedAttemptsPerBlocker !== undefined) o.maxFailedAttemptsPerBlocker = so.maxFailedAttemptsPerBlocker;
1190
1205
  if (Object.keys(o).length > 0) persisted.oracle = o;
1191
1206
  }
1207
+ if (settings.maxAutonomousRuns !== undefined) persisted.maxAutonomousRuns = settings.maxAutonomousRuns;
1192
1208
  if (settings.stallTimeoutMinutes !== undefined) persisted.stallTimeoutMinutes = settings.stallTimeoutMinutes;
1193
1209
  if (settings.objectiveMaxChars !== undefined) persisted.objectiveMaxChars = settings.objectiveMaxChars;
1194
1210
  if (settings.keybindings?.dashboard) {
@@ -1232,6 +1248,7 @@ export function effectiveSettingsReport(cwd: string, env: NodeJS.ProcessEnv = pr
1232
1248
  { key: "thinkingLevel", label: "thinking_level", format: () => snapshot.value.thinkingLevel ?? "(default)" },
1233
1249
  { key: "auditorProjectResources", label: "auditor project resources", format: () => String(snapshot.value.auditorProjectResources) },
1234
1250
  { key: "hideUnfocusedBanner", label: "hide unfocused banner", format: () => String(snapshot.value.hideUnfocusedBanner) },
1251
+ { key: "maxAutonomousRuns", label: "autonomous run allowance", format: () => snapshot.value.maxAutonomousRuns === 0 ? "0 (disabled)" : String(snapshot.value.maxAutonomousRuns ?? "unlimited (default)") },
1235
1252
  { key: "stallTimeoutMinutes", label: "stall timeout (minutes)", format: () => String(snapshot.value.stallTimeoutMinutes) },
1236
1253
  { key: "objectiveMaxChars", label: "max objective length (0 = none)", format: () => String(snapshot.value.objectiveMaxChars) },
1237
1254
  { key: "networkRecovery", label: "network recovery attempts (0 = unbounded)", format: () => String(snapshot.value.networkRecovery?.maxAttempts ?? 0) },
@@ -31,6 +31,8 @@ import {
31
31
  import { GoalService } from "./goal-service.ts";
32
32
  import { goalActivityEvents } from "./goal-ledger.ts";
33
33
  import { GoalAccounting } from "./goal-accounting.ts";
34
+ import { newGoalScheduler } from "./goal-scheduler-state.ts";
35
+ import { GoalScheduler } from "./goal-scheduler.ts";
34
36
  import { GoalRuntime } from "./goal-runtime.ts";
35
37
  import { GoalAuditMessages } from "./goal-session-safety.ts";
36
38
  import {
@@ -79,6 +81,7 @@ export interface GoalCore {
79
81
  goalWidgetComponentRef: { current: GoalWidgetComponent | null };
80
82
  goalService: GoalService;
81
83
  runtime: GoalRuntime;
84
+ scheduler: GoalScheduler;
82
85
  auditMessages: GoalAuditMessages;
83
86
  accounting: GoalAccounting;
84
87
 
@@ -262,7 +265,11 @@ export function createGoalCore(
262
265
  // token/time accounting lives in `accounting` (extensions/goal-accounting.ts).
263
266
  let goalWorkToolCalledThisTurn = false;
264
267
 
268
+ let scheduler: GoalScheduler;
265
269
  const runtime = new GoalRuntime({
270
+ authorize: ctx => scheduler.claim(ctx),
271
+ recover: ctx => scheduler.recover(ctx),
272
+ dispatchFailed: ctx => scheduler.failedDispatch(ctx),
266
273
  sendFollowUp: (content, details) => {
267
274
  pi.sendMessage<GoalEventDetails>(
268
275
  {
@@ -365,6 +372,7 @@ export function createGoalCore(
365
372
  }
366
373
 
367
374
  function clearContinuationState(resetNetworkErrorBackoff = true): void {
375
+ scheduler?.cancelTimer();
368
376
  runtime.clearContinuationState(resetNetworkErrorBackoff);
369
377
  }
370
378
 
@@ -413,6 +421,7 @@ export function createGoalCore(
413
421
  opts: { recordLedger?: boolean } = {},
414
422
  ): void {
415
423
  const previousGoalId = focusedGoalId;
424
+ if (previousGoalId !== goalId) scheduler.takeover(ctx);
416
425
  if (previousGoalId !== goalId) goalService.flushTurn(ctx); // P1-3: persist the old buffer before switching focus
417
426
  assignFocusedGoalId(goalId && goalsById.has(goalId) ? goalId : null);
418
427
  if (previousGoalId !== focusedGoalId) {
@@ -455,6 +464,7 @@ export function createGoalCore(
455
464
  }
456
465
 
457
466
  function beginAccounting(): void {
467
+ if (scheduler?.isWaiting()) { clearActiveAccounting(); return; }
458
468
  if (!state.goal || (state.goal.status !== "active")) {
459
469
  clearActiveAccounting();
460
470
  return;
@@ -862,8 +872,8 @@ export function createGoalCore(
862
872
  }
863
873
 
864
874
  function queueContinuation(ctx: ExtensionContext, force = false): void {
865
- if (!state.goal) return;
866
- runtime.queueContinuation(ctx, state.goal, force);
875
+ void force;
876
+ scheduler.schedule(ctx);
867
877
  }
868
878
 
869
879
  function enterGoalModal(): void {
@@ -876,6 +886,7 @@ export function createGoalCore(
876
886
 
877
887
  function replaceGoal(config: GoalCreationConfig, ctx: ExtensionContext, startNow = true, verificationContract?: string, tokenBudget?: number): void {
878
888
  const goal = createGoal(config);
889
+ goal.scheduler = newGoalScheduler(ctx.sessionManager.getSessionId());
879
890
  if (verificationContract) goal.verificationContract = verificationContract;
880
891
  if (config.taskList) goal.taskList = config.taskList;
881
892
  if (typeof tokenBudget === "number" && tokenBudget > 0) goal.tokenBudget = Math.floor(tokenBudget);
@@ -902,10 +913,10 @@ export function createGoalCore(
902
913
  if (result.focusChanged) appendFocusEntry(result.goalId, "created");
903
914
  beginAccounting();
904
915
  ctx.ui.notify(buildGoalRunningNotification(config), "info");
905
- if (startNow && state.goal?.autoContinue) queueContinuation(ctx, true);
916
+ if (startNow && state.goal?.autoContinue) scheduler.kickoff(ctx);
906
917
  }
907
918
 
908
- return {
919
+ const core: GoalCore = {
909
920
  pi,
910
921
  dependencies,
911
922
  state,
@@ -1002,6 +1013,7 @@ export function createGoalCore(
1002
1013
  goalWidgetComponentRef,
1003
1014
  goalService,
1004
1015
  runtime,
1016
+ get scheduler() { return scheduler; },
1005
1017
  auditMessages: new GoalAuditMessages(),
1006
1018
  accounting,
1007
1019
  assignFocusedGoalId,
@@ -1049,4 +1061,6 @@ export function createGoalCore(
1049
1061
  checkStall,
1050
1062
  replaceGoal,
1051
1063
  };
1064
+ scheduler = new GoalScheduler(core);
1065
+ return core;
1052
1066
  }
@@ -31,6 +31,7 @@ export const PLAIN_THEME = {
31
31
  } as never;
32
32
 
33
33
  export interface GoalStatusTextOptions {
34
+ maxAutonomousRuns?: number;
34
35
  goal: GoalRecord | null;
35
36
  focused: boolean;
36
37
  otherOpenGoals: number;
@@ -72,6 +73,7 @@ export function buildGoalStatusText(options: GoalStatusTextOptions): string {
72
73
  const events = options.ledgerEvents ?? [];
73
74
  const model = deriveGoalDashboardModel(options.goal, {
74
75
  focused: options.focused,
76
+ maxAutonomousRuns: options.maxAutonomousRuns,
75
77
  otherOpenGoals: options.otherOpenGoals,
76
78
  ledgerEvents: events,
77
79
  });
@@ -218,6 +220,7 @@ function visibleLen(value: string): number {
218
220
  function buildVerboseStatus(goal: GoalRecord, model: ReturnType<typeof deriveGoalDashboardModel>, events: GoalLedgerEvent[], settingsReport: string[], _width: number): string {
219
221
  const lines: string[] = [];
220
222
  lines.push(`Goal id: ${goal.id}`);
223
+ lines.push(...(model?.scheduling ?? []));
221
224
  lines.push(`Revision: ${goal.revision ?? 0}`);
222
225
  lines.push(`Status: ${model?.status.label}${model?.focused ? " (focused)" : " (not focused)"}`);
223
226
  lines.push(`Mode: ${goal.sisyphus ? "sisyphus" : "goal"} · auto-continue: ${goal.autoContinue ? "on" : "off"}`);
@@ -1,3 +1,4 @@
1
+ import { schedulerSummary } from "../goal-scheduler-state.ts";
1
2
  import { taskIndex } from "../goal-task-index.ts";
2
3
  import { statusLabel, truncateText } from "../goal-core.ts";
3
4
  import { promptSafeObjective } from "../goal-contract.ts";
@@ -182,9 +183,11 @@ export function sisyphusDisciplineBlock(goal: GoalRecord): string {
182
183
  }
183
184
 
184
185
  /** Shared outcome/blocker policy for active goals (bounded). */
185
- function lifecyclePolicyBlock(): string {
186
+ function lifecyclePolicyBlock(autonomous: boolean): string {
186
187
  return [
187
188
  "[OUTCOMES]",
189
+ '- Automatic runs default to unlimited. maxAutonomousRuns in .pi/pi-goal-x-settings.json caps runs; 0 disables (agents may set it). Only creation or user /goal-resume renews usage.',
190
+ ...(autonomous ? ['- End execution with update_goal: ready for runnable work, wait for an external condition, or a status below. Saved decisions terminate; further work invalidates them. Missing decisions allow one repair. Never busy-poll.'] : []),
188
191
  '- update_goal({status: "complete"}) only when every requirement is satisfied; the independent completion auditor checks actual evidence. Approval archives; rejection requires rework.',
189
192
  '- update_goal({status: "blocked"}) only after the SAME blocker recurs on three consecutive goal turns; keep trying concrete steps before then.',
190
193
  '- update_goal({status: "paused", reason: "…"}) pauses immediately. User controls: /goal-pause, /goal-resume, /goal-clear.',
@@ -204,7 +207,7 @@ let promptCacheChars = 0;
204
207
  function cachedPrompt(goal: GoalRecord, settings: GoalSettings | undefined, kind: "goal" | "continuation", build: () => string): string {
205
208
  const key = [kind, goal.id, goal.status, goal.autoContinue, goal.sisyphus, goal.objective,
206
209
  goal.verificationContract, settings?.disableTasks ? undefined : taskIndex(goal.taskList?.tasks),
207
- goal.taskList?.blockCompletion, promptProfile(), goal.currentTaskId, settings?.disableTasks, settings?.disableContracts];
210
+ goal.taskList?.blockCompletion, promptProfile(), goal.currentTaskId, settings?.disableTasks, settings?.disableContracts, settings?.maxAutonomousRuns !== 0];
208
211
  for (let i = promptFragmentCache.length - 1; i >= 0; i--) {
209
212
  const entry = promptFragmentCache[i]!;
210
213
  if (key.every((part, j) => part === entry.key[j])) return entry.value;
@@ -227,7 +230,7 @@ function retainedTaskChars(task: GoalTask): number {
227
230
  export function goalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
228
231
  const fixed = cachedPrompt(goal, settings, "goal", () => buildGoalPrompt(goal, settings));
229
232
  const budget = budgetLine(goal);
230
- return `${fixed}\nUsage: ${formatUsage(goal)}${budget ? `\n${budget}` : ""}`;
233
+ return `${fixed}\nUsage: ${formatUsage(goal)}${budget ? `\n${budget}` : ""}\n${schedulerSummary(goal.scheduler, settings?.maxAutonomousRuns)}`;
231
234
  }
232
235
 
233
236
  function buildGoalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
@@ -235,7 +238,7 @@ function buildGoalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
235
238
  // Bound individual data fields so essential rules can never be sliced off.
236
239
  return [
237
240
  `[PI GOAL ACTIVE goalId=${goal.id}]`,
238
- lifecyclePolicyBlock(), sisyphusDisciplineBlock(goal),
241
+ lifecyclePolicyBlock(settings?.maxAutonomousRuns !== 0), sisyphusDisciplineBlock(goal),
239
242
  `Status: ${statusLabel(goal)}\nMode: ${goal.sisyphus ? "sisyphus" : "regular"}`,
240
243
  untrustedObjectiveBlock(goal), taskListBlock(goal, settings), verificationContractBlock(goal, settings),
241
244
  ].filter(Boolean).join("\n\n");
@@ -1,3 +1,4 @@
1
+ import { schedulerSummary } from "../goal-scheduler-state.ts";
1
2
  import { taskIndex } from "../goal-task-index.ts";
2
3
  /**
3
4
  * Shared dashboard view model (plan §6) — the single source of truth for the
@@ -22,6 +23,7 @@ import { deriveGoalActivity, type GoalActivityItem } from "../goal-activity.ts";
22
23
  export type DashboardStatusCode = "running" | "idle" | "paused" | "blocked" | "budget_limited" | "complete";
23
24
 
24
25
  export interface GoalDashboardModel {
26
+ scheduling: string[];
25
27
  goalId: string;
26
28
  title: string;
27
29
 
@@ -100,6 +102,7 @@ export interface DashboardTaskNode {
100
102
  }
101
103
 
102
104
  export interface GoalDashboardModelOptions {
105
+ maxAutonomousRuns?: number;
103
106
  focused: boolean;
104
107
  otherOpenGoals: number;
105
108
  ledgerEvents?: readonly GoalLedgerEvent[];
@@ -470,6 +473,7 @@ export function deriveGoalDashboardModel(
470
473
  const { focused, otherOpenGoals, ledgerEvents = [], activityLimit, tasksDisabled = false } = options;
471
474
 
472
475
  const status = deriveGoalStatus(goal);
476
+ if (goal.status === "active" && goal.scheduler?.phase === "waiting") { status.code = "idle"; status.label = "Waiting"; status.footerLabel = "waiting"; }
473
477
  // §9.5: with tasks disabled, omit task sections entirely (status,
474
478
  // verification, usage, path, and focus remain).
475
479
  const {taskProgress, taskTree, currentTask, taskTitles} = taskPresentation(goal, tasksDisabled);
@@ -494,6 +498,7 @@ export function deriveGoalDashboardModel(
494
498
  if (goal.usage.tokensUsed > 0) footerUsageBits.push(formatCompactTokens(goal.usage.tokensUsed));
495
499
 
496
500
  return {
501
+ scheduling: schedulerSummary(goal.scheduler, options.maxAutonomousRuns).split("\n"),
497
502
  goalId: goal.id,
498
503
  title: displayObjectiveTitle(goal.objective),
499
504
  status,
@@ -310,6 +310,8 @@ export function renderCompactDashboard(
310
310
  lines.push(boxLine(theme, safeWidth, statusLine(theme, model)));
311
311
  }
312
312
 
313
+ for (const row of model.scheduling ?? []) lines.push(boxLine(theme, safeWidth, muted(theme, fit(row, inner))));
314
+
313
315
  // Budget (when configured): fuel gauge + amount, amber until the budget is
314
316
  // exhausted, then soft red.
315
317
  if (model.budget) {
@@ -455,6 +457,7 @@ export function renderExpandedDashboard(
455
457
  lines.push(boxHeader(theme, safeWidth, `${accent(theme, "pi-goal-x")} ${frame(theme, `─ ${model.title}`)}`));
456
458
 
457
459
  lines.push(boxLine(theme, safeWidth, statusLine(theme, model)));
460
+ for (const row of model.scheduling ?? []) lines.push(boxLine(theme, safeWidth, muted(theme, fit(row, inner))));
458
461
 
459
462
  if (spec.showPath && model.filePath) {
460
463
  lines.push(boxLine(theme, safeWidth, dim(theme, `File: ${model.filePath}`)));
@@ -548,6 +548,7 @@ export class GoalWidgetComponent implements Component {
548
548
  otherOpenGoals: otherCount,
549
549
  ledgerEvents: this.getLedgerEvents(),
550
550
  tasksDisabled: settings.disableTasks === true,
551
+ maxAutonomousRuns: settings.maxAutonomousRuns,
551
552
  }) : null;
552
553
  this.maybeReanchor(model);
553
554
  const lines = renderGoalWidgetLines(this.getGoal(), this.theme, safeWidth, {
@@ -629,6 +630,7 @@ export class GoalWidgetComponent implements Component {
629
630
  otherOpenGoals: Math.max(0, this.getOpenGoalCount() - 1),
630
631
  ledgerEvents: this.getLedgerEvents(),
631
632
  tasksDisabled: settings.disableTasks === true,
633
+ maxAutonomousRuns: settings.maxAutonomousRuns,
632
634
  }) : null;
633
635
  const list = model?.taskTree.filter((n) => n.depth === 0) ?? [];
634
636
  const rows = compactTaskViewportRows(this.lastRenderWidth);
@@ -663,6 +665,7 @@ export class GoalWidgetComponent implements Component {
663
665
  otherOpenGoals: Math.max(0, this.getOpenGoalCount() - 1),
664
666
  ledgerEvents: this.getLedgerEvents(),
665
667
  tasksDisabled: settings.disableTasks === true,
668
+ maxAutonomousRuns: settings.maxAutonomousRuns,
666
669
  }) : null;
667
670
  const list = model?.taskTree ?? [];
668
671
  if (list.length === 0) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.31.3",
3
+ "version": "0.31.4",
4
4
  "description": "Adds /goal to pi: conversational goal planning, flexible or ordered goals, persistent progress, and an independent completion auditor.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",