praxis-agent 0.33.0 → 0.35.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 (66) hide show
  1. package/README.md +4 -0
  2. package/dist/application/background-agent-manager.d.ts +48 -3
  3. package/dist/application/background-agent-manager.js +249 -73
  4. package/dist/application/background-task-runtime.d.ts +20 -1
  5. package/dist/application/background-task-runtime.js +45 -6
  6. package/dist/application/managed-worktree.d.ts +8 -0
  7. package/dist/application/managed-worktree.js +54 -2
  8. package/dist/application/session-service.d.ts +12 -1
  9. package/dist/application/session-service.js +1849 -1628
  10. package/dist/application/subagent-service.d.ts +34 -7
  11. package/dist/application/subagent-service.js +720 -150
  12. package/dist/application/turn-lifecycle.d.ts +38 -0
  13. package/dist/application/turn-lifecycle.js +36 -0
  14. package/dist/cli/interactive.d.ts +1 -0
  15. package/dist/cli/interactive.js +43 -8
  16. package/dist/cli/protocol.js +5 -2
  17. package/dist/cli/tui/claude-style.d.ts +5 -48
  18. package/dist/cli/tui/claude-style.js +19 -73
  19. package/dist/cli/tui/task-panel.d.ts +1 -1
  20. package/dist/cli/tui/task-panel.js +2 -1
  21. package/dist/cli/tui/transcript-presentation.d.ts +89 -0
  22. package/dist/cli/tui/transcript-presentation.js +169 -0
  23. package/dist/cli/tui/transcript-viewport.d.ts +5 -34
  24. package/dist/cli/tui/transcript-viewport.js +639 -158
  25. package/dist/cli/tui/tui-view-model.d.ts +6 -2
  26. package/dist/cli/tui/tui-view-model.js +23 -6
  27. package/dist/compatibility/claude/context.d.ts +2 -2
  28. package/dist/compatibility/claude/context.js +2 -14
  29. package/dist/compatibility/claude/data-plane-adapter.d.ts +9 -0
  30. package/dist/compatibility/claude/data-plane-adapter.js +44 -0
  31. package/dist/compatibility/claude/paths.js +2 -2
  32. package/dist/compatibility/claude/projection.d.ts +4 -0
  33. package/dist/compatibility/claude/projection.js +82 -0
  34. package/dist/compatibility/claude/shared-resources.d.ts +7 -38
  35. package/dist/compatibility/claude/sidechain.d.ts +5 -0
  36. package/dist/compatibility/claude/sidechain.js +13 -4
  37. package/dist/compatibility/claude/transcript-codec.d.ts +21 -0
  38. package/dist/compatibility/claude/transcript-codec.js +512 -0
  39. package/dist/core/context.d.ts +25 -7
  40. package/dist/core/context.js +46 -0
  41. package/dist/core/prompt-composer.d.ts +8 -16
  42. package/dist/core/prompt-composer.js +65 -25
  43. package/dist/core/resources.d.ts +39 -0
  44. package/dist/core/resources.js +2 -0
  45. package/dist/core/runtime.d.ts +1 -1
  46. package/dist/core/session.d.ts +2 -0
  47. package/dist/core/session.js +5 -0
  48. package/dist/core/transcript-codec.d.ts +70 -0
  49. package/dist/core/transcript-codec.js +60 -0
  50. package/dist/core/transcript-event.d.ts +33 -0
  51. package/dist/core/transcript-event.js +198 -0
  52. package/dist/persistence/claude-sidechain-store.js +19 -3
  53. package/dist/persistence/data-plane-adapter.d.ts +32 -0
  54. package/dist/persistence/data-plane-adapter.js +2 -0
  55. package/dist/persistence/data-plane.d.ts +4 -20
  56. package/dist/persistence/data-plane.js +10 -47
  57. package/dist/persistence/native-data-plane-adapter.d.ts +9 -0
  58. package/dist/persistence/native-data-plane-adapter.js +39 -0
  59. package/dist/persistence/native-resources.d.ts +4 -4
  60. package/dist/persistence/native-transcript-codec.d.ts +13 -0
  61. package/dist/persistence/native-transcript-codec.js +102 -0
  62. package/dist/persistence/subagent-lifecycle-store.d.ts +66 -0
  63. package/dist/persistence/subagent-lifecycle-store.js +299 -0
  64. package/dist/tools/claude-user-message.d.ts +0 -1
  65. package/dist/tools/claude-user-message.js +0 -1
  66. package/package.json +6 -2
@@ -6,6 +6,7 @@ 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
8
  import { discoverClaudeProjectRoot, isClaudeSessionId, resolveClaudePaths, resolveClaudeScheduledTaskFile, } from '../compatibility/claude/paths.js';
9
+ import { isClaudeAgentId } from '../compatibility/claude/sidechain.js';
9
10
  import { resolveDataPlanePaths, resolveScheduledTaskFile, } from '../persistence/data-plane.js';
10
11
  import { downloadClaudeFileResources, } from '../compatibility/claude/file-resources.js';
11
12
  import { createClaudeNativeFork } from '../compatibility/claude/fork.js';
@@ -20,17 +21,21 @@ import { createClaudeAgentSettingEntry, createClaudeHookAttachmentEntries, creat
20
21
  import { AgentRunCancelledError, AgentRuntime, ModelProviderError, } from '../core/runtime.js';
21
22
  import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
22
23
  import { BackgroundTaskRuntime, } from './background-task-runtime.js';
24
+ import { backgroundAgentNotificationMarkers, } from './background-agent-manager.js';
23
25
  import { usageCostUsd } from '../core/usage.js';
24
26
  import { ContextBudget, ContextRecoveryPlanner, contextRecoveryMadeProgress, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
25
- import { injectFirstUserMessageContext, } from '../core/context.js';
27
+ import { injectFirstUserMessageContext, projectContextSnapshot, } from '../core/context.js';
28
+ import { assembleContextSnapshot } from '../core/prompt-composer.js';
26
29
  import { ClaudeHookToolCoordinator } from '../hooks/claude-hook-tools.js';
27
30
  import { ClaudeFileChangeWatcher } from '../hooks/claude-file-change-watcher.js';
28
31
  import { ClaudeTranscriptStore, } from '../persistence/claude-transcript-store.js';
29
32
  import { ClaudeSessionIndexCandidateError, readClaudeSessionIndexes, } from '../persistence/claude-session-index.js';
30
33
  import { InMemoryTranscriptStore } from '../persistence/in-memory-transcript-store.js';
34
+ import { SubagentLifecycleStore } from '../persistence/subagent-lifecycle-store.js';
31
35
  import { ModelCompactor } from './model-compactor.js';
32
36
  import { agentMemoryPrompt, ClaudeSubagentExecutor, StructuredOutputRegistry, } from './subagent-service.js';
33
37
  import { ScheduledPromptManager, } from './scheduled-prompt-manager.js';
38
+ import { TurnTerminalController } from './turn-lifecycle.js';
34
39
  import { ClaudeScheduledToolRegistry } from '../tools/claude-scheduled-tools.js';
35
40
  import { ClaudeTaskToolRegistry } from '../tools/claude-task-tools.js';
36
41
  import { ClaudeWorkflowToolRegistry } from '../tools/claude-workflow-tools.js';
@@ -43,7 +48,7 @@ import { SessionMemoryController, SessionMemoryStateError, SessionMemoryStore, }
43
48
  import { FilteredToolRegistry } from '../tools/filtered-tool-registry.js';
44
49
  import { ClaudeCapabilityToolRegistry, resolveClaudeToolCapabilities, } from '../tools/claude-capabilities.js';
45
50
  import { generateToolUseSummary } from './tool-use-summary.js';
46
- import { ClaudeUserMessageToolRegistry, CLAUDE_USER_MESSAGE_PROMPT, } from '../tools/claude-user-message.js';
51
+ import { ClaudeUserMessageToolRegistry, } from '../tools/claude-user-message.js';
47
52
  function agentPermissionMode(mode) {
48
53
  return mode === undefined || mode === 'manual' ? 'default' : mode;
49
54
  }
@@ -51,6 +56,15 @@ function agentToolName(rule) {
51
56
  const opening = rule.indexOf('(');
52
57
  return (opening < 0 ? rule : rule.slice(0, opening)).trim();
53
58
  }
59
+ function transcriptContainsBackgroundAgentNotification(entries, notification) {
60
+ const markers = backgroundAgentNotificationMarkers(notification);
61
+ return entries.some((entry) => {
62
+ if (entry.type !== 'user')
63
+ return false;
64
+ const source = JSON.stringify(entry.message);
65
+ return markers.every((marker) => source.includes(marker));
66
+ });
67
+ }
54
68
  function mainAgentToolNames(tools, agent) {
55
69
  const requested = agent.tools ? new Set(agent.tools.map(agentToolName)) : null;
56
70
  if (requested && agent.memory) {
@@ -636,8 +650,14 @@ export class ClaudeSessionService {
636
650
  explicitResumeLeafUuids = new Map();
637
651
  sessionPermissionUpdates = new Map();
638
652
  hostedSubagents = new Set();
653
+ subagentExecutors = new Set();
639
654
  hostedSubagentsByRegistry = new WeakMap();
655
+ subagentExecutorSessions = new WeakMap();
656
+ hostedSubagentNotificationPumps = new WeakMap();
657
+ hostedSubagentPumpPromises = new Set();
658
+ hostedSubagentNotificationReservations = new WeakSet();
640
659
  backgroundNotificationWrites = new Map();
660
+ closing = false;
641
661
  downloadedFileResourceSessions = new Set();
642
662
  detachedHookRuns = new Map();
643
663
  activeProvider;
@@ -795,6 +815,56 @@ export class ClaudeSessionService {
795
815
  stopTask(sessionId, taskId) {
796
816
  return this.backgroundTasks.stop(sessionId, taskId);
797
817
  }
818
+ backgroundForegroundTask(sessionId) {
819
+ return this.backgroundTasks.backgroundForeground(sessionId);
820
+ }
821
+ sendOwnedBackgroundAgent(sessionId, agentId, message, summary, toolUseId) {
822
+ const sent = this.backgroundTasks.sendAgentWithOwner(sessionId, agentId, message, summary, toolUseId);
823
+ if (!sent)
824
+ return null;
825
+ const hosted = [...this.hostedSubagents].find((executor) => executor === sent.owner);
826
+ if (hosted)
827
+ this.startHostedSubagentNotificationPump(sessionId, hosted);
828
+ return sent.content;
829
+ }
830
+ sessionSubagentExecutors(sessionId, includeHosted = false) {
831
+ return [...this.subagentExecutors].filter((executor) => this.subagentExecutorSessions.get(executor) === sessionId &&
832
+ (includeHosted ||
833
+ (!this.hostedSubagentNotificationPumps.has(executor) &&
834
+ !this.hostedSubagentNotificationReservations.has(executor))));
835
+ }
836
+ async collectSubagentNotifications(sessionId, waitForExecutor) {
837
+ const executors = this.sessionSubagentExecutors(sessionId);
838
+ if (executors.length === 0)
839
+ return null;
840
+ const poll = () => Promise.all(executors.map((executor) => executor.notifications(false, false)));
841
+ let batches = await poll();
842
+ if (batches.every(({ messages }) => messages.length === 0)) {
843
+ if (waitForExecutor &&
844
+ executors.includes(waitForExecutor) &&
845
+ waitForExecutor.notificationClaimAgentIds().length > 0) {
846
+ await waitForExecutor.notifications(true, false);
847
+ batches = await poll();
848
+ }
849
+ }
850
+ const durationSeen = batches.some(({ durationApiMs, durationApiWithoutRetriesMs }) => durationApiMs !== undefined ||
851
+ durationApiWithoutRetriesMs !== undefined);
852
+ const modelUsage = mergeSessionRawModelUsage(...batches.map((batch) => batch.modelUsage));
853
+ return {
854
+ messages: batches.flatMap(({ messages }) => messages),
855
+ usage: batches.reduce((total, { usage }) => mergeUsage(total, usage), {
856
+ inputTokens: 0,
857
+ outputTokens: 0,
858
+ }),
859
+ ...(modelUsage ? { modelUsage } : {}),
860
+ ...(durationSeen
861
+ ? {
862
+ durationApiMs: batches.reduce((total, batch) => addApiDuration(batch.durationApiMs, total, 'durationApiMs'), 0),
863
+ durationApiWithoutRetriesMs: batches.reduce((total, batch) => addApiDuration(batch.durationApiWithoutRetriesMs ?? batch.durationApiMs, total, 'durationApiWithoutRetriesMs'), 0),
864
+ }
865
+ : {}),
866
+ };
867
+ }
798
868
  async applyPermissionUpdates(sessionId, updates) {
799
869
  if (updates.length === 0)
800
870
  return;
@@ -808,16 +878,19 @@ export class ClaudeSessionService {
808
878
  }
809
879
  }
810
880
  async close() {
881
+ this.closing = true;
811
882
  await this.fileChangeWatcher?.close(5_000);
812
883
  await this.hookLifecycle.close();
813
884
  await this.drainDetachedHookRuns(5_000);
814
885
  await this.options.hooks?.drainAsync(5_000);
815
886
  this.scheduledPrompts?.close();
816
887
  await this.options.projectMemoryExtraction?.close(5_000);
817
- await Promise.all([...this.hostedSubagents].map((executor) => executor.close()));
888
+ await Promise.all([...this.subagentExecutors].map((executor) => executor.close()));
889
+ await Promise.allSettled([...this.hostedSubagentPumpPromises]);
818
890
  await Promise.resolve();
819
891
  await Promise.all([...this.backgroundNotificationWrites.values()]);
820
892
  this.hostedSubagents.clear();
893
+ this.subagentExecutors.clear();
821
894
  this.backgroundTasks.clear();
822
895
  await Promise.all([...this.sessionMemoryControllers.values()].map((controller) => controller.close()));
823
896
  this.sessionMemoryControllers.clear();
@@ -1050,11 +1123,15 @@ export class ClaudeSessionService {
1050
1123
  backgroundTaskNotifications: (waitForRunning) => taskTools.notifications(waitForRunning),
1051
1124
  }
1052
1125
  : {}),
1126
+ stopOwnedBackgroundAgent: (ownerSessionId, agentId) => this.backgroundTasks.stopAgent(ownerSessionId, agentId),
1127
+ outputOwnedBackgroundAgent: (ownerSessionId, agentId, options) => this.backgroundTasks.outputAgent(ownerSessionId, agentId, options),
1128
+ sendOwnedBackgroundAgent: (ownerSessionId, agentId, message, summary, toolUseId) => this.sendOwnedBackgroundAgent(ownerSessionId, agentId, message, summary, toolUseId),
1053
1129
  })
1054
1130
  : null;
1055
- if (subagentExecutor)
1056
- this.hostedSubagents.add(subagentExecutor);
1057
1131
  if (subagentExecutor) {
1132
+ this.hostedSubagents.add(subagentExecutor);
1133
+ this.subagentExecutors.add(subagentExecutor);
1134
+ this.subagentExecutorSessions.set(subagentExecutor, sessionId);
1058
1135
  this.backgroundTasks.registerAgents(sessionId, subagentExecutor);
1059
1136
  }
1060
1137
  const agentTools = subagentExecutor
@@ -1157,19 +1234,67 @@ export class ClaudeSessionService {
1157
1234
  }
1158
1235
  async run(prompt, signal, sessionId = randomUUID(), name, images, documents) {
1159
1236
  this.worktreeManager?.bindSession(sessionId);
1160
- return this.executeTurn(sessionId, prompt, false, signal, name, images, documents);
1237
+ return this.executeTurn({
1238
+ activation: {
1239
+ kind: 'start',
1240
+ sessionId,
1241
+ ...(name !== undefined ? { name } : {}),
1242
+ },
1243
+ submission: {
1244
+ kind: 'prompt',
1245
+ text: prompt,
1246
+ ...(images ? { images } : {}),
1247
+ ...(documents ? { documents } : {}),
1248
+ },
1249
+ ...(signal ? { signal } : {}),
1250
+ });
1161
1251
  }
1162
1252
  async runShell(command, signal, sessionId = randomUUID(), name) {
1163
1253
  this.worktreeManager?.bindSession(sessionId);
1164
- return this.executeTurn(sessionId, `! ${command}`, false, signal, name, [], [], undefined, command);
1254
+ return this.executeTurn({
1255
+ activation: {
1256
+ kind: 'start',
1257
+ sessionId,
1258
+ ...(name !== undefined ? { name } : {}),
1259
+ },
1260
+ submission: { kind: 'shell', command },
1261
+ ...(signal ? { signal } : {}),
1262
+ });
1165
1263
  }
1166
1264
  async resume(sessionId, prompt, signal, name, images, documents, resumeSessionAt) {
1167
1265
  this.worktreeManager?.bindSession(sessionId);
1168
- return this.executeTurn(sessionId, prompt, true, signal, name, images, documents, resumeSessionAt);
1266
+ return this.executeTurn({
1267
+ activation: {
1268
+ kind: 'resume',
1269
+ sessionId,
1270
+ ...(name !== undefined ? { name } : {}),
1271
+ ...(resumeSessionAt !== undefined
1272
+ ? { atMessageId: resumeSessionAt }
1273
+ : {}),
1274
+ },
1275
+ submission: {
1276
+ kind: 'prompt',
1277
+ text: prompt,
1278
+ ...(images ? { images } : {}),
1279
+ ...(documents ? { documents } : {}),
1280
+ },
1281
+ ...(signal ? { signal } : {}),
1282
+ });
1169
1283
  }
1170
1284
  async resumeShell(sessionId, command, signal, name, resumeSessionAt) {
1171
1285
  this.worktreeManager?.bindSession(sessionId);
1172
- return this.executeTurn(sessionId, `! ${command}`, true, signal, name, [], [], resumeSessionAt, command);
1286
+ return this.executeTurn({
1287
+ activation: {
1288
+ kind: 'resume',
1289
+ sessionId,
1290
+ ...(name !== undefined ? { name } : {}),
1291
+ ...(resumeSessionAt !== undefined
1292
+ ? { atMessageId: resumeSessionAt }
1293
+ : {}),
1294
+ },
1295
+ submission: { kind: 'shell', command },
1296
+ ...(signal ? { signal } : {}),
1297
+ });
1173
1298
  }
1174
1299
  async answerSideQuestion(sessionId, question, signal, onDelta, permissionMode = 'default') {
1175
1300
  const prompt = question.trim();
@@ -1195,16 +1320,17 @@ export class ClaudeSessionService {
1195
1320
  entries = loaded.value.entries;
1196
1321
  this.restoreWorktree(entries);
1197
1322
  }
1198
- const assembledContext = await this.options.contextAssembler?.assemble({
1323
+ const assembledContext = await assembleContextSnapshot(this.options.contextAssembler, {
1199
1324
  cwd: this.activeCwd(),
1200
1325
  lifecycleId: activeSessionId,
1201
1326
  });
1327
+ const contextProjection = projectContextSnapshot(assembledContext);
1202
1328
  const messages = [
1203
- ...(assembledContext?.systemMessages ?? []),
1329
+ ...contextProjection.systemMessages,
1204
1330
  ...injectFirstUserMessageContext([
1205
1331
  ...projectClaudeModelMessages(entries),
1206
1332
  { role: 'user', content: prompt },
1207
- ], assembledContext?.firstUserMessageContext),
1333
+ ], contextProjection.firstUserMessageContext),
1208
1334
  ];
1209
1335
  const budget = this.contextBudget(provider);
1210
1336
  if (budget)
@@ -1215,6 +1341,7 @@ export class ClaudeSessionService {
1215
1341
  : 'praxis/provider';
1216
1342
  const metrics = await completeMeteredModelRequest(provider, {
1217
1343
  messages,
1344
+ stableSystemMessageCount: contextProjection.stableSystemSectionCount,
1218
1345
  ...(this.options.effort ? { effort: this.options.effort } : {}),
1219
1346
  ...(signal ? { signal } : {}),
1220
1347
  }, {
@@ -1257,6 +1384,9 @@ export class ClaudeSessionService {
1257
1384
  .join('-')
1258
1385
  .slice(0, 64) || 'side-question';
1259
1386
  const registry = this.createHostedToolRegistry(sessionId);
1387
+ const executor = this.hostedSubagentsByRegistry.get(registry);
1388
+ if (executor)
1389
+ this.hostedSubagentNotificationReservations.add(executor);
1260
1390
  const call = {
1261
1391
  id: randomUUID(),
1262
1392
  name: 'Agent',
@@ -1272,26 +1402,69 @@ export class ClaudeSessionService {
1272
1402
  cwd: this.activeCwd(),
1273
1403
  ...(signal ? { signal } : {}),
1274
1404
  };
1275
- const prepared = await registry.prepare(call, context);
1276
- const result = await registry.execute(prepared, context);
1277
- const agentId = result.nativeToolUseResult?.agentId;
1278
- if (result.isError || typeof agentId !== 'string') {
1279
- throw new Error(result.content || 'Could not fork side question');
1405
+ try {
1406
+ const prepared = await registry.prepare(call, context);
1407
+ const result = await registry.execute(prepared, context);
1408
+ const agentId = result.nativeToolUseResult?.agentId;
1409
+ if (result.isError || typeof agentId !== 'string') {
1410
+ throw new Error(result.content || 'Could not fork side question');
1411
+ }
1412
+ await this.appendSystemLocalCommand(sessionId, 'btw', prompt, `⑂ forked ${name} (${agentId.slice(-4)})`);
1413
+ if (executor) {
1414
+ this.startHostedSubagentNotificationPump(sessionId, executor);
1415
+ }
1416
+ return { agentId, name };
1280
1417
  }
1281
- await this.appendSystemLocalCommand(sessionId, 'btw', prompt, `⑂ forked ${name} (${agentId.slice(-4)})`);
1282
- const executor = this.hostedSubagentsByRegistry.get(registry);
1283
- if (executor) {
1284
- void executor
1285
- .notifications(true)
1286
- .then(({ messages }) => this.enqueueBackgroundNotifications(sessionId, messages))
1287
- .catch((error) => this.options.eventSink?.({
1288
- type: 'warning',
1289
- message: error instanceof Error
1290
- ? error.message
1291
- : `Could not persist background notification: ${String(error)}`,
1292
- }));
1418
+ catch (error) {
1419
+ if (executor) {
1420
+ this.hostedSubagentNotificationReservations.delete(executor);
1421
+ }
1422
+ throw error;
1293
1423
  }
1294
- return { agentId, name };
1424
+ }
1425
+ startHostedSubagentNotificationPump(sessionId, executor) {
1426
+ this.hostedSubagentNotificationReservations.delete(executor);
1427
+ if (this.hostedSubagentNotificationPumps.has(executor))
1428
+ return;
1429
+ const pump = (async () => {
1430
+ while (!this.closing) {
1431
+ const reconciled = await this.turnStore(sessionId).withLease(async (lease) => {
1432
+ const snapshot = await lease.load();
1433
+ await executor.reconcileDetachedNotifications((notification) => transcriptContainsBackgroundAgentNotification(snapshot.entries, notification));
1434
+ });
1435
+ if (reconciled.status !== 'completed') {
1436
+ await new Promise((resolve) => setTimeout(resolve, 25));
1437
+ continue;
1438
+ }
1439
+ const { messages } = await executor.notifications(true, false);
1440
+ if (messages.length === 0)
1441
+ return;
1442
+ await executor.prepareNotificationsDetached(messages);
1443
+ if (!(await this.enqueueBackgroundNotifications(sessionId, messages, async (message) => {
1444
+ await executor.confirmNotificationsDetached([message]);
1445
+ await executor.acknowledgeNotifications([message]);
1446
+ }))) {
1447
+ return;
1448
+ }
1449
+ if (executor.notificationClaimAgentIds().length === 0)
1450
+ return;
1451
+ }
1452
+ })()
1453
+ .catch((error) => this.options.eventSink?.({
1454
+ type: 'warning',
1455
+ message: error instanceof Error
1456
+ ? error.message
1457
+ : `Could not persist background notification: ${String(error)}`,
1458
+ }))
1459
+ .finally(() => {
1460
+ if (this.hostedSubagentNotificationPumps.get(executor) === pump) {
1461
+ this.hostedSubagentNotificationPumps.delete(executor);
1462
+ }
1463
+ this.hostedSubagentPumpPromises.delete(pump);
1464
+ });
1465
+ this.hostedSubagentNotificationPumps.set(executor, pump);
1466
+ this.hostedSubagentPumpPromises.add(pump);
1467
+ void pump;
1295
1468
  }
1296
1469
  async promptSuggestion(sessionId, signal) {
1297
1470
  const loaded = this.options.sessionPersistence === false
@@ -1309,27 +1482,21 @@ export class ClaudeSessionService {
1309
1482
  const provider = this.providerForAgent(agent);
1310
1483
  this.activeProvider = provider;
1311
1484
  const agentSystem = await this.mainAgentSystemPrompt(agent);
1312
- const assembledContext = await this.options.contextAssembler?.assemble({
1485
+ const assembledContext = await assembleContextSnapshot(this.options.contextAssembler, {
1313
1486
  cwd: this.activeCwd(),
1314
1487
  lifecycleId: sessionId,
1315
- ...(agentSystem ? { mode: 'agent', baseSystemPrompt: agentSystem } : {}),
1488
+ ...(agentSystem
1489
+ ? { mode: 'agent', baseSystemPrompt: agentSystem }
1490
+ : {}),
1316
1491
  });
1317
- const hasPromptManifest = assembledContext?.promptSections !== undefined;
1318
- const assembledSystemMessages = hasPromptManifest
1319
- ? (assembledContext?.systemMessages ?? [])
1320
- : this.assembledSystemMessages(agent, assembledContext?.systemMessages ?? []);
1321
- const contextMessages = [
1322
- ...(!hasPromptManifest && agentSystem
1323
- ? [{ role: 'system', content: agentSystem }]
1324
- : []),
1325
- ...assembledSystemMessages,
1326
- ];
1492
+ const contextProjection = projectContextSnapshot(assembledContext);
1493
+ const contextMessages = [...contextProjection.systemMessages];
1327
1494
  const messages = [
1328
1495
  ...contextMessages,
1329
1496
  ...injectFirstUserMessageContext([
1330
1497
  ...projectClaudeModelMessages(entries.entries),
1331
1498
  { role: 'user', content: PROMPT_SUGGESTION_INSTRUCTION },
1332
- ], assembledContext?.firstUserMessageContext),
1499
+ ], contextProjection.firstUserMessageContext),
1333
1500
  ];
1334
1501
  const suggestionTools = agent && this.options.tools
1335
1502
  ? new FilteredToolRegistry(this.options.tools, {
@@ -1339,6 +1506,7 @@ export class ClaudeSessionService {
1339
1506
  await this.activateSessionCostTracker(sessionId);
1340
1507
  const metrics = await completeMeteredModelRequest(provider, {
1341
1508
  messages,
1509
+ stableSystemMessageCount: contextProjection.stableSystemSectionCount,
1342
1510
  ...(provider.capabilities.tools
1343
1511
  ? { tools: suggestionTools?.definitions() ?? [] }
1344
1512
  : {}),
@@ -1866,7 +2034,11 @@ export class ClaudeSessionService {
1866
2034
  }
1867
2035
  async retryRecentlyDenied(sessionId, display, signal) {
1868
2036
  await this.appendPermissionGrant(sessionId, display, true);
1869
- return this.executeTurn(sessionId, '/permissions', true, signal, undefined, [], [], undefined, undefined, true);
2037
+ return this.executeTurn({
2038
+ activation: { kind: 'resume', sessionId },
2039
+ submission: { kind: 'retry', prompt: '/permissions' },
2040
+ ...(signal ? { signal } : {}),
2041
+ });
1870
2042
  }
1871
2043
  async recordBtwUsage(sessionId, permissionMode = 'default') {
1872
2044
  const activeSessionId = await this.ensureLocalSession(sessionId, permissionMode);
@@ -2428,8 +2600,9 @@ export class ClaudeSessionService {
2428
2600
  }
2429
2601
  async costSnapshot(sessionId) {
2430
2602
  const existing = this.sessionCostTrackers.get(sessionId);
2431
- if (existing)
2432
- return existing.snapshot();
2603
+ if (existing) {
2604
+ return this.withDetachedSubagentUsage(sessionId, existing.snapshot());
2605
+ }
2433
2606
  const store = this.options.costStateStore;
2434
2607
  const loaded = store ? await store.load(sessionId) : null;
2435
2608
  const tracker = new ClaudeSessionCostTracker({
@@ -2437,1679 +2610,1727 @@ export class ClaudeSessionService {
2437
2610
  ...(loaded ? { restored: loaded } : {}),
2438
2611
  });
2439
2612
  this.sessionCostTrackers.set(sessionId, tracker);
2440
- return tracker.snapshot();
2613
+ return this.withDetachedSubagentUsage(sessionId, tracker.snapshot());
2441
2614
  }
2442
- async executeTurn(sessionId, prompt, requireExisting, signal, name, images = [], documents = [], resumeSessionAt, shellCommand, skipUserPrompt = false) {
2443
- this.assertWritable();
2444
- if (prompt.length === 0 && images.length === 0 && documents.length === 0)
2445
- throw new Error('Prompt must not be empty');
2446
- if (name !== undefined && name.length === 0) {
2447
- throw new Error('Session name must not be empty');
2448
- }
2449
- if (shellCommand !== undefined && shellCommand.trim().length === 0) {
2450
- throw new Error('Shell command must not be empty');
2615
+ async withDetachedSubagentUsage(sessionId, base) {
2616
+ const tracker = new ClaudeSessionCostTracker({
2617
+ sessionId,
2618
+ restored: base,
2619
+ });
2620
+ const directory = join(this.paths(sessionId).praxisRoot, 'subagent-lifecycle', sessionId);
2621
+ let entries;
2622
+ try {
2623
+ entries = await readdir(directory);
2451
2624
  }
2452
- await this.activateSessionCostTracker(sessionId);
2453
- if (this.options.sessionPersistence !== false) {
2454
- this.durableMetadataSessions.add(sessionId);
2625
+ catch (error) {
2626
+ if (error.code === 'ENOENT')
2627
+ return base;
2628
+ throw error;
2455
2629
  }
2456
- await this.ensureFileResources(sessionId, signal);
2457
- this.worktreeManager?.bindSession(sessionId);
2458
- if (this.options.initialWorktree) {
2459
- await this.worktreeManager?.ensureInitial(this.options.initialWorktreeName);
2460
- }
2461
- const pinnedCwd = this.sessionCwds.get(sessionId) ?? this.activeCwd();
2462
- this.sessionCwds.set(sessionId, pinnedCwd);
2463
- if (this.options.workspace?.cwd() !== pinnedCwd) {
2464
- this.options.workspace?.setCwd(pinnedCwd);
2465
- }
2466
- this.runtimeCwd = pinnedCwd;
2467
- if (requireExisting) {
2468
- await this.discoverProjectRoot(sessionId);
2469
- }
2470
- const sessionPaths = this.paths(sessionId);
2471
- const toolResultDirectory = join(sessionPaths.projectRoot, sessionId, 'tool-results');
2472
- const store = this.turnStore(sessionId);
2473
- const leaseResult = await store.withLease(async (lease) => {
2474
- if (!requireExisting) {
2475
- const initialization = name === undefined
2476
- ? await store.reserve()
2477
- : await store.create(this.sessionNameEntries(sessionId, name));
2478
- if (initialization.status === 'conflict') {
2479
- throw new Error(`Session ID ${sessionId} is already in use`);
2630
+ for (const file of entries) {
2631
+ const agentId = file.endsWith('.json') ? file.slice(0, -5) : '';
2632
+ if (!isClaudeAgentId(agentId))
2633
+ continue;
2634
+ try {
2635
+ const lifecycle = await new SubagentLifecycleStore(this.paths(sessionId).praxisRoot, sessionId, agentId).read();
2636
+ for (const notification of lifecycle?.notifications ?? []) {
2637
+ if (notification.accounting?.kind !== 'detached' ||
2638
+ !notification.accounting.delivered ||
2639
+ notification.result === null) {
2640
+ continue;
2641
+ }
2642
+ const rows = Object.entries(notification.result.modelUsage ?? {
2643
+ [notification.accounting.model]: notification.result.usage,
2644
+ });
2645
+ if (rows.length === 0) {
2646
+ tracker.recordDurations({
2647
+ ...(notification.result.durationApiMs === undefined
2648
+ ? {}
2649
+ : { apiDurationMs: notification.result.durationApiMs }),
2650
+ ...(notification.result.durationApiWithoutRetriesMs === undefined
2651
+ ? {}
2652
+ : {
2653
+ durationApiWithoutRetriesMs: notification.result.durationApiWithoutRetriesMs,
2654
+ }),
2655
+ });
2656
+ continue;
2657
+ }
2658
+ for (const [index, [model, usage]] of rows.entries()) {
2659
+ const pricing = this.options.pricing?.resolve(model);
2660
+ const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
2661
+ tracker.recordTurn({
2662
+ model,
2663
+ usage,
2664
+ ...(costUsd === undefined ? {} : { costUsd }),
2665
+ ...(usage.webSearchRequests === undefined
2666
+ ? {}
2667
+ : { webSearchRequests: usage.webSearchRequests }),
2668
+ ...(index === 0
2669
+ ? {
2670
+ apiDurationMs: notification.result.durationApiMs,
2671
+ apiDurationWithoutRetriesMs: notification.result.durationApiWithoutRetriesMs,
2672
+ }
2673
+ : {}),
2674
+ });
2675
+ }
2480
2676
  }
2481
2677
  }
2482
- let snapshot = await lease.load();
2483
- let automaticReplayPrompt;
2484
- if (requireExisting &&
2485
- this.options.sessionPersistence === false &&
2486
- snapshot.entries.length === 0) {
2487
- const persisted = await this.store(sessionId).load();
2488
- if (persisted.entries.length > 0) {
2489
- const imported = await store.create(persisted.entries);
2490
- if (imported.status === 'created')
2491
- snapshot = await lease.load();
2492
- }
2678
+ catch (error) {
2679
+ this.options.eventSink?.({
2680
+ type: 'warning',
2681
+ message: `Could not account detached subagent ${agentId}: ${error instanceof Error ? error.message : String(error)}`,
2682
+ });
2493
2683
  }
2494
- if (requireExisting && snapshot.entries.length === 0) {
2495
- throw new Error(`Claude session not found: ${sessionId}`);
2684
+ }
2685
+ return tracker.snapshot();
2686
+ }
2687
+ async executeTurn(request) {
2688
+ const { activation, submission, signal } = request;
2689
+ const sessionId = activation.sessionId;
2690
+ const requireExisting = activation.kind === 'resume';
2691
+ const name = activation.name;
2692
+ const resumeSessionAt = activation.kind === 'resume' ? activation.atMessageId : undefined;
2693
+ const prompt = submission.kind === 'shell'
2694
+ ? `! ${submission.command}`
2695
+ : submission.kind === 'retry'
2696
+ ? submission.prompt
2697
+ : submission.text;
2698
+ const images = submission.kind === 'prompt' ? (submission.images ?? []) : [];
2699
+ const documents = submission.kind === 'prompt' ? (submission.documents ?? []) : [];
2700
+ const shellCommand = submission.kind === 'shell' ? submission.command : undefined;
2701
+ const skipUserPrompt = submission.kind === 'retry';
2702
+ const controller = new TurnTerminalController(this.options.eventSink ?? (() => undefined));
2703
+ try {
2704
+ this.assertWritable();
2705
+ if (prompt.length === 0 && images.length === 0 && documents.length === 0)
2706
+ throw new Error('Prompt must not be empty');
2707
+ if (name !== undefined && name.length === 0) {
2708
+ throw new Error('Session name must not be empty');
2496
2709
  }
2497
- this.rememberDurableMetadata(sessionId, snapshot.entries);
2498
- if (resumeSessionAt !== undefined) {
2499
- snapshot = {
2500
- entries: selectClaudeTranscriptAtMessage(snapshot.entries, resumeSessionAt),
2501
- tail: { ...snapshot.tail, branchParentUuid: resumeSessionAt },
2502
- };
2710
+ if (shellCommand !== undefined && shellCommand.trim().length === 0) {
2711
+ throw new Error('Shell command must not be empty');
2503
2712
  }
2504
- else if (this.explicitResumeLeafUuids.has(sessionId)) {
2505
- const selected = selectClaudeTranscriptFromNewestLeaf(snapshot.entries);
2506
- this.explicitResumeLeafUuids.set(sessionId, selected.leafUuid);
2507
- snapshot = {
2508
- entries: selected.entries,
2509
- tail: { ...snapshot.tail, branchParentUuid: selected.leafUuid },
2510
- };
2713
+ await this.activateSessionCostTracker(sessionId);
2714
+ if (this.options.sessionPersistence !== false) {
2715
+ this.durableMetadataSessions.add(sessionId);
2511
2716
  }
2512
- if (requireExisting &&
2513
- resumeSessionAt === undefined &&
2514
- this.options.resumeInterruptedTurn === true &&
2515
- shellCommand === undefined &&
2516
- !skipUserPrompt &&
2517
- !(this.options.approveRecovery !== undefined &&
2518
- findUnresolvedClaudeToolCalls(snapshot.entries).length > 0)) {
2519
- const interruption = classifyClaudeInterruption(snapshot.entries);
2520
- if ((interruption.kind === 'interrupted-prompt' ||
2521
- interruption.kind === 'interrupted-turn') &&
2522
- interruption.prompt !== undefined &&
2523
- interruption.replayEntries !== undefined) {
2524
- automaticReplayPrompt = interruption.prompt;
2525
- snapshot = {
2526
- entries: interruption.replayEntries,
2527
- tail: {
2528
- ...snapshot.tail,
2529
- branchParentUuid: interruption.replayParentUuid ?? null,
2530
- },
2531
- };
2532
- }
2717
+ await this.ensureFileResources(sessionId, signal);
2718
+ this.worktreeManager?.bindSession(sessionId);
2719
+ if (this.options.initialWorktree) {
2720
+ await this.worktreeManager?.ensureInitial(this.options.initialWorktreeName);
2533
2721
  }
2534
- const initialTransition = this.worktreeManager?.consumeTransition('__initial__');
2535
- if (initialTransition && snapshot.entries.length === 0) {
2536
- const stateEntry = {
2537
- type: 'worktree-state',
2538
- worktreeSession: initialTransition.state,
2539
- sessionId,
2540
- };
2541
- const stateTail = await this.append(lease, snapshot.tail, stateEntry);
2542
- snapshot = {
2543
- entries: [...snapshot.entries, stateEntry],
2544
- tail: stateTail,
2545
- };
2546
- this.rememberDurableMetadata(sessionId, snapshot.entries);
2722
+ const pinnedCwd = this.sessionCwds.get(sessionId) ?? this.activeCwd();
2723
+ this.sessionCwds.set(sessionId, pinnedCwd);
2724
+ if (this.options.workspace?.cwd() !== pinnedCwd) {
2725
+ this.options.workspace?.setCwd(pinnedCwd);
2547
2726
  }
2548
- this.restoreWorktree(snapshot.entries);
2549
- this.options.interactiveTools?.restore(sessionId, snapshot.entries);
2550
- if (requireExisting &&
2551
- name !== undefined &&
2552
- !this.hasSessionName(snapshot.entries, name)) {
2553
- const entries = this.sessionNameEntries(sessionId, name);
2554
- const appendResult = await lease.appendMany(snapshot.tail, entries);
2555
- if (appendResult.status === 'conflict') {
2556
- throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
2727
+ this.runtimeCwd = pinnedCwd;
2728
+ if (requireExisting) {
2729
+ await this.discoverProjectRoot(sessionId);
2730
+ }
2731
+ const sessionPaths = this.paths(sessionId);
2732
+ const toolResultDirectory = join(sessionPaths.projectRoot, sessionId, 'tool-results');
2733
+ const store = this.turnStore(sessionId);
2734
+ const leaseResult = await store.withLease(async (lease) => {
2735
+ if (!requireExisting) {
2736
+ const initialization = name === undefined
2737
+ ? await store.reserve()
2738
+ : await store.create(this.sessionNameEntries(sessionId, name));
2739
+ if (initialization.status === 'conflict') {
2740
+ throw new Error(`Session ID ${sessionId} is already in use`);
2741
+ }
2742
+ }
2743
+ let snapshot = await lease.load();
2744
+ let automaticReplayPrompt;
2745
+ if (requireExisting &&
2746
+ this.options.sessionPersistence === false &&
2747
+ snapshot.entries.length === 0) {
2748
+ const persisted = await this.store(sessionId).load();
2749
+ if (persisted.entries.length > 0) {
2750
+ const imported = await store.create(persisted.entries);
2751
+ if (imported.status === 'created')
2752
+ snapshot = await lease.load();
2753
+ }
2754
+ }
2755
+ if (requireExisting && snapshot.entries.length === 0) {
2756
+ throw new Error(`Claude session not found: ${sessionId}`);
2557
2757
  }
2558
- snapshot = {
2559
- entries: [...snapshot.entries, ...entries],
2560
- tail: appendResult.tail,
2561
- };
2562
2758
  this.rememberDurableMetadata(sessionId, snapshot.entries);
2563
- }
2564
- const agentName = this.options.agent ?? getClaudeAgentSetting(snapshot.entries);
2565
- const agent = this.resolveAgent(agentName);
2566
- const provider = this.providerForAgent(agent);
2567
- this.activeProvider = provider;
2568
- const effectivePrompt = automaticReplayPrompt ??
2569
- (!requireExisting &&
2570
- !skipUserPrompt &&
2571
- !this.options.agentInitialPromptHandledExternally &&
2759
+ if (resumeSessionAt !== undefined) {
2760
+ snapshot = {
2761
+ entries: selectClaudeTranscriptAtMessage(snapshot.entries, resumeSessionAt),
2762
+ tail: { ...snapshot.tail, branchParentUuid: resumeSessionAt },
2763
+ };
2764
+ }
2765
+ else if (this.explicitResumeLeafUuids.has(sessionId)) {
2766
+ const selected = selectClaudeTranscriptFromNewestLeaf(snapshot.entries);
2767
+ this.explicitResumeLeafUuids.set(sessionId, selected.leafUuid);
2768
+ snapshot = {
2769
+ entries: selected.entries,
2770
+ tail: { ...snapshot.tail, branchParentUuid: selected.leafUuid },
2771
+ };
2772
+ }
2773
+ if (requireExisting &&
2774
+ resumeSessionAt === undefined &&
2775
+ this.options.resumeInterruptedTurn === true &&
2572
2776
  shellCommand === undefined &&
2573
- agent?.initialPrompt
2574
- ? `${agent.initialPrompt}\n\n${prompt}`
2575
- : prompt);
2576
- const projectMemoryRecallTurn = !skipUserPrompt && shellCommand === undefined
2577
- ? this.options.projectMemoryRecall?.prefetch({
2578
- sessionId,
2579
- turnId: randomUUID(),
2580
- prompt: effectivePrompt,
2581
- ...(signal ? { signal } : {}),
2582
- })
2583
- : undefined;
2584
- const initialPricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
2585
- if (this.options.maxBudgetUsd !== undefined && !initialPricing) {
2586
- throw new Error(`Cannot enforce --max-budget-usd: no pricing is configured for model ${provider.model ?? 'praxis/provider'}`);
2587
- }
2588
- const shellInputUuid = shellCommand === undefined ? null : randomUUID();
2589
- let currentPromptId = shellInputUuid;
2590
- let lastAssistantUuid = null;
2591
- const fileHistory = this.options.fileCheckpointing &&
2592
- this.options.sessionPersistence !== false
2593
- ? new ClaudeFileHistory(this.options.configRoot, sessionId, [
2594
- this.activeCwd(),
2595
- ...(this.options.fileRewindRoots ?? []),
2596
- ])
2597
- : null;
2598
- const unresolvedToolCalls = findUnresolvedClaudeToolCalls(snapshot.entries);
2599
- const pendingRecoveryToolCallIds = new Set(unresolvedToolCalls.map((call) => call.id));
2600
- const pendingRecoveryHookOutcomes = [];
2601
- const currentHookCwd = () => this.activeCwd();
2602
- const hookSession = {
2603
- session_id: sessionId,
2604
- transcript_path: sessionPaths.sessionFile,
2605
- get cwd() {
2606
- return currentHookCwd();
2607
- },
2608
- permission_mode: 'default',
2609
- };
2610
- const appendHookOutcome = async (outcome) => {
2611
- for (const entry of createClaudeHookAttachmentEntries(outcome, this.translationContext(sessionId, snapshot))) {
2612
- const tail = await this.append(lease, snapshot.tail, entry);
2613
- snapshot = { entries: [...snapshot.entries, entry], tail };
2777
+ !skipUserPrompt &&
2778
+ !(this.options.approveRecovery !== undefined &&
2779
+ findUnresolvedClaudeToolCalls(snapshot.entries).length > 0)) {
2780
+ const interruption = classifyClaudeInterruption(snapshot.entries);
2781
+ if ((interruption.kind === 'interrupted-prompt' ||
2782
+ interruption.kind === 'interrupted-turn') &&
2783
+ interruption.prompt !== undefined &&
2784
+ interruption.replayEntries !== undefined) {
2785
+ automaticReplayPrompt = interruption.prompt;
2786
+ snapshot = {
2787
+ entries: interruption.replayEntries,
2788
+ tail: {
2789
+ ...snapshot.tail,
2790
+ branchParentUuid: interruption.replayParentUuid ?? null,
2791
+ },
2792
+ };
2793
+ }
2614
2794
  }
2615
- };
2616
- const recordHookOutcome = async (outcome, deferUntilApproval = false) => {
2617
- if (deferUntilApproval) {
2618
- pendingRecoveryHookOutcomes.push(outcome);
2619
- return;
2795
+ const initialTransition = this.worktreeManager?.consumeTransition('__initial__');
2796
+ if (initialTransition && snapshot.entries.length === 0) {
2797
+ const stateEntry = {
2798
+ type: 'worktree-state',
2799
+ worktreeSession: initialTransition.state,
2800
+ sessionId,
2801
+ };
2802
+ const stateTail = await this.append(lease, snapshot.tail, stateEntry);
2803
+ snapshot = {
2804
+ entries: [...snapshot.entries, stateEntry],
2805
+ tail: stateTail,
2806
+ };
2807
+ this.rememberDurableMetadata(sessionId, snapshot.entries);
2620
2808
  }
2621
- await appendHookOutcome(outcome);
2622
- };
2623
- const flushRecoveryHookOutcomes = async () => {
2624
- const entries = [];
2625
- let history = snapshot.entries;
2626
- let parentUuid = this.logicalTailUuid(snapshot.tail);
2627
- for (const outcome of pendingRecoveryHookOutcomes) {
2628
- const outcomeEntries = createClaudeHookAttachmentEntries(outcome, {
2629
- ...this.translationContext(sessionId, snapshot),
2630
- parentUuid,
2631
- history,
2632
- });
2633
- entries.push(...outcomeEntries);
2634
- history = [...history, ...outcomeEntries];
2635
- const lastEntry = outcomeEntries.at(-1);
2636
- if (typeof lastEntry?.uuid === 'string')
2637
- parentUuid = lastEntry.uuid;
2809
+ this.restoreWorktree(snapshot.entries);
2810
+ this.options.interactiveTools?.restore(sessionId, snapshot.entries);
2811
+ if (requireExisting &&
2812
+ name !== undefined &&
2813
+ !this.hasSessionName(snapshot.entries, name)) {
2814
+ const entries = this.sessionNameEntries(sessionId, name);
2815
+ const appendResult = await lease.appendMany(snapshot.tail, entries);
2816
+ if (appendResult.status === 'conflict') {
2817
+ throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
2818
+ }
2819
+ snapshot = {
2820
+ entries: [...snapshot.entries, ...entries],
2821
+ tail: appendResult.tail,
2822
+ };
2823
+ this.rememberDurableMetadata(sessionId, snapshot.entries);
2638
2824
  }
2639
- if (entries.length === 0) {
2640
- pendingRecoveryHookOutcomes.length = 0;
2641
- return;
2825
+ const agentName = this.options.agent ?? getClaudeAgentSetting(snapshot.entries);
2826
+ const agent = this.resolveAgent(agentName);
2827
+ const provider = this.providerForAgent(agent);
2828
+ this.activeProvider = provider;
2829
+ const effectivePrompt = automaticReplayPrompt ??
2830
+ (!requireExisting &&
2831
+ !skipUserPrompt &&
2832
+ !this.options.agentInitialPromptHandledExternally &&
2833
+ shellCommand === undefined &&
2834
+ agent?.initialPrompt
2835
+ ? `${agent.initialPrompt}\n\n${prompt}`
2836
+ : prompt);
2837
+ const projectMemoryRecallTurn = !skipUserPrompt && shellCommand === undefined
2838
+ ? this.options.projectMemoryRecall?.prefetch({
2839
+ sessionId,
2840
+ turnId: randomUUID(),
2841
+ prompt: effectivePrompt,
2842
+ ...(signal ? { signal } : {}),
2843
+ })
2844
+ : undefined;
2845
+ const initialPricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
2846
+ if (this.options.maxBudgetUsd !== undefined && !initialPricing) {
2847
+ throw new Error(`Cannot enforce --max-budget-usd: no pricing is configured for model ${provider.model ?? 'praxis/provider'}`);
2642
2848
  }
2643
- const appendResult = await lease.appendMany(snapshot.tail, entries);
2644
- if (appendResult.status === 'conflict') {
2645
- throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
2849
+ const shellInputUuid = shellCommand === undefined ? null : randomUUID();
2850
+ let currentPromptId = shellInputUuid;
2851
+ let lastAssistantUuid = null;
2852
+ const fileHistory = this.options.fileCheckpointing &&
2853
+ this.options.sessionPersistence !== false
2854
+ ? new ClaudeFileHistory(this.options.configRoot, sessionId, [
2855
+ this.activeCwd(),
2856
+ ...(this.options.fileRewindRoots ?? []),
2857
+ ])
2858
+ : null;
2859
+ const unresolvedToolCalls = findUnresolvedClaudeToolCalls(snapshot.entries);
2860
+ const pendingRecoveryToolCallIds = new Set(unresolvedToolCalls.map((call) => call.id));
2861
+ const pendingRecoveryHookOutcomes = [];
2862
+ const currentHookCwd = () => this.activeCwd();
2863
+ const hookSession = {
2864
+ session_id: sessionId,
2865
+ transcript_path: sessionPaths.sessionFile,
2866
+ get cwd() {
2867
+ return currentHookCwd();
2868
+ },
2869
+ permission_mode: 'default',
2870
+ };
2871
+ const appendHookOutcome = async (outcome) => {
2872
+ for (const entry of createClaudeHookAttachmentEntries(outcome, this.translationContext(sessionId, snapshot))) {
2873
+ const tail = await this.append(lease, snapshot.tail, entry);
2874
+ snapshot = { entries: [...snapshot.entries, entry], tail };
2875
+ }
2876
+ };
2877
+ const recordHookOutcome = async (outcome, deferUntilApproval = false) => {
2878
+ if (deferUntilApproval) {
2879
+ pendingRecoveryHookOutcomes.push(outcome);
2880
+ return;
2881
+ }
2882
+ await appendHookOutcome(outcome);
2883
+ };
2884
+ const flushRecoveryHookOutcomes = async () => {
2885
+ const entries = [];
2886
+ let history = snapshot.entries;
2887
+ let parentUuid = this.logicalTailUuid(snapshot.tail);
2888
+ for (const outcome of pendingRecoveryHookOutcomes) {
2889
+ const outcomeEntries = createClaudeHookAttachmentEntries(outcome, {
2890
+ ...this.translationContext(sessionId, snapshot),
2891
+ parentUuid,
2892
+ history,
2893
+ });
2894
+ entries.push(...outcomeEntries);
2895
+ history = [...history, ...outcomeEntries];
2896
+ const lastEntry = outcomeEntries.at(-1);
2897
+ if (typeof lastEntry?.uuid === 'string')
2898
+ parentUuid = lastEntry.uuid;
2899
+ }
2900
+ if (entries.length === 0) {
2901
+ pendingRecoveryHookOutcomes.length = 0;
2902
+ return;
2903
+ }
2904
+ const appendResult = await lease.appendMany(snapshot.tail, entries);
2905
+ if (appendResult.status === 'conflict') {
2906
+ throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
2907
+ }
2908
+ snapshot = { entries: history, tail: appendResult.tail };
2909
+ pendingRecoveryHookOutcomes.length = 0;
2910
+ };
2911
+ const capabilities = this.toolCapabilities();
2912
+ const taskToolNames = this.capabilityToolNames(this.options.taskToolNames, capabilities);
2913
+ const scheduledToolNames = this.capabilityToolNames(this.options.scheduledToolNames, capabilities);
2914
+ const taskTools = this.options.tools && taskToolNames.length > 0
2915
+ ? new ClaudeTaskToolRegistry({
2916
+ base: this.options.tools,
2917
+ cwd: this.activeCwd(),
2918
+ cwdProvider: () => this.activeCwd(),
2919
+ praxisRoot: sessionPaths.praxisRoot,
2920
+ sessionId,
2921
+ taskRoot: sessionPaths.taskRoot,
2922
+ ...(this.options.eventSink
2923
+ ? { eventSink: this.options.eventSink }
2924
+ : {}),
2925
+ enabledTools: taskToolNames,
2926
+ ...(this.options.hooks
2927
+ ? {
2928
+ taskHooks: {
2929
+ created: async (task, taskSignal) => {
2930
+ const outcome = await this.options.hooks?.run({
2931
+ ...hookSession,
2932
+ hook_event_name: 'TaskCreated',
2933
+ task_id: task.id,
2934
+ task_subject: task.subject,
2935
+ task_description: task.description,
2936
+ }, undefined, taskSignal);
2937
+ if (!outcome)
2938
+ return;
2939
+ await recordHookOutcome(outcome);
2940
+ if (outcome.blockedReason) {
2941
+ throw new Error(`TaskCreated hook error: ${outcome.blockedReason}`);
2942
+ }
2943
+ },
2944
+ completed: async (task, taskSignal) => {
2945
+ const outcome = await this.options.hooks?.run({
2946
+ ...hookSession,
2947
+ hook_event_name: 'TaskCompleted',
2948
+ task_id: task.id,
2949
+ task_subject: task.subject,
2950
+ task_description: task.description,
2951
+ }, undefined, taskSignal);
2952
+ if (!outcome)
2953
+ return;
2954
+ await recordHookOutcome(outcome);
2955
+ if (outcome.blockedReason) {
2956
+ throw new Error(`TaskCompleted hook error: ${outcome.blockedReason}`);
2957
+ }
2958
+ },
2959
+ },
2960
+ }
2961
+ : {}),
2962
+ })
2963
+ : null;
2964
+ const scheduledTools = this.scheduledPrompts &&
2965
+ this.options.tools &&
2966
+ scheduledToolNames.length > 0
2967
+ ? new ClaudeScheduledToolRegistry({
2968
+ base: taskTools ?? this.options.tools,
2969
+ manager: this.scheduledPrompts,
2970
+ sessionId,
2971
+ enabledTools: scheduledToolNames,
2972
+ dataPlane: this.options.dataPlane ?? 'claude',
2973
+ })
2974
+ : null;
2975
+ const baseTools = scheduledTools ?? taskTools ?? this.options.tools;
2976
+ const turnPermissions = this.options.interactiveTools?.permissions(sessionId) ??
2977
+ this.options.permissions;
2978
+ const subagentExecutor = (this.options.enableSubagents || this.options.enableWorkflows) &&
2979
+ baseTools &&
2980
+ turnPermissions
2981
+ ? new ClaudeSubagentExecutor({
2982
+ configRoot: this.options.configRoot,
2983
+ dataPlane: this.options.dataPlane ?? 'claude',
2984
+ cwd: this.activeCwd(),
2985
+ cwdProvider: () => this.activeCwd(),
2986
+ claudeVersion: this.options.claudeVersion,
2987
+ provider,
2988
+ persistence: this.options.sessionPersistence === false ? 'memory' : 'disk',
2989
+ ...(this.options.providerForModel
2990
+ ? { providerForModel: this.options.providerForModel }
2991
+ : {}),
2992
+ baseTools,
2993
+ permissions: turnPermissions,
2994
+ ...(this.options.permissionResolverForMode
2995
+ ? {
2996
+ permissionResolverForMode: this.options.permissionResolverForMode,
2997
+ }
2998
+ : {}),
2999
+ parentPermissionMode: () => agentPermissionMode(this.options.interactiveTools?.mode(sessionId) ??
3000
+ this.options.permissionMode),
3001
+ ...(this.options.subagentToolNames
3002
+ ? {
3003
+ toolNames: this.capabilityToolNames(this.options.subagentToolNames, capabilities),
3004
+ }
3005
+ : {}),
3006
+ ...(this.options.extensions
3007
+ ? { extensions: this.options.extensions }
3008
+ : {}),
3009
+ ...(this.options.mcp ? { mcp: this.options.mcp } : {}),
3010
+ ...(this.options.hooks ? { hooks: this.options.hooks } : {}),
3011
+ ...(this.options.contextAssembler
3012
+ ? { contextAssembler: this.options.contextAssembler }
3013
+ : {}),
3014
+ ...((this.options.contextBudget?.reserveTokens ??
3015
+ this.options.contextReserveTokens) === undefined
3016
+ ? {}
3017
+ : {
3018
+ contextReserveTokens: this.options.contextBudget?.reserveTokens ??
3019
+ this.options.contextReserveTokens,
3020
+ }),
3021
+ ...(this.options.approveTool
3022
+ ? { approveTool: this.options.approveTool }
3023
+ : {}),
3024
+ permissionUpdates: () => this.sessionPermissionUpdates.get(sessionId) ?? [],
3025
+ onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
3026
+ onLineChanges: (changes) => {
3027
+ const tracker = this.sessionCostTrackers.get(sessionId);
3028
+ if (!tracker) {
3029
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
3030
+ }
3031
+ tracker.recordLineChanges(changes);
3032
+ },
3033
+ ...(this.options.eventSink
3034
+ ? { eventSink: this.options.eventSink }
3035
+ : {}),
3036
+ ...(taskTools
3037
+ ? {
3038
+ backgroundTaskNotifications: (waitForRunning) => taskTools.notifications(waitForRunning),
3039
+ }
3040
+ : {}),
3041
+ notificationDelivered: (notification) => transcriptContainsBackgroundAgentNotification(snapshot.entries, notification),
3042
+ stopOwnedBackgroundAgent: (ownerSessionId, agentId) => this.backgroundTasks.stopAgent(ownerSessionId, agentId),
3043
+ outputOwnedBackgroundAgent: (ownerSessionId, agentId, options) => this.backgroundTasks.outputAgent(ownerSessionId, agentId, options),
3044
+ sendOwnedBackgroundAgent: (ownerSessionId, agentId, message, summary, toolUseId) => this.sendOwnedBackgroundAgent(ownerSessionId, agentId, message, summary, toolUseId),
3045
+ })
3046
+ : null;
3047
+ if (subagentExecutor) {
3048
+ this.subagentExecutors.add(subagentExecutor);
3049
+ this.subagentExecutorSessions.set(subagentExecutor, sessionId);
3050
+ this.backgroundTasks.registerAgents(sessionId, subagentExecutor);
2646
3051
  }
2647
- snapshot = { entries: history, tail: appendResult.tail };
2648
- pendingRecoveryHookOutcomes.length = 0;
2649
- };
2650
- const capabilities = this.toolCapabilities();
2651
- const taskToolNames = this.capabilityToolNames(this.options.taskToolNames, capabilities);
2652
- const scheduledToolNames = this.capabilityToolNames(this.options.scheduledToolNames, capabilities);
2653
- const taskTools = this.options.tools && taskToolNames.length > 0
2654
- ? new ClaudeTaskToolRegistry({
2655
- base: this.options.tools,
2656
- cwd: this.activeCwd(),
2657
- cwdProvider: () => this.activeCwd(),
2658
- praxisRoot: sessionPaths.praxisRoot,
2659
- sessionId,
2660
- taskRoot: sessionPaths.taskRoot,
2661
- ...(this.options.eventSink
2662
- ? { eventSink: this.options.eventSink }
3052
+ const agentTools = subagentExecutor
3053
+ ? subagentExecutor.registry(sessionId, 0, (callId) => currentPromptId ??
3054
+ this.promptIdForToolCall(snapshot.entries, callId))
3055
+ : baseTools;
3056
+ const turnTools = this.workflowManager && subagentExecutor && agentTools
3057
+ ? new ClaudeWorkflowToolRegistry({
3058
+ base: agentTools,
3059
+ manager: this.workflowManager,
3060
+ executor: subagentExecutor,
3061
+ cwd: this.activeCwd(),
3062
+ cwdProvider: () => this.activeCwd(),
3063
+ configRoot: this.options.configRoot,
3064
+ sessionId,
3065
+ promptIdForCall: (callId) => currentPromptId ??
3066
+ this.promptIdForToolCall(snapshot.entries, callId),
3067
+ defaultModel: provider.model ?? 'praxis/provider',
3068
+ tokenBudget: workflowTokenTarget(effectivePrompt),
3069
+ enabled: capabilities.has('Workflow'),
3070
+ dataPlane: this.options.dataPlane ?? 'claude',
3071
+ })
3072
+ : agentTools;
3073
+ const workspaceTools = this.worktreeManager &&
3074
+ turnTools &&
3075
+ this.options.workspace &&
3076
+ (this.options.worktreeToolNames?.length ?? 0) > 0
3077
+ ? new ClaudeWorktreeToolRegistry({
3078
+ base: turnTools,
3079
+ manager: this.worktreeManager,
3080
+ workspace: this.options.workspace,
3081
+ enabledTools: this.options.worktreeToolNames ?? [],
3082
+ dataPlane: this.options.dataPlane ?? 'claude',
3083
+ })
3084
+ : turnTools;
3085
+ const messageTools = this.options.brief && workspaceTools
3086
+ ? new ClaudeUserMessageToolRegistry(workspaceTools, (message) => this.options.eventSink?.({
3087
+ type: 'user-message',
3088
+ message: message.message,
3089
+ status: message.status,
3090
+ ...(message.attachments.length
3091
+ ? { attachments: message.attachments }
3092
+ : {}),
3093
+ }))
3094
+ : workspaceTools;
3095
+ const interactiveMessageTools = this.options.interactiveTools && messageTools
3096
+ ? this.options.interactiveTools.registry(messageTools, sessionId)
3097
+ : messageTools;
3098
+ const fileHistoryTools = fileHistory && interactiveMessageTools
3099
+ ? {
3100
+ definitions: () => interactiveMessageTools.definitions(),
3101
+ schedulingPolicy: (call) => ({
3102
+ ...resolveToolSchedulingPolicy(interactiveMessageTools, call),
3103
+ startAfterAssistant: true,
3104
+ }),
3105
+ prepare: (call, context) => interactiveMessageTools.prepare(call, context),
3106
+ execute: async (call, context) => {
3107
+ const path = call.name === 'Write' || call.name === 'Edit'
3108
+ ? call.input.file_path
3109
+ : call.name === 'NotebookEdit'
3110
+ ? call.input.notebook_path
3111
+ : undefined;
3112
+ if (typeof path !== 'string') {
3113
+ return interactiveMessageTools.execute(call, context);
3114
+ }
3115
+ if ((call.name === 'Write' || call.name === 'Edit') &&
3116
+ (await this.options.interactiveTools?.isPlanFile(sessionId, path))) {
3117
+ return interactiveMessageTools.execute(call, context);
3118
+ }
3119
+ const snapshotMessageId = currentPromptId ??
3120
+ this.promptIdForToolCall(snapshot.entries, call.id);
3121
+ const assistantMessageId = lastAssistantUuid ??
3122
+ this.assistantIdForToolCall(snapshot.entries, call.id);
3123
+ if (!snapshotMessageId || !assistantMessageId) {
3124
+ throw new Error('Claude file history could not link tool call');
3125
+ }
3126
+ const prepared = await fileHistory.prepareMutation(snapshot.entries, snapshotMessageId, path);
3127
+ let result;
3128
+ try {
3129
+ result = await interactiveMessageTools.execute(call, context);
3130
+ }
3131
+ catch (error) {
3132
+ await prepared.rollback();
3133
+ throw error;
3134
+ }
3135
+ if (result.isError) {
3136
+ await prepared.rollback();
3137
+ return result;
3138
+ }
3139
+ const entry = prepared.commit(assistantMessageId);
3140
+ if (entry) {
3141
+ const tail = await this.append(lease, snapshot.tail, entry);
3142
+ snapshot = { entries: [...snapshot.entries, entry], tail };
3143
+ }
3144
+ return result;
3145
+ },
3146
+ }
3147
+ : interactiveMessageTools;
3148
+ const capabilityTools = fileHistoryTools
3149
+ ? new ClaudeCapabilityToolRegistry(fileHistoryTools, capabilities)
3150
+ : undefined;
3151
+ const structuredCapture = this.options.structuredOutputSchema
3152
+ ? { calls: 0, value: undefined }
3153
+ : undefined;
3154
+ const agentScopedTools = agent && capabilityTools
3155
+ ? new FilteredToolRegistry(capabilityTools, {
3156
+ tools: mainAgentToolNames(capabilityTools, agent),
3157
+ })
3158
+ : capabilityTools;
3159
+ const structuredTools = this.options.structuredOutputSchema && structuredCapture
3160
+ ? new StructuredOutputRegistry(agentScopedTools ?? this.options.tools ?? emptyToolRegistry, this.options.structuredOutputSchema, structuredCapture)
3161
+ : agentScopedTools;
3162
+ const hookTools = this.options.hooks && structuredTools && turnPermissions
3163
+ ? new ClaudeHookToolCoordinator({
3164
+ tools: structuredTools,
3165
+ permissions: turnPermissions,
3166
+ hooks: this.options.hooks,
3167
+ session: hookSession,
3168
+ recordOutcome: recordHookOutcome,
3169
+ ...(this.options.eventSink
3170
+ ? {
3171
+ warn: (message) => this.options.eventSink?.({
3172
+ type: 'warning',
3173
+ message,
3174
+ }),
3175
+ }
3176
+ : {}),
3177
+ deferPreToolUseOutcome: (call) => pendingRecoveryToolCallIds.has(call.id),
3178
+ })
3179
+ : null;
3180
+ const runtime = new AgentRuntime(provider, controller.emit, {
3181
+ emitInitialContextState: false,
3182
+ ...(this.options.emitToolUseSummaries
3183
+ ? {
3184
+ generateToolUseSummary: ({ tools, lastAssistantText, signal: summarySignal, }) => generateToolUseSummary(provider, tools, summarySignal, lastAssistantText, (metrics) => this.recordAuxiliaryMetrics(sessionId, metrics)),
3185
+ }
2663
3186
  : {}),
2664
- enabledTools: taskToolNames,
2665
- ...(this.options.hooks
3187
+ ...(this.options.pricing
2666
3188
  ? {
2667
- taskHooks: {
2668
- created: async (task, taskSignal) => {
2669
- const outcome = await this.options.hooks?.run({
2670
- ...hookSession,
2671
- hook_event_name: 'TaskCreated',
2672
- task_id: task.id,
2673
- task_subject: task.subject,
2674
- task_description: task.description,
2675
- }, undefined, taskSignal);
2676
- if (!outcome)
2677
- return;
2678
- await recordHookOutcome(outcome);
2679
- if (outcome.blockedReason) {
2680
- throw new Error(`TaskCreated hook error: ${outcome.blockedReason}`);
2681
- }
2682
- },
2683
- completed: async (task, taskSignal) => {
2684
- const outcome = await this.options.hooks?.run({
2685
- ...hookSession,
2686
- hook_event_name: 'TaskCompleted',
2687
- task_id: task.id,
2688
- task_subject: task.subject,
2689
- task_description: task.description,
2690
- }, undefined, taskSignal);
2691
- if (!outcome)
2692
- return;
2693
- await recordHookOutcome(outcome);
2694
- if (outcome.blockedReason) {
2695
- throw new Error(`TaskCompleted hook error: ${outcome.blockedReason}`);
2696
- }
2697
- },
3189
+ costUsd: (usage) => {
3190
+ const pricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
3191
+ return pricing ? usageCostUsd(usage, pricing) : undefined;
2698
3192
  },
2699
3193
  }
2700
3194
  : {}),
2701
- })
2702
- : null;
2703
- const scheduledTools = this.scheduledPrompts &&
2704
- this.options.tools &&
2705
- scheduledToolNames.length > 0
2706
- ? new ClaudeScheduledToolRegistry({
2707
- base: taskTools ?? this.options.tools,
2708
- manager: this.scheduledPrompts,
2709
- sessionId,
2710
- enabledTools: scheduledToolNames,
2711
- dataPlane: this.options.dataPlane ?? 'claude',
2712
- })
2713
- : null;
2714
- const baseTools = scheduledTools ?? taskTools ?? this.options.tools;
2715
- const turnPermissions = this.options.interactiveTools?.permissions(sessionId) ??
2716
- this.options.permissions;
2717
- const subagentExecutor = (this.options.enableSubagents || this.options.enableWorkflows) &&
2718
- baseTools &&
2719
- turnPermissions
2720
- ? new ClaudeSubagentExecutor({
2721
- configRoot: this.options.configRoot,
2722
- dataPlane: this.options.dataPlane ?? 'claude',
2723
- cwd: this.activeCwd(),
2724
- cwdProvider: () => this.activeCwd(),
2725
- claudeVersion: this.options.claudeVersion,
2726
- provider,
2727
- persistence: this.options.sessionPersistence === false ? 'memory' : 'disk',
2728
- ...(this.options.providerForModel
2729
- ? { providerForModel: this.options.providerForModel }
2730
- : {}),
2731
- baseTools,
2732
- permissions: turnPermissions,
2733
- ...(this.options.permissionResolverForMode
2734
- ? {
2735
- permissionResolverForMode: this.options.permissionResolverForMode,
2736
- }
2737
- : {}),
2738
- parentPermissionMode: () => agentPermissionMode(this.options.interactiveTools?.mode(sessionId) ??
2739
- this.options.permissionMode),
2740
- ...(this.options.subagentToolNames
2741
- ? {
2742
- toolNames: this.capabilityToolNames(this.options.subagentToolNames, capabilities),
2743
- }
2744
- : {}),
2745
- ...(this.options.extensions
2746
- ? { extensions: this.options.extensions }
2747
- : {}),
2748
- ...(this.options.mcp ? { mcp: this.options.mcp } : {}),
2749
- ...(this.options.hooks ? { hooks: this.options.hooks } : {}),
2750
- ...(this.options.contextAssembler
2751
- ? { contextAssembler: this.options.contextAssembler }
2752
- : {}),
2753
- ...((this.options.contextBudget?.reserveTokens ??
2754
- this.options.contextReserveTokens) === undefined
3195
+ ...(this.options.maxBudgetUsd === undefined
2755
3196
  ? {}
3197
+ : { maxBudgetUsd: this.options.maxBudgetUsd }),
3198
+ ...(hookTools
3199
+ ? { tools: hookTools, permissions: hookTools }
2756
3200
  : {
2757
- contextReserveTokens: this.options.contextBudget?.reserveTokens ??
2758
- this.options.contextReserveTokens,
3201
+ ...(structuredTools ? { tools: structuredTools } : {}),
3202
+ ...(turnPermissions ? { permissions: turnPermissions } : {}),
2759
3203
  }),
2760
- ...(this.options.approveTool
2761
- ? { approveTool: this.options.approveTool }
2762
- : {}),
2763
- permissionUpdates: () => this.sessionPermissionUpdates.get(sessionId) ?? [],
2764
- onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
2765
- onLineChanges: (changes) => {
2766
- const tracker = this.sessionCostTrackers.get(sessionId);
2767
- if (!tracker) {
2768
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
2769
- }
2770
- tracker.recordLineChanges(changes);
3204
+ });
3205
+ let currentTurnUserMessages = null;
3206
+ let currentTurnToolCalls = 0;
3207
+ let projectMemoryMaintained = false;
3208
+ let projectMemoryRecallMessages = [];
3209
+ const observer = {
3210
+ assistantCompleted: async (message) => {
3211
+ const [entry] = translateProviderEvents([
3212
+ {
3213
+ type: 'assistant-message',
3214
+ text: message.content,
3215
+ ...(message.thinkingBlocks
3216
+ ? { thinkingBlocks: message.thinkingBlocks }
3217
+ : {}),
3218
+ toolCalls: message.toolCalls ?? [],
3219
+ providerMessageId: `msg_${randomUUID().replaceAll('-', '')}`,
3220
+ model: provider.model ?? 'praxis/provider',
3221
+ },
3222
+ ], this.translationContext(sessionId, snapshot));
3223
+ if (!entry)
3224
+ throw new Error('Could not translate assistant response');
3225
+ const tail = await this.append(lease, snapshot.tail, entry);
3226
+ snapshot = { entries: [...snapshot.entries, entry], tail };
3227
+ lastAssistantUuid =
3228
+ typeof entry.uuid === 'string' ? entry.uuid : lastAssistantUuid;
2771
3229
  },
2772
- ...(this.options.eventSink
2773
- ? { eventSink: this.options.eventSink }
2774
- : {}),
2775
- ...(taskTools
2776
- ? {
2777
- backgroundTaskNotifications: (waitForRunning) => taskTools.notifications(waitForRunning),
2778
- }
2779
- : {}),
2780
- })
2781
- : null;
2782
- const agentTools = subagentExecutor
2783
- ? subagentExecutor.registry(sessionId, 0, (callId) => currentPromptId ??
2784
- this.promptIdForToolCall(snapshot.entries, callId))
2785
- : baseTools;
2786
- const turnTools = this.workflowManager && subagentExecutor && agentTools
2787
- ? new ClaudeWorkflowToolRegistry({
2788
- base: agentTools,
2789
- manager: this.workflowManager,
2790
- executor: subagentExecutor,
2791
- cwd: this.activeCwd(),
2792
- cwdProvider: () => this.activeCwd(),
2793
- configRoot: this.options.configRoot,
2794
- sessionId,
2795
- promptIdForCall: (callId) => currentPromptId ??
2796
- this.promptIdForToolCall(snapshot.entries, callId),
2797
- defaultModel: provider.model ?? 'praxis/provider',
2798
- tokenBudget: workflowTokenTarget(effectivePrompt),
2799
- enabled: capabilities.has('Workflow'),
2800
- dataPlane: this.options.dataPlane ?? 'claude',
2801
- })
2802
- : agentTools;
2803
- const workspaceTools = this.worktreeManager &&
2804
- turnTools &&
2805
- this.options.workspace &&
2806
- (this.options.worktreeToolNames?.length ?? 0) > 0
2807
- ? new ClaudeWorktreeToolRegistry({
2808
- base: turnTools,
2809
- manager: this.worktreeManager,
2810
- workspace: this.options.workspace,
2811
- enabledTools: this.options.worktreeToolNames ?? [],
2812
- dataPlane: this.options.dataPlane ?? 'claude',
2813
- })
2814
- : turnTools;
2815
- const messageTools = this.options.brief && workspaceTools
2816
- ? new ClaudeUserMessageToolRegistry(workspaceTools, (message) => this.options.eventSink?.({
2817
- type: 'user-message',
2818
- message: message.message,
2819
- status: message.status,
2820
- ...(message.attachments.length
2821
- ? { attachments: message.attachments }
2822
- : {}),
2823
- }))
2824
- : workspaceTools;
2825
- const interactiveMessageTools = this.options.interactiveTools && messageTools
2826
- ? this.options.interactiveTools.registry(messageTools, sessionId)
2827
- : messageTools;
2828
- const fileHistoryTools = fileHistory && interactiveMessageTools
2829
- ? {
2830
- definitions: () => interactiveMessageTools.definitions(),
2831
- schedulingPolicy: (call) => ({
2832
- ...resolveToolSchedulingPolicy(interactiveMessageTools, call),
2833
- startAfterAssistant: true,
2834
- }),
2835
- prepare: (call, context) => interactiveMessageTools.prepare(call, context),
2836
- execute: async (call, context) => {
2837
- const path = call.name === 'Write' || call.name === 'Edit'
2838
- ? call.input.file_path
2839
- : call.name === 'NotebookEdit'
2840
- ? call.input.notebook_path
2841
- : undefined;
2842
- if (typeof path !== 'string') {
2843
- return interactiveMessageTools.execute(call, context);
2844
- }
2845
- if ((call.name === 'Write' || call.name === 'Edit') &&
2846
- (await this.options.interactiveTools?.isPlanFile(sessionId, path))) {
2847
- return interactiveMessageTools.execute(call, context);
2848
- }
2849
- const snapshotMessageId = currentPromptId ??
2850
- this.promptIdForToolCall(snapshot.entries, call.id);
2851
- const assistantMessageId = lastAssistantUuid ??
2852
- this.assistantIdForToolCall(snapshot.entries, call.id);
2853
- if (!snapshotMessageId || !assistantMessageId) {
2854
- throw new Error('Claude file history could not link tool call');
2855
- }
2856
- const prepared = await fileHistory.prepareMutation(snapshot.entries, snapshotMessageId, path);
2857
- let result;
2858
- try {
2859
- result = await interactiveMessageTools.execute(call, context);
2860
- }
2861
- catch (error) {
2862
- await prepared.rollback();
2863
- throw error;
2864
- }
2865
- if (result.isError) {
2866
- await prepared.rollback();
2867
- return result;
2868
- }
2869
- const entry = prepared.commit(assistantMessageId);
2870
- if (entry) {
2871
- const tail = await this.append(lease, snapshot.tail, entry);
2872
- snapshot = { entries: [...snapshot.entries, entry], tail };
3230
+ toolCompleted: async (call, toolResult) => {
3231
+ currentTurnToolCalls += 1;
3232
+ const transition = this.worktreeManager?.consumeTransition(call.id);
3233
+ if (transition) {
3234
+ this.options.contextAssembler?.invalidate?.({
3235
+ lifecycleId: sessionId,
3236
+ reason: 'worktree',
3237
+ });
3238
+ const stateEntry = {
3239
+ type: 'worktree-state',
3240
+ worktreeSession: transition.state,
3241
+ sessionId,
3242
+ };
3243
+ const stateTail = await this.append(lease, snapshot.tail, stateEntry);
3244
+ snapshot = {
3245
+ entries: [...snapshot.entries, stateEntry],
3246
+ tail: stateTail,
3247
+ };
2873
3248
  }
2874
- return result;
2875
- },
2876
- }
2877
- : interactiveMessageTools;
2878
- const capabilityTools = fileHistoryTools
2879
- ? new ClaudeCapabilityToolRegistry(fileHistoryTools, capabilities)
2880
- : undefined;
2881
- const structuredCapture = this.options.structuredOutputSchema
2882
- ? { calls: 0, value: undefined }
2883
- : undefined;
2884
- const agentScopedTools = agent && capabilityTools
2885
- ? new FilteredToolRegistry(capabilityTools, {
2886
- tools: mainAgentToolNames(capabilityTools, agent),
2887
- })
2888
- : capabilityTools;
2889
- const structuredTools = this.options.structuredOutputSchema && structuredCapture
2890
- ? new StructuredOutputRegistry(agentScopedTools ?? this.options.tools ?? emptyToolRegistry, this.options.structuredOutputSchema, structuredCapture)
2891
- : agentScopedTools;
2892
- const hookTools = this.options.hooks && structuredTools && turnPermissions
2893
- ? new ClaudeHookToolCoordinator({
2894
- tools: structuredTools,
2895
- permissions: turnPermissions,
2896
- hooks: this.options.hooks,
2897
- session: hookSession,
2898
- recordOutcome: recordHookOutcome,
2899
- ...(this.options.eventSink
2900
- ? {
2901
- warn: (message) => this.options.eventSink?.({ type: 'warning', message }),
3249
+ const permissionMode = this.options.interactiveTools?.consumeTransition(call.id);
3250
+ if (permissionMode) {
3251
+ const modeEntry = {
3252
+ type: 'permission-mode',
3253
+ permissionMode,
3254
+ sessionId,
3255
+ };
3256
+ const modeTail = await this.append(lease, snapshot.tail, modeEntry);
3257
+ snapshot = {
3258
+ entries: [...snapshot.entries, modeEntry],
3259
+ tail: modeTail,
3260
+ };
2902
3261
  }
2903
- : {}),
2904
- deferPreToolUseOutcome: (call) => pendingRecoveryToolCallIds.has(call.id),
2905
- })
2906
- : null;
2907
- const runtime = new AgentRuntime(provider, this.options.eventSink, {
2908
- emitInitialContextState: false,
2909
- ...(this.options.emitToolUseSummaries
2910
- ? {
2911
- generateToolUseSummary: ({ tools, lastAssistantText, signal }) => generateToolUseSummary(provider, tools, signal, lastAssistantText, (metrics) => this.recordAuxiliaryMetrics(sessionId, metrics)),
2912
- }
2913
- : {}),
2914
- ...(this.options.pricing
2915
- ? {
2916
- costUsd: (usage) => {
2917
- const pricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
2918
- return pricing ? usageCostUsd(usage, pricing) : undefined;
2919
- },
2920
- }
2921
- : {}),
2922
- ...(this.options.maxBudgetUsd === undefined
2923
- ? {}
2924
- : { maxBudgetUsd: this.options.maxBudgetUsd }),
2925
- ...(hookTools
2926
- ? { tools: hookTools, permissions: hookTools }
2927
- : {
2928
- ...(structuredTools ? { tools: structuredTools } : {}),
2929
- ...(turnPermissions ? { permissions: turnPermissions } : {}),
2930
- }),
2931
- });
2932
- let currentTurnUserMessages = null;
2933
- let currentTurnToolCalls = 0;
2934
- let projectMemoryMaintained = false;
2935
- let projectMemoryRecallMessages = [];
2936
- const observer = {
2937
- assistantCompleted: async (message) => {
2938
- const [entry] = translateProviderEvents([
2939
- {
2940
- type: 'assistant-message',
2941
- text: message.content,
2942
- ...(message.thinkingBlocks
2943
- ? { thinkingBlocks: message.thinkingBlocks }
2944
- : {}),
2945
- toolCalls: message.toolCalls ?? [],
2946
- providerMessageId: `msg_${randomUUID().replaceAll('-', '')}`,
2947
- model: provider.model ?? 'praxis/provider',
2948
- },
2949
- ], this.translationContext(sessionId, snapshot));
2950
- if (!entry)
2951
- throw new Error('Could not translate assistant response');
2952
- const tail = await this.append(lease, snapshot.tail, entry);
2953
- snapshot = { entries: [...snapshot.entries, entry], tail };
2954
- lastAssistantUuid =
2955
- typeof entry.uuid === 'string' ? entry.uuid : lastAssistantUuid;
2956
- },
2957
- toolCompleted: async (call, toolResult) => {
2958
- currentTurnToolCalls += 1;
2959
- const transition = this.worktreeManager?.consumeTransition(call.id);
2960
- if (transition) {
2961
- this.options.contextAssembler?.invalidate?.({
2962
- lifecycleId: sessionId,
2963
- reason: 'worktree',
2964
- });
2965
- const stateEntry = {
2966
- type: 'worktree-state',
2967
- worktreeSession: transition.state,
2968
- sessionId,
2969
- };
2970
- const stateTail = await this.append(lease, snapshot.tail, stateEntry);
2971
- snapshot = {
2972
- entries: [...snapshot.entries, stateEntry],
2973
- tail: stateTail,
2974
- };
2975
- }
2976
- const permissionMode = this.options.interactiveTools?.consumeTransition(call.id);
2977
- if (permissionMode) {
2978
- const modeEntry = {
2979
- type: 'permission-mode',
2980
- permissionMode,
2981
- sessionId,
2982
- };
2983
- const modeTail = await this.append(lease, snapshot.tail, modeEntry);
2984
- snapshot = {
2985
- entries: [...snapshot.entries, modeEntry],
2986
- tail: modeTail,
2987
- };
2988
- }
2989
- const [entry] = translateProviderEvents([
2990
- {
2991
- type: 'tool-result',
2992
- toolCallId: call.id,
2993
- content: toolResult.content,
2994
- ...(toolResult.contentBlocks
2995
- ? { contentBlocks: toolResult.contentBlocks }
2996
- : {}),
2997
- ...(toolResult.images ? { images: toolResult.images } : {}),
2998
- ...(toolResult.documents
2999
- ? { documents: toolResult.documents }
3000
- : {}),
3001
- isError: toolResult.isError,
3002
- ...(toolResult.nativeToolUseResult
3003
- ? { nativeToolUseResult: toolResult.nativeToolUseResult }
3004
- : {}),
3005
- ...(toolResult.nativeMcpMeta
3006
- ? { nativeMcpMeta: toolResult.nativeMcpMeta }
3007
- : {}),
3008
- },
3009
- ], this.translationContext(sessionId, snapshot));
3010
- if (!entry)
3011
- throw new Error('Could not translate tool result');
3012
- const tail = await this.append(lease, snapshot.tail, entry);
3013
- snapshot = { entries: [...snapshot.entries, entry], tail };
3014
- if (!toolResult.isError && this.options.projectMemoryDirectory) {
3015
- const pathValue = call.input.file_path;
3016
- const path = typeof pathValue === 'string'
3017
- ? resolve(this.activeCwd(), pathValue)
3018
- : null;
3019
- if (path &&
3020
- isPathWithin(this.options.projectMemoryDirectory, path)) {
3021
- if (call.name === 'Read') {
3022
- this.options.projectMemoryRecall?.recordRead(sessionId, path);
3262
+ const [entry] = translateProviderEvents([
3263
+ {
3264
+ type: 'tool-result',
3265
+ toolCallId: call.id,
3266
+ content: toolResult.content,
3267
+ ...(toolResult.contentBlocks
3268
+ ? { contentBlocks: toolResult.contentBlocks }
3269
+ : {}),
3270
+ ...(toolResult.images ? { images: toolResult.images } : {}),
3271
+ ...(toolResult.documents
3272
+ ? { documents: toolResult.documents }
3273
+ : {}),
3274
+ isError: toolResult.isError,
3275
+ ...(toolResult.nativeToolUseResult
3276
+ ? { nativeToolUseResult: toolResult.nativeToolUseResult }
3277
+ : {}),
3278
+ ...(toolResult.nativeMcpMeta
3279
+ ? { nativeMcpMeta: toolResult.nativeMcpMeta }
3280
+ : {}),
3281
+ },
3282
+ ], this.translationContext(sessionId, snapshot));
3283
+ if (!entry)
3284
+ throw new Error('Could not translate tool result');
3285
+ const tail = await this.append(lease, snapshot.tail, entry);
3286
+ snapshot = { entries: [...snapshot.entries, entry], tail };
3287
+ if (!toolResult.isError && this.options.projectMemoryDirectory) {
3288
+ const pathValue = call.input.file_path;
3289
+ const path = typeof pathValue === 'string'
3290
+ ? resolve(this.activeCwd(), pathValue)
3291
+ : null;
3292
+ if (path &&
3293
+ isPathWithin(this.options.projectMemoryDirectory, path)) {
3294
+ if (call.name === 'Read') {
3295
+ this.options.projectMemoryRecall?.recordRead(sessionId, path);
3296
+ }
3297
+ else if (call.name === 'Write' || call.name === 'Edit') {
3298
+ projectMemoryMaintained = true;
3299
+ }
3023
3300
  }
3024
- else if (call.name === 'Write' || call.name === 'Edit') {
3025
- projectMemoryMaintained = true;
3301
+ if (call.name === 'Read') {
3302
+ for (const accessedPath of toolResult.accessedPaths ?? []) {
3303
+ const resolvedAccessedPath = resolve(this.activeCwd(), accessedPath);
3304
+ if (isPathWithin(this.options.projectMemoryDirectory, resolvedAccessedPath)) {
3305
+ this.options.projectMemoryRecall?.recordRead(sessionId, resolvedAccessedPath);
3306
+ }
3307
+ }
3026
3308
  }
3027
3309
  }
3028
- if (call.name === 'Read') {
3029
- for (const accessedPath of toolResult.accessedPaths ?? []) {
3030
- const resolvedAccessedPath = resolve(this.activeCwd(), accessedPath);
3031
- if (isPathWithin(this.options.projectMemoryDirectory, resolvedAccessedPath)) {
3032
- this.options.projectMemoryRecall?.recordRead(sessionId, resolvedAccessedPath);
3033
- }
3310
+ if (toolResult.isError ||
3311
+ call.name !== 'Read' ||
3312
+ !this.options.conditionalRuleResolver ||
3313
+ !toolResult.accessedPaths) {
3314
+ return;
3315
+ }
3316
+ const attachedRulePaths = this.attachedRulePaths(snapshot.entries);
3317
+ for (const filePath of toolResult.accessedPaths) {
3318
+ const rules = await this.options.conditionalRuleResolver.resolve(filePath, [...attachedRulePaths]);
3319
+ for (const rule of rules) {
3320
+ const attachment = createClaudeRuleAttachmentEntry(rule, this.displayRulePath(rule.path), this.translationContext(sessionId, snapshot));
3321
+ const attachmentTail = await this.append(lease, snapshot.tail, attachment);
3322
+ snapshot = {
3323
+ entries: [...snapshot.entries, attachment],
3324
+ tail: attachmentTail,
3325
+ };
3326
+ attachedRulePaths.add(rule.path);
3327
+ await this.instructionLoaded(sessionId, {
3328
+ path: rule.path,
3329
+ memoryType: rule.scope === 'user'
3330
+ ? 'User'
3331
+ : rule.scope === 'local'
3332
+ ? 'Local'
3333
+ : 'Project',
3334
+ globs: rule.globs,
3335
+ triggerFilePath: filePath,
3336
+ ...(rule.importedFrom === undefined
3337
+ ? {}
3338
+ : { parentFilePath: rule.importedFrom }),
3339
+ }, 'path_glob_match');
3034
3340
  }
3035
3341
  }
3036
- }
3037
- if (toolResult.isError ||
3038
- call.name !== 'Read' ||
3039
- !this.options.conditionalRuleResolver ||
3040
- !toolResult.accessedPaths) {
3041
- return;
3042
- }
3043
- const attachedRulePaths = this.attachedRulePaths(snapshot.entries);
3044
- for (const filePath of toolResult.accessedPaths) {
3045
- const rules = await this.options.conditionalRuleResolver.resolve(filePath, [...attachedRulePaths]);
3046
- for (const rule of rules) {
3047
- const attachment = createClaudeRuleAttachmentEntry(rule, this.displayRulePath(rule.path), this.translationContext(sessionId, snapshot));
3048
- const attachmentTail = await this.append(lease, snapshot.tail, attachment);
3342
+ },
3343
+ followUpUserMessagesCompleted: async (messages) => {
3344
+ for (const content of messages) {
3345
+ const [followUpEntry] = translateProviderEvents([{ type: 'user-text-block', text: content }], this.translationContext(sessionId, snapshot));
3346
+ if (!followUpEntry) {
3347
+ throw new Error('Could not translate tool follow-up message');
3348
+ }
3349
+ const followUpTail = await this.append(lease, snapshot.tail, followUpEntry);
3049
3350
  snapshot = {
3050
- entries: [...snapshot.entries, attachment],
3051
- tail: attachmentTail,
3351
+ entries: [...snapshot.entries, followUpEntry],
3352
+ tail: followUpTail,
3052
3353
  };
3053
- attachedRulePaths.add(rule.path);
3054
- await this.instructionLoaded(sessionId, {
3055
- path: rule.path,
3056
- memoryType: rule.scope === 'user'
3057
- ? 'User'
3058
- : rule.scope === 'local'
3059
- ? 'Local'
3060
- : 'Project',
3061
- globs: rule.globs,
3062
- triggerFilePath: filePath,
3063
- ...(rule.importedFrom === undefined
3064
- ? {}
3065
- : { parentFilePath: rule.importedFrom }),
3066
- }, 'path_glob_match');
3354
+ currentTurnUserMessages?.push(content);
3355
+ await Promise.all(this.sessionSubagentExecutors(sessionId, true).map((executor) => executor.acknowledgeNotifications([content])));
3067
3356
  }
3357
+ },
3358
+ };
3359
+ let turnCompleted = false;
3360
+ try {
3361
+ const outcome = await this.hookLifecycle.start(sessionId, hookSession, requireExisting ? 'resume' : 'startup', signal);
3362
+ if (outcome) {
3363
+ await recordHookOutcome(outcome, pendingRecoveryToolCallIds.size > 0);
3068
3364
  }
3069
- },
3070
- followUpUserMessagesCompleted: async (messages) => {
3071
- for (const content of messages) {
3072
- const [followUpEntry] = translateProviderEvents([{ type: 'user-text-block', text: content }], this.translationContext(sessionId, snapshot));
3073
- if (!followUpEntry) {
3074
- throw new Error('Could not translate tool follow-up message');
3075
- }
3076
- const followUpTail = await this.append(lease, snapshot.tail, followUpEntry);
3365
+ const approveRecovery = this.options.approveRecovery;
3366
+ const recoveryRequest = {
3367
+ cwd: this.activeCwd(),
3368
+ toolResultDirectory,
3369
+ messages: projectClaudeModelMessages(snapshot.entries),
3370
+ observer,
3371
+ permissionUpdates: this.sessionPermissionUpdates.get(sessionId) ?? [],
3372
+ onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
3373
+ ...(signal ? { signal } : {}),
3374
+ ...(approveRecovery
3375
+ ? {
3376
+ approveRecovery: async (call) => {
3377
+ if (!(await approveRecovery(call))) {
3378
+ throw new Error(`Claude session tool call ${call.id} recovery was declined`);
3379
+ }
3380
+ if (signal?.aborted)
3381
+ throw new AgentRunCancelledError();
3382
+ await flushRecoveryHookOutcomes();
3383
+ pendingRecoveryToolCallIds.delete(call.id);
3384
+ return true;
3385
+ },
3386
+ approveTool: () => true,
3387
+ }
3388
+ : {}),
3389
+ };
3390
+ const unresolvedToolCall = unresolvedToolCalls[0];
3391
+ if (unresolvedToolCall && !approveRecovery) {
3392
+ throw new Error(`Claude session tool call ${unresolvedToolCall.id} requires explicit recovery approval`);
3393
+ }
3394
+ const recoveryResults = await runtime.recoverToolCalls(unresolvedToolCalls, recoveryRequest);
3395
+ const recoveryUsage = recoveryResults.reduce((usage, result) => result.usage ? mergeUsage(usage, result.usage) : usage, { inputTokens: 0, outputTokens: 0 });
3396
+ const recoveryModelUsage = mergeSessionRawModelUsage(...recoveryResults.map((result) => result.isError ? undefined : result.modelUsage));
3397
+ const foregroundLineChanges = createLineCountAccumulator();
3398
+ for (const result of recoveryResults) {
3399
+ foregroundLineChanges.add(result);
3400
+ }
3401
+ if (this.options.agent &&
3402
+ agent &&
3403
+ getClaudeAgentSetting(snapshot.entries) !== this.options.agent) {
3404
+ const agentSetting = createClaudeAgentSettingEntry(sessionId, this.options.agent);
3405
+ const settingTail = await this.append(lease, snapshot.tail, agentSetting);
3077
3406
  snapshot = {
3078
- entries: [...snapshot.entries, followUpEntry],
3079
- tail: followUpTail,
3407
+ entries: [...snapshot.entries, agentSetting],
3408
+ tail: settingTail,
3080
3409
  };
3081
- currentTurnUserMessages?.push(content);
3082
3410
  }
3083
- },
3084
- };
3085
- let turnCompleted = false;
3086
- try {
3087
- const outcome = await this.hookLifecycle.start(sessionId, hookSession, requireExisting ? 'resume' : 'startup', signal);
3088
- if (outcome) {
3089
- await recordHookOutcome(outcome, pendingRecoveryToolCallIds.size > 0);
3090
- }
3091
- const approveRecovery = this.options.approveRecovery;
3092
- const recoveryRequest = {
3093
- cwd: this.activeCwd(),
3094
- toolResultDirectory,
3095
- messages: projectClaudeModelMessages(snapshot.entries),
3096
- observer,
3097
- permissionUpdates: this.sessionPermissionUpdates.get(sessionId) ?? [],
3098
- onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
3099
- ...(signal ? { signal } : {}),
3100
- ...(approveRecovery
3101
- ? {
3102
- approveRecovery: async (call) => {
3103
- if (!(await approveRecovery(call))) {
3104
- throw new Error(`Claude session tool call ${call.id} recovery was declined`);
3105
- }
3106
- if (signal?.aborted)
3107
- throw new AgentRunCancelledError();
3108
- await flushRecoveryHookOutcomes();
3109
- pendingRecoveryToolCallIds.delete(call.id);
3110
- return true;
3411
+ const sessionMemory = this.sessionMemoryController(sessionId);
3412
+ let agentSystem = null;
3413
+ let planModeMessage;
3414
+ let sessionMemoryMessage = null;
3415
+ let contextMessages = [];
3416
+ let contextProjection = {
3417
+ systemMessages: [],
3418
+ stableSystemSectionCount: 0,
3419
+ };
3420
+ let stableSystemMessageCount = 0;
3421
+ const refreshRuntimeContext = async () => {
3422
+ agentSystem = await this.mainAgentSystemPrompt(agent);
3423
+ planModeMessage =
3424
+ this.options.interactiveTools?.contextMessage(sessionId);
3425
+ sessionMemoryMessage = sessionMemory
3426
+ ? this.sessionMemoryMessage(await sessionMemory.summary())
3427
+ : null;
3428
+ const assembledContext = await assembleContextSnapshot(this.options.contextAssembler, {
3429
+ cwd: this.activeCwd(),
3430
+ lifecycleId: sessionId,
3431
+ ...(agentSystem
3432
+ ? { mode: 'agent', baseSystemPrompt: agentSystem }
3433
+ : {}),
3434
+ turn: {
3435
+ ...(planModeMessage ? { planMode: planModeMessage } : {}),
3436
+ ...(sessionMemoryMessage
3437
+ ? { sessionMemory: sessionMemoryMessage }
3438
+ : {}),
3439
+ ...(this.options.brief ? { briefOutput: true } : {}),
3440
+ ...(this.options.structuredOutputSchema
3441
+ ? { structuredOutput: true }
3442
+ : {}),
3111
3443
  },
3112
- approveTool: () => true,
3113
- }
3114
- : {}),
3115
- };
3116
- const unresolvedToolCall = unresolvedToolCalls[0];
3117
- if (unresolvedToolCall && !approveRecovery) {
3118
- throw new Error(`Claude session tool call ${unresolvedToolCall.id} requires explicit recovery approval`);
3119
- }
3120
- const recoveryResults = await runtime.recoverToolCalls(unresolvedToolCalls, recoveryRequest);
3121
- const recoveryUsage = recoveryResults.reduce((usage, result) => result.usage ? mergeUsage(usage, result.usage) : usage, { inputTokens: 0, outputTokens: 0 });
3122
- const recoveryModelUsage = mergeSessionRawModelUsage(...recoveryResults.map((result) => result.isError ? undefined : result.modelUsage));
3123
- const foregroundLineChanges = createLineCountAccumulator();
3124
- for (const result of recoveryResults) {
3125
- foregroundLineChanges.add(result);
3126
- }
3127
- if (this.options.agent &&
3128
- agent &&
3129
- getClaudeAgentSetting(snapshot.entries) !== this.options.agent) {
3130
- const agentSetting = createClaudeAgentSettingEntry(sessionId, this.options.agent);
3131
- const settingTail = await this.append(lease, snapshot.tail, agentSetting);
3132
- snapshot = {
3133
- entries: [...snapshot.entries, agentSetting],
3134
- tail: settingTail,
3444
+ });
3445
+ contextProjection = projectContextSnapshot(assembledContext);
3446
+ stableSystemMessageCount =
3447
+ contextProjection.stableSystemSectionCount;
3448
+ contextMessages = [...contextProjection.systemMessages];
3135
3449
  };
3136
- }
3137
- const sessionMemory = this.sessionMemoryController(sessionId);
3138
- let assembledContext;
3139
- let agentSystem = null;
3140
- let planModeMessage;
3141
- let sessionMemoryMessage = null;
3142
- let contextMessages = [];
3143
- let stableSystemMessageCount;
3144
- const refreshRuntimeContext = async () => {
3145
- agentSystem = await this.mainAgentSystemPrompt(agent);
3146
- planModeMessage =
3147
- this.options.interactiveTools?.contextMessage(sessionId);
3148
- sessionMemoryMessage = sessionMemory
3149
- ? this.sessionMemoryMessage(await sessionMemory.summary())
3150
- : null;
3151
- const additionalSections = [
3152
- ...(planModeMessage
3153
- ? [
3154
- {
3155
- id: 'plan-mode',
3156
- placement: 'system',
3157
- stability: 'volatile',
3158
- content: planModeMessage,
3159
- },
3160
- ]
3161
- : []),
3162
- ...(sessionMemoryMessage
3163
- ? [
3164
- {
3165
- id: 'session-memory',
3166
- placement: 'system',
3167
- stability: 'volatile',
3168
- content: sessionMemoryMessage,
3169
- },
3170
- ]
3171
- : []),
3172
- ...(this.options.brief
3173
- ? [
3174
- {
3175
- id: 'brief-output',
3176
- placement: 'system',
3177
- stability: 'volatile',
3178
- content: CLAUDE_USER_MESSAGE_PROMPT,
3179
- },
3180
- ]
3181
- : []),
3182
- ...(this.options.structuredOutputSchema
3183
- ? [
3184
- {
3185
- id: 'structured-output',
3186
- placement: 'system',
3187
- stability: 'volatile',
3188
- content: 'You MUST call StructuredOutput exactly once at the end with a value matching the requested JSON Schema.',
3189
- },
3190
- ]
3191
- : []),
3192
- ];
3193
- assembledContext = await this.options.contextAssembler?.assemble({
3194
- cwd: this.activeCwd(),
3195
- lifecycleId: sessionId,
3196
- ...(agentSystem
3197
- ? { mode: 'agent', baseSystemPrompt: agentSystem }
3198
- : {}),
3199
- additionalSections,
3450
+ await refreshRuntimeContext();
3451
+ const expansion = skipUserPrompt
3452
+ ? { userMessages: [] }
3453
+ : shellCommand === undefined
3454
+ ? this.options.extensions
3455
+ ? await this.options.extensions.expandPromptAsync(effectivePrompt, signal, toolResultDirectory)
3456
+ : { userMessages: [effectivePrompt] }
3457
+ : {
3458
+ userMessages: [`<bash-input>${shellCommand}</bash-input>`],
3459
+ };
3460
+ const expansionMessages = 'messages' in expansion ? expansion.messages : undefined;
3461
+ const userMessages = 'userMessages' in expansion ? expansion.userMessages : [];
3462
+ const expandedMessages = expansionMessages ?? userMessages.map((text) => ({ text }));
3463
+ const attachmentIndex = expansionMessages
3464
+ ? expandedMessages.length - 1
3465
+ : 0;
3466
+ currentTurnUserMessages = [...expansion.userMessages];
3467
+ this.options.eventSink?.({
3468
+ type: 'state',
3469
+ state: 'assembling-context',
3200
3470
  });
3201
- const hasPromptManifest = assembledContext?.promptSections !== undefined;
3202
- stableSystemMessageCount = hasPromptManifest
3203
- ? assembledContext?.stableSystemSectionCount
3204
- : undefined;
3205
- const assembledSystemMessages = hasPromptManifest
3206
- ? (assembledContext?.systemMessages ?? [])
3207
- : this.assembledSystemMessages(agent, assembledContext?.systemMessages ?? []);
3208
- contextMessages = [
3209
- ...(!hasPromptManifest && agentSystem
3210
- ? [{ role: 'system', content: agentSystem }]
3211
- : []),
3212
- ...assembledSystemMessages,
3213
- ...(!hasPromptManifest && planModeMessage
3214
- ? [{ role: 'system', content: planModeMessage }]
3215
- : []),
3216
- ...(!hasPromptManifest && sessionMemoryMessage
3217
- ? [{ role: 'system', content: sessionMemoryMessage }]
3218
- : []),
3219
- ...(!hasPromptManifest && this.options.brief
3220
- ? [
3221
- {
3222
- role: 'system',
3223
- content: CLAUDE_USER_MESSAGE_PROMPT,
3224
- },
3225
- ]
3226
- : []),
3227
- ...(!hasPromptManifest && this.options.structuredOutputSchema
3228
- ? [
3229
- {
3230
- role: 'system',
3231
- content: 'You MUST call StructuredOutput exactly once at the end with a value matching the requested JSON Schema.',
3232
- },
3233
- ]
3234
- : []),
3235
- ];
3236
- };
3237
- await refreshRuntimeContext();
3238
- const expansion = skipUserPrompt
3239
- ? { userMessages: [] }
3240
- : shellCommand === undefined
3241
- ? this.options.extensions
3242
- ? await this.options.extensions.expandPromptAsync(effectivePrompt, signal, toolResultDirectory)
3243
- : { userMessages: [effectivePrompt] }
3244
- : {
3245
- userMessages: [`<bash-input>${shellCommand}</bash-input>`],
3246
- };
3247
- const expandedMessages = expansion.messages ?? expansion.userMessages.map((text) => ({ text }));
3248
- const attachmentIndex = expansion.messages
3249
- ? expandedMessages.length - 1
3250
- : 0;
3251
- currentTurnUserMessages = [...expansion.userMessages];
3252
- this.options.eventSink?.({
3253
- type: 'state',
3254
- state: 'assembling-context',
3255
- });
3256
- let compactionUsage = {
3257
- inputTokens: 0,
3258
- outputTokens: 0,
3259
- };
3260
- let compactionDurationMs;
3261
- let compactionDurationWithoutRetriesMs;
3262
- let compactionModelUsage;
3263
- const definitions = provider.capabilities.tools
3264
- ? (structuredTools?.definitions() ?? [])
3265
- : [];
3266
- const budget = this.contextBudget(provider);
3267
- const recoveryPlanner = new ContextRecoveryPlanner();
3268
- const pendingUserMessages = expandedMessages.map((message, index) => ({
3269
- role: 'user',
3270
- content: message.text,
3271
- ...(message.contentBlocks?.length
3272
- ? { contentBlocks: message.contentBlocks }
3273
- : {}),
3274
- ...((index === attachmentIndex && images.length > 0) ||
3275
- message.images?.length
3276
- ? {
3277
- images: [
3278
- ...(index === attachmentIndex ? images : []),
3279
- ...(message.images ?? []),
3280
- ],
3281
- }
3282
- : {}),
3283
- ...(index === attachmentIndex && documents.length > 0
3284
- ? { documents }
3285
- : {}),
3286
- }));
3287
- const agentMentionMessages = shellCommand === undefined && !skipUserPrompt
3288
- ? (this.options.extensions?.agentMentionMessages(effectivePrompt) ??
3289
- [])
3290
- : [];
3291
- const injectAgentMentionContext = (messages) => {
3292
- if (agentMentionMessages.length === 0)
3293
- return [...messages];
3294
- let insertionIndex = messages.length;
3295
- let foundPrompt = false;
3296
- for (let index = messages.length - 1; index >= 0; index -= 1) {
3297
- const message = messages[index];
3298
- if (message?.role === 'user' &&
3299
- typeof message.content === 'string' &&
3300
- message.content.endsWith(effectivePrompt)) {
3301
- insertionIndex = index;
3302
- foundPrompt = true;
3303
- break;
3471
+ let compactionUsage = {
3472
+ inputTokens: 0,
3473
+ outputTokens: 0,
3474
+ };
3475
+ let compactionDurationMs;
3476
+ let compactionDurationWithoutRetriesMs;
3477
+ let compactionModelUsage;
3478
+ const definitions = provider.capabilities.tools
3479
+ ? (structuredTools?.definitions() ?? [])
3480
+ : [];
3481
+ const budget = this.contextBudget(provider);
3482
+ const recoveryPlanner = new ContextRecoveryPlanner();
3483
+ const pendingUserMessages = expandedMessages.map((message, index) => ({
3484
+ role: 'user',
3485
+ content: message.text,
3486
+ ...(message.contentBlocks?.length
3487
+ ? { contentBlocks: message.contentBlocks }
3488
+ : {}),
3489
+ ...((index === attachmentIndex && images.length > 0) ||
3490
+ message.images?.length
3491
+ ? {
3492
+ images: [
3493
+ ...(index === attachmentIndex ? images : []),
3494
+ ...(message.images ?? []),
3495
+ ],
3496
+ }
3497
+ : {}),
3498
+ ...(index === attachmentIndex && documents.length > 0
3499
+ ? { documents }
3500
+ : {}),
3501
+ }));
3502
+ const agentMentionMessages = shellCommand === undefined && !skipUserPrompt
3503
+ ? (this.options.extensions?.agentMentionMessages(effectivePrompt) ?? [])
3504
+ : [];
3505
+ const injectAgentMentionContext = (messages) => {
3506
+ if (agentMentionMessages.length === 0)
3507
+ return [...messages];
3508
+ let insertionIndex = messages.length;
3509
+ let foundPrompt = false;
3510
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
3511
+ const message = messages[index];
3512
+ if (message?.role === 'user' &&
3513
+ typeof message.content === 'string' &&
3514
+ message.content.endsWith(effectivePrompt)) {
3515
+ insertionIndex = index;
3516
+ foundPrompt = true;
3517
+ break;
3518
+ }
3304
3519
  }
3305
- }
3306
- if (!foundPrompt)
3307
- return [...messages];
3308
- return [
3309
- ...messages.slice(0, insertionIndex),
3310
- ...agentMentionMessages.map((content) => ({
3311
- role: 'user',
3312
- content,
3313
- })),
3314
- ...messages.slice(insertionIndex),
3315
- ];
3316
- };
3317
- const injectDynamicContext = (messages) => injectFirstUserMessageContext(messages, assembledContext?.firstUserMessageContext);
3318
- const injectTurnContext = (messages) => injectAgentMentionContext(injectDynamicContext(messages));
3319
- let compactionAnchorUuid = this.lastMessageUuid(snapshot.entries);
3320
- const compactIfNeeded = async (pendingMessages = [], preservedUserMessages = [], options = {}) => {
3321
- if (!budget || this.options.autoCompact === false)
3322
- return;
3323
- const historyMessages = [
3324
- ...projectClaudeModelMessages(snapshot.entries),
3325
- ...projectMemoryRecallMessages,
3326
- ];
3327
- const predicted = budget.evaluate([
3328
- ...contextMessages,
3329
- ...injectTurnContext([...historyMessages, ...pendingMessages]),
3330
- ], definitions, options);
3331
- if (!predicted.shouldCompact)
3332
- return;
3333
- const irreducibleMessages = [
3334
- ...contextMessages,
3335
- ...injectTurnContext([
3336
- ...pendingMessages,
3337
- ...preservedUserMessages.map((content) => ({
3520
+ if (!foundPrompt)
3521
+ return [...messages];
3522
+ return [
3523
+ ...messages.slice(0, insertionIndex),
3524
+ ...agentMentionMessages.map((content) => ({
3338
3525
  role: 'user',
3339
3526
  content,
3340
3527
  })),
3341
- ]),
3342
- ];
3343
- const irreducible = budget.evaluate(irreducibleMessages, definitions);
3344
- budget.assertFits(irreducible);
3345
- let logicalParentUuid = compactionAnchorUuid;
3346
- if (!logicalParentUuid || historyMessages.length === 0) {
3347
- budget.assertFits(predicted);
3348
- throw new Error('Cannot compact an empty Claude transcript');
3349
- }
3350
- if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
3351
- throw new Error('Cannot compact a Claude session with unresolved tool calls');
3352
- }
3353
- const preCompact = await this.runAdvisoryHook(sessionId, 'PreCompact', { trigger: 'auto', custom_instructions: null }, 'auto', signal);
3354
- const memorySelection = sessionMemory
3355
- ? await this.selectMemoryPreservedCompact(sessionId, selectClaudeActiveTranscript(snapshot.entries))
3356
- : null;
3357
- const compactorMessages = memorySelection
3358
- ? [
3359
- memorySelection.memoryMessage,
3360
- ...projectClaudeModelMessages(memorySelection.compactedEntries),
3361
- ]
3362
- : historyMessages;
3363
- compactorMessages.push(...successfulHookOutput(preCompact).map((content) => ({
3364
- role: 'user',
3365
- content: `Additional summarization context: ${content}`,
3366
- })));
3367
- if (memorySelection) {
3368
- logicalParentUuid = memorySelection.logicalParentUuid;
3369
- }
3370
- this.options.eventSink?.({ type: 'state', state: 'compacting' });
3371
- const compactEnvelope = budget.evaluate([
3372
- ...irreducibleMessages,
3373
- {
3528
+ ...messages.slice(insertionIndex),
3529
+ ];
3530
+ };
3531
+ const injectDynamicContext = (messages) => injectFirstUserMessageContext(messages, contextProjection.firstUserMessageContext);
3532
+ const injectTurnContext = (messages) => injectAgentMentionContext(injectDynamicContext(messages));
3533
+ 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) => ({
3374
3578
  role: 'user',
3375
- content: formatClaudeCompactSummary(''),
3376
- },
3377
- ], definitions);
3378
- let targetTokens = Math.min(8192, compactEnvelope.availableTokens - compactEnvelope.estimatedTokens);
3379
- if (targetTokens < 1) {
3380
- budget.assertFits(budget.evaluate([
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([
3381
3586
  ...irreducibleMessages,
3382
3587
  {
3383
3588
  role: 'user',
3384
- content: formatClaudeCompactSummary('a'),
3589
+ content: formatClaudeCompactSummary(''),
3385
3590
  },
3386
- ], definitions));
3387
- targetTokens = 1;
3388
- }
3389
- const compacted = await (this.options.compactor ?? new ModelCompactor(provider)).compact({
3390
- messages: compactorMessages,
3391
- targetTokens,
3392
- contextWindowTokens: budget.contextWindowTokens,
3393
- ...(signal ? { signal } : {}),
3394
- });
3395
- const { durationMs: compactedDurationMs, durationWithoutRetriesMs: compactedDurationWithoutRetriesMs, } = requireCompactionDurations(compacted);
3396
- const proposedCompactionDurationMs = (compactionDurationMs ?? 0) + compactedDurationMs;
3397
- if (!Number.isFinite(proposedCompactionDurationMs)) {
3398
- throw new TypeError('compaction durationMs total overflow');
3399
- }
3400
- const proposedCompactionDurationWithoutRetriesMs = (compactionDurationWithoutRetriesMs ?? 0) +
3401
- compactedDurationWithoutRetriesMs;
3402
- if (!Number.isFinite(proposedCompactionDurationWithoutRetriesMs)) {
3403
- throw new TypeError('compaction durationWithoutRetriesMs total overflow');
3404
- }
3405
- const boundaryUuid = randomUUID();
3406
- const summaryUuid = randomUUID();
3407
- const timestamp = new Date().toISOString();
3408
- const preTokens = budget.evaluate([...contextMessages, ...injectTurnContext(historyMessages)], definitions).estimatedTokens;
3409
- const compactEntries = (postTokens) => {
3410
- const uuids = [boundaryUuid, summaryUuid];
3411
- return createClaudeCompactEntries({
3591
+ ], definitions);
3592
+ let targetTokens = Math.min(8192, compactEnvelope.availableTokens - compactEnvelope.estimatedTokens);
3593
+ if (targetTokens < 1) {
3594
+ budget.assertFits(budget.evaluate([
3595
+ ...irreducibleMessages,
3596
+ {
3597
+ role: 'user',
3598
+ content: formatClaudeCompactSummary('a'),
3599
+ },
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({
3626
+ sessionId,
3627
+ logicalParentUuid,
3628
+ summary: compacted.summary,
3629
+ preTokens,
3630
+ postTokens,
3631
+ previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
3632
+ durationMs: compactedDurationMs,
3633
+ cwd: this.activeCwd(),
3634
+ claudeVersion: this.options.claudeVersion,
3635
+ gitBranch: null,
3636
+ ...(memorySelection
3637
+ ? {
3638
+ preservedUuids: memorySelection.preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
3639
+ }
3640
+ : {}),
3641
+ createUuid: () => uuids.shift() ?? randomUUID(),
3642
+ now: () => timestamp,
3643
+ });
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 }), {
3412
3654
  sessionId,
3413
- logicalParentUuid,
3414
- summary: compacted.summary,
3415
- preTokens,
3416
- postTokens,
3417
- previousCumulativeDroppedTokens: getCumulativeDroppedTokens(snapshot.entries),
3418
- durationMs: compactedDurationMs,
3655
+ parentUuid: compactSummaryUuid,
3419
3656
  cwd: this.activeCwd(),
3420
3657
  claudeVersion: this.options.claudeVersion,
3421
3658
  gitBranch: null,
3422
- ...(memorySelection
3423
- ? {
3424
- preservedUuids: memorySelection.preservedEntries.flatMap((entry) => typeof entry.uuid === 'string' ? [entry.uuid] : []),
3425
- }
3426
- : {}),
3427
- createUuid: () => uuids.shift() ?? randomUUID(),
3659
+ history: [...snapshot.entries, ...provisionalEntries],
3660
+ createUuid: () => replayUuids.shift() ?? randomUUID(),
3428
3661
  now: () => timestamp,
3429
3662
  });
3430
- };
3431
- const provisionalEntries = compactEntries(0);
3432
- const compactSummaryUuid = provisionalEntries.at(-1)?.uuid;
3433
- if (typeof compactSummaryUuid !== 'string') {
3434
- throw new Error('Could not create Claude compact summary');
3435
- }
3436
- const replayUuids = preservedUserMessages.map(() => randomUUID());
3437
- const replayEntries = translateProviderEvents(preservedUserMessages.map((text, index) => index === 0
3438
- ? { type: 'user-text', text }
3439
- : { type: 'user-text-block', text }), {
3440
- sessionId,
3441
- parentUuid: compactSummaryUuid,
3442
- cwd: this.activeCwd(),
3443
- claudeVersion: this.options.claudeVersion,
3444
- gitBranch: null,
3445
- history: [...snapshot.entries, ...provisionalEntries],
3446
- createUuid: () => replayUuids.shift() ?? randomUUID(),
3447
- now: () => timestamp,
3448
- });
3449
- const compactedHistory = [
3450
- ...projectClaudeModelMessages([
3451
- ...snapshot.entries,
3452
- ...provisionalEntries,
3663
+ const compactedHistory = [
3664
+ ...projectClaudeModelMessages([
3665
+ ...snapshot.entries,
3666
+ ...provisionalEntries,
3667
+ ...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),
3453
3686
  ...replayEntries,
3454
- ]),
3455
- ...projectMemoryRecallMessages,
3456
- ];
3457
- const afterHistory = budget.evaluate([...contextMessages, ...injectTurnContext(compactedHistory)], definitions);
3458
- const afterPending = budget.evaluate([
3459
- ...contextMessages,
3460
- ...injectTurnContext([...compactedHistory, ...pendingMessages]),
3461
- ], definitions);
3462
- budget.assertFits(afterPending);
3463
- if (options.promptTooLong === true &&
3464
- !contextRecoveryMadeProgress({
3465
- beforeOccupancyTokens: predicted.occupancyTokens,
3466
- afterOccupancyTokens: afterPending.occupancyTokens,
3467
- })) {
3468
- throw new Error('Reactive context compaction made no occupancy progress');
3469
- }
3470
- const entries = [
3471
- ...compactEntries(afterHistory.estimatedTokens),
3472
- ...replayEntries,
3473
- ];
3474
- if (signal?.aborted)
3475
- throw new AgentRunCancelledError();
3476
- // Construct the exact tracker mutations for this single committed
3477
- // boundary and preflight them against a clone of the live tracker so
3478
- // invalid input or cumulative overflow rejects before the compact
3479
- // boundary is appended rather than after a half-commit.
3480
- const tracker = this.sessionCostTrackers.get(sessionId);
3481
- if (!tracker) {
3482
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
3483
- }
3484
- const compactModel = compacted.model !== undefined && compacted.model.trim() !== ''
3485
- ? compacted.model
3486
- : provider.model;
3487
- const compactModelNonBlank = compactModel !== undefined && compactModel.trim() !== '';
3488
- if (hasNonZeroUsage(compacted.usage) && !compactModelNonBlank) {
3489
- throw new Error('Auto compact usage requires a nonblank model identity');
3490
- }
3491
- let meteringTurnInput;
3492
- if (compactModelNonBlank && hasNonZeroUsage(compacted.usage)) {
3493
- const pricing = this.options.pricing?.resolve(compactModel);
3494
- const costUsd = pricing
3495
- ? usageCostUsd(compacted.usage, pricing)
3496
- : undefined;
3497
- meteringTurnInput = {
3498
- model: compactModel,
3499
- usage: compacted.usage,
3500
- ...(costUsd === undefined ? {} : { costUsd }),
3501
- ...(compacted.usage.webSearchRequests === undefined
3502
- ? {}
3503
- : { webSearchRequests: compacted.usage.webSearchRequests }),
3504
- };
3505
- }
3506
- let meteringDurationsInput;
3507
- if (compactedDurationMs > 0 ||
3508
- compactedDurationWithoutRetriesMs > 0) {
3509
- meteringDurationsInput = {
3510
- ...(compactedDurationMs === 0
3511
- ? {}
3512
- : { apiDurationMs: compactedDurationMs }),
3513
- apiDurationWithoutRetriesMs: compactedDurationWithoutRetriesMs,
3514
- };
3515
- }
3516
- if (meteringTurnInput !== undefined ||
3517
- meteringDurationsInput !== undefined) {
3518
- const preflight = new ClaudeSessionCostTracker({
3519
- sessionId,
3520
- restored: tracker.snapshot(),
3521
- });
3522
- if (meteringTurnInput !== undefined) {
3523
- preflight.recordTurn(meteringTurnInput);
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}`);
3524
3697
  }
3525
- if (meteringDurationsInput !== undefined) {
3526
- preflight.recordDurations(meteringDurationsInput);
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');
3527
3704
  }
3528
- }
3529
- const appendResult = await lease.appendMany(snapshot.tail, entries);
3530
- if (appendResult.status === 'conflict') {
3531
- throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
3532
- }
3533
- snapshot = {
3534
- entries: [...snapshot.entries, ...entries],
3535
- tail: appendResult.tail,
3536
- };
3537
- const metadataEntries = createClaudeDurableMetadataSnapshot(snapshot.entries, sessionId);
3538
- if (metadataEntries.length > 0) {
3539
- const metadataAppend = await lease.appendMany(snapshot.tail, metadataEntries);
3540
- if (metadataAppend.status === 'conflict') {
3541
- throw new Error(`Claude metadata snapshot conflict: ${metadataAppend.reason}`);
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);
3738
+ }
3739
+ if (meteringDurationsInput !== undefined) {
3740
+ preflight.recordDurations(meteringDurationsInput);
3741
+ }
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}`);
3542
3746
  }
3543
3747
  snapshot = {
3544
- entries: [...snapshot.entries, ...metadataEntries],
3545
- tail: metadataAppend.tail,
3546
- };
3547
- }
3548
- this.rememberDurableMetadata(sessionId, snapshot.entries);
3549
- await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3550
- // The boundary is durable: mirror Claude's full-compact behavior by
3551
- // rerunning SessionStart with source compact and refreshing the
3552
- // runtime-only context so the next request retains current
3553
- // instructions, plan state, session memory, and hook context.
3554
- if (this.options.hooks) {
3555
- const outcome = await this.hookLifecycle.refresh(sessionId, hookSession, signal);
3556
- if (outcome)
3557
- await recordHookOutcome(outcome);
3558
- }
3559
- this.options.contextAssembler?.invalidate?.({
3560
- lifecycleId: sessionId,
3561
- reason: 'compact',
3562
- });
3563
- this.options.projectMemoryRecall?.recordCompact(sessionId);
3564
- await refreshRuntimeContext();
3565
- this.options.eventSink?.({
3566
- type: 'compact-boundary',
3567
- trigger: 'auto',
3568
- preTokens,
3569
- uuid: boundaryUuid,
3570
- });
3571
- compactionAnchorUuid = compactSummaryUuid;
3572
- compactionUsage = mergeUsage(compactionUsage, compacted.usage);
3573
- compactionDurationMs = proposedCompactionDurationMs;
3574
- compactionDurationWithoutRetriesMs =
3575
- proposedCompactionDurationWithoutRetriesMs;
3576
- if (meteringTurnInput !== undefined) {
3577
- compactionModelUsage = mergeSessionRawModelUsage(compactionModelUsage, { [meteringTurnInput.model]: compacted.usage });
3578
- }
3579
- // Commit the preflighted mutations once the boundary is durable so a
3580
- // later main-provider failure or cancellation cannot lose the
3581
- // compactor's usage/cost/API durations.
3582
- if (meteringTurnInput !== undefined) {
3583
- tracker.recordTurn(meteringTurnInput);
3584
- }
3585
- if (meteringDurationsInput !== undefined) {
3586
- tracker.recordDurations(meteringDurationsInput);
3587
- }
3588
- };
3589
- await compactIfNeeded(pendingUserMessages);
3590
- for (const [index, message] of expandedMessages.entries()) {
3591
- if (shellCommand !== undefined)
3592
- break;
3593
- const messageImages = [
3594
- ...(index === attachmentIndex ? images : []),
3595
- ...(message.images ?? []),
3596
- ];
3597
- const persistenceEvent = messageImages.length > 0 ||
3598
- (index === attachmentIndex && documents.length > 0)
3599
- ? {
3600
- type: 'user-message',
3601
- text: message.text,
3602
- images: messageImages,
3603
- ...(index === attachmentIndex && documents.length > 0
3604
- ? { documents }
3605
- : {}),
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}`);
3756
+ }
3757
+ snapshot = {
3758
+ entries: [...snapshot.entries, ...metadataEntries],
3759
+ tail: metadataAppend.tail,
3760
+ };
3606
3761
  }
3607
- : index === 0
3608
- ? { type: 'user-text', text: message.text }
3609
- : { type: 'user-text-block', text: message.text };
3610
- const [userEntry] = translateProviderEvents([persistenceEvent], this.translationContext(sessionId, snapshot));
3611
- if (!userEntry)
3612
- throw new Error('Could not translate user prompt');
3613
- if (currentPromptId === null && typeof userEntry.uuid === 'string') {
3614
- currentPromptId = userEntry.uuid;
3615
- compactionAnchorUuid ??= userEntry.uuid;
3616
- }
3617
- const userTail = await this.append(lease, snapshot.tail, userEntry);
3618
- snapshot = {
3619
- entries: [...snapshot.entries, userEntry],
3620
- tail: userTail,
3621
- };
3622
- }
3623
- if (fileHistory && currentPromptId) {
3624
- const historySnapshot = await fileHistory.snapshot(snapshot.entries, currentPromptId);
3625
- const historyTail = await this.append(lease, snapshot.tail, historySnapshot);
3626
- snapshot = {
3627
- entries: [...snapshot.entries, historySnapshot],
3628
- tail: historyTail,
3629
- };
3630
- }
3631
- if (this.options.hooks && !skipUserPrompt) {
3632
- const outcome = await this.options.hooks.run({
3633
- ...hookSession,
3634
- hook_event_name: 'UserPromptSubmit',
3635
- prompt_id: currentPromptId ?? randomUUID(),
3636
- prompt: effectivePrompt,
3637
- }, undefined, signal);
3638
- await recordHookOutcome(outcome);
3639
- if (outcome.blockedReason) {
3640
- throw new Error(`UserPromptSubmit hook error: ${outcome.blockedReason}`);
3641
- }
3642
- }
3643
- let shellUsage = { inputTokens: 0, outputTokens: 0 };
3644
- let shellModelUsage;
3645
- if (shellCommand !== undefined) {
3646
- const call = {
3647
- id: `shell_${randomUUID().replaceAll('-', '')}`,
3648
- name: 'Bash',
3649
- input: { command: shellCommand },
3650
- };
3651
- this.options.eventSink?.({
3652
- type: 'shell-command',
3653
- callId: call.id,
3654
- command: shellCommand,
3655
- });
3656
- let shellResult;
3657
- try {
3658
- shellResult = await runtime.executeDirectToolCall(call, {
3659
- cwd: this.activeCwd(),
3660
- sessionId,
3661
- toolResultDirectory,
3662
- messages: projectClaudeModelMessages(snapshot.entries),
3663
- observer: {
3664
- assistantCompleted: async () => undefined,
3665
- toolCompleted: async () => undefined,
3666
- followUpUserMessagesCompleted: observer.followUpUserMessagesCompleted,
3667
- },
3668
- ...(this.options.approveTool
3669
- ? { approveTool: this.options.approveTool }
3670
- : {}),
3671
- permissionUpdates: this.sessionPermissionUpdates.get(sessionId) ?? [],
3672
- onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
3673
- ...(signal ? { signal } : {}),
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',
3674
3776
  });
3675
- }
3676
- catch (error) {
3777
+ this.options.projectMemoryRecall?.recordCompact(sessionId);
3778
+ await refreshRuntimeContext();
3677
3779
  this.options.eventSink?.({
3678
- type: 'shell-cancelled',
3679
- callId: call.id,
3780
+ type: 'compact-boundary',
3781
+ trigger: 'auto',
3782
+ preTokens,
3783
+ uuid: boundaryUuid,
3680
3784
  });
3681
- throw error;
3682
- }
3683
- shellUsage = shellResult.usage ?? shellUsage;
3684
- if (!shellResult.isError) {
3685
- shellModelUsage = shellResult.modelUsage;
3686
- foregroundLineChanges.add(shellResult);
3687
- }
3688
- const stdout = shellResult.processOutput?.stdout ??
3689
- (shellResult.isError ? '' : shellResult.content);
3690
- const stderr = shellResult.processOutput?.stderr ??
3691
- (shellResult.isError ? shellResult.content : '');
3692
- const shellUuids = [shellInputUuid ?? randomUUID(), randomUUID()];
3693
- const [inputEntry, outputEntry] = translateProviderEvents([
3694
- { type: 'bash-input', command: shellCommand },
3695
- { type: 'bash-output', stdout, stderr },
3696
- ], {
3697
- ...this.translationContext(sessionId, snapshot),
3698
- createUuid: () => shellUuids.shift() ?? randomUUID(),
3699
- });
3700
- if (!inputEntry || !outputEntry) {
3701
- throw new Error('Could not translate shell command result');
3702
- }
3703
- const shellAppend = await lease.appendMany(snapshot.tail, [
3704
- inputEntry,
3705
- outputEntry,
3706
- ]);
3707
- if (shellAppend.status === 'conflict') {
3708
- throw new Error(`Claude transcript append conflict: ${shellAppend.reason}`);
3709
- }
3710
- snapshot = {
3711
- entries: [...snapshot.entries, inputEntry, outputEntry],
3712
- tail: shellAppend.tail,
3713
- };
3714
- currentTurnUserMessages.push(`<bash-stdout>${stdout}</bash-stdout><bash-stderr>${stderr}</bash-stderr>`);
3715
- this.options.eventSink?.({
3716
- type: 'shell-result',
3717
- callId: call.id,
3718
- stdout,
3719
- stderr,
3720
- isError: shellResult.isError,
3721
- });
3722
- }
3723
- if (budget) {
3724
- await compactIfNeeded([], currentTurnUserMessages ?? []);
3725
- budget.assertFits(budget.evaluate([
3726
- ...contextMessages,
3727
- ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
3728
- ], definitions));
3729
- }
3730
- let stopHookActive = false;
3731
- const runtimeRequest = {
3732
- sessionId,
3733
- messages: [
3734
- ...contextMessages,
3735
- ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
3736
- ],
3737
- ...(stableSystemMessageCount === undefined
3738
- ? {}
3739
- : { stableSystemMessageCount }),
3740
- cwd: this.activeCwd(),
3741
- toolResultDirectory,
3742
- observer,
3743
- ...(this.options.effort ? { effort: this.options.effort } : {}),
3744
- ...(this.options.maxModelTurns !== undefined
3745
- ? { maxModelTurns: this.options.maxModelTurns }
3746
- : {}),
3747
- ...(this.options.betas?.length ? { betas: this.options.betas } : {}),
3748
- ...(this.options.collectMetrics || this.options.costStateStore
3749
- ? { collectMetrics: true }
3750
- : {}),
3751
- ...(budget
3752
- ? { deferFailureKinds: ['prompt_too_long'] }
3753
- : {}),
3754
- reloadMessages: async () => {
3755
- const recalled = projectMemoryRecallTurn?.consumeIfSettled();
3756
- if (recalled) {
3757
- projectMemoryRecallMessages = [
3758
- { role: 'user', content: recalled.content },
3759
- ];
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 });
3760
3792
  }
3761
- await compactIfNeeded([], currentTurnUserMessages ?? []);
3762
- if (stableSystemMessageCount === undefined) {
3763
- delete runtimeRequest.stableSystemMessageCount;
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);
3764
3798
  }
3765
- else {
3766
- runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
3799
+ if (meteringDurationsInput !== undefined) {
3800
+ tracker.recordDurations(meteringDurationsInput);
3767
3801
  }
3768
- return [
3769
- ...contextMessages,
3770
- ...injectTurnContext([
3771
- ...projectClaudeModelMessages(snapshot.entries),
3772
- ...projectMemoryRecallMessages,
3773
- ]),
3802
+ };
3803
+ await compactIfNeeded(pendingUserMessages);
3804
+ for (const [index, message] of expandedMessages.entries()) {
3805
+ if (shellCommand !== undefined)
3806
+ break;
3807
+ const messageImages = [
3808
+ ...(index === attachmentIndex ? images : []),
3809
+ ...(message.images ?? []),
3774
3810
  ];
3775
- },
3776
- ...(this.options.hooks ||
3777
- subagentExecutor ||
3778
- taskTools ||
3779
- this.scheduledPrompts ||
3780
- this.workflowManager
3781
- ? {
3782
- onStop: async (text) => {
3783
- const messages = [];
3784
- const outcome = await this.options.hooks?.run({
3785
- ...hookSession,
3786
- hook_event_name: 'Stop',
3787
- stop_hook_active: stopHookActive,
3788
- last_assistant_message: text,
3789
- }, undefined, signal);
3790
- if (outcome) {
3791
- await recordHookOutcome(outcome);
3792
- if (outcome.blockedReason) {
3793
- stopHookActive = true;
3794
- messages.push(`Stop hook error: ${outcome.blockedReason}`);
3795
- }
3796
- }
3797
- const background = await subagentExecutor?.notifications(true);
3798
- if (background)
3799
- messages.push(...background.messages);
3800
- const workflow = await this.workflowManager?.notifications(true);
3801
- if (workflow)
3802
- messages.push(...workflow.messages);
3803
- const bashMessages = await taskTools?.notifications(true);
3804
- if (bashMessages)
3805
- messages.push(...bashMessages);
3806
- const scheduled = await this.scheduledPrompts?.drainDue();
3807
- if (scheduled) {
3808
- messages.push(...scheduled.map(({ prompt }) => prompt));
3809
- }
3810
- const batchUsage = background || workflow
3811
- ? mergeUsage(background?.usage ?? {
3812
- inputTokens: 0,
3813
- outputTokens: 0,
3814
- }, workflow?.usage ?? {
3815
- inputTokens: 0,
3816
- outputTokens: 0,
3817
- })
3818
- : undefined;
3819
- const batchModelUsage = mergeSessionRawModelUsage(background?.modelUsage, workflow?.modelUsage);
3820
- const batchDurationApiMs = addApiDuration(workflow?.durationApiMs, addApiDuration(background?.durationApiMs, 0, 'durationApiMs'), 'durationApiMs');
3821
- const backgroundDurationWithoutRetries = background?.durationApiWithoutRetriesMs ??
3822
- background?.durationApiMs;
3823
- const workflowDurationWithoutRetries = workflow?.durationApiWithoutRetriesMs ??
3824
- workflow?.durationApiMs;
3825
- const batchDurationApiWithoutRetriesMs = addApiDuration(workflowDurationWithoutRetries, addApiDuration(backgroundDurationWithoutRetries, 0, 'durationApiWithoutRetriesMs'), 'durationApiWithoutRetriesMs');
3826
- const hasBatchDuration = background?.durationApiMs !== undefined ||
3827
- background?.durationApiWithoutRetriesMs !== undefined ||
3828
- workflow?.durationApiMs !== undefined ||
3829
- workflow?.durationApiWithoutRetriesMs !== undefined;
3830
- return {
3831
- messages,
3832
- ...(batchUsage
3833
- ? {
3834
- usage: batchUsage,
3835
- ...(batchModelUsage
3836
- ? { modelUsage: batchModelUsage }
3837
- : {}),
3838
- }
3839
- : {}),
3840
- ...(hasBatchDuration
3841
- ? {
3842
- durationApiMs: batchDurationApiMs,
3843
- durationApiWithoutRetriesMs: batchDurationApiWithoutRetriesMs,
3844
- }
3845
- : {}),
3846
- };
3847
- },
3811
+ const persistenceEvent = messageImages.length > 0 ||
3812
+ (index === attachmentIndex && documents.length > 0)
3813
+ ? {
3814
+ type: 'user-message',
3815
+ text: message.text,
3816
+ images: messageImages,
3817
+ ...(index === attachmentIndex && documents.length > 0
3818
+ ? { documents }
3819
+ : {}),
3820
+ }
3821
+ : index === 0
3822
+ ? { type: 'user-text', text: message.text }
3823
+ : { type: 'user-text-block', text: message.text };
3824
+ const [userEntry] = translateProviderEvents([persistenceEvent], this.translationContext(sessionId, snapshot));
3825
+ if (!userEntry)
3826
+ throw new Error('Could not translate user prompt');
3827
+ if (currentPromptId === null &&
3828
+ typeof userEntry.uuid === 'string') {
3829
+ currentPromptId = userEntry.uuid;
3830
+ compactionAnchorUuid ??= userEntry.uuid;
3848
3831
  }
3849
- : {}),
3850
- ...(this.options.approveTool
3851
- ? { approveTool: this.options.approveTool }
3852
- : {}),
3853
- permissionUpdates: this.sessionPermissionUpdates.get(sessionId) ?? [],
3854
- onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
3855
- };
3856
- const attemptMainTurn = () => signal
3857
- ? runtime.run({ ...runtimeRequest, signal })
3858
- : runtime.run(runtimeRequest);
3859
- const surfaceExhaustedRecovery = (error) => {
3860
- this.options.eventSink?.({
3861
- type: 'failed',
3862
- message: error.message,
3863
- retryable: false,
3864
- });
3865
- };
3866
- let result;
3867
- try {
3868
- try {
3869
- result = await attemptMainTurn();
3832
+ const userTail = await this.append(lease, snapshot.tail, userEntry);
3833
+ snapshot = {
3834
+ entries: [...snapshot.entries, userEntry],
3835
+ tail: userTail,
3836
+ };
3870
3837
  }
3871
- catch (error) {
3872
- if (!budget || !isPromptTooLongError(error))
3873
- throw error;
3874
- if (recoveryPlanner.reactiveRetriesRemaining === 0) {
3875
- surfaceExhaustedRecovery(error);
3876
- throw error;
3838
+ if (fileHistory && currentPromptId) {
3839
+ const historySnapshot = await fileHistory.snapshot(snapshot.entries, currentPromptId);
3840
+ const historyTail = await this.append(lease, snapshot.tail, historySnapshot);
3841
+ snapshot = {
3842
+ entries: [...snapshot.entries, historySnapshot],
3843
+ tail: historyTail,
3844
+ };
3845
+ }
3846
+ if (this.options.hooks && !skipUserPrompt) {
3847
+ const outcome = await this.options.hooks.run({
3848
+ ...hookSession,
3849
+ hook_event_name: 'UserPromptSubmit',
3850
+ prompt_id: currentPromptId ?? randomUUID(),
3851
+ prompt: effectivePrompt,
3852
+ }, undefined, signal);
3853
+ await recordHookOutcome(outcome);
3854
+ if (outcome.blockedReason) {
3855
+ throw new Error(`UserPromptSubmit hook error: ${outcome.blockedReason}`);
3877
3856
  }
3878
- const beforeRecovery = budget.evaluate(runtimeRequest.messages, definitions);
3857
+ }
3858
+ let shellUsage = { inputTokens: 0, outputTokens: 0 };
3859
+ let shellModelUsage;
3860
+ if (shellCommand !== undefined) {
3861
+ const call = {
3862
+ id: `shell_${randomUUID().replaceAll('-', '')}`,
3863
+ name: 'Bash',
3864
+ input: { command: shellCommand },
3865
+ };
3866
+ this.options.eventSink?.({
3867
+ type: 'shell-command',
3868
+ callId: call.id,
3869
+ command: shellCommand,
3870
+ });
3871
+ let shellResult;
3879
3872
  try {
3880
- await compactIfNeeded([], currentTurnUserMessages ?? [], {
3881
- promptTooLong: true,
3873
+ shellResult = await runtime.executeDirectToolCall(call, {
3874
+ cwd: this.activeCwd(),
3875
+ sessionId,
3876
+ toolResultDirectory,
3877
+ messages: projectClaudeModelMessages(snapshot.entries),
3878
+ observer: {
3879
+ assistantCompleted: async () => undefined,
3880
+ toolCompleted: async () => undefined,
3881
+ followUpUserMessagesCompleted: observer.followUpUserMessagesCompleted,
3882
+ },
3883
+ ...(this.options.approveTool
3884
+ ? { approveTool: this.options.approveTool }
3885
+ : {}),
3886
+ permissionUpdates: this.sessionPermissionUpdates.get(sessionId) ?? [],
3887
+ onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
3888
+ ...(signal ? { signal } : {}),
3882
3889
  });
3883
3890
  }
3884
- catch (compactionError) {
3885
- if (signal?.aborted ||
3886
- compactionError instanceof AgentRunCancelledError) {
3887
- throw new AgentRunCancelledError();
3888
- }
3889
- // Compaction could not free the provider-bounded context; surface
3890
- // the original prompt-too-long error rather than the compaction
3891
- // failure.
3892
- surfaceExhaustedRecovery(error);
3891
+ catch (error) {
3892
+ this.options.eventSink?.({
3893
+ type: 'shell-cancelled',
3894
+ callId: call.id,
3895
+ });
3893
3896
  throw error;
3894
3897
  }
3895
- // The single reactive retry must use the compacted transcript, not
3896
- // the stale request copy captured before the compact boundary.
3897
- runtimeRequest.messages = [
3898
- ...contextMessages,
3899
- ...injectTurnContext([
3900
- ...projectClaudeModelMessages(snapshot.entries),
3901
- ...projectMemoryRecallMessages,
3902
- ]),
3903
- ];
3904
- if (stableSystemMessageCount === undefined) {
3905
- delete runtimeRequest.stableSystemMessageCount;
3898
+ shellUsage = shellResult.usage ?? shellUsage;
3899
+ if (!shellResult.isError) {
3900
+ shellModelUsage = shellResult.modelUsage;
3901
+ foregroundLineChanges.add(shellResult);
3906
3902
  }
3907
- else {
3908
- runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
3903
+ const stdout = shellResult.processOutput?.stdout ??
3904
+ (shellResult.isError ? '' : shellResult.content);
3905
+ const stderr = shellResult.processOutput?.stderr ??
3906
+ (shellResult.isError ? shellResult.content : '');
3907
+ const shellUuids = [shellInputUuid ?? randomUUID(), randomUUID()];
3908
+ const [inputEntry, outputEntry] = translateProviderEvents([
3909
+ { type: 'bash-input', command: shellCommand },
3910
+ { type: 'bash-output', stdout, stderr },
3911
+ ], {
3912
+ ...this.translationContext(sessionId, snapshot),
3913
+ createUuid: () => shellUuids.shift() ?? randomUUID(),
3914
+ });
3915
+ if (!inputEntry || !outputEntry) {
3916
+ throw new Error('Could not translate shell command result');
3909
3917
  }
3910
- const afterRecovery = budget.evaluate(runtimeRequest.messages, definitions);
3911
- if (recoveryPlanner.consumeReactiveRetry({
3912
- beforeOccupancyTokens: beforeRecovery.occupancyTokens,
3913
- afterOccupancyTokens: afterRecovery.occupancyTokens,
3914
- }) !== 'reactive-retry') {
3915
- surfaceExhaustedRecovery(error);
3916
- throw error;
3918
+ const shellAppend = await lease.appendMany(snapshot.tail, [
3919
+ inputEntry,
3920
+ outputEntry,
3921
+ ]);
3922
+ if (shellAppend.status === 'conflict') {
3923
+ throw new Error(`Claude transcript append conflict: ${shellAppend.reason}`);
3917
3924
  }
3918
- runtimeRequest.deferFailureKinds = true;
3925
+ snapshot = {
3926
+ entries: [...snapshot.entries, inputEntry, outputEntry],
3927
+ tail: shellAppend.tail,
3928
+ };
3929
+ currentTurnUserMessages.push(`<bash-stdout>${stdout}</bash-stdout><bash-stderr>${stderr}</bash-stderr>`);
3930
+ this.options.eventSink?.({
3931
+ type: 'shell-result',
3932
+ callId: call.id,
3933
+ stdout,
3934
+ stderr,
3935
+ isError: shellResult.isError,
3936
+ });
3937
+ }
3938
+ if (budget) {
3939
+ await compactIfNeeded([], currentTurnUserMessages ?? []);
3940
+ budget.assertFits(budget.evaluate([
3941
+ ...contextMessages,
3942
+ ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
3943
+ ], definitions));
3944
+ }
3945
+ let stopHookActive = false;
3946
+ const runtimeRequest = {
3947
+ sessionId,
3948
+ messages: [
3949
+ ...contextMessages,
3950
+ ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
3951
+ ],
3952
+ stableSystemMessageCount,
3953
+ cwd: this.activeCwd(),
3954
+ toolResultDirectory,
3955
+ observer,
3956
+ ...(this.options.effort ? { effort: this.options.effort } : {}),
3957
+ ...(this.options.maxModelTurns !== undefined
3958
+ ? { maxModelTurns: this.options.maxModelTurns }
3959
+ : {}),
3960
+ ...(this.options.betas?.length
3961
+ ? { betas: this.options.betas }
3962
+ : {}),
3963
+ ...(this.options.collectMetrics || this.options.costStateStore
3964
+ ? { collectMetrics: true }
3965
+ : {}),
3966
+ ...(budget
3967
+ ? { deferFailureKinds: ['prompt_too_long'] }
3968
+ : {}),
3969
+ reloadMessages: async () => {
3970
+ const recalled = projectMemoryRecallTurn?.consumeIfSettled();
3971
+ if (recalled) {
3972
+ projectMemoryRecallMessages = [
3973
+ { role: 'user', content: recalled.content },
3974
+ ];
3975
+ }
3976
+ await compactIfNeeded([], currentTurnUserMessages ?? []);
3977
+ runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
3978
+ return [
3979
+ ...contextMessages,
3980
+ ...injectTurnContext([
3981
+ ...projectClaudeModelMessages(snapshot.entries),
3982
+ ...projectMemoryRecallMessages,
3983
+ ]),
3984
+ ];
3985
+ },
3986
+ ...(this.options.hooks ||
3987
+ subagentExecutor ||
3988
+ taskTools ||
3989
+ this.scheduledPrompts ||
3990
+ this.workflowManager
3991
+ ? {
3992
+ onStop: async (text) => {
3993
+ const messages = [];
3994
+ const outcome = await this.options.hooks?.run({
3995
+ ...hookSession,
3996
+ hook_event_name: 'Stop',
3997
+ stop_hook_active: stopHookActive,
3998
+ last_assistant_message: text,
3999
+ }, undefined, signal);
4000
+ if (outcome) {
4001
+ await recordHookOutcome(outcome);
4002
+ if (outcome.blockedReason) {
4003
+ stopHookActive = true;
4004
+ messages.push(`Stop hook error: ${outcome.blockedReason}`);
4005
+ }
4006
+ }
4007
+ const claimedAgentIds = new Set(this.sessionSubagentExecutors(sessionId, true).flatMap((executor) => executor.notificationClaimAgentIds()));
4008
+ await Promise.all(this.sessionSubagentExecutors(sessionId, true).map((executor) => {
4009
+ const delivered = (notification) => transcriptContainsBackgroundAgentNotification(snapshot.entries, notification);
4010
+ return this.hostedSubagents.has(executor)
4011
+ ? executor.reconcileDetachedNotifications(delivered)
4012
+ : executor.reconcileDeliveredNotifications(delivered);
4013
+ }));
4014
+ await subagentExecutor?.hydratePersistedTasks(sessionId, this.activeCwd(), claimedAgentIds);
4015
+ const background = await this.collectSubagentNotifications(sessionId, subagentExecutor ?? undefined);
4016
+ if (background)
4017
+ messages.push(...background.messages);
4018
+ const workflow = await this.workflowManager?.notifications(true);
4019
+ if (workflow)
4020
+ messages.push(...workflow.messages);
4021
+ const bashMessages = await taskTools?.notifications(true);
4022
+ if (bashMessages)
4023
+ messages.push(...bashMessages);
4024
+ const scheduled = await this.scheduledPrompts?.drainDue();
4025
+ if (scheduled) {
4026
+ messages.push(...scheduled.map(({ prompt }) => prompt));
4027
+ }
4028
+ const batchUsage = background || workflow
4029
+ ? mergeUsage(background?.usage ?? {
4030
+ inputTokens: 0,
4031
+ outputTokens: 0,
4032
+ }, workflow?.usage ?? {
4033
+ inputTokens: 0,
4034
+ outputTokens: 0,
4035
+ })
4036
+ : undefined;
4037
+ const batchModelUsage = mergeSessionRawModelUsage(background?.modelUsage, workflow?.modelUsage);
4038
+ const batchDurationApiMs = addApiDuration(workflow?.durationApiMs, addApiDuration(background?.durationApiMs, 0, 'durationApiMs'), 'durationApiMs');
4039
+ const backgroundDurationWithoutRetries = background?.durationApiWithoutRetriesMs ??
4040
+ background?.durationApiMs;
4041
+ const workflowDurationWithoutRetries = workflow?.durationApiWithoutRetriesMs ??
4042
+ workflow?.durationApiMs;
4043
+ const batchDurationApiWithoutRetriesMs = addApiDuration(workflowDurationWithoutRetries, addApiDuration(backgroundDurationWithoutRetries, 0, 'durationApiWithoutRetriesMs'), 'durationApiWithoutRetriesMs');
4044
+ const hasBatchDuration = background?.durationApiMs !== undefined ||
4045
+ background?.durationApiWithoutRetriesMs !== undefined ||
4046
+ workflow?.durationApiMs !== undefined ||
4047
+ workflow?.durationApiWithoutRetriesMs !== undefined;
4048
+ return {
4049
+ messages,
4050
+ ...(batchUsage
4051
+ ? {
4052
+ usage: batchUsage,
4053
+ ...(batchModelUsage
4054
+ ? { modelUsage: batchModelUsage }
4055
+ : {}),
4056
+ }
4057
+ : {}),
4058
+ ...(hasBatchDuration
4059
+ ? {
4060
+ durationApiMs: batchDurationApiMs,
4061
+ durationApiWithoutRetriesMs: batchDurationApiWithoutRetriesMs,
4062
+ }
4063
+ : {}),
4064
+ };
4065
+ },
4066
+ }
4067
+ : {}),
4068
+ ...(this.options.approveTool
4069
+ ? { approveTool: this.options.approveTool }
4070
+ : {}),
4071
+ permissionUpdates: this.sessionPermissionUpdates.get(sessionId) ?? [],
4072
+ onPermissionUpdates: (updates) => this.applyPermissionUpdates(sessionId, updates),
4073
+ };
4074
+ const attemptMainTurn = () => signal
4075
+ ? runtime.run({ ...runtimeRequest, signal })
4076
+ : runtime.run(runtimeRequest);
4077
+ const surfaceExhaustedRecovery = (error) => {
4078
+ this.options.eventSink?.({
4079
+ type: 'failed',
4080
+ message: error.message,
4081
+ retryable: false,
4082
+ });
4083
+ };
4084
+ let result;
4085
+ try {
3919
4086
  try {
3920
4087
  result = await attemptMainTurn();
3921
4088
  }
3922
- catch (retryError) {
3923
- // Exactly one reactive retry is consumed; fail deterministically
3924
- // and surface the original prompt-too-long error.
3925
- recoveryPlanner.recordFailure();
3926
- if (signal?.aborted ||
3927
- retryError instanceof AgentRunCancelledError) {
3928
- throw new AgentRunCancelledError();
4089
+ catch (error) {
4090
+ if (!budget || !isPromptTooLongError(error))
4091
+ 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.
4110
+ surfaceExhaustedRecovery(error);
4111
+ throw error;
4112
+ }
4113
+ // The single reactive retry must use the compacted transcript, not
4114
+ // the stale request copy captured before the compact boundary.
4115
+ runtimeRequest.messages = [
4116
+ ...contextMessages,
4117
+ ...injectTurnContext([
4118
+ ...projectClaudeModelMessages(snapshot.entries),
4119
+ ...projectMemoryRecallMessages,
4120
+ ]),
4121
+ ];
4122
+ 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
+ runtimeRequest.deferFailureKinds = true;
4132
+ try {
4133
+ result = await attemptMainTurn();
4134
+ }
4135
+ catch (retryError) {
4136
+ // Exactly one reactive retry is consumed; fail deterministically
4137
+ // and surface the original prompt-too-long error.
4138
+ recoveryPlanner.recordFailure();
4139
+ if (signal?.aborted ||
4140
+ retryError instanceof AgentRunCancelledError) {
4141
+ throw new AgentRunCancelledError();
4142
+ }
4143
+ surfaceExhaustedRecovery(error);
4144
+ throw error;
3929
4145
  }
3930
- surfaceExhaustedRecovery(error);
3931
- throw error;
3932
4146
  }
3933
4147
  }
3934
- }
3935
- catch (error) {
3936
- if (!signal?.aborted &&
3937
- !(error instanceof AgentRunCancelledError) &&
3938
- !(error instanceof ModelProviderError && error.kind === 'cancelled')) {
3939
- const failureKind = error instanceof ModelProviderError
3940
- ? claudeStopFailureError(error.kind)
3941
- : 'unknown';
3942
- await this.runAdvisoryHook(sessionId, 'StopFailure', {
3943
- error: failureKind,
3944
- error_details: error instanceof Error ? error.message : String(error),
3945
- }, failureKind, signal);
4148
+ catch (error) {
4149
+ if (!signal?.aborted &&
4150
+ !(error instanceof AgentRunCancelledError) &&
4151
+ !(error instanceof ModelProviderError &&
4152
+ error.kind === 'cancelled')) {
4153
+ const failureKind = error instanceof ModelProviderError
4154
+ ? claudeStopFailureError(error.kind)
4155
+ : 'unknown';
4156
+ await this.runAdvisoryHook(sessionId, 'StopFailure', {
4157
+ error: failureKind,
4158
+ error_details: error instanceof Error ? error.message : String(error),
4159
+ }, failureKind, signal);
4160
+ }
4161
+ throw error;
3946
4162
  }
3947
- throw error;
3948
- }
3949
- recoveryPlanner.recordSuccess();
3950
- const mainModel = provider.model !== undefined && provider.model.trim() !== ''
3951
- ? provider.model
3952
- : undefined;
3953
- const observedUsage = (mainModel !== undefined
3954
- ? result.modelUsage?.[mainModel]
3955
- : undefined) ??
3956
- Object.values(result.modelUsage ?? {})[0] ??
3957
- result.usage;
3958
- budget?.observeUsage(observedUsage, runtimeRequest.messages, definitions);
3959
- if (structuredCapture && structuredCapture.calls !== 1) {
3960
- throw new Error(`StructuredOutput must be called exactly once (received ${structuredCapture.calls})`);
3961
- }
3962
- const finalLeafUuid = lastAssistantUuid;
3963
- if (!finalLeafUuid) {
3964
- throw new Error('Could not locate final assistant response');
3965
- }
3966
- if (!skipUserPrompt) {
3967
- const lastPrompt = createClaudeLastPromptEntry({
3968
- sessionId,
3969
- lastPrompt: effectivePrompt,
3970
- leafUuid: finalLeafUuid,
3971
- });
3972
- const tail = await this.append(lease, snapshot.tail, lastPrompt);
3973
- snapshot = {
3974
- entries: [...snapshot.entries, lastPrompt],
3975
- tail,
3976
- };
3977
- }
3978
- this.rememberDurableMetadata(sessionId, snapshot.entries);
3979
- const totalUsage = mergeUsage(mergeUsage(mergeUsage(recoveryUsage, compactionUsage), shellUsage), result.usage);
3980
- const tracker = this.sessionCostTrackers.get(sessionId);
3981
- if (!tracker) {
3982
- throw new Error(`Session cost tracker is not active for session ${sessionId}`);
3983
- }
3984
- const turnModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, compactionModelUsage, shellModelUsage, result.modelUsage);
3985
- const combinedDurationMs = compactionDurationMs === undefined &&
3986
- result.durationApiMs === undefined
3987
- ? undefined
3988
- : (compactionDurationMs ?? 0) + (result.durationApiMs ?? 0);
3989
- let rawCostUsd;
3990
- if (turnModelUsage) {
3991
- for (const [model, usage] of Object.entries(turnModelUsage)) {
3992
- const pricing = this.options.pricing?.resolve(model);
3993
- const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
3994
- if (costUsd !== undefined)
3995
- rawCostUsd = (rawCostUsd ?? 0) + costUsd;
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
+ if (structuredCapture && structuredCapture.calls !== 1) {
4174
+ throw new Error(`StructuredOutput must be called exactly once (received ${structuredCapture.calls})`);
3996
4175
  }
3997
- }
3998
- // Auto-compaction metering was already recorded atomically with each
3999
- // committed boundary, and externally metered tool-summary metrics were
4000
- // committed through the summary callback, so the live tracker receives
4001
- // only the unrecorded subset here. The inclusive public aggregates
4002
- // above still contain every row and duration.
4003
- const trackedModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, shellModelUsage, result.unrecordedModelUsage ?? result.modelUsage);
4004
- if (trackedModelUsage) {
4005
- for (const [model, usage] of Object.entries(trackedModelUsage)) {
4006
- const pricing = this.options.pricing?.resolve(model);
4007
- const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
4008
- tracker.recordTurn({
4009
- model,
4010
- usage,
4011
- ...(costUsd === undefined ? {} : { costUsd }),
4012
- ...(usage.webSearchRequests === undefined
4013
- ? {}
4014
- : { webSearchRequests: usage.webSearchRequests }),
4176
+ const finalLeafUuid = lastAssistantUuid;
4177
+ if (!finalLeafUuid) {
4178
+ throw new Error('Could not locate final assistant response');
4179
+ }
4180
+ if (!skipUserPrompt) {
4181
+ const lastPrompt = createClaudeLastPromptEntry({
4182
+ sessionId,
4183
+ lastPrompt: effectivePrompt,
4184
+ leafUuid: finalLeafUuid,
4015
4185
  });
4186
+ const tail = await this.append(lease, snapshot.tail, lastPrompt);
4187
+ snapshot = {
4188
+ entries: [...snapshot.entries, lastPrompt],
4189
+ tail,
4190
+ };
4016
4191
  }
4017
- }
4018
- let combinedToolDurationMs = 0;
4019
- for (const recoveryResult of recoveryResults) {
4020
- combinedToolDurationMs = addToolDuration(recoveryResult.durationToolMs, combinedToolDurationMs);
4021
- }
4022
- combinedToolDurationMs = addToolDuration(result.durationToolMs, combinedToolDurationMs);
4023
- const trackedDurationApiMs = result.unrecordedDurationApiMs ?? result.durationApiMs;
4024
- const trackedDurationApiWithoutRetriesMs = result.unrecordedDurationApiWithoutRetriesMs ??
4025
- result.durationApiWithoutRetriesMs;
4026
- tracker.recordDurations({
4027
- ...(trackedDurationApiMs === undefined
4028
- ? {}
4029
- : { apiDurationMs: trackedDurationApiMs }),
4030
- ...(trackedDurationApiWithoutRetriesMs === undefined
4031
- ? {}
4032
- : {
4033
- apiDurationWithoutRetriesMs: trackedDurationApiWithoutRetriesMs,
4034
- }),
4035
- ...(combinedToolDurationMs === 0
4036
- ? {}
4037
- : { toolDurationMs: combinedToolDurationMs }),
4038
- });
4039
- foregroundLineChanges.add({
4040
- isError: false,
4041
- ...(result.linesAdded === undefined
4042
- ? {}
4043
- : { linesAdded: result.linesAdded }),
4044
- ...(result.linesRemoved === undefined
4045
- ? {}
4046
- : { linesRemoved: result.linesRemoved }),
4047
- });
4048
- if (foregroundLineChanges.linesAdded !== 0 ||
4049
- foregroundLineChanges.linesRemoved !== 0) {
4050
- tracker.recordLineChanges({
4051
- ...(foregroundLineChanges.linesAdded === 0
4192
+ this.rememberDurableMetadata(sessionId, snapshot.entries);
4193
+ const totalUsage = mergeUsage(mergeUsage(mergeUsage(recoveryUsage, compactionUsage), shellUsage), result.usage);
4194
+ const tracker = this.sessionCostTrackers.get(sessionId);
4195
+ if (!tracker) {
4196
+ throw new Error(`Session cost tracker is not active for session ${sessionId}`);
4197
+ }
4198
+ const turnModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, compactionModelUsage, shellModelUsage, result.modelUsage);
4199
+ const combinedDurationMs = compactionDurationMs === undefined &&
4200
+ result.durationApiMs === undefined
4201
+ ? undefined
4202
+ : (compactionDurationMs ?? 0) + (result.durationApiMs ?? 0);
4203
+ let rawCostUsd;
4204
+ if (turnModelUsage) {
4205
+ for (const [model, usage] of Object.entries(turnModelUsage)) {
4206
+ const pricing = this.options.pricing?.resolve(model);
4207
+ const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
4208
+ if (costUsd !== undefined)
4209
+ rawCostUsd = (rawCostUsd ?? 0) + costUsd;
4210
+ }
4211
+ }
4212
+ // Auto-compaction metering was already recorded atomically with each
4213
+ // committed boundary, and externally metered tool-summary metrics were
4214
+ // committed through the summary callback, so the live tracker receives
4215
+ // only the unrecorded subset here. The inclusive public aggregates
4216
+ // above still contain every row and duration.
4217
+ const trackedModelUsage = mergeSessionRawModelUsage(recoveryModelUsage, shellModelUsage, result.unrecordedModelUsage ?? result.modelUsage);
4218
+ if (trackedModelUsage) {
4219
+ for (const [model, usage] of Object.entries(trackedModelUsage)) {
4220
+ const pricing = this.options.pricing?.resolve(model);
4221
+ const costUsd = pricing ? usageCostUsd(usage, pricing) : undefined;
4222
+ tracker.recordTurn({
4223
+ model,
4224
+ usage,
4225
+ ...(costUsd === undefined ? {} : { costUsd }),
4226
+ ...(usage.webSearchRequests === undefined
4227
+ ? {}
4228
+ : { webSearchRequests: usage.webSearchRequests }),
4229
+ });
4230
+ }
4231
+ }
4232
+ let combinedToolDurationMs = 0;
4233
+ for (const recoveryResult of recoveryResults) {
4234
+ combinedToolDurationMs = addToolDuration(recoveryResult.durationToolMs, combinedToolDurationMs);
4235
+ }
4236
+ combinedToolDurationMs = addToolDuration(result.durationToolMs, combinedToolDurationMs);
4237
+ const trackedDurationApiMs = result.unrecordedDurationApiMs ?? result.durationApiMs;
4238
+ const trackedDurationApiWithoutRetriesMs = result.unrecordedDurationApiWithoutRetriesMs ??
4239
+ result.durationApiWithoutRetriesMs;
4240
+ tracker.recordDurations({
4241
+ ...(trackedDurationApiMs === undefined
4052
4242
  ? {}
4053
- : { linesAdded: foregroundLineChanges.linesAdded }),
4054
- ...(foregroundLineChanges.linesRemoved === 0
4243
+ : { apiDurationMs: trackedDurationApiMs }),
4244
+ ...(trackedDurationApiWithoutRetriesMs === undefined
4055
4245
  ? {}
4056
- : { linesRemoved: foregroundLineChanges.linesRemoved }),
4246
+ : {
4247
+ apiDurationWithoutRetriesMs: trackedDurationApiWithoutRetriesMs,
4248
+ }),
4249
+ ...(combinedToolDurationMs === 0
4250
+ ? {}
4251
+ : { toolDurationMs: combinedToolDurationMs }),
4057
4252
  });
4058
- }
4059
- if (sessionMemory && finalLeafUuid) {
4060
- const memorySnapshot = projectClaudeModelMessages(snapshot.entries);
4061
- const providerVisibleMessages = [
4062
- ...contextMessages,
4063
- ...injectTurnContext(memorySnapshot),
4064
- ];
4065
- const currentContextTokens = budget
4066
- ? budget.evaluate(providerVisibleMessages, definitions)
4067
- .occupancyTokens
4068
- : estimateModelRequestTokens(providerVisibleMessages, definitions);
4069
- try {
4070
- await sessionMemory.observeContext(currentContextTokens, currentTurnToolCalls, finalLeafUuid, memorySnapshot);
4071
- }
4072
- catch (error) {
4073
- // A failed observation must not fail the user turn; the sidecar
4074
- // retains a retryable error for the next observation.
4075
- this.options.eventSink?.({
4076
- type: 'warning',
4077
- message: `Session memory observation failed: ${error instanceof Error ? error.message : String(error)}`,
4253
+ foregroundLineChanges.add({
4254
+ isError: false,
4255
+ ...(result.linesAdded === undefined
4256
+ ? {}
4257
+ : { linesAdded: result.linesAdded }),
4258
+ ...(result.linesRemoved === undefined
4259
+ ? {}
4260
+ : { linesRemoved: result.linesRemoved }),
4261
+ });
4262
+ if (foregroundLineChanges.linesAdded !== 0 ||
4263
+ foregroundLineChanges.linesRemoved !== 0) {
4264
+ tracker.recordLineChanges({
4265
+ ...(foregroundLineChanges.linesAdded === 0
4266
+ ? {}
4267
+ : { linesAdded: foregroundLineChanges.linesAdded }),
4268
+ ...(foregroundLineChanges.linesRemoved === 0
4269
+ ? {}
4270
+ : { linesRemoved: foregroundLineChanges.linesRemoved }),
4078
4271
  });
4079
4272
  }
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,
4297
+ directMaintenance: projectMemoryMaintained,
4298
+ messages: projectMemoryMessages(snapshot.entries),
4299
+ });
4300
+ turnCompleted = true;
4301
+ return {
4302
+ sessionId,
4303
+ text: structuredCapture && structuredCapture.calls === 1
4304
+ ? JSON.stringify(structuredCapture.value)
4305
+ : result.text,
4306
+ usage: totalUsage,
4307
+ ...(combinedDurationMs === undefined
4308
+ ? {}
4309
+ : { durationApiMs: combinedDurationMs }),
4310
+ ...(rawCostUsd === undefined ? {} : { costUsd: rawCostUsd }),
4311
+ ...(turnModelUsage ? { modelUsage: { ...turnModelUsage } } : {}),
4312
+ ...(structuredCapture && structuredCapture.calls === 1
4313
+ ? { structuredOutput: structuredCapture.value }
4314
+ : {}),
4315
+ };
4080
4316
  }
4081
- this.options.projectMemoryExtraction?.observe({
4082
- sessionId,
4083
- directMaintenance: projectMemoryMaintained,
4084
- messages: projectMemoryMessages(snapshot.entries),
4085
- });
4086
- turnCompleted = true;
4087
- return {
4088
- sessionId,
4089
- text: structuredCapture && structuredCapture.calls === 1
4090
- ? JSON.stringify(structuredCapture.value)
4091
- : result.text,
4092
- usage: totalUsage,
4093
- ...(combinedDurationMs === undefined
4094
- ? {}
4095
- : { durationApiMs: combinedDurationMs }),
4096
- ...(rawCostUsd === undefined ? {} : { costUsd: rawCostUsd }),
4097
- ...(turnModelUsage ? { modelUsage: { ...turnModelUsage } } : {}),
4098
- ...(structuredCapture && structuredCapture.calls === 1
4099
- ? { structuredOutput: structuredCapture.value }
4100
- : {}),
4101
- };
4102
- }
4103
- finally {
4104
- if (!turnCompleted) {
4105
- await this.hookLifecycle.end(sessionId, 'other');
4317
+ finally {
4318
+ if (!turnCompleted) {
4319
+ await this.hookLifecycle.end(sessionId, 'other');
4320
+ }
4106
4321
  }
4322
+ });
4323
+ if (leaseResult.status === 'conflict') {
4324
+ throw new Error(`Claude transcript append conflict: ${leaseResult.reason}`);
4107
4325
  }
4108
- });
4109
- if (leaseResult.status === 'conflict') {
4110
- throw new Error(`Claude transcript append conflict: ${leaseResult.reason}`);
4326
+ const result = leaseResult.value;
4327
+ controller.complete();
4328
+ return result;
4329
+ }
4330
+ catch (error) {
4331
+ controller.fail(error, signal);
4332
+ throw error;
4111
4333
  }
4112
- return leaseResult.value;
4113
4334
  }
4114
4335
  async ensureFileResources(sessionId, signal) {
4115
4336
  const resources = this.options.fileResources ?? [];
@@ -4622,7 +4843,7 @@ export class ClaudeSessionService {
4622
4843
  }
4623
4844
  }
4624
4845
  async appendBackgroundNotification(sessionId, content) {
4625
- while (true) {
4846
+ while (!this.closing) {
4626
4847
  const result = await this.turnStore(sessionId).withLease(async (lease) => {
4627
4848
  const snapshot = await lease.load();
4628
4849
  if (snapshot.entries.length === 0) {
@@ -4668,16 +4889,23 @@ export class ClaudeSessionService {
4668
4889
  }
4669
4890
  });
4670
4891
  if (result.status === 'completed')
4671
- return;
4892
+ return true;
4672
4893
  await new Promise((resolve) => setTimeout(resolve, 25));
4673
4894
  }
4895
+ return false;
4674
4896
  }
4675
- enqueueBackgroundNotifications(sessionId, messages) {
4897
+ enqueueBackgroundNotifications(sessionId, messages, onAppended) {
4676
4898
  const previous = this.backgroundNotificationWrites.get(sessionId);
4677
- const queued = (previous ?? Promise.resolve()).then(async () => {
4899
+ const queued = (previous ?? Promise.resolve(true)).then(async (previousCompleted) => {
4900
+ if (!previousCompleted || this.closing)
4901
+ return false;
4678
4902
  for (const message of messages) {
4679
- await this.appendBackgroundNotification(sessionId, message);
4903
+ if (!(await this.appendBackgroundNotification(sessionId, message))) {
4904
+ return false;
4905
+ }
4906
+ await onAppended?.(message);
4680
4907
  }
4908
+ return true;
4681
4909
  });
4682
4910
  this.backgroundNotificationWrites.set(sessionId, queued);
4683
4911
  const cleanup = () => {
@@ -4868,13 +5096,6 @@ export class ClaudeSessionService {
4868
5096
  const system = memory ? `${agent.body}\n\n${memory}` : agent.body;
4869
5097
  return system.trim() ? system : null;
4870
5098
  }
4871
- assembledSystemMessages(agent, messages) {
4872
- return agent &&
4873
- this.options.explicitSystemPrompt &&
4874
- this.options.agentSystemPromptOverridesExplicit
4875
- ? messages.slice(1)
4876
- : messages;
4877
- }
4878
5099
  contextBudget(provider) {
4879
5100
  if (this.options.contextBudget)
4880
5101
  return this.options.contextBudget;