skimpyclaw 0.3.14 → 0.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 (222) hide show
  1. package/README.md +47 -37
  2. package/dist/__tests__/adapter-types.test.d.ts +4 -0
  3. package/dist/__tests__/adapter-types.test.js +63 -0
  4. package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
  5. package/dist/__tests__/anthropic-adapter.test.js +264 -0
  6. package/dist/__tests__/api.test.js +0 -1
  7. package/dist/__tests__/cli.integration.test.js +2 -4
  8. package/dist/__tests__/cli.test.js +0 -1
  9. package/dist/__tests__/code-agents-notifications.test.js +137 -0
  10. package/dist/__tests__/code-agents-parser.test.js +19 -1
  11. package/dist/__tests__/code-agents-preflight.test.js +3 -28
  12. package/dist/__tests__/code-agents-utils.test.js +34 -9
  13. package/dist/__tests__/code-agents-worktrees.test.js +116 -0
  14. package/dist/__tests__/codex-adapter.test.js +184 -0
  15. package/dist/__tests__/codex-auth.test.js +66 -0
  16. package/dist/__tests__/codex-provider-gating.test.js +35 -0
  17. package/dist/__tests__/codex-unified-loop.test.js +111 -0
  18. package/dist/__tests__/config-security.test.js +127 -0
  19. package/dist/__tests__/config.test.js +23 -0
  20. package/dist/__tests__/context-manager.test.js +243 -164
  21. package/dist/__tests__/cron-run.test.js +250 -0
  22. package/dist/__tests__/cron.test.js +12 -38
  23. package/dist/__tests__/digests.test.js +67 -0
  24. package/dist/__tests__/discord-attachments.test.js +211 -0
  25. package/dist/__tests__/discord-docs.test.d.ts +1 -0
  26. package/dist/__tests__/discord-docs.test.js +27 -0
  27. package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
  28. package/dist/__tests__/discord-thread-agents.test.js +115 -0
  29. package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
  30. package/dist/__tests__/discord-thread-context.test.js +42 -0
  31. package/dist/__tests__/doctor.formatters.test.js +4 -4
  32. package/dist/__tests__/doctor.index.test.js +1 -1
  33. package/dist/__tests__/doctor.runner.test.js +3 -15
  34. package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
  35. package/dist/__tests__/env-sanitizer.test.js +45 -0
  36. package/dist/__tests__/exec-approval.test.js +61 -0
  37. package/dist/__tests__/fetch-tool.test.d.ts +1 -0
  38. package/dist/__tests__/fetch-tool.test.js +85 -0
  39. package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
  40. package/dist/__tests__/gateway-status-auth.test.js +72 -0
  41. package/dist/__tests__/heartbeat.test.js +3 -3
  42. package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
  43. package/dist/__tests__/interactive-sessions.test.js +96 -0
  44. package/dist/__tests__/langfuse.test.js +6 -18
  45. package/dist/__tests__/model-selection.test.js +3 -4
  46. package/dist/__tests__/providers-init.test.js +2 -8
  47. package/dist/__tests__/providers-routing.test.js +1 -1
  48. package/dist/__tests__/providers-utils.test.js +13 -3
  49. package/dist/__tests__/sessions.test.js +14 -10
  50. package/dist/__tests__/setup.test.js +12 -29
  51. package/dist/__tests__/skills.test.js +10 -7
  52. package/dist/__tests__/stream-formatter.test.d.ts +1 -0
  53. package/dist/__tests__/stream-formatter.test.js +114 -0
  54. package/dist/__tests__/token-efficiency.test.js +131 -15
  55. package/dist/__tests__/tool-loop.test.d.ts +4 -0
  56. package/dist/__tests__/tool-loop.test.js +505 -0
  57. package/dist/__tests__/tools.test.js +101 -276
  58. package/dist/__tests__/utils.test.d.ts +1 -0
  59. package/dist/__tests__/utils.test.js +14 -0
  60. package/dist/__tests__/voice.test.js +21 -0
  61. package/dist/agent.js +35 -4
  62. package/dist/api.js +113 -37
  63. package/dist/channels/discord/attachments.d.ts +50 -0
  64. package/dist/channels/discord/attachments.js +137 -0
  65. package/dist/channels/discord/delegation.d.ts +5 -0
  66. package/dist/channels/discord/delegation.js +136 -0
  67. package/dist/channels/discord/handlers.js +694 -7
  68. package/dist/channels/discord/index.d.ts +16 -1
  69. package/dist/channels/discord/index.js +64 -1
  70. package/dist/channels/discord/thread-agents.d.ts +54 -0
  71. package/dist/channels/discord/thread-agents.js +323 -0
  72. package/dist/channels/discord/threads.d.ts +58 -0
  73. package/dist/channels/discord/threads.js +192 -0
  74. package/dist/channels/discord/types.js +4 -2
  75. package/dist/channels/discord/utils.d.ts +16 -0
  76. package/dist/channels/discord/utils.js +86 -6
  77. package/dist/channels/telegram/index.d.ts +1 -1
  78. package/dist/channels/telegram/types.js +1 -1
  79. package/dist/channels/telegram/utils.js +9 -3
  80. package/dist/channels.d.ts +1 -1
  81. package/dist/cli.js +20 -400
  82. package/dist/code-agents/executor.d.ts +1 -1
  83. package/dist/code-agents/executor.js +101 -45
  84. package/dist/code-agents/index.d.ts +2 -7
  85. package/dist/code-agents/index.js +111 -80
  86. package/dist/code-agents/interactive-resume.d.ts +6 -0
  87. package/dist/code-agents/interactive-resume.js +98 -0
  88. package/dist/code-agents/interactive-sessions.d.ts +20 -0
  89. package/dist/code-agents/interactive-sessions.js +132 -0
  90. package/dist/code-agents/parser.js +5 -1
  91. package/dist/code-agents/registry.d.ts +7 -1
  92. package/dist/code-agents/registry.js +11 -23
  93. package/dist/code-agents/stream-formatter.d.ts +8 -0
  94. package/dist/code-agents/stream-formatter.js +92 -0
  95. package/dist/code-agents/types.d.ts +16 -24
  96. package/dist/code-agents/utils.d.ts +35 -11
  97. package/dist/code-agents/utils.js +349 -95
  98. package/dist/code-agents/worktrees.d.ts +37 -0
  99. package/dist/code-agents/worktrees.js +116 -0
  100. package/dist/config.d.ts +2 -4
  101. package/dist/config.js +123 -23
  102. package/dist/cron.d.ts +1 -6
  103. package/dist/cron.js +175 -82
  104. package/dist/dashboard/assets/index-B345aOO-.js +65 -0
  105. package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/digests.d.ts +1 -0
  108. package/dist/digests.js +132 -42
  109. package/dist/doctor/checks.d.ts +0 -3
  110. package/dist/doctor/checks.js +1 -108
  111. package/dist/doctor/runner.js +1 -4
  112. package/dist/env-sanitizer.d.ts +2 -0
  113. package/dist/env-sanitizer.js +61 -0
  114. package/dist/exec-approval.d.ts +11 -1
  115. package/dist/exec-approval.js +17 -4
  116. package/dist/gateway.d.ts +3 -1
  117. package/dist/gateway.js +17 -7
  118. package/dist/heartbeat.js +1 -6
  119. package/dist/langfuse.js +3 -29
  120. package/dist/model-selection.js +3 -1
  121. package/dist/providers/adapter.d.ts +118 -0
  122. package/dist/providers/adapter.js +6 -0
  123. package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
  124. package/dist/providers/adapters/anthropic-adapter.js +204 -0
  125. package/dist/providers/adapters/codex-adapter.d.ts +26 -0
  126. package/dist/providers/adapters/codex-adapter.js +203 -0
  127. package/dist/providers/anthropic.d.ts +1 -0
  128. package/dist/providers/anthropic.js +10 -272
  129. package/dist/providers/codex.d.ts +21 -0
  130. package/dist/providers/codex.js +149 -330
  131. package/dist/providers/content.d.ts +1 -1
  132. package/dist/providers/content.js +2 -2
  133. package/dist/providers/context-manager.d.ts +18 -6
  134. package/dist/providers/context-manager.js +199 -223
  135. package/dist/providers/index.d.ts +9 -1
  136. package/dist/providers/index.js +73 -64
  137. package/dist/providers/loop-utils.d.ts +20 -0
  138. package/dist/providers/loop-utils.js +30 -0
  139. package/dist/providers/tool-loop.d.ts +12 -0
  140. package/dist/providers/tool-loop.js +251 -0
  141. package/dist/providers/utils.d.ts +19 -3
  142. package/dist/providers/utils.js +100 -29
  143. package/dist/secure-store.d.ts +8 -0
  144. package/dist/secure-store.js +80 -0
  145. package/dist/service.js +3 -28
  146. package/dist/sessions.d.ts +3 -0
  147. package/dist/sessions.js +147 -18
  148. package/dist/setup-templates.js +13 -25
  149. package/dist/setup.d.ts +10 -6
  150. package/dist/setup.js +84 -292
  151. package/dist/skills.js +3 -11
  152. package/dist/tools/agent-delegation.d.ts +19 -0
  153. package/dist/tools/agent-delegation.js +49 -0
  154. package/dist/tools/bash-tool.js +89 -34
  155. package/dist/tools/definitions.d.ts +199 -302
  156. package/dist/tools/definitions.js +70 -123
  157. package/dist/tools/execute-context.d.ts +13 -4
  158. package/dist/tools/fetch-tool.js +109 -13
  159. package/dist/tools/file-tools.js +7 -1
  160. package/dist/tools.d.ts +7 -7
  161. package/dist/tools.js +133 -151
  162. package/dist/types.d.ts +37 -30
  163. package/dist/utils.js +4 -6
  164. package/dist/voice.d.ts +1 -1
  165. package/dist/voice.js +17 -4
  166. package/package.json +33 -23
  167. package/templates/TOOLS.md +0 -27
  168. package/dist/__tests__/audit.test.js +0 -122
  169. package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
  170. package/dist/__tests__/code-agents-sandbox.test.js +0 -163
  171. package/dist/__tests__/orchestrator.test.js +0 -425
  172. package/dist/__tests__/sandbox-bridge.test.js +0 -116
  173. package/dist/__tests__/sandbox-manager.test.js +0 -144
  174. package/dist/__tests__/sandbox-mount-security.test.js +0 -139
  175. package/dist/__tests__/sandbox-runtime.test.js +0 -176
  176. package/dist/__tests__/subagent.test.js +0 -240
  177. package/dist/__tests__/telegram.test.js +0 -42
  178. package/dist/code-agents/orchestrator.d.ts +0 -29
  179. package/dist/code-agents/orchestrator.js +0 -694
  180. package/dist/code-agents/worktree.d.ts +0 -40
  181. package/dist/code-agents/worktree.js +0 -215
  182. package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
  183. package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
  184. package/dist/dashboard.d.ts +0 -8
  185. package/dist/dashboard.js +0 -4071
  186. package/dist/discord.d.ts +0 -8
  187. package/dist/discord.js +0 -792
  188. package/dist/mcp-context-a8c.d.ts +0 -13
  189. package/dist/mcp-context-a8c.js +0 -34
  190. package/dist/orchestrator.d.ts +0 -15
  191. package/dist/orchestrator.js +0 -676
  192. package/dist/providers/openai.d.ts +0 -10
  193. package/dist/providers/openai.js +0 -355
  194. package/dist/sandbox/bridge.d.ts +0 -5
  195. package/dist/sandbox/bridge.js +0 -63
  196. package/dist/sandbox/index.d.ts +0 -5
  197. package/dist/sandbox/index.js +0 -4
  198. package/dist/sandbox/manager.d.ts +0 -7
  199. package/dist/sandbox/manager.js +0 -100
  200. package/dist/sandbox/mount-security.d.ts +0 -12
  201. package/dist/sandbox/mount-security.js +0 -122
  202. package/dist/sandbox/runtime.d.ts +0 -39
  203. package/dist/sandbox/runtime.js +0 -192
  204. package/dist/sandbox-utils.d.ts +0 -6
  205. package/dist/sandbox-utils.js +0 -36
  206. package/dist/subagent.d.ts +0 -19
  207. package/dist/subagent.js +0 -407
  208. package/dist/telegram.d.ts +0 -2
  209. package/dist/telegram.js +0 -11
  210. package/dist/tools/browser-tool.d.ts +0 -3
  211. package/dist/tools/browser-tool.js +0 -266
  212. package/sandbox/Dockerfile +0 -40
  213. /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
  214. /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
  215. /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
  216. /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
  217. /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
  218. /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
  219. /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
  220. /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
  221. /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { existsSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs';
3
3
  import { join } from 'path';
4
- import { executeTool, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, getToolDefinitions, fromClaudeCodeName, toClaudeCodeName, buildCodeAgentArgs, getActiveCodeAgents, getRecentCodeAgents, getCodeAgent, readTeamState, decomposeTask, synthesizeResults, computeWaves } from '../tools.js';
4
+ import { executeTool, BUILTIN_TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CHECK_CODE_AGENT_TOOL, DELEGATE_TO_AGENT_TOOL, getToolDefinitions, fromClaudeCodeName, toClaudeCodeName, buildCodeAgentArgs, getActiveCodeAgents, getRecentCodeAgents, getCodeAgent, normalizeMcpToolArgsForExecution } from '../tools.js';
5
+ import { registerDelegateToAgentHandler } from '../tools/agent-delegation.js';
5
6
  import { resolveModelAlias } from '../code-agents/utils.js';
6
7
  const TEST_DIR = join(process.cwd(), '__test_sandbox__');
7
8
  const OUTSIDE_DIR = join(process.cwd(), '__test_outside__');
@@ -30,14 +31,10 @@ describe('BUILTIN_TOOL_DEFINITIONS', () => {
30
31
  expect(names).toContain('Glob');
31
32
  expect(names).toContain('Bash');
32
33
  });
33
- it('exports Browser tool definition separately', () => {
34
- expect(BROWSER_TOOL_DEFINITION.name).toBe('Browser');
35
- expect(BROWSER_TOOL_DEFINITION.input_schema).toBeDefined();
36
- });
37
34
  });
38
35
  describe('getToolDefinitions', () => {
39
36
  it('returns at least the 4 built-in tools', async () => {
40
- const tools = await getToolDefinitions();
37
+ const tools = await getToolDefinitions(undefined, { includeMcp: false });
41
38
  expect(tools.length).toBeGreaterThanOrEqual(4);
42
39
  const names = tools.map(t => t.name);
43
40
  expect(names).toContain('Read');
@@ -45,20 +42,6 @@ describe('getToolDefinitions', () => {
45
42
  expect(names).toContain('Glob');
46
43
  expect(names).toContain('Bash');
47
44
  }, 15000);
48
- it('includes Browser when browser.enabled is true', async () => {
49
- const config = { ...toolConfig, browser: { enabled: true } };
50
- const tools = await getToolDefinitions(config);
51
- expect(tools.map(t => t.name)).toContain('Browser');
52
- });
53
- it('excludes Browser when browser.enabled is false', async () => {
54
- const config = { ...toolConfig, browser: { enabled: false } };
55
- const tools = await getToolDefinitions(config);
56
- expect(tools.map(t => t.name)).not.toContain('Browser');
57
- });
58
- it('excludes Browser when no config provided', async () => {
59
- const tools = await getToolDefinitions();
60
- expect(tools.map(t => t.name)).not.toContain('Browser');
61
- });
62
45
  describe('tool profiles', () => {
63
46
  it('minimal returns built-in tools plus Fetch', async () => {
64
47
  const config = { ...toolConfig, toolProfile: 'minimal' };
@@ -66,23 +49,17 @@ describe('getToolDefinitions', () => {
66
49
  expect(tools).toHaveLength(5);
67
50
  expect(tools.map(t => t.name)).toEqual(['Read', 'Write', 'Glob', 'Bash', 'Fetch']);
68
51
  });
69
- it('minimal excludes Browser even when browser.enabled is true', async () => {
70
- const config = { ...toolConfig, toolProfile: 'minimal', browser: { enabled: true } };
71
- const tools = await getToolDefinitions(config);
72
- expect(tools.map(t => t.name)).not.toContain('Browser');
73
- });
74
52
  it('minimal excludes MCP tools', async () => {
75
53
  const config = { ...toolConfig, toolProfile: 'minimal' };
76
54
  const tools = await getToolDefinitions(config, { includeMcp: true });
77
55
  expect(tools.every(t => !t.name.startsWith('mcp__'))).toBe(true);
78
56
  });
79
- it('coding includes code_with_agent and check_code_agent but not code_with_team', async () => {
57
+ it('coding includes code_with_agent and check_code_agent', async () => {
80
58
  const config = { ...toolConfig, toolProfile: 'coding' };
81
59
  const tools = await getToolDefinitions(config, { includeAgentTools: true });
82
60
  const names = tools.map(t => t.name);
83
61
  expect(names).toContain('code_with_agent');
84
62
  expect(names).toContain('check_code_agent');
85
- expect(names).not.toContain('code_with_team');
86
63
  });
87
64
  it('coding excludes MCP tools', async () => {
88
65
  const config = { ...toolConfig, toolProfile: 'coding' };
@@ -90,8 +67,8 @@ describe('getToolDefinitions', () => {
90
67
  expect(tools.every(t => !t.name.startsWith('mcp__'))).toBe(true);
91
68
  });
92
69
  it('full profile behaves like default (no profile set)', async () => {
93
- const defaultTools = await getToolDefinitions(toolConfig);
94
- const fullTools = await getToolDefinitions({ ...toolConfig, toolProfile: 'full' });
70
+ const defaultTools = await getToolDefinitions(toolConfig, { includeMcp: false });
71
+ const fullTools = await getToolDefinitions({ ...toolConfig, toolProfile: 'full' }, { includeMcp: false });
95
72
  expect(fullTools.map(t => t.name)).toEqual(defaultTools.map(t => t.name));
96
73
  }, 15000);
97
74
  });
@@ -132,6 +109,27 @@ describe('MCP tool name parsing', () => {
132
109
  expect(result).toContain('Error:');
133
110
  expect(result).not.toContain('Unknown tool');
134
111
  });
112
+ it('normalizes context-a8c execute-tool parameter aliases', () => {
113
+ expect(normalizeMcpToolArgsForExecution('context-a8c', 'context-a8c-execute-tool', {
114
+ provider: 'zendesk',
115
+ tool: 'search',
116
+ parameters: { query: 'jetpack search' },
117
+ })).toEqual({
118
+ provider: 'zendesk',
119
+ tool: 'search',
120
+ params: { query: 'jetpack search' },
121
+ });
122
+ expect(normalizeMcpToolArgsForExecution('context-a8c', 'context-a8c-execute-tool', {
123
+ provider: 'slack',
124
+ tool: 'search',
125
+ query: 'jetpack search',
126
+ limit: 10,
127
+ })).toEqual({
128
+ provider: 'slack',
129
+ tool: 'search',
130
+ params: { query: 'jetpack search', limit: 10 },
131
+ });
132
+ });
135
133
  });
136
134
  describe('read_file', () => {
137
135
  it('reads a file within allowed paths', async () => {
@@ -164,13 +162,13 @@ describe('write_file', () => {
164
162
  it('writes a new file', async () => {
165
163
  const path = join(TEST_DIR, 'new.txt');
166
164
  const result = await executeTool('Write', { path, content: 'new content' }, toolConfig);
167
- expect(result).toContain('Written');
165
+ expect(result).toBe('OK');
168
166
  expect(readFileSync(path, 'utf-8')).toBe('new content');
169
167
  });
170
168
  it('creates parent directories', async () => {
171
169
  const path = join(TEST_DIR, 'sub', 'deep', 'file.txt');
172
170
  const result = await executeTool('Write', { path, content: 'deep' }, toolConfig);
173
- expect(result).toContain('Written');
171
+ expect(result).toBe('OK');
174
172
  expect(readFileSync(path, 'utf-8')).toBe('deep');
175
173
  });
176
174
  it('rejects writes outside allowed paths', async () => {
@@ -199,6 +197,14 @@ describe('bash', () => {
199
197
  const result = await executeTool('Bash', { command: 'echo hello' }, toolConfig);
200
198
  expect(result.trim()).toBe('hello');
201
199
  });
200
+ it('blocks shell command chaining to prevent injection patterns', async () => {
201
+ const result = await executeTool('Bash', { command: 'echo hello; uname -a' }, toolConfig);
202
+ expect(result).toContain('Shell control operators are blocked');
203
+ });
204
+ it('blocks shell pipe operators to avoid implicit shell mode', async () => {
205
+ const result = await executeTool('Bash', { command: 'echo hello | cat' }, toolConfig);
206
+ expect(result).toContain('Shell control operators are blocked');
207
+ });
202
208
  it('blocks dangerous commands via exec approval', async () => {
203
209
  const result = await executeTool('Bash', { command: `rm -rf ${TEST_DIR}` }, toolConfig);
204
210
  expect(result).toContain('⛔');
@@ -257,83 +263,6 @@ describe('bash', () => {
257
263
  });
258
264
  });
259
265
  });
260
- describe('browser', () => {
261
- const browserDisabledConfig = {
262
- ...toolConfig,
263
- browser: { enabled: false },
264
- };
265
- const browserEnabledConfig = {
266
- ...toolConfig,
267
- browser: { enabled: true },
268
- };
269
- const browserWithFileConfig = {
270
- ...toolConfig,
271
- browser: { enabled: true, allowFile: true },
272
- };
273
- it('returns error when browser is disabled', async () => {
274
- const result = await executeTool('Browser', { action: 'open', url: 'https://example.com' }, browserDisabledConfig);
275
- expect(result).toContain('Error: Browser tool is disabled');
276
- });
277
- it('returns error when browser config is missing', async () => {
278
- const result = await executeTool('Browser', { action: 'open', url: 'https://example.com' }, toolConfig);
279
- expect(result).toContain('Error: Browser tool is disabled');
280
- });
281
- it('blocks file:// URLs when allowFile is false', async () => {
282
- const result = await executeTool('Browser', { action: 'open', url: 'file:///etc/passwd' }, browserEnabledConfig);
283
- expect(result).toContain('Error: file:// URLs are blocked');
284
- });
285
- it('validates file:// URLs with new URL() parsing', async () => {
286
- // file://localhost/etc/passwd should parse to /etc/passwd, which is outside allowedPaths
287
- const result = await executeTool('Browser', { action: 'open', url: 'file://localhost/etc/passwd' }, browserWithFileConfig);
288
- expect(result).toContain('Error: file:// URLs are blocked');
289
- });
290
- it('returns error for unknown action', async () => {
291
- const result = await executeTool('Browser', { action: 'destroy' }, browserEnabledConfig);
292
- expect(result).toContain('Error: Unknown browser action "destroy"');
293
- });
294
- it('requires url for open action', async () => {
295
- const result = await executeTool('Browser', { action: 'open' }, browserEnabledConfig);
296
- expect(result).toContain('Error: url is required');
297
- });
298
- it('requires selector for click action', async () => {
299
- const result = await executeTool('Browser', { action: 'click' }, browserEnabledConfig);
300
- expect(result).toContain('Error: Browser not open');
301
- });
302
- it('requires selector and text for type action', async () => {
303
- const result = await executeTool('Browser', { action: 'type', selector: '#input' }, browserEnabledConfig);
304
- expect(result).toContain('Error: Browser not open');
305
- });
306
- it('requires script for evaluate action', async () => {
307
- const result = await executeTool('Browser', { action: 'evaluate' }, browserEnabledConfig);
308
- expect(result).toContain('Error: Browser not open');
309
- });
310
- it('requires selector for hover action', async () => {
311
- const result = await executeTool('Browser', { action: 'hover' }, browserEnabledConfig);
312
- expect(result).toContain('Error: Browser not open');
313
- });
314
- it('requires selector and text for select action', async () => {
315
- const result = await executeTool('Browser', { action: 'select' }, browserEnabledConfig);
316
- expect(result).toContain('Error: Browser not open');
317
- });
318
- it('returns "Browser not open" for actions before open', async () => {
319
- for (const action of ['click', 'type', 'waitfor', 'screenshot', 'evaluate', 'gettext', 'scroll', 'select', 'hover']) {
320
- const result = await executeTool('Browser', { action }, browserEnabledConfig);
321
- expect(result).toContain('Error: Browser not open');
322
- }
323
- });
324
- it('handles close when browser is not open', async () => {
325
- const result = await executeTool('Browser', { action: 'close' }, browserEnabledConfig);
326
- expect(result).toBe('Browser closed.');
327
- });
328
- it('handles case-insensitive actions', async () => {
329
- const result = await executeTool('Browser', { action: 'OPEN' }, browserEnabledConfig);
330
- expect(result).toContain('Error: url is required');
331
- });
332
- it('maps Browser name correctly', () => {
333
- expect(fromClaudeCodeName('Browser')).toBe('browser');
334
- expect(toClaudeCodeName('browser')).toBe('Browser');
335
- });
336
- });
337
266
  describe('code_with_agent', () => {
338
267
  describe('tool definition', () => {
339
268
  it('has correct name and required fields', () => {
@@ -344,17 +273,15 @@ describe('code_with_agent', () => {
344
273
  const props = Object.keys(CODE_WITH_AGENT_TOOL.input_schema.properties);
345
274
  expect(props).toContain('task');
346
275
  expect(props).toContain('agent');
347
- expect(props).toContain('workdir');
348
- expect(props).toContain('model');
349
- expect(props).toContain('max_turns');
350
- expect(props).toContain('validate');
276
+ expect(props).toContain('worktree');
277
+ // workdir, model, max_turns, validate — omitted from schema to save tokens (executor still accepts them)
351
278
  });
352
279
  it('is included in getToolDefinitions when includeSpawnSubagent is true', async () => {
353
- const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true });
280
+ const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true, includeMcp: false });
354
281
  expect(tools.map(t => t.name)).toContain('code_with_agent');
355
282
  });
356
283
  it('is excluded from getToolDefinitions when includeSpawnSubagent is false', async () => {
357
- const tools = await getToolDefinitions(toolConfig);
284
+ const tools = await getToolDefinitions(toolConfig, { includeMcp: false });
358
285
  expect(tools.map(t => t.name)).not.toContain('code_with_agent');
359
286
  });
360
287
  });
@@ -371,10 +298,10 @@ describe('code_with_agent', () => {
371
298
  expect(args[args.length - 1]).toBe('fix the bug');
372
299
  });
373
300
  it('builds claude args with model override', () => {
374
- const { cmd, args } = buildCodeAgentArgs({ task: 'fix it', model: 'opus' });
301
+ const { cmd, args } = buildCodeAgentArgs({ task: 'fix it', model: 'claude-opus-4-7' });
375
302
  expect(cmd).toContain('claude');
376
303
  expect(args).toContain('--model');
377
- expect(args).toContain('opus');
304
+ expect(args).toContain('claude-opus-4-7');
378
305
  });
379
306
  it('builds claude args with custom max_turns', () => {
380
307
  const { args } = buildCodeAgentArgs({ task: 'fix it', max_turns: 10 });
@@ -386,6 +313,7 @@ describe('code_with_agent', () => {
386
313
  expect(cmd).toContain('codex');
387
314
  expect(args[0]).toBe('exec');
388
315
  expect(args).toContain('--full-auto');
316
+ expect(args).toContain('--skip-git-repo-check');
389
317
  expect(args).toContain('--json');
390
318
  expect(args).toContain('--color');
391
319
  expect(args).toContain('never');
@@ -401,6 +329,11 @@ describe('code_with_agent', () => {
401
329
  expect(args).toContain('-m');
402
330
  expect(args).toContain('gpt-5.3-codex');
403
331
  });
332
+ it('builds codex args with effort override', () => {
333
+ const { args } = buildCodeAgentArgs({ task: 'fix it', agent: 'codex', effort: 'xhigh' });
334
+ expect(args).toContain('-c');
335
+ expect(args).toContain('model_reasoning_effort=xhigh');
336
+ });
404
337
  it('does not include --allowedTools for codex', () => {
405
338
  const { args } = buildCodeAgentArgs({ task: 'fix it', agent: 'codex' });
406
339
  expect(args).not.toContain('--allowedTools');
@@ -438,6 +371,9 @@ describe('code_with_agent', () => {
438
371
  expect(resolveModelAlias('claude-sonnet-4-5', {})).toBe('claude-sonnet-4-5');
439
372
  expect(resolveModelAlias('gpt-4.1', {})).toBe('gpt-4.1');
440
373
  });
374
+ it('normalizes dotted claude opus 4.6 model id', () => {
375
+ expect(resolveModelAlias('anthropic/claude-opus-4.6', {})).toBe('claude-opus-4-6');
376
+ });
441
377
  });
442
378
  describe('executeTool routing', () => {
443
379
  it('rejects workdir outside allowed paths', async () => {
@@ -465,14 +401,61 @@ describe('code_with_agent', () => {
465
401
  expect(CHECK_CODE_AGENT_TOOL.input_schema.properties).toHaveProperty('id');
466
402
  });
467
403
  it('is included in getToolDefinitions when includeSpawnSubagent is true', async () => {
468
- const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true });
404
+ const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true, includeMcp: false });
469
405
  expect(tools.map(t => t.name)).toContain('check_code_agent');
470
406
  });
471
407
  it('is excluded from getToolDefinitions when includeSpawnSubagent is false', async () => {
472
- const tools = await getToolDefinitions(toolConfig);
408
+ const tools = await getToolDefinitions(toolConfig, { includeMcp: false });
473
409
  expect(tools.map(t => t.name)).not.toContain('check_code_agent');
474
410
  });
475
411
  });
412
+ describe('delegate_to_agent tool', () => {
413
+ afterEach(() => {
414
+ registerDelegateToAgentHandler(null);
415
+ });
416
+ it('has expected tool definition', () => {
417
+ expect(DELEGATE_TO_AGENT_TOOL.name).toBe('delegate_to_agent');
418
+ expect(DELEGATE_TO_AGENT_TOOL.input_schema.required).toEqual(['alias', 'task']);
419
+ });
420
+ it('is included in getToolDefinitions when agent tools are enabled', async () => {
421
+ const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true, includeMcp: false });
422
+ expect(tools.map(t => t.name)).toContain('delegate_to_agent');
423
+ });
424
+ it('rejects non-Discord contexts', async () => {
425
+ const result = await executeTool('delegate_to_agent', {
426
+ alias: 'reviewer',
427
+ task: 'review this',
428
+ }, toolConfig, {
429
+ fullConfig: { agents: { default: 'main', list: {} } },
430
+ channel: 'telegram',
431
+ });
432
+ expect(result).toContain('Discord-only');
433
+ });
434
+ it('calls the registered delegation handler for Discord', async () => {
435
+ registerDelegateToAgentHandler(async (input) => `delegated ${input.alias}: ${input.task}`);
436
+ const result = await executeTool('delegate_to_agent', {
437
+ alias: '@Reviewer',
438
+ task: 'review this',
439
+ }, toolConfig, {
440
+ fullConfig: { agents: { default: 'main', list: {} } },
441
+ channel: 'discord',
442
+ channelTargetId: '123',
443
+ });
444
+ expect(result).toBe('delegated reviewer: review this');
445
+ });
446
+ it('blocks self delegation', async () => {
447
+ registerDelegateToAgentHandler(async () => 'should not run');
448
+ const result = await executeTool('delegate_to_agent', {
449
+ alias: 'reviewer',
450
+ task: 'review this',
451
+ }, toolConfig, {
452
+ fullConfig: { agents: { default: 'main', list: {} } },
453
+ channel: 'discord',
454
+ threadAgentAlias: 'reviewer',
455
+ });
456
+ expect(result).toContain('cannot delegate to itself');
457
+ });
458
+ });
476
459
  describe('multi-agent tracking', () => {
477
460
  it('getActiveCodeAgents returns empty array initially', () => {
478
461
  // Active agents are those currently running — initially none
@@ -498,161 +481,3 @@ describe('code_with_agent', () => {
498
481
  });
499
482
  });
500
483
  });
501
- describe('code_with_team', () => {
502
- describe('tool definition', () => {
503
- it('has correct name and required fields', () => {
504
- expect(CODE_WITH_TEAM_TOOL.name).toBe('code_with_team');
505
- expect(CODE_WITH_TEAM_TOOL.input_schema.required).toEqual(['task']);
506
- });
507
- it('has expected properties in schema (no max_turns)', () => {
508
- const props = Object.keys(CODE_WITH_TEAM_TOOL.input_schema.properties);
509
- expect(props).toContain('task');
510
- expect(props).toContain('team_size');
511
- expect(props).toContain('workdir');
512
- expect(props).toContain('agent');
513
- expect(props).toContain('model');
514
- expect(props).toContain('timeout_minutes');
515
- expect(props).toContain('validate');
516
- // max_turns removed — not relevant for parallel agents
517
- expect(props).not.toContain('max_turns');
518
- });
519
- it('is included in getToolDefinitions when includeSpawnSubagent is true', async () => {
520
- const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true });
521
- expect(tools.map(t => t.name)).toContain('code_with_team');
522
- });
523
- it('is excluded from getToolDefinitions when includeSpawnSubagent is false', async () => {
524
- const tools = await getToolDefinitions(toolConfig);
525
- expect(tools.map(t => t.name)).not.toContain('code_with_team');
526
- });
527
- });
528
- describe('executeTool routing', () => {
529
- it('rejects workdir outside allowed paths', async () => {
530
- const result = await executeTool('code_with_team', {
531
- task: 'refactor auth',
532
- workdir: '/tmp/not-allowed',
533
- }, toolConfig);
534
- expect(result).toContain('Error: Working directory not allowed');
535
- });
536
- it('returns error when task is missing', async () => {
537
- const result = await executeTool('code_with_team', {}, toolConfig);
538
- expect(result).toContain('Error: task is required');
539
- });
540
- it('returns error when agent is invalid', async () => {
541
- const result = await executeTool('code_with_team', {
542
- task: 'refactor auth',
543
- agent: 'not-a-real-agent',
544
- }, toolConfig);
545
- expect(result).toContain('Error: Invalid agent');
546
- });
547
- });
548
- });
549
- describe('decomposeTask', () => {
550
- it('falls back to numbered subtasks with DecomposedSubtask format on error', async () => {
551
- // decomposeTask with no valid config will fail the model call and use fallback
552
- const mockConfig = {
553
- agents: { list: {} },
554
- };
555
- const subtasks = await decomposeTask('fix everything', 3, mockConfig);
556
- expect(subtasks).toHaveLength(3);
557
- expect(subtasks[0].description).toContain('Part 1 of 3');
558
- expect(subtasks[1].description).toContain('Part 2 of 3');
559
- expect(subtasks[2].description).toContain('Part 3 of 3');
560
- expect(subtasks[0].description).toContain('fix everything');
561
- // Fallback produces all-independent subtasks
562
- expect(subtasks[0].dependsOn).toEqual([]);
563
- expect(subtasks[1].dependsOn).toEqual([]);
564
- expect(subtasks[2].dependsOn).toEqual([]);
565
- });
566
- });
567
- describe('computeWaves', () => {
568
- it('puts all independent subtasks in a single wave', () => {
569
- const subtasks = [
570
- { description: 'task A', dependsOn: [] },
571
- { description: 'task B', dependsOn: [] },
572
- { description: 'task C', dependsOn: [] },
573
- ];
574
- const waves = computeWaves(subtasks);
575
- expect(waves).toHaveLength(1);
576
- expect(waves[0]).toEqual([0, 1, 2]);
577
- });
578
- it('creates sequential waves for a dependency chain', () => {
579
- const subtasks = [
580
- { description: 'schema', dependsOn: [] },
581
- { description: 'queries', dependsOn: [0] },
582
- { description: 'tests', dependsOn: [1] },
583
- ];
584
- const waves = computeWaves(subtasks);
585
- expect(waves).toHaveLength(3);
586
- expect(waves[0]).toEqual([0]);
587
- expect(waves[1]).toEqual([1]);
588
- expect(waves[2]).toEqual([2]);
589
- });
590
- it('groups tasks with shared dependency into the same wave', () => {
591
- const subtasks = [
592
- { description: 'schema', dependsOn: [] },
593
- { description: 'API endpoints', dependsOn: [0] },
594
- { description: 'admin endpoints', dependsOn: [0] },
595
- { description: 'integration tests', dependsOn: [1, 2] },
596
- ];
597
- const waves = computeWaves(subtasks);
598
- expect(waves).toHaveLength(3);
599
- expect(waves[0]).toEqual([0]);
600
- expect(waves[1].sort()).toEqual([1, 2]);
601
- expect(waves[2]).toEqual([3]);
602
- });
603
- it('handles mixed independent and dependent tasks', () => {
604
- const subtasks = [
605
- { description: 'setup types', dependsOn: [] },
606
- { description: 'write docs', dependsOn: [] },
607
- { description: 'implement using types', dependsOn: [0] },
608
- ];
609
- const waves = computeWaves(subtasks);
610
- expect(waves).toHaveLength(2);
611
- expect(waves[0].sort()).toEqual([0, 1]);
612
- expect(waves[1]).toEqual([2]);
613
- });
614
- it('handles dependency cycles by forcing remaining into current wave', () => {
615
- const subtasks = [
616
- { description: 'A depends on B', dependsOn: [1] },
617
- { description: 'B depends on A', dependsOn: [0] },
618
- ];
619
- const waves = computeWaves(subtasks);
620
- // Both should end up in a wave despite the cycle
621
- const allIndices = waves.flat().sort();
622
- expect(allIndices).toEqual([0, 1]);
623
- });
624
- it('handles single subtask', () => {
625
- const subtasks = [
626
- { description: 'only task', dependsOn: [] },
627
- ];
628
- const waves = computeWaves(subtasks);
629
- expect(waves).toHaveLength(1);
630
- expect(waves[0]).toEqual([0]);
631
- });
632
- it('handles empty subtask list', () => {
633
- const waves = computeWaves([]);
634
- expect(waves).toHaveLength(0);
635
- });
636
- });
637
- describe('synthesizeResults', () => {
638
- it('falls back to mechanical summary on error', async () => {
639
- const mockConfig = {
640
- agents: { list: {} },
641
- };
642
- const results = [
643
- { subtask: 'fix auth', status: 'completed', output: 'done' },
644
- { subtask: 'fix tests', status: 'failed', error: 'timeout' },
645
- ];
646
- const summary = await synthesizeResults('fix everything', results, mockConfig);
647
- expect(summary).toContain('1/2 subtasks succeeded');
648
- expect(summary).toContain('1 failed');
649
- expect(summary).toContain('fix auth');
650
- expect(summary).toContain('fix tests');
651
- });
652
- });
653
- describe('readTeamState (deprecated)', () => {
654
- it('always returns null', () => {
655
- const state = readTeamState();
656
- expect(state).toBeNull();
657
- });
658
- });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateBearerToken } from '../utils.js';
3
+ describe('validateBearerToken', () => {
4
+ it('accepts valid bearer token', () => {
5
+ expect(validateBearerToken('abc123', 'Bearer abc123')).toBe(true);
6
+ });
7
+ it('rejects invalid bearer token', () => {
8
+ expect(validateBearerToken('abc123', 'Bearer nope')).toBe(false);
9
+ });
10
+ it('rejects missing or malformed auth headers', () => {
11
+ expect(validateBearerToken('abc123', undefined)).toBe(false);
12
+ expect(validateBearerToken('abc123', 'Basic abc123')).toBe(false);
13
+ });
14
+ });
@@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
3
  const { mockAudioSpeechCreate } = vi.hoisted(() => ({
4
4
  mockAudioSpeechCreate: vi.fn(),
5
5
  }));
6
+ const { mockSpawnSync } = vi.hoisted(() => ({
7
+ mockSpawnSync: vi.fn(() => ({ status: 0, stdout: '', stderr: '' })),
8
+ }));
6
9
  // Mock openai — use a class so `new OpenAI()` works correctly in ESM mocking context
7
10
  vi.mock('openai', () => ({
8
11
  default: class MockOpenAI {
@@ -17,6 +20,7 @@ vi.mock('openai', () => ({
17
20
  // Mock child_process — execSync used by macOS say provider
18
21
  vi.mock('child_process', () => ({
19
22
  execSync: vi.fn(() => Buffer.from('')),
23
+ spawnSync: (...args) => mockSpawnSync(...args),
20
24
  }));
21
25
  // Mock fs to avoid actual disk I/O
22
26
  vi.mock('fs', async (importOriginal) => {
@@ -183,6 +187,23 @@ describe('synthesizeSpeech', () => {
183
187
  Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
184
188
  }
185
189
  });
190
+ it('uses non-shell args for macOS say + ffmpeg', async () => {
191
+ const originalPlatform = process.platform;
192
+ Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
193
+ try {
194
+ const config = {
195
+ ...baseVoiceConfig,
196
+ providers: { macos: { tts: { voice: "Bad'Voice; rm -rf /" } } },
197
+ };
198
+ const result = await synthesizeSpeech("hello'; say hacked", config);
199
+ expect(result.format).toBe('ogg');
200
+ expect(mockSpawnSync).toHaveBeenNthCalledWith(1, 'say', ['-v', "Bad'Voice; rm -rf /", '-o', expect.stringContaining('.aiff'), "hello'; say hacked"], expect.any(Object));
201
+ expect(mockSpawnSync).toHaveBeenNthCalledWith(2, 'ffmpeg', ['-i', expect.stringContaining('.aiff'), '-c:a', 'libopus', expect.stringContaining('.ogg'), '-y'], expect.any(Object));
202
+ }
203
+ finally {
204
+ Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
205
+ }
206
+ });
186
207
  it('resolves API key from environment variable syntax', async () => {
187
208
  const fakeBuffer = Buffer.from('fake-audio');
188
209
  mockAudioSpeechCreate.mockResolvedValueOnce({
package/dist/agent.js CHANGED
@@ -80,6 +80,12 @@ export function appendToMemory(agentId, entry) {
80
80
  // Langfuse app tagging
81
81
  const LANGFUSE_APP_NAME = 'skimpyclaw';
82
82
  const LANGFUSE_APP_TAG = 'app:skimpyclaw';
83
+ const THINKING_LEVELS = new Set(['none', 'low', 'medium', 'high', 'xhigh']);
84
+ function metadataThinking(value) {
85
+ return typeof value === 'string' && THINKING_LEVELS.has(value)
86
+ ? value
87
+ : undefined;
88
+ }
83
89
  export async function runAgentTurn(agentId, userMessage, config, modelOverride, toolConfig, history, context) {
84
90
  const agentConfig = config.agents.list[agentId];
85
91
  if (!agentConfig) {
@@ -94,11 +100,27 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
94
100
  // Inject channel-specific formatting context
95
101
  if (context?.channel) {
96
102
  const channelHints = {
97
- telegram: `\n\n## Output Channel: Telegram\nTelegram does NOT render markdown. Use plain text only.\n- No **bold**, _italic_, or \`code blocks\`\n- Use CAPS or spacing for emphasis\n- Use plain dashes for lists\n- Include full URLs as plain text (no markdown links)`,
98
- discord: `\n\n## Output Channel: Discord\nDiscord renders markdown. Use it for formatting.\n- Use **bold**, *italic*, \`code\`, and \`\`\`code blocks\`\`\`\n- Use markdown links: [text](url)\n- Use bullet lists and headers`,
103
+ telegram: `\n\n## Channel: Telegram\nPlain text only. No markdown. Use CAPS for emphasis, plain URLs.`,
104
+ discord: `\n\n## Channel: Discord\nUse markdown: **bold**, *italic*, \`code\`, \`\`\`blocks\`\`\`, [links](url).`,
99
105
  };
100
106
  systemPrompt += channelHints[context.channel] || '';
101
107
  }
108
+ const metadata = context?.metadata;
109
+ const threadAgentAlias = typeof metadata?.threadAgentAlias === 'string'
110
+ ? metadata.threadAgentAlias.trim()
111
+ : '';
112
+ const threadAgentPrompt = typeof metadata?.threadAgentPromptOverlay === 'string'
113
+ ? metadata.threadAgentPromptOverlay.trim()
114
+ : '';
115
+ if (threadAgentAlias || threadAgentPrompt) {
116
+ systemPrompt += `\n\n## Discord Thread Agent`;
117
+ if (threadAgentAlias) {
118
+ systemPrompt += `\nAlias: ${threadAgentAlias}`;
119
+ }
120
+ if (threadAgentPrompt) {
121
+ systemPrompt += `\nFollow this additional thread-specific prompt:\n${threadAgentPrompt}`;
122
+ }
123
+ }
102
124
  // Build user content — support both string and content arrays (for images)
103
125
  let userContent;
104
126
  let sanitizedMessage;
@@ -123,7 +145,10 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
123
145
  { role: 'user', content: userContent },
124
146
  ];
125
147
  const model = modelOverride || agentConfig.model;
126
- const chatOptions = { model, thinking: agentConfig.thinking };
148
+ const thinking = metadataThinking(metadata?.threadAgentThinking)
149
+ ?? metadataThinking(metadata?.thinkingOverride)
150
+ ?? agentConfig.thinking;
151
+ const chatOptions = { model, thinking };
127
152
  const route = resolveProviderRoute(model, config);
128
153
  const { resolvedModel, provider, modelId } = route;
129
154
  let response = '';
@@ -152,9 +177,15 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
152
177
  channelTargetId,
153
178
  approverUserId: context?.userId,
154
179
  approverUsername: context?.metadata?.username,
155
- sandboxConfig: config.sandbox,
156
180
  sessionId: context?.sessionId || String(chatIdNum ?? 'default'),
157
181
  isCronJob: context?.metadata?.isCronJob === true,
182
+ discordThreadId: context?.metadata?.discordThreadId,
183
+ discordChannelId: context?.metadata?.discordChannelId,
184
+ isDm: context?.metadata?.isDm === true,
185
+ threadAgentAlias,
186
+ delegationDepth: typeof context?.metadata?.delegationDepth === 'number'
187
+ ? (context?.metadata).delegationDepth
188
+ : 0,
158
189
  };
159
190
  const runTurn = async () => {
160
191
  if (toolConfig?.enabled) {