praxis-agent 0.55.1 → 0.55.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.
@@ -8,13 +8,12 @@ import { formatClaudeCompactSummary } from '../native/compaction.js';
8
8
  import { isClaudeAgentId } from '../native/sidechain.js';
9
9
  import { assertNativeDataPlane, resolveDataPlanePaths, resolveScheduledTaskFile, } from '../persistence/data-plane.js';
10
10
  import { downloadClaudeFileResources, } from '../native/file-resources.js';
11
- import { selectClaudeActiveTranscript } from '../native/history.js';
12
11
  import { ClaudeFileHistory } from '../native/file-history.js';
13
12
  import { getClaudeAgentSetting, projectClaudeModelMessages, } from '../native/projection.js';
14
13
  import { projectNativeSessionEntries } from './native-session-projection.js';
15
14
  import { classifyClaudeInterruption, } from '../native/interruption.js';
16
15
  import { findUnresolvedClaudeToolCalls, getClaudeContentBlocks, } from '../native/tool-links.js';
17
- import { createClaudeAgentSettingEntry, createClaudeHookAttachmentEntries, createClaudeLastPromptEntry, createClaudeRuleAttachmentEntry, translateProviderEvents, } from '../native/translation.js';
16
+ import { createClaudeAgentSettingEntry, createClaudeHookAttachmentEntries, createClaudeRuleAttachmentEntry, translateProviderEvents, } from '../native/translation.js';
18
17
  import { AgentRunCancelledError, AgentRuntime, MALFORMED_TOOL_INPUT_MESSAGE, ModelProviderError, } from '../core/runtime.js';
19
18
  import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
20
19
  import { BackgroundTaskRuntime, } from './background-task-runtime.js';
@@ -34,6 +33,8 @@ import { lastUserPrompt, projectTranscriptDisplay, unresolvedActiveToolCallIds,
34
33
  import { InMemoryTranscriptStore } from '../persistence/in-memory-transcript-store.js';
35
34
  import { NativeTranscriptStore, } from '../persistence/native-transcript-store.js';
36
35
  import { NativeSessionTranscript, } from './native-session-transcript.js';
36
+ import { TurnPersistence } from './turn-persistence.js';
37
+ import { TurnAccounting } from './turn-accounting.js';
37
38
  import { SubagentLifecycleStore } from '../persistence/subagent-lifecycle-store.js';
38
39
  import { ModelCompactor } from './model-compactor.js';
39
40
  import { agentMemoryPrompt, ClaudeSubagentExecutor, StructuredOutputRegistry, } from './subagent-service.js';
@@ -249,18 +250,6 @@ function requireCompactionDurations(result) {
249
250
  }
250
251
  return { durationMs, durationWithoutRetriesMs };
251
252
  }
252
- function addToolDuration(value, total) {
253
- if (value === undefined)
254
- return total;
255
- if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
256
- throw new TypeError('durationToolMs must be a finite nonnegative number');
257
- }
258
- const next = total + value;
259
- if (!Number.isFinite(next) || next < 0) {
260
- throw new TypeError('durationToolMs total overflow');
261
- }
262
- return next;
263
- }
264
253
  function addApiDuration(value, total, field) {
265
254
  if (value === undefined)
266
255
  return total;
@@ -273,36 +262,6 @@ function addApiDuration(value, total, field) {
273
262
  }
274
263
  return next;
275
264
  }
276
- function createLineCountAccumulator() {
277
- let linesAdded = 0;
278
- let linesRemoved = 0;
279
- const addCounter = (current, value, field) => {
280
- if (value === undefined)
281
- return current;
282
- if (!Number.isSafeInteger(value) || value < 0) {
283
- throw new TypeError(`${field} must be a nonnegative safe integer`);
284
- }
285
- const next = current + value;
286
- if (!Number.isSafeInteger(next) || next < 0) {
287
- throw new TypeError(`${field} total must be a nonnegative safe integer`);
288
- }
289
- return next;
290
- };
291
- return {
292
- get linesAdded() {
293
- return linesAdded;
294
- },
295
- get linesRemoved() {
296
- return linesRemoved;
297
- },
298
- add(input) {
299
- if (input.isError)
300
- return;
301
- linesAdded = addCounter(linesAdded, input.linesAdded, 'linesAdded');
302
- linesRemoved = addCounter(linesRemoved, input.linesRemoved, 'linesRemoved');
303
- },
304
- };
305
- }
306
265
  export function workflowTokenTarget(prompt) {
307
266
  const match = /(?:^|\s)\+(\d+(?:\.\d+)?)([km])\b/iu.exec(prompt);
308
267
  if (!match)
@@ -495,22 +454,6 @@ function validSessionName(value) {
495
454
  ? name.toLocaleLowerCase()
496
455
  : null;
497
456
  }
498
- function projectMemoryMessages(entries) {
499
- return selectClaudeActiveTranscript(entries).flatMap((entry) => {
500
- if (typeof entry.uuid !== 'string')
501
- return [];
502
- return projectClaudeModelMessages([entry]).flatMap((message) => (message.role === 'user' || message.role === 'assistant') &&
503
- message.content.trim().length > 0
504
- ? [
505
- {
506
- id: entry.uuid,
507
- role: message.role,
508
- content: message.content,
509
- },
510
- ]
511
- : []);
512
- });
513
- }
514
457
  function successfulHookOutput(outcome) {
515
458
  return (outcome?.executions ?? []).flatMap((execution) => {
516
459
  const output = execution.stdout.trim();
@@ -2529,39 +2472,47 @@ export class ClaudeSessionService {
2529
2472
  }
2530
2473
  const sessionPaths = this.paths(sessionId);
2531
2474
  const toolResultDirectory = join(sessionPaths.projectRoot, sessionId, 'tool-results');
2532
- const runUnderLease = async (lease, nativeLease) => {
2533
- const loadedSnapshot = await lease.load();
2534
- let snapshot = {
2535
- entries: projectNativeSessionEntries(nativeLease.activeEvents()),
2536
- tail: loadedSnapshot.tail,
2475
+ const runUnderLease = async (persistence) => {
2476
+ const activeTracker = this.sessionCostTrackers.get(sessionId);
2477
+ if (!activeTracker) {
2478
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
2479
+ }
2480
+ const turnAccounting = new TurnAccounting({
2481
+ tracker: activeTracker,
2482
+ ...(this.options.pricing ? { pricing: this.options.pricing } : {}),
2483
+ });
2484
+ const projectionSnapshot = () => {
2485
+ const view = persistence.view();
2486
+ return {
2487
+ entries: [...view.projectionEntries],
2488
+ tail: view.projectionTail,
2489
+ };
2490
+ };
2491
+ const commitProjection = async (entries) => {
2492
+ await persistence.commit({ kind: 'projection', entries });
2537
2493
  };
2538
2494
  const activeTurnMessages = () => {
2539
2495
  // Native events remain authoritative; refresh the derived projection
2540
2496
  // after each append so memory/compaction sees the live branch.
2541
- snapshot = {
2542
- ...snapshot,
2543
- entries: projectNativeSessionEntries(nativeLease.activeEvents()),
2544
- };
2545
- return nativeLease.activeMessages();
2497
+ const view = persistence.refresh();
2498
+ return [...view.activeMessages];
2546
2499
  };
2547
- if (nativeLease &&
2548
- name !== undefined &&
2549
- this.options.sessionPersistence !== false) {
2500
+ if (name !== undefined && this.options.sessionPersistence !== false) {
2550
2501
  const marker = `<praxis-session-name>${name}</praxis-session-name>`;
2551
- if (!nativeLease
2552
- .activeEvents()
2553
- .some((event) => event.kind === 'messages' &&
2502
+ if (!persistence
2503
+ .view()
2504
+ .activeEvents.some((event) => event.kind === 'messages' &&
2554
2505
  event.messages.some((message) => message.role === 'user' && message.content === marker))) {
2555
- await nativeLease.appendMessages({
2556
- messages: [{ role: 'user', content: marker }],
2506
+ await persistence.commit({
2507
+ kind: 'messages',
2508
+ input: { messages: [{ role: 'user', content: marker }] },
2557
2509
  });
2558
2510
  }
2559
2511
  }
2560
- const nativeInterruption = nativeLease?.interruption();
2512
+ const nativeInterruption = persistence.view().interruption;
2561
2513
  if (nativeInterruption?.kind === 'indeterminate-tools')
2562
2514
  throw new Error(`Native tool execution is indeterminate: ${nativeInterruption.callIds.join(', ')}`);
2563
- const nativeResumeInterrupted = nativeLease !== undefined &&
2564
- this.options.resumeInterruptedTurn === true &&
2515
+ const nativeResumeInterrupted = this.options.resumeInterruptedTurn === true &&
2565
2516
  (nativeInterruption?.kind === 'interrupted-prompt' ||
2566
2517
  nativeInterruption?.kind === 'interrupted-turn');
2567
2518
  const shouldSkipUserPrompt = () => skipUserPrompt || nativeResumeInterrupted;
@@ -2572,48 +2523,35 @@ export class ClaudeSessionService {
2572
2523
  shellCommand === undefined &&
2573
2524
  !shouldSkipUserPrompt() &&
2574
2525
  !(this.options.approveRecovery !== undefined &&
2575
- findUnresolvedClaudeToolCalls(snapshot.entries).length > 0)) {
2576
- const interruption = classifyClaudeInterruption(snapshot.entries);
2526
+ findUnresolvedClaudeToolCalls(projectionSnapshot().entries).length >
2527
+ 0)) {
2528
+ const interruption = classifyClaudeInterruption(projectionSnapshot().entries);
2577
2529
  if ((interruption.kind === 'interrupted-prompt' ||
2578
2530
  interruption.kind === 'interrupted-turn') &&
2579
2531
  interruption.prompt !== undefined &&
2580
2532
  interruption.replayEntries !== undefined) {
2581
2533
  automaticReplayPrompt = interruption.prompt;
2582
- snapshot = {
2583
- entries: interruption.replayEntries,
2584
- tail: {
2585
- ...snapshot.tail,
2586
- branchParentId: interruption.replayParentUuid ?? null,
2587
- },
2588
- };
2589
2534
  }
2590
2535
  }
2591
2536
  const initialTransition = this.worktreeManager?.consumeTransition('__initial__');
2592
- if (initialTransition && snapshot.entries.length === 0) {
2537
+ if (initialTransition && projectionSnapshot().entries.length === 0) {
2593
2538
  const stateEntry = {
2594
2539
  type: 'worktree-state',
2595
2540
  worktreeSession: initialTransition.state,
2596
2541
  sessionId,
2597
2542
  };
2598
- const stateTail = await this.append(lease, snapshot.tail, stateEntry);
2599
- snapshot = {
2600
- entries: [...snapshot.entries, stateEntry],
2601
- tail: stateTail,
2602
- };
2543
+ await commitProjection([stateEntry]);
2603
2544
  }
2604
- this.restoreWorktree(snapshot.entries);
2605
- this.options.interactiveTools?.restore(sessionId, snapshot.entries);
2545
+ this.restoreWorktree(projectionSnapshot().entries);
2546
+ this.options.interactiveTools?.restore(sessionId, projectionSnapshot().entries);
2606
2547
  if (requireExisting &&
2607
2548
  name !== undefined &&
2608
- !this.hasSessionName(snapshot.entries, name)) {
2549
+ !this.hasSessionName(projectionSnapshot().entries, name)) {
2609
2550
  const entries = this.sessionNameEntries(sessionId, name);
2610
- const appendResult = await this.appendProjectionMany(snapshot.tail, entries);
2611
- snapshot = {
2612
- entries: [...snapshot.entries, ...entries],
2613
- tail: appendResult.tail,
2614
- };
2551
+ await commitProjection(entries);
2615
2552
  }
2616
- const agentName = this.options.agent ?? getClaudeAgentSetting(snapshot.entries);
2553
+ const agentName = this.options.agent ??
2554
+ getClaudeAgentSetting(projectionSnapshot().entries);
2617
2555
  const agent = this.resolveAgent(agentName);
2618
2556
  const provider = this.providerForAgent(agent);
2619
2557
  this.activeProvider = provider;
@@ -2660,11 +2598,9 @@ export class ClaudeSessionService {
2660
2598
  ...(this.options.fileRewindRoots ?? []),
2661
2599
  ])
2662
2600
  : null;
2663
- const unresolvedToolCalls = nativeLease
2664
- ? nativeInterruption?.kind === 'recoverable-tools'
2665
- ? [...nativeInterruption.calls]
2666
- : []
2667
- : findUnresolvedClaudeToolCalls(snapshot.entries);
2601
+ const unresolvedToolCalls = nativeInterruption?.kind === 'recoverable-tools'
2602
+ ? [...nativeInterruption.calls]
2603
+ : [];
2668
2604
  const pendingRecoveryToolCallIds = new Set(unresolvedToolCalls.map((call) => call.id));
2669
2605
  const pendingRecoveryHookOutcomes = [];
2670
2606
  let refreshRuntimeContext;
@@ -2678,26 +2614,25 @@ export class ClaudeSessionService {
2678
2614
  permission_mode: 'default',
2679
2615
  };
2680
2616
  const appendHookOutcome = async (outcome) => {
2681
- for (const entry of createClaudeHookAttachmentEntries(outcome, this.translationContext(sessionId, snapshot))) {
2682
- const tail = await this.append(lease, snapshot.tail, entry);
2683
- snapshot = { entries: [...snapshot.entries, entry], tail };
2684
- if (nativeLease) {
2685
- const attachment = entry.attachment;
2686
- const raw = attachment?.content;
2687
- const content = typeof raw === 'string'
2688
- ? raw
2689
- : Array.isArray(raw)
2690
- ? raw
2691
- .filter((value) => typeof value === 'string')
2692
- .join('\n')
2693
- : raw && typeof raw === 'object' && 'content' in raw
2694
- ? String(raw.content)
2695
- : '';
2696
- if (content) {
2697
- await nativeLease.appendMessages({
2698
- messages: [{ role: 'user', content }],
2699
- });
2700
- }
2617
+ for (const entry of createClaudeHookAttachmentEntries(outcome, this.translationContext(sessionId, projectionSnapshot()))) {
2618
+ const attachment = entry.attachment;
2619
+ const raw = attachment?.content;
2620
+ const content = typeof raw === 'string'
2621
+ ? raw
2622
+ : Array.isArray(raw)
2623
+ ? raw.filter((value) => typeof value === 'string').join('\n')
2624
+ : raw && typeof raw === 'object' && 'content' in raw
2625
+ ? String(raw.content)
2626
+ : '';
2627
+ if (content) {
2628
+ await persistence.commit({
2629
+ kind: 'messages',
2630
+ input: { messages: [{ role: 'user', content }] },
2631
+ projectionEntries: [entry],
2632
+ });
2633
+ }
2634
+ else {
2635
+ await commitProjection([entry]);
2701
2636
  }
2702
2637
  }
2703
2638
  };
@@ -2710,11 +2645,11 @@ export class ClaudeSessionService {
2710
2645
  };
2711
2646
  const flushRecoveryHookOutcomes = async () => {
2712
2647
  const entries = [];
2713
- let history = snapshot.entries;
2714
- let parentUuid = this.logicalTailUuid(snapshot.tail);
2648
+ let history = projectionSnapshot().entries;
2649
+ let parentUuid = this.logicalTailUuid(projectionSnapshot().tail);
2715
2650
  for (const outcome of pendingRecoveryHookOutcomes) {
2716
2651
  const outcomeEntries = createClaudeHookAttachmentEntries(outcome, {
2717
- ...this.translationContext(sessionId, snapshot),
2652
+ ...this.translationContext(sessionId, projectionSnapshot()),
2718
2653
  parentUuid,
2719
2654
  history,
2720
2655
  });
@@ -2728,8 +2663,7 @@ export class ClaudeSessionService {
2728
2663
  pendingRecoveryHookOutcomes.length = 0;
2729
2664
  return;
2730
2665
  }
2731
- const appendResult = await this.appendProjectionMany(snapshot.tail, entries);
2732
- snapshot = { entries: history, tail: appendResult.tail };
2666
+ await commitProjection(entries);
2733
2667
  pendingRecoveryHookOutcomes.length = 0;
2734
2668
  };
2735
2669
  const capabilities = this.toolCapabilities();
@@ -2870,7 +2804,7 @@ export class ClaudeSessionService {
2870
2804
  backgroundTaskNotifications: (waitForRunning) => taskTools.notifications(waitForRunning),
2871
2805
  }
2872
2806
  : {}),
2873
- notificationDelivered: (notification) => transcriptContainsBackgroundAgentNotification(snapshot.entries, notification),
2807
+ notificationDelivered: (notification) => transcriptContainsBackgroundAgentNotification(projectionSnapshot().entries, notification),
2874
2808
  stopOwnedBackgroundAgent: (ownerSessionId, agentId) => this.backgroundTasks.stopAgent(ownerSessionId, agentId),
2875
2809
  outputOwnedBackgroundAgent: (ownerSessionId, agentId, options) => this.backgroundTasks.outputAgent(ownerSessionId, agentId, options),
2876
2810
  sendOwnedBackgroundAgent: (ownerSessionId, agentId, message, summary, toolUseId) => this.sendOwnedBackgroundAgent(ownerSessionId, agentId, message, summary, toolUseId),
@@ -2883,7 +2817,7 @@ export class ClaudeSessionService {
2883
2817
  }
2884
2818
  const agentTools = subagentExecutor
2885
2819
  ? subagentExecutor.registry(sessionId, 0, (callId) => currentPromptId ??
2886
- this.promptIdForToolCall(snapshot.entries, callId))
2820
+ this.promptIdForToolCall(projectionSnapshot().entries, callId))
2887
2821
  : baseTools;
2888
2822
  const turnTools = this.workflowManager && subagentExecutor && agentTools
2889
2823
  ? new ClaudeWorkflowToolRegistry({
@@ -2895,7 +2829,7 @@ export class ClaudeSessionService {
2895
2829
  configRoot: this.options.configRoot,
2896
2830
  sessionId,
2897
2831
  promptIdForCall: (callId) => currentPromptId ??
2898
- this.promptIdForToolCall(snapshot.entries, callId),
2832
+ this.promptIdForToolCall(projectionSnapshot().entries, callId),
2899
2833
  defaultModel: provider.model ?? 'praxis/provider',
2900
2834
  tokenBudget: workflowTokenTarget(effectivePrompt),
2901
2835
  enabled: capabilities.has('Workflow'),
@@ -2948,13 +2882,13 @@ export class ClaudeSessionService {
2948
2882
  return interactiveMessageTools.execute(call, context);
2949
2883
  }
2950
2884
  const snapshotMessageId = currentPromptId ??
2951
- this.promptIdForToolCall(snapshot.entries, call.id);
2885
+ this.promptIdForToolCall(projectionSnapshot().entries, call.id);
2952
2886
  const assistantMessageId = lastAssistantUuid ??
2953
- this.assistantIdForToolCall(snapshot.entries, call.id);
2887
+ this.assistantIdForToolCall(projectionSnapshot().entries, call.id);
2954
2888
  if (!snapshotMessageId || !assistantMessageId) {
2955
2889
  throw new Error('Claude file history could not link tool call');
2956
2890
  }
2957
- const prepared = await fileHistory.prepareMutation(snapshot.entries, snapshotMessageId, path);
2891
+ const prepared = await fileHistory.prepareMutation(projectionSnapshot().entries, snapshotMessageId, path);
2958
2892
  let result;
2959
2893
  try {
2960
2894
  result = await interactiveMessageTools.execute(call, context);
@@ -2969,18 +2903,18 @@ export class ClaudeSessionService {
2969
2903
  }
2970
2904
  const entry = prepared.commit(assistantMessageId);
2971
2905
  if (entry) {
2972
- const tail = await this.append(lease, snapshot.tail, entry);
2973
- snapshot = { entries: [...snapshot.entries, entry], tail };
2974
- if (nativeLease) {
2975
- await nativeLease.appendMessages({
2906
+ await persistence.commit({
2907
+ kind: 'messages',
2908
+ input: {
2976
2909
  messages: [
2977
2910
  {
2978
2911
  role: 'user',
2979
2912
  content: `<praxis-file-history>${JSON.stringify(entry)}</praxis-file-history>`,
2980
2913
  },
2981
2914
  ],
2982
- });
2983
- }
2915
+ },
2916
+ projectionEntries: [entry],
2917
+ });
2984
2918
  }
2985
2919
  return result;
2986
2920
  },
@@ -3058,14 +2992,13 @@ export class ClaudeSessionService {
3058
2992
  const durableFollowUps = new DurableFollowUpTracker();
3059
2993
  const claimedToolCallIds = new Set();
3060
2994
  const observer = {
3061
- ...(nativeLease
3062
- ? {
3063
- toolExecutionStarted: async (call) => {
3064
- await nativeLease.beginToolExecution(call.id);
3065
- claimedToolCallIds.add(call.id);
3066
- },
3067
- }
3068
- : {}),
2995
+ toolExecutionStarted: async (call) => {
2996
+ await persistence.commit({
2997
+ kind: 'tool-execution-started',
2998
+ callId: call.id,
2999
+ });
3000
+ claimedToolCallIds.add(call.id);
3001
+ },
3069
3002
  modelRequestCompleted: async (input) => {
3070
3003
  observeModelRequestUsage(input);
3071
3004
  },
@@ -3081,13 +3014,12 @@ export class ClaudeSessionService {
3081
3014
  providerMessageId: `msg_${randomUUID().replaceAll('-', '')}`,
3082
3015
  model: provider.model ?? 'praxis/provider',
3083
3016
  },
3084
- ], this.translationContext(sessionId, snapshot));
3017
+ ], this.translationContext(sessionId, projectionSnapshot()));
3085
3018
  if (!entry)
3086
3019
  throw new Error('Could not translate assistant response');
3087
- const tail = await this.append(lease, snapshot.tail, entry);
3088
- snapshot = { entries: [...snapshot.entries, entry], tail };
3089
- lastAssistantUuid = nativeLease
3090
- ? await nativeLease.appendMessages({
3020
+ const nativeReceipt = await persistence.commit({
3021
+ kind: 'messages',
3022
+ input: {
3091
3023
  messages: [
3092
3024
  {
3093
3025
  role: 'assistant',
@@ -3101,29 +3033,26 @@ export class ClaudeSessionService {
3101
3033
  },
3102
3034
  ],
3103
3035
  model: provider.model ?? 'praxis/provider',
3104
- })
3105
- : typeof entry.uuid === 'string'
3106
- ? entry.uuid
3107
- : lastAssistantUuid;
3036
+ },
3037
+ projectionEntries: [entry],
3038
+ });
3039
+ if (nativeReceipt.kind !== 'messages')
3040
+ throw new Error('Turn persistence returned an invalid message receipt');
3041
+ lastAssistantUuid = nativeReceipt.eventId;
3108
3042
  },
3109
3043
  toolCompleted: async (call, toolResult) => {
3110
- currentTurnToolCalls += 1;
3111
3044
  const transition = this.worktreeManager?.consumeTransition(call.id);
3112
3045
  if (transition) {
3113
- this.options.contextAssembler?.invalidate?.({
3114
- lifecycleId: sessionId,
3115
- reason: 'worktree',
3116
- });
3117
3046
  const stateEntry = {
3118
3047
  type: 'worktree-state',
3119
3048
  worktreeSession: transition.state,
3120
3049
  sessionId,
3121
3050
  };
3122
- const stateTail = await this.append(lease, snapshot.tail, stateEntry);
3123
- snapshot = {
3124
- entries: [...snapshot.entries, stateEntry],
3125
- tail: stateTail,
3126
- };
3051
+ await commitProjection([stateEntry]);
3052
+ this.options.contextAssembler?.invalidate?.({
3053
+ lifecycleId: sessionId,
3054
+ reason: 'worktree',
3055
+ });
3127
3056
  }
3128
3057
  const permissionMode = this.options.interactiveTools?.consumeTransition(call.id);
3129
3058
  if (permissionMode) {
@@ -3132,21 +3061,18 @@ export class ClaudeSessionService {
3132
3061
  permissionMode,
3133
3062
  sessionId,
3134
3063
  };
3135
- const modeTail = await this.append(lease, snapshot.tail, modeEntry);
3136
- snapshot = {
3137
- entries: [...snapshot.entries, modeEntry],
3138
- tail: modeTail,
3139
- };
3140
- if (nativeLease) {
3141
- await nativeLease.appendMessages({
3064
+ await persistence.commit({
3065
+ kind: 'messages',
3066
+ input: {
3142
3067
  messages: [
3143
3068
  {
3144
3069
  role: 'user',
3145
3070
  content: `/permission-mode ${permissionMode}`,
3146
3071
  },
3147
3072
  ],
3148
- });
3149
- }
3073
+ },
3074
+ projectionEntries: [modeEntry],
3075
+ });
3150
3076
  // Interactive mode transitions change the runtime system
3151
3077
  // context for the very next provider request. Refresh here so
3152
3078
  // an EnterPlanMode/ExitPlanMode tool call is immediately
@@ -3154,43 +3080,21 @@ export class ClaudeSessionService {
3154
3080
  if (typeof refreshRuntimeContext === 'function')
3155
3081
  await refreshRuntimeContext();
3156
3082
  }
3157
- if (nativeLease) {
3158
- if (!claimedToolCallIds.has(call.id) && !signal?.aborted) {
3159
- await nativeLease.beginToolExecution(call.id);
3160
- claimedToolCallIds.add(call.id);
3161
- }
3162
- await nativeLease.appendToolCompletion({
3083
+ if (!claimedToolCallIds.has(call.id) && !signal?.aborted) {
3084
+ await persistence.commit({
3085
+ kind: 'tool-execution-started',
3163
3086
  callId: call.id,
3164
- result: toolResult,
3165
3087
  });
3088
+ claimedToolCallIds.add(call.id);
3166
3089
  }
3167
- else {
3168
- const [entry] = translateProviderEvents([
3169
- {
3170
- type: 'tool-result',
3171
- toolCallId: call.id,
3172
- content: toolResult.content,
3173
- ...(toolResult.contentBlocks
3174
- ? { contentBlocks: toolResult.contentBlocks }
3175
- : {}),
3176
- ...(toolResult.images ? { images: toolResult.images } : {}),
3177
- ...(toolResult.documents
3178
- ? { documents: toolResult.documents }
3179
- : {}),
3180
- isError: toolResult.isError,
3181
- ...(toolResult.nativeToolUseResult
3182
- ? { nativeToolUseResult: toolResult.nativeToolUseResult }
3183
- : {}),
3184
- ...(toolResult.nativeMcpMeta
3185
- ? { nativeMcpMeta: toolResult.nativeMcpMeta }
3186
- : {}),
3187
- },
3188
- ], this.translationContext(sessionId, snapshot));
3189
- if (!entry)
3190
- throw new Error('Could not translate tool result');
3191
- const tail = await this.append(lease, snapshot.tail, entry);
3192
- snapshot = { entries: [...snapshot.entries, entry], tail };
3193
- }
3090
+ await persistence.commit({
3091
+ kind: 'tool-completion',
3092
+ input: {
3093
+ callId: call.id,
3094
+ result: toolResult,
3095
+ },
3096
+ });
3097
+ currentTurnToolCalls += 1;
3194
3098
  if (!toolResult.isError && this.options.projectMemoryDirectory) {
3195
3099
  const pathValue = call.input.file_path;
3196
3100
  const path = typeof pathValue === 'string'
@@ -3220,23 +3124,23 @@ export class ClaudeSessionService {
3220
3124
  !toolResult.accessedPaths) {
3221
3125
  return;
3222
3126
  }
3223
- const attachedRulePaths = this.attachedRulePaths(snapshot.entries);
3127
+ const attachedRulePaths = this.attachedRulePaths(projectionSnapshot().entries);
3224
3128
  for (const filePath of toolResult.accessedPaths) {
3225
3129
  const rules = await this.options.conditionalRuleResolver.resolve(filePath, [...attachedRulePaths]);
3226
3130
  for (const rule of rules) {
3227
- const attachment = createClaudeRuleAttachmentEntry(rule, this.displayRulePath(rule.path), this.translationContext(sessionId, snapshot));
3228
- const attachmentTail = await this.append(lease, snapshot.tail, attachment);
3229
- snapshot = {
3230
- entries: [...snapshot.entries, attachment],
3231
- tail: attachmentTail,
3232
- };
3233
- if (nativeLease) {
3234
- const ruleContent = rule.content.trim();
3235
- if (ruleContent.length > 0) {
3236
- await nativeLease.appendMessages({
3131
+ const attachment = createClaudeRuleAttachmentEntry(rule, this.displayRulePath(rule.path), this.translationContext(sessionId, projectionSnapshot()));
3132
+ const ruleContent = rule.content.trim();
3133
+ if (ruleContent.length > 0) {
3134
+ await persistence.commit({
3135
+ kind: 'messages',
3136
+ input: {
3237
3137
  messages: [{ role: 'user', content: ruleContent }],
3238
- });
3239
- }
3138
+ },
3139
+ projectionEntries: [attachment],
3140
+ });
3141
+ }
3142
+ else {
3143
+ await commitProjection([attachment]);
3240
3144
  }
3241
3145
  attachedRulePaths.add(rule.path);
3242
3146
  await this.instructionLoaded(sessionId, {
@@ -3257,44 +3161,20 @@ export class ClaudeSessionService {
3257
3161
  },
3258
3162
  followUpUserMessagesCompleted: async (messages) => {
3259
3163
  for (const content of messages) {
3260
- if (nativeLease) {
3261
- await nativeLease.appendMessages({
3262
- messages: [{ role: 'user', content }],
3263
- });
3264
- currentTurnUserMessages?.push(content);
3265
- await Promise.all(this.sessionSubagentExecutors(sessionId, true).map((executor) => executor.acknowledgeNotifications([content])));
3266
- continue;
3267
- }
3268
- const [followUpEntry] = translateProviderEvents([{ type: 'user-text-block', text: content }], this.translationContext(sessionId, snapshot));
3269
- if (!followUpEntry) {
3270
- throw new Error('Could not translate tool follow-up message');
3271
- }
3272
- const followUpTail = await this.append(lease, snapshot.tail, followUpEntry);
3273
- snapshot = {
3274
- entries: [...snapshot.entries, followUpEntry],
3275
- tail: followUpTail,
3276
- };
3164
+ await persistence.commit({
3165
+ kind: 'messages',
3166
+ input: { messages: [{ role: 'user', content }] },
3167
+ });
3277
3168
  currentTurnUserMessages?.push(content);
3278
3169
  await Promise.all(this.sessionSubagentExecutors(sessionId, true).map((executor) => executor.acknowledgeNotifications([content])));
3279
3170
  }
3280
3171
  await durableFollowUps.followUpUserMessagesCompleted(messages);
3281
3172
  },
3282
3173
  userInputDelivered: async (item) => {
3283
- if (nativeLease) {
3284
- await nativeLease.appendMessages({
3285
- messages: [{ role: 'user', content: item.content }],
3286
- });
3287
- currentTurnUserMessages?.push(item.content);
3288
- return;
3289
- }
3290
- const [steeringEntry] = translateProviderEvents([{ type: 'user-text-block', text: item.content }], this.translationContext(sessionId, snapshot));
3291
- if (!steeringEntry)
3292
- throw new Error('Could not translate steering message');
3293
- const steeringTail = await this.append(lease, snapshot.tail, steeringEntry);
3294
- snapshot = {
3295
- entries: [...snapshot.entries, steeringEntry],
3296
- tail: steeringTail,
3297
- };
3174
+ await persistence.commit({
3175
+ kind: 'messages',
3176
+ input: { messages: [{ role: 'user', content: item.content }] },
3177
+ });
3298
3178
  currentTurnUserMessages?.push(item.content);
3299
3179
  },
3300
3180
  };
@@ -3338,31 +3218,23 @@ export class ClaudeSessionService {
3338
3218
  throw new Error(`Claude session tool call ${unresolvedToolCall.id} requires explicit recovery approval`);
3339
3219
  }
3340
3220
  const recoveryResults = await runtime.recoverToolCalls(unresolvedToolCalls, recoveryRequest);
3341
- const recoveryUsage = recoveryResults.reduce((usage, result) => result.usage ? mergeUsage(usage, result.usage) : usage, { inputTokens: 0, outputTokens: 0 });
3342
- const recoveryModelUsage = mergeSessionRawModelUsage(...recoveryResults.map((result) => result.isError ? undefined : result.modelUsage));
3343
- const foregroundLineChanges = createLineCountAccumulator();
3344
- for (const result of recoveryResults) {
3345
- foregroundLineChanges.add(result);
3346
- }
3347
3221
  if (this.options.agent &&
3348
3222
  agent &&
3349
- getClaudeAgentSetting(snapshot.entries) !== this.options.agent) {
3223
+ getClaudeAgentSetting(projectionSnapshot().entries) !==
3224
+ this.options.agent) {
3350
3225
  const agentSetting = createClaudeAgentSettingEntry(sessionId, this.options.agent);
3351
- const settingTail = await this.append(lease, snapshot.tail, agentSetting);
3352
- snapshot = {
3353
- entries: [...snapshot.entries, agentSetting],
3354
- tail: settingTail,
3355
- };
3356
- if (nativeLease) {
3357
- await nativeLease.appendMessages({
3226
+ await persistence.commit({
3227
+ kind: 'messages',
3228
+ input: {
3358
3229
  messages: [
3359
3230
  {
3360
3231
  role: 'user',
3361
3232
  content: `<praxis-agent-setting>${this.options.agent}</praxis-agent-setting>`,
3362
3233
  },
3363
3234
  ],
3364
- });
3365
- }
3235
+ },
3236
+ projectionEntries: [agentSetting],
3237
+ });
3366
3238
  }
3367
3239
  let agentSystem = null;
3368
3240
  let planModeMessage;
@@ -3429,13 +3301,6 @@ export class ClaudeSessionService {
3429
3301
  type: 'state',
3430
3302
  state: 'assembling-context',
3431
3303
  });
3432
- let compactionUsage = {
3433
- inputTokens: 0,
3434
- outputTokens: 0,
3435
- };
3436
- let compactionDurationMs;
3437
- let compactionDurationWithoutRetriesMs;
3438
- let compactionModelUsage;
3439
3304
  const budget = this.contextBudget(provider);
3440
3305
  const contextEngine = new ContextEngine({
3441
3306
  ...(budget ? { budget } : {}),
@@ -3471,7 +3336,7 @@ export class ClaudeSessionService {
3471
3336
  shellCommand === undefined && !shouldSkipUserPrompt()
3472
3337
  ? (this.options.extensions?.agentMentionMessages(effectivePrompt) ?? [])
3473
3338
  : [];
3474
- let compactionAnchorUuid = this.lastMessageUuid(snapshot.entries);
3339
+ let compactionAnchorUuid = this.lastMessageUuid(projectionSnapshot().entries);
3475
3340
  const contextTransitionPort = (pendingMessages = [], preservedUserMessages = []) => ({
3476
3341
  current: () => {
3477
3342
  const projection = contextPreparation.project({
@@ -3491,7 +3356,6 @@ export class ClaudeSessionService {
3491
3356
  ],
3492
3357
  }).envelope,
3493
3358
  propose: async () => {
3494
- const activeNativeLease = nativeLease;
3495
3359
  if (!budget)
3496
3360
  throw new Error('Context budget is unavailable');
3497
3361
  const definitions = contextPreparation.project().envelope.tools;
@@ -3547,15 +3411,15 @@ export class ClaudeSessionService {
3547
3411
  !promptContents.has(message.content)),
3548
3412
  ];
3549
3413
  if (earliest === 0 && preservedUserMessages[0] !== undefined) {
3550
- compactionLogicalParentId = activeNativeLease
3551
- .activeEvents()
3552
- .find((event) => event.kind === 'messages' &&
3414
+ compactionLogicalParentId = persistence
3415
+ .view()
3416
+ .activeEvents.find((event) => event.kind === 'messages' &&
3553
3417
  event.messages.some((message) => message.role === 'user' &&
3554
3418
  message.content === preservedUserMessages[0]))?.id;
3555
3419
  }
3556
3420
  }
3557
3421
  const memorySelection = sessionMemory
3558
- ? await this.selectMemoryPreservedCompact(sessionId, projectNativeSessionEntries(activeNativeLease.activeEvents()))
3422
+ ? await this.selectMemoryPreservedCompact(sessionId, persistence.view().projectionEntries)
3559
3423
  : null;
3560
3424
  if (memorySelection) {
3561
3425
  compactableMessages = [
@@ -3576,9 +3440,9 @@ export class ClaudeSessionService {
3576
3440
  preservedMessages = historyMessages.filter((message) => message.role === 'user');
3577
3441
  const anchor = preservedMessages[0];
3578
3442
  if (anchor !== undefined) {
3579
- compactionLogicalParentId = nativeLease
3580
- .activeEvents()
3581
- .find((event) => event.kind === 'messages' &&
3443
+ compactionLogicalParentId = persistence
3444
+ .view()
3445
+ .activeEvents.find((event) => event.kind === 'messages' &&
3582
3446
  event.messages.some((message) => message.role === 'user' &&
3583
3447
  message.content === anchor.content))?.id;
3584
3448
  }
@@ -3612,15 +3476,6 @@ export class ClaudeSessionService {
3612
3476
  const { durationMs, durationWithoutRetriesMs } = requireCompactionDurations(compacted);
3613
3477
  if (signal?.aborted)
3614
3478
  throw new AgentRunCancelledError();
3615
- const proposedCompactionDurationMs = (compactionDurationMs ?? 0) + durationMs;
3616
- if (!Number.isFinite(proposedCompactionDurationMs)) {
3617
- throw new TypeError('compaction durationMs total overflow');
3618
- }
3619
- const proposedCompactionDurationWithoutRetriesMs = (compactionDurationWithoutRetriesMs ?? 0) +
3620
- durationWithoutRetriesMs;
3621
- if (!Number.isFinite(proposedCompactionDurationWithoutRetriesMs)) {
3622
- throw new TypeError('compaction durationWithoutRetriesMs total overflow');
3623
- }
3624
3479
  const postTokens = estimateModelRequestTokens([
3625
3480
  { role: 'user', content: compacted.summary },
3626
3481
  ...preservedMessages,
@@ -3628,49 +3483,12 @@ export class ClaudeSessionService {
3628
3483
  const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
3629
3484
  ? compacted.model
3630
3485
  : provider.model;
3631
- const meaningfulUsage = hasNonZeroUsage(compacted.usage);
3632
- if (meaningfulUsage &&
3633
- (compactModel === undefined || compactModel.trim() === ''))
3634
- throw new Error('Auto compact usage requires a nonblank model identity');
3635
- const tracker = this.sessionCostTrackers.get(sessionId);
3636
- if (!tracker)
3637
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
3638
- let meteringTurnInput;
3639
- if (meaningfulUsage &&
3640
- compactModel !== undefined &&
3641
- compactModel.trim() !== '') {
3642
- const pricing = this.options.pricing?.resolve(compactModel);
3643
- const costUsd = pricing
3644
- ? usageCostUsd(compacted.usage, pricing)
3645
- : undefined;
3646
- meteringTurnInput = {
3647
- model: compactModel,
3648
- usage: compacted.usage,
3649
- ...(costUsd === undefined ? {} : { costUsd }),
3650
- ...(compacted.usage.webSearchRequests === undefined
3651
- ? {}
3652
- : {
3653
- webSearchRequests: compacted.usage.webSearchRequests,
3654
- }),
3655
- };
3656
- }
3657
- let meteringDurationsInput;
3658
- if (durationMs > 0 || durationWithoutRetriesMs > 0)
3659
- meteringDurationsInput = {
3660
- ...(durationMs === 0 ? {} : { apiDurationMs: durationMs }),
3661
- apiDurationWithoutRetriesMs: durationWithoutRetriesMs,
3662
- };
3663
- if (meteringTurnInput !== undefined ||
3664
- meteringDurationsInput !== undefined) {
3665
- const preflight = new ClaudeSessionCostTracker({
3666
- sessionId,
3667
- restored: tracker.snapshot(),
3668
- });
3669
- if (meteringTurnInput !== undefined)
3670
- preflight.recordTurn(meteringTurnInput);
3671
- if (meteringDurationsInput !== undefined)
3672
- preflight.recordDurations(meteringDurationsInput);
3673
- }
3486
+ const preparedCompaction = turnAccounting.prepareCompaction({
3487
+ usage: compacted.usage,
3488
+ ...(compactModel === undefined ? {} : { model: compactModel }),
3489
+ durationApiMs: durationMs,
3490
+ durationApiWithoutRetriesMs: durationWithoutRetriesMs,
3491
+ });
3674
3492
  const summaryMessage = {
3675
3493
  role: 'user',
3676
3494
  content: compacted.summary,
@@ -3705,25 +3523,30 @@ export class ClaudeSessionService {
3705
3523
  commit: async () => {
3706
3524
  if (signal?.aborted)
3707
3525
  throw new AgentRunCancelledError();
3708
- const committed = await replacement.commit(() => nativeLease.appendCompaction({
3709
- summary: compacted.summary,
3710
- trigger: 'auto',
3711
- preTokens,
3712
- postTokens,
3713
- durationMs,
3714
- preservedMessages: replayMessages,
3715
- ...(compactionLogicalParentId === undefined
3716
- ? {}
3717
- : { logicalParentId: compactionLogicalParentId }),
3718
- ...(memorySelection
3719
- ? {
3720
- direction: 'from',
3721
- messagesSummarized: compactableMessages.length,
3722
- preservePrefix: false,
3723
- }
3724
- : {}),
3526
+ const committed = await replacement.commit(() => persistence.commit({
3527
+ kind: 'compaction',
3528
+ input: {
3529
+ summary: compacted.summary,
3530
+ trigger: 'auto',
3531
+ preTokens,
3532
+ postTokens,
3533
+ durationMs,
3534
+ preservedMessages: replayMessages,
3535
+ ...(compactionLogicalParentId === undefined
3536
+ ? {}
3537
+ : { logicalParentId: compactionLogicalParentId }),
3538
+ ...(memorySelection
3539
+ ? {
3540
+ direction: 'from',
3541
+ messagesSummarized: compactableMessages.length,
3542
+ preservePrefix: false,
3543
+ }
3544
+ : {}),
3545
+ },
3725
3546
  }));
3726
3547
  const ids = committed.value;
3548
+ if (ids.kind !== 'compaction')
3549
+ throw new Error('Turn persistence returned an invalid compaction receipt');
3727
3550
  await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3728
3551
  if (this.options.hooks) {
3729
3552
  const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
@@ -3741,21 +3564,7 @@ export class ClaudeSessionService {
3741
3564
  preTokens,
3742
3565
  uuid: ids.boundaryId,
3743
3566
  });
3744
- compactionUsage = mergeUsage(compactionUsage, compacted.usage);
3745
- compactionDurationMs =
3746
- (compactionDurationMs ?? 0) + durationMs;
3747
- compactionDurationWithoutRetriesMs =
3748
- (compactionDurationWithoutRetriesMs ?? 0) +
3749
- durationWithoutRetriesMs;
3750
- if (meteringTurnInput !== undefined) {
3751
- tracker.recordTurn(meteringTurnInput);
3752
- }
3753
- if (compactModel !== undefined &&
3754
- compactModel.trim() !== '') {
3755
- compactionModelUsage = mergeSessionRawModelUsage(compactionModelUsage, { [compactModel]: compacted.usage });
3756
- }
3757
- if (meteringDurationsInput !== undefined)
3758
- tracker.recordDurations(meteringDurationsInput);
3567
+ preparedCompaction.commit();
3759
3568
  },
3760
3569
  };
3761
3570
  },
@@ -3783,7 +3592,7 @@ export class ClaudeSessionService {
3783
3592
  : index === 0
3784
3593
  ? { type: 'user-text', text: message.text }
3785
3594
  : { type: 'user-text-block', text: message.text };
3786
- const [userEntry] = translateProviderEvents([persistenceEvent], this.translationContext(sessionId, snapshot));
3595
+ const [userEntry] = translateProviderEvents([persistenceEvent], this.translationContext(sessionId, projectionSnapshot()));
3787
3596
  if (!userEntry)
3788
3597
  throw new Error('Could not translate user prompt');
3789
3598
  if (currentPromptId === null &&
@@ -3791,13 +3600,9 @@ export class ClaudeSessionService {
3791
3600
  currentPromptId = userEntry.uuid;
3792
3601
  compactionAnchorUuid ??= userEntry.uuid;
3793
3602
  }
3794
- const userTail = await this.append(lease, snapshot.tail, userEntry);
3795
- snapshot = {
3796
- entries: [...snapshot.entries, userEntry],
3797
- tail: userTail,
3798
- };
3799
- if (nativeLease) {
3800
- const nativePromptId = await nativeLease.appendMessages({
3603
+ const nativePrompt = await persistence.commit({
3604
+ kind: 'messages',
3605
+ input: {
3801
3606
  messages: [
3802
3607
  {
3803
3608
  role: 'user',
@@ -3808,29 +3613,29 @@ export class ClaudeSessionService {
3808
3613
  : {}),
3809
3614
  },
3810
3615
  ],
3811
- });
3812
- // File-history snapshots key their metadata to the durable
3813
- // native event id, not the scratch compatibility projection id.
3814
- currentPromptId = nativePromptId;
3815
- }
3616
+ },
3617
+ projectionEntries: [userEntry],
3618
+ });
3619
+ // File-history snapshots key their metadata to the durable native
3620
+ // event id, not the compatibility projection id.
3621
+ if (nativePrompt.kind !== 'messages')
3622
+ throw new Error('Turn persistence returned an invalid message receipt');
3623
+ currentPromptId = nativePrompt.eventId;
3816
3624
  }
3817
3625
  if (fileHistory && currentPromptId) {
3818
- const historySnapshot = await fileHistory.snapshot(snapshot.entries, currentPromptId);
3819
- const historyTail = await this.append(lease, snapshot.tail, historySnapshot);
3820
- snapshot = {
3821
- entries: [...snapshot.entries, historySnapshot],
3822
- tail: historyTail,
3823
- };
3824
- if (nativeLease) {
3825
- await nativeLease.appendMessages({
3626
+ const historySnapshot = await fileHistory.snapshot(projectionSnapshot().entries, currentPromptId);
3627
+ await persistence.commit({
3628
+ kind: 'messages',
3629
+ input: {
3826
3630
  messages: [
3827
3631
  {
3828
3632
  role: 'user',
3829
3633
  content: `<praxis-file-history>${JSON.stringify(historySnapshot)}</praxis-file-history>`,
3830
3634
  },
3831
3635
  ],
3832
- });
3833
- }
3636
+ },
3637
+ projectionEntries: [historySnapshot],
3638
+ });
3834
3639
  }
3835
3640
  if (this.options.hooks && !shouldSkipUserPrompt()) {
3836
3641
  const outcome = await this.options.hooks.run({
@@ -3844,11 +3649,7 @@ export class ClaudeSessionService {
3844
3649
  throw new Error(`UserPromptSubmit hook error: ${outcome.blockedReason}`);
3845
3650
  }
3846
3651
  }
3847
- let shellUsage = { inputTokens: 0, outputTokens: 0 };
3848
- let shellModelUsage;
3849
- let shellDurationApiMs;
3850
- let shellDurationApiWithoutRetriesMs;
3851
- let shellToolDurationMs;
3652
+ let shellResult;
3852
3653
  if (shellCommand !== undefined) {
3853
3654
  const call = {
3854
3655
  id: `shell_${randomUUID().replaceAll('-', '')}`,
@@ -3860,7 +3661,6 @@ export class ClaudeSessionService {
3860
3661
  callId: call.id,
3861
3662
  command: shellCommand,
3862
3663
  });
3863
- let shellResult;
3864
3664
  try {
3865
3665
  shellResult = await runtime.executeDirectToolCall(call, {
3866
3666
  cwd: this.activeCwd(),
@@ -3887,15 +3687,6 @@ export class ClaudeSessionService {
3887
3687
  });
3888
3688
  throw error;
3889
3689
  }
3890
- shellUsage = shellResult.usage ?? shellUsage;
3891
- shellDurationApiMs = shellResult.durationApiMs;
3892
- shellDurationApiWithoutRetriesMs =
3893
- shellResult.durationApiWithoutRetriesMs;
3894
- shellToolDurationMs = shellResult.durationToolMs;
3895
- if (!shellResult.isError) {
3896
- shellModelUsage = shellResult.modelUsage;
3897
- foregroundLineChanges.add(shellResult);
3898
- }
3899
3690
  const stdout = shellResult.processOutput?.stdout ??
3900
3691
  (shellResult.isError ? '' : shellResult.content);
3901
3692
  const stderr = shellResult.processOutput?.stderr ??
@@ -3905,22 +3696,15 @@ export class ClaudeSessionService {
3905
3696
  { type: 'bash-input', command: shellCommand },
3906
3697
  { type: 'bash-output', stdout, stderr },
3907
3698
  ], {
3908
- ...this.translationContext(sessionId, snapshot),
3699
+ ...this.translationContext(sessionId, projectionSnapshot()),
3909
3700
  createUuid: () => shellUuids.shift() ?? randomUUID(),
3910
3701
  });
3911
3702
  if (!inputEntry || !outputEntry) {
3912
3703
  throw new Error('Could not translate shell command result');
3913
3704
  }
3914
- const shellAppend = await this.appendProjectionMany(snapshot.tail, [
3915
- inputEntry,
3916
- outputEntry,
3917
- ]);
3918
- snapshot = {
3919
- entries: [...snapshot.entries, inputEntry, outputEntry],
3920
- tail: shellAppend.tail,
3921
- };
3922
- if (nativeLease) {
3923
- await nativeLease.appendMessages({
3705
+ await persistence.commit({
3706
+ kind: 'messages',
3707
+ input: {
3924
3708
  messages: [
3925
3709
  {
3926
3710
  role: 'user',
@@ -3931,8 +3715,9 @@ export class ClaudeSessionService {
3931
3715
  content: `<bash-stdout>${stdout}</bash-stdout><bash-stderr>${stderr}</bash-stderr>`,
3932
3716
  },
3933
3717
  ],
3934
- });
3935
- }
3718
+ },
3719
+ projectionEntries: [inputEntry, outputEntry],
3720
+ });
3936
3721
  currentTurnUserMessages.push(`<bash-stdout>${stdout}</bash-stdout><bash-stderr>${stderr}</bash-stderr>`);
3937
3722
  this.options.eventSink?.({
3938
3723
  type: 'shell-result',
@@ -3943,70 +3728,27 @@ export class ClaudeSessionService {
3943
3728
  });
3944
3729
  }
3945
3730
  if (shellCommand !== undefined) {
3946
- const tracker = this.sessionCostTrackers.get(sessionId);
3947
- if (!tracker) {
3948
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
3949
- }
3950
- const totalUsage = mergeUsage(recoveryUsage, shellUsage);
3951
- const turnModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, shellModelUsage);
3952
- let rawCostUsd;
3953
- if (turnModelUsage) {
3954
- for (const [model, usage] of Object.entries(turnModelUsage)) {
3955
- const pricing = this.options.pricing?.resolve(model);
3956
- const costUsd = pricing
3957
- ? usageCostUsd(usage, pricing)
3958
- : undefined;
3959
- if (costUsd !== undefined)
3960
- rawCostUsd = (rawCostUsd ?? 0) + costUsd;
3961
- tracker.recordTurn({
3962
- model,
3963
- usage,
3964
- ...(costUsd === undefined ? {} : { costUsd }),
3965
- ...(usage.webSearchRequests === undefined
3966
- ? {}
3967
- : { webSearchRequests: usage.webSearchRequests }),
3968
- });
3969
- }
3970
- }
3971
- let combinedToolDurationMs = 0;
3972
- for (const recoveryResult of recoveryResults) {
3973
- combinedToolDurationMs = addToolDuration(recoveryResult.durationToolMs, combinedToolDurationMs);
3974
- }
3975
- combinedToolDurationMs = addToolDuration(shellToolDurationMs, combinedToolDurationMs);
3976
- tracker.recordDurations({
3977
- ...(shellDurationApiMs === undefined
3978
- ? {}
3979
- : { apiDurationMs: shellDurationApiMs }),
3980
- ...(shellDurationApiWithoutRetriesMs === undefined
3981
- ? {}
3982
- : {
3983
- apiDurationWithoutRetriesMs: shellDurationApiWithoutRetriesMs,
3984
- }),
3985
- ...(combinedToolDurationMs === 0
3986
- ? {}
3987
- : { toolDurationMs: combinedToolDurationMs }),
3731
+ if (!shellResult)
3732
+ throw new Error('Shell command produced no result');
3733
+ const accounting = turnAccounting.complete({
3734
+ kind: 'shell',
3735
+ recovery: recoveryResults,
3736
+ result: shellResult,
3988
3737
  });
3989
- if (foregroundLineChanges.linesAdded !== 0 ||
3990
- foregroundLineChanges.linesRemoved !== 0) {
3991
- tracker.recordLineChanges({
3992
- ...(foregroundLineChanges.linesAdded === 0
3993
- ? {}
3994
- : { linesAdded: foregroundLineChanges.linesAdded }),
3995
- ...(foregroundLineChanges.linesRemoved === 0
3996
- ? {}
3997
- : { linesRemoved: foregroundLineChanges.linesRemoved }),
3998
- });
3999
- }
4000
3738
  turnCompleted = true;
4001
3739
  return {
4002
3740
  sessionId,
4003
3741
  text: '',
4004
- usage: totalUsage,
4005
- ...(rawCostUsd === undefined ? {} : { costUsd: rawCostUsd }),
4006
- ...(turnModelUsage ? { modelUsage: { ...turnModelUsage } } : {}),
4007
- ...(shellDurationApiMs === undefined
3742
+ usage: accounting.usage,
3743
+ ...(accounting.costUsd === undefined
3744
+ ? {}
3745
+ : { costUsd: accounting.costUsd }),
3746
+ ...(accounting.modelUsage === undefined
4008
3747
  ? {}
4009
- : { durationApiMs: shellDurationApiMs }),
3748
+ : { modelUsage: accounting.modelUsage }),
3749
+ ...(accounting.durationApiMs === undefined
3750
+ ? {}
3751
+ : { durationApiMs: accounting.durationApiMs }),
4010
3752
  };
4011
3753
  }
4012
3754
  if (shellCommand === undefined && budget) {
@@ -4086,9 +3828,7 @@ export class ClaudeSessionService {
4086
3828
  }
4087
3829
  const claimedAgentIds = new Set(this.sessionSubagentExecutors(sessionId, true).flatMap((executor) => executor.notificationClaimAgentIds()));
4088
3830
  await Promise.all(this.sessionSubagentExecutors(sessionId, true).map((executor) => {
4089
- const delivered = (notification) => transcriptContainsBackgroundAgentNotification(nativeLease
4090
- ? nativeLease.activeMessages()
4091
- : snapshot.entries, notification);
3831
+ const delivered = (notification) => transcriptContainsBackgroundAgentNotification(persistence.view().activeMessages, notification);
4092
3832
  return this.hostedSubagents.has(executor)
4093
3833
  ? executor.reconcileDetachedNotifications(delivered)
4094
3834
  : executor.reconcileDeliveredNotifications(delivered);
@@ -4096,11 +3836,8 @@ export class ClaudeSessionService {
4096
3836
  await subagentExecutor?.hydratePersistedTasks(sessionId, this.activeCwd(), claimedAgentIds);
4097
3837
  const background = await this.collectSubagentNotifications(sessionId, subagentExecutor ?? undefined);
4098
3838
  if (background) {
4099
- if (nativeLease) {
4100
- for (const message of background.messages) {
4101
- await this.appendBackgroundNotification(sessionId, message, nativeLease);
4102
- }
4103
- }
3839
+ for (const message of background.messages)
3840
+ await this.appendBackgroundNotification(sessionId, message, persistence);
4104
3841
  messages.push(...background.messages);
4105
3842
  }
4106
3843
  const workflow = await this.workflowManager?.notifications(true);
@@ -4226,103 +3963,15 @@ export class ClaudeSessionService {
4226
3963
  if (!finalLeafUuid) {
4227
3964
  throw new Error('Could not locate final assistant response');
4228
3965
  }
4229
- if (!nativeLease && !skipUserPrompt) {
4230
- const lastPrompt = createClaudeLastPromptEntry({
4231
- sessionId,
4232
- lastPrompt: effectivePrompt,
4233
- leafUuid: finalLeafUuid,
4234
- });
4235
- const tail = await this.append(lease, snapshot.tail, lastPrompt);
4236
- snapshot = {
4237
- entries: [...snapshot.entries, lastPrompt],
4238
- tail,
4239
- };
4240
- }
4241
- const totalUsage = mergeUsage(mergeUsage(mergeUsage(recoveryUsage, compactionUsage), shellUsage), result.usage);
4242
- const tracker = this.sessionCostTrackers.get(sessionId);
4243
- if (!tracker) {
4244
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
4245
- }
4246
- const turnModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, compactionModelUsage, shellModelUsage, result.modelUsage);
4247
- const combinedDurationMs = compactionDurationMs === undefined &&
4248
- result.durationApiMs === undefined
4249
- ? undefined
4250
- : (compactionDurationMs ?? 0) + (result.durationApiMs ?? 0);
4251
- let rawCostUsd;
4252
- if (turnModelUsage) {
4253
- for (const [model, usage] of Object.entries(turnModelUsage)) {
4254
- const pricing = this.options.pricing?.resolve(model);
4255
- const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
4256
- if (costUsd !== undefined)
4257
- rawCostUsd = (rawCostUsd ?? 0) + costUsd;
4258
- }
4259
- }
4260
- // Auto-compaction metering was already recorded atomically with each
4261
- // committed boundary, and externally metered tool-summary metrics were
4262
- // committed through the summary callback, so the live tracker receives
4263
- // only the unrecorded subset here. The inclusive public aggregates
4264
- // above still contain every row and duration.
4265
- const trackedModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, shellModelUsage, result.unrecordedModelUsage ?? result.modelUsage);
4266
- if (trackedModelUsage) {
4267
- for (const [model, usage] of Object.entries(trackedModelUsage)) {
4268
- const pricing = this.options.pricing?.resolve(model);
4269
- const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
4270
- tracker.recordTurn({
4271
- model,
4272
- usage,
4273
- ...(costUsd === undefined ? {} : { costUsd }),
4274
- ...(usage.webSearchRequests === undefined
4275
- ? {}
4276
- : { webSearchRequests: usage.webSearchRequests }),
4277
- });
4278
- }
4279
- }
4280
- let combinedToolDurationMs = 0;
4281
- for (const recoveryResult of recoveryResults) {
4282
- combinedToolDurationMs = addToolDuration(recoveryResult.durationToolMs, combinedToolDurationMs);
4283
- }
4284
- combinedToolDurationMs = addToolDuration(result.durationToolMs, combinedToolDurationMs);
4285
- const trackedDurationApiMs = result.unrecordedDurationApiMs ?? result.durationApiMs;
4286
- const trackedDurationApiWithoutRetriesMs = result.unrecordedDurationApiWithoutRetriesMs ??
4287
- result.durationApiWithoutRetriesMs;
4288
- tracker.recordDurations({
4289
- ...(trackedDurationApiMs === undefined
4290
- ? {}
4291
- : { apiDurationMs: trackedDurationApiMs }),
4292
- ...(trackedDurationApiWithoutRetriesMs === undefined
4293
- ? {}
4294
- : {
4295
- apiDurationWithoutRetriesMs: trackedDurationApiWithoutRetriesMs,
4296
- }),
4297
- ...(combinedToolDurationMs === 0
4298
- ? {}
4299
- : { toolDurationMs: combinedToolDurationMs }),
4300
- });
4301
- foregroundLineChanges.add({
4302
- isError: false,
4303
- ...(result.linesAdded === undefined
4304
- ? {}
4305
- : { linesAdded: result.linesAdded }),
4306
- ...(result.linesRemoved === undefined
4307
- ? {}
4308
- : { linesRemoved: result.linesRemoved }),
3966
+ const accounting = turnAccounting.complete({
3967
+ kind: 'runtime',
3968
+ recovery: recoveryResults,
3969
+ result,
4309
3970
  });
4310
- if (foregroundLineChanges.linesAdded !== 0 ||
4311
- foregroundLineChanges.linesRemoved !== 0) {
4312
- tracker.recordLineChanges({
4313
- ...(foregroundLineChanges.linesAdded === 0
4314
- ? {}
4315
- : { linesAdded: foregroundLineChanges.linesAdded }),
4316
- ...(foregroundLineChanges.linesRemoved === 0
4317
- ? {}
4318
- : { linesRemoved: foregroundLineChanges.linesRemoved }),
4319
- });
4320
- }
4321
3971
  const memorySnapshot = activeTurnMessages();
4322
- const nativeMemoryMessageId = nativeLease
4323
- ? projectNativeSessionEntries(nativeLease.activeEvents()).at(-1)
4324
- ?.uuid
4325
- : undefined;
3972
+ const nativeMemoryMessageId = persistence
3973
+ .view()
3974
+ .projectionEntries.at(-1)?.uuid;
4326
3975
  const providerProjection = contextPreparation.project({
4327
3976
  includeMemory: false,
4328
3977
  });
@@ -4344,18 +3993,16 @@ export class ClaudeSessionService {
4344
3993
  occupancyTokens: currentContextTokens,
4345
3994
  toolCalls: currentTurnToolCalls,
4346
3995
  messages: memorySnapshot,
4347
- projectMessages: nativeLease
4348
- ? memorySnapshot.flatMap((message, index) => (message.role === 'user' || message.role === 'assistant') &&
4349
- message.content.trim().length > 0
4350
- ? [
4351
- {
4352
- id: `${finalLeafUuid ?? sessionId}:${index}`,
4353
- role: message.role,
4354
- content: message.content,
4355
- },
4356
- ]
4357
- : [])
4358
- : projectMemoryMessages(snapshot.entries),
3996
+ projectMessages: memorySnapshot.flatMap((message, index) => (message.role === 'user' || message.role === 'assistant') &&
3997
+ message.content.trim().length > 0
3998
+ ? [
3999
+ {
4000
+ id: `${finalLeafUuid ?? sessionId}:${index}`,
4001
+ role: message.role,
4002
+ content: message.content,
4003
+ },
4004
+ ]
4005
+ : []),
4359
4006
  directMaintenance: projectMemoryMaintained,
4360
4007
  });
4361
4008
  turnCompleted = true;
@@ -4364,12 +4011,16 @@ export class ClaudeSessionService {
4364
4011
  text: structuredCapture && structuredCapture.calls === 1
4365
4012
  ? JSON.stringify(structuredCapture.value)
4366
4013
  : result.text,
4367
- usage: totalUsage,
4368
- ...(combinedDurationMs === undefined
4014
+ usage: accounting.usage,
4015
+ ...(accounting.durationApiMs === undefined
4016
+ ? {}
4017
+ : { durationApiMs: accounting.durationApiMs }),
4018
+ ...(accounting.costUsd === undefined
4369
4019
  ? {}
4370
- : { durationApiMs: combinedDurationMs }),
4371
- ...(rawCostUsd === undefined ? {} : { costUsd: rawCostUsd }),
4372
- ...(turnModelUsage ? { modelUsage: { ...turnModelUsage } } : {}),
4020
+ : { costUsd: accounting.costUsd }),
4021
+ ...(accounting.modelUsage === undefined
4022
+ ? {}
4023
+ : { modelUsage: accounting.modelUsage }),
4373
4024
  ...(structuredCapture && structuredCapture.calls === 1
4374
4025
  ? { structuredOutput: structuredCapture.value }
4375
4026
  : {}),
@@ -4405,12 +4056,8 @@ export class ClaudeSessionService {
4405
4056
  : { kind: 'resume', atEventId: resumeSessionAt }
4406
4057
  : { kind: 'start' };
4407
4058
  result = await nativeTranscript.withLease(activationKind, async (nativeLease) => {
4408
- const scratch = new InMemoryTranscriptStore();
4409
- const scratchResult = await scratch.withLease((scratchLease) => runUnderLease(scratchLease, nativeLease));
4410
- if (scratchResult.status === 'conflict') {
4411
- throw new Error(`native scratch transcript conflict: ${scratchResult.reason}`);
4412
- }
4413
- return scratchResult.value;
4059
+ const persistence = new TurnPersistence({ native: nativeLease });
4060
+ return runUnderLease(persistence);
4414
4061
  });
4415
4062
  }
4416
4063
  return result;
@@ -4788,6 +4435,16 @@ export class ClaudeSessionService {
4788
4435
  });
4789
4436
  }
4790
4437
  async appendBackgroundNotification(sessionId, content, nativeLease) {
4438
+ if (nativeLease instanceof TurnPersistence) {
4439
+ if (nativeLease.view().activeMessages.length === 0) {
4440
+ throw new Error(`Native session not found: ${sessionId}`);
4441
+ }
4442
+ await nativeLease.commit({
4443
+ kind: 'messages',
4444
+ input: { messages: [{ role: 'user', content }] },
4445
+ });
4446
+ return true;
4447
+ }
4791
4448
  if (nativeLease) {
4792
4449
  if (nativeLease.activeMessages().length === 0) {
4793
4450
  throw new Error(`Native session not found: ${sessionId}`);
@@ -5177,32 +4834,6 @@ export class ClaudeSessionService {
5177
4834
  }
5178
4835
  return factory(base, operations, sessionId, enabledTools);
5179
4836
  }
5180
- async append(lease, tail, entry) {
5181
- void lease;
5182
- return this.appendProjectionMany(tail, [entry]).then((result) => result.tail);
5183
- }
5184
- /**
5185
- * Update the in-memory compatibility projection used by the turn pipeline.
5186
- * Authoritative native persistence is performed by NativeSessionTranscript;
5187
- * these entries are never written to the native event store.
5188
- */
5189
- async appendProjectionMany(tail, entries) {
5190
- if (entries.length === 0)
5191
- throw new Error('Cannot append an empty projection');
5192
- const lastUuid = [...entries]
5193
- .reverse()
5194
- .find((entry) => typeof entry.uuid === 'string')?.uuid;
5195
- return {
5196
- status: 'appended',
5197
- tail: {
5198
- ...tail,
5199
- byteLength: tail.byteLength + entries.length,
5200
- lastLineHash: `projection:${tail.byteLength + entries.length}`,
5201
- lastEventId: typeof lastUuid === 'string' ? lastUuid : tail.lastEventId,
5202
- ...(tail.branchParentId === undefined ? {} : { branchParentId: null }),
5203
- },
5204
- };
5205
- }
5206
4837
  logicalTailUuid(tail) {
5207
4838
  return tail.branchParentId ?? tail.lastEventId;
5208
4839
  }