@nodes/agent 0.0.1 → 0.0.3
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/dist/cli.js +4 -1
- package/dist/core/cli/claude.d.ts +2 -0
- package/dist/core/cli/claude.js +107 -0
- package/dist/core/cli/codex.d.ts +2 -0
- package/dist/core/cli/codex.js +85 -0
- package/dist/core/cli/gemini.d.ts +2 -0
- package/dist/core/cli/gemini.js +82 -0
- package/dist/core/cli/shared.d.ts +18 -0
- package/dist/core/cli/shared.js +17 -0
- package/dist/core/cli-stream.d.ts +11 -0
- package/dist/core/cli-stream.js +21 -0
- package/dist/core/loop.d.ts +10 -1
- package/dist/core/loop.js +53 -13
- package/dist/core/providers.d.ts +9 -1
- package/dist/core/providers.js +21 -1
- package/dist/core/serve.js +206 -2
- package/package.json +24 -16
- package/src/cli.ts +120 -0
- package/src/index.ts +3 -0
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ const { values } = parseArgs({
|
|
|
11
11
|
options: {
|
|
12
12
|
prompt: { type: 'string', short: 'p' },
|
|
13
13
|
node: { type: 'string', short: 'n' },
|
|
14
|
+
provider: { type: 'string' },
|
|
14
15
|
serve: { type: 'boolean' },
|
|
15
16
|
simple: { type: 'boolean' },
|
|
16
17
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -27,6 +28,7 @@ Usage:
|
|
|
27
28
|
nodes -n <nodeId> Resume interactive chat on existing node
|
|
28
29
|
nodes -p "prompt" Run a single prompt and exit
|
|
29
30
|
nodes -p "prompt" -n <id> Run prompt in existing chat and exit
|
|
31
|
+
nodes --provider claude-cli Use local Claude CLI (Max subscription)
|
|
30
32
|
nodes --serve Start MCP server (exposes local tools)
|
|
31
33
|
nodes --simple Use simple readline UI (no TUI)
|
|
32
34
|
nodes --help Show this help
|
|
@@ -68,8 +70,9 @@ else {
|
|
|
68
70
|
const userId = tokenMatch?.[1];
|
|
69
71
|
const prompt = values.prompt;
|
|
70
72
|
const nodeId = values.node;
|
|
73
|
+
const provider = values.provider;
|
|
71
74
|
const nodes = new NodesClient({ url, apiKey, agent });
|
|
72
|
-
const config = { nodes, agent, userId, prompt, nodeId };
|
|
75
|
+
const config = { nodes, agent, userId, prompt, nodeId, provider, nodesUrl: url, nodesApiKey: apiKey };
|
|
73
76
|
if (prompt) {
|
|
74
77
|
// One-shot mode
|
|
75
78
|
runOnce(config)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createUIMessageStream } from 'ai';
|
|
4
|
+
import { waitForExit } from './shared.js';
|
|
5
|
+
export function createClaudeStream(options) {
|
|
6
|
+
const { prompt, cwd, systemPrompt, mcpConfig, model, sessionId, allowedTools, abortSignal } = options;
|
|
7
|
+
return createUIMessageStream({
|
|
8
|
+
async execute({ writer }) {
|
|
9
|
+
const args = buildArgs({ prompt, systemPrompt, mcpConfig, model, sessionId, allowedTools, bypassPermissions: options.bypassPermissions });
|
|
10
|
+
const child = spawn('claude', args, {
|
|
11
|
+
cwd: cwd || process.cwd(),
|
|
12
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
+
});
|
|
14
|
+
// Log stderr for debugging
|
|
15
|
+
let stderr = '';
|
|
16
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
17
|
+
if (abortSignal) {
|
|
18
|
+
abortSignal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
19
|
+
}
|
|
20
|
+
let textPartId = 0;
|
|
21
|
+
let sessionResult;
|
|
22
|
+
const rl = createInterface({ input: child.stdout });
|
|
23
|
+
writer.write({ type: 'start' });
|
|
24
|
+
writer.write({ type: 'message-metadata', messageMetadata: { provider: options.provider, model: options.model || 'default' } });
|
|
25
|
+
for await (const line of rl) {
|
|
26
|
+
let event;
|
|
27
|
+
try {
|
|
28
|
+
event = JSON.parse(line);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (event.type === 'assistant') {
|
|
34
|
+
for (const block of event.message.content) {
|
|
35
|
+
if (block.type === 'text') {
|
|
36
|
+
const id = `cli-text-${textPartId++}`;
|
|
37
|
+
writer.write({ type: 'text-start', id });
|
|
38
|
+
writer.write({ type: 'text-delta', id, delta: block.text });
|
|
39
|
+
writer.write({ type: 'text-end', id });
|
|
40
|
+
}
|
|
41
|
+
else if (block.type === 'tool_use') {
|
|
42
|
+
writer.write({ type: 'tool-input-start', toolCallId: block.id, toolName: block.name });
|
|
43
|
+
writer.write({ type: 'tool-input-available', toolCallId: block.id, toolName: block.name, input: block.input });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (event.type === 'user') {
|
|
48
|
+
const content = event.message.content;
|
|
49
|
+
if (Array.isArray(content)) {
|
|
50
|
+
for (const block of content) {
|
|
51
|
+
if (block.type === 'tool_result') {
|
|
52
|
+
writer.write({ type: 'tool-output-available', toolCallId: block.tool_use_id, output: block.content });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (event.type === 'result') {
|
|
58
|
+
sessionResult = event;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await waitForExit(child, abortSignal, 'claude').catch(err => {
|
|
62
|
+
if (stderr)
|
|
63
|
+
console.error('[claude] stderr:', stderr);
|
|
64
|
+
throw err;
|
|
65
|
+
});
|
|
66
|
+
if (sessionResult?.usage) {
|
|
67
|
+
writer.write({ type: 'message-metadata', messageMetadata: {
|
|
68
|
+
provider: options.provider,
|
|
69
|
+
model: options.model || 'default',
|
|
70
|
+
totalUsage: {
|
|
71
|
+
inputTokens: sessionResult.usage.input_tokens || 0,
|
|
72
|
+
outputTokens: sessionResult.usage.output_tokens || 0,
|
|
73
|
+
reasoningTokens: 0,
|
|
74
|
+
cachedInputTokens: sessionResult.usage.cache_read_input_tokens || 0,
|
|
75
|
+
},
|
|
76
|
+
timings: { totalMs: sessionResult.duration_ms || 0 },
|
|
77
|
+
costUsd: sessionResult.total_cost_usd,
|
|
78
|
+
} });
|
|
79
|
+
}
|
|
80
|
+
writer.write({ type: 'finish', finishReason: sessionResult?.subtype === 'success' ? 'stop' : 'error' });
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// --- Args ---
|
|
85
|
+
function buildArgs(opts) {
|
|
86
|
+
const args = ['-p', opts.prompt, '--output-format', 'stream-json', '--verbose'];
|
|
87
|
+
if (opts.systemPrompt) {
|
|
88
|
+
args.push('--append-system-prompt', opts.systemPrompt);
|
|
89
|
+
}
|
|
90
|
+
if (opts.mcpConfig) {
|
|
91
|
+
args.push('--mcp-config', JSON.stringify(opts.mcpConfig));
|
|
92
|
+
}
|
|
93
|
+
const MODELS = ['opus', 'sonnet', 'haiku'];
|
|
94
|
+
if (opts.model && MODELS.includes(opts.model)) {
|
|
95
|
+
args.push('--model', opts.model);
|
|
96
|
+
}
|
|
97
|
+
if (opts.sessionId) {
|
|
98
|
+
args.push('-r', opts.sessionId);
|
|
99
|
+
}
|
|
100
|
+
if (opts.allowedTools?.length) {
|
|
101
|
+
args.push('--allowedTools', opts.allowedTools.join(','));
|
|
102
|
+
}
|
|
103
|
+
if (opts.bypassPermissions) {
|
|
104
|
+
args.push('--dangerously-skip-permissions');
|
|
105
|
+
}
|
|
106
|
+
return args;
|
|
107
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createUIMessageStream } from 'ai';
|
|
4
|
+
import { waitForExit } from './shared.js';
|
|
5
|
+
export function createCodexStream(options) {
|
|
6
|
+
const { prompt, cwd, model, abortSignal } = options;
|
|
7
|
+
return createUIMessageStream({
|
|
8
|
+
async execute({ writer }) {
|
|
9
|
+
const args = buildArgs({ prompt, model, systemPrompt: options.systemPrompt, bypassPermissions: options.bypassPermissions });
|
|
10
|
+
const child = spawn('codex', args, {
|
|
11
|
+
cwd: cwd || process.cwd(),
|
|
12
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
+
});
|
|
14
|
+
// Log stderr for debugging
|
|
15
|
+
let stderr = '';
|
|
16
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
17
|
+
if (abortSignal) {
|
|
18
|
+
abortSignal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
19
|
+
}
|
|
20
|
+
let textPartId = 0;
|
|
21
|
+
let turnUsage;
|
|
22
|
+
const rl = createInterface({ input: child.stdout });
|
|
23
|
+
writer.write({ type: 'start' });
|
|
24
|
+
writer.write({ type: 'message-metadata', messageMetadata: { provider: options.provider, model: options.model || 'default' } });
|
|
25
|
+
for await (const line of rl) {
|
|
26
|
+
let event;
|
|
27
|
+
try {
|
|
28
|
+
event = JSON.parse(line);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (event.type === 'item.completed' && event.item.type === 'agent_message') {
|
|
34
|
+
const id = `cli-text-${textPartId++}`;
|
|
35
|
+
writer.write({ type: 'text-start', id });
|
|
36
|
+
writer.write({ type: 'text-delta', id, delta: event.item.text });
|
|
37
|
+
writer.write({ type: 'text-end', id });
|
|
38
|
+
}
|
|
39
|
+
else if (event.type === 'item.started' && event.item.type === 'command_execution') {
|
|
40
|
+
writer.write({ type: 'tool-input-start', toolCallId: event.item.id, toolName: 'command_execution' });
|
|
41
|
+
writer.write({ type: 'tool-input-available', toolCallId: event.item.id, toolName: 'command_execution', input: { command: event.item.command } });
|
|
42
|
+
}
|
|
43
|
+
else if (event.type === 'item.completed' && event.item.type === 'command_execution') {
|
|
44
|
+
writer.write({ type: 'tool-output-available', toolCallId: event.item.id, output: { output: event.item.aggregated_output, exit_code: event.item.exit_code, status: event.item.status } });
|
|
45
|
+
}
|
|
46
|
+
else if (event.type === 'turn.completed') {
|
|
47
|
+
turnUsage = event.usage;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await waitForExit(child, abortSignal, 'codex').catch(err => {
|
|
51
|
+
console.error('[codex] stderr:', stderr || '(empty)');
|
|
52
|
+
throw err;
|
|
53
|
+
});
|
|
54
|
+
if (turnUsage) {
|
|
55
|
+
writer.write({ type: 'message-metadata', messageMetadata: {
|
|
56
|
+
provider: options.provider,
|
|
57
|
+
model: options.model || 'default',
|
|
58
|
+
totalUsage: {
|
|
59
|
+
inputTokens: turnUsage.input_tokens || 0,
|
|
60
|
+
outputTokens: turnUsage.output_tokens || 0,
|
|
61
|
+
reasoningTokens: 0,
|
|
62
|
+
cachedInputTokens: turnUsage.cached_input_tokens || 0,
|
|
63
|
+
},
|
|
64
|
+
} });
|
|
65
|
+
}
|
|
66
|
+
writer.write({ type: 'finish', finishReason: 'stop' });
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// --- Args ---
|
|
71
|
+
function buildArgs(opts) {
|
|
72
|
+
const args = ['exec', '--json', opts.prompt];
|
|
73
|
+
// Only pass model if it looks like a valid codex model, skip generic IDs
|
|
74
|
+
if (opts.model && !opts.model.includes('/')) {
|
|
75
|
+
args.push('-m', opts.model);
|
|
76
|
+
}
|
|
77
|
+
if (opts.systemPrompt) {
|
|
78
|
+
// Triple-quoted TOML string handles newlines and quotes safely
|
|
79
|
+
args.push('-c', `instructions="""${opts.systemPrompt}"""`);
|
|
80
|
+
}
|
|
81
|
+
if (opts.bypassPermissions) {
|
|
82
|
+
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
83
|
+
}
|
|
84
|
+
return args;
|
|
85
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createUIMessageStream } from 'ai';
|
|
4
|
+
import { waitForExit } from './shared.js';
|
|
5
|
+
export function createGeminiStream(options) {
|
|
6
|
+
const { prompt, cwd, model, abortSignal } = options;
|
|
7
|
+
return createUIMessageStream({
|
|
8
|
+
async execute({ writer }) {
|
|
9
|
+
const args = buildArgs({ prompt, model, bypassPermissions: options.bypassPermissions });
|
|
10
|
+
const child = spawn('gemini', args, {
|
|
11
|
+
cwd: cwd || process.cwd(),
|
|
12
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
+
});
|
|
14
|
+
if (abortSignal) {
|
|
15
|
+
abortSignal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
16
|
+
}
|
|
17
|
+
let stderr = '';
|
|
18
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
19
|
+
let textPartId = 0;
|
|
20
|
+
let resultStats;
|
|
21
|
+
const rl = createInterface({ input: child.stdout });
|
|
22
|
+
writer.write({ type: 'start' });
|
|
23
|
+
writer.write({ type: 'message-metadata', messageMetadata: { provider: options.provider, model: options.model || 'default' } });
|
|
24
|
+
for await (const line of rl) {
|
|
25
|
+
let event;
|
|
26
|
+
try {
|
|
27
|
+
event = JSON.parse(line);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (event.type === 'message' && event.role === 'assistant') {
|
|
33
|
+
const id = `cli-text-${textPartId++}`;
|
|
34
|
+
writer.write({ type: 'text-start', id });
|
|
35
|
+
writer.write({ type: 'text-delta', id, delta: event.content });
|
|
36
|
+
writer.write({ type: 'text-end', id });
|
|
37
|
+
}
|
|
38
|
+
else if (event.type === 'tool_use') {
|
|
39
|
+
writer.write({ type: 'tool-input-start', toolCallId: event.tool_id, toolName: event.tool_name });
|
|
40
|
+
writer.write({ type: 'tool-input-available', toolCallId: event.tool_id, toolName: event.tool_name, input: event.parameters });
|
|
41
|
+
}
|
|
42
|
+
else if (event.type === 'tool_result') {
|
|
43
|
+
writer.write({ type: 'tool-output-available', toolCallId: event.tool_id, output: event.output });
|
|
44
|
+
}
|
|
45
|
+
else if (event.type === 'result') {
|
|
46
|
+
resultStats = event.stats;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
await waitForExit(child, abortSignal, 'gemini').catch(err => {
|
|
50
|
+
if (stderr)
|
|
51
|
+
console.error('[gemini] stderr:', stderr);
|
|
52
|
+
throw err;
|
|
53
|
+
});
|
|
54
|
+
if (resultStats) {
|
|
55
|
+
writer.write({ type: 'message-metadata', messageMetadata: {
|
|
56
|
+
provider: options.provider,
|
|
57
|
+
model: options.model || 'default',
|
|
58
|
+
totalUsage: {
|
|
59
|
+
inputTokens: resultStats.input_tokens || 0,
|
|
60
|
+
outputTokens: resultStats.output_tokens || 0,
|
|
61
|
+
reasoningTokens: 0,
|
|
62
|
+
cachedInputTokens: resultStats.cached || 0,
|
|
63
|
+
},
|
|
64
|
+
timings: { totalMs: resultStats.duration_ms || 0 },
|
|
65
|
+
} });
|
|
66
|
+
}
|
|
67
|
+
writer.write({ type: 'finish', finishReason: 'stop' });
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// --- Args ---
|
|
72
|
+
function buildArgs(opts) {
|
|
73
|
+
const args = ['-p', opts.prompt, '-o', 'stream-json'];
|
|
74
|
+
// Skip -m for auto mode (let gemini CLI pick the best model)
|
|
75
|
+
if (opts.model && !opts.model.includes('/') && !opts.model.startsWith('auto')) {
|
|
76
|
+
args.push('-m', opts.model);
|
|
77
|
+
}
|
|
78
|
+
if (opts.bypassPermissions) {
|
|
79
|
+
args.push('-y'); // YOLO mode — auto-approve all tool calls
|
|
80
|
+
}
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { spawn } from 'node:child_process';
|
|
2
|
+
export declare const CLI_PROVIDERS: readonly ["claude-cli", "codex-cli", "gemini-cli"];
|
|
3
|
+
export type CLIProvider = (typeof CLI_PROVIDERS)[number];
|
|
4
|
+
export declare function isCLIProvider(provider: string): provider is CLIProvider;
|
|
5
|
+
export interface CLIStreamOptions {
|
|
6
|
+
provider: CLIProvider;
|
|
7
|
+
prompt: string;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
systemPrompt?: string;
|
|
10
|
+
mcpConfig?: Record<string, unknown>;
|
|
11
|
+
model?: string;
|
|
12
|
+
sessionId?: string;
|
|
13
|
+
allowedTools?: string[];
|
|
14
|
+
/** Skip all permission prompts (for headless/webhook mode) */
|
|
15
|
+
bypassPermissions?: boolean;
|
|
16
|
+
abortSignal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
export declare function waitForExit(child: ReturnType<typeof spawn>, abortSignal?: AbortSignal, name?: string): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'gemini-cli'];
|
|
2
|
+
export function isCLIProvider(provider) {
|
|
3
|
+
return CLI_PROVIDERS.includes(provider);
|
|
4
|
+
}
|
|
5
|
+
export function waitForExit(child, abortSignal, name = 'cli') {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
child.on('close', (code) => {
|
|
8
|
+
if (code !== 0 && !abortSignal?.aborted) {
|
|
9
|
+
reject(new Error(`${name} exited with code ${code}`));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
resolve();
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
child.on('error', reject);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { CLI_PROVIDERS, isCLIProvider } from './cli/shared.js';
|
|
2
|
+
export type { CLIProvider, CLIStreamOptions } from './cli/shared.js';
|
|
3
|
+
export { createClaudeStream } from './cli/claude.js';
|
|
4
|
+
export { createCodexStream } from './cli/codex.js';
|
|
5
|
+
export { createGeminiStream } from './cli/gemini.js';
|
|
6
|
+
import type { CLIStreamOptions } from './cli/shared.js';
|
|
7
|
+
/**
|
|
8
|
+
* Spawns a CLI agent (claude, codex, or gemini) and returns a UIMessageStream
|
|
9
|
+
* compatible with readUIMessageStream() from Vercel AI SDK.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createCLIStream(options: CLIStreamOptions): ReadableStream<import("ai").InferUIMessageChunk<import("ai").UIMessage<unknown, import("ai").UIDataTypes, import("ai").UITools>>>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Re-export from per-provider modules
|
|
2
|
+
export { CLI_PROVIDERS, isCLIProvider } from './cli/shared.js';
|
|
3
|
+
export { createClaudeStream } from './cli/claude.js';
|
|
4
|
+
export { createCodexStream } from './cli/codex.js';
|
|
5
|
+
export { createGeminiStream } from './cli/gemini.js';
|
|
6
|
+
import { createClaudeStream } from './cli/claude.js';
|
|
7
|
+
import { createCodexStream } from './cli/codex.js';
|
|
8
|
+
import { createGeminiStream } from './cli/gemini.js';
|
|
9
|
+
/**
|
|
10
|
+
* Spawns a CLI agent (claude, codex, or gemini) and returns a UIMessageStream
|
|
11
|
+
* compatible with readUIMessageStream() from Vercel AI SDK.
|
|
12
|
+
*/
|
|
13
|
+
export function createCLIStream(options) {
|
|
14
|
+
if (options.provider === 'claude-cli')
|
|
15
|
+
return createClaudeStream(options);
|
|
16
|
+
if (options.provider === 'codex-cli')
|
|
17
|
+
return createCodexStream(options);
|
|
18
|
+
if (options.provider === 'gemini-cli')
|
|
19
|
+
return createGeminiStream(options);
|
|
20
|
+
throw new Error(`CLI provider "${options.provider}" not supported.`);
|
|
21
|
+
}
|
package/dist/core/loop.d.ts
CHANGED
|
@@ -5,21 +5,30 @@ export interface AgentConfig {
|
|
|
5
5
|
/** Agent slug (e.g. "nodes/my-agent") or user ID */
|
|
6
6
|
agent: string;
|
|
7
7
|
model?: string;
|
|
8
|
+
/** Override provider (e.g. "claude-cli" to use local CLI subscription) */
|
|
9
|
+
provider?: string;
|
|
8
10
|
/** Human user ID — extracted from user-scoped API key (nodes_<userId>_<secret>) */
|
|
9
11
|
userId?: string;
|
|
10
12
|
/** One-shot prompt — run and exit */
|
|
11
13
|
prompt?: string;
|
|
12
14
|
/** Existing chat node ID to resume */
|
|
13
15
|
nodeId?: string;
|
|
16
|
+
/** Nodes instance URL — needed for CLI providers to build MCP config */
|
|
17
|
+
nodesUrl?: string;
|
|
18
|
+
/** Nodes API key — needed for CLI providers to build MCP config */
|
|
19
|
+
nodesApiKey?: string;
|
|
14
20
|
}
|
|
15
21
|
export interface AgentEnv {
|
|
16
22
|
modelId: string;
|
|
17
|
-
model
|
|
23
|
+
model?: LanguageModel;
|
|
18
24
|
provider: string;
|
|
19
25
|
allTools: Record<string, any>;
|
|
20
26
|
systemPrompt: string;
|
|
21
27
|
chatsNodeId?: string;
|
|
22
28
|
agentName: string;
|
|
29
|
+
/** Nodes connection info — used by CLI providers for MCP config */
|
|
30
|
+
nodesUrl?: string;
|
|
31
|
+
nodesApiKey?: string;
|
|
23
32
|
}
|
|
24
33
|
export declare function loadAgentEnv(config: AgentConfig): Promise<AgentEnv>;
|
|
25
34
|
export interface TurnOptions {
|
package/dist/core/loop.js
CHANGED
|
@@ -2,17 +2,20 @@ import { streamText, hasToolCall, stepCountIs, readUIMessageStream } from 'ai';
|
|
|
2
2
|
import { localTools } from './tools.js';
|
|
3
3
|
import { loadRemoteTools } from './remote-tools.js';
|
|
4
4
|
import { resolveModel } from './providers.js';
|
|
5
|
+
import { createCLIStream, isCLIProvider } from './cli-stream.js';
|
|
5
6
|
const DEFAULT_MAX_STEPS = 50;
|
|
6
7
|
const MAX_STEPS = Number(process.env.MAX_STEPS) || DEFAULT_MAX_STEPS;
|
|
7
8
|
const stopWhen = [hasToolCall('ai_end'), hasToolCall('ai_buttons'), stepCountIs(MAX_STEPS)];
|
|
8
9
|
export async function loadAgentEnv(config) {
|
|
9
10
|
const { nodes, agent } = config;
|
|
10
11
|
const agentInfo = await nodes.ai.readAgent(agent, { include: ['systemPrompt'] });
|
|
11
|
-
const provider = agentInfo.ai?.provider || 'gateway';
|
|
12
|
+
const provider = config.provider || agentInfo.ai?.provider || 'gateway';
|
|
12
13
|
const modelId = config.model || agentInfo.ai?.model || 'google/gemini-2.5-flash';
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
14
|
+
// CLI providers use the local CLI binary — no API model needed
|
|
15
|
+
const model = isCLIProvider(provider) ? undefined : resolveModel(provider, modelId);
|
|
16
|
+
// CLI providers handle their own tools — skip loading for them
|
|
17
|
+
const remoteTools = isCLIProvider(provider) ? {} : await loadRemoteTools(nodes, agentInfo.id);
|
|
18
|
+
const allTools = isCLIProvider(provider) ? {} : { ...localTools, ...remoteTools };
|
|
16
19
|
const systemPrompt = agentInfo.systemPrompt || 'You are a helpful AI agent.';
|
|
17
20
|
return {
|
|
18
21
|
modelId,
|
|
@@ -22,10 +25,39 @@ export async function loadAgentEnv(config) {
|
|
|
22
25
|
systemPrompt,
|
|
23
26
|
chatsNodeId: agentInfo.ai?.chatsNode,
|
|
24
27
|
agentName: agentInfo.name,
|
|
28
|
+
nodesUrl: config.nodesUrl,
|
|
29
|
+
nodesApiKey: config.nodesApiKey,
|
|
25
30
|
};
|
|
26
31
|
}
|
|
27
32
|
export async function runTurn(env, options) {
|
|
28
33
|
const startMs = Date.now();
|
|
34
|
+
// CLI providers: spawn local CLI binary and parse its output
|
|
35
|
+
if (isCLIProvider(env.provider)) {
|
|
36
|
+
// For CLI mode, combine message history into a single prompt
|
|
37
|
+
const prompt = options.messages
|
|
38
|
+
? options.messages.map(m => `${m.role}: ${m.content}`).join('\n\n') + '\n\nRespond to the last user message above.'
|
|
39
|
+
: options.prompt || '';
|
|
40
|
+
// Build MCP config so Claude CLI can access Nodes platform tools
|
|
41
|
+
const mcpConfig = (env.nodesUrl && env.nodesApiKey) ? {
|
|
42
|
+
mcpServers: {
|
|
43
|
+
nodes: {
|
|
44
|
+
type: 'http',
|
|
45
|
+
url: `${env.nodesUrl}/api/v1/mcp/`,
|
|
46
|
+
headers: { Authorization: `Bearer ${env.nodesApiKey}` },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
} : undefined;
|
|
50
|
+
const cliStream = createCLIStream({
|
|
51
|
+
provider: env.provider,
|
|
52
|
+
prompt,
|
|
53
|
+
systemPrompt: env.systemPrompt,
|
|
54
|
+
model: env.modelId,
|
|
55
|
+
mcpConfig,
|
|
56
|
+
abortSignal: options.abortSignal,
|
|
57
|
+
});
|
|
58
|
+
return consumeUIStream(cliStream, env, options, startMs);
|
|
59
|
+
}
|
|
60
|
+
// API providers: use Vercel AI SDK streamText()
|
|
29
61
|
const result = streamText({
|
|
30
62
|
model: env.model,
|
|
31
63
|
system: env.systemPrompt,
|
|
@@ -37,7 +69,22 @@ export async function runTurn(env, options) {
|
|
|
37
69
|
: { prompt: options.prompt || '' }),
|
|
38
70
|
});
|
|
39
71
|
// Use native AI SDK UIMessage stream — same format the Nodes UI uses
|
|
40
|
-
const
|
|
72
|
+
const stream = result.toUIMessageStream();
|
|
73
|
+
const turnResult = await consumeUIStream(stream, env, options, startMs);
|
|
74
|
+
// API providers have precise usage stats from the SDK
|
|
75
|
+
const usage = await result.totalUsage;
|
|
76
|
+
turnResult.metadata.totalUsage = {
|
|
77
|
+
inputTokens: usage.inputTokens,
|
|
78
|
+
outputTokens: usage.outputTokens,
|
|
79
|
+
totalTokens: usage.totalTokens,
|
|
80
|
+
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
|
|
81
|
+
cachedInputTokens: usage.inputTokenDetails?.cacheReadTokens,
|
|
82
|
+
};
|
|
83
|
+
return turnResult;
|
|
84
|
+
}
|
|
85
|
+
// --- Shared stream consumer for both API and CLI paths ---
|
|
86
|
+
async function consumeUIStream(stream, env, options, startMs) {
|
|
87
|
+
const uiStream = readUIMessageStream({ stream });
|
|
41
88
|
let message;
|
|
42
89
|
let prevTextLen = 0;
|
|
43
90
|
let prevReasoningLen = 0;
|
|
@@ -79,7 +126,6 @@ export async function runTurn(env, options) {
|
|
|
79
126
|
}
|
|
80
127
|
}
|
|
81
128
|
const totalMs = Date.now() - startMs;
|
|
82
|
-
const usage = await result.totalUsage;
|
|
83
129
|
const parts = message?.parts || [];
|
|
84
130
|
const fullText = parts
|
|
85
131
|
.filter(p => p.type === 'text')
|
|
@@ -93,13 +139,7 @@ export async function runTurn(env, options) {
|
|
|
93
139
|
metadata: {
|
|
94
140
|
provider: env.provider,
|
|
95
141
|
model: env.modelId,
|
|
96
|
-
totalUsage: {
|
|
97
|
-
inputTokens: usage.inputTokens,
|
|
98
|
-
outputTokens: usage.outputTokens,
|
|
99
|
-
totalTokens: usage.totalTokens,
|
|
100
|
-
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
|
|
101
|
-
cachedInputTokens: usage.inputTokenDetails?.cacheReadTokens,
|
|
102
|
-
},
|
|
142
|
+
totalUsage: {},
|
|
103
143
|
timings: { totalMs },
|
|
104
144
|
},
|
|
105
145
|
};
|
package/dist/core/providers.d.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
import type { LanguageModel } from 'ai';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a language model from provider + model ID.
|
|
4
|
+
*
|
|
5
|
+
* Supports two formats:
|
|
6
|
+
* 1. Explicit provider: resolveModel('minimax', 'MiniMax-M2.7')
|
|
7
|
+
* 2. Slash format: resolveModel('minimax/MiniMax-M2.7')
|
|
8
|
+
* (provider extracted from prefix, rest is model ID)
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveModel(provider: string, modelId?: string): LanguageModel;
|
package/dist/core/providers.js
CHANGED
|
@@ -2,7 +2,25 @@ import { createGateway } from '@ai-sdk/gateway';
|
|
|
2
2
|
import { createOpenAI } from '@ai-sdk/openai';
|
|
3
3
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
4
4
|
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
|
5
|
+
import { createMinimax } from 'vercel-minimax-ai-provider';
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a language model from provider + model ID.
|
|
8
|
+
*
|
|
9
|
+
* Supports two formats:
|
|
10
|
+
* 1. Explicit provider: resolveModel('minimax', 'MiniMax-M2.7')
|
|
11
|
+
* 2. Slash format: resolveModel('minimax/MiniMax-M2.7')
|
|
12
|
+
* (provider extracted from prefix, rest is model ID)
|
|
13
|
+
*/
|
|
5
14
|
export function resolveModel(provider, modelId) {
|
|
15
|
+
// Support "provider/model" format — split on first slash
|
|
16
|
+
if (!modelId && provider.includes('/')) {
|
|
17
|
+
const idx = provider.indexOf('/');
|
|
18
|
+
modelId = provider.slice(idx + 1);
|
|
19
|
+
provider = provider.slice(0, idx);
|
|
20
|
+
}
|
|
21
|
+
if (!modelId) {
|
|
22
|
+
throw new Error(`No model ID provided for provider "${provider}"`);
|
|
23
|
+
}
|
|
6
24
|
switch (provider) {
|
|
7
25
|
case 'openai':
|
|
8
26
|
return createOpenAI({ apiKey: process.env.OPENAI_API_KEY }).languageModel(modelId);
|
|
@@ -10,8 +28,10 @@ export function resolveModel(provider, modelId) {
|
|
|
10
28
|
return createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY }).languageModel(modelId);
|
|
11
29
|
case 'google':
|
|
12
30
|
return createGoogleGenerativeAI({ apiKey: process.env.GOOGLE_API_KEY }).languageModel(modelId);
|
|
31
|
+
case 'minimax':
|
|
32
|
+
return createMinimax({ apiKey: process.env.MINIMAX_API_KEY }).languageModel(modelId);
|
|
13
33
|
case 'gateway':
|
|
14
34
|
default:
|
|
15
|
-
return createGateway({ apiKey: process.env.GATEWAY_API_KEY }).languageModel(modelId);
|
|
35
|
+
return createGateway({ apiKey: process.env.GATEWAY_API_KEY }).languageModel(modelId.includes('/') ? modelId : `${provider}/${modelId}`);
|
|
16
36
|
}
|
|
17
37
|
}
|
package/dist/core/serve.js
CHANGED
|
@@ -4,6 +4,10 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
4
4
|
import { createServer } from 'node:http';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { execBash, execReadFile, execWriteFile } from './tools.js';
|
|
7
|
+
import { createCLIStream, isCLIProvider } from './cli-stream.js';
|
|
8
|
+
import { resolveModel } from './providers.js';
|
|
9
|
+
import { localTools } from './tools.js';
|
|
10
|
+
import { JsonToSseTransformStream, readUIMessageStream, streamText, hasToolCall, stepCountIs } from 'ai';
|
|
7
11
|
const require = createRequire(import.meta.url);
|
|
8
12
|
const pkg = require('../../package.json');
|
|
9
13
|
export async function serve(options = {}) {
|
|
@@ -94,6 +98,204 @@ export async function serve(options = {}) {
|
|
|
94
98
|
}
|
|
95
99
|
return;
|
|
96
100
|
}
|
|
101
|
+
// Chat webhook — receives prompt, spawns CLI, returns SSE stream
|
|
102
|
+
if (url.pathname === '/chat' && req.method === 'POST') {
|
|
103
|
+
if (!checkAuth(req)) {
|
|
104
|
+
console.log(`[chat] ✗ Unauthorized request`);
|
|
105
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
106
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const chunks = [];
|
|
110
|
+
for await (const chunk of req)
|
|
111
|
+
chunks.push(chunk);
|
|
112
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
113
|
+
let body;
|
|
114
|
+
try {
|
|
115
|
+
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
119
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Build prompt from messages array or direct prompt
|
|
123
|
+
let prompt = body.prompt;
|
|
124
|
+
if (!prompt && body.messages?.length) {
|
|
125
|
+
// Pass full message history as JSON for context preservation
|
|
126
|
+
prompt = JSON.stringify(body.messages, null, 2);
|
|
127
|
+
}
|
|
128
|
+
if (!prompt) {
|
|
129
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify({ error: 'Missing prompt or messages' }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const rawProvider = (body.provider || 'claude-cli');
|
|
134
|
+
const startTime = Date.now();
|
|
135
|
+
const streamMode = body.stream === false ? 'off' : body.stream === 'raw' ? 'raw' : 'sse';
|
|
136
|
+
console.log(`[chat] → Received: provider=${rawProvider}, model=${body.model || 'default'}, stream=${streamMode}, prompt=${prompt.length} chars`);
|
|
137
|
+
// Resolve execution stream — CLI binary or API provider
|
|
138
|
+
let execStream;
|
|
139
|
+
if (isCLIProvider(rawProvider)) {
|
|
140
|
+
// CLI providers: spawn local binary
|
|
141
|
+
console.log(`[chat] Spawning CLI stream...`);
|
|
142
|
+
execStream = createCLIStream({
|
|
143
|
+
provider: rawProvider,
|
|
144
|
+
prompt,
|
|
145
|
+
systemPrompt: body.systemPrompt,
|
|
146
|
+
model: body.model,
|
|
147
|
+
bypassPermissions: true,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// API providers (including nodes-cli): resolve model from body.model (e.g. "minimax/MiniMax-M2.7")
|
|
152
|
+
const modelId = body.model || rawProvider;
|
|
153
|
+
console.log(`[chat] Resolving API model: ${modelId}`);
|
|
154
|
+
try {
|
|
155
|
+
const model = resolveModel(modelId);
|
|
156
|
+
const startAt = Date.now();
|
|
157
|
+
let firstTokenAt;
|
|
158
|
+
const result = streamText({
|
|
159
|
+
model,
|
|
160
|
+
system: body.systemPrompt,
|
|
161
|
+
tools: localTools,
|
|
162
|
+
stopWhen: [hasToolCall('ai_end'), stepCountIs(50)],
|
|
163
|
+
...(body.messages?.length
|
|
164
|
+
? { messages: body.messages }
|
|
165
|
+
: { prompt }),
|
|
166
|
+
});
|
|
167
|
+
// Parse provider/model from the slash format for metadata
|
|
168
|
+
const providerName = modelId.includes('/') ? modelId.split('/')[0] : rawProvider;
|
|
169
|
+
const modelName = modelId.includes('/') ? modelId.split('/').slice(1).join('/') : modelId;
|
|
170
|
+
// Embed real metadata into the stream — same pattern as handlerV2's createMessageMetadata
|
|
171
|
+
const messageMetadata = ({ part }) => {
|
|
172
|
+
if (!firstTokenAt && part && (part.type === 'text-delta' || part.type === 'reasoning-delta')) {
|
|
173
|
+
firstTokenAt = Date.now();
|
|
174
|
+
return { timings: { ttfbMs: firstTokenAt - startAt } };
|
|
175
|
+
}
|
|
176
|
+
if (part.type === 'start') {
|
|
177
|
+
return { provider: providerName, model: modelName };
|
|
178
|
+
}
|
|
179
|
+
if (part.type === 'finish') {
|
|
180
|
+
const totalMs = Date.now() - startAt;
|
|
181
|
+
const outputPhaseMs = firstTokenAt ? Date.now() - firstTokenAt : undefined;
|
|
182
|
+
const usageRaw = (part.totalUsage ?? {});
|
|
183
|
+
const outputTokenDetails = usageRaw.outputTokenDetails;
|
|
184
|
+
const inputTokenDetails = usageRaw.inputTokenDetails;
|
|
185
|
+
const outputTokens = typeof usageRaw.outputTokens === 'number' ? usageRaw.outputTokens : 0;
|
|
186
|
+
const reasoningTokens = outputTokenDetails?.reasoningTokens ?? (typeof usageRaw.reasoningTokens === 'number' ? usageRaw.reasoningTokens : 0);
|
|
187
|
+
const cachedInputTokens = inputTokenDetails?.cacheReadTokens;
|
|
188
|
+
const tokensPerSec = (outputTokens + reasoningTokens) > 0 && outputPhaseMs
|
|
189
|
+
? (outputTokens + reasoningTokens) / (outputPhaseMs / 1000) : undefined;
|
|
190
|
+
return {
|
|
191
|
+
totalUsage: {
|
|
192
|
+
...usageRaw,
|
|
193
|
+
reasoningTokens: reasoningTokens || undefined,
|
|
194
|
+
cachedInputTokens: cachedInputTokens || undefined,
|
|
195
|
+
},
|
|
196
|
+
timings: { ttfbMs: firstTokenAt ? firstTokenAt - startAt : undefined, totalMs, outputPhaseMs, tokensPerSec },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
};
|
|
201
|
+
execStream = result.toUIMessageStream({ messageMetadata, sendReasoning: true });
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
console.error('[chat] ✗ Model resolution failed:', err);
|
|
205
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
206
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Failed to resolve model' }));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (streamMode === 'raw') {
|
|
211
|
+
// Raw UIMessageStream chunks — for server-to-server (Nodes web)
|
|
212
|
+
res.writeHead(200, {
|
|
213
|
+
'Content-Type': 'application/x-ndjson',
|
|
214
|
+
'Cache-Control': 'no-cache',
|
|
215
|
+
Connection: 'keep-alive',
|
|
216
|
+
'X-Accel-Buffering': 'no',
|
|
217
|
+
});
|
|
218
|
+
res.flushHeaders();
|
|
219
|
+
console.log(`[chat] Streaming raw...`);
|
|
220
|
+
let chunkCount = 0;
|
|
221
|
+
const reader = execStream.getReader();
|
|
222
|
+
try {
|
|
223
|
+
while (true) {
|
|
224
|
+
const { done, value } = await reader.read();
|
|
225
|
+
if (done)
|
|
226
|
+
break;
|
|
227
|
+
chunkCount++;
|
|
228
|
+
// Each chunk is a JSON object from the UIMessageStream — write as NDJSON
|
|
229
|
+
const line = typeof value === 'string' ? value : JSON.stringify(value);
|
|
230
|
+
res.write(line + '\n');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
console.error('[chat] ✗ Stream error:', err);
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
238
|
+
console.log(`[chat] ← Done (raw): ${chunkCount} chunks in ${elapsed}s`);
|
|
239
|
+
res.end();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else if (streamMode === 'sse') {
|
|
243
|
+
// SSE streaming response — for browsers/external clients
|
|
244
|
+
const sseStream = execStream.pipeThrough(new JsonToSseTransformStream());
|
|
245
|
+
res.writeHead(200, {
|
|
246
|
+
'Content-Type': 'text/event-stream',
|
|
247
|
+
'Cache-Control': 'no-cache',
|
|
248
|
+
Connection: 'keep-alive',
|
|
249
|
+
'X-Accel-Buffering': 'no',
|
|
250
|
+
'X-Vercel-AI-UI-Message-Stream': 'v1',
|
|
251
|
+
});
|
|
252
|
+
res.flushHeaders();
|
|
253
|
+
console.log(`[chat] Streaming SSE...`);
|
|
254
|
+
let chunkCount = 0;
|
|
255
|
+
const reader = sseStream.getReader();
|
|
256
|
+
try {
|
|
257
|
+
while (true) {
|
|
258
|
+
const { done, value } = await reader.read();
|
|
259
|
+
if (done)
|
|
260
|
+
break;
|
|
261
|
+
chunkCount++;
|
|
262
|
+
res.write(value);
|
|
263
|
+
if (typeof res.flush === 'function') {
|
|
264
|
+
res.flush();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.error('[chat] ✗ Stream error:', err);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
273
|
+
console.log(`[chat] ← Done (SSE): ${chunkCount} chunks in ${elapsed}s`);
|
|
274
|
+
res.end();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
// Non-stream: consume stream, return JSON with final UIMessage
|
|
279
|
+
try {
|
|
280
|
+
let lastAssistant;
|
|
281
|
+
for await (const snapshot of readUIMessageStream({ stream: execStream })) {
|
|
282
|
+
if (snapshot.role === 'assistant')
|
|
283
|
+
lastAssistant = snapshot;
|
|
284
|
+
}
|
|
285
|
+
const message = lastAssistant || { id: 'assistant', role: 'assistant', parts: [] };
|
|
286
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
287
|
+
console.log(`[chat] ← Done (JSON): ${message.parts.length} parts in ${elapsed}s`);
|
|
288
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
289
|
+
res.end(JSON.stringify({ message }));
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
console.error('[chat] ✗ Error:', err);
|
|
293
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
294
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Execution failed' }));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
97
299
|
// Health check
|
|
98
300
|
if (url.pathname === '/health') {
|
|
99
301
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -104,8 +306,10 @@ export async function serve(options = {}) {
|
|
|
104
306
|
res.end('Not found');
|
|
105
307
|
});
|
|
106
308
|
httpServer.listen(port, () => {
|
|
107
|
-
console.log(`[serve] Nodes Agent
|
|
108
|
-
console.log(`[serve]
|
|
309
|
+
console.log(`[serve] Nodes Agent server listening on http://0.0.0.0:${port}`);
|
|
310
|
+
console.log(`[serve] MCP: http://localhost:${port}/mcp`);
|
|
311
|
+
console.log(`[serve] Chat: http://localhost:${port}/chat`);
|
|
312
|
+
console.log(`[serve] Health: http://localhost:${port}/health`);
|
|
109
313
|
console.log(`[serve] Tools: bash, read_file, write_file`);
|
|
110
314
|
console.log(`[serve] Auth: ${apiKey ? 'Bearer token required' : 'OPEN (no AGENT_API_KEY set)'}`);
|
|
111
315
|
});
|
package/package.json
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodes/agent",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Autonomous AI agent runtime for Nodes",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
8
|
"bin": {
|
|
9
|
-
"nodes": "
|
|
9
|
+
"nodes": "src/cli.ts"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"bin": {
|
|
15
|
+
"nodes": "dist/cli.js"
|
|
16
|
+
}
|
|
10
17
|
},
|
|
11
18
|
"files": [
|
|
12
19
|
"dist"
|
|
13
20
|
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"dev": "tsx --env-file=.env src/cli.ts",
|
|
24
|
+
"prompt": "tsx --env-file=.env src/cli.ts -p",
|
|
25
|
+
"serve": "tsx --env-file=.env src/cli.ts --serve",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest",
|
|
28
|
+
"test:integration": "vitest run src/tests/run-once.test.ts"
|
|
29
|
+
},
|
|
14
30
|
"dependencies": {
|
|
15
31
|
"@ai-sdk/anthropic": "3.0.47",
|
|
16
32
|
"@ai-sdk/gateway": "3.0.46",
|
|
@@ -18,24 +34,16 @@
|
|
|
18
34
|
"@ai-sdk/openai": "3.0.33",
|
|
19
35
|
"@mariozechner/pi-tui": "^0.55.0",
|
|
20
36
|
"@modelcontextprotocol/sdk": "1.23.0",
|
|
37
|
+
"@nodes/sdk": "workspace:*",
|
|
21
38
|
"ai": "6.0.86",
|
|
22
39
|
"picocolors": "^1.1.1",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
40
|
+
"vercel-minimax-ai-provider": "0.0.2",
|
|
41
|
+
"zod": "3.25.76"
|
|
25
42
|
},
|
|
26
43
|
"devDependencies": {
|
|
27
44
|
"@types/node": "^24.0.0",
|
|
28
45
|
"tsx": "^4.19.0",
|
|
29
46
|
"typescript": "^5.6.0",
|
|
30
47
|
"vitest": "^4.0.18"
|
|
31
|
-
},
|
|
32
|
-
"scripts": {
|
|
33
|
-
"build": "tsc",
|
|
34
|
-
"dev": "tsx --env-file=.env src/cli.ts",
|
|
35
|
-
"prompt": "tsx --env-file=.env src/cli.ts -p",
|
|
36
|
-
"serve": "tsx --env-file=.env src/cli.ts --serve",
|
|
37
|
-
"test": "vitest run",
|
|
38
|
-
"test:watch": "vitest",
|
|
39
|
-
"test:integration": "vitest run src/tests/run-once.test.ts"
|
|
40
48
|
}
|
|
41
|
-
}
|
|
49
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { createRequire } from 'module'
|
|
5
|
+
import { NodesClient } from '@nodes/sdk'
|
|
6
|
+
import { runOnce, interactive } from './core/loop.js'
|
|
7
|
+
import { interactiveTUI } from './core/tui-chat.js'
|
|
8
|
+
import { serve } from './core/serve.js'
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url)
|
|
11
|
+
const pkg = require('../package.json') as { version: string }
|
|
12
|
+
|
|
13
|
+
const { values } = parseArgs({
|
|
14
|
+
options: {
|
|
15
|
+
prompt: { type: 'string', short: 'p' },
|
|
16
|
+
node: { type: 'string', short: 'n' },
|
|
17
|
+
provider: { type: 'string' },
|
|
18
|
+
serve: { type: 'boolean' },
|
|
19
|
+
simple: { type: 'boolean' },
|
|
20
|
+
help: { type: 'boolean', short: 'h' },
|
|
21
|
+
version: { type: 'boolean', short: 'v' },
|
|
22
|
+
},
|
|
23
|
+
strict: false,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (values.help) {
|
|
27
|
+
console.log(`
|
|
28
|
+
nodes — Autonomous AI agent for Nodes
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
nodes Start in interactive mode (new chat)
|
|
32
|
+
nodes -n <nodeId> Resume interactive chat on existing node
|
|
33
|
+
nodes -p "prompt" Run a single prompt and exit
|
|
34
|
+
nodes -p "prompt" -n <id> Run prompt in existing chat and exit
|
|
35
|
+
nodes --provider claude-cli Use local Claude CLI (Max subscription)
|
|
36
|
+
nodes --serve Start MCP server (exposes local tools)
|
|
37
|
+
nodes --simple Use simple readline UI (no TUI)
|
|
38
|
+
nodes --help Show this help
|
|
39
|
+
|
|
40
|
+
Environment:
|
|
41
|
+
NODES_URL Nodes instance URL (default: https://nodes.ws)
|
|
42
|
+
NODES_API_KEY API key for authentication
|
|
43
|
+
NODES_AGENT Agent slug (e.g. "nodes/my-agent") or user ID
|
|
44
|
+
PORT MCP server port (default: 8788, --serve only)
|
|
45
|
+
`)
|
|
46
|
+
process.exit(0)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (values.version) {
|
|
50
|
+
console.log(`nodes ${pkg.version}`)
|
|
51
|
+
process.exit(0)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --serve doesn't require Nodes credentials (purely local MCP server)
|
|
55
|
+
if (values.serve) {
|
|
56
|
+
serve().catch((err) => {
|
|
57
|
+
console.error(err.message)
|
|
58
|
+
process.exit(1)
|
|
59
|
+
})
|
|
60
|
+
} else {
|
|
61
|
+
// All other modes require Nodes connection
|
|
62
|
+
const url = process.env.NODES_URL || 'https://nodes.ws'
|
|
63
|
+
const apiKey = process.env.NODES_API_KEY || ''
|
|
64
|
+
const agent = process.env.NODES_AGENT || ''
|
|
65
|
+
|
|
66
|
+
if (!apiKey) {
|
|
67
|
+
console.error('Error: NODES_API_KEY is required.')
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!agent) {
|
|
72
|
+
console.error('Error: NODES_AGENT is required (agent slug or user ID).')
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Extract userId from user-scoped token (nodes_<userId>_<secret>)
|
|
77
|
+
const tokenMatch = apiKey.match(/^nodes_([a-f0-9]{24})_[a-f0-9]{32}$/)
|
|
78
|
+
const userId = tokenMatch?.[1]
|
|
79
|
+
|
|
80
|
+
const prompt = values.prompt as string | undefined
|
|
81
|
+
const nodeId = values.node as string | undefined
|
|
82
|
+
const provider = values.provider as string | undefined
|
|
83
|
+
|
|
84
|
+
const nodes = new NodesClient({ url, apiKey, agent })
|
|
85
|
+
const config = { nodes, agent, userId, prompt, nodeId, provider, nodesUrl: url, nodesApiKey: apiKey }
|
|
86
|
+
|
|
87
|
+
if (prompt) {
|
|
88
|
+
// One-shot mode
|
|
89
|
+
runOnce(config)
|
|
90
|
+
.then((result) => {
|
|
91
|
+
console.log(JSON.stringify(result, null, 2))
|
|
92
|
+
process.exit(0)
|
|
93
|
+
})
|
|
94
|
+
.catch((err) => {
|
|
95
|
+
console.error(err.message)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
})
|
|
98
|
+
.finally(() => nodes.disconnect())
|
|
99
|
+
} else {
|
|
100
|
+
if (values.simple) {
|
|
101
|
+
// Simple readline mode
|
|
102
|
+
process.on('SIGINT', async () => {
|
|
103
|
+
console.log('\nGoodbye.')
|
|
104
|
+
await nodes.disconnect()
|
|
105
|
+
process.exit(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
interactive(config).catch((err) => {
|
|
109
|
+
console.error(err.message)
|
|
110
|
+
process.exit(1)
|
|
111
|
+
})
|
|
112
|
+
} else {
|
|
113
|
+
// TUI mode (default) — handles its own SIGINT
|
|
114
|
+
interactiveTUI(config).catch((err) => {
|
|
115
|
+
console.error(err.message)
|
|
116
|
+
process.exit(1)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/index.ts
ADDED