@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.
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 +219 -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 +321 -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,692 @@
1
+ import { goalStorageContext, type GoalStorageContext } from "./storage/goal-root.ts";
2
+ import { commandHint, commandInstructions } from "./goal-controls.ts";
3
+ import { cacheGoalHistory } from "./goal-prompt-cache.ts";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ GOAL_EVENT_ENTRY,
7
+ assistantTurnTokens,
8
+ extractGoalIdFromInjectedMessage,
9
+ goalEventMessageId,
10
+ hasAbortedAssistantMessage,
11
+ hasErrorAssistantMessage,
12
+ hasNetworkErrorAssistantMessage,
13
+ isAbortedAssistantMessage,
14
+ isErrorAssistantMessage,
15
+ isMeaningfulProgressToolCall,
16
+ } from "./goal-format.ts";
17
+ import { buildCompactionSummary, buildPostCompactionGoalDelta } from "./goal-compaction.ts";
18
+ import { latestAuditorResultForGoal, readGoalLedger, goalRuntimeEvents, invalidateGoalLedgerCache } from "./goal-ledger.ts";
19
+ import { shouldArmPostCompactReminder, shouldInjectPostCompactReminder } from "./goal-policy.ts";
20
+ import { formatTokenValue } from "./goal-core.ts";
21
+ import { loadGoalSettings, invalidateGoalSettingsCache } from "./goal-settings.ts";
22
+ import { modelBudgetLine, budgetRemaining } from "./goal-accounting.ts";
23
+ import { asRecord, nowIso, type AssistantMessageLike, type GoalRecord } from "./goal-record.ts";
24
+ import { goalSelectorLabel, otherOpenGoalCount } from "./goal-pool.ts";
25
+ import { invalidateGoalPoolCache } from "./storage/goal-files.ts";
26
+ import { checkpointTriggerPrompt } from "./prompts/goal-prompts.ts";
27
+ import { consumeOracleFollowupMarker, hasPendingOracleAdviceForFocusedGoal } from "./goal-oracle.ts";
28
+ import { LiveTailRetention } from "./goal-live-retention.ts";
29
+ import {
30
+ goalPromptParts,
31
+ staleContinuationPrompt,
32
+ unfocusedOpenGoalsPrompt,
33
+ untrustedObjectiveBlock,
34
+ } from "./prompts/goal-prompts.ts";
35
+ import { hasActiveDraft, rehydrateDraft } from "./goal-drafting.ts";
36
+ import { FIVE_GOAL_TOOLS } from "./goal-tool-names.ts";
37
+ import { syncTerminalInputPause } from "./goal-widget.ts";
38
+ import type { GoalCore } from "./goal-state.ts";
39
+ import { filterGoalSessionContext, isStaleExtensionContextError } from "./goal-session-safety.ts";
40
+ import { registerGoalContext } from "./goal-context.ts";
41
+ import type { GoalMutationOutcome } from "./goal-service.ts";
42
+
43
+ /** Normalize checkpoints independently: deleting old markers shifts the cached prefix. */
44
+ export function compactGoalCheckpointContext(
45
+ messages: readonly unknown[],
46
+ _currentGoal: GoalRecord | null,
47
+ ): unknown[] | null {
48
+ let output: unknown[] | null = null;
49
+ for (let i = 0; i < messages.length; i++) {
50
+ const message = messages[i] as { customType?: string; details?: unknown; content?: unknown };
51
+ const id = goalEventMessageId(message);
52
+ if (id === null) { output?.push(message); continue; }
53
+ output ??= messages.slice(0, i);
54
+ output.push({ ...message, content: checkpointTriggerPrompt(id), display: false,
55
+ details: { version: 2, kind: "checkpoint", goalId: id } });
56
+ }
57
+ return output;
58
+ }
59
+
60
+ /**
61
+ * The goal extension's lifecycle event handlers (context, turn_start,
62
+ * tool_call, tool_execution_end, turn_end, message_end, session_start,
63
+ * session_before_compact, session_compact, session_tree, before_agent_start,
64
+ * agent_end, agent_settled, session_shutdown). All state flows through the
65
+ * GoalCore.
66
+ */
67
+ export function registerGoalEvents(core: GoalCore): void {
68
+ const { pi } = core;
69
+ // Transients are keyed per session: the context hook of session B must never
70
+ // overwrite the set that session A's provider request will consume.
71
+ const liveRetention = new LiveTailRetention();
72
+ const sessionKeyFor = (ctx?: ExtensionContext): string =>
73
+ ctx?.sessionManager?.getSessionId?.() ? JSON.stringify([ctx.sessionManager.getSessionId(), ctx.model?.provider, ctx.model?.id]) : "unidentified";
74
+ type SessionOwner = {ctx: ExtensionContext; manager: ExtensionContext["sessionManager"]; storage: GoalStorageContext; keys: Set<string>};
75
+ let session: SessionOwner | undefined;
76
+ const contexts = new WeakMap<object, SessionOwner>();
77
+ const leases = new Map<string, SessionOwner>();
78
+ pi.on("before_provider_request", (event, ctx) => {
79
+ if (ctx && contexts.has(ctx) && contexts.get(ctx) !== session) return;
80
+ try {
81
+ if (session && ctx && ctx.sessionManager !== session.manager) return;
82
+ return cacheGoalHistory(event.payload, liveRetention.contents(sessionKeyFor(ctx)));
83
+ }
84
+ catch (error) { if (!isStaleExtensionContextError(error)) throw error; }
85
+ });
86
+ let continuationAfterSettleFor: string | null = null;
87
+ let networkErrorRecoveryAfterSettleFor: string | null = null;
88
+
89
+ registerGoalContext(pi, (input, ctx) => {
90
+ const filtered = filterGoalSessionContext(input);
91
+ const messages = compactGoalCheckpointContext(filtered ?? input, core.state.goal) ?? filtered;
92
+ const base = messages ?? input;
93
+ if (contexts.has(ctx) && contexts.get(ctx) !== session) return messages;
94
+ try {if (session && ctx.sessionManager !== session.manager) return messages;}
95
+ catch (error) {if (isStaleExtensionContextError(error)) return messages; throw error;}
96
+ // Reassert startup rejection only for the current host's provider boundary.
97
+ if (core.scheduler.isDenied()) ctx.abort?.();
98
+ if (session) contexts.set(ctx, session);
99
+ const sessionKey = sessionKeyFor(ctx);
100
+ if (session) {
101
+ leases.set(sessionKey, session);
102
+ session.keys.add(sessionKey);
103
+ if (leases.size > 16) leases.delete(leases.keys().next().value!);
104
+ if (session.keys.size > 16) session.keys.delete(session.keys.values().next().value!);
105
+ }
106
+ if (sessionKey === "unidentified") liveRetention.clear(sessionKey);
107
+ const parts = currentGoalContextParts(ctx);
108
+ if (parts) {
109
+ // Retain previously sent tails verbatim at their anchors so this
110
+ // request extends the previous one instead of displacing its tail.
111
+ // Only genuinely new state/counters content is appended.
112
+ const { messages: retained } = liveRetention.apply(sessionKey, base, { state: parts.state, counters: parts.counters });
113
+ return retained;
114
+ }
115
+ liveRetention.clear(sessionKey);
116
+ return messages;
117
+ });
118
+
119
+ pi.on("agent_start", async (_event, ctx) => { core.scheduler.begin(ctx); });
120
+ pi.on("message_start", async (event, ctx) => { core.scheduler.message(ctx, event.message); });
121
+ pi.on("turn_start", async (_event, ctx) => {
122
+ if (core.scheduler.isDenied()) return;
123
+ core.scheduler.turn(ctx);
124
+ // Per-turn flag resets (#4 + C9 fix).
125
+ core.advanceTurnSeq();
126
+ core.goalWorkToolCalledThisTurn = false;
127
+ core.beginAccounting();
128
+ core.goalService.beginTurn(ctx, core.focusedGoalId); // P1-3 transaction buffer
129
+ core.touchGoalActivity(); // F5
130
+ core.updateUI(ctx);
131
+ });
132
+
133
+ // #4 + C9 fix + Phase 5 C3: gate in-turn tool calls based on lifecycle state.
134
+ pi.on("tool_call", async (event, ctx) => {
135
+ const stoppedGoalId = core.currentTurnStoppedGoalId();
136
+ if (core.scheduler.isDenied()) return { block: true, reason: `Stale goal dispatch; use ${commandHint("goal-resume", core.loadedCommands)}.` };
137
+ // Post-stop in-turn block: after update_goal / set_goal_tasks (or a user
138
+ // lifecycle command) fires in this turn, block all subsequent tool calls
139
+ // except read-only inspection.
140
+ if (stoppedGoalId !== null && core.runtime.isStaleCheckpointBlocked(event.toolName)) {
141
+ return {
142
+ block: true,
143
+ reason: `The goal was already stopped earlier in this turn (goalId=${stoppedGoalId}). ` +
144
+ `Do not call more tools; end the turn with a brief summary and yield to the user.`,
145
+ };
146
+ }
147
+ // Stale checkpoint guard: if the turn was triggered by a queued continuation
148
+ // for a goal that is no longer active/autoContinue, block work tools.
149
+ const checkpointGoalId = core.runtime.getCheckpointGoalId();
150
+ if (checkpointGoalId !== null && !core.isActionableContinuationGoal(checkpointGoalId) && core.isStaleCheckpointBlockedToolCall(event.toolName)) {
151
+ // Block the tool call with a stale-checkpoint message.
152
+ return {
153
+ block: true,
154
+ reason: `Cannot call ${event.toolName}: the goal checkpoint that triggered this turn is no longer active. ` +
155
+ `Goal ${checkpointGoalId} has been paused, cleared, or replaced. ` +
156
+ `End the turn with a brief summary and yield to the user.`,
157
+ };
158
+ }
159
+ // Keep declarations cached; block unconfirmed goal operations at execution.
160
+ if (hasActiveDraft(core) && (FIVE_GOAL_TOOLS as readonly string[]).includes(event.toolName)) {
161
+ return {
162
+ block: true,
163
+ reason: commandInstructions("A goal draft is active. Finish it with propose_goal_draft or ask the user to run /goal-cancel.", core.loadedCommands),
164
+ };
165
+ }
166
+ // A proposal that confirmed or cancelled the draft must not let later calls
167
+ // in the same assistant message act on (or recreate) the unconfirmed goal.
168
+ if (core.runtime.isDraftResolvedThisTurn() && (FIVE_GOAL_TOOLS as readonly string[]).includes(event.toolName)) {
169
+ return {
170
+ block: true,
171
+ reason: "The goal draft was resolved earlier in this turn. End the turn with a brief summary and yield to the user.",
172
+ };
173
+ }
174
+ // Track Oracle work attempts; this does not authorize scheduling.
175
+ if (isMeaningfulProgressToolCall(event.toolName, event.input)) {
176
+ core.goalWorkToolCalledThisTurn = true;
177
+ // Goal mutations are measured by content changes at settlement. A
178
+ // ready declaration or an unchanged task update is only bookkeeping.
179
+ if (event.toolName !== "update_goal" && event.toolName !== "update_goal_task") core.scheduler.recordWork();
180
+ // Issue #26: record a meaningful work attempt against armed Oracle
181
+ // advice. get_goal / echo-only reads are excluded upstream by
182
+ // isMeaningfulProgressToolCall.
183
+ const focusedId = core.focusedGoalId;
184
+ if (focusedId && hasPendingOracleAdviceForFocusedGoal(focusedId)) {
185
+ const armed = consumeOracleFollowupMarker(focusedId);
186
+ if (armed) {
187
+ try {
188
+ core.goalService.appendEvents(ctx, [{
189
+ type: "oracle_followup_attempted",
190
+ goalId: armed.goalId,
191
+ fingerprint: armed.fingerprint,
192
+ adviceId: armed.adviceId,
193
+ firstToolName: event.toolName,
194
+ at: nowIso(),
195
+ }]);
196
+ } catch { /* best-effort ledger append */ }
197
+ }
198
+ }
199
+ }
200
+ return;
201
+ });
202
+
203
+ pi.on("tool_execution_end", async (_event, ctx) => {
204
+ if (core.scheduler.isDenied()) return;
205
+ core.touchGoalActivity(); // F5
206
+ core.accountProgress(ctx);
207
+ });
208
+
209
+ pi.on("turn_end", async (event, ctx) => {
210
+ if (core.scheduler.isDenied()) return;
211
+ const message = event.message as AssistantMessageLike;
212
+ const tokens = assistantTurnTokens(message);
213
+ core.touchGoalActivity(); // F5
214
+ core.accountProgress(ctx, { completedTurnTokens: tokens });
215
+ core.interruption.finishTurn(ctx);
216
+
217
+ if (isAbortedAssistantMessage(message)) {
218
+ // Pause only on a genuine user abort (signal fired). A provider- or
219
+ // transport-side abort without the signal routes into recovery via
220
+ // agent_end instead of stranding the goal.
221
+ if (ctx.signal?.aborted && !core.interruption.ignoreAbort() && !core.interruption.isHeld()) core.pauseActiveGoal(ctx);
222
+ return;
223
+ }
224
+ // Provider failures are not completed work: do not turn one failed turn
225
+ // into an unbounded auto-continue retry storm. Keep the display
226
+ // reconciled (and accounting already ran above), but never queue a
227
+ // continuation for an error turn (danim47c pattern).
228
+ if (isErrorAssistantMessage(message)) {
229
+ core.refreshGoalDisplayFromDisk(ctx);
230
+ core.updateUI(ctx);
231
+ return;
232
+ }
233
+ core.refreshGoalDisplayFromDisk(ctx);
234
+
235
+ // Archive a goal that was marked complete but whose archival was deferred
236
+ // so the agent could see/recognize the audit result first.
237
+ // This runs after the agent's turn ends — the agent has now seen the result.
238
+ if (core.state.goal?.status === "complete" && !core.state.goal?.archivedPath) {
239
+ const completedGoal = core.state.goal;
240
+ let archiveResult: GoalMutationOutcome;
241
+ try {
242
+ archiveResult = core.goalService.apply(ctx, {
243
+ reconcile: false,
244
+ archive: true,
245
+ commitFocused: false,
246
+ mutate: () => completedGoal,
247
+ ledger: (written) => [{
248
+ type: "goal_completed",
249
+ goalId: completedGoal.id,
250
+ archivePath: written.archivedPath,
251
+ at: nowIso(),
252
+ }],
253
+ });
254
+ } catch (err) {
255
+ // The archive write throws on failure (e.g. unwritable archived
256
+ // directory); surface it as a typed outcome (follow-up Stage 3).
257
+ archiveResult = { ok: false, message: err instanceof Error ? err.message : String(err) };
258
+ }
259
+ if (archiveResult.ok) {
260
+ core.goalsById.delete(completedGoal.id);
261
+ core.assignFocusedGoalId(null);
262
+ core.appendFocusEntry(null, "completed");
263
+ // §16.6: append the dedicated goal_archived event (the completion
264
+ // transaction keeps goal_completed for compatibility) and emit the
265
+ // real archive path.
266
+ try {
267
+ core.goalService.appendEvents(ctx, [{
268
+ type: "goal_archived",
269
+ goalId: completedGoal.id,
270
+ archivePath: archiveResult.goal?.archivedPath ?? "",
271
+ at: nowIso(),
272
+ }]);
273
+ } catch {
274
+ // Best-effort; the archive itself already succeeded.
275
+ }
276
+ const path = archiveResult.goal?.archivedPath ?? "";
277
+ ctx.ui.notify(path ? `Goal archived.\nFile: ${path}` : "Goal archived.", "info");
278
+ } else {
279
+ // §16.6 failure behavior: never claim success, keep the complete
280
+ // record recoverable at its active path, and write a diagnostic
281
+ // ledger event when possible.
282
+ const remainingPath = completedGoal.activePath ?? "(unknown)";
283
+ ctx.ui.notify(`Failed to archive completed goal: ${archiveResult.message}. The complete record remains at ${remainingPath}.`, "warning");
284
+ try {
285
+ core.goalService.appendEvents(ctx, [{
286
+ type: "goal_archive_failed",
287
+ goalId: completedGoal.id,
288
+ message: archiveResult.message ?? "archive write failed",
289
+ at: nowIso(),
290
+ }]);
291
+ } catch {
292
+ // Diagnostic write is best-effort.
293
+ }
294
+ }
295
+ core.updateUI(ctx);
296
+ }
297
+
298
+ core.goalService.endTurn(ctx); // P1-3: single flush (lock + write + ledger batch)
299
+ });
300
+
301
+ pi.on("message_end", async (event, ctx) => {
302
+ // Signal-aware: see turn_end — only user aborts pause; provider-side
303
+ // aborts are handled by agent_end's recovery path.
304
+ if (!core.scheduler.isDenied() && isAbortedAssistantMessage(event.message) && ctx.signal?.aborted && !core.interruption.ignoreAbort() && !core.interruption.isHeld()) core.pauseActiveGoal(ctx);
305
+ const raw = asRecord(event.message);
306
+ if (raw?.role === "custom" && raw.customType === GOAL_EVENT_ENTRY && raw.display !== false) {
307
+ return { message: { ...event.message, display: false } as typeof event.message };
308
+ }
309
+ });
310
+
311
+ /** Detach first; every local cleanup runs even when host or persistence throws. */
312
+ function teardown(ctx: ExtensionContext): void {
313
+ const outgoing = session;
314
+ session = undefined;
315
+ core.sessionOwner = null;
316
+ let failure: unknown;
317
+ const run = (action: () => void) => {
318
+ try {action();} catch (error) {if (!isStaleExtensionContextError(error)) failure ??= error;}
319
+ };
320
+ run(() => core.invalidateSession());
321
+ continuationAfterSettleFor = null;
322
+ networkErrorRecoveryAfterSettleFor = null;
323
+ run(() => core.interruption.settled());
324
+ run(() => core.interruption.reset());
325
+ run(() => core.auditMessages.clear());
326
+ run(() => core.scheduler.shutdown());
327
+ if (outgoing) run(() => core.accountProgress(outgoing.ctx));
328
+ run(() => core.goalService.endSession(outgoing?.storage));
329
+ run(() => core.clearContinuationState());
330
+ run(() => core.clearActiveAccounting());
331
+ run(() => core.stopAuditAnimation());
332
+ run(() => core.clearAuditResult());
333
+ core.auditProgress = null;
334
+ core.goalModalDepth = 0;
335
+ const unsubscribe = core.terminalInputUnsubscribe;
336
+ core.terminalInputUnsubscribe = null;
337
+ run(() => unsubscribe?.());
338
+ if (outgoing && core.state.goal) run(() => {core.goalService.persist(outgoing.storage);});
339
+ run(() => core.clearGoalWidget(outgoing?.ctx ?? ctx));
340
+ if (outgoing) for (const key of outgoing.keys) {
341
+ if (leases.get(key) !== outgoing) continue;
342
+ liveRetention.clear(key);
343
+ leases.delete(key);
344
+ }
345
+ if (failure) throw failure;
346
+ }
347
+
348
+ async function startSession(event: {reason?: string}, ctx: ExtensionContext): Promise<void> {
349
+ teardown(ctx);
350
+ try {
351
+ const owner: SessionOwner = {ctx, manager: ctx.sessionManager, storage: goalStorageContext(ctx), keys: new Set()};
352
+ session = owner;
353
+ core.sessionOwner = owner;
354
+ contexts.set(ctx, owner);
355
+ invalidateGoalSettingsCache();
356
+ invalidateGoalPoolCache();
357
+ invalidateGoalLedgerCache();
358
+ await core.loadState(ctx);
359
+ if (session !== owner) return;
360
+ if (ctx.hasUI) ctx.ui.setStatus("goal-interrupt", undefined);
361
+ if (core.controlWarnings?.length) ctx.ui.notify(`Goal command settings were not applied: ${core.controlWarnings.join(" ")} Open /goal-settings controls to correct them.`, "warning");
362
+ rehydrateDraft(core, ctx);
363
+ syncTerminalInputPause(core, ctx);
364
+ if (event.reason === "resume" && !core.state.goal && !core.hasExplicitSessionFocus && otherOpenGoalCount(core.goalsById, null) > 1 && ctx.hasUI) {
365
+ // Prompt the user to pick which open goal to focus (mirrors /goal-focus).
366
+ const open = core.openGoals();
367
+ const labels = open.map((item) => goalSelectorLabel(item, core.focusedGoalId));
368
+ const byLabel = new Map(labels.map((label, index) => [label, open[index]?.id]));
369
+ core.enterGoalModal();
370
+ try {
371
+ const selected = await ctx.ui.select("Focus open goal", labels);
372
+ const selectedId = selected ? byLabel.get(selected) : undefined;
373
+ if (selectedId && session === owner) {
374
+ core.setFocusedGoalId(selectedId, ctx, "selected");
375
+ core.armFocusedContinuation(ctx);
376
+ }
377
+ } finally {
378
+ if (session === owner) core.exitGoalModal();
379
+ }
380
+ }
381
+ if (session !== owner) return;
382
+ // Codex behavior: prompt before reactivating a paused goal on resume.
383
+ if (event.reason === "resume" && core.state.goal?.status === "paused" && ctx.hasUI) {
384
+ const current = core.state.goal;
385
+ const shouldResume = await ctx.ui.confirm("Resume paused goal?", `Goal: ${current.objective}`);
386
+ if (shouldResume && session === owner) {
387
+ core.scheduler.resume(ctx);
388
+ }
389
+ }
390
+ if (session !== owner) return;
391
+ core.beginAccounting();
392
+ core.scheduler.restore(ctx);
393
+ } catch (error) {if (!isStaleExtensionContextError(error)) throw error;}
394
+ }
395
+ pi.on("session_start", startSession);
396
+
397
+ pi.on("session_before_compact", async (_event, ctx) => {
398
+ core.accountProgress(ctx);
399
+ });
400
+
401
+ pi.on("session_compact", async (_event, ctx) => {
402
+ core.goalService.flushTurn(ctx); // P1-3: persist any buffered transaction before reload
403
+ if (core.state.goal) core.persist(ctx);
404
+ core.beginAccounting();
405
+ // Arm a deterministic compaction summary for the next agent turn.
406
+ // This replaces the generic reminder with artifact-backed state.
407
+ if (shouldArmPostCompactReminder(core.state.goal)) {
408
+ core.runtime.armPostCompactReminder();
409
+ }
410
+ core.queueContinuation(ctx, true);
411
+ });
412
+
413
+ pi.on("session_compact_failed", async (event, ctx) => {
414
+ // `session_before_compact` already charged progress to the goal, so
415
+ // flush the buffered transaction and persist first. Otherwise the goal
416
+ // record and the ledger can disagree about a compaction that never
417
+ // happened.
418
+ core.goalService.flushTurn(ctx);
419
+ if (core.state.goal) core.persist(ctx);
420
+ if (event.aborted || !core.state.goal) return;
421
+ const detail = event.errorMessage ? `: ${event.errorMessage}` : "";
422
+ ctx.ui.notify(
423
+ `Goal context compaction failed (${event.reason})${detail}. The goal stays active; retry with /compact or keep working.`,
424
+ "warning",
425
+ );
426
+ });
427
+
428
+ pi.on("session_tree", (_event, ctx) => startSession({}, ctx));
429
+
430
+ pi.on("before_agent_start", async (event, ctx) => {
431
+ core.advanceTurnSeq();
432
+ core.installGoalToolProfile(!loadGoalSettings(ctx.cwd).disableTasks);
433
+ const incomingGoalId = event.messageType === "custom"
434
+ ? goalEventMessageId(event.message) : extractGoalIdFromInjectedMessage(event.prompt ?? "");
435
+ if (event.messageType !== "custom" && !incomingGoalId) core.interruption.continueWithUser(ctx);
436
+
437
+ // If this turn was triggered by a hidden goal checkpoint that no longer
438
+ // matches the active goal, abort the whole turn instead of letting the
439
+ // model act on a stale instruction.
440
+ if (incomingGoalId !== null) {
441
+ // Reconcile from disk to pick up any external state changes before
442
+ // evaluating whether the checkpoint is actionable.
443
+ core.reconcileFocusedGoalFromDisk(ctx);
444
+ core.runtime.setCheckpoint(incomingGoalId);
445
+ // This can be the hidden checkpoint dispatched by the network-error
446
+ // timer. Clear ordinary continuation bookkeeping but retain the
447
+ // consecutive recovery count for a later failed retry.
448
+ core.clearContinuationState(false);
449
+ if (!core.isActionableContinuationGoal(incomingGoalId)) {
450
+ try {
451
+ core.scheduler.rejectCheckpoint(ctx);
452
+ } catch {}
453
+ core.updateUI(ctx);
454
+ return;
455
+ }
456
+ core.runtime.setCheckpoint(null);
457
+ } else {
458
+ // A user-driven turn — clear any queued continuation so we don't
459
+ // double-fire after the user's own message returns. Also reset the
460
+ // autoContinue nudge state so the user always gets a fresh chain.
461
+ core.runtime.setCheckpoint(null);
462
+ core.clearContinuationState();
463
+ networkErrorRecoveryAfterSettleFor = null;
464
+ }
465
+
466
+ core.reconcileFocusedGoalFromDisk(ctx);
467
+ core.runningGoalId = core.state.goal?.status === "active" ? core.state.goal.id : null;
468
+ });
469
+
470
+ /** Request-only state: no live counters, focus, or reminders enter the system prefix. */
471
+ /** Request-only goal state split into a rarely-changing policy block and per-turn counters. Other branches are stable-only. */
472
+ function currentGoalContextParts(ctx: ExtensionContext): { state: string; counters?: string } | undefined {
473
+ const checkpoint = core.runtime.getCheckpointGoalId();
474
+ if (checkpoint !== null && (core.scheduler.isDenied() || !core.isActionableContinuationGoal(checkpoint))) {
475
+ return { state: staleContinuationPrompt(checkpoint, core.state.goal) };
476
+ }
477
+ // Several prompt enrichments may need the same ledger snapshot. Keep one
478
+ // local read for this hook instead of repeatedly traversing the cached
479
+ // ledger when rejection and post-compaction steering overlap.
480
+ let promptLedger: ReturnType<typeof readGoalLedger> | undefined;
481
+ const getPromptLedger = () => promptLedger ??= { events: core.state.goal ? goalRuntimeEvents(ctx, core.state.goal.id) : [], malformed: 0 };
482
+
483
+ if (!core.state.goal) {
484
+ // Issue #72: hideUnfocusedPrompt opts out of the model-facing
485
+ // unfocused reminder only. The stale-checkpoint guard above and
486
+ // all focused-goal context below are unaffected.
487
+ if (loadGoalSettings(ctx.cwd).hideUnfocusedPrompt === true) return;
488
+ const openCount = otherOpenGoalCount(core.goalsById, null);
489
+ if (openCount > 0) {
490
+ return { state: commandInstructions(unfocusedOpenGoalsPrompt(openCount), core.loadedCommands) };
491
+ }
492
+ return;
493
+ }
494
+ if (core.state.goal.status === "complete") return;
495
+ if (core.state.goal.status === "paused") {
496
+ const current = core.state.goal;
497
+ const pauseExtras: string[] = [];
498
+ if (current.stopReason === "agent") {
499
+ pauseExtras.push("");
500
+ pauseExtras.push(`Pause reason: ${current.pauseReason ?? "(unknown)"}`);
501
+ if (current.pauseSuggestedAction) pauseExtras.push(`Suggested action: ${current.pauseSuggestedAction}`);
502
+ }
503
+ // Inject durable auditor feedback if available
504
+ let auditorExtra = "";
505
+ try {
506
+ const ledger = getPromptLedger();
507
+ const auditorResult = latestAuditorResultForGoal(ledger.events, current.id);
508
+ if (auditorResult && auditorResult.verdict === "disapproved") {
509
+ auditorExtra = `\n\n[AUDITOR REJECTION] An independent auditor previously rejected a completion request for this goal. Reason: ${auditorResult.report.slice(0, 300)}\nAddress the auditor's objections before requesting completion again.`;
510
+ }
511
+ } catch {
512
+ // Ledger read failure should not break the prompt
513
+ }
514
+ return { state: `[PI GOAL PAUSED goalId=${current.id}]\n${untrustedObjectiveBlock(current)}${pauseExtras.join("\n")}${auditorExtra}\n\nThe goal is paused. Do not autonomously continue substantive work unless the user resumes it with ${commandHint("goal-resume", core.loadedCommands)}. If the user explicitly asks to finish the paused goal and the objective is already satisfied based on available evidence, you may call update_goal({status: "complete"}). To abandon a goal, the user runs ${commandHint("goal-clear", core.loadedCommands)}. Do not report the goal blocked in response to a pause.` };
515
+ }
516
+ // Token-budget-limited goals get one-time wrap-up steering: summarize,
517
+ // do not start new substantive work, never claim completion unless real.
518
+ if (core.state.goal?.status === "budget_limited") {
519
+ const limitedGoal = core.state.goal;
520
+ const budgetText = modelBudgetLine(limitedGoal);
521
+ // E4: surface the remaining-vs-overshoot fact in the wrap-up steering.
522
+ const remaining = budgetRemaining(limitedGoal);
523
+ const balanceText = typeof remaining === "number"
524
+ ? remaining < 0
525
+ ? ` — ${formatTokenValue(-remaining)} over the budget`
526
+ : ` — ${formatTokenValue(remaining)} remaining`
527
+ : "";
528
+ const reminder = core.runtime.consumePostBudgetReminder()
529
+ ? `\n\n[TOKEN BUDGET REACHED goalId=${limitedGoal.id}]\nThe goal's lifetime token-spending cap (not context capacity) has been reached${budgetText ? ` (${budgetText}${balanceText})` : ""}. Wrap up the current work in one final response: summarize what was accomplished and what remains, do not start new substantive work, and do not claim the goal is complete unless it actually is. To continue, the user can use ${commandHint("goal-tweak", core.loadedCommands)} to raise or remove the token budget.`
530
+ : "";
531
+ return { state: `[PI GOAL BUDGET LIMITED goalId=${limitedGoal.id}]\n${untrustedObjectiveBlock(limitedGoal)}${budgetText ? `\n${budgetText}` : ""}${reminder}` };
532
+ }
533
+ if (core.state.goal.status === "blocked") {
534
+ const blocked = core.state.goal;
535
+ return { state: `[PI GOAL BLOCKED goalId=${blocked.id}]\n${untrustedObjectiveBlock(blocked)}\nBlocker: ${blocked.pauseReason ?? "unspecified"}\nThe goal is blocked; the user must run ${commandHint("goal-resume", core.loadedCommands)} before goal work continues.` };
536
+ }
537
+ const activeGoal = core.state.goal;
538
+ const settings = loadGoalSettings(ctx.cwd);
539
+ const { state: policy, counters } = goalPromptParts(activeGoal, { ...settings, commands: core.loadedCommands }, ctx.getContextUsage?.());
540
+ // Rare transitional steering rides in the stable block; per-turn usage and
541
+ // scheduling counters stay in the volatile block so retention grows slowly.
542
+ let state = policy;
543
+ // F5: [GOAL STALLED] steering note when the detector fired.
544
+ const stalledNote = core.checkStall(ctx);
545
+ if (stalledNote) state += stalledNote;
546
+ // Inject durable auditor feedback if the latest result was a rejection
547
+ try {
548
+ const ledger = getPromptLedger();
549
+ const auditorResult = latestAuditorResultForGoal(ledger.events, activeGoal.id);
550
+ if (auditorResult && auditorResult.verdict === "disapproved" && ledger.events.some((e) => e.type === "completion_requested" && e.goalId === activeGoal.id)) {
551
+ state = `${state}\n\n[AUDITOR REJECTION goalId=${activeGoal.id}]\nAn independent auditor previously rejected a completion request for this goal. Reason: ${auditorResult.report.slice(0, 300)}\nAddress the auditor's objections before requesting completion again.`;
552
+ }
553
+ } catch {
554
+ // Ledger read failure should not break the prompt
555
+ }
556
+ if (core.runtime.isPostCompactReminderPending() && shouldInjectPostCompactReminder({ pending: true, goal: activeGoal })) {
557
+ core.runtime.clearPostCompactReminder();
558
+ // PR E §62: post-compaction DELTA — the current goal block already
559
+ // carries objective/policy/task gate/contract; inject only what
560
+ // compaction may have lost. Falls back to a generic note on ledger
561
+ // read failure.
562
+ try {
563
+ const ledger = getPromptLedger();
564
+ const otherOpenCount = core.openGoals().filter((g) => g.id !== activeGoal.id).length;
565
+ const delta = buildPostCompactionGoalDelta({ goal: activeGoal, ledgerEvents: ledger.events, otherOpenCount });
566
+ state = `${state}\n\n${delta}`;
567
+ } catch {
568
+ state = `${state}\n\n[POST-COMPACTION RESYNC goalId=${core.state.goal.id}]\nThe conversation was just compacted. Re-read the objective and continue from the actual artifacts/state; do not rely on memory of the prior chat.`;
569
+ }
570
+ }
571
+ return { state, counters };
572
+ }
573
+
574
+ pi.on("agent_end", async (event, ctx) => {
575
+ const endedGoalId = core.runningGoalId;
576
+ core.runningGoalId = null;
577
+ continuationAfterSettleFor = null;
578
+ networkErrorRecoveryAfterSettleFor = null;
579
+ if (core.scheduler.isDenied()) return;
580
+
581
+ // Account for any tokens from aborted in-flight assistant messages so
582
+ // they are not silently lost (but charge them to the original goal).
583
+ const abortedTokens = event.messages
584
+ .filter(isAbortedAssistantMessage)
585
+ .reduce((sum, message) => sum + assistantTurnTokens(message), 0);
586
+ if (abortedTokens > 0 && endedGoalId && core.state.goal?.id === endedGoalId) {
587
+ core.accountProgress(ctx, { completedTurnTokens: abortedTokens });
588
+ }
589
+
590
+ // Keep any prior recovery attempt while Pi finishes its own automatic
591
+ // retries. A user-driven path resets it through the default argument.
592
+ core.runtime.clearContinuationState(false);
593
+ if (!core.state.goal || core.state.goal.status !== "active" || !core.state.goal.autoContinue) return;
594
+ if (endedGoalId && core.state.goal.id !== endedGoalId) return;
595
+ if (!core.reconcileFocusedGoalFromDisk(ctx)) return;
596
+ // A genuine user abort pauses the goal. An assistant message with
597
+ // stopReason "aborted" WITHOUT a user abort signal is a provider- or
598
+ // transport-side termination (e.g. after Pi exhausts its retries) —
599
+ // pausing there stranded goals during outages, so it routes into the
600
+ // same bounded recovery as classified transient errors instead.
601
+ if (core.interruption.isHeld() || core.interruption.ignoreAbort()) return;
602
+ if (ctx.signal?.aborted) {
603
+ if (!core.interruption.ignoreAbort() && !core.interruption.isHeld()) core.pauseActiveGoal(ctx);
604
+ return;
605
+ }
606
+ // Provider failures are not completed work: persist and refresh the
607
+ // display, but never queue a continuation for a run whose messages
608
+ // include an assistant error (danim47c pattern).
609
+ if (hasNetworkErrorAssistantMessage(event.messages) || hasAbortedAssistantMessage(event.messages)) {
610
+ core.persist(ctx);
611
+ core.updateUI(ctx);
612
+ networkErrorRecoveryAfterSettleFor = core.state.goal.id;
613
+ return;
614
+ }
615
+ if (hasErrorAssistantMessage(event.messages)) {
616
+ core.persist(ctx);
617
+ core.updateUI(ctx);
618
+ return;
619
+ }
620
+ core.runtime.clearNetworkErrorBackoff();
621
+ core.persist(ctx);
622
+ core.updateUI(ctx);
623
+ // agent_end runs before pi finishes retries, compaction, terminating-tool
624
+ // settlement, and queued messages. Starting the continuation timer here
625
+ // can poll a stale busy context. agent_settled is the first point where
626
+ // Pi guarantees no automatic work remains.
627
+ continuationAfterSettleFor = core.state.goal.id;
628
+ });
629
+
630
+ pi.on("agent_settled", async (event, ctx) => {
631
+ if (!core.auditMessages.flush(ctx, pi)) return;
632
+ const goalId = continuationAfterSettleFor;
633
+ continuationAfterSettleFor = null;
634
+ const networkErrorGoalId = networkErrorRecoveryAfterSettleFor;
635
+ networkErrorRecoveryAfterSettleFor = null;
636
+ if (core.scheduler.isDenied()) {
637
+ core.runningGoalId = null;
638
+ core.runtime.setCheckpoint(null);
639
+ core.scheduler.settled(ctx, false);
640
+ return;
641
+ }
642
+ const resumedInterruptedTurn = core.interruption.ignoreAbort() && !core.interruption.isHeld();
643
+ // Startup cancellation may leave no assistant message or request signal.
644
+ if (event.aborted) {
645
+ const abortedGoalId = goalId ?? networkErrorGoalId ?? core.runningGoalId;
646
+ core.runningGoalId = null;
647
+ if (abortedGoalId && !core.interruption.ignoreAbort(abortedGoalId) && !core.interruption.isHeld(abortedGoalId) && core.state.goal?.id === abortedGoalId && core.state.goal.status === "active") core.pauseActiveGoal(ctx);
648
+ core.scheduler.settled(ctx, false);
649
+ core.interruption.settled();
650
+ if (resumedInterruptedTurn) core.scheduler.schedule(ctx);
651
+ return;
652
+ }
653
+ core.interruption.settled();
654
+ core.scheduler.settled(ctx, !!goalId && core.isActionableContinuationGoal(goalId));
655
+ if (resumedInterruptedTurn) core.scheduler.schedule(ctx);
656
+ if (goalId && core.isActionableContinuationGoal(goalId)) return;
657
+ if (!networkErrorGoalId || !core.isActionableContinuationGoal(networkErrorGoalId)) return;
658
+ const recovery = loadGoalSettings(ctx.cwd).networkRecovery;
659
+ const policy = recovery
660
+ ? { maxAttempts: recovery.maxAttempts, maxDelayMs: recovery.maxDelayMs }
661
+ : undefined;
662
+ const plan = core.runtime.scheduleNetworkErrorRetry(ctx, core.state.goal!, policy);
663
+ if (plan) {
664
+ const cap = plan.maxAttempts > 0 ? `/${plan.maxAttempts}` : ", unbounded";
665
+ ctx.ui.notify(
666
+ `Provider network error. Retrying the goal in ${Math.round(plan.delayMs / 1000)}s (recovery ${plan.attempt}${cap}).`,
667
+ "warning",
668
+ );
669
+ return;
670
+ }
671
+ // Only reachable under a configured bounded cap (maxAttempts > 0).
672
+ ctx.ui.notify(
673
+ "Provider network errors persisted after all recovery attempts. The goal remains active; resume it when the provider is healthy.",
674
+ "warning",
675
+ );
676
+ });
677
+
678
+ pi.on("session_shutdown", async (_event, ctx) => {
679
+ // A delayed shutdown of a previously observed owner cannot touch its successor.
680
+ if (contexts.has(ctx) && contexts.get(ctx) !== session) return;
681
+ try {if (session && ctx.sessionManager !== session.manager) return;}
682
+ catch (error) {
683
+ if (!isStaleExtensionContextError(error)) throw error;
684
+ if (session && contexts.get(ctx) !== session) {
685
+ // An unseen stale callback cannot retire an owner whose host is still live.
686
+ try {session.ctx.sessionManager; return;}
687
+ catch (ownerError) {if (!isStaleExtensionContextError(ownerError)) throw ownerError;}
688
+ }
689
+ }
690
+ teardown(ctx);
691
+ });
692
+ }