praxis-agent 0.31.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.
@@ -188,6 +188,7 @@ export declare class ClaudeSessionService {
188
188
  private readonly hostedSubagentsByRegistry;
189
189
  private readonly backgroundNotificationWrites;
190
190
  private readonly downloadedFileResourceSessions;
191
+ private readonly detachedHookRuns;
191
192
  private activeProvider;
192
193
  private mcpClosePromise;
193
194
  private readonly sessionCostTrackers;
@@ -196,6 +197,7 @@ export declare class ClaudeSessionService {
196
197
  private readonly sessionMemoryControllers;
197
198
  private resolvedSessionMemoryProvider;
198
199
  private readonly hookLifecycle;
200
+ private readonly fileChangeWatcher;
199
201
  private runtimeCwd;
200
202
  constructor(options: ClaudeSessionServiceOptions);
201
203
  nextScheduledPrompt(signal?: AbortSignal): Promise<ScheduledPrompt | null>;
@@ -214,6 +216,23 @@ export declare class ClaudeSessionService {
214
216
  close(): Promise<void>;
215
217
  transitionHookSession(sessionId: string, reason: Exclude<HookSessionEndReason, 'other'>): Promise<void>;
216
218
  reloadContextResources(sessionId: string): void;
219
+ notify(sessionId: string | undefined, message: string, notificationType: string, title?: string, signal?: AbortSignal): Promise<void>;
220
+ notifyDetached(sessionId: string | undefined, message: string, notificationType: string, title?: string): void;
221
+ private drainDetachedHookRuns;
222
+ instructionsLoaded(sessionId: string, resources: readonly {
223
+ path: string;
224
+ scope: 'local' | 'project' | 'user';
225
+ importedFrom?: string;
226
+ }[], reason: 'session_start' | 'compact' | 'resource_reload'): Promise<void>;
227
+ instructionLoaded(sessionId: string, resource: {
228
+ path: string;
229
+ memoryType: 'User' | 'Project' | 'Local' | 'Managed';
230
+ globs?: readonly string[];
231
+ triggerFilePath?: string;
232
+ parentFilePath?: string;
233
+ }, loadReason: 'session_start' | 'nested_traversal' | 'path_glob_match' | 'include' | 'compact'): Promise<void>;
234
+ private runAdvisoryHook;
235
+ private cwdChanged;
217
236
  createHostedToolRegistry(sessionId: string): ToolRegistry;
218
237
  run(prompt: string, signal?: AbortSignal, sessionId?: string, name?: string, images?: readonly ModelImage[], documents?: readonly ModelDocument[]): Promise<SessionRunResult>;
219
238
  runShell(command: string, signal?: AbortSignal, sessionId?: string, name?: string): Promise<SessionRunResult>;
@@ -16,13 +16,14 @@ import { getClaudeAgentSetting, getClaudeLastPrompt, projectClaudeDisplayTranscr
16
16
  import { selectClaudeSchemaAdapter, } from '../compatibility/claude/schema.js';
17
17
  import { findUnresolvedClaudeToolCalls, getClaudeContentBlocks, } from '../compatibility/claude/tool-links.js';
18
18
  import { createClaudeAgentSettingEntry, createClaudeHookAttachmentEntries, createClaudeLastPromptEntry, createClaudeRuleAttachmentEntry, translateProviderEvents, } from '../compatibility/claude/translation.js';
19
- import { AgentRunCancelledError, AgentRuntime, } from '../core/runtime.js';
19
+ import { AgentRunCancelledError, AgentRuntime, ModelProviderError, } from '../core/runtime.js';
20
20
  import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
21
21
  import { BackgroundTaskRuntime, } from './background-task-runtime.js';
22
22
  import { usageCostUsd } from '../core/usage.js';
23
23
  import { ContextBudget, ContextRecoveryPlanner, contextRecoveryMadeProgress, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
24
24
  import { injectFirstUserMessageContext, } from '../core/context.js';
25
25
  import { ClaudeHookToolCoordinator } from '../hooks/claude-hook-tools.js';
26
+ import { ClaudeFileChangeWatcher } from '../hooks/claude-file-change-watcher.js';
26
27
  import { ClaudeTranscriptStore, } from '../persistence/claude-transcript-store.js';
27
28
  import { InMemoryTranscriptStore } from '../persistence/in-memory-transcript-store.js';
28
29
  import { ModelCompactor } from './model-compactor.js';
@@ -481,6 +482,34 @@ function projectMemoryMessages(entries) {
481
482
  : []);
482
483
  });
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
+ }
484
513
  const SESSION_END_HOOK_TIMEOUT_MS = 15_000;
485
514
  class HookLifecycle {
486
515
  hooks;
@@ -606,6 +635,7 @@ export class ClaudeSessionService {
606
635
  hostedSubagentsByRegistry = new WeakMap();
607
636
  backgroundNotificationWrites = new Map();
608
637
  downloadedFileResourceSessions = new Set();
638
+ detachedHookRuns = new Map();
609
639
  activeProvider;
610
640
  mcpClosePromise;
611
641
  sessionCostTrackers = new Map();
@@ -614,11 +644,26 @@ export class ClaudeSessionService {
614
644
  sessionMemoryControllers = new Map();
615
645
  resolvedSessionMemoryProvider;
616
646
  hookLifecycle;
647
+ fileChangeWatcher;
617
648
  runtimeCwd;
618
649
  constructor(options) {
619
650
  this.options = options;
620
651
  this.hookLifecycle = new HookLifecycle(options.hooks, options.eventSink);
621
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;
622
667
  this.schema = selectClaudeSchemaAdapter(options.claudeVersion);
623
668
  this.scheduledPrompts =
624
669
  options.tools && (options.scheduledToolNames?.length ?? 0) > 0
@@ -756,7 +801,10 @@ export class ClaudeSessionService {
756
801
  }
757
802
  }
758
803
  async close() {
804
+ await this.fileChangeWatcher?.close(5_000);
759
805
  await this.hookLifecycle.close();
806
+ await this.drainDetachedHookRuns(5_000);
807
+ await this.options.hooks?.drainAsync(5_000);
760
808
  this.scheduledPrompts?.close();
761
809
  await this.options.projectMemoryExtraction?.close(5_000);
762
810
  await Promise.all([...this.hostedSubagents].map((executor) => executor.close()));
@@ -788,6 +836,119 @@ export class ClaudeSessionService {
788
836
  reason: 'resource-reload',
789
837
  });
790
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
+ }
791
952
  createHostedToolRegistry(sessionId) {
792
953
  const baseTools = this.options.tools;
793
954
  if (!baseTools)
@@ -1451,6 +1612,7 @@ export class ClaudeSessionService {
1451
1612
  lifecycleId: sessionId,
1452
1613
  reason: 'cwd',
1453
1614
  });
1615
+ await this.cwdChanged(sessionId, previousCwd, cwd);
1454
1616
  return cwd;
1455
1617
  }
1456
1618
  this.runtimeCwd = cwd;
@@ -1462,6 +1624,7 @@ export class ClaudeSessionService {
1462
1624
  lifecycleId: sessionId,
1463
1625
  reason: 'cwd',
1464
1626
  });
1627
+ await this.cwdChanged(sessionId, previousCwd, cwd);
1465
1628
  }
1466
1629
  return cwd;
1467
1630
  }
@@ -1710,6 +1873,14 @@ export class ClaudeSessionService {
1710
1873
  if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
1711
1874
  throw new Error('Cannot compact a Claude session with unresolved tool calls');
1712
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
+ })));
1713
1884
  this.options.eventSink?.({ type: 'state', state: 'compacting' });
1714
1885
  const contextWindowTokens = this.contextBudget(provider)?.contextWindowTokens ??
1715
1886
  provider.capabilities.contextWindowTokens ??
@@ -1812,6 +1983,7 @@ export class ClaudeSessionService {
1812
1983
  preTokens,
1813
1984
  uuid: boundaryUuid,
1814
1985
  });
1986
+ await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'manual', compact_summary: summary }, 'manual', signal);
1815
1987
  if (meteringTurnInput !== undefined) {
1816
1988
  const tracker = this.sessionCostTrackers.get(sessionId);
1817
1989
  if (!tracker) {
@@ -2258,6 +2430,42 @@ export class ClaudeSessionService {
2258
2430
  ? { eventSink: this.options.eventSink }
2259
2431
  : {}),
2260
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
+ : {}),
2261
2469
  })
2262
2470
  : null;
2263
2471
  const scheduledTools = this.scheduledPrompts &&
@@ -2456,6 +2664,11 @@ export class ClaudeSessionService {
2456
2664
  hooks: this.options.hooks,
2457
2665
  session: hookSession,
2458
2666
  recordOutcome: recordHookOutcome,
2667
+ ...(this.options.eventSink
2668
+ ? {
2669
+ warn: (message) => this.options.eventSink?.({ type: 'warning', message }),
2670
+ }
2671
+ : {}),
2459
2672
  deferPreToolUseOutcome: (call) => pendingRecoveryToolCallIds.has(call.id),
2460
2673
  })
2461
2674
  : null;
@@ -2606,6 +2819,19 @@ export class ClaudeSessionService {
2606
2819
  tail: attachmentTail,
2607
2820
  };
2608
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');
2609
2835
  }
2610
2836
  }
2611
2837
  },
@@ -2892,6 +3118,7 @@ export class ClaudeSessionService {
2892
3118
  if (findUnresolvedClaudeToolCalls(snapshot.entries).length > 0) {
2893
3119
  throw new Error('Cannot compact a Claude session with unresolved tool calls');
2894
3120
  }
3121
+ const preCompact = await this.runAdvisoryHook(sessionId, 'PreCompact', { trigger: 'auto', custom_instructions: null }, 'auto', signal);
2895
3122
  const memorySelection = sessionMemory
2896
3123
  ? await this.selectMemoryPreservedCompact(sessionId, selectClaudeActiveTranscript(snapshot.entries))
2897
3124
  : null;
@@ -2901,6 +3128,10 @@ export class ClaudeSessionService {
2901
3128
  ...projectClaudeModelMessages(memorySelection.compactedEntries),
2902
3129
  ]
2903
3130
  : historyMessages;
3131
+ compactorMessages.push(...successfulHookOutput(preCompact).map((content) => ({
3132
+ role: 'user',
3133
+ content: `Additional summarization context: ${content}`,
3134
+ })));
2904
3135
  if (memorySelection) {
2905
3136
  logicalParentUuid = memorySelection.logicalParentUuid;
2906
3137
  }
@@ -3071,6 +3302,7 @@ export class ClaudeSessionService {
3071
3302
  entries: [...snapshot.entries, ...entries],
3072
3303
  tail: appendResult.tail,
3073
3304
  };
3305
+ await this.runAdvisoryHook(sessionId, 'PostCompact', { trigger: 'auto', compact_summary: compacted.summary }, 'auto', signal);
3074
3306
  // The boundary is durable: mirror Claude's full-compact behavior by
3075
3307
  // rerunning SessionStart with source compact and refreshing the
3076
3308
  // runtime-only context so the next request retains current
@@ -3181,6 +3413,7 @@ export class ClaudeSessionService {
3181
3413
  try {
3182
3414
  shellResult = await runtime.executeDirectToolCall(call, {
3183
3415
  cwd: this.activeCwd(),
3416
+ sessionId,
3184
3417
  toolResultDirectory,
3185
3418
  messages: projectClaudeModelMessages(snapshot.entries),
3186
3419
  observer: {
@@ -3252,6 +3485,7 @@ export class ClaudeSessionService {
3252
3485
  }
3253
3486
  let stopHookActive = false;
3254
3487
  const runtimeRequest = {
3488
+ sessionId,
3255
3489
  messages: [
3256
3490
  ...contextMessages,
3257
3491
  ...injectTurnContext(projectClaudeModelMessages(snapshot.entries)),
@@ -3387,70 +3621,86 @@ export class ClaudeSessionService {
3387
3621
  };
3388
3622
  let result;
3389
3623
  try {
3390
- result = await attemptMainTurn();
3391
- }
3392
- catch (error) {
3393
- if (!budget || !isPromptTooLongError(error))
3394
- throw error;
3395
- if (recoveryPlanner.reactiveRetriesRemaining === 0) {
3396
- surfaceExhaustedRecovery(error);
3397
- throw error;
3398
- }
3399
- const beforeRecovery = budget.evaluate(runtimeRequest.messages, definitions);
3400
- try {
3401
- await compactIfNeeded([], currentTurnUserMessages ?? [], {
3402
- promptTooLong: true,
3403
- });
3404
- }
3405
- catch (compactionError) {
3406
- if (signal?.aborted ||
3407
- compactionError instanceof AgentRunCancelledError) {
3408
- throw new AgentRunCancelledError();
3409
- }
3410
- // Compaction could not free the provider-bounded context; surface
3411
- // the original prompt-too-long error rather than the compaction
3412
- // failure.
3413
- surfaceExhaustedRecovery(error);
3414
- throw error;
3415
- }
3416
- // The single reactive retry must use the compacted transcript, not
3417
- // the stale request copy captured before the compact boundary.
3418
- runtimeRequest.messages = [
3419
- ...contextMessages,
3420
- ...injectTurnContext([
3421
- ...projectClaudeModelMessages(snapshot.entries),
3422
- ...projectMemoryRecallMessages,
3423
- ]),
3424
- ];
3425
- if (stableSystemMessageCount === undefined) {
3426
- delete runtimeRequest.stableSystemMessageCount;
3427
- }
3428
- else {
3429
- runtimeRequest.stableSystemMessageCount = stableSystemMessageCount;
3430
- }
3431
- const afterRecovery = budget.evaluate(runtimeRequest.messages, definitions);
3432
- if (recoveryPlanner.consumeReactiveRetry({
3433
- beforeOccupancyTokens: beforeRecovery.occupancyTokens,
3434
- afterOccupancyTokens: afterRecovery.occupancyTokens,
3435
- }) !== 'reactive-retry') {
3436
- surfaceExhaustedRecovery(error);
3437
- throw error;
3438
- }
3439
- runtimeRequest.deferFailureKinds = true;
3440
3624
  try {
3441
3625
  result = await attemptMainTurn();
3442
3626
  }
3443
- catch (retryError) {
3444
- // Exactly one reactive retry is consumed; fail deterministically
3445
- // and surface the original prompt-too-long error.
3446
- recoveryPlanner.recordFailure();
3447
- if (signal?.aborted ||
3448
- retryError instanceof AgentRunCancelledError) {
3449
- throw new AgentRunCancelledError();
3627
+ catch (error) {
3628
+ if (!budget || !isPromptTooLongError(error))
3629
+ throw error;
3630
+ if (recoveryPlanner.reactiveRetriesRemaining === 0) {
3631
+ surfaceExhaustedRecovery(error);
3632
+ throw error;
3450
3633
  }
3451
- surfaceExhaustedRecovery(error);
3452
- throw error;
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);
3453
3702
  }
3703
+ throw error;
3454
3704
  }
3455
3705
  recoveryPlanner.recordSuccess();
3456
3706
  const mainModel = provider.model !== undefined && provider.model.trim() !== ''
@@ -1318,6 +1318,11 @@ export class ClaudeSubagentExecutor {
1318
1318
  hooks: scopedHooks,
1319
1319
  session: hookSession,
1320
1320
  recordOutcome: recordHookOutcome,
1321
+ ...(this.options.eventSink
1322
+ ? {
1323
+ warn: (message) => this.options.eventSink?.({ type: 'warning', message }),
1324
+ }
1325
+ : {}),
1321
1326
  })
1322
1327
  : agentTools;
1323
1328
  const runtimePermissions = scopedHooks
@@ -1515,6 +1520,7 @@ export class ClaudeSubagentExecutor {
1515
1520
  const configuredEffort = typeof customAgent?.effort === 'string' ? customAgent.effort : undefined;
1516
1521
  const effectiveEffort = options.effort ?? configuredEffort;
1517
1522
  const result = await runtime.run({
1523
+ sessionId: String(options.root.sessionId),
1518
1524
  messages: await assembleMessages(),
1519
1525
  collectMetrics: true,
1520
1526
  reloadMessages: assembleMessages,
@@ -42,6 +42,7 @@ interface InteractiveSessionCommands {
42
42
  rewindFiles?(sessionId: string, userMessageId: string): Promise<void>;
43
43
  rewindPoints?(sessionId: string): Promise<RewindPoint[]>;
44
44
  changeCwd?(sessionId: string | undefined, cwd: string): Promise<string>;
45
+ notify?(sessionId: string | undefined, message: string, notificationType: string, title?: string): void;
45
46
  recordCdUsage?(sessionId: string): Promise<void>;
46
47
  approveRecentlyDenied?(sessionId: string, display: string): Promise<void>;
47
48
  retryRecentlyDenied?(sessionId: string, display: string, signal?: AbortSignal): Promise<SessionRunResult>;
@@ -189,11 +190,12 @@ interface InteractiveAppProps {
189
190
  runtimeSettings?: PraxisRuntimeSettings;
190
191
  runtimeSettingsTarget?: ConfigSettingsTarget;
191
192
  notificationWriter?: TuiNotificationWriter;
193
+ notificationDelayMs?: number;
192
194
  elicitationUrlOpener?: (url: string) => void | Promise<void>;
193
195
  releaseNotesLoader?: (configRoot: string) => Promise<string>;
194
196
  settingSources?: readonly ClaudeResourceScope[];
195
197
  }
196
- export declare function InteractiveApp({ dataPlane, configRoot: suppliedConfigRoot, statePath: suppliedStatePath, factory, initialSessions, initialPrompt, initialHistory, initialSessionColor, signal, onCancel, onTurnChange, onCleanup, onRendererChange, onBackground, axScreenReader, allowNewSession, resume, display, terminalWidth, slashCommands, agents, allowDangerouslySkipPermissions, additionalDirectories, diffLoader, doctorLoader, fileLoader, externalEditor, keybindingsConfigRoot, keybindingsFile, keybindingsLoader, keybindingsEditor, memoryFilesLoader, memoryEditor, memoryFolderOpener, suspendProcess, clipboardReader, clipboardWriter, sideQuestionClipboardWriter, exportWriter, permissionRuleStore, sandboxStore: suppliedSandboxStore, recentlyDeniedStore: suppliedRecentlyDeniedStore, terminalSetup: terminalSetupOverride, themeStore, initialThemeSettings, initialThemeLoadError, workspaceDirectoryResolver, workspaceDirectoryCompleter, runtimeSettings: suppliedRuntimeSettings, runtimeSettingsTarget, notificationWriter, elicitationUrlOpener, releaseNotesLoader, settingSources, }: InteractiveAppProps): import("react").JSX.Element;
198
+ export declare function InteractiveApp({ dataPlane, configRoot: suppliedConfigRoot, statePath: suppliedStatePath, factory, initialSessions, initialPrompt, initialHistory, initialSessionColor, signal, onCancel, onTurnChange, onCleanup, onRendererChange, onBackground, axScreenReader, allowNewSession, resume, display, terminalWidth, slashCommands, agents, allowDangerouslySkipPermissions, additionalDirectories, diffLoader, doctorLoader, fileLoader, externalEditor, keybindingsConfigRoot, keybindingsFile, keybindingsLoader, keybindingsEditor, memoryFilesLoader, memoryEditor, memoryFolderOpener, suspendProcess, clipboardReader, clipboardWriter, sideQuestionClipboardWriter, exportWriter, permissionRuleStore, sandboxStore: suppliedSandboxStore, recentlyDeniedStore: suppliedRecentlyDeniedStore, terminalSetup: terminalSetupOverride, themeStore, initialThemeSettings, initialThemeLoadError, workspaceDirectoryResolver, workspaceDirectoryCompleter, runtimeSettings: suppliedRuntimeSettings, runtimeSettingsTarget, notificationWriter, notificationDelayMs, elicitationUrlOpener, releaseNotesLoader, settingSources, }: InteractiveAppProps): import("react").JSX.Element;
197
199
  export declare function runInteractive(options: {
198
200
  dataPlane?: DataPlane;
199
201
  configRoot?: string;
@@ -267,7 +267,7 @@ const HIDDEN_TUI_SLASH_COMMANDS = new Set([
267
267
  'update',
268
268
  'usage',
269
269
  ]);
270
- export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: suppliedConfigRoot, statePath: suppliedStatePath, factory, initialSessions, initialPrompt, initialHistory = [], initialSessionColor, signal, onCancel, onTurnChange, onCleanup, onRendererChange, onBackground, axScreenReader = false, allowNewSession = true, resume, display = { version: 'dev', cwd: process.cwd() }, terminalWidth, slashCommands = EMPTY_SLASH_COMMANDS, agents = EMPTY_AGENTS, allowDangerouslySkipPermissions = false, additionalDirectories = [], diffLoader, doctorLoader, fileLoader, externalEditor = editTuiPrompt, keybindingsConfigRoot, keybindingsFile = ensureTuiKeybindingsFile, keybindingsLoader = loadTuiKeybindings, keybindingsEditor = openTuiEditorFile, memoryFilesLoader, memoryEditor = openTuiEditorFile, memoryFolderOpener = openTuiMemoryFolder, suspendProcess = suspendTuiProcess, clipboardReader = readTuiClipboard, clipboardWriter = writeTuiClipboard, sideQuestionClipboardWriter = writeTuiOsc52Clipboard, exportWriter = writeConversationExport, permissionRuleStore, sandboxStore: suppliedSandboxStore, recentlyDeniedStore: suppliedRecentlyDeniedStore, terminalSetup: terminalSetupOverride, themeStore, initialThemeSettings, initialThemeLoadError, workspaceDirectoryResolver = resolveTuiWorkspaceDirectory, workspaceDirectoryCompleter = completeTuiWorkspaceDirectory, runtimeSettings: suppliedRuntimeSettings, runtimeSettingsTarget, notificationWriter, elicitationUrlOpener = openTuiUrl, releaseNotesLoader = (configRoot) => loadClaudeReleaseNotes({ configRoot }), settingSources, }) {
270
+ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: suppliedConfigRoot, statePath: suppliedStatePath, factory, initialSessions, initialPrompt, initialHistory = [], initialSessionColor, signal, onCancel, onTurnChange, onCleanup, onRendererChange, onBackground, axScreenReader = false, allowNewSession = true, resume, display = { version: 'dev', cwd: process.cwd() }, terminalWidth, slashCommands = EMPTY_SLASH_COMMANDS, agents = EMPTY_AGENTS, allowDangerouslySkipPermissions = false, additionalDirectories = [], diffLoader, doctorLoader, fileLoader, externalEditor = editTuiPrompt, keybindingsConfigRoot, keybindingsFile = ensureTuiKeybindingsFile, keybindingsLoader = loadTuiKeybindings, keybindingsEditor = openTuiEditorFile, memoryFilesLoader, memoryEditor = openTuiEditorFile, memoryFolderOpener = openTuiMemoryFolder, suspendProcess = suspendTuiProcess, clipboardReader = readTuiClipboard, clipboardWriter = writeTuiClipboard, sideQuestionClipboardWriter = writeTuiOsc52Clipboard, exportWriter = writeConversationExport, permissionRuleStore, sandboxStore: suppliedSandboxStore, recentlyDeniedStore: suppliedRecentlyDeniedStore, terminalSetup: terminalSetupOverride, themeStore, initialThemeSettings, initialThemeLoadError, workspaceDirectoryResolver = resolveTuiWorkspaceDirectory, workspaceDirectoryCompleter = completeTuiWorkspaceDirectory, runtimeSettings: suppliedRuntimeSettings, runtimeSettingsTarget, notificationWriter, notificationDelayMs = 6_000, elicitationUrlOpener = openTuiUrl, releaseNotesLoader = (configRoot) => loadClaudeReleaseNotes({ configRoot }), settingSources, }) {
271
271
  const { exit, suspendTerminal, waitUntilRenderFlush } = useApp();
272
272
  const width = useTerminalWidth(terminalWidth);
273
273
  const rows = useTerminalRows();
@@ -1319,6 +1319,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1319
1319
  kind: 'notice',
1320
1320
  text: `MCP elicitation completed · ${event.mcpServerName}`,
1321
1321
  });
1322
+ serviceRef.current?.notify?.(sessionIdRef.current ?? undefined, `MCP elicitation completed · ${event.mcpServerName}`, 'elicitation_complete', 'Praxis');
1322
1323
  if (elicitationUrlWaitingRef.current?.request.serverName ===
1323
1324
  event.mcpServerName &&
1324
1325
  elicitationUrlWaitingRef.current.request.elicitationId ===
@@ -1364,6 +1365,11 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1364
1365
  : null;
1365
1366
  const editableRule = projected?.options.find((option) => option.editableRule)?.editableRule;
1366
1367
  let settled = false;
1368
+ const notificationTimer = setTimeout(() => {
1369
+ if (settled)
1370
+ return;
1371
+ serviceRef.current?.notify?.(sessionIdRef.current ?? undefined, `Approval required for ${call.name}`, 'permission_prompt', 'Praxis');
1372
+ }, notificationDelayMs);
1367
1373
  const pending = {
1368
1374
  kind,
1369
1375
  call,
@@ -1372,6 +1378,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1372
1378
  if (settled)
1373
1379
  return;
1374
1380
  settled = true;
1381
+ clearTimeout(notificationTimer);
1375
1382
  if (permissionRef.current === pending)
1376
1383
  permissionRef.current = null;
1377
1384
  setPermission((current) => (current === pending ? null : current));
@@ -1388,12 +1395,20 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1388
1395
  const approveTool = (call, _originalCall, decision) => requestApproval(call, 'tool', decision);
1389
1396
  const requestElicitation = (request) => new Promise((resolveResult) => {
1390
1397
  let settled = false;
1398
+ const notificationTimer = setTimeout(() => {
1399
+ if (settled)
1400
+ return;
1401
+ serviceRef.current?.notify?.(sessionIdRef.current ?? undefined, 'Praxis needs your input', request.mode === 'url'
1402
+ ? 'elicitation_url_dialog'
1403
+ : 'elicitation_dialog', 'Praxis');
1404
+ }, notificationDelayMs);
1391
1405
  const pending = {
1392
1406
  request,
1393
1407
  resolve: (result, options) => {
1394
1408
  if (settled)
1395
1409
  return;
1396
1410
  settled = true;
1411
+ clearTimeout(notificationTimer);
1397
1412
  if (elicitationRef.current === pending)
1398
1413
  elicitationRef.current = null;
1399
1414
  if (!options?.keepUrlDialog) {