skimpyclaw 0.3.6 → 0.3.9

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 (73) hide show
  1. package/README.md +14 -6
  2. package/dist/__tests__/api.test.js +1 -0
  3. package/dist/__tests__/channels.test.js +1 -1
  4. package/dist/__tests__/code-agents-orchestrator.test.js +74 -7
  5. package/dist/__tests__/code-agents-preflight.test.d.ts +1 -0
  6. package/dist/__tests__/code-agents-preflight.test.js +88 -0
  7. package/dist/__tests__/code-agents-sandbox.test.d.ts +1 -0
  8. package/dist/__tests__/code-agents-sandbox.test.js +163 -0
  9. package/dist/__tests__/code-agents-utils.test.js +12 -1
  10. package/dist/__tests__/context-manager.test.d.ts +1 -0
  11. package/dist/__tests__/context-manager.test.js +236 -0
  12. package/dist/__tests__/package-manager-detection.test.js +5 -5
  13. package/dist/__tests__/setup.test.js +7 -5
  14. package/dist/__tests__/skills.test.js +2 -2
  15. package/dist/__tests__/structured-context.test.d.ts +1 -0
  16. package/dist/__tests__/structured-context.test.js +100 -0
  17. package/dist/__tests__/tools.test.js +65 -3
  18. package/dist/agent.js +4 -5
  19. package/dist/api.js +10 -58
  20. package/dist/audit.js +5 -51
  21. package/dist/channels/telegram/handlers.js +2 -60
  22. package/dist/channels/telegram/index.js +0 -7
  23. package/dist/channels.js +1 -1
  24. package/dist/cli.js +151 -16
  25. package/dist/code-agents/executor.d.ts +9 -4
  26. package/dist/code-agents/executor.js +187 -13
  27. package/dist/code-agents/index.d.ts +1 -1
  28. package/dist/code-agents/index.js +30 -22
  29. package/dist/code-agents/orchestrator.d.ts +8 -2
  30. package/dist/code-agents/orchestrator.js +318 -27
  31. package/dist/code-agents/structured-context.d.ts +7 -0
  32. package/dist/code-agents/structured-context.js +54 -0
  33. package/dist/code-agents/types.d.ts +2 -0
  34. package/dist/code-agents/utils.d.ts +4 -0
  35. package/dist/code-agents/utils.js +38 -2
  36. package/dist/code-agents/worktree.d.ts +40 -0
  37. package/dist/code-agents/worktree.js +215 -0
  38. package/dist/config.d.ts +1 -0
  39. package/dist/config.js +5 -3
  40. package/dist/cron.js +18 -4
  41. package/dist/dashboard/assets/{index-CkonC7Cd.js → index-BoTHPby4.js} +20 -20
  42. package/dist/dashboard/assets/{index-EAg6lqF5.css → index-D4mufvBg.css} +1 -1
  43. package/dist/dashboard/index.html +2 -2
  44. package/dist/discord.js +4 -40
  45. package/dist/exec-approval.js +1 -1
  46. package/dist/file-lock.js +1 -1
  47. package/dist/gateway.js +3 -10
  48. package/dist/providers/anthropic.js +9 -5
  49. package/dist/providers/codex.js +10 -6
  50. package/dist/providers/context-manager.d.ts +22 -0
  51. package/dist/providers/context-manager.js +100 -0
  52. package/dist/providers/openai.js +9 -5
  53. package/dist/providers/types.d.ts +1 -0
  54. package/dist/security.js +9 -0
  55. package/dist/setup.js +122 -27
  56. package/dist/skills.js +9 -2
  57. package/dist/subagent.js +33 -2
  58. package/dist/tools/bash-tool.js +8 -0
  59. package/dist/tools/browser-tool.js +2 -1
  60. package/dist/tools/definitions.d.ts +0 -27
  61. package/dist/tools/definitions.js +0 -18
  62. package/dist/tools/execute-context.d.ts +4 -4
  63. package/dist/tools/file-tools.d.ts +1 -1
  64. package/dist/tools/file-tools.js +1 -1
  65. package/dist/tools.d.ts +5 -5
  66. package/dist/tools.js +87 -98
  67. package/dist/types.d.ts +14 -22
  68. package/dist/usage.d.ts +1 -0
  69. package/dist/usage.js +30 -46
  70. package/dist/utils.d.ts +18 -0
  71. package/dist/utils.js +71 -0
  72. package/dist/voice.js +9 -7
  73. package/package.json +26 -21
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { estimateTokens, compactAnthropicMessages, compactOpenAIMessages, compactCodexMessages, } from '../providers/context-manager.js';
3
+ // Helper: build an Anthropic-style tool exchange (assistant + user pair)
4
+ function anthropicExchange(toolResult) {
5
+ return [
6
+ {
7
+ role: 'assistant',
8
+ content: [
9
+ { type: 'text', text: 'Using a tool.' },
10
+ { type: 'tool_use', id: 'tu_1', name: 'Bash', input: { command: 'ls' } },
11
+ ],
12
+ },
13
+ {
14
+ role: 'user',
15
+ content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: toolResult }],
16
+ },
17
+ ];
18
+ }
19
+ // Helper: build a Codex-style function call exchange
20
+ function codexExchange(output) {
21
+ return [
22
+ { type: 'function_call', call_id: 'fc_1', name: 'Bash', arguments: '{}' },
23
+ { type: 'function_call_output', call_id: 'fc_1', output },
24
+ ];
25
+ }
26
+ describe('estimateTokens', () => {
27
+ it('returns a positive number for non-empty data', () => {
28
+ expect(estimateTokens([{ role: 'user', content: 'hello' }])).toBeGreaterThan(0);
29
+ });
30
+ it('returns a small number for empty array', () => {
31
+ expect(estimateTokens([])).toBeLessThan(5);
32
+ });
33
+ it('grows with more content', () => {
34
+ const small = estimateTokens([{ content: 'hi' }]);
35
+ const large = estimateTokens([{ content: 'x'.repeat(10_000) }]);
36
+ expect(large).toBeGreaterThan(small);
37
+ });
38
+ });
39
+ describe('compactAnthropicMessages', () => {
40
+ it('passes through unchanged when under threshold', () => {
41
+ const messages = anthropicExchange('short result');
42
+ const result = compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
43
+ expect(result).toEqual(messages);
44
+ });
45
+ it('returns same reference when no compaction needed', () => {
46
+ const messages = anthropicExchange('short result');
47
+ const result = compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
48
+ expect(result).toBe(messages);
49
+ });
50
+ it('truncates old tool_result content when over threshold', () => {
51
+ const longResult = 'x'.repeat(10_000);
52
+ // Build many exchanges to exceed threshold
53
+ const messages = [];
54
+ for (let i = 0; i < 30; i++) {
55
+ messages.push(...anthropicExchange(longResult));
56
+ }
57
+ const result = compactAnthropicMessages(messages, { maxContextTokens: 1_000 });
58
+ // Head messages should have truncated tool results
59
+ const headMessages = result.slice(0, -8);
60
+ const toolResultMessages = headMessages.filter(m => Array.isArray(m.content) && m.content.some((b) => b.type === 'tool_result'));
61
+ for (const msg of toolResultMessages) {
62
+ const block = msg.content.find((b) => b.type === 'tool_result');
63
+ expect(block.content).toContain('[truncated]');
64
+ expect(block.content.length).toBeLessThan(longResult.length);
65
+ }
66
+ });
67
+ it('keeps last 8 messages intact when compacting', () => {
68
+ const longResult = 'x'.repeat(10_000);
69
+ const messages = [];
70
+ for (let i = 0; i < 30; i++) {
71
+ messages.push(...anthropicExchange(longResult));
72
+ }
73
+ const result = compactAnthropicMessages(messages, { maxContextTokens: 1_000 });
74
+ // Last 8 messages should be untouched
75
+ const tail = result.slice(-8);
76
+ const originalTail = messages.slice(-8);
77
+ expect(tail).toEqual(originalTail);
78
+ });
79
+ it('does not mutate the input array', () => {
80
+ const longResult = 'x'.repeat(10_000);
81
+ const messages = [];
82
+ for (let i = 0; i < 30; i++) {
83
+ messages.push(...anthropicExchange(longResult));
84
+ }
85
+ const originalJson = JSON.stringify(messages);
86
+ compactAnthropicMessages(messages, { maxContextTokens: 1_000 });
87
+ expect(JSON.stringify(messages)).toBe(originalJson);
88
+ });
89
+ it('preserves non-tool_result blocks unchanged', () => {
90
+ const longResult = 'x'.repeat(10_000);
91
+ const messages = [];
92
+ for (let i = 0; i < 30; i++) {
93
+ messages.push(...anthropicExchange(longResult));
94
+ }
95
+ const result = compactAnthropicMessages(messages, { maxContextTokens: 1_000 });
96
+ // Assistant messages (tool_use blocks) should be untouched
97
+ const assistantMessages = result.filter(m => m.role === 'assistant');
98
+ for (const msg of assistantMessages) {
99
+ const toolUse = msg.content.find((b) => b.type === 'tool_use');
100
+ expect(toolUse).toBeDefined();
101
+ expect(toolUse.name).toBe('Bash');
102
+ }
103
+ });
104
+ it('passes through unchanged when disabled', () => {
105
+ const longResult = 'x'.repeat(10_000);
106
+ const messages = [];
107
+ for (let i = 0; i < 30; i++) {
108
+ messages.push(...anthropicExchange(longResult));
109
+ }
110
+ const result = compactAnthropicMessages(messages, { enabled: false, maxContextTokens: 1 });
111
+ expect(result).toBe(messages);
112
+ });
113
+ });
114
+ // Helper: build an OpenAI-style tool exchange (assistant + tool result)
115
+ function openaiExchange(toolResult) {
116
+ return [
117
+ {
118
+ role: 'assistant',
119
+ content: null,
120
+ tool_calls: [{ id: 'tc_1', type: 'function', function: { name: 'Bash', arguments: '{}' } }],
121
+ },
122
+ { role: 'tool', tool_call_id: 'tc_1', content: toolResult },
123
+ ];
124
+ }
125
+ describe('compactOpenAIMessages', () => {
126
+ it('passes through unchanged when under threshold', () => {
127
+ const messages = openaiExchange('short result');
128
+ const result = compactOpenAIMessages(messages, { maxContextTokens: 100_000 });
129
+ expect(result).toBe(messages);
130
+ });
131
+ it('truncates old tool content when over threshold', () => {
132
+ const longResult = 'x'.repeat(10_000);
133
+ const messages = [];
134
+ for (let i = 0; i < 30; i++) {
135
+ messages.push(...openaiExchange(longResult));
136
+ }
137
+ const result = compactOpenAIMessages(messages, { maxContextTokens: 1_000 });
138
+ const headItems = result.slice(0, -8);
139
+ const toolMessages = headItems.filter((m) => m.role === 'tool');
140
+ for (const msg of toolMessages) {
141
+ expect(msg.content).toContain('[truncated]');
142
+ expect(msg.content.length).toBeLessThan(longResult.length);
143
+ }
144
+ });
145
+ it('keeps last 8 messages intact', () => {
146
+ const longResult = 'x'.repeat(10_000);
147
+ const messages = [];
148
+ for (let i = 0; i < 30; i++) {
149
+ messages.push(...openaiExchange(longResult));
150
+ }
151
+ const result = compactOpenAIMessages(messages, { maxContextTokens: 1_000 });
152
+ expect(result.slice(-8)).toEqual(messages.slice(-8));
153
+ });
154
+ it('does not mutate the input array', () => {
155
+ const longResult = 'x'.repeat(10_000);
156
+ const messages = [];
157
+ for (let i = 0; i < 30; i++) {
158
+ messages.push(...openaiExchange(longResult));
159
+ }
160
+ const original = JSON.stringify(messages);
161
+ compactOpenAIMessages(messages, { maxContextTokens: 1_000 });
162
+ expect(JSON.stringify(messages)).toBe(original);
163
+ });
164
+ it('passes through unchanged when disabled', () => {
165
+ const longResult = 'x'.repeat(10_000);
166
+ const messages = [];
167
+ for (let i = 0; i < 30; i++) {
168
+ messages.push(...openaiExchange(longResult));
169
+ }
170
+ const result = compactOpenAIMessages(messages, { enabled: false, maxContextTokens: 1 });
171
+ expect(result).toBe(messages);
172
+ });
173
+ });
174
+ describe('compactCodexMessages', () => {
175
+ it('passes through unchanged when under threshold', () => {
176
+ const items = codexExchange('short result');
177
+ const result = compactCodexMessages(items, { maxContextTokens: 100_000 });
178
+ expect(result).toEqual(items);
179
+ });
180
+ it('truncates old function_call_output when over threshold', () => {
181
+ const longOutput = 'x'.repeat(10_000);
182
+ const items = [];
183
+ for (let i = 0; i < 30; i++) {
184
+ items.push(...codexExchange(longOutput));
185
+ }
186
+ const result = compactCodexMessages(items, { maxContextTokens: 1_000 });
187
+ const headItems = result.slice(0, -8);
188
+ const outputItems = headItems.filter((item) => item.type === 'function_call_output');
189
+ for (const item of outputItems) {
190
+ expect(item.output).toContain('[truncated]');
191
+ expect(item.output.length).toBeLessThan(longOutput.length);
192
+ }
193
+ });
194
+ it('keeps last 8 items intact when compacting', () => {
195
+ const longOutput = 'x'.repeat(10_000);
196
+ const items = [];
197
+ for (let i = 0; i < 30; i++) {
198
+ items.push(...codexExchange(longOutput));
199
+ }
200
+ const result = compactCodexMessages(items, { maxContextTokens: 1_000 });
201
+ const tail = result.slice(-8);
202
+ const originalTail = items.slice(-8);
203
+ expect(tail).toEqual(originalTail);
204
+ });
205
+ it('does not mutate the input array', () => {
206
+ const longOutput = 'x'.repeat(10_000);
207
+ const items = [];
208
+ for (let i = 0; i < 30; i++) {
209
+ items.push(...codexExchange(longOutput));
210
+ }
211
+ const originalJson = JSON.stringify(items);
212
+ compactCodexMessages(items, { maxContextTokens: 1_000 });
213
+ expect(JSON.stringify(items)).toBe(originalJson);
214
+ });
215
+ it('preserves function_call items (not just outputs) unchanged', () => {
216
+ const longOutput = 'x'.repeat(10_000);
217
+ const items = [];
218
+ for (let i = 0; i < 30; i++) {
219
+ items.push(...codexExchange(longOutput));
220
+ }
221
+ const result = compactCodexMessages(items, { maxContextTokens: 1_000 });
222
+ const callItems = result.filter((item) => item.type === 'function_call');
223
+ for (const item of callItems) {
224
+ expect(item.name).toBe('Bash');
225
+ }
226
+ });
227
+ it('passes through unchanged when disabled', () => {
228
+ const longOutput = 'x'.repeat(10_000);
229
+ const items = [];
230
+ for (let i = 0; i < 30; i++) {
231
+ items.push(...codexExchange(longOutput));
232
+ }
233
+ const result = compactCodexMessages(items, { enabled: false, maxContextTokens: 1 });
234
+ expect(result).toBe(items);
235
+ });
236
+ });
@@ -147,17 +147,17 @@ describe('buildValidationCommand', () => {
147
147
  writeFileSync(join(tempDir, 'yarn.lock'), '');
148
148
  expect(buildValidationCommand(tempDir)).toBe('yarn build');
149
149
  });
150
- it('falls back to both commands when no package.json exists', () => {
151
- // No package.json, no lockfile → pnpm fallback
152
- expect(buildValidationCommand(tempDir)).toBe('pnpm build && pnpm test');
150
+ it('returns empty when no package.json exists', () => {
151
+ // No package.json, no lockfile → nothing to validate
152
+ expect(buildValidationCommand(tempDir)).toBe('');
153
153
  });
154
- it('falls back to both commands when scripts object is empty', () => {
154
+ it('returns empty when scripts object is empty', () => {
155
155
  writeFileSync(join(tempDir, 'package.json'), JSON.stringify({
156
156
  name: 'test',
157
157
  scripts: {},
158
158
  }));
159
159
  writeFileSync(join(tempDir, 'yarn.lock'), '');
160
- expect(buildValidationCommand(tempDir)).toBe('yarn build && yarn test');
160
+ expect(buildValidationCommand(tempDir)).toBe('');
161
161
  });
162
162
  // wp-calypso scenario
163
163
  it('generates yarn commands for wp-calypso-like project', () => {
@@ -133,11 +133,13 @@ describe('setup config generation', () => {
133
133
  skillWebSearch: false,
134
134
  },
135
135
  });
136
- expect(config.cron.jobs).toHaveLength(2);
137
- expect(config.cron.jobs[0].id).toBe('tech-digest');
138
- expect(config.cron.jobs[1].id).toBe('weather');
139
- expect(config.cron.jobs[1].schedule.tz).toBe('America/New_York');
140
- expect(config.cron.jobs[1].payload.message).toContain('Austin, TX');
136
+ expect(config.cron.jobs).toHaveLength(3);
137
+ expect(config.cron.jobs[0].id).toBe('memory-trim');
138
+ expect(config.cron.jobs[0].model).toBe('claude-haiku');
139
+ expect(config.cron.jobs[1].id).toBe('tech-digest');
140
+ expect(config.cron.jobs[2].id).toBe('weather');
141
+ expect(config.cron.jobs[2].schedule.tz).toBe('America/New_York');
142
+ expect(config.cron.jobs[2].payload.message).toContain('Austin, TX');
141
143
  expect(config.skills.enabled).toBe(true);
142
144
  expect(config.skills.entries['daily-notes']).toBe(true);
143
145
  expect(config.skills.entries['weather']).toBe(true);
@@ -211,8 +211,8 @@ describe('checkEligibility', () => {
211
211
  const result = checkEligibility({ name: 'test', description: 'test', requires: { tools: ['Browser'] } }, { enabled: true, allowedPaths: ['/tmp'], browser: { enabled: true } });
212
212
  expect(result.eligible).toBe(true);
213
213
  });
214
- it('passes on spawn_subagent requirement when tools enabled', () => {
215
- const result = checkEligibility({ name: 'test', description: 'test', requires: { tools: ['spawn_subagent'] } }, { enabled: true, allowedPaths: ['/tmp'] });
214
+ it('passes on code_with_agent requirement when tools enabled', () => {
215
+ const result = checkEligibility({ name: 'test', description: 'test', requires: { tools: ['code_with_agent'] } }, { enabled: true, allowedPaths: ['/tmp'] });
216
216
  expect(result.eligible).toBe(true);
217
217
  });
218
218
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseAgentOutput, formatStructuredContext } from '../code-agents/structured-context.js';
3
+ describe('parseAgentOutput', () => {
4
+ it('returns empty files and errors for plain text', () => {
5
+ const result = parseAgentOutput('Everything looks good.');
6
+ expect(result.summary).toBe('Everything looks good.');
7
+ expect(result.files).toEqual([]);
8
+ expect(result.errors).toEqual([]);
9
+ });
10
+ it('extracts backtick-quoted file paths', () => {
11
+ const raw = 'Updated `src/agent.ts` and `src/types.ts` to add the new field.';
12
+ const result = parseAgentOutput(raw);
13
+ expect(result.files).toContain('src/agent.ts');
14
+ expect(result.files).toContain('src/types.ts');
15
+ });
16
+ it('extracts absolute file paths', () => {
17
+ const raw = 'Wrote changes to /Users/katre/Sites/skimpyclaw/src/agent.ts successfully.';
18
+ const result = parseAgentOutput(raw);
19
+ expect(result.files).toContain('/Users/katre/Sites/skimpyclaw/src/agent.ts');
20
+ });
21
+ it('ignores backtick tokens without dots (not file paths)', () => {
22
+ const raw = 'Call `myFunction` and `anotherMethod` to do the work.';
23
+ const result = parseAgentOutput(raw);
24
+ expect(result.files).toEqual([]);
25
+ });
26
+ it('ignores backtick tokens with spaces (commands, not paths)', () => {
27
+ const raw = 'Run `pnpm build && pnpm test` to verify.';
28
+ const result = parseAgentOutput(raw);
29
+ expect(result.files).toEqual([]);
30
+ });
31
+ it('extracts error lines', () => {
32
+ const raw = 'Build completed.\nError: Cannot find module src/missing.js\nAll tests passed.';
33
+ const result = parseAgentOutput(raw);
34
+ expect(result.errors).toHaveLength(1);
35
+ expect(result.errors[0]).toContain('Cannot find module');
36
+ });
37
+ it('caps errors at 5', () => {
38
+ const lines = Array.from({ length: 10 }, (_, i) => `Error: problem ${i}`).join('\n');
39
+ const result = parseAgentOutput(lines);
40
+ expect(result.errors.length).toBeLessThanOrEqual(5);
41
+ });
42
+ it('caps files at 15', () => {
43
+ const lines = Array.from({ length: 20 }, (_, i) => `Updated \`src/file${i}.ts\`.`).join('\n');
44
+ const result = parseAgentOutput(lines);
45
+ expect(result.files.length).toBeLessThanOrEqual(15);
46
+ });
47
+ it('deduplicates file paths', () => {
48
+ const raw = 'Updated `src/agent.ts`. Also modified `src/agent.ts` again.';
49
+ const result = parseAgentOutput(raw);
50
+ const count = result.files.filter(f => f === 'src/agent.ts').length;
51
+ expect(count).toBe(1);
52
+ });
53
+ it('truncates summary to 500 chars', () => {
54
+ const raw = 'x'.repeat(1000);
55
+ const result = parseAgentOutput(raw);
56
+ expect(result.summary).toHaveLength(500);
57
+ });
58
+ it('strips trailing punctuation from absolute paths', () => {
59
+ const raw = 'Modified /src/foo.ts, and /src/bar.ts.';
60
+ const result = parseAgentOutput(raw);
61
+ expect(result.files).not.toContain('/src/foo.ts,');
62
+ expect(result.files).not.toContain('/src/bar.ts.');
63
+ });
64
+ });
65
+ describe('formatStructuredContext', () => {
66
+ it('formats summary only when no files or errors', () => {
67
+ const result = formatStructuredContext({ summary: 'Done.', files: [], errors: [] });
68
+ expect(result).toBe('Summary: Done.');
69
+ });
70
+ it('includes files line when files present', () => {
71
+ const result = formatStructuredContext({
72
+ summary: 'Updated auth.',
73
+ files: ['src/auth.ts', 'src/types.ts'],
74
+ errors: [],
75
+ });
76
+ expect(result).toContain('Files: src/auth.ts, src/types.ts');
77
+ });
78
+ it('includes errors line when errors present', () => {
79
+ const result = formatStructuredContext({
80
+ summary: 'Build failed.',
81
+ files: [],
82
+ errors: ['Error: missing export'],
83
+ });
84
+ expect(result).toContain('Errors: Error: missing export');
85
+ });
86
+ it('separates multiple errors with pipe', () => {
87
+ const result = formatStructuredContext({
88
+ summary: 'Done.',
89
+ files: [],
90
+ errors: ['Error: foo', 'Error: bar'],
91
+ });
92
+ expect(result).toContain('Error: foo | Error: bar');
93
+ });
94
+ it('produces a shorter output than raw 1000-char input', () => {
95
+ const raw = 'x'.repeat(1000);
96
+ const parsed = parseAgentOutput(raw);
97
+ const formatted = formatStructuredContext(parsed);
98
+ expect(formatted.length).toBeLessThan(raw.length);
99
+ });
100
+ });
@@ -59,6 +59,42 @@ describe('getToolDefinitions', () => {
59
59
  const tools = await getToolDefinitions();
60
60
  expect(tools.map(t => t.name)).not.toContain('Browser');
61
61
  });
62
+ describe('tool profiles', () => {
63
+ it('minimal returns exactly 4 built-in tools', async () => {
64
+ const config = { ...toolConfig, toolProfile: 'minimal' };
65
+ const tools = await getToolDefinitions(config, { includeAgentTools: true, includeMcp: true });
66
+ expect(tools).toHaveLength(4);
67
+ expect(tools.map(t => t.name)).toEqual(['Read', 'Write', 'Glob', 'Bash']);
68
+ });
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
+ it('minimal excludes MCP tools', async () => {
75
+ const config = { ...toolConfig, toolProfile: 'minimal' };
76
+ const tools = await getToolDefinitions(config, { includeMcp: true });
77
+ expect(tools.every(t => !t.name.startsWith('mcp__'))).toBe(true);
78
+ });
79
+ it('coding includes code_with_agent and check_code_agent but not code_with_team', async () => {
80
+ const config = { ...toolConfig, toolProfile: 'coding' };
81
+ const tools = await getToolDefinitions(config, { includeAgentTools: true });
82
+ const names = tools.map(t => t.name);
83
+ expect(names).toContain('code_with_agent');
84
+ expect(names).toContain('check_code_agent');
85
+ expect(names).not.toContain('code_with_team');
86
+ });
87
+ it('coding excludes MCP tools', async () => {
88
+ const config = { ...toolConfig, toolProfile: 'coding' };
89
+ const tools = await getToolDefinitions(config, { includeMcp: true });
90
+ expect(tools.every(t => !t.name.startsWith('mcp__'))).toBe(true);
91
+ });
92
+ it('full profile behaves like default (no profile set)', async () => {
93
+ const defaultTools = await getToolDefinitions(toolConfig);
94
+ const fullTools = await getToolDefinitions({ ...toolConfig, toolProfile: 'full' });
95
+ expect(fullTools.map(t => t.name)).toEqual(defaultTools.map(t => t.name));
96
+ }, 15000);
97
+ });
62
98
  });
63
99
  describe('tool name mapping', () => {
64
100
  it('maps Claude Code names to internal names', () => {
@@ -192,6 +228,32 @@ describe('bash', () => {
192
228
  const result = await executeTool('delete_everything', {}, toolConfig);
193
229
  expect(result).toContain('Error: Unknown tool');
194
230
  });
231
+ describe('exec approval in unattended contexts', () => {
232
+ it('fast-denies tier 3 inline interpreter scripts in subagent context', async () => {
233
+ const result = await executeTool('Bash', { command: 'node -e "console.log(1)"' }, toolConfig, { channel: 'subagent' });
234
+ expect(result).toContain('⛔');
235
+ expect(result).toContain('tier 3');
236
+ expect(result).not.toContain('approved');
237
+ });
238
+ it('fast-denies tier 2 commands in subagent context', async () => {
239
+ const result = await executeTool('Bash', { command: 'gh pr review --approve' }, toolConfig, { channel: 'subagent' });
240
+ expect(result).toContain('⛔');
241
+ expect(result).toContain('tier 2');
242
+ });
243
+ it('fast-denies tier 3 commands in cron context', async () => {
244
+ const result = await executeTool('Bash', { command: 'node -e "console.log(1)"' }, toolConfig, { isCronJob: true });
245
+ expect(result).toContain('⛔');
246
+ expect(result).toContain('tier 3');
247
+ });
248
+ it('fast-denies when no approver and no chatId', async () => {
249
+ const result = await executeTool('Bash', { command: 'kubectl delete pods --all' }, toolConfig, {});
250
+ expect(result).toContain('⛔');
251
+ });
252
+ it('allows safe tier 0 commands in subagent context', async () => {
253
+ const result = await executeTool('Bash', { command: 'echo hello' }, toolConfig, { channel: 'subagent' });
254
+ expect(result.trim()).toBe('hello');
255
+ });
256
+ });
195
257
  });
196
258
  describe('browser', () => {
197
259
  const browserDisabledConfig = {
@@ -286,7 +348,7 @@ describe('code_with_agent', () => {
286
348
  expect(props).toContain('validate');
287
349
  });
288
350
  it('is included in getToolDefinitions when includeSpawnSubagent is true', async () => {
289
- const tools = await getToolDefinitions(toolConfig, { includeSpawnSubagent: true });
351
+ const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true });
290
352
  expect(tools.map(t => t.name)).toContain('code_with_agent');
291
353
  });
292
354
  it('is excluded from getToolDefinitions when includeSpawnSubagent is false', async () => {
@@ -401,7 +463,7 @@ describe('code_with_agent', () => {
401
463
  expect(CHECK_CODE_AGENT_TOOL.input_schema.properties).toHaveProperty('id');
402
464
  });
403
465
  it('is included in getToolDefinitions when includeSpawnSubagent is true', async () => {
404
- const tools = await getToolDefinitions(toolConfig, { includeSpawnSubagent: true });
466
+ const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true });
405
467
  expect(tools.map(t => t.name)).toContain('check_code_agent');
406
468
  });
407
469
  it('is excluded from getToolDefinitions when includeSpawnSubagent is false', async () => {
@@ -453,7 +515,7 @@ describe('code_with_team', () => {
453
515
  expect(props).not.toContain('max_turns');
454
516
  });
455
517
  it('is included in getToolDefinitions when includeSpawnSubagent is true', async () => {
456
- const tools = await getToolDefinitions(toolConfig, { includeSpawnSubagent: true });
518
+ const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true });
457
519
  expect(tools.map(t => t.name)).toContain('code_with_team');
458
520
  });
459
521
  it('is excluded from getToolDefinitions when includeSpawnSubagent is false', async () => {
package/dist/agent.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // Agent runner: loads templates, calls models, manages memory
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'fs';
2
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { getAgentDir } from './config.js';
5
5
  import { buildSafeSystemPrompt, sanitizeUserInput } from './security.js';
6
+ import { toErrorMessage } from './utils.js';
6
7
  import { startTrace, endTrace } from './audit.js';
7
8
  import { loadSkills, getSkillsForContext, formatSkillsPrompt } from './skills.js';
8
9
  import { getLangfuseConfig, isLangfuseEnabled } from './langfuse.js';
@@ -73,9 +74,7 @@ export function appendToMemory(agentId, entry) {
73
74
  }
74
75
  const path = getTodayMemoryPath(agentId);
75
76
  const timestamp = new Date().toISOString();
76
- const content = existsSync(path) ? readFileSync(path, 'utf-8') : '';
77
- const newContent = content + `\n## ${timestamp}\n\n${entry}\n`;
78
- writeFileSync(path, newContent.trim() + '\n');
77
+ appendFileSync(path, `\n## ${timestamp}\n\n${entry}\n`, 'utf-8');
79
78
  }
80
79
  // --- Agent Turn ---
81
80
  // Langfuse app tagging
@@ -230,7 +229,7 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
230
229
  return result;
231
230
  }
232
231
  catch (err) {
233
- const errorMessage = err instanceof Error ? err.message : String(err);
232
+ const errorMessage = toErrorMessage(err);
234
233
  agentObs.update({
235
234
  level: 'ERROR',
236
235
  statusMessage: errorMessage,