@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.
Files changed (76) hide show
  1. package/docs/ARCHITECTURE.md +118 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +46 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +123 -21
  6. package/src/agentProfiles.js +135 -0
  7. package/src/agentProfiles.ts +143 -0
  8. package/src/approvalGrants.js +17 -35
  9. package/src/approvalGrants.ts +54 -0
  10. package/src/auditLog.d.ts +3 -0
  11. package/src/browser/browserRuntime.js +88 -0
  12. package/src/browser/browserRuntime.ts +107 -0
  13. package/src/browser/causyneBrowser.js +495 -0
  14. package/src/browser/causyneBrowser.ts +594 -0
  15. package/src/browser/searchProviders.js +156 -0
  16. package/src/browser/searchProviders.ts +156 -0
  17. package/src/browser/snapshotWindow.js +28 -0
  18. package/src/browser/snapshotWindow.ts +40 -0
  19. package/src/browser/urlPolicy.js +89 -0
  20. package/src/browser/urlPolicy.ts +89 -0
  21. package/src/checkpoints.js +19 -6
  22. package/src/cliMode.js +13 -1
  23. package/src/commands.js +37 -8
  24. package/src/config.d.ts +2 -0
  25. package/src/decisionPolicy.js +68 -0
  26. package/src/decisionPolicy.ts +106 -0
  27. package/src/hooks.js +121 -107
  28. package/src/hooks.ts +193 -0
  29. package/src/implementationPlan.js +50 -0
  30. package/src/implementationPlan.ts +59 -0
  31. package/src/mcp/mcpClient.js +520 -132
  32. package/src/mcp/mcpClient.ts +584 -0
  33. package/src/mcp/mcpRegistry.js +399 -185
  34. package/src/mcp/mcpRegistry.ts +451 -0
  35. package/src/planCache.js +473 -450
  36. package/src/planCache.ts +840 -0
  37. package/src/plugins/pluginManager.js +40 -5
  38. package/src/plugins/pluginSkills.js +84 -0
  39. package/src/plugins/pluginSkills.ts +106 -0
  40. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  41. package/src/provisioning/capabilityProvisioner.js +5 -0
  42. package/src/provisioning/capabilityTools.js +2 -1
  43. package/src/sandbox.js +32 -2
  44. package/src/sessionJournal.js +471 -0
  45. package/src/sessionJournal.ts +496 -0
  46. package/src/sessionStore.js +3 -279
  47. package/src/skillsManager.js +18 -0
  48. package/src/subagentJournal.js +215 -0
  49. package/src/subagentJournal.ts +254 -0
  50. package/src/taskShape.js +179 -198
  51. package/src/taskShape.ts +259 -0
  52. package/src/toolPacks.js +7 -5
  53. package/src/tools/browserAutomator.js +45 -241
  54. package/src/tools/browserAutomator.ts +57 -0
  55. package/src/tools/manageLocalAiDependencies.js +1 -1
  56. package/src/tools/registry.js +4 -3
  57. package/src/tools/requestDecision.js +87 -0
  58. package/src/tools/requestDecision.ts +107 -0
  59. package/src/tools/subagentOrchestrator.js +73 -12
  60. package/src/tools/updatePlan.js +37 -84
  61. package/src/tools/updatePlan.ts +48 -0
  62. package/src/tools/webFetch.js +183 -68
  63. package/src/tools/webFetch.ts +185 -0
  64. package/src/tools/webSearch.js +50 -75
  65. package/src/tools/webSearch.ts +56 -0
  66. package/src/tui/ask.js +41 -1
  67. package/src/tui/chatRenderer.js +28 -0
  68. package/src/tui/dialogs.js +91 -0
  69. package/src/tui/prompt.js +32 -3
  70. package/src/tui/toolDisplay.js +0 -1
  71. package/src/types/contracts.js +8 -0
  72. package/src/types/contracts.ts +263 -0
  73. package/src/utils/paths.d.ts +3 -0
  74. package/src/version.d.ts +1 -0
  75. package/src/mcp/jsonRpc.js +0 -165
  76. 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
+