praxis-agent 0.59.0 → 0.60.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
@@ -126,8 +126,11 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
126
126
  composer grammar, compact stable tool rows, responsive density,
127
127
  terminal-native background, and a minimal composer/status row. Successful
128
128
  background Bash completion bursts collapse in normal reading, while
129
- failed/stopped notifications remain detailed. Interactive
130
- surfaces share the same presentation across terminals, with English
129
+ failed/stopped notifications remain detailed.
130
+ Prompt-like background Bash output that remains unchanged for 50 seconds
131
+ raises one warning and model follow-up without stopping or reclassifying the
132
+ running task; silent and ordinary output remain quiet.
133
+ Interactive surfaces share the same presentation across terminals, with English
131
134
  permission/configuration choices and a taught `❯` / Up/Down / Enter / Esc
132
135
  interaction grammar. While a regular turn is active, the composer remains
133
136
  editable: Enter steers at the next safe continuation boundary, Tab or
@@ -232,8 +235,12 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
232
235
  imports, memory, skills, commands, agents, hooks, settings, MCP servers,
233
236
  plugins, and append-only `praxis.transcript` JSONL sessions under `~/.praxis`,
234
237
  with bounded MCP connection, discovery, and tool operations plus safe
235
- disconnect recovery that never replays an already-dispatched call. Default
236
- tool selection defers `mcp__*` schemas behind a turn-scoped `ToolSearch`;
238
+ disconnect recovery that never replays an already-dispatched call. Stdio MCP
239
+ environment grants expand defined `${NAME}` references at launch without
240
+ exposing unrelated ambient credentials or shell-startup variables; reconnect
241
+ retains the derived grants, while reload derives them again from the current
242
+ environment. Default tool selection defers `mcp__*` schemas behind a
243
+ turn-scoped `ToolSearch`;
237
244
  each query activates at most eight deterministic matches for the next model
238
245
  request, and published MCP tool descriptions are capped at 2,048 Unicode
239
246
  code points. Text-only MCP results above 100,000 UTF-8 bytes are redacted
@@ -18,6 +18,7 @@ export interface BackgroundBashManagerOptions {
18
18
  stateRoot: string;
19
19
  maxOutputBytes?: number;
20
20
  eventSink?: RuntimeEventSink;
21
+ stallWatchdogMs?: number;
21
22
  }
22
23
  export interface BackgroundBashLaunchInput {
23
24
  command: string;
@@ -40,6 +41,7 @@ export declare class BackgroundBashManager {
40
41
  private readonly runner;
41
42
  private readonly outputRoot;
42
43
  private readonly sessionStateRoot;
44
+ private readonly stallWatchdogMs;
43
45
  constructor(options: BackgroundBashManagerOptions);
44
46
  has(taskId: string): boolean;
45
47
  snapshots(): Promise<readonly BackgroundBashSnapshot[]>;
@@ -56,6 +58,8 @@ export declare class BackgroundBashManager {
56
58
  stop(taskId: string): Promise<BackgroundBashToolResult>;
57
59
  notifications(waitForRunning: boolean): Promise<string[]>;
58
60
  private run;
61
+ private clearWatchdog;
62
+ private rearmWatchdog;
59
63
  private emitNotification;
60
64
  private complete;
61
65
  private outputResult;
@@ -53,14 +53,48 @@ function escapeXml(value) {
53
53
  .replaceAll('"', '&quot;')
54
54
  .replaceAll("'", '&apos;');
55
55
  }
56
+ function isPromptTail(output) {
57
+ const tail = output
58
+ .slice(-1024)
59
+ .replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'gu'), '');
60
+ const lines = tail.split(/\r?\n/u);
61
+ const line = [...lines].reverse().find((item) => item.trim() !== '');
62
+ if (!line)
63
+ return false;
64
+ const value = line.trim().replace(/[\t ]+$/u, '');
65
+ if (/\?\s*(?:\[[^\]]{1,20}\]|\([^)]{1,20}\))$/u.test(value))
66
+ return true;
67
+ if (/^(?:password|passphrase|username)\s*:\s*$/iu.test(value) ||
68
+ /^(?:enter|input|select|selection|choice|confirm)\b[^\n:]{0,100}:\s*$/iu.test(value))
69
+ return true;
70
+ return /^(?:press|hit)\s+(?:enter|return|any key)(?:\s+to\b[^\n]*)?[.!?:…]?$/iu.test(value);
71
+ }
72
+ function watchdogNotification(task, summary) {
73
+ return `<task-notification>\n<task-id>${escapeXml(task.taskId)}</task-id>\n<tool-use-id>${escapeXml(task.toolUseId)}</tool-use-id>\n<output-file>${escapeXml(task.outputFile)}</output-file>\n<summary>${escapeXml(summary)}</summary>\n</task-notification>\nLast output:\n${task.output}\n\nThe command is likely blocked on an interactive prompt. Stop this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`;
74
+ }
75
+ function createWatchdogWake() {
76
+ let resolveWake;
77
+ const promise = new Promise((resolve) => {
78
+ resolveWake = resolve;
79
+ });
80
+ return { promise, resolve: resolveWake };
81
+ }
56
82
  export class BackgroundBashManager {
57
83
  options;
58
84
  tasks = new Map();
59
85
  runner;
60
86
  outputRoot;
61
87
  sessionStateRoot;
88
+ stallWatchdogMs;
62
89
  constructor(options) {
63
90
  this.options = options;
91
+ if (options.stallWatchdogMs !== undefined &&
92
+ (!Number.isFinite(options.stallWatchdogMs) ||
93
+ !Number.isInteger(options.stallWatchdogMs) ||
94
+ options.stallWatchdogMs <= 0)) {
95
+ throw new RangeError('stallWatchdogMs must be a positive integer');
96
+ }
97
+ this.stallWatchdogMs = options.stallWatchdogMs ?? 50_000;
64
98
  this.runner = new BoundedProcessRunner({
65
99
  cwd: options.cwd,
66
100
  maxOutputBytes: options.maxOutputBytes ?? 128 * 1024,
@@ -88,6 +122,7 @@ export class BackgroundBashManager {
88
122
  const outputFile = resolve(this.outputRoot, `${id}.output`);
89
123
  await writeFile(outputFile, '', { mode: 0o600 });
90
124
  const controller = new AbortController();
125
+ const watchdogWake = createWatchdogWake();
91
126
  const task = {
92
127
  taskId: id,
93
128
  command: input.command,
@@ -108,6 +143,11 @@ export class BackgroundBashManager {
108
143
  parentAbort: () => controller.abort(),
109
144
  }
110
145
  : {}),
146
+ watchdogTimer: null,
147
+ watchdogFired: false,
148
+ pendingWatchdogMessage: null,
149
+ watchdogWake: watchdogWake.promise,
150
+ watchdogWakeResolve: watchdogWake.resolve,
111
151
  };
112
152
  if (task.parentSignal && task.parentAbort) {
113
153
  if (task.parentSignal.aborted)
@@ -192,12 +232,25 @@ export class BackgroundBashManager {
192
232
  async notifications(waitForRunning) {
193
233
  await this.hydratePersistedTasks();
194
234
  if (waitForRunning) {
195
- await Promise.all([...this.tasks.values()]
196
- .filter(({ status }) => status === 'running')
197
- .map(({ completion }) => completion));
235
+ const hasPendingWatchdog = [...this.tasks.values()].some(({ pendingWatchdogMessage }) => pendingWatchdogMessage !== null);
236
+ const running = [...this.tasks.values()].filter(({ status }) => status === 'running');
237
+ const completions = Promise.all(running.map(({ completion }) => completion));
238
+ const wakes = running
239
+ .filter((task) => !task.watchdogFired)
240
+ .map(({ watchdogWake }) => watchdogWake);
241
+ if (!hasPendingWatchdog && wakes.length > 0)
242
+ await Promise.race([completions, ...wakes]);
243
+ else if (!hasPendingWatchdog)
244
+ await completions;
198
245
  }
199
246
  const messages = [];
200
247
  for (const task of this.tasks.values()) {
248
+ if (task.pendingWatchdogMessage) {
249
+ const pendingWatchdogMessage = task.pendingWatchdogMessage;
250
+ task.pendingWatchdogMessage = null;
251
+ if (!task.notified && task.status !== 'stopped')
252
+ messages.push(pendingWatchdogMessage);
253
+ }
201
254
  if (task.status === 'running' ||
202
255
  task.status === 'stopped' ||
203
256
  task.notified) {
@@ -219,6 +272,7 @@ export class BackgroundBashManager {
219
272
  signal: task.controller.signal,
220
273
  onOutput: async (output) => {
221
274
  task.output = output;
275
+ this.rearmWatchdog(task);
222
276
  await writeFile(task.outputFile, output, { mode: 0o600 });
223
277
  },
224
278
  });
@@ -244,12 +298,45 @@ export class BackgroundBashManager {
244
298
  });
245
299
  }
246
300
  finally {
301
+ this.clearWatchdog(task);
247
302
  this.emitNotification(task);
248
303
  if (task.parentSignal && task.parentAbort) {
249
304
  task.parentSignal.removeEventListener('abort', task.parentAbort);
250
305
  }
251
306
  }
252
307
  }
308
+ clearWatchdog(task) {
309
+ if (task.watchdogTimer !== null) {
310
+ clearTimeout(task.watchdogTimer);
311
+ task.watchdogTimer = null;
312
+ }
313
+ }
314
+ rearmWatchdog(task) {
315
+ this.clearWatchdog(task);
316
+ if (task.status !== 'running' ||
317
+ task.watchdogFired ||
318
+ !isPromptTail(task.output))
319
+ return;
320
+ task.watchdogTimer = setTimeout(() => {
321
+ task.watchdogTimer = null;
322
+ if (task.status !== 'running' ||
323
+ task.watchdogFired ||
324
+ !isPromptTail(task.output))
325
+ return;
326
+ task.watchdogFired = true;
327
+ const summary = `Background command "${task.description}" appears to be waiting for interactive input`;
328
+ task.pendingWatchdogMessage = watchdogNotification(task, summary);
329
+ this.options.eventSink?.({
330
+ type: 'task-input-waiting',
331
+ taskId: task.taskId,
332
+ toolUseId: task.toolUseId,
333
+ outputFile: task.outputFile,
334
+ summary,
335
+ });
336
+ task.watchdogWakeResolve?.();
337
+ task.watchdogWakeResolve = null;
338
+ }, this.stallWatchdogMs);
339
+ }
253
340
  emitNotification(task) {
254
341
  if (task.status === 'running')
255
342
  return;
@@ -330,6 +417,11 @@ export class BackgroundBashManager {
330
417
  completion: Promise.resolve(),
331
418
  startedAt: state.startedAt ?? Math.floor(metadata.mtimeMs),
332
419
  durationMs: state.durationMs ?? null,
420
+ watchdogTimer: null,
421
+ watchdogFired: false,
422
+ pendingWatchdogMessage: null,
423
+ watchdogWake: createWatchdogWake().promise,
424
+ watchdogWakeResolve: null,
333
425
  };
334
426
  this.tasks.set(taskId, task);
335
427
  return task;
@@ -1968,6 +1968,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1968
1968
  },
1969
1969
  });
1970
1970
  break;
1971
+ case 'task-input-waiting':
1972
+ append({ kind: 'warning', text: event.summary });
1973
+ break;
1971
1974
  case 'compact-boundary':
1972
1975
  append({
1973
1976
  kind: 'notice',
@@ -2206,6 +2206,21 @@ export class StreamJsonOutput {
2206
2206
  });
2207
2207
  return;
2208
2208
  }
2209
+ if (event.type === 'task-input-waiting') {
2210
+ this.write({
2211
+ type: 'system',
2212
+ subtype: 'task_notification',
2213
+ task_id: event.taskId,
2214
+ ...(event.toolUseId === undefined
2215
+ ? {}
2216
+ : { tool_use_id: event.toolUseId }),
2217
+ output_file: event.outputFile,
2218
+ summary: event.summary,
2219
+ uuid: randomUUID(),
2220
+ session_id: this.sessionId,
2221
+ });
2222
+ return;
2223
+ }
2209
2224
  if (event.type === 'session-state-changed') {
2210
2225
  this.writeSessionState(event.state);
2211
2226
  return;
@@ -275,6 +275,12 @@ export type RuntimeEvent = {
275
275
  toolUses: number;
276
276
  durationMs: number;
277
277
  };
278
+ } | {
279
+ type: 'task-input-waiting';
280
+ taskId: string;
281
+ toolUseId?: string;
282
+ outputFile: string;
283
+ summary: string;
278
284
  } | {
279
285
  type: 'session-state-changed';
280
286
  state: 'idle' | 'running' | 'requires_action';
@@ -257,12 +257,12 @@ export function validateClaudeMcpConfiguration(resources) {
257
257
  });
258
258
  return { servers, warnings };
259
259
  }
260
- async function transport(serverName, config, cwd, configRoot) {
260
+ async function transport(serverName, config, cwd, configRoot, environment = process.env) {
261
261
  if (config.type === 'stdio') {
262
262
  return new StdioClientTransport({
263
263
  command: config.command,
264
264
  args: config.args,
265
- env: sanitizeChildEnvironment(config.env),
265
+ env: sanitizeChildEnvironment(config.env, environment),
266
266
  cwd: config.cwd ?? cwd,
267
267
  stderr: 'pipe',
268
268
  });
@@ -280,6 +280,21 @@ async function transport(serverName, config, cwd, configRoot) {
280
280
  ...(authProvider ? { authProvider } : {}),
281
281
  });
282
282
  }
283
+ function deriveMcpServerConfig(config, environment) {
284
+ if (config.type !== 'stdio')
285
+ return config;
286
+ const env = Object.fromEntries(Object.entries(config.env).map(([name, value]) => [
287
+ name,
288
+ value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/gu, (match, variable) => {
289
+ const replacement = environment[variable];
290
+ return Object.prototype.hasOwnProperty.call(environment, variable) &&
291
+ typeof replacement === 'string'
292
+ ? replacement
293
+ : match;
294
+ }),
295
+ ]));
296
+ return { ...config, env };
297
+ }
283
298
  function toolContent(result, sensitiveValues) {
284
299
  if (!Array.isArray(result.content)) {
285
300
  throw new Error('MCP tool result content must be an array');
@@ -883,7 +898,6 @@ export class ClaudeMcpToolRegistry {
883
898
  warn(`MCP server ${server.name} unavailable: ${error instanceof Error ? error.message : String(error)}`);
884
899
  continue;
885
900
  }
886
- this.serverSensitiveValues.set(server.name, configSensitiveValues(config, server.sensitiveValues ?? [], this.options.environment ?? process.env));
887
901
  await this.connectServer(server.name, config, server.sensitiveValues);
888
902
  }
889
903
  this.publishPrompts();
@@ -1091,7 +1105,10 @@ export class ClaudeMcpToolRegistry {
1091
1105
  async connectServer(serverName, config, additionalSensitiveValues = [], expectedGeneration = this.generation) {
1092
1106
  this.assertOpenGeneration(expectedGeneration);
1093
1107
  this.statuses.set(serverName, { name: serverName, status: 'failed' });
1094
- const sensitiveValues = configSensitiveValues(config, additionalSensitiveValues, this.options.environment ?? process.env);
1108
+ const environment = this.options.environment ?? process.env;
1109
+ const launchConfig = deriveMcpServerConfig(config, environment);
1110
+ const sensitiveValues = configSensitiveValues(launchConfig, additionalSensitiveValues, environment);
1111
+ this.serverSensitiveValues.set(serverName, sensitiveValues);
1095
1112
  const session = new McpServerSession({
1096
1113
  serverName,
1097
1114
  connectionTimeoutMs: this.timeouts.connectionTimeoutMs,
@@ -1099,7 +1116,7 @@ export class ClaudeMcpToolRegistry {
1099
1116
  ...(this.options.signal ? { lifetimeSignal: this.options.signal } : {}),
1100
1117
  createTransport: async () => {
1101
1118
  const configRoot = this.options.configRoot ?? join(homedir(), '.praxis');
1102
- return (await transport(serverName, config, this.options.cwd, configRoot));
1119
+ return (await transport(serverName, launchConfig, this.options.cwd, configRoot, environment));
1103
1120
  },
1104
1121
  configureClient: (client) => {
1105
1122
  client.setRequestHandler(ElicitRequestSchema, async (request) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",