openbroker 1.0.60 → 1.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: openbroker
3
- description: Hyperliquid trading plugin with background position monitoring. Execute market orders, limit orders, manage positions, view funding rates, and run trading strategies with automatic alerts for PnL changes and liquidation risk.
3
+ description: Hyperliquid trading plugin with background position monitoring and custom automations. Execute market orders, limit orders, manage positions, view funding rates, run trading strategies, and write event-driven automation scripts with automatic alerts for PnL changes and liquidation risk.
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.60", "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
- 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 Bash(openbroker:*)
7
+ metadata: {"author": "monemetrics", "version": "1.0.62", "openclaw": {"requires": {"bins": ["openbroker"], "env": ["HYPERLIQUID_PRIVATE_KEY"]}, "primaryEnv": "HYPERLIQUID_PRIVATE_KEY", "install": [{"id": "node", "kind": "node", "package": "openbroker", "bins": ["openbroker"], "label": "Install openbroker (npm)"}]}}
8
+ 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
 
11
11
  # Open Broker - Hyperliquid Trading CLI
@@ -66,6 +66,9 @@ If an `ob_*` plugin tool returns unexpected errors, empty results, or crashes, *
66
66
  | `ob_orders` | `openbroker orders --json` |
67
67
  | `ob_funding_scan` | `openbroker funding-scan --json` |
68
68
  | `ob_candles` | `openbroker candles --coin <COIN> --json` |
69
+ | `ob_auto_run` | `openbroker auto run <script> [--dry]` |
70
+ | `ob_auto_stop` | (stop via SIGINT when using CLI) |
71
+ | `ob_auto_list` | `openbroker auto list` |
69
72
 
70
73
  **When to use CLI fallback:**
71
74
  - Plugin tool returns `null`, empty data, or throws an error
@@ -448,6 +451,7 @@ This skill works standalone via Bash — every command above runs through the `o
448
451
 
449
452
  - **Structured agent tools** (`ob_account`, `ob_buy`, `ob_limit`, etc.) — typed tool calls with proper input schemas instead of Bash strings. The agent gets structured JSON responses.
450
453
  - **Background position watcher** — polls your Hyperliquid account every 30s and sends webhook alerts when positions open/close, PnL moves significantly, or margin usage gets dangerous.
454
+ - **Automation tools** (`ob_auto_run`, `ob_auto_stop`, `ob_auto_list`) — start, stop, and manage custom trading automations from within the agent.
451
455
  - **CLI commands** — `openclaw ob status` and `openclaw ob watch` for inspecting the watcher.
452
456
 
453
457
  ### Enable the plugin
@@ -487,6 +491,243 @@ Without hooks, the watcher still runs and tracks state (accessible via `ob_watch
487
491
  - **Skill only (no plugin):** Use Bash commands (`openbroker buy --coin ETH --size 0.1`). No background monitoring.
488
492
  - **Skill + plugin:** The agent prefers the `ob_*` tools when available (structured data), falls back to Bash for commands not covered by tools (strategies, scale). Background watcher sends alerts automatically.
489
493
 
494
+ ## Trading Automations
495
+
496
+ Automations let you write custom event-driven trading logic as TypeScript scripts. Instead of using the rigid built-in strategies, write exactly the logic you need and OpenBroker handles the polling, event detection, and SDK access.
497
+
498
+ ### How Automations Work
499
+
500
+ An automation is a `.ts` file that exports a default function. The function receives an `AutomationAPI` with the full Hyperliquid client, typed event subscriptions, persistent state, and a logger. The runtime polls Hyperliquid every 10s (configurable) and dispatches events when changes are detected.
501
+
502
+ ### Writing an Automation
503
+
504
+ Create a `.ts` file in `~/.openbroker/automations/` (or any path):
505
+
506
+ ```typescript
507
+ // ~/.openbroker/automations/funding-scalp.ts
508
+ export default function(api) {
509
+ const COIN = 'ETH';
510
+
511
+ api.on('funding_update', async ({ coin, annualized }) => {
512
+ if (coin !== COIN) return;
513
+
514
+ if (annualized > 0.5 && !api.state.get('isShort')) {
515
+ api.log.info('High positive funding — going short');
516
+ await api.client.marketOrder(COIN, false, 0.1);
517
+ api.state.set('isShort', true);
518
+ } else if (annualized < -0.1 && api.state.get('isShort')) {
519
+ api.log.info('Funding normalized — closing short');
520
+ await api.client.marketOrder(COIN, true, 0.1);
521
+ api.state.set('isShort', false);
522
+ }
523
+ });
524
+
525
+ api.onStop(async () => {
526
+ if (api.state.get('isShort')) {
527
+ api.log.warn('Closing short on shutdown');
528
+ await api.client.marketOrder('ETH', true, 0.1);
529
+ api.state.set('isShort', false);
530
+ }
531
+ });
532
+ }
533
+ ```
534
+
535
+ ### AutomationAPI Reference
536
+
537
+ | Property / Method | Description |
538
+ |-------------------|-------------|
539
+ | `api.client` | Full HyperliquidClient — `marketOrder()`, `limitOrder()`, `triggerOrder()`, `cancelAll()`, `getUserStateAll()`, `getAllMids()`, `updateLeverage()`, and 35+ more methods |
540
+ | `api.on(event, handler)` | Subscribe to a market/account event (see Events below) |
541
+ | `api.every(ms, handler)` | Run a handler on a recurring interval (aligned to poll loop) |
542
+ | `api.onStart(handler)` | Called after all handlers are registered, before first poll |
543
+ | `api.onStop(handler)` | Called on shutdown (SIGINT). Use for cleanup — close positions, cancel orders |
544
+ | `api.onError(handler)` | Called when a handler throws. Error is already logged — use for recovery logic |
545
+ | `api.state.get(key)` | Get a persisted value (survives restarts, stored in `~/.openbroker/state/`) |
546
+ | `api.state.set(key, value)` | Set a persisted value |
547
+ | `api.state.delete(key)` | Delete a persisted value |
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? }` |
550
+ | `api.log.info/warn/error/debug(msg)` | Structured logger |
551
+ | `api.utils` | `roundPrice`, `roundSize`, `sleep`, `normalizeCoin`, `formatUsd`, `annualizeFundingRate` |
552
+ | `api.id` | Automation ID (filename or `--id` flag) |
553
+ | `api.dryRun` | `true` if running with `--dry` (write methods are intercepted) |
554
+
555
+ ### Events
556
+
557
+ | Event | Payload | When |
558
+ |-------|---------|------|
559
+ | `tick` | `{ timestamp, pollCount }` | Every poll cycle (default: 10s) |
560
+ | `price_change` | `{ coin, oldPrice, newPrice, changePct }` | Mid price moved > 0.1% |
561
+ | `funding_update` | `{ coin, fundingRate, annualized, premium }` | Every poll for all assets |
562
+ | `position_opened` | `{ coin, side, size, entryPrice }` | New position detected |
563
+ | `position_closed` | `{ coin, previousSize, entryPrice }` | Position no longer present |
564
+ | `position_changed` | `{ coin, oldSize, newSize, entryPrice }` | Position size changed |
565
+ | `pnl_threshold` | `{ coin, unrealizedPnl, changePct, positionValue }` | PnL moved > 5% of position value |
566
+ | `margin_warning` | `{ marginUsedPct, equity, marginUsed }` | Margin usage > 80% |
567
+
568
+ ### Client Methods Available
569
+
570
+ The `api.client` object exposes the full Hyperliquid SDK:
571
+
572
+ **Trading:** `marketOrder(coin, isBuy, size)`, `limitOrder(coin, isBuy, size, price)`, `triggerOrder(coin, isBuy, size, triggerPx, isMarket)`, `takeProfit(coin, isBuy, size, triggerPx)`, `stopLoss(coin, isBuy, size, triggerPx)`, `cancel(coin, oid)`, `cancelAll(coin?)`
573
+
574
+ **Market Data:** `getAllMids()`, `getMetaAndAssetCtxs()`, `getRecentTrades(coin)`, `getCandleSnapshot(coin, interval)`, `getFundingHistory(coin)`, `getPredictedFundings()`
575
+
576
+ **Account:** `getUserStateAll()`, `getOpenOrders()`, `getUserFills()`, `getUserFunding()`, `getHistoricalOrders()`, `getUserFees()`, `getUserRateLimit()`, `getSpotBalances()`
577
+
578
+ **Leverage:** `updateLeverage(coin, leverage, isIsolated?)`
579
+
580
+ ### Example: Price Breakout
581
+
582
+ ```typescript
583
+ // ~/.openbroker/automations/breakout.ts
584
+ export default function(api) {
585
+ const COIN = 'ETH';
586
+ const BREAKOUT_PCT = 2; // 2% move triggers entry
587
+ const SIZE = 0.5;
588
+ let basePrice = null;
589
+
590
+ api.onStart(async () => {
591
+ const mids = await api.client.getAllMids();
592
+ basePrice = parseFloat(mids[COIN]);
593
+ api.log.info(`Watching ${COIN} from $${basePrice} for ${BREAKOUT_PCT}% breakout`);
594
+ });
595
+
596
+ api.on('price_change', async ({ coin, newPrice }) => {
597
+ if (coin !== COIN || !basePrice) return;
598
+ const totalChange = ((newPrice - basePrice) / basePrice) * 100;
599
+
600
+ if (Math.abs(totalChange) >= BREAKOUT_PCT && !api.state.get('inPosition')) {
601
+ const side = totalChange > 0; // true = long, false = short
602
+ api.log.info(`Breakout! ${totalChange.toFixed(2)}% — entering ${side ? 'long' : 'short'}`);
603
+ await api.client.marketOrder(COIN, side, SIZE);
604
+ api.state.set('inPosition', true);
605
+ }
606
+ });
607
+ }
608
+ ```
609
+
610
+ ### Example: Scheduled DCA
611
+
612
+ ```typescript
613
+ // ~/.openbroker/automations/hourly-dca.ts
614
+ export default function(api) {
615
+ const COIN = 'ETH';
616
+ const USD_PER_BUY = 100;
617
+
618
+ // Buy $100 of ETH every hour
619
+ api.every(60 * 60 * 1000, async () => {
620
+ const mids = await api.client.getAllMids();
621
+ const price = parseFloat(mids[COIN]);
622
+ const size = parseFloat(api.utils.roundSize(USD_PER_BUY / price, 4));
623
+ await api.client.marketOrder(COIN, true, size);
624
+ const count = (api.state.get('buyCount') || 0) + 1;
625
+ api.state.set('buyCount', count);
626
+ api.log.info(`DCA #${count}: bought ${size} ${COIN} at $${price}`);
627
+ });
628
+ }
629
+ ```
630
+
631
+ ### Example: Margin Guardian
632
+
633
+ ```typescript
634
+ // ~/.openbroker/automations/margin-guard.ts
635
+ export default function(api) {
636
+ api.on('margin_warning', async ({ marginUsedPct, equity }) => {
637
+ api.log.warn(`Margin at ${marginUsedPct.toFixed(1)}% — reducing positions`);
638
+
639
+ // Close the smallest position to free margin
640
+ const state = await api.client.getUserStateAll();
641
+ const positions = state.assetPositions
642
+ .filter(p => parseFloat(p.position.szi) !== 0)
643
+ .sort((a, b) => Math.abs(parseFloat(a.position.positionValue)) - Math.abs(parseFloat(b.position.positionValue)));
644
+
645
+ if (positions.length > 0) {
646
+ const pos = positions[0].position;
647
+ const size = Math.abs(parseFloat(pos.szi));
648
+ const isBuy = parseFloat(pos.szi) < 0; // Close short = buy, close long = sell
649
+ api.log.info(`Closing smallest position: ${pos.coin} (${pos.szi})`);
650
+ await api.client.marketOrder(pos.coin, isBuy, size);
651
+ }
652
+ });
653
+ }
654
+ ```
655
+
656
+ ### Publishing to the Agent (Webhooks)
657
+
658
+ Use `api.publish()` to send messages back to the OpenClaw agent. This triggers an agent turn — the agent receives the message and can notify the user via their preferred channel, take trading actions, or log the event.
659
+
660
+ ```typescript
661
+ // Simple notification
662
+ await api.publish(`ETH broke above $4000 — current price: $${price}`);
663
+
664
+ // With options
665
+ await api.publish(`Margin at ${pct}% — positions at risk`, {
666
+ name: 'margin-alert', // appears in logs
667
+ wakeMode: 'now', // 'now' (default) or 'next-heartbeat'
668
+ channel: 'slack', // target channel (optional)
669
+ });
670
+ ```
671
+
672
+ `api.publish()` returns `true` if delivered, `false` if webhooks are not configured (no hooks token). It requires `OPENCLAW_HOOKS_TOKEN` to be set (automatically configured when running as an OpenClaw plugin).
673
+
674
+ **Example: Price alert automation with publish**
675
+ ```typescript
676
+ // ~/.openbroker/automations/price-alert.ts
677
+ export default function(api) {
678
+ const COIN = 'ETH';
679
+ const THRESHOLD = 4000;
680
+
681
+ api.on('price_change', async ({ coin, newPrice, changePct }) => {
682
+ if (coin !== COIN) return;
683
+
684
+ const crossed = api.state.get<boolean>('crossed', false);
685
+ if (!crossed && newPrice >= THRESHOLD) {
686
+ api.state.set('crossed', true);
687
+ await api.publish(
688
+ `${COIN} crossed above $${THRESHOLD}! Price: $${newPrice.toFixed(2)} (+${changePct.toFixed(2)}%)`,
689
+ );
690
+ } else if (crossed && newPrice < THRESHOLD) {
691
+ api.state.set('crossed', false);
692
+ }
693
+ });
694
+ }
695
+ ```
696
+
697
+ ### Running Automations
698
+
699
+ **CLI:**
700
+ ```bash
701
+ openbroker auto run my-strategy --dry # Test without trading
702
+ openbroker auto run ./funding-scalp.ts # Run from path
703
+ openbroker auto run my-strategy --poll 5000 # Poll every 5s
704
+ openbroker auto list # Show available scripts
705
+ openbroker auto status # Show running automations
706
+ ```
707
+
708
+ **Plugin tools (for OpenClaw agents):**
709
+ - `ob_auto_run` — `{ "script": "funding-scalp", "dry": true }` — start an automation
710
+ - `ob_auto_stop` — `{ "id": "funding-scalp" }` — stop a running automation
711
+ - `ob_auto_list` — `{}` — list available and running automations
712
+
713
+ **Options:**
714
+ | Flag | Description | Default |
715
+ |------|-------------|---------|
716
+ | `--dry` | Intercept write methods — no real trades | false |
717
+ | `--verbose` | Show debug output | false |
718
+ | `--id <name>` | Custom automation ID | filename |
719
+ | `--poll <ms>` | Poll interval in milliseconds | 10000 |
720
+
721
+ **Important notes for agents writing automations:**
722
+ - Always test with `--dry` first before live trading
723
+ - Use `api.state` to track position state across restarts
724
+ - Use `api.onStop()` to clean up — close positions, cancel orders
725
+ - Use `api.publish()` to send alerts/events back to the OpenClaw agent — do NOT manually construct webhook requests
726
+ - The runtime catches errors per handler — one failing handler won't crash others
727
+ - Scripts are loaded from `~/.openbroker/automations/` by name, or from any absolute path
728
+ - All trading commands support HIP-3 assets (`api.client.marketOrder('xyz:CL', true, 1)`)
729
+ - Automations persist across gateway restarts — they are automatically restarted when the gateway comes back up
730
+
490
731
  ## Risk Warning
491
732
 
492
733
  - Always use `--dry` first to preview orders
package/bin/cli.ts CHANGED
@@ -52,6 +52,9 @@ const commands: Record<string, { script: string; description: string }> = {
52
52
  'dca': { script: 'strategies/dca.ts', description: 'DCA strategy' },
53
53
  'mm-spread': { script: 'strategies/mm-spread.ts', description: 'Market making (spread)' },
54
54
  'mm-maker': { script: 'strategies/mm-maker.ts', description: 'Market making (ALO)' },
55
+
56
+ // Automations
57
+ 'auto': { script: 'auto/cli.ts', description: 'Run/manage trading automations' },
55
58
  };
56
59
 
57
60
  function printHelp() {
@@ -103,6 +106,11 @@ Strategies:
103
106
  mm-spread Market making (spread-based)
104
107
  mm-maker Market making (ALO orders)
105
108
 
109
+ Automations:
110
+ auto run <script> Run a custom trading automation
111
+ auto list List available automations
112
+ auto status Show running automations
113
+
106
114
  Options:
107
115
  --help, -h Show help for a command
108
116
  --dry Preview without executing
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openbroker",
3
3
  "name": "OpenBroker — Hyperliquid Trading",
4
- "version": "1.0.60",
4
+ "version": "1.0.62",
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.60",
3
+ "version": "1.0.62",
4
4
  "description": "Hyperliquid trading CLI - execute orders, manage positions, and run trading strategies",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,239 @@
1
+ // CLI entry point for `openbroker auto` commands
2
+
3
+ import { parseArgs } from '../core/utils.js';
4
+ import { resolveScriptPath, listAutomations, ensureAutomationsDir } from './loader.js';
5
+ import { startAutomation, getRunningAutomations, getRegisteredAutomations } from './runtime.js';
6
+ import { unregisterAutomation, cleanRegistry } from './registry.js';
7
+
8
+ function printUsage() {
9
+ console.log(`
10
+ OpenBroker Automations — event-driven trading scripts
11
+
12
+ Usage:
13
+ openbroker auto run <script> [options] Run an automation script
14
+ openbroker auto stop <id> Unregister an automation (won't restart)
15
+ openbroker auto list List available automations
16
+ openbroker auto status Show running automations
17
+ openbroker auto clean Remove stale entries from registry
18
+
19
+ Options (for run):
20
+ --dry Intercept write methods (no real trades)
21
+ --verbose Show debug output
22
+ --id <name> Custom automation ID (default: filename)
23
+ --poll <ms> Poll interval in milliseconds (default: 10000)
24
+
25
+ Scripts are loaded from:
26
+ 1. Absolute or relative path
27
+ 2. ~/.openbroker/automations/<name>.ts
28
+
29
+ Writing an automation:
30
+ export default function(api) {
31
+ api.on('price_change', async ({ coin, changePct }) => {
32
+ api.log.info(\`\${coin} moved \${changePct.toFixed(2)}%\`);
33
+ });
34
+ }
35
+
36
+ Events: tick, price_change, funding_update, position_opened,
37
+ position_closed, position_changed, pnl_threshold, margin_warning
38
+
39
+ Examples:
40
+ openbroker auto run my-strategy --dry # Test without trading
41
+ openbroker auto run ./funding-scalp.ts # Run from path
42
+ openbroker auto list # Show available scripts
43
+ `);
44
+ }
45
+
46
+ async function runCommand(args: Record<string, string | boolean>, positional: string[]) {
47
+ const scriptName = positional[0];
48
+ if (!scriptName) {
49
+ console.error('Error: script name or path required');
50
+ console.log('Usage: openbroker auto run <script> [--dry] [--verbose]');
51
+ process.exit(1);
52
+ }
53
+
54
+ const scriptPath = resolveScriptPath(scriptName);
55
+ const dryRun = args.dry === true;
56
+ const verbose = args.verbose === true;
57
+ const pollIntervalMs = args.poll ? parseInt(String(args.poll), 10) : 10_000;
58
+ const id = args.id ? String(args.id) : undefined;
59
+
60
+ if (isNaN(pollIntervalMs) || pollIntervalMs < 1000) {
61
+ console.error('Error: --poll must be at least 1000ms');
62
+ process.exit(1);
63
+ }
64
+
65
+ const automation = await startAutomation({
66
+ scriptPath,
67
+ id,
68
+ dryRun,
69
+ verbose,
70
+ pollIntervalMs,
71
+ });
72
+
73
+ // Graceful shutdown on SIGINT/SIGTERM
74
+ const shutdown = async () => {
75
+ console.log('\nShutting down...');
76
+ await automation.stop();
77
+ process.exit(0);
78
+ };
79
+
80
+ process.on('SIGINT', shutdown);
81
+ process.on('SIGTERM', shutdown);
82
+
83
+ // Keep process alive
84
+ await new Promise(() => {});
85
+ }
86
+
87
+ function listCommand() {
88
+ ensureAutomationsDir();
89
+ const automations = listAutomations();
90
+
91
+ if (automations.length === 0) {
92
+ console.log('No automations found in ~/.openbroker/automations/');
93
+ console.log('\nCreate a .ts file there with:');
94
+ console.log(' export default function(api) { ... }');
95
+ return;
96
+ }
97
+
98
+ console.log('Available automations:\n');
99
+ for (const a of automations) {
100
+ console.log(` ${a.name.padEnd(30)} ${a.path}`);
101
+ }
102
+ console.log(`\nRun with: openbroker auto run <name>`);
103
+ }
104
+
105
+ function statusCommand() {
106
+ // Show in-process automations (if any running in this process)
107
+ const inProcess = getRunningAutomations();
108
+
109
+ // Show all registered automations from file-based registry (cross-process)
110
+ const registered = getRegisteredAutomations();
111
+
112
+ if (inProcess.length === 0 && registered.length === 0) {
113
+ console.log('No automations running');
114
+ return;
115
+ }
116
+
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;
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');
182
+ }
183
+
184
+ async function main() {
185
+ const rawArgs = process.argv.slice(2);
186
+
187
+ if (rawArgs.length === 0 || rawArgs[0] === '--help' || rawArgs[0] === '-h') {
188
+ printUsage();
189
+ process.exit(0);
190
+ }
191
+
192
+ const subcommand = rawArgs[0];
193
+ const restArgs = rawArgs.slice(1);
194
+
195
+ // Extract positional args (non-flag args)
196
+ const positional: string[] = [];
197
+ const flagArgs: string[] = [];
198
+ for (let i = 0; i < restArgs.length; i++) {
199
+ if (restArgs[i].startsWith('--')) {
200
+ flagArgs.push(restArgs[i]);
201
+ // If next arg doesn't start with --, it's a flag value
202
+ if (i + 1 < restArgs.length && !restArgs[i + 1].startsWith('--')) {
203
+ flagArgs.push(restArgs[i + 1]);
204
+ i++;
205
+ }
206
+ } else {
207
+ positional.push(restArgs[i]);
208
+ }
209
+ }
210
+
211
+ const args = parseArgs(flagArgs);
212
+
213
+ switch (subcommand) {
214
+ case 'run':
215
+ await runCommand(args, positional);
216
+ break;
217
+ case 'stop':
218
+ stopCommand(positional);
219
+ break;
220
+ case 'list':
221
+ listCommand();
222
+ break;
223
+ case 'status':
224
+ statusCommand();
225
+ break;
226
+ case 'clean':
227
+ cleanCommand();
228
+ break;
229
+ default:
230
+ console.error(`Unknown subcommand: ${subcommand}`);
231
+ console.log('Run "openbroker auto --help" for usage');
232
+ process.exit(1);
233
+ }
234
+ }
235
+
236
+ main().catch(err => {
237
+ console.error(err.message || err);
238
+ process.exit(1);
239
+ });
@@ -0,0 +1,52 @@
1
+ // Lightweight typed event bus for the automation runtime
2
+
3
+ import type {
4
+ AutomationEventType,
5
+ AutomationEventPayloads,
6
+ AutomationEventHandler,
7
+ } from './types.js';
8
+
9
+ export class AutomationEventBus {
10
+ private handlers = new Map<AutomationEventType, Set<Function>>();
11
+
12
+ on<E extends AutomationEventType>(
13
+ event: E,
14
+ handler: AutomationEventHandler<E>,
15
+ ): void {
16
+ let set = this.handlers.get(event);
17
+ if (!set) {
18
+ set = new Set();
19
+ this.handlers.set(event, set);
20
+ }
21
+ set.add(handler);
22
+ }
23
+
24
+ /** Emit an event — handlers run sequentially, errors are returned (not thrown) */
25
+ async emit<E extends AutomationEventType>(
26
+ event: E,
27
+ payload: AutomationEventPayloads[E],
28
+ ): Promise<Error[]> {
29
+ const set = this.handlers.get(event);
30
+ if (!set || set.size === 0) return [];
31
+
32
+ const errors: Error[] = [];
33
+ for (const handler of set) {
34
+ try {
35
+ await handler(payload);
36
+ } catch (err) {
37
+ errors.push(err instanceof Error ? err : new Error(String(err)));
38
+ }
39
+ }
40
+ return errors;
41
+ }
42
+
43
+ /** Check if any handlers are registered for an event */
44
+ has(event: AutomationEventType): boolean {
45
+ const set = this.handlers.get(event);
46
+ return set !== undefined && set.size > 0;
47
+ }
48
+
49
+ removeAll(): void {
50
+ this.handlers.clear();
51
+ }
52
+ }
@@ -0,0 +1,71 @@
1
+ // Automation script loader — discovers and loads .ts automation files
2
+
3
+ import { existsSync, readdirSync, mkdirSync } from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import type { AutomationFactory } from './types.js';
7
+
8
+ const AUTOMATIONS_DIR = path.join(os.homedir(), '.openbroker', 'automations');
9
+
10
+ /** Resolve a script path from a name or path */
11
+ export function resolveScriptPath(nameOrPath: string): string {
12
+ // Absolute path
13
+ if (path.isAbsolute(nameOrPath)) {
14
+ if (!existsSync(nameOrPath)) {
15
+ throw new Error(`Automation script not found: ${nameOrPath}`);
16
+ }
17
+ return nameOrPath;
18
+ }
19
+
20
+ // Relative to cwd
21
+ const cwdPath = path.resolve(process.cwd(), nameOrPath);
22
+ if (existsSync(cwdPath)) return cwdPath;
23
+
24
+ // Relative to ~/.openbroker/automations/
25
+ const globalPath = path.join(AUTOMATIONS_DIR, nameOrPath);
26
+ if (existsSync(globalPath)) return globalPath;
27
+
28
+ // Try appending .ts
29
+ const withExt = path.join(AUTOMATIONS_DIR, `${nameOrPath}.ts`);
30
+ if (existsSync(withExt)) return withExt;
31
+
32
+ throw new Error(
33
+ `Automation script not found: ${nameOrPath}\n` +
34
+ `Searched:\n ${cwdPath}\n ${globalPath}\n ${withExt}`,
35
+ );
36
+ }
37
+
38
+ /** Load an automation module and validate the default export */
39
+ export async function loadAutomation(scriptPath: string): Promise<AutomationFactory> {
40
+ const absolutePath = path.resolve(scriptPath);
41
+
42
+ // Dynamic import — tsx handles TypeScript transpilation
43
+ const mod = await import(absolutePath);
44
+
45
+ const factory = mod.default;
46
+ if (typeof factory !== 'function') {
47
+ throw new Error(
48
+ `Automation script must export a default function.\n` +
49
+ `Got: ${typeof factory} from ${scriptPath}`,
50
+ );
51
+ }
52
+
53
+ return factory as AutomationFactory;
54
+ }
55
+
56
+ /** List available automation scripts in ~/.openbroker/automations/ */
57
+ export function listAutomations(): Array<{ name: string; path: string }> {
58
+ if (!existsSync(AUTOMATIONS_DIR)) return [];
59
+
60
+ return readdirSync(AUTOMATIONS_DIR)
61
+ .filter(f => f.endsWith('.ts') && !f.startsWith('.'))
62
+ .map(f => ({
63
+ name: f.replace(/\.ts$/, ''),
64
+ path: path.join(AUTOMATIONS_DIR, f),
65
+ }));
66
+ }
67
+
68
+ /** Ensure the automations directory exists */
69
+ export function ensureAutomationsDir(): void {
70
+ mkdirSync(AUTOMATIONS_DIR, { recursive: true });
71
+ }