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,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
@@ -17,6 +17,7 @@ import { ModelPricingRegistry } from '../core/usage.js';
17
17
  import { detectInstalledClaudeVersion, } from '../platform/claude-version.js';
18
18
  import { redactSensitiveText, sensitiveEnvironmentValues, } from '../platform/sensitive-data.js';
19
19
  import { collectDoctorLocalDiagnostics, resolveDoctorUpdates, } from './doctor-diagnostic.js';
20
+ import { resolveProjectMemoryPolicy } from '../core/project-memory.js';
20
21
  function isRecord(value) {
21
22
  return typeof value === 'object' && value !== null && !Array.isArray(value);
22
23
  }
@@ -148,23 +149,36 @@ export async function runDoctor(options) {
148
149
  const updatesPromise = resolveDoctorUpdates(diagnosticOptions, local.updates);
149
150
  let settings;
150
151
  let nativeResources;
152
+ const settingsResources = async () => {
153
+ settings ??=
154
+ dataPlane === 'claude'
155
+ ? await loadClaudeSettings({ configRoot, cwd })
156
+ : await loadNativeSettings({ root: configRoot, cwd });
157
+ return settings;
158
+ };
159
+ const projectMemoryEnabled = async () => resolveProjectMemoryPolicy({
160
+ dataPlane,
161
+ settings: await settingsResources(),
162
+ environment: options.environment,
163
+ }).enabled;
151
164
  const sharedResources = async () => {
165
+ const includeProjectMemory = await projectMemoryEnabled();
152
166
  if (dataPlane === 'claude') {
153
167
  return loadClaudeSharedResources({
154
168
  configRoot,
155
169
  cwd,
156
170
  claudeStatePath: options.claudeStatePath,
171
+ includeProjectMemory,
157
172
  });
158
173
  }
159
174
  nativeResources ??= await loadNativeSharedResources({
160
175
  root: configRoot,
161
176
  cwd,
177
+ environment: options.environment,
178
+ includeProjectMemory,
162
179
  });
163
180
  return nativeResources;
164
181
  };
165
- const settingsResources = async () => dataPlane === 'claude'
166
- ? loadClaudeSettings({ configRoot, cwd })
167
- : loadNativeSettings({ root: configRoot, cwd });
168
182
  const checks = [];
169
183
  checks.push(await capture('installation', async () => {
170
184
  const executablePath = await canonicalExecutable(options.executablePath);
@@ -350,11 +364,21 @@ export async function runDoctor(options) {
350
364
  }));
351
365
  checks.push(await capture('resources', async () => {
352
366
  const resources = await sharedResources();
367
+ const includeProjectMemory = await projectMemoryEnabled();
353
368
  if (dataPlane === 'native') {
354
- await loadNativeContextResources({ root: configRoot, cwd });
369
+ await loadNativeContextResources({
370
+ root: configRoot,
371
+ cwd,
372
+ environment: options.environment,
373
+ includeProjectMemory,
374
+ });
355
375
  }
356
376
  else {
357
- await loadClaudeContextResources({ configRoot, cwd });
377
+ await loadClaudeContextResources({
378
+ configRoot,
379
+ cwd,
380
+ includeProjectMemory,
381
+ });
358
382
  }
359
383
  validateClaudeExtensions([
360
384
  ...resources.commands,
@@ -191,11 +191,13 @@ export class ClaudeMcpManagement {
191
191
  ? await loadNativeSharedResources({
192
192
  root: this.configRoot,
193
193
  cwd: this.cwd,
194
+ includeProjectMemory: false,
194
195
  })
195
196
  : await loadClaudeSharedResources({
196
197
  configRoot: this.configRoot,
197
198
  cwd: this.cwd,
198
199
  settingSources: ['user', 'project', 'local'],
200
+ includeProjectMemory: false,
199
201
  });
200
202
  const records = new Map();
201
203
  for (const resource of resources.mcp) {
@@ -2,8 +2,10 @@ import type { ClaudeContextResources, ClaudeJsonResource, ClaudeSharedResources
2
2
  export interface LoadNativeResourcesOptions {
3
3
  root: string;
4
4
  cwd: string;
5
+ environment?: Readonly<Record<string, string | undefined>>;
6
+ includeProjectMemory?: boolean;
5
7
  }
6
8
  export declare function loadNativeSettings({ root, cwd, }: LoadNativeResourcesOptions): Promise<ClaudeJsonResource[]>;
7
- export declare function loadNativeSharedResources({ root, cwd, }: LoadNativeResourcesOptions): Promise<ClaudeSharedResources>;
9
+ export declare function loadNativeSharedResources({ root, cwd, environment, includeProjectMemory, }: LoadNativeResourcesOptions): Promise<ClaudeSharedResources>;
8
10
  export declare function loadNativeContextResources(options: LoadNativeResourcesOptions): Promise<ClaudeContextResources>;
9
11
  //# sourceMappingURL=native-resources.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { join, resolve } from 'node:path';
3
- import { sanitizeClaudeProjectPath } from '../compatibility/claude/paths.js';
3
+ import { resolveProjectMemoryPolicy } from '../core/project-memory.js';
4
+ import { resolveProjectMemoryDirectory } from '../platform/project-memory-paths.js';
4
5
  async function text(path, scope) {
5
6
  try {
6
7
  return { path, scope, content: await readFile(path, 'utf8') };
@@ -56,13 +57,26 @@ export async function loadNativeSettings({ root, cwd, }) {
56
57
  ]);
57
58
  return resources.filter((resource) => resource !== null);
58
59
  }
59
- export async function loadNativeSharedResources({ root, cwd, }) {
60
+ export async function loadNativeSharedResources({ root, cwd, environment = process.env, includeProjectMemory = true, }) {
60
61
  const project = resolve(cwd, '.praxis');
61
- const memoryRoot = resolve(root, 'memory', sanitizeClaudeProjectPath(cwd));
62
- const [userInstruction, projectInstruction, memory, skills, commands, agents, settings, userMcp, projectMcp, localMcp,] = await Promise.all([
62
+ const memoryRoot = await resolveProjectMemoryDirectory({
63
+ dataPlane: 'native',
64
+ configRoot: root,
65
+ cwd,
66
+ });
67
+ const settings = await loadNativeSettings({ root, cwd });
68
+ const memoryEnabled = includeProjectMemory &&
69
+ resolveProjectMemoryPolicy({
70
+ dataPlane: 'native',
71
+ settings,
72
+ environment,
73
+ }).enabled;
74
+ const [userInstruction, projectInstruction, memory, skills, commands, agents, userMcp, projectMcp, localMcp,] = await Promise.all([
63
75
  text(join(root, 'PRAXIS.md'), 'user'),
64
76
  text(join(project, 'PRAXIS.md'), 'project'),
65
- files(memoryRoot, 'project', (name) => name.endsWith('.md')),
77
+ memoryEnabled
78
+ ? files(memoryRoot, 'project', (name) => name.endsWith('.md'))
79
+ : Promise.resolve([]),
66
80
  Promise.all([
67
81
  files(join(root, 'skills'), 'user', (name) => name === 'SKILL.md'),
68
82
  files(join(project, 'skills'), 'project', (name) => name === 'SKILL.md'),
@@ -75,7 +89,6 @@ export async function loadNativeSharedResources({ root, cwd, }) {
75
89
  files(join(root, 'agents'), 'user', (name) => name.endsWith('.md')),
76
90
  files(join(project, 'agents'), 'project', (name) => name.endsWith('.md')),
77
91
  ]).then(([user, local]) => [...user, ...local]),
78
- loadNativeSettings({ root, cwd }),
79
92
  json(join(root, 'mcp.json'), 'user'),
80
93
  json(join(project, 'mcp.json'), 'project'),
81
94
  json(join(project, 'mcp.local.json'), 'local'),
@@ -0,0 +1,2 @@
1
+ export declare function isPathWithin(root: string, candidate: string): boolean;
2
+ //# sourceMappingURL=path-containment.d.ts.map
@@ -0,0 +1,7 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
2
+ export function isPathWithin(root, candidate) {
3
+ const pathFromRoot = relative(resolve(root), resolve(candidate));
4
+ return (pathFromRoot === '' ||
5
+ (!pathFromRoot.startsWith('..') && !isAbsolute(pathFromRoot)));
6
+ }
7
+ //# sourceMappingURL=path-containment.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Resolves the stable local identity for a project. Linked worktrees share the
3
+ * main repository identity; non-git directories use their canonical path.
4
+ */
5
+ export declare function resolveProjectIdentity(cwd: string): Promise<string>;
6
+ //# sourceMappingURL=project-identity.d.ts.map
@@ -0,0 +1,66 @@
1
+ import { readFile, realpath, stat } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ async function canonicalPath(path) {
4
+ try {
5
+ return await realpath(path);
6
+ }
7
+ catch (error) {
8
+ if (error.code === 'ENOENT')
9
+ return resolve(path);
10
+ throw error;
11
+ }
12
+ }
13
+ async function exists(path) {
14
+ try {
15
+ await stat(path);
16
+ return true;
17
+ }
18
+ catch (error) {
19
+ if (error.code === 'ENOENT')
20
+ return false;
21
+ throw error;
22
+ }
23
+ }
24
+ async function findGitRoot(cwd) {
25
+ let current = await canonicalPath(cwd);
26
+ while (!(await exists(join(current, '.git')))) {
27
+ const parent = dirname(current);
28
+ if (parent === current)
29
+ return null;
30
+ current = parent;
31
+ }
32
+ return current;
33
+ }
34
+ async function optionalFile(path) {
35
+ try {
36
+ return await readFile(path, 'utf8');
37
+ }
38
+ catch (error) {
39
+ if (error.code === 'ENOENT')
40
+ return null;
41
+ throw error;
42
+ }
43
+ }
44
+ /**
45
+ * Resolves the stable local identity for a project. Linked worktrees share the
46
+ * main repository identity; non-git directories use their canonical path.
47
+ */
48
+ export async function resolveProjectIdentity(cwd) {
49
+ const canonicalCwd = await canonicalPath(cwd);
50
+ const gitRoot = await findGitRoot(canonicalCwd);
51
+ if (!gitRoot)
52
+ return canonicalCwd;
53
+ const marker = join(gitRoot, '.git');
54
+ if ((await stat(marker)).isDirectory())
55
+ return gitRoot;
56
+ const gitFile = await readFile(marker, 'utf8');
57
+ const gitDirectoryValue = /^gitdir:\s*(.+)\s*$/mu.exec(gitFile)?.[1];
58
+ if (!gitDirectoryValue)
59
+ return gitRoot;
60
+ const gitDirectory = resolve(gitRoot, gitDirectoryValue);
61
+ const commonDirectory = await optionalFile(join(gitDirectory, 'commondir'));
62
+ if (!commonDirectory)
63
+ return gitRoot;
64
+ return dirname(await canonicalPath(resolve(gitDirectory, commonDirectory.trim())));
65
+ }
66
+ //# sourceMappingURL=project-identity.js.map
@@ -0,0 +1,13 @@
1
+ import type { ProjectMemoryDataPlane } from '../core/project-memory.js';
2
+ export interface ResolveProjectMemoryDirectoryOptions {
3
+ dataPlane: ProjectMemoryDataPlane;
4
+ configRoot: string;
5
+ cwd: string;
6
+ }
7
+ export declare function resolveProjectMemoryDirectory({ dataPlane, configRoot, cwd, }: ResolveProjectMemoryDirectoryOptions): Promise<string>;
8
+ export declare function resolveProjectMemoryStatePath(options: {
9
+ dataPlane: ProjectMemoryDataPlane;
10
+ configRoot: string;
11
+ memoryDirectory: string;
12
+ }): string;
13
+ //# sourceMappingURL=project-memory-paths.d.ts.map
@@ -0,0 +1,28 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { resolveProjectIdentity } from './project-identity.js';
4
+ import { sanitizeProjectPath } from './project-path-key.js';
5
+ async function canonicalPath(path) {
6
+ try {
7
+ return await realpath(path);
8
+ }
9
+ catch (error) {
10
+ if (error.code === 'ENOENT')
11
+ return resolve(path);
12
+ throw error;
13
+ }
14
+ }
15
+ export async function resolveProjectMemoryDirectory({ dataPlane, configRoot, cwd, }) {
16
+ const [root, identity] = await Promise.all([
17
+ canonicalPath(configRoot),
18
+ resolveProjectIdentity(cwd),
19
+ ]);
20
+ const key = sanitizeProjectPath(identity);
21
+ return dataPlane === 'native'
22
+ ? join(root, 'memory', key)
23
+ : join(root, 'projects', key, 'memory');
24
+ }
25
+ export function resolveProjectMemoryStatePath(options) {
26
+ return join(resolve(options.configRoot), options.dataPlane === 'native' ? 'state' : 'praxis', 'project-memory', basename(options.memoryDirectory), 'cursor.json');
27
+ }
28
+ //# sourceMappingURL=project-memory-paths.js.map
@@ -0,0 +1,2 @@
1
+ export declare function sanitizeProjectPath(path: string): string;
2
+ //# sourceMappingURL=project-path-key.d.ts.map
@@ -0,0 +1,16 @@
1
+ const MAX_SANITIZED_PROJECT_PATH_LENGTH = 200;
2
+ function stablePathHash(value) {
3
+ let hash = 0;
4
+ for (let index = 0; index < value.length; index += 1) {
5
+ hash = Math.imul(hash, 31) + value.charCodeAt(index);
6
+ hash |= 0;
7
+ }
8
+ return Math.abs(hash);
9
+ }
10
+ export function sanitizeProjectPath(path) {
11
+ const sanitized = path.replace(/[^a-zA-Z0-9]/g, '-');
12
+ if (sanitized.length <= MAX_SANITIZED_PROJECT_PATH_LENGTH)
13
+ return sanitized;
14
+ return `${sanitized.slice(0, MAX_SANITIZED_PROJECT_PATH_LENGTH)}-${stablePathHash(path).toString(36)}`;
15
+ }
16
+ //# sourceMappingURL=project-path-key.js.map
@@ -2,7 +2,9 @@ import { modelProviderErrorKind, ModelProviderError, } from '../core/runtime.js'
2
2
  const MAX_ATTEMPTS_PER_MODEL = 3;
3
3
  const RETRY_DELAYS_MS = [500, 1_000];
4
4
  function retryable(error) {
5
- return error instanceof ModelProviderError && error.retryable;
5
+ return (error instanceof ModelProviderError &&
6
+ error.retryable &&
7
+ modelProviderErrorKind(error) !== 'prompt_too_long');
6
8
  }
7
9
  /** Routes each complete request through Claude's retry-then-fallback policy. */
8
10
  export class FallbackModelProvider {
@@ -1,5 +1,6 @@
1
1
  import type { ModelToolCall, ModelToolDefinition, ToolExecutionContext, ToolExecutionResult, ToolRegistry, RuntimeEventSink } from '../core/runtime.js';
2
2
  import { type BackgroundBashSnapshot, type BackgroundBashToolResult } from '../application/background-bash-manager.js';
3
+ import { type ClaudeTask } from '../persistence/claude-task-store.js';
3
4
  export interface ClaudeTaskToolRegistryOptions {
4
5
  base: ToolRegistry;
5
6
  cwd: string;
@@ -10,6 +11,10 @@ export interface ClaudeTaskToolRegistryOptions {
10
11
  enabledTools?: readonly string[];
11
12
  maxOutputBytes?: number;
12
13
  eventSink?: RuntimeEventSink;
14
+ taskHooks?: {
15
+ created(task: Readonly<ClaudeTask>, signal?: AbortSignal): Promise<void>;
16
+ completed(task: Readonly<ClaudeTask>, signal?: AbortSignal): Promise<void>;
17
+ };
13
18
  }
14
19
  export declare class ClaudeTaskToolRegistry implements ToolRegistry {
15
20
  private readonly options;
@@ -342,6 +342,13 @@ export class ClaudeTaskToolRegistry {
342
342
  switch (call.name) {
343
343
  case 'TaskCreate': {
344
344
  const task = await this.store.create(this.createInput(call.input));
345
+ try {
346
+ await this.options.taskHooks?.created(task, context.signal);
347
+ }
348
+ catch (error) {
349
+ await this.store.update(task.id, { status: 'deleted' });
350
+ throw error;
351
+ }
345
352
  return {
346
353
  content: `Task #${task.id} created successfully: ${task.subject}`,
347
354
  isError: false,
@@ -374,7 +381,14 @@ export class ClaudeTaskToolRegistry {
374
381
  }
375
382
  case 'TaskUpdate': {
376
383
  const id = stringField(call.input, 'taskId');
377
- const result = await this.store.update(id, this.updateInput(call.input));
384
+ const update = this.updateInput(call.input);
385
+ if (update.status === 'completed') {
386
+ const task = await this.store.get(id);
387
+ if (task && task.status !== 'completed') {
388
+ await this.options.taskHooks?.completed(task, context.signal);
389
+ }
390
+ }
391
+ const result = await this.store.update(id, update);
378
392
  const missing = {
379
393
  success: false,
380
394
  taskId: id,
@@ -11,6 +11,7 @@ export interface LocalToolRegistryOptions {
11
11
  maxShellTimeoutMs?: number;
12
12
  enableReportFindings?: boolean;
13
13
  environment?: Readonly<Record<string, string>>;
14
+ sessionEnvironment?: (sessionId: string) => Promise<Readonly<Record<string, string>> | undefined>;
14
15
  sandbox?: BashSandboxRuntime;
15
16
  homeDirectory?: string;
16
17
  configRoot?: string;
@@ -44,6 +45,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
44
45
  private readonly processRunner;
45
46
  private readonly enableReportFindings;
46
47
  private readonly environment;
48
+ private readonly sessionEnvironment;
47
49
  private readonly sandbox;
48
50
  private readonly homeDirectory;
49
51
  private readonly configRoot;
@@ -509,6 +509,7 @@ export class LocalToolRegistry {
509
509
  processRunner;
510
510
  enableReportFindings;
511
511
  environment;
512
+ sessionEnvironment;
512
513
  sandbox;
513
514
  homeDirectory;
514
515
  configRoot;
@@ -526,6 +527,7 @@ export class LocalToolRegistry {
526
527
  this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
527
528
  this.enableReportFindings = options.enableReportFindings ?? false;
528
529
  this.environment = options.environment;
530
+ this.sessionEnvironment = options.sessionEnvironment;
529
531
  this.sandbox = options.sandbox;
530
532
  this.homeDirectory = options.homeDirectory
531
533
  ? resolve(options.homeDirectory)
@@ -1258,18 +1260,21 @@ export class LocalToolRegistry {
1258
1260
  throw new Error('Prepared Bash call has no timeout');
1259
1261
  const rawCommand = stringInput(call.input, 'command');
1260
1262
  this.assertProtectedBashCommand(rawCommand, context);
1261
- const sandboxInput = {
1263
+ const sandboxPolicyInput = {
1262
1264
  command: rawCommand,
1263
1265
  ...(call.input.dangerouslyDisableSandbox === true
1264
1266
  ? { dangerouslyDisableSandbox: true }
1265
1267
  : {}),
1266
1268
  };
1267
- const sandboxed = this.sandbox?.shouldUseSandbox(sandboxInput) ?? false;
1269
+ const sandboxed = this.sandbox?.shouldUseSandbox(sandboxPolicyInput) ?? false;
1268
1270
  const shell = commandShell();
1269
1271
  let result;
1270
1272
  try {
1273
+ const sessionEnvironment = context.sessionId
1274
+ ? await this.sessionEnvironment?.(context.sessionId)
1275
+ : undefined;
1271
1276
  const command = sandboxed
1272
- ? await this.sandbox?.wrapCommand(sandboxInput, {
1277
+ ? await this.sandbox?.wrapCommand(sandboxPolicyInput, {
1273
1278
  shell,
1274
1279
  ...(context.signal ? { signal: context.signal } : {}),
1275
1280
  commandId: call.id,
@@ -1280,7 +1285,9 @@ export class LocalToolRegistry {
1280
1285
  args: commandShellArguments(command ?? rawCommand),
1281
1286
  timeoutMs: timeout,
1282
1287
  cwd: this.currentCwd(context),
1283
- ...(this.environment ? { env: this.environment } : {}),
1288
+ ...(this.environment || sessionEnvironment
1289
+ ? { env: { ...this.environment, ...sessionEnvironment } }
1290
+ : {}),
1284
1291
  ...(context.signal ? { signal: context.signal } : {}),
1285
1292
  });
1286
1293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",