upfynai-code 2.9.4 → 2.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "upfynai-code",
3
- "version": "2.9.4",
3
+ "version": "2.9.6",
4
4
  "description": "Visual AI coding interface for Claude Code, Cursor & Codex. Canvas whiteboard, multi-agent chat, terminal, git, voice assistant. Self-host locally or connect to cli.upfyn.com for remote access.",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -58,12 +58,10 @@
58
58
  "@iarna/toml": "^2.2.5",
59
59
  "@libsql/client": "^0.14.0",
60
60
  "@modelcontextprotocol/sdk": "^1.26.0",
61
- "@neondatabase/serverless": "^1.0.2",
62
61
  "@octokit/rest": "^22.0.0",
63
62
  "@openai/codex-sdk": "^0.101.0",
64
63
  "bcryptjs": "^3.0.3",
65
64
  "chokidar": "^4.0.3",
66
- "composio-core": "^0.5.39",
67
65
  "cookie-parser": "^1.4.7",
68
66
  "cors": "^2.8.5",
69
67
  "cross-spawn": "^7.0.3",
@@ -83,6 +81,8 @@
83
81
  "zod": "^3.25.76"
84
82
  },
85
83
  "optionalDependencies": {
84
+ "composio-core": "^0.5.39",
85
+ "@neondatabase/serverless": "^1.0.2",
86
86
  "node-pty": "^1.1.0-beta34"
87
87
  },
88
88
  "devDependencies": {
@@ -22,7 +22,7 @@ import {
22
22
  logRelayEvent,
23
23
  createSpinner,
24
24
  } from './cli-ui.js';
25
- import { executeAction, isStreamingAction } from '../../packages/shared/agents/index.js';
25
+ import { executeAction, isStreamingAction } from '../shared/agents/index.js';
26
26
 
27
27
  // Load package.json for version
28
28
  import { fileURLToPath } from 'url';
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Claude Agent
3
+ * Handles: claude-query, claude-task-query
4
+ *
5
+ * These are STREAMING actions — they use ctx.stream() to send chunks
6
+ * and return a Promise that resolves when the process completes.
7
+ *
8
+ * Three modes:
9
+ * - sdk (connect.js default) — full SDK streaming with rich messages (tool_use, text, result)
10
+ * - simple --print (relay-client.js) — basic stdout streaming
11
+ *
12
+ * The caller chooses the mode via ctx.streamMode ('structured' | 'simple').
13
+ * 'structured' now maps to SDK mode for rich message forwarding.
14
+ */
15
+ import { query } from '@anthropic-ai/claude-agent-sdk';
16
+ import { spawn } from 'child_process';
17
+ import os from 'os';
18
+
19
+ export default {
20
+ name: 'claude',
21
+ actions: {
22
+ 'claude-query': async (params, ctx) => {
23
+ const { command, options } = params;
24
+ const mode = ctx.streamMode || 'structured';
25
+
26
+ if (mode === 'simple') {
27
+ const resolveBinary = ctx.resolveBinary || ((name) => name);
28
+ return runSimpleClaudeQuery(command, options, ctx, resolveBinary);
29
+ }
30
+ return runSDKClaudeQuery(command, options, ctx);
31
+ },
32
+
33
+ 'claude-task-query': async (params, ctx) => {
34
+ const { command, options } = params;
35
+ return runSDKClaudeTaskQuery(command, options, ctx);
36
+ },
37
+ },
38
+ };
39
+
40
+ /**
41
+ * Build SDK options from relay command options.
42
+ * Maps the relay format to SDK Options type.
43
+ */
44
+ function buildSDKOptions(options = {}) {
45
+ const sdkOptions = {
46
+ cwd: options.projectPath || os.homedir(),
47
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
48
+ settingSources: ['project', 'user', 'local'],
49
+ };
50
+
51
+ if (options.sessionId) {
52
+ sdkOptions.resume = options.sessionId;
53
+ }
54
+
55
+ if (options.model) {
56
+ sdkOptions.model = options.model;
57
+ }
58
+
59
+ if (options.maxBudgetUsd) {
60
+ sdkOptions.maxBudgetUsd = options.maxBudgetUsd;
61
+ }
62
+
63
+ if (options.permissionMode && options.permissionMode !== 'default') {
64
+ sdkOptions.permissionMode = options.permissionMode;
65
+ }
66
+
67
+ if (options.allowedTools && options.allowedTools.length > 0) {
68
+ sdkOptions.allowedTools = options.allowedTools;
69
+ }
70
+
71
+ if (options.disallowedTools && options.disallowedTools.length > 0) {
72
+ sdkOptions.disallowedTools = options.disallowedTools;
73
+ }
74
+
75
+ if (options.maxTurns) {
76
+ sdkOptions.maxTurns = options.maxTurns;
77
+ }
78
+
79
+ // Pass through tools preset to make all Claude Code tools available
80
+ sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
81
+
82
+ return sdkOptions;
83
+ }
84
+
85
+ /**
86
+ * SDK-based structured streaming mode.
87
+ * Forwards rich SDK messages (tool_use, tool_result, text, system, result)
88
+ * through ctx.stream() for the relay to forward to the frontend.
89
+ */
90
+ async function runSDKClaudeQuery(command, options, ctx) {
91
+ const sdkOptions = buildSDKOptions(options);
92
+
93
+ // Set stream-close timeout for interactive tools
94
+ const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
95
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
96
+
97
+ let queryInstance;
98
+ try {
99
+ queryInstance = query({
100
+ prompt: command || '',
101
+ options: sdkOptions,
102
+ });
103
+ } finally {
104
+ // Restore immediately — Query constructor already captured the value
105
+ if (prevStreamTimeout !== undefined) {
106
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
107
+ } else {
108
+ delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
109
+ }
110
+ }
111
+
112
+ // Track the query instance for abort support (uses .interrupt() instead of .kill())
113
+ if (ctx.trackProcess) {
114
+ ctx.trackProcess(ctx.requestId, { instance: queryInstance, action: 'claude-query' });
115
+ }
116
+
117
+ let capturedSessionId = null;
118
+
119
+ try {
120
+ for await (const message of queryInstance) {
121
+ // Capture session ID from first message that has one
122
+ if (message.session_id && !capturedSessionId) {
123
+ capturedSessionId = message.session_id;
124
+ }
125
+
126
+ // Forward the full SDK message — the backend routeViaRelay() will
127
+ // detect 'claude-sdk-message' type and forward directly to frontend,
128
+ // matching the same format as queryClaudeSDK() in claude-sdk.js
129
+ ctx.stream({
130
+ type: 'claude-sdk-message',
131
+ data: message,
132
+ sessionId: capturedSessionId,
133
+ });
134
+ }
135
+
136
+ return { exitCode: 0, sessionId: capturedSessionId };
137
+ } catch (error) {
138
+ ctx.stream({ type: 'claude-error', content: error.message || 'SDK query failed' });
139
+ return { exitCode: 1, sessionId: capturedSessionId };
140
+ } finally {
141
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * SDK-based sub-agent for read-only research tasks.
147
+ * Restricts tools to read-only operations.
148
+ */
149
+ async function runSDKClaudeTaskQuery(command, options, ctx) {
150
+ const sdkOptions = buildSDKOptions(options);
151
+
152
+ // Restrict to read-only tools
153
+ sdkOptions.allowedTools = ['View', 'Glob', 'Grep', 'LS', 'Read'];
154
+
155
+ let queryInstance;
156
+ try {
157
+ queryInstance = query({
158
+ prompt: command || '',
159
+ options: sdkOptions,
160
+ });
161
+ } catch (error) {
162
+ ctx.stream({ type: 'claude-error', content: error.message || 'SDK task query failed' });
163
+ return { exitCode: 1 };
164
+ }
165
+
166
+ if (ctx.trackProcess) {
167
+ ctx.trackProcess(ctx.requestId, { instance: queryInstance, action: 'claude-task-query' });
168
+ }
169
+
170
+ let capturedSessionId = null;
171
+
172
+ try {
173
+ for await (const message of queryInstance) {
174
+ if (message.session_id && !capturedSessionId) {
175
+ capturedSessionId = message.session_id;
176
+ }
177
+
178
+ ctx.stream({
179
+ type: 'claude-sdk-message',
180
+ data: message,
181
+ sessionId: capturedSessionId,
182
+ });
183
+ }
184
+
185
+ return { exitCode: 0, sessionId: capturedSessionId };
186
+ } catch (error) {
187
+ ctx.stream({ type: 'claude-error', content: error.message || 'SDK task query failed' });
188
+ return { exitCode: 1, sessionId: capturedSessionId };
189
+ } finally {
190
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Simple streaming mode (--print).
196
+ * Just pipes stdout/stderr chunks. Kept for backward compat with relay-client.js.
197
+ */
198
+ function runSimpleClaudeQuery(command, options, ctx, resolveBinary) {
199
+ return new Promise((resolve) => {
200
+ const args = ['--print'];
201
+ if (options?.sessionId) args.push('--continue', options.sessionId);
202
+
203
+ const proc = spawn(resolveBinary('claude'), [...args, command || ''], {
204
+ shell: true,
205
+ cwd: options?.projectPath || os.homedir(),
206
+ env: process.env,
207
+ });
208
+
209
+ if (ctx.trackProcess) ctx.trackProcess(ctx.requestId, { proc, action: 'claude-query' });
210
+
211
+ proc.stdout.on('data', (chunk) => {
212
+ ctx.stream({ type: 'claude-response', content: chunk.toString() });
213
+ });
214
+
215
+ proc.stderr.on('data', (chunk) => {
216
+ ctx.stream({ type: 'claude-error', content: chunk.toString() });
217
+ });
218
+
219
+ proc.on('close', (code) => {
220
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
221
+ resolve({ exitCode: code });
222
+ });
223
+
224
+ proc.on('error', () => {
225
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
226
+ resolve({ exitCode: 1 });
227
+ });
228
+ });
229
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Codex Agent
3
+ * Handles: codex-query
4
+ * Spawns the OpenAI Codex CLI and streams output.
5
+ */
6
+ import { spawn } from 'child_process';
7
+ import os from 'os';
8
+
9
+ export default {
10
+ name: 'codex',
11
+ actions: {
12
+ 'codex-query': async (params, ctx) => {
13
+ const { command, options } = params;
14
+ const resolveBinary = ctx.resolveBinary || ((name) => name);
15
+
16
+ return new Promise((resolve) => {
17
+ const args = ['--quiet'];
18
+ if (options?.model) args.push('--model', options.model);
19
+
20
+ const proc = spawn(resolveBinary('codex'), [...args, command || ''], {
21
+ shell: true,
22
+ cwd: options?.projectPath || options?.cwd || os.homedir(),
23
+ env: process.env,
24
+ });
25
+
26
+ if (ctx.trackProcess) ctx.trackProcess(ctx.requestId, { proc, action: 'codex-query' });
27
+
28
+ proc.stdout.on('data', (chunk) => {
29
+ ctx.stream({ type: 'codex-response', content: chunk.toString() });
30
+ });
31
+
32
+ proc.stderr.on('data', (chunk) => {
33
+ ctx.stream({ type: 'codex-error', content: chunk.toString() });
34
+ });
35
+
36
+ proc.on('close', (code) => {
37
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
38
+ resolve({ exitCode: code });
39
+ });
40
+
41
+ proc.on('error', () => {
42
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
43
+ resolve({ exitCode: 1 });
44
+ });
45
+ });
46
+ },
47
+ },
48
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Cursor Agent
3
+ * Handles: cursor-query
4
+ * Spawns the Cursor Agent CLI and streams output.
5
+ */
6
+ import { spawn } from 'child_process';
7
+ import os from 'os';
8
+
9
+ export default {
10
+ name: 'cursor',
11
+ actions: {
12
+ 'cursor-query': async (params, ctx) => {
13
+ const { command, options } = params;
14
+ const resolveBinary = ctx.resolveBinary || ((name) => name);
15
+
16
+ return new Promise((resolve) => {
17
+ const args = [];
18
+ if (options?.model) args.push('--model', options.model);
19
+
20
+ const proc = spawn(resolveBinary('cursor-agent'), [...args, command || ''], {
21
+ shell: true,
22
+ cwd: options?.projectPath || options?.cwd || os.homedir(),
23
+ env: process.env,
24
+ });
25
+
26
+ if (ctx.trackProcess) ctx.trackProcess(ctx.requestId, { proc, action: 'cursor-query' });
27
+
28
+ proc.stdout.on('data', (chunk) => {
29
+ ctx.stream({ type: 'cursor-response', content: chunk.toString() });
30
+ });
31
+
32
+ proc.stderr.on('data', (chunk) => {
33
+ ctx.stream({ type: 'cursor-error', content: chunk.toString() });
34
+ });
35
+
36
+ proc.on('close', (code) => {
37
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
38
+ resolve({ exitCode: code });
39
+ });
40
+
41
+ proc.on('error', () => {
42
+ if (ctx.untrackProcess) ctx.untrackProcess(ctx.requestId);
43
+ resolve({ exitCode: 1 });
44
+ });
45
+ });
46
+ },
47
+ },
48
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Detect Agent
3
+ * Handles: detect-agents
4
+ * Checks which AI CLI agents are installed on the local machine.
5
+ */
6
+ import { execSync } from 'child_process';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+
10
+ const AGENT_DEFINITIONS = [
11
+ { name: 'claude', binary: 'claude', label: 'Claude Code' },
12
+ { name: 'codex', binary: 'codex', label: 'OpenAI Codex' },
13
+ { name: 'cursor', binary: 'cursor-agent', label: 'Cursor Agent' },
14
+ ];
15
+
16
+ export default {
17
+ name: 'detect',
18
+ actions: {
19
+ 'detect-agents': async (params, ctx) => {
20
+ const isWindows = process.platform === 'win32';
21
+ const whichCmd = isWindows ? 'where' : 'which';
22
+ const localBinDir = ctx.localBinDir || null;
23
+
24
+ const agents = {};
25
+ for (const agent of AGENT_DEFINITIONS) {
26
+ try {
27
+ const result = execSync(`${whichCmd} ${agent.binary}`, {
28
+ stdio: 'pipe',
29
+ timeout: 5000,
30
+ }).toString().trim();
31
+ agents[agent.name] = {
32
+ installed: true,
33
+ path: result.split('\n')[0].trim(),
34
+ label: agent.label,
35
+ };
36
+ } catch {
37
+ // Check local node_modules/.bin as fallback
38
+ if (localBinDir) {
39
+ const localPath = path.join(localBinDir, agent.binary + (isWindows ? '.cmd' : ''));
40
+ if (fs.existsSync(localPath)) {
41
+ agents[agent.name] = { installed: true, path: localPath, label: agent.label };
42
+ continue;
43
+ }
44
+ }
45
+ agents[agent.name] = { installed: false, label: agent.label };
46
+ }
47
+ }
48
+ return { agents };
49
+ },
50
+ },
51
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Exec Agent
3
+ * Handles: exec (arbitrary command execution)
4
+ */
5
+ import { execSync } from 'child_process';
6
+
7
+ export default {
8
+ name: 'exec',
9
+ actions: {
10
+ 'exec': async (params) => {
11
+ const { command, timeout: cmdTimeout = 60000, cwd } = params;
12
+ if (!command) throw new Error('No command provided');
13
+
14
+ try {
15
+ const output = execSync(command, {
16
+ encoding: 'utf8',
17
+ timeout: cmdTimeout,
18
+ cwd: cwd || process.cwd(),
19
+ stdio: ['pipe', 'pipe', 'pipe'],
20
+ });
21
+ return { output, exitCode: 0 };
22
+ } catch (execErr) {
23
+ return {
24
+ output: execErr.stdout || '',
25
+ stderr: execErr.stderr || '',
26
+ exitCode: execErr.status || 1,
27
+ };
28
+ }
29
+ },
30
+ },
31
+ };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * File System Agent
3
+ * Handles: file-read, file-write, file-tree, browse-dirs, validate-path, create-folder
4
+ */
5
+ import { promises as fsPromises } from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+ import {
9
+ buildFileTree,
10
+ resolveTildePath,
11
+ isBlockedPath,
12
+ BLOCKED_READ_PATTERNS,
13
+ BLOCKED_WRITE_PATTERNS,
14
+ } from './utils.js';
15
+
16
+ export default {
17
+ name: 'files',
18
+ actions: {
19
+ 'file-read': async (params) => {
20
+ let { filePath, encoding } = params;
21
+ if (!filePath || typeof filePath !== 'string') throw new Error('Invalid file path');
22
+
23
+ filePath = resolveTildePath(filePath);
24
+ const normalizedPath = path.resolve(filePath);
25
+ if (isBlockedPath(normalizedPath, BLOCKED_READ_PATTERNS)) throw new Error('Access denied');
26
+
27
+ const content = await fsPromises.readFile(normalizedPath, encoding === 'base64' ? null : 'utf8');
28
+ const result = encoding === 'base64' ? content.toString('base64') : content;
29
+ return { content: result };
30
+ },
31
+
32
+ 'file-write': async (params) => {
33
+ let { filePath, content } = params;
34
+ if (!filePath || typeof filePath !== 'string') throw new Error('Invalid file path');
35
+
36
+ filePath = resolveTildePath(filePath);
37
+ const normalizedPath = path.resolve(filePath);
38
+ if (isBlockedPath(normalizedPath, BLOCKED_WRITE_PATTERNS)) throw new Error('Access denied');
39
+
40
+ const parentDir = path.dirname(normalizedPath);
41
+ await fsPromises.mkdir(parentDir, { recursive: true }).catch(() => {});
42
+ await fsPromises.writeFile(normalizedPath, content, 'utf8');
43
+ return { success: true };
44
+ },
45
+
46
+ 'file-tree': async (params) => {
47
+ const { dirPath, depth, maxDepth } = params;
48
+ const treeDepth = depth || maxDepth || 3;
49
+ const resolvedDir = dirPath ? path.resolve(dirPath) : process.cwd();
50
+ const tree = await buildFileTree(resolvedDir, treeDepth, 0, {
51
+ maxEntries: 200,
52
+ includeStats: true,
53
+ skipDotfilesAtRoot: true,
54
+ });
55
+ return { files: tree };
56
+ },
57
+
58
+ 'browse-dirs': async (params) => {
59
+ const { dirPath: browsePath } = params;
60
+ let targetDir = resolveTildePath(browsePath);
61
+ targetDir = path.resolve(targetDir);
62
+
63
+ let drives = [];
64
+ // On Windows, detect available drives
65
+ if (process.platform === 'win32') {
66
+ try {
67
+ const { execSync } = await import('child_process');
68
+ const wmicOut = execSync('wmic logicaldisk get name', { encoding: 'utf8', timeout: 5000 });
69
+ drives = wmicOut.split('\n')
70
+ .map(l => l.trim())
71
+ .filter(l => /^[A-Z]:$/.test(l))
72
+ .map(d => ({ name: d + '\\', path: d + '\\', type: 'drive' }));
73
+ } catch { /* wmic not available */ }
74
+ }
75
+
76
+ const entries = await fsPromises.readdir(targetDir, { withFileTypes: true });
77
+ const dirs = entries
78
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
79
+ .map(e => ({ name: e.name, path: path.join(targetDir, e.name), type: 'directory' }))
80
+ .sort((a, b) => a.name.localeCompare(b.name));
81
+
82
+ return { path: targetDir, suggestions: dirs, drives, homedir: os.homedir() };
83
+ },
84
+
85
+ 'validate-path': async (params) => {
86
+ const { targetPath } = params;
87
+ const checkPath = resolveTildePath(targetPath);
88
+ const resolved = path.resolve(checkPath);
89
+
90
+ try {
91
+ const stats = await fsPromises.stat(resolved);
92
+ return { exists: true, isDirectory: stats.isDirectory(), resolvedPath: resolved };
93
+ } catch {
94
+ return { exists: false, resolvedPath: resolved };
95
+ }
96
+ },
97
+
98
+ 'create-folder': async (params) => {
99
+ const { folderPath } = params;
100
+ const mkPath = path.resolve(resolveTildePath(folderPath));
101
+ await fsPromises.mkdir(mkPath, { recursive: true });
102
+ return { success: true, path: mkPath };
103
+ },
104
+ },
105
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Git Agent
3
+ * Handles: git-operation
4
+ */
5
+ import path from 'path';
6
+ import { execCommand } from './utils.js';
7
+
8
+ export default {
9
+ name: 'git',
10
+ actions: {
11
+ 'git-operation': async (params) => {
12
+ const { gitCommand, cwd } = params;
13
+ const resolvedCwd = cwd ? path.resolve(cwd) : process.cwd();
14
+ const result = await execCommand('git', [gitCommand], { cwd: resolvedCwd });
15
+ return { stdout: result };
16
+ },
17
+ },
18
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Gitagent Agent
3
+ * Handles: gitagent-detect, gitagent-parse
4
+ *
5
+ * Relay-compatible actions for detecting and parsing gitagent directory structures
6
+ * on the user's local machine.
7
+ */
8
+ import fs from 'fs';
9
+ import { promises as fsp } from 'fs';
10
+ import path from 'path';
11
+ import { detectGitagent, parseGitagentRepo } from '../gitagent/parser.js';
12
+
13
+ /** fs-backed helpers injected into the parser */
14
+ async function fsFileExists(filePath) {
15
+ try {
16
+ await fsp.access(filePath, fs.constants.F_OK);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ async function fsReadFile(filePath) {
24
+ try {
25
+ return await fsp.readFile(filePath, 'utf8');
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ async function fsListDir(dirPath) {
32
+ try {
33
+ return await fsp.readdir(dirPath);
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ export default {
40
+ name: 'gitagent',
41
+ actions: {
42
+ /**
43
+ * Lightweight check for agent.yaml existence.
44
+ * @param {{ projectPath: string }} params
45
+ * @returns {{ detected: boolean }}
46
+ */
47
+ 'gitagent-detect': async (params) => {
48
+ const projectPath = params.projectPath;
49
+ if (!projectPath) return { detected: false };
50
+ const detected = await detectGitagent(projectPath, fsFileExists);
51
+ return { detected };
52
+ },
53
+
54
+ /**
55
+ * Full parse — returns the complete GitagentDefinition as JSON.
56
+ * @param {{ projectPath: string }} params
57
+ * @returns {{ definition: object|null }}
58
+ */
59
+ 'gitagent-parse': async (params) => {
60
+ const projectPath = params.projectPath;
61
+ if (!projectPath) return { definition: null };
62
+
63
+ const definition = await parseGitagentRepo(projectPath, fsReadFile, fsListDir);
64
+ return { definition };
65
+ },
66
+ },
67
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Agent Registry
3
+ * Central dispatch for all command actions.
4
+ *
5
+ * Usage:
6
+ * import { executeAction, isStreamingAction } from 'upfynai-shared/agents';
7
+ *
8
+ * // Sync action (file-read, git-operation, etc.)
9
+ * const result = await executeAction('file-read', { filePath: '/foo' }, ctx);
10
+ * // result = { content: '...' }
11
+ *
12
+ * // Streaming action (claude-query, codex-query, etc.)
13
+ * const result = await executeAction('claude-query', params, {
14
+ * ...ctx,
15
+ * stream: (data) => ws.send(JSON.stringify({ type: 'relay-stream', requestId, data })),
16
+ * });
17
+ * // result = { exitCode: 0, sessionId: '...' }
18
+ */
19
+ import claudeAgent from './claude.js';
20
+ import codexAgent from './codex.js';
21
+ import cursorAgent from './cursor.js';
22
+ import shellAgent from './shell.js';
23
+ import filesAgent from './files.js';
24
+ import gitAgent from './git.js';
25
+ import execAgent from './exec.js';
26
+ import detectAgent from './detect.js';
27
+ import gitagentAgent from './gitagent.js';
28
+
29
+ const agents = [claudeAgent, codexAgent, cursorAgent, shellAgent, filesAgent, gitAgent, execAgent, detectAgent, gitagentAgent];
30
+
31
+ /** Map of action name → handler function */
32
+ const actionMap = new Map();
33
+
34
+ /** Set of actions that use streaming (ctx.stream) instead of returning data */
35
+ const streamingActions = new Set(['claude-query', 'claude-task-query', 'codex-query', 'cursor-query']);
36
+
37
+ for (const agent of agents) {
38
+ for (const [action, handler] of Object.entries(agent.actions)) {
39
+ actionMap.set(action, handler);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Execute an agent action.
45
+ * @param {string} action - Action name (e.g., 'file-read', 'claude-query')
46
+ * @param {object} params - Action parameters from the command payload
47
+ * @param {object} ctx - Execution context:
48
+ * - stream(data): Send streaming chunk (required for streaming actions)
49
+ * - requestId: Current request ID
50
+ * - trackProcess(id, entry): Register process for abort support
51
+ * - untrackProcess(id): Remove process from tracking
52
+ * - getPersistentShell(cwd): Get persistent shell instance (CLI only)
53
+ * - resolveBinary(name): Resolve binary path (CLI only)
54
+ * - localBinDir: Path to local node_modules/.bin (CLI only)
55
+ * - streamMode: 'structured' | 'simple' (claude agent only)
56
+ * - log(msg): Logging function
57
+ * @returns {Promise<object>} Action result
58
+ */
59
+ export async function executeAction(action, params, ctx = {}) {
60
+ const handler = actionMap.get(action);
61
+ if (!handler) throw new Error(`Unknown action: ${action}`);
62
+ return handler(params, ctx);
63
+ }
64
+
65
+ /**
66
+ * Check if an action uses streaming.
67
+ * Streaming actions require ctx.stream() and return { exitCode, ... } via Promise.
68
+ * Non-streaming actions return { data } directly.
69
+ */
70
+ export function isStreamingAction(action) {
71
+ return streamingActions.has(action);
72
+ }
73
+
74
+ /**
75
+ * Check if an action is registered.
76
+ */
77
+ export function hasAction(action) {
78
+ return actionMap.has(action);
79
+ }
80
+
81
+ /**
82
+ * Get all registered action names.
83
+ */
84
+ export function getActionNames() {
85
+ return Array.from(actionMap.keys());
86
+ }
87
+
88
+ export { agents };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shell Agent
3
+ * Handles: shell-command
4
+ *
5
+ * Two execution modes:
6
+ * - Persistent shell (connect.js) — commands share one shell process
7
+ * - Simple exec (relay-client.js) — each command spawns a new process
8
+ *
9
+ * The caller provides the execution strategy via ctx.execShell or falls back to execCommand.
10
+ */
11
+ import { execCommand, DANGEROUS_SHELL_PATTERNS } from './utils.js';
12
+
13
+ export default {
14
+ name: 'shell',
15
+ actions: {
16
+ 'shell-command': async (params, ctx) => {
17
+ const { command, cwd } = params;
18
+ if (!command || typeof command !== 'string') throw new Error('Invalid command');
19
+
20
+ // Block dangerous shell patterns
21
+ const cmdLower = command.toLowerCase();
22
+ if (DANGEROUS_SHELL_PATTERNS.some(d => cmdLower.includes(d.toLowerCase()))) {
23
+ throw new Error('Command blocked for safety');
24
+ }
25
+
26
+ // Use persistent shell if provided (connect.js), otherwise simple exec
27
+ if (ctx.getPersistentShell) {
28
+ const shell = ctx.getPersistentShell(cwd || process.cwd());
29
+ const result = await shell.exec(command, { timeoutMs: 60000 });
30
+ return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, cwd: result.cwd };
31
+ }
32
+
33
+ // Simple exec fallback
34
+ const result = await execCommand(command, [], { cwd: cwd || process.cwd(), timeout: 60000 });
35
+ return { stdout: result };
36
+ },
37
+ },
38
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Shared utilities for agent command handlers.
3
+ * Used by both CLI relay clients and backend server.
4
+ */
5
+ import { spawn } from 'child_process';
6
+ import { promises as fsPromises } from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
9
+
10
+ /**
11
+ * Execute a shell command and return stdout.
12
+ * @param {string} cmd - Command to run
13
+ * @param {string[]} args - Command arguments
14
+ * @param {object} options - { cwd, env, timeout }
15
+ * @returns {Promise<string>} stdout
16
+ */
17
+ export function execCommand(cmd, args, options = {}) {
18
+ return new Promise((resolve, reject) => {
19
+ const proc = spawn(cmd, args, {
20
+ shell: true,
21
+ cwd: options.cwd || os.homedir(),
22
+ env: { ...process.env, ...options.env },
23
+ stdio: ['pipe', 'pipe', 'pipe'],
24
+ });
25
+
26
+ let stdout = '';
27
+ let stderr = '';
28
+ proc.stdout.on('data', (d) => { stdout += d; });
29
+ proc.stderr.on('data', (d) => { stderr += d; });
30
+
31
+ const timeout = setTimeout(() => {
32
+ proc.kill();
33
+ reject(new Error('Command timed out'));
34
+ }, options.timeout || 30000);
35
+
36
+ proc.on('close', (code) => {
37
+ clearTimeout(timeout);
38
+ if (code === 0) resolve(stdout);
39
+ else reject(new Error(stderr || `Exit code ${code}`));
40
+ });
41
+
42
+ proc.on('error', (err) => {
43
+ clearTimeout(timeout);
44
+ reject(err);
45
+ });
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Build a file tree for a directory (recursive).
51
+ * @param {string} dirPath - Directory to scan
52
+ * @param {number} maxDepth - Maximum recursion depth
53
+ * @param {number} currentDepth - Current depth (internal)
54
+ * @param {object} options - { maxEntries, includeStats, skipDotfilesAtRoot }
55
+ * @returns {Promise<Array>} File tree items
56
+ */
57
+ export async function buildFileTree(dirPath, maxDepth, currentDepth = 0, options = {}) {
58
+ const { maxEntries = 200, includeStats = false, skipDotfilesAtRoot = false } = options;
59
+ if (currentDepth >= maxDepth) return [];
60
+
61
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.svn', '.hg']);
62
+
63
+ try {
64
+ const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
65
+ const items = [];
66
+ for (const entry of entries.slice(0, maxEntries)) {
67
+ if (SKIP_DIRS.has(entry.name)) continue;
68
+ if (entry.name.startsWith('.') && skipDotfilesAtRoot && currentDepth === 0) continue;
69
+
70
+ const itemPath = path.join(dirPath, entry.name);
71
+ const item = {
72
+ name: entry.name,
73
+ path: itemPath,
74
+ type: entry.isDirectory() ? 'directory' : 'file',
75
+ };
76
+
77
+ if (includeStats) {
78
+ try {
79
+ const stats = await fsPromises.stat(itemPath);
80
+ item.size = stats.size;
81
+ item.modified = stats.mtime.toISOString();
82
+ } catch { /* ignore stat errors */ }
83
+ }
84
+
85
+ if (entry.isDirectory() && currentDepth < maxDepth - 1) {
86
+ item.children = await buildFileTree(itemPath, maxDepth, currentDepth + 1, options);
87
+ }
88
+ items.push(item);
89
+ }
90
+ return items;
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Resolve a path that may start with ~ to an absolute path.
98
+ * @param {string} inputPath - Path to resolve
99
+ * @returns {string} Resolved absolute path
100
+ */
101
+ export function resolveTildePath(inputPath) {
102
+ if (!inputPath) return os.homedir();
103
+ if (inputPath === '~') return os.homedir();
104
+ if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
105
+ return path.join(os.homedir(), inputPath.slice(2));
106
+ }
107
+ return path.resolve(inputPath);
108
+ }
109
+
110
+ /** Blocked paths for file reads (security) */
111
+ export const BLOCKED_READ_PATTERNS = ['/etc/shadow', '/etc/passwd', '.ssh/id_rsa', '.ssh/id_ed25519', '/.env'];
112
+
113
+ /** Blocked paths for file writes (security) */
114
+ export const BLOCKED_WRITE_PATTERNS = [
115
+ '/etc/', '/usr/bin/', '/usr/sbin/',
116
+ '/windows/system32', '/windows/syswow64', '/program files',
117
+ '.ssh/', '/.env',
118
+ ];
119
+
120
+ /** Dangerous shell patterns to block */
121
+ export const DANGEROUS_SHELL_PATTERNS = [
122
+ 'rm -rf /', 'mkfs', 'dd if=', ':(){', 'fork bomb', '> /dev/sd',
123
+ 'format c:', 'format d:', 'format e:', 'del /s /q c:\\',
124
+ 'rd /s /q c:\\', 'reg delete', 'bcdedit',
125
+ ];
126
+
127
+ /**
128
+ * Check if a normalized path matches any blocked patterns.
129
+ * @param {string} normalizedPath - Absolute path (resolved)
130
+ * @param {string[]} patterns - Blocked patterns to check
131
+ * @returns {boolean}
132
+ */
133
+ export function isBlockedPath(normalizedPath, patterns) {
134
+ const lower = normalizedPath.toLowerCase().replace(/\\/g, '/');
135
+ return patterns.some(b => lower.includes(b));
136
+ }