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
|
@@ -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) => [
|
|
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.
|
|
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"
|