profclaw 2.3.0 → 2.4.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 (60) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +5 -2
  3. package/config/pricing.yml +26 -2
  4. package/dist/agents/command-sandbox.d.ts +93 -0
  5. package/dist/agents/command-sandbox.js +235 -0
  6. package/dist/agents/executor-runner.d.ts +135 -0
  7. package/dist/agents/executor-runner.js +303 -0
  8. package/dist/agents/executor.d.ts +2 -0
  9. package/dist/agents/executor.js +35 -2
  10. package/dist/agents/output-truncator.d.ts +59 -0
  11. package/dist/agents/output-truncator.js +128 -0
  12. package/dist/agents/receipt/receipt-cli.d.ts +43 -0
  13. package/dist/agents/receipt/receipt-cli.js +82 -0
  14. package/dist/agents/receipt/receipt-types.d.ts +136 -0
  15. package/dist/agents/receipt/receipt-types.js +19 -0
  16. package/dist/agents/receipt/receipt-verify.d.ts +19 -0
  17. package/dist/agents/receipt/receipt-verify.js +176 -0
  18. package/dist/agents/receipt/receipt-view.d.ts +29 -0
  19. package/dist/agents/receipt/receipt-view.js +227 -0
  20. package/dist/agents/receipt/receipt-writer.d.ts +47 -0
  21. package/dist/agents/receipt/receipt-writer.js +186 -0
  22. package/dist/agents/result-store.d.ts +6 -0
  23. package/dist/agents/result-store.js +43 -2
  24. package/dist/agents/session-diff.d.ts +6 -0
  25. package/dist/agents/session-diff.js +10 -19
  26. package/dist/agents/tool-selector.d.ts +123 -0
  27. package/dist/agents/tool-selector.js +321 -0
  28. package/dist/agents/verified-run-git.d.ts +49 -0
  29. package/dist/agents/verified-run-git.js +82 -0
  30. package/dist/agents/verified-run.d.ts +149 -0
  31. package/dist/agents/verified-run.js +402 -0
  32. package/dist/agents/verifier.d.ts +64 -0
  33. package/dist/agents/verifier.js +186 -0
  34. package/dist/agents/worktree-tools.d.ts +47 -0
  35. package/dist/agents/worktree-tools.js +246 -0
  36. package/dist/chat/agentic-executor.js +14 -6
  37. package/dist/chat/execution/sandbox.d.ts +8 -0
  38. package/dist/chat/execution/sandbox.js +25 -1
  39. package/dist/chat/execution/tools/tts-speak.d.ts +2 -2
  40. package/dist/chat/meta-tool-handlers.d.ts +18 -0
  41. package/dist/chat/meta-tool-handlers.js +51 -0
  42. package/dist/chat/system-prompts.js +6 -1
  43. package/dist/chat/tool-handler.d.ts +2 -0
  44. package/dist/chat/tool-handler.js +47 -3
  45. package/dist/cli/commands/receipt.d.ts +7 -0
  46. package/dist/cli/commands/receipt.js +90 -0
  47. package/dist/cli/commands/run.d.ts +28 -0
  48. package/dist/cli/commands/run.js +204 -0
  49. package/dist/cli/index.js +4 -0
  50. package/dist/costs/token-tracker.d.ts +10 -1
  51. package/dist/costs/token-tracker.js +37 -5
  52. package/dist/providers/ai-sdk.js +33 -37
  53. package/dist/providers/core/models.js +59 -17
  54. package/dist/providers/core/types.d.ts +4 -0
  55. package/dist/providers/prompt-cache.d.ts +68 -0
  56. package/dist/providers/prompt-cache.js +165 -0
  57. package/dist/providers/smart-router.js +11 -3
  58. package/dist/routes/chat.js +22 -5
  59. package/dist/server.js +3 -0
  60. package/package.json +1 -1
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Verifier
3
+ *
4
+ * Runs a user-provided verification command (tests, typecheck, lint, build or
5
+ * anything custom) as an objective judge of "done". The command is the only
6
+ * shell input: goal text and model output are never interpolated into it.
7
+ *
8
+ * Safety conventions:
9
+ * - runs with cwd pinned to the run's worktree
10
+ * - hard timeout, process group is killed on expiry
11
+ * - captured output is capped in memory and trimmed for feedback
12
+ * - a small denylist rejects commands that publish or destroy (git push,
13
+ * gh pr create, rm -rf /, curl | sh) because a verifier must only observe
14
+ */
15
+ import { spawn } from 'node:child_process';
16
+ import { mkdtemp, rm } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { planSandboxedCommand, resolveAllowNetwork, resolveExtraWritable } from './command-sandbox.js';
20
+ // Command policy
21
+ const FORBIDDEN_PATTERNS = [
22
+ { pattern: /\bgit\b[^;&|\n]*\bpush\b/, reason: 'git push is not allowed in a verifier' },
23
+ { pattern: /\bgh\s+pr\s+(?:create|merge)\b/, reason: 'opening or merging PRs is not allowed in a verifier' },
24
+ { pattern: /\brm\s+(?:-\S*\s+)*-\S*[rR]\S*\s+(?:-\S+\s+)*(?:\/|~|\$HOME)(?:\s|$)/, reason: 'destructive rm is not allowed in a verifier' },
25
+ { pattern: /\b(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/, reason: 'piping downloads into a shell is not allowed' },
26
+ { pattern: /\bsudo\b/, reason: 'sudo is not allowed in a verifier' },
27
+ ];
28
+ /** Returns a rejection reason, or null when the command is acceptable. */
29
+ export function validateVerifyCommand(command) {
30
+ if (command.trim().length === 0)
31
+ return 'verify command is empty';
32
+ if (command.includes('\0'))
33
+ return 'verify command contains a NUL byte';
34
+ for (const { pattern, reason } of FORBIDDEN_PATTERNS) {
35
+ if (pattern.test(command))
36
+ return reason;
37
+ }
38
+ return null;
39
+ }
40
+ // Output trimming
41
+ /**
42
+ * Keep the head and tail of long output (failures usually name the problem
43
+ * at the top and summarise at the bottom) and drop the middle.
44
+ */
45
+ export function trimOutput(raw, maxChars) {
46
+ const cleaned = stripAnsi(raw).trim();
47
+ if (cleaned.length <= maxChars)
48
+ return { text: cleaned, truncated: false };
49
+ const marker = '\n... [output trimmed] ...\n';
50
+ const budget = Math.max(0, maxChars - marker.length);
51
+ const head = Math.ceil(budget * 0.4);
52
+ const tail = budget - head;
53
+ return {
54
+ text: cleaned.slice(0, head) + marker + (tail > 0 ? cleaned.slice(-tail) : ''),
55
+ truncated: true,
56
+ };
57
+ }
58
+ function stripAnsi(text) {
59
+ // eslint-disable-next-line no-control-regex
60
+ return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '');
61
+ }
62
+ // Command verifier
63
+ export class CommandVerifier {
64
+ command;
65
+ options;
66
+ constructor(command, options) {
67
+ this.command = command;
68
+ this.options = options;
69
+ }
70
+ async verify() {
71
+ const rejection = validateVerifyCommand(this.command);
72
+ if (rejection) {
73
+ return {
74
+ passed: false,
75
+ exitCode: null,
76
+ timedOut: false,
77
+ durationMs: 0,
78
+ output: `Verifier rejected: ${rejection}`,
79
+ truncated: false,
80
+ };
81
+ }
82
+ return runCommand(this.command, this.options);
83
+ }
84
+ }
85
+ // Cap on raw bytes retained in memory regardless of trim setting.
86
+ const RAW_CAPTURE_LIMIT = 2_000_000;
87
+ async function runCommand(command, options) {
88
+ if (!options.sandbox)
89
+ return spawnCommand({ file: '/bin/sh', args: ['-c', command], env: {}, backend: 'none' }, options);
90
+ const tempDir = await mkdtemp(join(tmpdir(), 'profclaw-verify-'));
91
+ try {
92
+ const plan = planSandboxedCommand(command, {
93
+ workdir: options.cwd,
94
+ tempDir,
95
+ allowNetwork: options.sandbox.allowNetwork ?? resolveAllowNetwork(),
96
+ extraWritable: resolveExtraWritable(),
97
+ }, options.sandbox);
98
+ return await spawnCommand(plan, options);
99
+ }
100
+ catch (error) {
101
+ return {
102
+ passed: false,
103
+ exitCode: null,
104
+ timedOut: false,
105
+ durationMs: 0,
106
+ output: `Verifier sandbox error: ${error instanceof Error ? error.message : String(error)}`,
107
+ truncated: false,
108
+ };
109
+ }
110
+ finally {
111
+ await rm(tempDir, { recursive: true, force: true });
112
+ }
113
+ }
114
+ function spawnCommand(plan, options) {
115
+ return new Promise((resolve) => {
116
+ const started = Date.now();
117
+ let buffer = '';
118
+ let rawTruncated = false;
119
+ let timedOut = false;
120
+ let settled = false;
121
+ const child = spawn(plan.file, plan.args, {
122
+ cwd: options.cwd,
123
+ env: { ...process.env, ...options.env, ...plan.env, CI: process.env.CI ?? '1' },
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ detached: true,
126
+ });
127
+ const append = (chunk) => {
128
+ if (buffer.length >= RAW_CAPTURE_LIMIT) {
129
+ rawTruncated = true;
130
+ return;
131
+ }
132
+ buffer += chunk.toString('utf-8');
133
+ };
134
+ child.stdout.on('data', append);
135
+ child.stderr.on('data', append);
136
+ const killGroup = () => {
137
+ if (child.pid === undefined)
138
+ return;
139
+ try {
140
+ process.kill(-child.pid, 'SIGKILL');
141
+ }
142
+ catch {
143
+ child.kill('SIGKILL');
144
+ }
145
+ };
146
+ const timer = setTimeout(() => {
147
+ timedOut = true;
148
+ killGroup();
149
+ }, options.timeoutMs);
150
+ const finish = (exitCode, extra) => {
151
+ if (settled)
152
+ return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ const trimmed = trimOutput(buffer + (extra ?? ''), options.maxOutputChars);
156
+ const output = timedOut
157
+ ? `${trimmed.text}\n[verifier timed out after ${options.timeoutMs}ms]`.trim()
158
+ : trimmed.text;
159
+ resolve({
160
+ passed: exitCode === 0 && !timedOut,
161
+ exitCode,
162
+ timedOut,
163
+ durationMs: Date.now() - started,
164
+ output,
165
+ truncated: trimmed.truncated || rawTruncated,
166
+ });
167
+ };
168
+ child.on('error', (err) => finish(null, `\n${err.message}`));
169
+ child.on('close', (code) => finish(code));
170
+ });
171
+ }
172
+ /** Format a result as feedback text for the next agent attempt. */
173
+ export function formatFailureFeedback(result, command) {
174
+ const status = result.timedOut
175
+ ? 'timed out'
176
+ : `exited with code ${result.exitCode === null ? 'unknown' : result.exitCode}`;
177
+ return [
178
+ `The verification command \`${command}\` ${status}.`,
179
+ 'Output (trimmed):',
180
+ '```',
181
+ result.output,
182
+ '```',
183
+ 'Fix the underlying problem so the command passes. Do not edit the verification command or weaken tests to force a pass.',
184
+ ].join('\n');
185
+ }
186
+ //# sourceMappingURL=verifier.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Worktree-confined tools for the verified-run agent.
3
+ *
4
+ * A small coding toolset (read, write, list, shell, complete) whose every file
5
+ * operation is resolved against a single root directory, the run's git
6
+ * worktree. Paths that escape the root (via `..`, absolute paths or symlinks)
7
+ * are rejected, writes into `.git` are refused, and shell commands that push
8
+ * or open pull requests are denied. Cautious and dangerous tools also go
9
+ * through the existing PermissionManager (src/agents/permissions.ts).
10
+ *
11
+ * The shell tool runs with the worktree as cwd and a scrubbed environment,
12
+ * confined by an OS sandbox when available (src/agents/command-sandbox.ts).
13
+ * Without one it falls back to pattern screening, a guard rail, not a jail.
14
+ */
15
+ import type { ToolSet } from 'ai';
16
+ import { PermissionManager } from './permissions.js';
17
+ import type { ToolExecuteHandler } from './executor.js';
18
+ import type { PlanOptions } from './command-sandbox.js';
19
+ export interface WorktreeToolsOptions {
20
+ /** Root every file operation is confined to */
21
+ workdir: string;
22
+ permissions?: PermissionManager;
23
+ /** Kill shell commands after this long */
24
+ commandTimeoutMs: number;
25
+ /** Cap on text returned to the model per tool call */
26
+ maxOutputChars: number;
27
+ /** Sandbox overrides (mode, availability check); defaults come from PROFCLAW_RUN_SANDBOX */
28
+ sandbox?: PlanOptions;
29
+ /** Override PROFCLAW_RUN_ALLOW_NETWORK */
30
+ allowNetwork?: boolean;
31
+ }
32
+ export interface WorktreeToolset {
33
+ tools: ToolSet;
34
+ execute: ToolExecuteHandler;
35
+ /** Remove the per-run sandbox temp dir */
36
+ dispose: () => Promise<void>;
37
+ }
38
+ export declare function isBlockedCommand(command: string): boolean;
39
+ /** Non-interactive policy: allow everything except publishing actions. */
40
+ export declare function createRunPermissionManager(): PermissionManager;
41
+ /**
42
+ * Resolve `requested` against `root` and guarantee the result stays inside it,
43
+ * including through symlinks (checked on the deepest existing ancestor).
44
+ */
45
+ export declare function confinePath(root: string, requested: string): Promise<string>;
46
+ export declare function createWorktreeToolset(options: WorktreeToolsOptions): WorktreeToolset;
47
+ //# sourceMappingURL=worktree-tools.d.ts.map
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Worktree-confined tools for the verified-run agent.
3
+ *
4
+ * A small coding toolset (read, write, list, shell, complete) whose every file
5
+ * operation is resolved against a single root directory, the run's git
6
+ * worktree. Paths that escape the root (via `..`, absolute paths or symlinks)
7
+ * are rejected, writes into `.git` are refused, and shell commands that push
8
+ * or open pull requests are denied. Cautious and dangerous tools also go
9
+ * through the existing PermissionManager (src/agents/permissions.ts).
10
+ *
11
+ * The shell tool runs with the worktree as cwd and a scrubbed environment,
12
+ * confined by an OS sandbox when available (src/agents/command-sandbox.ts).
13
+ * Without one it falls back to pattern screening, a guard rail, not a jail.
14
+ */
15
+ import { execFile, spawn } from 'node:child_process';
16
+ import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
19
+ import { jsonSchema, tool } from 'ai';
20
+ import { PermissionManager } from './permissions.js';
21
+ import { planSandboxedCommand, resolveAllowNetwork, resolveExtraWritable } from './command-sandbox.js';
22
+ /** Tools the agent must never be given, denied even if requested by name. */
23
+ const DENIED_TOOLS = new Set(['git_push', 'git_force_push']);
24
+ /** Shell commands that would publish work. Screened before execution. */
25
+ const BLOCKED_COMMAND_PATTERNS = [
26
+ /\bgit\b[^;&|\n]*\bpush\b/,
27
+ /\bgh\s+(pr|repo|release|api|workflow)\b/,
28
+ /\bgit\b[^;&|\n]*\bremote\s+(add|set-url)\b/,
29
+ /\bgit\b[^;&|\n]*--receive-pack\b/,
30
+ ];
31
+ /** Environment variables that carry credentials for remotes. */
32
+ const SCRUBBED_ENV_KEYS = ['GITHUB_TOKEN', 'GH_TOKEN', 'GITLAB_TOKEN', 'GIT_ASKPASS', 'SSH_AUTH_SOCK'];
33
+ export function isBlockedCommand(command) {
34
+ return BLOCKED_COMMAND_PATTERNS.some((p) => p.test(command));
35
+ }
36
+ /** Non-interactive policy: allow everything except publishing actions. */
37
+ export function createRunPermissionManager() {
38
+ const manager = new PermissionManager();
39
+ manager.setPromptCallback(async (toolName, args) => {
40
+ if (DENIED_TOOLS.has(toolName))
41
+ return 'deny';
42
+ if (toolName === 'bash') {
43
+ const command = getString(args, 'command');
44
+ if (command !== undefined && isBlockedCommand(command))
45
+ return 'deny';
46
+ }
47
+ return 'allow';
48
+ });
49
+ return manager;
50
+ }
51
+ function getString(args, key) {
52
+ if (typeof args !== 'object' || args === null)
53
+ return undefined;
54
+ const value = args[key];
55
+ return typeof value === 'string' ? value : undefined;
56
+ }
57
+ function isInside(root, target) {
58
+ const rel = relative(root, target);
59
+ if (rel === '')
60
+ return true;
61
+ return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
62
+ }
63
+ /**
64
+ * Resolve `requested` against `root` and guarantee the result stays inside it,
65
+ * including through symlinks (checked on the deepest existing ancestor).
66
+ */
67
+ export async function confinePath(root, requested) {
68
+ const absolute = resolve(root, requested);
69
+ if (!isInside(root, absolute))
70
+ throw new Error(`Path escapes the worktree: ${requested}`);
71
+ const rootReal = await realpath(root);
72
+ let probe = absolute;
73
+ for (;;) {
74
+ try {
75
+ const real = await realpath(probe);
76
+ if (!isInside(rootReal, real))
77
+ throw new Error(`Path escapes the worktree via symlink: ${requested}`);
78
+ return absolute;
79
+ }
80
+ catch (error) {
81
+ if (error instanceof Error && error.message.startsWith('Path escapes'))
82
+ throw error;
83
+ const parent = dirname(probe);
84
+ if (parent === probe)
85
+ throw new Error(`Cannot resolve path: ${requested}`);
86
+ probe = parent;
87
+ }
88
+ }
89
+ }
90
+ function truncateTail(text, max) {
91
+ return text.length <= max ? text : `...[truncated]\n${text.slice(-max)}`;
92
+ }
93
+ /** Git dirs a linked worktree must write to (index, refs, objects), minus hooks and config. */
94
+ async function resolveGitSandboxPaths(workdir) {
95
+ const run = (arg) => new Promise((res) => {
96
+ execFile('git', ['-C', workdir, 'rev-parse', arg], (err, stdout) => res(err ? '' : stdout.trim()));
97
+ });
98
+ const writable = new Set();
99
+ const readOnly = [];
100
+ for (const dir of [await run('--git-dir'), await run('--git-common-dir')]) {
101
+ if (dir === '')
102
+ continue;
103
+ const abs = resolve(workdir, dir);
104
+ writable.add(abs);
105
+ readOnly.push(join(abs, 'hooks'), join(abs, 'config'));
106
+ }
107
+ return { writable: [...writable], readOnly };
108
+ }
109
+ function runShell(spec, cwd, timeoutMs, maxChars) {
110
+ return new Promise((resolvePromise) => {
111
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', ...spec.env };
112
+ for (const key of SCRUBBED_ENV_KEYS)
113
+ delete env[key];
114
+ const child = spawn(spec.file, spec.args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
115
+ let output = '';
116
+ let timedOut = false;
117
+ const append = (d) => {
118
+ output = (output + d.toString('utf-8')).slice(-maxChars * 2);
119
+ };
120
+ child.stdout.on('data', append);
121
+ child.stderr.on('data', append);
122
+ const timer = setTimeout(() => {
123
+ timedOut = true;
124
+ child.kill('SIGKILL');
125
+ }, timeoutMs);
126
+ child.on('error', (err) => {
127
+ clearTimeout(timer);
128
+ resolvePromise({ exitCode: null, output: err.message, timedOut });
129
+ });
130
+ child.on('close', (code) => {
131
+ clearTimeout(timer);
132
+ resolvePromise({ exitCode: code, output: truncateTail(output, maxChars), timedOut });
133
+ });
134
+ });
135
+ }
136
+ function schemaTool(description, properties, required) {
137
+ return tool({
138
+ description,
139
+ inputSchema: jsonSchema({
140
+ type: 'object',
141
+ properties,
142
+ required,
143
+ additionalProperties: false,
144
+ }),
145
+ });
146
+ }
147
+ export function createWorktreeToolset(options) {
148
+ const { workdir, commandTimeoutMs, maxOutputChars } = options;
149
+ const permissions = options.permissions ?? createRunPermissionManager();
150
+ let sandboxBase;
151
+ const getSandboxBase = () => {
152
+ sandboxBase ??= (async () => {
153
+ const tempDir = await mkdtemp(join(tmpdir(), 'profclaw-run-'));
154
+ const git = await resolveGitSandboxPaths(workdir);
155
+ return {
156
+ workdir,
157
+ tempDir,
158
+ allowNetwork: options.allowNetwork ?? resolveAllowNetwork(),
159
+ extraWritable: [...git.writable, ...resolveExtraWritable()],
160
+ extraReadOnly: git.readOnly,
161
+ };
162
+ })();
163
+ return sandboxBase;
164
+ };
165
+ const dispose = async () => {
166
+ if (!sandboxBase)
167
+ return;
168
+ const base = await sandboxBase;
169
+ sandboxBase = undefined;
170
+ await rm(base.tempDir, { recursive: true, force: true });
171
+ };
172
+ const tools = {
173
+ read_file: schemaTool('Read a UTF-8 text file inside the working directory.', { path: { type: 'string' } }, ['path']),
174
+ write_file: schemaTool('Create or overwrite a file inside the working directory. Parent directories are created.', { path: { type: 'string' }, content: { type: 'string' } }, ['path', 'content']),
175
+ list_files: schemaTool('List entries of a directory inside the working directory (default: the root).', { path: { type: 'string' } }, []),
176
+ bash: schemaTool('Run a shell command with the working directory as cwd. Use it to run tests and the verify command. Pushing and opening pull requests is forbidden.', { command: { type: 'string' } }, ['command']),
177
+ complete_task: schemaTool('Call when the goal is done. Provide a short summary of what changed.', { summary: { type: 'string' } }, ['summary']),
178
+ };
179
+ const execute = async (name, args) => {
180
+ if (DENIED_TOOLS.has(name))
181
+ return { success: false, error: `Tool "${name}" is not allowed in a verified run` };
182
+ if (!(name in tools))
183
+ return { success: false, error: `Unknown tool "${name}"` };
184
+ const verdict = await permissions.check(name, args);
185
+ if (!verdict.allowed)
186
+ return { success: false, error: verdict.reason ?? `Permission denied for "${name}"` };
187
+ try {
188
+ switch (name) {
189
+ case 'read_file': {
190
+ const path = getString(args, 'path');
191
+ if (path === undefined)
192
+ return { success: false, error: 'path is required' };
193
+ const target = await confinePath(workdir, path);
194
+ const text = await readFile(target, 'utf-8');
195
+ return { success: true, data: { content: truncateTail(text, maxOutputChars) } };
196
+ }
197
+ case 'write_file': {
198
+ const path = getString(args, 'path');
199
+ const content = getString(args, 'content');
200
+ if (path === undefined || content === undefined)
201
+ return { success: false, error: 'path and content are required' };
202
+ const target = await confinePath(workdir, path);
203
+ const rel = relative(workdir, target);
204
+ if (rel === '.git' || rel.startsWith(`.git${sep}`))
205
+ return { success: false, error: 'Writing into .git is not allowed' };
206
+ await mkdir(dirname(target), { recursive: true });
207
+ await writeFile(target, content, 'utf-8');
208
+ return { success: true, data: { path: rel, bytes: Buffer.byteLength(content) } };
209
+ }
210
+ case 'list_files': {
211
+ const target = await confinePath(workdir, getString(args, 'path') ?? '.');
212
+ const entries = await readdir(target, { withFileTypes: true });
213
+ const names = entries
214
+ .filter((e) => e.name !== '.git')
215
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
216
+ .sort();
217
+ return { success: true, data: { entries: names } };
218
+ }
219
+ case 'bash': {
220
+ const command = getString(args, 'command');
221
+ if (command === undefined)
222
+ return { success: false, error: 'command is required' };
223
+ if (isBlockedCommand(command)) {
224
+ return { success: false, error: 'Command denied: pushing and opening pull requests are not allowed' };
225
+ }
226
+ const spec = planSandboxedCommand(command, await getSandboxBase(), options.sandbox);
227
+ const result = await runShell(spec, workdir, commandTimeoutMs, maxOutputChars);
228
+ return {
229
+ success: result.exitCode === 0,
230
+ data: { exitCode: result.exitCode, timedOut: result.timedOut, output: result.output },
231
+ ...(result.exitCode === 0 ? {} : { error: `exit ${result.exitCode ?? 'null'}${result.timedOut ? ' (timed out)' : ''}` }),
232
+ };
233
+ }
234
+ case 'complete_task':
235
+ return { success: true, data: { summary: getString(args, 'summary') ?? 'Done' } };
236
+ default:
237
+ return { success: false, error: `Unknown tool "${name}"` };
238
+ }
239
+ }
240
+ catch (error) {
241
+ return { success: false, error: error instanceof Error ? error.message : 'Tool failed' };
242
+ }
243
+ };
244
+ return { tools, execute, dispose };
245
+ }
246
+ //# sourceMappingURL=worktree-tools.js.map
@@ -33,6 +33,7 @@ function getExperienceStore() {
33
33
  }
34
34
  import { normalizeToolSchema } from '../providers/schema-utils.js';
35
35
  import { logger } from '../utils/logger.js';
36
+ import { selectToolsForRequest } from '../agents/tool-selector.js';
36
37
  import { runWithModelFallback, getUserFriendlyErrorMessage, getProvidersInCooldown, describeFailoverError, } from './failover/index.js';
37
38
  const passthroughValidate = (value) => ({
38
39
  success: true,
@@ -244,7 +245,9 @@ export async function executeAgenticChat(request) {
244
245
  }
245
246
  // Clone the base system prompt to avoid mutating the request object on retry
246
247
  const baseSystemPrompt = request.systemPrompt ?? '';
247
- let augmentedSystemPrompt = baseSystemPrompt;
248
+ // Volatile per-request context is kept out of the base prompt so the base stays byte-stable
249
+ // for prompt caching (a second system message follows the stable one)
250
+ let volatileContext = '';
248
251
  // Recall relevant past experiences to enhance system prompt
249
252
  try {
250
253
  const store = await getExperienceStore();
@@ -261,7 +264,7 @@ export async function executeAgenticChat(request) {
261
264
  return `Past experience: ${exp.intent} (score: ${exp.successScore})`;
262
265
  });
263
266
  if (hints.length > 0) {
264
- augmentedSystemPrompt += `\n\nRelevant past experiences:\n${hints.join('\n')}`;
267
+ volatileContext += `\n\nRelevant past experiences:\n${hints.join('\n')}`;
265
268
  logger.debug('[AgenticChat] Injected experience context', { count: hints.length });
266
269
  for (const exp of similar.slice(0, 2)) {
267
270
  store.markUsed(exp.id).catch(() => { });
@@ -286,7 +289,7 @@ export async function executeAgenticChat(request) {
286
289
  const contextBlock = context.sources
287
290
  .map(s => `[${s.type}${s.path ? `: ${s.path}` : ''}]\n${s.content.slice(0, 500)}`)
288
291
  .join('\n\n');
289
- augmentedSystemPrompt += `\n\nProject context (auto-gathered):\n${contextBlock}`;
292
+ volatileContext += `\n\nProject context (auto-gathered):\n${contextBlock}`;
290
293
  logger.debug('[AgenticChat] Injected project context', {
291
294
  sources: context.sources.length,
292
295
  tokens: context.tokens,
@@ -306,10 +309,13 @@ export async function executeAgenticChat(request) {
306
309
  effort: request.effort,
307
310
  };
308
311
  // Store raw tool definitions - we'll convert to AI SDK format per-provider
309
- const toolDefinitions = request.tools;
312
+ // Per-turn tool selection (core set + relevant groups + load_tools meta-tool)
313
+ const turnUserMessage = [...request.messages].reverse().find((m) => m.role === 'user');
314
+ const toolDefinitions = selectToolsForRequest(request.tools, request.conversationId, typeof turnUserMessage?.content === 'string' ? turnUserMessage.content : '');
310
315
  // Build messages with system prompt
311
316
  const messages = [
312
- { role: 'system', content: augmentedSystemPrompt },
317
+ { role: 'system', content: baseSystemPrompt },
318
+ ...(volatileContext ? [{ role: 'system', content: volatileContext.trim() }] : []),
313
319
  ...request.messages.map((m) => ({
314
320
  role: m.role,
315
321
  content: m.content,
@@ -641,7 +647,9 @@ export async function* streamAgenticChat(request) {
641
647
  effort: request.effort,
642
648
  };
643
649
  // Store raw tool definitions - we'll convert to AI SDK format per-provider
644
- const toolDefinitions = request.tools;
650
+ // Per-turn tool selection, same as the non-streaming path
651
+ const turnUserMessage = [...request.messages].reverse().find((m) => m.role === 'user');
652
+ const toolDefinitions = selectToolsForRequest(request.tools, request.conversationId, typeof turnUserMessage?.content === 'string' ? turnUserMessage.content : '');
645
653
  // Build messages with system prompt
646
654
  const messages = [
647
655
  { role: 'system', content: request.systemPrompt },
@@ -23,6 +23,14 @@ export interface SandboxExecuteResult {
23
23
  containerId?: string;
24
24
  error?: string;
25
25
  }
26
+ /**
27
+ * Ping the Docker daemon, giving up after a timeout. A wedged daemon (for example a
28
+ * half-started Docker Desktop) accepts the connection but never answers, which would
29
+ * otherwise hang startup forever.
30
+ */
31
+ export declare function pingDockerWithTimeout(docker: {
32
+ ping(): Promise<unknown>;
33
+ }, timeoutMs?: number): Promise<void>;
26
34
  export declare class SandboxManager {
27
35
  private config;
28
36
  private securityConfig;
@@ -16,6 +16,30 @@ const DEFAULT_CPU_LIMIT = '1';
16
16
  const CONTAINER_POOL_SIZE = 3;
17
17
  const CONTAINER_MAX_AGE_MS = 3600_000; // 1 hour
18
18
  const CLEANUP_INTERVAL_MS = 60_000; // 1 minute
19
+ const DEFAULT_DOCKER_PING_TIMEOUT_MS = 3_000;
20
+ /** How long to wait for the Docker daemon to answer before treating it as unavailable. */
21
+ function getDockerPingTimeoutMs(env = process.env) {
22
+ const parsed = Number(env['PROFCLAW_DOCKER_PING_TIMEOUT_MS']);
23
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_DOCKER_PING_TIMEOUT_MS;
24
+ }
25
+ /**
26
+ * Ping the Docker daemon, giving up after a timeout. A wedged daemon (for example a
27
+ * half-started Docker Desktop) accepts the connection but never answers, which would
28
+ * otherwise hang startup forever.
29
+ */
30
+ export async function pingDockerWithTimeout(docker, timeoutMs = getDockerPingTimeoutMs()) {
31
+ let timer;
32
+ const timeout = new Promise((_, reject) => {
33
+ timer = setTimeout(() => reject(new Error(`Docker daemon did not respond within ${timeoutMs}ms`)), timeoutMs);
34
+ });
35
+ try {
36
+ await Promise.race([docker.ping(), timeout]);
37
+ }
38
+ finally {
39
+ if (timer)
40
+ clearTimeout(timer);
41
+ }
42
+ }
19
43
  const DEFAULT_SESSION_IDLE_MS = 600_000; // 10 minutes
20
44
  export class SandboxManager {
21
45
  config;
@@ -76,7 +100,7 @@ export class SandboxManager {
76
100
  : new DockerModule.default();
77
101
  }
78
102
  // Check Docker connection
79
- await this.docker.ping();
103
+ await pingDockerWithTimeout(this.docker);
80
104
  logger.info('[Sandbox] Docker connection established', { component: 'Sandbox' });
81
105
  // Log active security level so operators know what enforcement is in place
82
106
  logger.info(`[Sandbox] Security level: ${this.securityConfig.level} (mode override: ${process.env.PROFCLAW_SANDBOX_LEVEL ?? 'none'}, resolved: ${getSecurityLevel()})`, {
@@ -16,13 +16,13 @@ declare const TtsSpeakParamsSchema: z.ZodObject<{
16
16
  text: string;
17
17
  speed: number;
18
18
  format: "opus" | "mp3" | "wav" | "aac" | "flac";
19
- voice: "echo" | "alloy" | "fable" | "onyx" | "nova" | "shimmer";
19
+ voice: "fable" | "echo" | "alloy" | "onyx" | "nova" | "shimmer";
20
20
  outputPath?: string | undefined;
21
21
  }, {
22
22
  text: string;
23
23
  speed?: number | undefined;
24
24
  format?: "opus" | "mp3" | "wav" | "aac" | "flac" | undefined;
25
- voice?: "echo" | "alloy" | "fable" | "onyx" | "nova" | "shimmer" | undefined;
25
+ voice?: "fable" | "echo" | "alloy" | "onyx" | "nova" | "shimmer" | undefined;
26
26
  outputPath?: string | undefined;
27
27
  }>;
28
28
  export type TtsSpeakParams = z.infer<typeof TtsSpeakParamsSchema>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Meta-tool handlers: load_tools and fetch_result.
3
+ *
4
+ * Kept separate from tool-handler.ts so the handler stays small. These are pure
5
+ * functions over injected dependencies, which also makes them easy to test.
6
+ */
7
+ import type { ResultStore } from '../agents/result-store.js';
8
+ import { type SelectableTool, type ToolSelectorSession } from '../agents/tool-selector.js';
9
+ export interface LoadToolsDeps {
10
+ session: ToolSelectorSession;
11
+ /** Every available tool (not just the selected ones) */
12
+ allTools: readonly SelectableTool[];
13
+ /** Runs a tool through the normal security pipeline */
14
+ invoke: (tool: string, args: Record<string, unknown>) => Promise<unknown>;
15
+ }
16
+ export declare function runLoadTools(args: unknown, deps: LoadToolsDeps): Promise<unknown>;
17
+ export declare function runFetchResult(args: unknown, store: ResultStore): Promise<unknown>;
18
+ //# sourceMappingURL=meta-tool-handlers.d.ts.map