killeros 2.1.26 → 2.1.28

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.
@@ -2,10 +2,12 @@ import { createHash } from "node:crypto";
2
2
  import type { Stats } from "node:fs";
3
3
  import { lstat, open, type FileHandle } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalState, GoalStateCommon, GoalStatus } from "./runtime.ts";
5
+ import type { GoalBlockerAudit, GoalContinueReport, GoalFileBaseline, GoalFileVerification, GoalPendingDecision, GoalState, GoalStateCommon, GoalStatus, GoalTurnDecision, GoalTurnPhase } from "./runtime.ts";
6
+ import { safeTerminalText } from "./safe-terminal-text.ts";
6
7
 
7
8
  export const DEFAULT_GOAL_MAX_TURNS = 20;
8
9
  export const GOAL_OBJECTIVE_LIMIT = 4_000;
10
+ export const GOAL_EVIDENCE_LIMIT = 2_000;
9
11
  export const GOAL_MAX_TURNS = 10_000;
10
12
  export const GOAL_VERSION = 1;
11
13
  const FILE_HASH_CHUNK_SIZE = 64 * 1024;
@@ -18,6 +20,10 @@ export interface GoalTransitionOptions {
18
20
  resetBlockedAudit?: boolean;
19
21
  resumeAfterManualCompaction?: true;
20
22
  blockerAudit?: GoalBlockerAudit;
23
+ keepTurnForRecovery?: true;
24
+ resumeInterruptedTurn?: true;
25
+ preserveTurnAuthorization?: true;
26
+ decision?: GoalTurnDecision;
21
27
  }
22
28
 
23
29
  function isGoalStatus(value: unknown): value is GoalStatus {
@@ -46,6 +52,79 @@ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
46
52
  return typeof value === "object" && value !== null && !Array.isArray(value);
47
53
  }
48
54
 
55
+ function graphemeCount(value: string): number {
56
+ let length = 0;
57
+ for (const _ of graphemeSegmenter.segment(value)) length += 1;
58
+ return length;
59
+ }
60
+
61
+ function isSafePersistedText(value: unknown, limit: number, required = true): value is string {
62
+ return typeof value === "string"
63
+ && (required ? Boolean(value.trim()) : true)
64
+ && value === value.trim()
65
+ && safeTerminalText(value) === value
66
+ && graphemeCount(value) <= limit;
67
+ }
68
+
69
+ /** Normalizes model or user text before it crosses the persisted goal boundary. */
70
+ export function normalizeGoalText(value: string, limit: number, label: string): string {
71
+ if (graphemeCount(value) > limit) throw new Error(`${label} must not exceed ${limit} characters`);
72
+ const normalized = safeTerminalText(value).trim();
73
+ if (!normalized) throw new Error(`${label} must not be empty`);
74
+ if (graphemeCount(normalized) > limit) throw new Error(`${label} must not exceed ${limit} characters`);
75
+ return normalized;
76
+ }
77
+
78
+ function isGoalTurnPhase(value: unknown): value is GoalTurnPhase {
79
+ return value === "ready" || value === "in-flight" || value === "authorized";
80
+ }
81
+
82
+ function isGoalContinueReport(value: unknown, turns: number): value is GoalContinueReport {
83
+ return isUnknownRecord(value)
84
+ && safeNonNegativeInteger(value.turn)
85
+ && value.turn >= 1
86
+ && value.turn <= turns
87
+ && isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)
88
+ && isSafePersistedText(value.nextAction, GOAL_EVIDENCE_LIMIT);
89
+ }
90
+
91
+ function isGoalPendingDecision(value: unknown, turns: number): value is GoalPendingDecision {
92
+ if (!isUnknownRecord(value)
93
+ || !safeNonNegativeInteger(value.turn)
94
+ || value.turn < 1
95
+ || value.turn > turns
96
+ || !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) return false;
97
+ if (value.kind === "continue") return isSafePersistedText(value.nextAction, GOAL_EVIDENCE_LIMIT);
98
+ return value.kind === "blocker-audit"
99
+ && typeof value.blockerKey === "string"
100
+ && /^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.blockerKey)
101
+ && typeof value.streak === "number"
102
+ && Number.isInteger(value.streak)
103
+ && value.streak >= 1
104
+ && value.streak < 3;
105
+ }
106
+
107
+ function isGoalTurnDecision(value: unknown, turns: number): value is GoalTurnDecision {
108
+ if (!isUnknownRecord(value)
109
+ || !safeNonNegativeInteger(value.turn)
110
+ || value.turn < 1
111
+ || value.turn > turns
112
+ || !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) return false;
113
+ if (value.kind === "continue") return isSafePersistedText(value.nextAction, GOAL_EVIDENCE_LIMIT);
114
+ if (value.kind === "complete") return value.verification === "file" || value.verification === "model-reported";
115
+ return (value.kind === "blocker-audit"
116
+ && typeof value.blockerKey === "string"
117
+ && /^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.blockerKey)
118
+ && typeof value.streak === "number"
119
+ && Number.isInteger(value.streak)
120
+ && value.streak >= 1
121
+ && value.streak < 3)
122
+ || (value.kind === "blocked"
123
+ && typeof value.blockerKey === "string"
124
+ && /^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.blockerKey)
125
+ && value.streak === 3);
126
+ }
127
+
49
128
  function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
50
129
  if (!isUnknownRecord(value)) return false;
51
130
  if (value.exists === false) {
@@ -75,8 +154,7 @@ function stripUnquotedPathPunctuation(value: string): string {
75
154
  function isGoalFileVerification(value: unknown): value is GoalFileVerification {
76
155
  return isUnknownRecord(value)
77
156
  && value.kind === "file"
78
- && typeof value.path === "string"
79
- && value.path === value.path.trim()
157
+ && isSafePersistedText(value.path, GOAL_OBJECTIVE_LIMIT)
80
158
  && isAbsoluteFilePath(value.path)
81
159
  && isGoalFileBaseline(value.baseline);
82
160
  }
@@ -85,22 +163,13 @@ function isMaxTurns(value: unknown): value is number {
85
163
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= GOAL_MAX_TURNS;
86
164
  }
87
165
 
88
- function exceedsBlockerEvidenceLimit(value: string): boolean {
89
- let length = 0;
90
- for (const _ of graphemeSegmenter.segment(value)) {
91
- if (++length > 2_000) return true;
92
- }
93
- return false;
94
- }
95
-
96
166
  function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
97
167
  if (!isUnknownRecord(value)
98
168
  || typeof value.key !== "string"
99
169
  || !/^[a-z0-9][a-z0-9._-]{0,119}$/u.test(value.key)
100
170
  || typeof value.streak !== "number" || !Number.isInteger(value.streak) || value.streak < 1 || value.streak > 3
101
171
  || typeof value.lastTurn !== "number" || !Number.isInteger(value.lastTurn) || value.lastTurn < 1 || value.lastTurn > turns
102
- || value.evidence !== undefined && (typeof value.evidence !== "string"
103
- || value.evidence !== value.evidence.trim() || !value.evidence || exceedsBlockerEvidenceLimit(value.evidence))) {
172
+ || value.evidence !== undefined && !isSafePersistedText(value.evidence, GOAL_EVIDENCE_LIMIT)) {
104
173
  return false;
105
174
  }
106
175
  if (status === "complete") return false;
@@ -126,10 +195,16 @@ export function parseGoalState(value: unknown): GoalState | undefined {
126
195
  blockerAudit,
127
196
  verification,
128
197
  maxTurns,
198
+ turnPhase,
199
+ turnDecision,
200
+ lastDecision,
201
+ lastContinueReport,
202
+ stopReason,
129
203
  } = value;
130
204
  if (version !== GOAL_VERSION
131
205
  || !incrementableNonNegativeInteger(revision) || revision < 1
132
- || typeof objective !== "string" || !objective.trim() || [...objective].length > GOAL_OBJECTIVE_LIMIT
206
+ || typeof objective !== "string" || !objective.trim() || safeTerminalText(objective) !== objective
207
+ || graphemeCount(objective.trim()) > GOAL_OBJECTIVE_LIMIT
133
208
  || !isGoalStatus(status)
134
209
  || !safeNonNegativeInteger(createdAt)
135
210
  || !safeNonNegativeInteger(updatedAt)
@@ -138,14 +213,37 @@ export function parseGoalState(value: unknown): GoalState | undefined {
138
213
  || blockedAuditStartTurn !== undefined
139
214
  && (!safeNonNegativeInteger(blockedAuditStartTurn) || blockedAuditStartTurn > turns)
140
215
  || !safeNonNegativeInteger(baselineTokens)
141
- || result !== undefined && typeof result !== "string"
216
+ || result !== undefined && !isSafePersistedText(result, GOAL_EVIDENCE_LIMIT, false)
142
217
  || verification !== undefined && !isGoalFileVerification(verification)
143
218
  || maxTurns !== undefined && !isMaxTurns(maxTurns)
144
219
  || resumeAfterManualCompaction !== undefined && resumeAfterManualCompaction !== true
145
- || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)) {
220
+ || blockerAudit !== undefined && !isGoalBlockerAudit(blockerAudit, turns, status)
221
+ || turnPhase !== undefined && !isGoalTurnPhase(turnPhase)
222
+ || turnDecision !== undefined && !isGoalPendingDecision(turnDecision, turns)
223
+ || lastDecision !== undefined && !isGoalTurnDecision(lastDecision, turns)
224
+ || lastContinueReport !== undefined && !isGoalContinueReport(lastContinueReport, turns)
225
+ || stopReason !== undefined && !isSafePersistedText(stopReason, GOAL_EVIDENCE_LIMIT)) {
146
226
  return undefined;
147
227
  }
148
228
 
229
+ const pending = turnDecision;
230
+ if (pending !== undefined && pending.turn !== turns) return undefined;
231
+ if (isGoalTurnDecision(lastDecision, turns)) {
232
+ if (lastDecision.kind === "complete") {
233
+ if (status !== "complete") return undefined;
234
+ if ((lastDecision.verification === "file") !== (verification !== undefined)) return undefined;
235
+ }
236
+ if (lastDecision.kind === "blocked" && status !== "blocked") return undefined;
237
+ }
238
+ if (pending !== undefined && turnPhase !== "authorized") return undefined;
239
+ if (turnPhase === "authorized" && pending === undefined) return undefined;
240
+ if (turnPhase === "ready" && pending !== undefined) return undefined;
241
+ if (status === "complete" && (turnPhase !== undefined || pending !== undefined)) return undefined;
242
+ if (status === "blocked" && (turnPhase !== undefined || pending !== undefined)) return undefined;
243
+ if (status === "paused" && turnPhase === "ready" && pending !== undefined) return undefined;
244
+ // A pause reason is only current while paused; on any other status it contradicts the state.
245
+ if (stopReason !== undefined && status !== "paused") return undefined;
246
+
149
247
  const common: GoalStateCommon = {
150
248
  version: GOAL_VERSION,
151
249
  revision,
@@ -158,6 +256,11 @@ export function parseGoalState(value: unknown): GoalState | undefined {
158
256
  baselineTokens,
159
257
  ...(verification === undefined ? {} : { verification }),
160
258
  ...(maxTurns === undefined ? {} : { maxTurns }),
259
+ ...(turnPhase === undefined ? {} : { turnPhase }),
260
+ ...(pending === undefined ? {} : { turnDecision: pending }),
261
+ ...(lastDecision === undefined ? {} : { lastDecision }),
262
+ ...(lastContinueReport === undefined ? {} : { lastContinueReport }),
263
+ ...(stopReason === undefined ? {} : { stopReason }),
161
264
  };
162
265
  switch (status) {
163
266
  case "active":
@@ -242,6 +345,45 @@ export async function captureGoalFileBaseline(
242
345
  }
243
346
  }
244
347
 
348
+ const GOAL_URL_PATTERN = /(?:https?|file):\/\/[^\s"'`]+/giu;
349
+ const GOAL_QUOTED_PATTERN = /`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'/gu;
350
+ const GOAL_ABSOLUTE_PATTERN = /(?:^|[\s"'`(\[{,;])([A-Za-z]:[\\/][^\s,;'"`]+|\/[^\s,;'"`]+)/gu;
351
+ const GOAL_LIST_FILE_PATTERN = /(?:\band\b|\bor\b|[,;&+])\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z0-9_][A-Za-z0-9_.-]*(?:[\\/][A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12})\b)/giu;
352
+
353
+ function isQuotedGoalFileMention(raw: string): boolean {
354
+ const value = raw.trim();
355
+ if (!value || /[\s\p{Cc}]/u.test(value) || /^(?:https?|file):\/\//iu.test(value) || /[\\/]$/u.test(value)) return false;
356
+ return /[\/\\.:]/u.test(value);
357
+ }
358
+
359
+ function normalizeGoalFileMention(raw: string, cwd: string): string | undefined {
360
+ const value = raw.trim().replace(/[.,;:!?)\]}]+$/u, "");
361
+ if (!value || /[\s\p{Cc}]/u.test(value)) return undefined;
362
+ const absolute = path.isAbsolute(value) || path.win32.isAbsolute(value) ? value : path.resolve(cwd, value);
363
+ if (!isAbsoluteFilePath(absolute)) return undefined;
364
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
365
+ }
366
+
367
+ /** Counts distinct path-shaped mentions so several files cannot bind proof to one. */
368
+ function countGoalTargetFiles(objective: string, cwd: string, target: string): number {
369
+ const prose = objective.replace(GOAL_URL_PATTERN, " ");
370
+ const mentions = new Set<string>();
371
+ const add = (raw: string): void => {
372
+ const normalized = normalizeGoalFileMention(raw, cwd);
373
+ if (normalized) mentions.add(normalized);
374
+ };
375
+ add(target);
376
+ for (const match of prose.matchAll(GOAL_QUOTED_PATTERN)) {
377
+ const raw = match[1] ?? match[2] ?? match[3] ?? "";
378
+ if (isQuotedGoalFileMention(raw)) add(raw);
379
+ }
380
+ for (const match of prose.matchAll(GOAL_ABSOLUTE_PATTERN)) {
381
+ if (match[1]) add(stripUnquotedPathPunctuation(match[1].trim()));
382
+ }
383
+ for (const match of prose.matchAll(GOAL_LIST_FILE_PATTERN)) add(match[1] ?? match[2] ?? match[3] ?? match[4] ?? "");
384
+ return mentions.size;
385
+ }
386
+
245
387
  /** Captures one explicit output path so goal completion can verify its creation or modification. */
246
388
  export async function inferGoalVerification(objective: string, cwd: string): Promise<GoalFileVerification | undefined> {
247
389
  const candidates: string[] = [];
@@ -262,8 +404,12 @@ export async function inferGoalVerification(objective: string, cwd: string): Pro
262
404
  if (isAbsoluteFilePath(absolute)) resolved.push(absolute);
263
405
  }
264
406
  const unique = [...new Set(resolved)];
265
- const filePath = unique.length === 1 ? unique[0] : undefined;
266
- return filePath ? { kind: "file", path: filePath, baseline: await captureGoalFileBaseline(filePath) } : undefined;
407
+ if (unique.length !== 1) return undefined;
408
+ // A goal that names several files must not verify from one file alone.
409
+ // Repeated references to the same file count as one target.
410
+ const filePath = unique[0];
411
+ if (countGoalTargetFiles(objective, cwd, filePath) > 1) return undefined;
412
+ return { kind: "file", path: filePath, baseline: await captureGoalFileBaseline(filePath) };
267
413
  }
268
414
 
269
415
  export async function verifyGoalDeliverable(verification: GoalFileVerification): Promise<void> {
@@ -296,10 +442,19 @@ export async function verifyGoalDeliverable(verification: GoalFileVerification):
296
442
  }
297
443
  }
298
444
 
445
+ export function boundGoalText(value: string, limit = GOAL_EVIDENCE_LIMIT): string {
446
+ const safe = safeTerminalText(value).trim();
447
+ return [...graphemeSegmenter.segment(safe)]
448
+ .slice(0, limit)
449
+ .map(({ segment }) => segment)
450
+ .join("")
451
+ .trimEnd();
452
+ }
453
+
299
454
  export function validateGoalObjective(input: string): string | undefined {
300
455
  const objective = input.trim();
301
- if (!objective) return undefined;
302
- return [...objective].length <= GOAL_OBJECTIVE_LIMIT ? objective : undefined;
456
+ if (!objective || safeTerminalText(objective) !== objective) return undefined;
457
+ return graphemeCount(objective) <= GOAL_OBJECTIVE_LIMIT ? objective : undefined;
303
458
  }
304
459
 
305
460
  export function goalElapsedMilliseconds(state: GoalState, now: number): number {
@@ -320,6 +475,11 @@ export function commonGoalState(state: GoalState): GoalStateCommon {
320
475
  baselineTokens: state.baselineTokens,
321
476
  ...(state.verification === undefined ? {} : { verification: state.verification }),
322
477
  ...(state.maxTurns === undefined ? {} : { maxTurns: state.maxTurns }),
478
+ ...(state.turnPhase === undefined ? {} : { turnPhase: state.turnPhase }),
479
+ ...(state.turnDecision === undefined ? {} : { turnDecision: state.turnDecision }),
480
+ ...(state.lastDecision === undefined ? {} : { lastDecision: state.lastDecision }),
481
+ ...(state.lastContinueReport === undefined ? {} : { lastContinueReport: state.lastContinueReport }),
482
+ ...(state.status === "paused" && state.stopReason !== undefined ? { stopReason: state.stopReason } : {}),
323
483
  };
324
484
  }
325
485
 
@@ -349,16 +509,33 @@ export function createNewGoalState(
349
509
  turns: 0,
350
510
  blockedAuditStartTurn: 0,
351
511
  baselineTokens,
512
+ turnPhase: "ready",
352
513
  ...(verification === undefined ? {} : { verification }),
353
514
  ...(controls.maxTurns === undefined ? {} : { maxTurns: controls.maxTurns }),
354
515
  };
355
516
  }
356
517
 
518
+ function nextRevision(current: { revision: number }): number {
519
+ if (!incrementableNonNegativeInteger(current.revision)) {
520
+ throw new Error("Goal revision cannot advance safely");
521
+ }
522
+ return current.revision + 1;
523
+ }
524
+
357
525
  export function beginGoalTurnState(
358
526
  current: Extract<GoalState, { status: "active" }>,
359
527
  now: number,
360
528
  ): GoalState {
361
- return { ...current, revision: current.revision + 1, turns: current.turns + 1, updatedAt: now };
529
+ if (!incrementableNonNegativeInteger(current.turns)) throw new Error("Goal turn counter cannot advance safely");
530
+ const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = current;
531
+ return {
532
+ ...withoutPending,
533
+ revision: nextRevision(current),
534
+ turns: current.turns + 1,
535
+ updatedAt: now,
536
+ turnPhase: "in-flight",
537
+ ...(current.turnDecision === undefined ? {} : { lastDecision: current.turnDecision }),
538
+ };
362
539
  }
363
540
 
364
541
  export function checkpointActiveGoalState(
@@ -367,7 +544,7 @@ export function checkpointActiveGoalState(
367
544
  ): GoalState {
368
545
  return {
369
546
  ...stopGoalClock(current, now),
370
- revision: current.revision + 1,
547
+ revision: nextRevision(current),
371
548
  status: "active",
372
549
  updatedAt: now,
373
550
  activeStartedAt: now,
@@ -381,12 +558,17 @@ export function pauseGoalState(
381
558
  result: string | undefined,
382
559
  now: number,
383
560
  resumeAfterManualCompaction = false,
561
+ preserveTurnAuthorization = false,
384
562
  ): GoalState {
385
563
  const common = stopGoalClock(current, now);
564
+ const { turnDecision: _decision, turnPhase: _phase, ...withoutPending } = common;
386
565
  return {
387
- ...common,
566
+ ...withoutPending,
388
567
  status: "paused",
568
+ turnPhase: preserveTurnAuthorization ? current.turnPhase : "ready",
569
+ ...(preserveTurnAuthorization && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
389
570
  ...(result === undefined ? {} : { result }),
571
+ ...(result === undefined ? {} : { stopReason: boundGoalText(result) }),
390
572
  ...(current.blockerAudit === undefined ? {} : { blockerAudit: current.blockerAudit }),
391
573
  ...(resumeAfterManualCompaction ? { resumeAfterManualCompaction: true as const } : {}),
392
574
  };
@@ -397,15 +579,26 @@ export function checkpointPausedGoalState(
397
579
  now: number,
398
580
  ): GoalState {
399
581
  const { resumeAfterManualCompaction: _resume, ...paused } = current;
400
- return { ...paused, revision: paused.revision + 1, updatedAt: now };
582
+ return { ...paused, revision: nextRevision(current), updatedAt: now };
401
583
  }
402
584
 
403
- export function recordGoalBlockerAudit(
585
+ export function recordGoalDecision(
404
586
  state: Extract<GoalState, { status: "active" }>,
405
- blockerAudit: GoalBlockerAudit,
587
+ decision: GoalPendingDecision,
406
588
  now: number,
407
- ): GoalState {
408
- return { ...state, revision: state.revision + 1, updatedAt: now, blockerAudit };
589
+ ): Extract<GoalState, { status: "active" }> {
590
+ if (decision.turn !== state.turns) throw new Error("Goal decision does not match the active turn");
591
+ return {
592
+ ...state,
593
+ revision: nextRevision(state),
594
+ updatedAt: now,
595
+ turnPhase: "authorized",
596
+ turnDecision: decision,
597
+ lastDecision: decision,
598
+ ...(decision.kind === "continue"
599
+ ? { lastContinueReport: { turn: decision.turn, evidence: decision.evidence, nextAction: decision.nextAction } }
600
+ : {}),
601
+ };
409
602
  }
410
603
 
411
604
  export function transitionGoalState(
@@ -418,27 +611,50 @@ export function transitionGoalState(
418
611
  const stopped = stopGoalClock(current, now);
419
612
  const common: GoalStateCommon = {
420
613
  ...stopped,
421
- revision: stopped.revision + 1,
614
+ revision: nextRevision(stopped),
422
615
  updatedAt: now,
423
616
  blockedAuditStartTurn: options.resetBlockedAudit ? stopped.turns : stopped.blockedAuditStartTurn,
424
617
  };
425
618
  const blockerAudit = options.resetBlockedAudit ? undefined : options.blockerAudit ?? current.blockerAudit;
619
+ const pending = options.preserveTurnAuthorization ? current.turnDecision : undefined;
620
+ const { turnDecision: _decision, turnPhase: _phase, stopReason: _stopReason, ...withoutPending } = common;
426
621
  switch (status) {
427
622
  case "active":
428
- return { ...common, status, activeStartedAt: now, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
623
+ return {
624
+ ...withoutPending,
625
+ ...(current.status === "blocked" ? { lastDecision: undefined } : {}),
626
+ status,
627
+ activeStartedAt: now,
628
+ turnPhase: pending !== undefined ? "authorized" : options.resumeInterruptedTurn ? "in-flight" : "ready",
629
+ ...(pending === undefined ? {} : { turnDecision: pending }),
630
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
631
+ };
429
632
  case "paused":
430
633
  return {
431
- ...common,
634
+ ...withoutPending,
432
635
  status,
433
- ...(result === undefined ? {} : { result }),
636
+ turnPhase: options.keepTurnForRecovery ? current.turnPhase : "ready",
637
+ ...(options.keepTurnForRecovery && current.turnDecision !== undefined ? { turnDecision: current.turnDecision } : {}),
638
+ ...(result === undefined ? {} : { result, stopReason: boundGoalText(result) }),
434
639
  ...(blockerAudit === undefined ? {} : { blockerAudit }),
435
640
  ...(options.resumeAfterManualCompaction === undefined ? {} : { resumeAfterManualCompaction: true }),
436
641
  };
437
642
  case "blocked":
438
643
  if (result === undefined) throw new Error("A blocked goal requires a result");
439
- return { ...common, status, result, ...(blockerAudit === undefined ? {} : { blockerAudit }) };
644
+ return {
645
+ ...withoutPending,
646
+ status,
647
+ result,
648
+ ...(options.decision === undefined ? {} : { lastDecision: options.decision }),
649
+ ...(blockerAudit === undefined ? {} : { blockerAudit }),
650
+ };
440
651
  case "complete":
441
652
  if (result === undefined) throw new Error("A complete goal requires a result");
442
- return { ...common, status, result };
653
+ return {
654
+ ...withoutPending,
655
+ status,
656
+ result,
657
+ ...(options.decision === undefined ? {} : { lastDecision: options.decision }),
658
+ };
443
659
  }
444
660
  }
@@ -104,6 +104,33 @@ function hasRequiredHandoffContent(document: string, focus: string): boolean {
104
104
  });
105
105
  }
106
106
 
107
+ function handoffAvailable(ctx: ExtensionCommandContext, goalRuntime: GoalRuntime): boolean {
108
+ return ctx.isIdle() && !ctx.hasPendingMessages() && goalRuntime.state?.status !== "active";
109
+ }
110
+
111
+ function sourceHandoffAvailable(
112
+ ctx: ExtensionCommandContext,
113
+ goalRuntime: GoalRuntime,
114
+ sourceSession: string,
115
+ sourceLeaf: string | undefined,
116
+ ): boolean {
117
+ try {
118
+ return ctx.sessionManager.getSessionFile() === sourceSession
119
+ && handoffAvailable(ctx, goalRuntime)
120
+ && ctx.sessionManager.buildContextEntries().at(-1)?.id === sourceLeaf;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function notifyHandoffUnavailable(ctx: ExtensionCommandContext): void {
127
+ try {
128
+ ctx.ui.notify(HANDOFF_UNAVAILABLE, "error");
129
+ } catch {
130
+ // A session replaced during generation has no valid source UI to notify.
131
+ }
132
+ }
133
+
107
134
  function assertHandoffContextReserve(ctx: ExtensionCommandContext, maxTokens: number): void {
108
135
  let usage: ReturnType<ExtensionCommandContext["getContextUsage"]>;
109
136
  try {
@@ -160,8 +187,8 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, hand
160
187
  pi.registerCommand("handoff", {
161
188
  description: "Create a fresh session with a continuation handoff",
162
189
  handler: async (args, ctx) => {
163
- if (!ctx.isIdle() || ctx.hasPendingMessages() || goalRuntime.state?.status === "active") {
164
- ctx.ui.notify(HANDOFF_UNAVAILABLE, "error");
190
+ if (!handoffAvailable(ctx, goalRuntime)) {
191
+ notifyHandoffUnavailable(ctx);
165
192
  return;
166
193
  }
167
194
 
@@ -175,8 +202,9 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, hand
175
202
  let document: string;
176
203
  let focus: string;
177
204
  try {
178
- const messages = ctx.sessionManager.buildContextEntries().flatMap(sessionEntryToContextMessages);
179
- const conversation = serializeConversation(convertToLlm(messages));
205
+ const entries = ctx.sessionManager.buildContextEntries();
206
+ const sourceLeaf = entries.at(-1)?.id;
207
+ const conversation = serializeConversation(convertToLlm(entries.flatMap(sessionEntryToContextMessages)));
180
208
  if (!conversation.trim()) throw new Error("No usable session context is available");
181
209
  focus = safeTerminalText(args).trim();
182
210
  let maxTokens = handoffMaxTokens;
@@ -215,6 +243,10 @@ export function registerHandoff(pi: ExtensionAPI, goalRuntime: GoalRuntime, hand
215
243
  if (!hasRequiredHandoffContent(document, focus)) {
216
244
  throw new Error("The handoff summary did not contain every required section");
217
245
  }
246
+ if (!sourceHandoffAvailable(ctx, goalRuntime, sourceSession, sourceLeaf)) {
247
+ notifyHandoffUnavailable(ctx);
248
+ return;
249
+ }
218
250
  } catch (error) {
219
251
  reportError(ctx, "Handoff failed", error);
220
252
  return;