praxis-agent 0.27.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 (39) hide show
  1. package/dist/application/session-service.js +8 -1
  2. package/dist/application/subagent-service.d.ts +1 -0
  3. package/dist/application/subagent-service.js +23 -1
  4. package/dist/core/runtime.d.ts +10 -0
  5. package/dist/core/runtime.js +111 -28
  6. package/dist/core/tool-execution-scheduler.d.ts +38 -0
  7. package/dist/core/tool-execution-scheduler.js +95 -0
  8. package/dist/core/tool-scheduling-policy.d.ts +4 -0
  9. package/dist/core/tool-scheduling-policy.js +24 -0
  10. package/dist/extensions/claude-extension-tools.d.ts +1 -0
  11. package/dist/extensions/claude-extension-tools.js +6 -0
  12. package/dist/hooks/claude-hook-tools.d.ts +4 -0
  13. package/dist/hooks/claude-hook-tools.js +6 -0
  14. package/dist/mcp/claude-mcp-tools.d.ts +1 -0
  15. package/dist/mcp/claude-mcp-tools.js +42 -0
  16. package/dist/permissions/claude-permission-resolver.js +3 -3
  17. package/dist/permissions/permission-updates.d.ts +1 -0
  18. package/dist/permissions/permission-updates.js +13 -1
  19. package/dist/tools/claude-capabilities.d.ts +1 -0
  20. package/dist/tools/claude-capabilities.js +5 -0
  21. package/dist/tools/claude-interactive-tools.js +7 -0
  22. package/dist/tools/claude-lsp-tool.js +7 -0
  23. package/dist/tools/claude-scheduled-tools.d.ts +1 -0
  24. package/dist/tools/claude-scheduled-tools.js +7 -0
  25. package/dist/tools/claude-task-tools.d.ts +1 -0
  26. package/dist/tools/claude-task-tools.js +11 -0
  27. package/dist/tools/claude-user-message.d.ts +1 -0
  28. package/dist/tools/claude-user-message.js +7 -0
  29. package/dist/tools/claude-workflow-tools.d.ts +1 -0
  30. package/dist/tools/claude-workflow-tools.js +7 -0
  31. package/dist/tools/claude-worktree-tools.d.ts +1 -0
  32. package/dist/tools/claude-worktree-tools.js +7 -0
  33. package/dist/tools/filtered-tool-registry.d.ts +1 -0
  34. package/dist/tools/filtered-tool-registry.js +5 -0
  35. package/dist/tools/local-tools.d.ts +17 -0
  36. package/dist/tools/local-tools.js +86 -1
  37. package/dist/tools/web.d.ts +1 -0
  38. package/dist/tools/web.js +27 -0
  39. package/package.json +1 -1
@@ -16,6 +16,7 @@ import { selectClaudeSchemaAdapter, } from '../compatibility/claude/schema.js';
16
16
  import { findUnresolvedClaudeToolCalls, getClaudeContentBlocks, } from '../compatibility/claude/tool-links.js';
17
17
  import { createClaudeAgentSettingEntry, createClaudeHookAttachmentEntries, createClaudeLastPromptEntry, createClaudeRuleAttachmentEntry, translateProviderEvents, } from '../compatibility/claude/translation.js';
18
18
  import { AgentRunCancelledError, AgentRuntime, } from '../core/runtime.js';
19
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
19
20
  import { BackgroundTaskRuntime, } from './background-task-runtime.js';
20
21
  import { usageCostUsd } from '../core/usage.js';
21
22
  import { ContextBudget, ContextRecoveryPlanner, estimateModelRequestTokens, isPromptTooLongError, } from '../core/context-budget.js';
@@ -61,6 +62,7 @@ function mainAgentToolNames(tools, agent) {
61
62
  }
62
63
  const emptyToolRegistry = {
63
64
  definitions: () => [],
65
+ schedulingPolicy: () => ({ concurrency: 'exclusive' }),
64
66
  prepare: async (call) => call,
65
67
  execute: async () => ({ content: '', isError: false }),
66
68
  };
@@ -943,6 +945,7 @@ export class ClaudeSessionService {
943
945
  (rightIndex < 0 ? preferredOrder.length : rightIndex));
944
946
  });
945
947
  },
948
+ schedulingPolicy: (call) => resolveToolSchedulingPolicy(capabilityRegistry, call),
946
949
  prepare: (call, context) => capabilityRegistry.prepare(call, context),
947
950
  execute: (call, context) => capabilityRegistry.execute(call, context),
948
951
  };
@@ -2316,6 +2319,10 @@ export class ClaudeSessionService {
2316
2319
  const fileHistoryTools = fileHistory && interactiveMessageTools
2317
2320
  ? {
2318
2321
  definitions: () => interactiveMessageTools.definitions(),
2322
+ schedulingPolicy: (call) => ({
2323
+ ...resolveToolSchedulingPolicy(interactiveMessageTools, call),
2324
+ startAfterAssistant: true,
2325
+ }),
2319
2326
  prepare: (call, context) => interactiveMessageTools.prepare(call, context),
2320
2327
  execute: async (call, context) => {
2321
2328
  const path = call.name === 'Write' || call.name === 'Edit'
@@ -3083,7 +3090,7 @@ export class ClaudeSessionService {
3083
3090
  toolResultDirectory,
3084
3091
  observer,
3085
3092
  ...(this.options.effort ? { effort: this.options.effort } : {}),
3086
- ...(this.options.maxModelTurns
3093
+ ...(this.options.maxModelTurns !== undefined
3087
3094
  ? { maxModelTurns: this.options.maxModelTurns }
3088
3095
  : {}),
3089
3096
  ...(this.options.betas?.length ? { betas: this.options.betas } : {}),
@@ -45,6 +45,7 @@ export declare class StructuredOutputRegistry implements ToolRegistry {
45
45
  value: unknown;
46
46
  } | undefined);
47
47
  definitions(): readonly ModelToolDefinition[];
48
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
48
49
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
49
50
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
50
51
  }
@@ -11,6 +11,7 @@ import { createClaudeHookAttachmentEntries, translateProviderEvents, } from '../
11
11
  import { injectFirstUserMessageContext, } from '../core/context.js';
12
12
  import { ContextBudget } from '../core/context-budget.js';
13
13
  import { AgentRuntime, } from '../core/runtime.js';
14
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
14
15
  import { BUILTIN_STATUSLINE_AGENT_PATH, } from '../extensions/claude-extensions.js';
15
16
  import { ClaudeHookToolCoordinator } from '../hooks/claude-hook-tools.js';
16
17
  import { ClaudeSidechainStore } from '../persistence/claude-sidechain-store.js';
@@ -26,6 +27,7 @@ const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
26
27
  const SIDECHAIN_DISCOVERY_MAX_DEPTH = 4;
27
28
  const structuredOnlyTools = {
28
29
  definitions: () => [],
30
+ schedulingPolicy: () => ({ concurrency: 'exclusive' }),
29
31
  prepare: async (call) => call,
30
32
  execute: async () => ({ content: '', isError: false }),
31
33
  };
@@ -68,6 +70,12 @@ export class StructuredOutputRegistry {
68
70
  ]),
69
71
  ];
70
72
  }
73
+ schedulingPolicy(call) {
74
+ if (call.name === 'StructuredOutput') {
75
+ return { concurrency: 'exclusive' };
76
+ }
77
+ return resolveToolSchedulingPolicy(this.base, call);
78
+ }
71
79
  prepare(call, context) {
72
80
  if (call.name !== 'StructuredOutput')
73
81
  return this.base.prepare(call, context);
@@ -106,6 +114,12 @@ class RestrictedToolRegistry {
106
114
  .definitions()
107
115
  .filter((definition) => this.allowed.has(definition.name));
108
116
  }
117
+ schedulingPolicy(call) {
118
+ if (!this.allowed.has(call.name)) {
119
+ return { concurrency: 'exclusive' };
120
+ }
121
+ return resolveToolSchedulingPolicy(this.base, call);
122
+ }
109
123
  prepare(call, context) {
110
124
  if (!this.allowed.has(call.name))
111
125
  throw new Error(`Tool ${call.name} is unavailable to this agent`);
@@ -1353,7 +1367,9 @@ export class ClaudeSubagentExecutor {
1353
1367
  const runtime = new AgentRuntime(options.provider, emit, {
1354
1368
  tools: runtimeTools,
1355
1369
  permissions: runtimePermissions,
1356
- maxModelTurns: customAgent?.maxTurns ?? 16,
1370
+ ...(customAgent?.maxTurns === undefined
1371
+ ? {}
1372
+ : { maxModelTurns: customAgent.maxTurns }),
1357
1373
  maxModelOutputBytes: this.options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
1358
1374
  maxToolCallsPerTurn: 32,
1359
1375
  maxToolInputBytes: 1024 * 1024,
@@ -1647,6 +1663,12 @@ class ClaudeSubagentToolRegistry {
1647
1663
  ...ordinary.slice(insertionIndex),
1648
1664
  ];
1649
1665
  }
1666
+ schedulingPolicy(call) {
1667
+ if (['Agent', 'SendMessage', 'TaskOutput', 'TaskStop'].includes(call.name)) {
1668
+ return { concurrency: 'exclusive', cancelOnInterrupt: true };
1669
+ }
1670
+ return resolveToolSchedulingPolicy(this.base, call);
1671
+ }
1650
1672
  async prepare(call, context) {
1651
1673
  if (call.name === 'Agent')
1652
1674
  return this.executor.prepare(call, this.depth);
@@ -322,8 +322,15 @@ export interface ToolExecutionContext {
322
322
  permissionPhase?: 'request' | 'execute';
323
323
  permissionApproved?: boolean;
324
324
  }
325
+ export interface ToolSchedulingPolicy {
326
+ concurrency: 'concurrent' | 'exclusive';
327
+ cancelOnInterrupt?: boolean;
328
+ abortGroupOnError?: boolean;
329
+ startAfterAssistant?: boolean;
330
+ }
325
331
  export interface ToolRegistry {
326
332
  definitions(): readonly ModelToolDefinition[];
333
+ schedulingPolicy?(call: ModelToolCall): ToolSchedulingPolicy;
327
334
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
328
335
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
329
336
  }
@@ -514,6 +521,9 @@ export declare class AgentRuntime {
514
521
  recoverToolCalls(calls: readonly ModelToolCall[], request: AgentToolRecoveryRequest): Promise<ToolExecutionResult[]>;
515
522
  executeDirectToolCall(call: ModelToolCall, request: AgentToolRecoveryRequest): Promise<ToolExecutionResult>;
516
523
  private completeToolCall;
524
+ private executeScheduledToolCall;
525
+ private presentToolResult;
526
+ private persistToolCall;
517
527
  private executeTool;
518
528
  private requireRecoveryApproval;
519
529
  private cancel;
@@ -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);
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readFile, realpath } from 'node:fs/promises';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
3
4
  const ASK_USER_QUESTION = {
4
5
  name: 'AskUserQuestion',
5
6
  description: "Use this tool only when you are blocked on a decision that is genuinely the user's to make: one you cannot resolve from the request, the code, or sensible defaults. Users can always provide custom text. Use multiSelect for non-exclusive choices. In plan mode, use this tool to clarify requirements before ExitPlanMode; do not use it to request plan approval.",
@@ -197,6 +198,12 @@ class ClaudeInteractiveToolRegistry {
197
198
  ...INTERACTIVE_DEFINITIONS.filter(({ name }) => this.manager.enabledNames.has(name)),
198
199
  ];
199
200
  }
201
+ schedulingPolicy(call) {
202
+ if (this.manager.enabledNames.has(call.name)) {
203
+ return { concurrency: 'exclusive', cancelOnInterrupt: true };
204
+ }
205
+ return resolveToolSchedulingPolicy(this.base, call);
206
+ }
200
207
  async prepare(call, context) {
201
208
  return this.manager.enabledNames.has(call.name)
202
209
  ? call
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { extname, resolve, sep } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { promisify } from 'node:util';
7
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
7
8
  import { redactSensitiveText, sanitizeChildEnvironment, sensitiveEnvironmentValues, } from '../platform/sensitive-data.js';
8
9
  import { formatClaudeLspResult } from './claude-lsp-formatters.js';
9
10
  const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
@@ -730,6 +731,12 @@ class ClaudeLspToolRegistry {
730
731
  definitions() {
731
732
  return [...this.base.definitions(), LSP_DEFINITION];
732
733
  }
734
+ schedulingPolicy(call) {
735
+ if (call.name === 'LSP') {
736
+ return { concurrency: 'exclusive', cancelOnInterrupt: true };
737
+ }
738
+ return resolveToolSchedulingPolicy(this.base, call);
739
+ }
733
740
  prepare(call, context) {
734
741
  return call.name === 'LSP'
735
742
  ? Promise.resolve(call)
@@ -16,6 +16,7 @@ export declare class ClaudeScheduledToolRegistry implements ToolRegistry {
16
16
  private readonly now;
17
17
  constructor(options: ClaudeScheduledToolRegistryOptions);
18
18
  definitions(): readonly ModelToolDefinition[];
19
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
19
20
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
20
21
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
21
22
  }
@@ -1,3 +1,4 @@
1
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
1
2
  const SCHEMA = 'https://json-schema.org/draft/2020-12/schema';
2
3
  const CRON_CREATE_DESCRIPTION = `Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.
3
4
 
@@ -191,6 +192,12 @@ export class ClaudeScheduledToolRegistry {
191
192
  ...scheduledDefinitions.filter(({ name }) => (this.enabled?.has(name) ?? true) && !existing.has(name)),
192
193
  ];
193
194
  }
195
+ schedulingPolicy(call) {
196
+ if (DEFINITION_NAMES.has(call.name)) {
197
+ return { concurrency: 'exclusive', cancelOnInterrupt: true };
198
+ }
199
+ return resolveToolSchedulingPolicy(this.options.base, call);
200
+ }
194
201
  async prepare(call, context) {
195
202
  if (!DEFINITION_NAMES.has(call.name)) {
196
203
  return this.options.base.prepare(call, context);
@@ -22,6 +22,7 @@ export declare class ClaudeTaskToolRegistry implements ToolRegistry {
22
22
  stopBackgroundTask(taskId: string): Promise<BackgroundBashToolResult>;
23
23
  isEnabled(name: string): boolean;
24
24
  definitions(): readonly ModelToolDefinition[];
25
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
25
26
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
26
27
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
27
28
  notifications(waitForRunning: boolean): Promise<string[]>;
@@ -1,4 +1,5 @@
1
1
  import { resolve } from 'node:path';
2
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
2
3
  import { BackgroundBashManager, } from '../application/background-bash-manager.js';
3
4
  import { isBackgroundBashTaskId } from '../application/background-task-id.js';
4
5
  import { ClaudeTaskStore, } from '../persistence/claude-task-store.js';
@@ -260,6 +261,16 @@ export class ClaudeTaskToolRegistry {
260
261
  ...TASK_DEFINITIONS.filter(({ name }) => this.isEnabled(name) && !existing.has(name)),
261
262
  ];
262
263
  }
264
+ schedulingPolicy(call) {
265
+ if (call.name !== 'Bash' &&
266
+ !TASK_DEFINITIONS.some(({ name }) => name === call.name)) {
267
+ return resolveToolSchedulingPolicy(this.options.base, call);
268
+ }
269
+ if (call.name === 'Bash' && call.input.run_in_background !== true) {
270
+ return resolveToolSchedulingPolicy(this.options.base, call);
271
+ }
272
+ return { concurrency: 'exclusive', cancelOnInterrupt: true };
273
+ }
263
274
  async prepare(call, context) {
264
275
  if (call.name === 'Bash') {
265
276
  return this.prepareBash(call, context);
@@ -10,6 +10,7 @@ export declare class ClaudeUserMessageToolRegistry implements ToolRegistry {
10
10
  private readonly onMessage;
11
11
  constructor(base: ToolRegistry, onMessage: (message: UserMessage) => void);
12
12
  definitions(): readonly ModelToolDefinition[];
13
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
13
14
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
14
15
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
15
16
  }
@@ -1,5 +1,6 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { extname, resolve } from 'node:path';
3
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
3
4
  export const CLAUDE_USER_MESSAGE_PROMPT = 'When brief mode is enabled, SendUserMessage is the primary user-visible reply channel. Use it for the answer, progress checkpoints, and blockers. Set status to normal for a direct reply and proactive for an unsolicited update.';
4
5
  const DEFINITION = {
5
6
  name: 'SendUserMessage',
@@ -42,6 +43,12 @@ export class ClaudeUserMessageToolRegistry {
42
43
  definitions() {
43
44
  return [...this.base.definitions(), DEFINITION];
44
45
  }
46
+ schedulingPolicy(call) {
47
+ if (call.name === DEFINITION.name) {
48
+ return { concurrency: 'exclusive', cancelOnInterrupt: true };
49
+ }
50
+ return resolveToolSchedulingPolicy(this.base, call);
51
+ }
45
52
  prepare(call, context) {
46
53
  if (call.name !== DEFINITION.name)
47
54
  return this.base.prepare(call, context);
@@ -21,6 +21,7 @@ export declare class ClaudeWorkflowToolRegistry implements ToolRegistry {
21
21
  private readonly prepared;
22
22
  constructor(options: ClaudeWorkflowToolRegistryOptions);
23
23
  definitions(): readonly ModelToolDefinition[];
24
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
24
25
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
25
26
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
26
27
  private resolveSource;
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
3
4
  import { parseWorkflowScript, } from '../application/workflow-manager.js';
4
5
  const MAX_SCRIPT_BYTES = 524_288;
5
6
  function workflowDefinition(dataPlane) {
@@ -91,6 +92,12 @@ export class ClaudeWorkflowToolRegistry {
91
92
  }
92
93
  return [...base, workflowDefinition(this.options.dataPlane ?? 'claude')];
93
94
  }
95
+ schedulingPolicy(call) {
96
+ if (['Workflow', 'TaskOutput', 'TaskStop'].includes(call.name)) {
97
+ return { concurrency: 'exclusive', cancelOnInterrupt: true };
98
+ }
99
+ return resolveToolSchedulingPolicy(this.options.base, call);
100
+ }
94
101
  async prepare(call, context) {
95
102
  if (call.name === 'TaskOutput' || call.name === 'TaskStop') {
96
103
  const taskId = call.input.task_id ?? call.input.shell_id;
@@ -12,6 +12,7 @@ export declare class ClaudeWorktreeToolRegistry implements ToolRegistry {
12
12
  dataPlane?: DataPlane;
13
13
  });
14
14
  definitions(): readonly ModelToolDefinition[];
15
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
15
16
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
16
17
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
17
18
  }
@@ -1,3 +1,4 @@
1
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
1
2
  const ENTER_DEFINITION = {
2
3
  name: 'EnterWorktree',
3
4
  description: 'Create or enter an isolated Git worktree and switch the current session into it.',
@@ -96,6 +97,12 @@ export class ClaudeWorktreeToolRegistry {
96
97
  ...definitions.filter((definition) => this.enabled.has(definition.name) && !existing.has(definition.name)),
97
98
  ];
98
99
  }
100
+ schedulingPolicy(call) {
101
+ if (['EnterWorktree', 'ExitWorktree'].includes(call.name)) {
102
+ return { concurrency: 'exclusive', cancelOnInterrupt: true };
103
+ }
104
+ return resolveToolSchedulingPolicy(this.options.base, call);
105
+ }
99
106
  async prepare(call, context) {
100
107
  if (call.name === 'EnterWorktree' && this.enabled.has(call.name)) {
101
108
  const input = objectInput(call);
@@ -9,6 +9,7 @@ export declare class FilteredToolRegistry implements ToolRegistry {
9
9
  private readonly enabledNames;
10
10
  constructor(base: ToolRegistry, options?: FilteredToolRegistryOptions);
11
11
  definitions(): readonly ModelToolDefinition[];
12
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
12
13
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
13
14
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
14
15
  private assertEnabled;
@@ -1,3 +1,4 @@
1
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
1
2
  function exactToolNames(rules) {
2
3
  return new Set(rules.flatMap((rule) => {
3
4
  const match = /^([A-Za-z][\w-]*)$/.exec(rule);
@@ -34,6 +35,10 @@ export class FilteredToolRegistry {
34
35
  return definition;
35
36
  });
36
37
  }
38
+ schedulingPolicy(call) {
39
+ this.assertEnabled(call.name);
40
+ return resolveToolSchedulingPolicy(this.base, call);
41
+ }
37
42
  async prepare(call, context) {
38
43
  this.assertEnabled(call.name);
39
44
  return this.base.prepare(call, context);
@@ -53,6 +53,23 @@ export declare class LocalToolRegistry implements ToolRegistry {
53
53
  private assertProtectedBashCommand;
54
54
  private currentCwd;
55
55
  definitions(): readonly ModelToolDefinition[];
56
+ schedulingPolicy(call: ModelToolCall): {
57
+ concurrency: "concurrent";
58
+ cancelOnInterrupt: boolean;
59
+ abortGroupOnError?: never;
60
+ } | {
61
+ concurrency: "concurrent";
62
+ cancelOnInterrupt: boolean;
63
+ abortGroupOnError: boolean;
64
+ } | {
65
+ abortGroupOnError?: boolean;
66
+ concurrency: "exclusive";
67
+ cancelOnInterrupt: boolean;
68
+ } | {
69
+ concurrency: "exclusive";
70
+ cancelOnInterrupt?: never;
71
+ abortGroupOnError?: never;
72
+ };
56
73
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
57
74
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
58
75
  private workspacePath;
@@ -11,7 +11,7 @@ import { editNotebook, formatNotebookForRead } from './notebook.js';
11
11
  import { openPdf } from './pdf.js';
12
12
  import { validateBashPathSafety } from '../permissions/bash-path-safety.js';
13
13
  import { protectedWritePathReason } from '../permissions/bypass-immune-paths.js';
14
- import { effectiveAdditionalDirectories } from '../permissions/permission-updates.js';
14
+ import { effectiveAdditionalDirectories, shellInputIsReadOnly, } from '../permissions/permission-updates.js';
15
15
  const REPORT_FINDINGS_DEFINITION = {
16
16
  name: 'ReportFindings',
17
17
  description: "Report code-review findings as a typed list so the host UI can render them. Use this only when the active code-review instructions tell you to report findings with this tool; otherwise follow whatever output format those instructions specify. When reporting a review's results, call it once with the verified findings ranked most-severe first (empty array if nothing survived verification) and do not also print the findings as text. When re-reporting after applying fixes (only if the apply instructions ask for it), set outcome on each finding to what actually happened.",
@@ -277,6 +277,64 @@ function optionalNonNegativeInteger(input, name) {
277
277
  function isRecord(value) {
278
278
  return typeof value === 'object' && value !== null && !Array.isArray(value);
279
279
  }
280
+ function hasOnlyInputKeys(input, allowed) {
281
+ const names = new Set(allowed);
282
+ return Object.keys(input).every((name) => names.has(name));
283
+ }
284
+ function localSchedulingInputIsValid(call) {
285
+ try {
286
+ if (call.name === 'Read') {
287
+ stringInput(call.input, 'file_path');
288
+ optionalNonNegativeInteger(call.input, 'offset');
289
+ optionalPositiveInteger(call.input, 'limit');
290
+ optionalString(call.input, 'pages');
291
+ return hasOnlyInputKeys(call.input, [
292
+ 'file_path',
293
+ 'offset',
294
+ 'limit',
295
+ 'pages',
296
+ ]);
297
+ }
298
+ if (call.name === 'Glob') {
299
+ stringInput(call.input, 'pattern', true);
300
+ optionalString(call.input, 'path');
301
+ return hasOnlyInputKeys(call.input, ['pattern', 'path']);
302
+ }
303
+ if (call.name === 'Grep') {
304
+ stringInput(call.input, 'pattern');
305
+ optionalString(call.input, 'path');
306
+ optionalString(call.input, 'glob');
307
+ return hasOnlyInputKeys(call.input, ['pattern', 'path', 'glob']);
308
+ }
309
+ if (call.name === 'Bash') {
310
+ stringInput(call.input, 'command');
311
+ optionalPositiveInteger(call.input, 'timeout');
312
+ const description = call.input.description;
313
+ const background = call.input.run_in_background;
314
+ const disableSandbox = call.input.dangerouslyDisableSandbox;
315
+ if (description !== undefined && typeof description !== 'string') {
316
+ return false;
317
+ }
318
+ if (background !== undefined && typeof background !== 'boolean') {
319
+ return false;
320
+ }
321
+ if (disableSandbox !== undefined && typeof disableSandbox !== 'boolean') {
322
+ return false;
323
+ }
324
+ return hasOnlyInputKeys(call.input, [
325
+ 'command',
326
+ 'timeout',
327
+ 'description',
328
+ 'run_in_background',
329
+ 'dangerouslyDisableSandbox',
330
+ ]);
331
+ }
332
+ return false;
333
+ }
334
+ catch {
335
+ return false;
336
+ }
337
+ }
280
338
  const REPORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
281
339
  const REPORT_VERDICTS = ['CONFIRMED', 'PLAUSIBLE'];
282
340
  const REPORT_OUTCOMES = ['fixed', 'skipped', 'no_change_needed'];
@@ -535,6 +593,33 @@ export class LocalToolRegistry {
535
593
  }
536
594
  : definition);
537
595
  }
596
+ schedulingPolicy(call) {
597
+ if (['Read', 'Glob', 'Grep'].includes(call.name) &&
598
+ localSchedulingInputIsValid(call)) {
599
+ return { concurrency: 'concurrent', cancelOnInterrupt: true };
600
+ }
601
+ if (call.name === 'Bash' &&
602
+ localSchedulingInputIsValid(call) &&
603
+ call.input.run_in_background !== true &&
604
+ typeof call.input.command === 'string' &&
605
+ shellInputIsReadOnly(call.input.command)) {
606
+ return {
607
+ concurrency: 'concurrent',
608
+ cancelOnInterrupt: true,
609
+ abortGroupOnError: true,
610
+ };
611
+ }
612
+ if (TOOL_DEFINITIONS.some(({ name }) => name === call.name)) {
613
+ return {
614
+ concurrency: 'exclusive',
615
+ cancelOnInterrupt: true,
616
+ ...(call.name === 'Bash' ? { abortGroupOnError: true } : {}),
617
+ };
618
+ }
619
+ return {
620
+ concurrency: 'exclusive',
621
+ };
622
+ }
538
623
  async prepare(call, context) {
539
624
  if (context.signal?.aborted)
540
625
  throw abortError();
@@ -36,6 +36,7 @@ export declare class WebToolRegistry implements ToolRegistry {
36
36
  private cacheBytes;
37
37
  constructor(options: WebToolRegistryOptions);
38
38
  definitions(): readonly ModelToolDefinition[];
39
+ schedulingPolicy(call: ModelToolCall): import("../core/runtime.js").ToolSchedulingPolicy;
39
40
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
40
41
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
41
42
  private webFetch;
package/dist/tools/web.js CHANGED
@@ -6,6 +6,7 @@ import { isIP } from 'node:net';
6
6
  import { join } from 'node:path';
7
7
  import ipaddr from 'ipaddr.js';
8
8
  import TurndownService from 'turndown';
9
+ import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
9
10
  const MAX_URL_LENGTH = 2_000;
10
11
  const MAX_MARKDOWN_LENGTH = 100_000;
11
12
  const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
@@ -273,6 +274,24 @@ function parseWebSearchInput(input) {
273
274
  ...(blockedDomains ? { blockedDomains } : {}),
274
275
  };
275
276
  }
277
+ function webSchedulingInputIsValid(call) {
278
+ try {
279
+ if (call.name === 'WebFetch') {
280
+ parseWebFetchInput(call.input);
281
+ return Object.keys(call.input).every((name) => name === 'url' || name === 'prompt');
282
+ }
283
+ if (call.name === 'WebSearch') {
284
+ parseWebSearchInput(call.input);
285
+ return Object.keys(call.input).every((name) => name === 'query' ||
286
+ name === 'allowed_domains' ||
287
+ name === 'blocked_domains');
288
+ }
289
+ return false;
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ }
276
295
  function normalizedAddress(value) {
277
296
  const parsed = ipaddr.parse(value);
278
297
  return parsed instanceof ipaddr.IPv6 && parsed.isIPv4MappedAddress()
@@ -475,6 +494,14 @@ export class WebToolRegistry {
475
494
  : []),
476
495
  ];
477
496
  }
497
+ schedulingPolicy(call) {
498
+ if (call.name === 'WebFetch' || call.name === 'WebSearch') {
499
+ return webSchedulingInputIsValid(call)
500
+ ? { concurrency: 'concurrent', cancelOnInterrupt: true }
501
+ : { concurrency: 'exclusive', cancelOnInterrupt: true };
502
+ }
503
+ return resolveToolSchedulingPolicy(this.options.base, call);
504
+ }
478
505
  async prepare(call, context) {
479
506
  if (context.signal?.aborted)
480
507
  throw abortError();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",