praxis-agent 0.53.0 → 0.54.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
@@ -13,7 +13,8 @@ Praxis is a local-first, single-user general agent for the command line.
13
13
 
14
14
  It provides an interactive or headless agent loop, local tools, permissions,
15
15
  sessions, skills, hooks, MCP, plugins, background agents, and provider-neutral
16
- Anthropic/OpenAI-compatible model access. Praxis deliberately excludes
16
+ Anthropic, OpenAI-compatible Chat Completions, and OpenAI Responses model
17
+ access. Praxis deliberately excludes
17
18
  accounts, organizations, billing, managed enterprise policy, remote control,
18
19
  IDE surfaces, and telemetry control planes.
19
20
 
@@ -27,9 +28,9 @@ sessions, configuration, or compatibility directories.
27
28
  - macOS or Linux
28
29
  - Node.js 24 or newer
29
30
  - [`ripgrep`](https://github.com/BurntSushi/ripgrep) (`rg`) for the Grep tool
30
- - an API key and model ID for an Anthropic or OpenAI-compatible provider (the
31
- stable setup), or the explicitly enabled experimental ChatGPT-backed Codex
32
- subscription integration
31
+ - an API key and model ID for an Anthropic, OpenAI-compatible, or OpenAI
32
+ Responses provider (the stable setup), or the explicitly enabled experimental
33
+ ChatGPT-backed Codex subscription integration
33
34
 
34
35
  Praxis does not use Claude subscription authentication. Claude-shaped message,
35
36
  tool, and CLI protocol forms remain supported where they are part of the
@@ -63,6 +64,20 @@ cd /path/to/project
63
64
  praxis
64
65
  ```
65
66
 
67
+ To use OpenAI's Responses API with an explicit API-key provider:
68
+
69
+ ```sh
70
+ export PRAXIS_PROVIDER="openai-responses"
71
+ export OPENAI_API_KEY="your-api-key"
72
+ export PRAXIS_MODEL="your-responses-model-id"
73
+
74
+ cd /path/to/project
75
+ praxis
76
+ ```
77
+
78
+ The `openai` provider remains OpenAI-compatible Chat Completions. Provider
79
+ protocols are selected explicitly; model IDs never switch protocols implicitly.
80
+
66
81
  Praxis also has an experimental `openai-codex` provider for ChatGPT-backed
67
82
  Codex subscriptions. It is separate from OpenAI API-key access, requires
68
83
  `experimental.codexSubscription: true`, and stores OAuth credentials in the
@@ -227,14 +242,22 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
227
242
  existing default behavior. This is a Praxis permission contract, not a claim
228
243
  of verified Claude Code 2.1.208 parity. Explicit concrete `--tools`
229
244
  selections load selected tools directly, while
230
- `--disallowedTools ToolSearch` restores the complete tool list.
245
+ `--disallowedTools ToolSearch` restores the complete tool list. For each
246
+ context assembly, Git status is refreshed from the caller-resolved cwd while
247
+ environment and memory remain lifecycle-stable; collection uses
248
+ `--no-optional-locks`, fails closed on repository/status errors, and bounds
249
+ the rendered status to 2,048 UTF-8 bytes.
231
250
  - **Provider-neutral models** — native Provider Registry/Vault routing, API
232
251
  adapters, an experimental Codex OAuth adapter, explicit capability checks,
233
252
  separate per-attempt connect, byte-idle, and absolute-total timeouts, typed
234
253
  recovery for malformed streamed tool arguments without tool execution or
235
254
  lost resumability, one default-on bounded Anthropic non-streaming replay for
236
255
  eligible stream/idle failures without exposing failed-attempt output, and
237
- token-only/no-API-dollar accounting for subscription runs.
256
+ token-only/no-API-dollar accounting for subscription runs. For each main user
257
+ turn, failed provider attempts stay buffered; the first successful route,
258
+ whether primary or fallback, is sticky and sealed through that turn's tool
259
+ continuations; incompatible fallback routes fail closed, and each new main
260
+ user turn starts from primary.
238
261
  - **Transactional self-update** — `praxis update` verifies the package before
239
262
  installing it, rejects concurrent updates, and can roll back after an
240
263
  interruption or crash.
@@ -70,6 +70,8 @@ export interface ClaudeSessionServiceOptions {
70
70
  enableWorkflows?: boolean;
71
71
  providerForModel?: (model: string) => ModelProvider;
72
72
  providerForMainModel?: (model: string) => ModelProvider;
73
+ /** Creates one fresh main-turn provider per outer user turn; never used for auxiliary model calls. */
74
+ providerForTurn?: (model?: string) => ModelProvider;
73
75
  /** Creates a provider adapter dedicated to Session memory requests so
74
76
  * adapter-local cache and retry state are not shared with the foreground. */
75
77
  sessionMemoryProviderFactory?: () => ModelProvider;
@@ -745,7 +745,16 @@ export class ClaudeSessionService {
745
745
  ? (options.extensions?.agent(options.agent) ?? null)
746
746
  : null;
747
747
  if (configuredAgent && options.provider) {
748
- this.activeProvider = this.providerForAgent(configuredAgent);
748
+ const selectedModel = !options.explicitModel &&
749
+ configuredAgent.model &&
750
+ configuredAgent.model !== 'inherit'
751
+ ? configuredAgent.model
752
+ : undefined;
753
+ const selectProvider = options.providerForMainModel ?? options.providerForModel;
754
+ this.activeProvider =
755
+ selectedModel !== undefined && selectProvider
756
+ ? selectProvider(selectedModel)
757
+ : this.options.provider;
749
758
  }
750
759
  }
751
760
  nextScheduledPrompt(signal) {
@@ -5043,6 +5052,12 @@ export class ClaudeSessionService {
5043
5052
  providerForAgent(agent) {
5044
5053
  const inherited = this.provider();
5045
5054
  const selectProvider = this.options.providerForMainModel ?? this.options.providerForModel;
5055
+ const effectiveModel = !this.options.explicitModel && agent?.model && agent.model !== 'inherit'
5056
+ ? agent.model
5057
+ : inherited.model;
5058
+ if (this.options.providerForTurn) {
5059
+ return this.options.providerForTurn(effectiveModel);
5060
+ }
5046
5061
  if (this.options.explicitModel ||
5047
5062
  !agent?.model ||
5048
5063
  agent.model === 'inherit' ||
@@ -1044,6 +1044,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1044
1044
  let provider;
1045
1045
  let providerForModel;
1046
1046
  let providerForMainModel;
1047
+ let providerForTurn;
1047
1048
  let providerBillingMode;
1048
1049
  const context = parseContextEnvironment(runtimeEnvironment);
1049
1050
  const apiKey = runtimeEnvironment.PRAXIS_API_KEY;
@@ -1164,17 +1165,25 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1164
1165
  providerBillingMode = registry.target.billingMode;
1165
1166
  providerForModel = (selectedModel) => registry.create(selectedModel);
1166
1167
  const createProvider = providerForModel;
1167
- providerForMainModel = (primaryModel) => {
1168
+ const createProviderStack = (primaryModel, routeScope) => {
1168
1169
  const models = [primaryModel, ...(cli.fallbackModels ?? [])].filter((candidate, index, all) => all.indexOf(candidate) === index);
1169
1170
  const providers = models.map((candidate) => createProvider(candidate));
1170
1171
  const selected = providers[0];
1171
1172
  if (!selected)
1172
1173
  throw new Error('A primary model is required');
1173
1174
  return providers.length > 1
1174
- ? new FallbackModelProvider({ providers })
1175
+ ? new FallbackModelProvider({ providers, routeScope })
1175
1176
  : selected;
1176
1177
  };
1177
- provider = providerForMainModel(model);
1178
+ providerForMainModel = (primaryModel) => createProviderStack(primaryModel, 'completion');
1179
+ const defaultProvider = providerForMainModel(model);
1180
+ provider = defaultProvider;
1181
+ providerForTurn = (turnModel) => {
1182
+ const selectedModel = turnModel ?? model;
1183
+ return selectedModel === undefined
1184
+ ? defaultProvider
1185
+ : createProviderStack(selectedModel, 'turn');
1186
+ };
1178
1187
  }
1179
1188
  catch (error) {
1180
1189
  const optionalProviderError = error instanceof ProviderAuthenticationError ||
@@ -1918,6 +1927,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1918
1927
  provider: hostedToolProvider,
1919
1928
  ...(providerForModel ? { providerForModel } : {}),
1920
1929
  ...(providerForMainModel ? { providerForMainModel } : {}),
1930
+ ...(providerForTurn ? { providerForTurn } : {}),
1921
1931
  tools: filteredTools,
1922
1932
  toolCapabilityEnvironment: runtimeEnvironment,
1923
1933
  ...(!experimentalNativeTranscriptWrites ? { mcp: mcpTools } : {}),
@@ -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;
@@ -14,19 +14,13 @@ export interface CodexSubscriptionProviderOptions {
14
14
  maxErrorBodyBytes?: number;
15
15
  thinking?: ModelThinkingConfig;
16
16
  }
17
- export declare function serializeCodexRequest(request: ModelRequest, model: string, configuredThinking?: ModelThinkingConfig): Record<string, unknown>;
18
- export declare function parseCodexSseFrame(data: string): ModelStreamEvent[];
19
17
  export declare class CodexSubscriptionProvider implements ModelProvider {
20
18
  private readonly options;
21
19
  readonly model: string;
22
20
  readonly capabilities: ModelProvider['capabilities'];
23
21
  private readonly fetchImplementation;
24
- private readonly maxStreamBufferBytes;
25
- private readonly maxToolArgumentsBytes;
26
- private readonly maxToolCallsPerResponse;
27
- private readonly maxToolMetadataBytes;
28
- private readonly maxReasoningBytes;
29
22
  private readonly maxErrorBodyBytes;
23
+ private readonly responsesCodec;
30
24
  constructor(options: CodexSubscriptionProviderOptions);
31
25
  complete(request: ModelRequest): AsyncIterable<ModelStreamEvent>;
32
26
  }