praxis-agent 0.52.0 → 0.53.1

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
@@ -220,9 +220,18 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
220
220
  into mode-`0600` `.txt` files under the session-scoped `tool-results`
221
221
  directory; providers and transcripts receive only a bounded instruction with
222
222
  the absolute file path. Results at or below the limit remain inline, while
223
- structured, mixed-media, and binary-resource handling is unchanged. Explicit
224
- concrete `--tools` selections load selected tools directly, while
225
- `--disallowedTools ToolSearch` restores the complete tool list.
223
+ structured, mixed-media, and binary-resource handling is unchanged. MCP
224
+ tools that declare `readOnlyHint: true` default to allow through
225
+ provider-neutral permission metadata; explicit PreToolUse and permission
226
+ ask/deny decisions retain precedence, and missing or false hints retain the
227
+ existing default behavior. This is a Praxis permission contract, not a claim
228
+ of verified Claude Code 2.1.208 parity. Explicit concrete `--tools`
229
+ selections load selected tools directly, while
230
+ `--disallowedTools ToolSearch` restores the complete tool list. For each
231
+ context assembly, Git status is refreshed from the caller-resolved cwd while
232
+ environment and memory remain lifecycle-stable; collection uses
233
+ `--no-optional-locks`, fails closed on repository/status errors, and bounds
234
+ the rendered status to 2,048 UTF-8 bytes.
226
235
  - **Provider-neutral models** — native Provider Registry/Vault routing, API
227
236
  adapters, an experimental Codex OAuth adapter, explicit capability checks,
228
237
  separate per-attempt connect, byte-idle, and absolute-total timeouts, typed
@@ -342,6 +342,7 @@ export interface ToolExecutionContext {
342
342
  toolResultDirectory?: string;
343
343
  originalCall?: ModelToolCall;
344
344
  permissionUpdates?: readonly PermissionUpdate[];
345
+ toolPermission?: ToolPermissionMetadata;
345
346
  permissionPhase?: 'request' | 'execute';
346
347
  permissionApproved?: boolean;
347
348
  /** Internal marker for an explicit PreToolUse allow decision. */
@@ -405,6 +406,9 @@ export type PermissionApproval = boolean | {
405
406
  message: string;
406
407
  interrupt?: boolean;
407
408
  };
409
+ export interface ToolPermissionMetadata {
410
+ readonly readOnly: true;
411
+ }
408
412
  export interface PermissionResolutionContext {
409
413
  cwd: string;
410
414
  messages?: readonly ModelMessage[];
@@ -412,6 +416,7 @@ export interface PermissionResolutionContext {
412
416
  toolResultDirectory?: string;
413
417
  originalCall?: ModelToolCall;
414
418
  permissionUpdates?: readonly PermissionUpdate[];
419
+ toolPermission?: ToolPermissionMetadata;
415
420
  }
416
421
  export type PermissionDecisionSource = 'auto-classifier' | 'rule' | 'mode' | 'default';
417
422
  export type AutoModePermissionOutcome = 'blocked' | 'unavailable';
@@ -944,7 +944,10 @@ export class ClaudeMcpToolRegistry {
944
944
  };
945
945
  }
946
946
  async prepare(call, context) {
947
- return this.connectedTools.has(call.name) ||
947
+ const tool = this.connectedTools.get(call.name);
948
+ if (tool?.readOnly === true)
949
+ context.toolPermission = { readOnly: true };
950
+ return tool ||
948
951
  MCP_RESOURCE_TOOL_DEFINITIONS.some((definition) => definition.name === call.name)
949
952
  ? call
950
953
  : this.options.base.prepare(call, context);
@@ -131,7 +131,7 @@ ${sections.join('\n\n')}`,
131
131
  tailSections.push({
132
132
  id: 'relocated-runtime-context',
133
133
  placement: 'first-user',
134
- stability: 'session',
134
+ stability: 'volatile',
135
135
  content: renderClaudeDynamicUserContext({
136
136
  environment: dynamic.environment,
137
137
  ...(dynamic.gitStatus ? { gitStatus: dynamic.gitStatus } : {}),
@@ -143,8 +143,19 @@ ${sections.join('\n\n')}`,
143
143
  id: 'runtime-context',
144
144
  placement: 'system',
145
145
  stability: 'session',
146
- content: renderClaudeDynamicSystemContext(dynamic),
146
+ content: renderClaudeDynamicSystemContext({
147
+ environment: dynamic.environment,
148
+ ...(dynamic.memory ? { memory: dynamic.memory } : {}),
149
+ }),
147
150
  });
151
+ if (dynamic.gitStatus) {
152
+ tailSections.push({
153
+ id: 'git-status',
154
+ placement: 'system',
155
+ stability: 'volatile',
156
+ content: dynamic.gitStatus,
157
+ });
158
+ }
148
159
  }
149
160
  }
150
161
  const composition = this.composer.compose({
@@ -227,15 +238,22 @@ ${sections.join('\n\n')}`,
227
238
  throw new Error('Dynamic context loader is unavailable');
228
239
  if (!snapshot)
229
240
  return load(cwd);
230
- if (!snapshot.dynamic) {
231
- const pending = load(cwd);
232
- snapshot.dynamic = pending;
241
+ const current = load(cwd);
242
+ if (!snapshot.stableDynamic) {
243
+ const pending = current.then(({ environment, memory }) => ({
244
+ environment,
245
+ ...(memory ? { memory } : {}),
246
+ }));
247
+ snapshot.stableDynamic = pending;
233
248
  void pending.catch(() => {
234
- if (snapshot.dynamic === pending)
235
- delete snapshot.dynamic;
249
+ if (snapshot.stableDynamic === pending)
250
+ delete snapshot.stableDynamic;
236
251
  });
237
252
  }
238
- return snapshot.dynamic;
253
+ return Promise.all([current, snapshot.stableDynamic]).then(([fresh, stable]) => ({
254
+ ...stable,
255
+ ...(fresh.gitStatus ? { gitStatus: fresh.gitStatus } : {}),
256
+ }));
239
257
  }
240
258
  loadMcpInstructions(snapshot) {
241
259
  const load = this.options.loadMcpInstructions;
@@ -1,7 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { basename } from 'node:path';
3
3
  import { platform, release, type } from 'node:os';
4
- const MAX_GIT_OUTPUT_BYTES = 128 * 1024;
4
+ const MAX_GIT_OUTPUT_BYTES = 2_048;
5
+ const MAX_GIT_STATUS_BYTES = 2_048;
5
6
  const MAX_GIT_ERROR_BYTES = 8 * 1024;
6
7
  const GIT_TIMEOUT_MS = 5_000;
7
8
  function collectBounded(chunks, chunk, state, limit) {
@@ -16,6 +17,22 @@ function collectBounded(chunks, chunk, state, limit) {
16
17
  if (accepted.length < chunk.length)
17
18
  state.truncated = true;
18
19
  }
20
+ function boundUtf8(value, limit, marker) {
21
+ if (Buffer.byteLength(value, 'utf8') <= limit)
22
+ return value;
23
+ const markerBytes = Buffer.byteLength(marker, 'utf8');
24
+ const prefixLimit = Math.max(0, limit - markerBytes);
25
+ let bytes = 0;
26
+ let prefix = '';
27
+ for (const character of value) {
28
+ const characterBytes = Buffer.byteLength(character, 'utf8');
29
+ if (bytes + characterBytes > prefixLimit)
30
+ break;
31
+ prefix += character;
32
+ bytes += characterBytes;
33
+ }
34
+ return `${prefix}${marker}`;
35
+ }
19
36
  function defaultRunGit(cwd, args) {
20
37
  return new Promise((resolve, reject) => {
21
38
  const child = spawn('git', ['-C', cwd, ...args], {
@@ -93,9 +110,16 @@ async function renderGitStatus(runGit) {
93
110
  optionalGit(runGit, ['branch', '--show-current']),
94
111
  optionalGit(runGit, ['branch', '--format=%(refname:short)']),
95
112
  optionalGit(runGit, ['config', 'user.name']),
96
- optionalGit(runGit, ['status', '--short', '--untracked-files=all']),
113
+ optionalGit(runGit, [
114
+ '--no-optional-locks',
115
+ 'status',
116
+ '--short',
117
+ '--untracked-files=all',
118
+ ]),
97
119
  optionalGit(runGit, ['log', '-5', '--oneline']),
98
120
  ]);
121
+ if (!status.available)
122
+ return undefined;
99
123
  const branch = branchValue.output || 'HEAD';
100
124
  const sections = [
101
125
  '# gitStatus',
@@ -108,18 +132,16 @@ async function renderGitStatus(runGit) {
108
132
  if (user.available && user.output) {
109
133
  sections.push('', `Git user: ${user.output}`);
110
134
  }
111
- const renderedStatus = !status.available
112
- ? 'Unavailable'
113
- : status.output
114
- ? `${status.output}${status.truncated ? '\n... [truncated]' : ''}`
115
- : status.truncated
116
- ? '... [truncated]'
117
- : 'Clean';
135
+ const renderedStatus = status.output
136
+ ? `${status.output}${status.truncated ? '\n... [truncated]' : ''}`
137
+ : status.truncated
138
+ ? '... [truncated]'
139
+ : 'Clean';
118
140
  sections.push('', 'Status:', renderedStatus);
119
141
  if (commits.available && commits.output) {
120
142
  sections.push('', 'Recent commits:', commits.output);
121
143
  }
122
- return sections.join('\n');
144
+ return boundUtf8(sections.join('\n'), MAX_GIT_STATUS_BYTES, '\n... [truncated]');
123
145
  }
124
146
  export async function loadClaudeDynamicContext(options) {
125
147
  const configuredRunGit = options.runGit;
@@ -613,7 +613,9 @@ export class ClaudePermissionResolver {
613
613
  if (permissionMode === 'auto' && call.name === 'Bash') {
614
614
  return annotatePermissionDecision({ behavior: 'allow' }, 'default');
615
615
  }
616
- const defaultBehavior = DEFAULT_BEHAVIOR[call.name];
616
+ const defaultBehavior = context?.toolPermission?.readOnly === true
617
+ ? 'allow'
618
+ : DEFAULT_BEHAVIOR[call.name];
617
619
  return defaultBehavior === 'ask'
618
620
  ? this.askDecision(call, cwd, permissionMode, context, 'default', command
619
621
  ? (subcommand) => matchingRule('allow', subcommandCall(subcommand)) === undefined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.52.0",
3
+ "version": "0.53.1",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",