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.
- package/dist/application/claude-project-purge.js +18 -5
- package/dist/application/project-memory.d.ts +141 -0
- package/dist/application/project-memory.js +607 -0
- package/dist/application/session-service.d.ts +24 -0
- package/dist/application/session-service.js +412 -44
- package/dist/application/subagent-service.js +6 -0
- package/dist/cli/interactive.d.ts +3 -1
- package/dist/cli/interactive.js +37 -4
- package/dist/cli/protocol.d.ts +1 -0
- package/dist/cli/protocol.js +24 -0
- package/dist/cli/tui/hook-settings.js +14 -1
- package/dist/cli/tui/memory-files.d.ts +2 -1
- package/dist/cli/tui/memory-files.js +30 -30
- package/dist/cli-runtime.d.ts +1 -0
- package/dist/cli-runtime.js +144 -24
- package/dist/compatibility/claude/context.d.ts +6 -1
- package/dist/compatibility/claude/context.js +16 -2
- package/dist/compatibility/claude/dynamic-context.js +2 -1
- package/dist/compatibility/claude/paths.js +2 -13
- package/dist/compatibility/claude/shared-resources.d.ts +4 -4
- package/dist/compatibility/claude/shared-resources.js +14 -35
- package/dist/core/context-budget.d.ts +9 -8
- package/dist/core/context-budget.js +16 -21
- package/dist/core/project-memory.d.ts +19 -0
- package/dist/core/project-memory.js +58 -0
- package/dist/core/runtime.d.ts +10 -1
- package/dist/core/runtime.js +47 -3
- package/dist/hooks/claude-file-change-watcher.d.ts +43 -0
- package/dist/hooks/claude-file-change-watcher.js +185 -0
- package/dist/hooks/claude-hook-tools.d.ts +2 -0
- package/dist/hooks/claude-hook-tools.js +45 -6
- package/dist/hooks/claude-hooks.d.ts +14 -2
- package/dist/hooks/claude-hooks.js +230 -5
- package/dist/hooks/claude-session-environment.d.ts +16 -0
- package/dist/hooks/claude-session-environment.js +112 -0
- package/dist/maintenance/doctor.js +29 -5
- package/dist/mcp/claude-mcp-management.js +2 -0
- package/dist/persistence/native-resources.d.ts +3 -1
- package/dist/persistence/native-resources.js +19 -6
- package/dist/platform/path-containment.d.ts +2 -0
- package/dist/platform/path-containment.js +7 -0
- package/dist/platform/project-identity.d.ts +6 -0
- package/dist/platform/project-identity.js +66 -0
- package/dist/platform/project-memory-paths.d.ts +13 -0
- package/dist/platform/project-memory-paths.js +28 -0
- package/dist/platform/project-path-key.d.ts +2 -0
- package/dist/platform/project-path-key.js +16 -0
- package/dist/providers/fallback-provider.js +3 -1
- package/dist/tools/claude-task-tools.d.ts +5 -0
- package/dist/tools/claude-task-tools.js +15 -1
- package/dist/tools/local-tools.d.ts +2 -0
- package/dist/tools/local-tools.js +11 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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}${
|
|
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,
|
|
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();
|