@kky42/pi-goal 1.0.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/package.json +73 -0
  5. package/src/commands.ts +107 -0
  6. package/src/continuation-scheduler.ts +174 -0
  7. package/src/format.ts +232 -0
  8. package/src/goal-accounting.ts +128 -0
  9. package/src/goal-persistence.ts +73 -0
  10. package/src/goal-runtime-agent-handlers.ts +51 -0
  11. package/src/goal-runtime-controller.ts +162 -0
  12. package/src/goal-runtime-event-handler-types.ts +166 -0
  13. package/src/goal-runtime-event-handlers.ts +31 -0
  14. package/src/goal-runtime-event-utils.ts +93 -0
  15. package/src/goal-runtime-events.ts +24 -0
  16. package/src/goal-runtime-input-context-handlers.ts +144 -0
  17. package/src/goal-runtime-session-handlers.ts +131 -0
  18. package/src/goal-runtime-state.ts +22 -0
  19. package/src/goal-runtime-status.ts +62 -0
  20. package/src/goal-runtime-turn-handlers.ts +66 -0
  21. package/src/goal-state-controller.ts +210 -0
  22. package/src/goal-transition-effects.ts +91 -0
  23. package/src/goal-transition.ts +396 -0
  24. package/src/index.ts +9 -0
  25. package/src/prompts.ts +170 -0
  26. package/src/queued-goal-messages.ts +166 -0
  27. package/src/queued-goal-work.ts +96 -0
  28. package/src/recovery-adapters.ts +66 -0
  29. package/src/recovery-machine.ts +196 -0
  30. package/src/recovery-phase.ts +95 -0
  31. package/src/recovery-runtime.ts +97 -0
  32. package/src/recovery.ts +151 -0
  33. package/src/runtime-config.ts +7 -0
  34. package/src/stale-queued-work-guard.ts +114 -0
  35. package/src/stale-queued-work-obligations.ts +291 -0
  36. package/src/stale-queued-work-reducer.ts +483 -0
  37. package/src/stale-queued-work-terminal-cleanup.ts +84 -0
  38. package/src/stale-queued-work-types.ts +81 -0
  39. package/src/state.ts +404 -0
  40. package/src/tools.ts +101 -0
  41. package/src/types.ts +60 -0
package/src/format.ts ADDED
@@ -0,0 +1,232 @@
1
+ import type { GoalStatus, ThreadGoal } from "./types.js";
2
+
3
+ const COMPACT_TOKEN_UNITS = [
4
+ { suffix: "T", value: 1_000_000_000_000 },
5
+ { suffix: "B", value: 1_000_000_000 },
6
+ { suffix: "M", value: 1_000_000 },
7
+ { suffix: "K", value: 1_000 },
8
+ ] as const;
9
+
10
+ export interface GoalToolRecord {
11
+ objective: string;
12
+ status: GoalStatus;
13
+ tokenBudget: number | null;
14
+ tokensUsed: number;
15
+ timeUsed: string;
16
+ createdAt: string;
17
+ updatedAt: string;
18
+ }
19
+
20
+ export interface GoalToolResponse {
21
+ goal: GoalToolRecord | null;
22
+ remainingTokens: number | null;
23
+ completionBudgetReport: string | null;
24
+ }
25
+
26
+ export function formatDuration(seconds: number): string {
27
+ const normalized = Math.max(0, Math.trunc(seconds));
28
+ const hours = Math.floor(normalized / 3_600);
29
+ const minutes = Math.floor((normalized % 3_600) / 60);
30
+ const remainingSeconds = normalized % 60;
31
+
32
+ if (hours > 0) {
33
+ return `${hours}h ${minutes}m ${remainingSeconds}s`;
34
+ }
35
+ if (minutes > 0) {
36
+ return `${minutes}m ${remainingSeconds}s`;
37
+ }
38
+ return `${remainingSeconds}s`;
39
+ }
40
+
41
+ function twoDigit(value: number): string {
42
+ return String(value).padStart(2, "0");
43
+ }
44
+
45
+ export function formatLocalTimestamp(unixSeconds: number): string {
46
+ const date = new Date(Math.max(0, Math.trunc(unixSeconds)) * 1000);
47
+ const day = `${date.getFullYear()}-${twoDigit(date.getMonth() + 1)}-${twoDigit(date.getDate())}`;
48
+ const time = `${twoDigit(date.getHours())}-${twoDigit(date.getMinutes())}-${twoDigit(date.getSeconds())}`;
49
+ return `${day} ${time}`;
50
+ }
51
+
52
+ export function formatInteger(value: number): string {
53
+ return Math.max(0, Math.trunc(value)).toLocaleString("en-US");
54
+ }
55
+
56
+ export function formatCompactTokenValue(value: number): string {
57
+ const normalized = Math.max(0, Math.trunc(value));
58
+ if (normalized < 100_000) {
59
+ return formatInteger(normalized);
60
+ }
61
+
62
+ const unit = COMPACT_TOKEN_UNITS.find((candidate) => normalized >= candidate.value);
63
+ if (!unit) {
64
+ return formatInteger(normalized);
65
+ }
66
+
67
+ const scaled = normalized / unit.value;
68
+ const fractionDigits = scaled < 10 ? 2 : scaled < 100 ? 1 : 0;
69
+ const compact = scaled.toLocaleString("en-US", {
70
+ maximumFractionDigits: fractionDigits,
71
+ minimumFractionDigits: 0,
72
+ });
73
+ return `${compact}${unit.suffix}`;
74
+ }
75
+
76
+ export function formatTokenValue(value: number): string {
77
+ const exact = formatInteger(value);
78
+ const compact = formatCompactTokenValue(value);
79
+ if (compact === exact) {
80
+ return exact;
81
+ }
82
+ return `${compact} (${exact})`;
83
+ }
84
+
85
+ export function formatBudget(goal: ThreadGoal): string {
86
+ if (goal.tokenBudget === null) {
87
+ return `${formatTokenValue(goal.usage.tokensUsed)} tokens`;
88
+ }
89
+ return `${formatTokenValue(goal.usage.tokensUsed)}/${formatTokenValue(goal.tokenBudget)} tokens`;
90
+ }
91
+
92
+ function statusLabel(status: GoalStatus): string {
93
+ return status === "budgetLimited" ? "limited by budget" : status;
94
+ }
95
+
96
+ function commandHint(status: GoalStatus): string {
97
+ if (status === "active") {
98
+ return "/goal pause, /goal clear";
99
+ }
100
+ if (status === "paused") {
101
+ return "/goal resume, /goal clear";
102
+ }
103
+ if (status === "blocked") {
104
+ return "/goal resume, /goal clear";
105
+ }
106
+ if (status === "complete") {
107
+ return "/goal <objective> to replace, /goal clear";
108
+ }
109
+ return "/goal clear";
110
+ }
111
+
112
+ export function formatGoalSummary(goal: ThreadGoal | null): string {
113
+ if (!goal) {
114
+ return ["Usage: /goal <objective>", "No goal is currently set."].join("\n");
115
+ }
116
+
117
+ const lines = [
118
+ `Status: ${statusLabel(goal.status)}`,
119
+ `Objective: ${goal.objective}`,
120
+ `Time used: ${formatDuration(goal.usage.activeSeconds)}`,
121
+ `Tokens used: ${formatTokenValue(goal.usage.tokensUsed)}`,
122
+ ];
123
+
124
+ if (goal.tokenBudget !== null) {
125
+ lines.push(`Token budget: ${formatTokenValue(goal.tokenBudget)}`);
126
+ }
127
+
128
+ lines.push(`Hint: ${commandHint(goal.status)}`);
129
+ return lines.join("\n");
130
+ }
131
+
132
+ function compactBudgetUsage(goal: ThreadGoal): string {
133
+ if (goal.tokenBudget === null) {
134
+ return `${formatCompactTokenValue(goal.usage.tokensUsed)} tokens`;
135
+ }
136
+ return `${formatCompactTokenValue(goal.usage.tokensUsed)} / ${formatCompactTokenValue(goal.tokenBudget)}`;
137
+ }
138
+
139
+ export function formatFooterStatus(goal: ThreadGoal | null, recoveryAttention: string | null = null): string | undefined {
140
+ if (!goal) {
141
+ return undefined;
142
+ }
143
+
144
+ if (goal.status === "budgetLimited") {
145
+ if (goal.tokenBudget !== null) {
146
+ return `Goal unmet (${compactBudgetUsage(goal)} tokens)`;
147
+ }
148
+ return "Goal abandoned";
149
+ }
150
+
151
+ if (recoveryAttention) {
152
+ return recoveryAttention;
153
+ }
154
+
155
+ if (goal.status === "active") {
156
+ if (goal.tokenBudget !== null) {
157
+ return `Pursuing goal (${compactBudgetUsage(goal)})`;
158
+ }
159
+ if (goal.usage.activeSeconds > 0) {
160
+ return `Pursuing goal (${formatDuration(goal.usage.activeSeconds)})`;
161
+ }
162
+ return "Pursuing goal";
163
+ }
164
+
165
+ if (goal.status === "paused") {
166
+ return "Goal paused (/goal resume)";
167
+ }
168
+
169
+ if (goal.status === "blocked") {
170
+ return "Goal blocked (/goal resume)";
171
+ }
172
+
173
+ if (goal.tokenBudget !== null) {
174
+ return `Goal achieved (${formatCompactTokenValue(goal.usage.tokensUsed)} tokens)`;
175
+ }
176
+ if (goal.usage.activeSeconds > 0) {
177
+ return `Goal achieved (${formatDuration(goal.usage.activeSeconds)})`;
178
+ }
179
+ return "Goal achieved";
180
+ }
181
+
182
+ export function toToolGoal(goal: ThreadGoal): GoalToolRecord {
183
+ return {
184
+ objective: goal.objective,
185
+ status: goal.status,
186
+ tokenBudget: goal.tokenBudget,
187
+ tokensUsed: goal.usage.tokensUsed,
188
+ timeUsed: formatDuration(goal.usage.activeSeconds),
189
+ createdAt: formatLocalTimestamp(goal.createdAt),
190
+ updatedAt: formatLocalTimestamp(goal.updatedAt),
191
+ };
192
+ }
193
+
194
+ export function remainingTokens(goal: ThreadGoal | null): number | null {
195
+ if (!goal || goal.tokenBudget === null) {
196
+ return null;
197
+ }
198
+ return Math.max(0, goal.tokenBudget - goal.usage.tokensUsed);
199
+ }
200
+
201
+ export function completionBudgetReport(goal: ThreadGoal | null): string | null {
202
+ if (!goal || goal.status !== "complete") {
203
+ return null;
204
+ }
205
+ if (goal.tokenBudget === null && goal.usage.activeSeconds <= 0) {
206
+ return null;
207
+ }
208
+
209
+ const parts: string[] = [];
210
+ if (goal.usage.activeSeconds > 0) {
211
+ parts.push(`time used: ${formatDuration(goal.usage.activeSeconds)}.`);
212
+ }
213
+ if (goal.tokenBudget !== null) {
214
+ parts.push(`tokens used: ${formatInteger(goal.usage.tokensUsed)} of ${formatInteger(goal.tokenBudget)}.`);
215
+ } else if (goal.usage.tokensUsed > 0) {
216
+ parts.push(`tokens used: ${formatInteger(goal.usage.tokensUsed)}.`);
217
+ }
218
+
219
+ return `Goal achieved. Report final budget usage to the user: ${parts.join(" ")}`;
220
+ }
221
+
222
+ export function goalToolResponse(goal: ThreadGoal | null, includeCompletionBudgetReport = false): GoalToolResponse {
223
+ return {
224
+ goal: goal ? toToolGoal(goal) : null,
225
+ remainingTokens: remainingTokens(goal),
226
+ completionBudgetReport: includeCompletionBudgetReport ? completionBudgetReport(goal) : null,
227
+ };
228
+ }
229
+
230
+ export function toToolText(goal: ThreadGoal | null, includeCompletionBudgetReport = false): string {
231
+ return JSON.stringify(goalToolResponse(goal, includeCompletionBudgetReport), null, 2);
232
+ }
@@ -0,0 +1,128 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { budgetLimitPrompt } from "./prompts.js";
4
+ import { applyUsage } from "./state.js";
5
+ import { CUSTOM_ENTRY_TYPE, type ThreadGoal } from "./types.js";
6
+
7
+ export interface AccountingState {
8
+ activeGoalId: string | null;
9
+ lastAccountedAt: number | null;
10
+ budgetWarningSentFor: string | null;
11
+ }
12
+
13
+ export interface AssistantUsage {
14
+ input: number;
15
+ output: number;
16
+ }
17
+
18
+ export interface AssistantTurnMessage {
19
+ role: string;
20
+ stopReason?: string;
21
+ usage?: AssistantUsage;
22
+ }
23
+
24
+ export function createAccountingState(): AccountingState {
25
+ return {
26
+ activeGoalId: null,
27
+ lastAccountedAt: null,
28
+ budgetWarningSentFor: null,
29
+ };
30
+ }
31
+
32
+ function usageChannelTokens(value: number): number {
33
+ if (!Number.isFinite(value)) {
34
+ return 0;
35
+ }
36
+ return Math.max(0, Math.trunc(value));
37
+ }
38
+
39
+ export function assistantTurnTokens(message: AssistantTurnMessage): number {
40
+ if (message.role !== "assistant" || !message.usage) {
41
+ return 0;
42
+ }
43
+ return usageChannelTokens(message.usage.input) + usageChannelTokens(message.usage.output);
44
+ }
45
+
46
+ export function isAbortedAssistantMessage(message: AssistantTurnMessage): boolean {
47
+ return message.role === "assistant" && message.stopReason === "aborted";
48
+ }
49
+
50
+ export function isToolUseAssistantMessage(message: AssistantTurnMessage): boolean {
51
+ return message.role === "assistant" && message.stopReason === "toolUse";
52
+ }
53
+
54
+ interface GoalAccountingDeps {
55
+ getGoal: () => ThreadGoal | null;
56
+ getAccounting: () => AccountingState;
57
+ applyRuntimeAccountingTransition: (ctx: ExtensionContext, nextGoal: ThreadGoal) => void;
58
+ sendMessage: ExtensionAPI["sendMessage"];
59
+ }
60
+
61
+ export function createGoalAccounting(deps: GoalAccountingDeps) {
62
+ const clearActiveAccounting = (): void => {
63
+ const accounting = deps.getAccounting();
64
+ accounting.activeGoalId = null;
65
+ accounting.lastAccountedAt = null;
66
+ };
67
+
68
+ const beginAccounting = (): void => {
69
+ const goal = deps.getGoal();
70
+ const accounting = deps.getAccounting();
71
+ if (!goal || goal.status !== "active") {
72
+ accounting.activeGoalId = null;
73
+ accounting.lastAccountedAt = null;
74
+ return;
75
+ }
76
+
77
+ accounting.activeGoalId = goal.goalId;
78
+ accounting.lastAccountedAt = Date.now();
79
+ };
80
+
81
+ const accountProgress = (
82
+ ctx: ExtensionContext,
83
+ allowBudgetSteering: boolean,
84
+ completedTurnTokens = 0,
85
+ accountBudgetLimited = false,
86
+ ): void => {
87
+ const goal = deps.getGoal();
88
+ const accounting = deps.getAccounting();
89
+ const canAccount = goal?.status === "active" || (accountBudgetLimited && goal?.status === "budgetLimited");
90
+ if (!goal || accounting.activeGoalId !== goal.goalId || !canAccount) {
91
+ beginAccounting();
92
+ return;
93
+ }
94
+
95
+ const now = Date.now();
96
+ const elapsed = accounting.lastAccountedAt === null ? 0 : Math.floor((now - accounting.lastAccountedAt) / 1000);
97
+ accounting.lastAccountedAt = now;
98
+
99
+ const result = applyUsage(goal, completedTurnTokens, elapsed, {
100
+ expectedGoalId: accounting.activeGoalId,
101
+ accountBudgetLimited,
102
+ });
103
+ if (!result.changed || !result.goal) {
104
+ return;
105
+ }
106
+
107
+ deps.applyRuntimeAccountingTransition(ctx, result.goal);
108
+
109
+ if (allowBudgetSteering && result.crossedBudget && accounting.budgetWarningSentFor !== result.goal.goalId) {
110
+ accounting.budgetWarningSentFor = result.goal.goalId;
111
+ deps.sendMessage(
112
+ {
113
+ customType: CUSTOM_ENTRY_TYPE,
114
+ content: budgetLimitPrompt(result.goal),
115
+ display: false,
116
+ details: { kind: "budget_limit", goalId: result.goal.goalId },
117
+ },
118
+ { triggerTurn: true, deliverAs: "steer" },
119
+ );
120
+ }
121
+ };
122
+
123
+ return {
124
+ clearActiveAccounting,
125
+ beginAccounting,
126
+ accountProgress,
127
+ };
128
+ }
@@ -0,0 +1,73 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { RUNTIME_PERSIST_INTERVAL_MS } from "./runtime-config.js";
4
+ import { clearEntry, cloneGoal, goalsEquivalent, setEntry } from "./state.js";
5
+ import { CUSTOM_ENTRY_TYPE, type GoalEntrySource, type ThreadGoal } from "./types.js";
6
+
7
+ interface GoalPersistenceDeps {
8
+ pi: Pick<ExtensionAPI, "appendEntry">;
9
+ }
10
+
11
+ export function createGoalPersistence(deps: GoalPersistenceDeps) {
12
+ let goal: ThreadGoal | null = null;
13
+ let lastPersistedGoal: ThreadGoal | null = null;
14
+ let lastRuntimePersistAt: number | null = null;
15
+
16
+ const getGoal = (): ThreadGoal | null => goal;
17
+
18
+ const setGoalSnapshot = (nextGoal: ThreadGoal | null): void => {
19
+ goal = nextGoal;
20
+ };
21
+
22
+ const syncPersistedSnapshot = (snapshot: ThreadGoal | null): void => {
23
+ lastPersistedGoal = snapshot ? cloneGoal(snapshot) : null;
24
+ lastRuntimePersistAt = null;
25
+ };
26
+
27
+ const flushGoalPersistence = (source: GoalEntrySource): boolean => {
28
+ if (!goal) {
29
+ return false;
30
+ }
31
+ if (lastPersistedGoal && goalsEquivalent(goal, lastPersistedGoal)) {
32
+ return false;
33
+ }
34
+
35
+ deps.pi.appendEntry(CUSTOM_ENTRY_TYPE, setEntry(goal, source));
36
+ lastPersistedGoal = cloneGoal(goal);
37
+ lastRuntimePersistAt = Date.now();
38
+ return true;
39
+ };
40
+
41
+ const maybeFlushRuntimePersistence = (source: GoalEntrySource): void => {
42
+ if (!goal || goal.status !== "active") {
43
+ return;
44
+ }
45
+ const now = Date.now();
46
+ if (lastRuntimePersistAt !== null && now - lastRuntimePersistAt < RUNTIME_PERSIST_INTERVAL_MS) {
47
+ return;
48
+ }
49
+ flushGoalPersistence(source);
50
+ };
51
+
52
+ const clearGoalSnapshot = (): void => {
53
+ goal = null;
54
+ lastPersistedGoal = null;
55
+ lastRuntimePersistAt = null;
56
+ };
57
+
58
+ const appendClearEntry = (clearedGoalId: string | null, source: GoalEntrySource): void => {
59
+ clearGoalSnapshot();
60
+ deps.pi.appendEntry(CUSTOM_ENTRY_TYPE, clearEntry(clearedGoalId, source));
61
+ };
62
+
63
+ return {
64
+ appendClearEntry,
65
+ flushGoalPersistence,
66
+ getGoal,
67
+ maybeFlushRuntimePersistence,
68
+ setGoalSnapshot,
69
+ syncPersistedSnapshot,
70
+ };
71
+ }
72
+
73
+ export type GoalPersistence = ReturnType<typeof createGoalPersistence>;
@@ -0,0 +1,51 @@
1
+ import type { AgentEndEvent, ExtensionHandler } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { assistantTurnTokens, isAbortedAssistantMessage } from "./goal-accounting.js";
4
+ import { isErrorAssistantMessage, type AssistantErrorMessage } from "./recovery.js";
5
+ import {
6
+ handleAgentErrorMessage,
7
+ recordAssistantContextOverflow,
8
+ runStaleQueuedWorkPlan,
9
+ } from "./goal-runtime-event-utils.js";
10
+ import type { GoalRuntimeAgentHandlerContext } from "./goal-runtime-event-handler-types.js";
11
+
12
+ export function createAgentEventHandlers(deps: GoalRuntimeAgentHandlerContext) {
13
+ const { runtimeState, stateController, continuation, goalAccounting, resetErrorRecovery } = deps;
14
+
15
+ return {
16
+ onAgentEnd: (async (event, ctx) => {
17
+ continuation.clearPassthroughContinuationInput();
18
+ if (runStaleQueuedWorkPlan(runtimeState.staleQueuedWorkGuard.planAgentEnd(event.messages), ctx, deps)) {
19
+ return;
20
+ }
21
+
22
+ const abortedMessages = event.messages.filter(isAbortedAssistantMessage);
23
+ const abortedTurnTokens = abortedMessages.reduce((sum, message) => {
24
+ return sum + assistantTurnTokens(message);
25
+ }, 0);
26
+ goalAccounting.accountProgress(ctx, false, abortedTurnTokens, true);
27
+ stateController.flushGoalPersistence("runtime");
28
+ if (abortedMessages.length > 0) {
29
+ stateController.pauseForAbort(ctx);
30
+ return;
31
+ }
32
+ const errorMessages = event.messages.filter(isErrorAssistantMessage);
33
+ if (errorMessages.length > 0) {
34
+ const lastError = errorMessages.at(-1) as AssistantErrorMessage | undefined;
35
+ if (lastError) {
36
+ handleAgentErrorMessage(lastError, ctx, deps);
37
+ }
38
+ return;
39
+ }
40
+
41
+ const lastAssistant = [...event.messages]
42
+ .reverse()
43
+ .find((message) => message.role === "assistant");
44
+ if (lastAssistant && recordAssistantContextOverflow(lastAssistant, ctx, deps)) {
45
+ return;
46
+ }
47
+ resetErrorRecovery();
48
+ continuation.requestContinuation(ctx);
49
+ }) satisfies ExtensionHandler<AgentEndEvent>,
50
+ };
51
+ }
@@ -0,0 +1,162 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { registerGoalCommand } from "./commands.js";
4
+ import { createContinuationScheduler } from "./continuation-scheduler.js";
5
+ import { createGoalAccounting } from "./goal-accounting.js";
6
+ import { createGoalPersistence } from "./goal-persistence.js";
7
+ import {
8
+ createGoalRuntimeEventHandlers,
9
+ type GoalRuntimeEventHandlers,
10
+ } from "./goal-runtime-event-handlers.js";
11
+ import { registerGoalRuntimeEvents } from "./goal-runtime-events.js";
12
+ import { createGoalRuntimeState } from "./goal-runtime-state.js";
13
+ import { createGoalRuntimeStatus } from "./goal-runtime-status.js";
14
+ import { createGoalStateController } from "./goal-state-controller.js";
15
+ import { createGoalRecoveryRuntime } from "./recovery-runtime.js";
16
+ import {
17
+ clearActiveHostOverflowRecovery,
18
+ resetRecoveryMachine,
19
+ setRecoveryPausedAttention,
20
+ } from "./recovery-machine.js";
21
+ import { goalWithLiveUsage } from "./state.js";
22
+ import { registerGoalTools } from "./tools.js";
23
+ import type { GoalEntrySource, GoalResult, ThreadGoal } from "./types.js";
24
+
25
+ export interface GoalRuntimeController extends GoalRuntimeEventHandlers {
26
+ getGoalForDisplay(): ThreadGoal | null;
27
+ setGoal(goal: ThreadGoal, source: GoalEntrySource, ctx: ExtensionContext): void;
28
+ clearGoal(source: GoalEntrySource, ctx: ExtensionContext): void;
29
+ updateGoal(status: "complete" | "blocked", source: GoalEntrySource, ctx: ExtensionContext): GoalResult;
30
+ requestContinuation(ctx: ExtensionContext): void;
31
+ }
32
+
33
+ export function createGoalRuntimeController(pi: ExtensionAPI): GoalRuntimeController {
34
+ const runtimeState = createGoalRuntimeState();
35
+ const persistence = createGoalPersistence({ pi });
36
+
37
+ const clearActiveAccounting = (): void => {
38
+ runtimeState.accounting.activeGoalId = null;
39
+ runtimeState.accounting.lastAccountedAt = null;
40
+ };
41
+
42
+ const resetErrorRecovery = (): void => {
43
+ resetRecoveryMachine(runtimeState.recoveryState);
44
+ };
45
+
46
+ const goalForDisplay = () =>
47
+ goalWithLiveUsage(
48
+ persistence.getGoal(),
49
+ runtimeState.accounting.activeGoalId,
50
+ runtimeState.accounting.lastAccountedAt,
51
+ );
52
+
53
+ const status = createGoalRuntimeStatus({
54
+ getGoalForDisplay: goalForDisplay,
55
+ getGoalStatus: () => persistence.getGoal()?.status ?? null,
56
+ getRecoveryAttention: () => runtimeState.recoveryState.attention,
57
+ });
58
+
59
+ const continuation = createContinuationScheduler({
60
+ pi,
61
+ getGoal: () => persistence.getGoal(),
62
+ getRecoveryState: () => runtimeState.recoveryState,
63
+ staleQueuedWorkGuard: runtimeState.staleQueuedWorkGuard,
64
+ getCurrentTurnIndex: () => runtimeState.currentTurnIndex,
65
+ });
66
+
67
+ const stateController = createGoalStateController({
68
+ pi,
69
+ persistence,
70
+ getRecoveryState: () => runtimeState.recoveryState,
71
+ transitionEffectHandlers: {
72
+ clearContinuation: continuation.clearContinuationState,
73
+ clearActiveAccounting,
74
+ resetRecovery: resetErrorRecovery,
75
+ clearBudgetWarning: () => {
76
+ runtimeState.accounting.budgetWarningSentFor = null;
77
+ },
78
+ clearHostOverflowRecovery: () => {
79
+ clearActiveHostOverflowRecovery(runtimeState.recoveryState);
80
+ },
81
+ setRecoveryPausedAttention: (reason: string) => {
82
+ setRecoveryPausedAttention(runtimeState.recoveryState, reason);
83
+ },
84
+ markContinuationQueued: continuation.markContinuationQueued,
85
+ stopStatusRefresh: () => status.stopStatusRefresh(),
86
+ },
87
+ refreshUi: (ctx) => status.refreshUi(ctx),
88
+ });
89
+
90
+ const goalAccounting = createGoalAccounting({
91
+ getGoal: () => stateController.getGoal(),
92
+ getAccounting: () => runtimeState.accounting,
93
+ applyRuntimeAccountingTransition(ctx, nextGoal) {
94
+ stateController.applyGoalTransition({ kind: "runtime_accounting", nextGoal }, ctx);
95
+ },
96
+ sendMessage: pi.sendMessage.bind(pi),
97
+ });
98
+
99
+ const recoveryRuntime = createGoalRecoveryRuntime({
100
+ getGoal: () => stateController.getGoal(),
101
+ getRecoveryState: () => runtimeState.recoveryState,
102
+ clearContinuationState: continuation.clearContinuationState,
103
+ pauseGoalForRecovery(ctx, recoveryReason) {
104
+ stateController.applyGoalTransition(
105
+ { kind: "recovery_pause", recoveryReason },
106
+ ctx,
107
+ );
108
+ },
109
+ refreshUi: status.refreshUi,
110
+ requestContinuation: continuation.requestContinuation,
111
+ });
112
+
113
+ const eventHandlers = createGoalRuntimeEventHandlers({
114
+ pi,
115
+ runtimeState,
116
+ stateController,
117
+ continuation,
118
+ goalAccounting,
119
+ recoveryRuntime,
120
+ status,
121
+ clearActiveAccounting,
122
+ resetErrorRecovery,
123
+ });
124
+
125
+ const updateGoal = (
126
+ status: "complete" | "blocked",
127
+ source: GoalEntrySource,
128
+ ctx: ExtensionContext,
129
+ ): GoalResult => {
130
+ goalAccounting.accountProgress(ctx, false, 0, true);
131
+ return stateController.updateGoal(status, source, ctx);
132
+ };
133
+
134
+ return {
135
+ getGoalForDisplay: goalForDisplay,
136
+ setGoal(nextGoal, source, ctx) {
137
+ stateController.applyGoalTransition({ kind: "set", nextGoal, source }, ctx);
138
+ },
139
+ clearGoal(source, ctx) {
140
+ stateController.applyGoalTransition({ kind: "clear", source }, ctx);
141
+ },
142
+ updateGoal,
143
+ requestContinuation: continuation.requestContinuation,
144
+ ...eventHandlers,
145
+ };
146
+ }
147
+
148
+ export function registerGoalRuntimeController(pi: ExtensionAPI): void {
149
+ const controller = createGoalRuntimeController(pi);
150
+ registerGoalTools(pi, {
151
+ getGoal: () => controller.getGoalForDisplay(),
152
+ setGoal: controller.setGoal.bind(controller),
153
+ updateGoal: controller.updateGoal.bind(controller),
154
+ });
155
+ registerGoalCommand(pi, {
156
+ getGoal: () => controller.getGoalForDisplay(),
157
+ setGoal: controller.setGoal.bind(controller),
158
+ clearGoal: controller.clearGoal.bind(controller),
159
+ requestContinuation: controller.requestContinuation.bind(controller),
160
+ });
161
+ registerGoalRuntimeEvents(pi, controller);
162
+ }