skimpyclaw 0.3.5 → 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 (71) hide show
  1. package/README.md +14 -6
  2. package/dist/__tests__/api.test.js +1 -19
  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 +10 -7
  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 -85
  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 +186 -17
  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-BoTHPby4.js +65 -0
  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.d.ts +2 -1
  52. package/dist/setup.js +156 -34
  53. package/dist/skills.js +9 -2
  54. package/dist/subagent.js +33 -2
  55. package/dist/tools/bash-tool.js +8 -0
  56. package/dist/tools/browser-tool.js +3 -2
  57. package/dist/tools/definitions.d.ts +0 -27
  58. package/dist/tools/definitions.js +0 -18
  59. package/dist/tools/execute-context.d.ts +4 -4
  60. package/dist/tools/file-tools.d.ts +1 -1
  61. package/dist/tools/file-tools.js +1 -1
  62. package/dist/tools.d.ts +5 -5
  63. package/dist/tools.js +87 -98
  64. package/dist/types.d.ts +14 -22
  65. package/dist/usage.d.ts +1 -0
  66. package/dist/usage.js +30 -46
  67. package/dist/utils.d.ts +18 -0
  68. package/dist/utils.js +71 -0
  69. package/dist/voice.js +9 -7
  70. package/package.json +1 -1
  71. package/dist/dashboard/assets/index-UVAjSXCG.js +0 -107
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { isPathAllowed } from './path-utils.js';
5
+ import { toErrorMessage } from '../utils.js';
5
6
  let playwrightModule = null;
6
7
  let browserContext = null;
7
8
  let browserPage = null;
@@ -97,11 +98,11 @@ async function ensureBrowser(config, overrides) {
97
98
  '--no-first-run',
98
99
  '--no-default-browser-check',
99
100
  ],
100
- ignoreDefaultArgs: ['--enable-automation'],
101
+ ignoreDefaultArgs: ['--enable-automation', '--no-sandbox'],
101
102
  });
102
103
  }
103
104
  catch (err) {
104
- const msg = err instanceof Error ? err.message : String(err);
105
+ const msg = toErrorMessage(err);
105
106
  throw new Error(`Failed to launch browser (${options.type}): ${msg}. Ensure the browser is installed: npx playwright install ${options.type}`);
106
107
  }
107
108
  const pages = browserContext.pages();
@@ -305,33 +305,6 @@ export declare const TOOL_DEFINITIONS: ({
305
305
  required: string[];
306
306
  };
307
307
  })[];
308
- export declare const SPAWN_SUBAGENT_TOOL: {
309
- name: string;
310
- description: string;
311
- input_schema: {
312
- type: "object";
313
- properties: {
314
- task: {
315
- type: string;
316
- description: string;
317
- };
318
- type: {
319
- type: string;
320
- enum: string[];
321
- description: string;
322
- };
323
- model: {
324
- type: string;
325
- description: string;
326
- };
327
- label: {
328
- type: string;
329
- description: string;
330
- };
331
- };
332
- required: string[];
333
- };
334
- };
335
308
  export declare const CODE_WITH_AGENT_TOOL: {
336
309
  name: string;
337
310
  description: string;
@@ -112,24 +112,6 @@ export const BROWSER_TOOL_DEFINITION = {
112
112
  };
113
113
  // Legacy export for backward compat — static list (built-ins + browser + no MCP)
114
114
  export const TOOL_DEFINITIONS = [...BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION];
115
- export const SPAWN_SUBAGENT_TOOL = {
116
- name: 'spawn_subagent',
117
- description: 'Spawn a background subagent to handle a task independently. Returns immediately with a run ID. Results are announced back to this chat when done. Use for tasks that benefit from parallel work or long-running operations.',
118
- input_schema: {
119
- type: 'object',
120
- properties: {
121
- task: { type: 'string', description: 'What the subagent should do — be specific and self-contained' },
122
- type: {
123
- type: 'string',
124
- enum: ['coding', 'research'],
125
- description: 'Agent type: coding (code/files/bash), research (investigation/reading)',
126
- },
127
- model: { type: 'string', description: 'Optional model override (e.g. claude-opus, claude-think)' },
128
- label: { type: 'string', description: 'Short label for status display (e.g. "write tests", "check logs")' },
129
- },
130
- required: ['task', 'type'],
131
- },
132
- };
133
115
  export const CODE_WITH_AGENT_TOOL = {
134
116
  name: 'code_with_agent',
135
117
  description: 'Delegate a coding task to a coding agent CLI (Claude Code or Codex). The agent will edit files, run commands, and return results. Always use this for code changes instead of writing code directly.',
@@ -1,13 +1,13 @@
1
1
  export interface ExecuteToolContext {
2
- /** Task ID for file lock acquisition (subagent writes) */
2
+ /** Task ID for file lock acquisition (concurrent writes) */
3
3
  lockTaskId?: string;
4
4
  /** Abort signal for cancelling long-running tool loops */
5
5
  abortSignal?: AbortSignal;
6
- /** Chat ID for spawn_subagent dispatch */
6
+ /** Chat ID for channel routing */
7
7
  chatId?: number;
8
- /** Full config for spawn_subagent */
8
+ /** Full config for agent tools */
9
9
  fullConfig?: import('../types.js').Config;
10
- /** Conversation history for spawn_subagent */
10
+ /** Conversation history */
11
11
  history?: import('../types.js').ChatMessage[];
12
12
  /** Audit trace ID for recording tool events */
13
13
  auditTraceId?: string;
@@ -1,7 +1,7 @@
1
1
  import type { ToolConfig } from '../types.js';
2
2
  export declare function executeReadFile(path: string, config: ToolConfig): string;
3
3
  /**
4
- * Write with file locking when a lockTaskId is provided (subagent context).
4
+ * Write with file locking when a lockTaskId is provided (concurrent context).
5
5
  * Falls back to unlocked write when no lockTaskId.
6
6
  */
7
7
  export declare function executeWriteFileLocked(path: string, content: string, config: ToolConfig, lockTaskId?: string): Promise<string>;
@@ -26,7 +26,7 @@ function executeWriteFile(path, content, config) {
26
26
  return `Written: ${path} (${content.length} bytes)`;
27
27
  }
28
28
  /**
29
- * Write with file locking when a lockTaskId is provided (subagent context).
29
+ * Write with file locking when a lockTaskId is provided (concurrent context).
30
30
  * Falls back to unlocked write when no lockTaskId.
31
31
  */
32
32
  export async function executeWriteFileLocked(path, content, config, lockTaskId) {
package/dist/tools.d.ts CHANGED
@@ -1,21 +1,21 @@
1
1
  import type { ToolConfig } from './types.js';
2
- import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, SPAWN_SUBAGENT_TOOL, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL } from './tools/definitions.js';
2
+ import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL } from './tools/definitions.js';
3
3
  import type { ExecuteToolContext } from './tools/execute-context.js';
4
4
  import { cleanupBrowser } from './tools/browser-tool.js';
5
5
  export { getActiveCodeAgents, getRecentCodeAgents, getAllCodeAgents, getCodeAgent, cancelCodeAgent, restoreCodeAgentTasks, setCodeAgentConfig, computeWaves, decomposeTask, synthesizeResults, runValidation, runCodeAgentBackground, buildCodeAgentArgs, readTeamState, parseStreamJsonForLive, } from './code-agents/index.js';
6
- export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, SPAWN_SUBAGENT_TOOL, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
6
+ export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
7
7
  export type { ExecuteToolContext };
8
8
  export type { CodeAgentTask, DecomposedSubtask } from './code-agents/index.js';
9
9
  export declare function discoverMcpTools(): Promise<any[]>;
10
10
  export declare function clearMcpToolCache(): void;
11
11
  /**
12
- * Get all available tool definitions: built-ins + browser (if enabled) + MCP (auto-discovered) + spawn_subagent.
12
+ * Get all available tool definitions: built-ins + browser (if enabled) + MCP (auto-discovered) + agent tools.
13
13
  * This is the primary way to get tools — replaces the static TOOL_DEFINITIONS export.
14
- * Pass includeSpawnSubagent: true to include the spawn_subagent tool (e.g. for Telegram conversations).
14
+ * Pass includeAgentTools: true to include code_with_agent, code_with_team, check_code_agent.
15
15
  * Results are cached for 60s to avoid rebuilding the array on every agent turn.
16
16
  */
17
17
  export declare function getToolDefinitions(config?: ToolConfig, options?: {
18
- includeSpawnSubagent?: boolean;
18
+ includeAgentTools?: boolean;
19
19
  includeMcp?: boolean;
20
20
  projects?: Record<string, string>;
21
21
  }): Promise<any[]>;
package/dist/tools.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // Tool definitions and executors for Anthropic API tool_use
2
- import { join } from 'path';
2
+ import { join, resolve } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { TTLCache } from './cache.js';
5
- import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, SPAWN_SUBAGENT_TOOL, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, } from './tools/definitions.js';
5
+ import { toErrorMessage } from './utils.js';
6
+ import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, } from './tools/definitions.js';
6
7
  import { executeReadFile, executeWriteFileLocked, executeListDirectory } from './tools/file-tools.js';
7
8
  import { executeBash } from './tools/bash-tool.js';
8
9
  import { executeBrowser, cleanupBrowser } from './tools/browser-tool.js';
9
10
  import { ensureContainer, SANDBOX_DEFAULTS, translatePath, validateMountPaths } from './sandbox/index.js';
10
11
  import { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './sandbox/index.js';
12
+ import { isBashCommandSafe } from './security.js';
13
+ import { classifyCommandRisk, requiresApproval } from './exec-approval.js';
11
14
  // Re-export from code-agents module for backward compatibility
12
15
  export {
13
16
  // Registry functions
@@ -23,7 +26,7 @@ buildCodeAgentArgs, readTeamState,
23
26
  // Parsers
24
27
  parseStreamJsonForLive, } from './code-agents/index.js';
25
28
  // Re-export from definitions
26
- export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, SPAWN_SUBAGENT_TOOL, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
29
+ export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
27
30
  // --- MCP (mcporter) ---
28
31
  let mcpRuntime = null;
29
32
  async function getMcpRuntime() {
@@ -82,83 +85,78 @@ export function clearMcpToolCache() {
82
85
  discoveredMcpTools = null;
83
86
  }
84
87
  const toolDefsCache = new TTLCache(60_000);
88
+ /** Inject project names into a tool's workdir description, or return the tool unchanged. */
89
+ function injectProjects(tool, projects) {
90
+ if (!projects || Object.keys(projects).length === 0)
91
+ return tool;
92
+ const projectList = Object.entries(projects)
93
+ .map(([name, path]) => `"${name}" → ${path}`)
94
+ .join(', ');
95
+ return {
96
+ ...tool,
97
+ input_schema: {
98
+ ...tool.input_schema,
99
+ properties: {
100
+ ...tool.input_schema.properties,
101
+ workdir: {
102
+ type: 'string',
103
+ description: `Working directory or project name. Named projects: ${projectList}. Default: SkimpyClaw repo root.`,
104
+ },
105
+ },
106
+ },
107
+ };
108
+ }
85
109
  /**
86
- * Get all available tool definitions: built-ins + browser (if enabled) + MCP (auto-discovered) + spawn_subagent.
110
+ * Get all available tool definitions: built-ins + browser (if enabled) + MCP (auto-discovered) + agent tools.
87
111
  * This is the primary way to get tools — replaces the static TOOL_DEFINITIONS export.
88
- * Pass includeSpawnSubagent: true to include the spawn_subagent tool (e.g. for Telegram conversations).
112
+ * Pass includeAgentTools: true to include code_with_agent, code_with_team, check_code_agent.
89
113
  * Results are cached for 60s to avoid rebuilding the array on every agent turn.
90
114
  */
91
115
  export async function getToolDefinitions(config, options) {
92
116
  const includeMcp = options?.includeMcp !== false; // default true for backwards compat
117
+ const profile = config?.toolProfile ?? 'full';
93
118
  const cacheKey = JSON.stringify({
94
119
  browser: config?.browser?.enabled,
95
- spawn: options?.includeSpawnSubagent,
120
+ agentTools: options?.includeAgentTools,
96
121
  mcp: includeMcp,
97
122
  projects: options?.projects,
123
+ profile,
98
124
  });
99
125
  const cached = toolDefsCache.get(cacheKey);
100
126
  if (cached)
101
127
  return cached;
102
128
  const tools = [...BUILTIN_TOOL_DEFINITIONS];
129
+ // Minimal profile: only the 4 built-in tools (Read, Write, Glob, Bash).
130
+ // Used by orchestrator decompose/synthesize calls.
131
+ if (profile === 'minimal') {
132
+ toolDefsCache.set(cacheKey, tools);
133
+ return tools;
134
+ }
103
135
  // Include browser tool only when explicitly enabled
104
136
  if (config?.browser?.enabled) {
105
137
  tools.push(BROWSER_TOOL_DEFINITION);
106
138
  }
139
+ // Coding profile: built-ins + browser + code_with_agent + check_code_agent.
140
+ // Skips MCP discovery and code_with_team.
141
+ if (profile === 'coding') {
142
+ if (options?.includeAgentTools) {
143
+ tools.push(injectProjects(CODE_WITH_AGENT_TOOL, options.projects));
144
+ tools.push(CHECK_CODE_AGENT_TOOL);
145
+ }
146
+ toolDefsCache.set(cacheKey, tools);
147
+ return tools;
148
+ }
149
+ // Full profile (default): everything including MCP and code_with_team.
107
150
  // Auto-discover MCP tools from mcporter config (only for Anthropic models)
108
151
  if (includeMcp) {
109
152
  const mcpTools = await discoverMcpTools();
110
153
  tools.push(...mcpTools);
111
154
  }
112
- // Include spawn_subagent, code_with_agent, and check_code_agent tools when requested
113
- if (options?.includeSpawnSubagent) {
114
- tools.push(SPAWN_SUBAGENT_TOOL);
115
- // Inject project names into code_with_agent description so the model knows what to use
155
+ // Include code_with_agent, code_with_team, and check_code_agent when requested
156
+ if (options?.includeAgentTools) {
116
157
  const projects = options.projects;
117
- if (projects && Object.keys(projects).length > 0) {
118
- const projectList = Object.entries(projects)
119
- .map(([name, path]) => `"${name}" → ${path}`)
120
- .join(', ');
121
- const codeAgentWithProjects = {
122
- ...CODE_WITH_AGENT_TOOL,
123
- input_schema: {
124
- ...CODE_WITH_AGENT_TOOL.input_schema,
125
- properties: {
126
- ...CODE_WITH_AGENT_TOOL.input_schema.properties,
127
- workdir: {
128
- type: 'string',
129
- description: `Working directory or project name. Named projects: ${projectList}. Default: SkimpyClaw repo root.`,
130
- },
131
- },
132
- },
133
- };
134
- tools.push(codeAgentWithProjects);
135
- }
136
- else {
137
- tools.push(CODE_WITH_AGENT_TOOL);
138
- }
139
- // Inject project names into code_with_team description too
140
- if (projects && Object.keys(projects).length > 0) {
141
- const projectList = Object.entries(projects)
142
- .map(([name, path]) => `"${name}" → ${path}`)
143
- .join(', ');
144
- const codeTeamWithProjects = {
145
- ...CODE_WITH_TEAM_TOOL,
146
- input_schema: {
147
- ...CODE_WITH_TEAM_TOOL.input_schema,
148
- properties: {
149
- ...CODE_WITH_TEAM_TOOL.input_schema.properties,
150
- workdir: {
151
- type: 'string',
152
- description: `Working directory or project name. Named projects: ${projectList}. Default: SkimpyClaw repo root.`,
153
- },
154
- },
155
- },
156
- };
157
- tools.push(codeTeamWithProjects);
158
- }
159
- else {
160
- tools.push(CODE_WITH_TEAM_TOOL);
161
- }
158
+ tools.push(injectProjects(CODE_WITH_AGENT_TOOL, projects));
159
+ tools.push(injectProjects(CODE_WITH_TEAM_TOOL, projects));
162
160
  tools.push(CHECK_CODE_AGENT_TOOL);
163
161
  }
164
162
  toolDefsCache.set(cacheKey, tools);
@@ -250,8 +248,6 @@ export async function executeTool(name, input, config, context) {
250
248
  const { isPathAllowed } = await import('./tools/path-utils.js');
251
249
  for (const [key, value] of Object.entries(input)) {
252
250
  if (typeof value === 'string' && (value.startsWith('/') || value.startsWith('~/') || value.startsWith('./'))) {
253
- const { resolve } = await import('path');
254
- const { homedir } = await import('os');
255
251
  const resolved = value.startsWith('~/') ? resolve(homedir(), value.slice(2)) : resolve(value);
256
252
  if (!isPathAllowed(resolved, config.allowedPaths)) {
257
253
  return `Error: MCP tool argument "${key}" references path outside allowed directories: ${value}`;
@@ -261,10 +257,6 @@ export async function executeTool(name, input, config, context) {
261
257
  }
262
258
  return await executeMcpToolGeneric(name, input);
263
259
  }
264
- // Route spawn_subagent
265
- if (name === 'spawn_subagent') {
266
- return await executeSpawnSubagent(input, context);
267
- }
268
260
  // Route code_with_agent - delegate to code-agents module
269
261
  if (name === 'code_with_agent') {
270
262
  const { executeCodeWithAgent } = await import('./code-agents/index.js');
@@ -330,8 +322,39 @@ export async function executeTool(name, input, config, context) {
330
322
  return reversed;
331
323
  };
332
324
  switch (normalized) {
333
- case 'bash':
334
- return await sandboxBash(containerName, translateBashPaths(input.command), input.cwd ? tp(input.cwd) : undefined, config.bashTimeout);
325
+ case 'bash': {
326
+ const translatedCmd = translateBashPaths(input.command);
327
+ // Apply hard safety blocks and exec-approval inside sandbox.
328
+ // The sandbox provides filesystem isolation but not command-level policy.
329
+ if (!isBashCommandSafe(translatedCmd)) {
330
+ return 'Error: Command blocked by safety filter.';
331
+ }
332
+ const classification = classifyCommandRisk(translatedCmd);
333
+ const approvalConfig = config.execApproval;
334
+ if (requiresApproval(classification, approvalConfig)) {
335
+ const isUnattended = context?.channel === 'subagent' ||
336
+ context?.isCronJob === true ||
337
+ (!context?.approverUserId && !context?.channelTargetId && !context?.chatId);
338
+ if (isUnattended) {
339
+ return `⛔ Command blocked — tier ${classification.tier} commands require approval but no approver is available in this context (${classification.reason}). Use safer alternatives or request approval via an interactive channel.`;
340
+ }
341
+ // Attended context — run full approval flow before executing in sandbox
342
+ const { createApprovalRequest: sbxCreate, waitForApproval: sbxWait } = await import('./exec-approval.js');
343
+ const ttlMs = approvalConfig?.ttlMs ?? 5 * 60 * 1000;
344
+ const channelMeta = context?.channel ? {
345
+ channel: context.channel,
346
+ chatId: context.channelTargetId ?? context.chatId,
347
+ userId: context.approverUserId,
348
+ username: context.approverUsername,
349
+ } : context?.chatId ? { channel: 'telegram', chatId: context.chatId } : undefined;
350
+ const sbxReq = sbxCreate(translatedCmd, input.cwd, classification, approvalConfig, channelMeta);
351
+ const sbxResolved = await sbxWait(sbxReq.id, ttlMs);
352
+ if (sbxResolved.status !== 'approved') {
353
+ return `⛔ Command not executed — approval ${sbxResolved.status} (tier ${classification.tier}: ${classification.reason}).`;
354
+ }
355
+ }
356
+ return await sandboxBash(containerName, translatedCmd, input.cwd ? tp(input.cwd) : undefined, config.bashTimeout);
357
+ }
335
358
  case 'read_file':
336
359
  return await sandboxReadFile(containerName, tp(input.file_path || input.path));
337
360
  case 'write_file':
@@ -365,41 +388,7 @@ export async function executeTool(name, input, config, context) {
365
388
  }
366
389
  }
367
390
  catch (err) {
368
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
391
+ return `Error: ${toErrorMessage(err)}`;
369
392
  }
370
393
  }
371
394
  // --- Individual Tool Implementations ---
372
- /**
373
- * Execute spawn_subagent tool — dispatches a background subagent.
374
- */
375
- async function executeSpawnSubagent(input, context) {
376
- if (!context?.fullConfig || !context?.chatId) {
377
- return 'Error: spawn_subagent requires a chat context (not available in this mode)';
378
- }
379
- const { dispatchSubagent } = await import('./subagent.js');
380
- const task = input.task;
381
- const type = input.type;
382
- const model = input.model;
383
- const label = input.label;
384
- // NOTE: allowedPaths deliberately NOT accepted from model input (security — prevents path escalation).
385
- // Subagents inherit their preset's allowedPaths from config.
386
- if (!task || !type) {
387
- return 'Error: task and type are required';
388
- }
389
- if (!['coding', 'research'].includes(type)) {
390
- return `Error: Invalid type "${type}". Must be coding or research.`;
391
- }
392
- try {
393
- const subagentTask = dispatchSubagent(type, task, context.chatId, context.fullConfig, model, context.history, { label });
394
- const labelStr = label ? ` "${label}"` : '';
395
- return JSON.stringify({
396
- status: 'accepted',
397
- runId: subagentTask.id,
398
- label: label || subagentTask.type,
399
- message: `Subagent ${subagentTask.id}${labelStr} dispatched (${subagentTask.type}, model: ${subagentTask.model}). Results will be announced when done.`,
400
- });
401
- }
402
- catch (err) {
403
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
404
- }
405
- }
package/dist/types.d.ts CHANGED
@@ -60,10 +60,16 @@ export interface Config {
60
60
  token?: string;
61
61
  frontend?: 'framework';
62
62
  };
63
- subagents?: {
63
+ codeAgents?: {
64
64
  maxConcurrent?: number;
65
- maxRetries?: number;
66
- defaultCodeAgent?: string;
65
+ defaultAgent?: string;
66
+ timeoutMinutes?: number;
67
+ teamTimeoutMinutes?: number;
68
+ maxTurns?: number;
69
+ skipPlaywright?: boolean;
70
+ /** Per-project validation commands. Keys match project names from `projects` config.
71
+ * Values are shell commands run in the project dir. Overrides auto-detected build+test. */
72
+ validationCommands?: Record<string, string>;
67
73
  };
68
74
  langfuse?: {
69
75
  enabled?: boolean;
@@ -150,6 +156,11 @@ export interface ToolConfig {
150
156
  maxIterations?: number;
151
157
  bashTimeout?: number;
152
158
  maxTurnTokens?: number;
159
+ toolProfile?: 'minimal' | 'coding' | 'full';
160
+ contextManagement?: {
161
+ enabled?: boolean;
162
+ maxContextTokens?: number;
163
+ };
153
164
  browser?: {
154
165
  enabled?: boolean;
155
166
  type?: 'chromium' | 'firefox' | 'webkit';
@@ -200,25 +211,6 @@ export interface ModelProvider {
200
211
  name: string;
201
212
  chat(messages: ChatMessage[], options: ChatOptions): Promise<string>;
202
213
  }
203
- export type SubagentType = 'coding' | 'research';
204
- export type SubagentStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
205
- export interface SubagentTask {
206
- id: string;
207
- type: SubagentType;
208
- prompt: string;
209
- status: SubagentStatus;
210
- chatId: number;
211
- model: string;
212
- label?: string;
213
- createdAt: Date;
214
- startedAt?: Date;
215
- completedAt?: Date;
216
- result?: string;
217
- error?: string;
218
- retryCount?: number;
219
- maxRetries?: number;
220
- abortController: AbortController;
221
- }
222
214
  export type ImageContentBlock = {
223
215
  type: 'image';
224
216
  source: {
package/dist/usage.d.ts CHANGED
@@ -72,5 +72,6 @@ export declare function readUsageRecords(options?: ReadUsageOptions): {
72
72
  export declare function aggregateUsage(startDate: string, endDate: string): UsageAggregation;
73
73
  /**
74
74
  * Get usage summary for today, last 7 days, and last 30 days.
75
+ * Reads files once for the full 30-day window, then partitions in memory.
75
76
  */
76
77
  export declare function getUsageSummary(): UsageSummary;
package/dist/usage.js CHANGED
@@ -1,18 +1,10 @@
1
1
  // Usage tracking — JSONL append-only storage at ~/.skimpyclaw/logs/usage/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 USAGE_DIR = join(homedir(), '.skimpyclaw', 'logs', 'usage');
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
- function getUsageFilePath(dateStr) {
14
- return join(USAGE_DIR, `${dateStr}.jsonl`);
15
- }
16
8
  /** For testing: override the usage directory */
17
9
  let usageDirOverride = null;
18
10
  export function setUsageDirForTesting(dir) {
@@ -67,45 +59,15 @@ export function buildUsageRecord(opts) {
67
59
  */
68
60
  export function readUsageRecords(options = {}) {
69
61
  const dir = getUsageDir();
70
- if (!existsSync(dir)) {
71
- return { records: [], total: 0 };
72
- }
73
62
  const limit = options.limit ?? 50;
74
63
  const offset = options.offset ?? 0;
75
64
  const now = new Date();
76
65
  const endDate = options.endDate ?? formatDate(now);
77
66
  const startDate = options.startDate ?? formatDate(new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000));
78
- const files = readdirSync(dir)
79
- .filter(f => f.endsWith('.jsonl'))
80
- .sort()
81
- .reverse(); // newest first
82
- const allRecords = [];
83
- for (const file of files) {
84
- const dateStr = file.replace('.jsonl', '');
85
- if (dateStr < startDate || dateStr > endDate)
86
- continue;
87
- const filePath = join(dir, file);
88
- try {
89
- const content = readFileSync(filePath, 'utf-8');
90
- const lines = content.trim().split('\n').filter(Boolean);
91
- for (let i = lines.length - 1; i >= 0; i--) {
92
- try {
93
- const record = JSON.parse(lines[i]);
94
- if (options.model && record.model !== options.model)
95
- continue;
96
- allRecords.push(record);
97
- }
98
- catch {
99
- // Skip malformed lines
100
- }
101
- }
102
- }
103
- catch {
104
- // Skip unreadable files
105
- }
106
- }
67
+ const modelFilter = options.model;
68
+ const allRecords = readJsonlDir(dir, startDate, endDate, modelFilter ? (r) => r.model === modelFilter : undefined);
107
69
  // Sort newest first
108
- allRecords.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
70
+ allRecords.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
109
71
  const total = allRecords.length;
110
72
  const paged = allRecords.slice(offset, offset + limit);
111
73
  return { records: paged, total };
@@ -136,15 +98,37 @@ export function aggregateUsage(startDate, endDate) {
136
98
  }
137
99
  /**
138
100
  * Get usage summary for today, last 7 days, and last 30 days.
101
+ * Reads files once for the full 30-day window, then partitions in memory.
139
102
  */
140
103
  export function getUsageSummary() {
141
104
  const now = new Date();
142
105
  const todayStr = formatDate(now);
143
106
  const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
144
107
  const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
108
+ const weekAgoStr = formatDate(weekAgo);
109
+ const monthAgoStr = formatDate(monthAgo);
110
+ // Read all records once for the full 30-day range
111
+ const { records: allRecords } = readUsageRecords({ startDate: monthAgoStr, endDate: todayStr, limit: 100000 });
112
+ const aggregate = (records) => {
113
+ const agg = emptyAggregation();
114
+ for (const r of records) {
115
+ agg.totalCost += r.totalCost;
116
+ agg.totalInputTokens += r.inputTokens;
117
+ agg.totalOutputTokens += r.outputTokens;
118
+ agg.totalCalls++;
119
+ if (!agg.byModel[r.model]) {
120
+ agg.byModel[r.model] = { calls: 0, inputTokens: 0, outputTokens: 0, cost: 0 };
121
+ }
122
+ agg.byModel[r.model].calls++;
123
+ agg.byModel[r.model].inputTokens += r.inputTokens;
124
+ agg.byModel[r.model].outputTokens += r.outputTokens;
125
+ agg.byModel[r.model].cost += r.totalCost;
126
+ }
127
+ return agg;
128
+ };
145
129
  return {
146
- today: aggregateUsage(todayStr, todayStr),
147
- week: aggregateUsage(formatDate(weekAgo), todayStr),
148
- month: aggregateUsage(formatDate(monthAgo), todayStr),
130
+ today: aggregate(allRecords.filter(r => r.timestamp.slice(0, 10) === todayStr)),
131
+ week: aggregate(allRecords.filter(r => r.timestamp.slice(0, 10) >= weekAgoStr)),
132
+ month: aggregate(allRecords),
149
133
  };
150
134
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Format a Date as YYYY-MM-DD string.
3
+ */
4
+ export declare function formatDate(date: Date): string;
5
+ /**
6
+ * Extract an error message from an unknown caught value.
7
+ */
8
+ export declare function toErrorMessage(err: unknown): string;
9
+ /**
10
+ * Read JSONL files from a dated directory (YYYY-MM-DD.jsonl), filtered by date range.
11
+ * Returns records in reverse chronological order (newest first).
12
+ */
13
+ export declare function readJsonlDir<T>(dir: string, startDate: string, endDate: string, filter?: (record: T) => boolean): T[];
14
+ /**
15
+ * Timing-safe bearer token validation.
16
+ * Returns true if the provided token matches the expected token.
17
+ */
18
+ export declare function validateBearerToken(expected: string, authHeader: string | undefined): boolean;