praxis-agent 0.55.3 → 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.
package/README.md CHANGED
@@ -172,8 +172,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
172
172
  deterministic resize-aware URL/form elicitation rendering, and measured
173
173
  context budgets; print mode,
174
174
  structured JSON/JSONL, context compaction, tool loops, and bounded execution.
175
- - **Built-in tools** — read, write, edit, glob, search, shell, notebook, PDF,
176
- image, web, scheduled prompts, workflows, and worktrees.
175
+ - **Built-in tools** — read, write, edit, `ApplyPatch` for bounded ordered exact
176
+ multi-file replacements, glob, search, shell, notebook, PDF, image, web,
177
+ scheduled prompts, workflows, and worktrees.
177
178
  - **Shell lifecycle** — foreground Bash allows up to 10 minutes and carries a
178
179
  validated final working directory across calls in the same session without
179
180
  leaking state across sessions or overriding an explicit `/cd`.
@@ -51,6 +51,7 @@ import { completeMeteredModelRequest } from './metered-model-completion.js';
51
51
  import { SessionMemoryController, SessionMemoryStateError, SessionMemoryStore, } from './session-memory.js';
52
52
  import { FilteredToolRegistry } from '../tools/filtered-tool-registry.js';
53
53
  import { DeferredToolCatalog } from '../tools/deferred-tool-catalog.js';
54
+ import { parseApplyPatchInput } from '../tools/apply-patch.js';
54
55
  import { ClaudeCapabilityToolRegistry, resolveClaudeToolCapabilities, } from '../tools/claude-capabilities.js';
55
56
  import { generateToolUseSummary } from './tool-use-summary.js';
56
57
  import { ClaudeUserMessageToolRegistry, } from '../tools/claude-user-message.js';
@@ -88,6 +89,21 @@ function mainAgentToolNames(tools, agent) {
88
89
  .map(({ name }) => name)
89
90
  .filter((name) => (!requested || requested.has(name)) && !disallowed.has(name));
90
91
  }
92
+ function mutationPaths(call) {
93
+ if (call.name === 'ApplyPatch')
94
+ return parseApplyPatchInput(call.input).map((edit) => edit.file_path);
95
+ if (call.name === 'Write' || call.name === 'Edit') {
96
+ return typeof call.input.file_path === 'string'
97
+ ? [call.input.file_path]
98
+ : [];
99
+ }
100
+ if (call.name === 'NotebookEdit') {
101
+ return typeof call.input.notebook_path === 'string'
102
+ ? [call.input.notebook_path]
103
+ : [];
104
+ }
105
+ return [];
106
+ }
91
107
  function isSessionCandidateError(error) {
92
108
  return (error instanceof NativeTranscriptIndexCandidateError ||
93
109
  ['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code ?? ''));
@@ -1168,6 +1184,7 @@ export class ClaudeSessionService {
1168
1184
  'Read',
1169
1185
  'LSP',
1170
1186
  'Edit',
1187
+ 'ApplyPatch',
1171
1188
  'Write',
1172
1189
  'NotebookEdit',
1173
1190
  'WebFetch',
@@ -2869,16 +2886,18 @@ export class ClaudeSessionService {
2869
2886
  }),
2870
2887
  prepare: (call, context) => interactiveMessageTools.prepare(call, context),
2871
2888
  execute: async (call, context) => {
2872
- const path = call.name === 'Write' || call.name === 'Edit'
2873
- ? call.input.file_path
2874
- : call.name === 'NotebookEdit'
2875
- ? call.input.notebook_path
2876
- : undefined;
2877
- if (typeof path !== 'string') {
2889
+ const paths = mutationPaths(call);
2890
+ if (paths.length === 0) {
2878
2891
  return interactiveMessageTools.execute(call, context);
2879
2892
  }
2880
- if ((call.name === 'Write' || call.name === 'Edit') &&
2881
- (await this.options.interactiveTools?.isPlanFile(sessionId, path))) {
2893
+ const historyPaths = call.name === 'Write' ||
2894
+ call.name === 'Edit' ||
2895
+ call.name === 'ApplyPatch'
2896
+ ? (await Promise.all(paths.map(async (path) => (await this.options.interactiveTools?.isPlanFile(sessionId, path))
2897
+ ? null
2898
+ : path))).filter((path) => path !== null)
2899
+ : paths;
2900
+ if (historyPaths.length === 0) {
2882
2901
  return interactiveMessageTools.execute(call, context);
2883
2902
  }
2884
2903
  const snapshotMessageId = currentPromptId ??
@@ -2888,21 +2907,31 @@ export class ClaudeSessionService {
2888
2907
  if (!snapshotMessageId || !assistantMessageId) {
2889
2908
  throw new Error('Claude file history could not link tool call');
2890
2909
  }
2891
- const prepared = await fileHistory.prepareMutation(projectionSnapshot().entries, snapshotMessageId, path);
2910
+ const preparedMutations = [];
2911
+ try {
2912
+ for (const path of [...new Set(historyPaths)])
2913
+ preparedMutations.push(await fileHistory.prepareMutation(projectionSnapshot().entries, snapshotMessageId, path));
2914
+ }
2915
+ catch (error) {
2916
+ await Promise.all(preparedMutations.map((mutation) => mutation.rollback()));
2917
+ throw error;
2918
+ }
2892
2919
  let result;
2893
2920
  try {
2894
2921
  result = await interactiveMessageTools.execute(call, context);
2895
2922
  }
2896
2923
  catch (error) {
2897
- await prepared.rollback();
2924
+ await Promise.all(preparedMutations.map((mutation) => mutation.rollback()));
2898
2925
  throw error;
2899
2926
  }
2900
2927
  if (result.isError) {
2901
- await prepared.rollback();
2928
+ await Promise.all(preparedMutations.map((mutation) => mutation.rollback()));
2902
2929
  return result;
2903
2930
  }
2904
- const entry = prepared.commit(assistantMessageId);
2905
- if (entry) {
2931
+ const entries = preparedMutations
2932
+ .map((mutation) => mutation.commit(assistantMessageId))
2933
+ .filter((entry) => entry !== null);
2934
+ for (const entry of entries) {
2906
2935
  await persistence.commit({
2907
2936
  kind: 'messages',
2908
2937
  input: {
@@ -3105,7 +3134,9 @@ export class ClaudeSessionService {
3105
3134
  if (call.name === 'Read') {
3106
3135
  turnMemory.recordRead(path);
3107
3136
  }
3108
- else if (call.name === 'Write' || call.name === 'Edit') {
3137
+ else if (call.name === 'Write' ||
3138
+ call.name === 'Edit' ||
3139
+ call.name === 'ApplyPatch') {
3109
3140
  projectMemoryMaintained = true;
3110
3141
  }
3111
3142
  }
@@ -3117,6 +3148,14 @@ export class ClaudeSessionService {
3117
3148
  }
3118
3149
  }
3119
3150
  }
3151
+ else if (call.name === 'ApplyPatch') {
3152
+ for (const accessedPath of toolResult.accessedPaths ?? []) {
3153
+ const resolvedAccessedPath = resolve(this.activeCwd(), accessedPath);
3154
+ if (isPathWithin(this.options.projectMemoryDirectory, resolvedAccessedPath)) {
3155
+ projectMemoryMaintained = true;
3156
+ }
3157
+ }
3158
+ }
3120
3159
  }
3121
3160
  if (toolResult.isError ||
3122
3161
  call.name !== 'Read' ||
@@ -299,6 +299,7 @@ const BACKGROUND_AGENT_TOOLS = new Set([
299
299
  'Glob',
300
300
  'Bash',
301
301
  'Edit',
302
+ 'ApplyPatch',
302
303
  'Write',
303
304
  'NotebookEdit',
304
305
  'Skill',
@@ -1885,7 +1885,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1885
1885
  }
1886
1886
  break;
1887
1887
  case 'tool-call':
1888
- if (['Edit', 'Write', 'NotebookEdit'].includes(event.call.name))
1888
+ if (['Edit', 'Write', 'ApplyPatch', 'NotebookEdit'].includes(event.call.name))
1889
1889
  turnMutatedFilesRef.current = true;
1890
1890
  permissionCallsRef.current.set(event.call.id, event.call);
1891
1891
  append({
@@ -4055,7 +4055,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
4055
4055
  }
4056
4056
  if (permission.kind === 'tool' &&
4057
4057
  selected.action === 'allow-session-edits') {
4058
- for (const rule of ['Write', 'Edit', 'NotebookEdit']) {
4058
+ for (const rule of ['Write', 'Edit', 'ApplyPatch', 'NotebookEdit']) {
4059
4059
  if (!immediatePermissionRulesRef.current.includes(rule))
4060
4060
  immediatePermissionRulesRef.current.push(rule);
4061
4061
  }
@@ -0,0 +1,3 @@
1
+ export declare const DIRECT_PROCESS_SIGINT: unique symbol;
2
+ export declare function isDirectProcessSigint(signal: AbortSignal | undefined): boolean;
3
+ //# sourceMappingURL=process-signal.d.ts.map
@@ -0,0 +1,5 @@
1
+ export const DIRECT_PROCESS_SIGINT = Symbol('praxis.direct-process-sigint');
2
+ export function isDirectProcessSigint(signal) {
3
+ return signal?.aborted === true && signal.reason === DIRECT_PROCESS_SIGINT;
4
+ }
5
+ //# sourceMappingURL=process-signal.js.map
@@ -323,6 +323,7 @@ export declare class StreamJsonOutput {
323
323
  private finishPartial;
324
324
  private finishPartialTail;
325
325
  private flushAssistant;
326
+ private flushPartialOnly;
326
327
  private finishTurn;
327
328
  }
328
329
  //# sourceMappingURL=protocol.d.ts.map
@@ -1929,9 +1929,16 @@ export class StreamJsonOutput {
1929
1929
  event.state === 'completed' ||
1930
1930
  event.state === 'cancelled' ||
1931
1931
  event.state === 'failed') {
1932
- if (event.state !== 'failed' ||
1933
- this.pendingFailureMessage === undefined)
1932
+ const shouldFlush = event.state === 'cancelled'
1933
+ ? this.turnText.length > 0 ||
1934
+ this.turnThinking.length > 0 ||
1935
+ this.turnCalls.length > 0
1936
+ : event.state !== 'failed' ||
1937
+ this.pendingFailureMessage === undefined;
1938
+ if (shouldFlush)
1934
1939
  this.flushAssistant();
1940
+ else if (event.state === 'cancelled')
1941
+ this.flushPartialOnly();
1935
1942
  if (event.state === 'awaiting-permission')
1936
1943
  this.writeSessionState('requires_action');
1937
1944
  }
@@ -2492,6 +2499,18 @@ export class StreamJsonOutput {
2492
2499
  });
2493
2500
  this.finishPartialTail();
2494
2501
  }
2502
+ flushPartialOnly() {
2503
+ if (!this.includePartialMessages ||
2504
+ this.assistantFlushed ||
2505
+ this.partialEvents.length === 0)
2506
+ return;
2507
+ this.assistantFlushed = true;
2508
+ this.writePartialMessageStart();
2509
+ for (const event of this.partialEvents)
2510
+ this.write(event);
2511
+ this.partialEvents = [];
2512
+ this.pendingPartialStop = undefined;
2513
+ }
2495
2514
  finishTurn() {
2496
2515
  this.flushAssistant();
2497
2516
  this.turnActive = false;
@@ -66,6 +66,7 @@ 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
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,
@@ -6016,6 +6017,15 @@ export async function run(argv, io = consoleIO, dependencies = createDefaultDepe
6016
6017
  }
6017
6018
  catch (error) {
6018
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
+ }
6019
6029
  io.stderr('Praxis run cancelled.\n');
6020
6030
  return 130;
6021
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;
@@ -12,6 +12,8 @@ import { countLineChanges } from './line-changes.js';
12
12
  import { editNotebook, formatNotebookForRead } from './notebook.js';
13
13
  import { openPdf } from './pdf.js';
14
14
  import { validateBashPathSafety } from '../permissions/bash-path-safety.js';
15
+ import { APPLY_PATCH_DEFINITION, APPLY_PATCH_MAX_FILES, parseApplyPatchInput, planApplyPatch, } from './apply-patch.js';
16
+ import { writeFileAtomically } from '../platform/atomic-write.js';
15
17
  import { protectedWritePathReason } from '../permissions/bypass-immune-paths.js';
16
18
  import { effectiveAdditionalDirectories, shellInputIsReadOnly, } from '../permissions/permission-updates.js';
17
19
  const REPORT_FINDINGS_DEFINITION = {
@@ -160,6 +162,7 @@ const TOOL_DEFINITIONS = [
160
162
  additionalProperties: false,
161
163
  },
162
164
  },
165
+ APPLY_PATCH_DEFINITION,
163
166
  {
164
167
  name: 'NotebookEdit',
165
168
  description: 'Replace, insert, or delete one cell in a Jupyter notebook after reading it.',
@@ -627,7 +630,15 @@ export class LocalToolRegistry {
627
630
  this.additionalReadDirectories.length === 0) {
628
631
  return definitions;
629
632
  }
630
- return definitions.map((definition) => ['Read', 'Write', 'Edit', 'NotebookEdit', 'Glob', 'Grep'].includes(definition.name)
633
+ return definitions.map((definition) => [
634
+ 'Read',
635
+ 'Write',
636
+ 'Edit',
637
+ 'ApplyPatch',
638
+ 'NotebookEdit',
639
+ 'Glob',
640
+ 'Grep',
641
+ ].includes(definition.name)
631
642
  ? {
632
643
  ...definition,
633
644
  description: `${definition.description}${this.additionalDirectories.length === 0
@@ -731,6 +742,26 @@ export class LocalToolRegistry {
731
742
  this.mutationTargetExisted.set(prepared, targetExisted);
732
743
  return prepared;
733
744
  }
745
+ case 'ApplyPatch': {
746
+ const edits = parseApplyPatchInput(call.input);
747
+ const preparedEdits = [];
748
+ for (const edit of edits) {
749
+ const filePath = await this.filePath(edit.file_path, false, false, context);
750
+ this.assertProtectedWritePath(filePath);
751
+ if (!(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
752
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
753
+ }
754
+ preparedEdits.push({
755
+ file_path: filePath,
756
+ old_string: edit.old_string,
757
+ new_string: edit.new_string,
758
+ });
759
+ }
760
+ if (new Set(preparedEdits.map((edit) => edit.file_path)).size >
761
+ APPLY_PATCH_MAX_FILES)
762
+ throw new Error('ApplyPatch may touch at most 8 files');
763
+ return { ...call, input: { edits: preparedEdits } };
764
+ }
734
765
  case 'NotebookEdit': {
735
766
  const requestedPath = stringInput(call.input, 'notebook_path');
736
767
  if (!isAbsolute(requestedPath)) {
@@ -839,6 +870,8 @@ export class LocalToolRegistry {
839
870
  return this.write(prepared, context, targetExisted);
840
871
  case 'Edit':
841
872
  return this.edit(prepared, targetExisted);
873
+ case 'ApplyPatch':
874
+ return this.applyPatch(prepared, context);
842
875
  case 'NotebookEdit':
843
876
  return this.notebookEdit(prepared);
844
877
  case 'Glob':
@@ -1222,6 +1255,74 @@ export class LocalToolRegistry {
1222
1255
  await handle.close();
1223
1256
  }
1224
1257
  }
1258
+ async applyPatch(call, context) {
1259
+ const edits = parseApplyPatchInput(call.input);
1260
+ const sources = new Map();
1261
+ const modes = new Map();
1262
+ const identities = new Map();
1263
+ const paths = [];
1264
+ for (const edit of edits) {
1265
+ const filePath = edit.file_path;
1266
+ if (sources.has(filePath))
1267
+ continue;
1268
+ paths.push(filePath);
1269
+ const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
1270
+ try {
1271
+ await this.assertStablePath(filePath);
1272
+ const metadata = await handle.stat();
1273
+ if (!metadata.isFile())
1274
+ throw new Error(`Not a file: ${filePath}`);
1275
+ if (metadata.size > this.maxFileBytes)
1276
+ throw new Error(`File exceeds ${this.maxFileBytes} byte edit limit`);
1277
+ sources.set(filePath, await handle.readFile('utf8'));
1278
+ identities.set(filePath, { dev: metadata.dev, ino: metadata.ino });
1279
+ modes.set(filePath, metadata.mode & 0o7777);
1280
+ }
1281
+ finally {
1282
+ await handle.close();
1283
+ }
1284
+ }
1285
+ const plan = planApplyPatch(edits, sources, this.maxFileBytes);
1286
+ for (const file of plan.files) {
1287
+ if (context.signal?.aborted)
1288
+ throw abortError();
1289
+ const mode = modes.get(file.filePath);
1290
+ const committed = await writeFileAtomically(file.filePath, file.after, {
1291
+ ...(mode === undefined ? {} : { mode }),
1292
+ beforeCommit: async () => {
1293
+ let handle;
1294
+ try {
1295
+ handle = await open(file.filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
1296
+ await this.assertStablePath(file.filePath);
1297
+ const metadata = await handle.stat();
1298
+ if (!metadata.isFile())
1299
+ return false;
1300
+ const identity = identities.get(file.filePath);
1301
+ if (!identity ||
1302
+ metadata.dev !== identity.dev ||
1303
+ metadata.ino !== identity.ino)
1304
+ return false;
1305
+ return (await handle.readFile('utf8')) === file.before;
1306
+ }
1307
+ catch {
1308
+ return false;
1309
+ }
1310
+ finally {
1311
+ await handle?.close();
1312
+ }
1313
+ },
1314
+ });
1315
+ if (!committed)
1316
+ throw new Error('Tool input changed after permission approval');
1317
+ }
1318
+ return {
1319
+ content: `Applied ${edits.length} replacement(s) to ${paths.length} file(s)`,
1320
+ isError: false,
1321
+ linesAdded: plan.linesAdded,
1322
+ linesRemoved: plan.linesRemoved,
1323
+ accessedPaths: paths,
1324
+ };
1325
+ }
1225
1326
  async edit(call, preparedTargetExisted) {
1226
1327
  const filePath = stringInput(call.input, 'file_path');
1227
1328
  this.assertProtectedWritePath(filePath);
@@ -5,8 +5,9 @@ import { analyzeBashStructure, normalizeBashWrapperArgv, } from '../permissions/
5
5
  import { validateBashPathSafety } from '../permissions/bash-path-safety.js';
6
6
  import { shellInputIsReadOnly } from '../permissions/permission-updates.js';
7
7
  import { isPathWithin } from '../platform/path-containment.js';
8
+ import { parseApplyPatchInput } from './apply-patch.js';
8
9
  const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'Bash']);
9
- const WRITE_TOOLS = new Set([...READ_ONLY_TOOLS, 'Write', 'Edit']);
10
+ const WRITE_TOOLS = new Set([...READ_ONLY_TOOLS, 'Write', 'Edit', 'ApplyPatch']);
10
11
  const SAFE_GIT_COMMANDS = new Set([
11
12
  'status',
12
13
  'diff',
@@ -88,6 +89,9 @@ function within(root, candidate) {
88
89
  function toolInputPaths(call) {
89
90
  if (call.name === 'Read' || call.name === 'Write' || call.name === 'Edit')
90
91
  return [call.input.file_path];
92
+ if (call.name === 'ApplyPatch') {
93
+ return parseApplyPatchInput(call.input).map((edit) => edit.file_path);
94
+ }
91
95
  if (call.name === 'Glob' || call.name === 'Grep')
92
96
  return call.input.path === undefined ? [] : [call.input.path];
93
97
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.55.3",
3
+ "version": "0.56.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -62,7 +62,7 @@
62
62
  "verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
63
63
  "verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
64
64
  "test:fixtures": "node scripts/run-fixture-contracts.mjs",
65
- "test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts"
65
+ "test:eval:baseline": "vitest run src/evals/coding-baseline.test.ts src/evals/project-eval-comparison.test.ts src/evals/apply-patch-admission.test.ts"
66
66
  },
67
67
  "engines": {
68
68
  "node": ">=24"