@11agents/cli 0.1.42 → 0.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.
@@ -59,6 +59,21 @@ class XAdbPublishResult:
59
59
  screenshot_path: str
60
60
  remote_media_path: str = ""
61
61
  link_url: str = ""
62
+ platform_permalink: str = ""
63
+ link_status: str = ""
64
+ link_error: str = ""
65
+ link_screenshot_path: str = ""
66
+ clipboard_method: str = ""
67
+ error: str = ""
68
+
69
+
70
+ @dataclass
71
+ class XAdbLinkResult:
72
+ device_id: str
73
+ status: str
74
+ platform_permalink: str
75
+ screenshot_path: str
76
+ clipboard_method: str = ""
62
77
  error: str = ""
63
78
 
64
79
 
@@ -118,6 +133,7 @@ class XAdbPublisher:
118
133
  remote = ""
119
134
  if local is not None:
120
135
  remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-x-{stamp}{local.suffix}"
136
+ link_result: XAdbLinkResult | None = None
121
137
 
122
138
  try:
123
139
  self._prepare_device(device)
@@ -136,6 +152,10 @@ class XAdbPublisher:
136
152
  else:
137
153
  self._tap_post(device, run_dir)
138
154
  final_shot = self._wait_for_publish_complete(device, run_dir, publish_text, verify_profile=verify_profile)
155
+ link_result = self._copy_published_post_link(device, run_dir / "link", publish_text)
156
+ if link_result.status != "retrieved" or not link_result.platform_permalink:
157
+ raise AutomationBlockedError(f"X post-publish link recovery failed: {link_result.error or link_result.status}")
158
+ final_shot = Path(link_result.screenshot_path)
139
159
  status = "published"
140
160
  append_publish_record(
141
161
  self.records_path,
@@ -148,12 +168,13 @@ class XAdbPublisher:
148
168
  local_media_path=str(local) if local else "",
149
169
  remote_media_path=remote,
150
170
  caption=publish_text,
151
- platform_permalink="",
171
+ platform_permalink=link_result.platform_permalink,
152
172
  published_at=utc_now_iso(),
153
173
  result_screenshot_path=str(final_shot),
154
174
  status="published",
155
175
  ),
156
176
  )
177
+ self._close_x_best_effort(device, run_dir)
157
178
 
158
179
  return XAdbPublishResult(
159
180
  device_id=device.device_id,
@@ -167,9 +188,14 @@ class XAdbPublisher:
167
188
  remote_media_path=remote,
168
189
  link_url=link_url,
169
190
  screenshot_path=str(final_shot),
191
+ platform_permalink=link_result.platform_permalink if link_result else "",
192
+ link_status=link_result.status if link_result else "",
193
+ link_error=link_result.error if link_result else "",
194
+ link_screenshot_path=link_result.screenshot_path if link_result else "",
195
+ clipboard_method=link_result.clipboard_method if link_result else "",
170
196
  )
171
197
  except AutomationBlockedError as exc:
172
- return self._failure_result(
198
+ result = self._failure_result(
173
199
  device,
174
200
  run_dir,
175
201
  record_id,
@@ -181,9 +207,12 @@ class XAdbPublisher:
181
207
  link_url=link_url,
182
208
  status="failed",
183
209
  error=str(exc),
210
+ link_result=link_result,
184
211
  )
212
+ self._close_x_best_effort(device, run_dir)
213
+ return result
185
214
  except Exception as exc:
186
- return self._failure_result(
215
+ result = self._failure_result(
187
216
  device,
188
217
  run_dir,
189
218
  record_id,
@@ -195,7 +224,10 @@ class XAdbPublisher:
195
224
  link_url=link_url,
196
225
  status="failed",
197
226
  error=str(exc),
227
+ link_result=link_result,
198
228
  )
229
+ self._close_x_best_effort(device, run_dir)
230
+ return result
199
231
 
200
232
  def _failure_result(
201
233
  self,
@@ -211,9 +243,11 @@ class XAdbPublisher:
211
243
  link_url: str = "",
212
244
  status: str,
213
245
  error: str,
246
+ link_result: XAdbLinkResult | None = None,
214
247
  ) -> XAdbPublishResult:
215
- failure_shot = run_dir / f"{status}.png"
216
- self.adb.screenshot(device.adb_serial, failure_shot)
248
+ failure_shot = Path(link_result.screenshot_path) if link_result and link_result.screenshot_path else run_dir / f"{status}.png"
249
+ if not failure_shot.exists():
250
+ self.adb.screenshot(device.adb_serial, failure_shot)
217
251
  return XAdbPublishResult(
218
252
  device_id=device.device_id,
219
253
  status=status,
@@ -226,6 +260,11 @@ class XAdbPublisher:
226
260
  remote_media_path=remote_media_path,
227
261
  link_url=link_url,
228
262
  screenshot_path=str(failure_shot),
263
+ platform_permalink=link_result.platform_permalink if link_result else "",
264
+ link_status=link_result.status if link_result else "",
265
+ link_error=link_result.error if link_result else "",
266
+ link_screenshot_path=link_result.screenshot_path if link_result else "",
267
+ clipboard_method=link_result.clipboard_method if link_result else "",
229
268
  error=error,
230
269
  )
231
270
 
@@ -247,6 +286,18 @@ class XAdbPublisher:
247
286
  raise AutomationBlockedError("X is not foreground after launch; check install/login/verification state")
248
287
  self._raise_if_blocked(device, run_dir / "home.xml")
249
288
 
289
+ def _close_x(self, device: Device) -> None:
290
+ self._ok(self.adb.force_stop(device.adb_serial, X_PACKAGE), "close X")
291
+
292
+ def _close_x_best_effort(self, device: Device, run_dir: Path | None = None) -> None:
293
+ try:
294
+ self._close_x(device)
295
+ if run_dir is not None:
296
+ time.sleep(1)
297
+ self.adb.screenshot(device.adb_serial, run_dir / "after-app-close.png")
298
+ except Exception:
299
+ pass
300
+
250
301
  def _open_compose(self, device: Device, run_dir: Path) -> None:
251
302
  if self._is_compose_open(device, run_dir / "compose-check-initial.xml"):
252
303
  return
@@ -461,6 +512,209 @@ class XAdbPublisher:
461
512
  self.adb.screenshot(device.adb_serial, run_dir / "profile-post-missing.png")
462
513
  raise AutomationBlockedError("X publish completed, but the expected post text was not found on the profile page")
463
514
 
515
+ def _copy_published_post_link(self, device: Device, run_dir: Path, publish_text: str) -> XAdbLinkResult:
516
+ run_dir.mkdir(parents=True, exist_ok=True)
517
+ try:
518
+ self._open_published_post_detail(device, run_dir, publish_text)
519
+ self._tap_share_from_detail(device, run_dir)
520
+ self._tap_copy_link(device, run_dir)
521
+ time.sleep(1.5)
522
+ final_shot = run_dir / "after-copy-link.png"
523
+ self.adb.screenshot(device.adb_serial, final_shot)
524
+ permalink, method, error = self._read_copied_link(device, run_dir)
525
+ if permalink:
526
+ return XAdbLinkResult(
527
+ device_id=device.device_id,
528
+ status="retrieved",
529
+ platform_permalink=permalink,
530
+ screenshot_path=str(final_shot),
531
+ clipboard_method=method,
532
+ )
533
+ return XAdbLinkResult(
534
+ device_id=device.device_id,
535
+ status="failed",
536
+ platform_permalink="",
537
+ screenshot_path=str(final_shot),
538
+ error=error or "copied link to device clipboard, but automatic clipboard read returned no URL",
539
+ )
540
+ except Exception as exc:
541
+ failure_shot = run_dir / "failed-copy-link.png"
542
+ self.adb.screenshot(device.adb_serial, failure_shot)
543
+ return XAdbLinkResult(
544
+ device_id=device.device_id,
545
+ status="failed",
546
+ platform_permalink="",
547
+ screenshot_path=str(failure_shot),
548
+ error=str(exc),
549
+ )
550
+
551
+ def _open_published_post_detail(self, device: Device, run_dir: Path, publish_text: str) -> Path:
552
+ if not self._is_profile_open(device):
553
+ self._open_profile(device, run_dir)
554
+ for attempt in range(6):
555
+ root = ui.dump_ui(self.adb, device, run_dir / f"profile-before-detail-{attempt + 1}.xml")
556
+ row = _find_post_row(root, publish_text)
557
+ if row is not None:
558
+ target = _find_post_text_node(row, publish_text) or row
559
+ self._tap_post_detail_target(device, target)
560
+ time.sleep(3)
561
+ detail_root = ui.dump_ui(self.adb, device, run_dir / f"detail-after-tap-{attempt + 1}.xml")
562
+ if self._is_post_detail_open(device, detail_root, publish_text):
563
+ detail_shot = run_dir / "post-detail.png"
564
+ self.adb.screenshot(device.adb_serial, detail_shot)
565
+ return detail_shot
566
+ if not self._is_profile_open(device):
567
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
568
+ time.sleep(1)
569
+ self.adb.swipe(device.adb_serial, 540, 1800, 540, 760, 350)
570
+ time.sleep(2)
571
+
572
+ self.adb.screenshot(device.adb_serial, run_dir / "post-detail-open-failed.png")
573
+ raise AutomationBlockedError("X published post could not be opened from the profile page")
574
+
575
+ def _tap_post_detail_target(self, device: Device, node) -> None:
576
+ bounds = ui.bounds(node)
577
+ if not bounds:
578
+ raise AutomationBlockedError("X published post row has no tappable bounds")
579
+ left, top, right, bottom = bounds
580
+ x = min(max((left + right) // 2, 220), 940)
581
+ y = min(max((top + bottom) // 2, 320), 1840)
582
+ self._ok(self.adb.tap(device.adb_serial, x, y), "tap X published post")
583
+
584
+ def _is_post_detail_open(self, device: Device, root, publish_text: str) -> bool:
585
+ focus = self.adb.current_focus(device.adb_serial)
586
+ if X_PACKAGE not in focus or X_COMPOSER_ACTIVITY in focus:
587
+ return False
588
+ if not _profile_has_post(root, publish_text):
589
+ return False
590
+ if X_PROFILE_ACTIVITY not in focus:
591
+ return True
592
+ return ui.find_node(
593
+ root,
594
+ ids={f"{X_PACKAGE}:id/tweet_detail_view"},
595
+ id_suffixes={"tweet_detail_view", "tweet_detail_container", "tweet_detail"},
596
+ visible_only=False,
597
+ ) is not None
598
+
599
+ def _tap_share_from_detail(self, device: Device, run_dir: Path) -> None:
600
+ for attempt in range(5):
601
+ root = ui.dump_ui(self.adb, device, run_dir / f"detail-before-share-{attempt + 1}.xml")
602
+ node = ui.find_node(
603
+ root,
604
+ ids={f"{X_PACKAGE}:id/inline_twitter_share"},
605
+ id_suffixes={"inline_twitter_share", "tweet_share_button", "share_button"},
606
+ descs={"分享", "Share"},
607
+ partial_texts={"分享", "share"},
608
+ clickable_only=False,
609
+ )
610
+ if node is not None:
611
+ ui.tap_node(self.adb, device, node, sleep_s=2)
612
+ self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
613
+ return
614
+ self.adb.swipe(device.adb_serial, 540, 1700, 540, 950, 300)
615
+ time.sleep(1)
616
+ self.adb.screenshot(device.adb_serial, run_dir / "share-icon-missing.png")
617
+ raise AutomationBlockedError("X share icon was not found on the post detail page")
618
+
619
+ def _tap_copy_link(self, device: Device, run_dir: Path) -> None:
620
+ for attempt in range(5):
621
+ root = ui.dump_ui(self.adb, device, run_dir / f"share-sheet-{attempt + 1}.xml")
622
+ if attempt == 0:
623
+ self.adb.screenshot(device.adb_serial, run_dir / "share-sheet.png")
624
+ node = ui.find_node(
625
+ root,
626
+ texts={"复制链接", "复制帖子链接", "复制推文链接", "Copy link", "Copy link to post", "Copy link to Tweet"},
627
+ descs={"复制链接", "复制帖子链接", "复制推文链接", "Copy link", "Copy link to post", "Copy link to Tweet"},
628
+ partial_texts={"复制链接", "复制帖子链接", "复制推文链接", "copy link"},
629
+ clickable_only=False,
630
+ )
631
+ if node is not None:
632
+ ui.tap_node(self.adb, device, node, sleep_s=1)
633
+ return
634
+ time.sleep(1)
635
+ self.adb.screenshot(device.adb_serial, run_dir / "copy-link-missing.png")
636
+ raise AutomationBlockedError("X copy link action was not found in the share sheet")
637
+
638
+ def _read_copied_link(self, device: Device, run_dir: Path) -> tuple[str, str, str]:
639
+ errors: list[str] = []
640
+
641
+ result = self.adb.shell(device.adb_serial, "cmd", "clipboard", "get")
642
+ if result.ok:
643
+ link = _extract_x_url(result.stdout)
644
+ if link:
645
+ return link, "adb_cmd_clipboard", ""
646
+ errors.append("adb_cmd_clipboard returned no URL")
647
+ else:
648
+ errors.append(f"adb_cmd_clipboard failed: {_short(result.stderr or result.stdout)}")
649
+
650
+ result = self.adb.shell(device.adb_serial, "dumpsys", "clipboard")
651
+ if result.ok:
652
+ link = _extract_x_url(result.stdout)
653
+ if link:
654
+ return link, "adb_dumpsys_clipboard", ""
655
+ errors.append("adb_dumpsys_clipboard returned no URL")
656
+ else:
657
+ errors.append(f"adb_dumpsys_clipboard failed: {_short(result.stderr or result.stdout)}")
658
+
659
+ link = self._read_link_by_paste_probe(device, run_dir)
660
+ if link:
661
+ return link, "paste_probe_ui_dump", ""
662
+ errors.append("paste_probe_ui_dump returned no URL")
663
+
664
+ appium = AppiumClient(self.appium_server)
665
+ try:
666
+ appium.start_session(_capabilities(device.adb_serial))
667
+ text = appium.get_clipboard()
668
+ link = _extract_x_url(text)
669
+ if link:
670
+ return link, "appium_get_clipboard", ""
671
+ errors.append("appium_get_clipboard returned no URL")
672
+ except Exception as exc:
673
+ errors.append(f"appium_get_clipboard failed: {_short(str(exc))}")
674
+ finally:
675
+ try:
676
+ appium.delete_session()
677
+ except Exception:
678
+ pass
679
+
680
+ return "", "", "; ".join(errors)
681
+
682
+ def _read_link_by_paste_probe(self, device: Device, run_dir: Path) -> str:
683
+ opened_input = False
684
+ try:
685
+ root = ui.dump_ui(self.adb, device, run_dir / "before-paste-probe.xml")
686
+ node = ui.find_node(
687
+ root,
688
+ ids={f"{X_PACKAGE}:id/tweet_text"},
689
+ id_suffixes={"tweet_text"},
690
+ partial_texts={"发布你的回复", "Post your reply", "Tweet your reply"},
691
+ clickable_only=False,
692
+ )
693
+ if node is None:
694
+ node = ui.find_node(
695
+ root,
696
+ ids={f"{X_PACKAGE}:id/persistent_reply"},
697
+ id_suffixes={"persistent_reply"},
698
+ partial_texts={"发布你的回复", "Post your reply", "reply"},
699
+ clickable_only=True,
700
+ )
701
+ if node is None:
702
+ self.adb.screenshot(device.adb_serial, run_dir / "paste-probe-input-missing.png")
703
+ return ""
704
+ ui.tap_node(self.adb, device, node, sleep_s=1)
705
+ opened_input = True
706
+ self.adb.keyevent(device.adb_serial, "KEYCODE_PASTE")
707
+ time.sleep(1)
708
+ root = ui.dump_ui(self.adb, device, run_dir / "paste-probe.xml")
709
+ self.adb.screenshot(device.adb_serial, run_dir / "paste-probe.png")
710
+ return _extract_x_url(ui.all_text(root))
711
+ except Exception:
712
+ return ""
713
+ finally:
714
+ if opened_input:
715
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
716
+ time.sleep(0.3)
717
+
464
718
  def _open_profile(self, device: Device, run_dir: Path) -> None:
465
719
  if self._is_profile_open(device):
466
720
  return
@@ -576,10 +830,70 @@ def _profile_has_post(root, expected_text: str) -> bool:
576
830
  return False
577
831
 
578
832
 
833
+ def _find_post_row(root, expected_text: str) -> object | None:
834
+ expected = _normalize_for_match(expected_text)
835
+ if not expected:
836
+ return None
837
+ candidates = []
838
+ for node in root.iter("node"):
839
+ resource_id = node.attrib.get("resource-id", "")
840
+ if not (resource_id == f"{X_PACKAGE}:id/row" or resource_id.endswith(":id/row")):
841
+ continue
842
+ if not ui.is_visible(node):
843
+ continue
844
+ text = _normalize_for_match(_node_text(node))
845
+ if expected in text or (len(expected) >= 80 and expected[:60] in text):
846
+ candidates.append(node)
847
+ return ui.sorted_visible_nodes(candidates)[0] if candidates else None
848
+
849
+
850
+ def _find_post_text_node(row, expected_text: str) -> object | None:
851
+ expected = _normalize_for_match(expected_text)
852
+ candidates = []
853
+ for node in row.iter("node"):
854
+ resource_id = node.attrib.get("resource-id", "")
855
+ if not (resource_id.endswith(":id/tweet_content_text") or node.attrib.get("text", "")):
856
+ continue
857
+ if not ui.is_visible(node):
858
+ continue
859
+ text = _normalize_for_match(_node_text(node))
860
+ if expected in text or (len(expected) >= 80 and expected[:60] in text):
861
+ candidates.append(node)
862
+ return ui.sorted_visible_nodes(candidates)[0] if candidates else None
863
+
864
+
865
+ def _node_text(node) -> str:
866
+ parts: list[str] = []
867
+ for item in node.iter("node"):
868
+ for key in ("text", "content-desc"):
869
+ value = item.attrib.get(key, "")
870
+ if value:
871
+ parts.append(value)
872
+ return "\n".join(parts)
873
+
874
+
579
875
  def _normalize_for_match(value: str) -> str:
580
876
  return re.sub(r"\s+", " ", value).strip()
581
877
 
582
878
 
879
+ def _extract_x_url(text: str) -> str:
880
+ urls = []
881
+ for raw in re.findall(r"https?://[^\s\"'<>]+", text or ""):
882
+ url = raw.rstrip(").,;,。]")
883
+ if url:
884
+ urls.append(url)
885
+ for url in urls:
886
+ lowered = url.lower()
887
+ if any(marker in lowered for marker in ("x.com/", "twitter.com/", "mobile.twitter.com/", "t.co/")):
888
+ return url
889
+ return urls[0] if urls else ""
890
+
891
+
892
+ def _short(text: str, limit: int = 220) -> str:
893
+ one_line = " ".join((text or "").split())
894
+ return one_line if len(one_line) <= limit else one_line[: limit - 3] + "..."
895
+
896
+
583
897
  def _find_top_left_clickable(root) -> object | None:
584
898
  candidates = []
585
899
  for node in root.iter("node"):
@@ -34,13 +34,13 @@ ADB fallback when explicitly requested:
34
34
 
35
35
  Use `--range`, `--parallel`, and `--max-concurrency N` only when the approved plan targets multiple devices. Keep concurrency at or below 5.
36
36
 
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.
37
+ Successful live publish is only confirmed after the CLI taps the publish button and verifies the post-publish surface. If the just-published video detail page already shows `复制链接`/`Copy link`, the CLI copies that link directly; otherwise it reopens TikTok on the profile post grid, opens the latest owned video, and copies its share link. Link readback uses the Android launcher/system search input as the default paste probe after direct clipboard reads; TikTok search is only a fallback. The CLI stores `platform_permalink`/`platform_post_id` in the publish record when available and force-stops TikTok after recovery. Treat missing `platform_permalink` or `link_status!="retrieved"` as a failed publish workflow even if the video tap itself succeeded. The run artifacts include `after-app-close.png`.
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.
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. The message starts with the project, includes the task/device/link, and uses `链接:未收集到` when link recovery returned empty.
40
40
 
41
41
  ## Output
42
42
 
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:
43
+ Return publish status, record id, account/device, screenshot path, `platform_permalink`, `platform_post_id`, `link_status`, duration, error action, and the data-collection handoff:
44
44
 
45
45
  ```bash
46
46
  11agents mobile data collect --platform tiktok --scope video --record-id <record_id> --json --task-id TASK_123
@@ -21,6 +21,8 @@ If approval, account access, content fields, or device readiness is missing, ask
21
21
  11agents mobile list-devices --health --json
22
22
  ```
23
23
 
24
+ Live publishing verifies the post on the account profile, opens the published post detail page, taps the share icon, copies the post link, reads the copied permalink back into the publish record, and closes X after link recovery. Treat missing `platform_permalink` or `link_status!="retrieved"` as a failed publish workflow even if the post tap itself succeeded.
25
+
24
26
  Examples:
25
27
 
26
28
  ```bash
@@ -31,9 +33,11 @@ Examples:
31
33
 
32
34
  Do not automate replies, follows, likes, reposts, random browsing, or DM actions from this skill.
33
35
 
36
+ 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 message starts with the project, includes the task/device/link, and uses `链接:未收集到` when link recovery returned empty.
37
+
34
38
  ## Output
35
39
 
36
- Return publish status, record id, account/device, screenshot path, permalink or platform post id when available, duration, error, and the data-collection handoff:
40
+ Return publish status, record id, account/device, screenshot path, `platform_permalink`, `link_status`, duration, error, and the data-collection handoff:
37
41
 
38
42
  ```bash
39
43
  11agents mobile data collect --platform x --record-id <record_id> --json --task-id TASK_123
@@ -35,7 +35,7 @@ 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.
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 message starts with the project, includes the task/device/link, and uses `链接:未收集到` when link recovery returned empty.
39
39
 
40
40
  Link copy after publish or during the recovery skill:
41
41
 
@@ -51,7 +51,7 @@ 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. 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.
54
+ 14. Do not run custom DingTalk webhook scripts after X, 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. The message starts with the project, includes the task/device/link, and uses `链接:未收集到` when link recovery returned empty.
55
55
  15. Stop on `failed`; surface screenshot and error instead of repeated retries.
56
56
 
57
57
  ## Output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@11agents/cli",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "description": "11agents local runtime and telemetry CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
1
+ import { mkdir, readFile, readdir, rm, rmdir, writeFile } from 'node:fs/promises'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { flag } from '../args.js'
@@ -116,6 +116,27 @@ export function buildKnowledgeBaseDir(project, { homeDir = os.homedir() } = {})
116
116
  return path.join(homeDir, '.11agents', slugify(project), 'knowledge_base')
117
117
  }
118
118
 
119
+ // Mirror the skill-sync strategy: after a sync the cloud snapshot is the source
120
+ // of truth, so local knowledge text files missing from it are removed. Scope is
121
+ // strictly the project's knowledge_base directory.
122
+ async function pruneLocalKnowledgeFiles(baseDir, keepPaths) {
123
+ for (const localPath of await listKnowledgeTextFiles(baseDir)) {
124
+ if (keepPaths.has(localPath)) continue
125
+ await rm(path.join(baseDir, localPath), { force: true })
126
+ }
127
+ const removeEmptyDirs = async dir => {
128
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
129
+ for (const entry of entries) {
130
+ if (entry.isDirectory()) await removeEmptyDirs(path.join(dir, entry.name))
131
+ }
132
+ if (dir !== baseDir) {
133
+ const remaining = await readdir(dir).catch(() => ['keep'])
134
+ if (remaining.length === 0) await rmdir(dir).catch(() => {})
135
+ }
136
+ }
137
+ await removeEmptyDirs(baseDir)
138
+ }
139
+
119
140
  export async function writeKnowledgeSnapshot(project, snapshot, { homeDir = os.homedir() } = {}) {
120
141
  const normalized = normalizeSnapshot(snapshot)
121
142
  const baseDir = buildKnowledgeBaseDir(project, { homeDir })
@@ -138,6 +159,7 @@ export async function writeKnowledgeSnapshot(project, snapshot, { homeDir = os.h
138
159
  await mkdir(path.dirname(target), { recursive: true })
139
160
  await writeFile(target, markdownForNode(node))
140
161
  }
162
+ await pruneLocalKnowledgeFiles(baseDir, new Set(normalized.nodes.map(node => node.local_path)))
141
163
  return index
142
164
  }
143
165
 
@@ -65,8 +65,10 @@ const COMMAND_ALIASES = new Map([
65
65
 
66
66
  const WRAPPER_FLAG_NAMES = new Set([
67
67
  'mobile-home',
68
+ 'project',
68
69
  'task-id',
69
70
  'task-title',
71
+ 'workspace',
70
72
  ])
71
73
 
72
74
  const EMPTY_DATA_FILES = [
@@ -99,7 +101,7 @@ Runtime:
99
101
  Python/device-control code is bundled with @11agents/cli.
100
102
  Mutable runtime state lives in ~/.11agents/mobile by default.
101
103
  Every command writes logs under ~/.11agents/mobile/runs/<task_id>/log.
102
- Xiaohongshu/TikTok publish-success DingTalk notification is enabled by
104
+ X/Xiaohongshu/TikTok publish-success DingTalk notification is enabled by
103
105
  ELEVENAGENTS_PUBLISH_DINGTALK_WEBHOOK and optional
104
106
  ELEVENAGENTS_PUBLISH_DINGTALK_SECRET.`)
105
107
  }
@@ -602,10 +604,12 @@ function buildRunContext(rawArgs, deps = {}) {
602
604
  || env.ELEVENAGENTS_ISSUE_TITLE
603
605
  || taskId
604
606
  ).trim()
607
+ const project = projectFromMobileContext(flags, env)
605
608
  const runDir = path.join(home, 'runs', taskId)
606
609
  return {
607
610
  flags,
608
611
  home,
612
+ project,
609
613
  taskId,
610
614
  taskTitle,
611
615
  runDir,
@@ -613,6 +617,20 @@ function buildRunContext(rawArgs, deps = {}) {
613
617
  }
614
618
  }
615
619
 
620
+ function projectFromMobileContext(flags = {}, env = process.env) {
621
+ return String(
622
+ flag(flags, 'project')
623
+ || flag(flags, 'workspace')
624
+ || env.ELEVENAGENTS_PROJECT_SLUG
625
+ || env.ELEVENAGENTS_PROJECT
626
+ || env.ELEVENAGENTS_PROJECT_NAME
627
+ || env.ELEVENAGENTS_WORKSPACE_SLUG
628
+ || env.GTM_SWARM_WORKSPACE
629
+ || env.WORKSPACE_SLUG
630
+ || ''
631
+ ).trim()
632
+ }
633
+
616
634
  async function dispatchPython(rawArgs, deps = {}) {
617
635
  const command = normalizePythonCommand(rawArgs[0] || '')
618
636
  if (!PYTHON_COMMANDS.has(command)) throw new Error(`unsupported mobile command: ${rawArgs[0] || ''}`)
@@ -622,6 +640,7 @@ async function dispatchPython(rawArgs, deps = {}) {
622
640
  const env = pythonEnv(context.home, deps.env || process.env)
623
641
  env.ELEVENAGENTS_MOBILE_RUN_DIR = context.runDir
624
642
  env.ELEVENAGENTS_TASK_ID = context.taskId
643
+ if (context.project) env.ELEVENAGENTS_PROJECT_SLUG = context.project
625
644
  const result = await runAndLog({
626
645
  command: python,
627
646
  args: childArgs,
@@ -661,6 +680,7 @@ async function dispatchScript(rawArgs, deps = {}) {
661
680
  const env = pythonEnv(context.home, deps.env || process.env)
662
681
  env.ELEVENAGENTS_MOBILE_RUN_DIR = context.runDir
663
682
  env.ELEVENAGENTS_TASK_ID = context.taskId
683
+ if (context.project) env.ELEVENAGENTS_PROJECT_SLUG = context.project
664
684
  const result = await runAndLog({
665
685
  command: python,
666
686
  args: [scriptPath, ...stripWrapperFlags(rawArgs.slice(1))],
@@ -702,6 +722,7 @@ async function notifyMobilePublishSuccess({ command, context, parsed, env = proc
702
722
  const link = publishResultLink(row)
703
723
  const content = buildPublishNotificationContent({
704
724
  platform,
725
+ project: context.project,
705
726
  task: context.taskTitle || context.taskId,
706
727
  deviceId: row.device_id || row.device || '',
707
728
  link,
@@ -755,7 +776,7 @@ function isPublishedResult(row) {
755
776
  }
756
777
 
757
778
  function shouldNotifyPublishPlatform(platform) {
758
- return platform === 'xiaohongshu' || platform === 'tiktok'
779
+ return platform === 'xiaohongshu' || platform === 'tiktok' || platform === 'x'
759
780
  }
760
781
 
761
782
  function publishResultLink(row) {
@@ -768,13 +789,14 @@ function publishResultLink(row) {
768
789
  ).trim()
769
790
  }
770
791
 
771
- function buildPublishNotificationContent({ platform, task, deviceId = '', link }) {
792
+ function buildPublishNotificationContent({ platform, project = '', task, deviceId = '', link }) {
772
793
  const label = platformLabel(platform)
794
+ const projectText = String(project || '').trim()
773
795
  const taskText = String(task || 'task').trim()
774
796
  const deviceText = deviceId ? `(设备 ${deviceId})` : ''
775
- if (platform === 'tiktok') return `${label} ${taskText}${deviceText}已发布`
776
- const linkText = String(link || '').trim() || '未回收'
777
- return `${label} ${taskText}${deviceText}已发布,链接:${linkText}`
797
+ const linkText = String(link || '').trim() || '未收集到'
798
+ const prefix = projectText ? `[${projectText}] ` : ''
799
+ return `${prefix}${label} ${taskText}${deviceText}已发布,链接:${linkText}`
778
800
  }
779
801
 
780
802
  function platformLabel(platform) {
@@ -946,6 +968,7 @@ export const mobileInternals = {
946
968
  mobileHome,
947
969
  parseRawArgs,
948
970
  platformFromMobileCommand,
971
+ projectFromMobileContext,
949
972
  publishResultLink,
950
973
  shouldNotifyPublishPlatform,
951
974
  signedDingTalkUrl,
@@ -1124,10 +1124,13 @@ async function prepareRuntimeTask(task, flags, deps, config) {
1124
1124
  // Resolve project token before skill sync so MCP auth is available.
1125
1125
  const projectToken = await projectSyncToken(projectTokenCandidatesForTask(task, flags), flags, deps, task.workspace?.swarm_token)
1126
1126
  const skills = await syncSkillsViaMcp({ task, workdir, flags, deps, projectToken, config })
1127
+ const projectSlug = projectSlugForTask(task, flags)
1127
1128
  const env = {
1128
1129
  ...process.env,
1129
1130
  ...agentEnvironment(task),
1130
1131
  ELEVENAGENTS_PROJECT_DIR: workdir,
1132
+ ELEVENAGENTS_PROJECT_SLUG: projectSlug,
1133
+ ELEVENAGENTS_PROJECT_NAME: String(task.workspace?.name || task.workspace?.slug || task.workspace_slug || ''),
1131
1134
  ELEVENAGENTS_AGENT_DIR: agentDir,
1132
1135
  ELEVENAGENTS_AGENT_SKILLS_DIR: agentSkillsDir,
1133
1136
  ELEVENAGENTS_AGENT_MEMORY_DIR: agentMemoryDir,
@@ -1146,7 +1149,7 @@ async function prepareRuntimeTask(task, flags, deps, config) {
1146
1149
  agent_results_dir: agentResultsDir,
1147
1150
  tmp_dir: tmpDir,
1148
1151
  run_dir: runDir,
1149
- project_slug: projectSlugForTask(task, flags),
1152
+ project_slug: projectSlug,
1150
1153
  readonly: true,
1151
1154
  env,
1152
1155
  database,