praxis-agent 0.30.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.
Files changed (53) hide show
  1. package/dist/application/claude-project-purge.js +18 -5
  2. package/dist/application/project-memory.d.ts +141 -0
  3. package/dist/application/project-memory.js +607 -0
  4. package/dist/application/session-service.d.ts +24 -0
  5. package/dist/application/session-service.js +412 -44
  6. package/dist/application/subagent-service.js +6 -0
  7. package/dist/cli/interactive.d.ts +3 -1
  8. package/dist/cli/interactive.js +37 -4
  9. package/dist/cli/protocol.d.ts +1 -0
  10. package/dist/cli/protocol.js +24 -0
  11. package/dist/cli/tui/hook-settings.js +14 -1
  12. package/dist/cli/tui/memory-files.d.ts +2 -1
  13. package/dist/cli/tui/memory-files.js +30 -30
  14. package/dist/cli-runtime.d.ts +1 -0
  15. package/dist/cli-runtime.js +144 -24
  16. package/dist/compatibility/claude/context.d.ts +6 -1
  17. package/dist/compatibility/claude/context.js +16 -2
  18. package/dist/compatibility/claude/dynamic-context.js +2 -1
  19. package/dist/compatibility/claude/paths.js +2 -13
  20. package/dist/compatibility/claude/shared-resources.d.ts +4 -4
  21. package/dist/compatibility/claude/shared-resources.js +14 -35
  22. package/dist/core/context-budget.d.ts +9 -8
  23. package/dist/core/context-budget.js +16 -21
  24. package/dist/core/project-memory.d.ts +19 -0
  25. package/dist/core/project-memory.js +58 -0
  26. package/dist/core/runtime.d.ts +10 -1
  27. package/dist/core/runtime.js +47 -3
  28. package/dist/hooks/claude-file-change-watcher.d.ts +43 -0
  29. package/dist/hooks/claude-file-change-watcher.js +185 -0
  30. package/dist/hooks/claude-hook-tools.d.ts +2 -0
  31. package/dist/hooks/claude-hook-tools.js +45 -6
  32. package/dist/hooks/claude-hooks.d.ts +14 -2
  33. package/dist/hooks/claude-hooks.js +230 -5
  34. package/dist/hooks/claude-session-environment.d.ts +16 -0
  35. package/dist/hooks/claude-session-environment.js +112 -0
  36. package/dist/maintenance/doctor.js +29 -5
  37. package/dist/mcp/claude-mcp-management.js +2 -0
  38. package/dist/persistence/native-resources.d.ts +3 -1
  39. package/dist/persistence/native-resources.js +19 -6
  40. package/dist/platform/path-containment.d.ts +2 -0
  41. package/dist/platform/path-containment.js +7 -0
  42. package/dist/platform/project-identity.d.ts +6 -0
  43. package/dist/platform/project-identity.js +66 -0
  44. package/dist/platform/project-memory-paths.d.ts +13 -0
  45. package/dist/platform/project-memory-paths.js +28 -0
  46. package/dist/platform/project-path-key.d.ts +2 -0
  47. package/dist/platform/project-path-key.js +16 -0
  48. package/dist/providers/fallback-provider.js +3 -1
  49. package/dist/tools/claude-task-tools.d.ts +5 -0
  50. package/dist/tools/claude-task-tools.js +15 -1
  51. package/dist/tools/local-tools.d.ts +2 -0
  52. package/dist/tools/local-tools.js +11 -4
  53. package/package.json +1 -1
@@ -0,0 +1,185 @@
1
+ import { existsSync, watch } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
3
+ function defaultWatchDirectory(directory, listener) {
4
+ return watch(directory, (_event, filename) => {
5
+ listener(filename);
6
+ });
7
+ }
8
+ export class ClaudeFileChangeWatcher {
9
+ options;
10
+ cwd;
11
+ staticPaths;
12
+ dynamicPaths = [];
13
+ watchers = new Map();
14
+ knownExistence = new Map();
15
+ pending = new Map();
16
+ inFlight = new Map();
17
+ debounceMs;
18
+ pathExists;
19
+ watchDirectory;
20
+ generation = 0;
21
+ closed = false;
22
+ constructor(options) {
23
+ this.options = options;
24
+ this.cwd = options.cwd;
25
+ this.staticPaths = options.staticPaths;
26
+ this.debounceMs = options.debounceMs ?? 500;
27
+ this.pathExists = options.pathExists ?? existsSync;
28
+ this.watchDirectory = options.watchDirectory ?? defaultWatchDirectory;
29
+ this.rebuild();
30
+ }
31
+ updateForCwd(cwd, staticPaths, dynamicPaths) {
32
+ if (this.closed)
33
+ return;
34
+ this.cwd = cwd;
35
+ this.staticPaths = staticPaths;
36
+ this.dynamicPaths = this.validDynamicPaths(dynamicPaths);
37
+ this.rebuild();
38
+ }
39
+ updateDynamicPaths(paths) {
40
+ if (this.closed)
41
+ return;
42
+ const next = this.validDynamicPaths(paths);
43
+ if (next.length === this.dynamicPaths.length &&
44
+ next.every((path, index) => path === this.dynamicPaths[index])) {
45
+ return;
46
+ }
47
+ this.dynamicPaths = next;
48
+ this.rebuild();
49
+ }
50
+ async close(timeoutMs = 5_000) {
51
+ if (this.closed)
52
+ return;
53
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
54
+ throw new Error('FileChanged watcher close timeout must be non-negative');
55
+ }
56
+ this.closed = true;
57
+ this.generation += 1;
58
+ this.clearResources();
59
+ const pending = [...this.inFlight.keys()];
60
+ if (pending.length === 0)
61
+ return;
62
+ let timer;
63
+ try {
64
+ if ((await Promise.race([
65
+ Promise.allSettled(pending).then(() => 'settled'),
66
+ new Promise((resolveTimeout) => {
67
+ timer = setTimeout(() => resolveTimeout('timeout'), timeoutMs);
68
+ }),
69
+ ])) === 'timeout') {
70
+ for (const controller of this.inFlight.values())
71
+ controller.abort();
72
+ this.inFlight.clear();
73
+ await Promise.resolve();
74
+ }
75
+ }
76
+ finally {
77
+ if (timer)
78
+ clearTimeout(timer);
79
+ }
80
+ }
81
+ validDynamicPaths(paths) {
82
+ const valid = [];
83
+ for (const path of paths) {
84
+ if (!isAbsolute(path)) {
85
+ this.options.warn(`FileChanged hook ignored non-absolute watch path: ${path}`);
86
+ continue;
87
+ }
88
+ valid.push(resolve(path));
89
+ }
90
+ return [...new Set(valid)];
91
+ }
92
+ targetPaths() {
93
+ return [
94
+ ...new Set([
95
+ ...this.staticPaths.map((path) => isAbsolute(path) ? resolve(path) : resolve(this.cwd, path)),
96
+ ...this.dynamicPaths,
97
+ ]),
98
+ ];
99
+ }
100
+ rebuild() {
101
+ this.generation += 1;
102
+ this.clearResources();
103
+ const targets = this.targetPaths();
104
+ const byDirectory = new Map();
105
+ for (const target of targets) {
106
+ this.knownExistence.set(target, this.pathExists(target));
107
+ const directory = dirname(target);
108
+ const names = byDirectory.get(directory) ?? new Set();
109
+ names.add(basename(target));
110
+ byDirectory.set(directory, names);
111
+ }
112
+ for (const [directory, names] of byDirectory) {
113
+ try {
114
+ const watcher = this.watchDirectory(directory, (filename) => {
115
+ if (filename === null) {
116
+ for (const name of names)
117
+ this.schedule(resolve(directory, name));
118
+ return;
119
+ }
120
+ if (names.has(filename))
121
+ this.schedule(resolve(directory, filename));
122
+ });
123
+ watcher.on('error', (error) => {
124
+ this.options.warn(`FileChanged watcher failed for ${directory}: ${error.message}`);
125
+ });
126
+ watcher.unref?.();
127
+ this.watchers.set(directory, watcher);
128
+ }
129
+ catch (error) {
130
+ this.options.warn(`FileChanged watcher could not watch ${directory}: ${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ }
133
+ }
134
+ schedule(path) {
135
+ const previous = this.pending.get(path);
136
+ if (previous)
137
+ clearTimeout(previous);
138
+ const generation = this.generation;
139
+ const timer = setTimeout(() => {
140
+ this.pending.delete(path);
141
+ if (this.closed || generation !== this.generation)
142
+ return;
143
+ const controller = new AbortController();
144
+ const dispatch = this.dispatch(path, generation, controller.signal);
145
+ this.inFlight.set(dispatch, controller);
146
+ void dispatch.finally(() => this.inFlight.delete(dispatch));
147
+ }, this.debounceMs);
148
+ timer.unref?.();
149
+ this.pending.set(path, timer);
150
+ }
151
+ async dispatch(path, generation, signal) {
152
+ const existed = this.knownExistence.get(path) ?? false;
153
+ const exists = this.pathExists(path);
154
+ this.knownExistence.set(path, exists);
155
+ if (!existed && !exists)
156
+ return;
157
+ const event = !existed
158
+ ? 'add'
159
+ : !exists
160
+ ? 'unlink'
161
+ : 'change';
162
+ try {
163
+ const watchPaths = await this.options.onFileChanged(path, event, signal);
164
+ if (!this.closed &&
165
+ generation === this.generation &&
166
+ watchPaths &&
167
+ watchPaths.length > 0) {
168
+ this.updateDynamicPaths(watchPaths);
169
+ }
170
+ }
171
+ catch (error) {
172
+ this.options.warn(`FileChanged hook failed for ${path}: ${error instanceof Error ? error.message : String(error)}`);
173
+ }
174
+ }
175
+ clearResources() {
176
+ for (const timer of this.pending.values())
177
+ clearTimeout(timer);
178
+ this.pending.clear();
179
+ for (const watcher of this.watchers.values())
180
+ watcher.close();
181
+ this.watchers.clear();
182
+ this.knownExistence.clear();
183
+ }
184
+ }
185
+ //# sourceMappingURL=claude-file-change-watcher.js.map
@@ -12,6 +12,7 @@ export interface ClaudeHookToolCoordinatorOptions {
12
12
  hooks: ClaudeHookRunner;
13
13
  session: ClaudeHookSessionInput;
14
14
  recordOutcome(outcome: ClaudeHookOutcome, deferUntilApproval?: boolean): Promise<void>;
15
+ warn?(message: string): void;
15
16
  deferPreToolUseOutcome?(call: ModelToolCall): boolean;
16
17
  }
17
18
  export declare class ClaudeHookToolCoordinator implements ToolRegistry, PermissionResolver {
@@ -25,6 +26,7 @@ export declare class ClaudeHookToolCoordinator implements ToolRegistry, Permissi
25
26
  };
26
27
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
27
28
  resolve(call: ModelToolCall, context?: PermissionResolutionContext): Promise<PermissionDecision>;
29
+ private withPermissionDeniedHook;
28
30
  execute(call: ModelToolCall, context: ToolExecutionContext): Promise<ToolExecutionResult>;
29
31
  }
30
32
  //# sourceMappingURL=claude-hook-tools.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { permissionDecisionSource } from '../core/runtime.js';
1
2
  export class ClaudeHookToolCoordinator {
2
3
  options;
3
4
  prepared = new Map();
@@ -57,11 +58,13 @@ export class ClaudeHookToolCoordinator {
57
58
  }
58
59
  async resolve(call, context) {
59
60
  const prepared = this.prepared.get(call.id);
60
- if (prepared?.permissionDecision)
61
- return prepared.permissionDecision;
61
+ if (prepared?.permissionDecision) {
62
+ return this.withPermissionDeniedHook(call, prepared.permissionDecision, prepared.signal);
63
+ }
62
64
  const decision = await this.options.permissions.resolve(call, context);
63
- if (decision.behavior !== 'ask')
64
- return decision;
65
+ if (decision.behavior !== 'ask') {
66
+ return this.withPermissionDeniedHook(call, decision, prepared?.signal);
67
+ }
65
68
  const outcome = await this.options.hooks.run({
66
69
  ...this.options.session,
67
70
  hook_event_name: 'PermissionRequest',
@@ -72,17 +75,53 @@ export class ClaudeHookToolCoordinator {
72
75
  }, call.name, prepared?.signal);
73
76
  await this.options.recordOutcome(outcome);
74
77
  if (outcome.blockedReason || outcome.permissionDecision === 'deny') {
75
- return {
78
+ return this.withPermissionDeniedHook(call, {
76
79
  behavior: 'deny',
77
80
  reason: outcome.blockedReason ??
78
81
  outcome.permissionDecisionReason ??
79
82
  `PermissionRequest:${call.name} hook denied tool`,
80
- };
83
+ }, prepared?.signal);
81
84
  }
82
85
  if (outcome.permissionDecision === 'allow')
83
86
  return { behavior: 'allow' };
84
87
  return decision;
85
88
  }
89
+ async withPermissionDeniedHook(call, decision, signal) {
90
+ const decisionSource = permissionDecisionSource(decision);
91
+ if (decision.behavior !== 'deny' || decisionSource !== 'auto-classifier') {
92
+ return decision;
93
+ }
94
+ const prepared = this.prepared.get(call.id);
95
+ try {
96
+ const outcome = await this.options.hooks.run({
97
+ ...this.options.session,
98
+ hook_event_name: 'PermissionDenied',
99
+ tool_name: call.name,
100
+ tool_input: prepared?.hookInput ?? call.input,
101
+ tool_use_id: call.id,
102
+ reason: decision.reason,
103
+ }, call.name, signal);
104
+ await this.options.recordOutcome(outcome);
105
+ if (outcome.retry) {
106
+ return {
107
+ ...decision,
108
+ followUpUserMessages: [
109
+ ...(decision.followUpUserMessages ?? []),
110
+ 'The PermissionDenied hook indicated this command is now approved. You may retry it if you would like.',
111
+ ],
112
+ source: decisionSource,
113
+ };
114
+ }
115
+ }
116
+ catch (error) {
117
+ if (signal?.aborted ||
118
+ (error instanceof Error && error.name === 'AbortError')) {
119
+ throw error;
120
+ }
121
+ this.options.warn?.(`PermissionDenied:${call.name} hook failed: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ return decision;
124
+ }
86
125
  async execute(call, context) {
87
126
  const prepared = this.prepared.get(call.id);
88
127
  try {
@@ -1,6 +1,7 @@
1
1
  import type { ClaudeJsonResource } from '../compatibility/claude/shared-resources.js';
2
2
  import type { PermissionBehavior } from '../core/runtime.js';
3
- export type ClaudeHookEventName = 'Setup' | 'SessionStart' | 'SubagentStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PermissionRequest' | 'PostToolUse' | 'PostToolUseFailure' | 'Stop' | 'SubagentStop' | 'SessionEnd';
3
+ import type { ClaudeSessionEnvironment } from './claude-session-environment.js';
4
+ export type ClaudeHookEventName = 'Setup' | 'SessionStart' | 'SubagentStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PermissionRequest' | 'PermissionDenied' | 'PostToolUse' | 'PostToolUseFailure' | 'Notification' | 'Stop' | 'StopFailure' | 'SubagentStop' | 'PreCompact' | 'PostCompact' | 'TaskCreated' | 'TaskCompleted' | 'ConfigChange' | 'InstructionsLoaded' | 'WorktreeCreate' | 'WorktreeRemove' | 'CwdChanged' | 'FileChanged' | 'SessionEnd';
4
5
  export interface ClaudeHookInput extends Record<string, unknown> {
5
6
  session_id: string;
6
7
  transcript_path: string;
@@ -53,6 +54,9 @@ export interface ClaudeHookOutcome {
53
54
  updatedInput?: Record<string, unknown>;
54
55
  permissionDecision?: PermissionBehavior;
55
56
  permissionDecisionReason?: string;
57
+ retry?: boolean;
58
+ watchPaths?: readonly string[];
59
+ systemMessages?: readonly string[];
56
60
  blockedReason?: string;
57
61
  }
58
62
  interface ProcessResult {
@@ -71,6 +75,7 @@ export interface ClaudeHookRunnerOptions {
71
75
  maxTimeoutMs?: number;
72
76
  executeCommand?: ClaudeHookCommandExecutor;
73
77
  onEvent?: (event: ClaudeHookStreamEvent) => void;
78
+ sessionEnvironment?: ClaudeSessionEnvironment;
74
79
  }
75
80
  export declare const HOOK_EVENTS: readonly ClaudeHookEventName[];
76
81
  export declare function validateClaudeHooks(settings: readonly ClaudeJsonResource[], maxTimeoutMs?: number): void;
@@ -81,9 +86,16 @@ export declare class ClaudeHookRunner {
81
86
  private readonly executeCommand;
82
87
  private readonly onEvent;
83
88
  private readonly cwd;
84
- constructor(options: ClaudeHookRunnerOptions);
89
+ private readonly sessionEnvironment;
90
+ private readonly asynchronousHooks;
91
+ constructor(options: ClaudeHookRunnerOptions, asynchronousHooks?: Map<Promise<void>, AbortController>);
85
92
  withAdditionalSettings(settings: readonly ClaudeJsonResource[]): ClaudeHookRunner;
93
+ fileChangedWatchPaths(cwd?: string): readonly string[];
86
94
  run(input: ClaudeHookInput, matcherValue?: string, signal?: AbortSignal): Promise<ClaudeHookOutcome>;
95
+ drainAsync(timeoutMs: number): Promise<void>;
96
+ clearCwdEnvironment(sessionId: string): Promise<void>;
97
+ private executionEnvironment;
98
+ private invalidateSessionEnvironment;
87
99
  private runCommand;
88
100
  }
89
101
  export {};
@@ -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();