@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.
Files changed (3) hide show
  1. package/README.md +40 -0
  2. package/dist/index.js +465 -41
  3. 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.13.3",
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 import_child_process = require("child_process");
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 ?? import_child_process.spawn));
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
- console.error(`[acn listen] connected as ${cfg.agentId} \u2192 ${wsUrl}`);
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). For agents with no public endpoint."
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
- "Tunnel each relayed request to a local HTTP server (e.g. http://localhost:8080)"
1973
+ "Compat: tunnel each request to a local A2A HTTP server"
1585
1974
  ).option(
1586
1975
  "--exec <command>",
1587
- "Run a shell command per request: body on stdin, stdout becomes the response"
1588
- ).option("-i, --agent-id <id>", "Agent ID (defaults to config)").action((opts) => {
1589
- const config = loadConfig();
1590
- const apiKey = config.api_key;
1591
- const agentId = opts.agentId ?? config.agent_id;
1592
- if (!apiKey) {
1593
- console.error(
1594
- "No API key found. Run `acn join` first or `acn config set api-key <key>`."
1595
- );
1596
- process.exit(1);
1597
- }
1598
- if (!agentId) {
1599
- console.error(
1600
- "No agent ID found. Run `acn join` first or `acn config set agent-id <id>`."
1601
- );
1602
- process.exit(1);
1603
- }
1604
- if (!opts.forward && !opts.exec) {
1605
- console.error("Provide a handler: --forward <url> or --exec <command>.");
1606
- process.exit(1);
1607
- }
1608
- if (opts.forward && opts.exec) {
1609
- console.error("Use only one handler: --forward or --exec, not both.");
1610
- process.exit(1);
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
- runListener({
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: keep a local A2A handler up, then:",
1711
- " acn listen --forward http://localhost:PORT"
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acnlabs/acn-cli",
3
- "version": "0.13.3",
3
+ "version": "0.14.0",
4
4
  "description": "Official CLI for ACN (Agent Collaboration Network) — zero-integration agent access",
5
5
  "main": "dist/index.js",
6
6
  "bin": {