clawdex-mobile 5.2.0 → 5.2.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 5.2.3 - 2026-05-30
6
+
7
+ ### Added
8
+ - Push notifications for agent turn completion and approval requests. Because the app's WebSocket closes when backgrounded, the always-on bridge is the sender: devices register an Expo push token (`bridge/push/register`/`unregister`/`list`) and the bridge POSTs a minimal payload to the Expo push service, which relays via APNs (iOS) and FCM (Android). Auto-enabled on first bridge connect; Settings → Notifications is the override (opt out + per-event toggles). Foreground banners are suppressed; tapping a notification deep-links to the thread.
9
+ - Reply preview on turn-completed notifications: the agent's last reply line, whitespace-collapsed and capped at 140 characters. A reply snippet therefore transits Expo/Apple/Google when notifications are enabled; disclosed in the privacy policy and store data-safety answers.
10
+ - Actionable Approve/Deny buttons on approval notifications, resolved over the authenticated bridge WebSocket without opening the conversation.
11
+ - Android push support (Firebase project + FCM v1 credentials).
12
+ - Prompt library in the composer: save and one-tap-insert reusable prompts, with search and inline add/edit/delete.
13
+
14
+ ### Improved
15
+ - Push delivery hardened for scale: retry with exponential backoff on Expo 429/5xx and transport errors, plus delayed receipt polling that prunes unregistered device tokens.
16
+ - Version synced to 5.2.3 across the monorepo (CLI, mobile app, and Rust bridge).
17
+
5
18
  ## 5.2.0 - 2026-05-18
6
19
 
7
20
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawdex-mobile",
3
- "version": "5.2.0",
3
+ "version": "5.2.3",
4
4
  "description": "Private-network mobile bridge and CLI for Codex, OpenCode, and Cursor",
5
5
  "keywords": [
6
6
  "codex",
@@ -149,7 +149,7 @@ dependencies = [
149
149
 
150
150
  [[package]]
151
151
  name = "codex-rust-bridge"
152
- version = "5.2.0"
152
+ version = "5.2.3"
153
153
  dependencies = [
154
154
  "axum",
155
155
  "base64",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "codex-rust-bridge"
3
- version = "5.2.0"
3
+ version = "5.2.3"
4
4
  edition = "2021"
5
5
 
6
6
  [dependencies]
@@ -663,6 +663,552 @@ async fn configure_git_credential_store(
663
663
  Ok(())
664
664
  }
665
665
 
666
+ // ---- Push notifications ----------------------------------------------------
667
+ //
668
+ // The mobile app can only run JavaScript (and therefore keep its WebSocket
669
+ // open) while it is foregrounded. The moment it is backgrounded or killed the
670
+ // socket closes, so the *phone* can never observe a turn completing. The bridge
671
+ // is the only component reliably alive at that moment, so it is the sender:
672
+ // devices register an Expo push token, and the bridge POSTs a minimal,
673
+ // content-free payload to the Expo push service when a turn completes or an
674
+ // approval is requested. Expo relays to APNs/FCM, which wakes the app.
675
+
676
+ const PUSH_REGISTRY_FILE_NAME: &str = ".clawdex-push-registry.json";
677
+ const EXPO_PUSH_SEND_ENDPOINT: &str = "https://exp.host/--/api/v2/push/send";
678
+ const EXPO_PUSH_RECEIPTS_ENDPOINT: &str = "https://exp.host/--/api/v2/push/getReceipts";
679
+ const EXPO_PUSH_BATCH_SIZE: usize = 100;
680
+ // Reply-preview tuning: cap how much streamed text we buffer per thread, and how
681
+ // many characters of the first line we surface in the notification body.
682
+ const PUSH_PREVIEW_ACCUMULATE_CAP: usize = 8000;
683
+ const PUSH_PREVIEW_MAX_CHARS: usize = 140;
684
+ const EXPO_RECEIPT_BATCH_SIZE: usize = 1000;
685
+ // Expo asks senders to wait at least ~15 minutes before fetching delivery receipts.
686
+ const RECEIPT_CHECK_DELAY_SECS: u64 = 900;
687
+ const PUSH_SEND_MAX_ATTEMPTS: u32 = 4;
688
+
689
+ #[derive(Debug, Clone, Serialize, Deserialize)]
690
+ #[serde(rename_all = "camelCase")]
691
+ struct PushEventPreferences {
692
+ #[serde(default = "default_true")]
693
+ turn_completed: bool,
694
+ #[serde(default = "default_true")]
695
+ approval_requested: bool,
696
+ }
697
+
698
+ fn default_true() -> bool {
699
+ true
700
+ }
701
+
702
+ impl Default for PushEventPreferences {
703
+ fn default() -> Self {
704
+ Self {
705
+ turn_completed: true,
706
+ approval_requested: true,
707
+ }
708
+ }
709
+ }
710
+
711
+ #[derive(Debug, Clone, Serialize, Deserialize)]
712
+ #[serde(rename_all = "camelCase")]
713
+ struct PushDeviceRegistration {
714
+ token: String,
715
+ #[serde(default)]
716
+ platform: String,
717
+ #[serde(default)]
718
+ device_name: String,
719
+ #[serde(default)]
720
+ events: PushEventPreferences,
721
+ created_at: String,
722
+ updated_at: String,
723
+ }
724
+
725
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
726
+ #[serde(rename_all = "camelCase")]
727
+ struct PushRegistry {
728
+ #[serde(default)]
729
+ devices: Vec<PushDeviceRegistration>,
730
+ }
731
+
732
+ struct PushService {
733
+ registry: RwLock<PushRegistry>,
734
+ registry_path: PathBuf,
735
+ project_label: String,
736
+ http: reqwest::Client,
737
+ access_token: Option<String>,
738
+ // Accumulates the in-flight agent reply text per thread (keyed by threadId),
739
+ // so a turn/completed push can include a short preview of what the agent said.
740
+ recent_replies: RwLock<HashMap<String, String>>,
741
+ }
742
+
743
+ impl PushService {
744
+ async fn load(workdir: &Path, project_label: String) -> Arc<Self> {
745
+ let registry_path = workdir.join(PUSH_REGISTRY_FILE_NAME);
746
+ let registry = match tokio::fs::read_to_string(&registry_path).await {
747
+ Ok(contents) => serde_json::from_str::<PushRegistry>(&contents).unwrap_or_default(),
748
+ Err(_) => PushRegistry::default(),
749
+ };
750
+ let access_token = env::var("EXPO_ACCESS_TOKEN")
751
+ .ok()
752
+ .map(|value| value.trim().to_string())
753
+ .filter(|value| !value.is_empty());
754
+ Arc::new(Self {
755
+ registry: RwLock::new(registry),
756
+ registry_path,
757
+ project_label,
758
+ http: reqwest::Client::new(),
759
+ access_token,
760
+ recent_replies: RwLock::new(HashMap::new()),
761
+ })
762
+ }
763
+
764
+ fn spawn_event_loop(self: &Arc<Self>, hub: &Arc<ClientHub>) {
765
+ let this = Arc::clone(self);
766
+ let mut receiver = hub.subscribe_notifications();
767
+ tokio::spawn(async move {
768
+ loop {
769
+ match receiver.recv().await {
770
+ Ok(notification) => {
771
+ this.handle_notification(&notification.method, &notification.params)
772
+ .await;
773
+ }
774
+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
775
+ Err(broadcast::error::RecvError::Closed) => break,
776
+ }
777
+ }
778
+ });
779
+ }
780
+
781
+ async fn persist(&self) {
782
+ let snapshot = { self.registry.read().await.clone() };
783
+ match serde_json::to_string_pretty(&snapshot) {
784
+ Ok(contents) => {
785
+ if let Err(error) = tokio::fs::write(&self.registry_path, contents).await {
786
+ eprintln!("failed to persist push registry: {error}");
787
+ }
788
+ }
789
+ Err(error) => eprintln!("failed to serialize push registry: {error}"),
790
+ }
791
+ }
792
+
793
+ async fn register(
794
+ &self,
795
+ token: String,
796
+ platform: String,
797
+ device_name: String,
798
+ events: PushEventPreferences,
799
+ ) -> usize {
800
+ let now = now_iso();
801
+ let count = {
802
+ let mut registry = self.registry.write().await;
803
+ if let Some(existing) = registry
804
+ .devices
805
+ .iter_mut()
806
+ .find(|device| device.token == token)
807
+ {
808
+ existing.platform = platform;
809
+ existing.device_name = device_name;
810
+ existing.events = events;
811
+ existing.updated_at = now;
812
+ } else {
813
+ registry.devices.push(PushDeviceRegistration {
814
+ token,
815
+ platform,
816
+ device_name,
817
+ events,
818
+ created_at: now.clone(),
819
+ updated_at: now,
820
+ });
821
+ }
822
+ registry.devices.len()
823
+ };
824
+ self.persist().await;
825
+ count
826
+ }
827
+
828
+ async fn unregister(&self, token: &str) -> bool {
829
+ let removed = {
830
+ let mut registry = self.registry.write().await;
831
+ let before = registry.devices.len();
832
+ registry.devices.retain(|device| device.token != token);
833
+ registry.devices.len() != before
834
+ };
835
+ if removed {
836
+ self.persist().await;
837
+ }
838
+ removed
839
+ }
840
+
841
+ async fn list(&self) -> Vec<Value> {
842
+ let registry = self.registry.read().await;
843
+ registry
844
+ .devices
845
+ .iter()
846
+ .map(|device| {
847
+ json!({
848
+ "platform": device.platform,
849
+ "deviceName": device.device_name,
850
+ "events": device.events,
851
+ "createdAt": device.created_at,
852
+ "updatedAt": device.updated_at,
853
+ // Never echo full tokens back to clients; expose only a short suffix.
854
+ "tokenSuffix": token_suffix(&device.token),
855
+ })
856
+ })
857
+ .collect()
858
+ }
859
+
860
+ /// Pull params.threadId (or thread_id), trimmed and non-empty.
861
+ fn read_thread_id(params: &Value) -> Option<String> {
862
+ read_string(params.get("threadId"))
863
+ .or_else(|| read_string(params.get("thread_id")))
864
+ .map(|value| value.trim().to_string())
865
+ .filter(|value| !value.is_empty())
866
+ }
867
+
868
+ /// Accumulate streamed agent reply text per thread so a completed turn can
869
+ /// include a short preview. Handles the app-server delta method and the
870
+ /// codex-event variant; only text deltas are captured. Returns true if the
871
+ /// notification was a reply delta (and thus fully handled here).
872
+ async fn accumulate_reply(&self, method: &str, params: &Value) -> bool {
873
+ let is_delta = matches!(
874
+ method,
875
+ "item/agentMessage/delta" | "codex/event/agent_message_delta"
876
+ );
877
+ if !is_delta {
878
+ return false;
879
+ }
880
+ let field_is_text = read_string(params.get("field"))
881
+ .map(|value| value == "text")
882
+ .unwrap_or(true);
883
+ let delta = read_string(params.get("delta"))
884
+ .or_else(|| read_string(params.get("text")))
885
+ .unwrap_or_default();
886
+ if !field_is_text || delta.is_empty() {
887
+ return true;
888
+ }
889
+ if let Some(thread_id) = Self::read_thread_id(params) {
890
+ let mut replies = self.recent_replies.write().await;
891
+ let entry = replies.entry(thread_id).or_default();
892
+ // Cap accumulation so a long turn cannot grow this unbounded.
893
+ if entry.len() < PUSH_PREVIEW_ACCUMULATE_CAP {
894
+ entry.push_str(&delta);
895
+ }
896
+ }
897
+ true
898
+ }
899
+
900
+ /// Remove and format the accumulated reply for a thread into a one-line
901
+ /// preview: last non-empty line (agents usually end with the conclusion),
902
+ /// whitespace-collapsed, length-capped.
903
+ async fn take_reply_preview(&self, thread_id: &str) -> Option<String> {
904
+ let raw = {
905
+ let mut replies = self.recent_replies.write().await;
906
+ replies.remove(thread_id)?
907
+ };
908
+ let last_line = raw
909
+ .lines()
910
+ .map(str::trim)
911
+ .filter(|line| !line.is_empty())
912
+ .next_back()?;
913
+ let collapsed = last_line.split_whitespace().collect::<Vec<_>>().join(" ");
914
+ if collapsed.is_empty() {
915
+ return None;
916
+ }
917
+ Some(truncate_chars(&collapsed, PUSH_PREVIEW_MAX_CHARS))
918
+ }
919
+
920
+ async fn handle_notification(self: &Arc<Self>, method: &str, params: &Value) {
921
+ if self.accumulate_reply(method, params).await {
922
+ return;
923
+ }
924
+ let event = match method {
925
+ "turn/completed" => PushEvent::TurnCompleted,
926
+ "bridge/approval.requested" => PushEvent::ApprovalRequested,
927
+ _ => return,
928
+ };
929
+
930
+ let thread_id = read_string(params.get("threadId"))
931
+ .or_else(|| read_string(params.get("thread_id")))
932
+ .map(|value| value.trim().to_string())
933
+ .filter(|value| !value.is_empty());
934
+
935
+ // For approval events, carry the approval id so a notification action can
936
+ // resolve exactly this approval without opening the conversation first.
937
+ let approval_id = match event {
938
+ PushEvent::ApprovalRequested => read_string(params.get("id"))
939
+ .map(|value| value.trim().to_string())
940
+ .filter(|value| !value.is_empty()),
941
+ PushEvent::TurnCompleted => None,
942
+ };
943
+
944
+ // Drain the accumulated reply buffer on completion regardless of whether
945
+ // any device is registered, otherwise threads streamed while no device
946
+ // is subscribed would leak their buffers indefinitely.
947
+ let reply_preview = match event {
948
+ PushEvent::TurnCompleted => match thread_id.as_deref() {
949
+ Some(tid) => self.take_reply_preview(tid).await,
950
+ None => None,
951
+ },
952
+ PushEvent::ApprovalRequested => None,
953
+ };
954
+
955
+ let targets: Vec<String> = {
956
+ let registry = self.registry.read().await;
957
+ registry
958
+ .devices
959
+ .iter()
960
+ .filter(|device| match event {
961
+ PushEvent::TurnCompleted => device.events.turn_completed,
962
+ PushEvent::ApprovalRequested => device.events.approval_requested,
963
+ })
964
+ .map(|device| device.token.clone())
965
+ .collect()
966
+ };
967
+ if targets.is_empty() {
968
+ return;
969
+ }
970
+ let (title, body) = match event {
971
+ PushEvent::TurnCompleted => (
972
+ "Turn finished".to_string(),
973
+ reply_preview
974
+ .unwrap_or_else(|| format!("Codex finished working in {}", self.project_label)),
975
+ ),
976
+ PushEvent::ApprovalRequested => (
977
+ "Approval needed".to_string(),
978
+ format!(
979
+ "Codex is waiting for your approval in {}",
980
+ self.project_label
981
+ ),
982
+ ),
983
+ };
984
+ let data = json!({
985
+ "type": event.as_str(),
986
+ "threadId": thread_id,
987
+ "approvalId": approval_id,
988
+ });
989
+ // Only approval pushes get the actionable category; turn-complete pushes
990
+ // have nothing to act on.
991
+ let category_id = match event {
992
+ PushEvent::ApprovalRequested if approval_id.is_some() => Some("approval"),
993
+ _ => None,
994
+ };
995
+
996
+ self.send(&title, &body, &data, category_id, targets).await;
997
+ }
998
+
999
+ async fn send(
1000
+ self: &Arc<Self>,
1001
+ title: &str,
1002
+ body: &str,
1003
+ data: &Value,
1004
+ category_id: Option<&str>,
1005
+ tokens: Vec<String>,
1006
+ ) {
1007
+ for chunk in tokens.chunks(EXPO_PUSH_BATCH_SIZE) {
1008
+ let messages: Vec<Value> = chunk
1009
+ .iter()
1010
+ .map(|token| {
1011
+ let mut message = json!({
1012
+ "to": token,
1013
+ "title": title,
1014
+ "body": body,
1015
+ "data": data,
1016
+ "sound": "default",
1017
+ "priority": "high",
1018
+ });
1019
+ // iOS action buttons are driven by a registered category; the
1020
+ // app maps this id to its Approve/Deny actions.
1021
+ if let Some(category) = category_id {
1022
+ message["categoryId"] = json!(category);
1023
+ }
1024
+ message
1025
+ })
1026
+ .collect();
1027
+
1028
+ let Some(payload) = self
1029
+ .post_with_retry(EXPO_PUSH_SEND_ENDPOINT, &Value::Array(messages))
1030
+ .await
1031
+ else {
1032
+ continue;
1033
+ };
1034
+
1035
+ // Expo returns one ticket per message, in request order. status="error"
1036
+ // is an immediate failure; status="ok" carries a receipt id that we
1037
+ // re-check later, because DeviceNotRegistered (and APNs/FCM delivery
1038
+ // failures) frequently only surface in the receipt, not the ticket.
1039
+ let Some(tickets) = payload.get("data").and_then(Value::as_array) else {
1040
+ continue;
1041
+ };
1042
+ let mut stale: Vec<String> = Vec::new();
1043
+ let mut pending_receipts: Vec<(String, String)> = Vec::new();
1044
+ for (index, ticket) in tickets.iter().enumerate() {
1045
+ let Some(token) = chunk.get(index).cloned() else {
1046
+ continue;
1047
+ };
1048
+ match read_string(ticket.get("status")).as_deref() {
1049
+ Some("ok") => {
1050
+ if let Some(receipt_id) = read_string(ticket.get("id")) {
1051
+ pending_receipts.push((receipt_id, token));
1052
+ }
1053
+ }
1054
+ Some("error") => {
1055
+ let error_kind = ticket
1056
+ .get("details")
1057
+ .and_then(|details| read_string(details.get("error")));
1058
+ if error_kind.as_deref() == Some("DeviceNotRegistered") {
1059
+ stale.push(token);
1060
+ }
1061
+ }
1062
+ _ => {}
1063
+ }
1064
+ }
1065
+ for token in stale {
1066
+ self.unregister(&token).await;
1067
+ }
1068
+ if !pending_receipts.is_empty() {
1069
+ self.spawn_receipt_check(pending_receipts);
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ /// POST JSON to Expo, retrying on 429 / 5xx / transport errors with
1075
+ /// exponential backoff (honoring Retry-After). Returns the parsed body, or
1076
+ /// None once attempts are exhausted.
1077
+ async fn post_with_retry(&self, url: &str, body: &Value) -> Option<Value> {
1078
+ let mut delay_ms: u64 = 500;
1079
+ for attempt in 1..=PUSH_SEND_MAX_ATTEMPTS {
1080
+ let mut request = self.http.post(url).json(body);
1081
+ if let Some(token) = &self.access_token {
1082
+ request = request.bearer_auth(token);
1083
+ }
1084
+ match request.send().await {
1085
+ Ok(response) => {
1086
+ let status = response.status();
1087
+ if status.as_u16() == 429 || status.is_server_error() {
1088
+ if attempt >= PUSH_SEND_MAX_ATTEMPTS {
1089
+ eprintln!(
1090
+ "push request to {url} gave up after {attempt} attempts (status {status})"
1091
+ );
1092
+ return None;
1093
+ }
1094
+ let wait_ms = response
1095
+ .headers()
1096
+ .get("retry-after")
1097
+ .and_then(|value| value.to_str().ok())
1098
+ .and_then(|value| value.parse::<u64>().ok())
1099
+ .map(|secs| secs.saturating_mul(1000))
1100
+ .unwrap_or(delay_ms);
1101
+ tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
1102
+ delay_ms = (delay_ms * 2).min(8000);
1103
+ continue;
1104
+ }
1105
+ match response.json::<Value>().await {
1106
+ Ok(value) => return Some(value),
1107
+ Err(error) => {
1108
+ eprintln!("push response parse failed: {error}");
1109
+ return None;
1110
+ }
1111
+ }
1112
+ }
1113
+ Err(error) => {
1114
+ if attempt >= PUSH_SEND_MAX_ATTEMPTS {
1115
+ eprintln!("push request to {url} failed after {attempt} attempts: {error}");
1116
+ return None;
1117
+ }
1118
+ tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
1119
+ delay_ms = (delay_ms * 2).min(8000);
1120
+ }
1121
+ }
1122
+ }
1123
+ None
1124
+ }
1125
+
1126
+ /// After Expo's recommended delay, fetch delivery receipts for the given
1127
+ /// (receiptId, token) pairs and prune tokens reported DeviceNotRegistered.
1128
+ fn spawn_receipt_check(self: &Arc<Self>, receipts: Vec<(String, String)>) {
1129
+ let this = Arc::clone(self);
1130
+ tokio::spawn(async move {
1131
+ tokio::time::sleep(std::time::Duration::from_secs(RECEIPT_CHECK_DELAY_SECS)).await;
1132
+ this.check_receipts(receipts).await;
1133
+ });
1134
+ }
1135
+
1136
+ async fn check_receipts(&self, receipts: Vec<(String, String)>) {
1137
+ for chunk in receipts.chunks(EXPO_RECEIPT_BATCH_SIZE) {
1138
+ let ids: Vec<&str> = chunk.iter().map(|(id, _)| id.as_str()).collect();
1139
+ let Some(payload) = self
1140
+ .post_with_retry(EXPO_PUSH_RECEIPTS_ENDPOINT, &json!({ "ids": ids }))
1141
+ .await
1142
+ else {
1143
+ continue;
1144
+ };
1145
+ let Some(map) = payload.get("data").and_then(Value::as_object) else {
1146
+ continue;
1147
+ };
1148
+ let mut stale: Vec<String> = Vec::new();
1149
+ for (receipt_id, receipt) in map {
1150
+ if read_string(receipt.get("status")).as_deref() != Some("error") {
1151
+ continue;
1152
+ }
1153
+ let error_kind = receipt
1154
+ .get("details")
1155
+ .and_then(|details| read_string(details.get("error")));
1156
+ if error_kind.as_deref() == Some("DeviceNotRegistered") {
1157
+ if let Some((_, token)) = chunk.iter().find(|(id, _)| id == receipt_id) {
1158
+ stale.push(token.clone());
1159
+ }
1160
+ }
1161
+ }
1162
+ for token in stale {
1163
+ self.unregister(&token).await;
1164
+ }
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ #[derive(Clone, Copy)]
1170
+ enum PushEvent {
1171
+ TurnCompleted,
1172
+ ApprovalRequested,
1173
+ }
1174
+
1175
+ impl PushEvent {
1176
+ fn as_str(self) -> &'static str {
1177
+ match self {
1178
+ PushEvent::TurnCompleted => "turn_completed",
1179
+ PushEvent::ApprovalRequested => "approval_requested",
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ /// Truncate to at most `max_chars` characters (char-safe), appending an ellipsis
1185
+ /// when content was dropped.
1186
+ fn truncate_chars(text: &str, max_chars: usize) -> String {
1187
+ if text.chars().count() <= max_chars {
1188
+ return text.to_string();
1189
+ }
1190
+ let truncated: String = text.chars().take(max_chars.saturating_sub(1)).collect();
1191
+ format!("{}…", truncated.trim_end())
1192
+ }
1193
+
1194
+ fn token_suffix(token: &str) -> String {
1195
+ let visible: String = token.chars().rev().take(6).collect::<String>();
1196
+ visible.chars().rev().collect()
1197
+ }
1198
+
1199
+ fn parse_push_event_preferences(value: Option<&Value>) -> PushEventPreferences {
1200
+ let defaults = PushEventPreferences::default();
1201
+ match value {
1202
+ Some(object) => PushEventPreferences {
1203
+ turn_completed: read_bool(object.get("turnCompleted"))
1204
+ .unwrap_or(defaults.turn_completed),
1205
+ approval_requested: read_bool(object.get("approvalRequested"))
1206
+ .unwrap_or(defaults.approval_requested),
1207
+ },
1208
+ None => defaults,
1209
+ }
1210
+ }
1211
+
666
1212
  #[derive(Clone)]
667
1213
  struct AppState {
668
1214
  config: Arc<BridgeConfig>,
@@ -675,6 +1221,7 @@ struct AppState {
675
1221
  git: Arc<GitService>,
676
1222
  updater: Arc<UpdateService>,
677
1223
  preview: Arc<BrowserPreviewService>,
1224
+ push: Arc<PushService>,
678
1225
  }
679
1226
 
680
1227
  #[allow(dead_code)]
@@ -6880,6 +7427,15 @@ async fn main() {
6880
7427
  ));
6881
7428
  let queue = BridgeQueueService::new(backend.clone(), hub.clone());
6882
7429
 
7430
+ let project_label = config
7431
+ .workdir
7432
+ .file_name()
7433
+ .map(|name| name.to_string_lossy().to_string())
7434
+ .filter(|name| !name.is_empty())
7435
+ .unwrap_or_else(|| "Clawdex".to_string());
7436
+ let push = PushService::load(&config.workdir, project_label).await;
7437
+ push.spawn_event_loop(&hub);
7438
+
6883
7439
  let state = Arc::new(AppState {
6884
7440
  config: config.clone(),
6885
7441
  started_at: Instant::now(),
@@ -6891,6 +7447,7 @@ async fn main() {
6891
7447
  git,
6892
7448
  updater,
6893
7449
  preview,
7450
+ push,
6894
7451
  });
6895
7452
 
6896
7453
  let app = Router::new()
@@ -7776,6 +8333,36 @@ async fn handle_bridge_method(
7776
8333
  .map_err(|error| BridgeError::server(&error.to_string())),
7777
8334
  "bridge/runtime/read" => serde_json::to_value(state.updater.runtime_info().await)
7778
8335
  .map_err(|error| BridgeError::server(&error.to_string())),
8336
+ "bridge/push/register" => {
8337
+ let params = params.unwrap_or_else(|| json!({}));
8338
+ let token = read_string(params.get("token"))
8339
+ .map(|value| value.trim().to_string())
8340
+ .filter(|value| !value.is_empty())
8341
+ .ok_or_else(|| BridgeError::invalid_params("push token is required"))?;
8342
+ let platform = read_string(params.get("platform"))
8343
+ .map(|value| value.trim().to_lowercase())
8344
+ .unwrap_or_else(|| "unknown".to_string());
8345
+ let device_name = read_string(params.get("deviceName"))
8346
+ .map(|value| value.trim().to_string())
8347
+ .filter(|value| !value.is_empty())
8348
+ .unwrap_or_else(|| "Unknown device".to_string());
8349
+ let events = parse_push_event_preferences(params.get("events"));
8350
+ let count = state
8351
+ .push
8352
+ .register(token, platform, device_name, events)
8353
+ .await;
8354
+ Ok(json!({ "ok": true, "deviceCount": count }))
8355
+ }
8356
+ "bridge/push/unregister" => {
8357
+ let params = params.unwrap_or_else(|| json!({}));
8358
+ let token = read_string(params.get("token"))
8359
+ .map(|value| value.trim().to_string())
8360
+ .filter(|value| !value.is_empty())
8361
+ .ok_or_else(|| BridgeError::invalid_params("push token is required"))?;
8362
+ let removed = state.push.unregister(&token).await;
8363
+ Ok(json!({ "ok": true, "removed": removed }))
8364
+ }
8365
+ "bridge/push/list" => Ok(json!({ "devices": state.push.list().await })),
7779
8366
  "bridge/cursor/credentials/read" => {
7780
8367
  let status = read_cursor_credential_status(state).await?;
7781
8368
  serde_json::to_value(status).map_err(|error| BridgeError::server(&error.to_string()))
@@ -14012,6 +14599,150 @@ fn normalize_path(path: &Path) -> PathBuf {
14012
14599
  mod tests {
14013
14600
  use super::*;
14014
14601
 
14602
+ #[test]
14603
+ fn token_suffix_masks_all_but_last_six_chars() {
14604
+ assert_eq!(token_suffix("ExponentPushToken[abcdef123456]"), "23456]");
14605
+ assert_eq!(token_suffix("abc"), "abc");
14606
+ assert_eq!(token_suffix(""), "");
14607
+ }
14608
+
14609
+ #[test]
14610
+ fn truncate_chars_caps_and_ellipsizes() {
14611
+ assert_eq!(truncate_chars("short", 140), "short");
14612
+ let long = "a".repeat(200);
14613
+ let out = truncate_chars(&long, 140);
14614
+ assert_eq!(out.chars().count(), 140); // 139 chars + ellipsis
14615
+ assert!(out.ends_with('…'));
14616
+ // Char-safe: must not split a multi-byte char mid-way.
14617
+ let emoji = "🚀".repeat(10);
14618
+ let out = truncate_chars(&emoji, 4);
14619
+ assert_eq!(out.chars().count(), 4);
14620
+ }
14621
+
14622
+ #[tokio::test]
14623
+ async fn take_reply_preview_uses_last_nonempty_line() {
14624
+ let dir = std::env::temp_dir().join(format!("clawdex-preview-{}", std::process::id()));
14625
+ let _ = tokio::fs::create_dir_all(&dir).await;
14626
+ let service = PushService::load(&dir, "demo".to_string()).await;
14627
+ service
14628
+ .accumulate_reply(
14629
+ "item/agentMessage/delta",
14630
+ &json!({ "threadId": "t1", "field": "text", "delta": "Working on it\n Done: fixed the bug \n\n" }),
14631
+ )
14632
+ .await;
14633
+ let preview = service.take_reply_preview("t1").await;
14634
+ assert_eq!(preview.as_deref(), Some("Done: fixed the bug"));
14635
+ // Buffer is consumed.
14636
+ assert!(service.take_reply_preview("t1").await.is_none());
14637
+ let _ = tokio::fs::remove_dir_all(&dir).await;
14638
+ }
14639
+
14640
+ #[tokio::test]
14641
+ async fn turn_completed_drains_reply_buffer_with_no_devices() {
14642
+ let dir = std::env::temp_dir().join(format!("clawdex-drain-{}", std::process::id()));
14643
+ let _ = tokio::fs::create_dir_all(&dir).await;
14644
+ let service = PushService::load(&dir, "demo".to_string()).await;
14645
+ // Stream a reply with no devices registered.
14646
+ service
14647
+ .accumulate_reply(
14648
+ "item/agentMessage/delta",
14649
+ &json!({ "threadId": "t1", "field": "text", "delta": "All done" }),
14650
+ )
14651
+ .await;
14652
+ // Completion with an empty registry must still drain the buffer, not leak it.
14653
+ service
14654
+ .handle_notification("turn/completed", &json!({ "threadId": "t1" }))
14655
+ .await;
14656
+ assert!(service.take_reply_preview("t1").await.is_none());
14657
+ let _ = tokio::fs::remove_dir_all(&dir).await;
14658
+ }
14659
+
14660
+ #[test]
14661
+ fn parse_push_event_preferences_defaults_to_enabled() {
14662
+ let defaults = parse_push_event_preferences(None);
14663
+ assert!(defaults.turn_completed);
14664
+ assert!(defaults.approval_requested);
14665
+
14666
+ let partial = parse_push_event_preferences(Some(&json!({ "approvalRequested": false })));
14667
+ assert!(partial.turn_completed);
14668
+ assert!(!partial.approval_requested);
14669
+ }
14670
+
14671
+ #[test]
14672
+ fn push_registry_round_trips_and_tolerates_missing_fields() {
14673
+ let raw = json!({
14674
+ "devices": [
14675
+ {
14676
+ "token": "ExponentPushToken[one]",
14677
+ "platform": "ios",
14678
+ "deviceName": "iPhone",
14679
+ "events": { "turnCompleted": true, "approvalRequested": false },
14680
+ "createdAt": "2026-05-29T00:00:00Z",
14681
+ "updatedAt": "2026-05-29T00:00:00Z"
14682
+ },
14683
+ {
14684
+ "token": "ExponentPushToken[two]",
14685
+ "createdAt": "2026-05-29T00:00:00Z",
14686
+ "updatedAt": "2026-05-29T00:00:00Z"
14687
+ }
14688
+ ]
14689
+ });
14690
+ let registry: PushRegistry = serde_json::from_value(raw).expect("parse registry");
14691
+ assert_eq!(registry.devices.len(), 2);
14692
+ // Missing event prefs fall back to enabled.
14693
+ assert!(registry.devices[1].events.turn_completed);
14694
+ assert!(registry.devices[1].events.approval_requested);
14695
+
14696
+ let serialized = serde_json::to_string(&registry).expect("serialize");
14697
+ let reparsed: PushRegistry = serde_json::from_str(&serialized).expect("reparse");
14698
+ assert_eq!(reparsed.devices[0].token, "ExponentPushToken[one]");
14699
+ assert!(!reparsed.devices[0].events.approval_requested);
14700
+ }
14701
+
14702
+ #[tokio::test]
14703
+ async fn push_service_registers_dedupes_and_unregisters() {
14704
+ let dir = std::env::temp_dir().join(format!("clawdex-push-test-{}", std::process::id()));
14705
+ let _ = tokio::fs::create_dir_all(&dir).await;
14706
+ let service = PushService::load(&dir, "demo".to_string()).await;
14707
+
14708
+ let prefs = PushEventPreferences::default();
14709
+ let count = service
14710
+ .register(
14711
+ "ExponentPushToken[a]".to_string(),
14712
+ "ios".to_string(),
14713
+ "Phone".to_string(),
14714
+ prefs.clone(),
14715
+ )
14716
+ .await;
14717
+ assert_eq!(count, 1);
14718
+
14719
+ // Re-registering the same token updates in place rather than duplicating.
14720
+ let count = service
14721
+ .register(
14722
+ "ExponentPushToken[a]".to_string(),
14723
+ "ios".to_string(),
14724
+ "Phone Renamed".to_string(),
14725
+ prefs,
14726
+ )
14727
+ .await;
14728
+ assert_eq!(count, 1);
14729
+
14730
+ let listed = service.list().await;
14731
+ assert_eq!(listed.len(), 1);
14732
+ assert_eq!(
14733
+ listed[0].get("deviceName").and_then(Value::as_str),
14734
+ Some("Phone Renamed")
14735
+ );
14736
+ // Full tokens are never echoed back.
14737
+ assert!(listed[0].get("token").is_none());
14738
+
14739
+ assert!(service.unregister("ExponentPushToken[a]").await);
14740
+ assert!(!service.unregister("ExponentPushToken[a]").await);
14741
+ assert!(service.list().await.is_empty());
14742
+
14743
+ let _ = tokio::fs::remove_dir_all(&dir).await;
14744
+ }
14745
+
14015
14746
  fn bridge_chatgpt_auth_test_lock() -> &'static std::sync::Mutex<()> {
14016
14747
  static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
14017
14748
  LOCK.get_or_init(|| std::sync::Mutex::new(()))
@@ -14208,6 +14939,7 @@ mod tests {
14208
14939
  config.preview_connect_url.clone(),
14209
14940
  ));
14210
14941
  let queue = BridgeQueueService::new(backend.clone(), hub.clone());
14942
+ let push = PushService::load(&config.workdir, "Clawdex".to_string()).await;
14211
14943
 
14212
14944
  Arc::new(AppState {
14213
14945
  config,
@@ -14220,6 +14952,7 @@ mod tests {
14220
14952
  git,
14221
14953
  updater,
14222
14954
  preview,
14955
+ push,
14223
14956
  })
14224
14957
  }
14225
14958