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.
- package/README.md +3 -2
- package/dist/application/session-service.js +53 -14
- package/dist/application/subagent-service.js +1 -0
- package/dist/cli/interactive.js +2 -2
- package/dist/cli/process-signal.d.ts +3 -0
- package/dist/cli/process-signal.js +5 -0
- package/dist/cli/protocol.d.ts +30 -6
- package/dist/cli/protocol.js +166 -39
- package/dist/cli-runtime.js +57 -10
- package/dist/cli.js +4 -2
- package/dist/evals/eval-contract.js +9 -1
- package/dist/mcp/praxis-mcp-server.js +13 -1
- package/dist/permissions/claude-permission-resolver.js +147 -4
- package/dist/platform/atomic-write.js +2 -0
- package/dist/tools/apply-patch.d.ts +25 -0
- package/dist/tools/apply-patch.js +128 -0
- package/dist/tools/claude-interactive-tools.js +8 -0
- package/dist/tools/local-tools.d.ts +1 -0
- package/dist/tools/local-tools.js +102 -1
- package/dist/tools/team-member-tools.js +5 -1
- package/package.json +2 -2
package/dist/cli-runtime.js
CHANGED
|
@@ -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',
|
|
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',
|
|
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 ([
|
|
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
|
-
|
|
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, [
|
|
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
|
}
|