@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.1

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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +239 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +330 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,1069 @@
1
+ import { GoalInterruption } from "./goal-interruption.ts";
2
+ import { commandHint, type GoalCommandId, type GoalCommands } from "./goal-controls.ts";
3
+ import * as path from "node:path";
4
+ import { goalStorageContext, goalStorageRoot } from "./storage/goal-root.ts";
5
+ import { type AgentToolResult, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { FOCUS_ENTRY, STATE_ENTRY, GOAL_EVENT_ENTRY, goalDetails } from "./goal-format.ts";
7
+ import { loadGoalSettings, loadGoalSettingsFileConfig } from "./goal-settings.ts";
8
+ import {
9
+ ALL_REGISTERED_GOAL_TOOLS,
10
+ applicableGoalTools,
11
+ } from "./goal-tool-names.ts";
12
+ import { budgetReached } from "./goal-accounting.ts";
13
+ import {
14
+ asRecord,
15
+ cloneGoal,
16
+ createGoal,
17
+ goalFocusDetails,
18
+ normalizeGoalFocusEntry,
19
+ normalizeGoalRecord,
20
+ nowIso,
21
+ type GoalCreationConfig,
22
+ type GoalEventDetails,
23
+ type GoalFocusEntry,
24
+ type GoalFocusReason,
25
+ type GoalRecord,
26
+ type GoalStatus,
27
+ type StopReason,
28
+ } from "./goal-record.ts";
29
+ import {
30
+ mergeGoalPromptFromDisk,
31
+ readActiveGoalPoolAsync,
32
+ sanitizeGoalPaths,
33
+ } from "./storage/goal-files.ts";
34
+ import { GoalService } from "./goal-service.ts";
35
+ import { goalActivityEvents } from "./goal-ledger.ts";
36
+ import { GoalAccounting } from "./goal-accounting.ts";
37
+ import { newGoalScheduler } from "./goal-scheduler-state.ts";
38
+ import { GoalScheduler } from "./goal-scheduler.ts";
39
+ import { GoalRuntime } from "./goal-runtime.ts";
40
+ import { GoalAuditMessages } from "./goal-session-safety.ts";
41
+ import {
42
+ focusedGoalFromPool,
43
+ openGoalsFromPool,
44
+ otherOpenGoalCount,
45
+ resolveSessionFocus,
46
+ } from "./goal-pool.ts";
47
+ import { buildGoalRunningNotification } from "./widgets/goal-notifications.ts";
48
+ import { GOAL_WIDGET_KEY, GoalWidgetComponent, liveDisplayGoal, makeGoalWidgetFactory, type AuditorWidgetProgress } from "./widgets/goal-widget.ts";
49
+ import type { AuditVerdict } from "./widgets/auditor-dashboard-model.ts";
50
+ import { runGoalCompletionAuditor } from "./goal-auditor.ts";
51
+
52
+
53
+
54
+ /**
55
+ * The shared mutable core of the goal extension. All state lives here; the
56
+ * tools/commands/events/widget modules receive this core and operate on it.
57
+ * `state.goal` mirrors the old `state` object: reading returns the focused
58
+ * goal, assigning replaces it in the pool and updates the focus.
59
+ */
60
+ export interface ActiveGoalAudit {
61
+ controller: AbortController;
62
+ phase: "running" | "awaiting-choice" | "settling" | "settled";
63
+ cancel(reason: string): void;
64
+ }
65
+
66
+ export interface GoalCore {
67
+ pi: ExtensionAPI;
68
+ dependencies: { runCompletionAuditor?: typeof runGoalCompletionAuditor };
69
+ state: { goal: GoalRecord | null };
70
+ readonly goalsById: Map<string, GoalRecord>;
71
+ readonly focusedGoalId: string | null;
72
+ readonly focusRevision: number;
73
+ sessionOwner: object | null;
74
+ hasExplicitSessionFocus: boolean;
75
+ runningGoalId: string | null;
76
+ auditProgress: AuditorWidgetProgress | null;
77
+ auditAnimationTimer: ReturnType<typeof setInterval> | null;
78
+ activeAudit: ActiveGoalAudit | null;
79
+ /** §15.4: finished audit result card shown briefly before the dashboard returns. */
80
+ auditResult: { verdict: AuditVerdict; report: string; at: string } | null;
81
+ setAuditResult(verdict: AuditVerdict, report: string): void;
82
+ clearAuditResult(): void;
83
+ goalModalDepth: number;
84
+ enterGoalModal(): void;
85
+ exitGoalModal(): void;
86
+ invalidateSession(): void;
87
+ goalWorkToolCalledThisTurn: boolean;
88
+ tasksEnabled: boolean;
89
+ debugMode: boolean;
90
+ terminalInputUnsubscribe: (() => void) | null;
91
+ goalWidgetComponentRef: { current: GoalWidgetComponent | null };
92
+ goalService: GoalService;
93
+ runtime: GoalRuntime;
94
+ scheduler: GoalScheduler;
95
+ interruption: GoalInterruption;
96
+ invokeGoalCommand?: (id: GoalCommandId, args: string, ctx: ExtensionContext) => Promise<void>;
97
+ loadedCommands?: GoalCommands;
98
+ controlWarnings?: string[];
99
+ auditMessages: GoalAuditMessages;
100
+ accounting: GoalAccounting;
101
+
102
+ assignFocusedGoalId(goalId: string | null): void;
103
+ focusedOperationToken(goalId: string): { goalId: string; revision: number };
104
+ isFocusedOperationCurrent(token: { goalId: string; revision: number }): boolean;
105
+ focusedOperationCancelledResult(action: string, token: { goalId: string; revision: number }): AgentToolResult<unknown>;
106
+ installGoalToolProfile(tasksEnabled: boolean): void;
107
+ stopAuditAnimation(): void;
108
+ abortAudit(ctx: ExtensionContext): void;
109
+ clearContinuationTimer(): void;
110
+ clearContinuationState(resetNetworkErrorBackoff?: boolean): void;
111
+ clearActiveAccounting(): void;
112
+ advanceTurnSeq(): void;
113
+ currentTurnStoppedGoalId(): string | null;
114
+ isActionableContinuationGoal(goalId: string | null | undefined): goalId is string;
115
+ isStaleCheckpointBlockedToolCall(toolName: string): boolean;
116
+ clearStoppedRuntimeState(): void;
117
+ openGoals(): GoalRecord[];
118
+ /** §10: toggle the unified dashboard between compact and expanded modes. */
119
+ toggleDashboardExpanded(): void;
120
+ /** §10: whether the unified dashboard is currently expanded. */
121
+ isDashboardExpanded(): boolean;
122
+ reconcileFocusedGoalFromDisk(ctx: ExtensionContext, opts?: { preserveMemoryUsage?: boolean }): boolean;
123
+ appendFocusEntry(goalId: string | null, reason: GoalFocusReason): void;
124
+ setFocusedGoalId(goalId: string | null, ctx: ExtensionContext, reason: GoalFocusReason, opts?: { recordLedger?: boolean }): void;
125
+ updateFocusedGoal(next: GoalRecord, ctx: ExtensionContext, shouldPersist?: boolean): void;
126
+ armFocusedContinuation(ctx: ExtensionContext): void;
127
+ removeFocusedGoal(ctx: ExtensionContext, reason: GoalFocusReason): void;
128
+ beginAccounting(): void;
129
+ goalForDisplay(): GoalRecord | null;
130
+ accountProgress(ctx: ExtensionContext, opts?: { completedTurnTokens?: number }): void;
131
+ syncGoalPromptFromDisk(ctx: ExtensionContext): boolean;
132
+ persist(ctx?: ExtensionContext): void;
133
+ refreshGoalDisplayFromDisk(ctx: ExtensionContext): void;
134
+ updateUI(ctx: ExtensionContext): void;
135
+ clearGoalWidget(ctx: ExtensionContext): void;
136
+ loadState(ctx: ExtensionContext): Promise<void>;
137
+ setGoal(next: GoalRecord | null, ctx: ExtensionContext, shouldPersist?: boolean, focusReason?: GoalFocusReason): void;
138
+ archiveCurrentGoal(ctx: ExtensionContext, reason: StopReason | undefined): GoalRecord | null;
139
+ stopActiveGoal(status: Exclude<GoalStatus, "active">, reason: StopReason | undefined, ctx: ExtensionContext): void;
140
+ pauseActiveGoal(ctx: ExtensionContext): void;
141
+ /** §auditor-toggle: flip the focused goal's persisted per-goal skipAuditor and record the ledger event. */
142
+ toggleGoalAuditor(ctx: ExtensionContext): void;
143
+ queueContinuation(ctx: ExtensionContext, force?: boolean): void;
144
+ flushGoalTransaction(ctx: ExtensionContext): void;
145
+ replaceGoal(config: GoalCreationConfig, ctx: ExtensionContext, startNow?: boolean, verificationContract?: string, tokenBudget?: number): void;
146
+ /** F5: bump the last-activity timestamp (called on real work events). */
147
+ touchGoalActivity(): void;
148
+ /** F5: detect a stalled active auto-continue goal; returns the [GOAL STALLED] steering note. */
149
+ checkStall(ctx: ExtensionContext): string;
150
+ }
151
+
152
+ export function createGoalCore(
153
+ pi: ExtensionAPI,
154
+ dependencies: { runCompletionAuditor?: typeof runGoalCompletionAuditor } = {},
155
+ ): GoalCore {
156
+ let goalsById = new Map<string, GoalRecord>();
157
+ let focusedGoalId: string | null = null;
158
+ let focusRevision = 0;
159
+ let hasExplicitSessionFocus = false;
160
+
161
+ function assignFocusedGoalId(next: string | null): void {
162
+ if (focusedGoalId !== next) {
163
+ focusRevision += 1;
164
+ activeAudit?.cancel("Goal focus changed during completion audit.");
165
+ }
166
+ focusedGoalId = next;
167
+ }
168
+
169
+ function focusedOperationToken(goalId: string): { goalId: string; revision: number } {
170
+ return { goalId, revision: focusRevision };
171
+ }
172
+
173
+ function isFocusedOperationCurrent(token: { goalId: string; revision: number }): boolean {
174
+ return focusedGoalId === token.goalId && focusRevision === token.revision;
175
+ }
176
+
177
+ function focusedOperationCancelledResult(action: string, token: { goalId: string; revision: number }) {
178
+ return {
179
+ content: [{
180
+ type: "text" as const,
181
+ text: `${action} cancelled because goal ${token.goalId} is no longer focused in this session. The shared goal was not modified.`,
182
+ }],
183
+ details: goalDetails(state.goal),
184
+ terminate: true,
185
+ };
186
+ }
187
+
188
+ const state = {
189
+ get goal(): GoalRecord | null {
190
+ return focusedGoalFromPool(goalsById, focusedGoalId);
191
+ },
192
+ set goal(next: GoalRecord | null) {
193
+ if (next) {
194
+ goalsById.set(next.id, next);
195
+ assignFocusedGoalId(next.id);
196
+ return;
197
+ }
198
+ if (focusedGoalId) goalsById.delete(focusedGoalId);
199
+ assignFocusedGoalId(null);
200
+ },
201
+ };
202
+
203
+ /**
204
+ * Sole mutation boundary for goal records. All goal-file writes, ledger
205
+ * appends, and ordered write→ledger→memory commits route through this
206
+ * service; handlers keep validation and runtime/UI effects.
207
+ */
208
+ const goalService = new GoalService({
209
+ getFocused: () => state.goal,
210
+ setFocused: (goal) => {
211
+ state.goal = goal;
212
+ },
213
+ getPool: () => goalsById,
214
+ replacePool: (pool) => {
215
+ goalsById = pool;
216
+ },
217
+ getFocusedGoalId: () => focusedGoalId,
218
+ assignFocusedGoalId: (goalId) => assignFocusedGoalId(goalId),
219
+ focusToken: (goalId) => focusedOperationToken(goalId),
220
+ isTokenCurrent: (token) => isFocusedOperationCurrent(token),
221
+ appendFocusEntry: (goalId, reason) => appendFocusEntry(goalId, reason),
222
+ onFocusedGoalLost: (lostGoalId, ctx) => {
223
+ clearStoppedRuntimeState();
224
+ updateUI(ctx as unknown as ExtensionContext);
225
+ },
226
+ onReconciled: (goal) => {
227
+ if (goal.status !== "active" || !goal.autoContinue) clearContinuationState();
228
+ if (goal.status !== "active") clearActiveAccounting();
229
+ },
230
+ onFocusChanged: () => {
231
+ clearContinuationState();
232
+ clearActiveAccounting();
233
+ },
234
+ onDiagnostic: (diagnostic) => {
235
+ // Non-fatal failure sink: ledger appends must never roll back the
236
+ // authoritative state write, but they stay observable.
237
+ console.warn(`[pi-goal] ${diagnostic.source} diagnostic: ${diagnostic.message}`);
238
+ },
239
+ });
240
+ let runningGoalId: string | null = null;
241
+ let terminalInputUnsubscribe: (() => void) | null = null;
242
+ let auditProgress: AuditorWidgetProgress | null = null;
243
+ let auditAnimationTimer: ReturnType<typeof setInterval> | null = null;
244
+ let auditResult: { verdict: AuditVerdict; report: string; at: string } | null = null;
245
+ let auditResultClearTimer: ReturnType<typeof setTimeout> | null = null;
246
+
247
+ function setAuditResult(verdict: AuditVerdict, report: string): void {
248
+ const result = { verdict, report, at: nowIso() };
249
+ auditResult = result;
250
+ if (auditResultClearTimer) clearTimeout(auditResultClearTimer);
251
+ // Short-lived foreground display (§2.5): the card is visible while the
252
+ // user reads it, then the normal dashboard returns automatically.
253
+ auditResultClearTimer = setTimeout(() => {
254
+ if (auditResult !== result) return;
255
+ auditResult = null;
256
+ auditResultClearTimer = null;
257
+ goalWidgetComponentRef.current?.invalidate();
258
+ }, 6000);
259
+ auditResultClearTimer.unref?.();
260
+ goalWidgetComponentRef.current?.invalidate();
261
+ }
262
+
263
+ function clearAuditResult(): void {
264
+ if (auditResultClearTimer) clearTimeout(auditResultClearTimer);
265
+ auditResultClearTimer = null;
266
+ auditResult = null;
267
+ }
268
+ let activeAudit: ActiveGoalAudit | null = null;
269
+ function invalidateSession(): void {
270
+ focusRevision += 1;
271
+ activeAudit?.cancel("Session replaced or shut down during completion audit.");
272
+ }
273
+
274
+ let goalModalDepth = 0;
275
+ let debugMode = false;
276
+ // §10: unified dashboard expansion state (compact vs expanded task view),
277
+ // owned by the core so it survives host-side widget re-instantiation.
278
+ let dashboardExpanded = false;
279
+
280
+ // Per-turn flags reset in turn_start (#4, C9 fix).
281
+ // goalWorkToolCalledThisTurn: tracks meaningful work attempts for Oracle.
282
+ // Scheduler idle pacing accumulates activity across the whole logical run.
283
+ // turn-stop guard, stale checkpoint, continuation scheduling, and one-time
284
+ // steering reminders live in `runtime` (extensions/goal-runtime.ts);
285
+ // token/time accounting lives in `accounting` (extensions/goal-accounting.ts).
286
+ let goalWorkToolCalledThisTurn = false;
287
+
288
+ let scheduler: GoalScheduler;
289
+ let interruption: GoalInterruption;
290
+ const runtime = new GoalRuntime({
291
+ authorize: ctx => scheduler.claim(ctx),
292
+ recover: ctx => scheduler.recover(ctx),
293
+ dispatchFailed: ctx => scheduler.failedDispatch(ctx),
294
+ sendFollowUp: (content, details) => {
295
+ pi.sendMessage<GoalEventDetails>(
296
+ {
297
+ customType: GOAL_EVENT_ENTRY,
298
+ content,
299
+ display: false,
300
+ details: details as unknown as GoalEventDetails,
301
+ },
302
+ { triggerTurn: true, deliverAs: "followUp" },
303
+ );
304
+ },
305
+ getGoal: () => state.goal,
306
+ isActionable: (goalId) => isActionableContinuationGoal(goalId),
307
+ });
308
+ const accounting = new GoalAccounting();
309
+
310
+ // Whether the task tools are advertised, decided once at session start from
311
+ // settings (disableTasks). Stage 4 replaces them with the two task tools.
312
+ let tasksEnabled = true;
313
+
314
+ // Transient runtime state: set when the user aborts a running audit via
315
+ // Escape. No ledger event is appended from the low-level abort callback;
316
+ // the completion flow appends exactly one canonical event after the
317
+ // user's dialog choice (follow-up Stage 2).
318
+
319
+ /**
320
+ * Install the fixed execution + drafting profile, changed only by disableTasks.
321
+ * Lifecycle transitions, focus changes, status changes, and compaction never
322
+ * add/remove/restore goal tools, and this never mutates the host's ordinary
323
+ * work-tool selection. On success the module-level tasksEnabled tracker is
324
+ * updated to the installed value, so callers can compare the effective
325
+ * setting against the last profile actually installed (used by the settings
326
+ * menu to detect repeated disableTasks toggles across one menu session).
327
+ */
328
+ function installGoalToolProfile(tasksEnabledArg: boolean): void {
329
+ try {
330
+ const current = new Set(pi.getActiveTools());
331
+ for (const knownGoalTool of ALL_REGISTERED_GOAL_TOOLS) current.delete(knownGoalTool);
332
+ for (const goalTool of applicableGoalTools(tasksEnabledArg)) current.add(goalTool);
333
+ const next = [...current].sort();
334
+ const before = [...pi.getActiveTools()].sort();
335
+ // Idempotent: never rebuild (or re-report) a profile that is already
336
+ // installed. Lifecycle transitions must not churn the tool surface.
337
+ if (next.length !== before.length || next.some((name, index) => name !== before[index])) {
338
+ pi.setActiveTools([...current]);
339
+ }
340
+ tasksEnabled = tasksEnabledArg;
341
+ } catch (err) {
342
+ console.error("[pi-goal] installGoalToolProfile error:", err instanceof Error ? err.message : String(err));
343
+ }
344
+ }
345
+
346
+ function stopAuditAnimation(): void {
347
+ if (auditAnimationTimer) {
348
+ clearInterval(auditAnimationTimer);
349
+ auditAnimationTimer = null;
350
+ }
351
+ }
352
+
353
+ function abortAudit(_ctx: ExtensionContext): void {
354
+ if (!activeAudit || activeAudit.phase !== "running") return;
355
+ activeAudit.phase = "awaiting-choice";
356
+ activeAudit.controller.abort();
357
+ stopAuditAnimation();
358
+ auditProgress = null;
359
+ goalWidgetComponentRef.current?.invalidate();
360
+ }
361
+
362
+ function clearContinuationTimer(): void {
363
+ runtime.clearContinuationTimer();
364
+ }
365
+
366
+ function clearContinuationState(resetNetworkErrorBackoff = true): void {
367
+ scheduler?.cancelTimer();
368
+ runtime.clearContinuationState(resetNetworkErrorBackoff);
369
+ }
370
+
371
+ function clearActiveAccounting(): void {
372
+ accounting.clear();
373
+ }
374
+
375
+ function advanceTurnSeq(): void {
376
+ runtime.advanceTurn();
377
+ }
378
+
379
+ function currentTurnStoppedGoalId(): string | null {
380
+ return runtime.currentTurnStoppedGoalId();
381
+ }
382
+
383
+ function isActionableContinuationGoal(goalId: string | null | undefined): goalId is string {
384
+ return !!goalId && state.goal?.id === goalId && state.goal.status === "active" && state.goal.autoContinue && !interruption?.isHeld(goalId);
385
+ }
386
+
387
+ function isStaleCheckpointBlockedToolCall(toolName: string): boolean {
388
+ return runtime.isStaleCheckpointBlocked(toolName);
389
+ }
390
+
391
+ function clearStoppedRuntimeState(): void {
392
+ clearContinuationState();
393
+ clearActiveAccounting();
394
+ }
395
+
396
+ function openGoals(): GoalRecord[] {
397
+ return openGoalsFromPool(goalsById);
398
+ }
399
+
400
+ function reconcileFocusedGoalFromDisk(ctx: ExtensionContext, opts: { preserveMemoryUsage?: boolean } = {}): boolean {
401
+ return goalService.reconcileFocused(ctx, opts);
402
+ }
403
+
404
+ let focusStorageRoot: string | undefined;
405
+ let externalFocusRoot = false;
406
+ function appendFocusEntry(goalId: string | null, reason: GoalFocusReason): void {
407
+ hasExplicitSessionFocus = true;
408
+ pi.appendEntry(FOCUS_ENTRY, { ...goalFocusDetails(goalId, reason), ...(externalFocusRoot ? { storageRoot: focusStorageRoot } : {}) });
409
+ }
410
+
411
+ function setFocusedGoalId(
412
+ goalId: string | null,
413
+ ctx: ExtensionContext,
414
+ reason: GoalFocusReason,
415
+ opts: { recordLedger?: boolean } = {},
416
+ ): void {
417
+ const previousGoalId = focusedGoalId;
418
+ if (previousGoalId !== goalId) { interruption?.reset(); if (ctx.hasUI) ctx.ui.setStatus("goal-interrupt", undefined); scheduler.takeover(ctx); }
419
+ if (previousGoalId !== goalId) goalService.flushTurn(ctx); // P1-3: persist the old buffer before switching focus
420
+ assignFocusedGoalId(goalId && goalsById.has(goalId) ? goalId : null);
421
+ if (previousGoalId !== focusedGoalId) {
422
+ clearContinuationState();
423
+ clearActiveAccounting();
424
+ }
425
+ appendFocusEntry(focusedGoalId, reason);
426
+ // Append ledger event for focus changes
427
+ try {
428
+ if (opts.recordLedger !== false && focusedGoalId) {
429
+ goalService.appendEvents(ctx, [{ type: "goal_focused", goalId: focusedGoalId, reason, at: nowIso() }]);
430
+ } else if (opts.recordLedger !== false && previousGoalId) {
431
+ goalService.appendEvents(ctx, [{ type: "goal_unfocused", reason, at: nowIso() }]);
432
+ }
433
+ } catch {
434
+ // Ledger append failure should not crash focus change
435
+ }
436
+ updateUI(ctx);
437
+ }
438
+
439
+ function updateFocusedGoal(next: GoalRecord, ctx: ExtensionContext, shouldPersist = true): void {
440
+ goalsById.set(next.id, next);
441
+ assignFocusedGoalId(next.id);
442
+ if (shouldPersist) persist(ctx);
443
+ updateUI(ctx);
444
+ }
445
+
446
+ function armFocusedContinuation(ctx: ExtensionContext): void {
447
+ beginAccounting();
448
+ if (state.goal?.status === "active" && state.goal.autoContinue) queueContinuation(ctx, true);
449
+ }
450
+
451
+ function removeFocusedGoal(ctx: ExtensionContext, reason: GoalFocusReason): void {
452
+ goalService.flushTurn(ctx); // P1-3: persist pending mutations before the goal leaves the session
453
+ if (focusedGoalId) goalsById.delete(focusedGoalId);
454
+ assignFocusedGoalId(null);
455
+ clearStoppedRuntimeState();
456
+ appendFocusEntry(null, reason);
457
+ updateUI(ctx);
458
+ }
459
+
460
+ function beginAccounting(): void {
461
+ if (scheduler?.isDenied() || (runningGoalId && runningGoalId !== state.goal?.id)) { clearActiveAccounting(); return; }
462
+ if (scheduler?.isWaiting() || interruption?.isHeld()) { clearActiveAccounting(); return; }
463
+ if (!state.goal || (state.goal.status !== "active")) {
464
+ clearActiveAccounting();
465
+ return;
466
+ }
467
+ accounting.begin(state.goal.id);
468
+ }
469
+
470
+ function goalForDisplay(): GoalRecord | null {
471
+ // P1-12: extracted live-usage view.
472
+ return liveDisplayGoal(state.goal, accounting);
473
+ }
474
+
475
+ function accountProgress(ctx: ExtensionContext, opts: { completedTurnTokens?: number } = {}): void {
476
+ if (scheduler?.isDenied() || (runningGoalId && runningGoalId !== state.goal?.id)) { clearActiveAccounting(); return; }
477
+ // Skip disk reconciliation for complete goals — they are pending archival at turn_end.
478
+ if (state.goal?.activePath && state.goal?.status !== "complete" && !reconcileFocusedGoalFromDisk(ctx, { preserveMemoryUsage: true })) return;
479
+ if (!state.goal || state.goal.status !== "active" || !accounting.isActiveFor(state.goal.id)) {
480
+ beginAccounting();
481
+ return;
482
+ }
483
+
484
+ // Serialized idempotent charge: never double-charges the same interval.
485
+ const { tokens, seconds } = accounting.charge({ completedTurnTokens: opts.completedTurnTokens });
486
+ if (tokens === 0 && seconds === 0) return;
487
+
488
+ const next = cloneGoal(state.goal);
489
+ next.usage.tokensUsed += tokens;
490
+ next.usage.activeSeconds += seconds;
491
+ next.updatedAt = nowIso();
492
+ state.goal = next;
493
+ persist(ctx);
494
+
495
+ // One warning per accounting update, even when several thresholds are crossed.
496
+ const budgetGoal = state.goal;
497
+ if (budgetGoal && budgetGoal.status === "active" && typeof budgetGoal.tokenBudget === "number" && budgetGoal.tokenBudget > 0 && budgetGoal.usage.tokensUsed > 0) {
498
+ const pct = budgetGoal.usage.tokensUsed / budgetGoal.tokenBudget;
499
+ const crossed = [0.5, 0.75, 0.9].filter(threshold => pct >= threshold && !budgetWarningsFired.has(`${budgetGoal.id}:${budgetGoal.tokenBudget}:${threshold}`));
500
+ for (const threshold of crossed) budgetWarningsFired.add(`${budgetGoal.id}:${budgetGoal.tokenBudget}:${threshold}`);
501
+ if (crossed.length) {
502
+ try {
503
+ goalService.appendEvents(ctx, [{type: "goal_budget_warning", goalId: budgetGoal.id, budget: budgetGoal.tokenBudget, tokensUsed: budgetGoal.usage.tokensUsed, pct: Math.round(pct * 100), at: nowIso()}]);
504
+ } catch { /* Alert must never crash the turn. */ }
505
+ ctx.ui.notify(`Token budget ${Math.round(pct * 100)}% used (${budgetGoal.usage.tokensUsed}/${budgetGoal.tokenBudget} tokens). Use ${commandHint("goal-tweak", core.loadedCommands)} to change or remove the budget.`, "warning");
506
+ }
507
+ }
508
+
509
+ // Token-budget transition: when accounted usage reaches the budget, mark the
510
+ // goal budget_limited exactly once (status no longer active, so accounting
511
+ // stops and the transition cannot re-fire), emit the ledger event, arm the
512
+ // one-time wrap-up steering, and cancel pending continuations.
513
+ if (budgetGoal && budgetGoal.status === "active" && typeof budgetGoal.tokenBudget === "number" && budgetReached(budgetGoal)) {
514
+ const transition = goalService.apply(ctx, {
515
+ reconcile: false,
516
+ mutate: (g) => ({ ...g, status: "budget_limited" as const, updatedAt: nowIso() }),
517
+ ledger: (written) => [{
518
+ type: "goal_budget_limited",
519
+ goalId: written.id,
520
+ budget: budgetGoal.tokenBudget ?? 0,
521
+ tokensUsed: written.usage.tokensUsed,
522
+ at: written.updatedAt,
523
+ }],
524
+ });
525
+ if (transition.ok) {
526
+ runtime.armPostBudgetReminder();
527
+ runtime.clearContinuationState();
528
+ accounting.clear();
529
+ updateUI(ctx);
530
+ }
531
+ }
532
+ }
533
+
534
+ function syncGoalPromptFromDisk(ctx: ExtensionContext): boolean {
535
+ if (!state.goal || state.goal.status === "complete") return false;
536
+ const previousObjective = state.goal.objective;
537
+ state.goal = mergeGoalPromptFromDisk(ctx, state.goal);
538
+ return state.goal.objective !== previousObjective;
539
+ }
540
+
541
+ function persist(ctx?: ExtensionContext): void {
542
+ if (ctx) {
543
+ goalService.persist(ctx);
544
+ } else {
545
+ const current = state.goal;
546
+ if (current) state.goal = { ...current, updatedAt: nowIso() };
547
+ }
548
+ if (ctx) updateUI(ctx);
549
+ }
550
+
551
+ function refreshGoalDisplayFromDisk(ctx: ExtensionContext): void {
552
+ if (!state.goal || state.goal.status === "complete") return;
553
+ if (syncGoalPromptFromDisk(ctx)) {
554
+ state.goal = { ...state.goal, updatedAt: nowIso() };
555
+ }
556
+ updateUI(ctx);
557
+ }
558
+
559
+ const goalWidgetComponentRef: { current: GoalWidgetComponent | null } = { current: null };
560
+ let widgetRegistered = false;
561
+
562
+ function clearGoalWidget(ctx: ExtensionContext): void {
563
+ lastUiCtx = null;
564
+ widgetRegistered = false;
565
+ goalWidgetComponentRef.current = null;
566
+ if (ctx.hasUI) {
567
+ ctx.ui.setStatus("goal", undefined);
568
+ ctx.ui.setWidget(GOAL_WIDGET_KEY, undefined);
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Live above-editor widget for the active goal. Inspired by rpiv-todo's
574
+ * TodoOverlay: register the widget once with a factory, read live state
575
+ * via the closure at render time, and call `tui.requestRender()` on every
576
+ * state change so the overlay refreshes without re-registration. Normal pi
577
+ * renders still read current values through the closure; do not request
578
+ * periodic renders just to tick elapsed time because terminal redraws pull
579
+ * users out of scrollback while they review long goals and earlier context.
580
+ *
581
+ * Layout (sisyphus, running):
582
+ * ◆ Sisyphus [▰▰▰▱▱] 3/5
583
+ * ├─ ⟡ extract validator … wire it … update tests.
584
+ * ├─ Status: sisyphus running · auto-continue · 14m 21s · 24.3k tokens
585
+ * └─ .pi/goals/active_goal_xxx.md
586
+ *
587
+ * Layout (paused with blocker):
588
+ * ⊘ Goal paused
589
+ * ├─ ⟡ improve benchmark coverage for the parser
590
+ * ├─ Status: paused (agent) · 2m 14s · 12.4k tokens
591
+ * ├─ Blocker: cannot find the tests directory
592
+ * └─ Suggested: ask the user for the test location
593
+ */
594
+
595
+ let lastGoalActivityAt = Date.now();
596
+ let stallNotified = false;
597
+ const budgetWarningsFired = new Set<string>(); // "goalId:budget:threshold"
598
+
599
+ function touchGoalActivity(): void {
600
+ lastGoalActivityAt = Date.now();
601
+ stallNotified = false;
602
+ }
603
+
604
+ function checkStall(ctx: ExtensionContext): string {
605
+ if (!state.goal || state.goal.status !== "active" || !state.goal.autoContinue) return "";
606
+ const timeoutMinutes = loadGoalSettings(ctx.cwd).stallTimeoutMinutes ?? 0;
607
+ if (timeoutMinutes <= 0 || stallNotified) return "";
608
+ const idleMs = Date.now() - lastGoalActivityAt;
609
+ if (idleMs < timeoutMinutes * 60_000) return "";
610
+ stallNotified = true;
611
+ const goalId = state.goal.id;
612
+ try {
613
+ goalService.appendEvents(ctx, [{
614
+ type: "goal_stalled",
615
+ goalId,
616
+ reason: `No continuation or tool activity for ${timeoutMinutes} minute${timeoutMinutes === 1 ? "" : "s"}.`,
617
+ at: nowIso(),
618
+ }]);
619
+ } catch {
620
+ // Stall detection must never crash the turn.
621
+ }
622
+ ctx.ui.notify(`Goal stalled: no activity for ${timeoutMinutes} minute${timeoutMinutes === 1 ? "" : "s"}.`, "warning");
623
+ return `\n\n[GOAL STALLED goalId=${goalId}]\nNo continuation or tool activity for ${timeoutMinutes} minute${timeoutMinutes === 1 ? "" : "s"}. Report your progress or ask the user to pause or resume the goal.`;
624
+ }
625
+
626
+ let uiFlushScheduled = false;
627
+ let lastUiCtx: ExtensionContext | null = null;
628
+
629
+ /**
630
+ * P1-9: coalesced widget updates. Multiple updateUI calls within one
631
+ * synchronous block (a tool handler typically calls it 2–4 times) collapse
632
+ * into a single microtask render; the final turn_end render still shows the
633
+ * latest state. The render itself reads live state at flush time.
634
+ */
635
+ function updateUI(ctx: ExtensionContext): void {
636
+ if (!ctx.hasUI) return;
637
+ lastUiCtx = ctx;
638
+ if (uiFlushScheduled) return;
639
+ uiFlushScheduled = true;
640
+ queueMicrotask(() => {
641
+ uiFlushScheduled = false;
642
+ if (lastUiCtx) renderUI(lastUiCtx);
643
+ });
644
+ }
645
+
646
+ function renderUI(ctx: ExtensionContext): void {
647
+ if (!interruption.isHeld()) ctx.ui.setStatus("goal-interrupt", undefined);
648
+ const goalCwd = ctx.cwd;
649
+ const storage = goalStorageContext(ctx);
650
+ const totalOpen = otherOpenGoalCount(goalsById, null);
651
+ if (!state.goal && totalOpen === 0) {
652
+ clearGoalWidget(ctx);
653
+ return;
654
+ }
655
+ if (!state.goal) {
656
+ // PR #29: layered hideUnfocusedBanner suppresses BOTH the unfocused
657
+ // widget and the status hint. Focused dashboards and audit UI are
658
+ // unaffected; the model-facing [PI GOAL UNFOCUSED] prompt is unchanged.
659
+ if (loadGoalSettings(ctx.cwd).hideUnfocusedBanner === true) {
660
+ clearGoalWidget(ctx);
661
+ return;
662
+ }
663
+ ctx.ui.setStatus("goal", `goal: unfocused [${totalOpen} open] - ${commandHint("goal-focus", core.loadedCommands)}`);
664
+ if (!widgetRegistered) {
665
+ ctx.ui.setWidget(
666
+ GOAL_WIDGET_KEY,
667
+ makeGoalWidgetFactory({
668
+ getGoal: () => goalForDisplay() ?? state.goal,
669
+ getOpenGoalCount: () => otherOpenGoalCount(goalsById, null),
670
+ getAuditorProgress: () => auditProgress,
671
+ getSettings: () => ({ ...loadGoalSettings(goalCwd), commands: core.loadedCommands }),
672
+ getDebugMode: () => debugMode,
673
+ getStalled: () => stallNotified,
674
+ getExpanded: () => dashboardExpanded,
675
+ getLedgerEvents: () => state.goal ? goalActivityEvents(storage, state.goal.id) : [],
676
+ getAuditResult: () => auditResult,
677
+ componentRef: goalWidgetComponentRef,
678
+ }),
679
+ { placement: "aboveEditor" },
680
+ );
681
+ widgetRegistered = true;
682
+ } else {
683
+ goalWidgetComponentRef.current?.update();
684
+ }
685
+ return;
686
+ }
687
+
688
+ // The goal widget is the single home for goal status: the status line
689
+ // lives in the compact/expanded dashboard (goal: <label> [<usage>]
690
+ // (+N open)), so the focused-goal footer segment is cleared — a stale
691
+ // unfocused hint or earlier status must not linger once a goal is
692
+ // focused. The unfocused branch above still sets its own hint.
693
+ ctx.ui.setStatus("goal", undefined);
694
+
695
+ if (!widgetRegistered) {
696
+ ctx.ui.setWidget(
697
+ GOAL_WIDGET_KEY,
698
+ makeGoalWidgetFactory({
699
+ getGoal: () => goalForDisplay() ?? state.goal,
700
+ getOpenGoalCount: () => otherOpenGoalCount(goalsById, null),
701
+ getAuditorProgress: () => auditProgress,
702
+ getSettings: () => ({ ...loadGoalSettings(goalCwd), commands: core.loadedCommands }),
703
+ getDebugMode: () => debugMode,
704
+ getStalled: () => stallNotified,
705
+ getExpanded: () => dashboardExpanded,
706
+ getLedgerEvents: () => state.goal ? goalActivityEvents(storage, state.goal.id) : [],
707
+ getAuditResult: () => auditResult,
708
+ componentRef: goalWidgetComponentRef,
709
+ }),
710
+ { placement: "aboveEditor" },
711
+ );
712
+ widgetRegistered = true;
713
+ } else {
714
+ goalWidgetComponentRef.current?.update();
715
+ }
716
+ }
717
+
718
+ async function loadState(ctx: ExtensionContext): Promise<void> {
719
+ const revision = ++focusRevision;
720
+ const storage = goalStorageContext(ctx);
721
+ const entries = ctx.sessionManager.getBranch();
722
+ const settings = loadGoalSettings(storage.cwd);
723
+ const pool = await readActiveGoalPoolAsync(storage);
724
+ if (revision !== focusRevision) return;
725
+ // The host may invalidate a context while disk I/O is pending.
726
+ ctx.sessionManager.getSessionId?.();
727
+ focusStorageRoot = storage.goalStorageRoot;
728
+ externalFocusRoot = focusStorageRoot !== path.resolve(storage.cwd, ".pi/goals");
729
+ goalsById = pool;
730
+ tasksEnabled = !loadGoalSettings(ctx.cwd).disableTasks;
731
+ assignFocusedGoalId(null);
732
+ hasExplicitSessionFocus = false;
733
+ let focusEntry: GoalFocusEntry | null = null;
734
+ let legacyGoal: GoalRecord | null = null;
735
+ let legacyStateSeen = false;
736
+ for (let i = entries.length - 1; i >= 0; i--) {
737
+ const entry = entries[i] as { type?: string; customType?: string; data?: unknown };
738
+ if (entry.type !== "custom") continue;
739
+ if (!focusEntry && entry.customType === FOCUS_ENTRY) {
740
+ focusEntry = normalizeGoalFocusEntry(entry.data);
741
+ }
742
+ if (!legacyStateSeen && entry.customType === STATE_ENTRY) {
743
+ legacyGoal = normalizeGoalRecord(asRecord(entry.data)?.goal);
744
+ legacyStateSeen = true;
745
+ }
746
+ if (focusEntry && legacyStateSeen) break;
747
+ }
748
+ if (legacyGoal && legacyGoal.status !== "complete") {
749
+ legacyGoal = sanitizeGoalPaths(ctx, mergeGoalPromptFromDisk(ctx, legacyGoal));
750
+ }
751
+ if (focusEntry && (focusEntry.storageRoot ?? path.resolve(ctx.cwd, ".pi/goals")) !== focusStorageRoot) {
752
+ focusEntry = { ...focusEntry, focusedGoalId: null };
753
+ legacyGoal = null;
754
+ }
755
+ if (focusStorageRoot !== path.resolve(ctx.cwd, ".pi/goals")) legacyGoal = null;
756
+ hasExplicitSessionFocus = focusEntry !== null;
757
+ assignFocusedGoalId(resolveSessionFocus({ pool: goalsById, focusEntry, legacyGoal, autoSelectSingleGoal: settings.autoSelectSingleGoal }));
758
+ if (!focusEntry && focusedGoalId) {
759
+ try {
760
+ appendFocusEntry(focusedGoalId, legacyGoal?.id === focusedGoalId ? "migrated" : "selected");
761
+ } catch {}
762
+ }
763
+ for (const [id, current] of goalsById) {
764
+ if (current.status === "complete") {
765
+ goalsById.delete(id);
766
+ }
767
+ }
768
+ clearStoppedRuntimeState();
769
+ runningGoalId = null;
770
+ updateUI(ctx);
771
+ }
772
+
773
+ function setGoal(next: GoalRecord | null, ctx: ExtensionContext, shouldPersist = true, focusReason?: GoalFocusReason): void {
774
+ const previousGoalId = state.goal?.id ?? null;
775
+ state.goal = next;
776
+ const focusChanged = previousGoalId !== focusedGoalId;
777
+ if (focusChanged) {
778
+ clearContinuationState();
779
+ clearActiveAccounting();
780
+ }
781
+ if (focusReason && focusChanged) appendFocusEntry(focusedGoalId, focusReason);
782
+ if (!state.goal || (state.goal.status !== "active") || !state.goal.autoContinue) {
783
+ clearContinuationState();
784
+ }
785
+ if (!state.goal || state.goal.status === "paused" || state.goal.status === "complete") {
786
+ clearActiveAccounting();
787
+ }
788
+ if (shouldPersist) persist(ctx);
789
+ updateUI(ctx);
790
+ }
791
+
792
+ function archiveCurrentGoal(ctx: ExtensionContext, reason: StopReason | undefined): GoalRecord | null {
793
+ if (!state.goal) return null;
794
+ const result = goalService.apply(ctx, {
795
+ reconcile: false,
796
+ refreshFromDisk: true,
797
+ archive: true,
798
+ commitFocused: false,
799
+ mutate: (g) => {
800
+ const status = g.status === "complete" ? "complete" : "paused";
801
+ return { ...g, status, stopReason: reason };
802
+ },
803
+ });
804
+ return result.ok ? result.goal : null;
805
+ }
806
+
807
+ function stopActiveGoal(status: Exclude<GoalStatus, "active">, reason: StopReason | undefined, ctx: ExtensionContext): void {
808
+ if (!state.goal) return;
809
+ const result = goalService.apply(ctx, {
810
+ reconcile: false,
811
+ refreshFromDisk: true,
812
+ mutate: (g) => ({ ...g, status, stopReason: reason, updatedAt: nowIso() }),
813
+ ledger: (written) => status === "paused"
814
+ ? [{
815
+ type: "goal_paused",
816
+ goalId: written.id,
817
+ reason: reason ?? "unknown",
818
+ suggestedAction: written.pauseSuggestedAction,
819
+ status,
820
+ at: written.updatedAt,
821
+ }]
822
+ : [],
823
+ });
824
+ if (result.ok) {
825
+ // setGoal() glue: a stopped goal can no longer queue continuations or
826
+ // accrue time, and the UI must reflect the new status immediately.
827
+ clearContinuationState();
828
+ clearActiveAccounting();
829
+ goalService.flushTurn(ctx); // P1-3: user-visible status change persists now, not at turn end
830
+ updateUI(ctx);
831
+ }
832
+ }
833
+
834
+ function pauseActiveGoal(ctx: ExtensionContext): void {
835
+ if (!state.goal || state.goal.status !== "active") return;
836
+ const pausedGoalId = state.goal.id;
837
+ // User-initiated pause (Esc / aborted turn). Clear any stale agent pause reason.
838
+ state.goal = { ...state.goal, autoContinue: false, pauseReason: undefined, pauseSuggestedAction: undefined };
839
+ stopActiveGoal("paused", "user", ctx);
840
+ ctx.ui.notify("Goal paused.", "info");
841
+ }
842
+
843
+ /**
844
+ * §auditor-toggle: flip the focused goal's independent-auditor setting.
845
+ * Persisted per-goal (revision-safe via goalService.apply), recorded as an
846
+ * auditor_toggled ledger event, reflected in the dashboard, and announced.
847
+ * Inert when no goal is focused or the goal is complete; the widget's
848
+ * modal-depth guard keeps it inert while a goal modal is open.
849
+ */
850
+ function toggleGoalAuditor(ctx: ExtensionContext): void {
851
+ if (!state.goal) {
852
+ ctx.ui.notify("No focused goal to toggle the auditor for.", "info");
853
+ return;
854
+ }
855
+ if (state.goal.status === "complete") {
856
+ ctx.ui.notify("This goal is complete; the auditor no longer applies.", "info");
857
+ return;
858
+ }
859
+ const nextEnabled = state.goal.skipAuditor === true;
860
+ const result = goalService.apply(ctx, {
861
+ reconcile: false,
862
+ refreshFromDisk: true,
863
+ mutate: (g) => ({ ...g, skipAuditor: g.skipAuditor === true ? undefined : true, updatedAt: nowIso() }),
864
+ ledger: (written) => [{ type: "auditor_toggled" as const, goalId: written.id, enabled: nextEnabled, at: written.updatedAt }],
865
+ });
866
+ if (!result.ok) {
867
+ ctx.ui.notify("Could not toggle the auditor: " + result.message, "error");
868
+ return;
869
+ }
870
+ goalService.flushTurn(ctx); // P1-3: user-visible setting change persists now, not at turn end
871
+ updateUI(ctx);
872
+ ctx.ui.notify(nextEnabled ? "Auditor enabled for this goal." : "Auditor disabled for this goal.", "info");
873
+ }
874
+
875
+ function flushGoalTransaction(ctx: ExtensionContext): void {
876
+ goalService.flushTurn(ctx);
877
+ }
878
+
879
+ function queueContinuation(ctx: ExtensionContext, force = false): void {
880
+ void force;
881
+ scheduler.schedule(ctx);
882
+ }
883
+
884
+ function enterGoalModal(): void {
885
+ goalModalDepth++;
886
+ }
887
+
888
+ function exitGoalModal(): void {
889
+ goalModalDepth = Math.max(0, goalModalDepth - 1);
890
+ }
891
+
892
+ function replaceGoal(config: GoalCreationConfig, ctx: ExtensionContext, startNow = true, verificationContract?: string, tokenBudget?: number): void {
893
+ const goal = createGoal(config);
894
+ goal.scheduler = newGoalScheduler(ctx.sessionManager.getSessionId());
895
+ if (verificationContract) goal.verificationContract = verificationContract;
896
+ if (config.taskList) goal.taskList = config.taskList;
897
+ if (typeof tokenBudget === "number" && tokenBudget > 0) goal.tokenBudget = Math.floor(tokenBudget);
898
+ const result = goalService.create(ctx, {
899
+ goal,
900
+ ledger: [
901
+ {
902
+ type: "goal_created",
903
+ goalId: goal.id,
904
+ objective: goal.objective,
905
+ sisyphus: goal.sisyphus,
906
+ autoContinue: goal.autoContinue,
907
+ at: goal.createdAt,
908
+ },
909
+ ...(goal.taskList ? [{
910
+ type: "task_list_set" as const,
911
+ goalId: goal.id,
912
+ taskCount: goal.taskList.tasks.length,
913
+ blockCompletion: goal.taskList.blockCompletion,
914
+ at: goal.createdAt,
915
+ }] : []),
916
+ ],
917
+ });
918
+ if (result.focusChanged) appendFocusEntry(result.goalId, "created");
919
+ beginAccounting();
920
+ ctx.ui.notify(`${buildGoalRunningNotification(config)}\nBudget: ${goal.tokenBudget === undefined ? "none" : `${goal.tokenBudget} tokens`}`, "info");
921
+ if (startNow && state.goal?.autoContinue) scheduler.kickoff(ctx);
922
+ }
923
+
924
+ const core: GoalCore = {
925
+ pi,
926
+ dependencies,
927
+ state,
928
+ sessionOwner: null,
929
+ get goalsById() {
930
+ return goalsById;
931
+ },
932
+ get focusedGoalId() {
933
+ return focusedGoalId;
934
+ },
935
+ get focusRevision() {
936
+ return focusRevision;
937
+ },
938
+ get hasExplicitSessionFocus() {
939
+ return hasExplicitSessionFocus;
940
+ },
941
+ set hasExplicitSessionFocus(value: boolean) {
942
+ hasExplicitSessionFocus = value;
943
+ },
944
+ get runningGoalId() {
945
+ return runningGoalId;
946
+ },
947
+ set runningGoalId(value: string | null) {
948
+ runningGoalId = value;
949
+ },
950
+ get auditProgress() {
951
+ return auditProgress;
952
+ },
953
+ set auditProgress(value: AuditorWidgetProgress | null) {
954
+ auditProgress = value;
955
+ },
956
+ get auditResult() {
957
+ return auditResult;
958
+ },
959
+ set auditResult(value: { verdict: AuditVerdict; report: string; at: string } | null) {
960
+ auditResult = value;
961
+ },
962
+ setAuditResult,
963
+ clearAuditResult,
964
+ get auditAnimationTimer() {
965
+ return auditAnimationTimer;
966
+ },
967
+ set auditAnimationTimer(value: ReturnType<typeof setInterval> | null) {
968
+ auditAnimationTimer = value;
969
+ },
970
+ get activeAudit() {
971
+ return activeAudit;
972
+ },
973
+ set activeAudit(value: ActiveGoalAudit | null) {
974
+ activeAudit = value;
975
+ },
976
+ get goalModalDepth() {
977
+ return goalModalDepth;
978
+ },
979
+ set goalModalDepth(value: number) {
980
+ goalModalDepth = value;
981
+ },
982
+ invalidateSession,
983
+
984
+ get goalWorkToolCalledThisTurn() {
985
+ return goalWorkToolCalledThisTurn;
986
+ },
987
+ set goalWorkToolCalledThisTurn(value: boolean) {
988
+ goalWorkToolCalledThisTurn = value;
989
+ },
990
+ get tasksEnabled() {
991
+ return tasksEnabled;
992
+ },
993
+ set tasksEnabled(value: boolean) {
994
+ tasksEnabled = value;
995
+ },
996
+ get debugMode() {
997
+ return debugMode;
998
+ },
999
+ set debugMode(value: boolean) {
1000
+ debugMode = value;
1001
+ },
1002
+ get terminalInputUnsubscribe() {
1003
+ return terminalInputUnsubscribe;
1004
+ },
1005
+ set terminalInputUnsubscribe(value: (() => void) | null) {
1006
+ terminalInputUnsubscribe = value;
1007
+ },
1008
+ toggleDashboardExpanded() {
1009
+ dashboardExpanded = !dashboardExpanded;
1010
+ goalWidgetComponentRef.current?.invalidate();
1011
+ },
1012
+ isDashboardExpanded() {
1013
+ return dashboardExpanded;
1014
+ },
1015
+ goalWidgetComponentRef,
1016
+ goalService,
1017
+ runtime,
1018
+ get scheduler() { return scheduler; },
1019
+ get interruption() { return interruption; },
1020
+ auditMessages: new GoalAuditMessages(),
1021
+ accounting,
1022
+ assignFocusedGoalId,
1023
+ focusedOperationToken,
1024
+ isFocusedOperationCurrent,
1025
+ focusedOperationCancelledResult,
1026
+ installGoalToolProfile,
1027
+ stopAuditAnimation,
1028
+ abortAudit,
1029
+ clearContinuationTimer,
1030
+ clearContinuationState,
1031
+ clearActiveAccounting,
1032
+ advanceTurnSeq,
1033
+ currentTurnStoppedGoalId,
1034
+ isActionableContinuationGoal,
1035
+ isStaleCheckpointBlockedToolCall,
1036
+ clearStoppedRuntimeState,
1037
+ enterGoalModal,
1038
+ exitGoalModal,
1039
+ openGoals,
1040
+ reconcileFocusedGoalFromDisk,
1041
+ appendFocusEntry,
1042
+ setFocusedGoalId,
1043
+ updateFocusedGoal,
1044
+ armFocusedContinuation,
1045
+ removeFocusedGoal,
1046
+ beginAccounting,
1047
+ goalForDisplay,
1048
+ accountProgress,
1049
+ syncGoalPromptFromDisk,
1050
+ persist,
1051
+ refreshGoalDisplayFromDisk,
1052
+ updateUI,
1053
+ clearGoalWidget,
1054
+ loadState,
1055
+ setGoal,
1056
+ archiveCurrentGoal,
1057
+ stopActiveGoal,
1058
+ pauseActiveGoal,
1059
+ toggleGoalAuditor,
1060
+ queueContinuation,
1061
+ flushGoalTransaction,
1062
+ touchGoalActivity,
1063
+ checkStall,
1064
+ replaceGoal,
1065
+ };
1066
+ interruption = new GoalInterruption(core);
1067
+ scheduler = new GoalScheduler(core);
1068
+ return core;
1069
+ }