@11agents/cli 0.1.36 → 0.1.38

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.
@@ -1,4 +1,5 @@
1
1
  # 11agents Mobile Data Templates
2
2
 
3
3
  These files seed `~/.11agents/mobile/data` during `11agents mobile setup`.
4
- Replace example devices and accounts with company-owned, authorized Android devices and logged-in accounts before publishing.
4
+ `devices.example.csv` contains the stable D01-D10 device registry. Keep those hardware ids in sync across runtime machines so a physical device keeps the same Dxx id even when it is attached through a different USB port or ADB transport.
5
+ Replace example accounts with company-owned, authorized Android accounts before publishing.
@@ -1,2 +1,11 @@
1
- device_id,adb_serial,ip_address,brand,model,android_version,status,notes
2
- D01,REPLACE_WITH_ADB_SERIAL,,ExampleBrand,ExampleModel,13,active,replace with an authorized company-owned Android device
1
+ device_id,adb_serial,hardware_id,ip_address,brand,model,android_version,status,notes
2
+ D01,e6209341,e6209341,,,,,active,stable device registry slot 1
3
+ D02,7441ce65,7441ce65,,,,,active,stable device registry slot 2
4
+ D03,2eecd64c,2eecd64c,,,,,active,stable device registry slot 3
5
+ D04,bfa3e5ba,bfa3e5ba,,,,,active,stable device registry slot 4
6
+ D05,78d99cb0,78d99cb0,,,,,active,stable device registry slot 5
7
+ D06,2c461593,2c461593,,,,,active,stable device registry slot 6
8
+ D07,2cd62d93,2cd62d93,,,,,active,stable device registry slot 7
9
+ D08,f8cabef1,f8cabef1,,,,,active,stable device registry slot 8
10
+ D09,8b427827,8b427827,,,,,active,stable device registry slot 9
11
+ D10,3185bee8,3185bee8,,,,,active,stable device registry slot 10
@@ -4,7 +4,7 @@ import argparse
4
4
  import csv
5
5
  import json
6
6
  from concurrent.futures import ThreadPoolExecutor, as_completed
7
- from dataclasses import asdict
7
+ from dataclasses import asdict, replace
8
8
  from pathlib import Path
9
9
 
10
10
  from .adb import AdbClient, resolve_adb_bin
@@ -349,7 +349,7 @@ def main() -> None:
349
349
  return
350
350
 
351
351
  if args.command == "ensure-appium":
352
- udids = _resolve_appium_udids(args.devices, args.device, args.udid)
352
+ udids = _resolve_appium_udids(args.devices, args.device, args.udid, adb)
353
353
  try:
354
354
  result = ensure_appium_server(
355
355
  args.server,
@@ -387,12 +387,13 @@ def main() -> None:
387
387
  return
388
388
 
389
389
  if args.command == "list-devices":
390
- devices = load_devices(args.devices)
390
+ devices = _load_devices(args.devices, adb, resolve_transports=args.health)
391
391
  connected = set(adb.list_devices()) if args.health else set()
392
392
  for device in devices.values():
393
393
  row = {
394
394
  "device_id": device.device_id,
395
395
  "adb_serial": device.adb_serial,
396
+ "hardware_id": device.hardware_id,
396
397
  "ip_address": device.ip_address,
397
398
  "brand": device.brand,
398
399
  "model": device.model,
@@ -413,7 +414,7 @@ def main() -> None:
413
414
  if args.health:
414
415
  suffix = f" online={row['online']} battery={row.get('battery', '')} focus={row.get('focus', '')}"
415
416
  print(
416
- f"{device.device_id} serial={device.adb_serial} ip={device.ip_address} "
417
+ f"{device.device_id} serial={device.adb_serial} hardware_id={device.hardware_id} ip={device.ip_address} "
417
418
  f"model={device.model} status={device.status}{suffix}"
418
419
  )
419
420
  return
@@ -428,6 +429,7 @@ def main() -> None:
428
429
  row = {
429
430
  "device_id": args.device_id,
430
431
  "adb_serial": serial,
432
+ "hardware_id": _device_hardware_id(adb, serial) or serial,
431
433
  "ip_address": _ip_from_serial(serial),
432
434
  "brand": adb.getprop(serial, "ro.product.brand"),
433
435
  "model": adb.getprop(serial, "ro.product.model"),
@@ -443,33 +445,34 @@ def main() -> None:
443
445
  return
444
446
 
445
447
  if args.command == "connect":
446
- devices = load_devices(args.devices)
448
+ devices = _load_devices(args.devices, adb, resolve_transports=False)
447
449
  for device in devices.values():
448
450
  if device.status != "active":
449
451
  print(f"{device.device_id}: skipped status={device.status}")
450
452
  continue
451
- result = adb.connect(device.adb_serial)
453
+ target = device.ip_address or device.adb_serial
454
+ result = adb.connect(target)
452
455
  status = "ok" if result.ok else "fail"
453
- print(f"{device.device_id} {device.adb_serial}: {status} {result.stdout or result.stderr}")
456
+ print(f"{device.device_id} {target}: {status} {result.stdout or result.stderr}")
454
457
  return
455
458
 
456
459
  if args.command == "health":
457
- devices = load_devices(args.devices)
460
+ devices = _load_devices(args.devices, adb)
458
461
  connected = set(adb.list_devices())
459
462
  for device in devices.values():
460
463
  online = device.adb_serial in connected
461
464
  if not online:
462
- print(f"{device.device_id}: offline serial={device.adb_serial}")
465
+ print(f"{device.device_id}: offline serial={device.adb_serial} hardware_id={device.hardware_id}")
463
466
  continue
464
467
  battery = adb.battery_level(device.adb_serial)
465
468
  model = adb.getprop(device.adb_serial, "ro.product.model")
466
469
  android = adb.getprop(device.adb_serial, "ro.build.version.release")
467
470
  focus = adb.current_focus(device.adb_serial)
468
- print(f"{device.device_id}: online battery={battery} model={model} android={android} focus={focus}")
471
+ print(f"{device.device_id}: online battery={battery} serial={device.adb_serial} hardware_id={device.hardware_id} model={model} android={android} focus={focus}")
469
472
  return
470
473
 
471
474
  if args.command == "screenshot":
472
- device = _get_device(args.devices, args.device_id)
475
+ device = _get_device(args.devices, args.device_id, adb)
473
476
  out = Path(args.out_dir) / f"{device.device_id}.png"
474
477
  result = adb.screenshot(device.adb_serial, out)
475
478
  if result.ok:
@@ -479,7 +482,7 @@ def main() -> None:
479
482
  return
480
483
 
481
484
  if args.command == "dump-ui":
482
- device = _get_device(args.devices, args.device_id)
485
+ device = _get_device(args.devices, args.device_id, adb)
483
486
  out = Path(args.out_dir) / f"{device.device_id}.xml"
484
487
  result = adb.dump_ui(device.adb_serial, out)
485
488
  if result.ok:
@@ -490,25 +493,25 @@ def main() -> None:
490
493
  return
491
494
 
492
495
  if args.command == "tap":
493
- device = _get_device(args.devices, args.device_id)
496
+ device = _get_device(args.devices, args.device_id, adb)
494
497
  result = adb.tap(device.adb_serial, args.x, args.y)
495
498
  print(result.stdout or result.stderr)
496
499
  return
497
500
 
498
501
  if args.command == "keyevent":
499
- device = _get_device(args.devices, args.device_id)
502
+ device = _get_device(args.devices, args.device_id, adb)
500
503
  result = adb.keyevent(device.adb_serial, args.keycode)
501
504
  print(result.stdout or result.stderr)
502
505
  return
503
506
 
504
507
  if args.command == "input-text":
505
- device = _get_device(args.devices, args.device_id)
508
+ device = _get_device(args.devices, args.device_id, adb)
506
509
  result = adb.input_text(device.adb_serial, args.text)
507
510
  print(result.stdout or result.stderr)
508
511
  return
509
512
 
510
513
  if args.command == "launch":
511
- device = _get_device(args.devices, args.device_id)
514
+ device = _get_device(args.devices, args.device_id, adb)
512
515
  result = adb.launch_package(device.adb_serial, args.package)
513
516
  print(result.stdout or result.stderr)
514
517
  return
@@ -632,7 +635,7 @@ def main() -> None:
632
635
  return
633
636
 
634
637
  if args.command == "collect-tiktok-account-metrics-adb":
635
- device = _get_device(args.devices, args.device)
638
+ device = _get_device(args.devices, args.device, adb)
636
639
  account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
637
640
  manual_values = _load_tiktok_account_metric_values(args.values_json, args.values_file)
638
641
  collector = TikTokAccountMetricsAdbCollector(adb, artifact_root=args.artifact_root)
@@ -673,7 +676,7 @@ def main() -> None:
673
676
  return
674
677
 
675
678
  if args.command == "collect-tiktok-video-metrics-adb":
676
- device = _get_device(args.devices, args.device)
679
+ device = _get_device(args.devices, args.device, adb)
677
680
  account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
678
681
  manual_values = _load_tiktok_video_metric_values(args.values_json, args.values_file)
679
682
  collector = TikTokVideoMetricsAdbCollector(adb, artifact_root=args.artifact_root)
@@ -715,7 +718,7 @@ def main() -> None:
715
718
  return
716
719
 
717
720
  if args.command == "publish-tiktok-adb":
718
- devices = load_devices(args.devices)
721
+ devices = _load_devices(args.devices, adb)
719
722
  selected = _select_devices(devices, args.device, args.ranges)
720
723
  if not selected:
721
724
  raise SystemExit("No target devices. Use --device D01 or --range 1-10.")
@@ -751,7 +754,7 @@ def main() -> None:
751
754
  return
752
755
 
753
756
  if args.command in {"publish-tiktok", "publish-tiktok-appium"}:
754
- devices = load_devices(args.devices)
757
+ devices = _load_devices(args.devices, adb)
755
758
  selected = _select_devices(devices, args.device, args.ranges)
756
759
  if not selected:
757
760
  raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
@@ -787,7 +790,7 @@ def main() -> None:
787
790
  return
788
791
 
789
792
  if args.command in {"publish-reddit", "publish-reddit-adb"}:
790
- devices = load_devices(args.devices)
793
+ devices = _load_devices(args.devices, adb)
791
794
  selected = _select_devices(devices, args.device, args.ranges)
792
795
  if not selected:
793
796
  raise SystemExit("No target devices. Use --device D01 or --range 1-10.")
@@ -832,7 +835,7 @@ def main() -> None:
832
835
  return
833
836
 
834
837
  if args.command == "publish-facebook":
835
- devices = load_devices(args.devices)
838
+ devices = _load_devices(args.devices, adb)
836
839
  selected = _select_devices(devices, args.device, args.ranges)
837
840
  if not selected:
838
841
  raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
@@ -871,7 +874,7 @@ def main() -> None:
871
874
 
872
875
  if args.command == "publish-instagram":
873
876
  _apply_instagram_publish_policy(args)
874
- devices = load_devices(args.devices)
877
+ devices = _load_devices(args.devices, adb)
875
878
  selected = _select_devices(devices, args.device, args.ranges)
876
879
  if not selected:
877
880
  raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
@@ -911,7 +914,7 @@ def main() -> None:
911
914
  return
912
915
 
913
916
  if args.command == "publish-x":
914
- devices = load_devices(args.devices)
917
+ devices = _load_devices(args.devices, adb)
915
918
  selected = _select_devices(devices, args.device, args.ranges)
916
919
  if not selected:
917
920
  raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
@@ -955,7 +958,7 @@ def main() -> None:
955
958
  if args.command == "publish-xiaohongshu":
956
959
  _apply_xiaohongshu_publish_package(args)
957
960
  _apply_xiaohongshu_live_publish_policy(args)
958
- devices = load_devices(args.devices)
961
+ devices = _load_devices(args.devices, adb)
959
962
  selected = _select_devices(devices, args.device, args.ranges)
960
963
  if not selected:
961
964
  raise SystemExit("No target devices. Use --device Dxx or --range 1-5.")
@@ -1011,7 +1014,7 @@ def main() -> None:
1011
1014
  device_id = args.device or (record.device_id if record else "")
1012
1015
  if not device_id:
1013
1016
  raise SystemExit("copy-xiaohongshu-link requires --device or --record-id for a record with device_id")
1014
- device = _get_device(args.devices, device_id)
1017
+ device = _get_device(args.devices, device_id, adb)
1015
1018
  _ensure_appium_preflight(args, [device])
1016
1019
  publisher = XiaohongshuAdbPublisher(
1017
1020
  adb,
@@ -1048,17 +1051,77 @@ def main() -> None:
1048
1051
  return
1049
1052
 
1050
1053
 
1051
- def _get_device(devices_path: str, device_id: str):
1054
+ DEVICE_IDENTITY_PROPS = (
1055
+ "ro.serialno",
1056
+ "ro.boot.serialno",
1057
+ "ro.vendor.boot.serialno",
1058
+ "ro.product.serialno",
1059
+ )
1060
+
1061
+
1062
+ def _load_devices(devices_path: str, adb: AdbClient, *, resolve_transports: bool = True):
1052
1063
  devices = load_devices(devices_path)
1064
+ if not resolve_transports:
1065
+ return devices
1066
+ return _resolve_device_transports(devices, adb)
1067
+
1068
+
1069
+ def _resolve_device_transports(devices: dict[str, object], adb: AdbClient):
1070
+ transport_by_identity = _connected_transport_by_identity(adb)
1071
+ if not transport_by_identity:
1072
+ return devices
1073
+ resolved = {}
1074
+ for device_id, device in devices.items():
1075
+ transport = ""
1076
+ for identity in _device_identity_candidates(device):
1077
+ transport = transport_by_identity.get(identity)
1078
+ if transport:
1079
+ break
1080
+ resolved[device_id] = replace(device, adb_serial=transport) if transport and transport != device.adb_serial else device
1081
+ return resolved
1082
+
1083
+
1084
+ def _connected_transport_by_identity(adb: AdbClient) -> dict[str, str]:
1085
+ out: dict[str, str] = {}
1086
+ for transport in adb.list_devices():
1087
+ for identity in {transport, _device_hardware_id(adb, transport)}:
1088
+ clean = _clean_device_identity(identity)
1089
+ if clean:
1090
+ out[clean] = transport
1091
+ return out
1092
+
1093
+
1094
+ def _device_hardware_id(adb: AdbClient, transport: str) -> str:
1095
+ for prop in DEVICE_IDENTITY_PROPS:
1096
+ value = adb.getprop(transport, prop).strip()
1097
+ if value and value.lower() not in {"unknown", "null", "none"}:
1098
+ return value
1099
+ return ""
1100
+
1101
+
1102
+ def _device_identity_candidates(device) -> list[str]:
1103
+ candidates = [
1104
+ getattr(device, "hardware_id", ""),
1105
+ getattr(device, "adb_serial", ""),
1106
+ ]
1107
+ return [clean for clean in (_clean_device_identity(item) for item in candidates) if clean]
1108
+
1109
+
1110
+ def _clean_device_identity(value: str) -> str:
1111
+ return str(value or "").strip().lower()
1112
+
1113
+
1114
+ def _get_device(devices_path: str, device_id: str, adb: AdbClient):
1115
+ devices = _load_devices(devices_path, adb)
1053
1116
  if device_id not in devices:
1054
1117
  raise SystemExit(f"Unknown device_id={device_id}")
1055
1118
  return devices[device_id]
1056
1119
 
1057
1120
 
1058
- def _resolve_appium_udids(devices_path: str, device_ids: list[str], udids: list[str]) -> list[str]:
1121
+ def _resolve_appium_udids(devices_path: str, device_ids: list[str], udids: list[str], adb: AdbClient) -> list[str]:
1059
1122
  resolved = [item for item in udids if item]
1060
1123
  if device_ids:
1061
- devices = load_devices(devices_path)
1124
+ devices = _load_devices(devices_path, adb)
1062
1125
  for device_id in device_ids:
1063
1126
  if device_id not in devices:
1064
1127
  raise SystemExit(f"Unknown device_id={device_id}")
@@ -11,6 +11,7 @@ class Device:
11
11
  device_id: str
12
12
  adb_serial: str
13
13
  ip_address: str
14
+ hardware_id: str = ""
14
15
  brand: str = ""
15
16
  model: str = ""
16
17
  android_version: str = ""
@@ -23,6 +23,7 @@ def load_devices(path: str | Path) -> dict[str, Device]:
23
23
  device_id=row["device_id"],
24
24
  adb_serial=row["adb_serial"],
25
25
  ip_address=row.get("ip_address", ""),
26
+ hardware_id=row.get("hardware_id", "") or row.get("physical_id", "") or row["adb_serial"],
26
27
  brand=row.get("brand", ""),
27
28
  model=row.get("model", ""),
28
29
  android_version=row.get("android_version", ""),
@@ -36,6 +36,8 @@ Use `--range`, `--parallel`, and `--max-concurrency N` only when the approved pl
36
36
 
37
37
  Successful live publish is only confirmed after the CLI taps the publish button and verifies TikTok publish completion. After writing the publish record, the CLI force-stops TikTok and captures `after-app-close.png` in the run artifacts.
38
38
 
39
+ When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends a DingTalk notification after status `published`; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional. TikTok notification content does not show a link.
40
+
39
41
  ## Output
40
42
 
41
43
  Return publish status, record id, account/device, screenshot path, permalink or platform post id when available, duration, error action, and the data-collection handoff:
@@ -35,6 +35,8 @@ Structured package:
35
35
 
36
36
  Successful live publish is only confirmed after the CLI taps the final publish button, waits without tapping while Xiaohongshu shows upload/progress state, closes and relaunches Xiaohongshu, then opens the "我" page, opens the note whose title matches the expected title or the first visible profile note as a verified fallback, and verifies the detail page against the expected title/body. The CLI recovers the note link by default for live publish; use `--no-copy-link-after-publish` only when link recovery is explicitly not wanted. After writing the publish record, the CLI force-stops Xiaohongshu and captures `after-app-close.png` in the run artifacts. If that evidence does not match, treat the result as `failed`; do not claim publish success from the publish button tap alone.
37
37
 
38
+ When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends a DingTalk notification after status `published`; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional. The Xiaohongshu notification includes the recovered link when available and still sends if the link was not recovered.
39
+
38
40
  Link copy after publish or during the recovery skill:
39
41
 
40
42
  ```bash
@@ -8,7 +8,7 @@ description: Use when a mobile publish agent needs to prepare or dry-run `11agen
8
8
  ## Required Inputs
9
9
  - Approved execution plan or dry-run request.
10
10
  - Installed `@11agents/cli` and completed `11agents mobile setup`.
11
- - Optional placeholders: `ELEVENAGENTS_MOBILE_HOME`, `ELEVENAGENTS_MOBILE_DEFAULT_DEVICE`, `ELEVENAGENTS_MOBILE_DEFAULT_RANGE`, and `ELEVENAGENTS_MOBILE_DEFAULT_MAX_CONCURRENCY`.
11
+ - Optional placeholders: `ELEVENAGENTS_MOBILE_HOME`, `ELEVENAGENTS_MOBILE_DEFAULT_DEVICE`, `ELEVENAGENTS_MOBILE_DEFAULT_RANGE`, `ELEVENAGENTS_MOBILE_DEFAULT_MAX_CONCURRENCY`, `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, and `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET`.
12
12
  - Media files, captions/titles/body/link fields, target device or range, optional account id/account prefix, and dry-run/live mode.
13
13
 
14
14
  If mobile setup, device online status, media path, app login readiness, or approval status is missing, ask the user before running commands. Missing account CSV alone is not a blocker when the target app is already logged in on the device.
@@ -51,7 +51,8 @@ If mobile setup, device online status, media path, app login readiness, or appro
51
51
  11. Use `11agents mobile publish-tiktok`, `publish-instagram`, `publish-facebook`, `publish-reddit`, `publish-x`, and `publish-xiaohongshu`.
52
52
  12. Prefer `--json` and parse status, duration, record id, screenshot path, permalink, platform post id, and error.
53
53
  13. Pass `--task-id <task_id>` so logs land at `~/.11agents/mobile/runs/<task_id>/log`.
54
- 14. Stop on `failed`; surface screenshot and error instead of repeated retries.
54
+ 14. Do not run custom DingTalk webhook scripts after Xiaohongshu or TikTok publish. When the agent environment contains `ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK`, the CLI sends the publish-success notification automatically; `ELEVENAGENTS_PUBLISH_DINGTALK_SECRET` is optional for signed robots.
55
+ 15. Stop on `failed`; surface screenshot and error instead of repeated retries.
55
56
 
56
57
  ## Output
57
58
  Return readiness status, dry-run result or execution preparation summary, device/app health notes, optional account-registry notes, command family selected, exact safe `11agents mobile` command shape, log path, and missing env placeholders.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@11agents/cli",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "11agents local runtime and telemetry CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -99,8 +99,9 @@ Runtime:
99
99
  Python/device-control code is bundled with @11agents/cli.
100
100
  Mutable runtime state lives in ~/.11agents/mobile by default.
101
101
  Every command writes logs under ~/.11agents/mobile/runs/<task_id>/log.
102
- Publish-success DingTalk notification is enabled by ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK
103
- and optional ELEVENAGENTS_PUBLISH_DINGTALK_SECRET.`)
102
+ Xiaohongshu/TikTok publish-success DingTalk notification is enabled by
103
+ ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK and optional
104
+ ELEVENAGENTS_PUBLISH_DINGTALK_SECRET.`)
104
105
  }
105
106
 
106
107
  function mobileHome(flags = {}, env = process.env) {
@@ -234,6 +235,104 @@ async function writeFileIfMissing(filePath, content) {
234
235
  }
235
236
  }
236
237
 
238
+ async function mergeDeviceRegistry(filePath, templateContent) {
239
+ let currentContent = ''
240
+ try {
241
+ currentContent = await readFile(filePath, 'utf8')
242
+ } catch {
243
+ await mkdir(path.dirname(filePath), { recursive: true })
244
+ await writeFile(filePath, templateContent)
245
+ return
246
+ }
247
+ const current = parseCsv(currentContent)
248
+ const template = parseCsv(templateContent)
249
+ const headers = [...current.headers]
250
+ for (const header of template.headers) {
251
+ if (!headers.includes(header)) headers.push(header)
252
+ }
253
+ const rows = current.rows.map(row => ({ ...row }))
254
+ const rowByDeviceId = new Map(rows.map(row => [row.device_id, row]))
255
+ for (const seed of template.rows) {
256
+ const deviceId = seed.device_id
257
+ const existing = rowByDeviceId.get(deviceId)
258
+ if (!existing) {
259
+ rows.push({ ...seed })
260
+ rowByDeviceId.set(deviceId, rows[rows.length - 1])
261
+ continue
262
+ }
263
+ if (isCanonicalDeviceId(deviceId)) {
264
+ const managedCanonicalRow = /^stable device registry slot/i.test(existing.notes || '')
265
+ const serialChanged = existing.adb_serial && existing.adb_serial !== seed.adb_serial
266
+ existing.adb_serial = seed.adb_serial
267
+ existing.hardware_id = seed.hardware_id || seed.adb_serial
268
+ if (managedCanonicalRow || serialChanged) {
269
+ existing.brand = seed.brand || ''
270
+ existing.model = seed.model || ''
271
+ existing.android_version = seed.android_version || ''
272
+ }
273
+ if (!existing.status) existing.status = seed.status || 'active'
274
+ if (!existing.notes || /replace with|single-device POC/i.test(existing.notes)) existing.notes = seed.notes || ''
275
+ }
276
+ }
277
+ await writeFile(filePath, formatCsv(headers, rows))
278
+ }
279
+
280
+ function isCanonicalDeviceId(deviceId = '') {
281
+ return /^D(?:0[1-9]|10)$/.test(String(deviceId))
282
+ }
283
+
284
+ function parseCsv(text = '') {
285
+ const lines = String(text || '').split(/\r?\n/).filter(line => line.length)
286
+ if (!lines.length) return { headers: [], rows: [] }
287
+ const headers = parseCsvLine(lines[0])
288
+ return {
289
+ headers,
290
+ rows: lines.slice(1).map(line => {
291
+ const values = parseCsvLine(line)
292
+ return Object.fromEntries(headers.map((header, index) => [header, values[index] || '']))
293
+ }),
294
+ }
295
+ }
296
+
297
+ function parseCsvLine(line = '') {
298
+ const out = []
299
+ let value = ''
300
+ let quoted = false
301
+ for (let i = 0; i < line.length; i += 1) {
302
+ const ch = line[i]
303
+ if (ch === '"') {
304
+ if (quoted && line[i + 1] === '"') {
305
+ value += '"'
306
+ i += 1
307
+ } else {
308
+ quoted = !quoted
309
+ }
310
+ continue
311
+ }
312
+ if (ch === ',' && !quoted) {
313
+ out.push(value)
314
+ value = ''
315
+ continue
316
+ }
317
+ value += ch
318
+ }
319
+ out.push(value)
320
+ return out
321
+ }
322
+
323
+ function formatCsv(headers, rows) {
324
+ return [
325
+ headers.join(','),
326
+ ...rows.map(row => headers.map(header => csvCell(row[header] || '')).join(',')),
327
+ ].join('\n') + '\n'
328
+ }
329
+
330
+ function csvCell(value) {
331
+ const text = String(value ?? '')
332
+ if (!/[",\n\r]/.test(text)) return text
333
+ return `"${text.replaceAll('"', '""')}"`
334
+ }
335
+
237
336
  async function scaffoldMobileHome(home) {
238
337
  await mkdir(home, { recursive: true })
239
338
  await mkdir(path.join(home, 'data'), { recursive: true })
@@ -247,7 +346,7 @@ async function scaffoldMobileHome(home) {
247
346
  path.join(home, 'data', 'accounts.csv'),
248
347
  await readFile(path.join(MOBILE_DATA_TEMPLATE_ROOT, 'accounts.example.csv'), 'utf8')
249
348
  )
250
- await writeFileIfMissing(
349
+ await mergeDeviceRegistry(
251
350
  path.join(home, 'data', 'devices.csv'),
252
351
  await readFile(path.join(MOBILE_DATA_TEMPLATE_ROOT, 'devices.example.csv'), 'utf8')
253
352
  )
@@ -597,11 +696,10 @@ async function notifyMobilePublishSuccess({ command, context, parsed, env = proc
597
696
  const config = dingTalkConfig(env)
598
697
  if (!config.webhook || !Array.isArray(parsed)) return
599
698
  const platform = platformFromMobileCommand(command)
600
- if (!platform) return
699
+ if (!shouldNotifyPublishPlatform(platform)) return
601
700
  for (const row of parsed) {
602
701
  if (!isPublishedResult(row)) continue
603
702
  const link = publishResultLink(row)
604
- if (!link) continue
605
703
  const content = buildPublishNotificationContent({
606
704
  platform,
607
705
  task: context.taskTitle || context.taskId,
@@ -623,8 +721,20 @@ async function notifyMobilePublishSuccess({ command, context, parsed, env = proc
623
721
 
624
722
  function dingTalkConfig(env = process.env) {
625
723
  return {
626
- webhook: env.ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK || env.ELEVENAGENTS_DINGTALK_WEBHOOK || '',
627
- secret: env.ELEVENAGENTS_PUBLISH_DINGTALK_SECRET || env.ELEVENAGENTS_DINGTALK_SECRET || '',
724
+ webhook: env.ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK
725
+ || env.ELEVENAGENTS_DINGTALK_WEBHOOK
726
+ || env.GTM_DINGTALK_WEBHOOK
727
+ || env.DINGTALK_WEBHOOK
728
+ || '',
729
+ secret: env.ELEVENAGENTS_PUBLISH_DINGTALK_SECRET
730
+ || env.ELEVENAGENTS_PUBLISH_DINGTALK_KEY
731
+ || env.ELEVENAGENTS_DINGTALK_SECRET
732
+ || env.ELEVENAGENTS_DINGTALK_KEY
733
+ || env.GTM_DINGTALK_SECRET
734
+ || env.GTM_DINGTALK_KEY
735
+ || env.DINGTALK_SECRET
736
+ || env.DINGTALK_KEY
737
+ || '',
628
738
  }
629
739
  }
630
740
 
@@ -644,6 +754,10 @@ function isPublishedResult(row) {
644
754
  return row && typeof row === 'object' && row.status === 'published'
645
755
  }
646
756
 
757
+ function shouldNotifyPublishPlatform(platform) {
758
+ return platform === 'xiaohongshu' || platform === 'tiktok'
759
+ }
760
+
647
761
  function publishResultLink(row) {
648
762
  return String(
649
763
  row.platform_permalink
@@ -658,7 +772,9 @@ function buildPublishNotificationContent({ platform, task, deviceId = '', link }
658
772
  const label = platformLabel(platform)
659
773
  const taskText = String(task || 'task').trim()
660
774
  const deviceText = deviceId ? `(设备 ${deviceId})` : ''
661
- return `${label} ${taskText}${deviceText}已发布,链接:${link}`
775
+ if (platform === 'tiktok') return `${label} ${taskText}${deviceText}已发布`
776
+ const linkText = String(link || '').trim() || '未回收'
777
+ return `${label} ${taskText}${deviceText}已发布,链接:${linkText}`
662
778
  }
663
779
 
664
780
  function platformLabel(platform) {
@@ -831,6 +947,7 @@ export const mobileInternals = {
831
947
  parseRawArgs,
832
948
  platformFromMobileCommand,
833
949
  publishResultLink,
950
+ shouldNotifyPublishPlatform,
834
951
  signedDingTalkUrl,
835
952
  stripWrapperFlags,
836
953
  taskIdFrom,