@acnlabs/acn-cli 0.13.3 → 0.14.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/README.md +40 -0
- package/dist/index.js +465 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,46 @@ acn heartbeat
|
|
|
67
67
|
acn heartbeat --agent-id <id> # override agent ID
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
### `acn listen` (Mode B — no public endpoint)
|
|
71
|
+
|
|
72
|
+
Hold an outbound WebSocket and receive relayed A2A requests in real time.
|
|
73
|
+
|
|
74
|
+
**Production (recommended):** built-in A2A receiver + wake your host runtime.
|
|
75
|
+
No local A2A port required.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Register for relay delivery, then:
|
|
79
|
+
acn listen --runtime http \
|
|
80
|
+
--wake-url http://127.0.0.1:10122/hooks/agent \
|
|
81
|
+
--wake-header 'Authorization: Bearer …'
|
|
82
|
+
|
|
83
|
+
acn listen --runtime command --wake-exec '/path/to/wake.sh'
|
|
84
|
+
acn listen --runtime log # debug: print normalized events to stderr
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Semantics: CLI answers `message/send` / `message/stream` with a valid A2A
|
|
88
|
+
`accepted` message **immediately**, then POSTs/execs a normalized event to
|
|
89
|
+
wake the host. Wake failure is logged (`wake_failed`) and does **not** fail
|
|
90
|
+
the A2A reply. Dedupe is on by default (`--no-dedupe` to disable).
|
|
91
|
+
|
|
92
|
+
**Coverage:** only traffic that arrives over the Mode B relay. Open Task Pool
|
|
93
|
+
rows that were never pushed as A2A still need `acn tasks list` / reconcile.
|
|
94
|
+
|
|
95
|
+
**Compat (advanced):** tunnel to your own A2A server, or let a subprocess
|
|
96
|
+
print the full JSON-RPC response:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
acn listen --forward http://127.0.0.1:8080
|
|
100
|
+
acn listen --exec './handle-a2a.sh' # stdout = full A2A JSON-RPC body
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
> Do not confuse legacy `--exec` with `--runtime command --wake-exec`.
|
|
104
|
+
> The former must emit a protocol-valid A2A response; the latter only wakes
|
|
105
|
+
> the host after the CLI has already answered A2A.
|
|
106
|
+
|
|
107
|
+
Keep `acn listen` and `acn heartbeat` in the same lifecycle for idle agents
|
|
108
|
+
(see [listen + heartbeat systemd example](../../docs/runbooks/acn-listen-heartbeat.md)).
|
|
109
|
+
|
|
70
110
|
### `acn agents`
|
|
71
111
|
|
|
72
112
|
Discover agents on ACN.
|
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "@acnlabs/acn-cli",
|
|
34
|
-
version: "0.
|
|
34
|
+
version: "0.14.0",
|
|
35
35
|
description: "Official CLI for ACN (Agent Collaboration Network) \u2014 zero-integration agent access",
|
|
36
36
|
main: "dist/index.js",
|
|
37
37
|
bin: {
|
|
@@ -1375,8 +1375,346 @@ ${formatPolicy(res)}`);
|
|
|
1375
1375
|
|
|
1376
1376
|
// src/commands/listen.ts
|
|
1377
1377
|
var import_commander10 = require("commander");
|
|
1378
|
-
var
|
|
1378
|
+
var import_child_process2 = require("child_process");
|
|
1379
1379
|
var import_ws = __toESM(require("ws"));
|
|
1380
|
+
|
|
1381
|
+
// src/commands/normalize-event.ts
|
|
1382
|
+
function asRecord(v) {
|
|
1383
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
1384
|
+
}
|
|
1385
|
+
function asNonEmptyString(v) {
|
|
1386
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
1387
|
+
}
|
|
1388
|
+
function parseJsonRpcBody(bodyText) {
|
|
1389
|
+
let parsed;
|
|
1390
|
+
try {
|
|
1391
|
+
parsed = JSON.parse(bodyText);
|
|
1392
|
+
} catch {
|
|
1393
|
+
return { ok: false, code: -32700, message: "Parse error" };
|
|
1394
|
+
}
|
|
1395
|
+
const body = asRecord(parsed);
|
|
1396
|
+
if (!body) {
|
|
1397
|
+
return { ok: false, code: -32600, message: "Invalid Request" };
|
|
1398
|
+
}
|
|
1399
|
+
if (body.jsonrpc !== "2.0" || typeof body.method !== "string") {
|
|
1400
|
+
return { ok: false, code: -32600, message: "Invalid Request" };
|
|
1401
|
+
}
|
|
1402
|
+
return { ok: true, body };
|
|
1403
|
+
}
|
|
1404
|
+
function extractTaskId(message) {
|
|
1405
|
+
const metadata = asRecord(message.metadata);
|
|
1406
|
+
if (metadata) {
|
|
1407
|
+
const fromMeta = asNonEmptyString(metadata.task_id) ?? asNonEmptyString(metadata.acn_task_id);
|
|
1408
|
+
if (fromMeta) return fromMeta;
|
|
1409
|
+
}
|
|
1410
|
+
const parts = message.parts;
|
|
1411
|
+
if (!Array.isArray(parts)) return null;
|
|
1412
|
+
for (const part of parts) {
|
|
1413
|
+
const p = asRecord(part);
|
|
1414
|
+
if (!p || p.kind !== "data") continue;
|
|
1415
|
+
const data = asRecord(p.data);
|
|
1416
|
+
if (!data) continue;
|
|
1417
|
+
const fromData = asNonEmptyString(data.task_id) ?? asNonEmptyString(data.acn_task_id);
|
|
1418
|
+
if (fromData) return fromData;
|
|
1419
|
+
}
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
function extractMessageId(message, generateId) {
|
|
1423
|
+
return asNonEmptyString(message.messageId) ?? asNonEmptyString(message.message_id) ?? generateId();
|
|
1424
|
+
}
|
|
1425
|
+
function extractContextId(message) {
|
|
1426
|
+
return asNonEmptyString(message.contextId) ?? asNonEmptyString(message.context_id);
|
|
1427
|
+
}
|
|
1428
|
+
function extractFromAgent(message) {
|
|
1429
|
+
const metadata = asRecord(message.metadata);
|
|
1430
|
+
if (!metadata) return null;
|
|
1431
|
+
return asNonEmptyString(metadata.from_agent) ?? asNonEmptyString(metadata.fromAgent);
|
|
1432
|
+
}
|
|
1433
|
+
function normalizeEvent(body, opts = {}) {
|
|
1434
|
+
const generateId = opts.generateId ?? (() => crypto.randomUUID());
|
|
1435
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
1436
|
+
const params = asRecord(body.params);
|
|
1437
|
+
const message = asRecord(params?.message) ?? {};
|
|
1438
|
+
return {
|
|
1439
|
+
event_type: "a2a_message",
|
|
1440
|
+
task_id: extractTaskId(message),
|
|
1441
|
+
message_id: extractMessageId(message, generateId),
|
|
1442
|
+
context_id: extractContextId(message),
|
|
1443
|
+
from_agent: extractFromAgent(message),
|
|
1444
|
+
received_at: now().toISOString(),
|
|
1445
|
+
raw: body
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
function dedupeKey(event) {
|
|
1449
|
+
return event.task_id ?? event.message_id;
|
|
1450
|
+
}
|
|
1451
|
+
var DedupeStore = class {
|
|
1452
|
+
constructor(ttlSec) {
|
|
1453
|
+
this.ttlSec = ttlSec;
|
|
1454
|
+
}
|
|
1455
|
+
ttlSec;
|
|
1456
|
+
map = /* @__PURE__ */ new Map();
|
|
1457
|
+
/** Returns true if key was already seen within TTL; otherwise marks and returns false. */
|
|
1458
|
+
isDuplicate(key, nowMs = Date.now()) {
|
|
1459
|
+
this.gc(nowMs);
|
|
1460
|
+
const exp = this.map.get(key);
|
|
1461
|
+
if (exp !== void 0 && exp > nowMs) return true;
|
|
1462
|
+
this.map.set(key, nowMs + this.ttlSec * 1e3);
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Drop a key so a later retry can wake again.
|
|
1467
|
+
* Used when wake fails after we reserved the slot on accept.
|
|
1468
|
+
*/
|
|
1469
|
+
forget(key) {
|
|
1470
|
+
this.map.delete(key);
|
|
1471
|
+
}
|
|
1472
|
+
/** Test helper — current window size after GC. */
|
|
1473
|
+
size(nowMs = Date.now()) {
|
|
1474
|
+
this.gc(nowMs);
|
|
1475
|
+
return this.map.size;
|
|
1476
|
+
}
|
|
1477
|
+
gc(nowMs) {
|
|
1478
|
+
for (const [k, exp] of this.map) {
|
|
1479
|
+
if (exp <= nowMs) this.map.delete(k);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
// src/commands/local-receiver.ts
|
|
1485
|
+
var import_crypto = require("crypto");
|
|
1486
|
+
|
|
1487
|
+
// src/commands/runtime-adapter.ts
|
|
1488
|
+
var import_child_process = require("child_process");
|
|
1489
|
+
var DEFAULT_WAKE_TIMEOUT_MS = 5e3;
|
|
1490
|
+
function parseWakeHeaders(raw) {
|
|
1491
|
+
const out = {};
|
|
1492
|
+
for (const item of raw ?? []) {
|
|
1493
|
+
const idx = item.indexOf(":");
|
|
1494
|
+
if (idx <= 0) {
|
|
1495
|
+
throw new Error(
|
|
1496
|
+
`Invalid --wake-header "${item}". Expected "Header-Name: value".`
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
const key = item.slice(0, idx).trim();
|
|
1500
|
+
const value = item.slice(idx + 1).trim();
|
|
1501
|
+
if (!key) {
|
|
1502
|
+
throw new Error(
|
|
1503
|
+
`Invalid --wake-header "${item}". Expected "Header-Name: value".`
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
out[key] = value;
|
|
1507
|
+
}
|
|
1508
|
+
return out;
|
|
1509
|
+
}
|
|
1510
|
+
function validateRuntimeOptions(opts) {
|
|
1511
|
+
if (!opts.runtime) return null;
|
|
1512
|
+
if (opts.runtime !== "http" && opts.runtime !== "command" && opts.runtime !== "log") {
|
|
1513
|
+
return `Unknown --runtime "${opts.runtime}". Use: http | command | log`;
|
|
1514
|
+
}
|
|
1515
|
+
if (opts.runtime === "http" && !opts.wakeUrl) {
|
|
1516
|
+
return "--runtime http requires --wake-url <url>";
|
|
1517
|
+
}
|
|
1518
|
+
if (opts.runtime === "command" && !opts.wakeExec) {
|
|
1519
|
+
return "--runtime command requires --wake-exec <cmd>";
|
|
1520
|
+
}
|
|
1521
|
+
return null;
|
|
1522
|
+
}
|
|
1523
|
+
async function wakeHttp(event, opts, deps) {
|
|
1524
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
1525
|
+
const timeoutMs = opts.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS;
|
|
1526
|
+
const controller = new AbortController();
|
|
1527
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1528
|
+
try {
|
|
1529
|
+
const res = await fetchFn(opts.wakeUrl, {
|
|
1530
|
+
method: "POST",
|
|
1531
|
+
headers: {
|
|
1532
|
+
"content-type": "application/json",
|
|
1533
|
+
...opts.wakeHeaders ?? {}
|
|
1534
|
+
},
|
|
1535
|
+
body: JSON.stringify(event),
|
|
1536
|
+
signal: controller.signal
|
|
1537
|
+
});
|
|
1538
|
+
try {
|
|
1539
|
+
await res.arrayBuffer();
|
|
1540
|
+
} catch {
|
|
1541
|
+
}
|
|
1542
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1543
|
+
return { ok: false, reason: `http_${res.status}` };
|
|
1544
|
+
}
|
|
1545
|
+
return { ok: true };
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1548
|
+
if (controller.signal.aborted || /abort|timeout/i.test(msg)) {
|
|
1549
|
+
return { ok: false, reason: "timeout" };
|
|
1550
|
+
}
|
|
1551
|
+
return { ok: false, reason: msg.slice(0, 200) };
|
|
1552
|
+
} finally {
|
|
1553
|
+
clearTimeout(timer);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
function wakeCommand(event, opts, deps) {
|
|
1557
|
+
const spawnFn = deps.spawnFn ?? import_child_process.spawn;
|
|
1558
|
+
const timeoutMs = opts.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS;
|
|
1559
|
+
const body = Buffer.from(JSON.stringify(event), "utf-8");
|
|
1560
|
+
return new Promise((resolve) => {
|
|
1561
|
+
let settled = false;
|
|
1562
|
+
const child = spawnFn(opts.wakeExec, { shell: true });
|
|
1563
|
+
const errOut = [];
|
|
1564
|
+
const finish = (result) => {
|
|
1565
|
+
if (settled) return;
|
|
1566
|
+
settled = true;
|
|
1567
|
+
clearTimeout(timer);
|
|
1568
|
+
resolve(result);
|
|
1569
|
+
};
|
|
1570
|
+
const timer = setTimeout(() => {
|
|
1571
|
+
child.kill("SIGTERM");
|
|
1572
|
+
finish({ ok: false, reason: "timeout" });
|
|
1573
|
+
}, timeoutMs);
|
|
1574
|
+
child.stderr?.on("data", (d) => errOut.push(Buffer.from(d)));
|
|
1575
|
+
child.on(
|
|
1576
|
+
"error",
|
|
1577
|
+
(e) => finish({ ok: false, reason: e.message.slice(0, 200) })
|
|
1578
|
+
);
|
|
1579
|
+
child.on("close", (code) => {
|
|
1580
|
+
if (code === 0) finish({ ok: true });
|
|
1581
|
+
else {
|
|
1582
|
+
const detail = Buffer.concat(errOut).toString("utf-8").slice(0, 80);
|
|
1583
|
+
finish({
|
|
1584
|
+
ok: false,
|
|
1585
|
+
reason: detail ? `exit_${code}:${detail}` : `exit_${code}`
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
child.stdin?.end(body);
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
function wakeLog(event, deps) {
|
|
1593
|
+
const logFn = deps.logFn ?? ((line) => console.error(line));
|
|
1594
|
+
logFn(JSON.stringify(event));
|
|
1595
|
+
return { ok: true };
|
|
1596
|
+
}
|
|
1597
|
+
async function wakeRuntime(event, opts, deps = {}) {
|
|
1598
|
+
switch (opts.runtime) {
|
|
1599
|
+
case "http":
|
|
1600
|
+
return wakeHttp(event, opts, deps);
|
|
1601
|
+
case "command":
|
|
1602
|
+
return wakeCommand(event, opts, deps);
|
|
1603
|
+
case "log":
|
|
1604
|
+
return wakeLog(event, deps);
|
|
1605
|
+
default:
|
|
1606
|
+
return { ok: false, reason: `unknown_runtime` };
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// src/commands/local-receiver.ts
|
|
1611
|
+
var HANDLED_METHODS = /* @__PURE__ */ new Set(["message/send", "message/stream"]);
|
|
1612
|
+
function jsonRpcResponse(correlationId, jsonrpcId, payload) {
|
|
1613
|
+
return {
|
|
1614
|
+
type: "a2a_response",
|
|
1615
|
+
id: correlationId,
|
|
1616
|
+
status: 200,
|
|
1617
|
+
headers: { "content-type": "application/json" },
|
|
1618
|
+
body: JSON.stringify({
|
|
1619
|
+
jsonrpc: "2.0",
|
|
1620
|
+
id: jsonrpcId ?? null,
|
|
1621
|
+
...payload
|
|
1622
|
+
})
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
function acceptedMessage(correlationId, jsonrpcId, messageId) {
|
|
1626
|
+
return jsonRpcResponse(correlationId, jsonrpcId, {
|
|
1627
|
+
result: {
|
|
1628
|
+
kind: "message",
|
|
1629
|
+
messageId,
|
|
1630
|
+
role: "agent",
|
|
1631
|
+
parts: [{ kind: "text", text: "accepted" }]
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
function jsonRpcError(correlationId, jsonrpcId, code, message) {
|
|
1636
|
+
return jsonRpcResponse(correlationId, jsonrpcId, {
|
|
1637
|
+
error: { code, message }
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
function processIncomingRequest(correlationId, bodyText, opts, dedupeStore, deps = {}) {
|
|
1641
|
+
const generateId = deps.generateId ?? (() => (0, import_crypto.randomUUID)());
|
|
1642
|
+
const parsed = parseJsonRpcBody(bodyText);
|
|
1643
|
+
if (!parsed.ok) {
|
|
1644
|
+
return {
|
|
1645
|
+
response: jsonRpcError(correlationId, null, parsed.code, parsed.message),
|
|
1646
|
+
event: null,
|
|
1647
|
+
shouldWake: false,
|
|
1648
|
+
dedupeHit: false
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
const { body } = parsed;
|
|
1652
|
+
const jsonrpcId = body.id ?? null;
|
|
1653
|
+
const method = body.method;
|
|
1654
|
+
if (!HANDLED_METHODS.has(method)) {
|
|
1655
|
+
return {
|
|
1656
|
+
response: jsonRpcError(
|
|
1657
|
+
correlationId,
|
|
1658
|
+
jsonrpcId,
|
|
1659
|
+
-32601,
|
|
1660
|
+
`Method not found: ${method}`
|
|
1661
|
+
),
|
|
1662
|
+
event: null,
|
|
1663
|
+
shouldWake: false,
|
|
1664
|
+
dedupeHit: false
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
const event = normalizeEvent(body, {
|
|
1668
|
+
generateId,
|
|
1669
|
+
now: deps.now
|
|
1670
|
+
});
|
|
1671
|
+
const replyMessageId = generateId();
|
|
1672
|
+
const response = acceptedMessage(correlationId, jsonrpcId, replyMessageId);
|
|
1673
|
+
if (opts.dedupe) {
|
|
1674
|
+
const key = dedupeKey(event);
|
|
1675
|
+
if (dedupeStore.isDuplicate(key)) {
|
|
1676
|
+
return { response, event, shouldWake: false, dedupeHit: true };
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
return { response, event, shouldWake: true, dedupeHit: false };
|
|
1680
|
+
}
|
|
1681
|
+
function formatWakeFailed(event, reason) {
|
|
1682
|
+
const task = event.task_id ?? "-";
|
|
1683
|
+
return `[acn listen] wake_failed message_id=${event.message_id} task_id=${task} reason=${reason}`;
|
|
1684
|
+
}
|
|
1685
|
+
function formatDeduped(event) {
|
|
1686
|
+
return `[acn listen] deduped key=${dedupeKey(event)}`;
|
|
1687
|
+
}
|
|
1688
|
+
function dispatchLocalReceiver(correlationId, bodyText, opts, dedupeStore, send, deps = {}) {
|
|
1689
|
+
const logFn = deps.logFn ?? ((line) => console.error(line));
|
|
1690
|
+
const result = processIncomingRequest(
|
|
1691
|
+
correlationId,
|
|
1692
|
+
bodyText,
|
|
1693
|
+
opts,
|
|
1694
|
+
dedupeStore,
|
|
1695
|
+
deps
|
|
1696
|
+
);
|
|
1697
|
+
send(result.response);
|
|
1698
|
+
if (result.dedupeHit && result.event) {
|
|
1699
|
+
logFn(formatDeduped(result.event));
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (!result.shouldWake || !result.event) return;
|
|
1703
|
+
const event = result.event;
|
|
1704
|
+
const key = opts.dedupe ? dedupeKey(event) : null;
|
|
1705
|
+
void wakeRuntime(event, opts, deps).then((wake) => {
|
|
1706
|
+
if (!wake.ok) {
|
|
1707
|
+
if (key) dedupeStore.forget(key);
|
|
1708
|
+
logFn(formatWakeFailed(event, wake.reason));
|
|
1709
|
+
}
|
|
1710
|
+
}).catch((err) => {
|
|
1711
|
+
if (key) dedupeStore.forget(key);
|
|
1712
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1713
|
+
logFn(formatWakeFailed(event, msg.slice(0, 200)));
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// src/commands/listen.ts
|
|
1380
1718
|
var STRIP_HEADERS = /* @__PURE__ */ new Set([
|
|
1381
1719
|
"host",
|
|
1382
1720
|
"content-length",
|
|
@@ -1408,12 +1746,28 @@ function errorResponse(id, status, detail) {
|
|
|
1408
1746
|
async function dispatchA2aRequest(frame, opts, send, deps = {}) {
|
|
1409
1747
|
const bodyBuf = decodeBody(frame);
|
|
1410
1748
|
try {
|
|
1749
|
+
if (opts.runtime) {
|
|
1750
|
+
const store = deps.dedupeStore ?? new DedupeStore(opts.runtime.dedupeTtlSec);
|
|
1751
|
+
dispatchLocalReceiver(
|
|
1752
|
+
frame.id,
|
|
1753
|
+
bodyBuf.toString("utf-8"),
|
|
1754
|
+
opts.runtime,
|
|
1755
|
+
store,
|
|
1756
|
+
send,
|
|
1757
|
+
{
|
|
1758
|
+
fetchFn: deps.fetchFn,
|
|
1759
|
+
spawnFn: deps.spawnFn,
|
|
1760
|
+
logFn: deps.logFn
|
|
1761
|
+
}
|
|
1762
|
+
);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1411
1765
|
if (opts.forward) {
|
|
1412
1766
|
await forwardToHttp(frame, bodyBuf, opts.forward, deps.fetchFn ?? fetch, send);
|
|
1413
1767
|
return;
|
|
1414
1768
|
}
|
|
1415
1769
|
if (opts.exec) {
|
|
1416
|
-
send(await runExec(frame, bodyBuf, opts.exec, deps.spawnFn ??
|
|
1770
|
+
send(await runExec(frame, bodyBuf, opts.exec, deps.spawnFn ?? import_child_process2.spawn));
|
|
1417
1771
|
return;
|
|
1418
1772
|
}
|
|
1419
1773
|
send(errorResponse(frame.id, 500, "no handler configured"));
|
|
@@ -1518,13 +1872,15 @@ function runListener(cfg) {
|
|
|
1518
1872
|
const wsUrl = toWebsocketUrl(cfg.baseUrl, cfg.agentId);
|
|
1519
1873
|
let backoff = INITIAL_BACKOFF_MS;
|
|
1520
1874
|
let stopped = false;
|
|
1875
|
+
const dedupeStore = cfg.runtime ? new DedupeStore(cfg.runtime.dedupeTtlSec) : void 0;
|
|
1521
1876
|
const connect = () => {
|
|
1522
1877
|
const ws = new import_ws.default(wsUrl, {
|
|
1523
1878
|
headers: { Authorization: `Bearer ${cfg.apiKey}` }
|
|
1524
1879
|
});
|
|
1525
1880
|
let keepalive;
|
|
1526
1881
|
ws.on("open", () => {
|
|
1527
|
-
|
|
1882
|
+
const mode = cfg.runtime ? `runtime=${cfg.runtime.runtime}` : cfg.forward ? `forward=${cfg.forward}` : `exec`;
|
|
1883
|
+
console.error(`[acn listen] connected as ${cfg.agentId} \u2192 ${wsUrl} (${mode})`);
|
|
1528
1884
|
backoff = INITIAL_BACKOFF_MS;
|
|
1529
1885
|
keepalive = setInterval(() => {
|
|
1530
1886
|
if (ws.readyState === import_ws.default.OPEN) {
|
|
@@ -1547,7 +1903,7 @@ function runListener(cfg) {
|
|
|
1547
1903
|
ws.send(JSON.stringify(out));
|
|
1548
1904
|
}
|
|
1549
1905
|
};
|
|
1550
|
-
void dispatchA2aRequest(f, cfg, send);
|
|
1906
|
+
void dispatchA2aRequest(f, cfg, send, { dedupeStore });
|
|
1551
1907
|
}
|
|
1552
1908
|
});
|
|
1553
1909
|
ws.on("close", (code, reason) => {
|
|
@@ -1576,47 +1932,114 @@ function runListener(cfg) {
|
|
|
1576
1932
|
});
|
|
1577
1933
|
connect();
|
|
1578
1934
|
}
|
|
1935
|
+
function collectWakeHeader(value, previous) {
|
|
1936
|
+
previous.push(value);
|
|
1937
|
+
return previous;
|
|
1938
|
+
}
|
|
1939
|
+
function validateListenHandlerFlags(opts) {
|
|
1940
|
+
const modes = [opts.runtime, opts.forward, opts.exec].filter(Boolean);
|
|
1941
|
+
if (modes.length === 0) {
|
|
1942
|
+
return "Provide a handler: --runtime http|command|log (recommended), or legacy --forward <url> / --exec <command>.";
|
|
1943
|
+
}
|
|
1944
|
+
if (modes.length > 1) {
|
|
1945
|
+
return "Use only one handler: --runtime, --forward, or --exec \u2014 not combined.";
|
|
1946
|
+
}
|
|
1947
|
+
return validateRuntimeOptions({
|
|
1948
|
+
runtime: opts.runtime,
|
|
1949
|
+
wakeUrl: opts.wakeUrl,
|
|
1950
|
+
wakeExec: opts.wakeExec
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1579
1953
|
function listenCommand() {
|
|
1580
1954
|
const cmd = new import_commander10.Command("listen").description(
|
|
1581
|
-
"Hold an outbound connection to ACN and answer relayed A2A requests in real time (ADR-0012 Mode B).
|
|
1955
|
+
"Hold an outbound connection to ACN and answer relayed A2A requests in real time (ADR-0012 Mode B). Prefer --runtime for production; --forward/--exec remain as compatibility tunnels."
|
|
1956
|
+
).option(
|
|
1957
|
+
"--runtime <id>",
|
|
1958
|
+
"Built-in A2A receiver + wake host: http | command | log (no local A2A port)"
|
|
1959
|
+
).option("--wake-url <url>", "POST target for --runtime http").option(
|
|
1960
|
+
"--wake-header <k:v>",
|
|
1961
|
+
"Extra header for --runtime http (repeatable)",
|
|
1962
|
+
collectWakeHeader,
|
|
1963
|
+
[]
|
|
1582
1964
|
).option(
|
|
1965
|
+
"--wake-exec <cmd>",
|
|
1966
|
+
"Shell command for --runtime command (event JSON on stdin). Not the same as legacy --exec (which must print a full A2A response)."
|
|
1967
|
+
).option(
|
|
1968
|
+
"--wake-timeout <ms>",
|
|
1969
|
+
"Wake timeout in ms (default 5000)",
|
|
1970
|
+
String(DEFAULT_WAKE_TIMEOUT_MS)
|
|
1971
|
+
).option("--no-dedupe", "Disable in-process task/message id dedupe (default: on)").option("--dedupe-ttl <sec>", "Dedupe window seconds (default 3600)", "3600").option(
|
|
1583
1972
|
"--forward <url>",
|
|
1584
|
-
"
|
|
1973
|
+
"Compat: tunnel each request to a local A2A HTTP server"
|
|
1585
1974
|
).option(
|
|
1586
1975
|
"--exec <command>",
|
|
1587
|
-
"
|
|
1588
|
-
).option("-i, --agent-id <id>", "Agent ID (defaults to config)").action(
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1976
|
+
"Compat: shell per request; stdout must be a full A2A JSON-RPC response"
|
|
1977
|
+
).option("-i, --agent-id <id>", "Agent ID (defaults to config)").action(
|
|
1978
|
+
(opts) => {
|
|
1979
|
+
const config = loadConfig();
|
|
1980
|
+
const apiKey = config.api_key;
|
|
1981
|
+
const agentId = opts.agentId ?? config.agent_id;
|
|
1982
|
+
if (!apiKey) {
|
|
1983
|
+
console.error(
|
|
1984
|
+
"No API key found. Run `acn join` first or `acn config set api-key <key>`."
|
|
1985
|
+
);
|
|
1986
|
+
process.exit(1);
|
|
1987
|
+
}
|
|
1988
|
+
if (!agentId) {
|
|
1989
|
+
console.error(
|
|
1990
|
+
"No agent ID found. Run `acn join` first or `acn config set agent-id <id>`."
|
|
1991
|
+
);
|
|
1992
|
+
process.exit(1);
|
|
1993
|
+
}
|
|
1994
|
+
const flagErr = validateListenHandlerFlags({
|
|
1995
|
+
runtime: opts.runtime,
|
|
1996
|
+
forward: opts.forward,
|
|
1997
|
+
exec: opts.exec,
|
|
1998
|
+
wakeUrl: opts.wakeUrl,
|
|
1999
|
+
wakeExec: opts.wakeExec
|
|
2000
|
+
});
|
|
2001
|
+
if (flagErr) {
|
|
2002
|
+
console.error(flagErr);
|
|
2003
|
+
process.exit(1);
|
|
2004
|
+
}
|
|
2005
|
+
let wakeHeaders;
|
|
2006
|
+
if (opts.runtime === "http") {
|
|
2007
|
+
try {
|
|
2008
|
+
wakeHeaders = parseWakeHeaders(opts.wakeHeader);
|
|
2009
|
+
} catch (err) {
|
|
2010
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2011
|
+
process.exit(1);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
const wakeTimeoutMs = Number.parseInt(opts.wakeTimeout ?? "", 10);
|
|
2015
|
+
const dedupeTtlSec = Number.parseInt(opts.dedupeTtl ?? "", 10);
|
|
2016
|
+
if (opts.runtime && (!Number.isFinite(wakeTimeoutMs) || wakeTimeoutMs <= 0)) {
|
|
2017
|
+
console.error("--wake-timeout must be a positive integer (ms).");
|
|
2018
|
+
process.exit(1);
|
|
2019
|
+
}
|
|
2020
|
+
if (opts.runtime && (!Number.isFinite(dedupeTtlSec) || dedupeTtlSec <= 0)) {
|
|
2021
|
+
console.error("--dedupe-ttl must be a positive integer (seconds).");
|
|
2022
|
+
process.exit(1);
|
|
2023
|
+
}
|
|
2024
|
+
runListener({
|
|
2025
|
+
agentId,
|
|
2026
|
+
apiKey,
|
|
2027
|
+
baseUrl: config.base_url,
|
|
2028
|
+
forward: opts.forward,
|
|
2029
|
+
exec: opts.exec,
|
|
2030
|
+
runtime: opts.runtime ? {
|
|
2031
|
+
runtime: opts.runtime,
|
|
2032
|
+
wakeUrl: opts.wakeUrl,
|
|
2033
|
+
wakeHeaders,
|
|
2034
|
+
wakeExec: opts.wakeExec,
|
|
2035
|
+
wakeTimeoutMs,
|
|
2036
|
+
// commander: --no-dedupe sets dedupe=false; default true
|
|
2037
|
+
dedupe: opts.dedupe !== false,
|
|
2038
|
+
dedupeTtlSec
|
|
2039
|
+
} : void 0
|
|
2040
|
+
});
|
|
1611
2041
|
}
|
|
1612
|
-
|
|
1613
|
-
agentId,
|
|
1614
|
-
apiKey,
|
|
1615
|
-
baseUrl: config.base_url,
|
|
1616
|
-
forward: opts.forward,
|
|
1617
|
-
exec: opts.exec
|
|
1618
|
-
});
|
|
1619
|
-
});
|
|
2042
|
+
);
|
|
1620
2043
|
return cmd;
|
|
1621
2044
|
}
|
|
1622
2045
|
|
|
@@ -1707,8 +2130,9 @@ function deliveryCommand() {
|
|
|
1707
2130
|
);
|
|
1708
2131
|
const followUp = res.delivery === "relay" ? [
|
|
1709
2132
|
"",
|
|
1710
|
-
"Next:
|
|
1711
|
-
" acn listen --
|
|
2133
|
+
"Next: run the Mode B listener (built-in A2A + wake host):",
|
|
2134
|
+
" acn listen --runtime http --wake-url http://127.0.0.1:PORT/wake",
|
|
2135
|
+
"Compat: acn listen --forward http://localhost:PORT"
|
|
1712
2136
|
] : [];
|
|
1713
2137
|
output(res, [formatDelivery(res), ...followUp].join("\n"));
|
|
1714
2138
|
} catch (err) {
|