ralph-review 0.2.2 → 0.2.4

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 (56) hide show
  1. package/README.md +123 -16
  2. package/package.json +7 -5
  3. package/src/cli-core.ts +51 -88
  4. package/src/cli-rrr.ts +1 -4
  5. package/src/cli.ts +1 -2
  6. package/src/commands/apply.ts +35 -20
  7. package/src/commands/config-handlers.ts +68 -69
  8. package/src/commands/config-model.ts +147 -125
  9. package/src/commands/doctor.ts +2 -4
  10. package/src/commands/fix.ts +73 -51
  11. package/src/commands/handoff-selection.ts +6 -8
  12. package/src/commands/interactive-deps.ts +43 -0
  13. package/src/commands/list.ts +24 -7
  14. package/src/commands/log.ts +12 -12
  15. package/src/commands/run.ts +32 -33
  16. package/src/commands/status.ts +25 -4
  17. package/src/commands/stop.ts +99 -62
  18. package/src/commands/update.ts +2 -4
  19. package/src/lib/agents/claude.ts +4 -16
  20. package/src/lib/agents/core.ts +16 -0
  21. package/src/lib/agents/droid.ts +4 -15
  22. package/src/lib/agents/models.ts +9 -0
  23. package/src/lib/cli-parser.ts +19 -14
  24. package/src/lib/handoff.ts +16 -7
  25. package/src/lib/logging/session-log.ts +2 -1
  26. package/src/lib/prompts/defaults/review.md +1 -1
  27. package/src/lib/prompts/protocol.ts +2 -1
  28. package/src/lib/review-workflow/findings/artifact.ts +3 -1
  29. package/src/lib/review-workflow/findings/types.ts +1 -1
  30. package/src/lib/review-workflow/remediation/prompt.ts +7 -7
  31. package/src/lib/review-workflow/remediation/run-batch-fix-phase.ts +30 -20
  32. package/src/lib/review-workflow/remediation/run-fix-session.ts +70 -68
  33. package/src/lib/review-workflow/results/finalize-result.ts +20 -3
  34. package/src/lib/review-workflow/run-review-cycle.ts +1 -12
  35. package/src/lib/review-workflow/session-status.ts +13 -0
  36. package/src/lib/review-workflow/shared/framed-json.ts +2 -47
  37. package/src/lib/session/state.ts +50 -38
  38. package/src/lib/structured-output.ts +24 -9
  39. package/src/lib/tui/dashboard/HelpOverlay.tsx +13 -57
  40. package/src/lib/tui/dashboard/ReviewModeOverlay.tsx +2 -2
  41. package/src/lib/tui/dashboard/StatusBar.tsx +12 -50
  42. package/src/lib/tui/dashboard/StopSessionPickerOverlay.tsx +4 -22
  43. package/src/lib/tui/sessions/detail/DetailPane.tsx +6 -64
  44. package/src/lib/tui/sessions/detail/IdleStateView.tsx +10 -12
  45. package/src/lib/tui/sessions/detail/SessionDetailView.tsx +1 -1
  46. package/src/lib/tui/sessions/detail/session-detail-parts.tsx +66 -87
  47. package/src/lib/tui/sessions/history/SessionListOverlay.tsx +17 -75
  48. package/src/lib/tui/sessions/review-summary-parser.ts +2 -68
  49. package/src/lib/tui/shared/CenteredModal.tsx +44 -0
  50. package/src/lib/tui/shared/KeyboardShortcutsModal.tsx +14 -0
  51. package/src/lib/tui/shared/ShortcutHint.tsx +33 -0
  52. package/src/lib/tui/workspace/Workspace.tsx +6 -91
  53. package/src/lib/tui/workspace/use-workspace-state.ts +113 -61
  54. package/src/lib/types/fix.ts +15 -48
  55. package/src/lib/types/guards.ts +47 -0
  56. package/src/lib/types/review.ts +5 -39
@@ -19,7 +19,7 @@ import type { Config } from "@/lib/types";
19
19
  import type { FixDecision } from "@/lib/types/domain";
20
20
 
21
21
  interface BatchFixerResultEntry {
22
- status: "resolved" | "unresolved";
22
+ status: "resolved" | "skipped" | "unresolved";
23
23
  summary: string;
24
24
  }
25
25
 
@@ -66,7 +66,9 @@ function isBatchFixerResultEntry(value: unknown): value is BatchFixerResultEntry
66
66
 
67
67
  const candidate = value as Record<string, unknown>;
68
68
  return (
69
- (candidate.status === "resolved" || candidate.status === "unresolved") &&
69
+ (candidate.status === "resolved" ||
70
+ candidate.status === "skipped" ||
71
+ candidate.status === "unresolved") &&
70
72
  typeof candidate.summary === "string"
71
73
  );
72
74
  }
@@ -124,6 +126,30 @@ function toFixResults(
124
126
  });
125
127
  }
126
128
 
129
+ async function appendBatchFixLog(
130
+ deps: RunBatchFixPhaseDependencies,
131
+ options: RunBatchFixPhaseOptions,
132
+ startedAt: number,
133
+ fixResults: FindingFixResult[],
134
+ error?: unknown
135
+ ): Promise<void> {
136
+ await deps.appendLog(options.artifact.logPath, {
137
+ type: "batch_fix",
138
+ timestamp: Date.now(),
139
+ duration: Date.now() - startedAt,
140
+ selectedFindingIds: options.selection.selectedFindingIds,
141
+ fixResults,
142
+ ...(error === undefined
143
+ ? {}
144
+ : {
145
+ error: {
146
+ phase: "fixer" as const,
147
+ message: error instanceof Error ? error.message : String(error),
148
+ },
149
+ }),
150
+ });
151
+ }
152
+
127
153
  export async function runBatchFixPhase(
128
154
  options: RunBatchFixPhaseOptions,
129
155
  deps: RunBatchFixPhaseDependencies = DEFAULT_RUN_BATCH_FIX_PHASE_DEPENDENCIES
@@ -168,13 +194,7 @@ export async function runBatchFixPhase(
168
194
  const fixResults = toFixResults(options.selection.selectedFindingIds, parsed);
169
195
 
170
196
  deps.discardCheckpoint(options.worktree.worktreeProjectPath, checkpoint);
171
- await deps.appendLog(options.artifact.logPath, {
172
- type: "batch_fix",
173
- timestamp: Date.now(),
174
- duration: Date.now() - startedAt,
175
- selectedFindingIds: options.selection.selectedFindingIds,
176
- fixResults,
177
- });
197
+ await appendBatchFixLog(deps, options, startedAt, fixResults);
178
198
 
179
199
  return {
180
200
  phase: "batch-fix",
@@ -183,17 +203,7 @@ export async function runBatchFixPhase(
183
203
  };
184
204
  } catch (error) {
185
205
  deps.rollbackToCheckpoint(options.worktree.worktreeProjectPath, checkpoint);
186
- await deps.appendLog(options.artifact.logPath, {
187
- type: "batch_fix",
188
- timestamp: Date.now(),
189
- duration: Date.now() - startedAt,
190
- selectedFindingIds: options.selection.selectedFindingIds,
191
- fixResults: [],
192
- error: {
193
- phase: "fixer",
194
- message: error instanceof Error ? error.message : String(error),
195
- },
196
- });
206
+ await appendBatchFixLog(deps, options, startedAt, [], error);
197
207
  throw error;
198
208
  }
199
209
  }
@@ -56,7 +56,9 @@ export interface RunFixSessionDependencies {
56
56
  discardSessionWorktree: typeof discardSessionWorktree;
57
57
  }
58
58
 
59
- async function defaultPromptForSelection(artifact: FindingsArtifact): Promise<FindingId[] | null> {
59
+ export async function promptForFixSelection(
60
+ artifact: FindingsArtifact
61
+ ): Promise<FindingId[] | null> {
60
62
  const selection = await p.multiselect({
61
63
  message: "Choose findings to fix",
62
64
  options: artifact.findings.map((finding) => ({
@@ -80,7 +82,7 @@ const DEFAULT_RUN_FIX_SESSION_DEPENDENCIES: RunFixSessionDependencies = {
80
82
  createSessionWorktreeAt,
81
83
  updateSelection,
82
84
  appendLog,
83
- promptForSelection: defaultPromptForSelection,
85
+ promptForSelection: promptForFixSelection,
84
86
  runBatchFixPhase,
85
87
  appendFixResults,
86
88
  updateRetainedWorktree,
@@ -123,6 +125,26 @@ function buildRetainedCleanupWorktree(
123
125
  };
124
126
  }
125
127
 
128
+ async function discardRetainedWorktree(
129
+ deps: RunFixSessionDependencies,
130
+ artifact: FindingsArtifact,
131
+ worktree: GitSessionWorktree,
132
+ retainedWorktree: RetainedSessionWorktree
133
+ ): Promise<FindingsArtifact> {
134
+ deps.discardSessionWorktree(buildRetainedCleanupWorktree(worktree, retainedWorktree));
135
+ try {
136
+ await deps.updateRetainedWorktree(
137
+ CONFIG_DIR,
138
+ artifact.projectPath,
139
+ artifact.sessionId,
140
+ undefined
141
+ );
142
+ } catch {
143
+ // Best-effort cleanup; the remediation result is still returned.
144
+ }
145
+ return artifact;
146
+ }
147
+
126
148
  function resolveSelectionMode(selector: FixSessionSelector | undefined): {
127
149
  mode: "all" | "priority" | "id" | "interactive";
128
150
  count: number;
@@ -232,6 +254,26 @@ async function emitProgress(
232
254
  await onProgress?.(updates);
233
255
  }
234
256
 
257
+ async function emitResultProgress(
258
+ onProgress: RunFixSessionOptions["onProgress"],
259
+ result: FixSessionResult,
260
+ updates: Partial<SessionState> = {}
261
+ ): Promise<void> {
262
+ await emitProgress(onProgress, {
263
+ currentPhase: result.phase,
264
+ phase: result.phase,
265
+ sessionStatus: result.sessionStatus,
266
+ currentAgent: null,
267
+ selectedFindingIds: result.selection.selectedFindingIds,
268
+ reviewOutcome: result.reviewOutcome,
269
+ handoffStatus: result.handoffStatus,
270
+ handoffId: result.handoffId,
271
+ handoffUpdatedAt: result.handoffUpdatedAt,
272
+ commitSha: result.commitSha,
273
+ ...updates,
274
+ });
275
+ }
276
+
235
277
  export async function runFixSession(
236
278
  config: Config,
237
279
  options: RunFixSessionOptions,
@@ -263,18 +305,7 @@ export async function runFixSession(
263
305
  unselectedFindings: [...artifact.findings],
264
306
  reason: resolvedSelection.error,
265
307
  });
266
- await emitProgress(options.onProgress, {
267
- currentPhase: result.phase,
268
- phase: result.phase,
269
- sessionStatus: result.sessionStatus,
270
- currentAgent: null,
271
- selectedFindingIds: result.selection.selectedFindingIds,
272
- reviewOutcome: result.reviewOutcome,
273
- handoffStatus: result.handoffStatus,
274
- handoffId: result.handoffId,
275
- handoffUpdatedAt: result.handoffUpdatedAt,
276
- commitSha: result.commitSha,
277
- });
308
+ await emitResultProgress(options.onProgress, result);
278
309
  return result;
279
310
  }
280
311
 
@@ -287,18 +318,7 @@ export async function runFixSession(
287
318
  reason: "Selection cancelled. Findings remain pending.",
288
319
  unselectedFindings: [...artifact.findings],
289
320
  });
290
- await emitProgress(options.onProgress, {
291
- currentPhase: result.phase,
292
- phase: result.phase,
293
- sessionStatus: result.sessionStatus,
294
- currentAgent: null,
295
- selectedFindingIds: result.selection.selectedFindingIds,
296
- reviewOutcome: result.reviewOutcome,
297
- handoffStatus: result.handoffStatus,
298
- handoffId: result.handoffId,
299
- handoffUpdatedAt: result.handoffUpdatedAt,
300
- commitSha: result.commitSha,
301
- });
321
+ await emitResultProgress(options.onProgress, result);
302
322
  return result;
303
323
  }
304
324
 
@@ -334,18 +354,7 @@ export async function runFixSession(
334
354
  selection: resolvedSelection.selection,
335
355
  unselectedFindings: [...artifact.findings],
336
356
  });
337
- await emitProgress(options.onProgress, {
338
- currentPhase: result.phase,
339
- phase: result.phase,
340
- sessionStatus: result.sessionStatus,
341
- currentAgent: null,
342
- selectedFindingIds: result.selection.selectedFindingIds,
343
- reviewOutcome: result.reviewOutcome,
344
- handoffStatus: result.handoffStatus,
345
- handoffId: result.handoffId,
346
- handoffUpdatedAt: result.handoffUpdatedAt,
347
- commitSha: result.commitSha,
348
- });
357
+ await emitResultProgress(options.onProgress, result);
349
358
  return result;
350
359
  }
351
360
 
@@ -412,7 +421,21 @@ export async function runFixSession(
412
421
  worktree,
413
422
  });
414
423
 
415
- if (result.reviewOutcome === "incomplete") {
424
+ if (result.handoffStatus) {
425
+ if (artifactWithFixResults.retainedWorktree) {
426
+ const artifactWithoutRetainedWorktree = await discardRetainedWorktree(
427
+ deps,
428
+ artifact,
429
+ worktree,
430
+ artifactWithFixResults.retainedWorktree
431
+ );
432
+ artifactForResult = artifactWithoutRetainedWorktree;
433
+ result = {
434
+ ...result,
435
+ artifact: artifactWithoutRetainedWorktree,
436
+ };
437
+ }
438
+ } else if (result.reviewOutcome === "incomplete") {
416
439
  try {
417
440
  retainedWorktree = deps.finalizeSessionWorktree(worktree) ?? undefined;
418
441
  } catch {
@@ -435,14 +458,11 @@ export async function runFixSession(
435
458
  };
436
459
  }
437
460
  } else if (artifactWithFixResults.retainedWorktree) {
438
- deps.discardSessionWorktree(
439
- buildRetainedCleanupWorktree(worktree, artifactWithFixResults.retainedWorktree)
440
- );
441
- const artifactWithoutRetainedWorktree = await deps.updateRetainedWorktree(
442
- CONFIG_DIR,
443
- artifact.projectPath,
444
- artifact.sessionId,
445
- undefined
461
+ const artifactWithoutRetainedWorktree = await discardRetainedWorktree(
462
+ deps,
463
+ artifact,
464
+ worktree,
465
+ artifactWithFixResults.retainedWorktree
446
466
  );
447
467
  artifactForResult = artifactWithoutRetainedWorktree;
448
468
  result = {
@@ -451,17 +471,7 @@ export async function runFixSession(
451
471
  };
452
472
  }
453
473
 
454
- await emitProgress(options.onProgress, {
455
- currentPhase: result.phase,
456
- phase: result.phase,
457
- sessionStatus: result.sessionStatus,
458
- currentAgent: null,
459
- selectedFindingIds: result.selection.selectedFindingIds,
460
- reviewOutcome: result.reviewOutcome,
461
- handoffStatus: result.handoffStatus,
462
- handoffId: result.handoffId,
463
- handoffUpdatedAt: result.handoffUpdatedAt,
464
- commitSha: result.commitSha,
474
+ await emitResultProgress(options.onProgress, result, {
465
475
  worktreeProjectPath: result.retainedWorktree?.worktreeProjectPath,
466
476
  worktreeBranch: result.retainedWorktree?.worktreeBranch,
467
477
  worktreeMergeReady: result.retainedWorktree?.mergeReady,
@@ -491,17 +501,9 @@ export async function runFixSession(
491
501
  (finding) => !selection.selectedFindingIds.includes(finding.id)
492
502
  ) ?? [],
493
503
  });
494
- await emitProgress(options.onProgress, {
504
+ await emitResultProgress(options.onProgress, result, {
495
505
  currentPhase: phase,
496
- phase: phase,
497
- sessionStatus: result.sessionStatus,
498
- currentAgent: null,
499
- selectedFindingIds: result.selection.selectedFindingIds,
500
- reviewOutcome: result.reviewOutcome,
501
- handoffStatus: result.handoffStatus,
502
- handoffId: result.handoffId,
503
- handoffUpdatedAt: result.handoffUpdatedAt,
504
- commitSha: result.commitSha,
506
+ phase,
505
507
  worktreeProjectPath: retainedWorktree?.worktreeProjectPath,
506
508
  worktreeBranch: retainedWorktree?.worktreeBranch,
507
509
  worktreeMergeReady: retainedWorktree?.mergeReady,
@@ -24,6 +24,9 @@ const DEFAULT_FINALIZE_RESULT_DEPENDENCIES: FinalizeResultDependencies = {
24
24
  appendLog,
25
25
  };
26
26
 
27
+ const MIXED_UNRESOLVED_REASON =
28
+ "Some selected findings were resolved, but others remain unresolved. Ralph retained the remediation worktree instead of creating a handoff because the partial edits may be unsafe to apply automatically.";
29
+
27
30
  export async function finalizeResult(
28
31
  input: FinalizeResultInput,
29
32
  deps: FinalizeResultDependencies = DEFAULT_FINALIZE_RESULT_DEPENDENCIES
@@ -39,13 +42,27 @@ export async function finalizeResult(
39
42
  const unselectedFindings = input.artifact.findings.filter(
40
43
  (finding) => !input.selection.selectedFindingIds.includes(finding.id)
41
44
  );
45
+ const hasResolvedSelectedFindings = input.fixResults.some(
46
+ (result) => result.status === "resolved"
47
+ );
48
+ const hasUnresolvedSelectedFindings = unresolvedSelectedFindings.length > 0;
49
+ const hasSkippedSelectedFindings = input.fixResults.some((result) => result.status === "skipped");
50
+ const shouldCreateHandoff = hasResolvedSelectedFindings && !hasUnresolvedSelectedFindings;
42
51
 
43
52
  let reviewOutcome: FixSessionResult["reviewOutcome"];
44
53
  let reason: string;
45
54
 
46
- if (unresolvedSelectedFindings.length > 0) {
55
+ if (hasUnresolvedSelectedFindings) {
47
56
  reviewOutcome = "incomplete";
48
- reason = "Some selected findings remain unresolved after remediation.";
57
+ reason = hasResolvedSelectedFindings
58
+ ? MIXED_UNRESOLVED_REASON
59
+ : "Some selected findings remain unresolved after remediation.";
60
+ } else if (hasResolvedSelectedFindings) {
61
+ reviewOutcome = "fixed-selected";
62
+ reason = "Selected findings were resolved by remediation.";
63
+ } else if (hasSkippedSelectedFindings) {
64
+ reviewOutcome = "fixed-selected";
65
+ reason = "Selected findings were skipped after verification.";
49
66
  } else {
50
67
  reviewOutcome = "fixed-selected";
51
68
  reason = "Selected findings were resolved by remediation.";
@@ -56,7 +73,7 @@ export async function finalizeResult(
56
73
  let handoffUpdatedAt: number | undefined;
57
74
  let commitSha: string | undefined;
58
75
 
59
- if (reviewOutcome === "fixed-selected") {
76
+ if (shouldCreateHandoff) {
60
77
  const handoff = await deps.createOrAutoApplyHandoff(undefined, {
61
78
  sessionId: input.artifact.sessionId,
62
79
  projectPath: input.artifact.projectPath,
@@ -24,6 +24,7 @@ import {
24
24
  type RunReviewSessionDependencies,
25
25
  runReviewSession,
26
26
  } from "@/lib/review-workflow/review/run-review-session";
27
+ import { mapSessionStatusToFinalStatus } from "@/lib/review-workflow/session-status";
27
28
  import { updateSessionState } from "@/lib/session";
28
29
  import { parseFixSummaryOutput, parseReviewSummaryOutput } from "@/lib/structured-output";
29
30
  import type {
@@ -203,18 +204,6 @@ function resetInterrupt(): void {
203
204
  interrupted = false;
204
205
  }
205
206
 
206
- function mapSessionStatusToFinalStatus(status: SessionStatus): CycleResult["finalStatus"] {
207
- if (status === "failed") {
208
- return "failed";
209
- }
210
-
211
- if (status === "interrupted") {
212
- return "interrupted";
213
- }
214
-
215
- return "completed";
216
- }
217
-
218
207
  function createSessionEndEntry(
219
208
  result: CycleResult | undefined,
220
209
  error: unknown,
@@ -0,0 +1,13 @@
1
+ import type { SessionEndEntry, SessionStatus } from "@/lib/types";
2
+
3
+ export function mapSessionStatusToFinalStatus(status: SessionStatus): SessionEndEntry["status"] {
4
+ if (status === "failed") {
5
+ return "failed";
6
+ }
7
+
8
+ if (status === "interrupted") {
9
+ return "interrupted";
10
+ }
11
+
12
+ return "completed";
13
+ }
@@ -1,3 +1,5 @@
1
+ import { removeTrailingCommas } from "@/lib/structured-output";
2
+
1
3
  interface ParseFramedJsonOptions<T> {
2
4
  extractedText: string | null;
3
5
  rawOutput: string;
@@ -10,53 +12,6 @@ function normalizeCandidate(candidate: string): string {
10
12
  return candidate.replace(/\r\n?/g, "\n").trim();
11
13
  }
12
14
 
13
- function removeTrailingCommas(candidate: string): string {
14
- let output = "";
15
- let inString = false;
16
- let escaped = false;
17
-
18
- for (let index = 0; index < candidate.length; index += 1) {
19
- const char = candidate[index];
20
-
21
- if (escaped) {
22
- output += char;
23
- escaped = false;
24
- continue;
25
- }
26
-
27
- if (char === "\\") {
28
- output += char;
29
- escaped = true;
30
- continue;
31
- }
32
-
33
- if (char === '"') {
34
- output += char;
35
- inString = !inString;
36
- continue;
37
- }
38
-
39
- if (inString || char !== ",") {
40
- output += char;
41
- continue;
42
- }
43
-
44
- let lookahead = index + 1;
45
- while (lookahead < candidate.length && /\s/u.test(candidate[lookahead] ?? "")) {
46
- lookahead += 1;
47
- }
48
-
49
- const nextChar = candidate[lookahead];
50
- if (nextChar === "}" || nextChar === "]") {
51
- continue;
52
- }
53
-
54
- output += char;
55
- }
56
-
57
- return output;
58
- }
59
-
60
15
  function extractFramedPayload(output: string, startToken: string, endToken: string): string | null {
61
16
  const normalized = normalizeCandidate(output);
62
17
  const startIndex = normalized.indexOf(startToken);
@@ -45,34 +45,39 @@ interface SessionStateGuardOptions {
45
45
  expectedSessionId?: string;
46
46
  }
47
47
 
48
- interface CreateSessionStateOptions {
49
- branch?: string;
48
+ interface CreateSessionStateOptions
49
+ extends Partial<
50
+ Pick<
51
+ SessionState,
52
+ | "branch"
53
+ | "state"
54
+ | "mode"
55
+ | "pid"
56
+ | "startTime"
57
+ | "lastHeartbeat"
58
+ | "sessionPath"
59
+ | "worktreeProjectPath"
60
+ | "worktreeBranch"
61
+ | "worktreeMergeReady"
62
+ | "worktreeCommitSha"
63
+ | "endTime"
64
+ | "reason"
65
+ | "currentPhase"
66
+ | "phase"
67
+ | "sessionStatus"
68
+ | "reviewOutcome"
69
+ | "handoffStatus"
70
+ | "handoffId"
71
+ | "handoffUpdatedAt"
72
+ | "commitSha"
73
+ | "artifactPath"
74
+ | "baselineCommitSha"
75
+ | "sourceBaselineFingerprint"
76
+ | "accumulatedFindings"
77
+ | "selectedFindingIds"
78
+ >
79
+ > {
50
80
  sessionId: string;
51
- state?: SessionStatus;
52
- mode?: SessionMode;
53
- pid?: number;
54
- startTime?: number;
55
- lastHeartbeat?: number;
56
- sessionPath?: string;
57
- worktreeProjectPath?: string;
58
- worktreeBranch?: string;
59
- worktreeMergeReady?: boolean;
60
- worktreeCommitSha?: string;
61
- endTime?: number;
62
- reason?: string;
63
- currentPhase?: ReviewPhase;
64
- phase?: ReviewPhase;
65
- sessionStatus?: WorkflowSessionStatus;
66
- reviewOutcome?: ReviewOutcome;
67
- handoffStatus?: HandoffStatus;
68
- handoffId?: string;
69
- handoffUpdatedAt?: number;
70
- commitSha?: string;
71
- artifactPath?: string;
72
- baselineCommitSha?: string;
73
- sourceBaselineFingerprint?: string;
74
- accumulatedFindings?: StoredFinding[];
75
- selectedFindingIds?: FindingId[];
76
81
  }
77
82
 
78
83
  export interface SessionState {
@@ -201,17 +206,28 @@ async function readSessionStateByPath(sessionStatePath: string): Promise<Session
201
206
  }
202
207
  }
203
208
 
209
+ async function readExpectedSessionStateByPath(
210
+ sessionStatePath: string,
211
+ options: SessionStateGuardOptions
212
+ ): Promise<SessionState | null> {
213
+ const existing = await readSessionStateByPath(sessionStatePath);
214
+ if (!existing) {
215
+ return null;
216
+ }
217
+
218
+ if (options.expectedSessionId && existing.sessionId !== options.expectedSessionId) {
219
+ return null;
220
+ }
221
+
222
+ return existing;
223
+ }
224
+
204
225
  async function removeSessionStateByPath(
205
226
  sessionStatePath: string,
206
227
  options: SessionStateGuardOptions = {}
207
228
  ): Promise<boolean> {
208
229
  return queueSessionStateWrite(sessionStatePath, async () => {
209
- const existing = await readSessionStateByPath(sessionStatePath);
210
- if (!existing) {
211
- return false;
212
- }
213
-
214
- if (options.expectedSessionId && existing.sessionId !== options.expectedSessionId) {
230
+ if (!(await readExpectedSessionStateByPath(sessionStatePath, options))) {
215
231
  return false;
216
232
  }
217
233
 
@@ -351,15 +367,11 @@ export async function updateSessionState(
351
367
  const sessionStatePath = getSessionStatePath(storageRoot, projectPath, sessionId);
352
368
 
353
369
  return await queueSessionStateWrite(sessionStatePath, async () => {
354
- const existing = await readSessionStateByPath(sessionStatePath);
370
+ const existing = await readExpectedSessionStateByPath(sessionStatePath, options);
355
371
  if (!existing) {
356
372
  return false;
357
373
  }
358
374
 
359
- if (options.expectedSessionId && existing.sessionId !== options.expectedSessionId) {
360
- return false;
361
- }
362
-
363
375
  const merged = { ...existing } as Record<string, unknown>;
364
376
  for (const [key, value] of Object.entries(updates)) {
365
377
  if (value === undefined) {
@@ -42,6 +42,12 @@ interface RepairedCandidate {
42
42
  changed: boolean;
43
43
  }
44
44
 
45
+ export interface JsonObjectSlice {
46
+ start: number;
47
+ end: number;
48
+ value: string;
49
+ }
50
+
45
51
  function normalizeCandidateText(candidate: string): string {
46
52
  return candidate
47
53
  .replace(/^\uFEFF/, "")
@@ -72,8 +78,8 @@ function extractJsonBlock(output: string): string | null {
72
78
  return match[1].trim();
73
79
  }
74
80
 
75
- function extractBalancedJsonObjects(text: string): string[] {
76
- const objects: string[] = [];
81
+ export function extractBalancedJsonObjectSlices(text: string): JsonObjectSlice[] {
82
+ const objects: JsonObjectSlice[] = [];
77
83
  let depth = 0;
78
84
  let startIndex = -1;
79
85
  let inString = false;
@@ -87,12 +93,12 @@ function extractBalancedJsonObjects(text: string): string[] {
87
93
  continue;
88
94
  }
89
95
 
90
- if (char === "\\") {
91
- escaped = true;
92
- continue;
93
- }
94
-
95
96
  if (inString) {
97
+ if (char === "\\") {
98
+ escaped = true;
99
+ continue;
100
+ }
101
+
96
102
  if (char === '"') {
97
103
  inString = false;
98
104
  }
@@ -122,7 +128,12 @@ function extractBalancedJsonObjects(text: string): string[] {
122
128
 
123
129
  depth -= 1;
124
130
  if (depth === 0 && startIndex >= 0) {
125
- objects.push(text.slice(startIndex, index + 1));
131
+ const endIndex = index + 1;
132
+ objects.push({
133
+ start: startIndex,
134
+ end: endIndex,
135
+ value: text.slice(startIndex, endIndex),
136
+ });
126
137
  startIndex = -1;
127
138
  }
128
139
  }
@@ -130,6 +141,10 @@ function extractBalancedJsonObjects(text: string): string[] {
130
141
  return objects;
131
142
  }
132
143
 
144
+ function extractBalancedJsonObjects(text: string): string[] {
145
+ return extractBalancedJsonObjectSlices(text).map((object) => object.value);
146
+ }
147
+
133
148
  function isolateJsonObject(candidate: string): string {
134
149
  const objects = extractBalancedJsonObjects(candidate);
135
150
  if (objects.length === 0) {
@@ -140,7 +155,7 @@ function isolateJsonObject(candidate: string): string {
140
155
  return lastObject?.trim() || candidate;
141
156
  }
142
157
 
143
- function removeTrailingCommas(candidate: string): string {
158
+ export function removeTrailingCommas(candidate: string): string {
144
159
  let output = "";
145
160
  let inString = false;
146
161
  let escaped = false;