osborn 0.1.1 → 0.1.6

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.
@@ -3,8 +3,10 @@ import { EventEmitter } from 'events';
3
3
  interface ClaudeHandlerOptions {
4
4
  workingDirectory?: string;
5
5
  allowedTools?: string[];
6
- permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions';
6
+ permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
7
7
  mcpServers?: Record<string, McpServerConfig>;
8
+ requireAllPermissions?: boolean;
9
+ agentRole?: 'plan' | 'execute';
8
10
  }
9
11
  export type { McpServerConfig };
10
12
  export interface PermissionRequestEvent {
@@ -28,10 +30,25 @@ export declare class ClaudeHandler extends EventEmitter {
28
30
  private abortController;
29
31
  private sessionId;
30
32
  private pendingPermission;
31
- private dangerousTools;
33
+ private toolStartTimes;
32
34
  private alwaysAllowedTools;
33
35
  private static readonly ALL_TOOLS;
36
+ private static readonly PLAN_TOOLS;
37
+ private static readonly EXECUTE_TOOLS;
38
+ private agentRole;
34
39
  constructor(options?: ClaudeHandlerOptions);
40
+ /**
41
+ * Get the agent's role
42
+ */
43
+ getRole(): 'plan' | 'execute';
44
+ /**
45
+ * Check if this is a plan-mode agent
46
+ */
47
+ isPlanMode(): boolean;
48
+ /**
49
+ * Generate human-readable description for a tool call
50
+ */
51
+ private getToolDescription;
35
52
  run(prompt: string): Promise<string>;
36
53
  /**
37
54
  * Request permission from user via event emission
@@ -1,13 +1,41 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { EventEmitter } from 'events';
3
+ // Log tool calls to terminal (async, non-blocking)
4
+ function logToolCall(entry) {
5
+ // Use setImmediate to avoid blocking the main execution
6
+ setImmediate(() => {
7
+ const time = new Date().toLocaleTimeString();
8
+ const inputStr = JSON.stringify(entry.input).substring(0, 100);
9
+ switch (entry.status) {
10
+ case 'started':
11
+ console.log(`\nšŸ”§ [${time}] TOOL START: ${entry.toolName}`);
12
+ console.log(` šŸ“„ Input: ${inputStr}${inputStr.length >= 100 ? '...' : ''}`);
13
+ break;
14
+ case 'completed':
15
+ const duration = entry.duration ? `${entry.duration}ms` : '?';
16
+ console.log(`āœ… [${time}] TOOL DONE: ${entry.toolName} (${duration})`);
17
+ if (entry.output) {
18
+ const outStr = typeof entry.output === 'string'
19
+ ? entry.output.substring(0, 150)
20
+ : JSON.stringify(entry.output).substring(0, 150);
21
+ console.log(` šŸ“¤ Output: ${outStr}${outStr.length >= 150 ? '...' : ''}`);
22
+ }
23
+ break;
24
+ case 'blocked':
25
+ console.log(`āŒ [${time}] TOOL BLOCKED: ${entry.toolName}`);
26
+ console.log(` ā›” Reason: ${entry.error || 'User denied'}`);
27
+ break;
28
+ }
29
+ });
30
+ }
3
31
  export class ClaudeHandler extends EventEmitter {
4
32
  options;
5
33
  abortController = null;
6
34
  sessionId = null;
7
35
  pendingPermission = null;
8
- // Tools that require permission
9
- dangerousTools = ['Bash', 'Write', 'Edit'];
10
- // Tools the user has permanently approved
36
+ // Track tool call start times for duration logging
37
+ toolStartTimes = new Map();
38
+ // Tools the user has permanently approved (for this session)
11
39
  alwaysAllowedTools = new Set();
12
40
  // All available Claude Agent SDK tools
13
41
  static ALL_TOOLS = [
@@ -26,19 +54,94 @@ export class ClaudeHandler extends EventEmitter {
26
54
  // LSP (Language Server Protocol)
27
55
  'LSP',
28
56
  ];
57
+ // Plan mode tools - read-only, research, context gathering
58
+ static PLAN_TOOLS = [
59
+ 'Read', // View file contents
60
+ 'Glob', // File pattern matching
61
+ 'Grep', // Content searching
62
+ 'Bash', // Read-only bash (ls, git status, git log, etc.)
63
+ 'Task', // Research agents
64
+ 'WebFetch', // Web content analysis
65
+ 'WebSearch', // Internet searching
66
+ 'LSP', // Code intelligence (go to definition, references)
67
+ ];
68
+ // Execute mode tools - full access
69
+ static EXECUTE_TOOLS = ClaudeHandler.ALL_TOOLS;
70
+ agentRole;
29
71
  constructor(options = {}) {
30
72
  super();
73
+ // Set agent role
74
+ this.agentRole = options.agentRole || (options.permissionMode === 'plan' ? 'plan' : 'execute');
75
+ // For plan mode, restrict to read-only tools
76
+ const isPlanMode = options.permissionMode === 'plan';
77
+ const defaultTools = isPlanMode ? ClaudeHandler.PLAN_TOOLS : ClaudeHandler.ALL_TOOLS;
31
78
  this.options = {
32
79
  workingDirectory: options.workingDirectory || process.cwd(),
33
- allowedTools: options.allowedTools || ClaudeHandler.ALL_TOOLS,
34
- permissionMode: options.permissionMode || 'default',
80
+ allowedTools: options.allowedTools || defaultTools,
81
+ // Plan mode uses 'default' permission mode but with restricted tools
82
+ permissionMode: isPlanMode ? 'default' : (options.permissionMode || 'default'),
35
83
  mcpServers: options.mcpServers,
84
+ // Plan mode doesn't require permissions (read-only is safe)
85
+ // Execute mode requires permissions for safety
86
+ requireAllPermissions: isPlanMode ? false : (options.requireAllPermissions ?? true),
36
87
  };
88
+ const roleEmoji = this.agentRole === 'plan' ? 'šŸ“‹' : 'šŸ”Ø';
89
+ console.log(`${roleEmoji} Agent role: ${this.agentRole.toUpperCase()}`);
37
90
  console.log(`šŸ”§ Allowed tools: ${this.options.allowedTools?.join(', ')}`);
91
+ console.log(`šŸ” Require permissions: ${this.options.requireAllPermissions}`);
38
92
  if (this.options.mcpServers) {
39
93
  console.log(`šŸ”Œ MCP servers: ${Object.keys(this.options.mcpServers).join(', ')}`);
40
94
  }
41
95
  }
96
+ /**
97
+ * Get the agent's role
98
+ */
99
+ getRole() {
100
+ return this.agentRole;
101
+ }
102
+ /**
103
+ * Check if this is a plan-mode agent
104
+ */
105
+ isPlanMode() {
106
+ return this.agentRole === 'plan';
107
+ }
108
+ /**
109
+ * Generate human-readable description for a tool call
110
+ */
111
+ getToolDescription(toolName, toolInput) {
112
+ switch (toolName) {
113
+ case 'Bash':
114
+ return `Run command: ${toolInput.command || 'unknown command'}`;
115
+ case 'Write':
116
+ return `Create file: ${toolInput.file_path || 'unknown file'}`;
117
+ case 'Edit':
118
+ return `Edit file: ${toolInput.file_path || 'unknown file'}`;
119
+ case 'MultiEdit':
120
+ return `Multi-edit: ${toolInput.edits?.length || 0} edits`;
121
+ case 'Read':
122
+ return `Read file: ${toolInput.file_path || 'unknown file'}`;
123
+ case 'Glob':
124
+ return `Find files: ${toolInput.pattern || 'unknown pattern'}`;
125
+ case 'Grep':
126
+ return `Search content: "${toolInput.pattern || 'unknown'}" in ${toolInput.path || 'cwd'}`;
127
+ case 'WebSearch':
128
+ return `🌐 Web search: "${toolInput.query || 'unknown query'}"`;
129
+ case 'WebFetch':
130
+ return `🌐 Fetch URL: ${toolInput.url || 'unknown url'}`;
131
+ case 'NotebookEdit':
132
+ return `Edit notebook: ${toolInput.notebook_path || 'unknown'}`;
133
+ case 'Task':
134
+ return `Spawn task: ${toolInput.description || 'unknown task'}`;
135
+ case 'TodoWrite':
136
+ return `Update todos: ${toolInput.todos?.length || 0} items`;
137
+ case 'LSP':
138
+ return `LSP ${toolInput.operation || 'query'}: ${toolInput.filePath || 'unknown'}`;
139
+ default:
140
+ // For MCP tools, show the tool name and first few input keys
141
+ const inputKeys = Object.keys(toolInput || {}).slice(0, 3).join(', ');
142
+ return `${toolName}: ${inputKeys || 'no params'}`;
143
+ }
144
+ }
42
145
  async run(prompt) {
43
146
  this.abortController = new AbortController();
44
147
  let fullResponse = '';
@@ -68,10 +171,26 @@ export class ClaudeHandler extends EventEmitter {
68
171
  hooks: [async (input, toolUseId) => {
69
172
  const toolName = input?.tool_name || 'unknown';
70
173
  const toolInput = input?.tool_input || {};
174
+ const id = toolUseId || `tool-${Date.now()}`;
175
+ const description = this.getToolDescription(toolName, toolInput);
176
+ // Record start time for duration tracking
177
+ this.toolStartTimes.set(id, Date.now());
178
+ // Log tool start (background, non-blocking)
179
+ logToolCall({
180
+ timestamp: new Date().toISOString(),
181
+ toolName,
182
+ toolUseId: id,
183
+ input: toolInput,
184
+ status: 'started',
185
+ });
71
186
  console.log(`šŸ”§ Tool: ${toolName}`);
72
- this.emit('tool_use', { name: toolName, input: toolInput });
187
+ console.log(` šŸ“‹ ${description}`);
188
+ this.emit('tool_use', { name: toolName, input: toolInput, description });
73
189
  // Check if this tool needs permission
74
- if (this.dangerousTools.includes(toolName) && this.options.permissionMode === 'default') {
190
+ // In default mode with requireAllPermissions, ALL tools need permission
191
+ const needsPermission = this.options.permissionMode === 'default' &&
192
+ this.options.requireAllPermissions;
193
+ if (needsPermission) {
75
194
  // Skip if user has permanently approved this tool
76
195
  if (this.alwaysAllowedTools.has(toolName)) {
77
196
  console.log(`āœ… Auto-approved (always allow): ${toolName}`);
@@ -79,9 +198,18 @@ export class ClaudeHandler extends EventEmitter {
79
198
  else {
80
199
  console.log(`āš ļø Permission required for: ${toolName}`);
81
200
  // Emit permission request and wait for approval
82
- const response = await this.requestPermission(toolName, toolInput, toolUseId || 'unknown');
201
+ const response = await this.requestPermission(toolName, toolInput, id, description);
83
202
  if (response === 'deny') {
84
203
  console.log(`āŒ Permission denied for: ${toolName}`);
204
+ // Log blocked tool
205
+ logToolCall({
206
+ timestamp: new Date().toISOString(),
207
+ toolName,
208
+ toolUseId: id,
209
+ input: toolInput,
210
+ status: 'blocked',
211
+ error: 'User denied permission',
212
+ });
85
213
  return {
86
214
  decision: 'block',
87
215
  reason: 'User denied permission for this operation'
@@ -101,10 +229,26 @@ export class ClaudeHandler extends EventEmitter {
101
229
  }],
102
230
  PostToolUse: [{
103
231
  matcher: '.*',
104
- hooks: [async (input) => {
232
+ hooks: [async (input, toolUseId) => {
105
233
  const toolName = input?.tool_name || 'unknown';
106
- console.log(`āœ… Completed: ${toolName}`);
107
- this.emit('tool_result', { name: toolName });
234
+ const toolOutput = input?.tool_output || input?.output;
235
+ const id = toolUseId || 'unknown';
236
+ // Calculate duration
237
+ const startTime = this.toolStartTimes.get(id);
238
+ const duration = startTime ? Date.now() - startTime : undefined;
239
+ this.toolStartTimes.delete(id);
240
+ // Log tool completion (background, non-blocking)
241
+ logToolCall({
242
+ timestamp: new Date().toISOString(),
243
+ toolName,
244
+ toolUseId: id,
245
+ input: input?.tool_input || {},
246
+ status: 'completed',
247
+ output: toolOutput ? (typeof toolOutput === 'string' ? toolOutput.substring(0, 500) : 'object') : undefined,
248
+ duration,
249
+ });
250
+ console.log(`āœ… Completed: ${toolName} (${duration ? duration + 'ms' : 'unknown duration'})`);
251
+ this.emit('tool_result', { name: toolName, output: toolOutput, duration });
108
252
  return {};
109
253
  }]
110
254
  }]
@@ -168,24 +312,15 @@ export class ClaudeHandler extends EventEmitter {
168
312
  * Request permission from user via event emission
169
313
  * Returns a promise that resolves when user responds with allow/deny/always_allow
170
314
  */
171
- requestPermission(toolName, toolInput, toolUseId) {
315
+ requestPermission(toolName, toolInput, toolUseId, description) {
172
316
  return new Promise((resolve) => {
173
- // Format the permission request message
174
- let description = '';
175
- if (toolName === 'Bash') {
176
- description = `Run command: ${toolInput.command || 'unknown command'}`;
177
- }
178
- else if (toolName === 'Write') {
179
- description = `Create file: ${toolInput.file_path || 'unknown file'}`;
180
- }
181
- else if (toolName === 'Edit') {
182
- description = `Edit file: ${toolInput.file_path || 'unknown file'}`;
183
- }
317
+ // Use provided description or generate one
318
+ const desc = description || this.getToolDescription(toolName, toolInput);
184
319
  this.pendingPermission = { toolName, toolInput, toolUseId, resolve: resolve };
185
320
  // Emit event for voice handler to pick up
186
321
  this.emit('permission_request', {
187
322
  toolName,
188
- description,
323
+ description: desc,
189
324
  toolInput,
190
325
  toolUseId,
191
326
  });
@@ -240,16 +375,7 @@ export class ClaudeHandler extends EventEmitter {
240
375
  if (!this.pendingPermission)
241
376
  return null;
242
377
  const { toolName, toolInput, toolUseId } = this.pendingPermission;
243
- let description = '';
244
- if (toolName === 'Bash') {
245
- description = `Run command: ${toolInput.command || 'unknown command'}`;
246
- }
247
- else if (toolName === 'Write') {
248
- description = `Create file: ${toolInput.file_path || 'unknown file'}`;
249
- }
250
- else if (toolName === 'Edit') {
251
- description = `Edit file: ${toolInput.file_path || 'unknown file'}`;
252
- }
378
+ const description = this.getToolDescription(toolName, toolInput);
253
379
  return { toolName, description, toolInput, toolUseId };
254
380
  }
255
381
  /**
@@ -0,0 +1,33 @@
1
+ import type { McpServerConfig } from './claude-handler.js';
2
+ export interface OsbornConfig {
3
+ workingDirectory?: string;
4
+ mcpServers?: Record<string, McpServerConfigYaml>;
5
+ defaultProvider?: 'openai' | 'gemini';
6
+ defaultCodingAgent?: 'claude' | 'codex';
7
+ }
8
+ interface McpServerConfigYaml {
9
+ enabled?: boolean;
10
+ command?: string;
11
+ args?: string[];
12
+ env?: Record<string, string>;
13
+ url?: string;
14
+ transport?: 'stdio' | 'sse' | 'http';
15
+ }
16
+ /**
17
+ * Load configuration from ~/.osborn/config.yaml
18
+ * Creates default config if it doesn't exist
19
+ */
20
+ export declare function loadConfig(): OsbornConfig;
21
+ /**
22
+ * Get enabled MCP servers in the format expected by Claude Agent SDK
23
+ */
24
+ export declare function getMcpServers(config: OsbornConfig): Record<string, McpServerConfig>;
25
+ /**
26
+ * Get list of enabled MCP server names (for display)
27
+ */
28
+ export declare function getEnabledMcpServerNames(config: OsbornConfig): string[];
29
+ /**
30
+ * Save config to file
31
+ */
32
+ export declare function saveConfig(config: OsbornConfig): void;
33
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,127 @@
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { parse, stringify } from 'yaml';
5
+ // Config file paths
6
+ const CONFIG_DIR = join(homedir(), '.osborn');
7
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.yaml');
8
+ // Default config template
9
+ const DEFAULT_CONFIG = {
10
+ workingDirectory: process.cwd(),
11
+ defaultProvider: 'openai',
12
+ defaultCodingAgent: 'claude',
13
+ mcpServers: {
14
+ // Example MCP servers (disabled by default)
15
+ // github: {
16
+ // enabled: true,
17
+ // command: 'npx',
18
+ // args: ['@modelcontextprotocol/server-github'],
19
+ // env: {
20
+ // GITHUB_TOKEN: '${GITHUB_TOKEN}',
21
+ // },
22
+ // },
23
+ // filesystem: {
24
+ // enabled: true,
25
+ // command: 'npx',
26
+ // args: ['@modelcontextprotocol/server-filesystem', '/allowed/path'],
27
+ // },
28
+ },
29
+ };
30
+ /**
31
+ * Resolve environment variable references in a string
32
+ * e.g., "${GITHUB_TOKEN}" becomes the value of process.env.GITHUB_TOKEN
33
+ */
34
+ function resolveEnvVar(value) {
35
+ return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
36
+ return process.env[envVar] || '';
37
+ });
38
+ }
39
+ /**
40
+ * Resolve environment variables in an object of strings
41
+ */
42
+ function resolveEnvVars(env) {
43
+ if (!env)
44
+ return undefined;
45
+ const resolved = {};
46
+ for (const [key, value] of Object.entries(env)) {
47
+ resolved[key] = resolveEnvVar(value);
48
+ }
49
+ return resolved;
50
+ }
51
+ /**
52
+ * Load configuration from ~/.osborn/config.yaml
53
+ * Creates default config if it doesn't exist
54
+ */
55
+ export function loadConfig() {
56
+ // Ensure config directory exists
57
+ if (!existsSync(CONFIG_DIR)) {
58
+ mkdirSync(CONFIG_DIR, { recursive: true });
59
+ console.log(`šŸ“ Created config directory: ${CONFIG_DIR}`);
60
+ }
61
+ // Create default config if it doesn't exist
62
+ if (!existsSync(CONFIG_FILE)) {
63
+ const defaultYaml = stringify(DEFAULT_CONFIG);
64
+ writeFileSync(CONFIG_FILE, defaultYaml, 'utf-8');
65
+ console.log(`šŸ“ Created default config: ${CONFIG_FILE}`);
66
+ return DEFAULT_CONFIG;
67
+ }
68
+ // Load and parse config
69
+ try {
70
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
71
+ const config = parse(content);
72
+ console.log(`šŸ“‹ Loaded config from: ${CONFIG_FILE}`);
73
+ return config;
74
+ }
75
+ catch (err) {
76
+ console.error(`āŒ Failed to load config: ${err.message}`);
77
+ return DEFAULT_CONFIG;
78
+ }
79
+ }
80
+ /**
81
+ * Get enabled MCP servers in the format expected by Claude Agent SDK
82
+ */
83
+ export function getMcpServers(config) {
84
+ const servers = {};
85
+ if (!config.mcpServers) {
86
+ return servers;
87
+ }
88
+ for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
89
+ // Skip disabled servers
90
+ if (serverConfig.enabled === false) {
91
+ continue;
92
+ }
93
+ // Build the McpServerConfig for Claude Agent SDK
94
+ if (serverConfig.command) {
95
+ servers[name] = {
96
+ command: serverConfig.command,
97
+ args: serverConfig.args,
98
+ env: resolveEnvVars(serverConfig.env),
99
+ };
100
+ }
101
+ // Note: SSE/HTTP transport may require different handling
102
+ }
103
+ return servers;
104
+ }
105
+ /**
106
+ * Get list of enabled MCP server names (for display)
107
+ */
108
+ export function getEnabledMcpServerNames(config) {
109
+ if (!config.mcpServers)
110
+ return [];
111
+ return Object.entries(config.mcpServers)
112
+ .filter(([_, serverConfig]) => serverConfig.enabled !== false && serverConfig.command)
113
+ .map(([name, _]) => name);
114
+ }
115
+ /**
116
+ * Save config to file
117
+ */
118
+ export function saveConfig(config) {
119
+ try {
120
+ const yaml = stringify(config);
121
+ writeFileSync(CONFIG_FILE, yaml, 'utf-8');
122
+ console.log(`šŸ’¾ Saved config to: ${CONFIG_FILE}`);
123
+ }
124
+ catch (err) {
125
+ console.error(`āŒ Failed to save config: ${err.message}`);
126
+ }
127
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1 @@
1
1
  import 'dotenv/config';
2
- declare const _default: import("@livekit/agents").Agent;
3
- export default _default;