praxis-agent 0.55.0 → 0.55.2

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,22 +8,21 @@ 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';
21
20
  import { backgroundAgentNotificationMarkers, } from './background-agent-manager.js';
22
21
  import { usageCostUsd } from '../core/usage.js';
23
22
  import { isSessionId } from '../core/session.js';
24
- import { ActiveTurnInputMailbox, } from '../core/active-turn-input.js';
25
23
  import { ContextBudget, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
26
24
  import { ContextEngine } from './context-engine.js';
25
+ import { ContextPreparation } from './context-preparation.js';
27
26
  import { TurnMemoryCoordinator } from './turn-memory-coordinator.js';
28
27
  import { injectFirstUserMessageContext, projectContextSnapshot, } from '../core/context.js';
29
28
  import { assembleContextSnapshot } from '../core/prompt-composer.js';
@@ -34,11 +33,13 @@ 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';
40
41
  import { ScheduledPromptManager, } from './scheduled-prompt-manager.js';
41
- import { TurnTerminalController } from './turn-lifecycle.js';
42
+ import { TurnCoordinator } from './turn-lifecycle.js';
42
43
  import { ClaudeScheduledToolRegistry } from '../tools/claude-scheduled-tools.js';
43
44
  import { ClaudeTaskToolRegistry } from '../tools/claude-task-tools.js';
44
45
  import { ClaudeWorkflowToolRegistry } from '../tools/claude-workflow-tools.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();
@@ -684,12 +627,16 @@ export class ClaudeSessionService {
684
627
  hookLifecycle;
685
628
  leadOperations;
686
629
  fileChangeWatcher;
687
- activeTurnInputs = new Map();
630
+ turnCoordinator;
688
631
  runtimeCwd;
689
632
  constructor(options) {
690
633
  const dataPlane = options.dataPlane ?? 'native';
691
634
  assertNativeDataPlane(dataPlane);
692
635
  this.options = { ...options, dataPlane };
636
+ this.turnCoordinator = new TurnCoordinator({
637
+ eventSink: options.eventSink ?? (() => undefined),
638
+ createSteeringId: randomUUID,
639
+ });
693
640
  this.assertNativeTranscriptOptions();
694
641
  this.leadOperations = options.teamLeadOperations ?? null;
695
642
  this.hookLifecycle = new HookLifecycle(options.hooks, options.eventSink);
@@ -899,18 +846,7 @@ export class ClaudeSessionService {
899
846
  }
900
847
  async close() {
901
848
  this.closing = true;
902
- for (const { mailbox } of this.activeTurnInputs.values()) {
903
- if (!mailbox)
904
- continue;
905
- for (const item of mailbox.close()) {
906
- this.options.eventSink?.({
907
- type: 'user-input-rejected',
908
- id: item.id,
909
- content: item.content,
910
- reason: 'closed',
911
- });
912
- }
913
- }
849
+ this.turnCoordinator.close();
914
850
  await this.fileChangeWatcher?.close(5_000);
915
851
  await this.hookLifecycle.close();
916
852
  await this.drainDetachedHookRuns(5_000);
@@ -1330,28 +1266,10 @@ export class ClaudeSessionService {
1330
1266
  });
1331
1267
  }
1332
1268
  steer(sessionId, content) {
1333
- const active = this.activeTurnInputs.get(sessionId);
1334
- if (!active)
1335
- return { kind: 'no-active-turn' };
1336
- const mailbox = active.mailbox;
1337
- if (!mailbox)
1338
- return { kind: 'not-steerable' };
1339
- const result = mailbox.enqueue(content);
1340
- if (result.kind === 'accepted')
1341
- return result;
1342
- if (result.kind === 'empty')
1343
- return result;
1344
- return { kind: 'turn-completing' };
1269
+ return this.turnCoordinator.steer(sessionId, content);
1345
1270
  }
1346
1271
  withdrawSteering(sessionId, id) {
1347
- const active = this.activeTurnInputs.get(sessionId);
1348
- if (!active)
1349
- return { kind: 'no-active-turn' };
1350
- const mailbox = active.mailbox;
1351
- if (!mailbox)
1352
- return { kind: 'not-steerable' };
1353
- const result = mailbox.withdraw(id);
1354
- return result.kind === 'withdrawn' ? result : { kind: 'not-pending' };
1272
+ return this.turnCoordinator.withdrawSteering(sessionId, id);
1355
1273
  }
1356
1274
  async resumeShell(sessionId, command, signal, name, resumeSessionAt) {
1357
1275
  this.worktreeManager?.bindSession(sessionId);
@@ -2535,29 +2453,8 @@ export class ClaudeSessionService {
2535
2453
  const documents = submission.kind === 'prompt' ? (submission.documents ?? []) : [];
2536
2454
  const shellCommand = submission.kind === 'shell' ? submission.command : undefined;
2537
2455
  const skipUserPrompt = submission.kind === 'retry';
2538
- const controller = new TurnTerminalController(this.options.eventSink ?? (() => undefined));
2539
- let activeTurnInput;
2540
- let activeTurnRecord;
2541
- try {
2456
+ return this.turnCoordinator.run(request, async ({ emit, steering }) => {
2542
2457
  this.assertTurnWritable();
2543
- if (prompt.length === 0 && images.length === 0 && documents.length === 0)
2544
- throw new Error('Prompt must not be empty');
2545
- if (name !== undefined && name.length === 0) {
2546
- throw new Error('Session name must not be empty');
2547
- }
2548
- if (shellCommand !== undefined && shellCommand.trim().length === 0) {
2549
- throw new Error('Shell command must not be empty');
2550
- }
2551
- if (this.activeTurnInputs.has(sessionId)) {
2552
- throw new Error(`conflict: locked (session ${sessionId} already has an active turn)`);
2553
- }
2554
- activeTurnRecord =
2555
- shellCommand === undefined
2556
- ? {
2557
- mailbox: (activeTurnInput = new ActiveTurnInputMailbox(randomUUID)),
2558
- }
2559
- : {};
2560
- this.activeTurnInputs.set(sessionId, activeTurnRecord);
2561
2458
  await this.activateSessionCostTracker(sessionId);
2562
2459
  await this.ensureFileResources(sessionId, signal);
2563
2460
  this.worktreeManager?.bindSession(sessionId);
@@ -2575,39 +2472,47 @@ export class ClaudeSessionService {
2575
2472
  }
2576
2473
  const sessionPaths = this.paths(sessionId);
2577
2474
  const toolResultDirectory = join(sessionPaths.projectRoot, sessionId, 'tool-results');
2578
- const runUnderLease = async (lease, nativeLease) => {
2579
- const loadedSnapshot = await lease.load();
2580
- let snapshot = {
2581
- entries: projectNativeSessionEntries(nativeLease.activeEvents()),
2582
- 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 });
2583
2493
  };
2584
2494
  const activeTurnMessages = () => {
2585
2495
  // Native events remain authoritative; refresh the derived projection
2586
2496
  // after each append so memory/compaction sees the live branch.
2587
- snapshot = {
2588
- ...snapshot,
2589
- entries: projectNativeSessionEntries(nativeLease.activeEvents()),
2590
- };
2591
- return nativeLease.activeMessages();
2497
+ const view = persistence.refresh();
2498
+ return [...view.activeMessages];
2592
2499
  };
2593
- if (nativeLease &&
2594
- name !== undefined &&
2595
- this.options.sessionPersistence !== false) {
2500
+ if (name !== undefined && this.options.sessionPersistence !== false) {
2596
2501
  const marker = `<praxis-session-name>${name}</praxis-session-name>`;
2597
- if (!nativeLease
2598
- .activeEvents()
2599
- .some((event) => event.kind === 'messages' &&
2502
+ if (!persistence
2503
+ .view()
2504
+ .activeEvents.some((event) => event.kind === 'messages' &&
2600
2505
  event.messages.some((message) => message.role === 'user' && message.content === marker))) {
2601
- await nativeLease.appendMessages({
2602
- messages: [{ role: 'user', content: marker }],
2506
+ await persistence.commit({
2507
+ kind: 'messages',
2508
+ input: { messages: [{ role: 'user', content: marker }] },
2603
2509
  });
2604
2510
  }
2605
2511
  }
2606
- const nativeInterruption = nativeLease?.interruption();
2512
+ const nativeInterruption = persistence.view().interruption;
2607
2513
  if (nativeInterruption?.kind === 'indeterminate-tools')
2608
2514
  throw new Error(`Native tool execution is indeterminate: ${nativeInterruption.callIds.join(', ')}`);
2609
- const nativeResumeInterrupted = nativeLease !== undefined &&
2610
- this.options.resumeInterruptedTurn === true &&
2515
+ const nativeResumeInterrupted = this.options.resumeInterruptedTurn === true &&
2611
2516
  (nativeInterruption?.kind === 'interrupted-prompt' ||
2612
2517
  nativeInterruption?.kind === 'interrupted-turn');
2613
2518
  const shouldSkipUserPrompt = () => skipUserPrompt || nativeResumeInterrupted;
@@ -2618,48 +2523,35 @@ export class ClaudeSessionService {
2618
2523
  shellCommand === undefined &&
2619
2524
  !shouldSkipUserPrompt() &&
2620
2525
  !(this.options.approveRecovery !== undefined &&
2621
- findUnresolvedClaudeToolCalls(snapshot.entries).length > 0)) {
2622
- const interruption = classifyClaudeInterruption(snapshot.entries);
2526
+ findUnresolvedClaudeToolCalls(projectionSnapshot().entries).length >
2527
+ 0)) {
2528
+ const interruption = classifyClaudeInterruption(projectionSnapshot().entries);
2623
2529
  if ((interruption.kind === 'interrupted-prompt' ||
2624
2530
  interruption.kind === 'interrupted-turn') &&
2625
2531
  interruption.prompt !== undefined &&
2626
2532
  interruption.replayEntries !== undefined) {
2627
2533
  automaticReplayPrompt = interruption.prompt;
2628
- snapshot = {
2629
- entries: interruption.replayEntries,
2630
- tail: {
2631
- ...snapshot.tail,
2632
- branchParentId: interruption.replayParentUuid ?? null,
2633
- },
2634
- };
2635
2534
  }
2636
2535
  }
2637
2536
  const initialTransition = this.worktreeManager?.consumeTransition('__initial__');
2638
- if (initialTransition && snapshot.entries.length === 0) {
2537
+ if (initialTransition && projectionSnapshot().entries.length === 0) {
2639
2538
  const stateEntry = {
2640
2539
  type: 'worktree-state',
2641
2540
  worktreeSession: initialTransition.state,
2642
2541
  sessionId,
2643
2542
  };
2644
- const stateTail = await this.append(lease, snapshot.tail, stateEntry);
2645
- snapshot = {
2646
- entries: [...snapshot.entries, stateEntry],
2647
- tail: stateTail,
2648
- };
2543
+ await commitProjection([stateEntry]);
2649
2544
  }
2650
- this.restoreWorktree(snapshot.entries);
2651
- this.options.interactiveTools?.restore(sessionId, snapshot.entries);
2545
+ this.restoreWorktree(projectionSnapshot().entries);
2546
+ this.options.interactiveTools?.restore(sessionId, projectionSnapshot().entries);
2652
2547
  if (requireExisting &&
2653
2548
  name !== undefined &&
2654
- !this.hasSessionName(snapshot.entries, name)) {
2549
+ !this.hasSessionName(projectionSnapshot().entries, name)) {
2655
2550
  const entries = this.sessionNameEntries(sessionId, name);
2656
- const appendResult = await this.appendProjectionMany(snapshot.tail, entries);
2657
- snapshot = {
2658
- entries: [...snapshot.entries, ...entries],
2659
- tail: appendResult.tail,
2660
- };
2551
+ await commitProjection(entries);
2661
2552
  }
2662
- const agentName = this.options.agent ?? getClaudeAgentSetting(snapshot.entries);
2553
+ const agentName = this.options.agent ??
2554
+ getClaudeAgentSetting(projectionSnapshot().entries);
2663
2555
  const agent = this.resolveAgent(agentName);
2664
2556
  const provider = this.providerForAgent(agent);
2665
2557
  this.activeProvider = provider;
@@ -2706,11 +2598,9 @@ export class ClaudeSessionService {
2706
2598
  ...(this.options.fileRewindRoots ?? []),
2707
2599
  ])
2708
2600
  : null;
2709
- const unresolvedToolCalls = nativeLease
2710
- ? nativeInterruption?.kind === 'recoverable-tools'
2711
- ? [...nativeInterruption.calls]
2712
- : []
2713
- : findUnresolvedClaudeToolCalls(snapshot.entries);
2601
+ const unresolvedToolCalls = nativeInterruption?.kind === 'recoverable-tools'
2602
+ ? [...nativeInterruption.calls]
2603
+ : [];
2714
2604
  const pendingRecoveryToolCallIds = new Set(unresolvedToolCalls.map((call) => call.id));
2715
2605
  const pendingRecoveryHookOutcomes = [];
2716
2606
  let refreshRuntimeContext;
@@ -2724,26 +2614,25 @@ export class ClaudeSessionService {
2724
2614
  permission_mode: 'default',
2725
2615
  };
2726
2616
  const appendHookOutcome = async (outcome) => {
2727
- for (const entry of createClaudeHookAttachmentEntries(outcome, this.translationContext(sessionId, snapshot))) {
2728
- const tail = await this.append(lease, snapshot.tail, entry);
2729
- snapshot = { entries: [...snapshot.entries, entry], tail };
2730
- if (nativeLease) {
2731
- const attachment = entry.attachment;
2732
- const raw = attachment?.content;
2733
- const content = typeof raw === 'string'
2734
- ? raw
2735
- : Array.isArray(raw)
2736
- ? raw
2737
- .filter((value) => typeof value === 'string')
2738
- .join('\n')
2739
- : raw && typeof raw === 'object' && 'content' in raw
2740
- ? String(raw.content)
2741
- : '';
2742
- if (content) {
2743
- await nativeLease.appendMessages({
2744
- messages: [{ role: 'user', content }],
2745
- });
2746
- }
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]);
2747
2636
  }
2748
2637
  }
2749
2638
  };
@@ -2756,11 +2645,11 @@ export class ClaudeSessionService {
2756
2645
  };
2757
2646
  const flushRecoveryHookOutcomes = async () => {
2758
2647
  const entries = [];
2759
- let history = snapshot.entries;
2760
- let parentUuid = this.logicalTailUuid(snapshot.tail);
2648
+ let history = projectionSnapshot().entries;
2649
+ let parentUuid = this.logicalTailUuid(projectionSnapshot().tail);
2761
2650
  for (const outcome of pendingRecoveryHookOutcomes) {
2762
2651
  const outcomeEntries = createClaudeHookAttachmentEntries(outcome, {
2763
- ...this.translationContext(sessionId, snapshot),
2652
+ ...this.translationContext(sessionId, projectionSnapshot()),
2764
2653
  parentUuid,
2765
2654
  history,
2766
2655
  });
@@ -2774,8 +2663,7 @@ export class ClaudeSessionService {
2774
2663
  pendingRecoveryHookOutcomes.length = 0;
2775
2664
  return;
2776
2665
  }
2777
- const appendResult = await this.appendProjectionMany(snapshot.tail, entries);
2778
- snapshot = { entries: history, tail: appendResult.tail };
2666
+ await commitProjection(entries);
2779
2667
  pendingRecoveryHookOutcomes.length = 0;
2780
2668
  };
2781
2669
  const capabilities = this.toolCapabilities();
@@ -2916,7 +2804,7 @@ export class ClaudeSessionService {
2916
2804
  backgroundTaskNotifications: (waitForRunning) => taskTools.notifications(waitForRunning),
2917
2805
  }
2918
2806
  : {}),
2919
- notificationDelivered: (notification) => transcriptContainsBackgroundAgentNotification(snapshot.entries, notification),
2807
+ notificationDelivered: (notification) => transcriptContainsBackgroundAgentNotification(projectionSnapshot().entries, notification),
2920
2808
  stopOwnedBackgroundAgent: (ownerSessionId, agentId) => this.backgroundTasks.stopAgent(ownerSessionId, agentId),
2921
2809
  outputOwnedBackgroundAgent: (ownerSessionId, agentId, options) => this.backgroundTasks.outputAgent(ownerSessionId, agentId, options),
2922
2810
  sendOwnedBackgroundAgent: (ownerSessionId, agentId, message, summary, toolUseId) => this.sendOwnedBackgroundAgent(ownerSessionId, agentId, message, summary, toolUseId),
@@ -2929,7 +2817,7 @@ export class ClaudeSessionService {
2929
2817
  }
2930
2818
  const agentTools = subagentExecutor
2931
2819
  ? subagentExecutor.registry(sessionId, 0, (callId) => currentPromptId ??
2932
- this.promptIdForToolCall(snapshot.entries, callId))
2820
+ this.promptIdForToolCall(projectionSnapshot().entries, callId))
2933
2821
  : baseTools;
2934
2822
  const turnTools = this.workflowManager && subagentExecutor && agentTools
2935
2823
  ? new ClaudeWorkflowToolRegistry({
@@ -2941,7 +2829,7 @@ export class ClaudeSessionService {
2941
2829
  configRoot: this.options.configRoot,
2942
2830
  sessionId,
2943
2831
  promptIdForCall: (callId) => currentPromptId ??
2944
- this.promptIdForToolCall(snapshot.entries, callId),
2832
+ this.promptIdForToolCall(projectionSnapshot().entries, callId),
2945
2833
  defaultModel: provider.model ?? 'praxis/provider',
2946
2834
  tokenBudget: workflowTokenTarget(effectivePrompt),
2947
2835
  enabled: capabilities.has('Workflow'),
@@ -2994,13 +2882,13 @@ export class ClaudeSessionService {
2994
2882
  return interactiveMessageTools.execute(call, context);
2995
2883
  }
2996
2884
  const snapshotMessageId = currentPromptId ??
2997
- this.promptIdForToolCall(snapshot.entries, call.id);
2885
+ this.promptIdForToolCall(projectionSnapshot().entries, call.id);
2998
2886
  const assistantMessageId = lastAssistantUuid ??
2999
- this.assistantIdForToolCall(snapshot.entries, call.id);
2887
+ this.assistantIdForToolCall(projectionSnapshot().entries, call.id);
3000
2888
  if (!snapshotMessageId || !assistantMessageId) {
3001
2889
  throw new Error('Claude file history could not link tool call');
3002
2890
  }
3003
- const prepared = await fileHistory.prepareMutation(snapshot.entries, snapshotMessageId, path);
2891
+ const prepared = await fileHistory.prepareMutation(projectionSnapshot().entries, snapshotMessageId, path);
3004
2892
  let result;
3005
2893
  try {
3006
2894
  result = await interactiveMessageTools.execute(call, context);
@@ -3015,18 +2903,18 @@ export class ClaudeSessionService {
3015
2903
  }
3016
2904
  const entry = prepared.commit(assistantMessageId);
3017
2905
  if (entry) {
3018
- const tail = await this.append(lease, snapshot.tail, entry);
3019
- snapshot = { entries: [...snapshot.entries, entry], tail };
3020
- if (nativeLease) {
3021
- await nativeLease.appendMessages({
2906
+ await persistence.commit({
2907
+ kind: 'messages',
2908
+ input: {
3022
2909
  messages: [
3023
2910
  {
3024
2911
  role: 'user',
3025
2912
  content: `<praxis-file-history>${JSON.stringify(entry)}</praxis-file-history>`,
3026
2913
  },
3027
2914
  ],
3028
- });
3029
- }
2915
+ },
2916
+ projectionEntries: [entry],
2917
+ });
3030
2918
  }
3031
2919
  return result;
3032
2920
  },
@@ -3071,7 +2959,7 @@ export class ClaudeSessionService {
3071
2959
  deferPreToolUseOutcome: (call) => pendingRecoveryToolCallIds.has(call.id),
3072
2960
  })
3073
2961
  : null;
3074
- const runtime = new AgentRuntime(provider, controller.emit, {
2962
+ const runtime = new AgentRuntime(provider, emit, {
3075
2963
  emitInitialContextState: false,
3076
2964
  ...(this.options.emitToolUseSummaries
3077
2965
  ? {
@@ -3104,14 +2992,13 @@ export class ClaudeSessionService {
3104
2992
  const durableFollowUps = new DurableFollowUpTracker();
3105
2993
  const claimedToolCallIds = new Set();
3106
2994
  const observer = {
3107
- ...(nativeLease
3108
- ? {
3109
- toolExecutionStarted: async (call) => {
3110
- await nativeLease.beginToolExecution(call.id);
3111
- claimedToolCallIds.add(call.id);
3112
- },
3113
- }
3114
- : {}),
2995
+ toolExecutionStarted: async (call) => {
2996
+ await persistence.commit({
2997
+ kind: 'tool-execution-started',
2998
+ callId: call.id,
2999
+ });
3000
+ claimedToolCallIds.add(call.id);
3001
+ },
3115
3002
  modelRequestCompleted: async (input) => {
3116
3003
  observeModelRequestUsage(input);
3117
3004
  },
@@ -3127,13 +3014,12 @@ export class ClaudeSessionService {
3127
3014
  providerMessageId: `msg_${randomUUID().replaceAll('-', '')}`,
3128
3015
  model: provider.model ?? 'praxis/provider',
3129
3016
  },
3130
- ], this.translationContext(sessionId, snapshot));
3017
+ ], this.translationContext(sessionId, projectionSnapshot()));
3131
3018
  if (!entry)
3132
3019
  throw new Error('Could not translate assistant response');
3133
- const tail = await this.append(lease, snapshot.tail, entry);
3134
- snapshot = { entries: [...snapshot.entries, entry], tail };
3135
- lastAssistantUuid = nativeLease
3136
- ? await nativeLease.appendMessages({
3020
+ const nativeReceipt = await persistence.commit({
3021
+ kind: 'messages',
3022
+ input: {
3137
3023
  messages: [
3138
3024
  {
3139
3025
  role: 'assistant',
@@ -3147,29 +3033,26 @@ export class ClaudeSessionService {
3147
3033
  },
3148
3034
  ],
3149
3035
  model: provider.model ?? 'praxis/provider',
3150
- })
3151
- : typeof entry.uuid === 'string'
3152
- ? entry.uuid
3153
- : 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;
3154
3042
  },
3155
3043
  toolCompleted: async (call, toolResult) => {
3156
- currentTurnToolCalls += 1;
3157
3044
  const transition = this.worktreeManager?.consumeTransition(call.id);
3158
3045
  if (transition) {
3159
- this.options.contextAssembler?.invalidate?.({
3160
- lifecycleId: sessionId,
3161
- reason: 'worktree',
3162
- });
3163
3046
  const stateEntry = {
3164
3047
  type: 'worktree-state',
3165
3048
  worktreeSession: transition.state,
3166
3049
  sessionId,
3167
3050
  };
3168
- const stateTail = await this.append(lease, snapshot.tail, stateEntry);
3169
- snapshot = {
3170
- entries: [...snapshot.entries, stateEntry],
3171
- tail: stateTail,
3172
- };
3051
+ await commitProjection([stateEntry]);
3052
+ this.options.contextAssembler?.invalidate?.({
3053
+ lifecycleId: sessionId,
3054
+ reason: 'worktree',
3055
+ });
3173
3056
  }
3174
3057
  const permissionMode = this.options.interactiveTools?.consumeTransition(call.id);
3175
3058
  if (permissionMode) {
@@ -3178,21 +3061,18 @@ export class ClaudeSessionService {
3178
3061
  permissionMode,
3179
3062
  sessionId,
3180
3063
  };
3181
- const modeTail = await this.append(lease, snapshot.tail, modeEntry);
3182
- snapshot = {
3183
- entries: [...snapshot.entries, modeEntry],
3184
- tail: modeTail,
3185
- };
3186
- if (nativeLease) {
3187
- await nativeLease.appendMessages({
3064
+ await persistence.commit({
3065
+ kind: 'messages',
3066
+ input: {
3188
3067
  messages: [
3189
3068
  {
3190
3069
  role: 'user',
3191
3070
  content: `/permission-mode ${permissionMode}`,
3192
3071
  },
3193
3072
  ],
3194
- });
3195
- }
3073
+ },
3074
+ projectionEntries: [modeEntry],
3075
+ });
3196
3076
  // Interactive mode transitions change the runtime system
3197
3077
  // context for the very next provider request. Refresh here so
3198
3078
  // an EnterPlanMode/ExitPlanMode tool call is immediately
@@ -3200,43 +3080,21 @@ export class ClaudeSessionService {
3200
3080
  if (typeof refreshRuntimeContext === 'function')
3201
3081
  await refreshRuntimeContext();
3202
3082
  }
3203
- if (nativeLease) {
3204
- if (!claimedToolCallIds.has(call.id) && !signal?.aborted) {
3205
- await nativeLease.beginToolExecution(call.id);
3206
- claimedToolCallIds.add(call.id);
3207
- }
3208
- await nativeLease.appendToolCompletion({
3083
+ if (!claimedToolCallIds.has(call.id) && !signal?.aborted) {
3084
+ await persistence.commit({
3085
+ kind: 'tool-execution-started',
3209
3086
  callId: call.id,
3210
- result: toolResult,
3211
3087
  });
3088
+ claimedToolCallIds.add(call.id);
3212
3089
  }
3213
- else {
3214
- const [entry] = translateProviderEvents([
3215
- {
3216
- type: 'tool-result',
3217
- toolCallId: call.id,
3218
- content: toolResult.content,
3219
- ...(toolResult.contentBlocks
3220
- ? { contentBlocks: toolResult.contentBlocks }
3221
- : {}),
3222
- ...(toolResult.images ? { images: toolResult.images } : {}),
3223
- ...(toolResult.documents
3224
- ? { documents: toolResult.documents }
3225
- : {}),
3226
- isError: toolResult.isError,
3227
- ...(toolResult.nativeToolUseResult
3228
- ? { nativeToolUseResult: toolResult.nativeToolUseResult }
3229
- : {}),
3230
- ...(toolResult.nativeMcpMeta
3231
- ? { nativeMcpMeta: toolResult.nativeMcpMeta }
3232
- : {}),
3233
- },
3234
- ], this.translationContext(sessionId, snapshot));
3235
- if (!entry)
3236
- throw new Error('Could not translate tool result');
3237
- const tail = await this.append(lease, snapshot.tail, entry);
3238
- snapshot = { entries: [...snapshot.entries, entry], tail };
3239
- }
3090
+ await persistence.commit({
3091
+ kind: 'tool-completion',
3092
+ input: {
3093
+ callId: call.id,
3094
+ result: toolResult,
3095
+ },
3096
+ });
3097
+ currentTurnToolCalls += 1;
3240
3098
  if (!toolResult.isError && this.options.projectMemoryDirectory) {
3241
3099
  const pathValue = call.input.file_path;
3242
3100
  const path = typeof pathValue === 'string'
@@ -3266,23 +3124,23 @@ export class ClaudeSessionService {
3266
3124
  !toolResult.accessedPaths) {
3267
3125
  return;
3268
3126
  }
3269
- const attachedRulePaths = this.attachedRulePaths(snapshot.entries);
3127
+ const attachedRulePaths = this.attachedRulePaths(projectionSnapshot().entries);
3270
3128
  for (const filePath of toolResult.accessedPaths) {
3271
3129
  const rules = await this.options.conditionalRuleResolver.resolve(filePath, [...attachedRulePaths]);
3272
3130
  for (const rule of rules) {
3273
- const attachment = createClaudeRuleAttachmentEntry(rule, this.displayRulePath(rule.path), this.translationContext(sessionId, snapshot));
3274
- const attachmentTail = await this.append(lease, snapshot.tail, attachment);
3275
- snapshot = {
3276
- entries: [...snapshot.entries, attachment],
3277
- tail: attachmentTail,
3278
- };
3279
- if (nativeLease) {
3280
- const ruleContent = rule.content.trim();
3281
- if (ruleContent.length > 0) {
3282
- 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: {
3283
3137
  messages: [{ role: 'user', content: ruleContent }],
3284
- });
3285
- }
3138
+ },
3139
+ projectionEntries: [attachment],
3140
+ });
3141
+ }
3142
+ else {
3143
+ await commitProjection([attachment]);
3286
3144
  }
3287
3145
  attachedRulePaths.add(rule.path);
3288
3146
  await this.instructionLoaded(sessionId, {
@@ -3303,44 +3161,20 @@ export class ClaudeSessionService {
3303
3161
  },
3304
3162
  followUpUserMessagesCompleted: async (messages) => {
3305
3163
  for (const content of messages) {
3306
- if (nativeLease) {
3307
- await nativeLease.appendMessages({
3308
- messages: [{ role: 'user', content }],
3309
- });
3310
- currentTurnUserMessages?.push(content);
3311
- await Promise.all(this.sessionSubagentExecutors(sessionId, true).map((executor) => executor.acknowledgeNotifications([content])));
3312
- continue;
3313
- }
3314
- const [followUpEntry] = translateProviderEvents([{ type: 'user-text-block', text: content }], this.translationContext(sessionId, snapshot));
3315
- if (!followUpEntry) {
3316
- throw new Error('Could not translate tool follow-up message');
3317
- }
3318
- const followUpTail = await this.append(lease, snapshot.tail, followUpEntry);
3319
- snapshot = {
3320
- entries: [...snapshot.entries, followUpEntry],
3321
- tail: followUpTail,
3322
- };
3164
+ await persistence.commit({
3165
+ kind: 'messages',
3166
+ input: { messages: [{ role: 'user', content }] },
3167
+ });
3323
3168
  currentTurnUserMessages?.push(content);
3324
3169
  await Promise.all(this.sessionSubagentExecutors(sessionId, true).map((executor) => executor.acknowledgeNotifications([content])));
3325
3170
  }
3326
3171
  await durableFollowUps.followUpUserMessagesCompleted(messages);
3327
3172
  },
3328
3173
  userInputDelivered: async (item) => {
3329
- if (nativeLease) {
3330
- await nativeLease.appendMessages({
3331
- messages: [{ role: 'user', content: item.content }],
3332
- });
3333
- currentTurnUserMessages?.push(item.content);
3334
- return;
3335
- }
3336
- const [steeringEntry] = translateProviderEvents([{ type: 'user-text-block', text: item.content }], this.translationContext(sessionId, snapshot));
3337
- if (!steeringEntry)
3338
- throw new Error('Could not translate steering message');
3339
- const steeringTail = await this.append(lease, snapshot.tail, steeringEntry);
3340
- snapshot = {
3341
- entries: [...snapshot.entries, steeringEntry],
3342
- tail: steeringTail,
3343
- };
3174
+ await persistence.commit({
3175
+ kind: 'messages',
3176
+ input: { messages: [{ role: 'user', content: item.content }] },
3177
+ });
3344
3178
  currentTurnUserMessages?.push(item.content);
3345
3179
  },
3346
3180
  };
@@ -3384,48 +3218,51 @@ export class ClaudeSessionService {
3384
3218
  throw new Error(`Claude session tool call ${unresolvedToolCall.id} requires explicit recovery approval`);
3385
3219
  }
3386
3220
  const recoveryResults = await runtime.recoverToolCalls(unresolvedToolCalls, recoveryRequest);
3387
- const recoveryUsage = recoveryResults.reduce((usage, result) => result.usage ? mergeUsage(usage, result.usage) : usage, { inputTokens: 0, outputTokens: 0 });
3388
- const recoveryModelUsage = mergeSessionRawModelUsage(...recoveryResults.map((result) => result.isError ? undefined : result.modelUsage));
3389
- const foregroundLineChanges = createLineCountAccumulator();
3390
- for (const result of recoveryResults) {
3391
- foregroundLineChanges.add(result);
3392
- }
3393
3221
  if (this.options.agent &&
3394
3222
  agent &&
3395
- getClaudeAgentSetting(snapshot.entries) !== this.options.agent) {
3223
+ getClaudeAgentSetting(projectionSnapshot().entries) !==
3224
+ this.options.agent) {
3396
3225
  const agentSetting = createClaudeAgentSettingEntry(sessionId, this.options.agent);
3397
- const settingTail = await this.append(lease, snapshot.tail, agentSetting);
3398
- snapshot = {
3399
- entries: [...snapshot.entries, agentSetting],
3400
- tail: settingTail,
3401
- };
3402
- if (nativeLease) {
3403
- await nativeLease.appendMessages({
3226
+ await persistence.commit({
3227
+ kind: 'messages',
3228
+ input: {
3404
3229
  messages: [
3405
3230
  {
3406
3231
  role: 'user',
3407
3232
  content: `<praxis-agent-setting>${this.options.agent}</praxis-agent-setting>`,
3408
3233
  },
3409
3234
  ],
3410
- });
3411
- }
3235
+ },
3236
+ projectionEntries: [agentSetting],
3237
+ });
3412
3238
  }
3413
3239
  let agentSystem = null;
3414
3240
  let planModeMessage;
3415
3241
  let sessionMemoryMessage = null;
3416
- let contextMessages = [];
3417
- let contextProjection = {
3418
- systemMessages: [],
3419
- stableSystemSectionCount: 0,
3420
- };
3421
- let stableSystemMessageCount = 0;
3242
+ let agentMentionMessages = [];
3243
+ const contextPreparation = new ContextPreparation({
3244
+ ...(this.options.contextAssembler
3245
+ ? { assembler: this.options.contextAssembler }
3246
+ : {}),
3247
+ sources: {
3248
+ history: activeTurnMessages,
3249
+ memory: () => projectMemoryRecallMessages,
3250
+ activeTools: () => provider.capabilities.tools
3251
+ ? (activeTurnTools?.definitions() ?? [])
3252
+ : [],
3253
+ },
3254
+ agentMentions: () => ({
3255
+ prompt: effectivePrompt,
3256
+ messages: agentMentionMessages,
3257
+ }),
3258
+ });
3422
3259
  refreshRuntimeContext = async () => {
3423
3260
  agentSystem = await this.mainAgentSystemPrompt(agent);
3424
3261
  planModeMessage =
3425
3262
  this.options.interactiveTools?.contextMessage(sessionId);
3426
3263
  // DEBUG_PLAN_CONTEXT
3427
3264
  sessionMemoryMessage = this.sessionMemoryMessage(await turnMemory.sessionSummary());
3428
- const assembledContext = await assembleContextSnapshot(this.options.contextAssembler, {
3265
+ await contextPreparation.refresh({
3429
3266
  cwd: this.activeCwd(),
3430
3267
  lifecycleId: sessionId,
3431
3268
  ...(agentSystem
@@ -3442,10 +3279,6 @@ export class ClaudeSessionService {
3442
3279
  : {}),
3443
3280
  },
3444
3281
  });
3445
- contextProjection = projectContextSnapshot(assembledContext);
3446
- stableSystemMessageCount =
3447
- contextProjection.stableSystemSectionCount;
3448
- contextMessages = [...contextProjection.systemMessages];
3449
3282
  };
3450
3283
  await refreshRuntimeContext();
3451
3284
  const expansion = shouldSkipUserPrompt()
@@ -3468,16 +3301,6 @@ export class ClaudeSessionService {
3468
3301
  type: 'state',
3469
3302
  state: 'assembling-context',
3470
3303
  });
3471
- let compactionUsage = {
3472
- inputTokens: 0,
3473
- outputTokens: 0,
3474
- };
3475
- let compactionDurationMs;
3476
- let compactionDurationWithoutRetriesMs;
3477
- let compactionModelUsage;
3478
- const currentDefinitions = () => provider.capabilities.tools
3479
- ? (activeTurnTools?.definitions() ?? [])
3480
- : [];
3481
3304
  const budget = this.contextBudget(provider);
3482
3305
  const contextEngine = new ContextEngine({
3483
3306
  ...(budget ? { budget } : {}),
@@ -3509,88 +3332,49 @@ export class ClaudeSessionService {
3509
3332
  ? { documents }
3510
3333
  : {}),
3511
3334
  }));
3512
- const agentMentionMessages = shellCommand === undefined && !shouldSkipUserPrompt()
3513
- ? (this.options.extensions?.agentMentionMessages(effectivePrompt) ?? [])
3514
- : [];
3515
- const injectAgentMentionContext = (messages) => {
3516
- if (agentMentionMessages.length === 0)
3517
- return [...messages];
3518
- let insertionIndex = messages.length;
3519
- let foundPrompt = false;
3520
- for (let index = messages.length - 1; index >= 0; index -= 1) {
3521
- const message = messages[index];
3522
- if (message?.role === 'user' &&
3523
- typeof message.content === 'string' &&
3524
- message.content.endsWith(effectivePrompt)) {
3525
- insertionIndex = index;
3526
- foundPrompt = true;
3527
- break;
3528
- }
3529
- }
3530
- if (!foundPrompt)
3531
- return [...messages];
3532
- return [
3533
- ...messages.slice(0, insertionIndex),
3534
- ...agentMentionMessages.map((content) => ({
3535
- role: 'user',
3536
- content,
3537
- })),
3538
- ...messages.slice(insertionIndex),
3539
- ];
3540
- };
3541
- const injectDynamicContext = (messages) => injectFirstUserMessageContext(messages, contextProjection.firstUserMessageContext);
3542
- const injectTurnContext = (messages) => injectAgentMentionContext(injectDynamicContext(messages));
3543
- let compactionAnchorUuid = this.lastMessageUuid(snapshot.entries);
3335
+ agentMentionMessages =
3336
+ shellCommand === undefined && !shouldSkipUserPrompt()
3337
+ ? (this.options.extensions?.agentMentionMessages(effectivePrompt) ?? [])
3338
+ : [];
3339
+ let compactionAnchorUuid = this.lastMessageUuid(projectionSnapshot().entries);
3544
3340
  const contextTransitionPort = (pendingMessages = [], preservedUserMessages = []) => ({
3545
3341
  current: () => {
3546
- const historyMessages = [
3547
- ...activeTurnMessages(),
3548
- ...projectMemoryRecallMessages,
3549
- ];
3550
- return {
3551
- messages: [
3552
- ...contextMessages,
3553
- ...injectTurnContext([
3554
- ...historyMessages,
3555
- ...pendingMessages,
3556
- ]),
3557
- ],
3558
- tools: currentDefinitions(),
3559
- };
3342
+ const projection = contextPreparation.project({
3343
+ pendingMessages,
3344
+ });
3345
+ return projection.envelope;
3560
3346
  },
3561
- irreducible: () => ({
3562
- messages: [
3563
- ...contextMessages,
3564
- ...injectTurnContext([
3565
- ...pendingMessages,
3566
- ...preservedUserMessages.map((content) => ({
3567
- role: 'user',
3568
- content,
3569
- })),
3570
- ]),
3347
+ irreducible: () => contextPreparation.project({
3348
+ includeHistory: false,
3349
+ includeMemory: false,
3350
+ pendingMessages: [
3351
+ ...pendingMessages,
3352
+ ...preservedUserMessages.map((content) => ({
3353
+ role: 'user',
3354
+ content,
3355
+ })),
3571
3356
  ],
3572
- tools: currentDefinitions(),
3573
- }),
3357
+ }).envelope,
3574
3358
  propose: async () => {
3575
- const activeNativeLease = nativeLease;
3576
3359
  if (!budget)
3577
3360
  throw new Error('Context budget is unavailable');
3578
- const definitions = currentDefinitions();
3361
+ const definitions = contextPreparation.project().envelope.tools;
3579
3362
  const historyMessages = activeTurnMessages();
3580
3363
  if (historyMessages.length === 0)
3581
3364
  throw new Error('Cannot compact an empty native transcript');
3582
3365
  if (unresolvedActiveToolCallIds(historyMessages).length > 0)
3583
3366
  throw new Error('Cannot compact a native transcript with unresolved tool calls');
3584
- const irreducibleMessages = [
3585
- ...contextMessages,
3586
- ...injectTurnContext([
3367
+ const irreducibleMessages = contextPreparation.project({
3368
+ includeHistory: false,
3369
+ includeMemory: false,
3370
+ pendingMessages: [
3587
3371
  ...pendingMessages,
3588
3372
  ...preservedUserMessages.map((content) => ({
3589
3373
  role: 'user',
3590
3374
  content,
3591
3375
  })),
3592
- ]),
3593
- ];
3376
+ ],
3377
+ }).envelope.messages;
3594
3378
  let compactableMessages = historyMessages;
3595
3379
  let preservedMessages = [];
3596
3380
  let compactionLogicalParentId;
@@ -3627,15 +3411,15 @@ export class ClaudeSessionService {
3627
3411
  !promptContents.has(message.content)),
3628
3412
  ];
3629
3413
  if (earliest === 0 && preservedUserMessages[0] !== undefined) {
3630
- compactionLogicalParentId = activeNativeLease
3631
- .activeEvents()
3632
- .find((event) => event.kind === 'messages' &&
3414
+ compactionLogicalParentId = persistence
3415
+ .view()
3416
+ .activeEvents.find((event) => event.kind === 'messages' &&
3633
3417
  event.messages.some((message) => message.role === 'user' &&
3634
3418
  message.content === preservedUserMessages[0]))?.id;
3635
3419
  }
3636
3420
  }
3637
3421
  const memorySelection = sessionMemory
3638
- ? await this.selectMemoryPreservedCompact(sessionId, projectNativeSessionEntries(activeNativeLease.activeEvents()))
3422
+ ? await this.selectMemoryPreservedCompact(sessionId, persistence.view().projectionEntries)
3639
3423
  : null;
3640
3424
  if (memorySelection) {
3641
3425
  compactableMessages = [
@@ -3656,9 +3440,9 @@ export class ClaudeSessionService {
3656
3440
  preservedMessages = historyMessages.filter((message) => message.role === 'user');
3657
3441
  const anchor = preservedMessages[0];
3658
3442
  if (anchor !== undefined) {
3659
- compactionLogicalParentId = nativeLease
3660
- .activeEvents()
3661
- .find((event) => event.kind === 'messages' &&
3443
+ compactionLogicalParentId = persistence
3444
+ .view()
3445
+ .activeEvents.find((event) => event.kind === 'messages' &&
3662
3446
  event.messages.some((message) => message.role === 'user' &&
3663
3447
  message.content === anchor.content))?.id;
3664
3448
  }
@@ -3692,15 +3476,6 @@ export class ClaudeSessionService {
3692
3476
  const { durationMs, durationWithoutRetriesMs } = requireCompactionDurations(compacted);
3693
3477
  if (signal?.aborted)
3694
3478
  throw new AgentRunCancelledError();
3695
- const proposedCompactionDurationMs = (compactionDurationMs ?? 0) + durationMs;
3696
- if (!Number.isFinite(proposedCompactionDurationMs)) {
3697
- throw new TypeError('compaction durationMs total overflow');
3698
- }
3699
- const proposedCompactionDurationWithoutRetriesMs = (compactionDurationWithoutRetriesMs ?? 0) +
3700
- durationWithoutRetriesMs;
3701
- if (!Number.isFinite(proposedCompactionDurationWithoutRetriesMs)) {
3702
- throw new TypeError('compaction durationWithoutRetriesMs total overflow');
3703
- }
3704
3479
  const postTokens = estimateModelRequestTokens([
3705
3480
  { role: 'user', content: compacted.summary },
3706
3481
  ...preservedMessages,
@@ -3708,63 +3483,27 @@ export class ClaudeSessionService {
3708
3483
  const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
3709
3484
  ? compacted.model
3710
3485
  : provider.model;
3711
- const meaningfulUsage = hasNonZeroUsage(compacted.usage);
3712
- if (meaningfulUsage &&
3713
- (compactModel === undefined || compactModel.trim() === ''))
3714
- throw new Error('Auto compact usage requires a nonblank model identity');
3715
- const tracker = this.sessionCostTrackers.get(sessionId);
3716
- if (!tracker)
3717
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
3718
- let meteringTurnInput;
3719
- if (meaningfulUsage &&
3720
- compactModel !== undefined &&
3721
- compactModel.trim() !== '') {
3722
- const pricing = this.options.pricing?.resolve(compactModel);
3723
- const costUsd = pricing
3724
- ? usageCostUsd(compacted.usage, pricing)
3725
- : undefined;
3726
- meteringTurnInput = {
3727
- model: compactModel,
3728
- usage: compacted.usage,
3729
- ...(costUsd === undefined ? {} : { costUsd }),
3730
- ...(compacted.usage.webSearchRequests === undefined
3731
- ? {}
3732
- : {
3733
- webSearchRequests: compacted.usage.webSearchRequests,
3734
- }),
3735
- };
3736
- }
3737
- let meteringDurationsInput;
3738
- if (durationMs > 0 || durationWithoutRetriesMs > 0)
3739
- meteringDurationsInput = {
3740
- ...(durationMs === 0 ? {} : { apiDurationMs: durationMs }),
3741
- apiDurationWithoutRetriesMs: durationWithoutRetriesMs,
3742
- };
3743
- if (meteringTurnInput !== undefined ||
3744
- meteringDurationsInput !== undefined) {
3745
- const preflight = new ClaudeSessionCostTracker({
3746
- sessionId,
3747
- restored: tracker.snapshot(),
3748
- });
3749
- if (meteringTurnInput !== undefined)
3750
- preflight.recordTurn(meteringTurnInput);
3751
- if (meteringDurationsInput !== undefined)
3752
- preflight.recordDurations(meteringDurationsInput);
3753
- }
3486
+ const preparedCompaction = turnAccounting.prepareCompaction({
3487
+ usage: compacted.usage,
3488
+ ...(compactModel === undefined ? {} : { model: compactModel }),
3489
+ durationApiMs: durationMs,
3490
+ durationApiWithoutRetriesMs: durationWithoutRetriesMs,
3491
+ });
3754
3492
  const summaryMessage = {
3755
3493
  role: 'user',
3756
3494
  content: compacted.summary,
3757
3495
  };
3758
3496
  let replayMessages = preservedMessages;
3759
3497
  try {
3760
- const replayReport = budget.evaluate([
3761
- ...contextMessages,
3762
- ...injectTurnContext([
3498
+ const replayReport = budget.evaluate(contextPreparation.project({
3499
+ includeHistory: false,
3500
+ includeMemory: false,
3501
+ pendingMessages: [
3763
3502
  summaryMessage,
3764
3503
  ...replayMessages,
3765
3504
  ...pendingMessages,
3766
- ]),
3767
- ], definitions);
3505
+ ],
3506
+ }).envelope.messages, definitions);
3768
3507
  if (replayReport.shouldCompact)
3769
3508
  throw new Error('replay overflow');
3770
3509
  }
@@ -3775,40 +3514,39 @@ export class ClaudeSessionService {
3775
3514
  // contains its contents and user prompts remain available.
3776
3515
  replayMessages = replayMessages.filter((message) => message.role === 'user' && !Array.isArray(message.content));
3777
3516
  }
3778
- const proposedMessages = [
3779
- ...contextMessages,
3780
- ...injectTurnContext([
3781
- summaryMessage,
3782
- ...replayMessages,
3783
- ...pendingMessages,
3784
- ]),
3785
- ];
3517
+ const replacement = contextPreparation.proposeHistoryReplacement({
3518
+ historyMessages: [summaryMessage, ...replayMessages],
3519
+ pendingMessages,
3520
+ });
3786
3521
  return {
3787
- envelope: {
3788
- messages: proposedMessages,
3789
- tools: definitions,
3790
- },
3522
+ envelope: replacement.envelope,
3791
3523
  commit: async () => {
3792
3524
  if (signal?.aborted)
3793
3525
  throw new AgentRunCancelledError();
3794
- const ids = await nativeLease.appendCompaction({
3795
- summary: compacted.summary,
3796
- trigger: 'auto',
3797
- preTokens,
3798
- postTokens,
3799
- durationMs,
3800
- preservedMessages: replayMessages,
3801
- ...(compactionLogicalParentId === undefined
3802
- ? {}
3803
- : { logicalParentId: compactionLogicalParentId }),
3804
- ...(memorySelection
3805
- ? {
3806
- direction: 'from',
3807
- messagesSummarized: compactableMessages.length,
3808
- preservePrefix: false,
3809
- }
3810
- : {}),
3811
- });
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
+ },
3546
+ }));
3547
+ const ids = committed.value;
3548
+ if (ids.kind !== 'compaction')
3549
+ throw new Error('Turn persistence returned an invalid compaction receipt');
3812
3550
  await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3813
3551
  if (this.options.hooks) {
3814
3552
  const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
@@ -3826,21 +3564,7 @@ export class ClaudeSessionService {
3826
3564
  preTokens,
3827
3565
  uuid: ids.boundaryId,
3828
3566
  });
3829
- compactionUsage = mergeUsage(compactionUsage, compacted.usage);
3830
- compactionDurationMs =
3831
- (compactionDurationMs ?? 0) + durationMs;
3832
- compactionDurationWithoutRetriesMs =
3833
- (compactionDurationWithoutRetriesMs ?? 0) +
3834
- durationWithoutRetriesMs;
3835
- if (meteringTurnInput !== undefined) {
3836
- tracker.recordTurn(meteringTurnInput);
3837
- }
3838
- if (compactModel !== undefined &&
3839
- compactModel.trim() !== '') {
3840
- compactionModelUsage = mergeSessionRawModelUsage(compactionModelUsage, { [compactModel]: compacted.usage });
3841
- }
3842
- if (meteringDurationsInput !== undefined)
3843
- tracker.recordDurations(meteringDurationsInput);
3567
+ preparedCompaction.commit();
3844
3568
  },
3845
3569
  };
3846
3570
  },
@@ -3868,7 +3592,7 @@ export class ClaudeSessionService {
3868
3592
  : index === 0
3869
3593
  ? { type: 'user-text', text: message.text }
3870
3594
  : { type: 'user-text-block', text: message.text };
3871
- const [userEntry] = translateProviderEvents([persistenceEvent], this.translationContext(sessionId, snapshot));
3595
+ const [userEntry] = translateProviderEvents([persistenceEvent], this.translationContext(sessionId, projectionSnapshot()));
3872
3596
  if (!userEntry)
3873
3597
  throw new Error('Could not translate user prompt');
3874
3598
  if (currentPromptId === null &&
@@ -3876,13 +3600,9 @@ export class ClaudeSessionService {
3876
3600
  currentPromptId = userEntry.uuid;
3877
3601
  compactionAnchorUuid ??= userEntry.uuid;
3878
3602
  }
3879
- const userTail = await this.append(lease, snapshot.tail, userEntry);
3880
- snapshot = {
3881
- entries: [...snapshot.entries, userEntry],
3882
- tail: userTail,
3883
- };
3884
- if (nativeLease) {
3885
- const nativePromptId = await nativeLease.appendMessages({
3603
+ const nativePrompt = await persistence.commit({
3604
+ kind: 'messages',
3605
+ input: {
3886
3606
  messages: [
3887
3607
  {
3888
3608
  role: 'user',
@@ -3893,29 +3613,29 @@ export class ClaudeSessionService {
3893
3613
  : {}),
3894
3614
  },
3895
3615
  ],
3896
- });
3897
- // File-history snapshots key their metadata to the durable
3898
- // native event id, not the scratch compatibility projection id.
3899
- currentPromptId = nativePromptId;
3900
- }
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;
3901
3624
  }
3902
3625
  if (fileHistory && currentPromptId) {
3903
- const historySnapshot = await fileHistory.snapshot(snapshot.entries, currentPromptId);
3904
- const historyTail = await this.append(lease, snapshot.tail, historySnapshot);
3905
- snapshot = {
3906
- entries: [...snapshot.entries, historySnapshot],
3907
- tail: historyTail,
3908
- };
3909
- if (nativeLease) {
3910
- await nativeLease.appendMessages({
3626
+ const historySnapshot = await fileHistory.snapshot(projectionSnapshot().entries, currentPromptId);
3627
+ await persistence.commit({
3628
+ kind: 'messages',
3629
+ input: {
3911
3630
  messages: [
3912
3631
  {
3913
3632
  role: 'user',
3914
3633
  content: `<praxis-file-history>${JSON.stringify(historySnapshot)}</praxis-file-history>`,
3915
3634
  },
3916
3635
  ],
3917
- });
3918
- }
3636
+ },
3637
+ projectionEntries: [historySnapshot],
3638
+ });
3919
3639
  }
3920
3640
  if (this.options.hooks && !shouldSkipUserPrompt()) {
3921
3641
  const outcome = await this.options.hooks.run({
@@ -3929,11 +3649,7 @@ export class ClaudeSessionService {
3929
3649
  throw new Error(`UserPromptSubmit hook error: ${outcome.blockedReason}`);
3930
3650
  }
3931
3651
  }
3932
- let shellUsage = { inputTokens: 0, outputTokens: 0 };
3933
- let shellModelUsage;
3934
- let shellDurationApiMs;
3935
- let shellDurationApiWithoutRetriesMs;
3936
- let shellToolDurationMs;
3652
+ let shellResult;
3937
3653
  if (shellCommand !== undefined) {
3938
3654
  const call = {
3939
3655
  id: `shell_${randomUUID().replaceAll('-', '')}`,
@@ -3945,7 +3661,6 @@ export class ClaudeSessionService {
3945
3661
  callId: call.id,
3946
3662
  command: shellCommand,
3947
3663
  });
3948
- let shellResult;
3949
3664
  try {
3950
3665
  shellResult = await runtime.executeDirectToolCall(call, {
3951
3666
  cwd: this.activeCwd(),
@@ -3972,15 +3687,6 @@ export class ClaudeSessionService {
3972
3687
  });
3973
3688
  throw error;
3974
3689
  }
3975
- shellUsage = shellResult.usage ?? shellUsage;
3976
- shellDurationApiMs = shellResult.durationApiMs;
3977
- shellDurationApiWithoutRetriesMs =
3978
- shellResult.durationApiWithoutRetriesMs;
3979
- shellToolDurationMs = shellResult.durationToolMs;
3980
- if (!shellResult.isError) {
3981
- shellModelUsage = shellResult.modelUsage;
3982
- foregroundLineChanges.add(shellResult);
3983
- }
3984
3690
  const stdout = shellResult.processOutput?.stdout ??
3985
3691
  (shellResult.isError ? '' : shellResult.content);
3986
3692
  const stderr = shellResult.processOutput?.stderr ??
@@ -3990,22 +3696,15 @@ export class ClaudeSessionService {
3990
3696
  { type: 'bash-input', command: shellCommand },
3991
3697
  { type: 'bash-output', stdout, stderr },
3992
3698
  ], {
3993
- ...this.translationContext(sessionId, snapshot),
3699
+ ...this.translationContext(sessionId, projectionSnapshot()),
3994
3700
  createUuid: () => shellUuids.shift() ?? randomUUID(),
3995
3701
  });
3996
3702
  if (!inputEntry || !outputEntry) {
3997
3703
  throw new Error('Could not translate shell command result');
3998
3704
  }
3999
- const shellAppend = await this.appendProjectionMany(snapshot.tail, [
4000
- inputEntry,
4001
- outputEntry,
4002
- ]);
4003
- snapshot = {
4004
- entries: [...snapshot.entries, inputEntry, outputEntry],
4005
- tail: shellAppend.tail,
4006
- };
4007
- if (nativeLease) {
4008
- await nativeLease.appendMessages({
3705
+ await persistence.commit({
3706
+ kind: 'messages',
3707
+ input: {
4009
3708
  messages: [
4010
3709
  {
4011
3710
  role: 'user',
@@ -4016,8 +3715,9 @@ export class ClaudeSessionService {
4016
3715
  content: `<bash-stdout>${stdout}</bash-stdout><bash-stderr>${stderr}</bash-stderr>`,
4017
3716
  },
4018
3717
  ],
4019
- });
4020
- }
3718
+ },
3719
+ projectionEntries: [inputEntry, outputEntry],
3720
+ });
4021
3721
  currentTurnUserMessages.push(`<bash-stdout>${stdout}</bash-stdout><bash-stderr>${stderr}</bash-stderr>`);
4022
3722
  this.options.eventSink?.({
4023
3723
  type: 'shell-result',
@@ -4028,92 +3728,48 @@ export class ClaudeSessionService {
4028
3728
  });
4029
3729
  }
4030
3730
  if (shellCommand !== undefined) {
4031
- const tracker = this.sessionCostTrackers.get(sessionId);
4032
- if (!tracker) {
4033
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
4034
- }
4035
- const totalUsage = mergeUsage(recoveryUsage, shellUsage);
4036
- const turnModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, shellModelUsage);
4037
- let rawCostUsd;
4038
- if (turnModelUsage) {
4039
- for (const [model, usage] of Object.entries(turnModelUsage)) {
4040
- const pricing = this.options.pricing?.resolve(model);
4041
- const costUsd = pricing
4042
- ? usageCostUsd(usage, pricing)
4043
- : undefined;
4044
- if (costUsd !== undefined)
4045
- rawCostUsd = (rawCostUsd ?? 0) + costUsd;
4046
- tracker.recordTurn({
4047
- model,
4048
- usage,
4049
- ...(costUsd === undefined ? {} : { costUsd }),
4050
- ...(usage.webSearchRequests === undefined
4051
- ? {}
4052
- : { webSearchRequests: usage.webSearchRequests }),
4053
- });
4054
- }
4055
- }
4056
- let combinedToolDurationMs = 0;
4057
- for (const recoveryResult of recoveryResults) {
4058
- combinedToolDurationMs = addToolDuration(recoveryResult.durationToolMs, combinedToolDurationMs);
4059
- }
4060
- combinedToolDurationMs = addToolDuration(shellToolDurationMs, combinedToolDurationMs);
4061
- tracker.recordDurations({
4062
- ...(shellDurationApiMs === undefined
4063
- ? {}
4064
- : { apiDurationMs: shellDurationApiMs }),
4065
- ...(shellDurationApiWithoutRetriesMs === undefined
4066
- ? {}
4067
- : {
4068
- apiDurationWithoutRetriesMs: shellDurationApiWithoutRetriesMs,
4069
- }),
4070
- ...(combinedToolDurationMs === 0
4071
- ? {}
4072
- : { 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,
4073
3737
  });
4074
- if (foregroundLineChanges.linesAdded !== 0 ||
4075
- foregroundLineChanges.linesRemoved !== 0) {
4076
- tracker.recordLineChanges({
4077
- ...(foregroundLineChanges.linesAdded === 0
4078
- ? {}
4079
- : { linesAdded: foregroundLineChanges.linesAdded }),
4080
- ...(foregroundLineChanges.linesRemoved === 0
4081
- ? {}
4082
- : { linesRemoved: foregroundLineChanges.linesRemoved }),
4083
- });
4084
- }
4085
3738
  turnCompleted = true;
4086
3739
  return {
4087
3740
  sessionId,
4088
3741
  text: '',
4089
- usage: totalUsage,
4090
- ...(rawCostUsd === undefined ? {} : { costUsd: rawCostUsd }),
4091
- ...(turnModelUsage ? { modelUsage: { ...turnModelUsage } } : {}),
4092
- ...(shellDurationApiMs === undefined
3742
+ usage: accounting.usage,
3743
+ ...(accounting.costUsd === undefined
3744
+ ? {}
3745
+ : { costUsd: accounting.costUsd }),
3746
+ ...(accounting.modelUsage === undefined
4093
3747
  ? {}
4094
- : { durationApiMs: shellDurationApiMs }),
3748
+ : { modelUsage: accounting.modelUsage }),
3749
+ ...(accounting.durationApiMs === undefined
3750
+ ? {}
3751
+ : { durationApiMs: accounting.durationApiMs }),
4095
3752
  };
4096
3753
  }
4097
3754
  if (shellCommand === undefined && budget) {
4098
- const definitions = currentDefinitions();
4099
3755
  await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4100
- budget.assertFits(budget.evaluate([
4101
- ...contextMessages,
4102
- ...injectTurnContext(activeTurnMessages()),
4103
- ], definitions));
3756
+ const projection = contextPreparation.project({
3757
+ includeMemory: false,
3758
+ });
3759
+ budget.assertFits(budget.evaluate(projection.envelope.messages, projection.envelope.tools));
4104
3760
  }
4105
3761
  let stopHookActive = false;
3762
+ const initialProjection = contextPreparation.project({
3763
+ includeMemory: false,
3764
+ });
4106
3765
  const runtimeRequest = {
4107
3766
  sessionId,
4108
- messages: [
4109
- ...contextMessages,
4110
- ...injectTurnContext(activeTurnMessages()),
4111
- ],
4112
- stableSystemMessageCount,
3767
+ messages: initialProjection.envelope.messages,
3768
+ stableSystemMessageCount: initialProjection.stableSystemMessageCount,
4113
3769
  cwd: this.activeCwd(),
4114
3770
  toolResultDirectory,
4115
3771
  observer,
4116
- ...(activeTurnInput ? { steering: activeTurnInput } : {}),
3772
+ ...(steering ? { steering } : {}),
4117
3773
  ...(this.options.effort ? { effort: this.options.effort } : {}),
4118
3774
  ...(this.options.maxModelTurns !== undefined
4119
3775
  ? { maxModelTurns: this.options.maxModelTurns }
@@ -4138,14 +3794,10 @@ export class ClaudeSessionService {
4138
3794
  if (shellCommand === undefined) {
4139
3795
  await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4140
3796
  }
4141
- runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
4142
- return [
4143
- ...contextMessages,
4144
- ...injectTurnContext([
4145
- ...activeTurnMessages(),
4146
- ...projectMemoryRecallMessages,
4147
- ]),
4148
- ];
3797
+ const projection = contextPreparation.project();
3798
+ runtimeRequest.stableSystemMessageCount =
3799
+ projection.stableSystemMessageCount;
3800
+ return projection.envelope.messages;
4149
3801
  },
4150
3802
  ...(this.options.hooks ||
4151
3803
  subagentExecutor ||
@@ -4176,9 +3828,7 @@ export class ClaudeSessionService {
4176
3828
  }
4177
3829
  const claimedAgentIds = new Set(this.sessionSubagentExecutors(sessionId, true).flatMap((executor) => executor.notificationClaimAgentIds()));
4178
3830
  await Promise.all(this.sessionSubagentExecutors(sessionId, true).map((executor) => {
4179
- const delivered = (notification) => transcriptContainsBackgroundAgentNotification(nativeLease
4180
- ? nativeLease.activeMessages()
4181
- : snapshot.entries, notification);
3831
+ const delivered = (notification) => transcriptContainsBackgroundAgentNotification(persistence.view().activeMessages, notification);
4182
3832
  return this.hostedSubagents.has(executor)
4183
3833
  ? executor.reconcileDetachedNotifications(delivered)
4184
3834
  : executor.reconcileDeliveredNotifications(delivered);
@@ -4186,11 +3836,8 @@ export class ClaudeSessionService {
4186
3836
  await subagentExecutor?.hydratePersistedTasks(sessionId, this.activeCwd(), claimedAgentIds);
4187
3837
  const background = await this.collectSubagentNotifications(sessionId, subagentExecutor ?? undefined);
4188
3838
  if (background) {
4189
- if (nativeLease) {
4190
- for (const message of background.messages) {
4191
- await this.appendBackgroundNotification(sessionId, message, nativeLease);
4192
- }
4193
- }
3839
+ for (const message of background.messages)
3840
+ await this.appendBackgroundNotification(sessionId, message, persistence);
4194
3841
  messages.push(...background.messages);
4195
3842
  }
4196
3843
  const workflow = await this.workflowManager?.notifications(true);
@@ -4274,14 +3921,10 @@ export class ClaudeSessionService {
4274
3921
  }
4275
3922
  // The single reactive retry must use the compacted transcript, not
4276
3923
  // the stale request copy captured before the compact boundary.
4277
- runtimeRequest.messages = [
4278
- ...contextMessages,
4279
- ...injectTurnContext([
4280
- ...activeTurnMessages(),
4281
- ...projectMemoryRecallMessages,
4282
- ]),
4283
- ];
4284
- runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
3924
+ const projection = contextPreparation.project();
3925
+ runtimeRequest.messages = projection.envelope.messages;
3926
+ runtimeRequest.stableSystemMessageCount =
3927
+ projection.stableSystemMessageCount;
4285
3928
  runtimeRequest.deferFailureKinds = true;
4286
3929
  try {
4287
3930
  result = await attemptMainTurn();
@@ -4320,108 +3963,20 @@ export class ClaudeSessionService {
4320
3963
  if (!finalLeafUuid) {
4321
3964
  throw new Error('Could not locate final assistant response');
4322
3965
  }
4323
- if (!nativeLease && !skipUserPrompt) {
4324
- const lastPrompt = createClaudeLastPromptEntry({
4325
- sessionId,
4326
- lastPrompt: effectivePrompt,
4327
- leafUuid: finalLeafUuid,
4328
- });
4329
- const tail = await this.append(lease, snapshot.tail, lastPrompt);
4330
- snapshot = {
4331
- entries: [...snapshot.entries, lastPrompt],
4332
- tail,
4333
- };
4334
- }
4335
- const totalUsage = mergeUsage(mergeUsage(mergeUsage(recoveryUsage, compactionUsage), shellUsage), result.usage);
4336
- const tracker = this.sessionCostTrackers.get(sessionId);
4337
- if (!tracker) {
4338
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
4339
- }
4340
- const turnModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, compactionModelUsage, shellModelUsage, result.modelUsage);
4341
- const combinedDurationMs = compactionDurationMs === undefined &&
4342
- result.durationApiMs === undefined
4343
- ? undefined
4344
- : (compactionDurationMs ?? 0) + (result.durationApiMs ?? 0);
4345
- let rawCostUsd;
4346
- if (turnModelUsage) {
4347
- for (const [model, usage] of Object.entries(turnModelUsage)) {
4348
- const pricing = this.options.pricing?.resolve(model);
4349
- const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
4350
- if (costUsd !== undefined)
4351
- rawCostUsd = (rawCostUsd ?? 0) + costUsd;
4352
- }
4353
- }
4354
- // Auto-compaction metering was already recorded atomically with each
4355
- // committed boundary, and externally metered tool-summary metrics were
4356
- // committed through the summary callback, so the live tracker receives
4357
- // only the unrecorded subset here. The inclusive public aggregates
4358
- // above still contain every row and duration.
4359
- const trackedModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, shellModelUsage, result.unrecordedModelUsage ?? result.modelUsage);
4360
- if (trackedModelUsage) {
4361
- for (const [model, usage] of Object.entries(trackedModelUsage)) {
4362
- const pricing = this.options.pricing?.resolve(model);
4363
- const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
4364
- tracker.recordTurn({
4365
- model,
4366
- usage,
4367
- ...(costUsd === undefined ? {} : { costUsd }),
4368
- ...(usage.webSearchRequests === undefined
4369
- ? {}
4370
- : { webSearchRequests: usage.webSearchRequests }),
4371
- });
4372
- }
4373
- }
4374
- let combinedToolDurationMs = 0;
4375
- for (const recoveryResult of recoveryResults) {
4376
- combinedToolDurationMs = addToolDuration(recoveryResult.durationToolMs, combinedToolDurationMs);
4377
- }
4378
- combinedToolDurationMs = addToolDuration(result.durationToolMs, combinedToolDurationMs);
4379
- const trackedDurationApiMs = result.unrecordedDurationApiMs ?? result.durationApiMs;
4380
- const trackedDurationApiWithoutRetriesMs = result.unrecordedDurationApiWithoutRetriesMs ??
4381
- result.durationApiWithoutRetriesMs;
4382
- tracker.recordDurations({
4383
- ...(trackedDurationApiMs === undefined
4384
- ? {}
4385
- : { apiDurationMs: trackedDurationApiMs }),
4386
- ...(trackedDurationApiWithoutRetriesMs === undefined
4387
- ? {}
4388
- : {
4389
- apiDurationWithoutRetriesMs: trackedDurationApiWithoutRetriesMs,
4390
- }),
4391
- ...(combinedToolDurationMs === 0
4392
- ? {}
4393
- : { toolDurationMs: combinedToolDurationMs }),
4394
- });
4395
- foregroundLineChanges.add({
4396
- isError: false,
4397
- ...(result.linesAdded === undefined
4398
- ? {}
4399
- : { linesAdded: result.linesAdded }),
4400
- ...(result.linesRemoved === undefined
4401
- ? {}
4402
- : { linesRemoved: result.linesRemoved }),
3966
+ const accounting = turnAccounting.complete({
3967
+ kind: 'runtime',
3968
+ recovery: recoveryResults,
3969
+ result,
4403
3970
  });
4404
- if (foregroundLineChanges.linesAdded !== 0 ||
4405
- foregroundLineChanges.linesRemoved !== 0) {
4406
- tracker.recordLineChanges({
4407
- ...(foregroundLineChanges.linesAdded === 0
4408
- ? {}
4409
- : { linesAdded: foregroundLineChanges.linesAdded }),
4410
- ...(foregroundLineChanges.linesRemoved === 0
4411
- ? {}
4412
- : { linesRemoved: foregroundLineChanges.linesRemoved }),
4413
- });
4414
- }
4415
3971
  const memorySnapshot = activeTurnMessages();
4416
- const nativeMemoryMessageId = nativeLease
4417
- ? projectNativeSessionEntries(nativeLease.activeEvents()).at(-1)
4418
- ?.uuid
4419
- : undefined;
4420
- const providerVisibleMessages = [
4421
- ...contextMessages,
4422
- ...injectTurnContext(memorySnapshot),
4423
- ];
4424
- const definitions = currentDefinitions();
3972
+ const nativeMemoryMessageId = persistence
3973
+ .view()
3974
+ .projectionEntries.at(-1)?.uuid;
3975
+ const providerProjection = contextPreparation.project({
3976
+ includeMemory: false,
3977
+ });
3978
+ const providerVisibleMessages = providerProjection.envelope.messages;
3979
+ const definitions = providerProjection.envelope.tools;
4425
3980
  const currentContextTokens = contextEngine.report({
4426
3981
  messages: providerVisibleMessages,
4427
3982
  tools: definitions,
@@ -4438,18 +3993,16 @@ export class ClaudeSessionService {
4438
3993
  occupancyTokens: currentContextTokens,
4439
3994
  toolCalls: currentTurnToolCalls,
4440
3995
  messages: memorySnapshot,
4441
- projectMessages: nativeLease
4442
- ? memorySnapshot.flatMap((message, index) => (message.role === 'user' || message.role === 'assistant') &&
4443
- message.content.trim().length > 0
4444
- ? [
4445
- {
4446
- id: `${finalLeafUuid ?? sessionId}:${index}`,
4447
- role: message.role,
4448
- content: message.content,
4449
- },
4450
- ]
4451
- : [])
4452
- : 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
+ : []),
4453
4006
  directMaintenance: projectMemoryMaintained,
4454
4007
  });
4455
4008
  turnCompleted = true;
@@ -4458,12 +4011,16 @@ export class ClaudeSessionService {
4458
4011
  text: structuredCapture && structuredCapture.calls === 1
4459
4012
  ? JSON.stringify(structuredCapture.value)
4460
4013
  : result.text,
4461
- usage: totalUsage,
4462
- ...(combinedDurationMs === undefined
4014
+ usage: accounting.usage,
4015
+ ...(accounting.durationApiMs === undefined
4463
4016
  ? {}
4464
- : { durationApiMs: combinedDurationMs }),
4465
- ...(rawCostUsd === undefined ? {} : { costUsd: rawCostUsd }),
4466
- ...(turnModelUsage ? { modelUsage: { ...turnModelUsage } } : {}),
4017
+ : { durationApiMs: accounting.durationApiMs }),
4018
+ ...(accounting.costUsd === undefined
4019
+ ? {}
4020
+ : { costUsd: accounting.costUsd }),
4021
+ ...(accounting.modelUsage === undefined
4022
+ ? {}
4023
+ : { modelUsage: accounting.modelUsage }),
4467
4024
  ...(structuredCapture && structuredCapture.calls === 1
4468
4025
  ? { structuredOutput: structuredCapture.value }
4469
4026
  : {}),
@@ -4499,35 +4056,12 @@ export class ClaudeSessionService {
4499
4056
  : { kind: 'resume', atEventId: resumeSessionAt }
4500
4057
  : { kind: 'start' };
4501
4058
  result = await nativeTranscript.withLease(activationKind, async (nativeLease) => {
4502
- const scratch = new InMemoryTranscriptStore();
4503
- const scratchResult = await scratch.withLease((scratchLease) => runUnderLease(scratchLease, nativeLease));
4504
- if (scratchResult.status === 'conflict') {
4505
- throw new Error(`native scratch transcript conflict: ${scratchResult.reason}`);
4506
- }
4507
- return scratchResult.value;
4059
+ const persistence = new TurnPersistence({ native: nativeLease });
4060
+ return runUnderLease(persistence);
4508
4061
  });
4509
4062
  }
4510
- controller.complete();
4511
4063
  return result;
4512
- }
4513
- catch (error) {
4514
- controller.fail(error, signal);
4515
- throw error;
4516
- }
4517
- finally {
4518
- if (activeTurnInput !== undefined) {
4519
- for (const item of activeTurnInput.close()) {
4520
- this.options.eventSink?.({
4521
- type: 'user-input-rejected',
4522
- id: item.id,
4523
- content: item.content,
4524
- reason: signal?.aborted ? 'cancelled' : 'failed',
4525
- });
4526
- }
4527
- }
4528
- if (this.activeTurnInputs.get(sessionId) === activeTurnRecord)
4529
- this.activeTurnInputs.delete(sessionId);
4530
- }
4064
+ });
4531
4065
  }
4532
4066
  async ensureFileResources(sessionId, signal) {
4533
4067
  const resources = this.options.fileResources ?? [];
@@ -4901,6 +4435,16 @@ export class ClaudeSessionService {
4901
4435
  });
4902
4436
  }
4903
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
+ }
4904
4448
  if (nativeLease) {
4905
4449
  if (nativeLease.activeMessages().length === 0) {
4906
4450
  throw new Error(`Native session not found: ${sessionId}`);
@@ -5290,32 +4834,6 @@ export class ClaudeSessionService {
5290
4834
  }
5291
4835
  return factory(base, operations, sessionId, enabledTools);
5292
4836
  }
5293
- async append(lease, tail, entry) {
5294
- void lease;
5295
- return this.appendProjectionMany(tail, [entry]).then((result) => result.tail);
5296
- }
5297
- /**
5298
- * Update the in-memory compatibility projection used by the turn pipeline.
5299
- * Authoritative native persistence is performed by NativeSessionTranscript;
5300
- * these entries are never written to the native event store.
5301
- */
5302
- async appendProjectionMany(tail, entries) {
5303
- if (entries.length === 0)
5304
- throw new Error('Cannot append an empty projection');
5305
- const lastUuid = [...entries]
5306
- .reverse()
5307
- .find((entry) => typeof entry.uuid === 'string')?.uuid;
5308
- return {
5309
- status: 'appended',
5310
- tail: {
5311
- ...tail,
5312
- byteLength: tail.byteLength + entries.length,
5313
- lastLineHash: `projection:${tail.byteLength + entries.length}`,
5314
- lastEventId: typeof lastUuid === 'string' ? lastUuid : tail.lastEventId,
5315
- ...(tail.branchParentId === undefined ? {} : { branchParentId: null }),
5316
- },
5317
- };
5318
- }
5319
4837
  logicalTailUuid(tail) {
5320
4838
  return tail.branchParentId ?? tail.lastEventId;
5321
4839
  }