praxis-agent 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +5 -1
  2. package/dist/application/background-agent-manager.d.ts +16 -6
  3. package/dist/application/background-agent-manager.js +139 -97
  4. package/dist/application/context-engine.d.ts +56 -0
  5. package/dist/application/context-engine.js +129 -0
  6. package/dist/application/session-service.d.ts +8 -4
  7. package/dist/application/session-service.js +528 -434
  8. package/dist/application/subagent-service.js +358 -177
  9. package/dist/application/top-level-agent-manager.d.ts +1 -0
  10. package/dist/application/top-level-agent-manager.js +1025 -403
  11. package/dist/application/transcript-projection.d.ts +33 -0
  12. package/dist/application/transcript-projection.js +110 -0
  13. package/dist/application/turn-lifecycle.d.ts +2 -0
  14. package/dist/application/turn-memory-coordinator.d.ts +43 -0
  15. package/dist/application/turn-memory-coordinator.js +98 -0
  16. package/dist/cli/agents-dashboard.js +10 -5
  17. package/dist/cli/interactive.js +264 -204
  18. package/dist/cli/tui/claude-style.js +154 -130
  19. package/dist/cli/tui/task-panel.js +10 -1
  20. package/dist/cli/tui/theme.d.ts +46 -3
  21. package/dist/cli/tui/theme.js +414 -56
  22. package/dist/cli/tui/tool-permission.js +9 -5
  23. package/dist/cli/tui/tui-interaction-router.d.ts +78 -0
  24. package/dist/cli/tui/tui-interaction-router.js +127 -0
  25. package/dist/cli-runtime.d.ts +2 -2
  26. package/dist/core/agent-orchestration.d.ts +31 -0
  27. package/dist/core/agent-orchestration.js +235 -0
  28. package/dist/core/runtime.d.ts +5 -0
  29. package/dist/core/runtime.js +15 -1
  30. package/dist/persistence/claude-job-store.d.ts +58 -0
  31. package/dist/persistence/claude-job-store.js +483 -1
  32. package/dist/persistence/native-transcript-reader.d.ts +53 -0
  33. package/dist/persistence/native-transcript-reader.js +283 -0
  34. package/dist/persistence/subagent-lifecycle-store.d.ts +41 -8
  35. package/dist/persistence/subagent-lifecycle-store.js +373 -146
  36. package/dist/platform/exclusive-file-lease.d.ts +3 -0
  37. package/dist/platform/exclusive-file-lease.js +3 -0
  38. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, } from
5
5
  import { isPathWithin } from '../platform/path-containment.js';
6
6
  import { AGENT_COLOR_DEFAULT, agentColorMessage, getClaudeEffectiveAgentColor, } from '../compatibility/claude/agent-color.js';
7
7
  import { createClaudeCompactEntries, formatClaudeCompactSummary, getCumulativeDroppedTokens, } from '../compatibility/claude/compaction.js';
8
- import { discoverClaudeProjectRoot, isClaudeSessionId, resolveClaudePaths, resolveClaudeScheduledTaskFile, } from '../compatibility/claude/paths.js';
8
+ import { discoverClaudeProjectRoot, resolveClaudePaths, resolveClaudeScheduledTaskFile, } from '../compatibility/claude/paths.js';
9
9
  import { isClaudeAgentId } from '../compatibility/claude/sidechain.js';
10
10
  import { resolveDataPlanePaths, resolveScheduledTaskFile, } from '../persistence/data-plane.js';
11
11
  import { downloadClaudeFileResources, } from '../compatibility/claude/file-resources.js';
@@ -23,13 +23,18 @@ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
23
23
  import { BackgroundTaskRuntime, } from './background-task-runtime.js';
24
24
  import { backgroundAgentNotificationMarkers, } from './background-agent-manager.js';
25
25
  import { usageCostUsd } from '../core/usage.js';
26
- import { ContextBudget, ContextRecoveryPlanner, contextRecoveryMadeProgress, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
26
+ import { isSessionId } from '../core/session.js';
27
+ import { ContextBudget, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
28
+ import { ContextEngine } from './context-engine.js';
29
+ import { TurnMemoryCoordinator } from './turn-memory-coordinator.js';
27
30
  import { injectFirstUserMessageContext, projectContextSnapshot, } from '../core/context.js';
28
31
  import { assembleContextSnapshot } from '../core/prompt-composer.js';
29
32
  import { ClaudeHookToolCoordinator } from '../hooks/claude-hook-tools.js';
30
33
  import { ClaudeFileChangeWatcher } from '../hooks/claude-file-change-watcher.js';
31
34
  import { ClaudeTranscriptStore, } from '../persistence/claude-transcript-store.js';
32
35
  import { ClaudeSessionIndexCandidateError, readClaudeSessionIndexes, } from '../persistence/claude-session-index.js';
36
+ import { readNativeTranscript, readNativeTranscriptIndexes, exportNativeTranscript, NativeTranscriptIndexCandidateError, } from '../persistence/native-transcript-reader.js';
37
+ import { lastUserPrompt, projectTranscriptDisplay, } from './transcript-projection.js';
33
38
  import { InMemoryTranscriptStore } from '../persistence/in-memory-transcript-store.js';
34
39
  import { SubagentLifecycleStore } from '../persistence/subagent-lifecycle-store.js';
35
40
  import { ModelCompactor } from './model-compactor.js';
@@ -78,6 +83,42 @@ function mainAgentToolNames(tools, agent) {
78
83
  .map(({ name }) => name)
79
84
  .filter((name) => (!requested || requested.has(name)) && !disallowed.has(name));
80
85
  }
86
+ function sessionMetadataFields(metadata, options) {
87
+ const name = metadata.title ??
88
+ (options.agentNameFallback ? metadata.agentName : undefined);
89
+ const prLink = metadata.prLink;
90
+ return {
91
+ ...(name === undefined ? {} : { name }),
92
+ ...(metadata.tag === undefined ? {} : { tag: metadata.tag }),
93
+ ...(metadata.agentName === undefined
94
+ ? {}
95
+ : { agentName: metadata.agentName }),
96
+ ...(metadata.agentColor === undefined
97
+ ? {}
98
+ : { agentColor: metadata.agentColor }),
99
+ ...(metadata.agentSetting === undefined
100
+ ? {}
101
+ : { agentSetting: metadata.agentSetting }),
102
+ ...(metadata.permissionMode === undefined
103
+ ? {}
104
+ : { permissionMode: metadata.permissionMode }),
105
+ ...(metadata.mode === undefined ? {} : { mode: metadata.mode }),
106
+ ...(prLink === undefined
107
+ ? {}
108
+ : {
109
+ prNumber: prLink.prNumber,
110
+ ...(prLink.prUrl === undefined ? {} : { prUrl: prLink.prUrl }),
111
+ ...(prLink.prRepository === undefined
112
+ ? {}
113
+ : { prRepository: prLink.prRepository }),
114
+ }),
115
+ };
116
+ }
117
+ function isSessionCandidateError(error) {
118
+ return (error instanceof ClaudeSessionIndexCandidateError ||
119
+ error instanceof NativeTranscriptIndexCandidateError ||
120
+ ['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code ?? ''));
121
+ }
81
122
  const emptyToolRegistry = {
82
123
  definitions: () => [],
83
124
  schedulingPolicy: () => ({ concurrency: 'exclusive' }),
@@ -1554,19 +1595,62 @@ export class ClaudeSessionService {
1554
1595
  }
1555
1596
  catch (error) {
1556
1597
  if (error.code === 'ENOENT')
1557
- return [];
1558
- throw error;
1598
+ names = [];
1599
+ else
1600
+ throw error;
1559
1601
  }
1560
1602
  const discoveredSessionIds = names
1561
1603
  .filter((name) => extname(name) === '.jsonl')
1562
1604
  .map((name) => basename(name, '.jsonl'))
1563
- .filter((sessionId) => isClaudeSessionId(sessionId));
1605
+ .filter((sessionId) => isSessionId(sessionId));
1564
1606
  const sessionIds = [
1565
1607
  ...new Set([
1566
1608
  ...discoveredSessionIds,
1567
1609
  ...this.explicitSessionFiles.keys(),
1568
1610
  ]),
1569
1611
  ];
1612
+ if (this.options.dataPlane === 'native') {
1613
+ const nativeResults = await readNativeTranscriptIndexes(sessionIds.map((sessionId) => ({
1614
+ sessionId,
1615
+ path: this.explicitSessionFiles.get(sessionId) ??
1616
+ join(projectRoot, `${sessionId}.jsonl`),
1617
+ })));
1618
+ const legacyResults = await readClaudeSessionIndexes(nativeResults.flatMap((result) => 'result' in result && result.result.format === 'legacy'
1619
+ ? [{ sessionId: result.sessionId, path: result.path }]
1620
+ : []), this.schema);
1621
+ const legacyBySessionId = new Map(legacyResults.map((result) => [result.sessionId, result]));
1622
+ const nativeSummaries = nativeResults.map((result) => {
1623
+ try {
1624
+ if ('error' in result)
1625
+ throw result.error;
1626
+ const file = result.result;
1627
+ if (file.format === 'legacy') {
1628
+ const legacy = legacyBySessionId.get(result.sessionId);
1629
+ if (!legacy)
1630
+ throw new Error(`Missing legacy transcript index for ${result.sessionId}`);
1631
+ if ('error' in legacy)
1632
+ throw legacy.error;
1633
+ return this.claudeSessionSummary(result.sessionId, legacy.index);
1634
+ }
1635
+ return {
1636
+ sessionId: result.sessionId,
1637
+ lastPrompt: lastUserPrompt(file.records.map((record) => record.event)),
1638
+ updatedAt: file.updatedAt,
1639
+ status: this.nativeSessionStatus(file.issue, file.records.length),
1640
+ issue: file.issue,
1641
+ };
1642
+ }
1643
+ catch (error) {
1644
+ if (isSessionCandidateError(error))
1645
+ return null;
1646
+ throw error;
1647
+ }
1648
+ });
1649
+ return nativeSummaries
1650
+ .filter((summary) => summary !== null)
1651
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) ||
1652
+ left.sessionId.localeCompare(right.sessionId));
1653
+ }
1570
1654
  if (discoveredRoot !== undefined) {
1571
1655
  for (const sessionId of discoveredSessionIds) {
1572
1656
  this.discoveredProjectRoots.set(sessionId, projectRoot);
@@ -1581,46 +1665,11 @@ export class ClaudeSessionService {
1581
1665
  try {
1582
1666
  if ('error' in result)
1583
1667
  throw result.error;
1584
- const { sessionId, index } = result;
1585
- const metadata = reduceClaudeSessionMetadata(index.entries, sessionId);
1586
- const name = metadata.title ?? metadata.agentName;
1587
- const prLink = metadata.prLink;
1588
- return {
1589
- sessionId,
1590
- ...(name === undefined ? {} : { name }),
1591
- ...(metadata.tag === undefined ? {} : { tag: metadata.tag }),
1592
- ...(metadata.agentName === undefined
1593
- ? {}
1594
- : { agentName: metadata.agentName }),
1595
- ...(metadata.agentColor === undefined
1596
- ? {}
1597
- : { agentColor: metadata.agentColor }),
1598
- ...(metadata.agentSetting === undefined
1599
- ? {}
1600
- : { agentSetting: metadata.agentSetting }),
1601
- ...(metadata.permissionMode === undefined
1602
- ? {}
1603
- : { permissionMode: metadata.permissionMode }),
1604
- ...(metadata.mode === undefined ? {} : { mode: metadata.mode }),
1605
- lastPrompt: metadata.lastPrompt ?? null,
1606
- updatedAt: index.updatedAt,
1607
- status: this.sessionStatus(index.issue, index.entries.length),
1608
- issue: index.issue,
1609
- ...(prLink
1610
- ? {
1611
- prNumber: prLink.prNumber,
1612
- prUrl: prLink.prUrl,
1613
- prRepository: prLink.prRepository,
1614
- }
1615
- : {}),
1616
- };
1668
+ return this.claudeSessionSummary(result.sessionId, result.index);
1617
1669
  }
1618
1670
  catch (error) {
1619
- if (error instanceof ClaudeSessionIndexCandidateError)
1620
- return null;
1621
- if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code ?? '')) {
1671
+ if (isSessionCandidateError(error))
1622
1672
  return null;
1623
- }
1624
1673
  throw error;
1625
1674
  }
1626
1675
  });
@@ -1631,6 +1680,37 @@ export class ClaudeSessionService {
1631
1680
  }
1632
1681
  async inspect(sessionId) {
1633
1682
  this.assertSessionPersistence();
1683
+ if (this.options.dataPlane === 'native') {
1684
+ const paths = this.paths(sessionId);
1685
+ const file = await readNativeTranscript(paths.sessionFile);
1686
+ if (file.format === 'legacy') {
1687
+ const recovery = await this.store(sessionId).loadReadOnly();
1688
+ const metadata = reduceClaudeSessionMetadata(recovery.entries, sessionId);
1689
+ return {
1690
+ sessionId,
1691
+ ...sessionMetadataFields(metadata, { agentNameFallback: false }),
1692
+ lastPrompt: metadata.lastPrompt ?? null,
1693
+ updatedAt: file.updatedAt,
1694
+ status: this.sessionStatus(recovery.issue, recovery.entries.length),
1695
+ issue: recovery.issue,
1696
+ writeMode: this.schema.writeMode,
1697
+ entryCount: recovery.entries.length,
1698
+ byteLength: file.byteLength,
1699
+ newlineTerminated: file.newlineTerminated,
1700
+ };
1701
+ }
1702
+ return {
1703
+ sessionId,
1704
+ lastPrompt: lastUserPrompt(file.records.map((record) => record.event)),
1705
+ updatedAt: file.updatedAt,
1706
+ status: this.nativeSessionStatus(file.issue, file.records.length),
1707
+ issue: file.issue,
1708
+ writeMode: file.writeMode,
1709
+ entryCount: file.records.length,
1710
+ byteLength: file.byteLength,
1711
+ newlineTerminated: file.newlineTerminated,
1712
+ };
1713
+ }
1634
1714
  await this.discoverProjectRoot(sessionId);
1635
1715
  const paths = this.paths(sessionId);
1636
1716
  let metadata;
@@ -1645,30 +1725,9 @@ export class ClaudeSessionService {
1645
1725
  }
1646
1726
  const recovery = await this.store(sessionId).loadReadOnly();
1647
1727
  const sessionMetadata = reduceClaudeSessionMetadata(recovery.entries, sessionId);
1648
- const prLink = sessionMetadata.prLink;
1649
1728
  return {
1650
1729
  sessionId,
1651
- ...(sessionMetadata.title === undefined
1652
- ? {}
1653
- : { name: sessionMetadata.title }),
1654
- ...(sessionMetadata.tag === undefined
1655
- ? {}
1656
- : { tag: sessionMetadata.tag }),
1657
- ...(sessionMetadata.agentName === undefined
1658
- ? {}
1659
- : { agentName: sessionMetadata.agentName }),
1660
- ...(sessionMetadata.agentColor === undefined
1661
- ? {}
1662
- : { agentColor: sessionMetadata.agentColor }),
1663
- ...(sessionMetadata.agentSetting === undefined
1664
- ? {}
1665
- : { agentSetting: sessionMetadata.agentSetting }),
1666
- ...(sessionMetadata.permissionMode === undefined
1667
- ? {}
1668
- : { permissionMode: sessionMetadata.permissionMode }),
1669
- ...(sessionMetadata.mode === undefined
1670
- ? {}
1671
- : { mode: sessionMetadata.mode }),
1730
+ ...sessionMetadataFields(sessionMetadata, { agentNameFallback: false }),
1672
1731
  lastPrompt: sessionMetadata.lastPrompt ?? null,
1673
1732
  updatedAt: metadata.mtime.toISOString(),
1674
1733
  status: this.sessionStatus(recovery.issue, recovery.entries.length),
@@ -1678,13 +1737,6 @@ export class ClaudeSessionService {
1678
1737
  entryCount: recovery.entries.length,
1679
1738
  byteLength: recovery.tail.byteLength,
1680
1739
  newlineTerminated: recovery.tail.newlineTerminated,
1681
- ...(prLink
1682
- ? {
1683
- prNumber: prLink.prNumber,
1684
- prUrl: prLink.prUrl,
1685
- prRepository: prLink.prRepository,
1686
- }
1687
- : {}),
1688
1740
  };
1689
1741
  }
1690
1742
  async readEffectiveAgentColor(sessionId) {
@@ -1694,6 +1746,8 @@ export class ClaudeSessionService {
1694
1746
  }
1695
1747
  async export(sessionId) {
1696
1748
  this.assertSessionPersistence();
1749
+ if (this.options.dataPlane === 'native')
1750
+ return exportNativeTranscript(this.paths(sessionId).sessionFile);
1697
1751
  await this.discoverProjectRoot(sessionId);
1698
1752
  try {
1699
1753
  return await this.store(sessionId).exportReadOnly();
@@ -1709,17 +1763,40 @@ export class ClaudeSessionService {
1709
1763
  this.assertSessionPersistence();
1710
1764
  const path = resolve(requestedPath);
1711
1765
  const pathMetadata = await lstat(path);
1766
+ const dataPlaneName = this.options.dataPlane === 'native' ? 'Native' : 'Claude';
1712
1767
  if (!pathMetadata.isFile()) {
1713
- throw new Error(`Claude resume path must be a regular JSONL file: ${path}`);
1768
+ throw new Error(`${dataPlaneName} resume path must be a regular JSONL file: ${path}`);
1714
1769
  }
1715
1770
  if (extname(path).toLowerCase() !== '.jsonl') {
1716
- throw new Error(`Claude resume path must end in .jsonl: ${path}`);
1771
+ throw new Error(`${dataPlaneName} resume path must end in .jsonl: ${path}`);
1717
1772
  }
1718
1773
  const sessionId = basename(path, '.jsonl');
1719
- if (!isClaudeSessionId(sessionId)) {
1720
- throw new Error(`Claude resume path filename must be a session UUID: ${path}`);
1774
+ if (!isSessionId(sessionId)) {
1775
+ throw new Error(`${dataPlaneName} resume path filename must be a session UUID: ${path}`);
1721
1776
  }
1722
1777
  const canonicalPath = await realpath(path);
1778
+ if (this.options.dataPlane === 'native') {
1779
+ const detected = await readNativeTranscript(canonicalPath);
1780
+ if (detected.format === 'native') {
1781
+ if (detected.issue?.kind === 'unsupported-version')
1782
+ throw new Error('Native resume transcript version is unsupported');
1783
+ if (detected.issue ||
1784
+ !detected.newlineTerminated ||
1785
+ detected.records.length === 0)
1786
+ throw new Error('Native resume transcript must be a complete non-empty newline-terminated file');
1787
+ if (detected.records.some((record) => record.event.sessionId !== sessionId))
1788
+ throw new Error('Native resume transcript contains a different sessionId');
1789
+ this.explicitSessionFiles.set(sessionId, canonicalPath);
1790
+ this.discoveredProjectRoots.set(sessionId, dirname(canonicalPath));
1791
+ return {
1792
+ sessionId,
1793
+ lastPrompt: lastUserPrompt(detected.records.map((record) => record.event)),
1794
+ updatedAt: detected.updatedAt,
1795
+ status: 'read-only',
1796
+ issue: detected.issue,
1797
+ };
1798
+ }
1799
+ }
1723
1800
  const exact = this.pathsForCwd(sessionId, this.activeCwd());
1724
1801
  const candidate = new ClaudeTranscriptStore({
1725
1802
  sessionFile: canonicalPath,
@@ -1733,6 +1810,10 @@ export class ClaudeSessionService {
1733
1810
  if (snapshot.entries.length === 0) {
1734
1811
  throw new Error('Claude resume transcript must not be empty');
1735
1812
  }
1813
+ if (this.options.dataPlane === 'native' &&
1814
+ this.schema.writeMode !== 'read-write') {
1815
+ throw new Error('Native legacy resume transcript is read-only');
1816
+ }
1736
1817
  let matchingSessionIdentity = false;
1737
1818
  for (const entry of snapshot.entries) {
1738
1819
  if (entry.sessionId === sessionId)
@@ -1750,40 +1831,21 @@ export class ClaudeSessionService {
1750
1831
  this.explicitResumeLeafUuids.set(sessionId, selected.leafUuid);
1751
1832
  this.discoveredProjectRoots.set(sessionId, dirname(canonicalPath));
1752
1833
  const metadata = reduceClaudeSessionMetadata(snapshot.entries, sessionId);
1753
- const prLink = metadata.prLink;
1754
1834
  return {
1755
1835
  sessionId,
1756
- ...(metadata.title === undefined ? {} : { name: metadata.title }),
1757
- ...(metadata.tag === undefined ? {} : { tag: metadata.tag }),
1758
- ...(metadata.agentName === undefined
1759
- ? {}
1760
- : { agentName: metadata.agentName }),
1761
- ...(metadata.agentColor === undefined
1762
- ? {}
1763
- : { agentColor: metadata.agentColor }),
1764
- ...(metadata.agentSetting === undefined
1765
- ? {}
1766
- : { agentSetting: metadata.agentSetting }),
1767
- ...(metadata.permissionMode === undefined
1768
- ? {}
1769
- : { permissionMode: metadata.permissionMode }),
1770
- ...(metadata.mode === undefined ? {} : { mode: metadata.mode }),
1836
+ ...sessionMetadataFields(metadata, { agentNameFallback: false }),
1771
1837
  lastPrompt: metadata.lastPrompt ?? null,
1772
1838
  updatedAt: pathMetadata.mtime.toISOString(),
1773
1839
  status: this.sessionStatus(null, snapshot.entries.length),
1774
1840
  issue: null,
1775
- ...(prLink === undefined
1776
- ? {}
1777
- : {
1778
- prNumber: prLink.prNumber,
1779
- ...(prLink.prUrl === undefined ? {} : { prUrl: prLink.prUrl }),
1780
- ...(prLink.prRepository === undefined
1781
- ? {}
1782
- : { prRepository: prLink.prRepository }),
1783
- }),
1784
1841
  };
1785
1842
  }
1786
1843
  async transcript(sessionId, resumeSessionAt) {
1844
+ if (this.options.dataPlane === 'native') {
1845
+ const file = await readNativeTranscript(this.paths(sessionId).sessionFile);
1846
+ if (file.format === 'native')
1847
+ return projectTranscriptDisplay(file.records.map((record) => record.event), resumeSessionAt);
1848
+ }
1787
1849
  await this.discoverProjectRoot(sessionId);
1788
1850
  try {
1789
1851
  const recovery = await this.store(sessionId).loadReadOnly();
@@ -2371,6 +2433,7 @@ export class ClaudeSessionService {
2371
2433
  }
2372
2434
  async fork(parentSessionId, sessionId = randomUUID(), resumeSessionAt) {
2373
2435
  this.assertSessionPersistence();
2436
+ await this.assertNativeWriteTargetIsLegacy(parentSessionId);
2374
2437
  this.assertWritable();
2375
2438
  const entries = await this.nativeForkEntries(parentSessionId, sessionId, resumeSessionAt);
2376
2439
  const result = await this.store(sessionId).create(entries);
@@ -2701,6 +2764,8 @@ export class ClaudeSessionService {
2701
2764
  const skipUserPrompt = submission.kind === 'retry';
2702
2765
  const controller = new TurnTerminalController(this.options.eventSink ?? (() => undefined));
2703
2766
  try {
2767
+ if (requireExisting)
2768
+ await this.assertNativeWriteTargetIsLegacy(sessionId);
2704
2769
  this.assertWritable();
2705
2770
  if (prompt.length === 0 && images.length === 0 && documents.length === 0)
2706
2771
  throw new Error('Prompt must not be empty');
@@ -2834,14 +2899,25 @@ export class ClaudeSessionService {
2834
2899
  agent?.initialPrompt
2835
2900
  ? `${agent.initialPrompt}\n\n${prompt}`
2836
2901
  : prompt);
2837
- const projectMemoryRecallTurn = !skipUserPrompt && shellCommand === undefined
2838
- ? this.options.projectMemoryRecall?.prefetch({
2839
- sessionId,
2902
+ const sessionMemory = this.sessionMemoryController(sessionId);
2903
+ const turnMemory = new TurnMemoryCoordinator({
2904
+ sessionId,
2905
+ ...(sessionMemory ? { session: sessionMemory } : {}),
2906
+ ...(this.options.projectMemoryRecall
2907
+ ? { projectRecall: this.options.projectMemoryRecall }
2908
+ : {}),
2909
+ ...(this.options.projectMemoryExtraction
2910
+ ? { projectExtraction: this.options.projectMemoryExtraction }
2911
+ : {}),
2912
+ warn: (message) => this.options.eventSink?.({ type: 'warning', message }),
2913
+ });
2914
+ if (!skipUserPrompt && shellCommand === undefined) {
2915
+ turnMemory.prefetch({
2840
2916
  turnId: randomUUID(),
2841
2917
  prompt: effectivePrompt,
2842
2918
  ...(signal ? { signal } : {}),
2843
- })
2844
- : undefined;
2919
+ });
2920
+ }
2845
2921
  const initialPricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
2846
2922
  if (this.options.maxBudgetUsd !== undefined && !initialPricing) {
2847
2923
  throw new Error(`Cannot enforce --max-budget-usd: no pricing is configured for model ${provider.model ?? 'praxis/provider'}`);
@@ -3206,7 +3282,11 @@ export class ClaudeSessionService {
3206
3282
  let currentTurnToolCalls = 0;
3207
3283
  let projectMemoryMaintained = false;
3208
3284
  let projectMemoryRecallMessages = [];
3285
+ let observeModelRequestUsage = () => undefined;
3209
3286
  const observer = {
3287
+ modelRequestCompleted: async (input) => {
3288
+ observeModelRequestUsage(input);
3289
+ },
3210
3290
  assistantCompleted: async (message) => {
3211
3291
  const [entry] = translateProviderEvents([
3212
3292
  {
@@ -3292,7 +3372,7 @@ export class ClaudeSessionService {
3292
3372
  if (path &&
3293
3373
  isPathWithin(this.options.projectMemoryDirectory, path)) {
3294
3374
  if (call.name === 'Read') {
3295
- this.options.projectMemoryRecall?.recordRead(sessionId, path);
3375
+ turnMemory.recordRead(path);
3296
3376
  }
3297
3377
  else if (call.name === 'Write' || call.name === 'Edit') {
3298
3378
  projectMemoryMaintained = true;
@@ -3302,7 +3382,7 @@ export class ClaudeSessionService {
3302
3382
  for (const accessedPath of toolResult.accessedPaths ?? []) {
3303
3383
  const resolvedAccessedPath = resolve(this.activeCwd(), accessedPath);
3304
3384
  if (isPathWithin(this.options.projectMemoryDirectory, resolvedAccessedPath)) {
3305
- this.options.projectMemoryRecall?.recordRead(sessionId, resolvedAccessedPath);
3385
+ turnMemory.recordRead(resolvedAccessedPath);
3306
3386
  }
3307
3387
  }
3308
3388
  }
@@ -3408,7 +3488,6 @@ export class ClaudeSessionService {
3408
3488
  tail: settingTail,
3409
3489
  };
3410
3490
  }
3411
- const sessionMemory = this.sessionMemoryController(sessionId);
3412
3491
  let agentSystem = null;
3413
3492
  let planModeMessage;
3414
3493
  let sessionMemoryMessage = null;
@@ -3422,9 +3501,7 @@ export class ClaudeSessionService {
3422
3501
  agentSystem = await this.mainAgentSystemPrompt(agent);
3423
3502
  planModeMessage =
3424
3503
  this.options.interactiveTools?.contextMessage(sessionId);
3425
- sessionMemoryMessage = sessionMemory
3426
- ? this.sessionMemoryMessage(await sessionMemory.summary())
3427
- : null;
3504
+ sessionMemoryMessage = this.sessionMemoryMessage(await turnMemory.sessionSummary());
3428
3505
  const assembledContext = await assembleContextSnapshot(this.options.contextAssembler, {
3429
3506
  cwd: this.activeCwd(),
3430
3507
  lifecycleId: sessionId,
@@ -3479,7 +3556,17 @@ export class ClaudeSessionService {
3479
3556
  ? (structuredTools?.definitions() ?? [])
3480
3557
  : [];
3481
3558
  const budget = this.contextBudget(provider);
3482
- const recoveryPlanner = new ContextRecoveryPlanner();
3559
+ const contextEngine = new ContextEngine({
3560
+ ...(budget ? { budget } : {}),
3561
+ autoCompact: this.options.autoCompact !== false,
3562
+ memory: {
3563
+ beforeCompact: () => turnMemory.beforeCompact(),
3564
+ afterCompact: () => turnMemory.afterCompact(),
3565
+ },
3566
+ });
3567
+ observeModelRequestUsage = ({ usage, messages, tools }) => {
3568
+ contextEngine.observeUsage(usage, messages, tools);
3569
+ };
3483
3570
  const pendingUserMessages = expandedMessages.map((message, index) => ({
3484
3571
  role: 'user',
3485
3572
  content: message.text,
@@ -3531,276 +3618,301 @@ export class ClaudeSessionService {
3531
3618
  const injectDynamicContext = (messages) => injectFirstUserMessageContext(messages, contextProjection.firstUserMessageContext);
3532
3619
  const injectTurnContext = (messages) => injectAgentMentionContext(injectDynamicContext(messages));
3533
3620
  let compactionAnchorUuid = this.lastMessageUuid(snapshot.entries);
3534
- const compactIfNeeded = async (pendingMessages = [], preservedUserMessages = [], options = {}) => {
3535
- if (!budget || this.options.autoCompact === false)
3536
- return;
3537
- const historyMessages = [
3538
- ...projectClaudeModelMessages(snapshot.entries),
3539
- ...projectMemoryRecallMessages,
3540
- ];
3541
- const predicted = budget.evaluate([
3542
- ...contextMessages,
3543
- ...injectTurnContext([...historyMessages, ...pendingMessages]),
3544
- ], definitions, options);
3545
- if (!predicted.shouldCompact)
3546
- return;
3547
- const irreducibleMessages = [
3548
- ...contextMessages,
3549
- ...injectTurnContext([
3550
- ...pendingMessages,
3551
- ...preservedUserMessages.map((content) => ({
3552
- role: 'user',
3553
- content,
3554
- })),
3555
- ]),
3556
- ];
3557
- const irreducible = budget.evaluate(irreducibleMessages, definitions);
3558
- budget.assertFits(irreducible);
3559
- let logicalParentUuid = compactionAnchorUuid;
3560
- if (!logicalParentUuid || historyMessages.length === 0) {
3561
- budget.assertFits(predicted);
3562
- throw new Error('Cannot compact an empty Claude transcript');
3563
- }
3564
- if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
3565
- throw new Error('Cannot compact a Claude session with unresolved tool calls');
3566
- }
3567
- const preCompact = await this.runAdvisoryHook(sessionId, 'PreCompact', { trigger: 'auto', custom_instructions: null }, 'auto', signal);
3568
- const memorySelection = sessionMemory
3569
- ? await this.selectMemoryPreservedCompact(sessionId, selectClaudeActiveTranscript(snapshot.entries))
3570
- : null;
3571
- const compactorMessages = memorySelection
3572
- ? [
3573
- memorySelection.memoryMessage,
3574
- ...projectClaudeModelMessages(memorySelection.compactedEntries),
3575
- ]
3576
- : historyMessages;
3577
- compactorMessages.push(...successfulHookOutput(preCompact).map((content) => ({
3578
- role: 'user',
3579
- content: `Additional summarization context: ${content}`,
3580
- })));
3581
- if (memorySelection) {
3582
- logicalParentUuid = memorySelection.logicalParentUuid;
3583
- }
3584
- this.options.eventSink?.({ type: 'state', state: 'compacting' });
3585
- const compactEnvelope = budget.evaluate([
3586
- ...irreducibleMessages,
3587
- {
3621
+ const contextTransitionPort = (pendingMessages = [], preservedUserMessages = []) => ({
3622
+ current: () => {
3623
+ const historyMessages = [
3624
+ ...projectClaudeModelMessages(snapshot.entries),
3625
+ ...projectMemoryRecallMessages,
3626
+ ];
3627
+ return {
3628
+ messages: [
3629
+ ...contextMessages,
3630
+ ...injectTurnContext([
3631
+ ...historyMessages,
3632
+ ...pendingMessages,
3633
+ ]),
3634
+ ],
3635
+ tools: definitions,
3636
+ };
3637
+ },
3638
+ irreducible: () => ({
3639
+ messages: [
3640
+ ...contextMessages,
3641
+ ...injectTurnContext([
3642
+ ...pendingMessages,
3643
+ ...preservedUserMessages.map((content) => ({
3644
+ role: 'user',
3645
+ content,
3646
+ })),
3647
+ ]),
3648
+ ],
3649
+ tools: definitions,
3650
+ }),
3651
+ propose: async () => {
3652
+ if (!budget) {
3653
+ throw new Error('Context budget is unavailable');
3654
+ }
3655
+ const historyMessages = [
3656
+ ...projectClaudeModelMessages(snapshot.entries),
3657
+ ...projectMemoryRecallMessages,
3658
+ ];
3659
+ const irreducibleMessages = [
3660
+ ...contextMessages,
3661
+ ...injectTurnContext([
3662
+ ...pendingMessages,
3663
+ ...preservedUserMessages.map((content) => ({
3664
+ role: 'user',
3665
+ content,
3666
+ })),
3667
+ ]),
3668
+ ];
3669
+ let logicalParentUuid = compactionAnchorUuid;
3670
+ if (!logicalParentUuid || historyMessages.length === 0) {
3671
+ throw new Error('Cannot compact an empty Claude transcript');
3672
+ }
3673
+ if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
3674
+ throw new Error('Cannot compact a Claude session with unresolved tool calls');
3675
+ }
3676
+ const preCompact = await this.runAdvisoryHook(sessionId, 'PreCompact', { trigger: 'auto', custom_instructions: null }, 'auto', signal);
3677
+ const memorySelection = sessionMemory
3678
+ ? await this.selectMemoryPreservedCompact(sessionId, selectClaudeActiveTranscript(snapshot.entries), { sessionMemoryReady: true })
3679
+ : null;
3680
+ const compactorMessages = memorySelection
3681
+ ? [
3682
+ memorySelection.memoryMessage,
3683
+ ...projectClaudeModelMessages(memorySelection.compactedEntries),
3684
+ ]
3685
+ : historyMessages;
3686
+ compactorMessages.push(...successfulHookOutput(preCompact).map((content) => ({
3588
3687
  role: 'user',
3589
- content: formatClaudeCompactSummary(''),
3590
- },
3591
- ], definitions);
3592
- let targetTokens = Math.min(8192, compactEnvelope.availableTokens - compactEnvelope.estimatedTokens);
3593
- if (targetTokens < 1) {
3594
- budget.assertFits(budget.evaluate([
3688
+ content: `Additional summarization context: ${content}`,
3689
+ })));
3690
+ if (memorySelection) {
3691
+ logicalParentUuid = memorySelection.logicalParentUuid;
3692
+ }
3693
+ this.options.eventSink?.({ type: 'state', state: 'compacting' });
3694
+ const compactEnvelope = budget.evaluate([
3595
3695
  ...irreducibleMessages,
3596
3696
  {
3597
3697
  role: 'user',
3598
- content: formatClaudeCompactSummary('a'),
3698
+ content: formatClaudeCompactSummary(''),
3599
3699
  },
3600
- ], definitions));
3601
- targetTokens = 1;
3602
- }
3603
- const compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
3604
- messages: compactorMessages,
3605
- targetTokens,
3606
- contextWindowTokens: budget.contextWindowTokens,
3607
- ...(signal ? { signal } : {}),
3608
- });
3609
- const { durationMs: compactedDurationMs, durationWithoutRetriesMs: compactedDurationWithoutRetriesMs, } = requireCompactionDurations(compacted);
3610
- const proposedCompactionDurationMs = (compactionDurationMs ?? 0) + compactedDurationMs;
3611
- if (!Number.isFinite(proposedCompactionDurationMs)) {
3612
- throw new TypeError('compaction durationMs total overflow');
3613
- }
3614
- const proposedCompactionDurationWithoutRetriesMs = (compactionDurationWithoutRetriesMs ?? 0) +
3615
- compactedDurationWithoutRetriesMs;
3616
- if (!Number.isFinite(proposedCompactionDurationWithoutRetriesMs)) {
3617
- throw new TypeError('compaction durationWithoutRetriesMs total overflow');
3618
- }
3619
- const boundaryUuid = randomUUID();
3620
- const summaryUuid = randomUUID();
3621
- const timestamp = new Date().toISOString();
3622
- const preTokens = budget.evaluate([...contextMessages, ...injectTurnContext(historyMessages)], definitions).estimatedTokens;
3623
- const compactEntries = (postTokens) => {
3624
- const uuids = [boundaryUuid, summaryUuid];
3625
- return createClaudeCompactEntries({
3700
+ ], definitions);
3701
+ let targetTokens = Math.min(8192, compactEnvelope.availableTokens -
3702
+ compactEnvelope.estimatedTokens);
3703
+ if (targetTokens < 1) {
3704
+ budget.assertFits(budget.evaluate([
3705
+ ...irreducibleMessages,
3706
+ {
3707
+ role: 'user',
3708
+ content: formatClaudeCompactSummary('a'),
3709
+ },
3710
+ ], definitions));
3711
+ targetTokens = 1;
3712
+ }
3713
+ const compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
3714
+ messages: compactorMessages,
3715
+ targetTokens,
3716
+ contextWindowTokens: budget.contextWindowTokens,
3717
+ ...(signal ? { signal } : {}),
3718
+ });
3719
+ const { durationMs: compactedDurationMs, durationWithoutRetriesMs: compactedDurationWithoutRetriesMs, } = requireCompactionDurations(compacted);
3720
+ const proposedCompactionDurationMs = (compactionDurationMs ?? 0) + compactedDurationMs;
3721
+ if (!Number.isFinite(proposedCompactionDurationMs)) {
3722
+ throw new TypeError('compaction durationMs total overflow');
3723
+ }
3724
+ const proposedCompactionDurationWithoutRetriesMs = (compactionDurationWithoutRetriesMs ?? 0) +
3725
+ compactedDurationWithoutRetriesMs;
3726
+ if (!Number.isFinite(proposedCompactionDurationWithoutRetriesMs)) {
3727
+ throw new TypeError('compaction durationWithoutRetriesMs total overflow');
3728
+ }
3729
+ const boundaryUuid = randomUUID();
3730
+ const summaryUuid = randomUUID();
3731
+ const timestamp = new Date().toISOString();
3732
+ const preTokens = budget.evaluate([...contextMessages, ...injectTurnContext(historyMessages)], definitions).estimatedTokens;
3733
+ const compactEntries = (postTokens) => {
3734
+ const uuids = [boundaryUuid, summaryUuid];
3735
+ return createClaudeCompactEntries({
3736
+ sessionId,
3737
+ logicalParentUuid,
3738
+ summary: compacted.summary,
3739
+ preTokens,
3740
+ postTokens,
3741
+ previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
3742
+ durationMs: compactedDurationMs,
3743
+ cwd: this.activeCwd(),
3744
+ claudeVersion: this.options.claudeVersion,
3745
+ gitBranch: null,
3746
+ ...(memorySelection
3747
+ ? {
3748
+ preservedUuids: memorySelection.preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
3749
+ }
3750
+ : {}),
3751
+ createUuid: () => uuids.shift() ?? randomUUID(),
3752
+ now: () => timestamp,
3753
+ });
3754
+ };
3755
+ const provisionalEntries = compactEntries(0);
3756
+ const compactSummaryUuid = provisionalEntries.at(-1)?.uuid;
3757
+ if (typeof compactSummaryUuid !== 'string') {
3758
+ throw new Error('Could not create Claude compact summary');
3759
+ }
3760
+ const replayUuids = preservedUserMessages.map(() => randomUUID());
3761
+ const replayEntries = translateProviderEvents(preservedUserMessages.map((text, index) => index === 0
3762
+ ? { type: 'user-text', text }
3763
+ : { type: 'user-text-block', text }), {
3626
3764
  sessionId,
3627
- logicalParentUuid,
3628
- summary: compacted.summary,
3629
- preTokens,
3630
- postTokens,
3631
- previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
3632
- durationMs: compactedDurationMs,
3765
+ parentUuid: compactSummaryUuid,
3633
3766
  cwd: this.activeCwd(),
3634
3767
  claudeVersion: this.options.claudeVersion,
3635
3768
  gitBranch: null,
3636
- ...(memorySelection
3637
- ? {
3638
- preservedUuids: memorySelection.preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
3639
- }
3640
- : {}),
3641
- createUuid: () => uuids.shift() ?? randomUUID(),
3769
+ history: [...snapshot.entries, ...provisionalEntries],
3770
+ createUuid: () => replayUuids.shift() ?? randomUUID(),
3642
3771
  now: () => timestamp,
3643
3772
  });
3644
- };
3645
- const provisionalEntries = compactEntries(0);
3646
- const compactSummaryUuid = provisionalEntries.at(-1)?.uuid;
3647
- if (typeof compactSummaryUuid !== 'string') {
3648
- throw new Error('Could not create Claude compact summary');
3649
- }
3650
- const replayUuids = preservedUserMessages.map(() => randomUUID());
3651
- const replayEntries = translateProviderEvents(preservedUserMessages.map((text, index) => index === 0
3652
- ? { type: 'user-text', text }
3653
- : { type: 'user-text-block', text }), {
3654
- sessionId,
3655
- parentUuid: compactSummaryUuid,
3656
- cwd: this.activeCwd(),
3657
- claudeVersion: this.options.claudeVersion,
3658
- gitBranch: null,
3659
- history: [...snapshot.entries, ...provisionalEntries],
3660
- createUuid: () => replayUuids.shift() ?? randomUUID(),
3661
- now: () => timestamp,
3662
- });
3663
- const compactedHistory = [
3664
- ...projectClaudeModelMessages([
3665
- ...snapshot.entries,
3666
- ...provisionalEntries,
3773
+ const compactedHistory = [
3774
+ ...projectClaudeModelMessages([
3775
+ ...snapshot.entries,
3776
+ ...provisionalEntries,
3777
+ ...replayEntries,
3778
+ ]),
3779
+ ...projectMemoryRecallMessages,
3780
+ ];
3781
+ const afterHistory = budget.evaluate([...contextMessages, ...injectTurnContext(compactedHistory)], definitions);
3782
+ const proposedMessages = [
3783
+ ...contextMessages,
3784
+ ...injectTurnContext([...compactedHistory, ...pendingMessages]),
3785
+ ];
3786
+ const entries = [
3787
+ ...compactEntries(afterHistory.estimatedTokens),
3667
3788
  ...replayEntries,
3668
- ]),
3669
- ...projectMemoryRecallMessages,
3670
- ];
3671
- const afterHistory = budget.evaluate([...contextMessages, ...injectTurnContext(compactedHistory)], definitions);
3672
- const afterPending = budget.evaluate([
3673
- ...contextMessages,
3674
- ...injectTurnContext([...compactedHistory, ...pendingMessages]),
3675
- ], definitions);
3676
- budget.assertFits(afterPending);
3677
- if (options.promptTooLong === true &&
3678
- !contextRecoveryMadeProgress({
3679
- beforeOccupancyTokens: predicted.occupancyTokens,
3680
- afterOccupancyTokens: afterPending.occupancyTokens,
3681
- })) {
3682
- throw new Error('Reactive context compaction made no occupancy progress');
3683
- }
3684
- const entries = [
3685
- ...compactEntries(afterHistory.estimatedTokens),
3686
- ...replayEntries,
3687
- ];
3688
- if (signal?.aborted)
3689
- throw new AgentRunCancelledError();
3690
- // Construct the exact tracker mutations for this single committed
3691
- // boundary and preflight them against a clone of the live tracker so
3692
- // invalid input or cumulative overflow rejects before the compact
3693
- // boundary is appended rather than after a half-commit.
3694
- const tracker = this.sessionCostTrackers.get(sessionId);
3695
- if (!tracker) {
3696
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
3697
- }
3698
- const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
3699
- ? compacted.model
3700
- : provider.model;
3701
- const compactModelNonBlank = compactModel !== undefined && compactModel.trim() !== '';
3702
- if (hasNonZeroUsage(compacted.usage) && !compactModelNonBlank) {
3703
- throw new Error('Auto compact usage requires a nonblank model identity');
3704
- }
3705
- let meteringTurnInput;
3706
- if (compactModelNonBlank && hasNonZeroUsage(compacted.usage)) {
3707
- const pricing = this.options.pricing?.resolve(compactModel);
3708
- const costUsd = pricing
3709
- ? usageCostUsd(compacted.usage, pricing)
3710
- : undefined;
3711
- meteringTurnInput = {
3712
- model: compactModel,
3713
- usage: compacted.usage,
3714
- ...(costUsd === undefined ? {} : { costUsd }),
3715
- ...(compacted.usage.webSearchRequests === undefined
3716
- ? {}
3717
- : { webSearchRequests: compacted.usage.webSearchRequests }),
3718
- };
3719
- }
3720
- let meteringDurationsInput;
3721
- if (compactedDurationMs > 0 ||
3722
- compactedDurationWithoutRetriesMs > 0) {
3723
- meteringDurationsInput = {
3724
- ...(compactedDurationMs === 0
3725
- ? {}
3726
- : { apiDurationMs: compactedDurationMs }),
3727
- apiDurationWithoutRetriesMs: compactedDurationWithoutRetriesMs,
3728
- };
3729
- }
3730
- if (meteringTurnInput !== undefined ||
3731
- meteringDurationsInput !== undefined) {
3732
- const preflight = new ClaudeSessionCostTracker({
3733
- sessionId,
3734
- restored: tracker.snapshot(),
3735
- });
3736
- if (meteringTurnInput !== undefined) {
3737
- preflight.recordTurn(meteringTurnInput);
3789
+ ];
3790
+ if (signal?.aborted)
3791
+ throw new AgentRunCancelledError();
3792
+ // Construct the exact tracker mutations for this single committed
3793
+ // boundary and preflight them against a clone of the live tracker so
3794
+ // invalid input or cumulative overflow rejects before the compact
3795
+ // boundary is appended rather than after a half-commit.
3796
+ const tracker = this.sessionCostTrackers.get(sessionId);
3797
+ if (!tracker) {
3798
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
3738
3799
  }
3739
- if (meteringDurationsInput !== undefined) {
3740
- preflight.recordDurations(meteringDurationsInput);
3800
+ const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
3801
+ ? compacted.model
3802
+ : provider.model;
3803
+ const compactModelNonBlank = compactModel !== undefined && compactModel.trim() !== '';
3804
+ if (hasNonZeroUsage(compacted.usage) && !compactModelNonBlank) {
3805
+ throw new Error('Auto compact usage requires a nonblank model identity');
3741
3806
  }
3742
- }
3743
- const appendResult = await lease.appendMany(snapshot.tail, entries);
3744
- if (appendResult.status === 'conflict') {
3745
- throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
3746
- }
3747
- snapshot = {
3748
- entries: [...snapshot.entries, ...entries],
3749
- tail: appendResult.tail,
3750
- };
3751
- const metadataEntries = createClaudeDurableMetadataSnapshot(snapshot.entries, sessionId);
3752
- if (metadataEntries.length > 0) {
3753
- const metadataAppend = await lease.appendMany(snapshot.tail, metadataEntries);
3754
- if (metadataAppend.status === 'conflict') {
3755
- throw new Error(`Claude metadata snapshot conflict: ${metadataAppend.reason}`);
3807
+ let meteringTurnInput;
3808
+ if (compactModelNonBlank && hasNonZeroUsage(compacted.usage)) {
3809
+ const pricing = this.options.pricing?.resolve(compactModel);
3810
+ const costUsd = pricing
3811
+ ? usageCostUsd(compacted.usage, pricing)
3812
+ : undefined;
3813
+ meteringTurnInput = {
3814
+ model: compactModel,
3815
+ usage: compacted.usage,
3816
+ ...(costUsd === undefined ? {} : { costUsd }),
3817
+ ...(compacted.usage.webSearchRequests === undefined
3818
+ ? {}
3819
+ : { webSearchRequests: compacted.usage.webSearchRequests }),
3820
+ };
3756
3821
  }
3757
- snapshot = {
3758
- entries: [...snapshot.entries, ...metadataEntries],
3759
- tail: metadataAppend.tail,
3822
+ let meteringDurationsInput;
3823
+ if (compactedDurationMs > 0 ||
3824
+ compactedDurationWithoutRetriesMs > 0) {
3825
+ meteringDurationsInput = {
3826
+ ...(compactedDurationMs === 0
3827
+ ? {}
3828
+ : { apiDurationMs: compactedDurationMs }),
3829
+ apiDurationWithoutRetriesMs: compactedDurationWithoutRetriesMs,
3830
+ };
3831
+ }
3832
+ if (meteringTurnInput !== undefined ||
3833
+ meteringDurationsInput !== undefined) {
3834
+ const preflight = new ClaudeSessionCostTracker({
3835
+ sessionId,
3836
+ restored: tracker.snapshot(),
3837
+ });
3838
+ if (meteringTurnInput !== undefined) {
3839
+ preflight.recordTurn(meteringTurnInput);
3840
+ }
3841
+ if (meteringDurationsInput !== undefined) {
3842
+ preflight.recordDurations(meteringDurationsInput);
3843
+ }
3844
+ }
3845
+ return {
3846
+ envelope: {
3847
+ messages: proposedMessages,
3848
+ tools: definitions,
3849
+ },
3850
+ commit: async () => {
3851
+ if (signal?.aborted)
3852
+ throw new AgentRunCancelledError();
3853
+ const appendResult = await lease.appendMany(snapshot.tail, entries);
3854
+ if (appendResult.status === 'conflict') {
3855
+ throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
3856
+ }
3857
+ snapshot = {
3858
+ entries: [...snapshot.entries, ...entries],
3859
+ tail: appendResult.tail,
3860
+ };
3861
+ const metadataEntries = createClaudeDurableMetadataSnapshot(snapshot.entries, sessionId);
3862
+ if (metadataEntries.length > 0) {
3863
+ const metadataAppend = await lease.appendMany(snapshot.tail, metadataEntries);
3864
+ if (metadataAppend.status === 'conflict') {
3865
+ throw new Error(`Claude metadata snapshot conflict: ${metadataAppend.reason}`);
3866
+ }
3867
+ snapshot = {
3868
+ entries: [...snapshot.entries, ...metadataEntries],
3869
+ tail: metadataAppend.tail,
3870
+ };
3871
+ }
3872
+ this.rememberDurableMetadata(sessionId, snapshot.entries);
3873
+ await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3874
+ // The boundary is durable: mirror Claude's full-compact behavior by
3875
+ // rerunning SessionStart with source compact and refreshing the
3876
+ // runtime-only context so the next request retains current
3877
+ // instructions, plan state, session memory, and hook context.
3878
+ if (this.options.hooks) {
3879
+ const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
3880
+ if (outcome)
3881
+ await recordHookOutcome(outcome);
3882
+ }
3883
+ this.options.contextAssembler?.invalidate?.({
3884
+ lifecycleId: sessionId,
3885
+ reason: 'compact',
3886
+ });
3887
+ await refreshRuntimeContext();
3888
+ this.options.eventSink?.({
3889
+ type: 'compact-boundary',
3890
+ trigger: 'auto',
3891
+ preTokens,
3892
+ uuid: boundaryUuid,
3893
+ });
3894
+ compactionAnchorUuid = compactSummaryUuid;
3895
+ compactionUsage = mergeUsage(compactionUsage, compacted.usage);
3896
+ compactionDurationMs = proposedCompactionDurationMs;
3897
+ compactionDurationWithoutRetriesMs =
3898
+ proposedCompactionDurationWithoutRetriesMs;
3899
+ if (meteringTurnInput !== undefined) {
3900
+ compactionModelUsage = mergeSessionRawModelUsage(compactionModelUsage, { [meteringTurnInput.model]: compacted.usage });
3901
+ }
3902
+ // Commit the preflighted mutations once the boundary is durable so a
3903
+ // later main-provider failure or cancellation cannot lose the
3904
+ // compactor's usage/cost/API durations.
3905
+ if (meteringTurnInput !== undefined) {
3906
+ tracker.recordTurn(meteringTurnInput);
3907
+ }
3908
+ if (meteringDurationsInput !== undefined) {
3909
+ tracker.recordDurations(meteringDurationsInput);
3910
+ }
3911
+ },
3760
3912
  };
3761
- }
3762
- this.rememberDurableMetadata(sessionId, snapshot.entries);
3763
- await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3764
- // The boundary is durable: mirror Claude's full-compact behavior by
3765
- // rerunning SessionStart with source compact and refreshing the
3766
- // runtime-only context so the next request retains current
3767
- // instructions, plan state, session memory, and hook context.
3768
- if (this.options.hooks) {
3769
- const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
3770
- if (outcome)
3771
- await recordHookOutcome(outcome);
3772
- }
3773
- this.options.contextAssembler?.invalidate?.({
3774
- lifecycleId: sessionId,
3775
- reason: 'compact',
3776
- });
3777
- this.options.projectMemoryRecall?.recordCompact(sessionId);
3778
- await refreshRuntimeContext();
3779
- this.options.eventSink?.({
3780
- type: 'compact-boundary',
3781
- trigger: 'auto',
3782
- preTokens,
3783
- uuid: boundaryUuid,
3784
- });
3785
- compactionAnchorUuid = compactSummaryUuid;
3786
- compactionUsage = mergeUsage(compactionUsage, compacted.usage);
3787
- compactionDurationMs = proposedCompactionDurationMs;
3788
- compactionDurationWithoutRetriesMs =
3789
- proposedCompactionDurationWithoutRetriesMs;
3790
- if (meteringTurnInput !== undefined) {
3791
- compactionModelUsage = mergeSessionRawModelUsage(compactionModelUsage, { [meteringTurnInput.model]: compacted.usage });
3792
- }
3793
- // Commit the preflighted mutations once the boundary is durable so a
3794
- // later main-provider failure or cancellation cannot lose the
3795
- // compactor's usage/cost/API durations.
3796
- if (meteringTurnInput !== undefined) {
3797
- tracker.recordTurn(meteringTurnInput);
3798
- }
3799
- if (meteringDurationsInput !== undefined) {
3800
- tracker.recordDurations(meteringDurationsInput);
3801
- }
3802
- };
3803
- await compactIfNeeded(pendingUserMessages);
3913
+ },
3914
+ });
3915
+ await contextEngine.prepare(contextTransitionPort(pendingUserMessages), signal);
3804
3916
  for (const [index, message] of expandedMessages.entries()) {
3805
3917
  if (shellCommand !== undefined)
3806
3918
  break;
@@ -3936,7 +4048,7 @@ export class ClaudeSessionService {
3936
4048
  });
3937
4049
  }
3938
4050
  if (budget) {
3939
- await compactIfNeeded([], currentTurnUserMessages ?? []);
4051
+ await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
3940
4052
  budget.assertFits(budget.evaluate([
3941
4053
  ...contextMessages,
3942
4054
  ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
@@ -3967,13 +4079,13 @@ export class ClaudeSessionService {
3967
4079
  ? { deferFailureKinds: ['prompt_too_long'] }
3968
4080
  : {}),
3969
4081
  reloadMessages: async () => {
3970
- const recalled = projectMemoryRecallTurn?.consumeIfSettled();
4082
+ const recalled = turnMemory.consumeRecall();
3971
4083
  if (recalled) {
3972
4084
  projectMemoryRecallMessages = [
3973
4085
  { role: 'user', content: recalled.content },
3974
4086
  ];
3975
4087
  }
3976
- await compactIfNeeded([], currentTurnUserMessages ?? []);
4088
+ await contextEngine.prepare(contextTransitionPort([], currentTurnUserMessages ?? []), signal);
3977
4089
  runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
3978
4090
  return [
3979
4091
  ...contextMessages,
@@ -4089,24 +4201,8 @@ export class ClaudeSessionService {
4089
4201
  catch (error) {
4090
4202
  if (!budget || !isPromptTooLongError(error))
4091
4203
  throw error;
4092
- if (recoveryPlanner.reactiveRetriesRemaining === 0) {
4093
- surfaceExhaustedRecovery(error);
4094
- throw error;
4095
- }
4096
- const beforeRecovery = budget.evaluate(runtimeRequest.messages, definitions);
4097
- try {
4098
- await compactIfNeeded([], currentTurnUserMessages ?? [], {
4099
- promptTooLong: true,
4100
- });
4101
- }
4102
- catch (compactionError) {
4103
- if (signal?.aborted ||
4104
- compactionError instanceof AgentRunCancelledError) {
4105
- throw new AgentRunCancelledError();
4106
- }
4107
- // Compaction could not free the provider-bounded context; surface
4108
- // the original prompt-too-long error rather than the compaction
4109
- // failure.
4204
+ const recovery = await contextEngine.recover(error, contextTransitionPort([], currentTurnUserMessages ?? []), signal);
4205
+ if (recovery.kind !== 'retry') {
4110
4206
  surfaceExhaustedRecovery(error);
4111
4207
  throw error;
4112
4208
  }
@@ -4120,14 +4216,6 @@ export class ClaudeSessionService {
4120
4216
  ]),
4121
4217
  ];
4122
4218
  runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
4123
- const afterRecovery = budget.evaluate(runtimeRequest.messages, definitions);
4124
- if (recoveryPlanner.consumeReactiveRetry({
4125
- beforeOccupancyTokens: beforeRecovery.occupancyTokens,
4126
- afterOccupancyTokens: afterRecovery.occupancyTokens,
4127
- }) !== 'reactive-retry') {
4128
- surfaceExhaustedRecovery(error);
4129
- throw error;
4130
- }
4131
4219
  runtimeRequest.deferFailureKinds = true;
4132
4220
  try {
4133
4221
  result = await attemptMainTurn();
@@ -4135,7 +4223,6 @@ export class ClaudeSessionService {
4135
4223
  catch (retryError) {
4136
4224
  // Exactly one reactive retry is consumed; fail deterministically
4137
4225
  // and surface the original prompt-too-long error.
4138
- recoveryPlanner.recordFailure();
4139
4226
  if (signal?.aborted ||
4140
4227
  retryError instanceof AgentRunCancelledError) {
4141
4228
  throw new AgentRunCancelledError();
@@ -4160,16 +4247,6 @@ export class ClaudeSessionService {
4160
4247
  }
4161
4248
  throw error;
4162
4249
  }
4163
- recoveryPlanner.recordSuccess();
4164
- const mainModel = provider.model !== undefined && provider.model.trim() !== ''
4165
- ? provider.model
4166
- : undefined;
4167
- const observedUsage = (mainModel !== undefined
4168
- ? result.modelUsage?.[mainModel]
4169
- : undefined) ??
4170
- Object.values(result.modelUsage ?? {})[0] ??
4171
- result.usage;
4172
- budget?.observeUsage(observedUsage, runtimeRequest.messages, definitions);
4173
4250
  if (structuredCapture && structuredCapture.calls !== 1) {
4174
4251
  throw new Error(`StructuredOutput must be called exactly once (received ${structuredCapture.calls})`);
4175
4252
  }
@@ -4270,32 +4347,23 @@ export class ClaudeSessionService {
4270
4347
  : { linesRemoved: foregroundLineChanges.linesRemoved }),
4271
4348
  });
4272
4349
  }
4273
- if (sessionMemory && finalLeafUuid) {
4274
- const memorySnapshot = projectClaudeModelMessages(snapshot.entries);
4275
- const providerVisibleMessages = [
4276
- ...contextMessages,
4277
- ...injectTurnContext(memorySnapshot),
4278
- ];
4279
- const currentContextTokens = budget
4280
- ? budget.evaluate(providerVisibleMessages, definitions)
4281
- .occupancyTokens
4282
- : estimateModelRequestTokens(providerVisibleMessages, definitions);
4283
- try {
4284
- await sessionMemory.observeContext(currentContextTokens, currentTurnToolCalls, finalLeafUuid, memorySnapshot);
4285
- }
4286
- catch (error) {
4287
- // A failed observation must not fail the user turn; the sidecar
4288
- // retains a retryable error for the next observation.
4289
- this.options.eventSink?.({
4290
- type: 'warning',
4291
- message: `Session memory observation failed: ${error instanceof Error ? error.message : String(error)}`,
4292
- });
4293
- }
4294
- }
4295
- this.options.projectMemoryExtraction?.observe({
4296
- sessionId,
4350
+ const memorySnapshot = projectClaudeModelMessages(snapshot.entries);
4351
+ const providerVisibleMessages = [
4352
+ ...contextMessages,
4353
+ ...injectTurnContext(memorySnapshot),
4354
+ ];
4355
+ const currentContextTokens = contextEngine.report({
4356
+ messages: providerVisibleMessages,
4357
+ tools: definitions,
4358
+ })?.occupancyTokens ??
4359
+ estimateModelRequestTokens(providerVisibleMessages, definitions);
4360
+ await turnMemory.observeSuccess({
4361
+ ...(finalLeafUuid ? { messageId: finalLeafUuid } : {}),
4362
+ occupancyTokens: currentContextTokens,
4363
+ toolCalls: currentTurnToolCalls,
4364
+ messages: memorySnapshot,
4365
+ projectMessages: projectMemoryMessages(snapshot.entries),
4297
4366
  directMaintenance: projectMemoryMaintained,
4298
- messages: projectMemoryMessages(snapshot.entries),
4299
4367
  });
4300
4368
  turnCompleted = true;
4301
4369
  return {
@@ -4425,11 +4493,12 @@ export class ClaudeSessionService {
4425
4493
  * recent suffix. Returns null to fall back to the existing full-compaction
4426
4494
  * behavior (missing/invalid watermark, empty projection, or oversized
4427
4495
  * projection). */
4428
- async selectMemoryPreservedCompact(sessionId, activeEntries) {
4496
+ async selectMemoryPreservedCompact(sessionId, activeEntries, options = {}) {
4429
4497
  const controller = this.sessionMemoryController(sessionId);
4430
4498
  if (controller === null)
4431
4499
  return null;
4432
- await controller.waitForCompact();
4500
+ if (options.sessionMemoryReady !== true)
4501
+ await controller.waitForCompact();
4433
4502
  let watermark = null;
4434
4503
  let memorySummary = '';
4435
4504
  try {
@@ -5061,12 +5130,37 @@ export class ClaudeSessionService {
5061
5130
  return 'corrupt';
5062
5131
  return this.schema.writeMode === 'read-write' ? 'ready' : 'read-only';
5063
5132
  }
5133
+ nativeSessionStatus(issue, entryCount) {
5134
+ if (issue?.kind === 'unsupported-version')
5135
+ return 'read-only';
5136
+ if (entryCount === 0 || issue)
5137
+ return 'corrupt';
5138
+ return 'read-only';
5139
+ }
5140
+ claudeSessionSummary(sessionId, index) {
5141
+ const metadata = reduceClaudeSessionMetadata(index.entries, sessionId);
5142
+ return {
5143
+ sessionId,
5144
+ ...sessionMetadataFields(metadata, { agentNameFallback: true }),
5145
+ lastPrompt: metadata.lastPrompt ?? null,
5146
+ updatedAt: index.updatedAt,
5147
+ status: this.sessionStatus(index.issue, index.entries.length),
5148
+ issue: index.issue,
5149
+ };
5150
+ }
5064
5151
  provider() {
5065
5152
  if (!this.options.provider) {
5066
5153
  throw new Error('A model provider is required for run and resume');
5067
5154
  }
5068
5155
  return this.options.provider;
5069
5156
  }
5157
+ async assertNativeWriteTargetIsLegacy(sessionId) {
5158
+ if (this.options.dataPlane !== 'native')
5159
+ return;
5160
+ const existing = await readNativeTranscript(this.paths(sessionId).sessionFile);
5161
+ if (existing.format === 'native')
5162
+ throw new Error('native transcript is read-only until native writes are enabled');
5163
+ }
5070
5164
  model() {
5071
5165
  return this.activeProvider?.model ?? this.options.provider?.model;
5072
5166
  }
@@ -5124,7 +5218,7 @@ export class ClaudeSessionService {
5124
5218
  return this.sessionMemoryProviderFactory() !== null;
5125
5219
  }
5126
5220
  sessionMemoryController(sessionId) {
5127
- if (!this.sessionMemoryEnabled() || !isClaudeSessionId(sessionId)) {
5221
+ if (!this.sessionMemoryEnabled() || !isSessionId(sessionId)) {
5128
5222
  return null;
5129
5223
  }
5130
5224
  let controller = this.sessionMemoryControllers.get(sessionId);