@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.0
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/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +219 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +321 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- 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
|
+
}
|