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 +240 -2
- 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 +81 -2
- package/scripts/auto/types.ts +30 -1
- package/scripts/plugin/index.ts +80 -4
- package/scripts/plugin/tools.ts +36 -5
- package/scripts/plugin/watcher.ts +8 -39
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.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.
|
|
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
|
|
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,
|
|
@@ -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.
|
|
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
|
}
|
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
|
];
|
|
@@ -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.
|
|
291
|
+
this.logger.debug('sendHook skipped — no 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
|
|
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:
|
|
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 (
|
|
341
|
-
this.logger.
|
|
309
|
+
if (!res.ok) {
|
|
310
|
+
this.logger.warn(`sendHook failed: HTTP ${res.status} ${res.statusText}`);
|
|
342
311
|
} else {
|
|
343
|
-
this.logger.
|
|
312
|
+
this.logger.debug(`sendHook delivered for ${event.type} (${event.message.length} chars)`);
|
|
344
313
|
}
|
|
345
314
|
} catch (err) {
|
|
346
|
-
this.logger.warn(`
|
|
315
|
+
this.logger.warn(`sendHook error: ${err instanceof Error ? err.message : String(err)}`);
|
|
347
316
|
}
|
|
348
317
|
}
|
|
349
318
|
}
|