openbroker 1.0.61 → 1.0.62
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/SKILL.md +45 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/auto/cli.ts +80 -13
- package/scripts/auto/registry.ts +118 -0
- package/scripts/auto/runtime.ts +80 -1
- package/scripts/auto/types.ts +30 -1
- package/scripts/plugin/index.ts +80 -4
- package/scripts/plugin/tools.ts +36 -5
package/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: Hyperliquid trading plugin with background position monitoring and
|
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Requires Node.js 22+, network access to api.hyperliquid.xyz
|
|
6
6
|
homepage: https://www.npmjs.com/package/openbroker
|
|
7
|
-
metadata: {"author": "monemetrics", "version": "1.0.
|
|
7
|
+
metadata: {"author": "monemetrics", "version": "1.0.62", "openclaw": {"requires": {"bins": ["openbroker"], "env": ["HYPERLIQUID_PRIVATE_KEY"]}, "primaryEnv": "HYPERLIQUID_PRIVATE_KEY", "install": [{"id": "node", "kind": "node", "package": "openbroker", "bins": ["openbroker"], "label": "Install openbroker (npm)"}]}}
|
|
8
8
|
allowed-tools: ob_account ob_positions ob_funding ob_markets ob_search ob_spot ob_fills ob_orders ob_order_status ob_fees ob_candles ob_funding_history ob_trades ob_rate_limit ob_funding_scan ob_buy ob_sell ob_limit ob_trigger ob_tpsl ob_cancel ob_twap ob_bracket ob_chase ob_watcher_status ob_auto_run ob_auto_stop ob_auto_list Bash(openbroker:*)
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -546,6 +546,7 @@ export default function(api) {
|
|
|
546
546
|
| `api.state.set(key, value)` | Set a persisted value |
|
|
547
547
|
| `api.state.delete(key)` | Delete a persisted value |
|
|
548
548
|
| `api.state.clear()` | Clear all state |
|
|
549
|
+
| `api.publish(message, options?)` | Send a message to the OpenClaw agent via webhook. Triggers an agent turn — the agent receives the message and can notify the user, take action, etc. Returns `true` if delivered. Options: `{ name?, wakeMode?, deliver?, channel? }` |
|
|
549
550
|
| `api.log.info/warn/error/debug(msg)` | Structured logger |
|
|
550
551
|
| `api.utils` | `roundPrice`, `roundSize`, `sleep`, `normalizeCoin`, `formatUsd`, `annualizeFundingRate` |
|
|
551
552
|
| `api.id` | Automation ID (filename or `--id` flag) |
|
|
@@ -652,6 +653,47 @@ export default function(api) {
|
|
|
652
653
|
}
|
|
653
654
|
```
|
|
654
655
|
|
|
656
|
+
### Publishing to the Agent (Webhooks)
|
|
657
|
+
|
|
658
|
+
Use `api.publish()` to send messages back to the OpenClaw agent. This triggers an agent turn — the agent receives the message and can notify the user via their preferred channel, take trading actions, or log the event.
|
|
659
|
+
|
|
660
|
+
```typescript
|
|
661
|
+
// Simple notification
|
|
662
|
+
await api.publish(`ETH broke above $4000 — current price: $${price}`);
|
|
663
|
+
|
|
664
|
+
// With options
|
|
665
|
+
await api.publish(`Margin at ${pct}% — positions at risk`, {
|
|
666
|
+
name: 'margin-alert', // appears in logs
|
|
667
|
+
wakeMode: 'now', // 'now' (default) or 'next-heartbeat'
|
|
668
|
+
channel: 'slack', // target channel (optional)
|
|
669
|
+
});
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
`api.publish()` returns `true` if delivered, `false` if webhooks are not configured (no hooks token). It requires `OPENCLAW_HOOKS_TOKEN` to be set (automatically configured when running as an OpenClaw plugin).
|
|
673
|
+
|
|
674
|
+
**Example: Price alert automation with publish**
|
|
675
|
+
```typescript
|
|
676
|
+
// ~/.openbroker/automations/price-alert.ts
|
|
677
|
+
export default function(api) {
|
|
678
|
+
const COIN = 'ETH';
|
|
679
|
+
const THRESHOLD = 4000;
|
|
680
|
+
|
|
681
|
+
api.on('price_change', async ({ coin, newPrice, changePct }) => {
|
|
682
|
+
if (coin !== COIN) return;
|
|
683
|
+
|
|
684
|
+
const crossed = api.state.get<boolean>('crossed', false);
|
|
685
|
+
if (!crossed && newPrice >= THRESHOLD) {
|
|
686
|
+
api.state.set('crossed', true);
|
|
687
|
+
await api.publish(
|
|
688
|
+
`${COIN} crossed above $${THRESHOLD}! Price: $${newPrice.toFixed(2)} (+${changePct.toFixed(2)}%)`,
|
|
689
|
+
);
|
|
690
|
+
} else if (crossed && newPrice < THRESHOLD) {
|
|
691
|
+
api.state.set('crossed', false);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
```
|
|
696
|
+
|
|
655
697
|
### Running Automations
|
|
656
698
|
|
|
657
699
|
**CLI:**
|
|
@@ -680,9 +722,11 @@ openbroker auto status # Show running automations
|
|
|
680
722
|
- Always test with `--dry` first before live trading
|
|
681
723
|
- Use `api.state` to track position state across restarts
|
|
682
724
|
- Use `api.onStop()` to clean up — close positions, cancel orders
|
|
725
|
+
- Use `api.publish()` to send alerts/events back to the OpenClaw agent — do NOT manually construct webhook requests
|
|
683
726
|
- The runtime catches errors per handler — one failing handler won't crash others
|
|
684
727
|
- Scripts are loaded from `~/.openbroker/automations/` by name, or from any absolute path
|
|
685
728
|
- All trading commands support HIP-3 assets (`api.client.marketOrder('xyz:CL', true, 1)`)
|
|
729
|
+
- Automations persist across gateway restarts — they are automatically restarted when the gateway comes back up
|
|
686
730
|
|
|
687
731
|
## Risk Warning
|
|
688
732
|
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/scripts/auto/cli.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { parseArgs } from '../core/utils.js';
|
|
4
4
|
import { resolveScriptPath, listAutomations, ensureAutomationsDir } from './loader.js';
|
|
5
|
-
import { startAutomation, getRunningAutomations } from './runtime.js';
|
|
5
|
+
import { startAutomation, getRunningAutomations, getRegisteredAutomations } from './runtime.js';
|
|
6
|
+
import { unregisterAutomation, cleanRegistry } from './registry.js';
|
|
6
7
|
|
|
7
8
|
function printUsage() {
|
|
8
9
|
console.log(`
|
|
@@ -10,8 +11,10 @@ OpenBroker Automations — event-driven trading scripts
|
|
|
10
11
|
|
|
11
12
|
Usage:
|
|
12
13
|
openbroker auto run <script> [options] Run an automation script
|
|
14
|
+
openbroker auto stop <id> Unregister an automation (won't restart)
|
|
13
15
|
openbroker auto list List available automations
|
|
14
16
|
openbroker auto status Show running automations
|
|
17
|
+
openbroker auto clean Remove stale entries from registry
|
|
15
18
|
|
|
16
19
|
Options (for run):
|
|
17
20
|
--dry Intercept write methods (no real trades)
|
|
@@ -100,24 +103,82 @@ function listCommand() {
|
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
function statusCommand() {
|
|
103
|
-
|
|
106
|
+
// Show in-process automations (if any running in this process)
|
|
107
|
+
const inProcess = getRunningAutomations();
|
|
104
108
|
|
|
105
|
-
|
|
109
|
+
// Show all registered automations from file-based registry (cross-process)
|
|
110
|
+
const registered = getRegisteredAutomations();
|
|
111
|
+
|
|
112
|
+
if (inProcess.length === 0 && registered.length === 0) {
|
|
106
113
|
console.log('No automations running');
|
|
107
114
|
return;
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
// Show in-process automations with live stats
|
|
118
|
+
if (inProcess.length > 0) {
|
|
119
|
+
console.log('Running in this process:\n');
|
|
120
|
+
for (const a of inProcess) {
|
|
121
|
+
const uptime = Math.round((Date.now() - a.startedAt.getTime()) / 1000);
|
|
122
|
+
console.log(` ${a.id}`);
|
|
123
|
+
console.log(` Script: ${a.scriptPath}`);
|
|
124
|
+
console.log(` Uptime: ${uptime}s`);
|
|
125
|
+
console.log(` Polls: ${a.pollCount}`);
|
|
126
|
+
console.log(` Events: ${a.eventsEmitted}`);
|
|
127
|
+
console.log(` Dry run: ${a.dryRun}`);
|
|
128
|
+
console.log('');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Show all registered automations (may include ones from other processes)
|
|
133
|
+
const external = registered.filter(
|
|
134
|
+
r => !inProcess.some(ip => ip.id === r.id),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (external.length > 0) {
|
|
138
|
+
if (inProcess.length > 0) console.log('Other processes:\n');
|
|
139
|
+
else console.log('Registered automations:\n');
|
|
140
|
+
|
|
141
|
+
for (const a of external) {
|
|
142
|
+
const uptime = a.status === 'running'
|
|
143
|
+
? `${Math.round((Date.now() - new Date(a.startedAt).getTime()) / 1000)}s`
|
|
144
|
+
: '-';
|
|
145
|
+
console.log(` ${a.id}`);
|
|
146
|
+
console.log(` Script: ${a.scriptPath}`);
|
|
147
|
+
console.log(` Status: ${a.status}${a.error ? ` (${a.error})` : ''}`);
|
|
148
|
+
console.log(` PID: ${a.pid}`);
|
|
149
|
+
console.log(` Uptime: ${uptime}`);
|
|
150
|
+
console.log(` Dry run: ${a.dryRun}`);
|
|
151
|
+
console.log('');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stopCommand(positional: string[]) {
|
|
157
|
+
const id = positional[0];
|
|
158
|
+
if (!id) {
|
|
159
|
+
console.error('Error: automation ID required');
|
|
160
|
+
console.log('Usage: openbroker auto stop <id>');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if running in this process
|
|
165
|
+
const inProcess = getRunningAutomations();
|
|
166
|
+
const running = inProcess.find(a => a.id === id);
|
|
167
|
+
if (running) {
|
|
168
|
+
running.stop().then(() => {
|
|
169
|
+
console.log(`Stopped and unregistered: ${id}`);
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
120
172
|
}
|
|
173
|
+
|
|
174
|
+
// Otherwise just remove from file registry (prevents restart)
|
|
175
|
+
unregisterAutomation(id);
|
|
176
|
+
console.log(`Unregistered: ${id} (will not restart on next gateway start)`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function cleanCommand() {
|
|
180
|
+
cleanRegistry();
|
|
181
|
+
console.log('Cleaned stale entries from registry');
|
|
121
182
|
}
|
|
122
183
|
|
|
123
184
|
async function main() {
|
|
@@ -153,12 +214,18 @@ async function main() {
|
|
|
153
214
|
case 'run':
|
|
154
215
|
await runCommand(args, positional);
|
|
155
216
|
break;
|
|
217
|
+
case 'stop':
|
|
218
|
+
stopCommand(positional);
|
|
219
|
+
break;
|
|
156
220
|
case 'list':
|
|
157
221
|
listCommand();
|
|
158
222
|
break;
|
|
159
223
|
case 'status':
|
|
160
224
|
statusCommand();
|
|
161
225
|
break;
|
|
226
|
+
case 'clean':
|
|
227
|
+
cleanCommand();
|
|
228
|
+
break;
|
|
162
229
|
default:
|
|
163
230
|
console.error(`Unknown subcommand: ${subcommand}`);
|
|
164
231
|
console.log('Run "openbroker auto --help" for usage');
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// File-based automation registry — tracks desired state across processes
|
|
2
|
+
// Persisted at ~/.openbroker/state/_registry.json so both CLI and plugin
|
|
3
|
+
// can see which automations should be running.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
|
|
9
|
+
const STATE_DIR = path.join(os.homedir(), '.openbroker', 'state');
|
|
10
|
+
const REGISTRY_FILE = path.join(STATE_DIR, '_registry.json');
|
|
11
|
+
|
|
12
|
+
export interface RegistryEntry {
|
|
13
|
+
id: string;
|
|
14
|
+
scriptPath: string;
|
|
15
|
+
dryRun: boolean;
|
|
16
|
+
verbose: boolean;
|
|
17
|
+
pollIntervalMs: number;
|
|
18
|
+
startedAt: string; // ISO timestamp
|
|
19
|
+
pid: number; // Process that started it
|
|
20
|
+
status: 'running' | 'stopped' | 'error';
|
|
21
|
+
error?: string; // Last error message if status is 'error'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureDir(): void {
|
|
25
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readRegistry(): RegistryEntry[] {
|
|
29
|
+
if (!existsSync(REGISTRY_FILE)) return [];
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync(REGISTRY_FILE, 'utf-8');
|
|
32
|
+
const entries = JSON.parse(raw);
|
|
33
|
+
return Array.isArray(entries) ? entries : [];
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeRegistry(entries: RegistryEntry[]): void {
|
|
40
|
+
ensureDir();
|
|
41
|
+
writeFileSync(REGISTRY_FILE, JSON.stringify(entries, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Check if a process is still alive */
|
|
45
|
+
function isProcessAlive(pid: number): boolean {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0); // Signal 0 = just check, don't kill
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Register an automation as running */
|
|
55
|
+
export function registerAutomation(entry: Omit<RegistryEntry, 'status' | 'pid' | 'startedAt'>): void {
|
|
56
|
+
const entries = readRegistry();
|
|
57
|
+
|
|
58
|
+
// Remove any existing entry with the same id
|
|
59
|
+
const filtered = entries.filter(e => e.id !== entry.id);
|
|
60
|
+
|
|
61
|
+
filtered.push({
|
|
62
|
+
...entry,
|
|
63
|
+
status: 'running',
|
|
64
|
+
pid: process.pid,
|
|
65
|
+
startedAt: new Date().toISOString(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
writeRegistry(filtered);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Unregister an automation (remove from desired state) */
|
|
72
|
+
export function unregisterAutomation(id: string): void {
|
|
73
|
+
const entries = readRegistry();
|
|
74
|
+
writeRegistry(entries.filter(e => e.id !== id));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Mark an automation as errored (keep in registry for visibility) */
|
|
78
|
+
export function markAutomationError(id: string, error: string): void {
|
|
79
|
+
const entries = readRegistry();
|
|
80
|
+
const entry = entries.find(e => e.id === id);
|
|
81
|
+
if (entry) {
|
|
82
|
+
entry.status = 'error';
|
|
83
|
+
entry.error = error;
|
|
84
|
+
writeRegistry(entries);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Get all registered automations, with stale process detection */
|
|
89
|
+
export function getRegisteredAutomations(): RegistryEntry[] {
|
|
90
|
+
const entries = readRegistry();
|
|
91
|
+
let dirty = false;
|
|
92
|
+
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (entry.status === 'running' && !isProcessAlive(entry.pid)) {
|
|
95
|
+
// Process died without cleanup — mark as stopped
|
|
96
|
+
entry.status = 'stopped';
|
|
97
|
+
dirty = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (dirty) writeRegistry(entries);
|
|
102
|
+
return entries;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Get automations that should be restarted (were running when process died) */
|
|
106
|
+
export function getAutomationsToRestart(): RegistryEntry[] {
|
|
107
|
+
const entries = getRegisteredAutomations();
|
|
108
|
+
// Return entries that were running but whose process is no longer alive
|
|
109
|
+
// (getRegisteredAutomations already marked them as 'stopped')
|
|
110
|
+
// We want entries that are 'stopped' — they need to be restarted
|
|
111
|
+
return entries.filter(e => e.status === 'stopped');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Clean up the registry — remove stopped/errored entries */
|
|
115
|
+
export function cleanRegistry(): void {
|
|
116
|
+
const entries = readRegistry();
|
|
117
|
+
writeRegistry(entries.filter(e => e.status === 'running' && isProcessAlive(e.pid)));
|
|
118
|
+
}
|
package/scripts/auto/runtime.ts
CHANGED
|
@@ -11,12 +11,14 @@ import {
|
|
|
11
11
|
} from '../core/utils.js';
|
|
12
12
|
import { AutomationEventBus } from './events.js';
|
|
13
13
|
import { loadAutomation } from './loader.js';
|
|
14
|
+
import { registerAutomation, unregisterAutomation, markAutomationError, getRegisteredAutomations as getRegisteredFromFile } from './registry.js';
|
|
14
15
|
import type {
|
|
15
16
|
AutomationAPI,
|
|
16
17
|
AutomationLogger,
|
|
17
18
|
AutomationState,
|
|
18
19
|
AutomationSnapshot,
|
|
19
20
|
PositionSnapshot,
|
|
21
|
+
PublishOptions,
|
|
20
22
|
ScheduledTask,
|
|
21
23
|
RunningAutomation,
|
|
22
24
|
} from './types.js';
|
|
@@ -167,6 +169,56 @@ async function buildSnapshot(
|
|
|
167
169
|
};
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
// ── Publish (webhook) ───────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function createPublish(
|
|
175
|
+
automationId: string,
|
|
176
|
+
log: AutomationLogger,
|
|
177
|
+
gatewayPort?: number,
|
|
178
|
+
hooksToken?: string,
|
|
179
|
+
): (message: string, options?: PublishOptions) => Promise<boolean> {
|
|
180
|
+
return async (message: string, options?: PublishOptions): Promise<boolean> => {
|
|
181
|
+
const token = hooksToken || process.env.OPENCLAW_HOOKS_TOKEN;
|
|
182
|
+
const port = gatewayPort || parseInt(process.env.OPENCLAW_GATEWAY_PORT || '18789', 10);
|
|
183
|
+
|
|
184
|
+
if (!token) {
|
|
185
|
+
log.debug('publish() skipped — no hooks token configured (set OPENCLAW_HOOKS_TOKEN or pass hooksToken in plugin config)');
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const body: Record<string, unknown> = {
|
|
190
|
+
message,
|
|
191
|
+
name: options?.name || `ob-auto-${automationId}`,
|
|
192
|
+
wakeMode: options?.wakeMode || 'now',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (options?.deliver !== undefined) body.deliver = options.deliver;
|
|
196
|
+
if (options?.channel) body.channel = options.channel;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: {
|
|
202
|
+
'Content-Type': 'application/json',
|
|
203
|
+
'Authorization': `Bearer ${token}`,
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify(body),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (!res.ok) {
|
|
209
|
+
log.warn(`publish() failed: HTTP ${res.status} ${res.statusText}`);
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
log.debug(`publish() delivered to /hooks/agent (${message.length} chars)`);
|
|
214
|
+
return true;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
log.warn(`publish() error: ${err instanceof Error ? err.message : String(err)}`);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
170
222
|
// ── Runtime ─────────────────────────────────────────────────────────
|
|
171
223
|
|
|
172
224
|
export interface RuntimeOptions {
|
|
@@ -175,6 +227,10 @@ export interface RuntimeOptions {
|
|
|
175
227
|
dryRun?: boolean;
|
|
176
228
|
verbose?: boolean;
|
|
177
229
|
pollIntervalMs?: number;
|
|
230
|
+
/** Gateway port for webhook delivery. Falls back to OPENCLAW_GATEWAY_PORT or 18789 */
|
|
231
|
+
gatewayPort?: number;
|
|
232
|
+
/** Hooks token for webhook auth. Falls back to OPENCLAW_HOOKS_TOKEN */
|
|
233
|
+
hooksToken?: string;
|
|
178
234
|
}
|
|
179
235
|
|
|
180
236
|
/** Registry of all running automations */
|
|
@@ -188,12 +244,17 @@ export function getAutomation(id: string): RunningAutomation | undefined {
|
|
|
188
244
|
return registry.get(id);
|
|
189
245
|
}
|
|
190
246
|
|
|
247
|
+
/** Get all automations from file-based registry (cross-process visibility) */
|
|
248
|
+
export { getRegisteredFromFile as getRegisteredAutomations };
|
|
249
|
+
|
|
191
250
|
export async function startAutomation(options: RuntimeOptions): Promise<RunningAutomation> {
|
|
192
251
|
const {
|
|
193
252
|
scriptPath,
|
|
194
253
|
dryRun = false,
|
|
195
254
|
verbose = false,
|
|
196
255
|
pollIntervalMs = 10_000,
|
|
256
|
+
gatewayPort,
|
|
257
|
+
hooksToken,
|
|
197
258
|
} = options;
|
|
198
259
|
|
|
199
260
|
const id = options.id || path.basename(scriptPath, '.ts');
|
|
@@ -215,6 +276,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
215
276
|
const scheduledTasks: ScheduledTask[] = [];
|
|
216
277
|
|
|
217
278
|
// Build the API object
|
|
279
|
+
const publish = createPublish(id, log, gatewayPort, hooksToken);
|
|
218
280
|
const api: AutomationAPI = {
|
|
219
281
|
client,
|
|
220
282
|
utils: { roundPrice, roundSize, sleep, normalizeCoin, formatUsd, formatPercent, annualizeFundingRate },
|
|
@@ -223,6 +285,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
223
285
|
onStart: (handler) => startHooks.push(handler),
|
|
224
286
|
onStop: (handler) => stopHooks.push(handler),
|
|
225
287
|
onError: (handler) => errorHooks.push(handler),
|
|
288
|
+
publish,
|
|
226
289
|
state,
|
|
227
290
|
log,
|
|
228
291
|
id,
|
|
@@ -416,7 +479,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
416
479
|
await poll();
|
|
417
480
|
|
|
418
481
|
// Stop function
|
|
419
|
-
async function stop() {
|
|
482
|
+
async function stop(opts?: { persist?: boolean }) {
|
|
420
483
|
if (stopped) return;
|
|
421
484
|
stopped = true;
|
|
422
485
|
clearInterval(timer);
|
|
@@ -429,6 +492,12 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
429
492
|
|
|
430
493
|
eventBus.removeAll();
|
|
431
494
|
registry.delete(id);
|
|
495
|
+
|
|
496
|
+
// persist defaults to true — fully remove from file registry.
|
|
497
|
+
// When false (gateway shutdown), keep the entry so it restarts next time.
|
|
498
|
+
if (opts?.persist !== false) {
|
|
499
|
+
unregisterAutomation(id);
|
|
500
|
+
}
|
|
432
501
|
log.info(`Stopped (${pollCount} polls, ${eventsEmitted} events)`);
|
|
433
502
|
}
|
|
434
503
|
|
|
@@ -443,5 +512,15 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
|
|
|
443
512
|
};
|
|
444
513
|
|
|
445
514
|
registry.set(id, entry);
|
|
515
|
+
|
|
516
|
+
// Persist to file-based registry so other processes (CLI, plugin) can see it
|
|
517
|
+
registerAutomation({
|
|
518
|
+
id,
|
|
519
|
+
scriptPath,
|
|
520
|
+
dryRun,
|
|
521
|
+
verbose,
|
|
522
|
+
pollIntervalMs,
|
|
523
|
+
});
|
|
524
|
+
|
|
446
525
|
return entry;
|
|
447
526
|
}
|
package/scripts/auto/types.ts
CHANGED
|
@@ -52,6 +52,19 @@ export interface AutomationLogger {
|
|
|
52
52
|
debug(message: string): void;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// ── Publish (webhook) ───────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface PublishOptions {
|
|
58
|
+
/** Human-readable name for the hook (appears in logs). Default: "ob-auto-<id>" */
|
|
59
|
+
name?: string;
|
|
60
|
+
/** Wake mode: "now" triggers immediate agent turn, "next-heartbeat" queues. Default: "now" */
|
|
61
|
+
wakeMode?: 'now' | 'next-heartbeat';
|
|
62
|
+
/** Whether to deliver the agent response to messaging channels. Default: true */
|
|
63
|
+
deliver?: boolean;
|
|
64
|
+
/** Target channel (e.g. "slack", "telegram", "last"). Default: agent decides */
|
|
65
|
+
channel?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
// ── Core API ────────────────────────────────────────────────────────
|
|
56
69
|
|
|
57
70
|
export interface AutomationAPI {
|
|
@@ -84,6 +97,17 @@ export interface AutomationAPI {
|
|
|
84
97
|
/** Called when a handler throws. The error is already logged — use this for recovery logic. */
|
|
85
98
|
onError(handler: (error: Error) => void | Promise<void>): void;
|
|
86
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Publish a message to the OpenClaw agent via webhook.
|
|
102
|
+
* Sends to POST /hooks/agent on the local gateway, triggering an agent turn.
|
|
103
|
+
* The agent receives the message and can act on it (notify user, trade, etc.).
|
|
104
|
+
*
|
|
105
|
+
* @param message — The message string the agent will receive
|
|
106
|
+
* @param options — Optional: name, wakeMode, deliver, channel
|
|
107
|
+
* @returns true if delivered, false if webhook is not configured
|
|
108
|
+
*/
|
|
109
|
+
publish(message: string, options?: PublishOptions): Promise<boolean>;
|
|
110
|
+
|
|
87
111
|
/** Persisted key-value state (~/.openbroker/state/<id>.json) */
|
|
88
112
|
state: AutomationState;
|
|
89
113
|
|
|
@@ -134,5 +158,10 @@ export interface RunningAutomation {
|
|
|
134
158
|
pollCount: number;
|
|
135
159
|
eventsEmitted: number;
|
|
136
160
|
dryRun: boolean;
|
|
137
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Stop the automation.
|
|
163
|
+
* @param opts.persist If false, keep the entry in the file registry so it
|
|
164
|
+
* restarts when the gateway comes back up. Default: true (fully remove).
|
|
165
|
+
*/
|
|
166
|
+
stop: (opts?: { persist?: boolean }) => Promise<void>;
|
|
138
167
|
}
|
package/scripts/plugin/index.ts
CHANGED
|
@@ -1,11 +1,78 @@
|
|
|
1
1
|
// OpenClaw Plugin Entry Point for OpenBroker
|
|
2
2
|
|
|
3
|
-
import type { OpenClawPluginApi, OpenBrokerPluginConfig } from './types.js';
|
|
3
|
+
import type { OpenClawPluginApi, OpenBrokerPluginConfig, PluginLogger } from './types.js';
|
|
4
4
|
import { applyConfigBridge } from './config-bridge.js';
|
|
5
5
|
import { PositionWatcher } from './watcher.js';
|
|
6
6
|
import { createTools } from './tools.js';
|
|
7
7
|
import { registerCliCommands } from './cli.js';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* AutomationService — restarts automations from the file-based registry
|
|
11
|
+
* when the OpenClaw gateway starts. When the gateway process dies,
|
|
12
|
+
* automations die with it. On next start, this service reads the registry
|
|
13
|
+
* and restarts any automations that were previously running.
|
|
14
|
+
*/
|
|
15
|
+
function createAutomationService(logger: PluginLogger, gatewayPort?: number, hooksToken?: string) {
|
|
16
|
+
return {
|
|
17
|
+
id: 'openbroker-automations',
|
|
18
|
+
|
|
19
|
+
async start() {
|
|
20
|
+
const { getAutomationsToRestart } = await import('../auto/registry.js');
|
|
21
|
+
const entries = getAutomationsToRestart();
|
|
22
|
+
|
|
23
|
+
if (entries.length === 0) {
|
|
24
|
+
logger.debug('No automations to restart');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
logger.info(`Restarting ${entries.length} automation(s) from previous session`);
|
|
29
|
+
|
|
30
|
+
const { startAutomation } = await import('../auto/runtime.js');
|
|
31
|
+
const { resolveScriptPath } = await import('../auto/loader.js');
|
|
32
|
+
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
try {
|
|
35
|
+
// Verify script still exists before restarting
|
|
36
|
+
const scriptPath = resolveScriptPath(entry.scriptPath);
|
|
37
|
+
await startAutomation({
|
|
38
|
+
scriptPath,
|
|
39
|
+
id: entry.id,
|
|
40
|
+
dryRun: entry.dryRun,
|
|
41
|
+
verbose: entry.verbose,
|
|
42
|
+
pollIntervalMs: entry.pollIntervalMs,
|
|
43
|
+
gatewayPort,
|
|
44
|
+
hooksToken,
|
|
45
|
+
});
|
|
46
|
+
logger.info(`Restarted automation: ${entry.id}`);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
49
|
+
logger.error(`Failed to restart automation "${entry.id}": ${msg}`);
|
|
50
|
+
|
|
51
|
+
// Mark as errored in registry so it doesn't retry forever
|
|
52
|
+
const { markAutomationError } = await import('../auto/registry.js');
|
|
53
|
+
markAutomationError(entry.id, msg);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async stop() {
|
|
59
|
+
// Stop all in-process automations but keep them in the file registry
|
|
60
|
+
// so they restart when the gateway comes back up
|
|
61
|
+
const { getRunningAutomations } = await import('../auto/runtime.js');
|
|
62
|
+
const running = getRunningAutomations();
|
|
63
|
+
|
|
64
|
+
for (const auto of running) {
|
|
65
|
+
try {
|
|
66
|
+
await auto.stop({ persist: false }); // Keep in registry for restart
|
|
67
|
+
logger.info(`Stopped automation for gateway shutdown: ${auto.id}`);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
logger.error(`Error stopping automation "${auto.id}": ${err instanceof Error ? err.message : String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
9
76
|
export default {
|
|
10
77
|
id: 'openbroker',
|
|
11
78
|
name: 'OpenBroker — Hyperliquid Trading',
|
|
@@ -43,14 +110,23 @@ export default {
|
|
|
43
110
|
logger.debug('OpenBroker position watcher disabled by config');
|
|
44
111
|
}
|
|
45
112
|
|
|
46
|
-
// 3. Register
|
|
47
|
-
const
|
|
113
|
+
// 3. Register automation restart service
|
|
114
|
+
const resolvedHooksToken = pluginConfig.hooksToken || process.env.OPENCLAW_HOOKS_TOKEN;
|
|
115
|
+
api.registerService(createAutomationService(logger, gatewayPort, resolvedHooksToken));
|
|
116
|
+
logger.debug('OpenBroker automation service registered');
|
|
117
|
+
|
|
118
|
+
// 4. Register agent tools
|
|
119
|
+
const tools = createTools({
|
|
120
|
+
watcher,
|
|
121
|
+
gatewayPort,
|
|
122
|
+
hooksToken: pluginConfig.hooksToken || process.env.OPENCLAW_HOOKS_TOKEN,
|
|
123
|
+
});
|
|
48
124
|
for (const tool of tools) {
|
|
49
125
|
api.registerTool(tool);
|
|
50
126
|
}
|
|
51
127
|
logger.debug(`Registered ${tools.length} OpenBroker agent tools`);
|
|
52
128
|
|
|
53
|
-
//
|
|
129
|
+
// 5. Register CLI commands
|
|
54
130
|
registerCliCommands(api, watcher, logger);
|
|
55
131
|
logger.debug('OpenBroker CLI commands registered');
|
|
56
132
|
},
|
package/scripts/plugin/tools.ts
CHANGED
|
@@ -18,7 +18,18 @@ function error(message: string) {
|
|
|
18
18
|
return json({ error: message });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export
|
|
21
|
+
export interface ToolsContext {
|
|
22
|
+
watcher: PositionWatcher | null;
|
|
23
|
+
gatewayPort?: number;
|
|
24
|
+
hooksToken?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createTools(watcherOrCtx: PositionWatcher | null | ToolsContext): PluginTool[] {
|
|
28
|
+
// Support both old signature (watcher only) and new (full context)
|
|
29
|
+
const ctx: ToolsContext = watcherOrCtx !== null && typeof watcherOrCtx === 'object' && 'watcher' in watcherOrCtx
|
|
30
|
+
? watcherOrCtx
|
|
31
|
+
: { watcher: watcherOrCtx };
|
|
32
|
+
const { watcher, gatewayPort, hooksToken } = ctx;
|
|
22
33
|
return [
|
|
23
34
|
// ── Info Tools ──────────────────────────────────────────────
|
|
24
35
|
|
|
@@ -1326,6 +1337,8 @@ export function createTools(watcher: PositionWatcher | null): PluginTool[] {
|
|
|
1326
1337
|
id: params.id ? String(params.id) : undefined,
|
|
1327
1338
|
dryRun: params.dry === true,
|
|
1328
1339
|
pollIntervalMs: params.poll ? Number(params.poll) : 10_000,
|
|
1340
|
+
gatewayPort,
|
|
1341
|
+
hooksToken,
|
|
1329
1342
|
});
|
|
1330
1343
|
|
|
1331
1344
|
return json({
|
|
@@ -1367,23 +1380,41 @@ export function createTools(watcher: PositionWatcher | null): PluginTool[] {
|
|
|
1367
1380
|
|
|
1368
1381
|
{
|
|
1369
1382
|
name: 'ob_auto_list',
|
|
1370
|
-
description: 'List available automation scripts and running automations',
|
|
1383
|
+
description: 'List available automation scripts and running automations (including those started from other processes)',
|
|
1371
1384
|
parameters: { type: 'object', properties: {} },
|
|
1372
1385
|
async execute() {
|
|
1373
1386
|
const { listAutomations } = await import('../auto/loader.js');
|
|
1374
|
-
const { getRunningAutomations } = await import('../auto/runtime.js');
|
|
1387
|
+
const { getRunningAutomations, getRegisteredAutomations } = await import('../auto/runtime.js');
|
|
1375
1388
|
|
|
1376
1389
|
const available = listAutomations();
|
|
1377
|
-
|
|
1390
|
+
|
|
1391
|
+
// In-process automations with live stats
|
|
1392
|
+
const inProcess = getRunningAutomations().map(a => ({
|
|
1378
1393
|
id: a.id,
|
|
1379
1394
|
scriptPath: a.scriptPath,
|
|
1380
1395
|
uptime: Math.round((Date.now() - a.startedAt.getTime()) / 1000),
|
|
1381
1396
|
pollCount: a.pollCount,
|
|
1382
1397
|
eventsEmitted: a.eventsEmitted,
|
|
1383
1398
|
dryRun: a.dryRun,
|
|
1399
|
+
source: 'this_process',
|
|
1384
1400
|
}));
|
|
1385
1401
|
|
|
1386
|
-
|
|
1402
|
+
// File-registry entries from other processes
|
|
1403
|
+
const registered = getRegisteredAutomations();
|
|
1404
|
+
const external = registered
|
|
1405
|
+
.filter(r => !inProcess.some(ip => ip.id === r.id))
|
|
1406
|
+
.map(r => ({
|
|
1407
|
+
id: r.id,
|
|
1408
|
+
scriptPath: r.scriptPath,
|
|
1409
|
+
status: r.status,
|
|
1410
|
+
pid: r.pid,
|
|
1411
|
+
startedAt: r.startedAt,
|
|
1412
|
+
dryRun: r.dryRun,
|
|
1413
|
+
error: r.error,
|
|
1414
|
+
source: 'other_process',
|
|
1415
|
+
}));
|
|
1416
|
+
|
|
1417
|
+
return json({ available, running: [...inProcess, ...external] });
|
|
1387
1418
|
},
|
|
1388
1419
|
},
|
|
1389
1420
|
];
|