@causyne/agent 1.10.13 → 1.10.17

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 (59) hide show
  1. package/docs/ARCHITECTURE.md +101 -17
  2. package/docs/SECURITY_REVIEW.md +13 -1
  3. package/docs/THIRD_PARTY_NOTICES.md +43 -10
  4. package/package.json +10 -4
  5. package/src/agentLoop.js +25 -3
  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/camofoxRuntime.js +93 -0
  12. package/src/browser/camofoxRuntime.ts +112 -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/commands.js +37 -8
  23. package/src/config.d.ts +2 -0
  24. package/src/hooks.js +121 -107
  25. package/src/hooks.ts +193 -0
  26. package/src/mcp/mcpClient.js +520 -132
  27. package/src/mcp/mcpClient.ts +584 -0
  28. package/src/mcp/mcpRegistry.js +399 -185
  29. package/src/mcp/mcpRegistry.ts +451 -0
  30. package/src/planCache.js +473 -450
  31. package/src/planCache.ts +840 -0
  32. package/src/plugins/pluginManager.js +40 -5
  33. package/src/plugins/pluginSkills.js +84 -0
  34. package/src/plugins/pluginSkills.ts +106 -0
  35. package/src/provisioning/capabilityProvisioner.d.ts +10 -0
  36. package/src/provisioning/capabilityProvisioner.js +5 -0
  37. package/src/provisioning/capabilityTools.js +2 -1
  38. package/src/sandbox.js +32 -2
  39. package/src/sessionJournal.js +471 -0
  40. package/src/sessionJournal.ts +496 -0
  41. package/src/sessionStore.js +3 -279
  42. package/src/skillsManager.js +18 -0
  43. package/src/subagentJournal.js +215 -0
  44. package/src/subagentJournal.ts +254 -0
  45. package/src/taskShape.js +179 -198
  46. package/src/taskShape.ts +259 -0
  47. package/src/tools/browserAutomator.js +45 -241
  48. package/src/tools/browserAutomator.ts +57 -0
  49. package/src/tools/manageLocalAiDependencies.js +1 -1
  50. package/src/tools/subagentOrchestrator.js +73 -12
  51. package/src/tools/webFetch.js +183 -68
  52. package/src/tools/webFetch.ts +185 -0
  53. package/src/tools/webSearch.js +50 -75
  54. package/src/tools/webSearch.ts +56 -0
  55. package/src/types/contracts.js +8 -0
  56. package/src/types/contracts.ts +263 -0
  57. package/src/utils/paths.d.ts +3 -0
  58. package/src/version.d.ts +1 -0
  59. package/src/mcp/jsonRpc.js +0 -165
package/src/commands.js CHANGED
@@ -20,7 +20,10 @@ import {
20
20
  } from './userSkills.js';
21
21
  import { buildContextText, buildUsageText, buildCheckpointsText, buildUndoText, buildStatusText } from './reporting.js';
22
22
  import { loadConfig, saveConfig } from './config.js';
23
- import { serverStatus, grantServer, revokeServer, addServer, removeServer } from './mcp/mcpRegistry.js';
23
+ import {
24
+ serverStatus, grantServer, revokeServer, addServer, removeServer,
25
+ connectServer, disconnectServer, refreshServer
26
+ } from './mcp/mcpRegistry.js';
24
27
  import { analyzeFiles, formatFindings } from './slopDetector.js';
25
28
  import { suggestMovements, formatMovementBrief, DESIGN_MOVEMENTS } from './designPalettes.js';
26
29
  import fs from 'fs';
@@ -657,10 +660,10 @@ export const COMMANDS = [
657
660
  {
658
661
  name: 'mcp',
659
662
  scope: 'local',
660
- usage: '/mcp [add <name> <cmd…> | add <name> --url <url> | remove|grant|revoke <name>]',
661
- description: 'Manage MCP servers: list, add, remove, grant, revoke',
663
+ usage: '/mcp [add <name> <cmd…> | add <name> --url <url> | remove|grant|revoke|connect|disconnect|test|refresh <name>]',
664
+ description: 'Manage and diagnose MCP server connections',
662
665
  dialogTitle: 'MCP servers',
663
- run: (argv) => {
666
+ run: async (argv) => {
664
667
  const [action, serverName, ...rest] = argv;
665
668
 
666
669
  if (action === 'add') {
@@ -702,15 +705,40 @@ export const COMMANDS = [
702
705
  const known = serverStatus().some(s => s.name === serverName);
703
706
  if (!known) return fail(`No MCP server named "${serverName}" is configured.`);
704
707
  if (action === 'grant') {
705
- grantServer(serverName);
708
+ const granted = grantServer(serverName);
709
+ if (!granted.ok) return fail(granted.error);
706
710
  return ok(`Granted "${serverName}". Its tools enter the catalogue on the next task.`);
707
711
  }
708
712
  revokeServer(serverName);
709
713
  return ok(`Revoked "${serverName}". Its tools are no longer callable.`);
710
714
  }
711
715
 
716
+ if (['connect', 'test', 'refresh', 'disconnect'].includes(action)) {
717
+ if (!serverName) return fail(`Usage: /mcp ${action} <server>`);
718
+ const known = serverStatus().some(server => server.name === serverName);
719
+ if (!known) return fail(`No MCP server named "${serverName}" is configured.`);
720
+
721
+ if (action === 'disconnect') {
722
+ await disconnectServer(serverName);
723
+ return ok(`Disconnected "${serverName}". Its grant remains in place.`);
724
+ }
725
+
726
+ if (action === 'refresh') {
727
+ const tools = await refreshServer(serverName);
728
+ return ok(`Refreshed "${serverName}": ${tools.length} tool(s) available${tools.length ? ` — ${tools.map(tool => tool.name).join(', ')}` : ''}.`);
729
+ }
730
+
731
+ const client = await connectServer(serverName);
732
+ return ok(
733
+ `${action === 'test' ? 'MCP test passed' : 'Connected'}: "${serverName}"\n`
734
+ + `Transport: ${client.status.transport || 'unknown'}\n`
735
+ + `Server: ${client.serverInfo ? `${client.serverInfo.name} ${client.serverInfo.version}` : 'identity not provided'}\n`
736
+ + `Tools: ${client.tools.length}${client.tools.length ? ` — ${client.tools.map(tool => tool.name).join(', ')}` : ''}`
737
+ );
738
+ }
739
+
712
740
  if (action) {
713
- return fail(`Unknown mcp action "${action}". Try: add, remove, grant, revoke, or mcp on its own to list.`);
741
+ return fail(`Unknown mcp action "${action}". Try: add, remove, grant, revoke, connect, disconnect, test, refresh, or mcp on its own to list.`);
714
742
  }
715
743
 
716
744
  const servers = serverStatus();
@@ -719,12 +747,13 @@ export const COMMANDS = [
719
747
  }
720
748
  const lines = ['MCP servers', ''];
721
749
  for (const server of servers) {
722
- lines.push(` ${server.name} [${server.transport}] ${server.granted ? 'granted' : 'NOT granted'}${server.connected ? ', connected' : ''}`);
750
+ lines.push(` ${server.name} [${server.transport}] ${server.granted ? 'granted' : 'NOT granted'} ${server.status}`);
723
751
  lines.push(` ${server.target}`);
724
752
  if (server.tools.length > 0) lines.push(` tools: ${server.tools.join(', ')}`);
753
+ if (server.error) lines.push(` error: ${server.error}`);
725
754
  }
726
755
  lines.push('');
727
- lines.push(' mcp grant <name> to enable, mcp remove <name> to delete.');
756
+ lines.push(' mcp grant <name> to enable, mcp test <name> to diagnose, mcp remove <name> to delete.');
728
757
  return ok(lines.join('\n'), { effect: 'pick-mcp' });
729
758
  }
730
759
  },
@@ -0,0 +1,2 @@
1
+ export function loadConfig(): Record<string, unknown>;
2
+ export function saveConfig(config: Record<string, unknown>): void;
package/src/hooks.js CHANGED
@@ -1,95 +1,108 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import path from 'node:path';
3
-
4
3
  const MAX_HOOKS_PER_EVENT = 32;
5
4
  const MAX_OUTPUT_BYTES = 32 * 1024;
6
5
  const DEFAULT_TIMEOUT_MS = 5_000;
7
-
8
6
  function safeObject(value) {
9
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
8
+ ? value
9
+ : {};
10
10
  }
11
-
12
11
  function matchesTool(rule, toolName) {
13
- const tools = Array.isArray(rule.tools)
14
- ? rule.tools
15
- : [rule.tool == null ? '*' : rule.tool];
16
- return tools.some((candidate) => candidate === '*' || candidate === toolName);
12
+ const tools = Array.isArray(rule.tools)
13
+ ? rule.tools
14
+ : [rule.tool == null ? '*' : rule.tool];
15
+ return tools.some((candidate) => candidate === '*' || candidate === toolName);
17
16
  }
18
-
19
17
  function normalizeRule(rule, event, toolName) {
20
- if (!rule || rule.enabled === false || rule.event !== event || !matchesTool(rule, toolName)) return null;
21
- if (!Array.isArray(rule.command) || rule.command.length === 0) return null;
22
- const command = rule.command.map((part) => String(part));
23
- if (!command[0] || command.length > 64 || command.some((part) => part.length > 4096)) return null;
24
- return {
25
- name: String(rule.name || `${event}:${toolName}`).slice(0, 128),
26
- command,
27
- timeoutMs: Number.isInteger(rule.timeoutMs)
28
- ? Math.max(100, Math.min(rule.timeoutMs, 10_000))
29
- : DEFAULT_TIMEOUT_MS
30
- };
18
+ if (rule === null || typeof rule !== 'object' || Array.isArray(rule))
19
+ return null;
20
+ const candidate = rule;
21
+ if (candidate.enabled === false || candidate.event !== event || !matchesTool(candidate, toolName))
22
+ return null;
23
+ if (!Array.isArray(candidate.command) || candidate.command.length === 0)
24
+ return null;
25
+ const command = candidate.command.map((part) => String(part));
26
+ const executable = command[0];
27
+ if (!executable || command.length > 64 || command.some((part) => part.length > 4096))
28
+ return null;
29
+ return {
30
+ name: String(candidate.name || `${event}:${toolName}`).slice(0, 128),
31
+ command: [executable, ...command.slice(1)],
32
+ timeoutMs: Number.isInteger(candidate.timeoutMs)
33
+ ? Math.max(100, Math.min(candidate.timeoutMs, 10_000))
34
+ : DEFAULT_TIMEOUT_MS
35
+ };
31
36
  }
32
-
33
37
  export function hasMatchingHook(hooks, event, toolName) {
34
- return (Array.isArray(hooks) ? hooks : [])
35
- .some((rule) => normalizeRule(rule, event, toolName) !== null);
38
+ return (Array.isArray(hooks) ? hooks : [])
39
+ .some((rule) => normalizeRule(rule, event, toolName) !== null);
36
40
  }
37
-
38
41
  function runOneHook(rule, payload, cwd) {
39
- return new Promise((resolve) => {
40
- const child = spawn(rule.command[0], rule.command.slice(1), {
41
- cwd,
42
- shell: false,
43
- stdio: ['pipe', 'pipe', 'pipe'],
44
- env: {
45
- PATH: process.env.PATH || '',
46
- LANG: process.env.LANG || 'C.UTF-8',
47
- TMPDIR: process.env.TMPDIR || ''
48
- }
42
+ return new Promise((resolve) => {
43
+ const child = spawn(rule.command[0], rule.command.slice(1), {
44
+ cwd,
45
+ shell: false,
46
+ stdio: ['pipe', 'pipe', 'pipe'],
47
+ env: {
48
+ PATH: process.env.PATH || '',
49
+ LANG: process.env.LANG || 'C.UTF-8',
50
+ TMPDIR: process.env.TMPDIR || ''
51
+ }
52
+ });
53
+ let stdout = '';
54
+ let stderr = '';
55
+ let settled = false;
56
+ const finish = (value) => {
57
+ if (settled)
58
+ return;
59
+ settled = true;
60
+ clearTimeout(timer);
61
+ resolve(value);
62
+ };
63
+ const timer = setTimeout(() => {
64
+ child.kill('SIGTERM');
65
+ finish({ exitCode: null, timedOut: true, stdout, stderr });
66
+ }, rule.timeoutMs);
67
+ timer.unref();
68
+ const collect = (target, chunk) => {
69
+ const next = target + chunk.toString();
70
+ if (Buffer.byteLength(next, 'utf8') > MAX_OUTPUT_BYTES) {
71
+ child.kill('SIGTERM');
72
+ finish({ exitCode: null, overflow: true, stdout, stderr });
73
+ return target;
74
+ }
75
+ return next;
76
+ };
77
+ child.stdout.on('data', (chunk) => { stdout = collect(stdout, chunk); });
78
+ child.stderr.on('data', (chunk) => { stderr = collect(stderr, chunk); });
79
+ child.on('error', (error) => finish({ exitCode: null, spawnError: error.message, stdout, stderr }));
80
+ child.on('close', (code) => finish({ exitCode: code, stdout, stderr }));
81
+ child.stdin.end(JSON.stringify(payload));
49
82
  });
50
- let stdout = '';
51
- let stderr = '';
52
- let settled = false;
53
-
54
- const finish = (value) => {
55
- if (settled) return;
56
- settled = true;
57
- clearTimeout(timer);
58
- resolve(value);
59
- };
60
- const timer = setTimeout(() => {
61
- child.kill('SIGTERM');
62
- finish({ exitCode: null, timedOut: true, stdout, stderr });
63
- }, rule.timeoutMs);
64
- if (timer.unref) timer.unref();
65
-
66
- const collect = (target, chunk) => {
67
- const next = target + chunk;
68
- if (Buffer.byteLength(next, 'utf8') > MAX_OUTPUT_BYTES) {
69
- child.kill('SIGTERM');
70
- finish({ exitCode: null, overflow: true, stdout, stderr });
71
- return target;
72
- }
73
- return next;
74
- };
75
- child.stdout.on('data', (chunk) => { stdout = collect(stdout, chunk); });
76
- child.stderr.on('data', (chunk) => { stderr = collect(stderr, chunk); });
77
- child.on('error', (error) => finish({ exitCode: null, spawnError: error.message, stdout, stderr }));
78
- child.on('close', (code) => finish({ exitCode: code, stdout, stderr }));
79
- child.stdin.end(JSON.stringify(payload));
80
- });
81
83
  }
82
-
83
84
  function feedbackFor(rule, result) {
84
- const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
85
- if (output) return `${rule.name}: ${output}`.slice(0, MAX_OUTPUT_BYTES);
86
- if (result.timedOut) return `${rule.name}: timed out after ${rule.timeoutMs} ms`;
87
- if (result.overflow) return `${rule.name}: output exceeded ${MAX_OUTPUT_BYTES} bytes`;
88
- if (result.spawnError) return `${rule.name}: ${result.spawnError}`;
89
- if (result.exitCode !== 0) return `${rule.name}: exited with code ${result.exitCode}`;
90
- return '';
85
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
86
+ if (output)
87
+ return `${rule.name}: ${output}`.slice(0, MAX_OUTPUT_BYTES);
88
+ if (result.timedOut)
89
+ return `${rule.name}: timed out after ${rule.timeoutMs} ms`;
90
+ if (result.overflow)
91
+ return `${rule.name}: output exceeded ${MAX_OUTPUT_BYTES} bytes`;
92
+ if (result.spawnError)
93
+ return `${rule.name}: ${result.spawnError}`;
94
+ if (result.exitCode !== 0)
95
+ return `${rule.name}: exited with code ${result.exitCode}`;
96
+ return '';
97
+ }
98
+ function resolveWorkspaceRoots(workspacePaths) {
99
+ if (!Array.isArray(workspacePaths))
100
+ return [process.cwd()];
101
+ const roots = workspacePaths
102
+ .filter((value) => typeof value === 'string' && value.length > 0)
103
+ .map((value) => path.resolve(value));
104
+ return roots.length > 0 ? roots : [process.cwd()];
91
105
  }
92
-
93
106
  /**
94
107
  * Run user-owned lifecycle hooks from ~/.causyne/config.json.
95
108
  *
@@ -98,39 +111,40 @@ function feedbackFor(rule, result) {
98
111
  * during startup before the user has trusted the workspace.
99
112
  */
100
113
  export async function runConfiguredHooks({ hooks, event, toolName, args, result, taskId, toolCallId, workspacePaths }) {
101
- const source = Array.isArray(hooks) ? hooks : [];
102
- const rules = source
103
- .map((rule) => normalizeRule(rule, event, toolName))
104
- .filter(Boolean)
105
- .slice(0, MAX_HOOKS_PER_EVENT);
106
- if (rules.length === 0) return { denied: false, messages: [] };
107
-
108
- const roots = Array.isArray(workspacePaths) && workspacePaths.length > 0
109
- ? workspacePaths.map((value) => path.resolve(value))
110
- : [process.cwd()];
111
- const payload = {
112
- event,
113
- tool: toolName,
114
- args: safeObject(args),
115
- result: result && typeof result === 'object' ? result : undefined,
116
- taskId: String(taskId || ''),
117
- toolCallId: String(toolCallId || ''),
118
- workspacePaths: roots
119
- };
120
- const messages = [];
121
-
122
- for (const rule of rules) {
123
- const hookResult = await runOneHook(rule, payload, roots[0]);
124
- const feedback = feedbackFor(rule, hookResult);
125
- if (feedback) messages.push(feedback);
126
-
127
- if (hookResult.exitCode === 2) return { denied: true, messages, reason: feedback };
128
- if (event === 'PreToolUse' && hookResult.exitCode !== 0) {
129
- return { denied: true, messages, reason: feedback || `${rule.name} failed closed` };
114
+ const source = Array.isArray(hooks) ? hooks : [];
115
+ const rules = source
116
+ .map((rule) => normalizeRule(rule, event, toolName))
117
+ .filter((rule) => rule !== null)
118
+ .slice(0, MAX_HOOKS_PER_EVENT);
119
+ if (rules.length === 0)
120
+ return { denied: false, messages: [] };
121
+ const roots = resolveWorkspaceRoots(workspacePaths);
122
+ const primaryRoot = roots[0] ?? process.cwd();
123
+ const payload = {
124
+ event,
125
+ tool: toolName,
126
+ args: safeObject(args),
127
+ taskId: String(taskId || ''),
128
+ toolCallId: String(toolCallId || ''),
129
+ workspacePaths: roots
130
+ };
131
+ if (result !== null && typeof result === 'object')
132
+ payload.result = result;
133
+ const messages = [];
134
+ for (const rule of rules) {
135
+ const hookResult = await runOneHook(rule, payload, primaryRoot);
136
+ const feedback = feedbackFor(rule, hookResult);
137
+ if (feedback)
138
+ messages.push(feedback);
139
+ if (hookResult.exitCode === 2) {
140
+ return feedback
141
+ ? { denied: true, messages, reason: feedback }
142
+ : { denied: true, messages };
143
+ }
144
+ if (event === 'PreToolUse' && hookResult.exitCode !== 0) {
145
+ return { denied: true, messages, reason: feedback || `${rule.name} failed closed` };
146
+ }
130
147
  }
131
- }
132
-
133
- return { denied: false, messages };
148
+ return { denied: false, messages };
134
149
  }
135
-
136
150
  export { MAX_HOOKS_PER_EVENT, MAX_OUTPUT_BYTES, DEFAULT_TIMEOUT_MS };
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 };