@agentsquared/cli 1.0.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.
@@ -0,0 +1,883 @@
1
+ #!/usr/bin/env node
2
+
3
+ import http from 'node:http'
4
+ import path from 'node:path'
5
+ import { URL } from 'node:url'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ import { parseArgs, parseList, requireArg } from '../shared/primitives.mjs'
9
+ import { getBindingDocument, getFriendDirectory } from '../transport/relay_http.mjs'
10
+ import { loadRuntimeKeyBundle } from '../runtime/keys.mjs'
11
+ import { DEFAULT_LISTEN_ADDRS, buildRelayListenAddrs, createNode, directListenAddrs, relayReservationAddrs } from '../transport/libp2p.mjs'
12
+ import { attachInboundRouter, currentTransport, openDirectPeerSession, publishGatewayPresence } from '../transport/peer_session.mjs'
13
+ import { assertGatewayStateFresh, currentRuntimeRevision, defaultGatewayStateFile, readGatewayState, writeGatewayState } from './state.mjs'
14
+ import { createGatewayRuntimeState } from './runtime_state.mjs'
15
+ import { createAgentRouter, DEFAULT_ROUTER_DEFAULT_SKILL, DEFAULT_ROUTER_SKILLS } from '../routing/agent_router.mjs'
16
+ import { createLocalRuntimeExecutor, createOwnerNotifier } from '../runtime/executor.mjs'
17
+ import { createInboxStore } from './inbox.mjs'
18
+ import { createLiveConversationStore } from '../conversation/store.mjs'
19
+ import { defaultInboxDir, defaultOpenClawStateDir, defaultPeerKeyFile as defaultPeerKeyFileFromLayout } from '../shared/paths.mjs'
20
+ import { detectHostRuntimeEnvironment } from '../../adapters/index.mjs'
21
+ import { resolveOpenClawAgentSelection } from '../../adapters/openclaw/detect.mjs'
22
+ import { buildStandardRuntimeOwnerLines, buildStandardRuntimeReport, currentRuntimeMetadata } from '../runtime/report.mjs'
23
+
24
+ const __filename = fileURLToPath(import.meta.url)
25
+
26
+ const DEFAULT_GATEWAY_HOST = '127.0.0.1'
27
+ const DEFAULT_GATEWAY_PORT = 0
28
+ const DEFAULT_PRESENCE_REFRESH_MS = 30 * 60 * 1000
29
+ const DEFAULT_HEALTH_CHECK_MS = 15 * 1000
30
+ const DEFAULT_RELAY_CONTROL_CHECK_MS = 5 * 60 * 1000
31
+ const DEFAULT_TRANSPORT_CHECK_TIMEOUT_MS = 1500
32
+ const DEFAULT_RECOVERY_IDLE_WAIT_MS = 3000
33
+ const DEFAULT_FAILURES_BEFORE_RECOVER = 2
34
+ const DEFAULT_ROUTER_WAIT_MS = 30000
35
+
36
+ function clean(value) {
37
+ return `${value ?? ''}`.trim()
38
+ }
39
+
40
+ function nowISO() {
41
+ return new Date().toISOString()
42
+ }
43
+
44
+ function sleep(ms) {
45
+ return new Promise((resolve) => setTimeout(resolve, ms))
46
+ }
47
+
48
+ function toOwnerFacingText(lines = []) {
49
+ return lines.filter(Boolean).join('\n')
50
+ }
51
+
52
+ function jsonResponse(res, status, payload) {
53
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
54
+ res.end(JSON.stringify(payload))
55
+ }
56
+
57
+ async function readJson(req) {
58
+ const chunks = []
59
+ for await (const chunk of req) {
60
+ chunks.push(Buffer.from(chunk))
61
+ }
62
+ const raw = Buffer.concat(chunks).toString('utf8').trim()
63
+ return raw ? JSON.parse(raw) : {}
64
+ }
65
+
66
+ function defaultPeerKeyFile(keyFile, agentId) {
67
+ return defaultPeerKeyFileFromLayout(keyFile, agentId)
68
+ }
69
+
70
+ export async function runGateway(argv) {
71
+ const args = parseArgs(argv)
72
+ const apiBase = (args['api-base'] ?? 'https://api.agentsquared.net').trim()
73
+ const agentId = requireArg(args['agent-id'], '--agent-id is required')
74
+ const keyFile = requireArg(args['key-file'], '--key-file is required')
75
+ const gatewayHost = (args['gateway-host'] ?? DEFAULT_GATEWAY_HOST).trim()
76
+ const gatewayPort = Number.parseInt(args['gateway-port'] ?? `${DEFAULT_GATEWAY_PORT}`, 10)
77
+ const presenceRefreshMs = Math.max(0, Number.parseInt(args['presence-refresh-ms'] ?? `${DEFAULT_PRESENCE_REFRESH_MS}`, 10) || DEFAULT_PRESENCE_REFRESH_MS)
78
+ const healthCheckMs = Math.max(1000, Number.parseInt(args['health-check-ms'] ?? `${DEFAULT_HEALTH_CHECK_MS}`, 10) || DEFAULT_HEALTH_CHECK_MS)
79
+ const relayControlCheckMs = Math.max(1000, Number.parseInt(args['relay-control-check-ms'] ?? `${DEFAULT_RELAY_CONTROL_CHECK_MS}`, 10) || DEFAULT_RELAY_CONTROL_CHECK_MS)
80
+ const transportCheckTimeoutMs = Math.max(250, Number.parseInt(args['transport-check-timeout-ms'] ?? `${DEFAULT_TRANSPORT_CHECK_TIMEOUT_MS}`, 10) || DEFAULT_TRANSPORT_CHECK_TIMEOUT_MS)
81
+ const recoveryIdleWaitMs = Math.max(0, Number.parseInt(args['recovery-idle-wait-ms'] ?? `${DEFAULT_RECOVERY_IDLE_WAIT_MS}`, 10) || DEFAULT_RECOVERY_IDLE_WAIT_MS)
82
+ const failuresBeforeRecover = Math.max(1, Number.parseInt(args['failures-before-recover'] ?? `${DEFAULT_FAILURES_BEFORE_RECOVER}`, 10) || DEFAULT_FAILURES_BEFORE_RECOVER)
83
+ const routerMode = `${args['router-mode'] ?? 'integrated'}`.trim().toLowerCase() === 'external' ? 'external' : 'integrated'
84
+ const routerWaitMs = Math.max(0, Number.parseInt(args['wait-ms'] ?? `${DEFAULT_ROUTER_WAIT_MS}`, 10) || DEFAULT_ROUTER_WAIT_MS)
85
+ const maxActiveMailboxes = Math.max(1, Number.parseInt(args['max-active-mailboxes'] ?? '8', 10) || 8)
86
+ const routerSkills = parseList(args['router-skills'] ?? args['allowed-skills'], DEFAULT_ROUTER_SKILLS)
87
+ const defaultSkill = (args['default-skill'] ?? args['fallback-skill'] ?? DEFAULT_ROUTER_DEFAULT_SKILL).trim() || DEFAULT_ROUTER_DEFAULT_SKILL
88
+ const hostRuntime = `${args['host-runtime'] ?? 'auto'}`.trim().toLowerCase() || 'auto'
89
+ const openclawAgent = `${args['openclaw-agent'] ?? process.env.OPENCLAW_AGENT ?? ''}`.trim()
90
+ const openclawCommand = `${args['openclaw-command'] ?? process.env.OPENCLAW_COMMAND ?? 'openclaw'}`.trim() || 'openclaw'
91
+ const openclawCwd = `${args['openclaw-cwd'] ?? process.env.OPENCLAW_CWD ?? ''}`.trim()
92
+ const openclawConfigPath = `${args['openclaw-config-path'] ?? process.env.OPENCLAW_CONFIG_PATH ?? ''}`.trim()
93
+ const openclawSessionPrefix = `${args['openclaw-session-prefix'] ?? args['openclaw-peer-target-prefix'] ?? process.env.OPENCLAW_SESSION_PREFIX ?? process.env.OPENCLAW_PEER_TARGET_PREFIX ?? 'agentsquared:'}`.trim() || 'agentsquared:'
94
+ const openclawTimeoutMs = Math.max(1000, Number.parseInt(args['openclaw-timeout-ms'] ?? `${process.env.OPENCLAW_TIMEOUT_MS ?? '180000'}`, 10) || 180000)
95
+ const openclawGatewayUrl = `${args['openclaw-gateway-url'] ?? process.env.OPENCLAW_GATEWAY_URL ?? ''}`.trim()
96
+ const openclawGatewayToken = `${args['openclaw-gateway-token'] ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? ''}`.trim()
97
+ const openclawGatewayPassword = `${args['openclaw-gateway-password'] ?? process.env.OPENCLAW_GATEWAY_PASSWORD ?? ''}`.trim()
98
+ const peerKeyFile = (args['peer-key-file'] ?? defaultPeerKeyFile(keyFile, agentId)).trim()
99
+ const gatewayStateFile = (args['gateway-state-file'] ?? defaultGatewayStateFile(keyFile, agentId)).trim()
100
+ const inboxDir = (args['inbox-dir'] ?? defaultInboxDir(keyFile, agentId)).trim()
101
+ const listenAddrs = parseList(args['listen-addrs'], DEFAULT_LISTEN_ADDRS)
102
+ const bundle = loadRuntimeKeyBundle(keyFile)
103
+ const runtimeState = createGatewayRuntimeState()
104
+ const conversationStore = createLiveConversationStore()
105
+ const inboxStore = createInboxStore({ inboxDir })
106
+ const runtimeRevision = currentRuntimeRevision()
107
+ const currentRuntime = currentRuntimeMetadata()
108
+ const previousGatewayState = readGatewayState(gatewayStateFile)
109
+ const detectedHostRuntime = await detectHostRuntimeEnvironment({
110
+ preferred: hostRuntime,
111
+ openclaw: {
112
+ command: openclawCommand,
113
+ cwd: openclawCwd,
114
+ openclawAgent,
115
+ configPath: openclawConfigPath,
116
+ gatewayUrl: openclawGatewayUrl,
117
+ gatewayToken: openclawGatewayToken,
118
+ gatewayPassword: openclawGatewayPassword
119
+ }
120
+ })
121
+ const resolvedHostRuntime = detectedHostRuntime.resolved || 'none'
122
+ if (resolvedHostRuntime !== 'openclaw') {
123
+ const detected = detectedHostRuntime.resolved || detectedHostRuntime.id || 'none'
124
+ const reason = `${detectedHostRuntime.reason ?? ''}`.trim()
125
+ throw new Error(
126
+ `AgentSquared gateway startup currently supports only the OpenClaw host runtime. Detected host runtime: ${detected}.${reason ? ` Detection reason: ${reason}.` : ''} This gateway will not start in a runtime-without-adapter mode. Finish configuring OpenClaw and then retry.`
127
+ )
128
+ }
129
+ const detectedOpenClawAgent = clean(resolveOpenClawAgentSelection(detectedHostRuntime).defaultAgentId)
130
+ const resolvedOpenClawAgent = openclawAgent || detectedOpenClawAgent
131
+ if (!resolvedOpenClawAgent) {
132
+ throw new Error('OpenClaw host runtime was detected, but no default OpenClaw agent id could be resolved. AgentSquared gateway startup stops here instead of guessing with the AgentSquared id.')
133
+ }
134
+ const runtimeMode = resolvedHostRuntime !== 'none' ? 'host' : 'none'
135
+ const ownerNotifyMode = resolvedHostRuntime !== 'none' ? 'host' : 'inbox'
136
+ const localRuntimeExecutor = createLocalRuntimeExecutor({
137
+ agentId,
138
+ mode: runtimeMode,
139
+ hostRuntime: resolvedHostRuntime,
140
+ conversationStore,
141
+ openclawStateDir: defaultOpenClawStateDir(keyFile, agentId),
142
+ openclawCommand,
143
+ openclawCwd,
144
+ openclawConfigPath,
145
+ openclawAgent: resolvedOpenClawAgent,
146
+ openclawSessionPrefix,
147
+ openclawTimeoutMs,
148
+ openclawGatewayUrl,
149
+ openclawGatewayToken,
150
+ openclawGatewayPassword
151
+ })
152
+ const ownerNotifier = createOwnerNotifier({
153
+ agentId,
154
+ mode: ownerNotifyMode,
155
+ hostRuntime: resolvedHostRuntime,
156
+ inbox: inboxStore,
157
+ openclawStateDir: defaultOpenClawStateDir(keyFile, agentId),
158
+ openclawCommand,
159
+ openclawCwd,
160
+ openclawConfigPath,
161
+ openclawAgent: resolvedOpenClawAgent,
162
+ openclawSessionPrefix,
163
+ openclawTimeoutMs,
164
+ openclawGatewayUrl,
165
+ openclawGatewayToken,
166
+ openclawGatewayPassword
167
+ })
168
+ const startupChecks = {
169
+ relay: { ok: false, error: '' },
170
+ hostRuntime: { ok: false, error: '' }
171
+ }
172
+
173
+ const binding = await getBindingDocument(apiBase)
174
+ startupChecks.relay = { ok: true, error: '' }
175
+ const relayListenAddrs = buildRelayListenAddrs(binding.relayMultiaddrs ?? [])
176
+ const requireRelayReservation = relayListenAddrs.length > 0
177
+
178
+ try {
179
+ const preflight = await localRuntimeExecutor.preflight?.()
180
+ startupChecks.hostRuntime = {
181
+ ok: Boolean(preflight?.ok ?? resolvedHostRuntime === 'none'),
182
+ error: preflight?.ok === false ? clean(preflight?.error || preflight?.reason || 'host runtime preflight failed') : ''
183
+ }
184
+ if (resolvedHostRuntime !== 'none' && startupChecks.hostRuntime.ok === false) {
185
+ throw new Error(`host runtime preflight failed: ${startupChecks.hostRuntime.error}`)
186
+ }
187
+ } catch (error) {
188
+ startupChecks.hostRuntime = {
189
+ ok: false,
190
+ error: clean(error?.message) || 'host runtime preflight failed'
191
+ }
192
+ throw error
193
+ }
194
+
195
+ let gatewayBase = `http://${gatewayHost}:${gatewayPort}`
196
+ let actualGatewayPort = gatewayPort
197
+ let currentNode = null
198
+ let online = null
199
+ let relayControl = {
200
+ ok: false,
201
+ checkedAt: '',
202
+ detail: 'Relay control-plane handshake has not completed yet.',
203
+ path: '/api/relay/friends'
204
+ }
205
+ let recoveryPromise = null
206
+ let stopping = false
207
+ let activeOperations = 0
208
+ const idleWaiters = []
209
+ const integratedRouter = routerMode === 'integrated'
210
+ ? createAgentRouter({
211
+ maxActiveMailboxes,
212
+ routerSkills,
213
+ defaultSkill,
214
+ executeInbound: localRuntimeExecutor,
215
+ notifyOwner: ownerNotifier,
216
+ onRespond(item, result) {
217
+ runtimeState.respondInbound({
218
+ inboundId: item.inboundId,
219
+ result
220
+ })
221
+ },
222
+ onReject(item, payload) {
223
+ runtimeState.rejectInbound({
224
+ inboundId: item.inboundId,
225
+ code: payload.code,
226
+ message: payload.message
227
+ })
228
+ }
229
+ })
230
+ : null
231
+ const lifecycle = {
232
+ generation: 0,
233
+ recovering: false,
234
+ lastRecoveryAt: '',
235
+ lastRecoveryReason: '',
236
+ lastHealthyAt: '',
237
+ lastError: '',
238
+ consecutiveFailures: 0,
239
+ routerMode
240
+ }
241
+
242
+ function flushIdleWaiters() {
243
+ if (activeOperations !== 0) {
244
+ return
245
+ }
246
+ while (idleWaiters.length > 0) {
247
+ const resolve = idleWaiters.shift()
248
+ resolve?.(true)
249
+ }
250
+ }
251
+
252
+ function beginOperation() {
253
+ activeOperations += 1
254
+ let done = false
255
+ return () => {
256
+ if (done) {
257
+ return
258
+ }
259
+ done = true
260
+ activeOperations = Math.max(0, activeOperations - 1)
261
+ flushIdleWaiters()
262
+ }
263
+ }
264
+
265
+ async function waitForIdle(timeoutMs) {
266
+ if (activeOperations === 0) {
267
+ return true
268
+ }
269
+ return new Promise((resolve) => {
270
+ const timer = setTimeout(() => {
271
+ const index = idleWaiters.indexOf(onIdle)
272
+ if (index >= 0) {
273
+ idleWaiters.splice(index, 1)
274
+ }
275
+ resolve(false)
276
+ }, timeoutMs)
277
+ const onIdle = () => {
278
+ clearTimeout(timer)
279
+ resolve(true)
280
+ }
281
+ idleWaiters.push(onIdle)
282
+ })
283
+ }
284
+
285
+ function markHealthy() {
286
+ lifecycle.lastHealthyAt = nowISO()
287
+ lifecycle.lastError = ''
288
+ lifecycle.consecutiveFailures = 0
289
+ }
290
+
291
+ function noteFailure(error) {
292
+ lifecycle.lastError = error?.message ?? `${error ?? 'gateway failure'}`
293
+ lifecycle.consecutiveFailures += 1
294
+ }
295
+
296
+ function buildLifecycleSnapshot() {
297
+ return {
298
+ ...lifecycle,
299
+ activeOperations,
300
+ hasNode: Boolean(currentNode),
301
+ online
302
+ }
303
+ }
304
+
305
+ function updateRelayControl(ok, detail) {
306
+ relayControl = {
307
+ ok: Boolean(ok),
308
+ checkedAt: nowISO(),
309
+ detail: `${detail ?? ''}`.trim() || (ok ? 'Relay control-plane handshake succeeded.' : 'Relay control-plane handshake failed.'),
310
+ path: '/api/relay/friends'
311
+ }
312
+ }
313
+
314
+ function relayControlIsFresh() {
315
+ if (!relayControl.checkedAt) {
316
+ return false
317
+ }
318
+ const parsed = Date.parse(relayControl.checkedAt)
319
+ if (!Number.isFinite(parsed)) {
320
+ return false
321
+ }
322
+ return (Date.now() - parsed) <= relayControlCheckMs
323
+ }
324
+
325
+ function buildRouterSnapshot() {
326
+ return integratedRouter
327
+ ? integratedRouter.snapshot()
328
+ : {
329
+ mode: 'external',
330
+ routerSkills,
331
+ defaultSkill,
332
+ runtimeMode: localRuntimeExecutor.mode,
333
+ ownerReportMode: ownerNotifier.mode,
334
+ hostRuntime: resolvedHostRuntime
335
+ }
336
+ }
337
+
338
+ function writeStateForNode(node) {
339
+ if (!gatewayBase || !node?.peerId?.toString?.()) {
340
+ return
341
+ }
342
+ writeGatewayState(gatewayStateFile, {
343
+ agentId,
344
+ gatewayBase,
345
+ gatewayPid: process.pid,
346
+ gatewayHost,
347
+ gatewayPort: actualGatewayPort,
348
+ keyFile: path.resolve(keyFile),
349
+ peerKeyFile: path.resolve(peerKeyFile),
350
+ peerId: node.peerId.toString(),
351
+ runtimePackageVersion: currentRuntime.packageVersion,
352
+ runtimeGitCommit: currentRuntime.gitCommit,
353
+ runtimeRepoUrl: currentRuntime.repoUrl,
354
+ skillsPackageVersion: currentRuntime.packageVersion,
355
+ skillsGitCommit: currentRuntime.gitCommit,
356
+ skillsRepoUrl: currentRuntime.repoUrl,
357
+ runtimeRevision,
358
+ runtimeStartedAt: nowISO(),
359
+ updatedAt: nowISO()
360
+ })
361
+ }
362
+
363
+ async function stopNode(node) {
364
+ if (!node) {
365
+ return
366
+ }
367
+ try {
368
+ await node.stop()
369
+ } catch (error) {
370
+ console.error(`gateway node stop failed: ${error.message}`)
371
+ }
372
+ }
373
+
374
+ async function createAttachedNode() {
375
+ const node = await createNode({
376
+ listenAddrs,
377
+ relayListenAddrs,
378
+ peerKeyFile
379
+ })
380
+ await attachInboundRouter({
381
+ apiBase,
382
+ agentId,
383
+ bundle,
384
+ node,
385
+ binding,
386
+ sessionStore: runtimeState
387
+ })
388
+ return node
389
+ }
390
+
391
+ async function verifyTransport(node, timeoutMs = transportCheckTimeoutMs) {
392
+ const transport = await currentTransport(node, binding, {
393
+ requireRelayReservation,
394
+ timeoutMs
395
+ })
396
+ markHealthy()
397
+ return transport
398
+ }
399
+
400
+ async function verifyRelayControlPlane(node, { force = false } = {}) {
401
+ if (!force && relayControl.ok && relayControlIsFresh()) {
402
+ return relayControl
403
+ }
404
+ const transport = await verifyTransport(node)
405
+ try {
406
+ await getFriendDirectory(apiBase, agentId, bundle, transport)
407
+ updateRelayControl(true, 'Signed relay MCP handshake succeeded and relay accepted the current transport.')
408
+ markHealthy()
409
+ return relayControl
410
+ } catch (error) {
411
+ updateRelayControl(false, error?.message ?? 'Relay control-plane handshake failed.')
412
+ throw error
413
+ }
414
+ }
415
+
416
+ async function publishPresence(node, activitySummary = 'Gateway listener ready for trusted direct sessions.') {
417
+ const value = await publishGatewayPresence(
418
+ apiBase,
419
+ agentId,
420
+ bundle,
421
+ node,
422
+ binding,
423
+ activitySummary,
424
+ { requireRelayReservation }
425
+ )
426
+ online = value
427
+ updateRelayControl(true, 'Signed relay presence publish succeeded.')
428
+ markHealthy()
429
+ return value
430
+ }
431
+
432
+ async function recoverGateway(reason) {
433
+ if (stopping) {
434
+ throw new Error('gateway is stopping')
435
+ }
436
+ if (recoveryPromise) {
437
+ return recoveryPromise
438
+ }
439
+
440
+ recoveryPromise = (async () => {
441
+ lifecycle.recovering = true
442
+ lifecycle.lastRecoveryAt = nowISO()
443
+ lifecycle.lastRecoveryReason = `${reason}`.trim() || 'gateway recovery'
444
+
445
+ const previousNode = currentNode
446
+ currentNode = null
447
+ online = null
448
+
449
+ await waitForIdle(recoveryIdleWaitMs)
450
+ runtimeState.reset({
451
+ reason: `gateway reconnect in progress: ${lifecycle.lastRecoveryReason}`,
452
+ preserveTrustedSessions: true
453
+ })
454
+ await stopNode(previousNode)
455
+
456
+ let nextNode = null
457
+ try {
458
+ nextNode = await createAttachedNode()
459
+ await publishPresence(nextNode)
460
+ await verifyRelayControlPlane(nextNode, { force: true })
461
+ currentNode = nextNode
462
+ lifecycle.generation += 1
463
+ lifecycle.recovering = false
464
+ writeStateForNode(nextNode)
465
+ const startupHealth = {
466
+ agentId,
467
+ gatewayBase,
468
+ runtimeRevision,
469
+ hostRuntime: detectedHostRuntime,
470
+ startupChecks,
471
+ relayControl,
472
+ peerId: nextNode.peerId.toString(),
473
+ listenAddrs: directListenAddrs(nextNode),
474
+ relayAddrs: relayReservationAddrs(nextNode),
475
+ streamProtocol: binding.streamProtocol,
476
+ supportedBindings: binding.supportedBindings ?? [],
477
+ routerMode,
478
+ agentRouter: buildRouterSnapshot(),
479
+ lifecycle: buildLifecycleSnapshot(),
480
+ runtimeState: runtimeState.snapshot(),
481
+ conversations: conversationStore.snapshot(),
482
+ inbox: inboxStore.snapshot()
483
+ }
484
+ const standardReport = buildStandardRuntimeReport({
485
+ apiBase,
486
+ agentId,
487
+ keyFile,
488
+ detectedHostRuntime,
489
+ gateway: {
490
+ started: true,
491
+ gatewayBase
492
+ },
493
+ gatewayHealth: startupHealth,
494
+ previousState: previousGatewayState
495
+ })
496
+ const ownerFacingLines = buildStandardRuntimeOwnerLines(standardReport)
497
+ console.log(JSON.stringify({
498
+ event: lifecycle.generation === 1 ? 'gateway-started' : 'gateway-recovered',
499
+ agentId,
500
+ gatewayBase,
501
+ gatewayStateFile,
502
+ peerId: nextNode.peerId.toString(),
503
+ listenAddrs: directListenAddrs(nextNode),
504
+ relayAddrs: relayReservationAddrs(nextNode),
505
+ streamProtocol: binding.streamProtocol,
506
+ peerKeyFile,
507
+ routerMode,
508
+ agentRouter: buildRouterSnapshot(),
509
+ lifecycle: buildLifecycleSnapshot(),
510
+ runtimeState: runtimeState.snapshot(),
511
+ conversations: conversationStore.snapshot(),
512
+ standardReport,
513
+ ownerFacingLines,
514
+ ownerFacingText: toOwnerFacingText(ownerFacingLines)
515
+ }, null, 2))
516
+ return nextNode
517
+ } catch (error) {
518
+ lifecycle.lastError = error.message
519
+ if (nextNode) {
520
+ await stopNode(nextNode)
521
+ }
522
+ throw error
523
+ } finally {
524
+ lifecycle.recovering = false
525
+ recoveryPromise = null
526
+ }
527
+ })()
528
+
529
+ return recoveryPromise
530
+ }
531
+
532
+ async function ensureGatewayReady(reason) {
533
+ if (recoveryPromise) {
534
+ await recoveryPromise
535
+ }
536
+ if (!currentNode) {
537
+ await recoverGateway(reason)
538
+ }
539
+ if (!currentNode) {
540
+ throw new Error('gateway transport is unavailable')
541
+ }
542
+ try {
543
+ await verifyTransport(currentNode)
544
+ await verifyRelayControlPlane(currentNode)
545
+ return currentNode
546
+ } catch (error) {
547
+ noteFailure(error)
548
+ await recoverGateway(`${reason}: ${error.message}`)
549
+ if (!currentNode) {
550
+ throw new Error(`gateway transport is unavailable: ${error.message}`)
551
+ }
552
+ await verifyTransport(currentNode)
553
+ await verifyRelayControlPlane(currentNode, { force: true })
554
+ return currentNode
555
+ }
556
+ }
557
+
558
+ async function runHealthCheck(source) {
559
+ if (recoveryPromise || stopping) {
560
+ return
561
+ }
562
+ if (!currentNode) {
563
+ try {
564
+ await recoverGateway(`${source}: no active gateway node`)
565
+ } catch (error) {
566
+ noteFailure(error)
567
+ console.error(`${source} recovery failed: ${error.message}`)
568
+ }
569
+ return
570
+ }
571
+ try {
572
+ await verifyTransport(currentNode)
573
+ await verifyRelayControlPlane(currentNode)
574
+ } catch (error) {
575
+ noteFailure(error)
576
+ console.error(`${source} failed: ${error.message}`)
577
+ if (lifecycle.consecutiveFailures >= failuresBeforeRecover) {
578
+ try {
579
+ await recoverGateway(`${source}: ${error.message}`)
580
+ } catch (recoveryError) {
581
+ noteFailure(recoveryError)
582
+ console.error(`${source} recovery failed: ${recoveryError.message}`)
583
+ }
584
+ }
585
+ }
586
+ }
587
+
588
+ async function runIntegratedRouterLoop() {
589
+ if (!integratedRouter) {
590
+ return
591
+ }
592
+ while (!stopping) {
593
+ const item = await runtimeState.nextInbound({ waitMs: routerWaitMs })
594
+ if (!item?.inboundId) {
595
+ continue
596
+ }
597
+ integratedRouter.enqueue(item).catch((error) => {
598
+ try {
599
+ runtimeState.rejectInbound({
600
+ inboundId: item.inboundId,
601
+ code: Number.parseInt(`${error?.code ?? 500}`, 10) || 500,
602
+ message: error?.message ?? 'integrated agent router failed to process inbound request'
603
+ })
604
+ } catch (rejectError) {
605
+ console.error(rejectError.message)
606
+ }
607
+ console.error(error?.message ?? 'integrated agent router failed to process inbound request')
608
+ })
609
+ }
610
+ }
611
+
612
+ const server = http.createServer(async (req, res) => {
613
+ try {
614
+ const url = new URL(req.url ?? '/', gatewayBase)
615
+ if (req.method === 'GET' && url.pathname === '/health') {
616
+ const state = readGatewayState(gatewayStateFile)
617
+ let revisionStatus
618
+ try {
619
+ revisionStatus = assertGatewayStateFresh(state, gatewayStateFile)
620
+ } catch (error) {
621
+ revisionStatus = {
622
+ stale: true,
623
+ expectedRevision: runtimeRevision,
624
+ currentRevision: `${state?.runtimeRevision ?? ''}`.trim(),
625
+ message: error.message
626
+ }
627
+ }
628
+ if (recoveryPromise || !currentNode) {
629
+ jsonResponse(res, 503, {
630
+ agentId,
631
+ gatewayBase,
632
+ gatewayStateFile,
633
+ runtimeRevision,
634
+ revisionStatus,
635
+ hostRuntime: detectedHostRuntime,
636
+ startupChecks,
637
+ relayControl,
638
+ routerMode,
639
+ agentRouter: buildRouterSnapshot(),
640
+ lifecycle: buildLifecycleSnapshot(),
641
+ runtimeState: runtimeState.snapshot(),
642
+ conversations: conversationStore.snapshot(),
643
+ inbox: inboxStore.snapshot()
644
+ })
645
+ return
646
+ }
647
+ try {
648
+ const transport = await verifyTransport(currentNode)
649
+ await verifyRelayControlPlane(currentNode, { force: true })
650
+ jsonResponse(res, 200, {
651
+ agentId,
652
+ gatewayBase,
653
+ gatewayStateFile,
654
+ runtimeRevision,
655
+ revisionStatus,
656
+ hostRuntime: detectedHostRuntime,
657
+ startupChecks,
658
+ relayControl,
659
+ peerId: transport.peerId,
660
+ listenAddrs: transport.listenAddrs,
661
+ relayAddrs: transport.relayAddrs,
662
+ directListenAddrs: directListenAddrs(currentNode),
663
+ relayReservationAddrs: relayReservationAddrs(currentNode),
664
+ streamProtocol: transport.streamProtocol,
665
+ supportedBindings: transport.supportedBindings,
666
+ routerMode,
667
+ agentRouter: buildRouterSnapshot(),
668
+ lifecycle: buildLifecycleSnapshot(),
669
+ runtimeState: runtimeState.snapshot(),
670
+ conversations: conversationStore.snapshot(),
671
+ inbox: inboxStore.snapshot()
672
+ })
673
+ return
674
+ } catch (error) {
675
+ noteFailure(error)
676
+ jsonResponse(res, 503, {
677
+ agentId,
678
+ gatewayBase,
679
+ gatewayStateFile,
680
+ runtimeRevision,
681
+ revisionStatus,
682
+ hostRuntime: detectedHostRuntime,
683
+ startupChecks,
684
+ relayControl,
685
+ routerMode,
686
+ error: { message: error.message },
687
+ agentRouter: buildRouterSnapshot(),
688
+ lifecycle: buildLifecycleSnapshot(),
689
+ runtimeState: runtimeState.snapshot(),
690
+ conversations: conversationStore.snapshot(),
691
+ inbox: inboxStore.snapshot()
692
+ })
693
+ return
694
+ }
695
+ }
696
+
697
+ if (req.method === 'GET' && url.pathname === '/inbox/index') {
698
+ jsonResponse(res, 200, {
699
+ index: inboxStore.readIndex(),
700
+ snapshot: inboxStore.snapshot()
701
+ })
702
+ return
703
+ }
704
+
705
+ if (req.method === 'GET' && url.pathname === '/inbound/next') {
706
+ if (integratedRouter) {
707
+ jsonResponse(res, 409, { error: { message: 'integrated agent router is active; external inbound polling is disabled' } })
708
+ return
709
+ }
710
+ const waitMs = Number.parseInt(url.searchParams.get('waitMs') ?? '30000', 10)
711
+ const nextInbound = await runtimeState.nextInbound({ waitMs })
712
+ jsonResponse(res, 200, { item: nextInbound })
713
+ return
714
+ }
715
+
716
+ if (req.method === 'POST' && url.pathname === '/inbound/respond') {
717
+ if (integratedRouter) {
718
+ jsonResponse(res, 409, { error: { message: 'integrated agent router is active; external inbound responses are disabled' } })
719
+ return
720
+ }
721
+ const body = await readJson(req)
722
+ runtimeState.respondInbound({
723
+ inboundId: requireArg(body.inboundId, 'inboundId is required'),
724
+ result: body.result ?? {}
725
+ })
726
+ jsonResponse(res, 200, { ok: true })
727
+ return
728
+ }
729
+
730
+ if (req.method === 'POST' && url.pathname === '/inbound/reject') {
731
+ if (integratedRouter) {
732
+ jsonResponse(res, 409, { error: { message: 'integrated agent router is active; external inbound rejects are disabled' } })
733
+ return
734
+ }
735
+ const body = await readJson(req)
736
+ runtimeState.rejectInbound({
737
+ inboundId: requireArg(body.inboundId, 'inboundId is required'),
738
+ code: Number.parseInt(body.code ?? '500', 10) || 500,
739
+ message: `${body.message ?? 'local runtime rejected the inbound request'}`
740
+ })
741
+ jsonResponse(res, 200, { ok: true })
742
+ return
743
+ }
744
+
745
+ if (req.method === 'POST' && url.pathname === '/connect') {
746
+ const node = await ensureGatewayReady('connect request')
747
+ const releaseOperation = beginOperation()
748
+ try {
749
+ const body = await readJson(req)
750
+ const result = await openDirectPeerSession({
751
+ apiBase,
752
+ agentId,
753
+ bundle,
754
+ node,
755
+ binding,
756
+ targetAgentId: requireArg(body.targetAgentId, 'targetAgentId is required'),
757
+ skillName: (body.skillHint ?? body.skillName ?? '').trim(),
758
+ method: requireArg(body.method, 'method is required'),
759
+ message: body.message,
760
+ metadata: body.metadata ?? null,
761
+ activitySummary: (body.activitySummary ?? '').trim() || `Preparing direct peer session${(body.skillHint ?? body.skillName ?? '').trim() ? ` for ${(body.skillHint ?? body.skillName ?? '').trim()}` : ''}.`,
762
+ report: body.report ?? null,
763
+ sessionStore: runtimeState
764
+ })
765
+ jsonResponse(res, 200, result)
766
+ return
767
+ } finally {
768
+ releaseOperation()
769
+ }
770
+ }
771
+
772
+ jsonResponse(res, 404, { error: { message: 'Not found' } })
773
+ } catch (error) {
774
+ jsonResponse(res, 500, { error: { message: error.message } })
775
+ }
776
+ })
777
+
778
+ await new Promise((resolve, reject) => {
779
+ server.once('error', reject)
780
+ server.listen(gatewayPort, gatewayHost, resolve)
781
+ })
782
+ const controlAddress = server.address()
783
+ actualGatewayPort = typeof controlAddress === 'object' && controlAddress ? controlAddress.port : gatewayPort
784
+ gatewayBase = `http://${gatewayHost}:${actualGatewayPort}`
785
+
786
+ try {
787
+ await recoverGateway('initial startup')
788
+ } catch (error) {
789
+ noteFailure(error)
790
+ console.error(`gateway initial startup failed: ${error.message}`)
791
+ throw error
792
+ }
793
+
794
+ const integratedRouterLoop = integratedRouter
795
+ ? runIntegratedRouterLoop().catch((error) => {
796
+ console.error(`integrated agent router stopped: ${error.message}`)
797
+ })
798
+ : null
799
+
800
+ const presenceTimer = presenceRefreshMs > 0
801
+ ? setInterval(async () => {
802
+ if (stopping || recoveryPromise) {
803
+ return
804
+ }
805
+ if (!currentNode) {
806
+ try {
807
+ await recoverGateway('presence refresh found no active gateway node')
808
+ } catch (error) {
809
+ noteFailure(error)
810
+ console.error(`gateway presence recovery failed: ${error.message}`)
811
+ }
812
+ return
813
+ }
814
+ try {
815
+ await publishPresence(currentNode)
816
+ } catch (error) {
817
+ noteFailure(error)
818
+ console.error(`gateway presence refresh failed: ${error.message}`)
819
+ if (lifecycle.consecutiveFailures >= failuresBeforeRecover) {
820
+ try {
821
+ await recoverGateway(`presence refresh failed: ${error.message}`)
822
+ } catch (recoveryError) {
823
+ noteFailure(recoveryError)
824
+ console.error(`gateway presence recovery failed: ${recoveryError.message}`)
825
+ }
826
+ }
827
+ }
828
+ }, presenceRefreshMs)
829
+ : null
830
+
831
+ const healthTimer = setInterval(async () => {
832
+ await runHealthCheck('gateway transport watchdog')
833
+ }, healthCheckMs)
834
+
835
+ const stop = async () => {
836
+ if (stopping) {
837
+ return
838
+ }
839
+ stopping = true
840
+ if (presenceTimer) {
841
+ clearInterval(presenceTimer)
842
+ }
843
+ clearInterval(healthTimer)
844
+ await sleep(10)
845
+ if (recoveryPromise) {
846
+ try {
847
+ await recoveryPromise
848
+ } catch {
849
+ // best-effort shutdown only
850
+ }
851
+ }
852
+ if (integratedRouter) {
853
+ try {
854
+ await integratedRouter.whenIdle()
855
+ } catch {
856
+ // best-effort shutdown only
857
+ }
858
+ }
859
+ await new Promise((resolve) => server.close(resolve))
860
+ await stopNode(currentNode)
861
+ process.exit(0)
862
+ }
863
+
864
+ process.on('SIGINT', () => {
865
+ stop().catch((error) => {
866
+ console.error(error.message)
867
+ process.exit(1)
868
+ })
869
+ })
870
+ process.on('SIGTERM', () => {
871
+ stop().catch((error) => {
872
+ console.error(error.message)
873
+ process.exit(1)
874
+ })
875
+ })
876
+ }
877
+
878
+ if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
879
+ runGateway(process.argv.slice(2)).catch((error) => {
880
+ console.error(error.message)
881
+ process.exit(1)
882
+ })
883
+ }