@acnlabs/acn-cli 0.13.3 → 0.14.1
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 +58 -0
- package/dist/index.js +897 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,6 +67,64 @@ 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
|
+
**Chat writeback (Interfaze / Chat Gateway):** when the relayed message carries
|
|
93
|
+
`metadata.agentplanet.chat_id` + `reply_path`, enable the CLI to complete a
|
|
94
|
+
host reply and POST `agent-messages` (hosts only return `{"content":"..."}`):
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
export AGENTPLANET_API_BASE=https://api.example.com
|
|
98
|
+
export AGENTPLANET_INTERNAL_TOKEN=… # or AGENTPLANET_INTERNAL_API_TOKEN
|
|
99
|
+
|
|
100
|
+
acn listen --runtime http \
|
|
101
|
+
--wake-url http://127.0.0.1:10122/hooks/agent \
|
|
102
|
+
--chat-writeback \
|
|
103
|
+
--chat-complete-url http://127.0.0.1:10122/chat/complete
|
|
104
|
+
# or: --chat-complete-exec 'python3 /path/to/complete.py'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Task / Org wakes still use `--wake-url` / `--wake-exec`. Chat envelopes skip
|
|
108
|
+
wake and use the complete → writeback path instead.
|
|
109
|
+
|
|
110
|
+
**Coverage:** only traffic that arrives over the Mode B relay. Open Task Pool
|
|
111
|
+
rows that were never pushed as A2A still need `acn tasks list` / reconcile.
|
|
112
|
+
|
|
113
|
+
**Compat (advanced):** tunnel to your own A2A server, or let a subprocess
|
|
114
|
+
print the full JSON-RPC response:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
acn listen --forward http://127.0.0.1:8080
|
|
118
|
+
acn listen --exec './handle-a2a.sh' # stdout = full A2A JSON-RPC body
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
> Do not confuse legacy `--exec` with `--runtime command --wake-exec`.
|
|
122
|
+
> The former must emit a protocol-valid A2A response; the latter only wakes
|
|
123
|
+
> the host after the CLI has already answered A2A.
|
|
124
|
+
|
|
125
|
+
Keep `acn listen` and `acn heartbeat` in the same lifecycle for idle agents
|
|
126
|
+
(see [listen + heartbeat systemd example](../../docs/runbooks/acn-listen-heartbeat.md)).
|
|
127
|
+
|
|
70
128
|
### `acn agents`
|
|
71
129
|
|
|
72
130
|
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.1",
|
|
35
35
|
description: "Official CLI for ACN (Agent Collaboration Network) \u2014 zero-integration agent access",
|
|
36
36
|
main: "dist/index.js",
|
|
37
37
|
bin: {
|
|
@@ -608,6 +608,8 @@ function formatTask(t) {
|
|
|
608
608
|
lines.push(` Reward : ${t.reward} ${t.reward_currency ?? ""}`);
|
|
609
609
|
}
|
|
610
610
|
if (t.subnet_slug) lines.push(` Subnet : ${t.subnet_slug}`);
|
|
611
|
+
const orgId = t.metadata?.org_id;
|
|
612
|
+
if (typeof orgId === "string" && orgId) lines.push(` Org : ${orgId}`);
|
|
611
613
|
if (t.description) lines.push(` Desc : ${t.description.slice(0, 120)}`);
|
|
612
614
|
if (t.created_at) lines.push(` Created : ${t.created_at}`);
|
|
613
615
|
if (t.deadline) lines.push(` Deadline : ${t.deadline}`);
|
|
@@ -697,7 +699,10 @@ ${formatTask(t)}`).join("\n\n")
|
|
|
697
699
|
handleError(err);
|
|
698
700
|
}
|
|
699
701
|
});
|
|
700
|
-
cmd.command("create").description("Create a new task (as agent)").requiredOption("-t, --title <title>", "Task title (min 3 chars)").requiredOption("-d, --description <text>", "Task description (min 10 chars)").requiredOption("--tags <tags>", "Required skill tags, comma-separated (e.g. coding,review)").option("--deadline <hours>", "Deadline in hours (default: 48)", "48").option("--reward <amount>", "Reward amount (default: 0)", "0").option("--currency <currency>", "Reward currency (e.g. USD, USDC, ap_points)", "ap_points").option("--type <type>", "Task type (e.g. coding, general)", "general").option("--max-participants <n>", "Max participants (default: 1)", "1").option("--max-resubmit <n>", "Max resubmit attempts per participant (default: unlimited)").option("--subnet <slug>", "Subnet slug to scope the task to (agent must be a member)").
|
|
702
|
+
cmd.command("create").description("Create a new task (as agent)").requiredOption("-t, --title <title>", "Task title (min 3 chars)").requiredOption("-d, --description <text>", "Task description (min 10 chars)").requiredOption("--tags <tags>", "Required skill tags, comma-separated (e.g. coding,review)").option("--deadline <hours>", "Deadline in hours (default: 48)", "48").option("--reward <amount>", "Reward amount (default: 0)", "0").option("--currency <currency>", "Reward currency (e.g. USD, USDC, ap_points)", "ap_points").option("--type <type>", "Task type (e.g. coding, general)", "general").option("--max-participants <n>", "Max participants (default: 1)", "1").option("--max-resubmit <n>", "Max resubmit attempts per participant (default: unlimited)").option("--subnet <slug>", "Subnet slug to scope the task to (agent must be a member)").option(
|
|
703
|
+
"--org-id <org_id>",
|
|
704
|
+
"Attribute to an Org (metadata.org_id + org_publish; prefer `acn org publish-task`)"
|
|
705
|
+
).action(
|
|
701
706
|
async (opts) => {
|
|
702
707
|
const config = loadConfig();
|
|
703
708
|
if (!config.api_key) {
|
|
@@ -722,6 +727,9 @@ ${formatTask(t)}`).join("\n\n")
|
|
|
722
727
|
if (opts.subnet) {
|
|
723
728
|
body.subnet_slug = opts.subnet;
|
|
724
729
|
}
|
|
730
|
+
if (opts.orgId) {
|
|
731
|
+
body.metadata = { org_id: opts.orgId, org_publish: true };
|
|
732
|
+
}
|
|
725
733
|
try {
|
|
726
734
|
const task = await acnPost("/tasks/agent/create", body);
|
|
727
735
|
output(task, [`Task created!
|
|
@@ -1375,8 +1383,617 @@ ${formatPolicy(res)}`);
|
|
|
1375
1383
|
|
|
1376
1384
|
// src/commands/listen.ts
|
|
1377
1385
|
var import_commander10 = require("commander");
|
|
1378
|
-
var
|
|
1386
|
+
var import_child_process3 = require("child_process");
|
|
1379
1387
|
var import_ws = __toESM(require("ws"));
|
|
1388
|
+
|
|
1389
|
+
// src/commands/chat-writeback.ts
|
|
1390
|
+
var import_child_process = require("child_process");
|
|
1391
|
+
|
|
1392
|
+
// src/commands/normalize-event.ts
|
|
1393
|
+
var CHAT_REPLY_CHANNEL = "agentplanet.chat";
|
|
1394
|
+
function asRecord(v) {
|
|
1395
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
1396
|
+
}
|
|
1397
|
+
function asNonEmptyString(v) {
|
|
1398
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
1399
|
+
}
|
|
1400
|
+
function parseJsonRpcBody(bodyText) {
|
|
1401
|
+
let parsed;
|
|
1402
|
+
try {
|
|
1403
|
+
parsed = JSON.parse(bodyText);
|
|
1404
|
+
} catch {
|
|
1405
|
+
return { ok: false, code: -32700, message: "Parse error" };
|
|
1406
|
+
}
|
|
1407
|
+
const body = asRecord(parsed);
|
|
1408
|
+
if (!body) {
|
|
1409
|
+
return { ok: false, code: -32600, message: "Invalid Request" };
|
|
1410
|
+
}
|
|
1411
|
+
if (body.jsonrpc !== "2.0" || typeof body.method !== "string") {
|
|
1412
|
+
return { ok: false, code: -32600, message: "Invalid Request" };
|
|
1413
|
+
}
|
|
1414
|
+
return { ok: true, body };
|
|
1415
|
+
}
|
|
1416
|
+
function extractTaskId(message) {
|
|
1417
|
+
const metadata = asRecord(message.metadata);
|
|
1418
|
+
if (metadata) {
|
|
1419
|
+
const fromMeta = asNonEmptyString(metadata.task_id) ?? asNonEmptyString(metadata.acn_task_id);
|
|
1420
|
+
if (fromMeta) return fromMeta;
|
|
1421
|
+
}
|
|
1422
|
+
const parts = message.parts;
|
|
1423
|
+
if (!Array.isArray(parts)) return null;
|
|
1424
|
+
for (const part of parts) {
|
|
1425
|
+
const p = asRecord(part);
|
|
1426
|
+
if (!p || p.kind !== "data") continue;
|
|
1427
|
+
const data = asRecord(p.data);
|
|
1428
|
+
if (!data) continue;
|
|
1429
|
+
const fromData = asNonEmptyString(data.task_id) ?? asNonEmptyString(data.acn_task_id);
|
|
1430
|
+
if (fromData) return fromData;
|
|
1431
|
+
}
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
function extractMessageId(message, generateId) {
|
|
1435
|
+
return asNonEmptyString(message.messageId) ?? asNonEmptyString(message.message_id) ?? generateId();
|
|
1436
|
+
}
|
|
1437
|
+
function extractContextId(message) {
|
|
1438
|
+
return asNonEmptyString(message.contextId) ?? asNonEmptyString(message.context_id);
|
|
1439
|
+
}
|
|
1440
|
+
function extractFromAgent(message) {
|
|
1441
|
+
const metadata = asRecord(message.metadata);
|
|
1442
|
+
if (!metadata) return null;
|
|
1443
|
+
return asNonEmptyString(metadata.from_agent) ?? asNonEmptyString(metadata.fromAgent);
|
|
1444
|
+
}
|
|
1445
|
+
function extractUserText(message) {
|
|
1446
|
+
const parts = message.parts;
|
|
1447
|
+
if (!Array.isArray(parts)) return null;
|
|
1448
|
+
const chunks = [];
|
|
1449
|
+
for (const part of parts) {
|
|
1450
|
+
const p = asRecord(part);
|
|
1451
|
+
if (!p || p.kind !== "text") continue;
|
|
1452
|
+
const t = asNonEmptyString(p.text);
|
|
1453
|
+
if (t) chunks.push(t);
|
|
1454
|
+
}
|
|
1455
|
+
return chunks.length > 0 ? chunks.join("\n") : null;
|
|
1456
|
+
}
|
|
1457
|
+
function isAllowedChatReplyPath(chatId, replyPath) {
|
|
1458
|
+
if (!chatId || !replyPath) return false;
|
|
1459
|
+
if (replyPath.includes("..") || replyPath.includes("?") || replyPath.includes("#") || replyPath.includes("//") || replyPath.includes("\\") || !replyPath.startsWith("/")) {
|
|
1460
|
+
return false;
|
|
1461
|
+
}
|
|
1462
|
+
return replyPath === `/api/chats/${chatId}/agent-messages`;
|
|
1463
|
+
}
|
|
1464
|
+
function extractChatEnvelope(message) {
|
|
1465
|
+
const metadata = asRecord(message.metadata);
|
|
1466
|
+
if (!metadata) return null;
|
|
1467
|
+
const ap = asRecord(metadata.agentplanet);
|
|
1468
|
+
if (!ap) return null;
|
|
1469
|
+
const chatId = asNonEmptyString(ap.chat_id);
|
|
1470
|
+
const replyPath = asNonEmptyString(ap.reply_path);
|
|
1471
|
+
const replyChannel = asNonEmptyString(ap.reply_channel);
|
|
1472
|
+
if (!chatId || !replyPath) return null;
|
|
1473
|
+
if (replyChannel !== CHAT_REPLY_CHANNEL) return null;
|
|
1474
|
+
if (!isAllowedChatReplyPath(chatId, replyPath)) return null;
|
|
1475
|
+
return {
|
|
1476
|
+
chat_id: chatId,
|
|
1477
|
+
reply_path: replyPath,
|
|
1478
|
+
reply_channel: CHAT_REPLY_CHANNEL,
|
|
1479
|
+
gateway_message_id: asNonEmptyString(ap.message_id) ?? asNonEmptyString(ap.messageId),
|
|
1480
|
+
user_text: extractUserText(message)
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
function normalizeEvent(body, opts = {}) {
|
|
1484
|
+
const generateId = opts.generateId ?? (() => crypto.randomUUID());
|
|
1485
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
1486
|
+
const params = asRecord(body.params);
|
|
1487
|
+
const message = asRecord(params?.message) ?? {};
|
|
1488
|
+
return {
|
|
1489
|
+
event_type: "a2a_message",
|
|
1490
|
+
task_id: extractTaskId(message),
|
|
1491
|
+
message_id: extractMessageId(message, generateId),
|
|
1492
|
+
context_id: extractContextId(message),
|
|
1493
|
+
from_agent: extractFromAgent(message),
|
|
1494
|
+
chat: extractChatEnvelope(message),
|
|
1495
|
+
received_at: now().toISOString(),
|
|
1496
|
+
raw: body
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
function dedupeKey(event) {
|
|
1500
|
+
if (event.chat) {
|
|
1501
|
+
const mid = event.chat.gateway_message_id ?? event.message_id;
|
|
1502
|
+
return `chat:${event.chat.chat_id}:${mid}`;
|
|
1503
|
+
}
|
|
1504
|
+
return event.task_id ?? event.message_id;
|
|
1505
|
+
}
|
|
1506
|
+
var DedupeStore = class {
|
|
1507
|
+
constructor(ttlSec) {
|
|
1508
|
+
this.ttlSec = ttlSec;
|
|
1509
|
+
}
|
|
1510
|
+
ttlSec;
|
|
1511
|
+
map = /* @__PURE__ */ new Map();
|
|
1512
|
+
/** Returns true if key was already seen within TTL; otherwise marks and returns false. */
|
|
1513
|
+
isDuplicate(key, nowMs = Date.now()) {
|
|
1514
|
+
this.gc(nowMs);
|
|
1515
|
+
const exp = this.map.get(key);
|
|
1516
|
+
if (exp !== void 0 && exp > nowMs) return true;
|
|
1517
|
+
this.map.set(key, nowMs + this.ttlSec * 1e3);
|
|
1518
|
+
return false;
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Drop a key so a later retry can wake again.
|
|
1522
|
+
* Used when wake fails after we reserved the slot on accept.
|
|
1523
|
+
*/
|
|
1524
|
+
forget(key) {
|
|
1525
|
+
this.map.delete(key);
|
|
1526
|
+
}
|
|
1527
|
+
/** Test helper — current window size after GC. */
|
|
1528
|
+
size(nowMs = Date.now()) {
|
|
1529
|
+
this.gc(nowMs);
|
|
1530
|
+
return this.map.size;
|
|
1531
|
+
}
|
|
1532
|
+
gc(nowMs) {
|
|
1533
|
+
for (const [k, exp] of this.map) {
|
|
1534
|
+
if (exp <= nowMs) this.map.delete(k);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
// src/commands/chat-writeback.ts
|
|
1540
|
+
var DEFAULT_COMPLETE_TIMEOUT_MS = 12e4;
|
|
1541
|
+
var DEFAULT_WRITEBACK_TIMEOUT_MS = 3e4;
|
|
1542
|
+
function asRecord2(v) {
|
|
1543
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
1544
|
+
}
|
|
1545
|
+
function validateChatWritebackOptions(opts) {
|
|
1546
|
+
if (!opts.chatWriteback) return null;
|
|
1547
|
+
if (!opts.agentId) {
|
|
1548
|
+
return "--chat-writeback requires a known agent id (join / --agent-id).";
|
|
1549
|
+
}
|
|
1550
|
+
if (!opts.chatApiBase?.trim()) {
|
|
1551
|
+
return "--chat-writeback requires --chat-api-base (or ACN_CHAT_API_BASE / AGENTPLANET_API_BASE).";
|
|
1552
|
+
}
|
|
1553
|
+
if (!opts.chatToken?.trim()) {
|
|
1554
|
+
return "--chat-writeback requires --chat-token (or ACN_CHAT_WRITEBACK_TOKEN / AGENTPLANET_INTERNAL_TOKEN / AGENTPLANET_INTERNAL_API_TOKEN).";
|
|
1555
|
+
}
|
|
1556
|
+
const hasUrl = Boolean(opts.chatCompleteUrl?.trim());
|
|
1557
|
+
const hasExec = Boolean(opts.chatCompleteExec?.trim());
|
|
1558
|
+
if (hasUrl === hasExec) {
|
|
1559
|
+
return '--chat-writeback requires exactly one of --chat-complete-url or --chat-complete-exec (host returns JSON {"content":"..."}).';
|
|
1560
|
+
}
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
function buildChatWritebackOptions(opts) {
|
|
1564
|
+
if (!opts.chatWriteback) return void 0;
|
|
1565
|
+
return {
|
|
1566
|
+
enabled: true,
|
|
1567
|
+
apiBase: opts.chatApiBase.replace(/\/+$/, ""),
|
|
1568
|
+
token: opts.chatToken,
|
|
1569
|
+
agentId: opts.agentId,
|
|
1570
|
+
completeUrl: opts.chatCompleteUrl?.trim() || void 0,
|
|
1571
|
+
completeExec: opts.chatCompleteExec?.trim() || void 0,
|
|
1572
|
+
completeTimeoutMs: opts.chatCompleteTimeoutMs,
|
|
1573
|
+
writebackTimeoutMs: opts.chatWritebackTimeoutMs
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
function extractContent(payload) {
|
|
1577
|
+
const rec = asRecord2(payload);
|
|
1578
|
+
if (!rec) return null;
|
|
1579
|
+
for (const key of ["content", "reply", "text"]) {
|
|
1580
|
+
const v = rec[key];
|
|
1581
|
+
if (typeof v === "string" && v.trim()) {
|
|
1582
|
+
const t = v.trim();
|
|
1583
|
+
if (t.toLowerCase() === "accepted") continue;
|
|
1584
|
+
return t;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
return null;
|
|
1588
|
+
}
|
|
1589
|
+
async function completeViaHttp(event, opts, deps) {
|
|
1590
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
1591
|
+
const timeoutMs = opts.completeTimeoutMs ?? DEFAULT_COMPLETE_TIMEOUT_MS;
|
|
1592
|
+
const controller = new AbortController();
|
|
1593
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1594
|
+
try {
|
|
1595
|
+
const res = await fetchFn(opts.completeUrl, {
|
|
1596
|
+
method: "POST",
|
|
1597
|
+
headers: { "content-type": "application/json" },
|
|
1598
|
+
body: JSON.stringify(event),
|
|
1599
|
+
signal: controller.signal
|
|
1600
|
+
});
|
|
1601
|
+
const text = await res.text();
|
|
1602
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1603
|
+
return { ok: false, reason: `complete_http_${res.status}` };
|
|
1604
|
+
}
|
|
1605
|
+
let parsed;
|
|
1606
|
+
try {
|
|
1607
|
+
parsed = JSON.parse(text);
|
|
1608
|
+
} catch {
|
|
1609
|
+
return { ok: false, reason: "complete_invalid_json" };
|
|
1610
|
+
}
|
|
1611
|
+
const content = extractContent(parsed);
|
|
1612
|
+
if (!content) return { ok: false, reason: "complete_missing_content" };
|
|
1613
|
+
return { ok: true, content };
|
|
1614
|
+
} catch (err) {
|
|
1615
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1616
|
+
if (controller.signal.aborted || /abort|timeout/i.test(msg)) {
|
|
1617
|
+
return { ok: false, reason: "complete_timeout" };
|
|
1618
|
+
}
|
|
1619
|
+
return { ok: false, reason: msg.slice(0, 200) };
|
|
1620
|
+
} finally {
|
|
1621
|
+
clearTimeout(timer);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
function completeViaExec(event, opts, deps) {
|
|
1625
|
+
const spawnFn = deps.spawnFn ?? import_child_process.spawn;
|
|
1626
|
+
const timeoutMs = opts.completeTimeoutMs ?? DEFAULT_COMPLETE_TIMEOUT_MS;
|
|
1627
|
+
const body = Buffer.from(JSON.stringify(event), "utf-8");
|
|
1628
|
+
return new Promise((resolve) => {
|
|
1629
|
+
let settled = false;
|
|
1630
|
+
const child = spawnFn(opts.completeExec, { shell: true });
|
|
1631
|
+
const stdout = [];
|
|
1632
|
+
const stderr = [];
|
|
1633
|
+
const finish = (result) => {
|
|
1634
|
+
if (settled) return;
|
|
1635
|
+
settled = true;
|
|
1636
|
+
clearTimeout(timer);
|
|
1637
|
+
resolve(result);
|
|
1638
|
+
};
|
|
1639
|
+
const timer = setTimeout(() => {
|
|
1640
|
+
child.kill("SIGTERM");
|
|
1641
|
+
finish({ ok: false, reason: "complete_timeout" });
|
|
1642
|
+
}, timeoutMs);
|
|
1643
|
+
child.stdout?.on("data", (d) => stdout.push(Buffer.from(d)));
|
|
1644
|
+
child.stderr?.on("data", (d) => stderr.push(Buffer.from(d)));
|
|
1645
|
+
child.on(
|
|
1646
|
+
"error",
|
|
1647
|
+
(e) => finish({ ok: false, reason: e.message.slice(0, 200) })
|
|
1648
|
+
);
|
|
1649
|
+
child.on("close", (code) => {
|
|
1650
|
+
if (code !== 0) {
|
|
1651
|
+
const detail = Buffer.concat(stderr).toString("utf-8").slice(0, 80);
|
|
1652
|
+
finish({
|
|
1653
|
+
ok: false,
|
|
1654
|
+
reason: detail ? `complete_exit_${code}:${detail}` : `complete_exit_${code}`
|
|
1655
|
+
});
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const text = Buffer.concat(stdout).toString("utf-8").trim();
|
|
1659
|
+
try {
|
|
1660
|
+
const parsed = JSON.parse(text);
|
|
1661
|
+
const content = extractContent(parsed);
|
|
1662
|
+
if (!content) {
|
|
1663
|
+
finish({ ok: false, reason: "complete_missing_content" });
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
finish({ ok: true, content });
|
|
1667
|
+
} catch {
|
|
1668
|
+
finish({ ok: false, reason: "complete_invalid_json" });
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1671
|
+
child.stdin?.end(body);
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
async function postWriteback(event, content, opts, deps) {
|
|
1675
|
+
const chat = event.chat;
|
|
1676
|
+
if (!chat) return { ok: false, reason: "no_chat_envelope" };
|
|
1677
|
+
if (!isAllowedChatReplyPath(chat.chat_id, chat.reply_path)) {
|
|
1678
|
+
return { ok: false, reason: "reply_path_rejected" };
|
|
1679
|
+
}
|
|
1680
|
+
if (chat.reply_channel !== "agentplanet.chat") {
|
|
1681
|
+
return { ok: false, reason: "reply_channel_rejected" };
|
|
1682
|
+
}
|
|
1683
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
1684
|
+
const path = chat.reply_path;
|
|
1685
|
+
let url;
|
|
1686
|
+
let baseOrigin;
|
|
1687
|
+
try {
|
|
1688
|
+
baseOrigin = new URL(opts.apiBase).origin;
|
|
1689
|
+
url = new URL(`${opts.apiBase}${path}`);
|
|
1690
|
+
} catch {
|
|
1691
|
+
return { ok: false, reason: "invalid_api_base" };
|
|
1692
|
+
}
|
|
1693
|
+
if (url.origin !== baseOrigin) {
|
|
1694
|
+
return { ok: false, reason: "reply_url_origin_mismatch" };
|
|
1695
|
+
}
|
|
1696
|
+
url.searchParams.set("agent_id", opts.agentId);
|
|
1697
|
+
const timeoutMs = opts.writebackTimeoutMs ?? DEFAULT_WRITEBACK_TIMEOUT_MS;
|
|
1698
|
+
const controller = new AbortController();
|
|
1699
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1700
|
+
try {
|
|
1701
|
+
const res = await fetchFn(url.toString(), {
|
|
1702
|
+
method: "POST",
|
|
1703
|
+
headers: {
|
|
1704
|
+
"content-type": "application/json",
|
|
1705
|
+
"X-Internal-Token": opts.token
|
|
1706
|
+
},
|
|
1707
|
+
body: JSON.stringify({ content }),
|
|
1708
|
+
signal: controller.signal
|
|
1709
|
+
});
|
|
1710
|
+
if (res.status === 200 || res.status === 201) {
|
|
1711
|
+
return { ok: true, httpStatus: res.status };
|
|
1712
|
+
}
|
|
1713
|
+
try {
|
|
1714
|
+
await res.arrayBuffer();
|
|
1715
|
+
} catch {
|
|
1716
|
+
}
|
|
1717
|
+
return { ok: false, reason: `writeback_http_${res.status}` };
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1720
|
+
if (controller.signal.aborted || /abort|timeout/i.test(msg)) {
|
|
1721
|
+
return { ok: false, reason: "writeback_timeout" };
|
|
1722
|
+
}
|
|
1723
|
+
return { ok: false, reason: msg.slice(0, 200) };
|
|
1724
|
+
} finally {
|
|
1725
|
+
clearTimeout(timer);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
async function handleChatWriteback(event, opts, deps = {}) {
|
|
1729
|
+
const logFn = deps.logFn ?? ((line) => console.error(line));
|
|
1730
|
+
if (!event.chat) return { ok: false, reason: "no_chat_envelope" };
|
|
1731
|
+
const completed = opts.completeUrl ? await completeViaHttp(event, opts, deps) : await completeViaExec(event, opts, deps);
|
|
1732
|
+
if (!completed.ok) {
|
|
1733
|
+
logFn(
|
|
1734
|
+
`[acn listen] chat_complete_failed chat_id=${event.chat.chat_id} message_id=${event.message_id} reason=${completed.reason}`
|
|
1735
|
+
);
|
|
1736
|
+
return { ok: false, reason: completed.reason };
|
|
1737
|
+
}
|
|
1738
|
+
const written = await postWriteback(event, completed.content, opts, deps);
|
|
1739
|
+
if (!written.ok) {
|
|
1740
|
+
logFn(
|
|
1741
|
+
`[acn listen] chat_writeback_failed chat_id=${event.chat.chat_id} message_id=${event.message_id} reason=${written.reason}`
|
|
1742
|
+
);
|
|
1743
|
+
return written;
|
|
1744
|
+
}
|
|
1745
|
+
logFn(
|
|
1746
|
+
`[acn listen] chat_writeback_ok chat_id=${event.chat.chat_id} message_id=${event.message_id} http=${written.httpStatus}`
|
|
1747
|
+
);
|
|
1748
|
+
return written;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// src/commands/local-receiver.ts
|
|
1752
|
+
var import_crypto = require("crypto");
|
|
1753
|
+
|
|
1754
|
+
// src/commands/runtime-adapter.ts
|
|
1755
|
+
var import_child_process2 = require("child_process");
|
|
1756
|
+
var DEFAULT_WAKE_TIMEOUT_MS = 5e3;
|
|
1757
|
+
function parseWakeHeaders(raw) {
|
|
1758
|
+
const out = {};
|
|
1759
|
+
for (const item of raw ?? []) {
|
|
1760
|
+
const idx = item.indexOf(":");
|
|
1761
|
+
if (idx <= 0) {
|
|
1762
|
+
throw new Error(
|
|
1763
|
+
`Invalid --wake-header "${item}". Expected "Header-Name: value".`
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
const key = item.slice(0, idx).trim();
|
|
1767
|
+
const value = item.slice(idx + 1).trim();
|
|
1768
|
+
if (!key) {
|
|
1769
|
+
throw new Error(
|
|
1770
|
+
`Invalid --wake-header "${item}". Expected "Header-Name: value".`
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
out[key] = value;
|
|
1774
|
+
}
|
|
1775
|
+
return out;
|
|
1776
|
+
}
|
|
1777
|
+
function validateRuntimeOptions(opts) {
|
|
1778
|
+
if (!opts.runtime) return null;
|
|
1779
|
+
if (opts.runtime !== "http" && opts.runtime !== "command" && opts.runtime !== "log") {
|
|
1780
|
+
return `Unknown --runtime "${opts.runtime}". Use: http | command | log`;
|
|
1781
|
+
}
|
|
1782
|
+
if (opts.runtime === "http" && !opts.wakeUrl) {
|
|
1783
|
+
return "--runtime http requires --wake-url <url>";
|
|
1784
|
+
}
|
|
1785
|
+
if (opts.runtime === "command" && !opts.wakeExec) {
|
|
1786
|
+
return "--runtime command requires --wake-exec <cmd>";
|
|
1787
|
+
}
|
|
1788
|
+
return null;
|
|
1789
|
+
}
|
|
1790
|
+
async function wakeHttp(event, opts, deps) {
|
|
1791
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
1792
|
+
const timeoutMs = opts.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS;
|
|
1793
|
+
const controller = new AbortController();
|
|
1794
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1795
|
+
try {
|
|
1796
|
+
const res = await fetchFn(opts.wakeUrl, {
|
|
1797
|
+
method: "POST",
|
|
1798
|
+
headers: {
|
|
1799
|
+
"content-type": "application/json",
|
|
1800
|
+
...opts.wakeHeaders ?? {}
|
|
1801
|
+
},
|
|
1802
|
+
body: JSON.stringify(event),
|
|
1803
|
+
signal: controller.signal
|
|
1804
|
+
});
|
|
1805
|
+
try {
|
|
1806
|
+
await res.arrayBuffer();
|
|
1807
|
+
} catch {
|
|
1808
|
+
}
|
|
1809
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1810
|
+
return { ok: false, reason: `http_${res.status}` };
|
|
1811
|
+
}
|
|
1812
|
+
return { ok: true };
|
|
1813
|
+
} catch (err) {
|
|
1814
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1815
|
+
if (controller.signal.aborted || /abort|timeout/i.test(msg)) {
|
|
1816
|
+
return { ok: false, reason: "timeout" };
|
|
1817
|
+
}
|
|
1818
|
+
return { ok: false, reason: msg.slice(0, 200) };
|
|
1819
|
+
} finally {
|
|
1820
|
+
clearTimeout(timer);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
function wakeCommand(event, opts, deps) {
|
|
1824
|
+
const spawnFn = deps.spawnFn ?? import_child_process2.spawn;
|
|
1825
|
+
const timeoutMs = opts.wakeTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS;
|
|
1826
|
+
const body = Buffer.from(JSON.stringify(event), "utf-8");
|
|
1827
|
+
return new Promise((resolve) => {
|
|
1828
|
+
let settled = false;
|
|
1829
|
+
const child = spawnFn(opts.wakeExec, { shell: true });
|
|
1830
|
+
const errOut = [];
|
|
1831
|
+
const finish = (result) => {
|
|
1832
|
+
if (settled) return;
|
|
1833
|
+
settled = true;
|
|
1834
|
+
clearTimeout(timer);
|
|
1835
|
+
resolve(result);
|
|
1836
|
+
};
|
|
1837
|
+
const timer = setTimeout(() => {
|
|
1838
|
+
child.kill("SIGTERM");
|
|
1839
|
+
finish({ ok: false, reason: "timeout" });
|
|
1840
|
+
}, timeoutMs);
|
|
1841
|
+
child.stderr?.on("data", (d) => errOut.push(Buffer.from(d)));
|
|
1842
|
+
child.on(
|
|
1843
|
+
"error",
|
|
1844
|
+
(e) => finish({ ok: false, reason: e.message.slice(0, 200) })
|
|
1845
|
+
);
|
|
1846
|
+
child.on("close", (code) => {
|
|
1847
|
+
if (code === 0) finish({ ok: true });
|
|
1848
|
+
else {
|
|
1849
|
+
const detail = Buffer.concat(errOut).toString("utf-8").slice(0, 80);
|
|
1850
|
+
finish({
|
|
1851
|
+
ok: false,
|
|
1852
|
+
reason: detail ? `exit_${code}:${detail}` : `exit_${code}`
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
});
|
|
1856
|
+
child.stdin?.end(body);
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
function wakeLog(event, deps) {
|
|
1860
|
+
const logFn = deps.logFn ?? ((line) => console.error(line));
|
|
1861
|
+
logFn(JSON.stringify(event));
|
|
1862
|
+
return { ok: true };
|
|
1863
|
+
}
|
|
1864
|
+
async function wakeRuntime(event, opts, deps = {}) {
|
|
1865
|
+
switch (opts.runtime) {
|
|
1866
|
+
case "http":
|
|
1867
|
+
return wakeHttp(event, opts, deps);
|
|
1868
|
+
case "command":
|
|
1869
|
+
return wakeCommand(event, opts, deps);
|
|
1870
|
+
case "log":
|
|
1871
|
+
return wakeLog(event, deps);
|
|
1872
|
+
default:
|
|
1873
|
+
return { ok: false, reason: `unknown_runtime` };
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// src/commands/local-receiver.ts
|
|
1878
|
+
var HANDLED_METHODS = /* @__PURE__ */ new Set(["message/send", "message/stream"]);
|
|
1879
|
+
function jsonRpcResponse(correlationId, jsonrpcId, payload) {
|
|
1880
|
+
return {
|
|
1881
|
+
type: "a2a_response",
|
|
1882
|
+
id: correlationId,
|
|
1883
|
+
status: 200,
|
|
1884
|
+
headers: { "content-type": "application/json" },
|
|
1885
|
+
body: JSON.stringify({
|
|
1886
|
+
jsonrpc: "2.0",
|
|
1887
|
+
id: jsonrpcId ?? null,
|
|
1888
|
+
...payload
|
|
1889
|
+
})
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
function acceptedMessage(correlationId, jsonrpcId, messageId) {
|
|
1893
|
+
return jsonRpcResponse(correlationId, jsonrpcId, {
|
|
1894
|
+
result: {
|
|
1895
|
+
kind: "message",
|
|
1896
|
+
messageId,
|
|
1897
|
+
role: "agent",
|
|
1898
|
+
parts: [{ kind: "text", text: "accepted" }]
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
function jsonRpcError(correlationId, jsonrpcId, code, message) {
|
|
1903
|
+
return jsonRpcResponse(correlationId, jsonrpcId, {
|
|
1904
|
+
error: { code, message }
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
function processIncomingRequest(correlationId, bodyText, opts, dedupeStore, deps = {}) {
|
|
1908
|
+
const generateId = deps.generateId ?? (() => (0, import_crypto.randomUUID)());
|
|
1909
|
+
const parsed = parseJsonRpcBody(bodyText);
|
|
1910
|
+
if (!parsed.ok) {
|
|
1911
|
+
return {
|
|
1912
|
+
response: jsonRpcError(correlationId, null, parsed.code, parsed.message),
|
|
1913
|
+
event: null,
|
|
1914
|
+
shouldWake: false,
|
|
1915
|
+
dedupeHit: false
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
const { body } = parsed;
|
|
1919
|
+
const jsonrpcId = body.id ?? null;
|
|
1920
|
+
const method = body.method;
|
|
1921
|
+
if (!HANDLED_METHODS.has(method)) {
|
|
1922
|
+
return {
|
|
1923
|
+
response: jsonRpcError(
|
|
1924
|
+
correlationId,
|
|
1925
|
+
jsonrpcId,
|
|
1926
|
+
-32601,
|
|
1927
|
+
`Method not found: ${method}`
|
|
1928
|
+
),
|
|
1929
|
+
event: null,
|
|
1930
|
+
shouldWake: false,
|
|
1931
|
+
dedupeHit: false
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
const event = normalizeEvent(body, {
|
|
1935
|
+
generateId,
|
|
1936
|
+
now: deps.now
|
|
1937
|
+
});
|
|
1938
|
+
const replyMessageId = generateId();
|
|
1939
|
+
const response = acceptedMessage(correlationId, jsonrpcId, replyMessageId);
|
|
1940
|
+
if (opts.dedupe) {
|
|
1941
|
+
const key = dedupeKey(event);
|
|
1942
|
+
if (dedupeStore.isDuplicate(key)) {
|
|
1943
|
+
return { response, event, shouldWake: false, dedupeHit: true };
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
return { response, event, shouldWake: true, dedupeHit: false };
|
|
1947
|
+
}
|
|
1948
|
+
function formatWakeFailed(event, reason) {
|
|
1949
|
+
const task = event.task_id ?? "-";
|
|
1950
|
+
return `[acn listen] wake_failed message_id=${event.message_id} task_id=${task} reason=${reason}`;
|
|
1951
|
+
}
|
|
1952
|
+
function formatDeduped(event) {
|
|
1953
|
+
return `[acn listen] deduped key=${dedupeKey(event)}`;
|
|
1954
|
+
}
|
|
1955
|
+
function dispatchLocalReceiver(correlationId, bodyText, opts, dedupeStore, send, deps = {}) {
|
|
1956
|
+
const logFn = deps.logFn ?? ((line) => console.error(line));
|
|
1957
|
+
const result = processIncomingRequest(
|
|
1958
|
+
correlationId,
|
|
1959
|
+
bodyText,
|
|
1960
|
+
opts,
|
|
1961
|
+
dedupeStore,
|
|
1962
|
+
deps
|
|
1963
|
+
);
|
|
1964
|
+
send(result.response);
|
|
1965
|
+
if (result.dedupeHit && result.event) {
|
|
1966
|
+
logFn(formatDeduped(result.event));
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
if (!result.shouldWake || !result.event) return;
|
|
1970
|
+
const event = result.event;
|
|
1971
|
+
const key = opts.dedupe ? dedupeKey(event) : null;
|
|
1972
|
+
if (event.chat && opts.chatWriteback?.enabled) {
|
|
1973
|
+
void handleChatWriteback(event, opts.chatWriteback, deps).then((written) => {
|
|
1974
|
+
if (!written.ok && key) dedupeStore.forget(key);
|
|
1975
|
+
}).catch((err) => {
|
|
1976
|
+
if (key) dedupeStore.forget(key);
|
|
1977
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1978
|
+
logFn(
|
|
1979
|
+
`[acn listen] chat_writeback_failed message_id=${event.message_id} reason=${msg.slice(0, 200)}`
|
|
1980
|
+
);
|
|
1981
|
+
});
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
void wakeRuntime(event, opts, deps).then((wake) => {
|
|
1985
|
+
if (!wake.ok) {
|
|
1986
|
+
if (key) dedupeStore.forget(key);
|
|
1987
|
+
logFn(formatWakeFailed(event, wake.reason));
|
|
1988
|
+
}
|
|
1989
|
+
}).catch((err) => {
|
|
1990
|
+
if (key) dedupeStore.forget(key);
|
|
1991
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1992
|
+
logFn(formatWakeFailed(event, msg.slice(0, 200)));
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// src/commands/listen.ts
|
|
1380
1997
|
var STRIP_HEADERS = /* @__PURE__ */ new Set([
|
|
1381
1998
|
"host",
|
|
1382
1999
|
"content-length",
|
|
@@ -1408,12 +2025,28 @@ function errorResponse(id, status, detail) {
|
|
|
1408
2025
|
async function dispatchA2aRequest(frame, opts, send, deps = {}) {
|
|
1409
2026
|
const bodyBuf = decodeBody(frame);
|
|
1410
2027
|
try {
|
|
2028
|
+
if (opts.runtime) {
|
|
2029
|
+
const store = deps.dedupeStore ?? new DedupeStore(opts.runtime.dedupeTtlSec);
|
|
2030
|
+
dispatchLocalReceiver(
|
|
2031
|
+
frame.id,
|
|
2032
|
+
bodyBuf.toString("utf-8"),
|
|
2033
|
+
opts.runtime,
|
|
2034
|
+
store,
|
|
2035
|
+
send,
|
|
2036
|
+
{
|
|
2037
|
+
fetchFn: deps.fetchFn,
|
|
2038
|
+
spawnFn: deps.spawnFn,
|
|
2039
|
+
logFn: deps.logFn
|
|
2040
|
+
}
|
|
2041
|
+
);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
1411
2044
|
if (opts.forward) {
|
|
1412
2045
|
await forwardToHttp(frame, bodyBuf, opts.forward, deps.fetchFn ?? fetch, send);
|
|
1413
2046
|
return;
|
|
1414
2047
|
}
|
|
1415
2048
|
if (opts.exec) {
|
|
1416
|
-
send(await runExec(frame, bodyBuf, opts.exec, deps.spawnFn ??
|
|
2049
|
+
send(await runExec(frame, bodyBuf, opts.exec, deps.spawnFn ?? import_child_process3.spawn));
|
|
1417
2050
|
return;
|
|
1418
2051
|
}
|
|
1419
2052
|
send(errorResponse(frame.id, 500, "no handler configured"));
|
|
@@ -1518,13 +2151,15 @@ function runListener(cfg) {
|
|
|
1518
2151
|
const wsUrl = toWebsocketUrl(cfg.baseUrl, cfg.agentId);
|
|
1519
2152
|
let backoff = INITIAL_BACKOFF_MS;
|
|
1520
2153
|
let stopped = false;
|
|
2154
|
+
const dedupeStore = cfg.runtime ? new DedupeStore(cfg.runtime.dedupeTtlSec) : void 0;
|
|
1521
2155
|
const connect = () => {
|
|
1522
2156
|
const ws = new import_ws.default(wsUrl, {
|
|
1523
2157
|
headers: { Authorization: `Bearer ${cfg.apiKey}` }
|
|
1524
2158
|
});
|
|
1525
2159
|
let keepalive;
|
|
1526
2160
|
ws.on("open", () => {
|
|
1527
|
-
|
|
2161
|
+
const mode = cfg.runtime ? `runtime=${cfg.runtime.runtime}` : cfg.forward ? `forward=${cfg.forward}` : `exec`;
|
|
2162
|
+
console.error(`[acn listen] connected as ${cfg.agentId} \u2192 ${wsUrl} (${mode})`);
|
|
1528
2163
|
backoff = INITIAL_BACKOFF_MS;
|
|
1529
2164
|
keepalive = setInterval(() => {
|
|
1530
2165
|
if (ws.readyState === import_ws.default.OPEN) {
|
|
@@ -1547,7 +2182,7 @@ function runListener(cfg) {
|
|
|
1547
2182
|
ws.send(JSON.stringify(out));
|
|
1548
2183
|
}
|
|
1549
2184
|
};
|
|
1550
|
-
void dispatchA2aRequest(f, cfg, send);
|
|
2185
|
+
void dispatchA2aRequest(f, cfg, send, { dedupeStore });
|
|
1551
2186
|
}
|
|
1552
2187
|
});
|
|
1553
2188
|
ws.on("close", (code, reason) => {
|
|
@@ -1576,47 +2211,171 @@ function runListener(cfg) {
|
|
|
1576
2211
|
});
|
|
1577
2212
|
connect();
|
|
1578
2213
|
}
|
|
2214
|
+
function collectWakeHeader(value, previous) {
|
|
2215
|
+
previous.push(value);
|
|
2216
|
+
return previous;
|
|
2217
|
+
}
|
|
2218
|
+
function validateListenHandlerFlags(opts) {
|
|
2219
|
+
const modes = [opts.runtime, opts.forward, opts.exec].filter(Boolean);
|
|
2220
|
+
if (modes.length === 0) {
|
|
2221
|
+
return "Provide a handler: --runtime http|command|log (recommended), or legacy --forward <url> / --exec <command>.";
|
|
2222
|
+
}
|
|
2223
|
+
if (modes.length > 1) {
|
|
2224
|
+
return "Use only one handler: --runtime, --forward, or --exec \u2014 not combined.";
|
|
2225
|
+
}
|
|
2226
|
+
return validateRuntimeOptions({
|
|
2227
|
+
runtime: opts.runtime,
|
|
2228
|
+
wakeUrl: opts.wakeUrl,
|
|
2229
|
+
wakeExec: opts.wakeExec
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
1579
2232
|
function listenCommand() {
|
|
1580
2233
|
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).
|
|
2234
|
+
"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."
|
|
2235
|
+
).option(
|
|
2236
|
+
"--runtime <id>",
|
|
2237
|
+
"Built-in A2A receiver + wake host: http | command | log (no local A2A port)"
|
|
2238
|
+
).option("--wake-url <url>", "POST target for --runtime http").option(
|
|
2239
|
+
"--wake-header <k:v>",
|
|
2240
|
+
"Extra header for --runtime http (repeatable)",
|
|
2241
|
+
collectWakeHeader,
|
|
2242
|
+
[]
|
|
2243
|
+
).option(
|
|
2244
|
+
"--wake-exec <cmd>",
|
|
2245
|
+
"Shell command for --runtime command (event JSON on stdin). Not the same as legacy --exec (which must print a full A2A response)."
|
|
1582
2246
|
).option(
|
|
2247
|
+
"--wake-timeout <ms>",
|
|
2248
|
+
"Wake timeout in ms (default 5000)",
|
|
2249
|
+
String(DEFAULT_WAKE_TIMEOUT_MS)
|
|
2250
|
+
).option("--no-dedupe", "Disable in-process task/message id dedupe (default: on)").option("--dedupe-ttl <sec>", "Dedupe window seconds (default 3600)", "3600").option(
|
|
1583
2251
|
"--forward <url>",
|
|
1584
|
-
"
|
|
2252
|
+
"Compat: tunnel each request to a local A2A HTTP server"
|
|
1585
2253
|
).option(
|
|
1586
2254
|
"--exec <command>",
|
|
1587
|
-
"
|
|
1588
|
-
).option(
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2255
|
+
"Compat: shell per request; stdout must be a full A2A JSON-RPC response"
|
|
2256
|
+
).option(
|
|
2257
|
+
"--chat-writeback",
|
|
2258
|
+
"On Chat Gateway envelopes: complete host reply then POST agent-messages (requires --runtime)"
|
|
2259
|
+
).option(
|
|
2260
|
+
"--chat-api-base <url>",
|
|
2261
|
+
"Chat Gateway origin for writeback (env: ACN_CHAT_API_BASE or AGENTPLANET_API_BASE)"
|
|
2262
|
+
).option(
|
|
2263
|
+
"--chat-token <token>",
|
|
2264
|
+
"X-Internal-Token for agent-messages (env: ACN_CHAT_WRITEBACK_TOKEN, AGENTPLANET_INTERNAL_TOKEN, or AGENTPLANET_INTERNAL_API_TOKEN)"
|
|
2265
|
+
).option(
|
|
2266
|
+
"--chat-complete-url <url>",
|
|
2267
|
+
'POST NormalizedEvent \u2192 JSON {"content":"..."} (mutually exclusive with --chat-complete-exec)'
|
|
2268
|
+
).option(
|
|
2269
|
+
"--chat-complete-exec <cmd>",
|
|
2270
|
+
'Shell: event JSON on stdin \u2192 stdout JSON {"content":"..."}'
|
|
2271
|
+
).option(
|
|
2272
|
+
"--chat-complete-timeout <ms>",
|
|
2273
|
+
`Host complete timeout in ms (default ${DEFAULT_COMPLETE_TIMEOUT_MS})`,
|
|
2274
|
+
String(DEFAULT_COMPLETE_TIMEOUT_MS)
|
|
2275
|
+
).option("-i, --agent-id <id>", "Agent ID (defaults to config)").action(
|
|
2276
|
+
(opts) => {
|
|
2277
|
+
const config = loadConfig();
|
|
2278
|
+
const apiKey = config.api_key;
|
|
2279
|
+
const agentId = opts.agentId ?? config.agent_id;
|
|
2280
|
+
if (!apiKey) {
|
|
2281
|
+
console.error(
|
|
2282
|
+
"No API key found. Run `acn join` first or `acn config set api-key <key>`."
|
|
2283
|
+
);
|
|
2284
|
+
process.exit(1);
|
|
2285
|
+
}
|
|
2286
|
+
if (!agentId) {
|
|
2287
|
+
console.error(
|
|
2288
|
+
"No agent ID found. Run `acn join` first or `acn config set agent-id <id>`."
|
|
2289
|
+
);
|
|
2290
|
+
process.exit(1);
|
|
2291
|
+
}
|
|
2292
|
+
const flagErr = validateListenHandlerFlags({
|
|
2293
|
+
runtime: opts.runtime,
|
|
2294
|
+
forward: opts.forward,
|
|
2295
|
+
exec: opts.exec,
|
|
2296
|
+
wakeUrl: opts.wakeUrl,
|
|
2297
|
+
wakeExec: opts.wakeExec
|
|
2298
|
+
});
|
|
2299
|
+
if (flagErr) {
|
|
2300
|
+
console.error(flagErr);
|
|
2301
|
+
process.exit(1);
|
|
2302
|
+
}
|
|
2303
|
+
const chatApiBase = opts.chatApiBase?.trim() || process.env.ACN_CHAT_API_BASE?.trim() || process.env.AGENTPLANET_API_BASE?.trim();
|
|
2304
|
+
const chatToken = opts.chatToken?.trim() || process.env.ACN_CHAT_WRITEBACK_TOKEN?.trim() || process.env.AGENTPLANET_INTERNAL_TOKEN?.trim() || process.env.AGENTPLANET_INTERNAL_API_TOKEN?.trim();
|
|
2305
|
+
const chatCompleteUrl = opts.chatCompleteUrl?.trim() || process.env.ACN_CHAT_COMPLETE_URL?.trim();
|
|
2306
|
+
const chatCompleteExec = opts.chatCompleteExec?.trim();
|
|
2307
|
+
if (opts.chatWriteback && !opts.runtime) {
|
|
2308
|
+
console.error("--chat-writeback requires --runtime http|command|log.");
|
|
2309
|
+
process.exit(1);
|
|
2310
|
+
}
|
|
2311
|
+
const chatErr = validateChatWritebackOptions({
|
|
2312
|
+
chatWriteback: opts.chatWriteback,
|
|
2313
|
+
chatApiBase,
|
|
2314
|
+
chatToken,
|
|
2315
|
+
chatCompleteUrl,
|
|
2316
|
+
chatCompleteExec,
|
|
2317
|
+
agentId
|
|
2318
|
+
});
|
|
2319
|
+
if (chatErr) {
|
|
2320
|
+
console.error(chatErr);
|
|
2321
|
+
process.exit(1);
|
|
2322
|
+
}
|
|
2323
|
+
let wakeHeaders;
|
|
2324
|
+
if (opts.runtime === "http") {
|
|
2325
|
+
try {
|
|
2326
|
+
wakeHeaders = parseWakeHeaders(opts.wakeHeader);
|
|
2327
|
+
} catch (err) {
|
|
2328
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2329
|
+
process.exit(1);
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
const wakeTimeoutMs = Number.parseInt(opts.wakeTimeout ?? "", 10);
|
|
2333
|
+
const dedupeTtlSec = Number.parseInt(opts.dedupeTtl ?? "", 10);
|
|
2334
|
+
const chatCompleteTimeoutMs = Number.parseInt(
|
|
2335
|
+
opts.chatCompleteTimeout ?? "",
|
|
2336
|
+
10
|
|
1601
2337
|
);
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
2338
|
+
if (opts.runtime && (!Number.isFinite(wakeTimeoutMs) || wakeTimeoutMs <= 0)) {
|
|
2339
|
+
console.error("--wake-timeout must be a positive integer (ms).");
|
|
2340
|
+
process.exit(1);
|
|
2341
|
+
}
|
|
2342
|
+
if (opts.runtime && (!Number.isFinite(dedupeTtlSec) || dedupeTtlSec <= 0)) {
|
|
2343
|
+
console.error("--dedupe-ttl must be a positive integer (seconds).");
|
|
2344
|
+
process.exit(1);
|
|
2345
|
+
}
|
|
2346
|
+
if (opts.chatWriteback && (!Number.isFinite(chatCompleteTimeoutMs) || chatCompleteTimeoutMs <= 0)) {
|
|
2347
|
+
console.error("--chat-complete-timeout must be a positive integer (ms).");
|
|
2348
|
+
process.exit(1);
|
|
2349
|
+
}
|
|
2350
|
+
const chatWriteback = buildChatWritebackOptions({
|
|
2351
|
+
chatWriteback: opts.chatWriteback,
|
|
2352
|
+
chatApiBase,
|
|
2353
|
+
chatToken,
|
|
2354
|
+
chatCompleteUrl,
|
|
2355
|
+
chatCompleteExec,
|
|
2356
|
+
chatCompleteTimeoutMs: opts.chatWriteback ? chatCompleteTimeoutMs : void 0,
|
|
2357
|
+
agentId
|
|
2358
|
+
});
|
|
2359
|
+
runListener({
|
|
2360
|
+
agentId,
|
|
2361
|
+
apiKey,
|
|
2362
|
+
baseUrl: config.base_url,
|
|
2363
|
+
forward: opts.forward,
|
|
2364
|
+
exec: opts.exec,
|
|
2365
|
+
runtime: opts.runtime ? {
|
|
2366
|
+
runtime: opts.runtime,
|
|
2367
|
+
wakeUrl: opts.wakeUrl,
|
|
2368
|
+
wakeHeaders,
|
|
2369
|
+
wakeExec: opts.wakeExec,
|
|
2370
|
+
wakeTimeoutMs,
|
|
2371
|
+
// commander: --no-dedupe sets dedupe=false; default true
|
|
2372
|
+
dedupe: opts.dedupe !== false,
|
|
2373
|
+
dedupeTtlSec,
|
|
2374
|
+
chatWriteback
|
|
2375
|
+
} : void 0
|
|
2376
|
+
});
|
|
1611
2377
|
}
|
|
1612
|
-
|
|
1613
|
-
agentId,
|
|
1614
|
-
apiKey,
|
|
1615
|
-
baseUrl: config.base_url,
|
|
1616
|
-
forward: opts.forward,
|
|
1617
|
-
exec: opts.exec
|
|
1618
|
-
});
|
|
1619
|
-
});
|
|
2378
|
+
);
|
|
1620
2379
|
return cmd;
|
|
1621
2380
|
}
|
|
1622
2381
|
|
|
@@ -1707,8 +2466,9 @@ function deliveryCommand() {
|
|
|
1707
2466
|
);
|
|
1708
2467
|
const followUp = res.delivery === "relay" ? [
|
|
1709
2468
|
"",
|
|
1710
|
-
"Next:
|
|
1711
|
-
" acn listen --
|
|
2469
|
+
"Next: run the Mode B listener (built-in A2A + wake host):",
|
|
2470
|
+
" acn listen --runtime http --wake-url http://127.0.0.1:PORT/wake",
|
|
2471
|
+
"Compat: acn listen --forward http://localhost:PORT"
|
|
1712
2472
|
] : [];
|
|
1713
2473
|
output(res, [formatDelivery(res), ...followUp].join("\n"));
|
|
1714
2474
|
} catch (err) {
|
|
@@ -2722,6 +3482,102 @@ function orgCommand() {
|
|
|
2722
3482
|
handleError(err);
|
|
2723
3483
|
}
|
|
2724
3484
|
});
|
|
3485
|
+
cmd.command("publish-task").description(
|
|
3486
|
+
"Publish a Task Pool task attributed to an Org (network by default; not Org work)"
|
|
3487
|
+
).requiredOption("--org <org_id>", "Org id (stored as metadata.org_id)").requiredOption("-t, --title <title>", "Task title (min 3 chars)").requiredOption("-d, --description <text>", "Task description (min 10 chars)").requiredOption("--tags <tags>", "Required skill tags, comma-separated").option("--deadline <hours>", "Deadline in hours (default: 48)", "48").option("--reward <amount>", "Reward amount (default: 0)", "0").option(
|
|
3488
|
+
"--pay-from <source>",
|
|
3489
|
+
"Who pays: agent (default, attribution only) | org (Org wallet + credits escrow)",
|
|
3490
|
+
"agent"
|
|
3491
|
+
).option("--type <type>", "Task type", "general").option("--max-participants <n>", "Max participants", "1").option(
|
|
3492
|
+
"--fence",
|
|
3493
|
+
"Scope task to the Org subnet fence (may deliver task.* to Org harness)",
|
|
3494
|
+
false
|
|
3495
|
+
).option("--subnet <slug>", "Override subnet slug (implies fence; default: Org fence)").action(
|
|
3496
|
+
async (opts) => {
|
|
3497
|
+
const config = loadConfig();
|
|
3498
|
+
if (!config.api_key) {
|
|
3499
|
+
console.error(
|
|
3500
|
+
"No API key found. Run `acn join` first or `acn config set api-key <key>`."
|
|
3501
|
+
);
|
|
3502
|
+
process.exit(1);
|
|
3503
|
+
}
|
|
3504
|
+
const payFrom = (opts.payFrom ?? "agent").toLowerCase();
|
|
3505
|
+
if (payFrom !== "agent" && payFrom !== "org") {
|
|
3506
|
+
console.error('--pay-from must be "agent" or "org"');
|
|
3507
|
+
process.exit(1);
|
|
3508
|
+
}
|
|
3509
|
+
try {
|
|
3510
|
+
const body = {
|
|
3511
|
+
title: opts.title,
|
|
3512
|
+
description: opts.description,
|
|
3513
|
+
deadline_hours: parseInt(opts.deadline ?? "48", 10),
|
|
3514
|
+
required_tags: opts.tags.split(",").map((s) => s.trim()).filter(Boolean),
|
|
3515
|
+
reward: opts.reward ?? "0",
|
|
3516
|
+
task_type: opts.type ?? "general",
|
|
3517
|
+
max_participants: parseInt(opts.maxParticipants ?? "1", 10),
|
|
3518
|
+
pay_from_org: payFrom === "org",
|
|
3519
|
+
fence: Boolean(opts.fence || opts.subnet)
|
|
3520
|
+
};
|
|
3521
|
+
if (opts.subnet) {
|
|
3522
|
+
body.subnet_slug = opts.subnet;
|
|
3523
|
+
}
|
|
3524
|
+
const task = await acnPost(
|
|
3525
|
+
`/orgs/${opts.org}/publish-task`,
|
|
3526
|
+
body
|
|
3527
|
+
);
|
|
3528
|
+
const lines = [
|
|
3529
|
+
payFrom === "org" ? "Task published \u2014 Org-paid (credits escrow when reward > 0)" : "Task published for Org (Task Pool \u2014 not Org work)",
|
|
3530
|
+
` Task ID : ${task.task_id}`,
|
|
3531
|
+
` Org : ${opts.org}`,
|
|
3532
|
+
` Pay from : ${payFrom}`,
|
|
3533
|
+
` Creator : ${task.creator_type}/${task.creator_id}`,
|
|
3534
|
+
` Status : ${task.status}`,
|
|
3535
|
+
` Title : ${task.title}`,
|
|
3536
|
+
` Currency : ${task.reward_currency}`,
|
|
3537
|
+
` Escrow : ${String(task.use_escrow ?? false)}`,
|
|
3538
|
+
` Subnet : ${task.subnet_slug ?? "(network / unscoped)"}`,
|
|
3539
|
+
` metadata : org_id=${String(task.metadata?.org_id ?? opts.org)} org_publish=${String(task.metadata?.org_publish ?? true)}`
|
|
3540
|
+
];
|
|
3541
|
+
if (body.fence) {
|
|
3542
|
+
lines.push(
|
|
3543
|
+
" Note : fenced \u2014 task.* may hit the Org harness webhook"
|
|
3544
|
+
);
|
|
3545
|
+
}
|
|
3546
|
+
output(task, lines.join("\n"));
|
|
3547
|
+
} catch (err) {
|
|
3548
|
+
handleError(err);
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
);
|
|
3552
|
+
cmd.command("import-task").description(
|
|
3553
|
+
"Import a Task Pool task as Org work (governance only; links via task.metadata)"
|
|
3554
|
+
).requiredOption("--org <org_id>", "Org id").requiredOption("--task <task_id>", "Task id to import").option("--assignee <agent_id>", "Optional assignee for the work item").action(
|
|
3555
|
+
async (opts) => {
|
|
3556
|
+
const config = loadConfig();
|
|
3557
|
+
if (!config.api_key) {
|
|
3558
|
+
console.error(
|
|
3559
|
+
"No API key found. Run `acn join` first or `acn config set api-key <key>`."
|
|
3560
|
+
);
|
|
3561
|
+
process.exit(1);
|
|
3562
|
+
}
|
|
3563
|
+
try {
|
|
3564
|
+
const body = { task_id: opts.task };
|
|
3565
|
+
if (opts.assignee) body.assignee_agent_id = opts.assignee;
|
|
3566
|
+
const res = await acnPost(`/orgs/${opts.org}/work/import-task`, body);
|
|
3567
|
+
const lines = [
|
|
3568
|
+
res.already_imported ? "Task already imported (idempotent)" : "Task imported as Org work",
|
|
3569
|
+
` Work ID : ${res.work_id}`,
|
|
3570
|
+
` Org : ${res.org_id}`,
|
|
3571
|
+
` Task ID : ${res.source_task_id ?? opts.task}`,
|
|
3572
|
+
` Status : ${res.status}`,
|
|
3573
|
+
` Title : ${res.title}`
|
|
3574
|
+
];
|
|
3575
|
+
output(res, lines.join("\n"));
|
|
3576
|
+
} catch (err) {
|
|
3577
|
+
handleError(err);
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
);
|
|
2725
3581
|
return cmd;
|
|
2726
3582
|
}
|
|
2727
3583
|
|