@lawrenceliang-btc/atel-sdk 1.1.41 → 1.1.43

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/bin/atel.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * atel start [port] Start endpoint (auto network + auto register)
10
10
  * atel setup [port] Network setup only (detect IP, UPnP, verify)
11
11
  * atel verify Verify port reachability
12
- * atel inbox [count] Show received messages
12
+ * atel inbox [count] Show received messages / notifications
13
13
  * atel register [name] [caps] [url] Register on public registry
14
14
  * atel search <capability> Search registry for agents
15
15
  * atel handshake <endpoint> [did] Handshake with a remote agent
@@ -79,6 +79,7 @@ const REGISTRY_URL = process.env.ATEL_REGISTRY || ATEL_PLATFORM || 'https://api.
79
79
  const ATEL_RELAY = process.env.ATEL_RELAY || 'https://api.atelai.org';
80
80
  const ATEL_NOTIFY_GATEWAY = process.env.ATEL_NOTIFY_GATEWAY || process.env.OPENCLAW_GATEWAY_URL || '';
81
81
  const ATEL_NOTIFY_TARGET = process.env.ATEL_NOTIFY_TARGET || '';
82
+ const ATEL_NOTIFY_AUTO_BIND = /^(1|true|yes)$/i.test(String(process.env.ATEL_NOTIFY_AUTO_BIND || ''));
82
83
  let EXECUTOR_URL = process.env.ATEL_EXECUTOR_URL || '';
83
84
  const INBOX_FILE = resolve(ATEL_DIR, 'inbox.jsonl');
84
85
  const POLICY_FILE = resolve(ATEL_DIR, 'policy.json');
@@ -90,6 +91,9 @@ const TRACES_DIR = resolve(ATEL_DIR, 'traces');
90
91
  const PENDING_FILE = resolve(ATEL_DIR, 'pending-tasks.json');
91
92
  const RESULT_PUSH_QUEUE_FILE = resolve(ATEL_DIR, 'pending-result-pushes.json');
92
93
  const NOTIFY_TARGETS_FILE = resolve(ATEL_DIR, 'notify-targets.json');
94
+ const NOTIFY_ROUTES_FILE = resolve(ATEL_DIR, 'notify-routes.json');
95
+ const TELEGRAM_UPDATES_STATE_FILE = resolve(process.env.HOME || '/root', '.openclaw', 'deploy', 'sdk-telegram-updates.json');
96
+ const TELEGRAM_BINDINGS_FILE = resolve(process.env.HOME || '/root', '.openclaw', 'deploy', 'sdk-telegram-bindings.json');
93
97
  const TRADE_TRACK_FILE = resolve(ATEL_DIR, 'tracked-orders.json');
94
98
  const P2P_STATUS_FILE = resolve(ATEL_DIR, 'p2p-task-status.jsonl');
95
99
  const PENDING_AGENT_CALLBACKS_FILE = resolve(ATEL_DIR, 'pending-agent-callbacks.json');
@@ -222,6 +226,96 @@ function saveNotifyTargets(data) {
222
226
  writeFileSync(NOTIFY_TARGETS_FILE, JSON.stringify(data, null, 2));
223
227
  }
224
228
 
229
+ function loadNotifyRoutes() {
230
+ try { return JSON.parse(readFileSync(NOTIFY_ROUTES_FILE, 'utf-8')); }
231
+ catch { return { version: 1, defaultTelegram: null, orderBindings: {} }; }
232
+ }
233
+
234
+ function saveNotifyRoutes(data) {
235
+ ensureDir();
236
+ writeFileSync(NOTIFY_ROUTES_FILE, JSON.stringify(data, null, 2));
237
+ }
238
+
239
+ function getPrimaryTelegramTarget() {
240
+ const targets = loadNotifyTargets();
241
+ return (targets.targets || []).find((target) => target && target.enabled !== false && target.channel === 'telegram' && target.target) || null;
242
+ }
243
+
244
+ function rememberTelegramRoute(chatId, botToken, orderId = '') {
245
+ const normalizedChatId = String(chatId || '').trim();
246
+ if (!normalizedChatId) return false;
247
+ const routes = loadNotifyRoutes();
248
+ const updatedAt = new Date().toISOString();
249
+ const nextDefault = { chatId: normalizedChatId, updatedAt };
250
+ if (botToken) nextDefault.botToken = botToken;
251
+ routes.defaultTelegram = nextDefault;
252
+ if (!routes.orderBindings || typeof routes.orderBindings !== 'object') routes.orderBindings = {};
253
+ if (orderId) {
254
+ routes.orderBindings[orderId] = { chatId: normalizedChatId, updatedAt };
255
+ if (botToken) routes.orderBindings[orderId].botToken = botToken;
256
+ }
257
+ saveNotifyRoutes(routes);
258
+ return true;
259
+ }
260
+
261
+ function rememberCurrentTelegramRoute(orderId = '') {
262
+ if (!String(process.env.OPENCLAW_SHELL || '').trim()) return false;
263
+ let chatId = discoverTelegramChat();
264
+ let botToken = discoverTelegramBot();
265
+ if (!chatId) {
266
+ const primary = getPrimaryTelegramTarget();
267
+ if (primary?.target) {
268
+ chatId = primary.target;
269
+ botToken = botToken || primary.botToken;
270
+ }
271
+ }
272
+ if (!chatId) return false;
273
+ return rememberTelegramRoute(chatId, botToken, orderId);
274
+ }
275
+
276
+ function dropTelegramRoute(orderId = '') {
277
+ if (!orderId) return false;
278
+ const routes = loadNotifyRoutes();
279
+ if (!routes.orderBindings || !routes.orderBindings[orderId]) return false;
280
+ delete routes.orderBindings[orderId];
281
+ saveNotifyRoutes(routes);
282
+ return true;
283
+ }
284
+
285
+ function resolveNotifyTargets(orderId = '') {
286
+ const targets = loadNotifyTargets();
287
+ let enabled = (targets.targets || []).filter(t => t.enabled !== false);
288
+ const routes = loadNotifyRoutes();
289
+ const primary = getPrimaryTelegramTarget();
290
+ const preferred = (orderId && routes.orderBindings && routes.orderBindings[orderId]) || routes.defaultTelegram || (primary ? { chatId: String(primary.target), botToken: primary.botToken, updatedAt: primary.lastUsedAt || primary.createdAt || new Date().toISOString() } : null);
291
+ if (!preferred?.chatId) return { targets, enabled };
292
+ let hasTelegram = false;
293
+ enabled = enabled.map((target) => {
294
+ if (target.channel !== 'telegram') return target;
295
+ hasTelegram = true;
296
+ return {
297
+ ...target,
298
+ target: String(preferred.chatId),
299
+ botToken: preferred.botToken || target.botToken,
300
+ routeSource: orderId && routes.orderBindings && routes.orderBindings[orderId] ? 'order' : 'default',
301
+ };
302
+ });
303
+ if (!hasTelegram) {
304
+ enabled = [{
305
+ id: `tg_${preferred.chatId}`,
306
+ channel: 'telegram',
307
+ target: String(preferred.chatId),
308
+ botToken: preferred.botToken || discoverTelegramBot() || undefined,
309
+ label: orderId ? `order:${orderId}` : 'owner',
310
+ enabled: true,
311
+ createdAt: preferred.updatedAt || new Date().toISOString(),
312
+ lastUsedAt: null,
313
+ routeSource: orderId && routes.orderBindings && routes.orderBindings[orderId] ? 'order' : 'default',
314
+ }, ...enabled];
315
+ }
316
+ return { targets, enabled };
317
+ }
318
+
225
319
  // Auto-detect current TG chat from OpenClaw session state
226
320
  function discoverTelegramChat() {
227
321
  const sessionPaths = [
@@ -252,10 +346,11 @@ function autoBindNotifications() {
252
346
  const targets = loadNotifyTargets();
253
347
  if (targets.targets.length > 0) return; // already has targets
254
348
 
255
- const chatId = discoverTelegramChat();
349
+ const routes = loadNotifyRoutes();
350
+ const chatId = routes.defaultTelegram?.chatId || discoverTelegramChat();
256
351
  if (!chatId) return;
257
352
 
258
- const botToken = discoverTelegramBot();
353
+ const botToken = routes.defaultTelegram?.botToken || discoverTelegramBot();
259
354
  const id = `tg_${chatId}`;
260
355
  targets.targets.push({
261
356
  id, channel: 'telegram', target: chatId,
@@ -264,6 +359,7 @@ function autoBindNotifications() {
264
359
  createdAt: new Date().toISOString(), lastUsedAt: null,
265
360
  });
266
361
  saveNotifyTargets(targets);
362
+ rememberTelegramRoute(chatId, botToken || undefined);
267
363
  console.log(`🔔 Auto-bound TG notifications to chat ${chatId}`);
268
364
  }
269
365
 
@@ -305,80 +401,324 @@ function discoverTelegramBot() {
305
401
  // Also check env
306
402
  }
307
403
 
404
+ function loadTelegramUpdatesState() {
405
+ try { return JSON.parse(readFileSync(TELEGRAM_UPDATES_STATE_FILE, 'utf-8')); }
406
+ catch { return { version: 1, cursors: {}, leaders: {} }; }
407
+ }
408
+
409
+ function saveTelegramUpdatesState(data) {
410
+ ensureDir();
411
+ writeFileSync(TELEGRAM_UPDATES_STATE_FILE, JSON.stringify(data, null, 2));
412
+ }
413
+
414
+ function loadTelegramBindings() {
415
+ try { return JSON.parse(readFileSync(TELEGRAM_BINDINGS_FILE, 'utf-8')); }
416
+ catch { return { version: 1, bindings: {} }; }
417
+ }
418
+
419
+ function saveTelegramBindings(data) {
420
+ mkdirSync(dirname(TELEGRAM_BINDINGS_FILE), { recursive: true });
421
+ writeFileSync(TELEGRAM_BINDINGS_FILE, JSON.stringify(data, null, 2));
422
+ }
423
+
424
+ function registerTelegramInboundBinding(did, config) {
425
+ if (!did || !config?.cursorKey || !config?.chatId) return { ownerDid: '', rebound: false };
426
+ const data = loadTelegramBindings();
427
+ if (!data.bindings || typeof data.bindings !== 'object') data.bindings = {};
428
+ const key = config.cursorKey;
429
+ const current = data.bindings[key];
430
+ const ownerDid = String(current?.ownerDid || '').trim();
431
+ if (ownerDid && ownerDid !== did) {
432
+ return { ownerDid, rebound: false };
433
+ }
434
+ data.bindings[key] = {
435
+ chatId: config.chatId,
436
+ botId: config.botId,
437
+ ownerDid: did,
438
+ updatedAt: new Date().toISOString(),
439
+ };
440
+ saveTelegramBindings(data);
441
+ return { ownerDid: did, rebound: true };
442
+ }
443
+
444
+ function acquireTelegramInboundLeader(did, config) {
445
+ const state = loadTelegramUpdatesState();
446
+ if (!state.leaders || typeof state.leaders !== 'object') state.leaders = {};
447
+ const now = Date.now();
448
+ const leaseMs = 15000;
449
+ const current = state.leaders[config.cursorKey];
450
+ if (current && current.did && current.did !== did && Number(current.expiresAt || 0) > now) {
451
+ return { leader: current.did, acquired: false };
452
+ }
453
+ state.leaders[config.cursorKey] = { did, expiresAt: now + leaseMs, updatedAt: new Date().toISOString() };
454
+ saveTelegramUpdatesState(state);
455
+ return { leader: did, acquired: true };
456
+ }
457
+
458
+ function getTelegramInboundConfig() {
459
+ const routes = loadNotifyRoutes();
460
+ const primary = getPrimaryTelegramTarget();
461
+ const chatId = String(routes.defaultTelegram?.chatId || primary?.target || '').trim();
462
+ const botToken = String(routes.defaultTelegram?.botToken || primary?.botToken || discoverTelegramBot() || '').trim();
463
+ if (!chatId || !botToken) return null;
464
+ const botId = botToken.split(':', 1)[0] || 'telegram';
465
+ return { chatId, botToken, botId, cursorKey: `${botId}:${chatId}` };
466
+ }
467
+
468
+ function extractTelegramInboundPayload(update) {
469
+ const msg = update?.message || update?.edited_message;
470
+ if (!msg || msg?.from?.is_bot) return null;
471
+ const chatId = String(msg?.chat?.id || '').trim();
472
+ const text = typeof msg?.text === 'string' && msg.text.trim()
473
+ ? msg.text.trim()
474
+ : (typeof msg?.caption === 'string' && msg.caption.trim() ? msg.caption.trim() : '');
475
+ if (!chatId || !text) return null;
476
+ const nameParts = [];
477
+ if (typeof msg?.from?.username === 'string' && msg.from.username.trim()) nameParts.push(`@${msg.from.username.trim()}`);
478
+ if (typeof msg?.from?.first_name === 'string' && msg.from.first_name.trim()) nameParts.push(msg.from.first_name.trim());
479
+ if (typeof msg?.from?.last_name === 'string' && msg.from.last_name.trim()) nameParts.push(msg.from.last_name.trim());
480
+ const unixTs = Number(msg?.date || update?.edited_message?.date || 0);
481
+ const occurredAt = unixTs > 0 ? new Date(unixTs * 1000).toISOString() : new Date().toISOString();
482
+ return {
483
+ chatId,
484
+ text,
485
+ occurredAt,
486
+ messageId: msg?.message_id ?? null,
487
+ senderName: nameParts.join(' ').trim(),
488
+ };
489
+ }
490
+
491
+ async function queueTelegramInboundMirror(targetDid, config, update) {
492
+ const payload = extractTelegramInboundPayload(update);
493
+ if (!payload || payload.chatId !== config.chatId) return false;
494
+ const sender = `telegram:${payload.chatId}`;
495
+ const relayMessage = {
496
+ method: 'POST',
497
+ path: '/atel/v1/relay-message',
498
+ body: {
499
+ kind: 'telegram_message',
500
+ channel: 'telegram',
501
+ text: payload.text,
502
+ senderDid: sender,
503
+ timestamp: payload.occurredAt,
504
+ telegramChatId: payload.chatId,
505
+ telegramUpdateId: update?.update_id ?? null,
506
+ telegramMessageId: payload.messageId,
507
+ telegramSender: payload.senderName || '',
508
+ },
509
+ };
510
+ const resp = await fetch(`${ATEL_PLATFORM}/relay/v1/respond/${encodeURIComponent(targetDid)}`, {
511
+ method: 'POST',
512
+ headers: { 'Content-Type': 'application/json' },
513
+ body: JSON.stringify({ sender, message: relayMessage }),
514
+ signal: AbortSignal.timeout(10000),
515
+ });
516
+ const raw = await resp.text().catch(() => '');
517
+ if (!resp.ok) throw new Error(`telegram_mirror_http_${resp.status}:${raw || 'unknown'}`);
518
+ log({ event: 'telegram_inbound_mirrored', did: targetDid, chatId: payload.chatId, updateId: update?.update_id ?? null, text: payload.text.slice(0, 120) });
519
+ return true;
520
+ }
521
+
522
+ async function mirrorTelegramInboundToBindings(config, update) {
523
+ const bindings = loadTelegramBindings();
524
+ const ownerDid = String(bindings.bindings?.[config.cursorKey]?.ownerDid || '').trim();
525
+ if (!ownerDid) return 0;
526
+ try {
527
+ return (await queueTelegramInboundMirror(ownerDid, config, update)) ? 1 : 0;
528
+ } catch (e) {
529
+ log({ event: 'telegram_inbound_mirror_error', did: ownerDid, chatId: config.chatId, updateId: update?.update_id ?? null, error: e.message || 'unknown_error' });
530
+ return 0;
531
+ }
532
+ }
533
+
534
+ async function pollTelegramInboundUpdates(targetDid) {
535
+ const config = getTelegramInboundConfig();
536
+ if (!config) return { status: 'noop' };
537
+
538
+ const binding = registerTelegramInboundBinding(targetDid, config);
539
+ if (binding.ownerDid && binding.ownerDid !== targetDid) {
540
+ return { status: 'follower', ownerDid: binding.ownerDid };
541
+ }
542
+
543
+ const leadership = acquireTelegramInboundLeader(targetDid, config);
544
+ if (!leadership.acquired) {
545
+ return { status: 'follower', leader: leadership.leader };
546
+ }
547
+
548
+ const state = loadTelegramUpdatesState();
549
+ if (!state.cursors || typeof state.cursors !== 'object') state.cursors = {};
550
+ const cursor = state.cursors[config.cursorKey] || {};
551
+ const nextUpdateId = Number(cursor.nextUpdateId || 0);
552
+ const params = new URLSearchParams({ timeout: '0', limit: '20' });
553
+ if (nextUpdateId > 0) params.set('offset', String(nextUpdateId));
554
+
555
+ const resp = await fetch(`https://api.telegram.org/bot${config.botToken}/getUpdates?${params.toString()}`, {
556
+ signal: AbortSignal.timeout(12000),
557
+ });
558
+ const data = await resp.json().catch(() => null);
559
+ if (!resp.ok || data?.ok === false) {
560
+ throw new Error(data?.description || `telegram_getUpdates_http_${resp.status}`);
561
+ }
562
+
563
+ const updates = Array.isArray(data?.result) ? data.result : [];
564
+ if (updates.length === 0) return { status: 'idle' };
565
+
566
+ let nextCursor = nextUpdateId;
567
+ for (const update of updates) {
568
+ const updateId = Number(update?.update_id || 0);
569
+ if (updateId > 0) nextCursor = Math.max(nextCursor, updateId + 1);
570
+ }
571
+
572
+ let effectiveUpdates = updates;
573
+ if (nextUpdateId === 0) {
574
+ effectiveUpdates = updates.slice(-1);
575
+ state.cursors[config.cursorKey] = {
576
+ nextUpdateId: nextCursor,
577
+ chatId: config.chatId,
578
+ botId: config.botId,
579
+ updatedAt: new Date().toISOString(),
580
+ };
581
+ saveTelegramUpdatesState(state);
582
+ log({ event: 'telegram_inbound_bootstrap', did: targetDid, chatId: config.chatId, skipped: Math.max(0, updates.length - effectiveUpdates.length), mirroredOnBootstrap: effectiveUpdates.length, nextUpdateId: nextCursor });
583
+ }
584
+
585
+ let mirrored = 0;
586
+ for (const update of effectiveUpdates) {
587
+ mirrored += await mirrorTelegramInboundToBindings(config, update);
588
+ }
589
+
590
+ state.cursors[config.cursorKey] = {
591
+ nextUpdateId: nextCursor,
592
+ chatId: config.chatId,
593
+ botId: config.botId,
594
+ updatedAt: new Date().toISOString(),
595
+ };
596
+ saveTelegramUpdatesState(state);
597
+ return { status: 'ok', mirrored, nextUpdateId: nextCursor };
598
+ }
599
+
308
600
  // Send notification to all enabled targets (fire-and-forget, never blocks)
309
601
  async function pushTradeNotification(eventType, payload, body) {
310
- const targets = loadNotifyTargets();
311
- const enabled = (targets.targets || []).filter(t => t.enabled !== false);
602
+ const orderId = payload?.orderId || body?.orderId || '';
603
+ const { targets, enabled } = resolveNotifyTargets(orderId);
312
604
  if (enabled.length === 0) return;
313
605
 
314
- const chainLabel = (p) => {
315
- const c = p?.chain || body?.chain || '';
316
- if (c === 'fast-coop') return ' (Fast)';
317
- if (c === 'bsc') return ' (BSC)';
318
- return '';
319
- };
320
606
  const templates = {
321
- 'order_created': (p) => `📥 收到新订单\n订单: ${p.orderId || body?.orderId || '?'}\n金额: $${p.priceAmount ?? '?'} USDC${chainLabel(p)}\n来自: ${p.requesterDid || '未知请求方'}\n请审核后决定是否接单`,
322
- 'order_accepted': (p) => `📋 订单已被接单\n订单: ${p.orderId || body?.orderId || '?'}${chainLabel(p)}\n执行方已开始处理,进入里程碑阶段`,
607
+ 'order_created': (p) => `📥 收到新订单
608
+ 订单: ${p.orderId || body?.orderId || '?'}
609
+ 金额: $${p.priceAmount ?? '?'} USDC
610
+ 来自: ${p.requesterDid || '未知请求方'}
611
+ 请审核后决定是否接单`,
612
+ 'order_accepted': (p) => `📋 订单已被接单
613
+ 订单: ${p.orderId || body?.orderId || '?'}
614
+ 执行方已开始处理,进入里程碑阶段`,
615
+ 'escrow_confirmed': (p) => `🔒 托管已确认
616
+ 订单: ${p.orderId || body?.orderId || '?'}
617
+ 链上托管已完成,等待里程碑方案确认`,
618
+ 'milestone_plan_confirmed': (p) => {
619
+ const desc = p.milestoneDescription || p.nextMilestoneDescription || '';
620
+ const progress = p.totalMilestones ? `
621
+ 进度: 1/${p.totalMilestones}` : '';
622
+ return `🚀 里程碑方案已确认
623
+ 订单: ${p.orderId || body?.orderId || '?'}${desc ? `
624
+ 当前里程碑: ${desc}` : ''}${progress}`;
625
+ },
323
626
  'milestone_submitted': (p) => {
324
- const desc = p.milestoneDescription ? `\n目标: ${p.milestoneDescription}` : '';
325
- const content = p.resultSummary ? `\n提交内容: ${String(p.resultSummary).substring(0, 200)}` : '';
326
- return `📝 里程碑 M${p.milestoneIndex ?? '?'} 已提交\n订单: ${p.orderId || body?.orderId || '?'}${desc}${content}\n等待审核`;
627
+ const desc = p.milestoneDescription ? `
628
+ 目标: ${p.milestoneDescription}` : '';
629
+ const content = p.resultSummary ? `
630
+ 提交内容: ${String(p.resultSummary).substring(0, 200)}` : '';
631
+ return `📝 里程碑 M${p.milestoneIndex ?? '?'} 已提交
632
+ 订单: ${p.orderId || body?.orderId || '?'}${desc}${content}
633
+ 等待审核`;
327
634
  },
328
635
  'milestone_verified': (p) => {
329
- const desc = p.milestoneDescription ? `\n目标: ${p.milestoneDescription}` : '';
330
- const content = p.resultSummary ? `\n提交内容: ${String(p.resultSummary).substring(0, 200)}` : '';
331
- const progress = p.totalMilestones ? `\n进度: ${(p.milestoneIndex ?? 0) + 1}/${p.totalMilestones}` : '';
332
- return `✅ 里程碑 M${p.milestoneIndex ?? '?'} 审核通过\n订单: ${p.orderId || body?.orderId || '?'}${desc}${content}${progress}`;
636
+ const desc = p.milestoneDescription ? `
637
+ 目标: ${p.milestoneDescription}` : '';
638
+ const content = p.resultSummary ? `
639
+ 提交内容: ${String(p.resultSummary).substring(0, 200)}` : '';
640
+ const progress = p.totalMilestones ? `
641
+ 进度: ${(p.milestoneIndex ?? 0) + 1}/${p.totalMilestones}` : '';
642
+ return `✅ 里程碑 M${p.milestoneIndex ?? '?'} 审核通过
643
+ 订单: ${p.orderId || body?.orderId || '?'}${desc}${content}${progress}`;
333
644
  },
334
645
  'milestone_rejected': (p) => {
335
- const desc = p.milestoneDescription ? `\n目标: ${p.milestoneDescription}` : '';
336
- return `❌ 里程碑 M${p.milestoneIndex ?? '?'} 被拒绝\n订单: ${p.orderId || body?.orderId || '?'}${desc}\n原因: ${p.rejectReason || '未说明'}`;
646
+ const desc = p.milestoneDescription ? `
647
+ 目标: ${p.milestoneDescription}` : '';
648
+ return `❌ 里程碑 M${p.milestoneIndex ?? '?'} 被拒绝
649
+ 订单: ${p.orderId || body?.orderId || '?'}${desc}
650
+ 原因: ${p.rejectReason || '未说明'}`;
337
651
  },
652
+ 'order_completed': (p) => `📦 订单已提交完成
653
+ 订单: ${p.orderId || body?.orderId || '?'}
654
+ 等待对方确认或系统自动确认`,
655
+ 'order_cancelled': (p) => `🛑 订单已取消
656
+ 订单: ${p.orderId || body?.orderId || '?'}
657
+ 原因: ${p.reason || '未说明'}`,
658
+ 'order_expired': (p) => `⌛ 订单已过期
659
+ 订单: ${p.orderId || body?.orderId || '?'}
660
+ 系统已自动结束该订单`,
661
+ 'dispute_created': (p) => `⚖️ 争议已创建
662
+ 订单: ${p.orderId || body?.orderId || '?'}
663
+ 请尽快补充证据`,
664
+ 'dispute_resolved': (p) => `🧾 争议已处理完成
665
+ 订单: ${p.orderId || body?.orderId || '?'}
666
+ 结果: ${p.result || p.resolution || '已裁定'}`,
338
667
  'order_settled': (p) => {
339
- const cl = chainLabel(p);
340
- const amount = p.priceAmount ? `\n金额: $${p.priceAmount} USDC${cl}` : '';
341
- const dest = cl === ' (Fast)' ? '\n资金已到达执行方 Fast 钱包' : '\nUSDC 已支付';
342
- return `💰 订单已结算完成\n订单: ${p.orderId || body?.orderId || '?'}${amount}${dest}`;
668
+ const amount = p.priceAmount ? `
669
+ 金额: $${p.priceAmount} USDC` : '';
670
+ return `💰 订单已结算完成
671
+ 订单: ${p.orderId || body?.orderId || '?'}${amount}
672
+ USDC 已支付`;
343
673
  },
344
674
  };
345
675
  const tmpl = templates[eventType];
346
- if (!tmpl) return;
347
- const message = tmpl(payload);
676
+ const orderIdText = orderId || '?';
677
+ const message = tmpl ? tmpl(payload) : `🔔 平台事件更新
678
+ 事件: ${eventType}
679
+ 订单: ${orderIdText}`;
348
680
 
349
681
  for (const target of enabled) {
350
682
  try {
351
- if (target.channel === 'telegram' && target.botToken) {
352
- // Direct TG Bot API — no gateway needed
353
- // Note: parse_mode is intentionally omitted. Templates render plain
354
- // text (no HTML markup), and milestone payloads frequently contain
355
- // raw `<` characters (e.g. "<5秒", "<30秒") that would otherwise
356
- // trip Telegram's HTML parser and cause silent 400 drops.
357
- await fetch(`https://api.telegram.org/bot${target.botToken}/sendMessage`, {
683
+ if (target.channel === 'telegram') {
684
+ if (!target.botToken) {
685
+ log({ event: 'trade_notify_target_skipped', eventType, channel: 'telegram', target: target.id || target.target, reason: 'missing_bot_token' });
686
+ continue;
687
+ }
688
+ const resp = await fetch(`https://api.telegram.org/bot${target.botToken}/sendMessage`, {
358
689
  method: 'POST',
359
690
  headers: { 'Content-Type': 'application/json' },
360
691
  body: JSON.stringify({ chat_id: target.target, text: message }),
361
692
  signal: AbortSignal.timeout(5000),
362
- }).catch(() => {});
693
+ });
694
+ const data = await resp.json().catch(() => null);
695
+ if (!resp.ok || data?.ok === false) {
696
+ log({ event: 'trade_notify_delivery_error', eventType, channel: 'telegram', target: target.id || target.target, status: resp.status, error: data?.description || `http_${resp.status}` });
697
+ continue;
698
+ }
363
699
  } else if (target.channel === 'gateway') {
364
- // OpenClaw gateway
365
700
  const gw = discoverGateway();
366
701
  if (gw?.url && gw?.token) {
367
- await fetch(`${gw.url}/tools/invoke`, {
702
+ const resp = await fetch(`${gw.url}/tools/invoke`, {
368
703
  method: 'POST',
369
704
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${gw.token}` },
370
705
  body: JSON.stringify({ tool: 'message', args: { action: 'send', message, target: target.target } }),
371
706
  signal: AbortSignal.timeout(5000),
372
- }).catch(() => {});
707
+ });
708
+ if (!resp.ok) {
709
+ log({ event: 'trade_notify_delivery_error', eventType, channel: 'gateway', target: target.id || target.target, status: resp.status, error: `http_${resp.status}` });
710
+ continue;
711
+ }
712
+ } else {
713
+ log({ event: 'trade_notify_target_skipped', eventType, channel: 'gateway', target: target.id || target.target, reason: 'missing_gateway' });
714
+ continue;
373
715
  }
374
716
  }
375
- // Update lastUsedAt
376
717
  target.lastUsedAt = new Date().toISOString();
377
718
  } catch (e) {
378
- // Never block main flow
719
+ log({ event: 'trade_notify_delivery_error', eventType, channel: target.channel, target: target.id || target.target, error: e.message || 'unknown_error' });
379
720
  }
380
721
  }
381
- // Best-effort save lastUsedAt
382
722
  try { saveNotifyTargets(targets); } catch {}
383
723
  }
384
724
 
@@ -389,17 +729,34 @@ function appendP2PTaskStatus(entry) {
389
729
  }
390
730
 
391
731
  async function pushP2PNotification(eventType, payload = {}) {
392
- const targets = loadNotifyTargets();
393
- const enabled = (targets.targets || []).filter(t => t.enabled !== false);
732
+ const { targets, enabled } = resolveNotifyTargets('');
394
733
  if (enabled.length === 0) return;
395
734
 
396
735
  const templates = {
397
- 'p2p_task_sent': (p) => `📤 P2P任务已发送\n任务: ${p.taskId || '?'}\n目标: ${p.peerDid || '?'}`,
398
- 'p2p_task_received': (p) => `📩 收到新的P2P任务\n任务: ${p.taskId || '?'}\n来自: ${p.peerDid || '?'}`,
399
- 'p2p_task_started': (p) => `▶️ P2P任务开始处理\n任务: ${p.taskId || '?'}\n来自: ${p.peerDid || '?'}`,
400
- 'p2p_result_submitted': (p) => `📨 P2P结果已发回对方\n任务: ${p.taskId || '?'}\n目标: ${p.peerDid || '?'}`,
401
- 'p2p_result_received': (p) => `✅ P2P任务已完成\n任务: ${p.taskId || '?'}\n结果: ${String(p.result || '').slice(0, 80) || '已返回'}`,
402
- 'p2p_task_failed': (p) => `❌ P2P任务失败\n任务: ${p.taskId || '?'}\n原因: ${p.reason || '未知错误'}`,
736
+ 'p2p_task_sent': (p) => `📤 P2P任务已发送
737
+ 任务: ${p.taskId || '?'}
738
+ 目标: ${p.peerDid || '?'}`,
739
+ 'p2p_task_received': (p) => `📩 收到新的P2P任务
740
+ 任务: ${p.taskId || '?'}
741
+ 来自: ${p.peerDid || '?'}`,
742
+ 'p2p_task_started': (p) => `▶️ P2P任务开始处理
743
+ 任务: ${p.taskId || '?'}
744
+ 来自: ${p.peerDid || '?'}`,
745
+ 'p2p_result_submitted': (p) => `📨 P2P结果已发回对方
746
+ 任务: ${p.taskId || '?'}
747
+ 目标: ${p.peerDid || '?'}`,
748
+ 'p2p_result_received': (p) => `✅ P2P任务已完成
749
+ 任务: ${p.taskId || '?'}
750
+ 结果: ${String(p.result || '').slice(0, 80) || '已返回'}`,
751
+ 'p2p_task_failed': (p) => `❌ P2P任务失败
752
+ 任务: ${p.taskId || '?'}
753
+ 原因: ${p.reason || '未知错误'}`,
754
+ 'p2p_message_received': (p) => `💬 收到新消息
755
+ 来自: ${p.peerDid || '?'}
756
+ 内容: ${String(p.text || '').slice(0, 120) || '[message]'}`,
757
+ 'p2p_contact_added': (p) => `👤 联系人提醒
758
+ 来自: ${p.peerDid || '?'}${p.alias ? `
759
+ 备注: ${p.alias}` : ''}`,
403
760
  };
404
761
  const tmpl = templates[eventType];
405
762
  if (!tmpl) return;
@@ -407,27 +764,44 @@ async function pushP2PNotification(eventType, payload = {}) {
407
764
 
408
765
  for (const target of enabled) {
409
766
  try {
410
- if (target.channel === 'telegram' && target.botToken) {
411
- // parse_mode intentionally omitted — see pushTradeNotification for rationale
412
- await fetch(`https://api.telegram.org/bot${target.botToken}/sendMessage`, {
767
+ if (target.channel === 'telegram') {
768
+ if (!target.botToken) {
769
+ log({ event: 'p2p_notify_target_skipped', eventType, channel: 'telegram', target: target.id || target.target, reason: 'missing_bot_token' });
770
+ continue;
771
+ }
772
+ const resp = await fetch(`https://api.telegram.org/bot${target.botToken}/sendMessage`, {
413
773
  method: 'POST',
414
774
  headers: { 'Content-Type': 'application/json' },
415
775
  body: JSON.stringify({ chat_id: target.target, text: message }),
416
776
  signal: AbortSignal.timeout(5000),
417
- }).catch(() => {});
777
+ });
778
+ const data = await resp.json().catch(() => null);
779
+ if (!resp.ok || data?.ok === false) {
780
+ log({ event: 'p2p_notify_delivery_error', eventType, channel: 'telegram', target: target.id || target.target, status: resp.status, error: data?.description || `http_${resp.status}` });
781
+ continue;
782
+ }
418
783
  } else if (target.channel === 'gateway') {
419
784
  const gw = discoverGateway();
420
785
  if (gw?.url && gw?.token) {
421
- await fetch(`${gw.url}/tools/invoke`, {
786
+ const resp = await fetch(`${gw.url}/tools/invoke`, {
422
787
  method: 'POST',
423
788
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${gw.token}` },
424
789
  body: JSON.stringify({ tool: 'message', args: { action: 'send', message, target: target.target } }),
425
790
  signal: AbortSignal.timeout(5000),
426
- }).catch(() => {});
791
+ });
792
+ if (!resp.ok) {
793
+ log({ event: 'p2p_notify_delivery_error', eventType, channel: 'gateway', target: target.id || target.target, status: resp.status, error: `http_${resp.status}` });
794
+ continue;
795
+ }
796
+ } else {
797
+ log({ event: 'p2p_notify_target_skipped', eventType, channel: 'gateway', target: target.id || target.target, reason: 'missing_gateway' });
798
+ continue;
427
799
  }
428
800
  }
429
801
  target.lastUsedAt = new Date().toISOString();
430
- } catch {}
802
+ } catch (e) {
803
+ log({ event: 'p2p_notify_delivery_error', eventType, channel: target.channel, target: target.id || target.target, error: e.message || 'unknown_error' });
804
+ }
431
805
  }
432
806
  try { saveNotifyTargets(targets); } catch {}
433
807
  }
@@ -1082,6 +1456,7 @@ function saveTrackedOrders(orders) {
1082
1456
  }
1083
1457
  function trackOrder(orderId, role) {
1084
1458
  if (!orderId || !role) return;
1459
+ rememberCurrentTelegramRoute(orderId);
1085
1460
  const now = new Date().toISOString();
1086
1461
  const orders = loadTrackedOrders().filter((o) => o && o.orderId);
1087
1462
  const existing = orders.find((o) => o.orderId === orderId);
@@ -1096,11 +1471,146 @@ function trackOrder(orderId, role) {
1096
1471
  function untrackOrder(orderId) {
1097
1472
  if (!orderId) return;
1098
1473
  saveTrackedOrders(loadTrackedOrders().filter((o) => o?.orderId !== orderId));
1474
+ dropTelegramRoute(orderId);
1099
1475
  }
1100
1476
  function loadTasks() { if (!existsSync(TASKS_FILE)) return {}; try { return JSON.parse(readFileSync(TASKS_FILE, 'utf-8')); } catch { return {}; } }
1101
1477
  function saveTasks(t) { ensureDir(); writeFileSync(TASKS_FILE, JSON.stringify(t, null, 2)); }
1102
1478
  function loadNetwork() { if (!existsSync(NETWORK_FILE)) return null; try { return JSON.parse(readFileSync(NETWORK_FILE, 'utf-8')); } catch { return null; } }
1103
1479
  function saveNetwork(n) { ensureDir(); writeFileSync(NETWORK_FILE, JSON.stringify(n, null, 2)); }
1480
+ function syncNetworkConfigToPort(networkConfig, port) {
1481
+ if (!networkConfig || typeof networkConfig !== 'object') return { config: networkConfig, changed: false };
1482
+ let changed = false;
1483
+ const next = { ...networkConfig };
1484
+ if (next.port !== port) { next.port = port; changed = true; }
1485
+ const rewriteUrlPort = (value) => {
1486
+ if (typeof value !== 'string' || !value.trim()) return value;
1487
+ try {
1488
+ const parsed = new URL(value);
1489
+ const desiredPort = String(port);
1490
+ if (parsed.port === desiredPort) return value;
1491
+ parsed.port = desiredPort;
1492
+ changed = true;
1493
+ return parsed.toString();
1494
+ } catch {
1495
+ return value;
1496
+ }
1497
+ };
1498
+ next.endpoint = rewriteUrlPort(next.endpoint);
1499
+ if (Array.isArray(next.candidates)) {
1500
+ next.candidates = next.candidates.map((candidate) => {
1501
+ if (!candidate || typeof candidate !== 'object') return candidate;
1502
+ const updated = { ...candidate };
1503
+ if (updated.type === 'relay') {
1504
+ if (typeof next.relayUrl === 'string' && next.relayUrl.trim() && updated.url !== next.relayUrl) {
1505
+ updated.url = next.relayUrl;
1506
+ changed = true;
1507
+ }
1508
+ if (updated.port && updated.port !== 18204) {
1509
+ updated.port = 18204;
1510
+ changed = true;
1511
+ }
1512
+ return updated;
1513
+ }
1514
+ if (updated.port !== port) { updated.port = port; changed = true; }
1515
+ updated.url = rewriteUrlPort(updated.url);
1516
+ return updated;
1517
+ });
1518
+ }
1519
+ if (changed) next.configuredAt = new Date().toISOString();
1520
+ return { config: next, changed };
1521
+ }
1522
+ function isLegacyPassiveRelayMessage(req) {
1523
+ if (!req || typeof req !== 'object' || Array.isArray(req)) return false;
1524
+ const path = typeof req.path === 'string' ? req.path.trim() : '';
1525
+ const method = typeof req.method === 'string' ? req.method.trim().toUpperCase() : '';
1526
+ if (path || method) return false;
1527
+ const kind = typeof req.kind === 'string' ? req.kind.trim() : '';
1528
+ const text = typeof req.text === 'string' ? req.text.trim() : '';
1529
+ const msgType = typeof req.msgType === 'string' ? req.msgType.trim() : '';
1530
+ const p2pType = typeof req.type === 'string' ? req.type.trim() : '';
1531
+ return Boolean(text || msgType === 'message' || kind === 'portal_message' || kind === 'contact_added' || ['friend_request','friend_accept','friend_reject'].includes(p2pType));
1532
+ }
1533
+ function buildPassiveRelayRequest(req, senderDid = '') {
1534
+ const normalizedSender = String(req?.senderDid || req?.from || senderDid || '').trim();
1535
+ const normalizedKind = typeof req?.kind === 'string' && req.kind.trim()
1536
+ ? req.kind.trim()
1537
+ : ((typeof req?.msgType === 'string' && req.msgType.trim() === 'message') || typeof req?.text === 'string'
1538
+ ? 'portal_message'
1539
+ : 'relay_message');
1540
+ return {
1541
+ method: 'POST',
1542
+ path: '/atel/v1/relay-message',
1543
+ body: {
1544
+ ...req,
1545
+ senderDid: normalizedSender,
1546
+ kind: normalizedKind,
1547
+ timestamp: req?.timestamp || new Date().toISOString(),
1548
+ },
1549
+ };
1550
+ }
1551
+ function extractSignedRelayEnvelope(message) {
1552
+ if (!message || typeof message !== 'object' || Array.isArray(message)) return {};
1553
+ if (
1554
+ message.envelope === 'atel.msg.v1' &&
1555
+ typeof message.from === 'string' &&
1556
+ typeof message.to === 'string' &&
1557
+ typeof message.type === 'string' &&
1558
+ message.payload !== undefined &&
1559
+ typeof message.signature === 'string'
1560
+ ) {
1561
+ return {
1562
+ envelope: message.envelope,
1563
+ type: message.type,
1564
+ from: message.from,
1565
+ to: message.to,
1566
+ timestamp: message.timestamp,
1567
+ nonce: message.nonce,
1568
+ payload: message.payload,
1569
+ signature: message.signature,
1570
+ };
1571
+ }
1572
+ const signed = {};
1573
+ for (const key of ['to', 'from', 'type', 'action', 'msgType', 'msgId', 'text', 'images', 'attachments', 'summary', 'kind', 'nonce', 'payload', 'envelope', 'signature', 'timestamp']) {
1574
+ if (message[key] !== undefined) signed[key] = message[key];
1575
+ }
1576
+ return signed;
1577
+ }
1578
+ function isAckableMalformedRelayMessage(req, inner) {
1579
+ if (typeof inner === 'string') {
1580
+ const raw = inner.trim();
1581
+ if (raw === '' || raw === '{}') return true;
1582
+ }
1583
+ if (!req || typeof req !== 'object' || Array.isArray(req)) return false;
1584
+ const keys = Object.keys(req);
1585
+ if (keys.length === 0) return true;
1586
+ const path = typeof req.path === 'string' ? req.path.trim() : '';
1587
+ const method = typeof req.method === 'string' ? req.method.trim().toUpperCase() : '';
1588
+ const body = req.body;
1589
+ const hasBodyObject = !!body && typeof body === 'object' && !Array.isArray(body) && Object.keys(body).length > 0;
1590
+ const hasBodyString = typeof body === 'string' && body.trim() !== '';
1591
+ if ((!path || path === '/') && !hasBodyObject && !hasBodyString) return true;
1592
+ return !path && !method && !hasBodyObject && !hasBodyString;
1593
+ }
1594
+ const HUMAN_INBOX_EVENT_PREFIXES = ['p2p_task_', 'p2p_result_'];
1595
+ const HUMAN_INBOX_EVENTS = new Set([
1596
+ 'notification',
1597
+ 'result_received',
1598
+ 'rejection_proof',
1599
+ 'friend_added',
1600
+ 'friend_removed',
1601
+ 'friend_request_received',
1602
+ 'friend_request_accepted_by_peer',
1603
+ 'friend_request_rejected_by_peer',
1604
+ 'relay_message_received',
1605
+ ]);
1606
+ function isHumanInboxEntry(entry) {
1607
+ if (!entry || typeof entry !== 'object') return false;
1608
+ if (entry.type === 'task-result') return true;
1609
+ const event = typeof entry.event === 'string' ? entry.event : '';
1610
+ if (!event) return false;
1611
+ if (HUMAN_INBOX_EVENTS.has(event)) return true;
1612
+ return HUMAN_INBOX_EVENT_PREFIXES.some((prefix) => event.startsWith(prefix));
1613
+ }
1104
1614
  function saveTrace(taskId, trace) { if (!existsSync(TRACES_DIR)) mkdirSync(TRACES_DIR, { recursive: true }); writeFileSync(resolve(TRACES_DIR, `${taskId}.jsonl`), trace.export()); }
1105
1615
  function loadTrace(taskId) { const f = resolve(TRACES_DIR, `${taskId}.jsonl`); if (!existsSync(f)) return null; return readFileSync(f, 'utf-8'); }
1106
1616
  function loadPending() { if (!existsSync(PENDING_FILE)) return {}; try { return JSON.parse(readFileSync(PENDING_FILE, 'utf-8')); } catch { return {}; } }
@@ -1319,7 +1829,9 @@ function removeFriend(did) {
1319
1829
 
1320
1830
  // Check if DID is a friend
1321
1831
  function isFriend(did) {
1322
- const data = getCachedFriends();
1832
+ // Friendship can be updated by a separate CLI process (for example `atel friend accept`).
1833
+ // Read from disk here so the long-running agent process does not enforce a stale cache.
1834
+ const data = loadFriends();
1323
1835
  return data.friends.some(f => f.did === did && f.status === 'accepted');
1324
1836
  }
1325
1837
 
@@ -1759,6 +2271,121 @@ function checkP2PAccess(from, action, payload, currentPolicy) {
1759
2271
  };
1760
2272
  }
1761
2273
 
2274
+ function checkP2PMessageAccess(from, currentPolicy) {
2275
+ if (!from) {
2276
+ return {
2277
+ allowed: false,
2278
+ reason: 'UNKNOWN_SENDER',
2279
+ message: 'Missing sender DID',
2280
+ code: 'INVALID_SENDER'
2281
+ };
2282
+ }
2283
+
2284
+ if (currentPolicy.blockedDIDs && currentPolicy.blockedDIDs.length > 0 && currentPolicy.blockedDIDs.includes(from)) {
2285
+ log({
2286
+ event: 'p2p_access_denied',
2287
+ from,
2288
+ action: 'message',
2289
+ reason: 'DID_BLOCKED',
2290
+ timestamp: new Date().toISOString()
2291
+ });
2292
+ return {
2293
+ allowed: false,
2294
+ reason: 'DID_BLOCKED',
2295
+ message: 'You are blocked by this agent',
2296
+ code: 'BLOCKED'
2297
+ };
2298
+ }
2299
+
2300
+ const relPolicy = currentPolicy.relationshipPolicy || getDefaultRelationshipPolicy();
2301
+
2302
+ if (isFriend(from)) {
2303
+ log({
2304
+ event: 'p2p_access_granted',
2305
+ from,
2306
+ action: 'message',
2307
+ reason: 'FRIEND',
2308
+ relationship: 'friend',
2309
+ timestamp: new Date().toISOString()
2310
+ });
2311
+ return {
2312
+ allowed: true,
2313
+ reason: 'FRIEND',
2314
+ relationship: 'friend'
2315
+ };
2316
+ }
2317
+
2318
+ const tempSession = getActiveTempSession(from);
2319
+ if (tempSession) {
2320
+ if (Date.now() > new Date(tempSession.expiresAt).getTime()) {
2321
+ removeTempSession(tempSession.sessionId);
2322
+ log({
2323
+ event: 'p2p_access_denied',
2324
+ from,
2325
+ action: 'message',
2326
+ reason: 'TEMP_SESSION_EXPIRED',
2327
+ sessionId: tempSession.sessionId,
2328
+ timestamp: new Date().toISOString()
2329
+ });
2330
+ return {
2331
+ allowed: false,
2332
+ reason: 'TEMP_SESSION_EXPIRED',
2333
+ message: 'Your temporary session has expired. Please request a new one.',
2334
+ code: 'TEMP_EXPIRED'
2335
+ };
2336
+ }
2337
+
2338
+ log({
2339
+ event: 'p2p_access_granted',
2340
+ from,
2341
+ action: 'message',
2342
+ reason: 'TEMP_SESSION',
2343
+ relationship: 'temporary',
2344
+ sessionId: tempSession.sessionId,
2345
+ timestamp: new Date().toISOString()
2346
+ });
2347
+ return {
2348
+ allowed: true,
2349
+ reason: 'TEMP_SESSION',
2350
+ relationship: 'temporary',
2351
+ sessionId: tempSession.sessionId
2352
+ };
2353
+ }
2354
+
2355
+ if (relPolicy.defaultMode === 'open') {
2356
+ log({
2357
+ event: 'p2p_access_granted',
2358
+ from,
2359
+ action: 'message',
2360
+ reason: 'OPEN_MODE',
2361
+ relationship: 'stranger',
2362
+ timestamp: new Date().toISOString()
2363
+ });
2364
+ return {
2365
+ allowed: true,
2366
+ reason: 'OPEN_MODE',
2367
+ relationship: 'stranger'
2368
+ };
2369
+ }
2370
+
2371
+ log({
2372
+ event: 'p2p_access_denied',
2373
+ from,
2374
+ action: 'message',
2375
+ reason: 'NOT_FRIEND',
2376
+ defaultMode: relPolicy.defaultMode || 'unknown',
2377
+ timestamp: new Date().toISOString()
2378
+ });
2379
+
2380
+ return {
2381
+ allowed: false,
2382
+ reason: 'NOT_FRIEND',
2383
+ message: 'You are not a friend. Please send a friend request first.',
2384
+ hint: 'Send friend request: atel friend request <your-did>',
2385
+ code: 'NOT_FRIEND'
2386
+ };
2387
+ }
2388
+
1762
2389
  // ─── Unified Trust Score & Level System ──────────────────────────
1763
2390
  // Single source of truth: computeTrustScore() calculates score,
1764
2391
  // trustLevel is derived from score. No independent logic.
@@ -1979,19 +2606,8 @@ async function promptInput(question) {
1979
2606
  // ─── Anchor Configuration ────────────────────────────────────────
1980
2607
 
1981
2608
  async function configureAnchor() {
1982
- console.log('\n🔗 Configure On-Chain Anchoring (legacy V1 mode)\n');
1983
- console.log('⚠️ In V2 the ATEL Platform anchors on behalf of agents using its own');
1984
- console.log(' executor wallets — you do NOT need this for paid orders.');
1985
- console.log(' Your smart wallet addresses (derived from your DID) already receive');
1986
- console.log(' USDC and the platform pays gas for you. Only continue if you are');
1987
- console.log(' running a legacy V1 self-anchoring agent.');
1988
- console.log('');
1989
- const go = await promptYesNo('Continue anyway?');
1990
- if (!go) {
1991
- console.log('Aborted. Your identity is unchanged.');
1992
- return;
1993
- }
1994
-
2609
+ console.log('\n🔗 Configure On-Chain Anchoring\n');
2610
+
1995
2611
  // 1. Select chain
1996
2612
  const chain = await promptChoice(
1997
2613
  'Select blockchain for anchoring:',
@@ -2048,108 +2664,99 @@ async function configureAnchor() {
2048
2664
  // ─── Commands ────────────────────────────────────────────────────
2049
2665
 
2050
2666
  async function cmdInit(agentId) {
2051
- const name = agentId || `agent-${Date.now()}`;
2667
+ const existingIdentity = loadIdentity();
2668
+ if (existingIdentity && process.env.ATEL_REINIT != '1') {
2669
+ console.log(JSON.stringify({
2670
+ status: 'exists',
2671
+ agent_id: existingIdentity.agent_id,
2672
+ did: existingIdentity.did,
2673
+ next: 'Reuse existing identity. Run: atel start [port]'
2674
+ }, null, 2));
2675
+ return;
2676
+ }
2677
+
2678
+ let name = (agentId || '').trim();
2679
+ if (!name && process.stdin.isTTY) {
2680
+ const chosen = await promptInput('Choose your agent name (required):');
2681
+ name = (chosen || '').trim();
2682
+ }
2683
+ if (!name) {
2684
+ console.error('Agent name is required. Pass a name explicitly, or run `atel init` interactively and enter one.');
2685
+ process.exit(1);
2686
+ }
2687
+
2052
2688
  const identity = new AgentIdentity({ agent_id: name });
2053
2689
  saveIdentity(identity);
2054
2690
  savePolicy(DEFAULT_POLICY);
2055
-
2691
+
2056
2692
  // Create default agent-context.md for built-in executor
2057
2693
  const ctxFile = resolve(ATEL_DIR, 'agent-context.md');
2058
2694
  if (!existsSync(ctxFile)) {
2059
- writeFileSync(ctxFile, `# Agent Context\n\nYou are an ATEL agent (${name}) processing tasks from other agents via the ATEL protocol.\n\n## Guidelines\n- Complete the task accurately and concisely\n- Return only the requested result, no extra commentary\n- If the task is unclear, do your best interpretation\n- Do not access private files or sensitive data\n- Do not make external network requests unless the task requires it\n`);
2060
- }
2061
-
2062
- // NOTE (2026-04-09): We intentionally do NOT prompt for an on-chain anchor
2063
- // private key here any more. That prompt is a V1 leftover — in V2 the
2064
- // Platform handles all on-chain anchoring on behalf of agents using its own
2065
- // registered executor wallets, and the user's USDC lives in a smart wallet
2066
- // (AA) whose address is derived from the ATEL identity key. Asking users to
2067
- // paste a raw Base/BSC/Solana private key at install time is both
2068
- // unnecessary and intimidating. If someone genuinely needs legacy
2069
- // self-anchoring they can opt in later via `atel anchor config`.
2695
+ writeFileSync(ctxFile, `# Agent Context
2070
2696
 
2071
- // Derive the smart-wallet addresses from the identity so the user can see
2072
- // where to top up USDC for paid orders.
2073
- let smartWallet = null;
2074
- try {
2075
- const info = identity.toJSON?.() || {};
2076
- smartWallet = info.smart_wallet || info.wallet || null;
2077
- } catch { /* best-effort */ }
2697
+ You are an ATEL agent (${name}) processing tasks from other agents via the ATEL protocol.
2078
2698
 
2079
- const output = {
2080
- status: 'created',
2081
- agent_id: identity.agent_id,
2082
- did: identity.did,
2699
+ ## Guidelines
2700
+ - Complete the task accurately and concisely
2701
+ - Return only the requested result, no extra commentary
2702
+ - If the task is unclear, do your best interpretation
2703
+ - Do not access private files or sensitive data
2704
+ - Do not make external network requests unless the task requires it
2705
+ `);
2706
+ }
2707
+
2708
+ console.log('');
2709
+ const providePaidOrders = await promptYesNo(
2710
+ 'Do you want to provide paid order services? (requires on-chain anchoring)'
2711
+ );
2712
+
2713
+ let anchorConfigured = false;
2714
+ if (providePaidOrders) {
2715
+ try {
2716
+ await configureAnchor();
2717
+ anchorConfigured = true;
2718
+ } catch (e) {
2719
+ console.error(`❌ Failed to configure anchor: ${e.message}`);
2720
+ }
2721
+ }
2722
+
2723
+ console.log(JSON.stringify({
2724
+ status: 'created',
2725
+ agent_id: identity.agent_id,
2726
+ did: identity.did,
2083
2727
  policy: POLICY_FILE,
2084
- nextSteps: [
2085
- 'atel start bring the agent online (network + auto-register)',
2086
- `atel register ${name} "<capability1>,<capability2>" — advertise what you can do`,
2087
- 'atel info — show your DID, wallets, and policy',
2088
- 'atel balance — check USDC balance on Base/BSC smart wallets',
2089
- ],
2090
- note: 'Paid orders work out of the box in V2 — no on-chain private key needed. The Platform anchors on your behalf using its own executor wallets. If you specifically need legacy self-anchoring, run: atel anchor config',
2091
- };
2092
- if (smartWallet) output.smartWallet = smartWallet;
2093
- console.log(JSON.stringify(output, null, 2));
2728
+ anchor: anchorConfigured ? 'configured' : 'disabled',
2729
+ next: 'Run: atel start [port] auto-configures network and registers'
2730
+ }, null, 2));
2094
2731
 
2095
- // Auto-install SKILL.md to OpenClaw skills directory
2096
2732
  try {
2097
- const installed = syncAtelSkillToOpenClaw({ verbose: true });
2098
- if (!installed.length) {
2099
- const sdkSkillPath = resolveSdkSkillPath();
2100
- console.log('\n📋 To teach your AI agent about ATEL, send it this message:');
2101
- console.log(` "Read ${sdkSkillPath} carefully, then help me set up ATEL and start earning."`);
2102
- }
2103
- } catch (e) { /* skill install is best-effort */ }
2104
- }
2105
-
2106
- // Path to the SKILL.md shipped inside the currently-installed SDK package.
2107
- // Used both at `atel init` time and on every `atel start` to keep openclaw's
2108
- // workspace copy in sync with the latest published skill content.
2109
- function resolveSdkSkillPath() {
2110
- return resolve(dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'atel-agent', 'SKILL.md');
2111
- }
2112
-
2113
- // Sync the SDK's SKILL.md into every openclaw skills dir that already hosts
2114
- // an atel-agent skill. Written to fix a subtle version-drift bug: `npm i -g`
2115
- // upgrades the SDK package on disk, but openclaw's session `skillsSnapshot`
2116
- // reads from `~/.openclaw/workspace/skills/atel-agent/SKILL.md`, which is a
2117
- // physical copy made at init time and never refreshed. After an upgrade the
2118
- // agent keeps seeing stale skill content indefinitely. See incident
2119
- // 2026-04-09, order ord-4c12a03d-ea8, where the workspace copy was ~12 days
2120
- // older than the npm copy and missing the "--desc required" guidance.
2121
- //
2122
- // We intentionally sync to *every* candidate dir where an `atel-agent` sibling
2123
- // is present (not just the first one), because different openclaw versions
2124
- // read from different roots and we want all of them to end up coherent.
2125
- // Returns the list of destinations actually written.
2126
- function syncAtelSkillToOpenClaw({ verbose = false } = {}) {
2127
- const sdkSkillPath = resolveSdkSkillPath();
2128
- if (!existsSync(sdkSkillPath)) return [];
2129
- const home = process.env.HOME || '';
2130
- // Candidate parent dirs, in the order we want to probe. We sync to any
2131
- // that exist, creating the leaf `atel-agent/` dir if its parent is present.
2132
- const parents = [
2133
- join(home, '.openclaw', 'workspace', 'skills'),
2134
- join(home, '.openclaw', 'skills'),
2135
- ];
2136
- const written = [];
2137
- for (const parent of parents) {
2138
- if (!existsSync(parent)) continue;
2139
- const dir = join(parent, 'atel-agent');
2140
- try {
2141
- mkdirSync(dir, { recursive: true });
2142
- const dest = join(dir, 'SKILL.md');
2143
- copyFileSync(sdkSkillPath, dest);
2144
- written.push(dest);
2145
- if (verbose) {
2146
- console.log(`✅ ATEL skill synced → ${dest}`);
2733
+ const sdkSkillPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'atel-agent', 'SKILL.md');
2734
+ if (existsSync(sdkSkillPath)) {
2735
+ const home = process.env.HOME || '';
2736
+ const candidates = [
2737
+ join(home, '.openclaw', 'skills', 'atel-agent'),
2738
+ join(home, '.openclaw', 'workspace', 'skills', 'atel-agent'),
2739
+ ];
2740
+ let installed = false;
2741
+ for (const dir of candidates) {
2742
+ if (existsSync(dirname(dir))) {
2743
+ mkdirSync(dir, { recursive: true });
2744
+ copyFileSync(sdkSkillPath, join(dir, 'SKILL.md'));
2745
+ console.log(`\n✅ ATEL Skill auto-installed to ${dir}`);
2746
+ console.log(' Your AI agent will automatically know how to use ATEL.');
2747
+ console.log('');
2748
+ console.log('📌 Tell your agent this message to get started:');
2749
+ console.log(' "Read the atel-agent skill at ~/.openclaw/skills/atel-agent/SKILL.md carefully, then help me set up ATEL and start earning."');
2750
+ installed = true;
2751
+ break;
2752
+ }
2753
+ }
2754
+ if (!installed) {
2755
+ console.log('\n📋 To teach your AI agent about ATEL, send it this message:');
2756
+ console.log(` "Read ${sdkSkillPath} carefully, then help me set up ATEL and start earning."`);
2147
2757
  }
2148
- } catch (e) {
2149
- if (verbose) console.error(` (skipped ${dir}: ${e.message})`);
2150
2758
  }
2151
- }
2152
- return written;
2759
+ } catch (e) { /* skill install is best-effort */ }
2153
2760
  }
2154
2761
 
2155
2762
  async function cmdAnchor(subcommand) {
@@ -2319,7 +2926,7 @@ async function cmdStatus() {
2319
2926
  async function cmdSetup(port) {
2320
2927
  const p = parseInt(port || '3100');
2321
2928
  console.log(JSON.stringify({ event: 'network_setup', port: p }));
2322
- const net = await autoNetworkSetup(p);
2929
+ const net = await autoNetworkSetup(p, ATEL_RELAY);
2323
2930
  for (const step of net.steps) console.log(JSON.stringify({ event: 'step', message: step }));
2324
2931
  if (net.endpoint) {
2325
2932
  saveNetwork({ publicIP: net.publicIP, port: p, endpoint: net.endpoint, upnp: net.upnpSuccess, reachable: net.reachable, configuredAt: new Date().toISOString() });
@@ -2495,21 +3102,6 @@ async function cmdStart(port) {
2495
3102
  log({ event: 'openclaw_lock_cleanup_error', error: e.message });
2496
3103
  }
2497
3104
 
2498
- // Refresh workspace SKILL.md from the currently-installed SDK package.
2499
- // Without this, `npm i -g` upgrades leave a stale copy at
2500
- // ~/.openclaw/workspace/skills/atel-agent/SKILL.md that openclaw keeps
2501
- // reading — the documented behaviour of the upgrade then silently lags
2502
- // behind the code. Fixes incident 2026-04-09 where a ~12-day-old workspace
2503
- // skill copy was missing "--desc is required" guidance.
2504
- try {
2505
- const synced = syncAtelSkillToOpenClaw();
2506
- if (synced.length > 0) {
2507
- log({ event: 'atel_skill_synced', count: synced.length, destinations: synced });
2508
- }
2509
- } catch (e) {
2510
- log({ event: 'atel_skill_sync_error', error: e.message });
2511
- }
2512
-
2513
3105
  // Initialize Ollama only if explicitly enabled (optional local AI audit)
2514
3106
  if (process.env.ATEL_OLLAMA_ENABLED === 'true') {
2515
3107
  await initializeOllama().catch(err => {
@@ -2646,12 +3238,19 @@ async function cmdStart(port) {
2646
3238
  let networkConfig = loadNetwork();
2647
3239
  if (!networkConfig) {
2648
3240
  log({ event: 'network_setup', status: 'auto-detecting' });
2649
- networkConfig = await autoNetworkSetup(p);
3241
+ networkConfig = await autoNetworkSetup(p, ATEL_RELAY);
2650
3242
  for (const step of networkConfig.steps) log({ event: 'network_step', message: step });
2651
3243
  delete networkConfig.steps;
2652
3244
  saveNetwork(networkConfig);
2653
3245
  } else {
2654
- log({ event: 'network_loaded', candidates: networkConfig.candidates?.length || 0 });
3246
+ const synced = syncNetworkConfigToPort(networkConfig, p);
3247
+ networkConfig = synced.config;
3248
+ if (synced.changed) {
3249
+ saveNetwork(networkConfig);
3250
+ log({ event: 'network_port_aligned', port: p, endpoint: networkConfig.endpoint, candidates: networkConfig.candidates?.length || 0 });
3251
+ } else {
3252
+ log({ event: 'network_loaded', candidates: networkConfig.candidates?.length || 0 });
3253
+ }
2655
3254
  }
2656
3255
 
2657
3256
  // ── Start endpoint ──
@@ -2922,6 +3521,7 @@ async function cmdStart(port) {
2922
3521
  summary: body.summary,
2923
3522
  error: body.error,
2924
3523
  childSessionKey: pending.childSessionKey,
3524
+ duration_ms: pending.startedAt ? Date.now() - pending.startedAt : undefined,
2925
3525
  });
2926
3526
 
2927
3527
  if (body.status === 'failed') {
@@ -2937,6 +3537,7 @@ async function cmdStart(port) {
2937
3537
  childSessionKey: pending.childSessionKey,
2938
3538
  summary: body.summary,
2939
3539
  error: body.error,
3540
+ duration_ms: pending.startedAt ? Date.now() - pending.startedAt : undefined,
2940
3541
  });
2941
3542
 
2942
3543
  if (failedAction.action?.type === 'local_result') {
@@ -3115,7 +3716,7 @@ async function cmdStart(port) {
3115
3716
 
3116
3717
  Important requirements:
3117
3718
  1. This is a P2P task. Do not call atel result; the local SDK will submit the result automatically after your callback.
3118
- 2. Your job is to complete the AI work carefully and send the final conclusion back to the local SDK through the callback.
3719
+ 2. Your job is to complete the AI work carefully and send the final conclusion back to the local SDK through the callback. Writing text alone does NOT deliver the result. Only the callback counts.
3119
3720
  ${fileAccessRule}
3120
3721
  ${contextRule}
3121
3722
  5. After finishing, you must immediately run the success callback command template below and replace its content with your real result:
@@ -3132,7 +3733,7 @@ ${callbackFailed}
3132
3733
 
3133
3734
  Important requirements:
3134
3735
  1. Do not run atel milestone-submit / milestone-verify / milestone-feedback. The local SDK will execute those commands on your behalf after the callback.
3135
- 2. Your job is to complete the AI work carefully and send the final conclusion back to the local SDK through the callback.
3736
+ 2. Your job is to complete the AI work carefully and send the final conclusion back to the local SDK through the callback. Writing text alone does NOT deliver the result. Only the callback counts.
3136
3737
  ${fileAccessRule}
3137
3738
  ${contextRule}
3138
3739
  ${repoRule}
@@ -3333,11 +3934,14 @@ Format:
3333
3934
  if (!safePrompt) return { ok: false, error: 'empty_agent_prompt' };
3334
3935
  const taskPrompt = buildGatewayCallbackPrompt(eventType, safePrompt, callbackUrl, dedupeKey, cwd, payload);
3335
3936
  const timeoutMs = 10 * 60 * 1000;
3937
+ const startedAt = Date.now();
3336
3938
 
3337
3939
  return await new Promise(async (resolve) => {
3338
3940
  const timer = setTimeout(() => {
3941
+ const active = pendingAgentCallbacks.get(dedupeKey);
3339
3942
  pendingAgentCallbacks.delete(dedupeKey);
3340
3943
  persistPendingAgentCallback(dedupeKey, { eventType, payload, cwd, source: dedupeKey.startsWith('reconcile:') ? 'reconcile' : 'main', timeout: true });
3944
+ log({ event: 'agent_callback_timeout', eventType, dedupeKey, childSessionKey: active?.childSessionKey || null, duration_ms: Date.now() - startedAt });
3341
3945
  resolve({ ok: false, error: 'agent_callback_timeout' });
3342
3946
  }, timeoutMs);
3343
3947
 
@@ -3346,6 +3950,7 @@ Format:
3346
3950
  payload,
3347
3951
  cwd,
3348
3952
  childSessionKey: null,
3953
+ startedAt,
3349
3954
  resolve: (result) => {
3350
3955
  clearTimeout(timer);
3351
3956
  resolve(result);
@@ -3375,9 +3980,11 @@ Format:
3375
3980
  signal: AbortSignal.timeout(15000),
3376
3981
  });
3377
3982
  if (!resp.ok) {
3983
+ const errorText = await resp.text().catch(() => '');
3378
3984
  pendingAgentCallbacks.delete(dedupeKey);
3379
3985
  clearPersistedPendingAgentCallback(dedupeKey);
3380
3986
  clearTimeout(timer);
3987
+ log({ event: 'agent_session_spawn_failed', eventType, dedupeKey, status: resp.status, error: errorText.slice(0, 300), duration_ms: Date.now() - startedAt });
3381
3988
  resolve({ ok: false, error: `sessions_spawn_http_${resp.status}` });
3382
3989
  return;
3383
3990
  }
@@ -3386,25 +3993,35 @@ Format:
3386
3993
  const childSessionKey = data.result?.details?.childSessionKey || data.result?.childSessionKey || '';
3387
3994
  const pending = pendingAgentCallbacks.get(dedupeKey);
3388
3995
  if (pending) pending.childSessionKey = childSessionKey;
3389
- log({ event: 'agent_session_spawned', eventType, dedupeKey, childSessionKey });
3996
+ log({ event: 'agent_session_spawned', eventType, dedupeKey, childSessionKey, spawn_duration_ms: Date.now() - startedAt });
3390
3997
  } catch (e) {
3391
3998
  pendingAgentCallbacks.delete(dedupeKey);
3392
3999
  clearPersistedPendingAgentCallback(dedupeKey);
3393
4000
  clearTimeout(timer);
4001
+ log({ event: 'agent_session_spawn_failed', eventType, dedupeKey, error: e.message, duration_ms: Date.now() - startedAt });
3394
4002
  resolve({ ok: false, error: e.message });
3395
4003
  }
3396
4004
  });
3397
4005
  }
3398
4006
 
3399
4007
  function queueAgentHook(eventType, dedupeKey, promptText, cwd, payload = {}, options = {}) {
3400
- if (!detectedAgentCmd) return false;
4008
+ if (!detectedAgentCmd) {
4009
+ log({ event: 'agent_hook_not_queued', eventType, dedupeKey, reason: 'missing_agent_cmd' });
4010
+ return false;
4011
+ }
3401
4012
  const safePrompt = sanitizeAgentPrompt(promptText, { eventType, dedupeKey });
3402
- if (!safePrompt) return false;
4013
+ if (!safePrompt) {
4014
+ log({ event: 'agent_hook_not_queued', eventType, dedupeKey, reason: 'empty_prompt' });
4015
+ return false;
4016
+ }
3403
4017
  const parsedCmd = detectedAgentCmd.trim().split(/\s+/);
3404
4018
  parsedCmd.push(safePrompt);
3405
4019
  const recoveryKey = options.recoveryKey || '';
3406
4020
  if (recoveryKey) {
3407
- if (activeRecoveryKeys.has(recoveryKey)) return false;
4021
+ if (activeRecoveryKeys.has(recoveryKey)) {
4022
+ log({ event: 'agent_hook_not_queued', eventType, dedupeKey, reason: 'recovery_key_active', recoveryKey });
4023
+ return false;
4024
+ }
3408
4025
  activeRecoveryKeys.add(recoveryKey);
3409
4026
  }
3410
4027
  hookQueue.push({ event: eventType, dedupeKey, cmd: parsedCmd[0], args: parsedCmd.slice(1), cwd, payload, recoveryKey });
@@ -3451,7 +4068,7 @@ Format:
3451
4068
  const ms = await fetchMilestoneState(orderId);
3452
4069
  if (ms.orderStatus !== 'executing') return;
3453
4070
 
3454
- if (executorDid === id.did && ms.phase === 'waiting_executor_submission') {
4071
+ if (executorDid === id.did && ['waiting_executor_submission', 'waiting_executor_resubmission'].includes(ms.phase)) {
3455
4072
  const currentIndex = Number.isFinite(ms.currentMilestone) ? ms.currentMilestone : 0;
3456
4073
  const currentMilestone = (ms.milestones || []).find(m => m.index === currentIndex) || {};
3457
4074
  const previousApprovedOutputs = summarizeApprovedMilestones(ms.milestones || [], currentIndex);
@@ -3466,33 +4083,64 @@ Format:
3466
4083
  milestoneObjective: currentMilestone.title || '',
3467
4084
  previousApprovedOutputs,
3468
4085
  });
3469
- const eventType = currentIndex === 0 ? 'milestone_plan_confirmed' : 'milestone_verified';
3470
- const payload = currentIndex === 0
4086
+ const isResubmission = ms.phase === 'waiting_executor_resubmission' || currentMilestone.status === 'rejected';
4087
+ const eventType = isResubmission
4088
+ ? 'milestone_rejected'
4089
+ : (currentIndex === 0 ? 'milestone_plan_confirmed' : 'milestone_verified');
4090
+ const payload = isResubmission
3471
4091
  ? {
3472
4092
  orderId,
3473
- milestoneIndex: 0,
4093
+ milestoneIndex: currentIndex,
3474
4094
  totalMilestones: ms.totalMilestones || 5,
3475
4095
  milestoneDescription: currentMilestone.title || '',
3476
4096
  orderDescription,
3477
4097
  previousApprovedOutputs,
4098
+ rejectReason: currentMilestone.rejectReason || '',
4099
+ submitCount: currentMilestone.submitCount || 0,
3478
4100
  }
3479
- : {
3480
- orderId,
3481
- milestoneIndex: currentIndex - 1,
3482
- currentMilestone: currentIndex,
3483
- totalMilestones: ms.totalMilestones || 5,
3484
- allComplete: false,
3485
- nextMilestoneDescription: currentMilestone.title || '',
3486
- orderDescription,
3487
- previousApprovedOutputs,
3488
- };
3489
- const promptText = currentIndex === 0
3490
- ? `You are the ATEL executor agent. The plan has been confirmed and execution begins.\nOriginal order requirements: ${orderDescription || 'not provided'}\nCurrent milestone M0: ${currentMilestone.title || ''}\nComplete only this milestone for the current order and return the final deliverable via the callback.`
3491
- : `You are the ATEL executor agent. M${currentIndex - 1} has been approved.\nOriginal order requirements: ${orderDescription || 'not provided'}\nNext milestone M${currentIndex}: ${currentMilestone.title || ''}\nPreviously approved outputs:\n${previousApprovedOutputs || 'none'}\n\nAdvance the current milestone strictly based on these approved results. Do not invent missing materials or read local shared files to fill missing context. After completion, return the final deliverable via the callback.`;
4101
+ : (currentIndex === 0
4102
+ ? {
4103
+ orderId,
4104
+ milestoneIndex: 0,
4105
+ totalMilestones: ms.totalMilestones || 5,
4106
+ milestoneDescription: currentMilestone.title || '',
4107
+ orderDescription,
4108
+ previousApprovedOutputs,
4109
+ }
4110
+ : {
4111
+ orderId,
4112
+ milestoneIndex: currentIndex - 1,
4113
+ currentMilestone: currentIndex,
4114
+ totalMilestones: ms.totalMilestones || 5,
4115
+ allComplete: false,
4116
+ nextMilestoneDescription: currentMilestone.title || '',
4117
+ orderDescription,
4118
+ previousApprovedOutputs,
4119
+ });
4120
+ const promptText = isResubmission
4121
+ ? `You are the ATEL executor agent. Your previous submission for milestone M${currentIndex} was rejected.
4122
+ Original order requirements: ${orderDescription || 'not provided'}
4123
+ Current milestone M${currentIndex}: ${currentMilestone.title || ''}
4124
+ Previously approved outputs:
4125
+ ${previousApprovedOutputs || 'none'}
4126
+
4127
+ Revise only the rejected milestone based on the rejection feedback and submit an improved deliverable.`
4128
+ : (currentIndex === 0
4129
+ ? `You are the ATEL executor agent. The plan has been confirmed and execution begins.
4130
+ Original order requirements: ${orderDescription || 'not provided'}
4131
+ Current milestone M0: ${currentMilestone.title || ''}
4132
+ Complete only this milestone for the current order and return the final deliverable via the callback.`
4133
+ : `You are the ATEL executor agent. M${currentIndex - 1} has been approved.
4134
+ Original order requirements: ${orderDescription || 'not provided'}
4135
+ Next milestone M${currentIndex}: ${currentMilestone.title || ''}
4136
+ Previously approved outputs:
4137
+ ${previousApprovedOutputs || 'none'}
4138
+
4139
+ Advance the current milestone strictly based on these approved results. Do not invent missing materials or read local shared files to fill missing context. After completion, return the final deliverable via the callback.`);
3492
4140
  const recoveryKey = buildMilestoneHookRecoveryKey(eventType, payload);
3493
- log({ event: 'trade_reconcile_executor', orderId, currentMilestone: currentIndex, recoveryKey });
4141
+ log({ event: 'trade_reconcile_executor', orderId, currentMilestone: currentIndex, phase: ms.phase, recoveryKey });
3494
4142
  const queued = queueAgentHook(eventType, recoveryKey, promptText, workspace.dir, payload, { recoveryKey });
3495
- if (queued) log({ event: 'trade_reconcile_executor_queued', orderId, currentMilestone: currentIndex, recoveryKey });
4143
+ if (queued) log({ event: 'trade_reconcile_executor_queued', orderId, currentMilestone: currentIndex, phase: ms.phase, recoveryKey });
3496
4144
  return;
3497
4145
  }
3498
4146
 
@@ -3578,6 +4226,108 @@ Format:
3578
4226
  }
3579
4227
  }
3580
4228
 
4229
+ endpoint.app?.post?.('/atel/v1/relay-message', async (req, res) => {
4230
+ const body = req.body || {};
4231
+ const senderDid = String(body.senderDid || body.from || '').trim();
4232
+ const p2pType = typeof body.type === 'string' ? body.type.trim() : '';
4233
+ const relaySignedMessage = extractSignedRelayEnvelope(body);
4234
+ const relaySignedSenderDid = String(relaySignedMessage.from || senderDid || '').trim();
4235
+
4236
+ if (p2pType === 'friend_accept') {
4237
+ const { requestId } = relaySignedMessage.payload || {};
4238
+ const verified = relaySignedSenderDid ? verifyMessage(relaySignedMessage, parseDID(relaySignedSenderDid)) : { valid: false };
4239
+ if (!verified.valid) {
4240
+ log({ event: 'friend_accept_rejected', from: relaySignedSenderDid || body.from, reason: 'invalid_signature_via_relay' });
4241
+ return res.json({ status: 'rejected', error: 'Invalid signature' });
4242
+ }
4243
+ const requests = loadFriendRequests();
4244
+ if (!requests.outgoing) requests.outgoing = [];
4245
+ const request = requests.outgoing.find(r => r.requestId === requestId);
4246
+ if (!request) {
4247
+ log({ event: 'friend_accept_ignored', from: relaySignedSenderDid, requestId, reason: 'request_not_found_via_relay' });
4248
+ return res.json({ status: 'error', error: 'Request not found' });
4249
+ }
4250
+ request.status = 'accepted';
4251
+ request.acceptedAt = new Date().toISOString();
4252
+ saveFriendRequests(requests);
4253
+ addFriend(relaySignedSenderDid, { addedBy: 'request', alias: '', notes: `Accepted our request ${requestId}` });
4254
+ log({ event: 'friend_request_accepted_by_peer', from: relaySignedSenderDid, requestId, via: 'relay' });
4255
+ return res.json({ status: 'ok', type: 'friend_accept' });
4256
+ }
4257
+
4258
+ if (p2pType === 'friend_reject') {
4259
+ const { requestId, reason } = relaySignedMessage.payload || {};
4260
+ const verified = relaySignedSenderDid ? verifyMessage(relaySignedMessage, parseDID(relaySignedSenderDid)) : { valid: false };
4261
+ if (!verified.valid) {
4262
+ log({ event: 'friend_reject_ignored', from: relaySignedSenderDid || body.from, reason: 'invalid_signature_via_relay' });
4263
+ return res.json({ status: 'rejected', error: 'Invalid signature' });
4264
+ }
4265
+ const requests = loadFriendRequests();
4266
+ if (!requests.outgoing) requests.outgoing = [];
4267
+ const request = requests.outgoing.find(r => r.requestId === requestId);
4268
+ if (request) {
4269
+ request.status = 'rejected';
4270
+ request.rejectedAt = new Date().toISOString();
4271
+ request.reason = reason || '';
4272
+ saveFriendRequests(requests);
4273
+ }
4274
+ log({ event: 'friend_request_rejected_by_peer', from: relaySignedSenderDid, requestId, reason, via: 'relay' });
4275
+ return res.json({ status: 'ok', type: 'friend_reject' });
4276
+ }
4277
+
4278
+ const kind = String(body.kind || ((typeof body.msgType === 'string' && body.msgType === 'message') || typeof body.text === 'string' ? 'portal_message' : 'relay_message')).trim();
4279
+ const text = typeof body.text === 'string' && body.text.trim()
4280
+ ? body.text.trim()
4281
+ : (typeof body.summary === 'string' && body.summary.trim()
4282
+ ? body.summary.trim()
4283
+ : (kind === 'contact_added' && senderDid ? `${senderDid} added you as a contact` : '[message]'));
4284
+ const messageSenderDid = relaySignedSenderDid || senderDid;
4285
+
4286
+ if (kind === 'portal_message' && messageSenderDid && messageSenderDid !== 'did:atel:platform') {
4287
+ if (relaySignedMessage.signature) {
4288
+ const verified = verifyMessage(relaySignedMessage, parseDID(messageSenderDid));
4289
+ if (!verified.valid) {
4290
+ log({ event: 'relay_message_rejected', kind, from: messageSenderDid, reason: 'invalid_signature' });
4291
+ return res.json({ status: 'rejected', error: 'Invalid signature', code: 'INVALID_SIGNATURE' });
4292
+ }
4293
+ }
4294
+
4295
+ const currentPolicy = loadPolicy();
4296
+ const accessCheck = checkP2PMessageAccess(messageSenderDid, currentPolicy);
4297
+ if (!accessCheck.allowed) {
4298
+ log({
4299
+ event: 'p2p_message_rejected',
4300
+ from: messageSenderDid,
4301
+ reason: accessCheck.reason,
4302
+ code: accessCheck.code,
4303
+ timestamp: new Date().toISOString()
4304
+ });
4305
+ return res.json({
4306
+ status: 'rejected',
4307
+ error: accessCheck.message || 'Access denied',
4308
+ code: accessCheck.code,
4309
+ hint: accessCheck.hint
4310
+ });
4311
+ }
4312
+ }
4313
+
4314
+ log({
4315
+ event: 'relay_message_received',
4316
+ kind,
4317
+ from: messageSenderDid || senderDid,
4318
+ text,
4319
+ timestamp: body.timestamp || new Date().toISOString(),
4320
+ });
4321
+
4322
+ if (kind === 'contact_added') {
4323
+ pushP2PNotification('p2p_contact_added', { peerDid: messageSenderDid || senderDid, alias: body.alias || '', text }).catch((e) => log({ event: 'p2p_notify_error', kind, error: e.message }));
4324
+ } else if (kind !== 'telegram_message') {
4325
+ pushP2PNotification('p2p_message_received', { peerDid: messageSenderDid || senderDid, text }).catch((e) => log({ event: 'p2p_notify_error', kind, error: e.message }));
4326
+ }
4327
+
4328
+ res.json({ status: 'ok', kind });
4329
+ });
4330
+
3581
4331
  // Webhook notification: POST /atel/v1/notify
3582
4332
  // SDK only: logs, writes inbox, prints prompt. Does NOT execute any actions.
3583
4333
  // Agent reads inbox or webhook to decide what to do.
@@ -3638,29 +4388,6 @@ Format:
3638
4388
  dedupeKey: body.dedupeKey,
3639
4389
  });
3640
4390
 
3641
- // ── Fast-coop: auto-submit deliverable when all milestones verified ──
3642
- // When executor receives the final milestone_verified for a fast-coop order,
3643
- // sign and submit Escrow::Submit on Fast staging so the Platform can Complete.
3644
- if (event === 'milestone_verified' && payload.allComplete === true) {
3645
- const fastOrderId = body.orderId || payload.orderId || '';
3646
- if (fastOrderId) {
3647
- // Payload may not include chain — fetch order to check
3648
- try {
3649
- const _resp = await fetch(`${PLATFORM_URL}/trade/v1/order/${fastOrderId}`, { signal: AbortSignal.timeout(10000) });
3650
- const orderInfo = _resp.ok ? await _resp.json() : null;
3651
- const orderChain = orderInfo?.chain || orderInfo?.Chain || '';
3652
- if (orderChain === 'fast-coop') {
3653
- log({ event: 'fast_submit_triggered', orderId: fastOrderId, chain: orderChain });
3654
- submitFastDeliverable(fastOrderId, id).catch(e => {
3655
- log({ event: 'fast_submit_error', orderId: fastOrderId, error: e.message });
3656
- });
3657
- }
3658
- } catch (e) {
3659
- log({ event: 'fast_submit_check_error', orderId: fastOrderId, error: e.message });
3660
- }
3661
- }
3662
- }
3663
-
3664
4391
  const dedupeKey = body.dedupeKey || `${event}:${body.orderId || payload.orderId || ''}`;
3665
4392
  const orderIdForCwd = body.orderId || payload.orderId || '';
3666
4393
  let workspace = getOrderWorkspace(orderIdForCwd, {
@@ -3875,27 +4602,34 @@ Format:
3875
4602
  const localHookTimeoutMs = isMilestoneHook ? 180000 : 600000;
3876
4603
  const preparedInvocation = prepareHookInvocation(spawnCmd, spawnArgs, hookKey, Math.ceil(localHookTimeoutMs / 1000));
3877
4604
  const runHook = (attempt, invocation = preparedInvocation) => {
4605
+ const hookStartedAt = Date.now();
3878
4606
  execFile(invocation.cmd, invocation.args, { timeout: localHookTimeoutMs, cwd: hookCwd, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
3879
4607
  const errMsg = (err?.message || '') + (stderr || '');
4608
+ const durationMs = Date.now() - hookStartedAt;
3880
4609
  const isSessionLock = errMsg.includes('session file locked') || errMsg.includes('session locked');
3881
4610
  const isNetworkError = err && (err.killed || err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET');
3882
4611
 
3883
4612
  if (isSessionLock && attempt < MAX_ATTEMPTS) {
3884
4613
  // OpenClaw session still held by previous call — wait for lock release then retry
3885
4614
  const delay = 15000 + (attempt * 5000); // 15s, 20s, 25s, 30s
3886
- log({ event: 'agent_cmd_session_lock', eventType: hookEvent, attempt: attempt + 1, delayMs: delay });
4615
+ log({ event: 'agent_cmd_session_lock', eventType: hookEvent, attempt: attempt + 1, delayMs: delay, duration_ms: durationMs });
3887
4616
  setTimeout(() => runHook(attempt + 1), delay);
3888
4617
  } else if (isNetworkError && attempt < 2) {
3889
- log({ event: 'agent_cmd_retry', eventType: hookEvent, attempt: attempt + 1, error: err.message });
4618
+ log({ event: 'agent_cmd_retry', eventType: hookEvent, attempt: attempt + 1, error: err.message, duration_ms: durationMs });
3890
4619
  setTimeout(() => runHook(attempt + 1), 10000);
3891
4620
  } else if (err) {
3892
- log({ event: 'agent_cmd_error', eventType: hookEvent, error: err.message, stderr: (stderr || '').substring(0, 200) });
4621
+ if (err.killed || err.code === 'ETIMEDOUT') {
4622
+ log({ event: 'agent_cmd_timeout', eventType: hookEvent, dedupeKey: hookKey, timeout_ms: localHookTimeoutMs, duration_ms: durationMs, error: err.message, stderr: (stderr || '').substring(0, 200) });
4623
+ } else {
4624
+ log({ event: 'agent_cmd_error', eventType: hookEvent, dedupeKey: hookKey, error: err.message, duration_ms: durationMs, stderr: (stderr || '').substring(0, 200) });
4625
+ }
3893
4626
  finishHook();
3894
4627
  } else if (isKnownUpstreamModelInputError(stdout)) {
3895
4628
  log({
3896
4629
  event: 'agent_cmd_upstream_input_error',
3897
4630
  eventType: hookEvent,
3898
4631
  dedupeKey: hookKey,
4632
+ duration_ms: durationMs,
3899
4633
  note: 'suppressed known upstream model input-length error',
3900
4634
  });
3901
4635
  finishHook();
@@ -3926,21 +4660,21 @@ Format:
3926
4660
  : Promise.resolve(false);
3927
4661
  guardCheck.then(alreadySubmitted => {
3928
4662
  if (alreadySubmitted) {
3929
- log({ event: 'agent_cmd_done', eventType: hookEvent, mode: 'already_submitted_by_agent', dedupeKey: hookKey });
4663
+ log({ event: 'agent_cmd_done', eventType: hookEvent, mode: 'already_submitted_by_agent', dedupeKey: hookKey, duration_ms: durationMs });
3930
4664
  finishHook();
3931
4665
  return;
3932
4666
  }
3933
4667
  executeRecommendedActionDirect(hookEvent, localAction.action, hookCwd || process.cwd(), hookKey)
3934
4668
  .then((execResult) => {
3935
4669
  if (execResult.ok) {
3936
- log({ event: 'agent_cmd_done', eventType: hookEvent, mode: 'local_stdout_action', dedupeKey: hookKey, stdout: summarizeAgentOutput(stdout, 200) });
4670
+ log({ event: 'agent_cmd_done', eventType: hookEvent, mode: 'local_stdout_action', dedupeKey: hookKey, duration_ms: durationMs, stdout: summarizeAgentOutput(stdout, 200) });
3937
4671
  } else {
3938
- log({ event: 'agent_cmd_local_action_error', eventType: hookEvent, dedupeKey: hookKey, error: execResult.error || 'local_action_failed', stdout: summarizeAgentOutput(stdout, 200) });
4672
+ log({ event: 'agent_cmd_local_action_error', eventType: hookEvent, dedupeKey: hookKey, error: execResult.error || 'local_action_failed', duration_ms: durationMs, stdout: summarizeAgentOutput(stdout, 200) });
3939
4673
  }
3940
4674
  finishHook();
3941
4675
  })
3942
4676
  .catch((e) => {
3943
- log({ event: 'agent_cmd_local_action_error', eventType: hookEvent, dedupeKey: hookKey, error: e.message || 'local_action_failed', stdout: summarizeAgentOutput(stdout, 200) });
4677
+ log({ event: 'agent_cmd_local_action_error', eventType: hookEvent, dedupeKey: hookKey, error: e.message || 'local_action_failed', duration_ms: durationMs, stdout: summarizeAgentOutput(stdout, 200) });
3944
4678
  finishHook();
3945
4679
  });
3946
4680
  return;
@@ -3948,7 +4682,13 @@ Format:
3948
4682
  return;
3949
4683
  }
3950
4684
 
3951
- log({ event: 'agent_cmd_done', eventType: hookEvent, stdout: summarizeAgentOutput(stdout, 300) });
4685
+ if (localAction.skipped) {
4686
+ log({ event: 'agent_cmd_skipped', eventType: hookEvent, dedupeKey: hookKey, reason: localAction.reason || 'skipped', duration_ms: durationMs, stdout: summarizeAgentOutput(stdout, 200) });
4687
+ finishHook();
4688
+ return;
4689
+ }
4690
+
4691
+ log({ event: 'agent_cmd_local_parse_error', eventType: hookEvent, dedupeKey: hookKey, error: localAction.error || 'invalid_local_agent_stdout', duration_ms: durationMs, stdout: summarizeAgentOutput(stdout, 300) });
3952
4692
  finishHook();
3953
4693
  }
3954
4694
  });
@@ -4507,6 +5247,42 @@ Format:
4507
5247
  };
4508
5248
  }
4509
5249
 
5250
+ if (payload.msgType === 'message') {
5251
+ const text = typeof payload.text === 'string' && payload.text.trim()
5252
+ ? payload.text.trim()
5253
+ : (typeof payload.summary === 'string' && payload.summary.trim() ? payload.summary.trim() : '[message]');
5254
+ const currentPolicy = loadPolicy();
5255
+ const accessCheck = checkP2PMessageAccess(message.from, currentPolicy);
5256
+ if (!accessCheck.allowed) {
5257
+ log({
5258
+ event: 'p2p_message_rejected',
5259
+ from: message.from,
5260
+ reason: accessCheck.reason,
5261
+ code: accessCheck.code,
5262
+ timestamp: new Date().toISOString()
5263
+ });
5264
+ return {
5265
+ status: 'rejected',
5266
+ error: accessCheck.message || 'Access denied',
5267
+ code: accessCheck.code,
5268
+ hint: accessCheck.hint
5269
+ };
5270
+ }
5271
+ log({
5272
+ event: 'relay_message_received',
5273
+ kind: 'portal_message',
5274
+ from: message.from,
5275
+ text,
5276
+ timestamp: new Date().toISOString(),
5277
+ });
5278
+ pushP2PNotification('p2p_message_received', { peerDid: message.from, text }).catch((e) => log({ event: 'p2p_notify_error', kind: 'portal_message', error: e.message }));
5279
+ return {
5280
+ status: 'ok',
5281
+ kind: 'portal_message',
5282
+ msgId: payload.msgId || null,
5283
+ };
5284
+ }
5285
+
4510
5286
  // Ignore task-result messages (these are responses, not new tasks)
4511
5287
  if (message.type === 'task-result' || payload.status === 'completed' || payload.status === 'failed') {
4512
5288
  log({ event: 'result_received', type: 'task-result', from: message.from, taskId: payload.taskId, status: payload.status, proof: payload.proof || null, anchor: payload.anchor || null, execution: payload.execution || null, result: payload.result || null, timestamp: new Date().toISOString() });
@@ -4805,8 +5581,10 @@ Format:
4805
5581
 
4806
5582
  await endpoint.start();
4807
5583
 
4808
- // Auto-bind TG notifications on first start
4809
- try { autoBindNotifications(); } catch (e) { /* never block startup */ }
5584
+ // Telegram notifications are opt-in by default. Only auto-bind when explicit consent was already collected.
5585
+ if (ATEL_NOTIFY_AUTO_BIND) {
5586
+ try { autoBindNotifications(); } catch (e) { /* never block startup */ }
5587
+ }
4810
5588
 
4811
5589
  // Background retry for failed result pushes (durable queue)
4812
5590
  const flushResultPushQueue = async () => {
@@ -4945,21 +5723,30 @@ Format:
4945
5723
  try {
4946
5724
  req = typeof inner === 'string' ? JSON.parse(inner) : inner;
4947
5725
  } catch (e) {
4948
- if (m.id) failedIds.push(m.id);
4949
- log({ event: 'relay_message_parse_error', id: m.id, error: e.message, rawType: typeof inner });
5726
+ if (m.id) ackedIds.push(m.id);
5727
+ log({ event: 'relay_message_parse_error_acked', id: m.id, error: e.message, rawType: typeof inner });
4950
5728
  continue;
4951
5729
  }
4952
5730
 
4953
5731
  try {
5732
+ if (isLegacyPassiveRelayMessage(req)) {
5733
+ req = buildPassiveRelayRequest(req, m.sender || '');
5734
+ }
5735
+ if (isAckableMalformedRelayMessage(req, inner)) {
5736
+ if (m.id) ackedIds.push(m.id);
5737
+ log({ event: 'relay_message_invalid_acked', id: m.id, path: typeof req?.path === 'string' && req.path.trim() ? req.path.trim() : '/', note: 'empty_or_invalid_relay_message' });
5738
+ continue;
5739
+ }
4954
5740
  const method = req.method || 'POST';
4955
- const path = req.path || '/';
5741
+ const path = typeof req.path === 'string' ? req.path.trim() : '';
5742
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
4956
5743
  const fetchOpts = {
4957
5744
  method,
4958
5745
  headers: { 'Content-Type': 'application/json' },
4959
5746
  signal: AbortSignal.timeout(30000),
4960
5747
  };
4961
- if (method !== 'GET' && method !== 'HEAD') fetchOpts.body = JSON.stringify(req.body || {});
4962
- const localUrl = `http://127.0.0.1:${p}${path}`;
5748
+ if (method !== 'GET' && method !== 'HEAD') fetchOpts.body = JSON.stringify(body);
5749
+ const localUrl = `http://127.0.0.1:${p}${path || '/'}`;
4963
5750
  const localResp = await fetch(localUrl, fetchOpts);
4964
5751
  const rawText = await localResp.text();
4965
5752
  let parsed;
@@ -5020,6 +5807,23 @@ Format:
5020
5807
  }, 120000);
5021
5808
  }
5022
5809
 
5810
+ let telegramInboundBusy = false;
5811
+ const pollTelegramInbound = async () => {
5812
+ if (telegramInboundBusy) return;
5813
+ telegramInboundBusy = true;
5814
+ try {
5815
+ await pollTelegramInboundUpdates(id.did);
5816
+ } catch (e) {
5817
+ log({ event: 'telegram_inbound_poll_error', did: id.did, error: e.message || 'unknown_error' });
5818
+ } finally {
5819
+ telegramInboundBusy = false;
5820
+ }
5821
+ };
5822
+ pollTelegramInbound().catch((e) => log({ event: 'telegram_inbound_poll_bootstrap_error', did: id.did, error: e.message || 'unknown_error' }));
5823
+ setInterval(() => {
5824
+ pollTelegramInbound().catch((e) => log({ event: 'telegram_inbound_poll_interval_error', did: id.did, error: e.message || 'unknown_error' }));
5825
+ }, 4000);
5826
+
5023
5827
  console.log(JSON.stringify({
5024
5828
  status: 'listening', agent_id: id.agent_id, did: id.did,
5025
5829
  port: p, candidates: networkConfig.candidates || [], capabilities: capTypes,
@@ -5099,10 +5903,16 @@ Format:
5099
5903
 
5100
5904
  async function cmdInbox(count) {
5101
5905
  const n = parseInt(count || '20');
5102
- if (!existsSync(INBOX_FILE)) { console.log(JSON.stringify({ messages: [], count: 0 })); return; }
5906
+ if (!existsSync(INBOX_FILE)) {
5907
+ console.log(JSON.stringify({ messages: [], count: 0, total: 0, filteredOut: 0 }, null, 2));
5908
+ return;
5909
+ }
5103
5910
  const lines = readFileSync(INBOX_FILE, 'utf-8').trim().split('\n').filter(Boolean);
5104
- const messages = lines.slice(-n).map(l => JSON.parse(l));
5105
- console.log(JSON.stringify({ messages, count: messages.length, total: lines.length }, null, 2));
5911
+ const parsed = lines.map((line) => {
5912
+ try { return JSON.parse(line); } catch { return null; }
5913
+ }).filter(Boolean);
5914
+ const messages = parsed.filter(isHumanInboxEntry).slice(-n);
5915
+ console.log(JSON.stringify({ messages, count: messages.length, total: parsed.length, filteredOut: parsed.length - messages.length }, null, 2));
5106
5916
  }
5107
5917
 
5108
5918
  async function cmdRegister(name, capabilities, endpointUrl) {
@@ -5857,7 +6667,8 @@ async function signedFetch(method, path, payload = {}) {
5857
6667
  // ─── Auth Command ────────────────────────────────────────────────
5858
6668
 
5859
6669
  async function cmdAuth(code) {
5860
- if (!code) {
6670
+ const normalizedCode = (code || '').trim().toUpperCase();
6671
+ if (!normalizedCode) {
5861
6672
  console.error('Usage: atel auth <code>');
5862
6673
  console.error(' Authorize a Dashboard session using the code displayed on the login page.');
5863
6674
  process.exit(1);
@@ -5867,7 +6678,7 @@ async function cmdAuth(code) {
5867
6678
  const { serializePayload } = await import('@lawrenceliang-btc/atel-sdk');
5868
6679
 
5869
6680
  const ts = new Date().toISOString();
5870
- const payload = { code: code.toUpperCase(), did: id.did, timestamp: ts };
6681
+ const payload = { code: normalizedCode, did: id.did, timestamp: ts };
5871
6682
  const signable = serializePayload({ payload, did: id.did, timestamp: ts });
5872
6683
  const sig = Buffer.from(nacl.sign.detached(Buffer.from(signable), id.secretKey)).toString('base64');
5873
6684
 
@@ -5875,7 +6686,7 @@ async function cmdAuth(code) {
5875
6686
  const resp = await fetch(`${ATEL_PLATFORM}/auth/v1/verify`, {
5876
6687
  method: 'POST',
5877
6688
  headers: { 'Content-Type': 'application/json' },
5878
- body: JSON.stringify({ code: code.toUpperCase(), did: id.did, signature: sig, timestamp: ts }),
6689
+ body: JSON.stringify({ code: normalizedCode, did: id.did, signature: sig, timestamp: ts }),
5879
6690
  });
5880
6691
  const data = await resp.json();
5881
6692
  if (resp.ok) {
@@ -6014,19 +6825,11 @@ async function cmdTransactions() {
6014
6825
  // ─── Trade Task: High-level one-shot command ────────────────────
6015
6826
  async function cmdTradeTask(capability, description) {
6016
6827
  if (!capability) { console.error('Usage: atel trade-task <capability> <description> [--budget N] [--executor DID] [--timeout 300]'); process.exit(1); }
6017
- if (!description || !description.trim()) {
6018
- console.error('Error: <description> is required and must contain the full task description.');
6019
- console.error(' The executor can only understand what to do via this field.');
6020
- console.error(' Pass the user\'s original task text verbatim, e.g.:');
6021
- console.error(' atel trade-task <capability> "<full user message>" --budget 5');
6022
- console.error(' Do NOT summarize, translate, or shorten the user\'s request.');
6023
- process.exit(2);
6024
- }
6025
6828
  const id = requireIdentity();
6026
6829
  const budget = parseFloat(rawArgs.find((a, i) => rawArgs[i-1] === '--budget') || '0');
6027
6830
  const executorArg = rawArgs.find((a, i) => rawArgs[i-1] === '--executor') || '';
6028
6831
  const timeout = parseInt(rawArgs.find((a, i) => rawArgs[i-1] === '--timeout') || '300') * 1000;
6029
- const desc = description;
6832
+ const desc = description || capability;
6030
6833
 
6031
6834
  // Step 1: Find executor
6032
6835
  let executorDid = executorArg;
@@ -6048,7 +6851,6 @@ async function cmdTradeTask(capability, description) {
6048
6851
  console.error(`[trade-task] Creating order: ${capability}, budget: $${budget}...`);
6049
6852
  const orderData = await signedFetch('POST', '/trade/v1/order', {
6050
6853
  executorDid, capabilityType: capability, priceAmount: budget, priceCurrency: 'USD', pricingModel: 'per_task',
6051
- description: desc,
6052
6854
  });
6053
6855
  const orderId = orderData.orderId;
6054
6856
  console.error(`[trade-task] Order created: ${orderId}`);
@@ -6096,27 +6898,12 @@ async function cmdTradeTask(capability, description) {
6096
6898
  }
6097
6899
 
6098
6900
  async function cmdOrder(executorDid, capType, price) {
6099
- if (!executorDid || !capType || !price) { console.error('Usage: atel order <executorDid> <capabilityType> <price> --desc "task description"'); process.exit(1); }
6901
+ if (!executorDid || !capType || !price) { console.error('Usage: atel order <executorDid> <capabilityType> <price> [--desc "task description"]'); process.exit(1); }
6100
6902
  // Resolve @alias to full DID
6101
6903
  if (executorDid.startsWith('@')) {
6102
6904
  try { executorDid = resolveDID(executorDid); } catch (e) { console.error(e.message); process.exit(1); }
6103
6905
  }
6104
6906
  const description = rawArgs.find((a, i) => rawArgs[i-1] === '--desc') || '';
6105
- // --desc is MANDATORY: the executor can only see the task via this field.
6106
- // An empty description would make the executor work from its own imagination
6107
- // (see production incident 2026-04-09, order ord-4c12a03d-ea8 where the
6108
- // executor generated an off-topic "system validation" milestone because
6109
- // orderDescription arrived empty). We fail loudly here so the caller
6110
- // (human or ReAct agent loop) gets an actionable error and can retry
6111
- // with the real task text from the user's message.
6112
- if (!description || !description.trim()) {
6113
- console.error('Error: --desc is required and must contain the full task description.');
6114
- console.error(' The executor can only understand what to do via --desc.');
6115
- console.error(' Pass the user\'s original task text verbatim, e.g.:');
6116
- console.error(' atel order <executorDid> <capability> <price> --desc "<full user message>"');
6117
- console.error(' Do NOT summarize, translate, or shorten the user\'s request.');
6118
- process.exit(2);
6119
- }
6120
6907
  const chainArg = rawArgs.find((a, i) => rawArgs[i-1] === '--chain') || '';
6121
6908
  const id = requireIdentity();
6122
6909
 
@@ -6191,6 +6978,7 @@ async function cmdOrder(executorDid, capType, price) {
6191
6978
  };
6192
6979
  if (chainArg) { orderBody.chain = chainArg; }
6193
6980
  const data = await signedFetch('POST', '/trade/v1/order', orderBody);
6981
+ if (data?.orderId) trackOrder(data.orderId, 'requester');
6194
6982
 
6195
6983
  // For paid orders: show escrow info (chain escrow creation handled by Platform backend)
6196
6984
  if (data.orderId && parseFloat(price) > 0 && data.escrow?.escrowContract) {
@@ -6766,6 +7554,7 @@ async function cmdComplete(orderId, taskId) {
6766
7554
  async function cmdConfirm(orderId) {
6767
7555
  if (!orderId) { console.error('Usage: atel confirm <orderId>'); process.exit(1); }
6768
7556
  const data = await signedFetch('POST', `/trade/v1/order/${orderId}/confirm`);
7557
+ rememberCurrentTelegramRoute(orderId);
6769
7558
  console.log(JSON.stringify(data, null, 2));
6770
7559
  }
6771
7560
 
@@ -6823,6 +7612,7 @@ async function cmdMilestoneFeedback(orderId) {
6823
7612
 
6824
7613
  const payload = approve ? { approved: true } : { approved: false, feedback };
6825
7614
  const data = await signedFetch('POST', `/trade/v1/order/${orderId}/milestones/feedback`, payload);
7615
+ rememberCurrentTelegramRoute(orderId);
6826
7616
  console.log(JSON.stringify(data, null, 2));
6827
7617
  }
6828
7618
 
@@ -6894,6 +7684,7 @@ async function cmdMilestoneSubmit(orderId, indexStr) {
6894
7684
  resultSummary: resultText,
6895
7685
  resultHash,
6896
7686
  });
7687
+ rememberCurrentTelegramRoute(orderId);
6897
7688
  console.log(JSON.stringify(data, null, 2));
6898
7689
  }
6899
7690
 
@@ -6943,9 +7734,11 @@ async function cmdMilestoneVerify(orderId, indexStr) {
6943
7734
  process.exit(1);
6944
7735
  }
6945
7736
  const data = await signedFetch('POST', `/trade/v1/order/${orderId}/milestone/${indexStr}/verify`, { passed: false, rejectReason });
7737
+ rememberCurrentTelegramRoute(orderId);
6946
7738
  console.log(JSON.stringify(data, null, 2));
6947
7739
  } else {
6948
7740
  const data = await signedFetch('POST', `/trade/v1/order/${orderId}/milestone/${indexStr}/verify`, { passed: true });
7741
+ rememberCurrentTelegramRoute(orderId);
6949
7742
  console.log(JSON.stringify(data, null, 2));
6950
7743
  }
6951
7744
  }
@@ -7849,35 +8642,130 @@ async function cmdFriendStatus(args) {
7849
8642
  // ─── P2P Send Helper ────────────────────────────────────────────
7850
8643
  // Resolves DID → endpoint via registry, then sends a signed message.
7851
8644
  // Replaces the previously undefined sendP2PMessage.
8645
+ function extractCapabilityTypes(capabilities) {
8646
+ if (!Array.isArray(capabilities)) return [];
8647
+ return capabilities.map((cap) => {
8648
+ if (typeof cap === 'string') return cap.trim().toLowerCase();
8649
+ if (cap && typeof cap === 'object' && typeof cap.type === 'string') return cap.type.trim().toLowerCase();
8650
+ return '';
8651
+ }).filter(Boolean);
8652
+ }
8653
+
7852
8654
  async function sendP2PMessage(targetDid, signedMsg) {
7853
8655
  const id = loadIdentity();
7854
8656
  if (!id) throw new Error('No identity found');
7855
8657
 
7856
8658
  let endpoint = null;
8659
+ let relayEndpoint = '';
7857
8660
  try {
7858
8661
  const resp = await fetch(`${REGISTRY_URL}/registry/v1/agent/${encodeURIComponent(targetDid)}`, { signal: AbortSignal.timeout(5000) });
7859
8662
  if (resp.ok) {
7860
8663
  const entry = await resp.json();
7861
- const candidates = entry.endpoint_candidates || [];
8664
+ const candidates = entry.endpoint_candidates || entry.candidates || [];
8665
+ const local = candidates.find(c => c.type === 'local');
7862
8666
  const direct = candidates.find(c => c.type === 'direct');
7863
8667
  const relay = candidates.find(c => c.type === 'relay');
7864
- endpoint = direct?.url || relay?.url || entry.endpoint;
8668
+ endpoint = local?.url || direct?.url || relay?.url || entry.endpoint;
8669
+ relayEndpoint = relay?.url || '';
7865
8670
  }
7866
8671
  } catch (e) {
7867
- throw new Error(`Registry lookup failed for ${targetDid}: ${e.message}`);
8672
+ log({ event: 'p2p_registry_lookup_failed', to: targetDid, error: e.message });
8673
+ }
8674
+
8675
+ const tryRelayFallback = async (reason) => {
8676
+ const base = relayEndpoint || process.env.ATEL_RELAY || process.env.ATEL_PLATFORM || REGISTRY_URL;
8677
+ if (!base) throw new Error(reason || `No reachable endpoint for ${targetDid}`);
8678
+ const relayUrl = `${String(base).replace(/\/+$/, '')}/relay/v1/respond/${encodeURIComponent(targetDid)}`;
8679
+ const resp = await fetch(relayUrl, {
8680
+ method: 'POST',
8681
+ headers: { 'Content-Type': 'application/json' },
8682
+ body: JSON.stringify({ sender: id.did, message: signedMsg }),
8683
+ signal: AbortSignal.timeout(8000),
8684
+ });
8685
+ if (!resp.ok) {
8686
+ const text = await resp.text().catch(() => '');
8687
+ throw new Error(`${reason || 'direct_send_failed'}; relay fallback ${resp.status}: ${text || 'unknown error'}`);
8688
+ }
8689
+ log({ event: 'p2p_relay_fallback_ok', to: targetDid, relay: relayUrl, reason });
8690
+ return { status: 'queued_via_relay', relay: relayUrl };
8691
+ };
8692
+
8693
+ if (!endpoint) {
8694
+ return await tryRelayFallback(`no_reachable_endpoint_for_${targetDid}`);
7868
8695
  }
7869
- if (!endpoint) throw new Error(`No reachable endpoint for ${targetDid}`);
7870
8696
 
7871
8697
  const hsManager = new HandshakeManager(id);
7872
8698
  const client = new AgentClient(id);
7873
- return await client.sendTask(endpoint, signedMsg, hsManager);
8699
+ try {
8700
+ return await client.sendTask(endpoint, signedMsg, hsManager);
8701
+ } catch (e) {
8702
+ log({ event: 'p2p_direct_send_failed', to: targetDid, endpoint, error: e.message });
8703
+ return await tryRelayFallback(`direct_send_failed:${e.message}`);
8704
+ }
8705
+ }
8706
+
8707
+ async function sendPortalMessage(targetDid, messageBody, options = {}) {
8708
+ const id = loadIdentity();
8709
+ if (!id) throw new Error('No identity found');
8710
+
8711
+ const relayEndpoint = String(options.relayEndpoint || '').trim();
8712
+ const directEndpoint = String(options.directEndpoint || '').trim();
8713
+ const signedEnvelope = options.signedEnvelope || null;
8714
+
8715
+ const tryRelayFallback = async (reason) => {
8716
+ const base = relayEndpoint || process.env.ATEL_RELAY || process.env.ATEL_PLATFORM || REGISTRY_URL;
8717
+ if (!base || !targetDid) throw new Error(reason || `No reachable endpoint for ${targetDid || 'target'}`);
8718
+ const relayUrl = `${String(base).replace(/\/+$/, '')}/relay/v1/respond/${encodeURIComponent(targetDid)}`;
8719
+ const resp = await fetch(relayUrl, {
8720
+ method: 'POST',
8721
+ headers: { 'Content-Type': 'application/json' },
8722
+ body: JSON.stringify({ sender: id.did, message: messageBody }),
8723
+ signal: AbortSignal.timeout(8000),
8724
+ });
8725
+ const raw = await resp.text().catch(() => '');
8726
+ let parsed = null;
8727
+ try { parsed = raw ? JSON.parse(raw) : null; } catch {}
8728
+ if (!resp.ok) throw new Error(`${reason || 'direct_send_failed'}; relay fallback ${resp.status}: ${raw || 'unknown error'}`);
8729
+ log({ event: 'p2p_message_relay_fallback_ok', to: targetDid, relay: relayUrl, reason });
8730
+ return { status: 'queued_via_relay', relay: relayUrl, result: parsed };
8731
+ };
8732
+
8733
+ if (directEndpoint) {
8734
+ const directUrl = `${String(directEndpoint).replace(/\/+$/, '')}/atel/v1/task`;
8735
+ try {
8736
+ const resp = await fetch(directUrl, {
8737
+ method: 'POST',
8738
+ headers: { 'Content-Type': 'application/json' },
8739
+ body: JSON.stringify(signedEnvelope || messageBody),
8740
+ signal: AbortSignal.timeout(8000),
8741
+ });
8742
+ const raw = await resp.text().catch(() => '');
8743
+ let parsed = null;
8744
+ try { parsed = raw ? JSON.parse(raw) : null; } catch {}
8745
+ if (!resp.ok) {
8746
+ throw new Error(`direct_send ${resp.status}: ${raw || 'unknown error'}`);
8747
+ }
8748
+ if (parsed && (parsed.status === 'rejected' || parsed.status === 'error')) {
8749
+ return { status: parsed.status, direct: directUrl, result: parsed };
8750
+ }
8751
+ return { status: 'delivered', direct: directUrl, result: parsed };
8752
+ } catch (e) {
8753
+ log({ event: 'p2p_message_direct_send_failed', to: targetDid || directEndpoint, endpoint: directEndpoint, error: e.message });
8754
+ if (targetDid) {
8755
+ return await tryRelayFallback(`direct_send_failed:${e.message}`);
8756
+ }
8757
+ throw e;
8758
+ }
8759
+ }
8760
+
8761
+ return await tryRelayFallback(`no_reachable_endpoint_for_${targetDid}`);
7874
8762
  }
7875
8763
 
7876
8764
  // ─── Send Command (Rich Media P2P Messaging) ─────────────────────
7877
8765
 
7878
8766
  function showSendHelp() {
7879
8767
  console.log(`
7880
- atal send — Send a message (with optional rich media) to another agent
8768
+ atel send — Send a message (with optional rich media) to another agent
7881
8769
 
7882
8770
  Usage:
7883
8771
  atel send <did|alias|endpoint> "message text"
@@ -7900,6 +8788,10 @@ Notes:
7900
8788
  and ask the other side to accept with:
7901
8789
  atel friend accept <request-id>
7902
8790
 
8791
+ Some agents do not expose the "general" capability needed for free-form
8792
+ chat messages. If you get an "outside capability boundary" rejection,
8793
+ enable "general" on the recipient or use atel task / atel order instead.
8794
+
7903
8795
  To disable the friend requirement on your own agent, set in policy.json:
7904
8796
  { "relationshipPolicy": { "defaultMode": "open" } }
7905
8797
 
@@ -7962,6 +8854,8 @@ async function cmdSend(args) {
7962
8854
  // Resolve target → endpoint + remoteDid
7963
8855
  let remoteEndpoint = target;
7964
8856
  let remoteDid;
8857
+ let registryEntry = null;
8858
+ let remoteRelayEndpoint = '';
7965
8859
 
7966
8860
  // Resolve @alias
7967
8861
  try { const r = resolveDID(target); if (r !== target) remoteEndpoint = r; } catch (_) {}
@@ -7979,11 +8873,14 @@ async function cmdSend(args) {
7979
8873
  if (results.length > 0) entry = results[0];
7980
8874
  }
7981
8875
  if (!entry) { console.error(`Agent not found: ${target}`); process.exit(1); }
8876
+ registryEntry = entry;
7982
8877
  remoteDid = entry.did;
7983
- const candidates = entry.endpoint_candidates || [];
8878
+ const candidates = entry.endpoint_candidates || entry.candidates || [];
8879
+ const local = candidates.find(c => c.type === 'local');
7984
8880
  const direct = candidates.find(c => c.type === 'direct');
7985
8881
  const relay = candidates.find(c => c.type === 'relay');
7986
- remoteEndpoint = direct?.url || relay?.url || entry.endpoint;
8882
+ remoteRelayEndpoint = relay?.url || '';
8883
+ remoteEndpoint = local?.url || direct?.url || (entry.endpoint && entry.endpoint !== remoteRelayEndpoint ? entry.endpoint : '');
7987
8884
  } else {
7988
8885
  remoteDid = target.startsWith('did:') ? target : undefined;
7989
8886
  // Try to get remoteDid from handshake cache for direct endpoints
@@ -8001,14 +8898,38 @@ async function cmdSend(args) {
8001
8898
  process.exit(1);
8002
8899
  }
8003
8900
 
8901
+ // IM-style messages are relationship-gated, not capability-gated.
8902
+
8004
8903
  // Send
8005
8904
  try {
8006
- const hsManager = new HandshakeManager(id);
8007
- const client = new AgentClient(id);
8008
- const msg = createMessage({ type: 'task', from: id.did, to: remoteDid || remoteEndpoint, payload, secretKey: id.secretKey });
8009
- const result = await client.sendTask(remoteEndpoint, msg, hsManager);
8905
+ const messagePayload = {
8906
+ msgType: 'message',
8907
+ msgId,
8908
+ text,
8909
+ images: payload.images,
8910
+ attachments: payload.attachments,
8911
+ timestamp: payload.timestamp,
8912
+ };
8913
+ const msg = createMessage({
8914
+ type: 'task_delegate',
8915
+ from: id.did,
8916
+ to: remoteDid || remoteEndpoint,
8917
+ payload: messagePayload,
8918
+ secretKey: id.secretKey,
8919
+ });
8920
+ const messageBody = {
8921
+ ...messagePayload,
8922
+ from: msg.from,
8923
+ to: msg.to,
8924
+ type: msg.type,
8925
+ payload: msg.payload,
8926
+ nonce: msg.nonce,
8927
+ envelope: msg.envelope,
8928
+ signature: msg.signature,
8929
+ timestamp: msg.timestamp,
8930
+ };
8931
+ const result = await sendPortalMessage(remoteDid, messageBody, { directEndpoint: remoteEndpoint, relayEndpoint: remoteRelayEndpoint, signedEnvelope: msg });
8010
8932
 
8011
- // Surface friendly errors
8012
8933
  const inner = result?.result;
8013
8934
  if (inner?.code === 'NOT_FRIEND' || inner?.error?.includes('NOT_FRIEND') || inner?.error?.includes('not a friend')) {
8014
8935
  if (isJson) {
@@ -8020,19 +8941,28 @@ async function cmdSend(args) {
8020
8941
  }
8021
8942
  process.exit(1);
8022
8943
  }
8023
- if (inner?.status === 'rejected') {
8944
+ if (inner?.status === 'rejected' || result?.status === 'rejected') {
8945
+ const rejectionMessage = inner?.error || 'Message rejected';
8024
8946
  if (isJson) {
8025
- console.log(JSON.stringify({ status: 'error', message: inner.error || 'Message rejected', result }));
8947
+ console.log(JSON.stringify({
8948
+ status: 'error',
8949
+ code: inner?.code,
8950
+ message: rejectionMessage,
8951
+ hint: inner?.hint,
8952
+ result
8953
+ }));
8026
8954
  } else {
8027
- console.error(`✗ Message rejected: ${inner.error || 'unknown reason'}`);
8955
+ console.error('✗ Message rejected: ' + rejectionMessage);
8956
+ if (inner?.hint) console.error(' ' + inner.hint);
8028
8957
  }
8029
8958
  process.exit(1);
8030
8959
  }
8031
8960
 
8032
8961
  if (isJson) {
8033
- console.log(JSON.stringify({ status: 'sent', msgId, to: remoteDid || remoteEndpoint, attachments: (payload.images?.length || 0) + (payload.attachments?.length || 0), result }, null, 2));
8962
+ console.log(JSON.stringify({ status: 'sent', msgId, to: remoteDid || remoteEndpoint, attachments: (payload.images?.length || 0) + (payload.attachments?.length || 0), delivery: result }, null, 2));
8034
8963
  } else {
8035
- console.log(`✓ Message sent to ${remoteDid || remoteEndpoint}`);
8964
+ const deliveryLabel = result?.status === 'queued_via_relay' ? 'queued via relay' : 'sent';
8965
+ console.log(`✓ Message ${deliveryLabel} to ${remoteDid || remoteEndpoint}`);
8036
8966
  if (payload.images?.length) console.log(` Images: ${payload.images.length}`);
8037
8967
  if (payload.attachments?.length) console.log(` Attachments: ${payload.attachments.length}`);
8038
8968
  }
@@ -8392,130 +9322,6 @@ async function cmdAliasRemove(args) {
8392
9322
 
8393
9323
  // ─── Main ────────────────────────────────────────────────────────
8394
9324
 
8395
- // ── Fast-coop: Executor auto-submit deliverable to Fast escrow ──────
8396
- // Called when executor receives milestone_verified with allComplete=true
8397
- // for a fast-coop order. Signs Escrow::Submit with the agent's DID
8398
- // private key (= Fast Ed25519 account key).
8399
- async function submitFastDeliverable(orderId, identity) {
8400
- try {
8401
- // 1. Get order info
8402
- const orderResp = await fetch(`${PLATFORM_URL}/trade/v1/order/${orderId}`, { signal: AbortSignal.timeout(10000) });
8403
- if (!orderResp.ok) throw new Error(`order fetch failed: ${orderResp.status}`);
8404
- const orderInfo = await orderResp.json();
8405
- const chain = orderInfo.chain || orderInfo.Chain || '';
8406
- if (chain !== 'fast-coop') return;
8407
-
8408
- // 2. Find escrow job_id from chain-records
8409
- const recordsResp = await fetch(`${PLATFORM_URL}/trade/v1/order/${orderId}`, {
8410
- signal: AbortSignal.timeout(10000),
8411
- });
8412
- // The job_id is stored in the on_chain_records by the Platform.
8413
- // We need to get it from the order or from the chain-records endpoint.
8414
- // For now, compute it the same way Platform does: keccak256(orderId)
8415
- const { createHash } = await import('node:crypto');
8416
-
8417
- // 3. Get the agent's Fast account info
8418
- const FAST_RPC = process.env.ATEL_FASTCOOP_RPC_URL || 'https://staging.api.fast.xyz/proxy-rest';
8419
- const pubKeyHex = Buffer.from(identity.publicKey).toString('hex');
8420
-
8421
- const accountResp = await fetch(`${FAST_RPC}/v1/accounts/${pubKeyHex}`, {
8422
- signal: AbortSignal.timeout(10000),
8423
- });
8424
- if (!accountResp.ok) throw new Error(`Fast account query failed: ${accountResp.status}`);
8425
- const accountData = (await accountResp.json()).data;
8426
- const nonce = accountData.next_nonce;
8427
-
8428
- // 4. Find escrow job for this order (query by provider)
8429
- const jobsResp = await fetch(`${FAST_RPC}/v1/escrow-jobs?provider=${pubKeyHex}&status=Funded`, {
8430
- signal: AbortSignal.timeout(10000),
8431
- });
8432
- if (!jobsResp.ok) throw new Error(`escrow jobs query failed: ${jobsResp.status}`);
8433
- const jobs = (await jobsResp.json()).data || [];
8434
- const job = jobs.find(j => j.description && j.description.includes(orderId));
8435
- if (!job) {
8436
- log({ event: 'fast_submit_no_job', orderId, note: 'no Funded escrow job found for this order' });
8437
- return;
8438
- }
8439
-
8440
- // 5. Build Escrow::Submit transaction
8441
- // deliverable = keccak256(orderId) as 32-byte hash
8442
- const deliverableHash = createHash('sha3-256').update(orderId).digest('hex');
8443
-
8444
- // BCS encode: Operation::Escrow(12) → Escrow::Submit(2) → jobId(32) + deliverable(32)
8445
- const jobIdBytes = Buffer.from(job.job_id, 'hex');
8446
- const deliverableBytes = Buffer.from(deliverableHash, 'hex');
8447
-
8448
- // Build minimal BCS by hand (matching Go implementation):
8449
- // ULEB128(12) = 0x0c, ULEB128(2) = 0x02, then 32+32 bytes
8450
- const claimBytes = Buffer.concat([
8451
- Buffer.from([0x0c]), // Operation::Escrow variant 12
8452
- Buffer.from([0x02]), // Escrow::Submit variant 2
8453
- jobIdBytes.subarray(0, 32), // job_id 32 bytes
8454
- deliverableBytes.subarray(0, 32), // deliverable 32 bytes
8455
- ]);
8456
-
8457
- // Transaction BCS: VersionedTransaction variant 1 (Release20260407)
8458
- const networkId = process.env.ATEL_FASTCOOP_NETWORK_ID || 'fast:devnet';
8459
- const senderBytes = Buffer.from(identity.publicKey);
8460
- const tsNanos = BigInt(Date.now()) * 1000000n;
8461
-
8462
- // ULEB128 encoder
8463
- const uleb128 = (v) => {
8464
- const buf = [];
8465
- do { let b = Number(v & 0x7fn); v >>= 7n; if (v > 0n) b |= 0x80; buf.push(b); } while (v > 0n);
8466
- return Buffer.from(buf.length ? buf : [0]);
8467
- };
8468
- const u64le = (v) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(v)); return b; };
8469
- const u128le = (v) => { const b = Buffer.alloc(16); b.writeBigUInt64LE(v & 0xffffffffffffffffn); b.writeBigUInt64LE(v >> 64n, 8); return b; };
8470
- const bcsString = (s) => { const b = Buffer.from(s, 'utf-8'); return Buffer.concat([uleb128(BigInt(b.length)), b]); };
8471
-
8472
- // Transaction body
8473
- const txBody = Buffer.concat([
8474
- bcsString(networkId),
8475
- senderBytes.subarray(0, 32),
8476
- u64le(nonce),
8477
- u128le(tsNanos),
8478
- uleb128(1n), // claims vector length = 1
8479
- claimBytes,
8480
- Buffer.from([0x00]), // archival = false
8481
- Buffer.from([0x00]), // fee_token = None
8482
- ]);
8483
-
8484
- // VersionedTransaction = enum variant 1
8485
- const versionedTx = Buffer.concat([uleb128(1n), txBody]);
8486
-
8487
- // 6. Sign
8488
- const { default: nacl } = await import('tweetnacl');
8489
- const sigMessage = Buffer.concat([Buffer.from('VersionedTransaction::'), versionedTx]);
8490
- const signature = nacl.sign.detached(sigMessage, identity.secretKey);
8491
-
8492
- // SignatureOrMultiSig::Signature = variant 0 + 64 bytes
8493
- const bcsSig = Buffer.concat([Buffer.from([0x00]), Buffer.from(signature)]);
8494
-
8495
- // 7. Submit to Fast
8496
- const submitResp = await fetch(`${FAST_RPC}/v1/submit-transaction`, {
8497
- method: 'POST',
8498
- headers: { 'Content-Type': 'application/json' },
8499
- body: JSON.stringify({
8500
- transaction: versionedTx.toString('hex'),
8501
- signature: bcsSig.toString('hex'),
8502
- }),
8503
- signal: AbortSignal.timeout(15000),
8504
- });
8505
-
8506
- const submitResult = await submitResp.json();
8507
- if (submitResult.error) {
8508
- throw new Error(`Fast submit failed: ${submitResult.error.message || JSON.stringify(submitResult.error)}`);
8509
- }
8510
-
8511
- log({ event: 'fast_submit_success', orderId, jobId: job.job_id, nonce });
8512
- console.log(`⚡ [Fast] Deliverable submitted for ${orderId} (job: ${job.job_id.substring(0, 16)}...)`);
8513
- } catch (e) {
8514
- log({ event: 'fast_submit_error', orderId, error: e.message });
8515
- console.error(`⚠️ [Fast] Submit failed for ${orderId}: ${e.message}`);
8516
- }
8517
- }
8518
-
8519
9325
  const [,, cmd, ...rawArgs] = process.argv;
8520
9326
  const args = rawArgs.filter(a => !a.startsWith('--'));
8521
9327
  const commands = {
@@ -8726,6 +9532,7 @@ const commands = {
8726
9532
  createdAt: new Date().toISOString(), lastUsedAt: null,
8727
9533
  });
8728
9534
  saveNotifyTargets(targets);
9535
+ rememberTelegramRoute(chatId, botToken || undefined);
8729
9536
  console.log(`✅ Bound TG chat ${chatId} as notification target (id: ${id})`);
8730
9537
  if (!botToken) console.log('⚠️ No bot token found. Set TELEGRAM_BOT_TOKEN or use --bot-token');
8731
9538
  return;
@@ -8845,7 +9652,7 @@ Protocol Commands:
8845
9652
  setup [port] Configure network (detect IP, UPnP, verify)
8846
9653
  verify Verify port reachability
8847
9654
  start [port] Start endpoint (auto network + auto register)
8848
- inbox [count] Show received messages (default: 20)
9655
+ inbox [count] Show received messages / notifications (default: 20)
8849
9656
  register [name] [caps] [endpoint] Register on public registry (caps: "type1:price1,type2:price2" or "type1,type2" for free)
8850
9657
  search <capability> Search registry for agents (shows pricing info)
8851
9658
  handshake <endpoint> [did] Handshake with remote agent