openbroker 1.0.61 → 1.0.63

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 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.61", "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)"}]}}
7
+ metadata: {"author": "monemetrics", "version": "1.0.63", "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) |
@@ -556,7 +557,7 @@ export default function(api) {
556
557
  | Event | Payload | When |
557
558
  |-------|---------|------|
558
559
  | `tick` | `{ timestamp, pollCount }` | Every poll cycle (default: 10s) |
559
- | `price_change` | `{ coin, oldPrice, newPrice, changePct }` | Mid price moved > 0.1% |
560
+ | `price_change` | `{ coin, oldPrice, newPrice, changePct }` | Mid price moved > 0.01% between polls |
560
561
  | `funding_update` | `{ coin, fundingRate, annualized, premium }` | Every poll for all assets |
561
562
  | `position_opened` | `{ coin, side, size, entryPrice }` | New position detected |
562
563
  | `position_closed` | `{ coin, previousSize, entryPrice }` | Position no longer present |
@@ -564,6 +565,200 @@ export default function(api) {
564
565
  | `pnl_threshold` | `{ coin, unrealizedPnl, changePct, positionValue }` | PnL moved > 5% of position value |
565
566
  | `margin_warning` | `{ marginUsedPct, equity, marginUsed }` | Margin usage > 80% |
566
567
 
568
+ ### Event Details — Choosing the Right Event
569
+
570
+ #### `tick` — The universal heartbeat
571
+ Fires **every single poll cycle** (default: 10s) regardless of market conditions. Use this when you need to check something on every poll — absolute price thresholds, custom conditions, periodic account checks. This is the most reliable event because it always fires.
572
+
573
+ **Payload:** `{ timestamp: number, pollCount: number }`
574
+
575
+ **When to use:**
576
+ - Checking if a price is above/below an absolute threshold (e.g. "alert me when ETH < $3000")
577
+ - Custom conditions that don't fit other events (e.g. "if I have no positions and funding is high, enter")
578
+ - Periodic tasks that need to run every poll (though `api.every()` is better for longer intervals)
579
+
580
+ **Example — absolute price alert:**
581
+ ```typescript
582
+ api.on('tick', async () => {
583
+ const mids = await api.client.getAllMids();
584
+ const price = parseFloat(mids['HYPE']);
585
+ if (price < 38 && !api.state.get('alerted')) {
586
+ api.state.set('alerted', true);
587
+ await api.publish(`HYPE dropped below $38 — now at $${price.toFixed(3)}`);
588
+ }
589
+ });
590
+ ```
591
+
592
+ **Note:** `tick` does not include price data in its payload — you must fetch it yourself via `api.client.getAllMids()`. This is because tick fires before any other event processing. If you only care about price movements, use `price_change` instead.
593
+
594
+ #### `price_change` — Relative price movements
595
+ Fires when a coin's mid price moves **≥ 0.01%** compared to the previous poll. This filters out rounding noise while catching virtually any real price movement. The comparison is between consecutive polls (not from a fixed baseline), so it detects incremental changes.
596
+
597
+ **Payload:** `{ coin: string, oldPrice: number, newPrice: number, changePct: number }`
598
+
599
+ **When to use:**
600
+ - Reacting to price movements (breakouts, momentum, mean reversion)
601
+ - Monitoring specific coins for volatility
602
+ - Building price-triggered entry/exit logic
603
+
604
+ **When NOT to use:**
605
+ - Checking if price is above/below a fixed threshold — use `tick` instead, because `price_change` only fires on relative movement between polls. During slow drifts (e.g. price slowly declining $0.001/s), the change between any two 10s polls may be < 0.01%, so the event won't fire even though the price has crossed your threshold.
606
+
607
+ **Example — momentum detector:**
608
+ ```typescript
609
+ api.on('price_change', async ({ coin, changePct, newPrice }) => {
610
+ if (coin !== 'ETH') return;
611
+ if (changePct > 0.5) {
612
+ api.log.info(`ETH surging +${changePct.toFixed(2)}% — price $${newPrice}`);
613
+ // Enter long on strong upward momentum
614
+ }
615
+ });
616
+ ```
617
+
618
+ #### `funding_update` — Funding rate data
619
+ Fires **every poll** for **every asset** that has funding rate data. This is high-frequency — if there are 150 perp assets, this fires 150 times per poll. Filter by coin in your handler.
620
+
621
+ **Payload:** `{ coin: string, fundingRate: number, annualized: number, premium: number }`
622
+ - `fundingRate` — the raw hourly funding rate (e.g. 0.0001 = 0.01%/hr)
623
+ - `annualized` — annualized rate (fundingRate × 8760 × 100, as a percentage)
624
+ - `premium` — the premium component
625
+
626
+ **When to use:**
627
+ - Funding rate arbitrage strategies
628
+ - Monitoring for extreme funding (entry/exit signals)
629
+ - Scanning for highest/lowest funding across all assets
630
+
631
+ **Example — funding scalp:**
632
+ ```typescript
633
+ api.on('funding_update', async ({ coin, annualized }) => {
634
+ if (coin !== 'ETH') return;
635
+ if (annualized > 50 && !api.state.get('isShort')) {
636
+ api.log.info(`ETH funding at ${annualized.toFixed(1)}% annualized — shorting`);
637
+ await api.client.marketOrder('ETH', false, 0.1);
638
+ api.state.set('isShort', true);
639
+ }
640
+ });
641
+ ```
642
+
643
+ #### `position_opened` — New position detected
644
+ Fires when a position appears that wasn't present in the previous poll. Useful for tracking entries made by other systems or confirming your own orders filled.
645
+
646
+ **Payload:** `{ coin: string, side: 'long' | 'short', size: number, entryPrice: number }`
647
+
648
+ **When to use:**
649
+ - Setting TP/SL on new positions automatically
650
+ - Logging/alerting when positions are opened (by you or another system)
651
+ - Starting position-specific monitoring
652
+
653
+ **Example — auto TP/SL on new positions:**
654
+ ```typescript
655
+ api.on('position_opened', async ({ coin, side, size, entryPrice }) => {
656
+ const tpPrice = side === 'long' ? entryPrice * 1.05 : entryPrice * 0.95;
657
+ const slPrice = side === 'long' ? entryPrice * 0.97 : entryPrice * 1.03;
658
+ await api.client.takeProfit(coin, side !== 'long', size, tpPrice);
659
+ await api.client.stopLoss(coin, side !== 'long', size, slPrice);
660
+ api.log.info(`Set TP at ${tpPrice} / SL at ${slPrice} for ${coin}`);
661
+ });
662
+ ```
663
+
664
+ #### `position_closed` — Position gone
665
+ Fires when a position that existed in the previous poll is no longer present. The position was either closed by you, liquidated, or filled by TP/SL.
666
+
667
+ **Payload:** `{ coin: string, previousSize: number, entryPrice: number }`
668
+
669
+ **When to use:**
670
+ - Logging/alerting when positions close
671
+ - Cleaning up related orders or state
672
+ - Re-entry logic after a position closes
673
+
674
+ **Example:**
675
+ ```typescript
676
+ api.on('position_closed', async ({ coin, previousSize, entryPrice }) => {
677
+ api.log.info(`${coin} position closed (was ${previousSize} @ ${entryPrice})`);
678
+ api.state.delete(`${coin}_tp`);
679
+ await api.publish(`Position closed: ${coin} (entry: $${entryPrice})`);
680
+ });
681
+ ```
682
+
683
+ #### `position_changed` — Size or direction changed
684
+ Fires when an existing position's size changes (partial close, add to position, or flip direction). Does NOT fire when a new position opens or an existing one fully closes — use `position_opened` and `position_closed` for those.
685
+
686
+ **Payload:** `{ coin: string, oldSize: number, newSize: number, entryPrice: number }`
687
+ - `oldSize`/`newSize` are signed: positive = long, negative = short
688
+
689
+ **When to use:**
690
+ - Detecting partial closes or position scaling
691
+ - Adjusting TP/SL when position size changes
692
+ - Tracking DCA entries
693
+
694
+ **Example:**
695
+ ```typescript
696
+ api.on('position_changed', async ({ coin, oldSize, newSize }) => {
697
+ if (Math.abs(newSize) > Math.abs(oldSize)) {
698
+ api.log.info(`${coin} position increased: ${oldSize} → ${newSize}`);
699
+ } else {
700
+ api.log.info(`${coin} position reduced: ${oldSize} → ${newSize}`);
701
+ }
702
+ });
703
+ ```
704
+
705
+ #### `pnl_threshold` — Significant PnL movement
706
+ Fires when unrealized PnL changes by **≥ 5% of position value** between consecutive polls. This is a large move detector — useful for risk management alerts rather than routine monitoring.
707
+
708
+ **Payload:** `{ coin: string, unrealizedPnl: number, changePct: number, positionValue: number }`
709
+ - `changePct` — the PnL change as a percentage of total position value (not % of PnL itself)
710
+
711
+ **When to use:**
712
+ - Risk alerts for large PnL swings
713
+ - Auto-close or reduce positions on sudden adverse moves
714
+ - Escalating alerts to the user via `api.publish()`
715
+
716
+ **Example:**
717
+ ```typescript
718
+ api.on('pnl_threshold', async ({ coin, unrealizedPnl, changePct }) => {
719
+ if (unrealizedPnl < 0) {
720
+ await api.publish(
721
+ `⚠️ ${coin} PnL dropped sharply: $${unrealizedPnl.toFixed(2)} (${changePct.toFixed(1)}% of position)`,
722
+ { name: 'pnl-alert' },
723
+ );
724
+ }
725
+ });
726
+ ```
727
+
728
+ #### `margin_warning` — High margin usage
729
+ Fires when margin usage exceeds **80%** of equity. After the first trigger, it only fires again if margin usage increases by another 5 percentage points (prevents spam). Resets when margin drops back below 80%.
730
+
731
+ **Payload:** `{ marginUsedPct: number, equity: number, marginUsed: number }`
732
+
733
+ **When to use:**
734
+ - Automated risk reduction (close smallest position to free margin)
735
+ - Alerting the user before liquidation risk
736
+ - Pausing new entries when margin is high
737
+
738
+ **Example:**
739
+ ```typescript
740
+ api.on('margin_warning', async ({ marginUsedPct, equity }) => {
741
+ await api.publish(
742
+ `🚨 Margin at ${marginUsedPct.toFixed(1)}% — equity: $${equity.toFixed(2)}. Consider reducing exposure.`,
743
+ { name: 'margin-alert' },
744
+ );
745
+ });
746
+ ```
747
+
748
+ ### Choosing the Right Event — Quick Guide
749
+
750
+ | Use case | Best event | Why |
751
+ |----------|-----------|-----|
752
+ | Alert when price crosses a fixed level | `tick` | Fires every poll — no minimum change threshold |
753
+ | React to price momentum/volatility | `price_change` | Provides relative change data between polls |
754
+ | Funding rate strategy | `funding_update` | Gives annualized rate directly |
755
+ | Auto TP/SL on new positions | `position_opened` | Fires exactly when a new position appears |
756
+ | Log when positions close | `position_closed` | Fires when position disappears |
757
+ | Track position scaling | `position_changed` | Fires on size changes only |
758
+ | Risk management — PnL spikes | `pnl_threshold` | Only fires on large moves (≥5% of position value) |
759
+ | Risk management — margin | `margin_warning` | Fires at 80%+ margin usage |
760
+ | Periodic task (DCA, rebalance) | `api.every(ms, fn)` | Better than tick for longer intervals |
761
+
567
762
  ### Client Methods Available
568
763
 
569
764
  The `api.client` object exposes the full Hyperliquid SDK:
@@ -652,6 +847,47 @@ export default function(api) {
652
847
  }
653
848
  ```
654
849
 
850
+ ### Publishing to the Agent (Webhooks)
851
+
852
+ 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.
853
+
854
+ ```typescript
855
+ // Simple notification
856
+ await api.publish(`ETH broke above $4000 — current price: $${price}`);
857
+
858
+ // With options
859
+ await api.publish(`Margin at ${pct}% — positions at risk`, {
860
+ name: 'margin-alert', // appears in logs
861
+ wakeMode: 'now', // 'now' (default) or 'next-heartbeat'
862
+ channel: 'slack', // target channel (optional)
863
+ });
864
+ ```
865
+
866
+ `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).
867
+
868
+ **Example: Price alert automation with publish**
869
+ ```typescript
870
+ // ~/.openbroker/automations/price-alert.ts
871
+ export default function(api) {
872
+ const COIN = 'ETH';
873
+ const THRESHOLD = 4000;
874
+
875
+ api.on('price_change', async ({ coin, newPrice, changePct }) => {
876
+ if (coin !== COIN) return;
877
+
878
+ const crossed = api.state.get<boolean>('crossed', false);
879
+ if (!crossed && newPrice >= THRESHOLD) {
880
+ api.state.set('crossed', true);
881
+ await api.publish(
882
+ `${COIN} crossed above $${THRESHOLD}! Price: $${newPrice.toFixed(2)} (+${changePct.toFixed(2)}%)`,
883
+ );
884
+ } else if (crossed && newPrice < THRESHOLD) {
885
+ api.state.set('crossed', false);
886
+ }
887
+ });
888
+ }
889
+ ```
890
+
655
891
  ### Running Automations
656
892
 
657
893
  **CLI:**
@@ -680,9 +916,11 @@ openbroker auto status # Show running automations
680
916
  - Always test with `--dry` first before live trading
681
917
  - Use `api.state` to track position state across restarts
682
918
  - Use `api.onStop()` to clean up — close positions, cancel orders
919
+ - Use `api.publish()` to send alerts/events back to the OpenClaw agent — do NOT manually construct webhook requests
683
920
  - The runtime catches errors per handler — one failing handler won't crash others
684
921
  - Scripts are loaded from `~/.openbroker/automations/` by name, or from any absolute path
685
922
  - All trading commands support HIP-3 assets (`api.client.marketOrder('xyz:CL', true, 1)`)
923
+ - Automations persist across gateway restarts — they are automatically restarted when the gateway comes back up
686
924
 
687
925
  ## Risk Warning
688
926
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openbroker",
3
3
  "name": "OpenBroker — Hyperliquid Trading",
4
- "version": "1.0.61",
4
+ "version": "1.0.63",
5
5
  "description": "Trade on Hyperliquid DEX with background position monitoring",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbroker",
3
- "version": "1.0.61",
3
+ "version": "1.0.63",
4
4
  "description": "Hyperliquid trading CLI - execute orders, manage positions, and run trading strategies",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const running = getRunningAutomations();
106
+ // Show in-process automations (if any running in this process)
107
+ const inProcess = getRunningAutomations();
104
108
 
105
- if (running.length === 0) {
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
- console.log('Running automations:\n');
111
- for (const a of running) {
112
- const uptime = Math.round((Date.now() - a.startedAt.getTime()) / 1000);
113
- console.log(` ${a.id}`);
114
- console.log(` Script: ${a.scriptPath}`);
115
- console.log(` Uptime: ${uptime}s`);
116
- console.log(` Polls: ${a.pollCount}`);
117
- console.log(` Events: ${a.eventsEmitted}`);
118
- console.log(` Dry run: ${a.dryRun}`);
119
- console.log('');
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
+ }
@@ -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,
@@ -278,7 +341,7 @@ export async function startAutomation(options: RuntimeOptions): Promise<RunningA
278
341
  const oldPrice = previousSnapshot.prices.get(coin);
279
342
  if (oldPrice === undefined || oldPrice === 0) continue;
280
343
  const changePct = ((newPrice - oldPrice) / oldPrice) * 100;
281
- if (Math.abs(changePct) >= 0.1) { // 0.1% minimum to fire
344
+ if (Math.abs(changePct) >= 0.01) { // 0.01% minimum to fire (filters rounding noise)
282
345
  const errors = await eventBus.emit('price_change', { coin, oldPrice, newPrice, changePct });
283
346
  if (errors.length) await handleErrors(errors);
284
347
  eventsEmitted++;
@@ -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
  }
@@ -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
- stop: () => Promise<void>;
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
  }
@@ -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 agent tools
47
- const tools = createTools(watcher);
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
- // 4. Register CLI commands
129
+ // 5. Register CLI commands
54
130
  registerCliCommands(api, watcher, logger);
55
131
  logger.debug('OpenBroker CLI commands registered');
56
132
  },
@@ -18,7 +18,18 @@ function error(message: string) {
18
18
  return json({ error: message });
19
19
  }
20
20
 
21
- export function createTools(watcher: PositionWatcher | null): PluginTool[] {
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
- const running = getRunningAutomations().map(a => ({
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
- return json({ available, running });
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
  ];
@@ -284,66 +284,35 @@ export class PositionWatcher implements PluginService {
284
284
  return events;
285
285
  }
286
286
 
287
- private buildHookMessage(event: PositionEvent): string {
288
- const lines: string[] = [
289
- `[OpenBroker Alert] ${this.formatEventHeadline(event)}`,
290
- '',
291
- 'Details:',
292
- ...Object.entries(event.details ?? {}).map(([k, v]) => ` ${k}: ${v}`),
293
- '',
294
- `Notify the user of this trading alert via their preferred channel.`,
295
- ];
296
- return lines.join('\n');
297
- }
298
-
299
- private formatEventHeadline(event: PositionEvent): string {
300
- switch (event.type) {
301
- case 'position_opened':
302
- return `New ${event.details?.side ?? ''} position opened on ${event.coin}: ${Math.abs(parseFloat(String(event.details?.size ?? '0')))} ${event.coin} at $${event.details?.entryPrice}`;
303
- case 'position_closed':
304
- return `Position on ${event.coin} has been closed (was ${event.details?.previousSize} at $${event.details?.entryPrice}, last unrealized PnL: $${event.details?.lastPnl})`;
305
- case 'position_size_changed':
306
- return `Position on ${event.coin} size changed from ${event.details?.previousSize} to ${event.details?.newSize}`;
307
- case 'pnl_threshold':
308
- return `Significant PnL movement on ${event.coin}: $${Number(event.details?.previousPnl ?? 0).toFixed(2)} → $${Number(event.details?.currentPnl ?? 0).toFixed(2)} (${Number(event.details?.changePct ?? 0).toFixed(1)}% of position value)`;
309
- case 'margin_warning':
310
- return `Margin usage at ${Number(event.details?.marginUsedPct ?? 0).toFixed(1)}% — approaching risk threshold (equity: $${event.details?.equity}, margin used: $${event.details?.marginUsed})`;
311
- default:
312
- return event.message;
313
- }
314
- }
315
-
316
287
  private async sendHook(event: PositionEvent): Promise<void> {
317
288
  const port = this.gatewayPort || 18789;
318
289
 
319
290
  if (!this.hooksToken) {
320
- this.logger.warn('No hooks token configured skipping hook delivery. Set hooksToken in plugin config or OPENCLAW_HOOKS_TOKEN env var.');
291
+ this.logger.debug('sendHook skippedno hooks token configured');
321
292
  return;
322
293
  }
323
294
 
324
- const hookUrl = `http://127.0.0.1:${port}/hooks/agent`;
325
-
326
295
  try {
327
- const response = await fetch(hookUrl, {
296
+ const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
328
297
  method: 'POST',
329
298
  headers: {
330
299
  'Content-Type': 'application/json',
331
300
  'Authorization': `Bearer ${this.hooksToken}`,
332
301
  },
333
302
  body: JSON.stringify({
334
- message: this.buildHookMessage(event),
335
- name: `ob-${event.type}`,
303
+ message: event.message,
304
+ name: `ob-watcher-${event.type}`,
336
305
  wakeMode: 'now',
337
306
  }),
338
307
  });
339
308
 
340
- if (response.status === 202 || response.ok) {
341
- this.logger.debug(`Hook delivered for ${event.type}`);
309
+ if (!res.ok) {
310
+ this.logger.warn(`sendHook failed: HTTP ${res.status} ${res.statusText}`);
342
311
  } else {
343
- this.logger.warn(`Hook POST failed: ${response.status} ${response.statusText}`);
312
+ this.logger.debug(`sendHook delivered for ${event.type} (${event.message.length} chars)`);
344
313
  }
345
314
  } catch (err) {
346
- this.logger.warn(`Hook POST error: ${err instanceof Error ? err.message : String(err)}`);
315
+ this.logger.warn(`sendHook error: ${err instanceof Error ? err.message : String(err)}`);
347
316
  }
348
317
  }
349
318
  }