praxis-agent 0.26.0 → 0.28.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 (44) hide show
  1. package/dist/application/session-memory.d.ts +43 -10
  2. package/dist/application/session-memory.js +263 -62
  3. package/dist/application/session-service.d.ts +13 -4
  4. package/dist/application/session-service.js +139 -36
  5. package/dist/application/subagent-service.d.ts +1 -0
  6. package/dist/application/subagent-service.js +23 -1
  7. package/dist/cli-runtime.d.ts +4 -0
  8. package/dist/cli-runtime.js +10 -0
  9. package/dist/core/runtime.d.ts +10 -0
  10. package/dist/core/runtime.js +111 -28
  11. package/dist/core/tool-execution-scheduler.d.ts +38 -0
  12. package/dist/core/tool-execution-scheduler.js +95 -0
  13. package/dist/core/tool-scheduling-policy.d.ts +4 -0
  14. package/dist/core/tool-scheduling-policy.js +24 -0
  15. package/dist/extensions/claude-extension-tools.d.ts +1 -0
  16. package/dist/extensions/claude-extension-tools.js +6 -0
  17. package/dist/hooks/claude-hook-tools.d.ts +4 -0
  18. package/dist/hooks/claude-hook-tools.js +6 -0
  19. package/dist/mcp/claude-mcp-tools.d.ts +1 -0
  20. package/dist/mcp/claude-mcp-tools.js +42 -0
  21. package/dist/permissions/claude-permission-resolver.js +3 -3
  22. package/dist/permissions/permission-updates.d.ts +1 -0
  23. package/dist/permissions/permission-updates.js +13 -1
  24. package/dist/tools/claude-capabilities.d.ts +1 -0
  25. package/dist/tools/claude-capabilities.js +5 -0
  26. package/dist/tools/claude-interactive-tools.js +7 -0
  27. package/dist/tools/claude-lsp-tool.js +7 -0
  28. package/dist/tools/claude-scheduled-tools.d.ts +1 -0
  29. package/dist/tools/claude-scheduled-tools.js +7 -0
  30. package/dist/tools/claude-task-tools.d.ts +1 -0
  31. package/dist/tools/claude-task-tools.js +11 -0
  32. package/dist/tools/claude-user-message.d.ts +1 -0
  33. package/dist/tools/claude-user-message.js +7 -0
  34. package/dist/tools/claude-workflow-tools.d.ts +1 -0
  35. package/dist/tools/claude-workflow-tools.js +7 -0
  36. package/dist/tools/claude-worktree-tools.d.ts +1 -0
  37. package/dist/tools/claude-worktree-tools.js +7 -0
  38. package/dist/tools/filtered-tool-registry.d.ts +1 -0
  39. package/dist/tools/filtered-tool-registry.js +5 -0
  40. package/dist/tools/local-tools.d.ts +17 -0
  41. package/dist/tools/local-tools.js +86 -1
  42. package/dist/tools/web.d.ts +1 -0
  43. package/dist/tools/web.js +27 -0
  44. package/package.json +1 -1
@@ -1,3 +1,5 @@
1
+ import { ToolExecutionScheduler } from './tool-execution-scheduler.js';
2
+ import { resolveToolSchedulingPolicy } from './tool-scheduling-policy.js';
1
3
  const permissionDecisionSources = new WeakMap();
2
4
  const autoModePermissionOutcomes = new WeakMap();
3
5
  export function annotatePermissionDecision(decision, source) {
@@ -274,13 +276,23 @@ export class AgentRuntime {
274
276
  const definitions = this.provider.capabilities.tools
275
277
  ? (this.options.tools?.definitions() ?? [])
276
278
  : [];
277
- const maxModelTurns = request.maxModelTurns ?? this.options.maxModelTurns ?? 16;
279
+ const maxModelTurns = request.maxModelTurns ?? this.options.maxModelTurns;
280
+ if (maxModelTurns !== undefined &&
281
+ (!Number.isSafeInteger(maxModelTurns) || maxModelTurns <= 0)) {
282
+ throw new TypeError('maxModelTurns must be a positive integer');
283
+ }
278
284
  const maxModelOutputBytes = this.options.maxModelOutputBytes ?? 1024 * 1024;
279
285
  const maxToolCallsPerTurn = this.options.maxToolCallsPerTurn ?? 32;
280
286
  const maxToolInputBytes = this.options.maxToolInputBytes ?? 1024 * 1024;
281
287
  let pendingToolUseSummary;
282
288
  try {
283
- for (let turn = 0; turn < maxModelTurns; turn += 1) {
289
+ let modelTurns = 0;
290
+ while (true) {
291
+ if (request.signal?.aborted)
292
+ return this.cancel();
293
+ if (maxModelTurns !== undefined && modelTurns >= maxModelTurns) {
294
+ throw new Error(`Maximum model turns of ${maxModelTurns} exceeded`);
295
+ }
284
296
  if (pendingToolUseSummary) {
285
297
  const summaryRequest = pendingToolUseSummary;
286
298
  pendingToolUseSummary = undefined;
@@ -326,6 +338,7 @@ export class AgentRuntime {
326
338
  spent >= this.options.maxBudgetUsd) {
327
339
  throw new Error(`Maximum budget of $${this.options.maxBudgetUsd.toFixed(6)} exceeded`);
328
340
  }
341
+ modelTurns += 1;
329
342
  this.emit({ type: 'state', state: 'awaiting-model' });
330
343
  const providerRequest = {
331
344
  messages: prepareProviderMessages(messages, this.provider.capabilities.images === true, this.provider.capabilities.documents === true),
@@ -347,6 +360,25 @@ export class AgentRuntime {
347
360
  let streaming = false;
348
361
  const toolCalls = [];
349
362
  let terminalReason;
363
+ const toolScheduler = new ToolExecutionScheduler({
364
+ ...(request.signal ? { parentSignal: request.signal } : {}),
365
+ execute: (call, signal) => this.executeScheduledToolCall(call, request, messages, signal),
366
+ isError: (result) => result.isError,
367
+ cancelledResult: (call, reason) => ({
368
+ content: `${call.name} cancelled: ${reason instanceof Error ? reason.message : String(reason)}`,
369
+ isError: true,
370
+ }),
371
+ failedResult: (call, reason) => ({
372
+ content: `${call.name} failed: ${reason instanceof Error ? reason.message : String(reason)}`,
373
+ isError: true,
374
+ }),
375
+ resultCompleted: (call, result) => this.presentToolResult(call, result),
376
+ });
377
+ const failScheduledTurn = async (error) => {
378
+ toolScheduler.abort(error);
379
+ await toolScheduler.settle().catch(() => undefined);
380
+ throw error;
381
+ };
350
382
  const apiStartedAt = request.collectMetrics ? performance.now() : 0;
351
383
  let turnApiDurationMs = 0;
352
384
  let turnApiDurationWithoutRetriesMs;
@@ -422,6 +454,7 @@ export class AgentRuntime {
422
454
  }
423
455
  toolCalls.push(event.call);
424
456
  this.emit(event);
457
+ toolScheduler.schedule(event.call, resolveToolSchedulingPolicy(this.options.tools, event.call));
425
458
  }
426
459
  else if (event.type === 'terminal') {
427
460
  terminalReason = event.reason;
@@ -433,6 +466,19 @@ export class AgentRuntime {
433
466
  }
434
467
  }
435
468
  }
469
+ catch (error) {
470
+ if (!request.signal?.aborted &&
471
+ !(error instanceof AgentRunCancelledError)) {
472
+ toolScheduler.abort(error);
473
+ }
474
+ else {
475
+ toolScheduler.releaseExclusiveTools();
476
+ }
477
+ await toolScheduler.settle().catch(() => undefined);
478
+ if (request.signal?.aborted)
479
+ return this.cancel();
480
+ throw error;
481
+ }
436
482
  finally {
437
483
  if (request.collectMetrics) {
438
484
  turnApiDurationMs = Math.max(0, performance.now() - apiStartedAt);
@@ -442,15 +488,15 @@ export class AgentRuntime {
442
488
  }
443
489
  if (this.provider.capabilities.terminalReasons === true &&
444
490
  terminalReason === undefined) {
445
- throw new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true });
491
+ return failScheduledTurn(new ModelProviderError('Provider stream ended without a terminal reason', { retryable: true }));
446
492
  }
447
493
  if (terminalReason === 'tool_use' && toolCalls.length === 0) {
448
- throw new ModelProviderError('Provider reported tool_use without a completed tool call', { retryable: false });
494
+ return failScheduledTurn(new ModelProviderError('Provider reported tool_use without a completed tool call', { retryable: false }));
449
495
  }
450
496
  if (terminalReason !== undefined &&
451
497
  terminalReason !== 'tool_use' &&
452
498
  toolCalls.length > 0) {
453
- throw new ModelProviderError(`Provider reported ${terminalReason} with completed tool calls`, { retryable: false });
499
+ return failScheduledTurn(new ModelProviderError(`Provider reported ${terminalReason} with completed tool calls`, { retryable: false }));
454
500
  }
455
501
  if (request.collectMetrics) {
456
502
  const turnApiDurationWithoutRetriesMsResolved = turnApiDurationWithoutRetriesMs ?? turnApiDurationMs;
@@ -480,8 +526,14 @@ export class AgentRuntime {
480
526
  ...(thinkingBlocks.length > 0 ? { thinkingBlocks } : {}),
481
527
  toolCalls,
482
528
  };
483
- await request.observer?.assistantCompleted(assistantMessage);
529
+ try {
530
+ await request.observer?.assistantCompleted(assistantMessage);
531
+ }
532
+ catch (error) {
533
+ return failScheduledTurn(error);
534
+ }
484
535
  messages.push(assistantMessage);
536
+ toolScheduler.releaseExclusiveTools();
485
537
  if (toolCalls.length === 0) {
486
538
  const stopResult = (await request.onStop?.(text)) ?? [];
487
539
  const stopBatch = Array.isArray(stopResult)
@@ -547,10 +599,11 @@ export class AgentRuntime {
547
599
  ...(linesRemoved === 0 ? {} : { linesRemoved }),
548
600
  };
549
601
  }
602
+ const scheduledToolResults = await toolScheduler.settle();
550
603
  const followUpUserMessages = [];
551
604
  const completedTools = [];
552
- for (const call of toolCalls) {
553
- const result = await this.completeToolCall(call, request, messages);
605
+ for (const { call, result } of scheduledToolResults) {
606
+ await this.persistToolCall(call, result, request);
554
607
  completedTools.push({
555
608
  name: call.name,
556
609
  input: call.input,
@@ -584,6 +637,10 @@ export class AgentRuntime {
584
637
  });
585
638
  followUpUserMessages.push(...(result.followUpUserMessages ?? []));
586
639
  }
640
+ const toolFailure = toolScheduler.failure;
641
+ if (toolFailure) {
642
+ throw toolFailure.error;
643
+ }
587
644
  if (this.options.generateToolUseSummary) {
588
645
  const summarySignal = request.signal ?? new AbortController().signal;
589
646
  pendingToolUseSummary = {
@@ -609,7 +666,6 @@ export class AgentRuntime {
609
666
  messages.splice(0, messages.length, ...reloadedMessages);
610
667
  }
611
668
  }
612
- throw new Error(`Agent exceeded ${maxModelTurns} model turns`);
613
669
  }
614
670
  catch (error) {
615
671
  if (request.signal?.aborted)
@@ -659,19 +715,38 @@ export class AgentRuntime {
659
715
  return result;
660
716
  }
661
717
  async completeToolCall(call, request, messages = request.messages ?? [], emitPresentation = true) {
718
+ const signal = request.signal ?? new AbortController().signal;
719
+ let result;
720
+ try {
721
+ result = await this.executeScheduledToolCall(call, request, messages, signal, emitPresentation);
722
+ }
723
+ catch (error) {
724
+ if (request.signal?.aborted)
725
+ return this.cancel();
726
+ throw error;
727
+ }
728
+ if (emitPresentation)
729
+ this.presentToolResult(call, result);
730
+ await this.persistToolCall(call, result, request);
731
+ return result;
732
+ }
733
+ async executeScheduledToolCall(call, request, messages, signal, emitPresentation = true) {
662
734
  const startedAt = performance.now();
663
- const emitProgress = () => this.emit({
735
+ const emitProgress = (elapsedTimeSeconds) => this.emit({
664
736
  type: 'tool-progress',
665
737
  toolUseId: call.id,
666
738
  toolName: call.name,
667
- elapsedTimeSeconds: Math.max(0, Math.round(((performance.now() - startedAt) / 1000) * 1000) / 1000),
739
+ elapsedTimeSeconds: elapsedTimeSeconds ??
740
+ Math.max(0, Math.round(((performance.now() - startedAt) / 1000) * 1000) / 1000),
668
741
  });
669
742
  const progressTimer = emitPresentation
670
743
  ? setInterval(emitProgress, 1000)
671
744
  : undefined;
672
745
  progressTimer?.unref();
673
746
  try {
674
- const executed = await this.executeTool(call, request, messages);
747
+ if (emitPresentation)
748
+ emitProgress(0);
749
+ const executed = await this.executeTool(call, { ...request, signal }, messages);
675
750
  const unsupportedImages = executed.images?.length && this.provider.capabilities.images !== true;
676
751
  const unsupportedDocuments = executed.documents?.length &&
677
752
  this.provider.capabilities.documents !== true;
@@ -690,16 +765,8 @@ export class AgentRuntime {
690
765
  : {}),
691
766
  }
692
767
  : executed;
693
- this.emit({ type: 'state', state: 'persisting-results' });
694
- await request.observer?.toolCompleted(call, result);
695
768
  if (emitPresentation) {
696
769
  emitProgress();
697
- this.emit({
698
- type: 'tool-result',
699
- callId: call.id,
700
- content: result.content,
701
- isError: result.isError,
702
- });
703
770
  }
704
771
  return result;
705
772
  }
@@ -708,6 +775,18 @@ export class AgentRuntime {
708
775
  clearInterval(progressTimer);
709
776
  }
710
777
  }
778
+ presentToolResult(call, result) {
779
+ this.emit({
780
+ type: 'tool-result',
781
+ callId: call.id,
782
+ content: result.content,
783
+ isError: result.isError,
784
+ });
785
+ }
786
+ async persistToolCall(call, result, request) {
787
+ this.emit({ type: 'state', state: 'persisting-results' });
788
+ await request.observer?.toolCompleted(call, result);
789
+ }
711
790
  async executeTool(call, request, messages) {
712
791
  const tools = this.options.tools;
713
792
  const permissions = this.options.permissions;
@@ -808,8 +887,9 @@ export class AgentRuntime {
808
887
  : 'Permission approval was not provided');
809
888
  return { content: reason, isError: true };
810
889
  }
811
- if (request.signal?.aborted)
812
- return this.cancel();
890
+ if (request.signal?.aborted) {
891
+ throw request.signal.reason ?? new AgentRunCancelledError();
892
+ }
813
893
  this.emit({ type: 'state', state: 'executing-tools' });
814
894
  context.permissionPhase = 'execute';
815
895
  context.permissionApproved = true;
@@ -829,8 +909,9 @@ export class AgentRuntime {
829
909
  };
830
910
  }
831
911
  catch (error) {
832
- if (request.signal?.aborted)
833
- return this.cancel();
912
+ if (request.signal?.aborted) {
913
+ throw request.signal.reason ?? new AgentRunCancelledError();
914
+ }
834
915
  const durationToolMs = Math.max(0, performance.now() - toolStartedAt);
835
916
  return {
836
917
  content: error instanceof Error ? error.message : String(error),
@@ -840,13 +921,15 @@ export class AgentRuntime {
840
921
  }
841
922
  }
842
923
  async requireRecoveryApproval(call, request) {
843
- if (request.signal?.aborted)
844
- return this.cancel();
924
+ if (request.signal?.aborted) {
925
+ throw request.signal.reason ?? new AgentRunCancelledError();
926
+ }
845
927
  if (request.approveRecovery && !(await request.approveRecovery(call))) {
846
928
  throw new Error(`Tool call ${call.id} recovery was declined`);
847
929
  }
848
- if (request.signal?.aborted)
849
- return this.cancel();
930
+ if (request.signal?.aborted) {
931
+ throw request.signal.reason ?? new AgentRunCancelledError();
932
+ }
850
933
  }
851
934
  cancel() {
852
935
  this.emit({ type: 'state', state: 'cancelled' });
@@ -0,0 +1,38 @@
1
+ import type { ModelToolCall, ToolSchedulingPolicy } from './runtime.js';
2
+ interface ScheduledToolResult<TResult> {
3
+ call: ModelToolCall;
4
+ result: TResult;
5
+ }
6
+ interface ToolExecutionSchedulerOptions<TResult> {
7
+ parentSignal?: AbortSignal;
8
+ execute(call: ModelToolCall, signal: AbortSignal): Promise<TResult>;
9
+ isError(result: TResult): boolean;
10
+ cancelledResult(call: ModelToolCall, reason: unknown): TResult;
11
+ failedResult(call: ModelToolCall, reason: unknown): TResult;
12
+ resultCompleted(call: ModelToolCall, result: TResult): void;
13
+ }
14
+ export declare class ToolExecutionScheduler<TResult> {
15
+ private readonly options;
16
+ private readonly groupController;
17
+ private readonly exclusiveStart;
18
+ private releaseExclusiveStart;
19
+ private exclusiveStarted;
20
+ private hasFailure;
21
+ private firstFailure;
22
+ private readonly scheduled;
23
+ private readonly completionOrder;
24
+ private precedingBarrier;
25
+ private concurrentGroup;
26
+ constructor(options: ToolExecutionSchedulerOptions<TResult>);
27
+ schedule(call: ModelToolCall, policy: ToolSchedulingPolicy): void;
28
+ abort(reason?: unknown): void;
29
+ releaseExclusiveTools(): void;
30
+ settle(): Promise<readonly ScheduledToolResult<TResult>[]>;
31
+ get failure(): {
32
+ error: unknown;
33
+ } | undefined;
34
+ private record;
35
+ private executionSignal;
36
+ }
37
+ export {};
38
+ //# sourceMappingURL=tool-execution-scheduler.d.ts.map
@@ -0,0 +1,95 @@
1
+ const settled = Promise.resolve();
2
+ export class ToolExecutionScheduler {
3
+ options;
4
+ groupController = new AbortController();
5
+ exclusiveStart;
6
+ releaseExclusiveStart;
7
+ exclusiveStarted = false;
8
+ hasFailure = false;
9
+ firstFailure;
10
+ scheduled = [];
11
+ completionOrder = [];
12
+ precedingBarrier = settled;
13
+ concurrentGroup = [];
14
+ constructor(options) {
15
+ this.options = options;
16
+ this.exclusiveStart = new Promise((resolve) => {
17
+ this.releaseExclusiveStart = resolve;
18
+ });
19
+ }
20
+ schedule(call, policy) {
21
+ const predecessors = policy.concurrency === 'exclusive'
22
+ ? Promise.allSettled([
23
+ ...(policy.startAfterAssistant ? [this.exclusiveStart] : []),
24
+ this.precedingBarrier,
25
+ ...this.concurrentGroup,
26
+ ]).then(() => undefined)
27
+ : this.precedingBarrier;
28
+ const signal = this.executionSignal(policy);
29
+ const execution = predecessors.then(async () => {
30
+ let result;
31
+ try {
32
+ if (signal.aborted)
33
+ throw signal.reason;
34
+ result = await this.options.execute(call, signal);
35
+ if (signal.aborted)
36
+ throw signal.reason;
37
+ }
38
+ catch (error) {
39
+ if (signal.aborted) {
40
+ result = this.options.cancelledResult(call, error);
41
+ }
42
+ else {
43
+ if (!this.hasFailure) {
44
+ this.hasFailure = true;
45
+ this.firstFailure = error;
46
+ }
47
+ result = this.options.failedResult(call, error);
48
+ }
49
+ }
50
+ this.record(call, result);
51
+ if (policy.abortGroupOnError && this.options.isError(result)) {
52
+ this.groupController.abort(new Error(`Tool ${call.name} failed; cancelling streamed siblings`));
53
+ }
54
+ });
55
+ this.scheduled.push(execution);
56
+ if (policy.concurrency === 'exclusive') {
57
+ this.precedingBarrier = execution.catch(() => undefined);
58
+ this.concurrentGroup = [];
59
+ }
60
+ else {
61
+ this.concurrentGroup.push(execution.catch(() => undefined));
62
+ }
63
+ }
64
+ abort(reason) {
65
+ this.releaseExclusiveTools();
66
+ this.groupController.abort(reason);
67
+ }
68
+ releaseExclusiveTools() {
69
+ if (this.exclusiveStarted)
70
+ return;
71
+ this.exclusiveStarted = true;
72
+ this.releaseExclusiveStart();
73
+ }
74
+ async settle() {
75
+ await Promise.all(this.scheduled);
76
+ return this.completionOrder;
77
+ }
78
+ get failure() {
79
+ return this.hasFailure ? { error: this.firstFailure } : undefined;
80
+ }
81
+ record(call, result) {
82
+ this.completionOrder.push({ call, result });
83
+ this.options.resultCompleted(call, result);
84
+ }
85
+ executionSignal(policy) {
86
+ const signals = [this.groupController.signal];
87
+ if (policy.cancelOnInterrupt && this.options.parentSignal) {
88
+ signals.push(this.options.parentSignal);
89
+ }
90
+ return signals.length === 1
91
+ ? this.groupController.signal
92
+ : AbortSignal.any(signals);
93
+ }
94
+ }
95
+ //# sourceMappingURL=tool-execution-scheduler.js.map
@@ -0,0 +1,4 @@
1
+ import type { ModelToolCall, ToolRegistry, ToolSchedulingPolicy } from './runtime.js';
2
+ export declare const exclusiveToolSchedulingPolicy: ToolSchedulingPolicy;
3
+ export declare function resolveToolSchedulingPolicy(tools: ToolRegistry | undefined, call: ModelToolCall): ToolSchedulingPolicy;
4
+ //# sourceMappingURL=tool-scheduling-policy.d.ts.map
@@ -0,0 +1,24 @@
1
+ export const exclusiveToolSchedulingPolicy = {
2
+ concurrency: 'exclusive',
3
+ };
4
+ export function resolveToolSchedulingPolicy(tools, call) {
5
+ try {
6
+ const policy = tools?.schedulingPolicy?.(structuredClone(call));
7
+ if (!policy ||
8
+ (policy.concurrency !== 'concurrent' &&
9
+ policy.concurrency !== 'exclusive') ||
10
+ (policy.cancelOnInterrupt !== undefined &&
11
+ typeof policy.cancelOnInterrupt !== 'boolean') ||
12
+ (policy.abortGroupOnError !== undefined &&
13
+ typeof policy.abortGroupOnError !== 'boolean') ||
14
+ (policy.startAfterAssistant !== undefined &&
15
+ typeof policy.startAfterAssistant !== 'boolean')) {
16
+ return exclusiveToolSchedulingPolicy;
17
+ }
18
+ return policy;
19
+ }
20
+ catch {
21
+ return exclusiveToolSchedulingPolicy;
22
+ }
23
+ }
24
+ //# sourceMappingURL=tool-scheduling-policy.js.map
@@ -5,6 +5,7 @@ export declare class ClaudeExtensionToolRegistry implements ToolRegistry {
5
5
  private readonly catalog;
6
6
  constructor(base: ToolRegistry, catalog: ClaudeExtensionCatalog);
7
7
  definitions(): readonly ModelToolDefinition[];
8
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
8
9
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
9
10
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
10
11
  }
@@ -1,3 +1,4 @@
1
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
1
2
  function skillInput(call) {
2
3
  const skill = call.input.skill;
3
4
  const args = call.input.args ?? '';
@@ -39,6 +40,11 @@ export class ClaudeExtensionToolRegistry {
39
40
  },
40
41
  ];
41
42
  }
43
+ schedulingPolicy(call) {
44
+ if (call.name === 'Skill')
45
+ return { concurrency: 'exclusive' };
46
+ return resolveToolSchedulingPolicy(this.base, call);
47
+ }
42
48
  async prepare(call, context) {
43
49
  if (call.name !== 'Skill')
44
50
  return this.base.prepare(call, context);
@@ -19,6 +19,10 @@ export declare class ClaudeHookToolCoordinator implements ToolRegistry, Permissi
19
19
  private readonly prepared;
20
20
  constructor(options: ClaudeHookToolCoordinatorOptions);
21
21
  definitions(): readonly import("../core/runtime.js").ModelToolDefinition[];
22
+ schedulingPolicy(): {
23
+ concurrency: "exclusive";
24
+ startAfterAssistant: boolean;
25
+ };
22
26
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
23
27
  resolve(call: ModelToolCall, context?: PermissionResolutionContext): Promise<PermissionDecision>;
24
28
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
@@ -7,6 +7,12 @@ export class ClaudeHookToolCoordinator {
7
7
  definitions() {
8
8
  return this.options.tools.definitions();
9
9
  }
10
+ schedulingPolicy() {
11
+ return {
12
+ concurrency: 'exclusive',
13
+ startAfterAssistant: true,
14
+ };
15
+ }
10
16
  async prepare(call, context) {
11
17
  const outcome = await this.options.hooks.run({
12
18
  ...this.options.session,
@@ -102,6 +102,7 @@ export declare class ClaudeMcpToolRegistry implements ToolRegistry, ClaudeMcpRun
102
102
  private constructor();
103
103
  static connect(options: ClaudeMcpToolRegistryOptions): Promise<ClaudeMcpToolRegistry>;
104
104
  definitions(): readonly ModelToolDefinition[];
105
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
105
106
  serverStatuses(): readonly ClaudeMcpServerStatus[];
106
107
  inspect(): Promise<readonly ClaudeMcpServerStatus[]>;
107
108
  reconnect(name: string): Promise<void>;
@@ -7,6 +7,8 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
7
7
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
8
8
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
9
9
  import { ElicitRequestSchema, ElicitationCompleteNotificationSchema, PromptListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js';
10
+ import { Ajv2020 } from 'ajv/dist/2020.js';
11
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
10
12
  import { redactSensitiveError, redactSensitiveText, redactSensitiveValue, sanitizeChildEnvironment, sensitiveEnvironmentValues, } from '../platform/sensitive-data.js';
11
13
  import { parsePermissionUpdates } from '../permissions/permission-updates.js';
12
14
  import { loadMcpOAuthProvider, mcpOAuthServerIdentity, } from './claude-mcp-oauth.js';
@@ -77,6 +79,28 @@ const MCP_RESOURCE_TOOL_DEFINITIONS = [
77
79
  },
78
80
  },
79
81
  ];
82
+ const schedulingAjv = new Ajv2020({ strict: false, validateFormats: false });
83
+ function schedulingInputValidator(schema) {
84
+ try {
85
+ const validate = schedulingAjv.compile(schema);
86
+ return (input) => validate(input) === true;
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ }
92
+ function mcpResourceSchedulingInputIsValid(call) {
93
+ const keys = Object.keys(call.input);
94
+ if (call.name === 'ListMcpResourcesTool') {
95
+ return (keys.every((name) => name === 'server') &&
96
+ (call.input.server === undefined || typeof call.input.server === 'string'));
97
+ }
98
+ return ((call.name === 'ReadMcpResourceDirTool' ||
99
+ call.name === 'ReadMcpResourceTool') &&
100
+ keys.every((name) => name === 'server' || name === 'uri') &&
101
+ typeof call.input.server === 'string' &&
102
+ typeof call.input.uri === 'string');
103
+ }
80
104
  function isRecord(value) {
81
105
  return typeof value === 'object' && value !== null && !Array.isArray(value);
82
106
  }
@@ -687,6 +711,21 @@ export class ClaudeMcpToolRegistry {
687
711
  ...(this.resourceServers.size > 0 ? MCP_RESOURCE_TOOL_DEFINITIONS : []),
688
712
  ];
689
713
  }
714
+ schedulingPolicy(call) {
715
+ if (MCP_RESOURCE_TOOL_DEFINITIONS.some((definition) => definition.name === call.name)) {
716
+ return mcpResourceSchedulingInputIsValid(call)
717
+ ? { concurrency: 'concurrent', cancelOnInterrupt: true }
718
+ : { concurrency: 'exclusive', cancelOnInterrupt: true };
719
+ }
720
+ const connected = this.connectedTools.get(call.name);
721
+ if (connected) {
722
+ return connected.readOnly &&
723
+ connected.schedulingInputIsValid?.(call.input)
724
+ ? { concurrency: 'concurrent', cancelOnInterrupt: true }
725
+ : { concurrency: 'exclusive', cancelOnInterrupt: true };
726
+ }
727
+ return resolveToolSchedulingPolicy(this.options.base, call);
728
+ }
690
729
  serverStatuses() {
691
730
  return [...this.statuses.values()].map(({ name, status }) => ({
692
731
  name,
@@ -1070,10 +1109,13 @@ export class ClaudeMcpToolRegistry {
1070
1109
  if (this.connectedTools.has(name) || connectedTools.has(name)) {
1071
1110
  throw new Error(`Duplicate MCP tool ${name}`);
1072
1111
  }
1112
+ const inputIsValid = schedulingInputValidator(tool.inputSchema);
1073
1113
  connectedTools.set(name, {
1074
1114
  client,
1075
1115
  serverName,
1076
1116
  toolName: tool.name,
1117
+ readOnly: tool.annotations?.readOnlyHint === true,
1118
+ ...(inputIsValid ? { schedulingInputIsValid: inputIsValid } : {}),
1077
1119
  sensitiveValues,
1078
1120
  definition: {
1079
1121
  name,
@@ -9,7 +9,7 @@ import { shellPermissionMatchCandidates } from './bash-normalization.js';
9
9
  import { pathIsInsideRoots, validateBashPathSafety, } from './bash-path-safety.js';
10
10
  import { validateSedSafety } from './sed-safety.js';
11
11
  import { parseShellRule, shellRuleMatches } from './shell-rule-matching.js';
12
- import { effectiveAdditionalDirectories, effectivePermissionMode, filePermissionSuggestions, permissionRuleValueFromString, permissionRuleValueToString, shellCommandIsReadOnly, shellPermissionSuggestions, shellSubcommands, skillPermissionSuggestions, } from './permission-updates.js';
12
+ import { effectiveAdditionalDirectories, effectivePermissionMode, filePermissionSuggestions, permissionRuleValueFromString, permissionRuleValueToString, shellInputIsReadOnly, shellPermissionSuggestions, shellSubcommands, skillPermissionSuggestions, } from './permission-updates.js';
13
13
  const DEFAULT_BEHAVIOR = {
14
14
  Agent: 'allow',
15
15
  SendMessage: 'allow',
@@ -536,8 +536,8 @@ export class ClaudePermissionResolver {
536
536
  if (command &&
537
537
  (permissionMode !== 'auto' || !this.shouldClassify(call)) &&
538
538
  subcommands.length > 0 &&
539
- subcommands.every((subcommand) => shellCommandIsReadOnly(subcommand) ||
540
- matchingRule('allow', subcommandCall(subcommand)) !== undefined)) {
539
+ (shellInputIsReadOnly(command) ||
540
+ subcommands.every((subcommand) => matchingRule('allow', subcommandCall(subcommand)) !== undefined))) {
541
541
  return annotatePermissionDecision({ behavior: 'allow' }, 'rule');
542
542
  }
543
543
  const filePath = FILE_TOOLS.has(call.name) ? permissionTarget(call) : null;
@@ -11,6 +11,7 @@ export declare function readDirectoryPermissionUpdate(path: string, cwd: string,
11
11
  export declare function filePermissionSuggestions(path: string, cwd: string, operation: 'read' | 'write', mode: PermissionMode, outsideWorkingDirectory: boolean, pathsToCheck?: readonly string[], targetIsDirectory?: boolean): readonly PermissionUpdate[];
12
12
  export declare function shellSubcommands(command: string, shell?: 'bash' | 'powershell'): readonly string[];
13
13
  export declare function shellCommandIsReadOnly(command: string): boolean;
14
+ export declare function shellInputIsReadOnly(command: string): boolean;
14
15
  export declare function shellPermissionSuggestions(toolName: 'Bash' | 'PowerShell', command: string, include?: (subcommand: string) => boolean): readonly PermissionUpdate[];
15
16
  export declare function skillPermissionSuggestions(skill: string): readonly PermissionUpdate[];
16
17
  //# sourceMappingURL=permission-updates.d.ts.map
@@ -281,11 +281,23 @@ const READ_ONLY_SHELL_COMMANDS = new Set([
281
281
  function commandName(command) {
282
282
  return command.trim().split(/\s+/u)[0] ?? '';
283
283
  }
284
+ const AMBIGUOUS_SHELL_SYNTAX = /[<>`$*?[\]{}~]/u;
284
285
  export function shellCommandIsReadOnly(command) {
285
- if (/[<>`]|$\(|\|\||&&|[;|\n\r]/u.test(command))
286
+ if (AMBIGUOUS_SHELL_SYNTAX.test(command) || /[&;|\n\r]/u.test(command)) {
286
287
  return false;
288
+ }
287
289
  return READ_ONLY_SHELL_COMMANDS.has(commandName(command));
288
290
  }
291
+ export function shellInputIsReadOnly(command) {
292
+ if (AMBIGUOUS_SHELL_SYNTAX.test(command) ||
293
+ /(^|[^&])&($|[^&])/u.test(command)) {
294
+ return false;
295
+ }
296
+ const analysis = analyzeBashCommands(command);
297
+ return (analysis.parsed &&
298
+ analysis.commands.length > 0 &&
299
+ analysis.commands.every(shellCommandIsReadOnly));
300
+ }
289
301
  export function shellPermissionSuggestions(toolName, command, include = () => true) {
290
302
  const parts = shellSubcommands(command, toolName === 'Bash' ? 'bash' : 'powershell');
291
303
  const candidates = (parts.length > 0 ? parts : [command]).filter((part) => !shellCommandIsReadOnly(part) && include(part));
@@ -72,6 +72,7 @@ export declare class ClaudeCapabilityToolRegistry implements ToolRegistry {
72
72
  private readonly capabilities;
73
73
  constructor(base: ToolRegistry, capabilities: ReadonlySet<string>);
74
74
  definitions(): readonly ModelToolDefinition[];
75
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
75
76
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
76
77
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
77
78
  private assertEnabled;
@@ -1,3 +1,4 @@
1
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
1
2
  /** Stable environment names. Explicit input booleans override these. */
2
3
  export const CLAUDE_CODE_ENABLE_TASKS = 'CLAUDE_CODE_ENABLE_TASKS';
3
4
  export const CLAUDE_CODE_DISABLE_CRON = 'CLAUDE_CODE_DISABLE_CRON';
@@ -170,6 +171,10 @@ export class ClaudeCapabilityToolRegistry {
170
171
  definitions() {
171
172
  return filterClaudeToolDefinitions(this.base.definitions(), this.capabilities);
172
173
  }
174
+ schedulingPolicy(call) {
175
+ this.assertEnabled(call.name);
176
+ return resolveToolSchedulingPolicy(this.base, call);
177
+ }
173
178
  async prepare(call, context) {
174
179
  this.assertEnabled(call.name);
175
180
  return this.base.prepare(call, context);