killeros 2.0.2 → 2.0.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.3] - 2026-08-09
8
+
9
+ ### Added
10
+
11
+ - Added an opt-in global completion sound with `/notification` and an enabled-state Nerd Font bell in the terminal tab title.
12
+ - Moved active goal status to the footer's right side in warning yellow, replacing the path with `/goal is active (...)` and exact seconds.
13
+
14
+ ### Changed
15
+
16
+ - Returned compaction timing, summary generation, manual instructions, retries, file tracking, and overflow recovery to Pi's public lifecycle.
17
+ - Continued active goals only after Pi reaches its settled boundary and preserved `/goal` as the sole durable objective and status owner.
18
+
19
+ ### Fixed
20
+
21
+ - Added fail-closed, revision-bound recovery after successful manual compaction, including reload and branch navigation recovery without reviving stale or explicitly paused goals.
22
+ - Rendered question help from the effective selector keybindings across supported Pi versions.
23
+
7
24
  ## [2.0.2] - 2026-08-08
8
25
 
9
26
  ### Changed
package/Killeros.ts CHANGED
@@ -1,27 +1,32 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerAliases, registerSlashAutocomplete } from "./killeros/commands.ts";
3
3
  import { registerConcisePrompt } from "./killeros/concise.ts";
4
- import { registerContextCompaction } from "./killeros/context-compaction.ts";
5
4
  import { registerFooter } from "./killeros/footer.ts";
6
5
  import { registerGoal, registerGoalSettlement } from "./killeros/goals.ts";
7
6
  import { registerLifecycleHooks } from "./killeros/hooks.ts";
8
7
  import { registerInitCommand, registerInitSettlement } from "./killeros/init.ts";
8
+ import {
9
+ registerCompletionNotifications,
10
+ type CompletionNotificationDependencies,
11
+ } from "./killeros/notifications.ts";
9
12
  import { registerPersonalInstructions } from "./killeros/personal-instructions.ts";
10
13
  import { registerQuestionTool } from "./killeros/question.ts";
11
- import { createCompactionRuntime, createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
14
+ import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
12
15
  import { registerShellUi } from "./killeros/shell-ui.ts";
13
16
  import { registerVariants } from "./killeros/variants.ts";
14
17
 
15
18
  export { CONCISE_SYSTEM_PROMPT, isConcisedEnabled } from "./killeros/concise.ts";
16
- export { contextPercentRemaining } from "./killeros/context-compaction.ts";
17
- export { formatCost, formatContextProgress } from "./killeros/footer.ts";
19
+ export { contextPercentRemaining, formatCost, formatContextProgress } from "./killeros/footer.ts";
18
20
  export { executeHook } from "./killeros/hooks.ts";
19
21
  export { INIT_WORKFLOW_PROMPT, writeInitAgentsFile } from "./killeros/init.ts";
20
22
 
21
- export default function Killeros(pi: ExtensionAPI): void {
23
+ export interface KillerosOptions {
24
+ completionNotifications?: CompletionNotificationDependencies;
25
+ }
26
+
27
+ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}): void {
22
28
  const initRuntime = createInitRuntime();
23
29
  const goalRuntime = createGoalRuntime();
24
- const compactionRuntime = createCompactionRuntime();
25
30
  registerShellUi(pi);
26
31
  registerConcisePrompt(pi);
27
32
  registerGoal(pi, goalRuntime, initRuntime);
@@ -33,7 +38,7 @@ export default function Killeros(pi: ExtensionAPI): void {
33
38
  registerVariants(pi);
34
39
  registerInitCommand(pi, initRuntime, goalRuntime);
35
40
  registerLifecycleHooks(pi);
36
- registerContextCompaction(pi, compactionRuntime, goalRuntime);
37
- registerGoalSettlement(pi, goalRuntime, initRuntime, compactionRuntime);
41
+ registerGoalSettlement(pi, goalRuntime, initRuntime);
38
42
  registerInitSettlement(pi, initRuntime);
43
+ registerCompletionNotifications(pi, options.completionNotifications);
39
44
  }
package/README.md CHANGED
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
31
31
  Pin an install to a release:
32
32
 
33
33
  ```bash
34
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.2
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.3
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -43,7 +43,8 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
43
43
  - Animated orange 12-frame activity glyph loop at 120 ms per frame, with orange shuffled Claude-adjacent verbs changing every 2.5 seconds, a gray `(esc to interrupt · thinking)` status with bold `esc`, and a quiet hidden-thinking label
44
44
  - Framed multiline editor with Shift+Enter support and live command-blue highlighting for recognized slash command prefixes
45
45
  - Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
46
- - Automatic model-backed context compaction at 40% remaining; active goals continue after the saved summary
46
+ - Pi-owned context compaction with active goals continuing from Pi's settled boundary after manual, threshold, and overflow compaction
47
+ - Optional completion sounds after successful or failed settled requests, excluding manual aborts
47
48
  - `/variants` selector and direct reasoning-level arguments
48
49
  - Codex-style `/goal` with an interactive status/action panel, durable objectives, pause, resume, edit, confirmed panel clearing, automatic continuation, and explicit completion
49
50
  - Claude Code-style `/init` that scans the repository and generates a concise root `AGENTS.md` without setup questions
@@ -64,6 +65,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
64
65
  /goal clear Remove the current goal
65
66
  /variants Open the reasoning-level selector
66
67
  /variants high Set a reasoning level directly
68
+ /notification Configure the completion sound
67
69
  /clear Start a new session after confirmation
68
70
  /exit Quit Pi gracefully
69
71
  ```
@@ -78,9 +80,13 @@ Supported reasoning levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh
78
80
 
79
81
  KillerOS activates its packaged `killeros` theme when a TUI session starts. Tool-call backgrounds stay neutral across pending, successful, and failed states; restrained text and icons preserve status visibility.
80
82
 
81
- KillerOS displays session costs in USD. The footer uses Pi's human-readable model name when available, keeps the provider visually secondary, and renders context as `percent left (tokens)` without a progress bar. Active, paused, and blocked goals remain visible; completed goals remain in transcript history and `/goal` status rather than the footer. At narrow widths, context pressure and actionable goal state take priority.
83
+ The completion sound is a global user preference stored in Pi's agent directory and is off by default. Run `/notification` in TUI mode to enable or disable it; enabling does not play a preview. Enabled TUI tabs append `󰂚`, which requires a Nerd Font in the terminal tab UI. An unsupported font may show a box without affecting sound. KillerOS uses the terminal's audible bell and cannot produce sound when the terminal disables it.
82
84
 
83
- KillerOS checks context after each agent turn. At 40% remaining, it starts Pi's model-backed compaction after the current run settles, so the active turn is not aborted. Manual `/compact` uses the same model path and keeps custom focus instructions. If model compaction is unavailable or exhausts its retries, KillerOS uses the disclosed deterministic fallback and warns that repeated compaction can reduce accuracy.
85
+ KillerOS displays session costs in USD. The footer uses Pi's human-readable model name when available, keeps the provider visually secondary, and renders context as `percent left (tokens)` without a progress bar. An active goal replaces the right-side path with warning-yellow `/goal is active (...)` and keeps exact seconds in minute and hour formats. Paused and blocked goals retain their existing placement; completed goals remain in transcript history and `/goal` status rather than the footer. At narrow widths, context pressure and actionable goal state take priority.
86
+
87
+ Pi decides when compaction runs and Pi writes the summary, applies manual focus instructions, tracks files, retries summarization, and handles overflow recovery. KillerOS does not add a second threshold or replace Pi's summary. Active `/goal` work continues from Pi's settled boundary, after Pi finishes retries, compaction, and queued work.
88
+
89
+ Manual `/compact` aborts the current goal turn before summarization, so KillerOS records an honest temporary pause for that exact goal revision. After Pi saves the manual summary, KillerOS resumes that revision automatically. A failed or cancelled manual compaction stays paused; run `/goal pause` during the pause to cancel automatic recovery.
84
90
 
85
91
  For trusted projects, KillerOS loads `AGENTS.local.md` after Pi's shared repository context. A one-line `@path` or `@~/path` file imports personal guidance from another location.
86
92
 
@@ -90,9 +96,9 @@ Lifecycle hooks are loaded from `.pi/killeros-hooks.json` at session start. Supp
90
96
 
91
97
  | Mode | Behavior |
92
98
  |---|---|
93
- | TUI | All features are available |
94
- | RPC | Goal set/view/pause/resume/clear and concise prompt guidance work; TUI components, `/goal edit`, and `/init` are disabled |
95
- | Print/JSON | Concise prompt guidance works; interactive questions, `/goal`, and `/init` fail explicitly |
99
+ | TUI | All features are available, including the completion sound and tab-title indicator |
100
+ | RPC | Goal set/view/pause/resume/clear and concise prompt guidance work; TUI components, `/goal edit`, `/init`, completion sounds, and the title indicator are disabled |
101
+ | Print/JSON | Concise prompt guidance works; interactive questions, `/goal`, and `/init` fail explicitly; completion sounds and the title indicator are disabled |
96
102
 
97
103
  ## Validation
98
104
 
@@ -12,6 +12,20 @@ export function formatCost(usd: number): string {
12
12
  return `$${usd.toFixed(2)}`;
13
13
  }
14
14
 
15
+ export function contextPercentRemaining(ctx: ExtensionContext): number | null {
16
+ let usage: ReturnType<ExtensionContext["getContextUsage"]>;
17
+ try {
18
+ usage = ctx.getContextUsage();
19
+ } catch {
20
+ return null;
21
+ }
22
+ if (!usage || !Number.isFinite(usage.contextWindow) || usage.contextWindow <= 0) return null;
23
+ if (usage.tokens === null || !Number.isFinite(usage.tokens)) return null;
24
+
25
+ const percentRemaining = ((usage.contextWindow - Math.max(0, usage.tokens)) / usage.contextWindow) * 100;
26
+ return Math.round(Math.max(0, Math.min(100, percentRemaining)));
27
+ }
28
+
15
29
  export function formatContextProgress(tokensUsed: number | null, contextWindow: number, theme: Theme): string {
16
30
  if (tokensUsed === null || !Number.isFinite(tokensUsed)) return theme.fg("dim", "—% left (—)");
17
31
  const windowSize = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : 128_000;
@@ -111,9 +125,26 @@ function renderFooterRow(left: string, right: string, width: number): string {
111
125
  return ` ${clippedLeft}${gap}${clippedRight} `;
112
126
  }
113
127
 
128
+ function formatGoalElapsed(milliseconds: number): string {
129
+ const totalSeconds = Number.isFinite(milliseconds) ? Math.max(0, Math.floor(milliseconds / 1_000)) : 0;
130
+ if (totalSeconds < 60) return `${totalSeconds}s`;
131
+
132
+ const seconds = totalSeconds % 60;
133
+ const totalMinutes = Math.floor(totalSeconds / 60);
134
+ if (totalMinutes < 60) return `${totalMinutes}m ${seconds.toString().padStart(2, "0")}s`;
135
+
136
+ const hours = Math.floor(totalMinutes / 60);
137
+ const minutes = totalMinutes % 60;
138
+ return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
139
+ }
140
+
141
+ function formatActiveGoalFooter(state: GoalState | undefined, theme: Theme): string {
142
+ if (state?.status !== "active") return "";
143
+ return theme.fg("warning", `/goal is active (${formatGoalElapsed(goalElapsedMilliseconds(state))})`);
144
+ }
145
+
114
146
  function formatGoalFooter(state: GoalState | undefined, theme: Theme): string {
115
147
  if (!state) return "";
116
- if (state.status === "active") return theme.fg("accent", `✻ goal · ${formatTime(goalElapsedMilliseconds(state))}`);
117
148
  if (state.status === "paused") return theme.fg("warning", "Ⅱ goal paused");
118
149
  if (state.status === "blocked") return theme.fg("error", "! goal blocked");
119
150
  return "";
@@ -180,6 +211,7 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
180
211
  const signature = formatModel(model, theme);
181
212
  const fullDirectory = theme.fg("dim", cwd);
182
213
  const focusedDirectory = theme.fg("dim", compactDirectory(cwd));
214
+ const activeGoal = formatActiveGoalFooter(goalRuntime.state, theme);
183
215
  const goal = formatGoalFooter(goalRuntime.state, theme);
184
216
  const rich = joinFooterParts([
185
217
  signature,
@@ -192,6 +224,19 @@ export function registerFooter(pi: ExtensionAPI, goalRuntime: GoalRuntime): void
192
224
  ], theme);
193
225
  const focused = joinFooterParts([signature, context, goal], theme);
194
226
 
227
+ if (activeGoal) {
228
+ if (footerRowFits(rich, activeGoal, width)) {
229
+ return [renderFooterRow(rich, activeGoal, width)];
230
+ }
231
+ if (footerRowFits(focused, activeGoal, width)) {
232
+ return [renderFooterRow(focused, activeGoal, width)];
233
+ }
234
+ if (footerRowFits(context, activeGoal, width)) {
235
+ return [renderFooterRow(context, activeGoal, width)];
236
+ }
237
+ return [renderFooterRow("", activeGoal, width)];
238
+ }
239
+
195
240
  if (footerRowFits(rich, fullDirectory, width)) {
196
241
  return [renderFooterRow(rich, fullDirectory, width)];
197
242
  }
package/killeros/goals.ts CHANGED
@@ -7,7 +7,7 @@ import { CONCISE_SYSTEM_PROMPT } from "./concise.ts";
7
7
  import { formatTime, formatTokens } from "./display.ts";
8
8
  import { reportError } from "./errors.ts";
9
9
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
10
- import type { CompactionRuntime, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
10
+ import type { GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
11
11
 
12
12
  const GOAL_ENTRY_TYPE = "killeros-goal";
13
13
  const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
@@ -21,6 +21,16 @@ interface GoalEntryData {
21
21
  state: GoalState | null;
22
22
  }
23
23
 
24
+ interface GoalTransitionOptions {
25
+ resetBlockedAudit?: boolean;
26
+ resumeAfterManualCompaction?: true;
27
+ }
28
+
29
+ interface RestoredGoalState {
30
+ state?: GoalState;
31
+ recoveryProven: boolean;
32
+ }
33
+
24
34
  const GoalUpdateParams = Type.Object({
25
35
  status: Type.Union([Type.Literal("complete"), Type.Literal("blocked")], {
26
36
  description: "Mark the active goal complete or blocked",
@@ -61,7 +71,9 @@ function parseGoalState(value: unknown): GoalState | undefined {
61
71
  && (!Number.isInteger(candidate.blockedAuditStartTurn) || candidate.blockedAuditStartTurn < 0 || candidate.blockedAuditStartTurn > candidate.turns!)
62
72
  || !finiteNonNegative(candidate.baselineTokens)
63
73
  || candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
64
- || candidate.result !== undefined && typeof candidate.result !== "string") {
74
+ || candidate.result !== undefined && typeof candidate.result !== "string"
75
+ || candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
76
+ || candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
65
77
  return undefined;
66
78
  }
67
79
  return {
@@ -77,6 +89,7 @@ function parseGoalState(value: unknown): GoalState | undefined {
77
89
  blockedAuditStartTurn: candidate.blockedAuditStartTurn ?? 0,
78
90
  baselineTokens: candidate.baselineTokens,
79
91
  result: candidate.result,
92
+ resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
80
93
  };
81
94
  }
82
95
 
@@ -88,21 +101,26 @@ function goalBranchEntries(ctx: ExtensionContext): ReturnType<ExtensionContext["
88
101
  }
89
102
  }
90
103
 
91
- function restoreGoalState(ctx: ExtensionContext): GoalState | undefined {
104
+ function restoreGoalState(ctx: ExtensionContext): RestoredGoalState {
92
105
  const entries = goalBranchEntries(ctx);
93
106
  for (let index = entries.length - 1; index >= 0; index -= 1) {
94
107
  const entry = entries[index];
95
108
  if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
96
109
  const data = entry.data as Partial<GoalEntryData> | undefined;
97
- if (!data || data.version !== GOAL_VERSION) return undefined;
98
- if (data.state === null) return undefined;
110
+ if (!data || data.version !== GOAL_VERSION || data.state === null) {
111
+ return { state: undefined, recoveryProven: false };
112
+ }
99
113
  const restored = parseGoalState(data.state);
100
- if (!restored) return undefined;
101
- return restored.status === "active"
114
+ if (!restored) return { state: undefined, recoveryProven: false };
115
+ const state = restored.status === "active"
102
116
  ? { ...restored, activeStartedAt: Date.now() }
103
117
  : { ...restored, activeStartedAt: undefined };
118
+ const recoveryProven = state.status === "paused"
119
+ && state.resumeAfterManualCompaction === true
120
+ && entries.slice(index + 1).some((candidate) => candidate.type === "compaction");
121
+ return { state, recoveryProven };
104
122
  }
105
- return undefined;
123
+ return { state: undefined, recoveryProven: false };
106
124
  }
107
125
 
108
126
  export function goalElapsedMilliseconds(state: GoalState, now = Date.now()): number {
@@ -152,7 +170,7 @@ function transitionGoal(
152
170
  event: GoalEntryEvent,
153
171
  status: GoalStatus,
154
172
  result?: string,
155
- resetBlockedAudit = false,
173
+ options: GoalTransitionOptions = {},
156
174
  ): GoalState {
157
175
  const current = runtime.state;
158
176
  if (!current) throw new Error("No goal is set");
@@ -164,8 +182,9 @@ function transitionGoal(
164
182
  status,
165
183
  updatedAt: now,
166
184
  activeStartedAt: status === "active" ? now : undefined,
167
- blockedAuditStartTurn: resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
185
+ blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
168
186
  result,
187
+ resumeAfterManualCompaction: options.resumeAfterManualCompaction,
169
188
  };
170
189
  persistGoalState(pi, runtime, event, next);
171
190
  if (status !== "active") runtime.continuationScheduled = false;
@@ -209,7 +228,12 @@ export function pauseGoalAfterFailure(
209
228
  try {
210
229
  transitionGoal(pi, runtime, "error", "paused", reason);
211
230
  } catch {
212
- runtime.state = runtime.state ? { ...stopGoalClock(runtime.state, Date.now()), status: "paused", result: reason } : undefined;
231
+ runtime.state = runtime.state ? {
232
+ ...stopGoalClock(runtime.state, Date.now()),
233
+ status: "paused",
234
+ result: reason,
235
+ resumeAfterManualCompaction: undefined,
236
+ } : undefined;
213
237
  runtime.persistenceRetryNeeded = true;
214
238
  runtime.continuationScheduled = false;
215
239
  runtime.requestRender?.();
@@ -217,6 +241,56 @@ export function pauseGoalAfterFailure(
217
241
  ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
218
242
  }
219
243
 
244
+ function pauseGoalForPossibleManualCompaction(
245
+ pi: ExtensionAPI,
246
+ runtime: GoalRuntime,
247
+ ctx: ExtensionContext,
248
+ reason: string,
249
+ ): void {
250
+ if (runtime.state?.status !== "active") return;
251
+ try {
252
+ transitionGoal(pi, runtime, "error", "paused", reason, {
253
+ resumeAfterManualCompaction: true,
254
+ });
255
+ } catch {
256
+ runtime.state = runtime.state ? {
257
+ ...stopGoalClock(runtime.state, Date.now()),
258
+ status: "paused",
259
+ result: reason,
260
+ resumeAfterManualCompaction: true,
261
+ } : undefined;
262
+ runtime.persistenceRetryNeeded = true;
263
+ runtime.continuationScheduled = false;
264
+ runtime.requestRender?.();
265
+ }
266
+ ctx.ui.notify(
267
+ "Goal paused because the turn was aborted. If /compact is running, KillerOS will resume after Pi saves the summary. Run /goal pause to keep it paused.",
268
+ "warning",
269
+ );
270
+ }
271
+
272
+ function recoverGoalAfterManualCompaction(
273
+ pi: ExtensionAPI,
274
+ runtime: GoalRuntime,
275
+ initState: InitRuntime,
276
+ ctx: ExtensionContext,
277
+ ): boolean {
278
+ if (runtime.state?.status !== "paused"
279
+ || runtime.state.resumeAfterManualCompaction !== true
280
+ || initState.active) return false;
281
+ try {
282
+ transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
283
+ } catch (error) {
284
+ runtime.persistenceRetryNeeded = true;
285
+ reportError(ctx, "Manual compaction succeeded, but the goal could not be resumed", error);
286
+ return false;
287
+ }
288
+ runtime.continuationScheduled = false;
289
+ ctx.ui.notify("Manual compaction complete. Goal resumed.", "info");
290
+ setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
291
+ return true;
292
+ }
293
+
220
294
  function scheduleGoalContinuation(
221
295
  pi: ExtensionAPI,
222
296
  runtime: GoalRuntime,
@@ -259,6 +333,8 @@ function goalInstructions(state: GoalState, heading: string): string {
259
333
  "Objective:",
260
334
  state.objective,
261
335
  "",
336
+ "Treat the exact objective above from /goal as authoritative; a compaction summary may describe it but does not replace it.",
337
+ "If the current context contains a compaction summary, take its first concrete next step after checking the current repository state.",
262
338
  "Continue making concrete progress toward this unchanged objective. Re-check repository state and prior results instead of repeating work.",
263
339
  "Do not stop merely because one response is complete: KillerOS will start another goal turn while the goal remains active.",
264
340
  "Before declaring completion, audit every part of the objective and verify the relevant results. Then call killeros_goal_update with status complete and concise evidence.",
@@ -354,37 +430,26 @@ export function registerGoal(
354
430
  },
355
431
  });
356
432
 
357
- pi.on("session_start", (_event, ctx) => {
358
- runtime.state = restoreGoalState(ctx);
433
+ const restoreGoal = (ctx: ExtensionContext): void => {
434
+ const restored = restoreGoalState(ctx);
435
+ runtime.state = restored.state;
359
436
  runtime.continuationScheduled = false;
360
437
  runtime.continuationHeld = false;
361
- runtime.continuationHeldForCompaction = false;
362
438
  runtime.goalTurnInFlight = false;
363
439
  runtime.agentEndObserved = false;
364
440
  runtime.persistenceRetryNeeded = false;
365
441
  runtime.lastStopReason = undefined;
366
442
  runtime.lastError = undefined;
367
443
  runtime.requestRender?.();
368
- if (runtime.state?.status === "active") {
444
+ if (restored.recoveryProven) {
445
+ recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
446
+ } else if (runtime.state?.status === "active") {
369
447
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
370
448
  }
371
- });
449
+ };
372
450
 
373
- pi.on("session_tree", (_event, ctx) => {
374
- runtime.state = restoreGoalState(ctx);
375
- runtime.continuationScheduled = false;
376
- runtime.continuationHeld = false;
377
- runtime.continuationHeldForCompaction = false;
378
- runtime.goalTurnInFlight = false;
379
- runtime.agentEndObserved = false;
380
- runtime.persistenceRetryNeeded = false;
381
- runtime.lastStopReason = undefined;
382
- runtime.lastError = undefined;
383
- runtime.requestRender?.();
384
- if (runtime.state?.status === "active") {
385
- setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
386
- }
387
- });
451
+ pi.on("session_start", (_event, ctx) => restoreGoal(ctx));
452
+ pi.on("session_tree", (_event, ctx) => restoreGoal(ctx));
388
453
 
389
454
  pi.on("session_shutdown", (_event, ctx) => {
390
455
  if (runtime.state?.status === "active") {
@@ -403,7 +468,6 @@ export function registerGoal(
403
468
  runtime.state = undefined;
404
469
  runtime.continuationScheduled = false;
405
470
  runtime.continuationHeld = false;
406
- runtime.continuationHeldForCompaction = false;
407
471
  runtime.goalTurnInFlight = false;
408
472
  runtime.agentEndObserved = false;
409
473
  runtime.persistenceRetryNeeded = false;
@@ -423,6 +487,7 @@ export function registerGoal(
423
487
  turns: current.turns + 1,
424
488
  updatedAt: now,
425
489
  activeStartedAt: current.activeStartedAt ?? now,
490
+ resumeAfterManualCompaction: undefined,
426
491
  };
427
492
  try {
428
493
  persistGoalState(pi, runtime, "turn", next);
@@ -507,7 +572,7 @@ export function registerGoal(
507
572
  return;
508
573
  }
509
574
  if (runtime.state.status === "paused") {
510
- if (!runtime.persistenceRetryNeeded) {
575
+ if (!runtime.persistenceRetryNeeded && runtime.state.resumeAfterManualCompaction !== true) {
511
576
  ctx.ui.notify("Goal is already paused", "info");
512
577
  return;
513
578
  }
@@ -516,11 +581,16 @@ export function registerGoal(
516
581
  ...runtime.state,
517
582
  revision: runtime.state.revision + 1,
518
583
  updatedAt: now,
584
+ resumeAfterManualCompaction: undefined,
519
585
  };
520
586
  try {
521
587
  persistGoalState(pi, runtime, "pause", checkpoint);
522
- ctx.ui.notify("Goal pause saved", "info");
588
+ ctx.ui.notify("Goal pause saved. Goal remains paused. Automatic compaction recovery is off.", "info");
523
589
  } catch (error) {
590
+ runtime.state = checkpoint;
591
+ runtime.persistenceRetryNeeded = true;
592
+ runtime.continuationScheduled = false;
593
+ runtime.requestRender?.();
524
594
  reportError(ctx, "Goal pause still could not be saved", error);
525
595
  }
526
596
  return;
@@ -562,7 +632,7 @@ export function registerGoal(
562
632
  return;
563
633
  }
564
634
  try {
565
- transitionGoal(pi, runtime, "resume", "active", undefined, true);
635
+ transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
566
636
  runtime.continuationScheduled = false;
567
637
  if (scheduleGoalContinuation(pi, runtime, initState, ctx)) ctx.ui.notify("Goal resumed", "info");
568
638
  } catch (error) {
@@ -620,6 +690,7 @@ export function registerGoal(
620
690
  activeStartedAt: now,
621
691
  blockedAuditStartTurn: current.turns,
622
692
  result: undefined,
693
+ resumeAfterManualCompaction: undefined,
623
694
  };
624
695
  try {
625
696
  persistGoalState(pi, runtime, "edit", next);
@@ -720,7 +791,6 @@ export function registerGoalSettlement(
720
791
  pi: ExtensionAPI,
721
792
  runtime: GoalRuntime,
722
793
  initState: InitRuntime,
723
- compactionRuntime?: CompactionRuntime,
724
794
  ): void {
725
795
  pi.on("agent_settled", (_event, ctx) => {
726
796
  const wasGoalTurn = runtime.goalTurnInFlight;
@@ -739,17 +809,18 @@ export function registerGoalSettlement(
739
809
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
740
810
  return;
741
811
  }
742
- if (runtime.lastStopReason === "error" || runtime.lastStopReason === "aborted") {
743
- const reason = runtime.lastError || (runtime.lastStopReason === "aborted" ? "the agent turn was aborted" : "the agent turn failed");
812
+ if (runtime.lastStopReason === "aborted") {
813
+ const reason = runtime.lastError || "the agent turn was aborted";
744
814
  runtime.lastStopReason = undefined;
745
815
  runtime.lastError = undefined;
746
- pauseGoalAfterFailure(pi, runtime, ctx, reason);
816
+ pauseGoalForPossibleManualCompaction(pi, runtime, ctx, reason);
747
817
  return;
748
818
  }
749
- if (compactionRuntime?.compactionInFlight) {
750
- runtime.continuationHeld = true;
751
- runtime.continuationHeldForCompaction = true;
752
- runtime.requestRender?.();
819
+ if (runtime.lastStopReason === "error") {
820
+ const reason = runtime.lastError || "the agent turn failed";
821
+ runtime.lastStopReason = undefined;
822
+ runtime.lastError = undefined;
823
+ pauseGoalAfterFailure(pi, runtime, ctx, reason);
753
824
  return;
754
825
  }
755
826
  runtime.lastStopReason = undefined;
@@ -757,16 +828,8 @@ export function registerGoalSettlement(
757
828
  scheduleGoalContinuation(pi, runtime, initState, ctx);
758
829
  });
759
830
 
760
- if (compactionRuntime) {
761
- pi.on("session_compact", (_event, ctx) => {
762
- if (!runtime.continuationHeldForCompaction) return;
763
- runtime.continuationHeldForCompaction = false;
764
- if (!runtime.continuationHeld || runtime.state?.status !== "active" || initState.active) {
765
- runtime.continuationHeld = false;
766
- return;
767
- }
768
- runtime.continuationHeld = false;
769
- setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
770
- });
771
- }
831
+ pi.on("session_compact", (event, ctx) => {
832
+ if (event.reason !== "manual") return;
833
+ recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
834
+ });
772
835
  }