skimpyclaw 0.3.6 → 0.3.8

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 (69) 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-sandbox.test.d.ts +1 -0
  6. package/dist/__tests__/code-agents-sandbox.test.js +163 -0
  7. package/dist/__tests__/context-manager.test.d.ts +1 -0
  8. package/dist/__tests__/context-manager.test.js +236 -0
  9. package/dist/__tests__/package-manager-detection.test.js +5 -5
  10. package/dist/__tests__/setup.test.js +7 -5
  11. package/dist/__tests__/skills.test.js +2 -2
  12. package/dist/__tests__/structured-context.test.d.ts +1 -0
  13. package/dist/__tests__/structured-context.test.js +100 -0
  14. package/dist/__tests__/tools.test.js +65 -3
  15. package/dist/agent.js +4 -5
  16. package/dist/api.js +10 -58
  17. package/dist/audit.js +5 -51
  18. package/dist/channels/telegram/handlers.js +2 -60
  19. package/dist/channels/telegram/index.js +0 -7
  20. package/dist/channels.js +1 -1
  21. package/dist/cli.js +151 -16
  22. package/dist/code-agents/executor.d.ts +9 -4
  23. package/dist/code-agents/executor.js +187 -13
  24. package/dist/code-agents/index.d.ts +1 -1
  25. package/dist/code-agents/index.js +23 -21
  26. package/dist/code-agents/orchestrator.d.ts +8 -2
  27. package/dist/code-agents/orchestrator.js +297 -27
  28. package/dist/code-agents/structured-context.d.ts +7 -0
  29. package/dist/code-agents/structured-context.js +54 -0
  30. package/dist/code-agents/types.d.ts +2 -0
  31. package/dist/code-agents/utils.js +12 -2
  32. package/dist/code-agents/worktree.d.ts +40 -0
  33. package/dist/code-agents/worktree.js +215 -0
  34. package/dist/config.d.ts +1 -0
  35. package/dist/config.js +5 -3
  36. package/dist/cron.js +18 -4
  37. package/dist/dashboard/assets/{index-CkonC7Cd.js → index-BoTHPby4.js} +20 -20
  38. package/dist/dashboard/assets/{index-EAg6lqF5.css → index-D4mufvBg.css} +1 -1
  39. package/dist/dashboard/index.html +2 -2
  40. package/dist/discord.js +4 -40
  41. package/dist/exec-approval.js +1 -1
  42. package/dist/file-lock.js +1 -1
  43. package/dist/gateway.js +3 -10
  44. package/dist/providers/anthropic.js +9 -5
  45. package/dist/providers/codex.js +10 -6
  46. package/dist/providers/context-manager.d.ts +22 -0
  47. package/dist/providers/context-manager.js +100 -0
  48. package/dist/providers/openai.js +9 -5
  49. package/dist/providers/types.d.ts +1 -0
  50. package/dist/security.js +9 -0
  51. package/dist/setup.js +112 -14
  52. package/dist/skills.js +9 -2
  53. package/dist/subagent.js +33 -2
  54. package/dist/tools/bash-tool.js +8 -0
  55. package/dist/tools/browser-tool.js +2 -1
  56. package/dist/tools/definitions.d.ts +0 -27
  57. package/dist/tools/definitions.js +0 -18
  58. package/dist/tools/execute-context.d.ts +4 -4
  59. package/dist/tools/file-tools.d.ts +1 -1
  60. package/dist/tools/file-tools.js +1 -1
  61. package/dist/tools.d.ts +5 -5
  62. package/dist/tools.js +87 -98
  63. package/dist/types.d.ts +14 -22
  64. package/dist/usage.d.ts +1 -0
  65. package/dist/usage.js +30 -46
  66. package/dist/utils.d.ts +18 -0
  67. package/dist/utils.js +71 -0
  68. package/dist/voice.js +9 -7
  69. package/package.json +1 -1
@@ -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,
package/dist/api.js CHANGED
@@ -1,14 +1,13 @@
1
1
  // Dashboard API endpoints
2
2
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, rmSync } from 'fs';
3
- import { timingSafeEqual } from 'crypto';
3
+ import { validateBearerToken } from './utils.js';
4
4
  import { join, basename, resolve } from 'path';
5
5
  import { homedir } from 'os';
6
- import { loadConfig, loadRawConfig, saveConfig, getSessionsDir, getLogsDir, getAgentDir, listMemoryFiles, readMemoryFile, } from './config.js';
6
+ import { loadConfig, loadRawConfig, saveConfig, getSessionsDir, getLogsDir, getAgentDir, isValidAgentId, listMemoryFiles, readMemoryFile, } from './config.js';
7
7
  import { TEMPLATE_FILES, getAgentTemplateContent, saveAgentTemplate, } from './agent.js';
8
8
  import { getCronJobs, getCronJobDetails, runCronJob } from './cron.js';
9
9
  import { getCurrentModel, setCurrentModel, getLastMessage, setGatewayConfig } from './gateway.js';
10
10
  import { redactSecrets } from './security.js';
11
- import { getActiveTasks, getRecentTasks } from './subagent.js';
12
11
  import { readAuditTraces } from './audit.js';
13
12
  import { getUsageSummary, readUsageRecords } from './usage.js';
14
13
  import { getAllCodeAgents, getCodeAgent, cancelCodeAgent } from './tools.js';
@@ -36,10 +35,7 @@ const DEFAULT_MODEL_ALIASES = {
36
35
  function validateFilename(filename) {
37
36
  return !filename.includes('..') && filename === basename(filename);
38
37
  }
39
- function validateAgentId(agentId) {
40
- // Agent IDs should be simple identifiers: alphanumeric, hyphens, underscores
41
- return /^[a-zA-Z0-9_-]+$/.test(agentId);
42
- }
38
+ // Use isValidAgentId from config.ts
43
39
  function validateSkillName(name) {
44
40
  return /^[a-zA-Z0-9-]+$/.test(name) && name.length <= 100;
45
41
  }
@@ -98,30 +94,14 @@ export function registerDashboardAPI(fastify, config) {
98
94
  if (!token) {
99
95
  return; // No token configured, allow access
100
96
  }
101
- const authHeader = request.headers.authorization;
102
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
103
- return reply.code(401).send({ error: 'Unauthorized: Bearer token required' });
104
- }
105
- const providedToken = authHeader.slice(7);
106
- // Timing-safe comparison to prevent token extraction via timing attacks
107
- const tokenBuf = Buffer.from(token, 'utf8');
108
- const providedBuf = Buffer.from(providedToken, 'utf8');
109
- if (tokenBuf.length !== providedBuf.length || !timingSafeEqual(tokenBuf, providedBuf)) {
110
- return reply.code(401).send({ error: 'Unauthorized: Invalid token' });
97
+ if (!validateBearerToken(token, request.headers.authorization)) {
98
+ return reply.code(401).send({ error: 'Unauthorized: Invalid or missing token' });
111
99
  }
112
100
  });
113
101
  // --- Status ---
114
102
  fastify.get('/api/dashboard/status', async () => {
115
103
  const jobs = getCronJobs();
116
104
  const uptime = process.uptime();
117
- const activeTasks = getActiveTasks();
118
- const recentTasks = getRecentTasks(20);
119
- const pendingCount = activeTasks.filter(t => t.status === 'pending').length;
120
- const runningCount = activeTasks.filter(t => t.status === 'running').length;
121
- const recentCompleted = recentTasks.filter(t => t.status === 'completed').length;
122
- const recentFailed = recentTasks.filter(t => t.status === 'failed').length;
123
- const recentCancelled = recentTasks.filter(t => t.status === 'cancelled').length;
124
- const maxConcurrent = runtimeConfig.subagents?.maxConcurrent ?? 5;
125
105
  return {
126
106
  uptime,
127
107
  model: getCurrentModel(),
@@ -129,34 +109,6 @@ export function registerDashboardAPI(fastify, config) {
129
109
  lastMessage: getLastMessage(),
130
110
  activeChannel: getActiveChannelId() ?? runtimeConfig.channels.active ?? null,
131
111
  cronJobs: jobs,
132
- subagents: {
133
- maxConcurrent,
134
- active: activeTasks.length,
135
- running: runningCount,
136
- pending: pendingCount,
137
- recentTotal: recentTasks.length,
138
- recentCompleted,
139
- recentFailed,
140
- recentCancelled,
141
- },
142
- activeSubagents: activeTasks
143
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
144
- .map(task => {
145
- const started = task.startedAt || task.createdAt;
146
- return {
147
- id: task.id,
148
- type: task.type,
149
- status: task.status,
150
- model: task.model,
151
- label: task.label,
152
- promptPreview: task.prompt.slice(0, 120),
153
- retryCount: task.retryCount ?? 0,
154
- maxRetries: task.maxRetries ?? 2,
155
- createdAt: task.createdAt,
156
- startedAt: task.startedAt,
157
- elapsedSeconds: Math.max(0, Math.round((Date.now() - started.getTime()) / 1000)),
158
- };
159
- }),
160
112
  };
161
113
  });
162
114
  // --- Sessions ---
@@ -306,7 +258,7 @@ export function registerDashboardAPI(fastify, config) {
306
258
  // --- Memory ---
307
259
  fastify.get('/api/dashboard/memory/:agentId', async (request, reply) => {
308
260
  const { agentId } = request.params;
309
- if (!validateAgentId(agentId)) {
261
+ if (!isValidAgentId(agentId)) {
310
262
  return reply.code(400).send({ error: 'Invalid agent ID' });
311
263
  }
312
264
  const files = listMemoryFiles(agentId);
@@ -314,7 +266,7 @@ export function registerDashboardAPI(fastify, config) {
314
266
  });
315
267
  fastify.get('/api/dashboard/memory/:agentId/:filename', async (request, reply) => {
316
268
  const { agentId, filename } = request.params;
317
- if (!validateAgentId(agentId)) {
269
+ if (!isValidAgentId(agentId)) {
318
270
  return reply.code(400).send({ error: 'Invalid agent ID' });
319
271
  }
320
272
  // Special case: "curated" reads MEMORY.md from the agent root
@@ -455,7 +407,7 @@ export function registerDashboardAPI(fastify, config) {
455
407
  // --- Templates ---
456
408
  fastify.get('/api/dashboard/templates/:agentId', async (request, reply) => {
457
409
  const { agentId } = request.params;
458
- if (!validateAgentId(agentId)) {
410
+ if (!isValidAgentId(agentId)) {
459
411
  return reply.code(400).send({ error: 'Invalid agent ID' });
460
412
  }
461
413
  const agentDir = getAgentDir(agentId);
@@ -472,7 +424,7 @@ export function registerDashboardAPI(fastify, config) {
472
424
  });
473
425
  fastify.get('/api/dashboard/templates/:agentId/:name', async (request, reply) => {
474
426
  const { agentId, name } = request.params;
475
- if (!validateAgentId(agentId)) {
427
+ if (!isValidAgentId(agentId)) {
476
428
  return reply.code(400).send({ error: 'Invalid agent ID' });
477
429
  }
478
430
  const content = getAgentTemplateContent(agentId, name);
@@ -483,7 +435,7 @@ export function registerDashboardAPI(fastify, config) {
483
435
  });
484
436
  fastify.put('/api/dashboard/templates/:agentId/:name', async (request, reply) => {
485
437
  const { agentId, name } = request.params;
486
- if (!validateAgentId(agentId)) {
438
+ if (!isValidAgentId(agentId)) {
487
439
  return reply.code(400).send({ error: 'Invalid agent ID' });
488
440
  }
489
441
  const { content } = request.body;
package/dist/audit.js CHANGED
@@ -1,15 +1,10 @@
1
1
  // Audit log reader/writer for ~/.skimpyclaw/logs/audit/YYYY-MM-DD.jsonl
2
2
  import { randomUUID } from 'crypto';
3
- import { readFileSync, appendFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
3
+ import { appendFileSync, existsSync, mkdirSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { homedir } from 'os';
6
+ import { formatDate, readJsonlDir } from './utils.js';
6
7
  const AUDIT_DIR = join(homedir(), '.skimpyclaw', 'logs', 'audit');
7
- function formatDate(date) {
8
- const y = date.getFullYear();
9
- const m = String(date.getMonth() + 1).padStart(2, '0');
10
- const d = String(date.getDate()).padStart(2, '0');
11
- return `${y}-${m}-${d}`;
12
- }
13
8
  // --- In-memory trace lifecycle ---
14
9
  /** Active traces keyed by traceId */
15
10
  const activeTraces = new Map();
@@ -60,52 +55,11 @@ export async function readAuditTraces(options = {}) {
60
55
  const limit = options.limit ?? 50;
61
56
  const offset = options.offset ?? 0;
62
57
  const triggerFilter = options.trigger;
63
- // Determine date range
64
58
  const endDate = options.endDate ?? new Date();
65
- const startDate = options.startDate ?? new Date(endDate.getTime() - 90 * 24 * 60 * 60 * 1000); // 90 days back
66
- // Get all audit JSONL files in the directory
67
- if (!existsSync(AUDIT_DIR)) {
68
- return { traces: [], total: 0 };
69
- }
70
- const files = readdirSync(AUDIT_DIR)
71
- .filter(f => f.endsWith('.jsonl'))
72
- .sort()
73
- .reverse(); // Newest first
74
- const startStr = formatDate(startDate);
75
- const endStr = formatDate(endDate);
76
- // Collect all matching traces
77
- const allTraces = [];
78
- for (const file of files) {
79
- const dateStr = file.replace('.jsonl', '');
80
- if (dateStr < startStr || dateStr > endStr)
81
- continue;
82
- const filePath = join(AUDIT_DIR, file);
83
- try {
84
- const content = readFileSync(filePath, 'utf-8');
85
- const lines = content.trim().split('\n').filter(Boolean);
86
- // Parse lines in reverse (newest first within file)
87
- for (let i = lines.length - 1; i >= 0; i--) {
88
- try {
89
- const trace = JSON.parse(lines[i]);
90
- if (triggerFilter && trace.trigger !== triggerFilter)
91
- continue;
92
- allTraces.push(trace);
93
- }
94
- catch {
95
- // Skip malformed lines
96
- }
97
- }
98
- }
99
- catch {
100
- // Skip unreadable files
101
- }
102
- }
59
+ const startDate = options.startDate ?? new Date(endDate.getTime() - 90 * 24 * 60 * 60 * 1000);
60
+ const allTraces = readJsonlDir(AUDIT_DIR, formatDate(startDate), formatDate(endDate), triggerFilter ? (t) => t.trigger === triggerFilter : undefined);
103
61
  // Sort newest first by startedAt
104
- allTraces.sort((a, b) => {
105
- const dateA = new Date(b.startedAt || b.endedAt).getTime();
106
- const dateB = new Date(a.startedAt || a.endedAt).getTime();
107
- return dateA - dateB;
108
- });
62
+ allTraces.sort((a, b) => Date.parse(b.startedAt || b.endedAt) - Date.parse(a.startedAt || a.endedAt));
109
63
  const total = allTraces.length;
110
64
  const paged = allTraces.slice(offset, offset + limit);
111
65
  return { traces: paged, total };
@@ -4,7 +4,6 @@ import { spawnSync } from 'child_process';
4
4
  import { getCurrentModel, setCurrentModel, getLastMessage } from '../../gateway.js';
5
5
  import { getCronJobs, runCronJob } from '../../cron.js';
6
6
  import { runHeartbeatCheck } from '../../heartbeat.js';
7
- import { cancelTask, getActiveTasks, getRecentTasks } from '../../subagent.js';
8
7
  import { getActiveCodeAgents, getRecentCodeAgents } from '../../code-agents/index.js';
9
8
  import { listApprovals, approveRequest, denyRequest, getApproval, onApprovalEvent } from '../../exec-approval.js';
10
9
  import { loadSkills } from '../../skills.js';
@@ -47,28 +46,9 @@ export async function handleStatus(ctx, cfg) {
47
46
  const model = getCurrentModel();
48
47
  const last = getLastMessage();
49
48
  const jobs = getCronJobs();
50
- const activeTasks = getActiveTasks();
51
- const recentTasks = getRecentTasks(20);
52
49
  const jobList = jobs
53
50
  .map((j) => ` - ${j.name}: ${j.nextRun?.toLocaleString() || 'unknown'}`)
54
51
  .join('\n');
55
- const pendingCount = activeTasks.filter((t) => t.status === 'pending').length;
56
- const runningCount = activeTasks.filter((t) => t.status === 'running').length;
57
- const maxConcurrent = cfg.subagents?.maxConcurrent ?? 5;
58
- const recentCompleted = recentTasks.filter((t) => t.status === 'completed').length;
59
- const recentFailed = recentTasks.filter((t) => t.status === 'failed').length;
60
- const recentCancelled = recentTasks.filter((t) => t.status === 'cancelled').length;
61
- const activePreview = activeTasks
62
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
63
- .slice(0, 3)
64
- .map((task) => {
65
- const started = task.startedAt || task.createdAt;
66
- const elapsedSeconds = Math.max(0, Math.round((Date.now() - started.getTime()) / 1000));
67
- const elapsed = elapsedSeconds < 60 ? `${elapsedSeconds}s` : `${Math.round(elapsedSeconds / 60)}m`;
68
- const label = task.label ? ` (${task.label})` : '';
69
- return ` - ${task.id} [${task.type}] ${task.status}${label} • ${elapsed}`;
70
- })
71
- .join('\n');
72
52
  // Coding agents status (multi-agent)
73
53
  const caActive = getActiveCodeAgents();
74
54
  const caRecent = getRecentCodeAgents(20);
@@ -109,9 +89,6 @@ export async function handleStatus(ctx, cfg) {
109
89
  `Last message: ${last?.toLocaleString() || 'never'}\n` +
110
90
  `Silence until: ${state.silenceUntil?.toLocaleTimeString() || 'not silenced'}\n\n` +
111
91
  `${caLine}\n\n` +
112
- `Subagents: ${activeTasks.length}/${maxConcurrent} active (running: ${runningCount}, pending: ${pendingCount})\n` +
113
- `Recent (last ${recentTasks.length}): ✅ ${recentCompleted} • ❌ ${recentFailed} • 🚫 ${recentCancelled}\n` +
114
- `${activePreview ? `Active now:\n${activePreview}\n\n` : '\n'}` +
115
92
  `Scheduled jobs:\n${jobList || ' (none)'}`);
116
93
  }
117
94
  export async function handleCron(ctx, cfg) {
@@ -182,45 +159,10 @@ export async function handleRestart(ctx, cfg) {
182
159
  }
183
160
  }
184
161
  export async function handleTasks(ctx, cfg) {
185
- const active = getActiveTasks();
186
- const recent = getRecentTasks(5);
187
- if (recent.length === 0) {
188
- await ctx.reply('No agent tasks yet. Subagents spawn automatically for complex requests.');
189
- return;
190
- }
191
- const formatTask = (t) => {
192
- const elapsed = ((t.completedAt || new Date()).getTime() - t.createdAt.getTime()) / 1000;
193
- const elapsedStr = elapsed < 60 ? `${Math.round(elapsed)}s` : `${Math.round(elapsed / 60)}m`;
194
- const status = {
195
- pending: '⏳ Pending',
196
- running: `🔄 Running (${elapsedStr})`,
197
- completed: `✅ Done (${elapsedStr})`,
198
- failed: `❌ Failed (${elapsedStr})`,
199
- cancelled: '🚫 Cancelled'
200
- };
201
- const promptPreview = t.prompt.slice(0, 60) + (t.prompt.length > 60 ? '...' : '');
202
- return `${t.id}: ${status[t.status] || t.status} [${t.type}] ${promptPreview}`;
203
- };
204
- const lines = [...active, ...recent].slice(0, 10).map(formatTask).join('\n');
205
- await ctx.reply(`Agent tasks:\n\n${lines}`);
162
+ await ctx.reply('Use /agents to list active coding agents, or /cron to manage scheduled tasks.');
206
163
  }
207
164
  export async function handleCancel(ctx, cfg) {
208
- const id = String(ctx.match || '').trim();
209
- if (!id) {
210
- await ctx.reply('Usage: /cancel <task-id>\nExample: /cancel t1');
211
- return;
212
- }
213
- const task = cancelTask(id);
214
- if (!task) {
215
- await ctx.reply(`No task found: ${id}`);
216
- return;
217
- }
218
- if (task.status === 'cancelled') {
219
- await ctx.reply(`Cancelled ${id}.`);
220
- }
221
- else {
222
- await ctx.reply(`Task ${id} is already ${task.status}.`);
223
- }
165
+ await ctx.reply('Use the dashboard to cancel coding agents, or /cron to manage scheduled tasks.');
224
166
  }
225
167
  export async function handleSkills(ctx, cfg) {
226
168
  const skillConfig = cfg.skills;
@@ -3,7 +3,6 @@ import { Bot, GrammyError, HttpError, InputFile } from 'grammy';
3
3
  import { run } from '@grammyjs/runner';
4
4
  import { isAllowed, isRateLimited } from '../../security.js';
5
5
  import { runAgentTurn } from '../../agent.js';
6
- import { initSubagentSystem } from '../../subagent.js';
7
6
  import { getCurrentModel } from '../../gateway.js';
8
7
  import { getApproval, approveRequest, denyRequest } from '../../exec-approval.js';
9
8
  import { transcribeAudio, synthesizeSpeech } from '../../voice.js';
@@ -93,12 +92,6 @@ export async function initTelegram(cfg) {
93
92
  }
94
93
  const bot = new Bot(cfg.channels.telegram.token);
95
94
  activeBot = bot;
96
- // Initialize subagent system with message delivery callback
97
- initSubagentSystem(async (chatId, message) => {
98
- if (!activeBot)
99
- return;
100
- await sendLongMessage({ reply: (text) => activeBot.api.sendMessage(chatId, text) }, message);
101
- });
102
95
  // Register commands with Telegram for the / menu
103
96
  bot.api.setMyCommands(BOT_COMMANDS).catch((err) => {
104
97
  console.error('[telegram] Failed to set bot commands:', err);
package/dist/channels.js CHANGED
@@ -27,7 +27,7 @@ function resolveChannelPreference(config) {
27
27
  }
28
28
  async function loadAdapter(channel) {
29
29
  if (channel === 'telegram') {
30
- const telegram = await import('./telegram.js');
30
+ const telegram = await import('./channels/telegram/index.js');
31
31
  return {
32
32
  init: async (config) => (await telegram.initTelegram(config)) !== null,
33
33
  start: telegram.startTelegram,