praxis-agent 0.55.2 → 0.56.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.
@@ -19,7 +19,7 @@ import { isSessionId } from './core/session.js';
19
19
  import { assertNativeDataPlane, resolveDataPlane, resolveDataPlaneRoot, resolveDataPlanePaths, } from './persistence/data-plane.js';
20
20
  import { loadNativeContextResources, loadNativeSettings, loadNativeSharedResources, } from './persistence/native-resources.js';
21
21
  import { resolveProjectIdentity } from './platform/project-identity.js';
22
- import { AgentRunCancelledError, } from './core/runtime.js';
22
+ import { AgentRunCancelledError, ModelProviderError, } from './core/runtime.js';
23
23
  import { persistTuiPermissionUpdates } from './cli/tui/permission-settings.js';
24
24
  import { loadClaudeReleaseNotes } from './cli/tui/release-notes.js';
25
25
  import { canonicalClaudeCostModelName, formatCostSummary, } from './cli/tui/cost-summary.js';
@@ -65,7 +65,8 @@ import { WorkspaceContext } from './application/session-worktree.js';
65
65
  import { launchTmuxWorktree } from './platform/tmux-worktree.js';
66
66
  import { claudeSandboxRuntime } from './sandbox/claude-sandbox-runtime.js';
67
67
  import { nativeSandboxTempDirectory, loadClaudeSandboxSettings, } from './sandbox/claude-sandbox-settings.js';
68
- import { createErrorResult, createSuccessResult, isHeadlessCostCommand, matchHeadlessColorCommand, parseCliInvocation, readStreamJsonMessages, StreamJsonOutput, } from './cli/protocol.js';
68
+ import { createErrorResult, createSuccessResult, isHeadlessCostCommand, matchHeadlessColorCommand, parseCliInvocation, projectProtocolTimings, readStreamJsonMessages, StreamJsonOutput, } from './cli/protocol.js';
69
+ import { isDirectProcessSigint } from './cli/process-signal.js';
69
70
  import { executeProviderAuthCommand } from './cli/provider-auth-command.js';
70
71
  import { describeClaudePlugin, initClaudePlugin, installClaudePlugin, loadClaudePlugins, readPluginRegistry, setClaudePluginEnabled, uninstallClaudePlugin, updateClaudePlugin, validateClaudePlugin, } from './plugins/claude-plugin-runtime.js';
71
72
  import { addClaudeMarketplace, disableAllNativePlugins, installClaudeMarketplacePlugin, listClaudeMarketplaceAvailablePlugins, listNativePluginRecords, readClaudeKnownMarketplaces, removeClaudeMarketplace, setNativePluginEnabled, saveClaudePluginConfig, uninstallNativePlugin, updateClaudeMarketplace, updateNativePlugin, validateClaudeMarketplace, } from './plugins/claude-plugin-marketplace.js';
@@ -1795,7 +1796,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1795
1796
  const filteredTools = new FilteredToolRegistry(extensionAndLspTools, {
1796
1797
  ...(cli.tools === undefined
1797
1798
  ? simpleMode
1798
- ? { tools: ['Bash', 'Edit', 'Read'] }
1799
+ ? { tools: ['Bash', 'Edit', 'ApplyPatch', 'Read'] }
1799
1800
  : {}
1800
1801
  : { tools: selectedBaseTools ?? [] }),
1801
1802
  disallowedTools: cli.disallowedTools,
@@ -5412,6 +5413,9 @@ async function execute(argv, io, dependencies, signal) {
5412
5413
  }
5413
5414
  let streamOutput;
5414
5415
  let jsonModelTurns = 0;
5416
+ let jsonRequestAt;
5417
+ let jsonOutputAt;
5418
+ let jsonTerminalReason;
5415
5419
  const pendingEvents = [];
5416
5420
  let streamIterator;
5417
5421
  let firstStreamMessage;
@@ -5615,10 +5619,10 @@ async function execute(argv, io, dependencies, signal) {
5615
5619
  output.init();
5616
5620
  output.sink({ type: 'text-delta', delta: text });
5617
5621
  output.sink({ type: 'usage', usage: result.usage });
5618
- output.result(result, startedAt);
5622
+ output.result(result, startedAt, { localCommand: true });
5619
5623
  }
5620
5624
  else if (outputFormat === 'json' || invocation.legacyJson) {
5621
- writeJson(io, createSuccessResult(result, info, startedAt, 0));
5625
+ writeJson(io, createSuccessResult(result, info, startedAt, 0, { localCommand: true }));
5622
5626
  }
5623
5627
  else {
5624
5628
  io.stdout(`${text}\n`);
@@ -5663,6 +5667,20 @@ async function execute(argv, io, dependencies, signal) {
5663
5667
  ? (event) => {
5664
5668
  if (event.type === 'state' && event.state === 'awaiting-model') {
5665
5669
  jsonModelTurns += 1;
5670
+ if (jsonRequestAt === undefined)
5671
+ jsonRequestAt = Date.now();
5672
+ }
5673
+ if (event.type === 'terminal') {
5674
+ jsonTerminalReason = event.reason;
5675
+ }
5676
+ if ((event.type === 'text-delta' ||
5677
+ event.type === 'thinking-start' ||
5678
+ event.type === 'thinking-delta' ||
5679
+ event.type === 'thinking-signature-delta' ||
5680
+ event.type === 'thinking-stop' ||
5681
+ event.type === 'tool-call') &&
5682
+ jsonOutputAt === undefined) {
5683
+ jsonOutputAt = Date.now();
5666
5684
  }
5667
5685
  if (event.type === 'warning') {
5668
5686
  io.stderr(`Warning: ${redactSensitiveText(event.message, sensitiveEnvironmentValues(process.env))}\n`);
@@ -5816,8 +5834,12 @@ async function execute(argv, io, dependencies, signal) {
5816
5834
  currentTurnAbort = streamIterator ? turnAbort : undefined;
5817
5835
  const runSignal = streamIterator ? turnAbort.signal : signal;
5818
5836
  await ensureCostBaseline(activeSessionId);
5837
+ jsonModelTurns = 0;
5838
+ jsonRequestAt = undefined;
5839
+ jsonOutputAt = undefined;
5840
+ jsonTerminalReason = undefined;
5819
5841
  if (streamOutput) {
5820
- streamOutput.init();
5842
+ streamOutput.init(startedAt);
5821
5843
  if (isFirstTurn) {
5822
5844
  for (const event of pendingEvents)
5823
5845
  streamOutput.sink(event);
@@ -5902,10 +5924,20 @@ async function execute(argv, io, dependencies, signal) {
5902
5924
  throw error;
5903
5925
  }
5904
5926
  const message = redactSensitiveText(error instanceof Error ? error.message : String(error), sensitiveEnvironmentValues(process.env));
5927
+ const providerApiError = error instanceof ModelProviderError &&
5928
+ (error.status !== undefined || error.kind === 'api_error');
5929
+ const errorContext = providerApiError
5930
+ ? {
5931
+ providerApiError: true,
5932
+ apiErrorStatus: error instanceof ModelProviderError
5933
+ ? (error.status ?? null)
5934
+ : null,
5935
+ }
5936
+ : {};
5905
5937
  if (streamOutput)
5906
- streamOutput.error(message, startedAt);
5938
+ streamOutput.error(message, startedAt, errorContext);
5907
5939
  else if (outputFormat === 'json') {
5908
- writeJson(io, createErrorResult(message, activeSessionId, startedAt, jsonModelTurns));
5940
+ writeJson(io, createErrorResult(message, activeSessionId, startedAt, jsonModelTurns, errorContext));
5909
5941
  }
5910
5942
  else {
5911
5943
  if (currentTurnAbort === turnAbort)
@@ -5925,11 +5957,17 @@ async function execute(argv, io, dependencies, signal) {
5925
5957
  if (localCommand) {
5926
5958
  streamOutput.syntheticAssistant(result.text);
5927
5959
  }
5928
- streamOutput.result(result, startedAt);
5960
+ streamOutput.result(result, startedAt, { localCommand });
5929
5961
  }
5930
5962
  else if (outputFormat === 'json') {
5931
5963
  const resultRuntimeInfo = service.runtimeInfo?.() ?? runtimeInfo;
5932
- writeJson(io, createSuccessResult(result, resultRuntimeInfo, startedAt, localCommand ? 0 : Math.max(1, jsonModelTurns)));
5964
+ writeJson(io, createSuccessResult(result, resultRuntimeInfo, startedAt, localCommand ? 0 : Math.max(1, jsonModelTurns), {
5965
+ localCommand,
5966
+ ...(jsonTerminalReason === undefined
5967
+ ? {}
5968
+ : { stopReason: jsonTerminalReason }),
5969
+ ...projectProtocolTimings(startedAt, jsonRequestAt, jsonOutputAt),
5970
+ }));
5933
5971
  }
5934
5972
  else if (outputFormat !== 'text')
5935
5973
  writeJson(io, { type: 'result', ...result });
@@ -5979,6 +6017,15 @@ export async function run(argv, io = consoleIO, dependencies = createDefaultDepe
5979
6017
  }
5980
6018
  catch (error) {
5981
6019
  if (isCancellation(error, signal)) {
6020
+ if (isDirectProcessSigint(signal)) {
6021
+ try {
6022
+ if (parseCliInvocation(argv).print)
6023
+ return 0;
6024
+ }
6025
+ catch {
6026
+ // Preserve the ordinary cancellation boundary when parsing fails.
6027
+ }
6028
+ }
5982
6029
  io.stderr('Praxis run cancelled.\n');
5983
6030
  return 130;
5984
6031
  }
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { DIRECT_PROCESS_SIGINT } from './cli/process-signal.js';
5
6
  export { parseContextEnvironment, parseProviderEnvironment, } from './providers/environment.js';
6
7
  const VERSION = createRequire(import.meta.url)('../package.json').version;
7
8
  export async function run(argv, io, dependencies, signal) {
@@ -36,14 +37,15 @@ function isDirectExecution(moduleUrl, argvPath) {
36
37
  }
37
38
  if (isDirectExecution(import.meta.url, process.argv[1])) {
38
39
  const controller = new AbortController();
40
+ const cancelWithSigint = () => controller.abort(DIRECT_PROCESS_SIGINT);
39
41
  const cancel = () => controller.abort();
40
- process.on('SIGINT', cancel);
42
+ process.on('SIGINT', cancelWithSigint);
41
43
  process.on('SIGTERM', cancel);
42
44
  try {
43
45
  process.exitCode = await run(process.argv.slice(2), undefined, undefined, controller.signal);
44
46
  }
45
47
  finally {
46
- process.removeListener('SIGINT', cancel);
48
+ process.removeListener('SIGINT', cancelWithSigint);
47
49
  process.removeListener('SIGTERM', cancel);
48
50
  }
49
51
  }
@@ -10,7 +10,15 @@ function toolName(rule) {
10
10
  }
11
11
  function gatedTool(rule) {
12
12
  const name = toolName(rule);
13
- return (['Bash', 'Write', 'Edit', 'NotebookEdit', 'WebFetch', 'WebSearch'].includes(name) || name.startsWith('mcp__'));
13
+ return ([
14
+ 'Bash',
15
+ 'Write',
16
+ 'Edit',
17
+ 'ApplyPatch',
18
+ 'NotebookEdit',
19
+ 'WebFetch',
20
+ 'WebSearch',
21
+ ].includes(name) || name.startsWith('mcp__'));
14
22
  }
15
23
  function grants(requested, allowed) {
16
24
  const name = toolName(requested);
@@ -247,6 +247,7 @@ class PraxisMcpRuntime {
247
247
  sensitiveValues = sensitiveEnvironmentValues(process.env);
248
248
  toolRegistryPromise;
249
249
  closed = false;
250
+ messageHistory = [];
250
251
  constructor(options) {
251
252
  this.options = options;
252
253
  this.localTools = new LocalToolRegistry({
@@ -261,6 +262,7 @@ class PraxisMcpRuntime {
261
262
  'Bash',
262
263
  'Read',
263
264
  'Edit',
265
+ 'ApplyPatch',
264
266
  'Write',
265
267
  'NotebookEdit',
266
268
  'Glob',
@@ -307,10 +309,20 @@ class PraxisMcpRuntime {
307
309
  const call = { id: randomUUID(), name, input };
308
310
  const context = {
309
311
  cwd: this.options.cwd,
312
+ messages: [...this.messageHistory],
310
313
  ...(signal ? { signal } : {}),
311
314
  };
312
315
  const prepared = await registry.prepare(call, context);
313
- return registry.execute(prepared, context);
316
+ const result = await registry.execute(prepared, context);
317
+ if (name === 'Read' && !result.isError) {
318
+ this.messageHistory.push({ role: 'assistant', content: '', toolCalls: [prepared] }, {
319
+ role: 'tool',
320
+ toolCallId: prepared.id,
321
+ content: result.content,
322
+ isError: false,
323
+ });
324
+ }
325
+ return result;
314
326
  }
315
327
  async close() {
316
328
  if (this.closed)
@@ -3,7 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { realpath } from 'node:fs/promises';
4
4
  import { resolveProjectIdentity } from '../platform/project-identity.js';
5
5
  import { sanitizeProjectPath } from '../platform/project-path-key.js';
6
- import { annotateAutoModePermissionOutcome, annotatePermissionDecision, } from '../core/runtime.js';
6
+ import { annotateAutoModePermissionOutcome, annotatePermissionDecision, autoModePermissionOutcome, permissionDecisionSource, } from '../core/runtime.js';
7
+ import { parseApplyPatchInput } from '../tools/apply-patch.js';
7
8
  import { loadClaudeAutoModeConfig, } from './claude-auto-classifier.js';
8
9
  import { analyzeBashCommands, validateBashSemantics } from './bash-ast.js';
9
10
  import { shellPermissionMatchCandidates } from './bash-normalization.js';
@@ -30,6 +31,7 @@ const DEFAULT_BEHAVIOR = {
30
31
  Grep: 'allow',
31
32
  Write: 'ask',
32
33
  Edit: 'ask',
34
+ ApplyPatch: 'ask',
33
35
  NotebookEdit: 'ask',
34
36
  Glob: 'allow',
35
37
  LSP: 'allow',
@@ -50,6 +52,7 @@ const FILE_TOOLS = new Set([
50
52
  'Read',
51
53
  'Write',
52
54
  'Edit',
55
+ 'ApplyPatch',
53
56
  'NotebookEdit',
54
57
  'Glob',
55
58
  'Grep',
@@ -206,6 +209,13 @@ function permissionTarget(call) {
206
209
  ? call.input.notebook_path
207
210
  : null;
208
211
  }
212
+ if (call.name === 'ApplyPatch') {
213
+ const edits = call.input.edits;
214
+ const first = Array.isArray(edits) ? edits[0] : undefined;
215
+ return isRecord(first) && typeof first.file_path === 'string'
216
+ ? first.file_path
217
+ : null;
218
+ }
209
219
  if (call.name === 'WebFetch') {
210
220
  return typeof call.input.url === 'string' ? call.input.url : null;
211
221
  }
@@ -217,7 +227,7 @@ function permissionTarget(call) {
217
227
  function matchesRule(rule, call, cwd, homeDirectory) {
218
228
  const toolMatches = rule.toolName === call.name ||
219
229
  (rule.toolName === 'Edit' &&
220
- ['Edit', 'Write', 'NotebookEdit'].includes(call.name)) ||
230
+ ['Edit', 'Write', 'NotebookEdit', 'ApplyPatch'].includes(call.name)) ||
221
231
  (rule.toolName === 'Read' && ['Read', 'Glob', 'Grep'].includes(call.name));
222
232
  if (!toolMatches)
223
233
  return false;
@@ -400,6 +410,131 @@ export class ClaudePermissionResolver {
400
410
  ...call,
401
411
  input: { ...call.input, command: subcommand },
402
412
  });
413
+ if (call.name === 'ApplyPatch') {
414
+ const edits = parseApplyPatchInput(call.input);
415
+ const originalEdits = context?.originalCall?.name === 'ApplyPatch'
416
+ ? parseApplyPatchInput(context.originalCall.input)
417
+ : edits;
418
+ if (originalEdits.length !== edits.length) {
419
+ throw new Error('ApplyPatch original and canonical edits must match');
420
+ }
421
+ const targetInputs = edits.map((edit, index) => {
422
+ const originalEdit = originalEdits[index];
423
+ if (!originalEdit) {
424
+ throw new Error('ApplyPatch original and canonical edits must match');
425
+ }
426
+ const targetCall = {
427
+ ...call,
428
+ name: 'Edit',
429
+ input: { ...edit },
430
+ };
431
+ const applyView = {
432
+ ...call,
433
+ input: { edits: [{ ...edit }] },
434
+ };
435
+ const originalView = {
436
+ ...call,
437
+ input: { edits: [{ ...originalEdit }] },
438
+ };
439
+ const originalTargetCall = {
440
+ ...targetCall,
441
+ input: { ...originalEdit },
442
+ };
443
+ return {
444
+ edit,
445
+ originalEdit,
446
+ targetCall,
447
+ applyView,
448
+ originalView,
449
+ originalTargetCall,
450
+ };
451
+ });
452
+ const directDenied = targetInputs.flatMap(({ applyView, originalView, targetCall, originalTargetCall }) => effectiveRules('deny').filter((rule) => matchesRule(rule, applyView, cwd, this.homeDirectory) ||
453
+ matchesRule(rule, originalView, cwd, this.homeDirectory) ||
454
+ matchesRule(rule, targetCall, cwd, this.homeDirectory) ||
455
+ matchesRule(rule, originalTargetCall, cwd, this.homeDirectory)))[0];
456
+ if (directDenied) {
457
+ const suffix = directDenied.pattern === null ? '' : `(${directDenied.pattern})`;
458
+ return annotatePermissionDecision({
459
+ behavior: 'deny',
460
+ reason: `Denied by Claude permission rule ${directDenied.toolName}${suffix}`,
461
+ }, 'rule');
462
+ }
463
+ const targetDecisions = await Promise.all(targetInputs.map(async ({ originalEdit, targetCall, applyView, originalView }) => {
464
+ if (permissionMode === 'plan') {
465
+ return annotatePermissionDecision({
466
+ behavior: 'deny',
467
+ reason: 'Cannot use ApplyPatch while in plan mode',
468
+ }, 'mode');
469
+ }
470
+ const directAsked = effectiveRules('ask').find((rule) => matchesRule(rule, applyView, cwd, this.homeDirectory) ||
471
+ matchesRule(rule, originalView, cwd, this.homeDirectory));
472
+ if (directAsked)
473
+ return this.askDecision(targetCall, cwd, permissionMode, context, 'rule', undefined, true);
474
+ const directAllowed = effectiveRules('allow').find((rule) => matchesRule(rule, applyView, cwd, this.homeDirectory) ||
475
+ matchesRule(rule, originalView, cwd, this.homeDirectory));
476
+ const resolved = await this.resolve(targetCall, context
477
+ ? {
478
+ ...context,
479
+ originalCall: {
480
+ ...call,
481
+ input: { edits: [{ ...originalEdit }] },
482
+ },
483
+ }
484
+ : { cwd, originalCall: originalView });
485
+ if (resolved.behavior === 'allow' &&
486
+ permissionDecisionSource(resolved) === 'default' &&
487
+ !directAllowed) {
488
+ return this.askDecision(targetCall, cwd, permissionMode, context
489
+ ? {
490
+ ...context,
491
+ originalCall: {
492
+ ...call,
493
+ input: { edits: [{ ...originalEdit }] },
494
+ },
495
+ }
496
+ : { cwd, originalCall: originalView }, 'default', undefined, true);
497
+ }
498
+ if (directAllowed &&
499
+ resolved.behavior === 'ask' &&
500
+ resolved.reason !==
501
+ 'Path is outside allowed working directories' &&
502
+ (permissionMode !== 'auto' || !this.shouldClassify(targetCall))) {
503
+ return annotatePermissionDecision({ behavior: 'allow' }, 'rule');
504
+ }
505
+ return resolved;
506
+ }));
507
+ const deniedDecision = targetDecisions.find((decision) => decision.behavior === 'deny');
508
+ if (deniedDecision)
509
+ return deniedDecision;
510
+ const askedDecisions = targetDecisions.filter((decision) => decision.behavior === 'ask');
511
+ if (askedDecisions.length > 0) {
512
+ const first = askedDecisions[0];
513
+ if (!first)
514
+ throw new Error('ApplyPatch permission decision is missing');
515
+ const suggestions = [
516
+ ...new Map(askedDecisions
517
+ .flatMap((decision) => decision.behavior === 'ask' ? (decision.suggestions ?? []) : [])
518
+ .map((suggestion) => [JSON.stringify(suggestion), suggestion])).values(),
519
+ ];
520
+ const aggregate = {
521
+ ...first,
522
+ suggestions,
523
+ };
524
+ const source = permissionDecisionSource(first);
525
+ const annotated = source
526
+ ? annotatePermissionDecision(aggregate, source)
527
+ : aggregate;
528
+ const outcome = autoModePermissionOutcome(first);
529
+ return outcome
530
+ ? annotateAutoModePermissionOutcome(annotated, outcome)
531
+ : annotated;
532
+ }
533
+ const allowed = targetDecisions.find((decision) => decision.behavior === 'allow');
534
+ if (allowed)
535
+ return allowed;
536
+ throw new Error('ApplyPatch permission resolution produced no decision');
537
+ }
403
538
  const denied = matchingRule('deny') ??
404
539
  subcommands
405
540
  .map((subcommand) => matchingRule('deny', subcommandCall(subcommand)))
@@ -414,6 +549,7 @@ export class ClaudePermissionResolver {
414
549
  if (permissionMode === 'plan' &&
415
550
  (call.name === 'Write' ||
416
551
  call.name === 'Edit' ||
552
+ call.name === 'ApplyPatch' ||
417
553
  call.name === 'NotebookEdit')) {
418
554
  return annotatePermissionDecision({
419
555
  behavior: 'deny',
@@ -558,7 +694,7 @@ export class ClaudePermissionResolver {
558
694
  absolutePath,
559
695
  ]),
560
696
  ];
561
- const write = ['Write', 'Edit', 'NotebookEdit'].includes(call.name);
697
+ const write = ['Write', 'Edit', 'ApplyPatch', 'NotebookEdit'].includes(call.name);
562
698
  const internalRoots = write
563
699
  ? internalEditableRoots
564
700
  : internalReadableRoots;
@@ -585,6 +721,7 @@ export class ClaudePermissionResolver {
585
721
  if (permissionMode === 'acceptEdits' &&
586
722
  (call.name === 'Write' ||
587
723
  call.name === 'Edit' ||
724
+ call.name === 'ApplyPatch' ||
588
725
  call.name === 'NotebookEdit')) {
589
726
  return annotatePermissionDecision({ behavior: 'allow' }, 'mode');
590
727
  }
@@ -651,7 +788,12 @@ export class ClaudePermissionResolver {
651
788
  FILE_TOOLS.has(call.name) &&
652
789
  permissionTarget(call)
653
790
  ? {
654
- suggestions: filePermissionSuggestions(permissionTarget(call) ?? cwd, cwd, ['Write', 'Edit', 'NotebookEdit'].includes(call.name)
791
+ suggestions: filePermissionSuggestions(permissionTarget(call) ?? cwd, cwd, [
792
+ 'Write',
793
+ 'Edit',
794
+ 'ApplyPatch',
795
+ 'NotebookEdit',
796
+ ].includes(call.name)
655
797
  ? 'write'
656
798
  : 'read', (effectivePermissionMode(context?.permissionUpdates) ??
657
799
  permissionMode), false),
@@ -664,6 +806,7 @@ export class ClaudePermissionResolver {
664
806
  return true;
665
807
  if (call.name === 'Write' ||
666
808
  call.name === 'Edit' ||
809
+ call.name === 'ApplyPatch' ||
667
810
  call.name === 'NotebookEdit' ||
668
811
  call.name === 'WebFetch' ||
669
812
  call.name === 'WebSearch' ||
@@ -8,6 +8,8 @@ export async function writeFileAtomically(filePath, content, options = {}) {
8
8
  const handle = await open(temporary, 'wx', options.mode ?? 0o600);
9
9
  try {
10
10
  await handle.writeFile(content);
11
+ if (options.mode !== undefined)
12
+ await handle.chmod(options.mode);
11
13
  await handle.sync();
12
14
  }
13
15
  finally {
@@ -0,0 +1,25 @@
1
+ import type { ModelToolDefinition } from '../core/runtime.js';
2
+ export declare const APPLY_PATCH_MAX_EDITS = 32;
3
+ export declare const APPLY_PATCH_MAX_FILES = 8;
4
+ export declare const APPLY_PATCH_MAX_INPUT_BYTES: number;
5
+ export interface ApplyPatchEdit {
6
+ file_path: string;
7
+ old_string: string;
8
+ new_string: string;
9
+ }
10
+ export interface ApplyPatchPlanFile {
11
+ filePath: string;
12
+ before: string;
13
+ after: string;
14
+ linesAdded: number;
15
+ linesRemoved: number;
16
+ }
17
+ export interface ApplyPatchPlan {
18
+ files: readonly ApplyPatchPlanFile[];
19
+ linesAdded: number;
20
+ linesRemoved: number;
21
+ }
22
+ export declare const APPLY_PATCH_DEFINITION: ModelToolDefinition;
23
+ export declare function parseApplyPatchInput(input: unknown): ApplyPatchEdit[];
24
+ export declare function planApplyPatch(edits: readonly ApplyPatchEdit[], sources: ReadonlyMap<string, string>, maxFileBytes: number): ApplyPatchPlan;
25
+ //# sourceMappingURL=apply-patch.d.ts.map
@@ -0,0 +1,128 @@
1
+ import { countLineChanges } from './line-changes.js';
2
+ export const APPLY_PATCH_MAX_EDITS = 32;
3
+ export const APPLY_PATCH_MAX_FILES = 8;
4
+ export const APPLY_PATCH_MAX_INPUT_BYTES = 256 * 1024;
5
+ export const APPLY_PATCH_DEFINITION = {
6
+ name: 'ApplyPatch',
7
+ description: 'Applies a bounded batch of exact, unique string replacements to existing files.',
8
+ inputSchema: {
9
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
10
+ type: 'object',
11
+ properties: {
12
+ edits: {
13
+ type: 'array',
14
+ minItems: 1,
15
+ maxItems: APPLY_PATCH_MAX_EDITS,
16
+ items: {
17
+ type: 'object',
18
+ properties: {
19
+ file_path: { type: 'string', minLength: 1 },
20
+ old_string: { type: 'string', minLength: 1 },
21
+ new_string: { type: 'string' },
22
+ },
23
+ required: ['file_path', 'old_string', 'new_string'],
24
+ additionalProperties: false,
25
+ },
26
+ },
27
+ },
28
+ required: ['edits'],
29
+ additionalProperties: false,
30
+ },
31
+ };
32
+ function objectInput(input, label) {
33
+ if (!input || typeof input !== 'object' || Array.isArray(input))
34
+ throw new Error(`${label} must be an object`);
35
+ return input;
36
+ }
37
+ function exactKeys(input, keys) {
38
+ const unexpected = Object.keys(input).find((key) => !keys.includes(key));
39
+ if (unexpected)
40
+ throw new Error(`Unexpected ApplyPatch field: ${unexpected}`);
41
+ }
42
+ function stringInput(input, key, nonEmpty = false) {
43
+ const value = input[key];
44
+ if (typeof value !== 'string' || (nonEmpty && value.length === 0))
45
+ throw new Error(`${key} must be ${nonEmpty ? 'a non-empty ' : 'a '}string`);
46
+ return value;
47
+ }
48
+ export function parseApplyPatchInput(input) {
49
+ let encoded;
50
+ try {
51
+ encoded = JSON.stringify(input);
52
+ }
53
+ catch {
54
+ throw new Error('ApplyPatch input must be JSON-serializable');
55
+ }
56
+ if (encoded === undefined)
57
+ throw new Error('ApplyPatch input must be JSON-serializable');
58
+ if (Buffer.byteLength(encoded, 'utf8') > APPLY_PATCH_MAX_INPUT_BYTES)
59
+ throw new Error('ApplyPatch input exceeds 256 KiB');
60
+ const object = objectInput(input, 'ApplyPatch input');
61
+ exactKeys(object, ['edits']);
62
+ const rawEdits = object.edits;
63
+ if (!Array.isArray(rawEdits) ||
64
+ rawEdits.length < 1 ||
65
+ rawEdits.length > APPLY_PATCH_MAX_EDITS)
66
+ throw new Error('ApplyPatch edits must contain 1 to 32 operations');
67
+ return rawEdits.map((raw, index) => {
68
+ const item = objectInput(raw, `ApplyPatch edits[${index}]`);
69
+ exactKeys(item, ['file_path', 'old_string', 'new_string']);
70
+ const edit = {
71
+ file_path: stringInput(item, 'file_path', true),
72
+ old_string: stringInput(item, 'old_string', true),
73
+ new_string: stringInput(item, 'new_string'),
74
+ };
75
+ if (edit.old_string === edit.new_string)
76
+ throw new Error('new_string must differ from old_string');
77
+ return edit;
78
+ });
79
+ }
80
+ export function planApplyPatch(edits, sources, maxFileBytes) {
81
+ if (edits.length < 1 || edits.length > APPLY_PATCH_MAX_EDITS)
82
+ throw new Error('ApplyPatch edits must contain 1 to 32 operations');
83
+ if (new Set(edits.map((edit) => edit.file_path)).size > APPLY_PATCH_MAX_FILES)
84
+ throw new Error('ApplyPatch may touch at most 8 files');
85
+ const snapshots = new Map();
86
+ for (const edit of edits) {
87
+ if (!edit.old_string)
88
+ throw new Error('old_string must be non-empty');
89
+ if (edit.old_string === edit.new_string)
90
+ throw new Error('new_string must differ from old_string');
91
+ if (!snapshots.has(edit.file_path)) {
92
+ const source = sources.get(edit.file_path);
93
+ if (source === undefined)
94
+ throw new Error(`ApplyPatch source is missing: ${edit.file_path}`);
95
+ snapshots.set(edit.file_path, source);
96
+ }
97
+ }
98
+ for (const edit of edits) {
99
+ const source = snapshots.get(edit.file_path);
100
+ if (source === undefined)
101
+ throw new Error(`ApplyPatch source is missing: ${edit.file_path}`);
102
+ const first = source.indexOf(edit.old_string);
103
+ if (first < 0)
104
+ throw new Error('old_string was not found');
105
+ const second = source.indexOf(edit.old_string, first + edit.old_string.length);
106
+ if (second >= 0)
107
+ throw new Error('old_string must match exactly once');
108
+ const after = source.slice(0, first) +
109
+ edit.new_string +
110
+ source.slice(first + edit.old_string.length);
111
+ if (Buffer.byteLength(after, 'utf8') > maxFileBytes)
112
+ throw new Error(`Edited content exceeds ${maxFileBytes} bytes`);
113
+ snapshots.set(edit.file_path, after);
114
+ }
115
+ const files = [...snapshots].map(([filePath, after]) => {
116
+ const before = sources.get(filePath);
117
+ if (before === undefined)
118
+ throw new Error(`ApplyPatch source is missing: ${filePath}`);
119
+ const changes = countLineChanges(before, after);
120
+ return { filePath, before, after, ...changes };
121
+ });
122
+ return {
123
+ files,
124
+ linesAdded: files.reduce((total, file) => total + file.linesAdded, 0),
125
+ linesRemoved: files.reduce((total, file) => total + file.linesRemoved, 0),
126
+ };
127
+ }
128
+ //# sourceMappingURL=apply-patch.js.map
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, realpath } from 'node:fs/promises';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
3
  import { resolveToolSchedulingPolicy } from '../core/tool-scheduling-policy.js';
4
+ import { parseApplyPatchInput } from './apply-patch.js';
4
5
  const ASK_USER_QUESTION = {
5
6
  name: 'AskUserQuestion',
6
7
  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.",
@@ -354,6 +355,13 @@ Use AskUserQuestion for decisions that genuinely require the user. Write a compl
354
355
  typeof call.input.file_path === 'string') {
355
356
  return this.resolvePlanFile(state, call, context);
356
357
  }
358
+ if (state.mode === 'plan' && call.name === 'ApplyPatch') {
359
+ const edits = parseApplyPatchInput(call.input);
360
+ if ((await Promise.all(edits.map((edit) => this.isPlanFileForState(state, edit.file_path)))).every(Boolean)) {
361
+ return { behavior: 'allow' };
362
+ }
363
+ return this.resolver(state.mode).resolve(call, context);
364
+ }
357
365
  const decision = await this.resolver(this.planPermissionMode(sessionId)).resolve(call, context);
358
366
  return this.applyRetainedPrompts(state, call, decision);
359
367
  }
@@ -92,6 +92,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
92
92
  private readPdf;
93
93
  private createPdfOutputDirectory;
94
94
  private write;
95
+ private applyPatch;
95
96
  private edit;
96
97
  private notebookEdit;
97
98
  private glob;