killeros 2.0.17 → 2.0.19

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/killeros/goals.ts CHANGED
@@ -7,9 +7,9 @@ import { Text } from "@earendil-works/pi-tui";
7
7
  import { Type } from "typebox";
8
8
  import { BoundedText } from "./bounded-text.ts";
9
9
  import { formatTime, formatTokens } from "./display.ts";
10
- import { reportError } from "./errors.ts";
10
+ import { hasErrorCode, reportError } from "./errors.ts";
11
11
  import { resolvePersonalInstructions } from "./personal-instructions.ts";
12
- import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
12
+ import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalRuntime, GoalState, GoalStateCommon, GoalStatus, InitRuntime } from "./runtime.ts";
13
13
  import { safeTerminalText } from "./safe-terminal-text.ts";
14
14
 
15
15
  const GOAL_ENTRY_TYPE = "killeros-goal";
@@ -69,26 +69,30 @@ function finiteNonNegative(value: unknown): value is number {
69
69
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
70
70
  }
71
71
 
72
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
72
76
  function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
73
- if (!value || typeof value !== "object") return false;
74
- const candidate = value as { exists?: unknown; size?: unknown; mtimeMs?: unknown; contentHash?: unknown };
75
- if (candidate.exists === false) return candidate.size === undefined && candidate.mtimeMs === undefined && candidate.contentHash === undefined;
76
- return candidate.exists === true
77
- && finiteNonNegative(candidate.size)
78
- && finiteNonNegative(candidate.mtimeMs)
79
- && (candidate.contentHash === undefined
80
- || candidate.contentHash === null
81
- || typeof candidate.contentHash === "string" && /^[a-f0-9]{64}$/u.test(candidate.contentHash));
77
+ if (!isUnknownRecord(value)) return false;
78
+ if (value.exists === false) {
79
+ return value.size === undefined && value.mtimeMs === undefined && value.contentHash === undefined;
80
+ }
81
+ return value.exists === true
82
+ && finiteNonNegative(value.size)
83
+ && finiteNonNegative(value.mtimeMs)
84
+ && (value.contentHash === undefined
85
+ || value.contentHash === null
86
+ || typeof value.contentHash === "string" && /^[a-f0-9]{64}$/u.test(value.contentHash));
82
87
  }
83
88
 
84
89
  function isGoalFileVerification(value: unknown): value is GoalFileVerification {
85
- if (!value || typeof value !== "object") return false;
86
- const candidate = value as Partial<GoalFileVerification>;
87
- return candidate.kind === "file"
88
- && typeof candidate.path === "string"
89
- && candidate.path === candidate.path.trim()
90
- && isAbsoluteFilePath(candidate.path)
91
- && isGoalFileBaseline(candidate.baseline);
90
+ return isUnknownRecord(value)
91
+ && value.kind === "file"
92
+ && typeof value.path === "string"
93
+ && value.path === value.path.trim()
94
+ && isAbsoluteFilePath(value.path)
95
+ && isGoalFileBaseline(value.baseline);
92
96
  }
93
97
 
94
98
  function isAbsoluteFilePath(value: string): boolean {
@@ -118,8 +122,9 @@ function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
118
122
  let artifact: ReturnType<typeof lstatSync>;
119
123
  try {
120
124
  artifact = lstatSync(filePath);
121
- } catch {
122
- return { exists: false };
125
+ } catch (error) {
126
+ if (hasErrorCode(error, "ENOENT")) return { exists: false };
127
+ throw error;
123
128
  }
124
129
  const baseline = { exists: true as const, size: artifact.size, mtimeMs: artifact.mtimeMs };
125
130
  if (!artifact.isFile()) return baseline;
@@ -174,56 +179,98 @@ function verifyGoalDeliverable(verification: GoalFileVerification): void {
174
179
  }
175
180
 
176
181
  function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
177
- if (!value || typeof value !== "object") return false;
178
- const candidate = value as Partial<GoalBlockerAudit>;
179
- if (typeof candidate.key !== "string"
180
- || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(candidate.key)
181
- || !Number.isInteger(candidate.streak) || (candidate.streak ?? 0) < 1 || (candidate.streak ?? 0) > 3
182
- || !Number.isInteger(candidate.lastTurn) || (candidate.lastTurn ?? 0) < 1 || (candidate.lastTurn ?? 0) > turns) return false;
182
+ if (!isUnknownRecord(value)
183
+ || typeof value.key !== "string"
184
+ || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
185
+ || typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
186
+ || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns) {
187
+ return false;
188
+ }
183
189
  if (status === "complete") return false;
184
- return status === "blocked" ? candidate.streak === 3 : candidate.streak! < 3;
190
+ return status === "blocked" ? value.streak === 3 : value.streak < 3;
185
191
  }
186
192
 
187
193
  function parseGoalState(value: unknown): GoalState | undefined {
188
- if (!value || typeof value !== "object") return undefined;
189
- const candidate = value as Partial<GoalState>;
190
- if (candidate.version !== GOAL_VERSION
191
- || !Number.isInteger(candidate.revision) || (candidate.revision ?? 0) < 1
192
- || typeof candidate.objective !== "string" || !candidate.objective.trim()
193
- || [...candidate.objective].length > GOAL_OBJECTIVE_LIMIT
194
- || !isGoalStatus(candidate.status)
195
- || !finiteNonNegative(candidate.createdAt)
196
- || !finiteNonNegative(candidate.updatedAt)
197
- || !finiteNonNegative(candidate.activeMilliseconds)
198
- || !Number.isInteger(candidate.turns) || (candidate.turns ?? -1) < 0
199
- || candidate.blockedAuditStartTurn !== undefined
200
- && (!Number.isInteger(candidate.blockedAuditStartTurn) || candidate.blockedAuditStartTurn < 0 || candidate.blockedAuditStartTurn > candidate.turns!)
201
- || !finiteNonNegative(candidate.baselineTokens)
202
- || candidate.activeStartedAt !== undefined && !finiteNonNegative(candidate.activeStartedAt)
203
- || candidate.result !== undefined && typeof candidate.result !== "string"
204
- || candidate.verification !== undefined && !isGoalFileVerification(candidate.verification)
205
- || candidate.blockerAudit !== undefined && !isGoalBlockerAudit(candidate.blockerAudit, candidate.turns!, candidate.status)
206
- || candidate.resumeAfterManualCompaction !== undefined && candidate.resumeAfterManualCompaction !== true
207
- || candidate.resumeAfterManualCompaction === true && candidate.status !== "paused") {
194
+ if (!isUnknownRecord(value)) return undefined;
195
+ const {
196
+ version,
197
+ revision,
198
+ objective,
199
+ status,
200
+ createdAt,
201
+ updatedAt,
202
+ activeMilliseconds,
203
+ activeStartedAt,
204
+ turns,
205
+ blockedAuditStartTurn,
206
+ baselineTokens,
207
+ result,
208
+ resumeAfterManualCompaction,
209
+ blockerAudit,
210
+ verification,
211
+ } = value;
212
+ if (version !== GOAL_VERSION
213
+ || typeof revision !== "number" || !Number.isInteger(revision) || revision < 1
214
+ || typeof objective !== "string" || !objective.trim() || [...objective].length > GOAL_OBJECTIVE_LIMIT
215
+ || !isGoalStatus(status)
216
+ || !finiteNonNegative(createdAt)
217
+ || !finiteNonNegative(updatedAt)
218
+ || !finiteNonNegative(activeMilliseconds)
219
+ || typeof turns !== "number" || !Number.isInteger(turns) || turns < 0
220
+ || blockedAuditStartTurn !== undefined
221
+ && (typeof blockedAuditStartTurn !== "number" || !Number.isInteger(blockedAuditStartTurn)
222
+ || blockedAuditStartTurn < 0 || blockedAuditStartTurn > turns)
223
+ || !finiteNonNegative(baselineTokens)
224
+ || result !== undefined && typeof result !== "string"
225
+ || verification !== undefined && !isGoalFileVerification(verification)
226
+ || resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
227
+ || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)) {
208
228
  return undefined;
209
229
  }
210
- return {
230
+
231
+ const common: GoalStateCommon = {
211
232
  version: GOAL_VERSION,
212
- revision: candidate.revision!,
213
- objective: candidate.objective.trim(),
214
- status: candidate.status,
215
- createdAt: candidate.createdAt,
216
- updatedAt: candidate.updatedAt,
217
- activeMilliseconds: candidate.activeMilliseconds,
218
- activeStartedAt: candidate.activeStartedAt,
219
- turns: candidate.turns!,
220
- blockedAuditStartTurn: candidate.blockedAuditStartTurn ?? 0,
221
- baselineTokens: candidate.baselineTokens,
222
- result: candidate.result,
223
- resumeAfterManualCompaction: candidate.resumeAfterManualCompaction,
224
- blockerAudit: candidate.blockerAudit,
225
- verification: candidate.verification,
233
+ revision,
234
+ objective: objective.trim(),
235
+ createdAt,
236
+ updatedAt,
237
+ activeMilliseconds,
238
+ turns,
239
+ blockedAuditStartTurn: blockedAuditStartTurn ?? 0,
240
+ baselineTokens,
241
+ ...(verification === undefined ? {} : { verification }),
226
242
  };
243
+ switch (status) {
244
+ case "active":
245
+ if (!finiteNonNegative(activeStartedAt) || resumeAfterManualCompaction !== undefined) return undefined;
246
+ return {
247
+ ...common,
248
+ status,
249
+ activeStartedAt,
250
+ ...(result === undefined ? {} : { result }),
251
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
252
+ };
253
+ case "paused":
254
+ if (activeStartedAt !== undefined) return undefined;
255
+ return {
256
+ ...common,
257
+ status,
258
+ ...(result === undefined ? {} : { result }),
259
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
260
+ ...(resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction }),
261
+ };
262
+ case "blocked":
263
+ if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined || typeof result !== "string") return undefined;
264
+ return { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
265
+ case "complete":
266
+ if (activeStartedAt !== undefined || resumeAfterManualCompaction !== undefined
267
+ || typeof result !== "string" || blockerAudit !== undefined) return undefined;
268
+ return { ...common, status, result };
269
+ default: {
270
+ const exhaustive: never = status;
271
+ return exhaustive;
272
+ }
273
+ }
227
274
  }
228
275
 
229
276
  function goalBranchEntries(ctx: ExtensionContext): ReturnType<ExtensionContext["sessionManager"]["getEntries"]> {
@@ -239,37 +286,60 @@ function restoreGoalState(ctx: ExtensionContext): RestoredGoalState {
239
286
  for (let index = entries.length - 1; index >= 0; index -= 1) {
240
287
  const entry = entries[index];
241
288
  if (entry?.type !== "custom" || entry.customType !== GOAL_ENTRY_TYPE) continue;
242
- const data = entry.data as Partial<GoalEntryData> | undefined;
243
- if (!data || data.version !== GOAL_VERSION || data.state === null) {
289
+ const data: unknown = entry.data;
290
+ if (!isUnknownRecord(data) || data.version !== GOAL_VERSION || data.state === null) {
244
291
  return { state: undefined };
245
292
  }
246
- const restored = parseGoalState(data.state);
293
+ // v2.0.18 shutdown checkpoints stopped active clocks by omitting activeStartedAt.
294
+ const savedState = data.event === "checkpoint"
295
+ && isUnknownRecord(data.state)
296
+ && data.state.status === "active"
297
+ && data.state.activeStartedAt === undefined
298
+ ? { ...data.state, activeStartedAt: Date.now() }
299
+ : data.state;
300
+ const restored = parseGoalState(savedState);
247
301
  if (!restored) return { state: undefined };
248
- const state = restored.status === "active"
249
- ? { ...restored, activeStartedAt: Date.now() }
250
- : { ...restored, activeStartedAt: undefined };
251
- if (state.status === "paused") state.resumeAfterManualCompaction = undefined;
252
- return { state };
302
+ if (restored.status === "active") {
303
+ return { state: { ...restored, activeStartedAt: Date.now() } };
304
+ }
305
+ if (restored.status === "paused") {
306
+ const { resumeAfterManualCompaction: _resume, ...state } = restored;
307
+ return { state };
308
+ }
309
+ return { state: restored };
253
310
  }
254
311
  return { state: undefined };
255
312
  }
256
313
 
257
314
  export function goalElapsedMilliseconds(state: GoalState, now = Date.now()): number {
258
- const activeInterval = state.status === "active" && state.activeStartedAt !== undefined
315
+ const activeInterval = state.status === "active"
259
316
  ? Math.max(0, now - state.activeStartedAt)
260
317
  : 0;
261
318
  return state.activeMilliseconds + activeInterval;
262
319
  }
263
320
 
264
- function stopGoalClock(state: GoalState, now: number): GoalState {
265
- if (state.status !== "active" || state.activeStartedAt === undefined) return state;
321
+ function commonGoalState(state: GoalState): GoalStateCommon {
266
322
  return {
267
- ...state,
268
- activeMilliseconds: state.activeMilliseconds + Math.max(0, now - state.activeStartedAt),
269
- activeStartedAt: undefined,
323
+ version: state.version,
324
+ revision: state.revision,
325
+ objective: state.objective,
326
+ createdAt: state.createdAt,
327
+ updatedAt: state.updatedAt,
328
+ activeMilliseconds: state.activeMilliseconds,
329
+ turns: state.turns,
330
+ blockedAuditStartTurn: state.blockedAuditStartTurn,
331
+ baselineTokens: state.baselineTokens,
332
+ ...(state.verification === undefined ? {} : { verification: state.verification }),
270
333
  };
271
334
  }
272
335
 
336
+ function stopGoalClock(state: GoalState, now: number): GoalStateCommon {
337
+ const common = commonGoalState(state);
338
+ return state.status === "active"
339
+ ? { ...common, activeMilliseconds: common.activeMilliseconds + Math.max(0, now - state.activeStartedAt) }
340
+ : common;
341
+ }
342
+
273
343
  function sumGoalTokens(ctx: ExtensionContext): number {
274
344
  let total = 0;
275
345
  for (const entry of goalBranchEntries(ctx)) {
@@ -302,6 +372,7 @@ function persistGoalState(
302
372
  state: GoalState | undefined,
303
373
  ): void {
304
374
  const data: GoalEntryData = { version: GOAL_VERSION, event, state: state ?? null };
375
+ runtime.automaticCompaction = undefined;
305
376
  pi.appendEntry(GOAL_ENTRY_TYPE, data);
306
377
  runtime.state = state;
307
378
  syncGoalUpdateTool(pi, runtime);
@@ -321,17 +392,40 @@ function transitionGoal(
321
392
  if (!current) throw new Error("No goal is set");
322
393
  const now = Date.now();
323
394
  const stopped = stopGoalClock(current, now);
324
- const next: GoalState = {
395
+ const common: GoalStateCommon = {
325
396
  ...stopped,
326
397
  revision: stopped.revision + 1,
327
- status,
328
398
  updatedAt: now,
329
- activeStartedAt: status === "active" ? now : undefined,
330
399
  blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
331
- blockerAudit: options.resetBlockedAudit ? undefined : options.blockerAudit ?? stopped.blockerAudit,
332
- result,
333
- resumeAfterManualCompaction: options.resumeAfterManualCompaction,
334
400
  };
401
+ const blockerAudit = options.resetBlockedAudit ? undefined : options.blockerAudit ?? current.blockerAudit;
402
+ let next: GoalState;
403
+ switch (status) {
404
+ case "active":
405
+ next = { ...common, status, activeStartedAt: now, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
406
+ break;
407
+ case "paused":
408
+ next = {
409
+ ...common,
410
+ status,
411
+ ...(result === undefined ? {} : { result }),
412
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
413
+ ...(options.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
414
+ };
415
+ break;
416
+ case "blocked":
417
+ if (result === undefined) throw new Error("A blocked goal requires a result");
418
+ next = { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
419
+ break;
420
+ case "complete":
421
+ if (result === undefined) throw new Error("A complete goal requires a result");
422
+ next = { ...common, status, result };
423
+ break;
424
+ default: {
425
+ const exhaustive: never = status;
426
+ return exhaustive;
427
+ }
428
+ }
335
429
  persistGoalState(pi, runtime, event, next);
336
430
  if (status !== "active") {
337
431
  runtime.continuationScheduled = false;
@@ -382,7 +476,7 @@ function goalStatusSummary(state: GoalState, ctx: ExtensionContext): string {
382
476
  state.objective,
383
477
  ];
384
478
  if (state.result) lines.push(state.result);
385
- return lines.join("\n");
479
+ return safeTerminalText(lines.join("\n"));
386
480
  }
387
481
 
388
482
  export function pauseGoalAfterFailure(
@@ -394,14 +488,16 @@ export function pauseGoalAfterFailure(
394
488
  notify = true,
395
489
  ): void {
396
490
  if (runtime.state?.status !== "active") return;
491
+ const safeReason = safeTerminalText(reason);
397
492
  try {
398
- transitionGoal(pi, runtime, "error", "paused", reason);
493
+ transitionGoal(pi, runtime, "error", "paused", safeReason);
399
494
  } catch {
400
- runtime.state = runtime.state ? {
401
- ...stopGoalClock(runtime.state, Date.now()),
495
+ const current = runtime.state;
496
+ runtime.state = current ? {
497
+ ...stopGoalClock(current, Date.now()),
402
498
  status: "paused",
403
- result: reason,
404
- resumeAfterManualCompaction: undefined,
499
+ result: safeReason,
500
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
405
501
  } : undefined;
406
502
  syncGoalUpdateTool(pi, runtime);
407
503
  runtime.persistenceRetryNeeded = true;
@@ -409,7 +505,7 @@ export function pauseGoalAfterFailure(
409
505
  runtime.automaticCompaction = undefined;
410
506
  runtime.requestRender?.();
411
507
  }
412
- if (notify) ctx.ui.notify(`Goal paused: ${reason}\n${recoveryInstruction}`, "error");
508
+ if (notify) ctx.ui.notify(`Goal paused: ${safeReason}\n${recoveryInstruction}`, "error");
413
509
  }
414
510
 
415
511
  function pauseGoalForPossibleManualCompaction(
@@ -419,16 +515,19 @@ function pauseGoalForPossibleManualCompaction(
419
515
  reason: string,
420
516
  ): void {
421
517
  if (runtime.state?.status !== "active") return;
518
+ const safeReason = safeTerminalText(reason);
422
519
  try {
423
- transitionGoal(pi, runtime, "error", "paused", reason, {
520
+ transitionGoal(pi, runtime, "error", "paused", safeReason, {
424
521
  resumeAfterManualCompaction: true,
425
522
  });
426
523
  } catch {
427
- runtime.state = runtime.state ? {
428
- ...stopGoalClock(runtime.state, Date.now()),
524
+ const current = runtime.state;
525
+ runtime.state = current ? {
526
+ ...stopGoalClock(current, Date.now()),
429
527
  status: "paused",
430
- result: reason,
528
+ result: safeReason,
431
529
  resumeAfterManualCompaction: true,
530
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
432
531
  } : undefined;
433
532
  syncGoalUpdateTool(pi, runtime);
434
533
  runtime.persistenceRetryNeeded = true;
@@ -469,7 +568,7 @@ function beginGoalTurn(
469
568
  pi: ExtensionAPI,
470
569
  runtime: GoalRuntime,
471
570
  ctx: ExtensionContext,
472
- current: GoalState,
571
+ current: Extract<GoalState, { status: "active" }>,
473
572
  ): GoalState | undefined {
474
573
  const now = Date.now();
475
574
  const next: GoalState = {
@@ -477,8 +576,6 @@ function beginGoalTurn(
477
576
  revision: current.revision + 1,
478
577
  turns: current.turns + 1,
479
578
  updatedAt: now,
480
- activeStartedAt: current.activeStartedAt ?? now,
481
- resumeAfterManualCompaction: undefined,
482
579
  };
483
580
  try {
484
581
  persistGoalState(pi, runtime, "turn", next);
@@ -527,38 +624,81 @@ function scheduleGoalContinuation(
527
624
  }
528
625
  }
529
626
 
530
- function completeAutomaticCompaction(
627
+ /** Resumes the revision paused by automatic compaction after both host callbacks settle. */
628
+ function finalizeAutomaticCompaction(
531
629
  pi: ExtensionAPI,
532
630
  runtime: GoalRuntime,
533
631
  initState: InitRuntime,
534
632
  ctx: ExtensionContext,
535
633
  ): void {
536
- if (runtime.automaticCompaction === undefined) return;
537
- if (runtime.state?.status !== "active" || initState.active) {
538
- runtime.automaticCompaction = undefined;
634
+ const recovery = runtime.automaticCompaction;
635
+ if (!recovery?.compactionSucceeded || !recovery.turnSettled) return;
636
+ runtime.automaticCompaction = undefined;
637
+ if (runtime.state?.status !== "paused"
638
+ || runtime.state.revision !== recovery.pausedRevision
639
+ || initState.active) return;
640
+ try {
641
+ transitionGoal(pi, runtime, "resume", "active", undefined, { resetBlockedAudit: true });
642
+ } catch (error) {
643
+ runtime.persistenceRetryNeeded = true;
644
+ reportError(ctx, "Automatic compaction succeeded, but the goal could not be resumed", error);
539
645
  return;
540
646
  }
541
- runtime.automaticCompaction = undefined;
647
+ runtime.continuationScheduled = false;
542
648
  setImmediate(() => scheduleGoalContinuation(pi, runtime, initState, ctx));
543
649
  }
544
650
 
651
+ /** Records Pi's successful compaction callback and attempts guarded recovery. */
652
+ function completeAutomaticCompaction(
653
+ pi: ExtensionAPI,
654
+ runtime: GoalRuntime,
655
+ initState: InitRuntime,
656
+ ctx: ExtensionContext,
657
+ ): void {
658
+ if (!runtime.automaticCompaction) return;
659
+ runtime.automaticCompaction.compactionSucceeded = true;
660
+ finalizeAutomaticCompaction(pi, runtime, initState, ctx);
661
+ }
662
+
663
+ /** Consumes automatic recovery and records its failure on the eligible paused goal. */
664
+ function stopAutomaticCompactionRecovery(
665
+ pi: ExtensionAPI,
666
+ runtime: GoalRuntime,
667
+ ctx: ExtensionContext,
668
+ reason: string,
669
+ ): void {
670
+ const recovery = runtime.automaticCompaction;
671
+ runtime.automaticCompaction = undefined;
672
+ const safeReason = safeTerminalText(reason);
673
+ if (runtime.state?.status !== "paused" || runtime.state.revision !== recovery?.pausedRevision) return;
674
+ try {
675
+ transitionGoal(pi, runtime, "error", "paused", safeReason);
676
+ } catch {
677
+ runtime.state = { ...runtime.state, result: safeReason };
678
+ runtime.persistenceRetryNeeded = true;
679
+ runtime.requestRender?.();
680
+ }
681
+ ctx.ui.notify(
682
+ `Goal paused: ${safeReason}\nAutomatic continuation is stopped. Run /goal resume after resolving the compaction problem.`,
683
+ "error",
684
+ );
685
+ }
686
+
687
+ /** Leaves the goal paused when Pi rejects automatic compaction. */
545
688
  function failAutomaticCompaction(
546
689
  pi: ExtensionAPI,
547
690
  runtime: GoalRuntime,
548
691
  ctx: ExtensionContext,
549
692
  error: unknown,
550
693
  ): void {
551
- if (runtime.automaticCompaction === undefined) return;
552
- runtime.automaticCompaction = undefined;
553
- if (runtime.state?.status !== "active") return;
554
694
  const reason = error instanceof Error ? error.message : String(error);
555
- pauseGoalAfterFailure(
556
- pi,
557
- runtime,
558
- ctx,
559
- `automatic compaction failed: ${reason}`,
560
- "Automatic continuation is stopped. Run /goal resume after resolving the compaction problem.",
561
- );
695
+ if (!runtime.automaticCompaction) {
696
+ if (runtime.persistenceRetryNeeded) {
697
+ ctx.ui.notify(`Automatic compaction did not start: ${safeTerminalText(reason)}`, "error");
698
+ }
699
+ return;
700
+ }
701
+ stopAutomaticCompactionRecovery(pi, runtime, ctx, `automatic compaction failed: ${reason}`);
562
702
  }
563
703
 
564
704
  function goalInstructions(state: GoalState, heading: string): string {
@@ -587,7 +727,7 @@ function goalContinuationMessage(state: GoalState, ctx: ExtensionContext): strin
587
727
  if (ctx.isProjectTrusted()) {
588
728
  const personal = resolvePersonalInstructions(ctx.cwd);
589
729
  if (personal) {
590
- sections.push(`<personal_instructions source=${JSON.stringify(personal.source)}>\n${personal.content}\n</personal_instructions>`);
730
+ sections.push(personal);
591
731
  }
592
732
  }
593
733
  return sections.join("\n\n");
@@ -729,7 +869,11 @@ export function registerGoal(
729
869
  const checkpoint: GoalState = {
730
870
  ...stopGoalClock(runtime.state, now),
731
871
  revision: runtime.state.revision + 1,
872
+ status: "active",
732
873
  updatedAt: now,
874
+ activeStartedAt: now,
875
+ ...(runtime.state.result === undefined ? {} : { result: runtime.state.result }),
876
+ ...(runtime.state.blockerAudit === undefined ? {} : { blockerAudit: runtime.state.blockerAudit }),
733
877
  };
734
878
  try {
735
879
  persistGoalState(pi, runtime, "checkpoint", checkpoint);
@@ -793,7 +937,7 @@ export function registerGoal(
793
937
  const selected = await ctx.ui.select(goalStatusSummary(runtime.state, ctx), actions.map((action) => action.label));
794
938
  const action = actions.find((candidate) => candidate.label === selected);
795
939
  if (!action) return;
796
- if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", runtime.state.objective)) return;
940
+ if (action.control === "clear" && !await ctx.ui.confirm("Clear goal?", safeTerminalText(runtime.state.objective))) return;
797
941
  await handleGoalCommand(action.control, ctx);
798
942
  return;
799
943
  }
@@ -843,16 +987,18 @@ export function registerGoal(
843
987
  return;
844
988
  }
845
989
  if (runtime.state.status === "paused") {
846
- if (!runtime.persistenceRetryNeeded && runtime.state.resumeAfterManualCompaction !== true) {
990
+ if (!runtime.persistenceRetryNeeded
991
+ && runtime.state.resumeAfterManualCompaction !== true
992
+ && runtime.automaticCompaction === undefined) {
847
993
  ctx.ui.notify("Goal is already paused", "info");
848
994
  return;
849
995
  }
850
996
  const now = Date.now();
997
+ const { resumeAfterManualCompaction: _resume, ...paused } = runtime.state;
851
998
  const checkpoint: GoalState = {
852
- ...runtime.state,
853
- revision: runtime.state.revision + 1,
999
+ ...paused,
1000
+ revision: paused.revision + 1,
854
1001
  updatedAt: now,
855
- resumeAfterManualCompaction: undefined,
856
1002
  };
857
1003
  try {
858
1004
  persistGoalState(pi, runtime, "pause", checkpoint);
@@ -878,7 +1024,7 @@ export function registerGoal(
878
1024
  transitionGoal(pi, runtime, "pause", "paused");
879
1025
  saved = true;
880
1026
  } catch (error) {
881
- failureReason = `the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`;
1027
+ failureReason = safeTerminalText(`the requested pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
882
1028
  pauseGoalAfterFailure(
883
1029
  pi,
884
1030
  runtime,
@@ -969,18 +1115,17 @@ export function registerGoal(
969
1115
  }
970
1116
  const now = Date.now();
971
1117
  const current = stopGoalClock(runtime.state, now);
1118
+ const { verification: _previousVerification, ...currentWithoutVerification } = current;
1119
+ const verification = inferGoalVerification(objective);
972
1120
  const next: GoalState = {
973
- ...current,
1121
+ ...currentWithoutVerification,
974
1122
  revision: current.revision + 1,
975
1123
  objective,
976
1124
  status: "active",
977
1125
  updatedAt: now,
978
1126
  activeStartedAt: now,
979
1127
  blockedAuditStartTurn: current.turns,
980
- blockerAudit: undefined,
981
- verification: inferGoalVerification(objective),
982
- result: undefined,
983
- resumeAfterManualCompaction: undefined,
1128
+ ...(verification === undefined ? {} : { verification }),
984
1129
  };
985
1130
  try {
986
1131
  persistGoalState(pi, runtime, "edit", next);
@@ -1038,21 +1183,21 @@ export function registerGoal(
1038
1183
  return;
1039
1184
  }
1040
1185
  const now = Date.now();
1041
- const state: GoalState = {
1042
- version: GOAL_VERSION,
1043
- revision: 1,
1044
- objective,
1045
- status: "active",
1046
- createdAt: now,
1047
- updatedAt: now,
1048
- activeMilliseconds: 0,
1049
- activeStartedAt: now,
1050
- turns: 0,
1051
- blockedAuditStartTurn: 0,
1052
- baselineTokens: sumGoalTokens(ctx),
1053
- verification: inferGoalVerification(objective),
1054
- };
1055
1186
  try {
1187
+ const state: GoalState = {
1188
+ version: GOAL_VERSION,
1189
+ revision: 1,
1190
+ objective,
1191
+ status: "active",
1192
+ createdAt: now,
1193
+ updatedAt: now,
1194
+ activeMilliseconds: 0,
1195
+ activeStartedAt: now,
1196
+ turns: 0,
1197
+ blockedAuditStartTurn: 0,
1198
+ baselineTokens: sumGoalTokens(ctx),
1199
+ verification: inferGoalVerification(objective),
1200
+ };
1056
1201
  persistGoalState(pi, runtime, unfinished ? "replace" : "set", state);
1057
1202
  if (scheduleGoalContinuation(pi, runtime, initState, ctx)) {
1058
1203
  ctx.ui.notify("Goal active. KillerOS will continue until completion, a repeated blocker, or pause.", "info");
@@ -1110,6 +1255,32 @@ export function registerGoalSettlement(
1110
1255
  runtime.goalTurnInFlight = false;
1111
1256
  runtime.agentEndObserved = false;
1112
1257
  runtime.continuationScheduled = false;
1258
+
1259
+ if (runtime.automaticCompaction) {
1260
+ const stopReason = runtime.lastStopReason;
1261
+ const error = safeTerminalText(runtime.lastError ?? "");
1262
+ runtime.lastStopReason = undefined;
1263
+ runtime.lastError = undefined;
1264
+ const expectedInterruption = stopReason === "aborted"
1265
+ || stopReason === "error" && error === "This operation was aborted";
1266
+ if (!wasGoalTurn || !agentEndObserved) {
1267
+ stopAutomaticCompactionRecovery(pi, runtime, ctx, "the goal turn ended without an agent result");
1268
+ return;
1269
+ }
1270
+ if ((stopReason === "error" || stopReason === "aborted") && !expectedInterruption) {
1271
+ stopAutomaticCompactionRecovery(
1272
+ pi,
1273
+ runtime,
1274
+ ctx,
1275
+ error || "the agent turn failed",
1276
+ );
1277
+ return;
1278
+ }
1279
+ runtime.automaticCompaction.turnSettled = true;
1280
+ finalizeAutomaticCompaction(pi, runtime, initState, ctx);
1281
+ return;
1282
+ }
1283
+
1113
1284
  if (!wasGoalTurn || runtime.state?.status !== "active" || initState.active) {
1114
1285
  if (continuationWasScheduled && runtime.state?.status === "active" && !initState.active) {
1115
1286
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal continuation ended before an agent turn started");
@@ -1119,7 +1290,6 @@ export function registerGoalSettlement(
1119
1290
  return;
1120
1291
  }
1121
1292
  if (!agentEndObserved) {
1122
- if (runtime.automaticCompaction !== undefined) return;
1123
1293
  pauseGoalAfterFailure(pi, runtime, ctx, "the goal turn ended without an agent result");
1124
1294
  return;
1125
1295
  }
@@ -1127,7 +1297,6 @@ export function registerGoalSettlement(
1127
1297
  const reason = runtime.lastError || "the agent turn was aborted";
1128
1298
  runtime.lastStopReason = undefined;
1129
1299
  runtime.lastError = undefined;
1130
- if (runtime.automaticCompaction !== undefined) return;
1131
1300
  pauseGoalForPossibleManualCompaction(pi, runtime, ctx, reason);
1132
1301
  return;
1133
1302
  }
@@ -1149,13 +1318,40 @@ export function registerGoalSettlement(
1149
1318
  recoverGoalAfterManualCompaction(pi, runtime, initState, ctx);
1150
1319
  });
1151
1320
 
1321
+ const resetAutomaticRecovery = (): void => { runtime.automaticCompaction = undefined; };
1322
+ pi.on("session_before_switch", resetAutomaticRecovery);
1323
+ pi.on("session_before_fork", resetAutomaticRecovery);
1324
+
1152
1325
  return {
1153
1326
  isActive: (ctx: ExtensionContext): boolean => isGoalModeSupported(ctx)
1154
1327
  && isSavedSession(ctx)
1155
1328
  && runtime.state?.status === "active"
1156
1329
  && !initState.active,
1157
1330
  onRequested: (): void => {
1158
- if (runtime.state?.status === "active") runtime.automaticCompaction = "pending";
1331
+ if (runtime.state?.status !== "active") return;
1332
+ try {
1333
+ const paused = transitionGoal(pi, runtime, "pause", "paused");
1334
+ runtime.automaticCompaction = {
1335
+ pausedRevision: paused.revision,
1336
+ compactionSucceeded: false,
1337
+ turnSettled: false,
1338
+ };
1339
+ } catch (error) {
1340
+ const current = runtime.state;
1341
+ const reason = safeTerminalText(`automatic compaction pause could not be saved: ${error instanceof Error ? error.message : String(error)}`);
1342
+ runtime.state = current ? {
1343
+ ...stopGoalClock(current, Date.now()),
1344
+ status: "paused",
1345
+ result: reason,
1346
+ ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
1347
+ } : undefined;
1348
+ syncGoalUpdateTool(pi, runtime);
1349
+ runtime.persistenceRetryNeeded = true;
1350
+ runtime.continuationScheduled = false;
1351
+ runtime.automaticCompaction = undefined;
1352
+ runtime.requestRender?.();
1353
+ throw error;
1354
+ }
1159
1355
  },
1160
1356
  onCompleted: (ctx: ExtensionContext): void => completeAutomaticCompaction(pi, runtime, initState, ctx),
1161
1357
  onFailed: (ctx: ExtensionContext, error: unknown): void => failAutomaticCompaction(pi, runtime, ctx, error),