praxis-agent 0.31.0 → 0.32.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.
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { basename, isAbsolute, resolve } from 'node:path';
2
3
  import { commandShell, commandShellArguments, } from '../platform/command-shell.js';
3
4
  import { redactSensitiveText, sanitizeChildEnvironment, sensitiveEnvironmentValues, } from '../platform/sensitive-data.js';
4
5
  const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
@@ -11,12 +12,23 @@ export const HOOK_EVENTS = [
11
12
  'UserPromptSubmit',
12
13
  'PreToolUse',
13
14
  'PermissionRequest',
15
+ 'PermissionDenied',
14
16
  'PostToolUse',
15
17
  'PostToolUseFailure',
18
+ 'Notification',
16
19
  'Stop',
20
+ 'StopFailure',
17
21
  'SubagentStop',
22
+ 'PreCompact',
23
+ 'PostCompact',
24
+ 'TaskCreated',
25
+ 'TaskCompleted',
26
+ 'InstructionsLoaded',
27
+ 'CwdChanged',
28
+ 'FileChanged',
18
29
  'SessionEnd',
19
30
  ];
31
+ const EXECUTABLE_HOOK_EVENTS = new Set(HOOK_EVENTS);
20
32
  function isRecord(value) {
21
33
  return typeof value === 'object' && value !== null && !Array.isArray(value);
22
34
  }
@@ -33,6 +45,7 @@ function combinedSensitiveValues(...groups) {
33
45
  }
34
46
  function eventSettings(settings, event, maxTimeoutMs) {
35
47
  const matchers = [];
48
+ let hookIndex = 0;
36
49
  for (const resource of settings) {
37
50
  if (!isRecord(resource.value) || !isRecord(resource.value.hooks))
38
51
  continue;
@@ -64,6 +77,8 @@ function eventSettings(settings, event, maxTimeoutMs) {
64
77
  }
65
78
  }
66
79
  const hooks = group.hooks.flatMap((hook) => {
80
+ const currentHookIndex = hookIndex;
81
+ hookIndex += 1;
67
82
  if (!isRecord(hook) || hook.type !== 'command')
68
83
  return [];
69
84
  const command = nonEmptyString(hook.command, `Claude ${event} hook command`);
@@ -73,10 +88,15 @@ function eventSettings(settings, event, maxTimeoutMs) {
73
88
  timeoutSeconds <= 0) {
74
89
  throw new Error(`Invalid Claude ${event} hook timeout: ${resource.path}`);
75
90
  }
91
+ if (hook.async !== undefined && typeof hook.async !== 'boolean') {
92
+ throw new Error(`Invalid Claude ${event} hook async: ${resource.path}`);
93
+ }
76
94
  return [
77
95
  {
78
96
  command,
79
97
  timeoutMs: Math.min(timeoutSeconds * 1000, maxTimeoutMs),
98
+ asynchronous: hook.async === true,
99
+ hookIndex: currentHookIndex,
80
100
  ...(resource.environment === undefined
81
101
  ? {}
82
102
  : { environment: resource.environment }),
@@ -93,6 +113,32 @@ function eventSettings(settings, event, maxTimeoutMs) {
93
113
  }
94
114
  return matchers;
95
115
  }
116
+ function fileChangedMatcherPaths(settings, cwd) {
117
+ const paths = [];
118
+ for (const resource of settings) {
119
+ if (!isRecord(resource.value) || !isRecord(resource.value.hooks))
120
+ continue;
121
+ const groups = resource.value.hooks.FileChanged;
122
+ if (!Array.isArray(groups))
123
+ continue;
124
+ for (const group of groups) {
125
+ if (!isRecord(group) || !Array.isArray(group.hooks))
126
+ continue;
127
+ if (!group.hooks.some((hook) => isRecord(hook) && hook.type === 'command')) {
128
+ continue;
129
+ }
130
+ if (typeof group.matcher !== 'string')
131
+ continue;
132
+ for (const candidate of group.matcher.split('|')) {
133
+ const path = candidate.trim();
134
+ if (!path)
135
+ continue;
136
+ paths.push(isAbsolute(path) ? path : resolve(cwd, path));
137
+ }
138
+ }
139
+ }
140
+ return [...new Set(paths)];
141
+ }
96
142
  export function validateClaudeHooks(settings, maxTimeoutMs = DEFAULT_TIMEOUT_MS) {
97
143
  if (!Number.isFinite(maxTimeoutMs) ||
98
144
  maxTimeoutMs <= 0 ||
@@ -130,6 +176,38 @@ function permissionBehavior(value) {
130
176
  ? value
131
177
  : undefined;
132
178
  }
179
+ function hookMatcherValue(input) {
180
+ switch (input.hook_event_name) {
181
+ case 'Setup':
182
+ return optionalString(input.trigger) ?? '';
183
+ case 'SessionStart':
184
+ return optionalString(input.source) ?? '';
185
+ case 'SubagentStart':
186
+ case 'SubagentStop':
187
+ return optionalString(input.agent_type) ?? '';
188
+ case 'PreToolUse':
189
+ case 'PermissionRequest':
190
+ case 'PermissionDenied':
191
+ case 'PostToolUse':
192
+ case 'PostToolUseFailure':
193
+ return optionalString(input.tool_name) ?? '';
194
+ case 'Notification':
195
+ return optionalString(input.notification_type) ?? '';
196
+ case 'StopFailure':
197
+ return optionalString(input.error) ?? '';
198
+ case 'PreCompact':
199
+ case 'PostCompact':
200
+ return optionalString(input.trigger) ?? '';
201
+ case 'InstructionsLoaded':
202
+ return optionalString(input.load_reason) ?? '';
203
+ case 'FileChanged':
204
+ return basename(optionalString(input.file_path) ?? '');
205
+ case 'SessionEnd':
206
+ return optionalString(input.reason) ?? '';
207
+ default:
208
+ return '';
209
+ }
210
+ }
133
211
  function abortError() {
134
212
  return new DOMException('Hook execution aborted', 'AbortError');
135
213
  }
@@ -140,13 +218,17 @@ export class ClaudeHookRunner {
140
218
  executeCommand;
141
219
  onEvent;
142
220
  cwd;
143
- constructor(options) {
221
+ sessionEnvironment;
222
+ asynchronousHooks;
223
+ constructor(options, asynchronousHooks = new Map()) {
144
224
  this.settings = options.settings;
145
225
  this.maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
146
226
  this.maxTimeoutMs = options.maxTimeoutMs ?? DEFAULT_TIMEOUT_MS;
147
227
  this.executeCommand = options.executeCommand ?? this.runCommand.bind(this);
148
228
  this.onEvent = options.onEvent;
149
229
  this.cwd = options.cwd;
230
+ this.sessionEnvironment = options.sessionEnvironment;
231
+ this.asynchronousHooks = asynchronousHooks;
150
232
  }
151
233
  withAdditionalSettings(settings) {
152
234
  return new ClaudeHookRunner({
@@ -155,25 +237,40 @@ export class ClaudeHookRunner {
155
237
  maxTimeoutMs: this.maxTimeoutMs,
156
238
  executeCommand: this.executeCommand,
157
239
  ...(this.onEvent ? { onEvent: this.onEvent } : {}),
240
+ ...(this.sessionEnvironment
241
+ ? { sessionEnvironment: this.sessionEnvironment }
242
+ : {}),
158
243
  cwd: this.cwd,
159
- });
244
+ }, this.asynchronousHooks);
245
+ }
246
+ fileChangedWatchPaths(cwd = this.cwd) {
247
+ return fileChangedMatcherPaths(this.settings, cwd);
160
248
  }
161
249
  async run(input, matcherValue, signal) {
162
250
  if (signal?.aborted)
163
251
  throw abortError();
252
+ if (!EXECUTABLE_HOOK_EVENTS.has(input.hook_event_name)) {
253
+ return { executions: [], additionalContext: [] };
254
+ }
255
+ const matchQuery = matcherValue ?? hookMatcherValue(input);
164
256
  const ambientSensitiveValues = sensitiveEnvironmentValues(process.env);
165
- const groups = eventSettings(this.settings, input.hook_event_name, this.maxTimeoutMs).filter((group) => !group.matcher || group.matcher.test(matcherValue ?? ''));
257
+ const groups = eventSettings(this.settings, input.hook_event_name, this.maxTimeoutMs).filter((group) => input.hook_event_name === 'CwdChanged' ||
258
+ !group.matcher ||
259
+ group.matcher.test(matchQuery));
166
260
  const executions = [];
167
261
  const additionalContext = [];
168
262
  let updatedInput;
169
263
  let permissionDecision;
170
264
  let permissionDecisionReason;
265
+ let retry = false;
266
+ const watchPaths = [];
267
+ const systemMessages = [];
171
268
  let blockedReason;
172
269
  for (const group of groups) {
173
270
  for (const hook of group.hooks) {
174
271
  const sensitiveValues = combinedSensitiveValues(ambientSensitiveValues, hook.sensitiveValues);
175
272
  const hookId = crypto.randomUUID();
176
- const hookName = `${input.hook_event_name}${matcherValue ? `:${matcherValue}` : ''}`;
273
+ const hookName = `${input.hook_event_name}${matchQuery ? `:${matchQuery}` : ''}`;
177
274
  this.onEvent?.({
178
275
  type: 'started',
179
276
  hookId,
@@ -199,8 +296,57 @@ export class ClaudeHookRunner {
199
296
  output,
200
297
  });
201
298
  };
299
+ const executionEnvironment = await this.executionEnvironment(input, hook);
300
+ if (hook.asynchronous) {
301
+ const controller = new AbortController();
302
+ const pending = this.executeCommand(hook.command, input, hook.timeoutMs, controller.signal, reportProgress, executionEnvironment)
303
+ .finally(() => this.invalidateSessionEnvironment(input))
304
+ .then((result) => {
305
+ const stdout = redactSensitiveText(result.stdout, sensitiveValues);
306
+ const stderr = redactSensitiveText(result.stderr, sensitiveValues);
307
+ const output = redactSensitiveText(result.output ?? `${result.stdout}${result.stderr}`, sensitiveValues);
308
+ const outputExceeded = Buffer.byteLength(stdout) + Buffer.byteLength(stderr) >
309
+ this.maxOutputBytes;
310
+ this.onEvent?.({
311
+ type: 'response',
312
+ hookId,
313
+ hookName,
314
+ hookEvent: input.hook_event_name,
315
+ stdout: outputExceeded ? '' : stdout,
316
+ stderr: outputExceeded
317
+ ? 'Hook output exceeded byte limit'
318
+ : stderr,
319
+ output: outputExceeded
320
+ ? 'Hook output exceeded byte limit'
321
+ : output,
322
+ exitCode: outputExceeded ? 1 : result.exitCode,
323
+ outcome: outputExceeded || result.exitCode !== 0 ? 'error' : 'success',
324
+ });
325
+ })
326
+ .catch((error) => {
327
+ const message = redactSensitiveText(hookErrorMessage(error), sensitiveValues);
328
+ this.onEvent?.({
329
+ type: 'response',
330
+ hookId,
331
+ hookName,
332
+ hookEvent: input.hook_event_name,
333
+ output: message,
334
+ stdout: '',
335
+ stderr: message,
336
+ ...(hookWasCancelled(error, controller.signal)
337
+ ? {}
338
+ : { exitCode: 1 }),
339
+ outcome: hookWasCancelled(error, controller.signal)
340
+ ? 'cancelled'
341
+ : 'error',
342
+ });
343
+ });
344
+ this.asynchronousHooks.set(pending, controller);
345
+ void pending.finally(() => this.asynchronousHooks.delete(pending));
346
+ continue;
347
+ }
202
348
  try {
203
- result = await this.executeCommand(hook.command, input, hook.timeoutMs, signal, reportProgress, hook.environment);
349
+ result = await this.executeCommand(hook.command, input, hook.timeoutMs, signal, reportProgress, executionEnvironment);
204
350
  }
205
351
  catch (error) {
206
352
  const message = redactSensitiveText(hookErrorMessage(error), sensitiveValues);
@@ -218,6 +364,9 @@ export class ClaudeHookRunner {
218
364
  });
219
365
  throw error;
220
366
  }
367
+ finally {
368
+ this.invalidateSessionEnvironment(input);
369
+ }
221
370
  const toolUseId = optionalString(input.tool_use_id) ?? crypto.randomUUID();
222
371
  const stdout = redactSensitiveText(result.stdout, sensitiveValues);
223
372
  const stderr = redactSensitiveText(result.stderr, sensitiveValues);
@@ -288,6 +437,22 @@ export class ClaudeHookRunner {
288
437
  permissionDecisionReason = nextPermissionReason
289
438
  ? redactSensitiveText(nextPermissionReason, sensitiveValues)
290
439
  : permissionDecisionReason;
440
+ if (input.hook_event_name === 'PermissionDenied' &&
441
+ specific?.hookEventName === 'PermissionDenied' &&
442
+ specific.retry === true) {
443
+ retry = true;
444
+ }
445
+ if ((input.hook_event_name === 'CwdChanged' ||
446
+ input.hook_event_name === 'FileChanged') &&
447
+ specific?.hookEventName === input.hook_event_name &&
448
+ Array.isArray(specific.watchPaths) &&
449
+ specific.watchPaths.every((path) => typeof path === 'string')) {
450
+ watchPaths.push(...specific.watchPaths);
451
+ }
452
+ const systemMessage = optionalString(parsedOutput?.systemMessage);
453
+ if (systemMessage) {
454
+ systemMessages.push(redactSensitiveText(systemMessage, sensitiveValues));
455
+ }
291
456
  if (parsedOutput?.continue === false ||
292
457
  parsedOutput?.decision === 'block') {
293
458
  blockedReason = redactSensitiveText(optionalString(parsedOutput.stopReason) ??
@@ -305,9 +470,69 @@ export class ClaudeHookRunner {
305
470
  ...(updatedInput ? { updatedInput } : {}),
306
471
  ...(permissionDecision ? { permissionDecision } : {}),
307
472
  ...(permissionDecisionReason ? { permissionDecisionReason } : {}),
473
+ ...(retry ? { retry } : {}),
474
+ ...(watchPaths.length > 0 ? { watchPaths } : {}),
475
+ ...(systemMessages.length > 0 ? { systemMessages } : {}),
308
476
  ...(blockedReason ? { blockedReason } : {}),
309
477
  };
310
478
  }
479
+ async drainAsync(timeoutMs) {
480
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
481
+ throw new Error('Hook async drain timeout must be non-negative');
482
+ }
483
+ const pending = [...this.asynchronousHooks.keys()];
484
+ if (pending.length === 0)
485
+ return;
486
+ let timer;
487
+ const timeout = new Promise((resolve) => {
488
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
489
+ });
490
+ try {
491
+ if ((await Promise.race([
492
+ Promise.allSettled(pending).then(() => 'settled'),
493
+ timeout,
494
+ ])) === 'timeout') {
495
+ for (const controller of this.asynchronousHooks.values()) {
496
+ controller.abort();
497
+ }
498
+ // Give cooperative executors one event-loop turn to publish their
499
+ // cancelled response. Keep the wait bounded because custom executors
500
+ // are allowed to ignore AbortSignal entirely.
501
+ await Promise.race([
502
+ Promise.allSettled(pending),
503
+ new Promise((resolve) => setTimeout(resolve, 0)),
504
+ ]);
505
+ // Executors normally terminate their child process on abort. A custom
506
+ // executor may ignore cancellation, so shutdown abandons tracking
507
+ // after the deadline instead of turning a bounded drain into an
508
+ // unbounded wait.
509
+ this.asynchronousHooks.clear();
510
+ }
511
+ }
512
+ finally {
513
+ if (timer)
514
+ clearTimeout(timer);
515
+ }
516
+ }
517
+ async clearCwdEnvironment(sessionId) {
518
+ await this.sessionEnvironment?.clearCwdFiles(sessionId);
519
+ }
520
+ async executionEnvironment(input, hook) {
521
+ if (!this.sessionEnvironment ||
522
+ !['Setup', 'SessionStart', 'CwdChanged', 'FileChanged'].includes(input.hook_event_name)) {
523
+ return hook.environment;
524
+ }
525
+ const environmentFile = await this.sessionEnvironment.hookFile(input.session_id, input.hook_event_name, hook.hookIndex);
526
+ return {
527
+ ...hook.environment,
528
+ ...(environmentFile ? { CLAUDE_ENV_FILE: environmentFile } : {}),
529
+ };
530
+ }
531
+ invalidateSessionEnvironment(input) {
532
+ if (['Setup', 'SessionStart', 'CwdChanged', 'FileChanged'].includes(input.hook_event_name)) {
533
+ this.sessionEnvironment?.invalidate(input.session_id);
534
+ }
535
+ }
311
536
  runCommand(command, input, timeoutMs, signal, onProgress, environment) {
312
537
  return new Promise((resolve, reject) => {
313
538
  const startedAt = Date.now();
@@ -0,0 +1,16 @@
1
+ export type ClaudeEnvironmentHookEvent = 'Setup' | 'SessionStart' | 'CwdChanged' | 'FileChanged';
2
+ export interface ClaudeSessionEnvironmentOptions {
3
+ stateRoot: string;
4
+ warn?(message: string): void;
5
+ }
6
+ export declare class ClaudeSessionEnvironment {
7
+ private readonly options;
8
+ private readonly cache;
9
+ constructor(options: ClaudeSessionEnvironmentOptions);
10
+ hookFile(sessionId: string, event: ClaudeEnvironmentHookEvent, hookIndex: number): Promise<string | undefined>;
11
+ invalidate(sessionId: string): void;
12
+ clearCwdFiles(sessionId: string): Promise<void>;
13
+ environment(sessionId: string): Promise<Readonly<Record<string, string>> | undefined>;
14
+ private directory;
15
+ }
16
+ //# sourceMappingURL=claude-session-environment.d.ts.map
@@ -0,0 +1,112 @@
1
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const ENV_FILE = /^(setup|sessionstart|cwdchanged|filechanged)-hook-(\d+)\.sh$/u;
4
+ const PRIORITY = {
5
+ setup: 0,
6
+ sessionstart: 1,
7
+ cwdchanged: 2,
8
+ filechanged: 3,
9
+ };
10
+ export class ClaudeSessionEnvironment {
11
+ options;
12
+ cache = new Map();
13
+ constructor(options) {
14
+ this.options = options;
15
+ }
16
+ async hookFile(sessionId, event, hookIndex) {
17
+ if (process.platform === 'win32')
18
+ return undefined;
19
+ const directory = this.directory(sessionId);
20
+ await mkdir(directory, { recursive: true });
21
+ this.invalidate(sessionId);
22
+ return join(directory, `${event.toLowerCase()}-hook-${hookIndex}.sh`);
23
+ }
24
+ invalidate(sessionId) {
25
+ this.cache.delete(sessionId);
26
+ }
27
+ async clearCwdFiles(sessionId) {
28
+ const directory = this.directory(sessionId);
29
+ try {
30
+ const files = await readdir(directory);
31
+ await Promise.all(files
32
+ .filter((file) => ENV_FILE.test(file) &&
33
+ (file.startsWith('cwdchanged-') ||
34
+ file.startsWith('filechanged-')))
35
+ .map((file) => writeFile(join(directory, file), '')));
36
+ }
37
+ catch (error) {
38
+ if (error.code !== 'ENOENT') {
39
+ this.options.warn?.(`Could not clear hook environment: ${error instanceof Error ? error.message : String(error)}`);
40
+ }
41
+ }
42
+ this.invalidate(sessionId);
43
+ }
44
+ async environment(sessionId) {
45
+ if (process.platform === 'win32')
46
+ return undefined;
47
+ const cached = this.cache.get(sessionId);
48
+ if (cached !== undefined)
49
+ return cached ?? undefined;
50
+ const directory = this.directory(sessionId);
51
+ const environment = Object.create(null);
52
+ try {
53
+ const files = (await readdir(directory))
54
+ .filter((file) => ENV_FILE.test(file))
55
+ .sort((left, right) => {
56
+ const leftMatch = left.match(ENV_FILE);
57
+ const rightMatch = right.match(ENV_FILE);
58
+ const priority = (PRIORITY[leftMatch?.[1] ?? ''] ?? 99) -
59
+ (PRIORITY[rightMatch?.[1] ?? ''] ?? 99);
60
+ if (priority !== 0)
61
+ return priority;
62
+ return Number(leftMatch?.[2] ?? 0) - Number(rightMatch?.[2] ?? 0);
63
+ });
64
+ for (const file of files) {
65
+ const path = join(directory, file);
66
+ const content = await readFile(path, 'utf8');
67
+ for (const [index, line] of content.split(/\r?\n/u).entries()) {
68
+ const trimmed = line.trim();
69
+ if (!trimmed || trimmed.startsWith('#'))
70
+ continue;
71
+ const match = trimmed.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
72
+ const name = match?.[1];
73
+ const source = match?.[2];
74
+ const value = source === undefined ? undefined : literalExportValue(source);
75
+ if (!name || value === undefined) {
76
+ this.options.warn?.(`Ignored unsafe CLAUDE_ENV_FILE entry at ${path}:${index + 1}`);
77
+ continue;
78
+ }
79
+ environment[name] = value;
80
+ }
81
+ }
82
+ }
83
+ catch (error) {
84
+ if (error.code !== 'ENOENT') {
85
+ this.options.warn?.(`Could not load hook environment: ${error instanceof Error ? error.message : String(error)}`);
86
+ }
87
+ }
88
+ const result = Object.keys(environment).length > 0 ? { ...environment } : null;
89
+ this.cache.set(sessionId, result);
90
+ return result ?? undefined;
91
+ }
92
+ directory(sessionId) {
93
+ return join(this.options.stateRoot, 'session-env', sessionId);
94
+ }
95
+ }
96
+ function literalExportValue(source) {
97
+ const value = source.trim();
98
+ if (value === '')
99
+ return '';
100
+ if (value.startsWith("'") && value.endsWith("'")) {
101
+ const literal = value.slice(1, -1);
102
+ return literal.includes("'") ? undefined : literal;
103
+ }
104
+ if (value.startsWith('"') && value.endsWith('"')) {
105
+ const literal = value.slice(1, -1);
106
+ if (literal.includes('$') || literal.includes('`'))
107
+ return undefined;
108
+ return literal.replace(/\\(["\\])/gu, '$1');
109
+ }
110
+ return /^[A-Za-z0-9_./:@%+,=-]*$/u.test(value) ? value : undefined;
111
+ }
112
+ //# sourceMappingURL=claude-session-environment.js.map
@@ -1,5 +1,6 @@
1
1
  import type { ModelToolCall, ModelToolDefinition, ToolExecutionContext, ToolExecutionResult, ToolRegistry, RuntimeEventSink } from '../core/runtime.js';
2
2
  import { type BackgroundBashSnapshot, type BackgroundBashToolResult } from '../application/background-bash-manager.js';
3
+ import { type ClaudeTask } from '../persistence/claude-task-store.js';
3
4
  export interface ClaudeTaskToolRegistryOptions {
4
5
  base: ToolRegistry;
5
6
  cwd: string;
@@ -10,6 +11,10 @@ export interface ClaudeTaskToolRegistryOptions {
10
11
  enabledTools?: readonly string[];
11
12
  maxOutputBytes?: number;
12
13
  eventSink?: RuntimeEventSink;
14
+ taskHooks?: {
15
+ created(task: Readonly<ClaudeTask>, signal?: AbortSignal): Promise<void>;
16
+ completed(task: Readonly<ClaudeTask>, signal?: AbortSignal): Promise<void>;
17
+ };
13
18
  }
14
19
  export declare class ClaudeTaskToolRegistry implements ToolRegistry {
15
20
  private readonly options;
@@ -342,6 +342,13 @@ export class ClaudeTaskToolRegistry {
342
342
  switch (call.name) {
343
343
  case 'TaskCreate': {
344
344
  const task = await this.store.create(this.createInput(call.input));
345
+ try {
346
+ await this.options.taskHooks?.created(task, context.signal);
347
+ }
348
+ catch (error) {
349
+ await this.store.update(task.id, { status: 'deleted' });
350
+ throw error;
351
+ }
345
352
  return {
346
353
  content: `Task #${task.id} created successfully: ${task.subject}`,
347
354
  isError: false,
@@ -374,7 +381,14 @@ export class ClaudeTaskToolRegistry {
374
381
  }
375
382
  case 'TaskUpdate': {
376
383
  const id = stringField(call.input, 'taskId');
377
- const result = await this.store.update(id, this.updateInput(call.input));
384
+ const update = this.updateInput(call.input);
385
+ if (update.status === 'completed') {
386
+ const task = await this.store.get(id);
387
+ if (task && task.status !== 'completed') {
388
+ await this.options.taskHooks?.completed(task, context.signal);
389
+ }
390
+ }
391
+ const result = await this.store.update(id, update);
378
392
  const missing = {
379
393
  success: false,
380
394
  taskId: id,
@@ -11,6 +11,7 @@ export interface LocalToolRegistryOptions {
11
11
  maxShellTimeoutMs?: number;
12
12
  enableReportFindings?: boolean;
13
13
  environment?: Readonly<Record<string, string>>;
14
+ sessionEnvironment?: (sessionId: string) => Promise<Readonly<Record<string, string>> | undefined>;
14
15
  sandbox?: BashSandboxRuntime;
15
16
  homeDirectory?: string;
16
17
  configRoot?: string;
@@ -44,6 +45,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
44
45
  private readonly processRunner;
45
46
  private readonly enableReportFindings;
46
47
  private readonly environment;
48
+ private readonly sessionEnvironment;
47
49
  private readonly sandbox;
48
50
  private readonly homeDirectory;
49
51
  private readonly configRoot;
@@ -509,6 +509,7 @@ export class LocalToolRegistry {
509
509
  processRunner;
510
510
  enableReportFindings;
511
511
  environment;
512
+ sessionEnvironment;
512
513
  sandbox;
513
514
  homeDirectory;
514
515
  configRoot;
@@ -526,6 +527,7 @@ export class LocalToolRegistry {
526
527
  this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
527
528
  this.enableReportFindings = options.enableReportFindings ?? false;
528
529
  this.environment = options.environment;
530
+ this.sessionEnvironment = options.sessionEnvironment;
529
531
  this.sandbox = options.sandbox;
530
532
  this.homeDirectory = options.homeDirectory
531
533
  ? resolve(options.homeDirectory)
@@ -1258,18 +1260,21 @@ export class LocalToolRegistry {
1258
1260
  throw new Error('Prepared Bash call has no timeout');
1259
1261
  const rawCommand = stringInput(call.input, 'command');
1260
1262
  this.assertProtectedBashCommand(rawCommand, context);
1261
- const sandboxInput = {
1263
+ const sandboxPolicyInput = {
1262
1264
  command: rawCommand,
1263
1265
  ...(call.input.dangerouslyDisableSandbox === true
1264
1266
  ? { dangerouslyDisableSandbox: true }
1265
1267
  : {}),
1266
1268
  };
1267
- const sandboxed = this.sandbox?.shouldUseSandbox(sandboxInput) ?? false;
1269
+ const sandboxed = this.sandbox?.shouldUseSandbox(sandboxPolicyInput) ?? false;
1268
1270
  const shell = commandShell();
1269
1271
  let result;
1270
1272
  try {
1273
+ const sessionEnvironment = context.sessionId
1274
+ ? await this.sessionEnvironment?.(context.sessionId)
1275
+ : undefined;
1271
1276
  const command = sandboxed
1272
- ? await this.sandbox?.wrapCommand(sandboxInput, {
1277
+ ? await this.sandbox?.wrapCommand(sandboxPolicyInput, {
1273
1278
  shell,
1274
1279
  ...(context.signal ? { signal: context.signal } : {}),
1275
1280
  commandId: call.id,
@@ -1280,7 +1285,9 @@ export class LocalToolRegistry {
1280
1285
  args: commandShellArguments(command ?? rawCommand),
1281
1286
  timeoutMs: timeout,
1282
1287
  cwd: this.currentCwd(context),
1283
- ...(this.environment ? { env: this.environment } : {}),
1288
+ ...(this.environment || sessionEnvironment
1289
+ ? { env: { ...this.environment, ...sessionEnvironment } }
1290
+ : {}),
1284
1291
  ...(context.signal ? { signal: context.signal } : {}),
1285
1292
  });
1286
1293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",