clawmoney 0.8.0 → 0.8.2

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.
@@ -0,0 +1,14 @@
1
+ export declare function hubStartCommand(options: {
2
+ cli?: string;
3
+ }): Promise<void>;
4
+ export declare function hubStopCommand(): Promise<void>;
5
+ export declare function hubStatusCommand(): Promise<void>;
6
+ interface RegisterOptions {
7
+ name: string;
8
+ category: string;
9
+ description: string;
10
+ price: string;
11
+ }
12
+ export declare function hubRegisterCommand(options: RegisterOptions): Promise<void>;
13
+ export declare function hubSkillsCommand(): Promise<void>;
14
+ export {};
@@ -0,0 +1,166 @@
1
+ import { spawn } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import chalk from "chalk";
5
+ import ora from "ora";
6
+ import { requireConfig } from "../utils/config.js";
7
+ import { apiGet, apiPost } from "../utils/api.js";
8
+ import { readPid, isPidAlive, removePid } from "../hub/provider.js";
9
+ const LOG_FILE = join(homedir(), ".clawmoney", "provider.log");
10
+ // ── hub start ──
11
+ export async function hubStartCommand(options) {
12
+ const config = requireConfig();
13
+ // Check if already running
14
+ const existingPid = readPid();
15
+ if (existingPid && isPidAlive(existingPid)) {
16
+ console.log(chalk.yellow(`Hub Provider is already running (PID ${existingPid}). Use "clawmoney hub stop" first.`));
17
+ return;
18
+ }
19
+ const spinner = ora("Starting Hub Provider...").start();
20
+ try {
21
+ // Resolve daemon script path relative to this file's directory
22
+ // Works for both compiled (dist/commands/hub.js) and dev (src/commands/hub.ts)
23
+ const thisDir = import.meta.url.replace("file://", "").replace(/\/[^/]+$/, "");
24
+ const parentDir = thisDir.replace(/\/[^/]+$/, "");
25
+ const daemonScript = join(parentDir, "hub", "daemon.js");
26
+ const args = [daemonScript];
27
+ if (options.cli) {
28
+ args.push("--cli", options.cli);
29
+ }
30
+ const child = spawn(process.execPath, args, {
31
+ stdio: "ignore",
32
+ detached: true,
33
+ env: {
34
+ ...process.env,
35
+ CLAWMONEY_DAEMON: "1",
36
+ },
37
+ });
38
+ child.unref();
39
+ // Give the daemon a moment to start and write PID
40
+ await new Promise((resolve) => setTimeout(resolve, 1000));
41
+ const pid = readPid();
42
+ if (pid && isPidAlive(pid)) {
43
+ spinner.succeed(chalk.green(`Hub Provider started (PID ${pid})`));
44
+ console.log(chalk.dim(` Log file: ${LOG_FILE}`));
45
+ console.log(chalk.dim(` CLI command: ${options.cli || "claude"}`));
46
+ console.log(chalk.dim(` API key: ${config.api_key.slice(0, 8)}...`));
47
+ }
48
+ else {
49
+ spinner.fail(chalk.red("Failed to start Hub Provider. Check logs at: " + LOG_FILE));
50
+ process.exit(1);
51
+ }
52
+ }
53
+ catch (err) {
54
+ spinner.fail(chalk.red("Failed to start Hub Provider"));
55
+ throw err;
56
+ }
57
+ }
58
+ // ── hub stop ──
59
+ export async function hubStopCommand() {
60
+ const pid = readPid();
61
+ if (!pid) {
62
+ console.log(chalk.dim("Hub Provider is not running (no PID file)."));
63
+ return;
64
+ }
65
+ if (!isPidAlive(pid)) {
66
+ console.log(chalk.dim(`Hub Provider PID ${pid} is not alive. Cleaning up PID file.`));
67
+ removePid();
68
+ return;
69
+ }
70
+ try {
71
+ process.kill(pid, "SIGTERM");
72
+ console.log(chalk.green(`Hub Provider stopped (PID ${pid}).`));
73
+ }
74
+ catch (err) {
75
+ console.error(chalk.red(`Failed to stop process ${pid}:`), err.message);
76
+ }
77
+ // Wait briefly for cleanup, then ensure PID file is removed
78
+ await new Promise((resolve) => setTimeout(resolve, 500));
79
+ removePid();
80
+ }
81
+ // ── hub status ──
82
+ export async function hubStatusCommand() {
83
+ const pid = readPid();
84
+ if (!pid) {
85
+ console.log(chalk.dim("Hub Provider is not running."));
86
+ return;
87
+ }
88
+ if (isPidAlive(pid)) {
89
+ console.log(chalk.green(`Hub Provider is running (PID ${pid}).`));
90
+ console.log(chalk.dim(` Log file: ${LOG_FILE}`));
91
+ }
92
+ else {
93
+ console.log(chalk.yellow(`Hub Provider PID ${pid} is not alive (stale PID file).`));
94
+ removePid();
95
+ }
96
+ }
97
+ export async function hubRegisterCommand(options) {
98
+ const config = requireConfig();
99
+ const price = parseFloat(options.price);
100
+ if (isNaN(price) || price < 0) {
101
+ console.error(chalk.red("Invalid price. Must be a non-negative number."));
102
+ process.exit(1);
103
+ }
104
+ const spinner = ora("Registering skill...").start();
105
+ try {
106
+ const resp = await apiPost("/api/v1/hub/skills", {
107
+ skill_name: options.name,
108
+ category: options.category,
109
+ description: options.description,
110
+ price: price,
111
+ }, config.api_key);
112
+ if (!resp.ok) {
113
+ const raw = resp.data && typeof resp.data === "object" && "detail" in resp.data
114
+ ? resp.data.detail
115
+ : resp.data;
116
+ const detail = typeof raw === "string" ? raw : JSON.stringify(raw);
117
+ spinner.fail(chalk.red(`Failed to register skill (${resp.status}): ${detail}`));
118
+ process.exit(1);
119
+ }
120
+ spinner.succeed(chalk.green("Skill registered successfully!"));
121
+ console.log("");
122
+ console.log(` ${chalk.bold("Name:")} ${options.name}`);
123
+ console.log(` ${chalk.bold("Category:")} ${options.category}`);
124
+ console.log(` ${chalk.bold("Price:")} $${price.toFixed(2)}/call`);
125
+ }
126
+ catch (err) {
127
+ spinner.fail(chalk.red("Failed to register skill"));
128
+ throw err;
129
+ }
130
+ }
131
+ export async function hubSkillsCommand() {
132
+ const config = requireConfig();
133
+ const spinner = ora("Fetching skills...").start();
134
+ try {
135
+ const resp = await apiGet("/api/v1/hub/skills/mine", config.api_key);
136
+ if (!resp.ok) {
137
+ spinner.fail(chalk.red(`Failed to fetch skills (${resp.status})`));
138
+ process.exit(1);
139
+ }
140
+ const skills = Array.isArray(resp.data)
141
+ ? resp.data
142
+ : resp.data.data ?? [];
143
+ spinner.succeed(`My Skills (${skills.length})`);
144
+ console.log("");
145
+ if (skills.length === 0) {
146
+ console.log(chalk.dim(' No skills registered. Use "clawmoney hub register" to add one.'));
147
+ return;
148
+ }
149
+ // Table header
150
+ console.log(chalk.bold(` ${"Name".padEnd(20)} ${"Category".padEnd(20)} ${"Price".padEnd(10)} ${"Calls".padEnd(8)} ${"Status".padEnd(10)}`));
151
+ console.log(chalk.dim(" " + "-".repeat(70)));
152
+ for (const skill of skills) {
153
+ const name = (skill.skill_name ?? skill.name ?? "-").slice(0, 19);
154
+ const category = (skill.category ?? "-").slice(0, 19);
155
+ const rawPrice = skill.price ?? skill.price_per_call;
156
+ const price = rawPrice !== undefined ? `$${Number(rawPrice).toFixed(2)}` : "-";
157
+ const calls = String(skill.total_calls ?? skill.call_count ?? "-");
158
+ const status = skill.is_active !== undefined ? (skill.is_active ? "active" : "inactive") : (skill.status ?? "-");
159
+ console.log(` ${chalk.cyan(name.padEnd(20))} ${category.padEnd(20)} ${chalk.green(price.padEnd(10))} ${calls.padEnd(8)} ${status.padEnd(10)}`);
160
+ }
161
+ }
162
+ catch (err) {
163
+ spinner.fail(chalk.red("Failed to fetch skills"));
164
+ throw err;
165
+ }
166
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Daemon entry point for the Hub Provider.
4
+ * This file is spawned as a detached child process by `clawmoney hub start`.
5
+ * It runs the provider main loop (WS + Poller + Executor).
6
+ */
7
+ export {};
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Daemon entry point for the Hub Provider.
4
+ * This file is spawned as a detached child process by `clawmoney hub start`.
5
+ * It runs the provider main loop (WS + Poller + Executor).
6
+ */
7
+ import { runProvider } from "./provider.js";
8
+ // Parse CLI args passed from the parent
9
+ let cliCommand;
10
+ const args = process.argv.slice(2);
11
+ for (let i = 0; i < args.length; i++) {
12
+ if (args[i] === "--cli" && args[i + 1]) {
13
+ cliCommand = args[i + 1];
14
+ i++;
15
+ }
16
+ }
17
+ // Run the provider (this blocks until shutdown signal)
18
+ runProvider(cliCommand);
@@ -0,0 +1,4 @@
1
+ export declare function isProcessed(orderId: string): boolean;
2
+ export declare function markProcessed(orderId: string): void;
3
+ export declare function startDedup(): void;
4
+ export declare function stopDedup(): void;
@@ -0,0 +1,35 @@
1
+ import { logger } from "./logger.js";
2
+ const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
3
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
4
+ const seen = new Map();
5
+ let cleanupTimer = null;
6
+ export function isProcessed(orderId) {
7
+ return seen.has(orderId);
8
+ }
9
+ export function markProcessed(orderId) {
10
+ seen.set(orderId, Date.now());
11
+ }
12
+ function cleanup() {
13
+ const cutoff = Date.now() - TTL_MS;
14
+ let removed = 0;
15
+ for (const [id, ts] of seen) {
16
+ if (ts < cutoff) {
17
+ seen.delete(id);
18
+ removed++;
19
+ }
20
+ }
21
+ if (removed > 0) {
22
+ logger.info(`Dedup cleanup: removed ${removed} stale entries, ${seen.size} remaining`);
23
+ }
24
+ }
25
+ export function startDedup() {
26
+ cleanupTimer = setInterval(cleanup, CLEANUP_INTERVAL_MS);
27
+ cleanupTimer.unref();
28
+ }
29
+ export function stopDedup() {
30
+ if (cleanupTimer) {
31
+ clearInterval(cleanupTimer);
32
+ cleanupTimer = null;
33
+ }
34
+ seen.clear();
35
+ }
@@ -0,0 +1,13 @@
1
+ import type { ProviderConfig, ServiceCallEvent, TestCallEvent, DeliverEvent, TestResponseEvent } from "./types.js";
2
+ type SendFn = (event: DeliverEvent | TestResponseEvent) => boolean;
3
+ export declare class Executor {
4
+ private config;
5
+ private send;
6
+ private activeTasks;
7
+ constructor(config: ProviderConfig, send: SendFn);
8
+ get activeCount(): number;
9
+ handleServiceCall(call: ServiceCallEvent): void;
10
+ handleTestCall(call: TestCallEvent): void;
11
+ private executeTask;
12
+ }
13
+ export {};
@@ -0,0 +1,164 @@
1
+ import { spawn } from "node:child_process";
2
+ import { isProcessed, markProcessed } from "./dedup.js";
3
+ import { replaceLocalPaths } from "./media.js";
4
+ import { logger } from "./logger.js";
5
+ const TIMEOUT_BUFFER_S = 15;
6
+ function buildPrompt(call, config) {
7
+ const skillConfig = config.provider.skills?.[call.skill];
8
+ if (skillConfig?.prompt_template) {
9
+ return skillConfig.prompt_template
10
+ .replace("{{skill}}", call.skill)
11
+ .replace("{{input}}", JSON.stringify(call.input, null, 2));
12
+ }
13
+ return [
14
+ "You received a paid service request via ClawMoney Hub.",
15
+ `Skill: ${call.skill}`,
16
+ `Input: ${JSON.stringify(call.input, null, 2)}`,
17
+ "",
18
+ "Execute this task and return the result as JSON.",
19
+ "If you generate any files, save them and include their paths in the output.",
20
+ ].join("\n");
21
+ }
22
+ function runCli(command, prompt, timeoutMs) {
23
+ return new Promise((resolve) => {
24
+ const args = ["-p", prompt, "--output-format", "json"];
25
+ const child = spawn(command, args, {
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ timeout: timeoutMs,
28
+ env: { ...process.env },
29
+ });
30
+ let stdout = "";
31
+ let stderr = "";
32
+ child.stdout.on("data", (chunk) => {
33
+ stdout += chunk.toString();
34
+ });
35
+ child.stderr.on("data", (chunk) => {
36
+ stderr += chunk.toString();
37
+ });
38
+ child.on("close", (code) => {
39
+ resolve({ stdout, stderr, exitCode: code });
40
+ });
41
+ child.on("error", (err) => {
42
+ stderr += err.message;
43
+ resolve({ stdout, stderr, exitCode: null });
44
+ });
45
+ });
46
+ }
47
+ function parseJsonOutput(raw) {
48
+ try {
49
+ return JSON.parse(raw);
50
+ }
51
+ catch {
52
+ // Ignore
53
+ }
54
+ const match = raw.match(/\{[\s\S]*\}/);
55
+ if (match) {
56
+ try {
57
+ return JSON.parse(match[0]);
58
+ }
59
+ catch {
60
+ // Ignore
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+ export class Executor {
66
+ config;
67
+ send;
68
+ activeTasks = new Set();
69
+ constructor(config, send) {
70
+ this.config = config;
71
+ this.send = send;
72
+ }
73
+ get activeCount() {
74
+ return this.activeTasks.size;
75
+ }
76
+ handleServiceCall(call) {
77
+ if (isProcessed(call.order_id)) {
78
+ logger.info(`Skipping duplicate order: ${call.order_id}`);
79
+ return;
80
+ }
81
+ if (this.activeTasks.size >= this.config.provider.max_concurrent) {
82
+ logger.warn(`Rejecting order ${call.order_id}: at max concurrency (${this.config.provider.max_concurrent})`);
83
+ this.send({
84
+ event: "deliver",
85
+ order_id: call.order_id,
86
+ error: "Provider is at maximum capacity. Please try again later.",
87
+ });
88
+ markProcessed(call.order_id);
89
+ return;
90
+ }
91
+ markProcessed(call.order_id);
92
+ this.activeTasks.add(call.order_id);
93
+ logger.info(`Processing order=${call.order_id} skill="${call.skill}" from=${call.from}`);
94
+ this.executeTask(call).catch((err) => {
95
+ logger.error(`Unhandled error in executeTask for ${call.order_id}:`, err);
96
+ });
97
+ }
98
+ handleTestCall(call) {
99
+ logger.info(`Test call received: order=${call.order_id}`);
100
+ const response = {
101
+ event: "test_response",
102
+ order_id: call.order_id,
103
+ output: {
104
+ echo: call.input,
105
+ provider_status: "ok",
106
+ active_tasks: this.activeTasks.size,
107
+ max_concurrent: this.config.provider.max_concurrent,
108
+ },
109
+ };
110
+ this.send(response);
111
+ }
112
+ async executeTask(call) {
113
+ try {
114
+ const prompt = buildPrompt(call, this.config);
115
+ const timeoutMs = Math.max((call.timeout - TIMEOUT_BUFFER_S) * 1000, 30_000);
116
+ const command = this.config.provider.cli_command;
117
+ logger.info(`Executing: ${command} for skill="${call.skill}" order=${call.order_id} (timeout=${Math.round(timeoutMs / 1000)}s)`);
118
+ const { stdout, stderr, exitCode } = await runCli(command, prompt, timeoutMs);
119
+ if (exitCode !== 0) {
120
+ const errMsg = stderr.trim() || `CLI exited with code ${exitCode}`;
121
+ logger.error(`CLI failed (code=${exitCode}):`, errMsg);
122
+ this.send({
123
+ event: "deliver",
124
+ order_id: call.order_id,
125
+ error: errMsg.slice(0, 2000),
126
+ });
127
+ return;
128
+ }
129
+ const parsed = parseJsonOutput(stdout);
130
+ if (!parsed) {
131
+ logger.warn("CLI output was not valid JSON, wrapping as text");
132
+ this.send({
133
+ event: "deliver",
134
+ order_id: call.order_id,
135
+ output: { result: stdout.trim().slice(0, 5000) },
136
+ });
137
+ return;
138
+ }
139
+ const output = await replaceLocalPaths(parsed, this.config);
140
+ const sent = this.send({
141
+ event: "deliver",
142
+ order_id: call.order_id,
143
+ output,
144
+ });
145
+ if (sent) {
146
+ logger.info(`Delivered order=${call.order_id} (success)`);
147
+ }
148
+ else {
149
+ logger.warn(`Failed to send delivery for order=${call.order_id} (WS disconnected)`);
150
+ }
151
+ }
152
+ catch (err) {
153
+ logger.error(`Execution error for order=${call.order_id}:`, err);
154
+ this.send({
155
+ event: "deliver",
156
+ order_id: call.order_id,
157
+ error: err instanceof Error ? err.message : "Unknown execution error",
158
+ });
159
+ }
160
+ finally {
161
+ this.activeTasks.delete(call.order_id);
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,5 @@
1
+ export declare const logger: {
2
+ info: (...args: unknown[]) => void;
3
+ warn: (...args: unknown[]) => void;
4
+ error: (...args: unknown[]) => void;
5
+ };
@@ -0,0 +1,47 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ const LOG_DIR = join(homedir(), ".clawmoney");
5
+ const LOG_FILE = join(LOG_DIR, "provider.log");
6
+ function timestamp() {
7
+ return new Date().toISOString().replace("T", " ").replace("Z", "");
8
+ }
9
+ function ensureDir() {
10
+ try {
11
+ mkdirSync(LOG_DIR, { recursive: true });
12
+ }
13
+ catch {
14
+ // already exists
15
+ }
16
+ }
17
+ function log(level, ...args) {
18
+ const prefix = `${timestamp()} [${level}]`;
19
+ const message = args
20
+ .map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
21
+ .join(" ");
22
+ const line = `${prefix} ${message}\n`;
23
+ // Write to log file
24
+ try {
25
+ ensureDir();
26
+ appendFileSync(LOG_FILE, line, "utf-8");
27
+ }
28
+ catch {
29
+ // best effort
30
+ }
31
+ // Also write to stderr (visible only if not detached)
32
+ switch (level) {
33
+ case "ERROR":
34
+ console.error(prefix, ...args);
35
+ break;
36
+ case "WARN":
37
+ console.warn(prefix, ...args);
38
+ break;
39
+ default:
40
+ console.log(prefix, ...args);
41
+ }
42
+ }
43
+ export const logger = {
44
+ info: (...args) => log("INFO", ...args),
45
+ warn: (...args) => log("WARN", ...args),
46
+ error: (...args) => log("ERROR", ...args),
47
+ };
@@ -0,0 +1,11 @@
1
+ import type { ProviderConfig } from "./types.js";
2
+ /**
3
+ * Upload a local file to the Hub media endpoint (R2).
4
+ * Returns the public CDN URL on success, or null on failure.
5
+ */
6
+ export declare function uploadFile(filePath: string, config: ProviderConfig): Promise<string | null>;
7
+ /**
8
+ * Walk the output object and replace any local file paths with CDN URLs.
9
+ * Mutates the object in-place and returns it.
10
+ */
11
+ export declare function replaceLocalPaths(output: Record<string, unknown>, config: ProviderConfig): Promise<Record<string, unknown>>;
@@ -0,0 +1,70 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { logger } from "./logger.js";
4
+ /**
5
+ * Upload a local file to the Hub media endpoint (R2).
6
+ * Returns the public CDN URL on success, or null on failure.
7
+ */
8
+ export async function uploadFile(filePath, config) {
9
+ const url = `${config.provider.api_base_url}/hub/media/upload`;
10
+ try {
11
+ const stat = statSync(filePath);
12
+ if (!stat.isFile()) {
13
+ logger.warn(`uploadFile: not a regular file: ${filePath}`);
14
+ return null;
15
+ }
16
+ const fileBuffer = readFileSync(filePath);
17
+ const fileName = basename(filePath);
18
+ const formData = new FormData();
19
+ formData.append("file", new Blob([fileBuffer]), fileName);
20
+ const resp = await fetch(url, {
21
+ method: "POST",
22
+ headers: {
23
+ Authorization: `Bearer ${config.api_key}`,
24
+ },
25
+ body: formData,
26
+ });
27
+ if (!resp.ok) {
28
+ const body = await resp.text();
29
+ logger.error(`uploadFile failed (${resp.status}): ${body}`);
30
+ return null;
31
+ }
32
+ const data = (await resp.json());
33
+ if (!data.file_url) {
34
+ logger.error("uploadFile: response missing file_url");
35
+ return null;
36
+ }
37
+ logger.info(`Uploaded ${fileName} -> ${data.file_url}`);
38
+ return data.file_url;
39
+ }
40
+ catch (err) {
41
+ logger.error(`uploadFile error for ${filePath}:`, err);
42
+ return null;
43
+ }
44
+ }
45
+ /** Known output keys that may contain local file paths. */
46
+ const FILE_PATH_KEYS = [
47
+ "image_path",
48
+ "video_path",
49
+ "audio_path",
50
+ "file_path",
51
+ "document_path",
52
+ ];
53
+ /**
54
+ * Walk the output object and replace any local file paths with CDN URLs.
55
+ * Mutates the object in-place and returns it.
56
+ */
57
+ export async function replaceLocalPaths(output, config) {
58
+ for (const key of FILE_PATH_KEYS) {
59
+ const val = output[key];
60
+ if (typeof val === "string" && val.startsWith("/")) {
61
+ const cdnUrl = await uploadFile(val, config);
62
+ if (cdnUrl) {
63
+ const urlKey = key.replace("_path", "_url");
64
+ output[urlKey] = cdnUrl;
65
+ delete output[key];
66
+ }
67
+ }
68
+ }
69
+ return output;
70
+ }
@@ -0,0 +1,14 @@
1
+ import type { ProviderConfig, ServiceCallEvent } from "./types.js";
2
+ export type PollCallback = (event: ServiceCallEvent) => void;
3
+ export declare class Poller {
4
+ private config;
5
+ private onServiceCall;
6
+ private isWsConnected;
7
+ private timer;
8
+ private stopping;
9
+ constructor(config: ProviderConfig, onServiceCall: PollCallback, isWsConnected: () => boolean);
10
+ start(): void;
11
+ stop(): void;
12
+ private scheduleNext;
13
+ private poll;
14
+ }
@@ -0,0 +1,66 @@
1
+ import { logger } from "./logger.js";
2
+ export class Poller {
3
+ config;
4
+ onServiceCall;
5
+ isWsConnected;
6
+ timer = null;
7
+ stopping = false;
8
+ constructor(config, onServiceCall, isWsConnected) {
9
+ this.config = config;
10
+ this.onServiceCall = onServiceCall;
11
+ this.isWsConnected = isWsConnected;
12
+ }
13
+ start() {
14
+ this.stopping = false;
15
+ this.scheduleNext();
16
+ }
17
+ stop() {
18
+ this.stopping = true;
19
+ if (this.timer) {
20
+ clearTimeout(this.timer);
21
+ this.timer = null;
22
+ }
23
+ }
24
+ scheduleNext() {
25
+ if (this.stopping)
26
+ return;
27
+ const interval = this.isWsConnected()
28
+ ? this.config.provider.polling.connected_interval
29
+ : this.config.provider.polling.disconnected_interval;
30
+ this.timer = setTimeout(async () => {
31
+ this.timer = null;
32
+ await this.poll();
33
+ this.scheduleNext();
34
+ }, interval * 1000);
35
+ this.timer.unref();
36
+ }
37
+ async poll() {
38
+ const url = `${this.config.provider.api_base_url}/hub/tasks/pending`;
39
+ try {
40
+ const resp = await fetch(url, {
41
+ method: "GET",
42
+ headers: {
43
+ Authorization: `Bearer ${this.config.api_key}`,
44
+ "Content-Type": "application/json",
45
+ },
46
+ });
47
+ if (!resp.ok) {
48
+ if (resp.status !== 404) {
49
+ logger.warn(`Poll failed (${resp.status}): ${await resp.text()}`);
50
+ }
51
+ return;
52
+ }
53
+ const data = (await resp.json());
54
+ const tasks = data.tasks ?? [];
55
+ if (tasks.length > 0) {
56
+ logger.info(`Poll: received ${tasks.length} pending task(s)`);
57
+ }
58
+ for (const task of tasks) {
59
+ this.onServiceCall(task);
60
+ }
61
+ }
62
+ catch (err) {
63
+ logger.error("Poll error:", err);
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,4 @@
1
+ export declare function readPid(): number | null;
2
+ export declare function isPidAlive(pid: number): boolean;
3
+ export declare function removePid(): void;
4
+ export declare function runProvider(cliCommand?: string): void;
@@ -0,0 +1,163 @@
1
+ import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import YAML from "yaml";
5
+ import { WsClient } from "./ws-client.js";
6
+ import { Poller } from "./poller.js";
7
+ import { Executor } from "./executor.js";
8
+ import { startDedup, stopDedup } from "./dedup.js";
9
+ import { logger } from "./logger.js";
10
+ const CONFIG_DIR = join(homedir(), ".clawmoney");
11
+ const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
12
+ const PID_FILE = join(CONFIG_DIR, "provider.pid");
13
+ const DEFAULT_PROVIDER = {
14
+ cli_command: "claude",
15
+ max_concurrent: 3,
16
+ ws_url: "wss://api.bnbot.ai/api/v1/ws/agent",
17
+ api_base_url: "https://api.bnbot.ai/api/v1",
18
+ polling: {
19
+ connected_interval: 120,
20
+ disconnected_interval: 15,
21
+ },
22
+ reconnect: {
23
+ initial: 5,
24
+ max: 300,
25
+ multiplier: 2,
26
+ },
27
+ };
28
+ // ── PID helpers ──
29
+ export function readPid() {
30
+ try {
31
+ const content = readFileSync(PID_FILE, "utf-8").trim();
32
+ const pid = parseInt(content, 10);
33
+ return isNaN(pid) ? null : pid;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export function isPidAlive(pid) {
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ function writePid() {
49
+ writeFileSync(PID_FILE, String(process.pid), "utf-8");
50
+ }
51
+ export function removePid() {
52
+ try {
53
+ unlinkSync(PID_FILE);
54
+ }
55
+ catch {
56
+ // Ignore
57
+ }
58
+ }
59
+ // ── Config loading ──
60
+ function loadProviderConfig(cliCommand) {
61
+ let raw;
62
+ try {
63
+ const content = readFileSync(CONFIG_FILE, "utf-8");
64
+ raw = YAML.parse(content);
65
+ }
66
+ catch (err) {
67
+ logger.error(`Failed to read config from ${CONFIG_FILE}:`, err);
68
+ process.exit(1);
69
+ }
70
+ if (!raw.api_key || typeof raw.api_key !== "string") {
71
+ logger.error("api_key is required in config.yaml. Run 'clawmoney setup' first.");
72
+ process.exit(1);
73
+ }
74
+ const userProvider = (raw.provider ?? {});
75
+ const provider = {
76
+ cli_command: cliCommand ?? userProvider.cli_command ?? DEFAULT_PROVIDER.cli_command,
77
+ max_concurrent: userProvider.max_concurrent ?? DEFAULT_PROVIDER.max_concurrent,
78
+ ws_url: userProvider.ws_url ?? DEFAULT_PROVIDER.ws_url,
79
+ api_base_url: userProvider.api_base_url ?? DEFAULT_PROVIDER.api_base_url,
80
+ polling: {
81
+ connected_interval: userProvider.polling?.connected_interval ??
82
+ DEFAULT_PROVIDER.polling.connected_interval,
83
+ disconnected_interval: userProvider.polling?.disconnected_interval ??
84
+ DEFAULT_PROVIDER.polling.disconnected_interval,
85
+ },
86
+ reconnect: {
87
+ initial: userProvider.reconnect?.initial ?? DEFAULT_PROVIDER.reconnect.initial,
88
+ max: userProvider.reconnect?.max ?? DEFAULT_PROVIDER.reconnect.max,
89
+ multiplier: userProvider.reconnect?.multiplier ??
90
+ DEFAULT_PROVIDER.reconnect.multiplier,
91
+ },
92
+ skills: userProvider.skills,
93
+ };
94
+ return {
95
+ api_key: raw.api_key,
96
+ agent_id: raw.agent_id,
97
+ agent_slug: raw.agent_slug,
98
+ provider,
99
+ };
100
+ }
101
+ // ── Main daemon entry point ──
102
+ export function runProvider(cliCommand) {
103
+ // Check for existing process
104
+ const existingPid = readPid();
105
+ if (existingPid && isPidAlive(existingPid)) {
106
+ logger.error(`Hub Provider is already running (PID ${existingPid}). Use "hub stop" first.`);
107
+ process.exit(1);
108
+ }
109
+ const config = loadProviderConfig(cliCommand);
110
+ // Initialize dedup
111
+ startDedup();
112
+ // Create WS client
113
+ const wsClient = new WsClient(config, (event) => {
114
+ handleEvent(event);
115
+ });
116
+ // Create executor
117
+ const executor = new Executor(config, (event) => wsClient.send(event));
118
+ // Event router
119
+ function handleEvent(event) {
120
+ switch (event.event) {
121
+ case "connected":
122
+ logger.info(`Connected as "${event.agent_name}" (id=${event.agent_id}, hub_level=${event.hub_level})`);
123
+ break;
124
+ case "service_call":
125
+ executor.handleServiceCall(event);
126
+ break;
127
+ case "test_call":
128
+ executor.handleTestCall(event);
129
+ break;
130
+ case "error":
131
+ logger.error(`Server error: ${event.message}`);
132
+ break;
133
+ default:
134
+ logger.warn("Unknown event:", event);
135
+ }
136
+ }
137
+ // Create poller
138
+ const poller = new Poller(config, (task) => {
139
+ handleEvent(task);
140
+ }, () => wsClient.connected);
141
+ // Graceful shutdown
142
+ let shuttingDown = false;
143
+ function shutdown(signal) {
144
+ if (shuttingDown)
145
+ return;
146
+ shuttingDown = true;
147
+ logger.info(`Received ${signal}. Shutting down...`);
148
+ wsClient.stop();
149
+ poller.stop();
150
+ stopDedup();
151
+ removePid();
152
+ logger.info("Hub Provider stopped.");
153
+ process.exit(0);
154
+ }
155
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
156
+ process.on("SIGINT", () => shutdown("SIGINT"));
157
+ // Write PID and start
158
+ writePid();
159
+ wsClient.start();
160
+ poller.start();
161
+ logger.info("Hub Provider running. Listening for service calls...");
162
+ logger.info(`Config: max_concurrent=${config.provider.max_concurrent}, cli=${config.provider.cli_command}`);
163
+ }
@@ -0,0 +1,63 @@
1
+ export interface ServiceCallEvent {
2
+ event: "service_call";
3
+ order_id: string;
4
+ from: string;
5
+ skill: string;
6
+ category: string;
7
+ input: Record<string, unknown>;
8
+ price: number;
9
+ timeout: number;
10
+ payment_method: string;
11
+ }
12
+ export interface TestCallEvent {
13
+ event: "test_call";
14
+ order_id: string;
15
+ input: Record<string, unknown>;
16
+ }
17
+ export interface ConnectedEvent {
18
+ event: "connected";
19
+ agent_id: string;
20
+ agent_name: string;
21
+ hub_level: number;
22
+ }
23
+ export interface ErrorEvent {
24
+ event: "error";
25
+ message: string;
26
+ }
27
+ export type IncomingEvent = ServiceCallEvent | TestCallEvent | ConnectedEvent | ErrorEvent;
28
+ export interface DeliverEvent {
29
+ event: "deliver";
30
+ order_id: string;
31
+ output?: Record<string, unknown>;
32
+ error?: string;
33
+ }
34
+ export interface TestResponseEvent {
35
+ event: "test_response";
36
+ order_id: string;
37
+ output: Record<string, unknown>;
38
+ }
39
+ export type OutgoingEvent = DeliverEvent | TestResponseEvent;
40
+ export interface ProviderSettings {
41
+ cli_command: string;
42
+ max_concurrent: number;
43
+ ws_url: string;
44
+ api_base_url: string;
45
+ polling: {
46
+ connected_interval: number;
47
+ disconnected_interval: number;
48
+ };
49
+ reconnect: {
50
+ initial: number;
51
+ max: number;
52
+ multiplier: number;
53
+ };
54
+ skills?: Record<string, {
55
+ prompt_template?: string;
56
+ }>;
57
+ }
58
+ export interface ProviderConfig {
59
+ api_key: string;
60
+ agent_id?: string;
61
+ agent_slug?: string;
62
+ provider: ProviderSettings;
63
+ }
@@ -0,0 +1,2 @@
1
+ // ── WebSocket events received from server ──
2
+ export {};
@@ -0,0 +1,23 @@
1
+ import type { ProviderConfig, IncomingEvent, OutgoingEvent } from "./types.js";
2
+ export type EventCallback = (event: IncomingEvent) => void;
3
+ export declare class WsClient {
4
+ private config;
5
+ private onEvent;
6
+ private ws;
7
+ private heartbeatTimer;
8
+ private reconnectDelay;
9
+ private reconnectTimer;
10
+ private _connected;
11
+ private wsFailLogged;
12
+ private stopping;
13
+ constructor(config: ProviderConfig, onEvent: EventCallback);
14
+ get connected(): boolean;
15
+ start(): void;
16
+ stop(): void;
17
+ send(event: OutgoingEvent): boolean;
18
+ private connect;
19
+ private scheduleReconnect;
20
+ private startHeartbeat;
21
+ private stopHeartbeat;
22
+ private clearTimers;
23
+ }
@@ -0,0 +1,123 @@
1
+ import WebSocket from "ws";
2
+ import { logger } from "./logger.js";
3
+ const HEARTBEAT_INTERVAL_MS = 30_000;
4
+ export class WsClient {
5
+ config;
6
+ onEvent;
7
+ ws = null;
8
+ heartbeatTimer = null;
9
+ reconnectDelay;
10
+ reconnectTimer = null;
11
+ _connected = false;
12
+ wsFailLogged = false;
13
+ stopping = false;
14
+ constructor(config, onEvent) {
15
+ this.config = config;
16
+ this.onEvent = onEvent;
17
+ this.reconnectDelay = config.provider.reconnect.initial;
18
+ }
19
+ get connected() {
20
+ return this._connected;
21
+ }
22
+ start() {
23
+ this.stopping = false;
24
+ this.connect();
25
+ }
26
+ stop() {
27
+ this.stopping = true;
28
+ this.clearTimers();
29
+ if (this.ws) {
30
+ this.ws.removeAllListeners();
31
+ this.ws.close();
32
+ this.ws = null;
33
+ }
34
+ this._connected = false;
35
+ }
36
+ send(event) {
37
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
38
+ logger.warn("WS send failed: not connected");
39
+ return false;
40
+ }
41
+ try {
42
+ this.ws.send(JSON.stringify(event));
43
+ return true;
44
+ }
45
+ catch (err) {
46
+ logger.error("WS send error:", err);
47
+ return false;
48
+ }
49
+ }
50
+ // ── Private ──
51
+ connect() {
52
+ if (this.stopping)
53
+ return;
54
+ const url = `${this.config.provider.ws_url}?api_key=${this.config.api_key}`;
55
+ this.ws = new WebSocket(url);
56
+ this.ws.on("open", () => {
57
+ this._connected = true;
58
+ this.wsFailLogged = false;
59
+ this.reconnectDelay = this.config.provider.reconnect.initial;
60
+ logger.info("WebSocket connected");
61
+ this.startHeartbeat();
62
+ });
63
+ this.ws.on("message", (data) => {
64
+ try {
65
+ const msg = JSON.parse(data.toString());
66
+ this.onEvent(msg);
67
+ }
68
+ catch (err) {
69
+ logger.error("WS message parse error:", err);
70
+ }
71
+ });
72
+ this.ws.on("close", (code, reason) => {
73
+ this._connected = false;
74
+ this.stopHeartbeat();
75
+ if (!this.wsFailLogged) {
76
+ logger.warn(`WebSocket closed (code=${code}, reason=${reason.toString()})`);
77
+ this.wsFailLogged = true;
78
+ }
79
+ this.scheduleReconnect();
80
+ });
81
+ this.ws.on("error", (err) => {
82
+ if (!this.wsFailLogged) {
83
+ logger.error("WebSocket error:", err.message);
84
+ this.wsFailLogged = true;
85
+ }
86
+ });
87
+ }
88
+ scheduleReconnect() {
89
+ if (this.stopping)
90
+ return;
91
+ const delay = this.reconnectDelay;
92
+ logger.info(`Reconnecting in ${delay}s...`);
93
+ this.reconnectTimer = setTimeout(() => {
94
+ this.reconnectTimer = null;
95
+ this.connect();
96
+ }, delay * 1000);
97
+ this.reconnectTimer.unref();
98
+ const { max, multiplier } = this.config.provider.reconnect;
99
+ this.reconnectDelay = Math.min(delay * multiplier, max);
100
+ }
101
+ startHeartbeat() {
102
+ this.stopHeartbeat();
103
+ this.heartbeatTimer = setInterval(() => {
104
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
105
+ this.ws.ping();
106
+ }
107
+ }, HEARTBEAT_INTERVAL_MS);
108
+ this.heartbeatTimer.unref();
109
+ }
110
+ stopHeartbeat() {
111
+ if (this.heartbeatTimer) {
112
+ clearInterval(this.heartbeatTimer);
113
+ this.heartbeatTimer = null;
114
+ }
115
+ }
116
+ clearTimers() {
117
+ this.stopHeartbeat();
118
+ if (this.reconnectTimer) {
119
+ clearTimeout(this.reconnectTimer);
120
+ this.reconnectTimer = null;
121
+ }
122
+ }
123
+ }
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { browseCommand } from './commands/browse.js';
5
5
  import { promoteSubmitCommand, promoteVerifyCommand } from './commands/promote.js';
6
6
  import { walletStatusCommand, walletBalanceCommand, walletAddressCommand, walletSendCommand, } from './commands/wallet.js';
7
7
  import { tweetCommand } from './commands/tweet.js';
8
+ import { hubStartCommand, hubStopCommand, hubStatusCommand, hubRegisterCommand, hubSkillsCommand, } from './commands/hub.js';
8
9
  const program = new Command();
9
10
  program
10
11
  .name('clawmoney')
@@ -137,4 +138,73 @@ program
137
138
  process.exit(1);
138
139
  }
139
140
  });
141
+ // hub
142
+ const hub = program
143
+ .command('hub')
144
+ .description('Agent Hub: provide services, register skills');
145
+ hub
146
+ .command('start')
147
+ .description('Start Hub Provider (background process)')
148
+ .option('--cli <command>', 'CLI command for task execution', 'claude')
149
+ .action(async (options) => {
150
+ try {
151
+ await hubStartCommand(options);
152
+ }
153
+ catch (err) {
154
+ console.error(err.message);
155
+ process.exit(1);
156
+ }
157
+ });
158
+ hub
159
+ .command('stop')
160
+ .description('Stop Hub Provider')
161
+ .action(async () => {
162
+ try {
163
+ await hubStopCommand();
164
+ }
165
+ catch (err) {
166
+ console.error(err.message);
167
+ process.exit(1);
168
+ }
169
+ });
170
+ hub
171
+ .command('status')
172
+ .description('Check Hub Provider status')
173
+ .action(async () => {
174
+ try {
175
+ await hubStatusCommand();
176
+ }
177
+ catch (err) {
178
+ console.error(err.message);
179
+ process.exit(1);
180
+ }
181
+ });
182
+ hub
183
+ .command('register')
184
+ .description('Register a skill on the Hub')
185
+ .requiredOption('-n, --name <name>', 'Skill name')
186
+ .requiredOption('-c, --category <category>', 'Category (e.g., generation/image)')
187
+ .requiredOption('-d, --description <desc>', 'Description')
188
+ .requiredOption('-p, --price <price>', 'Price per call in USD')
189
+ .action(async (options) => {
190
+ try {
191
+ await hubRegisterCommand(options);
192
+ }
193
+ catch (err) {
194
+ console.error(err.message);
195
+ process.exit(1);
196
+ }
197
+ });
198
+ hub
199
+ .command('skills')
200
+ .description('List my registered skills')
201
+ .action(async () => {
202
+ try {
203
+ await hubSkillsCommand();
204
+ }
205
+ catch (err) {
206
+ console.error(err.message);
207
+ process.exit(1);
208
+ }
209
+ });
140
210
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmoney",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "ClawMoney CLI -- Earn rewards with your AI agent",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,10 +21,12 @@
21
21
  "chalk": "^5.3.0",
22
22
  "commander": "^12.0.0",
23
23
  "ora": "^8.0.0",
24
+ "ws": "^8.20.0",
24
25
  "yaml": "^2.4.0"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@types/node": "^20.0.0",
29
+ "@types/ws": "^8.18.1",
28
30
  "typescript": "^5.4.0"
29
31
  },
30
32
  "engines": {