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.
- package/dist/application/session-service.d.ts +19 -0
- package/dist/application/session-service.js +310 -60
- package/dist/application/subagent-service.js +6 -0
- package/dist/cli/interactive.d.ts +3 -1
- package/dist/cli/interactive.js +16 -1
- package/dist/cli/tui/hook-settings.js +14 -1
- package/dist/cli-runtime.d.ts +1 -0
- package/dist/cli-runtime.js +18 -0
- package/dist/compatibility/claude/context.d.ts +6 -1
- package/dist/compatibility/claude/context.js +14 -1
- package/dist/core/runtime.d.ts +4 -1
- package/dist/core/runtime.js +9 -1
- 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/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
|
@@ -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();
|
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
1288
|
+
...(this.environment || sessionEnvironment
|
|
1289
|
+
? { env: { ...this.environment, ...sessionEnvironment } }
|
|
1290
|
+
: {}),
|
|
1284
1291
|
...(context.signal ? { signal: context.signal } : {}),
|
|
1285
1292
|
});
|
|
1286
1293
|
}
|