camo-cli 2.0.1

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 (44) hide show
  1. package/README.md +184 -0
  2. package/dist/agent.js +977 -0
  3. package/dist/art.js +33 -0
  4. package/dist/components/App.js +71 -0
  5. package/dist/components/Chat.js +509 -0
  6. package/dist/components/HITLConfirmation.js +89 -0
  7. package/dist/components/ModelSelector.js +100 -0
  8. package/dist/components/SetupScreen.js +43 -0
  9. package/dist/config/constants.js +58 -0
  10. package/dist/config/prompts.js +98 -0
  11. package/dist/config/store.js +5 -0
  12. package/dist/core/AgentLoop.js +159 -0
  13. package/dist/hooks/useAutocomplete.js +52 -0
  14. package/dist/hooks/useKeyboard.js +73 -0
  15. package/dist/index.js +31 -0
  16. package/dist/mcp.js +95 -0
  17. package/dist/memory/MemoryManager.js +228 -0
  18. package/dist/providers/index.js +85 -0
  19. package/dist/providers/registry.js +121 -0
  20. package/dist/providers/types.js +5 -0
  21. package/dist/theme.js +45 -0
  22. package/dist/tools/FileTools.js +88 -0
  23. package/dist/tools/MemoryTools.js +53 -0
  24. package/dist/tools/SearchTools.js +45 -0
  25. package/dist/tools/ShellTools.js +40 -0
  26. package/dist/tools/TaskTools.js +52 -0
  27. package/dist/tools/ToolDefinitions.js +102 -0
  28. package/dist/tools/ToolRegistry.js +30 -0
  29. package/dist/types/Agent.js +6 -0
  30. package/dist/types/ink.js +1 -0
  31. package/dist/types/message.js +1 -0
  32. package/dist/types/ui.js +1 -0
  33. package/dist/utils/CriticAgent.js +88 -0
  34. package/dist/utils/DecisionLogger.js +156 -0
  35. package/dist/utils/MessageHistory.js +55 -0
  36. package/dist/utils/PermissionManager.js +253 -0
  37. package/dist/utils/SessionManager.js +180 -0
  38. package/dist/utils/TaskState.js +108 -0
  39. package/dist/utils/debug.js +35 -0
  40. package/dist/utils/execAsync.js +3 -0
  41. package/dist/utils/retry.js +50 -0
  42. package/dist/utils/tokenCounter.js +24 -0
  43. package/dist/utils/uiFormatter.js +106 -0
  44. package/package.json +92 -0
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Message History - Persistent conversation state for n0 loop
3
+ */
4
+ class MessageHistoryManager {
5
+ constructor() {
6
+ this.messages = [];
7
+ }
8
+ static getInstance() {
9
+ if (!MessageHistoryManager.instance) {
10
+ MessageHistoryManager.instance = new MessageHistoryManager();
11
+ }
12
+ return MessageHistoryManager.instance;
13
+ }
14
+ addUser(content) {
15
+ this.messages.push({
16
+ role: 'user',
17
+ content,
18
+ timestamp: Date.now()
19
+ });
20
+ }
21
+ addAssistant(content) {
22
+ this.messages.push({
23
+ role: 'assistant',
24
+ content,
25
+ timestamp: Date.now()
26
+ });
27
+ }
28
+ addToolResult(toolName, toolCallId, result) {
29
+ this.messages.push({
30
+ role: 'tool',
31
+ content: result,
32
+ toolName,
33
+ toolCallId,
34
+ timestamp: Date.now()
35
+ });
36
+ }
37
+ getAll() {
38
+ return [...this.messages];
39
+ }
40
+ getForLLM() {
41
+ return this.messages.map(m => ({
42
+ role: m.role === 'tool' ? 'user' : m.role,
43
+ content: m.role === 'tool'
44
+ ? `[TOOL_RESULT:${m.toolName}] ${m.content}`
45
+ : m.content
46
+ }));
47
+ }
48
+ clear() {
49
+ this.messages = [];
50
+ }
51
+ get length() {
52
+ return this.messages.length;
53
+ }
54
+ }
55
+ export const MessageHistory = MessageHistoryManager.getInstance();
@@ -0,0 +1,253 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ export class PermissionManager {
4
+ constructor() {
5
+ this.sessionAllowedResources = new Set();
6
+ this.sessionAllowedPaths = new Set();
7
+ // Safe-List: Tools that auto-execute without HITL
8
+ this.SAFE_TOOLS = new Set([
9
+ 'readFile',
10
+ 'listFiles',
11
+ 'listSymbols',
12
+ 'grep',
13
+ 'internal_thought',
14
+ 'ToDoWrite'
15
+ ]);
16
+ // Safe shell command patterns
17
+ this.SAFE_SHELL_PATTERNS = [
18
+ // Git read operations
19
+ /^git\s+(status|log|diff|show|branch|remote|config\s+--get)/,
20
+ // File listing
21
+ /^(ls|dir|find|tree|pwd|whoami)\s/,
22
+ /^(ls|dir|pwd|whoami)$/,
23
+ // File reading
24
+ /^(cat|head|tail|less|more|grep|rg|ag)\s/,
25
+ // Package managers (read-only)
26
+ /^(npm|yarn|pnpm)\s+(list|ls|view|info|outdated|audit)\s/,
27
+ /^(npm|yarn|pnpm)\s+-v$/,
28
+ /^node\s+-v$/,
29
+ // Testing (read-only execution, no modifications)
30
+ /^(npm|yarn|pnpm)\s+(test|t)\s/,
31
+ /^(jest|vitest|mocha|ava)\s/,
32
+ // Build tools (read-only check)
33
+ /^(tsc|eslint|prettier)\s+--noEmit/,
34
+ // Other safe commands
35
+ /^(date|echo|printf|which|type|command\s+-v)/
36
+ ];
37
+ // Critical shell command patterns (require HITL)
38
+ this.CRITICAL_SHELL_PATTERNS = [
39
+ /^(rm|mv|cp)\s/,
40
+ /^git\s+(push|commit|add|reset|rebase|merge|cherry-pick|pull)/,
41
+ /^(npm|yarn|pnpm)\s+(install|i|add|remove|uninstall|publish|run\s+(?!test))/,
42
+ /^(curl|wget|fetch)\s/,
43
+ /^chmod\s/,
44
+ /^sudo\s/,
45
+ />/, // Output redirection
46
+ />>/ // Append redirection
47
+ ];
48
+ // BLOCKED commands - never allowed, even with HITL
49
+ this.BLOCKED_COMMANDS = [
50
+ /^sudo\s+rm\s+-rf\s+\/$/,
51
+ /^rm\s+-rf\s+\/$/,
52
+ /^rm\s+-rf\s+~$/,
53
+ /^chmod\s+777\s+\/$/,
54
+ /^:(){ :\|:& };:/, // Fork bomb
55
+ /^dd\s+if=.*of=\/dev\//,
56
+ /^mkfs\./,
57
+ /^format\s/
58
+ ];
59
+ this.mode = 'manual';
60
+ this.logPath = path.join(process.cwd(), '.camo', 'permissions.log');
61
+ this.ensureLogDir();
62
+ }
63
+ static getInstance() {
64
+ if (!PermissionManager.instance) {
65
+ PermissionManager.instance = new PermissionManager();
66
+ }
67
+ return PermissionManager.instance;
68
+ }
69
+ async ensureLogDir() {
70
+ try {
71
+ await fs.mkdir(path.dirname(this.logPath), { recursive: true });
72
+ }
73
+ catch (e) { /* ignore */ }
74
+ }
75
+ async logDecision(type, resource, decision, reason, scope) {
76
+ // Redact secrets from log
77
+ const sanitizedResource = this.redactSecrets(resource);
78
+ const entry = `[${new Date().toISOString()}] [${type}] [${decision}] [${scope}] Resource: "${sanitizedResource}" | Reason: ${reason}\n`;
79
+ try {
80
+ await fs.appendFile(this.logPath, entry, 'utf-8');
81
+ }
82
+ catch (e) { /* ignore logging errors */ }
83
+ }
84
+ redactSecrets(text) {
85
+ // Redact common secret patterns
86
+ return text
87
+ .replace(/AIza[A-Za-z0-9_-]{35}/g, '[REDACTED_API_KEY]')
88
+ .replace(/sk-[A-Za-z0-9]{48}/g, '[REDACTED_API_KEY]')
89
+ .replace(/ghp_[A-Za-z0-9]{36}/g, '[REDACTED_TOKEN]')
90
+ .replace(/password[=:]\S+/gi, 'password=[REDACTED]')
91
+ .replace(/token[=:]\S+/gi, 'token=[REDACTED]');
92
+ }
93
+ isPathSafe(targetPath) {
94
+ const resolved = path.resolve(process.cwd(), targetPath);
95
+ const projectRoot = process.cwd();
96
+ return resolved.startsWith(projectRoot);
97
+ }
98
+ isCommandBlocked(command) {
99
+ return this.BLOCKED_COMMANDS.some(pattern => pattern.test(command.trim()));
100
+ }
101
+ setMode(mode) {
102
+ this.mode = mode;
103
+ }
104
+ getMode() {
105
+ return this.mode;
106
+ }
107
+ async validate(type, context, callbacks) {
108
+ const resource = this.getResourceId(type, context);
109
+ // 0. BLOCKED commands - never allowed
110
+ if (type === 'SHELL' && context.command && this.isCommandBlocked(context.command)) {
111
+ await this.logDecision(type, resource, 'DENIED', 'Blocked dangerous command', 'AUTO');
112
+ callbacks.onChunk('\n[SECURITY] Command blocked: dangerous operation not allowed.\n');
113
+ return false;
114
+ }
115
+ // 0b. Path sandboxing - reject writes outside project
116
+ if (type === 'FILE_WRITE' && context.path && !this.isPathSafe(context.path)) {
117
+ await this.logDecision(type, resource, 'DENIED', 'Path outside project root', 'AUTO');
118
+ callbacks.onChunk('\n[SECURITY] Write blocked: path outside project root.\n');
119
+ return false;
120
+ }
121
+ // 1. Safe-List Check for Tools (Auto-Execute)
122
+ if (context.toolName && this.SAFE_TOOLS.has(context.toolName)) {
123
+ await this.logDecision(type, resource, 'APPROVED', 'Safe-List Tool', 'AUTO');
124
+ return true;
125
+ }
126
+ // 2. Session Allow-List Check
127
+ if (this.sessionAllowedResources.has(resource)) {
128
+ await this.logDecision(type, resource, 'APPROVED', 'Session Allowed', 'SESSION');
129
+ return true;
130
+ }
131
+ // 3. Session Path Trust Check (for FILE_WRITE)
132
+ if (type === 'FILE_WRITE' && context.path) {
133
+ const normalizedPath = path.resolve(process.cwd(), context.path);
134
+ if (this.sessionAllowedPaths.has(normalizedPath)) {
135
+ await this.logDecision(type, resource, 'APPROVED', 'Session Trusted Path', 'SESSION');
136
+ return true;
137
+ }
138
+ }
139
+ // 4. Auto-Allow Safety Checks (Only in AUTO mode or for explicitly safe types)
140
+ const { isSafe, reason } = this.checkSafety(type, context);
141
+ if (isSafe) {
142
+ await this.logDecision(type, resource, 'APPROVED', reason, 'AUTO');
143
+ return true;
144
+ }
145
+ // 5. HITL Trigger for Critical Operations
146
+ const hitlResult = await callbacks.onHITL({
147
+ id: Date.now().toString(),
148
+ type: type,
149
+ command: context.command,
150
+ path: context.path,
151
+ diff: context.diff,
152
+ reason: reason,
153
+ mode: this.mode
154
+ });
155
+ if (hitlResult.approved) {
156
+ if (hitlResult.scope === 'session') {
157
+ this.sessionAllowedResources.add(resource);
158
+ // For FILE_WRITE, also trust the path
159
+ if (type === 'FILE_WRITE' && context.path) {
160
+ const normalizedPath = path.resolve(process.cwd(), context.path);
161
+ this.sessionAllowedPaths.add(normalizedPath);
162
+ }
163
+ await this.logDecision(type, resource, 'APPROVED', 'User Approved (Session)', 'SESSION');
164
+ }
165
+ else {
166
+ await this.logDecision(type, resource, 'APPROVED', 'User Approved (Once)', 'ONCE');
167
+ }
168
+ return true;
169
+ }
170
+ else {
171
+ await this.logDecision(type, resource, 'DENIED', 'User Denied', 'ONCE');
172
+ return false;
173
+ }
174
+ }
175
+ getResourceId(type, context) {
176
+ if (type === 'SHELL')
177
+ return `SHELL:${context.command}`;
178
+ if (type === 'FILE_WRITE')
179
+ return `WRITE:${context.path}`;
180
+ if (type === 'FILE_READ')
181
+ return `READ:${context.path}`;
182
+ if (type === 'WEB_ACCESS')
183
+ return `WEB:${context.url}`;
184
+ return 'UNKNOWN';
185
+ }
186
+ checkSafety(type, context) {
187
+ // FILE_READ is always safe (auto-execute)
188
+ if (type === 'FILE_READ') {
189
+ return { isSafe: true, reason: 'Safe read operation' };
190
+ }
191
+ // SHELL command analysis
192
+ if (type === 'SHELL' && context.command) {
193
+ // MANUAL MODE: Require approval for ALL shell commands
194
+ if (this.mode === 'manual') {
195
+ return { isSafe: false, reason: 'Manual Mode: Shell commands require approval' };
196
+ }
197
+ const cmd = context.command.trim();
198
+ // Check if command matches safe patterns
199
+ const isSafeCommand = this.SAFE_SHELL_PATTERNS.some(pattern => pattern.test(cmd));
200
+ if (isSafeCommand) {
201
+ return { isSafe: true, reason: 'Safe read-only command' };
202
+ }
203
+ // Check if command matches critical patterns
204
+ const isCritical = this.CRITICAL_SHELL_PATTERNS.some(pattern => pattern.test(cmd));
205
+ if (isCritical) {
206
+ return { isSafe: false, reason: 'Critical operation requires approval' };
207
+ }
208
+ // Additional path check for unlisted commands
209
+ const paths = this.extractPaths(cmd);
210
+ const isInternal = paths.length === 0 || paths.every(p => p.startsWith(process.cwd()));
211
+ if (!isInternal) {
212
+ return { isSafe: false, reason: 'Accessing files outside project' };
213
+ }
214
+ // Default to requiring approval for unknown commands
215
+ return { isSafe: false, reason: 'Unknown command, requires approval' };
216
+ }
217
+ // WEB_ACCESS whitelist check
218
+ if (type === 'WEB_ACCESS' && context.url) {
219
+ const url = context.url;
220
+ const inUserMsg = context.userMessage?.includes(url);
221
+ const inProject = context.projectContext?.includes(url);
222
+ if (inUserMsg || inProject) {
223
+ return { isSafe: true, reason: 'URL mentioned in user request' };
224
+ }
225
+ return { isSafe: false, reason: 'External URL not explicitly requested' };
226
+ }
227
+ // FILE_WRITE always requires approval (unless session trusted)
228
+ if (type === 'FILE_WRITE') {
229
+ return { isSafe: false, reason: 'File modification requires approval' };
230
+ }
231
+ return { isSafe: false, reason: 'Unknown operation' };
232
+ }
233
+ extractPaths(cmd) {
234
+ const parts = cmd.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
235
+ const commandWords = ['ls', 'cat', 'grep', 'find', 'git', 'npm', 'yarn', 'pnpm', 'node', 'echo', 'pwd', 'jest', 'vitest', 'mocha', 'tsc', 'eslint'];
236
+ const potentialPaths = parts.filter(p => !p.startsWith('-') &&
237
+ !commandWords.includes(p.replace(/['"]/g, '')));
238
+ return potentialPaths.map(p => {
239
+ const clean = p.replace(/['"]/g, '');
240
+ try {
241
+ return path.resolve(process.cwd(), clean);
242
+ }
243
+ catch {
244
+ return clean;
245
+ }
246
+ });
247
+ }
248
+ // Clear session trust (for testing or explicit reset)
249
+ clearSessionTrust() {
250
+ this.sessionAllowedResources.clear();
251
+ this.sessionAllowedPaths.clear();
252
+ }
253
+ }
@@ -0,0 +1,180 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ export class SessionManager {
4
+ constructor() {
5
+ this.currentSession = null;
6
+ this.autoSaveInterval = null;
7
+ this.sessionDir = path.join(process.cwd(), '.camo', 'sessions');
8
+ this.ensureSessionDir();
9
+ }
10
+ static getInstance() {
11
+ if (!SessionManager.instance) {
12
+ SessionManager.instance = new SessionManager();
13
+ }
14
+ return SessionManager.instance;
15
+ }
16
+ async ensureSessionDir() {
17
+ try {
18
+ await fs.mkdir(this.sessionDir, { recursive: true });
19
+ }
20
+ catch (e) { /* ignore */ }
21
+ }
22
+ /**
23
+ * Create a new session
24
+ */
25
+ async createSession() {
26
+ const sessionId = `session-${Date.now()}`;
27
+ this.currentSession = {
28
+ sessionId,
29
+ createdAt: Date.now(),
30
+ lastUpdatedAt: Date.now(),
31
+ messages: [],
32
+ metadata: {
33
+ totalTokens: 0,
34
+ totalCost: 0,
35
+ toolsUsed: []
36
+ }
37
+ };
38
+ await this.saveSession();
39
+ this.startAutoSave();
40
+ return sessionId;
41
+ }
42
+ /**
43
+ * Resume an existing session
44
+ */
45
+ async resumeSession(sessionId) {
46
+ try {
47
+ const sessionPath = path.join(this.sessionDir, `${sessionId}.json`);
48
+ const data = await fs.readFile(sessionPath, 'utf-8');
49
+ this.currentSession = JSON.parse(data);
50
+ this.startAutoSave();
51
+ return this.currentSession;
52
+ }
53
+ catch (e) {
54
+ return null;
55
+ }
56
+ }
57
+ /**
58
+ * Get the latest session (for auto-resume)
59
+ */
60
+ async getLatestSession() {
61
+ try {
62
+ const files = await fs.readdir(this.sessionDir);
63
+ const sessionFiles = files.filter(f => f.startsWith('session-') && f.endsWith('.json'));
64
+ if (sessionFiles.length === 0)
65
+ return null;
66
+ // Sort by timestamp (newest first)
67
+ sessionFiles.sort().reverse();
68
+ const latestFile = sessionFiles[0];
69
+ const sessionPath = path.join(this.sessionDir, latestFile);
70
+ const data = await fs.readFile(sessionPath, 'utf-8');
71
+ return JSON.parse(data);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ /**
78
+ * Add a message to the current session
79
+ */
80
+ addMessage(message) {
81
+ if (!this.currentSession)
82
+ return;
83
+ this.currentSession.messages.push(message);
84
+ this.currentSession.lastUpdatedAt = Date.now();
85
+ }
86
+ /**
87
+ * Update session metadata
88
+ */
89
+ updateMetadata(updates) {
90
+ if (!this.currentSession)
91
+ return;
92
+ this.currentSession.metadata = {
93
+ ...this.currentSession.metadata,
94
+ ...updates
95
+ };
96
+ this.currentSession.lastUpdatedAt = Date.now();
97
+ }
98
+ /**
99
+ * Save current session to disk
100
+ */
101
+ async saveSession() {
102
+ if (!this.currentSession)
103
+ return;
104
+ try {
105
+ const sessionPath = path.join(this.sessionDir, `${this.currentSession.sessionId}.json`);
106
+ await fs.writeFile(sessionPath, JSON.stringify(this.currentSession, null, 2), 'utf-8');
107
+ }
108
+ catch (e) {
109
+ console.error('Failed to save session:', e);
110
+ }
111
+ }
112
+ /**
113
+ * Auto-save every 30 seconds
114
+ */
115
+ startAutoSave() {
116
+ if (this.autoSaveInterval) {
117
+ clearInterval(this.autoSaveInterval);
118
+ }
119
+ this.autoSaveInterval = setInterval(() => {
120
+ this.saveSession();
121
+ }, 30000); // 30 seconds
122
+ }
123
+ /**
124
+ * Stop auto-save
125
+ */
126
+ stopAutoSave() {
127
+ if (this.autoSaveInterval) {
128
+ clearInterval(this.autoSaveInterval);
129
+ this.autoSaveInterval = null;
130
+ }
131
+ }
132
+ /**
133
+ * Get current session
134
+ */
135
+ getCurrentSession() {
136
+ return this.currentSession;
137
+ }
138
+ /**
139
+ * List all sessions
140
+ */
141
+ async listSessions() {
142
+ try {
143
+ const files = await fs.readdir(this.sessionDir);
144
+ const sessionFiles = files.filter(f => f.startsWith('session-') && f.endsWith('.json'));
145
+ const sessions = await Promise.all(sessionFiles.map(async (file) => {
146
+ const sessionPath = path.join(this.sessionDir, file);
147
+ const data = await fs.readFile(sessionPath, 'utf-8');
148
+ const session = JSON.parse(data);
149
+ return {
150
+ id: session.sessionId,
151
+ createdAt: session.createdAt,
152
+ messageCount: session.messages.length
153
+ };
154
+ }));
155
+ return sessions.sort((a, b) => b.createdAt - a.createdAt);
156
+ }
157
+ catch {
158
+ return [];
159
+ }
160
+ }
161
+ /**
162
+ * Delete a session
163
+ */
164
+ async deleteSession(sessionId) {
165
+ try {
166
+ const sessionPath = path.join(this.sessionDir, `${sessionId}.json`);
167
+ await fs.unlink(sessionPath);
168
+ }
169
+ catch (e) {
170
+ console.error('Failed to delete session:', e);
171
+ }
172
+ }
173
+ /**
174
+ * Cleanup: Save and stop auto-save
175
+ */
176
+ async cleanup() {
177
+ await this.saveSession();
178
+ this.stopAutoSave();
179
+ }
180
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * TaskState - Persistent state for two-phase agent execution
3
+ */
4
+ class TaskStateManager {
5
+ constructor() {
6
+ this.state = {
7
+ phase: 'idle',
8
+ originalTask: '',
9
+ plan: [],
10
+ currentTaskIndex: 0,
11
+ errorCount: 0,
12
+ lastError: null
13
+ };
14
+ }
15
+ static getInstance() {
16
+ if (!TaskStateManager.instance) {
17
+ TaskStateManager.instance = new TaskStateManager();
18
+ }
19
+ return TaskStateManager.instance;
20
+ }
21
+ // Start new task - enters planning phase
22
+ startTask(userInput) {
23
+ this.state = {
24
+ phase: 'planning',
25
+ originalTask: userInput,
26
+ plan: [],
27
+ currentTaskIndex: 0,
28
+ errorCount: 0,
29
+ lastError: null
30
+ };
31
+ }
32
+ // Set plan from manageTasks
33
+ setPlan(tasks) {
34
+ this.state.plan = tasks;
35
+ this.state.phase = 'awaiting_approval';
36
+ }
37
+ // User approved - start execution
38
+ approve() {
39
+ this.state.phase = 'executing';
40
+ this.state.currentTaskIndex = 0;
41
+ }
42
+ // Mark current task in progress
43
+ startCurrentTask() {
44
+ if (this.state.plan[this.state.currentTaskIndex]) {
45
+ this.state.plan[this.state.currentTaskIndex].status = 'in_progress';
46
+ }
47
+ }
48
+ // Complete current task and advance
49
+ completeCurrentTask() {
50
+ if (this.state.plan[this.state.currentTaskIndex]) {
51
+ this.state.plan[this.state.currentTaskIndex].status = 'completed';
52
+ this.state.currentTaskIndex++;
53
+ }
54
+ // Check if all done
55
+ if (this.state.currentTaskIndex >= this.state.plan.length) {
56
+ this.state.phase = 'complete';
57
+ }
58
+ }
59
+ // Record error for self-correction
60
+ recordError(error) {
61
+ this.state.errorCount++;
62
+ this.state.lastError = error;
63
+ }
64
+ // Reset state
65
+ reset() {
66
+ this.state = {
67
+ phase: 'idle',
68
+ originalTask: '',
69
+ plan: [],
70
+ currentTaskIndex: 0,
71
+ errorCount: 0,
72
+ lastError: null
73
+ };
74
+ }
75
+ // Getters
76
+ get phase() { return this.state.phase; }
77
+ get originalTask() { return this.state.originalTask; }
78
+ get plan() { return this.state.plan; }
79
+ get currentTaskIndex() { return this.state.currentTaskIndex; }
80
+ get totalTasks() { return this.state.plan.length; }
81
+ get progress() {
82
+ return `${this.state.currentTaskIndex + 1}/${this.state.plan.length}`;
83
+ }
84
+ get isComplete() { return this.state.phase === 'complete'; }
85
+ get hasError() { return this.state.lastError !== null; }
86
+ get lastError() { return this.state.lastError; }
87
+ // Get state for context injection
88
+ getContextString() {
89
+ if (this.state.phase === 'idle')
90
+ return '';
91
+ let ctx = `\n**TASK STATE**\n`;
92
+ ctx += `Original: ${this.state.originalTask}\n`;
93
+ ctx += `Phase: ${this.state.phase}\n`;
94
+ if (this.state.plan.length > 0) {
95
+ ctx += `Plan:\n`;
96
+ this.state.plan.forEach((t, i) => {
97
+ const marker = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '→' : ' ';
98
+ ctx += ` [${marker}] ${i + 1}. ${t.title}\n`;
99
+ });
100
+ ctx += `Progress: ${this.progress}\n`;
101
+ }
102
+ if (this.state.lastError) {
103
+ ctx += `Last Error: ${this.state.lastError}\n`;
104
+ }
105
+ return ctx;
106
+ }
107
+ }
108
+ export const TaskState = TaskStateManager.getInstance();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Debug utilities
3
+ */
4
+ import { promises as fs } from 'fs';
5
+ import { homedir } from 'os';
6
+ import * as path from 'path';
7
+ const DEBUG_LOG_FILE = path.join(homedir(), '.camo-debug.log');
8
+ export async function debugLog(message, data) {
9
+ try {
10
+ const timestamp = new Date().toISOString();
11
+ let logLine = `[${timestamp}] ${message}`;
12
+ if (data) {
13
+ // Hide sensitive data
14
+ const sanitized = JSON.stringify(data)
15
+ .replace(/"apiKey":"[^"]*"/g, '"apiKey":"***"')
16
+ .replace(/"api_key":"[^"]*"/g, '"api_key":"***"');
17
+ logLine += ` ${sanitized}`;
18
+ }
19
+ await fs.appendFile(DEBUG_LOG_FILE, logLine + '\n', 'utf-8');
20
+ }
21
+ catch (e) {
22
+ // Silent fail
23
+ }
24
+ }
25
+ export function isDebugEnabled() {
26
+ return process.env.CAMO_DEBUG === '1' || process.env.CAMO_DEBUG === 'true';
27
+ }
28
+ export async function getDebugLog() {
29
+ try {
30
+ return await fs.readFile(DEBUG_LOG_FILE, 'utf-8');
31
+ }
32
+ catch {
33
+ return 'No debug log found';
34
+ }
35
+ }
@@ -0,0 +1,3 @@
1
+ import { promisify } from 'util';
2
+ import { exec } from 'child_process';
3
+ export const execAsync = promisify(exec);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Retry utility with exponential backoff
3
+ */
4
+ const defaultOptions = {
5
+ maxAttempts: 3,
6
+ baseDelayMs: 1000,
7
+ maxDelayMs: 10000,
8
+ };
9
+ export async function withRetry(fn, options = {}) {
10
+ const opts = { ...defaultOptions, ...options };
11
+ let lastError = null;
12
+ for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
13
+ try {
14
+ return await fn();
15
+ }
16
+ catch (error) {
17
+ lastError = error;
18
+ if (attempt === opts.maxAttempts) {
19
+ break;
20
+ }
21
+ // Exponential backoff: 1s, 2s, 4s...
22
+ const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt - 1), opts.maxDelayMs);
23
+ opts.onRetry?.(attempt, error);
24
+ await sleep(delay);
25
+ }
26
+ }
27
+ throw lastError;
28
+ }
29
+ function sleep(ms) {
30
+ return new Promise(resolve => setTimeout(resolve, ms));
31
+ }
32
+ /**
33
+ * Execute with timeout
34
+ */
35
+ export function withTimeout(promise, timeoutMs, errorMessage = 'Operation timed out') {
36
+ return new Promise((resolve, reject) => {
37
+ const timer = setTimeout(() => {
38
+ reject(new Error(errorMessage));
39
+ }, timeoutMs);
40
+ promise
41
+ .then(result => {
42
+ clearTimeout(timer);
43
+ resolve(result);
44
+ })
45
+ .catch(error => {
46
+ clearTimeout(timer);
47
+ reject(error);
48
+ });
49
+ });
50
+ }