@causyne/agent 1.10.13 → 1.10.18
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/docs/ARCHITECTURE.md +118 -17
- package/docs/SECURITY_REVIEW.md +13 -1
- package/docs/THIRD_PARTY_NOTICES.md +46 -10
- package/package.json +10 -4
- package/src/agentLoop.js +123 -21
- package/src/agentProfiles.js +135 -0
- package/src/agentProfiles.ts +143 -0
- package/src/approvalGrants.js +17 -35
- package/src/approvalGrants.ts +54 -0
- package/src/auditLog.d.ts +3 -0
- package/src/browser/browserRuntime.js +88 -0
- package/src/browser/browserRuntime.ts +107 -0
- package/src/browser/causyneBrowser.js +495 -0
- package/src/browser/causyneBrowser.ts +594 -0
- package/src/browser/searchProviders.js +156 -0
- package/src/browser/searchProviders.ts +156 -0
- package/src/browser/snapshotWindow.js +28 -0
- package/src/browser/snapshotWindow.ts +40 -0
- package/src/browser/urlPolicy.js +89 -0
- package/src/browser/urlPolicy.ts +89 -0
- package/src/checkpoints.js +19 -6
- package/src/cliMode.js +13 -1
- package/src/commands.js +37 -8
- package/src/config.d.ts +2 -0
- package/src/decisionPolicy.js +68 -0
- package/src/decisionPolicy.ts +106 -0
- package/src/hooks.js +121 -107
- package/src/hooks.ts +193 -0
- package/src/implementationPlan.js +50 -0
- package/src/implementationPlan.ts +59 -0
- package/src/mcp/mcpClient.js +520 -132
- package/src/mcp/mcpClient.ts +584 -0
- package/src/mcp/mcpRegistry.js +399 -185
- package/src/mcp/mcpRegistry.ts +451 -0
- package/src/planCache.js +473 -450
- package/src/planCache.ts +840 -0
- package/src/plugins/pluginManager.js +40 -5
- package/src/plugins/pluginSkills.js +84 -0
- package/src/plugins/pluginSkills.ts +106 -0
- package/src/provisioning/capabilityProvisioner.d.ts +10 -0
- package/src/provisioning/capabilityProvisioner.js +5 -0
- package/src/provisioning/capabilityTools.js +2 -1
- package/src/sandbox.js +32 -2
- package/src/sessionJournal.js +471 -0
- package/src/sessionJournal.ts +496 -0
- package/src/sessionStore.js +3 -279
- package/src/skillsManager.js +18 -0
- package/src/subagentJournal.js +215 -0
- package/src/subagentJournal.ts +254 -0
- package/src/taskShape.js +179 -198
- package/src/taskShape.ts +259 -0
- package/src/toolPacks.js +7 -5
- package/src/tools/browserAutomator.js +45 -241
- package/src/tools/browserAutomator.ts +57 -0
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/registry.js +4 -3
- package/src/tools/requestDecision.js +87 -0
- package/src/tools/requestDecision.ts +107 -0
- package/src/tools/subagentOrchestrator.js +73 -12
- package/src/tools/updatePlan.js +37 -84
- package/src/tools/updatePlan.ts +48 -0
- package/src/tools/webFetch.js +183 -68
- package/src/tools/webFetch.ts +185 -0
- package/src/tools/webSearch.js +50 -75
- package/src/tools/webSearch.ts +56 -0
- package/src/tui/ask.js +41 -1
- package/src/tui/chatRenderer.js +28 -0
- package/src/tui/dialogs.js +91 -0
- package/src/tui/prompt.js +32 -3
- package/src/tui/toolDisplay.js +0 -1
- package/src/types/contracts.js +8 -0
- package/src/types/contracts.ts +263 -0
- package/src/utils/paths.d.ts +3 -0
- package/src/version.d.ts +1 -0
- package/src/mcp/jsonRpc.js +0 -165
- package/src/tools/todoWrite.js +0 -75
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { HookDecision, HookEvent } from './types/contracts.js';
|
|
5
|
+
|
|
6
|
+
const MAX_HOOKS_PER_EVENT = 32;
|
|
7
|
+
const MAX_OUTPUT_BYTES = 32 * 1024;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
type UnknownRecord = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
interface NormalizedHookRule {
|
|
13
|
+
name: string;
|
|
14
|
+
command: [string, ...string[]];
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface HookProcessResult {
|
|
19
|
+
exitCode: number | null;
|
|
20
|
+
stdout: string;
|
|
21
|
+
stderr: string;
|
|
22
|
+
timedOut?: boolean;
|
|
23
|
+
overflow?: boolean;
|
|
24
|
+
spawnError?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RunConfiguredHooksOptions {
|
|
28
|
+
hooks?: unknown;
|
|
29
|
+
event: HookEvent;
|
|
30
|
+
toolName: string;
|
|
31
|
+
args?: unknown;
|
|
32
|
+
result?: unknown;
|
|
33
|
+
taskId?: unknown;
|
|
34
|
+
toolCallId?: unknown;
|
|
35
|
+
workspacePaths?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeObject(value: unknown): UnknownRecord {
|
|
39
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
40
|
+
? value as UnknownRecord
|
|
41
|
+
: {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function matchesTool(rule: UnknownRecord, toolName: string): boolean {
|
|
45
|
+
const tools = Array.isArray(rule.tools)
|
|
46
|
+
? rule.tools
|
|
47
|
+
: [rule.tool == null ? '*' : rule.tool];
|
|
48
|
+
return tools.some((candidate) => candidate === '*' || candidate === toolName);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeRule(rule: unknown, event: HookEvent, toolName: string): NormalizedHookRule | null {
|
|
52
|
+
if (rule === null || typeof rule !== 'object' || Array.isArray(rule)) return null;
|
|
53
|
+
const candidate = rule as UnknownRecord;
|
|
54
|
+
if (candidate.enabled === false || candidate.event !== event || !matchesTool(candidate, toolName)) return null;
|
|
55
|
+
if (!Array.isArray(candidate.command) || candidate.command.length === 0) return null;
|
|
56
|
+
|
|
57
|
+
const command = candidate.command.map((part) => String(part));
|
|
58
|
+
const executable = command[0];
|
|
59
|
+
if (!executable || command.length > 64 || command.some((part) => part.length > 4096)) return null;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: String(candidate.name || `${event}:${toolName}`).slice(0, 128),
|
|
63
|
+
command: [executable, ...command.slice(1)],
|
|
64
|
+
timeoutMs: Number.isInteger(candidate.timeoutMs)
|
|
65
|
+
? Math.max(100, Math.min(candidate.timeoutMs as number, 10_000))
|
|
66
|
+
: DEFAULT_TIMEOUT_MS
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function hasMatchingHook(hooks: unknown, event: HookEvent, toolName: string): boolean {
|
|
71
|
+
return (Array.isArray(hooks) ? hooks : [])
|
|
72
|
+
.some((rule) => normalizeRule(rule, event, toolName) !== null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runOneHook(rule: NormalizedHookRule, payload: UnknownRecord, cwd: string): Promise<HookProcessResult> {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const child = spawn(rule.command[0], rule.command.slice(1), {
|
|
78
|
+
cwd,
|
|
79
|
+
shell: false,
|
|
80
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
81
|
+
env: {
|
|
82
|
+
PATH: process.env.PATH || '',
|
|
83
|
+
LANG: process.env.LANG || 'C.UTF-8',
|
|
84
|
+
TMPDIR: process.env.TMPDIR || ''
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
let stdout = '';
|
|
88
|
+
let stderr = '';
|
|
89
|
+
let settled = false;
|
|
90
|
+
|
|
91
|
+
const finish = (value: HookProcessResult): void => {
|
|
92
|
+
if (settled) return;
|
|
93
|
+
settled = true;
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve(value);
|
|
96
|
+
};
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
child.kill('SIGTERM');
|
|
99
|
+
finish({ exitCode: null, timedOut: true, stdout, stderr });
|
|
100
|
+
}, rule.timeoutMs);
|
|
101
|
+
timer.unref();
|
|
102
|
+
|
|
103
|
+
const collect = (target: string, chunk: Buffer | string): string => {
|
|
104
|
+
const next = target + chunk.toString();
|
|
105
|
+
if (Buffer.byteLength(next, 'utf8') > MAX_OUTPUT_BYTES) {
|
|
106
|
+
child.kill('SIGTERM');
|
|
107
|
+
finish({ exitCode: null, overflow: true, stdout, stderr });
|
|
108
|
+
return target;
|
|
109
|
+
}
|
|
110
|
+
return next;
|
|
111
|
+
};
|
|
112
|
+
child.stdout.on('data', (chunk: Buffer | string) => { stdout = collect(stdout, chunk); });
|
|
113
|
+
child.stderr.on('data', (chunk: Buffer | string) => { stderr = collect(stderr, chunk); });
|
|
114
|
+
child.on('error', (error: Error) => finish({ exitCode: null, spawnError: error.message, stdout, stderr }));
|
|
115
|
+
child.on('close', (code: number | null) => finish({ exitCode: code, stdout, stderr }));
|
|
116
|
+
child.stdin.end(JSON.stringify(payload));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function feedbackFor(rule: NormalizedHookRule, result: HookProcessResult): string {
|
|
121
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
122
|
+
if (output) return `${rule.name}: ${output}`.slice(0, MAX_OUTPUT_BYTES);
|
|
123
|
+
if (result.timedOut) return `${rule.name}: timed out after ${rule.timeoutMs} ms`;
|
|
124
|
+
if (result.overflow) return `${rule.name}: output exceeded ${MAX_OUTPUT_BYTES} bytes`;
|
|
125
|
+
if (result.spawnError) return `${rule.name}: ${result.spawnError}`;
|
|
126
|
+
if (result.exitCode !== 0) return `${rule.name}: exited with code ${result.exitCode}`;
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveWorkspaceRoots(workspacePaths: unknown): string[] {
|
|
131
|
+
if (!Array.isArray(workspacePaths)) return [process.cwd()];
|
|
132
|
+
const roots = workspacePaths
|
|
133
|
+
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
|
134
|
+
.map((value) => path.resolve(value));
|
|
135
|
+
return roots.length > 0 ? roots : [process.cwd()];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Run user-owned lifecycle hooks from ~/.causyne/config.json.
|
|
140
|
+
*
|
|
141
|
+
* Hooks are arrays of argv values and never pass through a shell. Project files
|
|
142
|
+
* are not searched for hooks, which prevents repository code from executing
|
|
143
|
+
* during startup before the user has trusted the workspace.
|
|
144
|
+
*/
|
|
145
|
+
export async function runConfiguredHooks({
|
|
146
|
+
hooks,
|
|
147
|
+
event,
|
|
148
|
+
toolName,
|
|
149
|
+
args,
|
|
150
|
+
result,
|
|
151
|
+
taskId,
|
|
152
|
+
toolCallId,
|
|
153
|
+
workspacePaths
|
|
154
|
+
}: RunConfiguredHooksOptions): Promise<HookDecision> {
|
|
155
|
+
const source: unknown[] = Array.isArray(hooks) ? hooks : [];
|
|
156
|
+
const rules = source
|
|
157
|
+
.map((rule) => normalizeRule(rule, event, toolName))
|
|
158
|
+
.filter((rule): rule is NormalizedHookRule => rule !== null)
|
|
159
|
+
.slice(0, MAX_HOOKS_PER_EVENT);
|
|
160
|
+
if (rules.length === 0) return { denied: false, messages: [] };
|
|
161
|
+
|
|
162
|
+
const roots = resolveWorkspaceRoots(workspacePaths);
|
|
163
|
+
const primaryRoot = roots[0] ?? process.cwd();
|
|
164
|
+
const payload: UnknownRecord = {
|
|
165
|
+
event,
|
|
166
|
+
tool: toolName,
|
|
167
|
+
args: safeObject(args),
|
|
168
|
+
taskId: String(taskId || ''),
|
|
169
|
+
toolCallId: String(toolCallId || ''),
|
|
170
|
+
workspacePaths: roots
|
|
171
|
+
};
|
|
172
|
+
if (result !== null && typeof result === 'object') payload.result = result;
|
|
173
|
+
const messages: string[] = [];
|
|
174
|
+
|
|
175
|
+
for (const rule of rules) {
|
|
176
|
+
const hookResult = await runOneHook(rule, payload, primaryRoot);
|
|
177
|
+
const feedback = feedbackFor(rule, hookResult);
|
|
178
|
+
if (feedback) messages.push(feedback);
|
|
179
|
+
|
|
180
|
+
if (hookResult.exitCode === 2) {
|
|
181
|
+
return feedback
|
|
182
|
+
? { denied: true, messages, reason: feedback }
|
|
183
|
+
: { denied: true, messages };
|
|
184
|
+
}
|
|
185
|
+
if (event === 'PreToolUse' && hookResult.exitCode !== 0) {
|
|
186
|
+
return { denied: true, messages, reason: feedback || `${rule.name} failed closed` };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { denied: false, messages };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export { MAX_HOOKS_PER_EVENT, MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const MAX_STEPS = 24;
|
|
2
|
+
function cleanText(value, name, limit) {
|
|
3
|
+
if (typeof value !== 'string')
|
|
4
|
+
throw new Error(`${name} must be a string`);
|
|
5
|
+
const cleaned = value.replace(/\s+/g, ' ').trim();
|
|
6
|
+
if (!cleaned)
|
|
7
|
+
throw new Error(`${name} must not be empty`);
|
|
8
|
+
if (cleaned.length > limit)
|
|
9
|
+
throw new Error(`${name} exceeds ${limit} characters`);
|
|
10
|
+
return cleaned;
|
|
11
|
+
}
|
|
12
|
+
export function normalizePlanUpdate(value) {
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
14
|
+
throw new Error('Plan update must be an object');
|
|
15
|
+
const input = value;
|
|
16
|
+
if (!Array.isArray(input.plan) || input.plan.length === 0)
|
|
17
|
+
throw new Error('plan must contain at least one step');
|
|
18
|
+
if (input.plan.length > MAX_STEPS)
|
|
19
|
+
throw new Error(`plan may contain at most ${MAX_STEPS} steps`);
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
let active = 0;
|
|
22
|
+
const plan = input.plan.map((raw, index) => {
|
|
23
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
24
|
+
throw new Error(`plan[${index}] must be an object`);
|
|
25
|
+
const item = raw;
|
|
26
|
+
const id = cleanText(item.id ?? `step-${index + 1}`, `plan[${index}].id`, 64);
|
|
27
|
+
const step = cleanText(item.step ?? item.content, `plan[${index}].step`, 240);
|
|
28
|
+
const status = item.status;
|
|
29
|
+
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') {
|
|
30
|
+
throw new Error(`plan[${index}].status must be pending, in_progress, or completed`);
|
|
31
|
+
}
|
|
32
|
+
if (ids.has(id))
|
|
33
|
+
throw new Error(`Duplicate plan step id: ${id}`);
|
|
34
|
+
ids.add(id);
|
|
35
|
+
if (status === 'in_progress')
|
|
36
|
+
active += 1;
|
|
37
|
+
return { id, step, status };
|
|
38
|
+
});
|
|
39
|
+
if (active > 1)
|
|
40
|
+
throw new Error('Only one plan step may be in_progress');
|
|
41
|
+
const explanation = typeof input.explanation === 'string'
|
|
42
|
+
? input.explanation.replace(/\s+/g, ' ').trim().slice(0, 500)
|
|
43
|
+
: '';
|
|
44
|
+
return { explanation, plan };
|
|
45
|
+
}
|
|
46
|
+
export function summarizePlan(plan) {
|
|
47
|
+
const completed = plan.filter(item => item.status === 'completed').length;
|
|
48
|
+
const active = plan.find(item => item.status === 'in_progress');
|
|
49
|
+
return `${completed}/${plan.length} completed${active ? `; in progress: ${active.step}` : ''}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type PlanStatus = 'pending' | 'in_progress' | 'completed';
|
|
2
|
+
|
|
3
|
+
export interface PlanStep {
|
|
4
|
+
id: string;
|
|
5
|
+
step: string;
|
|
6
|
+
status: PlanStatus;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PlanUpdate {
|
|
10
|
+
explanation: string;
|
|
11
|
+
plan: PlanStep[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAX_STEPS = 24;
|
|
15
|
+
|
|
16
|
+
function cleanText(value: unknown, name: string, limit: number): string {
|
|
17
|
+
if (typeof value !== 'string') throw new Error(`${name} must be a string`);
|
|
18
|
+
const cleaned = value.replace(/\s+/g, ' ').trim();
|
|
19
|
+
if (!cleaned) throw new Error(`${name} must not be empty`);
|
|
20
|
+
if (cleaned.length > limit) throw new Error(`${name} exceeds ${limit} characters`);
|
|
21
|
+
return cleaned;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizePlanUpdate(value: unknown): PlanUpdate {
|
|
25
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Plan update must be an object');
|
|
26
|
+
const input = value as Record<string, unknown>;
|
|
27
|
+
if (!Array.isArray(input.plan) || input.plan.length === 0) throw new Error('plan must contain at least one step');
|
|
28
|
+
if (input.plan.length > MAX_STEPS) throw new Error(`plan may contain at most ${MAX_STEPS} steps`);
|
|
29
|
+
|
|
30
|
+
const ids = new Set<string>();
|
|
31
|
+
let active = 0;
|
|
32
|
+
const plan = input.plan.map((raw, index): PlanStep => {
|
|
33
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`plan[${index}] must be an object`);
|
|
34
|
+
const item = raw as Record<string, unknown>;
|
|
35
|
+
const id = cleanText(item.id ?? `step-${index + 1}`, `plan[${index}].id`, 64);
|
|
36
|
+
const step = cleanText(item.step ?? item.content, `plan[${index}].step`, 240);
|
|
37
|
+
const status = item.status;
|
|
38
|
+
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') {
|
|
39
|
+
throw new Error(`plan[${index}].status must be pending, in_progress, or completed`);
|
|
40
|
+
}
|
|
41
|
+
if (ids.has(id)) throw new Error(`Duplicate plan step id: ${id}`);
|
|
42
|
+
ids.add(id);
|
|
43
|
+
if (status === 'in_progress') active += 1;
|
|
44
|
+
return { id, step, status };
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (active > 1) throw new Error('Only one plan step may be in_progress');
|
|
48
|
+
const explanation = typeof input.explanation === 'string'
|
|
49
|
+
? input.explanation.replace(/\s+/g, ' ').trim().slice(0, 500)
|
|
50
|
+
: '';
|
|
51
|
+
return { explanation, plan };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function summarizePlan(plan: readonly PlanStep[]): string {
|
|
55
|
+
const completed = plan.filter(item => item.status === 'completed').length;
|
|
56
|
+
const active = plan.find(item => item.status === 'in_progress');
|
|
57
|
+
return `${completed}/${plan.length} completed${active ? `; in progress: ${active.step}` : ''}`;
|
|
58
|
+
}
|
|
59
|
+
|