@clawlabz/clawnetwork 0.1.0

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/index.ts ADDED
@@ -0,0 +1,1545 @@
1
+ /* eslint-disable @typescript-eslint/no-require-imports */
2
+ declare const process: { stdout: { write: (s: string) => void }; env: Record<string, string | undefined>; platform: string; arch: string; kill: (pid: number, sig: string) => boolean; pid: number; on: (event: string, handler: (...args: unknown[]) => void) => void }
3
+ declare function require(id: string): any
4
+ declare function setTimeout(fn: () => void, ms: number): unknown
5
+ declare function clearTimeout(id: unknown): void
6
+ declare function setInterval(fn: () => void, ms: number): unknown
7
+ declare function clearInterval(id: unknown): void
8
+ declare function fetch(url: string, init?: Record<string, unknown>): Promise<{ status: number; ok: boolean; text: () => Promise<string>; json: () => Promise<unknown> }>
9
+
10
+ const VERSION = '0.1.0'
11
+ const PLUGIN_ID = 'clawnetwork'
12
+ const GITHUB_REPO = 'clawlabz/claw-network'
13
+ const DEFAULT_RPC_PORT = 9710
14
+ const DEFAULT_P2P_PORT = 9711
15
+ const DEFAULT_NETWORK = 'mainnet'
16
+ const DEFAULT_SYNC_MODE = 'full'
17
+ const DEFAULT_HEALTH_CHECK_SECONDS = 30
18
+ const DEFAULT_UI_PORT = 19877
19
+ const MAX_RESTART_ATTEMPTS = 3
20
+ const RESTART_BACKOFF_BASE_MS = 5_000
21
+ const DECIMALS = 9
22
+ const ONE_CLAW = BigInt(10 ** DECIMALS)
23
+ const MAX_LOG_BYTES = 5 * 1024 * 1024 // 5 MB log rotation threshold
24
+ const HEX64_RE = /^[0-9a-f]{64}$/i
25
+ const HEX_RE = /^[0-9a-f]+$/i
26
+
27
+ // ============================================================
28
+ // OpenClaw API Types (mirrors Gateway runtime)
29
+ // ============================================================
30
+
31
+ type GatewayRespond = (ok: boolean, payload: Record<string, unknown>) => void
32
+
33
+ interface GatewayMethodContext {
34
+ respond?: GatewayRespond
35
+ params?: Record<string, unknown>
36
+ }
37
+
38
+ interface CliCommandChain {
39
+ description: (text: string) => CliCommandChain
40
+ argument: (spec: string, desc?: string) => CliCommandChain
41
+ option: (flags: string, desc: string) => CliCommandChain
42
+ action: (handler: (...args: unknown[]) => void) => CliCommandChain
43
+ command: (name: string) => CliCommandChain
44
+ allowExcessArguments: (allow: boolean) => CliCommandChain
45
+ }
46
+
47
+ interface CliProgram {
48
+ command: (name: string) => CliCommandChain
49
+ }
50
+
51
+ interface RegisterCliContext {
52
+ program: CliProgram
53
+ }
54
+
55
+ interface OpenClawApi {
56
+ config?: Record<string, unknown>
57
+ logger?: {
58
+ info?: (message: string, payload?: Record<string, unknown>) => void
59
+ warn?: (message: string, payload?: Record<string, unknown>) => void
60
+ error?: (message: string, payload?: Record<string, unknown>) => void
61
+ }
62
+ registerGatewayMethod?: (name: string, handler: (ctx: GatewayMethodContext) => void) => void
63
+ registerCli?: (
64
+ handler: (ctx: RegisterCliContext) => void,
65
+ options?: { commands?: string[] }
66
+ ) => void
67
+ registerService?: (service: { id: string; start?: () => void; stop?: () => void }) => void
68
+ }
69
+
70
+ // ============================================================
71
+ // Configuration
72
+ // ============================================================
73
+
74
+ interface PluginConfig {
75
+ network: string
76
+ autoStart: boolean
77
+ autoDownload: boolean
78
+ autoRegisterAgent: boolean
79
+ rpcPort: number
80
+ p2pPort: number
81
+ syncMode: string
82
+ healthCheckSeconds: number
83
+ uiPort: number
84
+ }
85
+
86
+ function getConfig(api: OpenClawApi): PluginConfig {
87
+ const c = (api.config && typeof api.config === 'object') ? api.config : {}
88
+ return {
89
+ network: typeof c.network === 'string' ? c.network : DEFAULT_NETWORK,
90
+ autoStart: typeof c.autoStart === 'boolean' ? c.autoStart : true,
91
+ autoDownload: typeof c.autoDownload === 'boolean' ? c.autoDownload : true,
92
+ autoRegisterAgent: typeof c.autoRegisterAgent === 'boolean' ? c.autoRegisterAgent : true,
93
+ rpcPort: typeof c.rpcPort === 'number' ? c.rpcPort : DEFAULT_RPC_PORT,
94
+ p2pPort: typeof c.p2pPort === 'number' ? c.p2pPort : DEFAULT_P2P_PORT,
95
+ syncMode: typeof c.syncMode === 'string' ? c.syncMode : DEFAULT_SYNC_MODE,
96
+ healthCheckSeconds: typeof c.healthCheckSeconds === 'number' ? c.healthCheckSeconds : DEFAULT_HEALTH_CHECK_SECONDS,
97
+ uiPort: typeof c.uiPort === 'number' ? c.uiPort : DEFAULT_UI_PORT,
98
+ }
99
+ }
100
+
101
+ // ============================================================
102
+ // Utilities
103
+ // ============================================================
104
+
105
+ const os = require('os')
106
+ const path = require('path')
107
+ const fs = require('fs')
108
+ const { execFileSync, spawn: nodeSpawn, fork } = require('child_process')
109
+
110
+ function homePath(...segments: string[]): string {
111
+ return path.join(os.homedir(), ...segments)
112
+ }
113
+
114
+ const WORKSPACE_DIR = homePath('.openclaw', 'workspace', 'clawnetwork')
115
+ const BIN_DIR = homePath('.openclaw', 'bin')
116
+ const DATA_DIR = homePath('.clawnetwork')
117
+ const WALLET_PATH = path.join(WORKSPACE_DIR, 'wallet.json')
118
+ const LOG_PATH = path.join(WORKSPACE_DIR, 'node.log')
119
+ const UI_PORT_FILE = homePath('.openclaw', 'clawnetwork-ui-port')
120
+
121
+ function ensureDir(dir: string): void {
122
+ fs.mkdirSync(dir, { recursive: true })
123
+ }
124
+
125
+ function sleep(ms: number): Promise<void> {
126
+ return new Promise(resolve => setTimeout(resolve, ms))
127
+ }
128
+
129
+ function formatClaw(raw: bigint | string): string {
130
+ const value = typeof raw === 'string' ? BigInt(raw) : raw
131
+ const whole = value / ONE_CLAW
132
+ const frac = value % ONE_CLAW
133
+ if (frac === 0n) return `${whole} CLAW`
134
+ const fracStr = frac.toString().padStart(DECIMALS, '0').replace(/0+$/, '')
135
+ return `${whole}.${fracStr} CLAW`
136
+ }
137
+
138
+ function formatUptime(seconds: number): string {
139
+ if (seconds < 60) return `${seconds}s`
140
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
141
+ const h = Math.floor(seconds / 3600)
142
+ const m = Math.floor((seconds % 3600) / 60)
143
+ return `${h}h ${m}m`
144
+ }
145
+
146
+ // ── Input validation ──
147
+
148
+ function isValidAddress(addr: string): boolean {
149
+ return HEX64_RE.test(addr)
150
+ }
151
+
152
+ function isValidPrivateKey(key: string): boolean {
153
+ return key.length === 64 && HEX_RE.test(key)
154
+ }
155
+
156
+ function isValidAmount(amount: string): boolean {
157
+ return /^\d+(\.\d+)?$/.test(amount) && parseFloat(amount) > 0
158
+ }
159
+
160
+ function isValidNetwork(network: string): boolean {
161
+ return ['mainnet', 'testnet', 'devnet'].includes(network)
162
+ }
163
+
164
+ function isValidSyncMode(mode: string): boolean {
165
+ return ['full', 'fast', 'light'].includes(mode)
166
+ }
167
+
168
+ function sanitizeAgentName(name: string): string {
169
+ return name.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 32)
170
+ }
171
+
172
+ // ── Log rotation ──
173
+
174
+ function rotateLogIfNeeded(): void {
175
+ try {
176
+ if (!fs.existsSync(LOG_PATH)) return
177
+ const stat = fs.statSync(LOG_PATH)
178
+ if (stat.size > MAX_LOG_BYTES) {
179
+ const rotated = `${LOG_PATH}.1`
180
+ try { fs.unlinkSync(rotated) } catch { /* ok */ }
181
+ fs.renameSync(LOG_PATH, rotated)
182
+ }
183
+ } catch { /* ok */ }
184
+ }
185
+
186
+ // ============================================================
187
+ // Binary Management
188
+ // ============================================================
189
+
190
+ function findBinary(): string | null {
191
+ // 1. Check our managed location
192
+ const managedPath = path.join(BIN_DIR, process.platform === 'win32' ? 'claw-node.exe' : 'claw-node')
193
+ if (fs.existsSync(managedPath)) return managedPath
194
+
195
+ // 2. Check if in PATH
196
+ try {
197
+ const which = process.platform === 'win32' ? 'where' : 'which'
198
+ const result = execFileSync(which, ['claw-node'], { encoding: 'utf8', timeout: 5000 }).trim()
199
+ if (result) return result.split('\n')[0]
200
+ } catch { /* not found */ }
201
+
202
+ // 3. Check data dir bin (install.sh puts it here)
203
+ const dataDirBin = path.join(DATA_DIR, 'bin', 'claw-node')
204
+ if (fs.existsSync(dataDirBin)) return dataDirBin
205
+
206
+ return null
207
+ }
208
+
209
+ function getBinaryVersion(binaryPath: string): string | null {
210
+ try {
211
+ const output = execFileSync(binaryPath, ['--version'], { encoding: 'utf8', timeout: 5000 }).trim()
212
+ const match = output.match(/(\d+\.\d+\.\d+)/)
213
+ return match ? match[1] : output
214
+ } catch { return null }
215
+ }
216
+
217
+ function detectPlatformTarget(): string {
218
+ const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux'
219
+ const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'
220
+ return `${platform}-${arch}`
221
+ }
222
+
223
+ async function downloadBinary(api: OpenClawApi): Promise<string> {
224
+ ensureDir(BIN_DIR)
225
+ const target = detectPlatformTarget()
226
+ const ext = process.platform === 'win32' ? 'zip' : 'tar.gz'
227
+ const binaryName = process.platform === 'win32' ? 'claw-node.exe' : 'claw-node'
228
+ const destPath = path.join(BIN_DIR, binaryName)
229
+
230
+ api.logger?.info?.(`[clawnetwork] downloading claw-node for ${target}...`)
231
+
232
+ // Resolve latest version from GitHub (HTTPS only)
233
+ let version = 'latest'
234
+ try {
235
+ const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`)
236
+ if (res.ok) {
237
+ const data = await res.json() as Record<string, unknown>
238
+ if (typeof data.tag_name === 'string') version = data.tag_name
239
+ }
240
+ } catch { /* fallback to latest redirect */ }
241
+
242
+ const baseUrl = version === 'latest'
243
+ ? `https://github.com/${GITHUB_REPO}/releases/latest/download`
244
+ : `https://github.com/${GITHUB_REPO}/releases/download/${version}`
245
+
246
+ const downloadUrl = `${baseUrl}/claw-node-${target}.${ext}`
247
+ const checksumUrl = `${baseUrl}/SHA256SUMS.txt`
248
+ api.logger?.info?.(`[clawnetwork] download URL: ${downloadUrl}`)
249
+
250
+ const tmpFile = path.join(os.tmpdir(), `claw-node-download-${Date.now()}.${ext}`)
251
+ try {
252
+ execFileSync('curl', ['-sSfL', '-o', tmpFile, downloadUrl], { timeout: 120_000 })
253
+ } catch {
254
+ try {
255
+ execFileSync('wget', ['-qO', tmpFile, downloadUrl], { timeout: 120_000 })
256
+ } catch (e: unknown) {
257
+ throw new Error(`Failed to download claw-node: ${(e as Error).message}`)
258
+ }
259
+ }
260
+
261
+ // Verify SHA256 checksum
262
+ try {
263
+ const checksumTmp = path.join(os.tmpdir(), `claw-node-sha256-${Date.now()}.txt`)
264
+ execFileSync('curl', ['-sSfL', '-o', checksumTmp, checksumUrl], { timeout: 30_000 })
265
+ const checksumContent = fs.readFileSync(checksumTmp, 'utf8')
266
+ const expectedLine = checksumContent.split('\n').find((l: string) => l.includes(`claw-node-${target}`))
267
+ if (expectedLine) {
268
+ const expectedHash = expectedLine.split(/\s+/)[0]
269
+ const cmd = process.platform === 'darwin' ? 'shasum' : 'sha256sum'
270
+ const args = process.platform === 'darwin' ? ['-a', '256', tmpFile] : [tmpFile]
271
+ const actualOutput = execFileSync(cmd, args, { encoding: 'utf8', timeout: 30_000 })
272
+ const actualHash = actualOutput.split(/\s+/)[0]
273
+ if (actualHash.toLowerCase() !== expectedHash.toLowerCase()) {
274
+ fs.unlinkSync(tmpFile)
275
+ try { fs.unlinkSync(checksumTmp) } catch { /* ok */ }
276
+ throw new Error(`SHA256 mismatch: expected ${expectedHash}, got ${actualHash}`)
277
+ }
278
+ api.logger?.info?.(`[clawnetwork] SHA256 verified: ${actualHash.slice(0, 16)}...`)
279
+ }
280
+ try { fs.unlinkSync(checksumTmp) } catch { /* ok */ }
281
+ } catch (e: unknown) {
282
+ // If checksum verification fails but file was downloaded, warn but continue
283
+ const msg = (e as Error).message
284
+ if (msg.includes('SHA256 mismatch')) throw e
285
+ api.logger?.warn?.(`[clawnetwork] checksum verification skipped: ${msg}`)
286
+ }
287
+
288
+ // Extract
289
+ if (ext === 'tar.gz') {
290
+ execFileSync('tar', ['xzf', tmpFile, '-C', BIN_DIR], { timeout: 30_000 })
291
+ } else {
292
+ execFileSync('powershell', ['-Command', `Expand-Archive -Path "${tmpFile}" -DestinationPath "${BIN_DIR}" -Force`], { timeout: 30_000 })
293
+ }
294
+
295
+ // Ensure executable
296
+ if (process.platform !== 'win32') {
297
+ fs.chmodSync(destPath, 0o755)
298
+ }
299
+
300
+ // Cleanup
301
+ try { fs.unlinkSync(tmpFile) } catch { /* ok */ }
302
+
303
+ if (!fs.existsSync(destPath)) {
304
+ throw new Error(`Binary not found after extraction at ${destPath}`)
305
+ }
306
+
307
+ api.logger?.info?.(`[clawnetwork] claw-node installed at ${destPath} (${version})`)
308
+ return destPath
309
+ }
310
+
311
+ // ============================================================
312
+ // Node Init
313
+ // ============================================================
314
+
315
+ function isInitialized(): boolean {
316
+ // Both genesis config AND chain data must exist for a proper init
317
+ const hasGenesis = fs.existsSync(path.join(DATA_DIR, 'genesis.json'))
318
+ const hasChainDb = fs.existsSync(path.join(DATA_DIR, 'chain.redb'))
319
+ return hasGenesis && hasChainDb
320
+ }
321
+
322
+ function initNode(binaryPath: string, network: string, api: OpenClawApi): void {
323
+ if (!isValidNetwork(network)) throw new Error(`Invalid network: ${network}`)
324
+ if (isInitialized()) {
325
+ api.logger?.info?.('[clawnetwork] node already initialized, skipping init')
326
+ return
327
+ }
328
+ api.logger?.info?.(`[clawnetwork] initializing node for ${network}...`)
329
+ try {
330
+ const output = execFileSync(binaryPath, ['init', '--network', network], {
331
+ encoding: 'utf8',
332
+ timeout: 30_000,
333
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' }, // minimal env
334
+ })
335
+ api.logger?.info?.(`[clawnetwork] init complete: ${output.trim().slice(0, 200)}`)
336
+ } catch (e: unknown) {
337
+ throw new Error(`Node init failed: ${(e as Error).message}`)
338
+ }
339
+ }
340
+
341
+ // ============================================================
342
+ // Wallet Management
343
+ // ============================================================
344
+
345
+ interface WalletData {
346
+ address: string
347
+ secretKey: string
348
+ createdAt: string
349
+ network: string
350
+ }
351
+
352
+ function loadWallet(): WalletData | null {
353
+ try {
354
+ const raw = fs.readFileSync(WALLET_PATH, 'utf8')
355
+ return JSON.parse(raw) as WalletData
356
+ } catch { return null }
357
+ }
358
+
359
+ function saveWallet(data: WalletData): void {
360
+ ensureDir(WORKSPACE_DIR)
361
+ fs.writeFileSync(WALLET_PATH, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
362
+ }
363
+
364
+ function ensureWallet(network: string, api?: OpenClawApi): WalletData {
365
+ const existing = loadWallet()
366
+ if (existing) return existing
367
+
368
+ // Try to read from claw-node's key.json
369
+ const nodeKeyPath = path.join(DATA_DIR, 'key.json')
370
+ if (fs.existsSync(nodeKeyPath)) {
371
+ try {
372
+ const nodeKey = JSON.parse(fs.readFileSync(nodeKeyPath, 'utf8'))
373
+ const wallet: WalletData = {
374
+ address: String(nodeKey.address || nodeKey.public_key || ''),
375
+ secretKey: String(nodeKey.secret_key || nodeKey.private_key || ''),
376
+ createdAt: new Date().toISOString(),
377
+ network,
378
+ }
379
+ if (wallet.address && wallet.secretKey) {
380
+ saveWallet(wallet)
381
+ api?.logger?.info?.(`[clawnetwork] wallet loaded from node key: ${wallet.address.slice(0, 12)}...`)
382
+ return wallet
383
+ }
384
+ } catch { /* fallthrough to generate */ }
385
+ }
386
+
387
+ // Generate new wallet via crypto.randomBytes
388
+ const crypto = require('crypto')
389
+ const privKey = crypto.randomBytes(32)
390
+ const secretKeyHex = privKey.toString('hex')
391
+
392
+ let address = ''
393
+ const binary = findBinary()
394
+ if (binary) {
395
+ try {
396
+ execFileSync(binary, ['key', 'import', secretKeyHex], {
397
+ encoding: 'utf8',
398
+ timeout: 10_000,
399
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
400
+ })
401
+ const showOut = execFileSync(binary, ['key', 'show'], {
402
+ encoding: 'utf8',
403
+ timeout: 5000,
404
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
405
+ }).trim()
406
+ const showMatch = showOut.match(/[0-9a-f]{64}/i)
407
+ if (showMatch) address = showMatch[0]
408
+ } catch { /* ok, address resolved later */ }
409
+ }
410
+
411
+ const wallet: WalletData = {
412
+ address,
413
+ secretKey: secretKeyHex,
414
+ createdAt: new Date().toISOString(),
415
+ network,
416
+ }
417
+ saveWallet(wallet)
418
+ api?.logger?.info?.(`[clawnetwork] new wallet generated: ${address ? address.slice(0, 12) + '...' : '(pending)'}`)
419
+ return wallet
420
+ }
421
+
422
+ // ============================================================
423
+ // Node Process Manager
424
+ // ============================================================
425
+
426
+ let nodeProcess: any = null
427
+ let healthTimer: unknown = null
428
+ let restartCount = 0
429
+ let stopping = false
430
+
431
+ interface NodeStatus {
432
+ running: boolean
433
+ pid: number | null
434
+ blockHeight: number | null
435
+ peerCount: number | null
436
+ network: string
437
+ syncMode: string
438
+ rpcUrl: string
439
+ walletAddress: string
440
+ binaryVersion: string | null
441
+ pluginVersion: string
442
+ uptime: number | null
443
+ uptimeFormatted: string | null
444
+ restartCount: number
445
+ dataDir: string
446
+ }
447
+
448
+ let nodeStartedAt: number | null = null
449
+ let lastHealth: { blockHeight: number | null; peerCount: number | null; syncing: boolean } = { blockHeight: null, peerCount: null, syncing: false }
450
+ let cachedBinaryVersion: string | null = null
451
+
452
+ function isNodeRunning(): { running: boolean; pid: number | null } {
453
+ // Check in-memory process first
454
+ if (nodeProcess && !nodeProcess.killed) return { running: true, pid: nodeProcess.pid }
455
+ // Check PID file (for detached processes from previous CLI invocations)
456
+ const pidFile = path.join(WORKSPACE_DIR, 'node.pid')
457
+ try {
458
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10)
459
+ if (pid > 0) {
460
+ try { execFileSync('kill', ['-0', String(pid)], { timeout: 2000 }); return { running: true, pid } } catch { /* dead */ }
461
+ }
462
+ } catch { /* no file */ }
463
+ // Last resort: check if RPC port is responding (covers orphaned processes)
464
+ try {
465
+ execFileSync('curl', ['-sf', '--max-time', '1', 'http://localhost:9710/health'], { timeout: 3000, encoding: 'utf8' })
466
+ // Port is responding — find PID by port
467
+ try {
468
+ const lsof = execFileSync('lsof', ['-ti', ':9710'], { timeout: 3000, encoding: 'utf8' }).trim()
469
+ const pid = parseInt(lsof.split('\n')[0], 10)
470
+ if (pid > 0) return { running: true, pid }
471
+ } catch { /* ok */ }
472
+ return { running: true, pid: null }
473
+ } catch { /* not responding */ }
474
+ return { running: false, pid: null }
475
+ }
476
+
477
+ function buildStatus(cfg: PluginConfig): NodeStatus {
478
+ const wallet = loadWallet()
479
+ const nodeState = isNodeRunning()
480
+ const uptime = nodeStartedAt ? Math.floor((Date.now() - nodeStartedAt) / 1000) : null
481
+ return {
482
+ running: nodeState.running,
483
+ pid: nodeState.pid,
484
+ blockHeight: lastHealth.blockHeight,
485
+ peerCount: lastHealth.peerCount,
486
+ network: cfg.network,
487
+ syncMode: cfg.syncMode,
488
+ rpcUrl: `http://localhost:${cfg.rpcPort}`,
489
+ walletAddress: wallet?.address ?? '',
490
+ binaryVersion: cachedBinaryVersion,
491
+ pluginVersion: VERSION,
492
+ uptime,
493
+ uptimeFormatted: uptime !== null ? formatUptime(uptime) : null,
494
+ restartCount,
495
+ dataDir: DATA_DIR,
496
+ }
497
+ }
498
+
499
+ async function checkHealth(rpcPort: number): Promise<{ blockHeight: number | null; peerCount: number | null; syncing: boolean }> {
500
+ try {
501
+ const res = await fetch(`http://localhost:${rpcPort}/health`)
502
+ if (!res.ok) return { blockHeight: null, peerCount: null, syncing: false }
503
+ const data = await res.json() as Record<string, unknown>
504
+ return {
505
+ blockHeight: typeof data.block_height === 'number' ? data.block_height : typeof data.blockHeight === 'number' ? data.blockHeight : null,
506
+ peerCount: typeof data.peer_count === 'number' ? data.peer_count : typeof data.peers === 'number' ? data.peers : null,
507
+ syncing: data.syncing === true,
508
+ }
509
+ } catch {
510
+ return { blockHeight: null, peerCount: null, syncing: false }
511
+ }
512
+ }
513
+
514
+ async function rpcCall(rpcPort: number, method: string, params: unknown[] = []): Promise<unknown> {
515
+ const res = await fetch(`http://localhost:${rpcPort}`, {
516
+ method: 'POST',
517
+ headers: { 'Content-Type': 'application/json' },
518
+ body: JSON.stringify({ jsonrpc: '2.0', method, params, id: Date.now() }),
519
+ })
520
+ const data = await res.json() as Record<string, unknown>
521
+ if (data.error) {
522
+ const err = data.error as Record<string, unknown>
523
+ throw new Error(String(err.message || JSON.stringify(err)))
524
+ }
525
+ return data.result
526
+ }
527
+
528
+ function startNodeProcess(binaryPath: string, cfg: PluginConfig, api: OpenClawApi): void {
529
+ // Guard: check in-memory reference, PID file, AND health endpoint
530
+ if (nodeProcess && !nodeProcess.killed) {
531
+ api.logger?.warn?.('[clawnetwork] node already running (in-memory)')
532
+ return
533
+ }
534
+ const existingState = isNodeRunning()
535
+ if (existingState.running) {
536
+ api.logger?.info?.(`[clawnetwork] node already running (pid=${existingState.pid}), skipping start`)
537
+ return
538
+ }
539
+
540
+ if (!isValidNetwork(cfg.network)) { api.logger?.error?.(`[clawnetwork] invalid network: ${cfg.network}`); return }
541
+ if (!isValidSyncMode(cfg.syncMode)) { api.logger?.error?.(`[clawnetwork] invalid sync mode: ${cfg.syncMode}`); return }
542
+
543
+ const args = ['start', '--network', cfg.network, '--rpc-port', String(cfg.rpcPort), '--p2p-port', String(cfg.p2pPort), '--sync-mode', cfg.syncMode, '--allow-genesis']
544
+
545
+ api.logger?.info?.(`[clawnetwork] starting node: ${binaryPath} ${args.join(' ')}`)
546
+
547
+ rotateLogIfNeeded()
548
+ ensureDir(WORKSPACE_DIR)
549
+
550
+ // Minimal env to prevent leaking secrets from parent process
551
+ const safeEnv: Record<string, string> = {
552
+ HOME: os.homedir(),
553
+ PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
554
+ RUST_LOG: process.env.RUST_LOG || 'claw=info',
555
+ }
556
+
557
+ // Open log file as fd for direct stdio redirect (allows parent process to exit)
558
+ const logFd = fs.openSync(LOG_PATH, 'a')
559
+
560
+ nodeProcess = nodeSpawn(binaryPath, args, {
561
+ stdio: ['ignore', logFd, logFd],
562
+ detached: true,
563
+ env: safeEnv,
564
+ })
565
+
566
+ // Unref so CLI process can exit while node keeps running in background
567
+ nodeProcess.unref()
568
+
569
+ nodeStartedAt = Date.now()
570
+ restartCount = 0
571
+ stopping = false
572
+
573
+ // Cache binary version
574
+ cachedBinaryVersion = getBinaryVersion(binaryPath)
575
+
576
+ // Save PID for later management
577
+ const pidFile = path.join(WORKSPACE_DIR, 'node.pid')
578
+ fs.writeFileSync(pidFile, String(nodeProcess.pid))
579
+
580
+ nodeProcess.on('exit', (code: number | null) => {
581
+ api.logger?.warn?.(`[clawnetwork] node exited with code ${code}`)
582
+ fs.closeSync(logFd)
583
+ nodeProcess = null
584
+ nodeStartedAt = null
585
+ lastHealth = { blockHeight: null, peerCount: null, syncing: false }
586
+ try { fs.unlinkSync(pidFile) } catch { /* ok */ }
587
+
588
+ // Check file-based stop signal (set by stop from different CLI process)
589
+ const stopFile = path.join(WORKSPACE_DIR, 'stop.signal')
590
+ const wasStopped = stopping || fs.existsSync(stopFile)
591
+ try { fs.unlinkSync(stopFile) } catch { /* ok */ }
592
+
593
+ if (!wasStopped && code !== 0 && restartCount < MAX_RESTART_ATTEMPTS) {
594
+ restartCount++
595
+ const delay = RESTART_BACKOFF_BASE_MS * Math.pow(2, restartCount - 1)
596
+ api.logger?.info?.(`[clawnetwork] restarting in ${delay}ms (attempt ${restartCount}/${MAX_RESTART_ATTEMPTS})...`)
597
+ setTimeout(() => startNodeProcess(binaryPath, cfg, api), delay)
598
+ }
599
+ })
600
+
601
+ startHealthCheck(cfg, api)
602
+ }
603
+
604
+ function startHealthCheck(cfg: PluginConfig, api: OpenClawApi): void {
605
+ if (healthTimer) clearTimeout(healthTimer)
606
+
607
+ const check = async () => {
608
+ lastHealth = await checkHealth(cfg.rpcPort)
609
+ if (lastHealth.blockHeight !== null) {
610
+ api.logger?.info?.(`[clawnetwork] height=${lastHealth.blockHeight} peers=${lastHealth.peerCount} syncing=${lastHealth.syncing}`)
611
+ }
612
+ if (!stopping) {
613
+ healthTimer = setTimeout(check, cfg.healthCheckSeconds * 1000)
614
+ }
615
+ }
616
+
617
+ healthTimer = setTimeout(check, 5000)
618
+ }
619
+
620
+ function stopNode(api: OpenClawApi): void {
621
+ stopping = true
622
+ if (healthTimer) {
623
+ clearTimeout(healthTimer)
624
+ healthTimer = null
625
+ }
626
+
627
+ // Find PID: in-memory process or PID file (for detached processes)
628
+ let pid: number | null = nodeProcess?.pid ?? null
629
+ const pidFile = path.join(WORKSPACE_DIR, 'node.pid')
630
+ if (!pid) {
631
+ try {
632
+ const savedPid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10)
633
+ if (savedPid > 0) pid = savedPid
634
+ } catch { /* no pid file */ }
635
+ }
636
+
637
+ if (pid) {
638
+ api.logger?.info?.(`[clawnetwork] stopping node pid=${pid} (SIGTERM)...`)
639
+ try { process.kill(pid, 'SIGTERM') } catch { /* already dead */ }
640
+ setTimeout(() => {
641
+ try { process.kill(pid as number, 'SIGKILL') } catch { /* ok */ }
642
+ }, 10_000)
643
+ }
644
+
645
+ // Write stop signal file (tells restart loop in other CLI processes to stop)
646
+ const stopFile = path.join(WORKSPACE_DIR, 'stop.signal')
647
+ try { fs.writeFileSync(stopFile, String(Date.now())) } catch { /* ok */ }
648
+
649
+ // Also kill any claw-node processes by name (covers orphans from restart loops)
650
+ try { execFileSync('pkill', ['-f', 'claw-node start'], { timeout: 3000 }) } catch { /* ok */ }
651
+
652
+ nodeProcess = null
653
+ nodeStartedAt = null
654
+ lastHealth = { blockHeight: null, peerCount: null, syncing: false }
655
+ try { fs.unlinkSync(pidFile) } catch { /* ok */ }
656
+ }
657
+
658
+ // ============================================================
659
+ // Agent Registration
660
+ // ============================================================
661
+
662
+ async function autoRegisterAgent(cfg: PluginConfig, wallet: WalletData, api: OpenClawApi): Promise<void> {
663
+ if (!cfg.autoRegisterAgent) return
664
+ if (!wallet.address) return
665
+
666
+ try {
667
+ const agent = await rpcCall(cfg.rpcPort, 'claw_getAgent', [wallet.address])
668
+ if (agent) {
669
+ api.logger?.info?.(`[clawnetwork] agent already registered on-chain: ${wallet.address.slice(0, 12)}...`)
670
+ return
671
+ }
672
+ } catch {
673
+ return // Node not ready
674
+ }
675
+
676
+ if (cfg.network === 'testnet' || cfg.network === 'devnet') {
677
+ try {
678
+ await rpcCall(cfg.rpcPort, 'claw_faucet', [wallet.address])
679
+ api.logger?.info?.('[clawnetwork] faucet: received testnet CLAW')
680
+ } catch { /* ok */ }
681
+ }
682
+
683
+ const binary = findBinary()
684
+ if (!binary) return
685
+
686
+ try {
687
+ const agentName = sanitizeAgentName(`openclaw-${wallet.address.slice(0, 8)}`)
688
+ const output = execFileSync(binary, [
689
+ 'agent', 'register', '--name', agentName,
690
+ '--rpc', `http://localhost:${cfg.rpcPort}`,
691
+ ], {
692
+ encoding: 'utf8',
693
+ timeout: 30_000,
694
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
695
+ })
696
+ api.logger?.info?.(`[clawnetwork] agent registered: ${agentName} — ${output.trim().slice(0, 200)}`)
697
+ } catch (e: unknown) {
698
+ api.logger?.warn?.(`[clawnetwork] agent registration skipped: ${(e as Error).message}`)
699
+ }
700
+ }
701
+
702
+ // ============================================================
703
+ // WebUI Server
704
+ // ============================================================
705
+
706
+ function buildUiHtml(cfg: PluginConfig): string {
707
+ return `<!DOCTYPE html>
708
+ <html lang="en">
709
+ <head>
710
+ <meta charset="UTF-8">
711
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
712
+ <title>ClawNetwork Node Dashboard</title>
713
+ <link rel="icon" href="https://cdn.clawlabz.xyz/brand/favicon.png">
714
+ <style>
715
+ :root {
716
+ --bg: #0a0a12;
717
+ --bg-panel: #12121f;
718
+ --border: #1e1e3a;
719
+ --accent: #00ccff;
720
+ --accent-dim: rgba(0, 204, 255, 0.15);
721
+ --green: #00ff88;
722
+ --green-dim: rgba(0, 255, 136, 0.15);
723
+ --purple: #8b5cf6;
724
+ --text: #e0e0f0;
725
+ --text-dim: #666688;
726
+ --danger: #ff4455;
727
+ --font: system-ui, -apple-system, sans-serif;
728
+ --font-mono: 'SF Mono', 'Fira Code', Consolas, monospace;
729
+ --radius: 10px;
730
+ --shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
731
+ }
732
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
733
+ body { background: var(--bg); color: var(--text); font-family: var(--font); line-height: 1.6; min-height: 100vh; }
734
+ .container { max-width: 960px; margin: 0 auto; padding: 0 20px; }
735
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
736
+
737
+ .header { background: var(--bg-panel); border-bottom: 1px solid var(--border); padding: 16px 0; position: sticky; top: 0; z-index: 100; }
738
+ .header .container { display: flex; align-items: center; justify-content: space-between; }
739
+ .logo { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; }
740
+ .logo-claw { color: var(--accent); }
741
+ .logo-net { color: var(--green); }
742
+ .header-badge { font-size: 11px; background: var(--accent-dim); color: var(--accent); padding: 2px 8px; border-radius: 4px; }
743
+
744
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 24px 0; }
745
+ .stat-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
746
+ .stat-label { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; }
747
+ .stat-value { font-size: 28px; font-weight: 700; font-family: var(--font-mono); margin-top: 4px; }
748
+ .stat-value.green { color: var(--green); }
749
+ .stat-value.accent { color: var(--accent); }
750
+ .stat-value.danger { color: var(--danger); }
751
+
752
+ .panel { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin: 16px 0; }
753
+ .panel-title { font-size: 14px; font-weight: 600; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px; }
754
+ .info-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
755
+ .info-row:last-child { border-bottom: none; }
756
+ .info-label { color: var(--text-dim); }
757
+ .info-value { font-family: var(--font-mono); color: var(--text); word-break: break-all; max-width: 60%; text-align: right; }
758
+
759
+ .status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
760
+ .status-dot.online { background: var(--green); box-shadow: 0 0 8px var(--green); }
761
+ .status-dot.offline { background: var(--danger); }
762
+ .status-dot.syncing { background: #ffaa00; animation: pulse 1.5s infinite; }
763
+
764
+ .btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-panel); color: var(--text); font-size: 13px; cursor: pointer; transition: 0.2s; }
765
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
766
+ .btn.danger:hover { border-color: var(--danger); color: var(--danger); }
767
+ .btn.primary { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
768
+ .btn-group { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; }
769
+
770
+ .logs-box { background: #080810; border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-family: var(--font-mono); font-size: 12px; max-height: 300px; overflow-y: auto; white-space: pre-wrap; color: var(--text-dim); line-height: 1.8; }
771
+
772
+ .wallet-addr { font-family: var(--font-mono); font-size: 13px; background: var(--bg); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); word-break: break-all; display: flex; align-items: center; gap: 8px; }
773
+ .copy-btn { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 14px; padding: 2px 6px; }
774
+ .copy-btn:hover { opacity: 0.7; }
775
+
776
+ .toast { position: fixed; bottom: 24px; right: 24px; background: var(--bg-panel); border: 1px solid var(--accent); color: var(--accent); padding: 12px 20px; border-radius: 8px; font-size: 13px; opacity: 0; transition: 0.3s; z-index: 1000; }
777
+ .toast.show { opacity: 1; }
778
+ </style>
779
+ </head>
780
+ <body>
781
+ <header class="header">
782
+ <div class="container">
783
+ <div style="display:flex;align-items:center;gap:14px">
784
+ <div class="logo"><span class="logo-claw">Claw</span><span class="logo-net">Network</span></div>
785
+ <span class="header-badge">Node Dashboard</span>
786
+ </div>
787
+ <span id="lastUpdate" style="font-size:12px;color:var(--text-dim)"></span>
788
+ </div>
789
+ </header>
790
+
791
+ <main class="container" style="padding-top:8px;padding-bottom:40px">
792
+ <div class="stats-grid">
793
+ <div class="stat-card">
794
+ <div class="stat-label">Status</div>
795
+ <div class="stat-value" id="statusValue"><span class="status-dot offline"></span>Offline</div>
796
+ </div>
797
+ <div class="stat-card">
798
+ <div class="stat-label">Block Height</div>
799
+ <div class="stat-value accent" id="heightValue">—</div>
800
+ </div>
801
+ <div class="stat-card">
802
+ <div class="stat-label">Peers</div>
803
+ <div class="stat-value" id="peersValue">—</div>
804
+ </div>
805
+ <div class="stat-card">
806
+ <div class="stat-label">Uptime</div>
807
+ <div class="stat-value" id="uptimeValue">—</div>
808
+ </div>
809
+ </div>
810
+
811
+ <div class="btn-group">
812
+ <button class="btn primary" onclick="doAction('start')">Start Node</button>
813
+ <button class="btn danger" onclick="doAction('stop')">Stop Node</button>
814
+ <button class="btn" onclick="doAction('faucet')">Faucet (testnet)</button>
815
+ <button class="btn" onclick="refreshLogs()">Refresh Logs</button>
816
+ </div>
817
+
818
+ <div class="panel">
819
+ <div class="panel-title">Wallet</div>
820
+ <div id="walletInfo">Loading...</div>
821
+ </div>
822
+
823
+ <div class="panel">
824
+ <div class="panel-title">Node Info</div>
825
+ <div id="nodeInfo">Loading...</div>
826
+ </div>
827
+
828
+ <div class="panel">
829
+ <div class="panel-title">Recent Logs</div>
830
+ <div class="logs-box" id="logsBox">Loading...</div>
831
+ </div>
832
+ </main>
833
+
834
+ <div class="toast" id="toast"></div>
835
+
836
+ <script>
837
+ const API = '';
838
+ let autoRefresh = null;
839
+
840
+ function toast(msg) {
841
+ const el = document.getElementById('toast');
842
+ el.textContent = msg;
843
+ el.classList.add('show');
844
+ setTimeout(() => el.classList.remove('show'), 3000);
845
+ }
846
+
847
+ function copyText(text) {
848
+ navigator.clipboard.writeText(text).then(() => toast('Copied!')).catch(() => {});
849
+ }
850
+
851
+ async function fetchStatus() {
852
+ try {
853
+ const res = await fetch(API + '/api/status');
854
+ const data = await res.json();
855
+ renderStatus(data);
856
+ document.getElementById('lastUpdate').textContent = 'Updated: ' + new Date().toLocaleTimeString();
857
+ } catch (e) { console.error(e); }
858
+ }
859
+
860
+ function renderStatus(s) {
861
+ const statusEl = document.getElementById('statusValue');
862
+ if (s.running) {
863
+ const dotClass = s.syncing ? 'syncing' : 'online';
864
+ const label = s.syncing ? 'Syncing' : 'Online';
865
+ statusEl.innerHTML = '<span class="status-dot ' + dotClass + '"></span>' + label;
866
+ statusEl.className = 'stat-value green';
867
+ } else {
868
+ statusEl.innerHTML = '<span class="status-dot offline"></span>Offline';
869
+ statusEl.className = 'stat-value danger';
870
+ }
871
+
872
+ document.getElementById('heightValue').textContent = s.blockHeight !== null ? s.blockHeight.toLocaleString() : '—';
873
+ document.getElementById('peersValue').textContent = s.peerCount !== null ? s.peerCount : '—';
874
+ document.getElementById('uptimeValue').textContent = s.uptimeFormatted || '—';
875
+
876
+ // Wallet
877
+ const wHtml = s.walletAddress
878
+ ? '<div class="wallet-addr">' + s.walletAddress + ' <button class="copy-btn" onclick="copyText(\\''+s.walletAddress+'\\')">Copy</button></div>' +
879
+ (s.balance ? '<div style="margin-top:8px;font-size:14px;color:var(--green)">' + s.balance + '</div>' : '')
880
+ : '<div style="color:var(--text-dim)">No wallet yet — start the node to generate one</div>';
881
+ document.getElementById('walletInfo').innerHTML = wHtml;
882
+
883
+ // Node info
884
+ const rows = [
885
+ ['Network', s.network],
886
+ ['Sync Mode', s.syncMode],
887
+ ['RPC URL', s.rpcUrl],
888
+ ['Binary Version', s.binaryVersion || '—'],
889
+ ['Plugin Version', s.pluginVersion],
890
+ ['PID', s.pid || '—'],
891
+ ['Restart Count', s.restartCount],
892
+ ['Data Dir', s.dataDir],
893
+ ];
894
+ document.getElementById('nodeInfo').innerHTML = rows.map(function(r) {
895
+ return '<div class="info-row"><span class="info-label">' + r[0] + '</span><span class="info-value">' + r[1] + '</span></div>';
896
+ }).join('');
897
+ }
898
+
899
+ async function doAction(action) {
900
+ try {
901
+ const res = await fetch(API + '/api/action/' + action, { method: 'POST' });
902
+ const data = await res.json();
903
+ toast(data.message || data.error || 'Done');
904
+ setTimeout(fetchStatus, 1500);
905
+ } catch (e) { toast('Error: ' + e.message); }
906
+ }
907
+
908
+ async function refreshLogs() {
909
+ try {
910
+ const res = await fetch(API + '/api/logs');
911
+ const data = await res.json();
912
+ const box = document.getElementById('logsBox');
913
+ box.textContent = data.logs || 'No logs yet';
914
+ box.scrollTop = box.scrollHeight;
915
+ } catch (e) { document.getElementById('logsBox').textContent = 'Failed to load logs'; }
916
+ }
917
+
918
+ fetchStatus();
919
+ refreshLogs();
920
+ autoRefresh = setInterval(fetchStatus, 10000);
921
+ </script>
922
+ </body>
923
+ </html>`
924
+ }
925
+
926
+ let uiServer: unknown = null
927
+
928
+ function startUiServer(cfg: PluginConfig, api: OpenClawApi): string | null {
929
+ const http = require('http')
930
+
931
+ if (uiServer) return null
932
+
933
+ let actualPort = cfg.uiPort
934
+ const maxRetries = 10
935
+
936
+ const handleRequest = async (req: { method: string; url: string }, res: { writeHead: (s: number, h?: Record<string, string>) => void; end: (data?: string) => void; setHeader: (k: string, v: string) => void }) => {
937
+ const url = new URL(req.url, `http://localhost:${actualPort}`)
938
+ const pathname = url.pathname
939
+
940
+ res.setHeader('Access-Control-Allow-Origin', '*')
941
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
942
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
943
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return }
944
+
945
+ const json = (status: number, data: unknown) => {
946
+ res.writeHead(status, { 'content-type': 'application/json' })
947
+ res.end(JSON.stringify(data))
948
+ }
949
+
950
+ // Serve dashboard
951
+ if (pathname === '/' || pathname === '/index.html') {
952
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
953
+ res.end(buildUiHtml(cfg))
954
+ return
955
+ }
956
+
957
+ // Status API
958
+ if (pathname === '/api/status' && req.method === 'GET') {
959
+ const health = await checkHealth(cfg.rpcPort)
960
+ lastHealth = health
961
+ const status = buildStatus(cfg)
962
+ // Enrich with balance
963
+ let balance = ''
964
+ const wallet = loadWallet()
965
+ if (wallet?.address) {
966
+ try {
967
+ const raw = await rpcCall(cfg.rpcPort, 'claw_getBalance', [wallet.address])
968
+ balance = formatClaw(String(raw as string))
969
+ } catch { /* ok */ }
970
+ }
971
+ json(200, { ...status, balance, syncing: health.syncing })
972
+ return
973
+ }
974
+
975
+ // Logs API
976
+ if (pathname === '/api/logs' && req.method === 'GET') {
977
+ try {
978
+ if (!fs.existsSync(LOG_PATH)) { json(200, { logs: 'No logs yet' }); return }
979
+ const content = fs.readFileSync(LOG_PATH, 'utf8')
980
+ const lines = content.split('\n')
981
+ json(200, { logs: lines.slice(-80).join('\n') })
982
+ } catch (e: unknown) {
983
+ json(500, { error: (e as Error).message })
984
+ }
985
+ return
986
+ }
987
+
988
+ // Action API
989
+ if (pathname.startsWith('/api/action/') && req.method === 'POST') {
990
+ const action = pathname.split('/').pop()
991
+
992
+ if (action === 'start') {
993
+ let binary = findBinary()
994
+ if (!binary && cfg.autoDownload) {
995
+ try { binary = await downloadBinary(api) } catch (e: unknown) {
996
+ json(500, { error: (e as Error).message }); return
997
+ }
998
+ }
999
+ if (!binary) { json(400, { error: 'claw-node binary not found' }); return }
1000
+ if (nodeProcess && !nodeProcess.killed) { json(200, { message: 'Node already running' }); return }
1001
+ initNode(binary, cfg.network, api)
1002
+ startNodeProcess(binary, cfg, api)
1003
+ json(200, { message: 'Node starting...' })
1004
+ return
1005
+ }
1006
+
1007
+ if (action === 'stop') {
1008
+ stopNode(api)
1009
+ json(200, { message: 'Node stopped' })
1010
+ return
1011
+ }
1012
+
1013
+ if (action === 'faucet') {
1014
+ const wallet = loadWallet()
1015
+ if (!wallet?.address) { json(400, { error: 'No wallet' }); return }
1016
+ try {
1017
+ const result = await rpcCall(cfg.rpcPort, 'claw_faucet', [wallet.address])
1018
+ json(200, { message: 'Faucet success', ...(result as Record<string, unknown>) })
1019
+ } catch (e: unknown) {
1020
+ json(400, { error: (e as Error).message, message: 'Faucet only works on testnet/devnet' })
1021
+ }
1022
+ return
1023
+ }
1024
+
1025
+ json(404, { error: 'Unknown action' })
1026
+ return
1027
+ }
1028
+
1029
+ json(404, { error: 'Not found' })
1030
+ }
1031
+
1032
+ // Try ports with fallback
1033
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
1034
+ const tryPort = cfg.uiPort + attempt
1035
+ try {
1036
+ const server = http.createServer((req: unknown, res: unknown) => {
1037
+ handleRequest(req as any, res as any).catch((err: Error) => {
1038
+ try { (res as any).writeHead(500); (res as any).end(JSON.stringify({ error: err.message })) } catch { /* ok */ }
1039
+ })
1040
+ })
1041
+
1042
+ // Synchronous-ish listen check
1043
+ let bound = false
1044
+ server.listen(tryPort, '127.0.0.1')
1045
+ server.on('listening', () => { bound = true })
1046
+ server.on('error', () => { /* handled below */ })
1047
+
1048
+ // Give it a moment
1049
+ actualPort = tryPort
1050
+ uiServer = server
1051
+
1052
+ // Write port file for discovery
1053
+ ensureDir(path.dirname(UI_PORT_FILE))
1054
+ fs.writeFileSync(UI_PORT_FILE, JSON.stringify({ port: tryPort, pid: process.pid, startedAt: new Date().toISOString() }))
1055
+
1056
+ api.logger?.info?.(`[clawnetwork] dashboard: http://127.0.0.1:${tryPort}`)
1057
+ return `http://127.0.0.1:${tryPort}`
1058
+ } catch {
1059
+ continue
1060
+ }
1061
+ }
1062
+
1063
+ api.logger?.warn?.('[clawnetwork] failed to start dashboard UI server')
1064
+ return null
1065
+ }
1066
+
1067
+ function stopUiServer(): void {
1068
+ if (uiServer) {
1069
+ try { uiServer.close() } catch { /* ok */ }
1070
+ uiServer = null
1071
+ }
1072
+ try { fs.unlinkSync(UI_PORT_FILE) } catch { /* ok */ }
1073
+ }
1074
+
1075
+ function getDashboardUrl(): string | null {
1076
+ try {
1077
+ const raw = fs.readFileSync(UI_PORT_FILE, 'utf8')
1078
+ const info = JSON.parse(raw)
1079
+ return `http://127.0.0.1:${info.port}`
1080
+ } catch { return null }
1081
+ }
1082
+
1083
+ // ============================================================
1084
+ // CLI Command Handlers
1085
+ // ============================================================
1086
+
1087
+ const CLI_COMMANDS = [
1088
+ 'clawnetwork:status', 'clawnetwork:start', 'clawnetwork:stop',
1089
+ 'clawnetwork:wallet', 'clawnetwork:wallet:import', 'clawnetwork:wallet:export',
1090
+ 'clawnetwork:faucet', 'clawnetwork:transfer', 'clawnetwork:stake',
1091
+ 'clawnetwork:logs', 'clawnetwork:config', 'clawnetwork:ui',
1092
+ 'clawnetwork:service:register', 'clawnetwork:service:search',
1093
+ ]
1094
+
1095
+ // ============================================================
1096
+ // Main Plugin Registration
1097
+ // ============================================================
1098
+
1099
+ export default function register(api: OpenClawApi) {
1100
+ const cfg = getConfig(api)
1101
+
1102
+ // ── Gateway Methods ──
1103
+
1104
+ api.registerGatewayMethod?.('clawnetwork.status', ctx => {
1105
+ checkHealth(cfg.rpcPort).then(health => {
1106
+ lastHealth = health
1107
+ ctx.respond?.(true, buildStatus(cfg) as unknown as Record<string, unknown>)
1108
+ }).catch(() => ctx.respond?.(true, buildStatus(cfg) as unknown as Record<string, unknown>))
1109
+ })
1110
+
1111
+ api.registerGatewayMethod?.('clawnetwork.balance', ctx => {
1112
+ const address = (ctx.params?.address as string) || loadWallet()?.address || ''
1113
+ if (!address) { ctx.respond?.(false, { error: 'No wallet address' }); return }
1114
+ if (!isValidAddress(address)) { ctx.respond?.(false, { error: 'Invalid address format (expected 64-char hex)' }); return }
1115
+ rpcCall(cfg.rpcPort, 'claw_getBalance', [address])
1116
+ .then(result => ctx.respond?.(true, { address, balance: String(result), formatted: formatClaw(String(result as string)) }))
1117
+ .catch(err => ctx.respond?.(false, { error: (err as Error).message }))
1118
+ })
1119
+
1120
+ api.registerGatewayMethod?.('clawnetwork.transfer', ctx => {
1121
+ const to = ctx.params?.to as string
1122
+ const amount = ctx.params?.amount as string
1123
+ if (!to || !amount) { ctx.respond?.(false, { error: 'Missing params: to, amount' }); return }
1124
+ if (!isValidAddress(to)) { ctx.respond?.(false, { error: 'Invalid address (expected 64-char hex)' }); return }
1125
+ if (!isValidAmount(amount)) { ctx.respond?.(false, { error: 'Invalid amount (must be positive number)' }); return }
1126
+ const binary = findBinary()
1127
+ if (!binary) { ctx.respond?.(false, { error: 'claw-node binary not found' }); return }
1128
+ try {
1129
+ const output = execFileSync(binary, [
1130
+ 'transfer', to, amount, '--rpc', `http://localhost:${cfg.rpcPort}`,
1131
+ ], {
1132
+ encoding: 'utf8',
1133
+ timeout: 30_000,
1134
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
1135
+ })
1136
+ const hashMatch = output.match(/[0-9a-f]{64}/i)
1137
+ ctx.respond?.(true, { txHash: hashMatch?.[0] ?? '', to, amount, raw: output.trim() })
1138
+ } catch (e: unknown) {
1139
+ ctx.respond?.(false, { error: (e as Error).message })
1140
+ }
1141
+ })
1142
+
1143
+ api.registerGatewayMethod?.('clawnetwork.agent-register', ctx => {
1144
+ const rawName = (ctx.params?.name as string) || `openclaw-agent-${Date.now().toString(36)}`
1145
+ const name = sanitizeAgentName(rawName)
1146
+ if (!name || name.length < 2) { ctx.respond?.(false, { error: 'Invalid agent name' }); return }
1147
+ const binary = findBinary()
1148
+ if (!binary) { ctx.respond?.(false, { error: 'claw-node binary not found' }); return }
1149
+ try {
1150
+ const output = execFileSync(binary, [
1151
+ 'agent', 'register', '--name', name,
1152
+ '--rpc', `http://localhost:${cfg.rpcPort}`,
1153
+ ], {
1154
+ encoding: 'utf8',
1155
+ timeout: 30_000,
1156
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
1157
+ })
1158
+ const hashMatch = output.match(/[0-9a-f]{64}/i)
1159
+ ctx.respond?.(true, { txHash: hashMatch?.[0] ?? '', name, raw: output.trim() })
1160
+ } catch (e: unknown) {
1161
+ ctx.respond?.(false, { error: (e as Error).message })
1162
+ }
1163
+ })
1164
+
1165
+ api.registerGatewayMethod?.('clawnetwork.faucet', ctx => {
1166
+ const wallet = loadWallet()
1167
+ if (!wallet?.address) { ctx.respond?.(false, { error: 'No wallet' }); return }
1168
+ rpcCall(cfg.rpcPort, 'claw_faucet', [wallet.address])
1169
+ .then(result => ctx.respond?.(true, result as Record<string, unknown>))
1170
+ .catch(err => ctx.respond?.(false, { error: (err as Error).message }))
1171
+ })
1172
+
1173
+ api.registerGatewayMethod?.('clawnetwork.start', ctx => {
1174
+ const binary = findBinary()
1175
+ if (!binary) { ctx.respond?.(false, { error: 'claw-node binary not found. Set autoDownload=true or install manually.' }); return }
1176
+ if (nodeProcess && !nodeProcess.killed) { ctx.respond?.(true, { message: 'Node already running', ...buildStatus(cfg) }); return }
1177
+ initNode(binary, cfg.network, api)
1178
+ startNodeProcess(binary, cfg, api)
1179
+ ctx.respond?.(true, { message: 'Node starting...', ...buildStatus(cfg) })
1180
+ })
1181
+
1182
+ api.registerGatewayMethod?.('clawnetwork.stop', ctx => {
1183
+ stopNode(api)
1184
+ ctx.respond?.(true, { message: 'Node stopped' })
1185
+ })
1186
+
1187
+ api.registerGatewayMethod?.('clawnetwork.service-register', ctx => {
1188
+ const serviceType = ctx.params?.serviceType as string
1189
+ const endpoint = ctx.params?.endpoint as string
1190
+ const description = (ctx.params?.description as string) || ''
1191
+ const priceAmount = (ctx.params?.priceAmount as string) || '0'
1192
+ if (!serviceType || !endpoint) { ctx.respond?.(false, { error: 'Missing params: serviceType, endpoint' }); return }
1193
+ const binary = findBinary()
1194
+ if (!binary) { ctx.respond?.(false, { error: 'claw-node binary not found' }); return }
1195
+ try {
1196
+ const output = execFileSync(binary, [
1197
+ 'service', 'register',
1198
+ '--type', serviceType,
1199
+ '--endpoint', endpoint,
1200
+ '--description', description,
1201
+ '--price', priceAmount,
1202
+ '--rpc', `http://localhost:${cfg.rpcPort}`,
1203
+ ], {
1204
+ encoding: 'utf8',
1205
+ timeout: 30_000,
1206
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
1207
+ })
1208
+ ctx.respond?.(true, { ok: true, raw: output.trim() })
1209
+ } catch (e: unknown) {
1210
+ ctx.respond?.(false, { error: (e as Error).message })
1211
+ }
1212
+ })
1213
+
1214
+ api.registerGatewayMethod?.('clawnetwork.service-search', ctx => {
1215
+ const serviceType = ctx.params?.serviceType as string | undefined
1216
+ const params = serviceType ? [serviceType] : []
1217
+ rpcCall(cfg.rpcPort, 'claw_getServices', params)
1218
+ .then(result => ctx.respond?.(true, { services: result }))
1219
+ .catch(err => ctx.respond?.(false, { error: (err as Error).message }))
1220
+ })
1221
+
1222
+ // ── CLI Commands ──
1223
+
1224
+ api.registerCli?.(({ program }) => {
1225
+ function cmd(parent: CliCommandChain | CliProgram, name: string) {
1226
+ return parent.command(name).allowExcessArguments(true)
1227
+ }
1228
+
1229
+ function out(data: unknown) {
1230
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n')
1231
+ }
1232
+
1233
+ const handleStatus = async () => {
1234
+ const health = await checkHealth(cfg.rpcPort)
1235
+ lastHealth = health
1236
+ const status = buildStatus(cfg)
1237
+ // Enrich with balance
1238
+ let balance = ''
1239
+ const wallet = loadWallet()
1240
+ if (wallet?.address && status.running) {
1241
+ try {
1242
+ const raw = await rpcCall(cfg.rpcPort, 'claw_getBalance', [wallet.address])
1243
+ balance = formatClaw(String(raw as string))
1244
+ } catch { /* ok */ }
1245
+ }
1246
+ const dashboard = getDashboardUrl()
1247
+ out({ ...status, balance: balance || undefined, dashboard: dashboard || undefined })
1248
+ }
1249
+
1250
+ const handleStart = async () => {
1251
+ // Check if already running (in-memory or detached via PID file)
1252
+ const state = isNodeRunning()
1253
+ if (state.running) {
1254
+ out({ message: 'Node already running', pid: state.pid })
1255
+ return
1256
+ }
1257
+ let binary = findBinary()
1258
+ if (!binary) {
1259
+ if (cfg.autoDownload) {
1260
+ process.stdout.write('Downloading claw-node...\n')
1261
+ binary = await downloadBinary(api)
1262
+ } else {
1263
+ out({ error: 'claw-node not found. Run: curl -sSf https://raw.githubusercontent.com/clawlabz/claw-network/main/claw-node/scripts/install.sh | bash' })
1264
+ return
1265
+ }
1266
+ }
1267
+ initNode(binary, cfg.network, api)
1268
+ startNodeProcess(binary, cfg, api)
1269
+ out({ message: 'Node started', pid: nodeProcess?.pid, network: cfg.network, rpc: `http://localhost:${cfg.rpcPort}` })
1270
+ }
1271
+
1272
+ const handleStop = () => {
1273
+ stopNode(api)
1274
+ out({ message: 'Node stopped' })
1275
+ }
1276
+
1277
+ const handleWallet = async () => {
1278
+ const wallet = ensureWallet(cfg.network, api)
1279
+ let balance = 'unknown (node offline)'
1280
+ try {
1281
+ const raw = await rpcCall(cfg.rpcPort, 'claw_getBalance', [wallet.address])
1282
+ balance = formatClaw(String(raw as string))
1283
+ } catch { /* node might be down */ }
1284
+ out({ address: wallet.address, balance, network: wallet.network, createdAt: wallet.createdAt })
1285
+ }
1286
+
1287
+ const handleWalletImport = (privateKeyHex?: string) => {
1288
+ if (typeof privateKeyHex !== 'string' || !privateKeyHex) {
1289
+ process.stdout.write('Usage: openclaw clawnetwork wallet import <private-key-hex>\n')
1290
+ return
1291
+ }
1292
+ if (!isValidPrivateKey(privateKeyHex)) {
1293
+ out({ error: 'Invalid private key: must be 64 hex characters' })
1294
+ return
1295
+ }
1296
+ const wallet: WalletData = {
1297
+ address: '',
1298
+ secretKey: privateKeyHex,
1299
+ createdAt: new Date().toISOString(),
1300
+ network: cfg.network,
1301
+ }
1302
+ const binary = findBinary()
1303
+ if (binary) {
1304
+ try {
1305
+ execFileSync(binary, ['key', 'import', privateKeyHex], {
1306
+ encoding: 'utf8',
1307
+ timeout: 10_000,
1308
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
1309
+ })
1310
+ const showOut = execFileSync(binary, ['key', 'show'], {
1311
+ encoding: 'utf8',
1312
+ timeout: 5000,
1313
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
1314
+ })
1315
+ const match = showOut.match(/[0-9a-f]{64}/i)
1316
+ if (match) wallet.address = match[0]
1317
+ } catch { /* ok */ }
1318
+ }
1319
+ saveWallet(wallet)
1320
+ out({ message: 'Wallet imported', address: wallet.address || '(address resolved on node start)' })
1321
+ }
1322
+
1323
+ const handleWalletExport = () => {
1324
+ const wallet = loadWallet()
1325
+ if (!wallet) { out({ error: 'No wallet found. Run: openclaw clawnetwork wallet' }); return }
1326
+ out({ address: wallet.address, secretKey: wallet.secretKey, _warning: 'NEVER share your secret key with anyone' })
1327
+ }
1328
+
1329
+ const handleFaucet = async () => {
1330
+ const wallet = ensureWallet(cfg.network, api)
1331
+ if (!wallet.address) { out({ error: 'No wallet address' }); return }
1332
+ try {
1333
+ const result = await rpcCall(cfg.rpcPort, 'claw_faucet', [wallet.address])
1334
+ out(result)
1335
+ } catch (e: unknown) {
1336
+ out({ error: (e as Error).message, hint: 'Faucet is only available on testnet/devnet' })
1337
+ }
1338
+ }
1339
+
1340
+ const handleTransfer = async (to?: string, amount?: string) => {
1341
+ if (typeof to !== 'string' || typeof amount !== 'string') {
1342
+ process.stdout.write('Usage: openclaw clawnetwork transfer <to-address> <amount>\n')
1343
+ return
1344
+ }
1345
+ if (!isValidAddress(to)) { out({ error: 'Invalid address: must be 64 hex characters' }); return }
1346
+ if (!isValidAmount(amount)) { out({ error: 'Invalid amount: must be a positive number' }); return }
1347
+ const binary = findBinary()
1348
+ if (!binary) { out({ error: 'claw-node binary not found' }); return }
1349
+ try {
1350
+ const output = execFileSync(binary, [
1351
+ 'transfer', to, amount, '--rpc', `http://localhost:${cfg.rpcPort}`,
1352
+ ], {
1353
+ encoding: 'utf8',
1354
+ timeout: 30_000,
1355
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
1356
+ })
1357
+ out({ ok: true, to, amount, raw: output.trim() })
1358
+ } catch (e: unknown) {
1359
+ out({ error: (e as Error).message })
1360
+ }
1361
+ }
1362
+
1363
+ const handleStake = async (amount?: string) => {
1364
+ if (typeof amount !== 'string') {
1365
+ process.stdout.write('Usage: openclaw clawnetwork stake <amount>\n')
1366
+ return
1367
+ }
1368
+ if (!isValidAmount(amount)) { out({ error: 'Invalid amount' }); return }
1369
+ const binary = findBinary()
1370
+ if (!binary) { out({ error: 'claw-node binary not found' }); return }
1371
+ try {
1372
+ const output = execFileSync(binary, [
1373
+ 'stake', 'deposit', amount, '--rpc', `http://localhost:${cfg.rpcPort}`,
1374
+ ], {
1375
+ encoding: 'utf8',
1376
+ timeout: 30_000,
1377
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
1378
+ })
1379
+ out({ ok: true, amount, raw: output.trim() })
1380
+ } catch (e: unknown) {
1381
+ out({ error: (e as Error).message })
1382
+ }
1383
+ }
1384
+
1385
+ const handleServiceRegister = async (serviceType?: string, endpoint?: string) => {
1386
+ if (typeof serviceType !== 'string' || typeof endpoint !== 'string') {
1387
+ process.stdout.write('Usage: openclaw clawnetwork service register <type> <endpoint>\n')
1388
+ return
1389
+ }
1390
+ const binary = findBinary()
1391
+ if (!binary) { out({ error: 'claw-node binary not found' }); return }
1392
+ try {
1393
+ const output = execFileSync(binary, [
1394
+ 'service', 'register', '--type', serviceType, '--endpoint', endpoint,
1395
+ '--rpc', `http://localhost:${cfg.rpcPort}`,
1396
+ ], {
1397
+ encoding: 'utf8',
1398
+ timeout: 30_000,
1399
+ env: { HOME: os.homedir(), PATH: process.env.PATH || '' },
1400
+ })
1401
+ out({ ok: true, serviceType, endpoint, raw: output.trim() })
1402
+ } catch (e: unknown) {
1403
+ out({ error: (e as Error).message })
1404
+ }
1405
+ }
1406
+
1407
+ const handleServiceSearch = async (serviceType?: string) => {
1408
+ try {
1409
+ const params = typeof serviceType === 'string' ? [serviceType] : []
1410
+ const result = await rpcCall(cfg.rpcPort, 'claw_getServices', params)
1411
+ out({ services: result })
1412
+ } catch (e: unknown) {
1413
+ out({ error: (e as Error).message })
1414
+ }
1415
+ }
1416
+
1417
+ const handleLogs = () => {
1418
+ if (!fs.existsSync(LOG_PATH)) { process.stdout.write('No logs yet\n'); return }
1419
+ const content = fs.readFileSync(LOG_PATH, 'utf8')
1420
+ const lines = content.split('\n')
1421
+ process.stdout.write(lines.slice(-80).join('\n') + '\n')
1422
+ }
1423
+
1424
+ const handleConfig = () => { out(cfg) }
1425
+
1426
+ const handleUi = async () => {
1427
+ const existing = getDashboardUrl()
1428
+ if (existing) {
1429
+ process.stdout.write(`Dashboard already running: ${existing}\n`)
1430
+ const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
1431
+ try { execFileSync(open, [existing], { timeout: 5000 }) } catch { /* ok */ }
1432
+ return
1433
+ }
1434
+ const url = startUiServer(cfg, api)
1435
+ if (url) {
1436
+ process.stdout.write(`Dashboard: ${url}\n`)
1437
+ const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
1438
+ try { execFileSync(open, [url], { timeout: 5000 }) } catch { /* ok */ }
1439
+ } else {
1440
+ out({ error: 'Failed to start dashboard' })
1441
+ }
1442
+ }
1443
+
1444
+ // Space format: `openclaw clawnetwork <sub>`
1445
+ const group = cmd(program, 'clawnetwork').description('ClawNetwork blockchain node management')
1446
+ cmd(group, 'status').description('Show node status').action(handleStatus)
1447
+ cmd(group, 'start').description('Start the blockchain node').action(handleStart)
1448
+ cmd(group, 'stop').description('Stop the blockchain node').action(handleStop)
1449
+ cmd(group, 'faucet').description('Get testnet CLAW from faucet').action(handleFaucet)
1450
+ cmd(group, 'transfer').description('Transfer CLAW').argument('<to>', 'Recipient address').argument('<amount>', 'Amount in CLAW').action(handleTransfer)
1451
+ cmd(group, 'stake').description('Stake CLAW').argument('<amount>', 'Amount to stake').action(handleStake)
1452
+ cmd(group, 'logs').description('Show recent node logs').action(handleLogs)
1453
+ cmd(group, 'config').description('Show current config').action(handleConfig)
1454
+ cmd(group, 'ui').description('Open visual dashboard').action(handleUi)
1455
+
1456
+ // Wallet subcommands
1457
+ const walletGroup = cmd(group, 'wallet').description('Wallet management')
1458
+ cmd(walletGroup, 'show').description('Show wallet address and balance').action(handleWallet)
1459
+ cmd(walletGroup, 'import').description('Import wallet from private key').argument('<key>', 'Private key hex').action(handleWalletImport)
1460
+ cmd(walletGroup, 'export').description('Export wallet private key').action(handleWalletExport)
1461
+
1462
+ // Service subcommands
1463
+ const serviceGroup = cmd(group, 'service').description('Service discovery')
1464
+ cmd(serviceGroup, 'register').description('Register a service').argument('<type>', 'Service type').argument('<endpoint>', 'Endpoint URL').action(handleServiceRegister)
1465
+ cmd(serviceGroup, 'search').description('Search services').argument('[type]', 'Filter by type').action(handleServiceSearch)
1466
+
1467
+ // Colon format: `openclaw clawnetwork:status`
1468
+ for (const prefix of ['clawnetwork']) {
1469
+ cmd(program, `${prefix}:status`).description('Show node status').action(handleStatus)
1470
+ cmd(program, `${prefix}:start`).description('Start the blockchain node').action(handleStart)
1471
+ cmd(program, `${prefix}:stop`).description('Stop the blockchain node').action(handleStop)
1472
+ cmd(program, `${prefix}:wallet`).description('Show wallet').action(handleWallet)
1473
+ cmd(program, `${prefix}:wallet:import`).description('Import wallet').argument('<key>', 'Private key hex').action(handleWalletImport)
1474
+ cmd(program, `${prefix}:wallet:export`).description('Export wallet').action(handleWalletExport)
1475
+ cmd(program, `${prefix}:faucet`).description('Get testnet CLAW').action(handleFaucet)
1476
+ cmd(program, `${prefix}:transfer`).description('Transfer CLAW').argument('<to>', 'Recipient').argument('<amount>', 'Amount').action(handleTransfer)
1477
+ cmd(program, `${prefix}:stake`).description('Stake CLAW').argument('<amount>', 'Amount').action(handleStake)
1478
+ cmd(program, `${prefix}:logs`).description('Show recent node logs').action(handleLogs)
1479
+ cmd(program, `${prefix}:config`).description('Show current config').action(handleConfig)
1480
+ cmd(program, `${prefix}:ui`).description('Open dashboard').action(handleUi)
1481
+ cmd(program, `${prefix}:service:register`).description('Register service').argument('<type>', 'Type').argument('<endpoint>', 'URL').action(handleServiceRegister)
1482
+ cmd(program, `${prefix}:service:search`).description('Search services').argument('[type]', 'Type filter').action(handleServiceSearch)
1483
+ }
1484
+
1485
+ }, { commands: CLI_COMMANDS })
1486
+
1487
+ // ── Service Lifecycle ──
1488
+
1489
+ api.registerService?.({
1490
+ id: 'clawnetwork-node',
1491
+ start: () => {
1492
+ api.logger?.info?.(`[clawnetwork] plugin v${VERSION} loaded, network=${cfg.network}, autoStart=${cfg.autoStart}`)
1493
+
1494
+ if (!cfg.autoStart) return
1495
+
1496
+ ;(async () => {
1497
+ try {
1498
+ // Check if already running (e.g. from a previous detached start)
1499
+ const state = isNodeRunning()
1500
+ if (state.running) {
1501
+ api.logger?.info?.(`[clawnetwork] node already running (pid=${state.pid}), skipping auto-start`)
1502
+ startHealthCheck(cfg, api)
1503
+ return
1504
+ }
1505
+
1506
+ // Step 1: Ensure binary
1507
+ let binary = findBinary()
1508
+ if (!binary) {
1509
+ if (cfg.autoDownload) {
1510
+ api.logger?.info?.('[clawnetwork] claw-node not found, downloading...')
1511
+ binary = await downloadBinary(api)
1512
+ } else {
1513
+ api.logger?.error?.('[clawnetwork] claw-node not found and autoDownload is disabled')
1514
+ return
1515
+ }
1516
+ }
1517
+
1518
+ // Step 2: Init
1519
+ initNode(binary, cfg.network, api)
1520
+
1521
+ // Step 3: Wallet
1522
+ const wallet = ensureWallet(cfg.network, api)
1523
+
1524
+ // Step 4: Start node
1525
+ startNodeProcess(binary, cfg, api)
1526
+
1527
+ // Step 5: Start UI dashboard
1528
+ startUiServer(cfg, api)
1529
+
1530
+ // Step 6: Auto-register agent (wait for node to sync)
1531
+ await sleep(15_000)
1532
+ await autoRegisterAgent(cfg, wallet, api)
1533
+
1534
+ } catch (err: unknown) {
1535
+ api.logger?.error?.(`[clawnetwork] startup failed: ${(err as Error).message}`)
1536
+ }
1537
+ })()
1538
+ },
1539
+ stop: () => {
1540
+ api.logger?.info?.('[clawnetwork] shutting down...')
1541
+ stopNode(api)
1542
+ stopUiServer()
1543
+ },
1544
+ })
1545
+ }