@agent-native/core 0.109.2 → 0.109.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.
Files changed (97) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/create.ts +18 -12
  5. package/corpus/core/src/client/require-session.tsx +19 -3
  6. package/corpus/core/src/client/use-action.ts +145 -2
  7. package/corpus/core/src/client/use-session.ts +79 -48
  8. package/corpus/core/src/db/client.ts +13 -7
  9. package/corpus/core/src/db/create-get-db.ts +20 -13
  10. package/corpus/core/src/db/request-telemetry.ts +154 -0
  11. package/corpus/core/src/integrations/index.ts +1 -0
  12. package/corpus/core/src/integrations/plugin.ts +9 -3
  13. package/corpus/core/src/integrations/remote-push-delivery-job.ts +48 -0
  14. package/corpus/core/src/integrations/remote-push-delivery.ts +370 -0
  15. package/corpus/core/src/integrations/remote-push-store.ts +275 -26
  16. package/corpus/core/src/server/better-auth-instance.ts +11 -3
  17. package/corpus/core/src/server/core-routes-plugin.ts +36 -14
  18. package/corpus/core/src/server/framework-request-handler.ts +11 -1
  19. package/corpus/core/src/server/http-response-telemetry.ts +233 -31
  20. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +19 -0
  21. package/corpus/templates/clips/actions/finalize-recording.ts +3 -1
  22. package/corpus/templates/clips/actions/set-mobile-capture-state.ts +4 -1
  23. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -6
  24. package/corpus/templates/clips/changelog/2026-07-18-new-recording-is-now-emphasized-as-the-primary-action-in-the.md +6 -0
  25. package/corpus/templates/clips/changelog/2026-07-18-recordings-now-remain-safely-recoverable-until-the-stored-me.md +6 -0
  26. package/corpus/templates/tasks/AGENTS.md +3 -0
  27. package/corpus/templates/tasks/actions/render-task-list-inline.ts +202 -0
  28. package/corpus/templates/tasks/changelog/2026-07-18-task-lists-can-now-appear-as-interactive-widgets-inline-in-c.md +6 -0
  29. package/corpus/templates/tasks/docs/features/f8-chat.mdx +2 -0
  30. package/corpus/templates/tasks/server/plugins/agent-chat.ts +2 -0
  31. package/dist/cli/create.d.ts +10 -1
  32. package/dist/cli/create.d.ts.map +1 -1
  33. package/dist/cli/create.js +17 -13
  34. package/dist/cli/create.js.map +1 -1
  35. package/dist/client/require-session.d.ts.map +1 -1
  36. package/dist/client/require-session.js +6 -3
  37. package/dist/client/require-session.js.map +1 -1
  38. package/dist/client/use-action.d.ts.map +1 -1
  39. package/dist/client/use-action.js +117 -1
  40. package/dist/client/use-action.js.map +1 -1
  41. package/dist/client/use-session.d.ts.map +1 -1
  42. package/dist/client/use-session.js +72 -50
  43. package/dist/client/use-session.js.map +1 -1
  44. package/dist/collab/routes.d.ts +1 -1
  45. package/dist/collab/struct-routes.d.ts +1 -1
  46. package/dist/db/client.d.ts.map +1 -1
  47. package/dist/db/client.js +7 -1
  48. package/dist/db/client.js.map +1 -1
  49. package/dist/db/create-get-db.d.ts +3 -6
  50. package/dist/db/create-get-db.d.ts.map +1 -1
  51. package/dist/db/create-get-db.js +9 -2
  52. package/dist/db/create-get-db.js.map +1 -1
  53. package/dist/db/request-telemetry.d.ts +23 -0
  54. package/dist/db/request-telemetry.d.ts.map +1 -0
  55. package/dist/db/request-telemetry.js +95 -0
  56. package/dist/db/request-telemetry.js.map +1 -0
  57. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  58. package/dist/integrations/index.d.ts +1 -0
  59. package/dist/integrations/index.d.ts.map +1 -1
  60. package/dist/integrations/index.js +1 -0
  61. package/dist/integrations/index.js.map +1 -1
  62. package/dist/integrations/plugin.d.ts.map +1 -1
  63. package/dist/integrations/plugin.js +8 -3
  64. package/dist/integrations/plugin.js.map +1 -1
  65. package/dist/integrations/remote-push-delivery-job.d.ts +4 -0
  66. package/dist/integrations/remote-push-delivery-job.d.ts.map +1 -0
  67. package/dist/integrations/remote-push-delivery-job.js +44 -0
  68. package/dist/integrations/remote-push-delivery-job.js.map +1 -0
  69. package/dist/integrations/remote-push-delivery.d.ts +11 -0
  70. package/dist/integrations/remote-push-delivery.d.ts.map +1 -0
  71. package/dist/integrations/remote-push-delivery.js +265 -0
  72. package/dist/integrations/remote-push-delivery.js.map +1 -0
  73. package/dist/integrations/remote-push-store.d.ts +33 -0
  74. package/dist/integrations/remote-push-store.d.ts.map +1 -1
  75. package/dist/integrations/remote-push-store.js +195 -26
  76. package/dist/integrations/remote-push-store.js.map +1 -1
  77. package/dist/progress/routes.d.ts +1 -1
  78. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  79. package/dist/resources/handlers.d.ts +3 -3
  80. package/dist/secrets/routes.d.ts +9 -9
  81. package/dist/server/better-auth-instance.js +7 -3
  82. package/dist/server/better-auth-instance.js.map +1 -1
  83. package/dist/server/core-routes-plugin.d.ts +8 -0
  84. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  85. package/dist/server/core-routes-plugin.js +23 -8
  86. package/dist/server/core-routes-plugin.js.map +1 -1
  87. package/dist/server/framework-request-handler.d.ts.map +1 -1
  88. package/dist/server/framework-request-handler.js +9 -1
  89. package/dist/server/framework-request-handler.js.map +1 -1
  90. package/dist/server/http-response-telemetry.d.ts +3 -2
  91. package/dist/server/http-response-telemetry.d.ts.map +1 -1
  92. package/dist/server/http-response-telemetry.js +160 -27
  93. package/dist/server/http-response-telemetry.js.map +1 -1
  94. package/dist/server/transcribe-voice.d.ts +1 -1
  95. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +19 -0
  96. package/package.json +1 -1
  97. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +19 -0
@@ -0,0 +1,154 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export type DatabaseOperationKind = "connect" | "query";
4
+
5
+ export interface DatabaseRequestTelemetry {
6
+ operationCount: number;
7
+ queryCount: number;
8
+ connectCount: number;
9
+ retryCount: number;
10
+ errorCount: number;
11
+ timeoutCount: number;
12
+ operationTotalMs: number;
13
+ operationWallMs: number;
14
+ queryTotalMs: number;
15
+ connectTotalMs: number;
16
+ slowestOperationMs: number;
17
+ activeOperationCount: number;
18
+ activeWindowStartedAt?: number;
19
+ }
20
+
21
+ const STORAGE_KEY = Symbol.for(
22
+ "@agent-native/core/db.request-telemetry-storage",
23
+ );
24
+ const STARTUP_STATE_KEY = Symbol.for(
25
+ "@agent-native/core/db.startup-telemetry-state",
26
+ );
27
+ const STARTUP_CAPTURE_WINDOW_MS = 120_000;
28
+ interface StartupDatabaseTelemetryState {
29
+ captureUntil: number;
30
+ claimed: boolean;
31
+ telemetry: DatabaseRequestTelemetry;
32
+ }
33
+ type GlobalWithDatabaseTelemetry = typeof globalThis & {
34
+ [STORAGE_KEY]?: AsyncLocalStorage<DatabaseRequestTelemetry>;
35
+ [STARTUP_STATE_KEY]?: StartupDatabaseTelemetryState;
36
+ };
37
+
38
+ const globalRef = globalThis as GlobalWithDatabaseTelemetry;
39
+ const storage =
40
+ globalRef[STORAGE_KEY] ??
41
+ (globalRef[STORAGE_KEY] = new AsyncLocalStorage<DatabaseRequestTelemetry>());
42
+
43
+ export function createDatabaseRequestTelemetry(): DatabaseRequestTelemetry {
44
+ return {
45
+ operationCount: 0,
46
+ queryCount: 0,
47
+ connectCount: 0,
48
+ retryCount: 0,
49
+ errorCount: 0,
50
+ timeoutCount: 0,
51
+ operationTotalMs: 0,
52
+ operationWallMs: 0,
53
+ queryTotalMs: 0,
54
+ connectTotalMs: 0,
55
+ slowestOperationMs: 0,
56
+ activeOperationCount: 0,
57
+ };
58
+ }
59
+
60
+ const startupState =
61
+ globalRef[STARTUP_STATE_KEY] ??
62
+ (globalRef[STARTUP_STATE_KEY] = {
63
+ captureUntil: Date.now() + STARTUP_CAPTURE_WINDOW_MS,
64
+ claimed: false,
65
+ telemetry: createDatabaseRequestTelemetry(),
66
+ });
67
+
68
+ function currentDatabaseTelemetry(): DatabaseRequestTelemetry | undefined {
69
+ const requestTelemetry = storage.getStore();
70
+ if (requestTelemetry) return requestTelemetry;
71
+ if (!startupState.claimed && Date.now() <= startupState.captureUntil) {
72
+ return startupState.telemetry;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ export function claimStartupDatabaseTelemetry():
78
+ | DatabaseRequestTelemetry
79
+ | undefined {
80
+ if (startupState.claimed) return undefined;
81
+ startupState.claimed = true;
82
+ return startupState.telemetry;
83
+ }
84
+
85
+ export function runWithDatabaseRequestTelemetry<T>(
86
+ telemetry: DatabaseRequestTelemetry,
87
+ fn: () => T,
88
+ ): T {
89
+ return storage.run(telemetry, fn);
90
+ }
91
+
92
+ export function enterDatabaseRequestTelemetry(
93
+ telemetry: DatabaseRequestTelemetry,
94
+ ): void {
95
+ storage.enterWith(telemetry);
96
+ }
97
+
98
+ export function beginDatabaseOperation(
99
+ operation: DatabaseOperationKind,
100
+ ): (outcome: "success" | "error" | "timeout") => void {
101
+ const telemetry = currentDatabaseTelemetry();
102
+ if (!telemetry) return () => {};
103
+
104
+ const startedAt = Date.now();
105
+ if (telemetry.activeOperationCount === 0) {
106
+ telemetry.activeWindowStartedAt = startedAt;
107
+ }
108
+ telemetry.activeOperationCount += 1;
109
+ let completed = false;
110
+
111
+ return (outcome) => {
112
+ if (completed) return;
113
+ completed = true;
114
+ const completedAt = Date.now();
115
+ const duration = Math.max(0, completedAt - startedAt);
116
+ telemetry.operationCount += 1;
117
+ telemetry.operationTotalMs += duration;
118
+ telemetry.slowestOperationMs = Math.max(
119
+ telemetry.slowestOperationMs,
120
+ duration,
121
+ );
122
+
123
+ if (operation === "connect") {
124
+ telemetry.connectCount += 1;
125
+ telemetry.connectTotalMs += duration;
126
+ } else {
127
+ telemetry.queryCount += 1;
128
+ telemetry.queryTotalMs += duration;
129
+ }
130
+
131
+ if (outcome !== "success") telemetry.errorCount += 1;
132
+ if (outcome === "timeout") telemetry.timeoutCount += 1;
133
+
134
+ telemetry.activeOperationCount = Math.max(
135
+ 0,
136
+ telemetry.activeOperationCount - 1,
137
+ );
138
+ if (
139
+ telemetry.activeOperationCount === 0 &&
140
+ telemetry.activeWindowStartedAt !== undefined
141
+ ) {
142
+ telemetry.operationWallMs += Math.max(
143
+ 0,
144
+ completedAt - telemetry.activeWindowStartedAt,
145
+ );
146
+ telemetry.activeWindowStartedAt = undefined;
147
+ }
148
+ };
149
+ }
150
+
151
+ export function recordDatabaseRetry(): void {
152
+ const telemetry = currentDatabaseTelemetry();
153
+ if (telemetry) telemetry.retryCount += 1;
154
+ }
@@ -84,6 +84,7 @@ export {
84
84
  unregisterRemotePushRegistrationForOwner,
85
85
  upsertRemotePushRegistration,
86
86
  } from "./remote-push-store.js";
87
+ export { deliverPendingRemotePushNotifications } from "./remote-push-delivery.js";
87
88
  export type {
88
89
  ComputerApprovalScope,
89
90
  ComputerCommandAction,
@@ -109,6 +109,7 @@ import {
109
109
  unregisterRemoteDevice,
110
110
  updateRemoteDeviceDetails,
111
111
  } from "./remote-devices-store.js";
112
+ import { startRemotePushDeliveryJob } from "./remote-push-delivery-job.js";
112
113
  import {
113
114
  listRemotePushNotificationsForOwner,
114
115
  listRemotePushRegistrationsForOwner,
@@ -579,7 +580,6 @@ function mountedPathParts(event: any, mountSuffix: string): string[] {
579
580
  function remoteCommandPushPayload(
580
581
  command: RemoteCommand,
581
582
  ): Record<string, unknown> {
582
- const result = readObject(command.result);
583
583
  const status = command.status;
584
584
  const title =
585
585
  status === "completed"
@@ -587,14 +587,19 @@ function remoteCommandPushPayload(
587
587
  : status === "failed"
588
588
  ? "Remote run failed"
589
589
  : "Remote run updated";
590
+ const body =
591
+ status === "completed"
592
+ ? "Open Agent Native to review the result."
593
+ : status === "failed"
594
+ ? "Open Agent Native to review the failure."
595
+ : "Open Agent Native to review the latest status.";
590
596
  return {
591
597
  title,
592
- body: command.errorMessage ?? readString(result?.message),
598
+ body,
593
599
  commandId: command.id,
594
600
  hostId: command.deviceId,
595
601
  kind: command.kind,
596
602
  status,
597
- result: command.result,
598
603
  updatedAt: command.updatedAt,
599
604
  };
600
605
  }
@@ -2897,6 +2902,7 @@ export function createIntegrationsPlugin(
2897
2902
  });
2898
2903
  startA2AContinuationRetryJob(adapterMap);
2899
2904
  startRemoteCommandsRetryJob();
2905
+ startRemotePushDeliveryJob();
2900
2906
 
2901
2907
  // ─── Start Google Docs poller/push ────────────────────────────
2902
2908
  if (adapterMap.has("google-docs")) {
@@ -0,0 +1,48 @@
1
+ import { deliverPendingRemotePushNotifications } from "./remote-push-delivery.js";
2
+
3
+ const DELIVERY_INTERVAL_MS = 60_000;
4
+
5
+ let retryInterval: ReturnType<typeof setInterval> | null = null;
6
+ let initialTimer: ReturnType<typeof setTimeout> | null = null;
7
+ let running = false;
8
+
9
+ export async function runRemotePushDelivery(): Promise<void> {
10
+ if (running) return;
11
+ running = true;
12
+ try {
13
+ await deliverPendingRemotePushNotifications();
14
+ } catch (error) {
15
+ console.error("[integrations] Remote push delivery failed", {
16
+ error: error instanceof Error ? error.name : "UnknownError",
17
+ });
18
+ } finally {
19
+ running = false;
20
+ }
21
+ }
22
+
23
+ export function startRemotePushDeliveryJob(): void {
24
+ if (retryInterval) return;
25
+ initialTimer = setTimeout(() => void runRemotePushDelivery(), 10_000);
26
+ unrefTimer(initialTimer);
27
+ retryInterval = setInterval(
28
+ () => void runRemotePushDelivery(),
29
+ DELIVERY_INTERVAL_MS,
30
+ );
31
+ unrefTimer(retryInterval);
32
+ }
33
+
34
+ export function stopRemotePushDeliveryJob(): void {
35
+ if (initialTimer) {
36
+ clearTimeout(initialTimer);
37
+ initialTimer = null;
38
+ }
39
+ if (retryInterval) {
40
+ clearInterval(retryInterval);
41
+ retryInterval = null;
42
+ }
43
+ running = false;
44
+ }
45
+
46
+ function unrefTimer(timer: ReturnType<typeof setInterval>): void {
47
+ (timer as unknown as { unref?: () => void }).unref?.();
48
+ }
@@ -0,0 +1,370 @@
1
+ import {
2
+ claimNextRemotePushDelivery,
3
+ deactivateRemotePushRegistration,
4
+ failRemotePushDelivery,
5
+ markRemotePushDelivered,
6
+ markRemotePushTicketAccepted,
7
+ retryRemotePushDelivery,
8
+ type ClaimedRemotePushDelivery,
9
+ } from "./remote-push-store.js";
10
+
11
+ const EXPO_PUSH_SEND_URL = "https://exp.host/--/api/v2/push/send";
12
+ const EXPO_PUSH_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
13
+ const REQUEST_TIMEOUT_MS = 10_000;
14
+ const RECEIPT_CHECK_DELAY_MS = 15 * 60_000;
15
+ const MAX_DELIVERY_ATTEMPTS = 12;
16
+ const DEFAULT_DELIVERY_LIMIT = 100;
17
+
18
+ type ExpoResult = {
19
+ status?: unknown;
20
+ id?: unknown;
21
+ message?: unknown;
22
+ details?: { error?: unknown };
23
+ };
24
+
25
+ type ExpoPushResponse = {
26
+ data?: ExpoResult | ExpoResult[] | Record<string, ExpoResult>;
27
+ errors?: Array<{ code?: unknown; message?: unknown }>;
28
+ };
29
+
30
+ type DeliveryOutcome =
31
+ | { kind: "ticket"; ticketId: string }
32
+ | { kind: "delivered" }
33
+ | { kind: "retry"; errorCode: string; resend?: boolean }
34
+ | { kind: "failed"; errorCode: string; deactivate?: boolean };
35
+
36
+ export async function deliverPendingRemotePushNotifications(options?: {
37
+ fetchImpl?: typeof fetch;
38
+ now?: () => number;
39
+ limit?: number;
40
+ }): Promise<{
41
+ sent: number;
42
+ delivered: number;
43
+ retried: number;
44
+ failed: number;
45
+ }> {
46
+ const fetchImpl = options?.fetchImpl ?? fetch;
47
+ const now = options?.now ?? Date.now;
48
+ const limit = Math.max(
49
+ 1,
50
+ Math.min(options?.limit ?? DEFAULT_DELIVERY_LIMIT, 100),
51
+ );
52
+ const summary = { sent: 0, delivered: 0, retried: 0, failed: 0 };
53
+ const claimedDeliveries: ClaimedRemotePushDelivery[] = [];
54
+
55
+ for (let processed = 0; processed < limit; processed++) {
56
+ const delivery = await claimNextRemotePushDelivery({ now: now() });
57
+ if (!delivery) break;
58
+
59
+ if (delivery.attempts > MAX_DELIVERY_ATTEMPTS) {
60
+ await failRemotePushDelivery({
61
+ id: delivery.id,
62
+ phase: delivery.phase,
63
+ errorCode: "attempts_exhausted",
64
+ });
65
+ summary.failed++;
66
+ continue;
67
+ }
68
+
69
+ claimedDeliveries.push(delivery);
70
+ }
71
+
72
+ const sendDeliveries = claimedDeliveries.filter(
73
+ (delivery) => delivery.phase === "send",
74
+ );
75
+ if (sendDeliveries.length > 0) {
76
+ let outcomes: DeliveryOutcome[];
77
+ try {
78
+ outcomes = await sendExpoPushNotifications(sendDeliveries, fetchImpl);
79
+ } catch (error) {
80
+ const errorCode = classifyTransportError(error);
81
+ outcomes = sendDeliveries.map(() => ({ kind: "retry", errorCode }));
82
+ }
83
+ for (let index = 0; index < sendDeliveries.length; index++) {
84
+ await applyDeliveryOutcome(
85
+ sendDeliveries[index]!,
86
+ outcomes[index]!,
87
+ summary,
88
+ now,
89
+ );
90
+ }
91
+ }
92
+
93
+ for (const delivery of claimedDeliveries) {
94
+ if (delivery.phase !== "receipt") continue;
95
+ let outcome: DeliveryOutcome;
96
+ try {
97
+ outcome = await readExpoPushReceipt(delivery, fetchImpl);
98
+ } catch (error) {
99
+ outcome = {
100
+ kind: "retry",
101
+ errorCode: classifyTransportError(error),
102
+ };
103
+ }
104
+
105
+ await applyDeliveryOutcome(delivery, outcome, summary, now);
106
+ }
107
+
108
+ return summary;
109
+ }
110
+
111
+ async function applyDeliveryOutcome(
112
+ delivery: ClaimedRemotePushDelivery,
113
+ outcome: DeliveryOutcome,
114
+ summary: {
115
+ sent: number;
116
+ delivered: number;
117
+ retried: number;
118
+ failed: number;
119
+ },
120
+ now: () => number,
121
+ ): Promise<void> {
122
+ if (outcome.kind === "ticket") {
123
+ await markRemotePushTicketAccepted({
124
+ id: delivery.id,
125
+ providerTicketId: outcome.ticketId,
126
+ checkAfter: now() + RECEIPT_CHECK_DELAY_MS,
127
+ });
128
+ summary.sent++;
129
+ return;
130
+ }
131
+ if (outcome.kind === "delivered") {
132
+ await markRemotePushDelivered(delivery.id);
133
+ summary.delivered++;
134
+ return;
135
+ }
136
+ if (outcome.kind === "failed") {
137
+ await failRemotePushDelivery({
138
+ id: delivery.id,
139
+ phase: delivery.phase,
140
+ errorCode: outcome.errorCode,
141
+ });
142
+ if (outcome.deactivate) {
143
+ await deactivateRemotePushRegistration(delivery.registrationId);
144
+ }
145
+ summary.failed++;
146
+ return;
147
+ }
148
+
149
+ await retryRemotePushDelivery({
150
+ id: delivery.id,
151
+ phase: delivery.phase,
152
+ retryAt: now() + retryDelayMs(delivery.attempts),
153
+ errorCode: outcome.errorCode,
154
+ resend: outcome.resend,
155
+ });
156
+ summary.retried++;
157
+ }
158
+
159
+ async function sendExpoPushNotifications(
160
+ deliveries: ClaimedRemotePushDelivery[],
161
+ fetchImpl: typeof fetch,
162
+ ): Promise<DeliveryOutcome[]> {
163
+ const outcomes = new Map<string, DeliveryOutcome>();
164
+ const supportedDeliveries = deliveries.filter((delivery) => {
165
+ const supported =
166
+ delivery.provider === "expo" && isExpoPushToken(delivery.token);
167
+ if (!supported) {
168
+ outcomes.set(delivery.id, {
169
+ kind: "failed",
170
+ errorCode: "unsupported_push_registration",
171
+ deactivate: true,
172
+ });
173
+ }
174
+ return supported;
175
+ });
176
+ if (supportedDeliveries.length === 0) {
177
+ return deliveries.map((delivery) => outcomes.get(delivery.id)!);
178
+ }
179
+
180
+ const messages = supportedDeliveries.map(buildExpoMessage);
181
+ const response = await fetchWithTimeout(
182
+ fetchImpl,
183
+ EXPO_PUSH_SEND_URL,
184
+ expoRequestInit(messages.length === 1 ? messages[0] : messages),
185
+ );
186
+ const body = await readExpoResponse(response);
187
+ if (!response.ok) {
188
+ const outcome = responseFailure(response.status, body);
189
+ for (const delivery of supportedDeliveries) {
190
+ outcomes.set(delivery.id, outcome);
191
+ }
192
+ return deliveries.map((delivery) => outcomes.get(delivery.id)!);
193
+ }
194
+
195
+ const tickets = Array.isArray(body.data)
196
+ ? body.data
197
+ : body.data && isExpoResult(body.data)
198
+ ? [body.data]
199
+ : [];
200
+ if (
201
+ tickets.length !== supportedDeliveries.length ||
202
+ tickets.some((ticket) => !isExpoResult(ticket))
203
+ ) {
204
+ for (const delivery of supportedDeliveries) {
205
+ outcomes.set(delivery.id, {
206
+ kind: "retry",
207
+ errorCode: "invalid_push_ticket",
208
+ });
209
+ }
210
+ return deliveries.map((delivery) => outcomes.get(delivery.id)!);
211
+ }
212
+
213
+ for (let index = 0; index < supportedDeliveries.length; index++) {
214
+ const delivery = supportedDeliveries[index]!;
215
+ const ticket = tickets[index]!;
216
+ outcomes.set(
217
+ delivery.id,
218
+ ticket.status === "ok" && typeof ticket.id === "string"
219
+ ? { kind: "ticket", ticketId: ticket.id }
220
+ : expoResultFailure(ticket, false),
221
+ );
222
+ }
223
+ return deliveries.map((delivery) => outcomes.get(delivery.id)!);
224
+ }
225
+
226
+ async function readExpoPushReceipt(
227
+ delivery: ClaimedRemotePushDelivery,
228
+ fetchImpl: typeof fetch,
229
+ ): Promise<DeliveryOutcome> {
230
+ if (!delivery.providerTicketId) {
231
+ return { kind: "retry", errorCode: "missing_push_ticket", resend: true };
232
+ }
233
+ const response = await fetchWithTimeout(
234
+ fetchImpl,
235
+ EXPO_PUSH_RECEIPTS_URL,
236
+ expoRequestInit({ ids: [delivery.providerTicketId] }),
237
+ );
238
+ const body = await readExpoResponse(response);
239
+ if (!response.ok) return responseFailure(response.status, body);
240
+
241
+ const receipts = body.data;
242
+ if (!receipts || Array.isArray(receipts) || isExpoResult(receipts)) {
243
+ return { kind: "retry", errorCode: "push_receipt_unavailable" };
244
+ }
245
+ const receipt = receipts[delivery.providerTicketId];
246
+ if (!receipt) {
247
+ return { kind: "retry", errorCode: "push_receipt_unavailable" };
248
+ }
249
+ if (receipt.status === "ok") return { kind: "delivered" };
250
+ return expoResultFailure(receipt, true);
251
+ }
252
+
253
+ function buildExpoMessage(delivery: ClaimedRemotePushDelivery) {
254
+ const payload = readRecord(delivery.payload);
255
+ const title = boundedString(payload?.title, 120) ?? "Agent Native update";
256
+ const body = boundedString(payload?.body, 300);
257
+ const data = compactData(payload);
258
+ return {
259
+ to: delivery.token,
260
+ title,
261
+ ...(body ? { body } : {}),
262
+ sound: "default",
263
+ priority: "high",
264
+ data: {
265
+ url: "agentnative://sessions",
266
+ ...data,
267
+ },
268
+ };
269
+ }
270
+
271
+ function compactData(payload: Record<string, unknown> | null) {
272
+ if (!payload) return {};
273
+ const data: Record<string, string | number> = {};
274
+ for (const key of ["commandId", "hostId", "kind", "status"] as const) {
275
+ const value = boundedString(payload[key], 200);
276
+ if (value) data[key] = value;
277
+ }
278
+ if (
279
+ typeof payload.updatedAt === "number" &&
280
+ Number.isFinite(payload.updatedAt)
281
+ ) {
282
+ data.updatedAt = payload.updatedAt;
283
+ }
284
+ return data;
285
+ }
286
+
287
+ function expoRequestInit(body: unknown): RequestInit {
288
+ const headers: Record<string, string> = {
289
+ Accept: "application/json",
290
+ "Content-Type": "application/json",
291
+ };
292
+ const accessToken = process.env.EXPO_ACCESS_TOKEN?.trim();
293
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
294
+ return { method: "POST", headers, body: JSON.stringify(body) };
295
+ }
296
+
297
+ async function fetchWithTimeout(
298
+ fetchImpl: typeof fetch,
299
+ url: string,
300
+ init: RequestInit,
301
+ ): Promise<Response> {
302
+ const controller = new AbortController();
303
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
304
+ try {
305
+ return await fetchImpl(url, { ...init, signal: controller.signal });
306
+ } finally {
307
+ clearTimeout(timeout);
308
+ }
309
+ }
310
+
311
+ async function readExpoResponse(response: Response): Promise<ExpoPushResponse> {
312
+ const body = await response.json().catch(() => null);
313
+ return (readRecord(body) ?? {}) as ExpoPushResponse;
314
+ }
315
+
316
+ function responseFailure(
317
+ status: number,
318
+ body: ExpoPushResponse,
319
+ ): DeliveryOutcome {
320
+ const code = boundedString(body.errors?.[0]?.code, 120);
321
+ if (status === 429 || status >= 500) {
322
+ return { kind: "retry", errorCode: code ?? `expo_http_${status}` };
323
+ }
324
+ return { kind: "failed", errorCode: code ?? `expo_http_${status}` };
325
+ }
326
+
327
+ function expoResultFailure(
328
+ result: ExpoResult,
329
+ fromReceipt: boolean,
330
+ ): DeliveryOutcome {
331
+ const code = boundedString(result.details?.error, 120) ?? "expo_push_error";
332
+ if (code === "DeviceNotRegistered") {
333
+ return { kind: "failed", errorCode: code, deactivate: true };
334
+ }
335
+ if (code === "MessageRateExceeded") {
336
+ return { kind: "retry", errorCode: code, resend: fromReceipt };
337
+ }
338
+ return { kind: "failed", errorCode: code };
339
+ }
340
+
341
+ function retryDelayMs(attempt: number): number {
342
+ return Math.min(60 * 60_000, 5_000 * 2 ** Math.max(0, attempt - 1));
343
+ }
344
+
345
+ function classifyTransportError(error: unknown): string {
346
+ if (error instanceof DOMException && error.name === "AbortError") {
347
+ return "expo_request_timeout";
348
+ }
349
+ return "expo_transport_error";
350
+ }
351
+
352
+ function isExpoPushToken(value: string): boolean {
353
+ return /^(Expo(nent)?PushToken)\[[A-Za-z0-9_-]+\]$/.test(value);
354
+ }
355
+
356
+ function isExpoResult(value: unknown): value is ExpoResult {
357
+ return value !== null && typeof value === "object" && "status" in value;
358
+ }
359
+
360
+ function readRecord(value: unknown): Record<string, unknown> | null {
361
+ return value && typeof value === "object" && !Array.isArray(value)
362
+ ? (value as Record<string, unknown>)
363
+ : null;
364
+ }
365
+
366
+ function boundedString(value: unknown, maxLength: number): string | null {
367
+ if (typeof value !== "string") return null;
368
+ const trimmed = value.trim();
369
+ return trimmed ? trimmed.slice(0, maxLength) : null;
370
+ }