@agent-native/core 0.172.1 → 0.172.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.
@@ -116,6 +116,9 @@ export function ReadinessPanel({
116
116
 
117
117
  const [statuses, setStatuses] = useState<PermissionStatuses | null>(null);
118
118
  const [checking, setChecking] = useState(false);
119
+ const [refreshingPane, setRefreshingPane] = useState<MacosPrivacyPane | null>(
120
+ null,
121
+ );
119
122
 
120
123
  const checkStatuses = useCallback(async () => {
121
124
  setChecking(true);
@@ -126,6 +129,18 @@ export function ReadinessPanel({
126
129
  }
127
130
  }, []);
128
131
 
132
+ const refreshPermissions = useCallback(
133
+ (pane: MacosPrivacyPane) => {
134
+ setRefreshingPane(null);
135
+ window.requestAnimationFrame(() => setRefreshingPane(pane));
136
+ window.setTimeout(() => {
137
+ setRefreshingPane((current) => (current === pane ? null : current));
138
+ }, 450);
139
+ void checkStatuses();
140
+ },
141
+ [checkStatuses],
142
+ );
143
+
129
144
  const grantPermission = useCallback(
130
145
  (pane: MacosPrivacyPane) =>
131
146
  requestOrOpenPermission(pane, {
@@ -172,8 +187,8 @@ export function ReadinessPanel({
172
187
  {mac ? (
173
188
  <button
174
189
  type="button"
175
- className={`readiness-refresh ${checking ? "readiness-refresh-spinning" : ""}`}
176
- onClick={checkStatuses}
190
+ className={`readiness-refresh ${refreshingPane === item.pane ? "readiness-refresh-rotating" : ""}`}
191
+ onClick={() => refreshPermissions(item.pane)}
177
192
  disabled={checking}
178
193
  aria-label="Recheck permissions"
179
194
  title="Recheck"
@@ -306,8 +306,27 @@ export function useMeetingTranscription({
306
306
  capturedUntil: string;
307
307
  } | null;
308
308
  } = { current: null };
309
+ let includeFromMeetingStart = payload.includeFromMeetingStart === true;
309
310
  try {
310
- if (payload.includeFromMeetingStart) {
311
+ if (
312
+ !includeFromMeetingStart &&
313
+ payload.reason === "user" &&
314
+ payload.scheduledStart
315
+ ) {
316
+ try {
317
+ const historyStatus = await invoke<{ available: boolean }>(
318
+ "rewind_meeting_history_status",
319
+ { scheduledStart: payload.scheduledStart },
320
+ );
321
+ includeFromMeetingStart = historyStatus.available === true;
322
+ } catch (error) {
323
+ console.warn(
324
+ "[clips-popover] Rewind meeting history status unavailable:",
325
+ error,
326
+ );
327
+ }
328
+ }
329
+ if (includeFromMeetingStart) {
311
330
  if (payload.reason !== "user" || !payload.scheduledStart) {
312
331
  throw new Error(
313
332
  "Include from meeting start is only available when you manually start a scheduled meeting.",
@@ -614,7 +633,7 @@ export function useMeetingTranscription({
614
633
  // The local index may need a moment after the fragment fence. Anchor
615
634
  // the live engine where it actually begins, not at the earlier click,
616
635
  // so every stored segment remains on one honest meeting timeline.
617
- if (payload.includeFromMeetingStart && payload.scheduledStart) {
636
+ if (includeFromMeetingStart && payload.scheduledStart) {
618
637
  session.liveTimelineOffsetMs = Math.max(
619
638
  0,
620
639
  Date.now() - Date.parse(payload.scheduledStart),
@@ -350,6 +350,7 @@ export function MeetingNotification() {
350
350
  meetingId: data.meetingId,
351
351
  joinUrl: data.joinUrl,
352
352
  reason: "user",
353
+ scheduledStart: data.scheduledStart,
353
354
  }).catch((err) => {
354
355
  setPending(false);
355
356
  setError((err as Error)?.message ?? "Could not start notes.");
@@ -1131,9 +1131,17 @@ body[data-clips-route="recording-pill"] #root {
1131
1131
  cursor: pointer;
1132
1132
  border-radius: var(--radius-sm);
1133
1133
  transition:
1134
+ opacity 120ms,
1134
1135
  color 120ms,
1135
1136
  background 120ms;
1136
1137
  flex-shrink: 0;
1138
+ opacity: 0;
1139
+ }
1140
+
1141
+ .readiness-item:hover .readiness-refresh,
1142
+ .readiness-item:focus-within .readiness-refresh,
1143
+ .readiness-item .readiness-refresh-rotating {
1144
+ opacity: 1;
1137
1145
  }
1138
1146
 
1139
1147
  .readiness-refresh:hover {
@@ -1146,8 +1154,8 @@ body[data-clips-route="recording-pill"] #root {
1146
1154
  cursor: default;
1147
1155
  }
1148
1156
 
1149
- .readiness-refresh-spinning svg {
1150
- animation: readiness-spin 0.8s linear infinite;
1157
+ .readiness-refresh-rotating svg {
1158
+ animation: readiness-spin 420ms ease-out;
1151
1159
  }
1152
1160
 
1153
1161
  @keyframes readiness-spin {
@@ -1156,6 +1164,12 @@ body[data-clips-route="recording-pill"] #root {
1156
1164
  }
1157
1165
  }
1158
1166
 
1167
+ @media (prefers-reduced-motion: reduce) {
1168
+ .readiness-refresh-rotating svg {
1169
+ animation: none;
1170
+ }
1171
+ }
1172
+
1159
1173
  .readiness-empty {
1160
1174
  padding: 8px;
1161
1175
  color: var(--fg-muted);
@@ -1620,7 +1634,7 @@ body[data-clips-route="recording-pill"] #root {
1620
1634
  flex-direction: column;
1621
1635
  gap: 6px;
1622
1636
  margin-bottom: 4px;
1623
- max-height: 280px;
1637
+ max-height: 420px;
1624
1638
  overflow-y: auto;
1625
1639
  overflow-x: hidden;
1626
1640
  overscroll-behavior: contain;
@@ -17,13 +17,16 @@ use tauri::{AppHandle, Emitter, Manager};
17
17
 
18
18
  use crate::config::{feature_config, MeetingTranscriptionMode};
19
19
  use crate::dlog;
20
- use crate::meetings_watcher::MeetingsWatcherState;
20
+ use crate::meetings_watcher::{
21
+ find_matching_calendar_meeting, parse_meetings, MeetingItem, MeetingsWatcherState,
22
+ CALENDAR_MATCH_WINDOW_MINUTES,
23
+ };
21
24
 
22
25
  /// How often to sample the frontmost app.
23
- const POLL_SECS: u64 = 4;
26
+ const POLL_SECS: u64 = 2;
24
27
 
25
28
  /// Require this many consecutive frontmost seconds before firing.
26
- const DWELL_SECS: u64 = 9;
29
+ const DWELL_SECS: u64 = 3;
27
30
 
28
31
  /// After dismiss / fire, suppress re-prompts for this platform until the
29
32
  /// cooldown elapses (or the VC app leaves front — see tick logic).
@@ -202,7 +205,7 @@ async fn tick_macos(
202
205
  let front = crate::util::frontmost_bundle_id();
203
206
  let matched = front.as_deref().and_then(match_vc_bundle);
204
207
 
205
- let Some((platform, title)) = matched else {
208
+ let Some((platform, fallback_title)) = matched else {
206
209
  // VC app left front — clear session dedupe so a later re-focus can
207
210
  // fire again (cooldown still applies).
208
211
  clear_session_if_left(app, None);
@@ -294,8 +297,8 @@ async fn tick_macos(
294
297
  platform
295
298
  );
296
299
 
297
- let meeting_id = match create_adhoc_meeting(app, client, platform).await {
298
- Ok(id) => id,
300
+ let meeting = match create_adhoc_meeting(app, client, platform).await {
301
+ Ok(meeting) => meeting,
299
302
  Err(err) => {
300
303
  // Allow retry on next dwell if create failed.
301
304
  if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
@@ -315,22 +318,34 @@ async fn tick_macos(
315
318
 
316
319
  if show_widget {
317
320
  let app_clone = app.clone();
318
- let id_clone = meeting_id.clone();
319
- let title_clone = title.to_string();
320
- let platform_clone = platform.to_string();
321
- let scheduled_start = chrono::Utc::now().to_rfc3339();
321
+ let id_clone = meeting.id.clone();
322
+ let title_clone = meeting
323
+ .title
324
+ .clone()
325
+ .unwrap_or_else(|| fallback_title.to_string());
326
+ let platform_clone = meeting
327
+ .platform
328
+ .clone()
329
+ .unwrap_or_else(|| platform.to_string());
330
+ let scheduled_start = meeting
331
+ .scheduled_start
332
+ .clone()
333
+ .or_else(|| Some(chrono::Utc::now().to_rfc3339()));
334
+ let scheduled_end = meeting.scheduled_end.clone();
335
+ let join_url = meeting.join_url.clone();
336
+ let source = meeting.source.clone().or_else(|| Some("adhoc".to_string()));
322
337
  tauri::async_runtime::spawn(async move {
323
338
  let _ = crate::notifications::notify_meeting_starting(
324
339
  app_clone,
325
340
  id_clone,
326
341
  title_clone,
327
342
  0,
328
- None,
329
- Some(scheduled_start),
330
- None,
343
+ join_url,
344
+ scheduled_start,
345
+ scheduled_end,
331
346
  Some(platform_clone),
332
347
  Some(auto_start),
333
- Some("adhoc".to_string()),
348
+ source,
334
349
  )
335
350
  .await;
336
351
  });
@@ -340,9 +355,10 @@ async fn tick_macos(
340
355
  let _ = app.emit(
341
356
  "meetings:start-transcription",
342
357
  serde_json::json!({
343
- "meetingId": meeting_id,
344
- "joinUrl": null,
358
+ "meetingId": meeting.id,
359
+ "joinUrl": meeting.join_url,
345
360
  "reason": "adhoc-auto",
361
+ "scheduledStart": meeting.scheduled_start,
346
362
  }),
347
363
  );
348
364
  }
@@ -472,26 +488,49 @@ async fn create_adhoc_meeting(
472
488
  app: &AppHandle,
473
489
  client: &reqwest::Client,
474
490
  platform: &str,
475
- ) -> Result<String, String> {
491
+ ) -> Result<MeetingItem, String> {
476
492
  let session = app
477
493
  .try_state::<MeetingsWatcherState>()
478
494
  .map(|s| s.session_snapshot())
479
495
  .unwrap_or_default();
480
- let Some(server_url) = session.server_url else {
496
+ let Some(server_url) = session.server_url.as_deref() else {
481
497
  return Err("no server_url for create-meeting".to_string());
482
498
  };
483
499
 
500
+ // The native watcher knows which conferencing app is active, but not the
501
+ // calendar event title. Resolve the nearest joinable event first so a
502
+ // calendar-backed meeting keeps its title, URL, and scheduled span. A
503
+ // disconnected calendar only loses that enrichment and still gets an
504
+ // adhoc meeting below.
505
+ match find_calendar_meeting(app, client, server_url, &session, platform).await {
506
+ Ok(Some(meeting)) => {
507
+ dlog!(
508
+ "[clips-tray] adhoc matched calendar meeting {} for {}",
509
+ meeting.id,
510
+ platform
511
+ );
512
+ return Ok(meeting);
513
+ }
514
+ Ok(None) => {}
515
+ Err(error) => dlog!(
516
+ "[clips-tray] adhoc calendar title lookup skipped for {}: {}",
517
+ platform,
518
+ error
519
+ ),
520
+ }
521
+
484
522
  let url = format!("{}/_agent-native/actions/create-meeting", server_url);
485
523
  let row_title = if platform == "zoom" {
486
524
  "Zoom meeting"
487
525
  } else {
488
526
  "Teams meeting"
489
527
  };
528
+ let scheduled_start = chrono::Utc::now().to_rfc3339();
490
529
  let body = serde_json::json!({
491
530
  "title": row_title,
492
531
  "platform": platform,
493
532
  "source": "adhoc",
494
- "scheduledStart": chrono::Utc::now().to_rfc3339(),
533
+ "scheduledStart": scheduled_start,
495
534
  });
496
535
 
497
536
  let mut req = client
@@ -524,14 +563,69 @@ async fn create_adhoc_meeting(
524
563
  ));
525
564
  }
526
565
  let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
527
- extract_meeting_id(&body).ok_or_else(|| {
566
+ let id = extract_meeting_id(&body).ok_or_else(|| {
528
567
  format!(
529
568
  "create-meeting response missing meeting id: {}",
530
569
  body.to_string().chars().take(200).collect::<String>()
531
570
  )
571
+ })?;
572
+ Ok(MeetingItem {
573
+ id,
574
+ title: Some(row_title.to_string()),
575
+ scheduled_start: Some(scheduled_start),
576
+ scheduled_end: None,
577
+ join_url: None,
578
+ platform: Some(platform.to_string()),
579
+ source: Some("adhoc".to_string()),
532
580
  })
533
581
  }
534
582
 
583
+ async fn find_calendar_meeting(
584
+ app: &AppHandle,
585
+ client: &reqwest::Client,
586
+ server_url: &str,
587
+ session: &crate::meetings_watcher::MeetingsSessionSnapshot,
588
+ platform: &str,
589
+ ) -> Result<Option<MeetingItem>, String> {
590
+ let url = format!("{server_url}/_agent-native/actions/list-meetings");
591
+ let upcoming_within_min = CALENDAR_MATCH_WINDOW_MINUTES.to_string();
592
+ let mut req = client.get(url).query(&[
593
+ ("view", "upcoming"),
594
+ ("limit", "20"),
595
+ ("upcomingWithinMin", upcoming_within_min.as_str()),
596
+ ("includeStartedWithinMin", "15"),
597
+ ("excludePersonalSoloEvents", "true"),
598
+ ("excludeDeclinedEvents", "true"),
599
+ ]);
600
+ req = req.header("X-Request-Source", "clips-desktop");
601
+ if let Some(cookie) = session.session_cookie.as_deref() {
602
+ req = req.header("Cookie", cookie);
603
+ }
604
+ if let Some(token) = session.auth_token.as_deref() {
605
+ req = req.bearer_auth(token);
606
+ }
607
+ let response = req
608
+ .send()
609
+ .await
610
+ .map_err(|error| format!("list-meetings fetch: {error}"))?;
611
+ if response.status() == reqwest::StatusCode::UNAUTHORIZED {
612
+ let _ = app.emit("meetings:auth-needed", serde_json::json!({}));
613
+ return Err("list-meetings http 401".to_string());
614
+ }
615
+ if !response.status().is_success() {
616
+ return Err(format!("list-meetings http {}", response.status()));
617
+ }
618
+ let body: serde_json::Value = response
619
+ .json()
620
+ .await
621
+ .map_err(|error| format!("list-meetings response: {error}"))?;
622
+ Ok(find_matching_calendar_meeting(
623
+ &parse_meetings(&body),
624
+ platform,
625
+ chrono::Utc::now(),
626
+ ))
627
+ }
628
+
535
629
  fn extract_meeting_id(body: &serde_json::Value) -> Option<String> {
536
630
  // Framework wraps action returns as `{ result: { meeting, created } }`.
537
631
  let meeting = body
@@ -1,6 +1,6 @@
1
1
  //! Background poller for upcoming meetings.
2
2
  //!
3
- //! Runs as a tokio task spawned from `lib.rs::run` setup. Every 30s it calls
3
+ //! Runs as a tokio task spawned from `lib.rs::run` setup. Every 10s it calls
4
4
  //! the backend's `list-meetings` action for the next handful of live Google
5
5
  //! Calendar meetings. For any meeting in the Granola-style reminder window
6
6
  //! (1 minute before start through 5 minutes after) that we haven't already
@@ -134,21 +134,24 @@ impl MeetingsWatcherState {
134
134
  }
135
135
 
136
136
  #[derive(Debug, Clone, Deserialize, Serialize)]
137
- struct MeetingItem {
138
- id: String,
139
- title: Option<String>,
137
+ pub(crate) struct MeetingItem {
138
+ pub(crate) id: String,
139
+ pub(crate) title: Option<String>,
140
140
  #[serde(default, alias = "scheduledStart")]
141
- scheduled_start: Option<String>,
141
+ pub(crate) scheduled_start: Option<String>,
142
142
  #[serde(default, alias = "scheduledEnd")]
143
- scheduled_end: Option<String>,
143
+ pub(crate) scheduled_end: Option<String>,
144
144
  #[serde(default, alias = "joinUrl")]
145
- join_url: Option<String>,
145
+ pub(crate) join_url: Option<String>,
146
146
  #[serde(default)]
147
- platform: Option<String>,
147
+ pub(crate) platform: Option<String>,
148
148
  #[serde(default)]
149
- source: Option<String>,
149
+ pub(crate) source: Option<String>,
150
150
  }
151
151
 
152
+ pub(crate) const CALENDAR_MATCH_WINDOW_MINUTES: i64 = 15;
153
+ const CALENDAR_MATCH_AMBIGUITY_MARGIN_SECS: i64 = 60;
154
+
152
155
  #[derive(Debug, Deserialize)]
153
156
  struct ListMeetingsResponse {
154
157
  #[serde(default)]
@@ -245,7 +248,7 @@ pub fn spawn_watcher(app: AppHandle) {
245
248
  }
246
249
 
247
250
  async fn run_watcher(app: AppHandle) {
248
- let mut interval = tokio::time::interval(Duration::from_secs(30));
251
+ let mut interval = tokio::time::interval(Duration::from_secs(10));
249
252
  // Skip the first tick — gives the frontend time to push us a server URL.
250
253
  interval.tick().await;
251
254
  let client = match reqwest::Client::builder()
@@ -316,7 +319,7 @@ async fn tick_once(app: &AppHandle, client: &reqwest::Client) -> Result<(), Stri
316
319
  let status = resp.status();
317
320
  if status == reqwest::StatusCode::UNAUTHORIZED {
318
321
  // Tell the renderer to re-push a fresh cookie or surface a
319
- // re-login prompt. We keep silently retrying every 30s — once
322
+ // re-login prompt. We keep silently retrying every 10s - once
320
323
  // the renderer pushes a new cookie via
321
324
  // `meetings_watcher_set_session` we'll succeed on the next tick.
322
325
  let _ = app.emit("meetings:auth-needed", serde_json::json!({}));
@@ -454,8 +457,47 @@ fn is_calendar_reminder_candidate(meeting: &MeetingItem) -> bool {
454
457
  meeting.source.as_deref() != Some("adhoc")
455
458
  }
456
459
 
457
- fn parse_meetings(body: &serde_json::Value) -> Vec<MeetingItem> {
458
- if let Ok(parsed) = serde_json::from_value::<ListMeetingsResponse>(body.clone()) {
460
+ pub(crate) fn find_matching_calendar_meeting(
461
+ meetings: &[MeetingItem],
462
+ platform: &str,
463
+ started_at: chrono::DateTime<chrono::Utc>,
464
+ ) -> Option<MeetingItem> {
465
+ let mut candidates: Vec<_> = meetings
466
+ .iter()
467
+ .filter_map(|meeting| {
468
+ if !is_calendar_reminder_candidate(meeting)
469
+ || meeting
470
+ .platform
471
+ .as_deref()
472
+ .is_none_or(|value| !value.eq_ignore_ascii_case(platform))
473
+ || meeting.join_url.as_deref().is_none_or(str::is_empty)
474
+ {
475
+ return None;
476
+ }
477
+ let scheduled_start = meeting
478
+ .scheduled_start
479
+ .as_deref()
480
+ .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())?
481
+ .with_timezone(&chrono::Utc);
482
+ let distance = (scheduled_start - started_at).num_seconds().abs();
483
+ (distance <= CALENDAR_MATCH_WINDOW_MINUTES * 60).then(|| (distance, meeting))
484
+ })
485
+ .collect();
486
+ candidates.sort_by_key(|(distance, _)| *distance);
487
+ let Some((distance, meeting)) = candidates.first() else {
488
+ return None;
489
+ };
490
+ if candidates.get(1).is_some_and(|(next_distance, _)| {
491
+ next_distance - distance <= CALENDAR_MATCH_AMBIGUITY_MARGIN_SECS
492
+ }) {
493
+ return None;
494
+ }
495
+ Some((*meeting).clone())
496
+ }
497
+
498
+ pub(crate) fn parse_meetings(body: &serde_json::Value) -> Vec<MeetingItem> {
499
+ let payload = body.get("result").unwrap_or(body);
500
+ if let Ok(parsed) = serde_json::from_value::<ListMeetingsResponse>(payload.clone()) {
459
501
  if let Some(v) = parsed.upcoming {
460
502
  return v;
461
503
  }
@@ -466,7 +508,7 @@ fn parse_meetings(body: &serde_json::Value) -> Vec<MeetingItem> {
466
508
  return v;
467
509
  }
468
510
  }
469
- if let Ok(arr) = serde_json::from_value::<Vec<MeetingItem>>(body.clone()) {
511
+ if let Ok(arr) = serde_json::from_value::<Vec<MeetingItem>>(payload.clone()) {
470
512
  return arr;
471
513
  }
472
514
  Vec::new()
@@ -474,7 +516,9 @@ fn parse_meetings(body: &serde_json::Value) -> Vec<MeetingItem> {
474
516
 
475
517
  #[cfg(test)]
476
518
  mod tests {
477
- use super::{is_calendar_reminder_candidate, parse_meetings};
519
+ use chrono::{TimeZone, Utc};
520
+
521
+ use super::{find_matching_calendar_meeting, is_calendar_reminder_candidate, parse_meetings};
478
522
 
479
523
  #[test]
480
524
  fn excludes_adhoc_meetings_from_calendar_reminders() {
@@ -512,4 +556,75 @@ mod tests {
512
556
  assert_eq!(meetings.len(), 1);
513
557
  assert!(is_calendar_reminder_candidate(&meetings[0]));
514
558
  }
559
+
560
+ #[test]
561
+ fn finds_the_nearest_joinable_calendar_meeting_for_adhoc_detection() {
562
+ let meetings = parse_meetings(&serde_json::json!({
563
+ "result": {
564
+ "meetings": [
565
+ {
566
+ "id": "too-far",
567
+ "title": "Later Zoom",
568
+ "scheduledStart": "2026-07-22T19:40:00Z",
569
+ "joinUrl": "https://zoom.us/j/2",
570
+ "platform": "zoom",
571
+ "source": "calendar"
572
+ },
573
+ {
574
+ "id": "nearest",
575
+ "title": "Product sync",
576
+ "scheduledStart": "2026-07-22T19:11:00Z",
577
+ "joinUrl": "https://zoom.us/j/1",
578
+ "platform": "zoom",
579
+ "source": "calendar"
580
+ },
581
+ {
582
+ "id": "adhoc",
583
+ "title": "Zoom meeting",
584
+ "scheduledStart": "2026-07-22T19:10:00Z",
585
+ "joinUrl": "https://zoom.us/j/3",
586
+ "platform": "zoom",
587
+ "source": "adhoc"
588
+ }
589
+ ]
590
+ }
591
+ }));
592
+
593
+ let started_at = Utc.with_ymd_and_hms(2026, 7, 22, 19, 10, 0).unwrap();
594
+ let matched = find_matching_calendar_meeting(&meetings, "zoom", started_at);
595
+
596
+ assert_eq!(
597
+ matched.as_ref().map(|meeting| meeting.id.as_str()),
598
+ Some("nearest")
599
+ );
600
+ assert_eq!(
601
+ matched.and_then(|meeting| meeting.title),
602
+ Some("Product sync".to_string())
603
+ );
604
+ }
605
+
606
+ #[test]
607
+ fn avoids_ambiguous_same_platform_calendar_matches() {
608
+ let meetings = parse_meetings(&serde_json::json!({
609
+ "meetings": [
610
+ {
611
+ "id": "first",
612
+ "scheduledStart": "2026-07-22T19:09:00Z",
613
+ "joinUrl": "https://zoom.us/j/1",
614
+ "platform": "zoom",
615
+ "source": "calendar"
616
+ },
617
+ {
618
+ "id": "second",
619
+ "scheduledStart": "2026-07-22T19:11:00Z",
620
+ "joinUrl": "https://zoom.us/j/2",
621
+ "platform": "zoom",
622
+ "source": "calendar"
623
+ }
624
+ ]
625
+ }));
626
+
627
+ let started_at = Utc.with_ymd_and_hms(2026, 7, 22, 19, 10, 0).unwrap();
628
+ assert!(find_matching_calendar_meeting(&meetings, "zoom", started_at).is_none());
629
+ }
515
630
  }
@@ -44,9 +44,8 @@ mod macos {
44
44
 
45
45
  extern "C" {
46
46
  fn CGPreflightScreenCaptureAccess() -> bool;
47
- // kIOHIDRequestTypeListenEvent = 1; returns IOHIDAccessType where
48
- // kIOHIDAccessTypeGranted = 0, kIOHIDAccessTypeDenied = 1, kIOHIDAccessTypeUnknown = 2
49
- fn IOHIDCheckAccess(request_type: u32) -> i32;
47
+ // This is the preflight for the CGEventTap used by the Fn listener.
48
+ fn CGPreflightListenEventAccess() -> bool;
50
49
  }
51
50
 
52
51
  pub fn check_all() -> PermissionStatuses {
@@ -85,6 +84,6 @@ mod macos {
85
84
  }
86
85
 
87
86
  fn check_input_monitoring() -> bool {
88
- unsafe { IOHIDCheckAccess(1) == 0 }
87
+ unsafe { CGPreflightListenEventAccess() }
89
88
  }
90
89
  }
@@ -870,6 +870,26 @@ async function withMutationLocks<T>(
870
870
  );
871
871
  }
872
872
 
873
+ export function nextPosition(max: unknown): number {
874
+ const raw = max ?? -1;
875
+ const value =
876
+ typeof raw === "number"
877
+ ? raw
878
+ : typeof raw === "string" && raw.trim() !== ""
879
+ ? Number(raw)
880
+ : Number.NaN;
881
+ const next = value + 1;
882
+ if (
883
+ !Number.isSafeInteger(value) ||
884
+ value < -1 ||
885
+ !Number.isSafeInteger(next) ||
886
+ next > 2_147_483_647
887
+ ) {
888
+ throw new Error("Database position is outside the supported range.");
889
+ }
890
+ return next;
891
+ }
892
+
873
893
  async function createInsideTransaction(
874
894
  tx: Db,
875
895
  context: MutationContext,
@@ -913,7 +933,7 @@ async function createInsideTransaction(
913
933
  title: args.title?.trim() ?? "",
914
934
  content: "",
915
935
  icon: null,
916
- position: (maxDoc?.max ?? -1) + 1,
936
+ position: nextPosition(maxDoc?.max),
917
937
  isFavorite: 0,
918
938
  hideFromSearch: context.databaseDocument.hideFromSearch ?? 0,
919
939
  visibility: context.databaseDocument.visibility ?? "private",
@@ -926,7 +946,7 @@ async function createInsideTransaction(
926
946
  orgId: context.database.orgId,
927
947
  databaseId: context.database.id,
928
948
  documentId,
929
- position: (maxItem?.max ?? -1) + 1,
949
+ position: nextPosition(maxItem?.max),
930
950
  createdAt: now,
931
951
  updatedAt: now,
932
952
  });
@@ -4431,6 +4431,12 @@ export async function runAgentLoop(opts) {
4431
4431
  completedSideEffect: false,
4432
4432
  });
4433
4433
  recordToolResult(result, false);
4434
+ // Control is genuinely handed to the user here — the result above
4435
+ // tells the model "the turn is paused". `requestedActionStop` alone
4436
+ // does not stop anything until after the tool loop drains, so
4437
+ // without this the *rest* of this assistant message still executes
4438
+ // while the human is still looking at the approval card.
4439
+ turnYieldedToUser = true;
4434
4440
  requestedActionStop ??= {
4435
4441
  message: `Waiting for your approval to run ${toolCall.name}.`,
4436
4442
  errorCode: "needs-approval",
@@ -5029,6 +5035,16 @@ export async function runAgentLoop(opts) {
5029
5035
  // call and break the model's intended sequencing.
5030
5036
  if (!entry)
5031
5037
  return null;
5038
+ // A call that can pause the turn must not share a batch. `flushParallelBatch`
5039
+ // dispatches through `Promise.all`, so a gated call and its siblings all
5040
+ // start before the gate is reached, and `turnYieldedToUser` would then be
5041
+ // set too late to stop a sibling that already ran. Serializing here is what
5042
+ // makes that flag mean anything for the calls after it. `needsApproval` may
5043
+ // be an async predicate, so this cannot resolve whether approval is truly
5044
+ // required — declaring it at all is enough to serialize.
5045
+ if (entry.needsApproval !== undefined || entry.endsTurn === true) {
5046
+ return null;
5047
+ }
5032
5048
  if (entry.readOnly === true)
5033
5049
  return "read";
5034
5050
  if (entry.parallelSafe === true)
@@ -5056,7 +5072,8 @@ export async function runAgentLoop(opts) {
5056
5072
  // as not executed rather than running them: a second `ask-question` would
5057
5073
  // overwrite the first one's card before anyone could answer it.
5058
5074
  const skipToolCallAfterYield = (toolCall) => {
5059
- const result = `Not executed: ${toolCall.name} was called after an action that ends the turn. ` +
5075
+ const result = `Not executed: ${toolCall.name} was called after an action that paused the turn ` +
5076
+ `(an action that ends the turn, or one waiting on the user's approval). ` +
5060
5077
  `The turn is paused for the user's answer — call it again on a later turn if still needed.`;
5061
5078
  send({
5062
5079
  type: "tool_start",
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- error: string;
17
16
  ok?: undefined;
17
+ error: string;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;