praxis-agent 0.30.0 → 0.32.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 (53) hide show
  1. package/dist/application/claude-project-purge.js +18 -5
  2. package/dist/application/project-memory.d.ts +141 -0
  3. package/dist/application/project-memory.js +607 -0
  4. package/dist/application/session-service.d.ts +24 -0
  5. package/dist/application/session-service.js +412 -44
  6. package/dist/application/subagent-service.js +6 -0
  7. package/dist/cli/interactive.d.ts +3 -1
  8. package/dist/cli/interactive.js +37 -4
  9. package/dist/cli/protocol.d.ts +1 -0
  10. package/dist/cli/protocol.js +24 -0
  11. package/dist/cli/tui/hook-settings.js +14 -1
  12. package/dist/cli/tui/memory-files.d.ts +2 -1
  13. package/dist/cli/tui/memory-files.js +30 -30
  14. package/dist/cli-runtime.d.ts +1 -0
  15. package/dist/cli-runtime.js +144 -24
  16. package/dist/compatibility/claude/context.d.ts +6 -1
  17. package/dist/compatibility/claude/context.js +16 -2
  18. package/dist/compatibility/claude/dynamic-context.js +2 -1
  19. package/dist/compatibility/claude/paths.js +2 -13
  20. package/dist/compatibility/claude/shared-resources.d.ts +4 -4
  21. package/dist/compatibility/claude/shared-resources.js +14 -35
  22. package/dist/core/context-budget.d.ts +9 -8
  23. package/dist/core/context-budget.js +16 -21
  24. package/dist/core/project-memory.d.ts +19 -0
  25. package/dist/core/project-memory.js +58 -0
  26. package/dist/core/runtime.d.ts +10 -1
  27. package/dist/core/runtime.js +47 -3
  28. package/dist/hooks/claude-file-change-watcher.d.ts +43 -0
  29. package/dist/hooks/claude-file-change-watcher.js +185 -0
  30. package/dist/hooks/claude-hook-tools.d.ts +2 -0
  31. package/dist/hooks/claude-hook-tools.js +45 -6
  32. package/dist/hooks/claude-hooks.d.ts +14 -2
  33. package/dist/hooks/claude-hooks.js +230 -5
  34. package/dist/hooks/claude-session-environment.d.ts +16 -0
  35. package/dist/hooks/claude-session-environment.js +112 -0
  36. package/dist/maintenance/doctor.js +29 -5
  37. package/dist/mcp/claude-mcp-management.js +2 -0
  38. package/dist/persistence/native-resources.d.ts +3 -1
  39. package/dist/persistence/native-resources.js +19 -6
  40. package/dist/platform/path-containment.d.ts +2 -0
  41. package/dist/platform/path-containment.js +7 -0
  42. package/dist/platform/project-identity.d.ts +6 -0
  43. package/dist/platform/project-identity.js +66 -0
  44. package/dist/platform/project-memory-paths.d.ts +13 -0
  45. package/dist/platform/project-memory-paths.js +28 -0
  46. package/dist/platform/project-path-key.d.ts +2 -0
  47. package/dist/platform/project-path-key.js +16 -0
  48. package/dist/providers/fallback-provider.js +3 -1
  49. package/dist/tools/claude-task-tools.d.ts +5 -0
  50. package/dist/tools/claude-task-tools.js +15 -1
  51. package/dist/tools/local-tools.d.ts +2 -0
  52. package/dist/tools/local-tools.js +11 -4
  53. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { appendFile, copyFile, link, lstat, mkdir, readdir, realpath, stat, unlink, } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, extname, isAbsolute, join, relative, resolve, } from 'node:path';
5
+ import { isPathWithin } from '../platform/path-containment.js';
5
6
  import { AGENT_COLOR_DEFAULT, agentColorMessage, getClaudeEffectiveAgentColor, } from '../compatibility/claude/agent-color.js';
6
7
  import { createClaudeCompactEntries, formatClaudeCompactSummary, getCumulativeDroppedTokens, } from '../compatibility/claude/compaction.js';
7
8
  import { discoverClaudeProjectRoot, isClaudeSessionId, resolveClaudePaths, resolveClaudeScheduledTaskFile, } from '../compatibility/claude/paths.js';
@@ -15,13 +16,14 @@ import { getClaudeAgentSetting, getClaudeLastPrompt, projectClaudeDisplayTranscr
15
16
  import { selectClaudeSchemaAdapter, } from '../compatibility/claude/schema.js';
16
17
  import { findUnresolvedClaudeToolCalls, getClaudeContentBlocks, } from '../compatibility/claude/tool-links.js';
17
18
  import { createClaudeAgentSettingEntry, createClaudeHookAttachmentEntries, createClaudeLastPromptEntry, createClaudeRuleAttachmentEntry, translateProviderEvents, } from '../compatibility/claude/translation.js';
18
- import { AgentRunCancelledError, AgentRuntime, } from '../core/runtime.js';
19
+ import { AgentRunCancelledError, AgentRuntime, ModelProviderError, } from '../core/runtime.js';
19
20
  import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
20
21
  import { BackgroundTaskRuntime, } from './background-task-runtime.js';
21
22
  import { usageCostUsd } from '../core/usage.js';
22
- import { ContextBudget, ContextRecoveryPlanner, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
23
+ import { ContextBudget, ContextRecoveryPlanner, contextRecoveryMadeProgress, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
23
24
  import { injectFirstUserMessageContext, } from '../core/context.js';
24
25
  import { ClaudeHookToolCoordinator } from '../hooks/claude-hook-tools.js';
26
+ import { ClaudeFileChangeWatcher } from '../hooks/claude-file-change-watcher.js';
25
27
  import { ClaudeTranscriptStore, } from '../persistence/claude-transcript-store.js';
26
28
  import { InMemoryTranscriptStore } from '../persistence/in-memory-transcript-store.js';
27
29
  import { ModelCompactor } from './model-compactor.js';
@@ -464,6 +466,50 @@ function validSessionName(value) {
464
466
  ? name.toLocaleLowerCase()
465
467
  : null;
466
468
  }
469
+ function projectMemoryMessages(entries) {
470
+ return selectClaudeActiveTranscript(entries).flatMap((entry) => {
471
+ if (typeof entry.uuid !== 'string')
472
+ return [];
473
+ return projectClaudeModelMessages([entry]).flatMap((message) => (message.role === 'user' || message.role === 'assistant') &&
474
+ message.content.trim().length > 0
475
+ ? [
476
+ {
477
+ id: entry.uuid,
478
+ role: message.role,
479
+ content: message.content,
480
+ },
481
+ ]
482
+ : []);
483
+ });
484
+ }
485
+ function successfulHookOutput(outcome) {
486
+ return (outcome?.executions ?? []).flatMap((execution) => {
487
+ const output = execution.stdout.trim();
488
+ return execution.exitCode === 0 && output.length > 0 ? [output] : [];
489
+ });
490
+ }
491
+ function claudeStopFailureError(kind) {
492
+ switch (kind) {
493
+ case 'authentication_failed':
494
+ case 'billing_error':
495
+ case 'rate_limit':
496
+ case 'invalid_request':
497
+ case 'server_error':
498
+ case 'max_output_tokens':
499
+ return kind;
500
+ case 'prompt_too_long':
501
+ return 'invalid_request';
502
+ case 'timeout':
503
+ case 'overloaded':
504
+ case 'api_error':
505
+ case 'transport_error':
506
+ return 'server_error';
507
+ case 'cancelled':
508
+ case 'unknown':
509
+ case undefined:
510
+ return 'unknown';
511
+ }
512
+ }
467
513
  const SESSION_END_HOOK_TIMEOUT_MS = 15_000;
468
514
  class HookLifecycle {
469
515
  hooks;
@@ -589,6 +635,7 @@ export class ClaudeSessionService {
589
635
  hostedSubagentsByRegistry = new WeakMap();
590
636
  backgroundNotificationWrites = new Map();
591
637
  downloadedFileResourceSessions = new Set();
638
+ detachedHookRuns = new Map();
592
639
  activeProvider;
593
640
  mcpClosePromise;
594
641
  sessionCostTrackers = new Map();
@@ -597,11 +644,26 @@ export class ClaudeSessionService {
597
644
  sessionMemoryControllers = new Map();
598
645
  resolvedSessionMemoryProvider;
599
646
  hookLifecycle;
647
+ fileChangeWatcher;
600
648
  runtimeCwd;
601
649
  constructor(options) {
602
650
  this.options = options;
603
651
  this.hookLifecycle = new HookLifecycle(options.hooks, options.eventSink);
604
652
  this.runtimeCwd = options.workspace?.cwd() ?? options.cwd;
653
+ this.fileChangeWatcher = options.hooks
654
+ ? new ClaudeFileChangeWatcher({
655
+ cwd: this.runtimeCwd,
656
+ staticPaths: options.hooks.fileChangedWatchPaths(this.runtimeCwd),
657
+ onFileChanged: async (filePath, event, signal) => {
658
+ const sessionId = this.activeCostSessionId;
659
+ if (!sessionId)
660
+ return undefined;
661
+ const outcome = await this.runAdvisoryHook(sessionId, 'FileChanged', { file_path: filePath, event }, undefined, signal);
662
+ return outcome?.watchPaths;
663
+ },
664
+ warn: (message) => options.eventSink?.({ type: 'warning', message }),
665
+ })
666
+ : null;
605
667
  this.schema = selectClaudeSchemaAdapter(options.claudeVersion);
606
668
  this.scheduledPrompts =
607
669
  options.tools && (options.scheduledToolNames?.length ?? 0) > 0
@@ -739,8 +801,12 @@ export class ClaudeSessionService {
739
801
  }
740
802
  }
741
803
  async close() {
804
+ await this.fileChangeWatcher?.close(5_000);
742
805
  await this.hookLifecycle.close();
806
+ await this.drainDetachedHookRuns(5_000);
807
+ await this.options.hooks?.drainAsync(5_000);
743
808
  this.scheduledPrompts?.close();
809
+ await this.options.projectMemoryExtraction?.close(5_000);
744
810
  await Promise.all([...this.hostedSubagents].map((executor) => executor.close()));
745
811
  await Promise.resolve();
746
812
  await Promise.all([...this.backgroundNotificationWrites.values()]);
@@ -756,6 +822,9 @@ export class ClaudeSessionService {
756
822
  }
757
823
  async transitionHookSession(sessionId, reason) {
758
824
  await this.hookLifecycle.transition(sessionId, reason);
825
+ if (reason === 'clear') {
826
+ this.options.projectMemoryRecall?.clearSession?.(sessionId);
827
+ }
759
828
  this.options.contextAssembler?.invalidate?.({
760
829
  lifecycleId: sessionId,
761
830
  reason: reason === 'resume' ? 'restore' : 'clear',
@@ -767,6 +836,119 @@ export class ClaudeSessionService {
767
836
  reason: 'resource-reload',
768
837
  });
769
838
  }
839
+ async notify(sessionId, message, notificationType, title, signal) {
840
+ const targetSessionId = sessionId ?? this.activeCostSessionId;
841
+ if (!targetSessionId)
842
+ return;
843
+ await this.runAdvisoryHook(targetSessionId, 'Notification', {
844
+ message,
845
+ notification_type: notificationType,
846
+ ...(title === undefined ? {} : { title }),
847
+ }, notificationType, signal);
848
+ }
849
+ notifyDetached(sessionId, message, notificationType, title) {
850
+ const controller = new AbortController();
851
+ const pending = this.notify(sessionId, message, notificationType, title, controller.signal);
852
+ this.detachedHookRuns.set(pending, controller);
853
+ void pending.finally(() => this.detachedHookRuns.delete(pending));
854
+ }
855
+ async drainDetachedHookRuns(timeoutMs) {
856
+ const pending = [...this.detachedHookRuns.keys()];
857
+ if (pending.length === 0)
858
+ return;
859
+ let timer;
860
+ try {
861
+ if ((await Promise.race([
862
+ Promise.allSettled(pending).then(() => 'settled'),
863
+ new Promise((resolve) => {
864
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
865
+ }),
866
+ ])) === 'timeout') {
867
+ for (const controller of this.detachedHookRuns.values()) {
868
+ controller.abort();
869
+ }
870
+ this.detachedHookRuns.clear();
871
+ await Promise.resolve();
872
+ }
873
+ }
874
+ finally {
875
+ if (timer)
876
+ clearTimeout(timer);
877
+ }
878
+ }
879
+ async instructionsLoaded(sessionId, resources, reason) {
880
+ if (reason === 'resource_reload')
881
+ return;
882
+ for (const resource of resources) {
883
+ const loadReason = resource.importedFrom
884
+ ? 'include'
885
+ : reason === 'compact'
886
+ ? 'compact'
887
+ : 'session_start';
888
+ await this.instructionLoaded(sessionId, {
889
+ path: resource.path,
890
+ memoryType: resource.scope === 'user'
891
+ ? 'User'
892
+ : resource.scope === 'local'
893
+ ? 'Local'
894
+ : 'Project',
895
+ ...(resource.importedFrom === undefined
896
+ ? {}
897
+ : { parentFilePath: resource.importedFrom }),
898
+ }, loadReason);
899
+ }
900
+ }
901
+ async instructionLoaded(sessionId, resource, loadReason) {
902
+ await this.runAdvisoryHook(sessionId, 'InstructionsLoaded', {
903
+ file_path: resource.path,
904
+ memory_type: resource.memoryType,
905
+ load_reason: loadReason,
906
+ ...(resource.globs === undefined ? {} : { globs: resource.globs }),
907
+ ...(resource.triggerFilePath === undefined
908
+ ? {}
909
+ : { trigger_file_path: resource.triggerFilePath }),
910
+ ...(resource.parentFilePath === undefined
911
+ ? {}
912
+ : { parent_file_path: resource.parentFilePath }),
913
+ }, loadReason);
914
+ }
915
+ async runAdvisoryHook(sessionId, event, fields, matcher, signal) {
916
+ if (!this.options.hooks)
917
+ return undefined;
918
+ try {
919
+ const outcome = await this.options.hooks.run({
920
+ session_id: sessionId,
921
+ transcript_path: this.paths(sessionId).sessionFile,
922
+ cwd: this.activeCwd(),
923
+ permission_mode: 'default',
924
+ hook_event_name: event,
925
+ ...fields,
926
+ }, matcher, signal);
927
+ for (const message of outcome.systemMessages ?? []) {
928
+ this.options.eventSink?.({
929
+ type: 'user-message',
930
+ message,
931
+ status: 'proactive',
932
+ });
933
+ }
934
+ return outcome;
935
+ }
936
+ catch (error) {
937
+ this.options.eventSink?.({
938
+ type: 'warning',
939
+ message: `${event} hook failed: ${error instanceof Error ? error.message : String(error)}`,
940
+ });
941
+ return undefined;
942
+ }
943
+ }
944
+ async cwdChanged(sessionId, previousCwd, cwd) {
945
+ await this.options.hooks?.clearCwdEnvironment(sessionId);
946
+ const outcome = await this.runAdvisoryHook(sessionId, 'CwdChanged', {
947
+ old_cwd: previousCwd,
948
+ new_cwd: cwd,
949
+ });
950
+ this.fileChangeWatcher?.updateForCwd(cwd, this.options.hooks?.fileChangedWatchPaths(cwd) ?? [], outcome?.watchPaths ?? []);
951
+ }
770
952
  createHostedToolRegistry(sessionId) {
771
953
  const baseTools = this.options.tools;
772
954
  if (!baseTools)
@@ -1430,6 +1612,7 @@ export class ClaudeSessionService {
1430
1612
  lifecycleId: sessionId,
1431
1613
  reason: 'cwd',
1432
1614
  });
1615
+ await this.cwdChanged(sessionId, previousCwd, cwd);
1433
1616
  return cwd;
1434
1617
  }
1435
1618
  this.runtimeCwd = cwd;
@@ -1441,6 +1624,7 @@ export class ClaudeSessionService {
1441
1624
  lifecycleId: sessionId,
1442
1625
  reason: 'cwd',
1443
1626
  });
1627
+ await this.cwdChanged(sessionId, previousCwd, cwd);
1444
1628
  }
1445
1629
  return cwd;
1446
1630
  }
@@ -1689,6 +1873,14 @@ export class ClaudeSessionService {
1689
1873
  if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
1690
1874
  throw new Error('Cannot compact a Claude session with unresolved tool calls');
1691
1875
  }
1876
+ const preCompact = await this.runAdvisoryHook(sessionId, 'PreCompact', {
1877
+ trigger: 'manual',
1878
+ custom_instructions: selection?.context ?? null,
1879
+ }, 'manual', signal);
1880
+ messages.push(...successfulHookOutput(preCompact).map((content) => ({
1881
+ role: 'user',
1882
+ content: `Additional summarization context: ${content}`,
1883
+ })));
1692
1884
  this.options.eventSink?.({ type: 'state', state: 'compacting' });
1693
1885
  const contextWindowTokens = this.contextBudget(provider)?.contextWindowTokens ??
1694
1886
  provider.capabilities.contextWindowTokens ??
@@ -1784,12 +1976,14 @@ export class ClaudeSessionService {
1784
1976
  if (appendResult.status === 'conflict') {
1785
1977
  throw new Error(`Claude transcript append conflict: ${appendResult.reason}`);
1786
1978
  }
1979
+ this.options.projectMemoryRecall?.recordCompact(sessionId);
1787
1980
  this.options.eventSink?.({
1788
1981
  type: 'compact-boundary',
1789
1982
  trigger: 'manual',
1790
1983
  preTokens,
1791
1984
  uuid: boundaryUuid,
1792
1985
  });
1986
+ await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'manual', compact_summary: summary }, 'manual', signal);
1793
1987
  if (meteringTurnInput !== undefined) {
1794
1988
  const tracker = this.sessionCostTrackers.get(sessionId);
1795
1989
  if (!tracker) {
@@ -2147,6 +2341,14 @@ export class ClaudeSessionService {
2147
2341
  agent?.initialPrompt
2148
2342
  ? `${agent.initialPrompt}\n\n${prompt}`
2149
2343
  : prompt;
2344
+ const projectMemoryRecallTurn = !skipUserPrompt && shellCommand === undefined
2345
+ ? this.options.projectMemoryRecall?.prefetch({
2346
+ sessionId,
2347
+ turnId: randomUUID(),
2348
+ prompt: effectivePrompt,
2349
+ ...(signal ? { signal } : {}),
2350
+ })
2351
+ : undefined;
2150
2352
  const initialPricing = this.options.pricing?.resolve(provider.model ?? 'praxis/provider');
2151
2353
  if (this.options.maxBudgetUsd !== undefined && !initialPricing) {
2152
2354
  throw new Error(`Cannot enforce --max-budget-usd: no pricing is configured for model ${provider.model ?? 'praxis/provider'}`);
@@ -2228,6 +2430,42 @@ export class ClaudeSessionService {
2228
2430
  ? { eventSink: this.options.eventSink }
2229
2431
  : {}),
2230
2432
  enabledTools: taskToolNames,
2433
+ ...(this.options.hooks
2434
+ ? {
2435
+ taskHooks: {
2436
+ created: async (task, taskSignal) => {
2437
+ const outcome = await this.options.hooks?.run({
2438
+ ...hookSession,
2439
+ hook_event_name: 'TaskCreated',
2440
+ task_id: task.id,
2441
+ task_subject: task.subject,
2442
+ task_description: task.description,
2443
+ }, undefined, taskSignal);
2444
+ if (!outcome)
2445
+ return;
2446
+ await recordHookOutcome(outcome);
2447
+ if (outcome.blockedReason) {
2448
+ throw new Error(`TaskCreated hook error: ${outcome.blockedReason}`);
2449
+ }
2450
+ },
2451
+ completed: async (task, taskSignal) => {
2452
+ const outcome = await this.options.hooks?.run({
2453
+ ...hookSession,
2454
+ hook_event_name: 'TaskCompleted',
2455
+ task_id: task.id,
2456
+ task_subject: task.subject,
2457
+ task_description: task.description,
2458
+ }, undefined, taskSignal);
2459
+ if (!outcome)
2460
+ return;
2461
+ await recordHookOutcome(outcome);
2462
+ if (outcome.blockedReason) {
2463
+ throw new Error(`TaskCompleted hook error: ${outcome.blockedReason}`);
2464
+ }
2465
+ },
2466
+ },
2467
+ }
2468
+ : {}),
2231
2469
  })
2232
2470
  : null;
2233
2471
  const scheduledTools = this.scheduledPrompts &&
@@ -2426,6 +2664,11 @@ export class ClaudeSessionService {
2426
2664
  hooks: this.options.hooks,
2427
2665
  session: hookSession,
2428
2666
  recordOutcome: recordHookOutcome,
2667
+ ...(this.options.eventSink
2668
+ ? {
2669
+ warn: (message) => this.options.eventSink?.({ type: 'warning', message }),
2670
+ }
2671
+ : {}),
2429
2672
  deferPreToolUseOutcome: (call) => pendingRecoveryToolCallIds.has(call.id),
2430
2673
  })
2431
2674
  : null;
@@ -2456,6 +2699,8 @@ export class ClaudeSessionService {
2456
2699
  });
2457
2700
  let currentTurnUserMessages = null;
2458
2701
  let currentTurnToolCalls = 0;
2702
+ let projectMemoryMaintained = false;
2703
+ let projectMemoryRecallMessages = [];
2459
2704
  const observer = {
2460
2705
  assistantCompleted: async (message) => {
2461
2706
  const [entry] = translateProviderEvents([
@@ -2534,6 +2779,29 @@ export class ClaudeSessionService {
2534
2779
  throw new Error('Could not translate tool result');
2535
2780
  const tail = await this.append(lease, snapshot.tail, entry);
2536
2781
  snapshot = { entries: [...snapshot.entries, entry], tail };
2782
+ if (!toolResult.isError && this.options.projectMemoryDirectory) {
2783
+ const pathValue = call.input.file_path;
2784
+ const path = typeof pathValue === 'string'
2785
+ ? resolve(this.activeCwd(), pathValue)
2786
+ : null;
2787
+ if (path &&
2788
+ isPathWithin(this.options.projectMemoryDirectory, path)) {
2789
+ if (call.name === 'Read') {
2790
+ this.options.projectMemoryRecall?.recordRead(sessionId, path);
2791
+ }
2792
+ else if (call.name === 'Write' || call.name === 'Edit') {
2793
+ projectMemoryMaintained = true;
2794
+ }
2795
+ }
2796
+ if (call.name === 'Read') {
2797
+ for (const accessedPath of toolResult.accessedPaths ?? []) {
2798
+ const resolvedAccessedPath = resolve(this.activeCwd(), accessedPath);
2799
+ if (isPathWithin(this.options.projectMemoryDirectory, resolvedAccessedPath)) {
2800
+ this.options.projectMemoryRecall?.recordRead(sessionId, resolvedAccessedPath);
2801
+ }
2802
+ }
2803
+ }
2804
+ }
2537
2805
  if (toolResult.isError ||
2538
2806
  call.name !== 'Read' ||
2539
2807
  !this.options.conditionalRuleResolver ||
@@ -2551,6 +2819,19 @@ export class ClaudeSessionService {
2551
2819
  tail: attachmentTail,
2552
2820
  };
2553
2821
  attachedRulePaths.add(rule.path);
2822
+ await this.instructionLoaded(sessionId, {
2823
+ path: rule.path,
2824
+ memoryType: rule.scope === 'user'
2825
+ ? 'User'
2826
+ : rule.scope === 'local'
2827
+ ? 'Local'
2828
+ : 'Project',
2829
+ globs: rule.globs,
2830
+ triggerFilePath: filePath,
2831
+ ...(rule.importedFrom === undefined
2832
+ ? {}
2833
+ : { parentFilePath: rule.importedFrom }),
2834
+ }, 'path_glob_match');
2554
2835
  }
2555
2836
  }
2556
2837
  },
@@ -2807,7 +3088,10 @@ export class ClaudeSessionService {
2807
3088
  const compactIfNeeded = async (pendingMessages = [], preservedUserMessages = [], options = {}) => {
2808
3089
  if (!budget || this.options.autoCompact === false)
2809
3090
  return;
2810
- const historyMessages = projectClaudeModelMessages(snapshot.entries);
3091
+ const historyMessages = [
3092
+ ...projectClaudeModelMessages(snapshot.entries),
3093
+ ...projectMemoryRecallMessages,
3094
+ ];
2811
3095
  const predicted = budget.evaluate([
2812
3096
  ...contextMessages,
2813
3097
  ...injectTurnContext([...historyMessages, ...pendingMessages]),
@@ -2834,6 +3118,7 @@ export class ClaudeSessionService {
2834
3118
  if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
2835
3119
  throw new Error('Cannot compact a Claude session with unresolved tool calls');
2836
3120
  }
3121
+ const preCompact = await this.runAdvisoryHook(sessionId, 'PreCompact', { trigger: 'auto', custom_instructions: null }, 'auto', signal);
2837
3122
  const memorySelection = sessionMemory
2838
3123
  ? await this.selectMemoryPreservedCompact(sessionId, selectClaudeActiveTranscript(snapshot.entries))
2839
3124
  : null;
@@ -2843,6 +3128,10 @@ export class ClaudeSessionService {
2843
3128
  ...projectClaudeModelMessages(memorySelection.compactedEntries),
2844
3129
  ]
2845
3130
  : historyMessages;
3131
+ compactorMessages.push(...successfulHookOutput(preCompact).map((content) => ({
3132
+ role: 'user',
3133
+ content: `Additional summarization context: ${content}`,
3134
+ })));
2846
3135
  if (memorySelection) {
2847
3136
  logicalParentUuid = memorySelection.logicalParentUuid;
2848
3137
  }
@@ -2925,17 +3214,27 @@ export class ClaudeSessionService {
2925
3214
  createUuid: () => replayUuids.shift() ?? randomUUID(),
2926
3215
  now: () => timestamp,
2927
3216
  });
2928
- const compactedHistory = projectClaudeModelMessages([
2929
- ...snapshot.entries,
2930
- ...provisionalEntries,
2931
- ...replayEntries,
2932
- ]);
3217
+ const compactedHistory = [
3218
+ ...projectClaudeModelMessages([
3219
+ ...snapshot.entries,
3220
+ ...provisionalEntries,
3221
+ ...replayEntries,
3222
+ ]),
3223
+ ...projectMemoryRecallMessages,
3224
+ ];
2933
3225
  const afterHistory = budget.evaluate([...contextMessages, ...injectTurnContext(compactedHistory)], definitions);
2934
3226
  const afterPending = budget.evaluate([
2935
3227
  ...contextMessages,
2936
3228
  ...injectTurnContext([...compactedHistory, ...pendingMessages]),
2937
3229
  ], definitions);
2938
3230
  budget.assertFits(afterPending);
3231
+ if (options.promptTooLong === true &&
3232
+ !contextRecoveryMadeProgress({
3233
+ beforeOccupancyTokens: predicted.occupancyTokens,
3234
+ afterOccupancyTokens: afterPending.occupancyTokens,
3235
+ })) {
3236
+ throw new Error('Reactive context compaction made no occupancy progress');
3237
+ }
2939
3238
  const entries = [
2940
3239
  ...compactEntries(afterHistory.estimatedTokens),
2941
3240
  ...replayEntries,
@@ -3003,6 +3302,7 @@ export class ClaudeSessionService {
3003
3302
  entries: [...snapshot.entries, ...entries],
3004
3303
  tail: appendResult.tail,
3005
3304
  };
3305
+ await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3006
3306
  // The boundary is durable: mirror Claude's full-compact behavior by
3007
3307
  // rerunning SessionStart with source compact and refreshing the
3008
3308
  // runtime-only context so the next request retains current
@@ -3016,6 +3316,7 @@ export class ClaudeSessionService {
3016
3316
  lifecycleId: sessionId,
3017
3317
  reason: 'compact',
3018
3318
  });
3319
+ this.options.projectMemoryRecall?.recordCompact(sessionId);
3019
3320
  await refreshRuntimeContext();
3020
3321
  this.options.eventSink?.({
3021
3322
  type: 'compact-boundary',
@@ -3112,6 +3413,7 @@ export class ClaudeSessionService {
3112
3413
  try {
3113
3414
  shellResult = await runtime.executeDirectToolCall(call, {
3114
3415
  cwd: this.activeCwd(),
3416
+ sessionId,
3115
3417
  toolResultDirectory,
3116
3418
  messages: projectClaudeModelMessages(snapshot.entries),
3117
3419
  observer: {
@@ -3183,6 +3485,7 @@ export class ClaudeSessionService {
3183
3485
  }
3184
3486
  let stopHookActive = false;
3185
3487
  const runtimeRequest = {
3488
+ sessionId,
3186
3489
  messages: [
3187
3490
  ...contextMessages,
3188
3491
  ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
@@ -3201,7 +3504,16 @@ export class ClaudeSessionService {
3201
3504
  ...(this.options.collectMetrics || this.options.costStateStore
3202
3505
  ? { collectMetrics: true }
3203
3506
  : {}),
3507
+ ...(budget
3508
+ ? { deferFailureKinds: ['prompt_too_long'] }
3509
+ : {}),
3204
3510
  reloadMessages: async () => {
3511
+ const recalled = projectMemoryRecallTurn?.consumeIfSettled();
3512
+ if (recalled) {
3513
+ projectMemoryRecallMessages = [
3514
+ { role: 'user', content: recalled.content },
3515
+ ];
3516
+ }
3205
3517
  await compactIfNeeded([], currentTurnUserMessages ?? []);
3206
3518
  if (stableSystemMessageCount === undefined) {
3207
3519
  delete runtimeRequest.stableSystemMessageCount;
@@ -3211,7 +3523,10 @@ export class ClaudeSessionService {
3211
3523
  }
3212
3524
  return [
3213
3525
  ...contextMessages,
3214
- ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
3526
+ ...injectTurnContext([
3527
+ ...projectClaudeModelMessages(snapshot.entries),
3528
+ ...projectMemoryRecallMessages,
3529
+ ]),
3215
3530
  ];
3216
3531
  },
3217
3532
  ...(this.options.hooks ||
@@ -3297,47 +3612,95 @@ export class ClaudeSessionService {
3297
3612
  const attemptMainTurn = () => signal
3298
3613
  ? runtime.run({ ...runtimeRequest, signal })
3299
3614
  : runtime.run(runtimeRequest);
3615
+ const surfaceExhaustedRecovery = (error) => {
3616
+ this.options.eventSink?.({
3617
+ type: 'failed',
3618
+ message: error.message,
3619
+ retryable: false,
3620
+ });
3621
+ };
3300
3622
  let result;
3301
3623
  try {
3302
- result = await attemptMainTurn();
3303
- }
3304
- catch (error) {
3305
- if (!budget || !isPromptTooLongError(error))
3306
- throw error;
3307
- if (recoveryPlanner.consumeReactiveRetry() !== 'reactive-retry')
3308
- throw error;
3309
- try {
3310
- await compactIfNeeded([], currentTurnUserMessages ?? [], {
3311
- promptTooLong: true,
3312
- });
3313
- }
3314
- catch {
3315
- // Compaction could not free the provider-bounded context; surface
3316
- // the original prompt-too-long error rather than the compaction
3317
- // failure.
3318
- throw error;
3319
- }
3320
- // The single reactive retry must use the compacted transcript, not
3321
- // the stale request copy captured before the compact boundary.
3322
- runtimeRequest.messages = [
3323
- ...contextMessages,
3324
- ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
3325
- ];
3326
- if (stableSystemMessageCount === undefined) {
3327
- delete runtimeRequest.stableSystemMessageCount;
3328
- }
3329
- else {
3330
- runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
3331
- }
3332
3624
  try {
3333
3625
  result = await attemptMainTurn();
3334
3626
  }
3335
- catch {
3336
- // Exactly one reactive retry is consumed; fail deterministically
3337
- // and surface the original prompt-too-long error.
3338
- recoveryPlanner.recordFailure();
3339
- throw error;
3627
+ catch (error) {
3628
+ if (!budget || !isPromptTooLongError(error))
3629
+ throw error;
3630
+ if (recoveryPlanner.reactiveRetriesRemaining === 0) {
3631
+ surfaceExhaustedRecovery(error);
3632
+ throw error;
3633
+ }
3634
+ const beforeRecovery = budget.evaluate(runtimeRequest.messages, definitions);
3635
+ try {
3636
+ await compactIfNeeded([], currentTurnUserMessages ?? [], {
3637
+ promptTooLong: true,
3638
+ });
3639
+ }
3640
+ catch (compactionError) {
3641
+ if (signal?.aborted ||
3642
+ compactionError instanceof AgentRunCancelledError) {
3643
+ throw new AgentRunCancelledError();
3644
+ }
3645
+ // Compaction could not free the provider-bounded context; surface
3646
+ // the original prompt-too-long error rather than the compaction
3647
+ // failure.
3648
+ surfaceExhaustedRecovery(error);
3649
+ throw error;
3650
+ }
3651
+ // The single reactive retry must use the compacted transcript, not
3652
+ // the stale request copy captured before the compact boundary.
3653
+ runtimeRequest.messages = [
3654
+ ...contextMessages,
3655
+ ...injectTurnContext([
3656
+ ...projectClaudeModelMessages(snapshot.entries),
3657
+ ...projectMemoryRecallMessages,
3658
+ ]),
3659
+ ];
3660
+ if (stableSystemMessageCount === undefined) {
3661
+ delete runtimeRequest.stableSystemMessageCount;
3662
+ }
3663
+ else {
3664
+ runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
3665
+ }
3666
+ const afterRecovery = budget.evaluate(runtimeRequest.messages, definitions);
3667
+ if (recoveryPlanner.consumeReactiveRetry({
3668
+ beforeOccupancyTokens: beforeRecovery.occupancyTokens,
3669
+ afterOccupancyTokens: afterRecovery.occupancyTokens,
3670
+ }) !== 'reactive-retry') {
3671
+ surfaceExhaustedRecovery(error);
3672
+ throw error;
3673
+ }
3674
+ runtimeRequest.deferFailureKinds = true;
3675
+ try {
3676
+ result = await attemptMainTurn();
3677
+ }
3678
+ catch (retryError) {
3679
+ // Exactly one reactive retry is consumed; fail deterministically
3680
+ // and surface the original prompt-too-long error.
3681
+ recoveryPlanner.recordFailure();
3682
+ if (signal?.aborted ||
3683
+ retryError instanceof AgentRunCancelledError) {
3684
+ throw new AgentRunCancelledError();
3685
+ }
3686
+ surfaceExhaustedRecovery(error);
3687
+ throw error;
3688
+ }
3689
+ }
3690
+ }
3691
+ catch (error) {
3692
+ if (!signal?.aborted &&
3693
+ !(error instanceof AgentRunCancelledError) &&
3694
+ !(error instanceof ModelProviderError && error.kind === 'cancelled')) {
3695
+ const failureKind = error instanceof ModelProviderError
3696
+ ? claudeStopFailureError(error.kind)
3697
+ : 'unknown';
3698
+ await this.runAdvisoryHook(sessionId, 'StopFailure', {
3699
+ error: failureKind,
3700
+ error_details: error instanceof Error ? error.message : String(error),
3701
+ }, failureKind, signal);
3340
3702
  }
3703
+ throw error;
3341
3704
  }
3342
3705
  recoveryPlanner.recordSuccess();
3343
3706
  const mainModel = provider.model !== undefined && provider.model.trim() !== ''
@@ -3465,6 +3828,11 @@ export class ClaudeSessionService {
3465
3828
  });
3466
3829
  }
3467
3830
  }
3831
+ this.options.projectMemoryExtraction?.observe({
3832
+ sessionId,
3833
+ directMaintenance: projectMemoryMaintained,
3834
+ messages: projectMemoryMessages(snapshot.entries),
3835
+ });
3468
3836
  turnCompleted = true;
3469
3837
  return {
3470
3838
  sessionId,