@agent-native/core 0.98.5 → 0.98.7

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 (135) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +18 -0
  3. package/corpus/core/docs/content/external-agents.mdx +66 -7
  4. package/corpus/core/docs/content/locales/ar-SA/external-agents.mdx +40 -7
  5. package/corpus/core/docs/content/locales/de-DE/external-agents.mdx +41 -7
  6. package/corpus/core/docs/content/locales/es-ES/external-agents.mdx +41 -7
  7. package/corpus/core/docs/content/locales/fr-FR/external-agents.mdx +41 -7
  8. package/corpus/core/docs/content/locales/hi-IN/external-agents.mdx +40 -7
  9. package/corpus/core/docs/content/locales/ja-JP/external-agents.mdx +40 -7
  10. package/corpus/core/docs/content/locales/ko-KR/external-agents.mdx +40 -7
  11. package/corpus/core/docs/content/locales/pt-BR/external-agents.mdx +41 -7
  12. package/corpus/core/docs/content/locales/zh-CN/external-agents.mdx +39 -7
  13. package/corpus/core/docs/content/locales/zh-TW/external-agents.mdx +39 -7
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/a2a/handlers.ts +101 -26
  16. package/corpus/core/src/a2a/task-store.ts +128 -3
  17. package/corpus/core/src/client/session-replay.ts +67 -5
  18. package/corpus/core/src/integrations/adapters/slack.ts +207 -17
  19. package/corpus/core/src/integrations/identity-links-store.ts +210 -0
  20. package/corpus/core/src/integrations/identity.ts +196 -0
  21. package/corpus/core/src/integrations/index.ts +6 -0
  22. package/corpus/core/src/integrations/plugin.ts +130 -1
  23. package/corpus/core/src/integrations/types.ts +37 -0
  24. package/corpus/core/src/integrations/webhook-handler.ts +1 -0
  25. package/corpus/core/src/mcp/build-server.ts +127 -22
  26. package/corpus/core/src/mcp/builtin-tools.ts +5 -2
  27. package/corpus/core/src/mcp/external-agent-policy.ts +18 -0
  28. package/corpus/core/src/mcp/index.ts +1 -0
  29. package/corpus/core/src/server/agent-chat/plugin-options.ts +12 -1
  30. package/corpus/core/src/server/agent-chat/script-entries.ts +20 -3
  31. package/corpus/core/src/server/agent-chat-plugin.ts +3 -0
  32. package/corpus/core/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
  33. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +4 -1
  34. package/corpus/templates/analytics/AGENTS.md +20 -5
  35. package/corpus/templates/analytics/actions/create-session-replay-agent-link.ts +0 -1
  36. package/corpus/templates/analytics/actions/get-session-replay-events.ts +0 -1
  37. package/corpus/templates/analytics/actions/get-session-replay-timeline.ts +40 -0
  38. package/corpus/templates/analytics/actions/list-error-issues.ts +12 -0
  39. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +4 -1
  40. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +48 -17
  41. package/corpus/templates/analytics/changelog/2026-07-12-analytics-uses-the-full-in-app-agent-for-multi-step-incident.md +6 -0
  42. package/corpus/templates/analytics/changelog/2026-07-12-connected-external-agents-can-now-look-up-sessions-error-iss.md +6 -0
  43. package/corpus/templates/analytics/changelog/2026-07-12-session-identities-stay-visible-when-demo-mode-is-off.md +6 -0
  44. package/corpus/templates/analytics/server/handlers/session-replay.ts +15 -9
  45. package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +2 -2
  46. package/corpus/templates/analytics/server/lib/error-capture.ts +47 -5
  47. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +72 -22
  48. package/corpus/templates/analytics/server/lib/session-replay.ts +41 -11
  49. package/corpus/templates/analytics/server/plugins/agent-chat.ts +7 -0
  50. package/corpus/templates/analytics/server/plugins/db.ts +15 -0
  51. package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +8 -5
  52. package/corpus/templates/analytics/server/routes/api/session-replay/agent-events.json.get.ts +8 -3
  53. package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +12 -2
  54. package/corpus/templates/forms/app/global.css +54 -8
  55. package/corpus/templates/forms/app/pages/AskPage.tsx +35 -13
  56. package/corpus/templates/forms/app/pages/FormBuilderPage.tsx +11 -17
  57. package/corpus/templates/forms/app/pages/ResponsesPage.tsx +8 -5
  58. package/corpus/templates/forms/changelog/2026-07-12-ask-forms-suggestions-now-sit-beneath-the-composer-for-a-tig.md +6 -0
  59. package/corpus/templates/forms/changelog/2026-07-12-builder-response-tables-now-fill-the-view-without-a-redundan.md +6 -0
  60. package/corpus/templates/forms/changelog/2026-07-12-response-tables-now-fill-the-available-pane-with-a-flexible-.md +6 -0
  61. package/corpus/templates/mail/AGENTS.md +3 -1
  62. package/corpus/templates/mail/server/lib/mail-integrations.ts +3 -0
  63. package/dist/a2a/handlers.d.ts.map +1 -1
  64. package/dist/a2a/handlers.js +89 -20
  65. package/dist/a2a/handlers.js.map +1 -1
  66. package/dist/a2a/task-store.d.ts +32 -1
  67. package/dist/a2a/task-store.d.ts.map +1 -1
  68. package/dist/a2a/task-store.js +111 -4
  69. package/dist/a2a/task-store.js.map +1 -1
  70. package/dist/client/session-replay.d.ts.map +1 -1
  71. package/dist/client/session-replay.js +53 -8
  72. package/dist/client/session-replay.js.map +1 -1
  73. package/dist/collab/routes.d.ts +1 -1
  74. package/dist/integrations/adapters/slack.d.ts.map +1 -1
  75. package/dist/integrations/adapters/slack.js +176 -17
  76. package/dist/integrations/adapters/slack.js.map +1 -1
  77. package/dist/integrations/identity-links-store.d.ts +23 -0
  78. package/dist/integrations/identity-links-store.d.ts.map +1 -0
  79. package/dist/integrations/identity-links-store.js +158 -0
  80. package/dist/integrations/identity-links-store.js.map +1 -0
  81. package/dist/integrations/identity.d.ts +28 -0
  82. package/dist/integrations/identity.d.ts.map +1 -0
  83. package/dist/integrations/identity.js +142 -0
  84. package/dist/integrations/identity.js.map +1 -0
  85. package/dist/integrations/index.d.ts +2 -0
  86. package/dist/integrations/index.d.ts.map +1 -1
  87. package/dist/integrations/index.js +2 -0
  88. package/dist/integrations/index.js.map +1 -1
  89. package/dist/integrations/plugin.d.ts.map +1 -1
  90. package/dist/integrations/plugin.js +93 -1
  91. package/dist/integrations/plugin.js.map +1 -1
  92. package/dist/integrations/types.d.ts +31 -0
  93. package/dist/integrations/types.d.ts.map +1 -1
  94. package/dist/integrations/types.js.map +1 -1
  95. package/dist/integrations/webhook-handler.js +1 -0
  96. package/dist/integrations/webhook-handler.js.map +1 -1
  97. package/dist/mcp/build-server.d.ts +9 -2
  98. package/dist/mcp/build-server.d.ts.map +1 -1
  99. package/dist/mcp/build-server.js +97 -20
  100. package/dist/mcp/build-server.js.map +1 -1
  101. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  102. package/dist/mcp/builtin-tools.js +5 -2
  103. package/dist/mcp/builtin-tools.js.map +1 -1
  104. package/dist/mcp/external-agent-policy.d.ts +19 -0
  105. package/dist/mcp/external-agent-policy.d.ts.map +1 -0
  106. package/dist/mcp/external-agent-policy.js +2 -0
  107. package/dist/mcp/external-agent-policy.js.map +1 -0
  108. package/dist/mcp/index.d.ts +1 -0
  109. package/dist/mcp/index.d.ts.map +1 -1
  110. package/dist/mcp/index.js.map +1 -1
  111. package/dist/notifications/routes.d.ts +1 -1
  112. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  113. package/dist/server/agent-chat/plugin-options.d.ts +11 -1
  114. package/dist/server/agent-chat/plugin-options.d.ts.map +1 -1
  115. package/dist/server/agent-chat/plugin-options.js.map +1 -1
  116. package/dist/server/agent-chat/script-entries.d.ts.map +1 -1
  117. package/dist/server/agent-chat/script-entries.js +16 -2
  118. package/dist/server/agent-chat/script-entries.js.map +1 -1
  119. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  120. package/dist/server/agent-chat-plugin.js +3 -0
  121. package/dist/server/agent-chat-plugin.js.map +1 -1
  122. package/dist/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
  123. package/docs/content/external-agents.mdx +66 -7
  124. package/docs/content/locales/ar-SA/external-agents.mdx +40 -7
  125. package/docs/content/locales/de-DE/external-agents.mdx +41 -7
  126. package/docs/content/locales/es-ES/external-agents.mdx +41 -7
  127. package/docs/content/locales/fr-FR/external-agents.mdx +41 -7
  128. package/docs/content/locales/hi-IN/external-agents.mdx +40 -7
  129. package/docs/content/locales/ja-JP/external-agents.mdx +40 -7
  130. package/docs/content/locales/ko-KR/external-agents.mdx +40 -7
  131. package/docs/content/locales/pt-BR/external-agents.mdx +41 -7
  132. package/docs/content/locales/zh-CN/external-agents.mdx +39 -7
  133. package/docs/content/locales/zh-TW/external-agents.mdx +39 -7
  134. package/package.json +1 -1
  135. package/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +34 -5
@@ -200,11 +200,12 @@ export async function getA2ATaskDispatchState(id: string): Promise<{
200
200
  statusState: string;
201
201
  metadata: Record<string, unknown> | undefined;
202
202
  updatedAt: number;
203
+ createdAt: number;
203
204
  } | null> {
204
205
  await ensureTable();
205
206
  const client = getDbExec();
206
207
  const { rows } = await client.execute({
207
- sql: `SELECT id, status_state, metadata, updated_at FROM a2a_tasks WHERE id = ?`,
208
+ sql: `SELECT id, status_state, metadata, created_at, updated_at FROM a2a_tasks WHERE id = ?`,
208
209
  args: [id],
209
210
  });
210
211
  const row = rows[0] as any;
@@ -214,6 +215,7 @@ export async function getA2ATaskDispatchState(id: string): Promise<{
214
215
  statusState: row.status_state as string,
215
216
  metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
216
217
  updatedAt: Number(row.updated_at ?? 0),
218
+ createdAt: Number(row.created_at ?? 0),
217
219
  };
218
220
  }
219
221
 
@@ -269,10 +271,21 @@ export async function resetStuckA2ATaskForRetry(
269
271
  return affected !== 0;
270
272
  }
271
273
 
274
+ /**
275
+ * Fail a processing task once it is stuck. Two independent conditions can
276
+ * trigger this, either of which alone is sufficient:
277
+ * - `updated_at <= processingCutoff`: no heartbeat/progress touch in a
278
+ * while — the processor likely died.
279
+ * - `created_at <= createdAtCutoff` — a hard wall on total run time. A
280
+ * hung await inside a still-alive process keeps `updated_at` fresh via
281
+ * the liveness heartbeat forever, so staleness alone never trips; age
282
+ * since creation is the only bound that catches it.
283
+ */
272
284
  export async function failStuckA2ATask(
273
285
  id: string,
274
286
  processingCutoff: number,
275
287
  reason: string,
288
+ createdAtCutoff?: number,
276
289
  ): Promise<boolean> {
277
290
  await ensureTable();
278
291
  const client = getDbExec();
@@ -282,6 +295,10 @@ export async function failStuckA2ATask(
282
295
  role: "agent",
283
296
  parts: [{ type: "text", text: reason }],
284
297
  };
298
+ const ageCondition =
299
+ createdAtCutoff !== undefined
300
+ ? `AND (updated_at <= ? OR created_at <= ?)`
301
+ : `AND updated_at <= ?`;
285
302
  const result = await client.execute({
286
303
  sql: `UPDATE a2a_tasks
287
304
  SET status_state = 'failed',
@@ -290,8 +307,53 @@ export async function failStuckA2ATask(
290
307
  updated_at = ?
291
308
  WHERE id = ?
292
309
  AND status_state = 'processing'
293
- AND updated_at <= ?`,
294
- args: [JSON.stringify(message), timestamp, now, id, processingCutoff],
310
+ ${ageCondition}`,
311
+ args:
312
+ createdAtCutoff !== undefined
313
+ ? [
314
+ JSON.stringify(message),
315
+ timestamp,
316
+ now,
317
+ id,
318
+ processingCutoff,
319
+ createdAtCutoff,
320
+ ]
321
+ : [JSON.stringify(message), timestamp, now, id, processingCutoff],
322
+ });
323
+ const affected = getAffectedRowCount(result);
324
+ return affected !== 0;
325
+ }
326
+
327
+ /**
328
+ * Fail a queued (submitted/working) task whose age since creation exceeds
329
+ * `createdAtCutoff` — the dispatch-retry loop kept throttling/refiring
330
+ * without ever reaching `processing`. Mirrors `failStuckA2ATask` but is
331
+ * gated on the queued state set and on `created_at` (queued tasks have no
332
+ * heartbeat, so staleness of `updated_at` isn't a meaningful signal here).
333
+ */
334
+ export async function failStuckQueuedA2ATask(
335
+ id: string,
336
+ createdAtCutoff: number,
337
+ reason: string,
338
+ ): Promise<boolean> {
339
+ await ensureTable();
340
+ const client = getDbExec();
341
+ const now = Date.now();
342
+ const timestamp = new Date().toISOString();
343
+ const message: Message = {
344
+ role: "agent",
345
+ parts: [{ type: "text", text: reason }],
346
+ };
347
+ const result = await client.execute({
348
+ sql: `UPDATE a2a_tasks
349
+ SET status_state = 'failed',
350
+ status_message = ?,
351
+ status_timestamp = ?,
352
+ updated_at = ?
353
+ WHERE id = ?
354
+ AND status_state IN ('submitted', 'working')
355
+ AND created_at <= ?`,
356
+ args: [JSON.stringify(message), timestamp, now, id, createdAtCutoff],
295
357
  });
296
358
  const affected = getAffectedRowCount(result);
297
359
  return affected !== 0;
@@ -361,6 +423,69 @@ export async function updateTask(
361
423
  return task;
362
424
  }
363
425
 
426
+ /**
427
+ * Persist the terminal result produced by the async processor, but only while
428
+ * that processor still owns a task in `processing`. A tasks/get request may
429
+ * fail an over-lifetime processor while its handler is still running; the
430
+ * handler cannot be canceled reliably, so this compare-and-set is what keeps
431
+ * its eventual completion (or error) from overwriting the timeout result.
432
+ */
433
+ export async function settleProcessingA2ATask(
434
+ id: string,
435
+ update: {
436
+ state: "completed" | "failed";
437
+ message?: Message;
438
+ artifacts?: Artifact[];
439
+ },
440
+ ): Promise<Task | null> {
441
+ await ensureTable();
442
+ const client = getDbExec();
443
+
444
+ const { rows } = await client.execute({
445
+ sql: `SELECT * FROM a2a_tasks WHERE id = ?`,
446
+ args: [id],
447
+ });
448
+ if (rows.length === 0) return null;
449
+
450
+ const task = taskFromRow(rows[0]);
451
+ const now = Date.now();
452
+ task.status = {
453
+ state: update.state,
454
+ message: update.message ?? task.status.message,
455
+ timestamp: new Date().toISOString(),
456
+ };
457
+ if (update.message && task.history) {
458
+ task.history.push(update.message);
459
+ }
460
+ if (update.artifacts) {
461
+ task.artifacts = [...(task.artifacts ?? []), ...update.artifacts];
462
+ }
463
+
464
+ const result = await client.execute({
465
+ sql: `UPDATE a2a_tasks
466
+ SET status_state = ?,
467
+ status_message = ?,
468
+ status_timestamp = ?,
469
+ history = ?,
470
+ artifacts = ?,
471
+ updated_at = ?
472
+ WHERE id = ?
473
+ AND status_state = 'processing'`,
474
+ args: [
475
+ task.status.state,
476
+ task.status.message ? JSON.stringify(task.status.message) : null,
477
+ task.status.timestamp,
478
+ JSON.stringify(task.history),
479
+ JSON.stringify(task.artifacts),
480
+ now,
481
+ id,
482
+ ],
483
+ });
484
+ const affected = getAffectedRowCount(result);
485
+ if (affected === 0) return null;
486
+ return task;
487
+ }
488
+
364
489
  export async function updateTaskStatusMessage(
365
490
  id: string,
366
491
  message: Message,
@@ -65,6 +65,8 @@ interface RrwebRecordModule {
65
65
  interface SessionReplayState {
66
66
  active: boolean;
67
67
  startPromise: Promise<SessionReplayStartResult> | null;
68
+ /** Invalidates deferred recorder startup when stop is requested mid-start. */
69
+ startGeneration: number;
68
70
  replayId: string | null;
69
71
  startedAtMs: number | null;
70
72
  sequence: number;
@@ -432,6 +434,7 @@ function getState(): SessionReplayState {
432
434
  g[SESSION_REPLAY_STATE_KEY] = {
433
435
  active: false,
434
436
  startPromise: null,
437
+ startGeneration: 0,
435
438
  replayId: null,
436
439
  startedAtMs: null,
437
440
  sequence: 0,
@@ -464,6 +467,7 @@ function getState(): SessionReplayState {
464
467
  state.pendingFlushReason ??= null;
465
468
  state.pendingFlushWaiters ??= [];
466
469
  state.awaitingFullSnapshot ??= false;
470
+ state.startGeneration ??= 0;
467
471
  return state;
468
472
  }
469
473
 
@@ -1054,12 +1058,14 @@ const REPLAY_RESOURCE_TAGS = new Set([
1054
1058
  "audio",
1055
1059
  "track",
1056
1060
  "input",
1061
+ "object",
1057
1062
  "link",
1058
1063
  ]);
1059
1064
  const NO_REPLAY_RESOURCE_ATTRIBUTES = new Set<string>();
1060
1065
  const REPLAY_SRC_ATTRIBUTES = new Set(["src"]);
1061
1066
  const REPLAY_SRCSET_ATTRIBUTES = new Set(["src", "srcset"]);
1062
1067
  const REPLAY_VIDEO_ATTRIBUTES = new Set(["src", "poster"]);
1068
+ const REPLAY_OBJECT_ATTRIBUTES = new Set(["data"]);
1063
1069
  const REPLAY_HREF_ATTRIBUTES = new Set(["href"]);
1064
1070
 
1065
1071
  function replayAttributeString(
@@ -1101,6 +1107,8 @@ function replayPreservedResourceAttributes(
1101
1107
  case "audio":
1102
1108
  case "track":
1103
1109
  return REPLAY_SRC_ATTRIBUTES;
1110
+ case "object":
1111
+ return REPLAY_OBJECT_ATTRIBUTES;
1104
1112
  case "input":
1105
1113
  return node.type === "image"
1106
1114
  ? REPLAY_SRC_ATTRIBUTES
@@ -1452,12 +1460,12 @@ class ReplayUploadHttpError extends Error {
1452
1460
  }
1453
1461
  }
1454
1462
 
1455
- /** 4xx statuses where retrying the exact same batch can never succeed --
1456
- * e.g. a 409 chunk-sequence/checksum conflict, or a 400 the server will
1457
- * reject again. 408 (timeout) and 429 (rate limit) are excluded because a
1458
- * later retry can plausibly succeed. */
1463
+ /** 4xx statuses where retrying the exact same batch can never succeed.
1464
+ * Keep this deliberately narrow: 401/403/404 can be temporary during auth or
1465
+ * deploy transitions, and stopping forever on one of those would silently
1466
+ * black out the rest of a long-lived SPA session. */
1459
1467
  function isDefinitiveReplayUploadClientError(status: number): boolean {
1460
- return status >= 400 && status < 500 && status !== 408 && status !== 429;
1468
+ return status === 400 || status === 409 || status === 413 || status === 422;
1461
1469
  }
1462
1470
 
1463
1471
  async function sendReplayUpload(
@@ -1903,6 +1911,31 @@ async function restartSessionReplayAfterConflict(
1903
1911
  state: SessionReplayState,
1904
1912
  options: NormalizedSessionReplayOptions,
1905
1913
  sessionId: string,
1914
+ ): Promise<SessionReplayStartResult> {
1915
+ // Share the public-start mutex. A consumer may call start in the await gap
1916
+ // after the rejected recorder stops; both paths must converge on one rrweb
1917
+ // instance rather than racing two fresh identities.
1918
+ if (state.startPromise) return state.startPromise;
1919
+
1920
+ const startGeneration = ++state.startGeneration;
1921
+ let startPromise: Promise<SessionReplayStartResult>;
1922
+ startPromise = restartSessionReplayAfterConflictInternal(
1923
+ state,
1924
+ options,
1925
+ sessionId,
1926
+ startGeneration,
1927
+ ).finally(() => {
1928
+ if (state.startPromise === startPromise) state.startPromise = null;
1929
+ });
1930
+ state.startPromise = startPromise;
1931
+ return startPromise;
1932
+ }
1933
+
1934
+ async function restartSessionReplayAfterConflictInternal(
1935
+ state: SessionReplayState,
1936
+ options: NormalizedSessionReplayOptions,
1937
+ sessionId: string,
1938
+ startGeneration: number,
1906
1939
  ): Promise<SessionReplayStartResult> {
1907
1940
  if (options.shouldStart && !options.shouldStart()) {
1908
1941
  return { started: false, reason: "disabled", sessionId, sampled: true };
@@ -1932,6 +1965,7 @@ async function restartSessionReplayAfterConflict(
1932
1965
  sessionId,
1933
1966
  true,
1934
1967
  initialProperties,
1968
+ startGeneration,
1935
1969
  );
1936
1970
  }
1937
1971
 
@@ -2879,6 +2913,11 @@ export async function startSessionReplay(
2879
2913
  }
2880
2914
  if (state.startPromise) return state.startPromise;
2881
2915
 
2916
+ // This is a new caller-initiated recording episode. A prior episode's
2917
+ // conflict-loop guard must not prevent this one from recovering once.
2918
+ state.automaticConflictRestartAttempted = false;
2919
+ const startGeneration = ++state.startGeneration;
2920
+
2882
2921
  let startPromise: Promise<SessionReplayStartResult>;
2883
2922
  startPromise = startSessionReplayRecorder(
2884
2923
  state,
@@ -2886,6 +2925,7 @@ export async function startSessionReplay(
2886
2925
  sessionId,
2887
2926
  sampled,
2888
2927
  initialProperties,
2928
+ startGeneration,
2889
2929
  ).finally(() => {
2890
2930
  if (state.startPromise === startPromise) {
2891
2931
  state.startPromise = null;
@@ -2901,6 +2941,7 @@ async function startSessionReplayRecorder(
2901
2941
  sessionId: string,
2902
2942
  sampled: boolean,
2903
2943
  initialProperties: Record<string, unknown> | undefined,
2944
+ startGeneration: number,
2904
2945
  ): Promise<SessionReplayStartResult> {
2905
2946
  if (state.active && state.replayId) {
2906
2947
  return {
@@ -2918,6 +2959,9 @@ async function startSessionReplayRecorder(
2918
2959
  } catch {
2919
2960
  return { started: false, reason: "import-failed", sessionId, sampled };
2920
2961
  }
2962
+ if (state.startGeneration !== startGeneration) {
2963
+ return { started: false, reason: "disabled", sessionId, sampled };
2964
+ }
2921
2965
  if (normalized.shouldStart && !normalized.shouldStart()) {
2922
2966
  return { started: false, reason: "disabled", sessionId, sampled };
2923
2967
  }
@@ -2950,6 +2994,20 @@ async function startSessionReplayRecorder(
2950
2994
  };
2951
2995
  }
2952
2996
  }
2997
+ // stopSessionReplay may be called while the duplicate-tab probe is waiting.
2998
+ // Recheck both cancellation and the caller's live eligibility before rrweb
2999
+ // is activated so a deferred start cannot escape a route/auth teardown.
3000
+ if (
3001
+ state.startGeneration !== startGeneration ||
3002
+ (normalized.shouldStart && !normalized.shouldStart())
3003
+ ) {
3004
+ try {
3005
+ replayChannel?.close();
3006
+ } catch {
3007
+ // best-effort cleanup
3008
+ }
3009
+ return { started: false, reason: "disabled", sessionId, sampled };
3010
+ }
2953
3011
  state.options = normalized;
2954
3012
  state.replayId = replaySession.replayId;
2955
3013
  state.startedAtMs = replaySession.startedAtMs;
@@ -3049,6 +3107,10 @@ async function startSessionReplayRecorder(
3049
3107
 
3050
3108
  export async function stopSessionReplay(reason = "manual"): Promise<void> {
3051
3109
  const state = getState();
3110
+ // Invalidate an import/probe that has not set `active` yet. Without this,
3111
+ // stop during the duplicated-tab claim window was a no-op and rrweb started
3112
+ // after the caller believed recording had been disabled.
3113
+ state.startGeneration += 1;
3052
3114
  if (!state.active) return;
3053
3115
  // Restore console/fetch/XHR before tearing down the recorder: the restore
3054
3116
  // flushes any pending collapsed console duplicate, which must still be able
@@ -38,6 +38,48 @@ const SLACK_PERMALINK_TIMEOUT_MS = 1_000;
38
38
  const SLACK_CONTEXT_MESSAGE_LIMIT = 15;
39
39
  const SLACK_CONTEXT_TEXT_LIMIT = 2_000;
40
40
  const SLACK_CALM_PROGRESS_THRESHOLD_SECONDS = 30;
41
+ const SLACK_IDENTITY_TIMEOUT_MS = 1_000;
42
+ const SLACK_IDENTITY_CACHE_TTL_MS = 10 * 60 * 1_000;
43
+ // Failed users.info lookups only get a short negative TTL: a transient Slack
44
+ // API blip must not fail-close a sender's identity (and DMs) for 10 minutes.
45
+ const SLACK_IDENTITY_NEGATIVE_CACHE_TTL_MS = 30 * 1_000;
46
+ const SLACK_IDENTITY_CACHE_MAX_ENTRIES = 1_000;
47
+
48
+ interface SlackUserIdentity {
49
+ email: string | null;
50
+ name: string | null;
51
+ memberType: "owner" | "admin" | "member" | "guest" | "external" | "unknown";
52
+ }
53
+
54
+ const slackIdentityCache = new Map<
55
+ string,
56
+ { identity: SlackUserIdentity | null; expiresAt: number }
57
+ >();
58
+
59
+ // Deduped system notices send at most once per key per TTL so senders are
60
+ // informed without being spammed per message. Callers pick the window: the
61
+ // anonymous-tier heads-up uses the default day-long TTL; decline replies pass
62
+ // a short TTL so a persistent condition still reminds the sender occasionally.
63
+ const SLACK_SYSTEM_NOTICE_DEDUPE_TTL_MS = 24 * 60 * 60 * 1_000;
64
+ const SLACK_SYSTEM_NOTICE_CACHE_MAX_ENTRIES = 1_000;
65
+ const slackSystemNoticeCache = new Map<string, number>();
66
+
67
+ /** Returns true when a deduped notice should send now, claiming the slot. */
68
+ function claimSlackSystemNoticeSlot(
69
+ key: string,
70
+ ttlMs = SLACK_SYSTEM_NOTICE_DEDUPE_TTL_MS,
71
+ ): boolean {
72
+ const now = Date.now();
73
+ const expiresAt = slackSystemNoticeCache.get(key);
74
+ if (expiresAt && expiresAt > now) return false;
75
+ if (expiresAt) slackSystemNoticeCache.delete(key);
76
+ if (slackSystemNoticeCache.size >= SLACK_SYSTEM_NOTICE_CACHE_MAX_ENTRIES) {
77
+ const oldestKey = slackSystemNoticeCache.keys().next().value;
78
+ if (oldestKey) slackSystemNoticeCache.delete(oldestKey);
79
+ }
80
+ slackSystemNoticeCache.set(key, now + ttlMs);
81
+ return true;
82
+ }
41
83
 
42
84
  export interface SlackAdapterOptions {
43
85
  /** Resolve the bot token for the exact Slack installation. */
@@ -247,7 +289,10 @@ export function slackAdapter(
247
289
  triggerKind: isDm ? "dm" : isMention ? "mention" : "thread_reply",
248
290
  conversationType: isDm ? "dm" : "unknown",
249
291
  tenantId: teamId,
250
- actorTrust: { memberType: "unknown", verified: true },
292
+ // The signed Slack envelope authenticates the workspace, not the
293
+ // sender's membership tier. `users.info` hydration below is the
294
+ // authority that may promote this to a verified member.
295
+ actorTrust: { memberType: "unknown", verified: false },
251
296
  platformContext: {
252
297
  channelId: e.channel,
253
298
  channelType: e.channel_type,
@@ -356,6 +401,14 @@ export function slackAdapter(
356
401
  return hydrateSlackContext(token, incoming);
357
402
  },
358
403
 
404
+ async hydrateIncomingIdentity(
405
+ incoming: IncomingMessage,
406
+ ): Promise<IncomingMessage> {
407
+ const token = await resolveBotToken(incoming);
408
+ if (!token) return incoming;
409
+ return hydrateSlackIdentity(token, incoming);
410
+ },
411
+
359
412
  async startRunProgress(
360
413
  incoming: IncomingMessage,
361
414
  ): Promise<PlatformRunProgress | null> {
@@ -468,6 +521,48 @@ export function slackAdapter(
468
521
  }
469
522
  },
470
523
 
524
+ async sendSystemNotice(
525
+ incoming: IncomingMessage,
526
+ text: string,
527
+ opts?: { dedupeKey?: string; dedupeTtlMs?: number },
528
+ ): Promise<void> {
529
+ if (!text.trim()) return;
530
+ const dedupeKey = opts?.dedupeKey;
531
+ if (
532
+ dedupeKey &&
533
+ !claimSlackSystemNoticeSlot(dedupeKey, opts?.dedupeTtlMs)
534
+ ) {
535
+ return;
536
+ }
537
+ try {
538
+ const token = await resolveBotToken(incoming);
539
+ if (!token) {
540
+ if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
541
+ return;
542
+ }
543
+ const channelId = incoming.platformContext.channelId;
544
+ if (typeof channelId !== "string" || !channelId) {
545
+ if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
546
+ return;
547
+ }
548
+ const threadTs =
549
+ typeof incoming.platformContext.threadTs === "string"
550
+ ? incoming.platformContext.threadTs
551
+ : undefined;
552
+ await postFresh(token, channelId, threadTs, {
553
+ text,
554
+ unfurl_links: false,
555
+ unfurl_media: false,
556
+ mrkdwn: true,
557
+ });
558
+ } catch (error) {
559
+ // A failed delivery did not inform the sender, so release the slot and
560
+ // allow the next message to retry instead of suppressing notices for a day.
561
+ if (dedupeKey) slackSystemNoticeCache.delete(dedupeKey);
562
+ throw error;
563
+ }
564
+ },
565
+
471
566
  async sendMessageToTarget(
472
567
  message: OutgoingMessage,
473
568
  target: OutboundTarget,
@@ -1070,6 +1165,102 @@ async function slackJson(
1070
1165
  }
1071
1166
  }
1072
1167
 
1168
+ function slackIdentityCacheKey(incoming: IncomingMessage): string | null {
1169
+ const teamId = incoming.platformContext.teamId;
1170
+ const senderId = incoming.senderId;
1171
+ if (typeof senderId !== "string" || !senderId.trim()) return null;
1172
+ return `${typeof teamId === "string" && teamId ? teamId : "unknown"}:${senderId}`;
1173
+ }
1174
+
1175
+ async function resolveSlackUserIdentity(
1176
+ token: string,
1177
+ incoming: IncomingMessage,
1178
+ ): Promise<SlackUserIdentity | null> {
1179
+ const cacheKey = slackIdentityCacheKey(incoming);
1180
+ if (!cacheKey) return null;
1181
+ const cached = slackIdentityCache.get(cacheKey);
1182
+ if (cached && cached.expiresAt > Date.now()) return cached.identity;
1183
+ if (cached) slackIdentityCache.delete(cacheKey);
1184
+
1185
+ const user = await slackJson(
1186
+ token,
1187
+ "users.info",
1188
+ { user: incoming.senderId! },
1189
+ SLACK_IDENTITY_TIMEOUT_MS,
1190
+ );
1191
+ const profile = user?.user?.profile;
1192
+ const identity: SlackUserIdentity | null = user?.user
1193
+ ? {
1194
+ email:
1195
+ typeof profile?.email === "string" && profile.email.trim()
1196
+ ? profile.email.trim().toLowerCase()
1197
+ : null,
1198
+ name:
1199
+ typeof profile?.real_name === "string" && profile.real_name.trim()
1200
+ ? profile.real_name.trim()
1201
+ : typeof profile?.display_name === "string" &&
1202
+ profile.display_name.trim()
1203
+ ? profile.display_name.trim()
1204
+ : typeof user.user.real_name === "string" &&
1205
+ user.user.real_name.trim()
1206
+ ? user.user.real_name.trim()
1207
+ : typeof user.user.name === "string" && user.user.name.trim()
1208
+ ? user.user.name.trim()
1209
+ : null,
1210
+ // is_stranger marks Slack Connect DM participants from another
1211
+ // workspace; they must map to "external", never "member".
1212
+ memberType:
1213
+ user.user.is_stranger || user.user.is_ultra_restricted
1214
+ ? "external"
1215
+ : user.user.is_restricted
1216
+ ? "guest"
1217
+ : user.user.is_owner
1218
+ ? "owner"
1219
+ : user.user.is_admin
1220
+ ? "admin"
1221
+ : "member",
1222
+ }
1223
+ : null;
1224
+ if (slackIdentityCache.size >= SLACK_IDENTITY_CACHE_MAX_ENTRIES) {
1225
+ const oldestKey = slackIdentityCache.keys().next().value;
1226
+ if (oldestKey) slackIdentityCache.delete(oldestKey);
1227
+ }
1228
+ slackIdentityCache.set(cacheKey, {
1229
+ identity,
1230
+ expiresAt:
1231
+ Date.now() +
1232
+ (identity
1233
+ ? SLACK_IDENTITY_CACHE_TTL_MS
1234
+ : SLACK_IDENTITY_NEGATIVE_CACHE_TTL_MS),
1235
+ });
1236
+ return identity;
1237
+ }
1238
+
1239
+ async function hydrateSlackIdentity(
1240
+ token: string,
1241
+ incoming: IncomingMessage,
1242
+ ): Promise<IncomingMessage> {
1243
+ const identity = await resolveSlackUserIdentity(token, incoming);
1244
+ if (!identity) {
1245
+ return {
1246
+ ...incoming,
1247
+ senderVerified: false,
1248
+ actorTrust: { memberType: "unknown", verified: false },
1249
+ };
1250
+ }
1251
+ return {
1252
+ ...incoming,
1253
+ ...(identity.name ? { senderName: identity.name } : {}),
1254
+ ...(identity.email
1255
+ ? { senderEmail: identity.email, senderVerified: true }
1256
+ : { senderVerified: false }),
1257
+ actorTrust: {
1258
+ memberType: identity.memberType,
1259
+ verified: true,
1260
+ },
1261
+ };
1262
+ }
1263
+
1073
1264
  async function hydrateSlackContext(
1074
1265
  token: string,
1075
1266
  incoming: IncomingMessage,
@@ -1081,7 +1272,7 @@ async function hydrateSlackContext(
1081
1272
  return incoming;
1082
1273
  }
1083
1274
 
1084
- const [thread, history, pins, conversation, user] = await Promise.all([
1275
+ const [thread, history, pins, conversation, identity] = await Promise.all([
1085
1276
  slackJson(token, "conversations.replies", {
1086
1277
  channel: channelId,
1087
1278
  ts: threadTs,
@@ -1095,7 +1286,7 @@ async function hydrateSlackContext(
1095
1286
  }),
1096
1287
  slackJson(token, "pins.list", { channel: channelId }),
1097
1288
  slackJson(token, "conversations.info", { channel: channelId }),
1098
- senderId ? slackJson(token, "users.info", { user: senderId }) : null,
1289
+ senderId ? resolveSlackUserIdentity(token, incoming) : null,
1099
1290
  ]);
1100
1291
 
1101
1292
  // Agent View can attach the Slack surface the user is currently viewing to
@@ -1186,9 +1377,7 @@ async function hydrateSlackContext(
1186
1377
  })
1187
1378
  : [];
1188
1379
 
1189
- const profile = user?.user?.profile;
1190
- const isGuest = user?.user?.is_restricted === true;
1191
- const isExternal = user?.user?.is_ultra_restricted === true;
1380
+ const profile = identity;
1192
1381
  const isPrivate = conversation?.channel?.is_private === true;
1193
1382
  const isExternalShared = conversation?.channel?.is_ext_shared === true;
1194
1383
  const isMpim = conversation?.channel?.is_mpim === true;
@@ -1231,19 +1420,20 @@ async function hydrateSlackContext(
1231
1420
  );
1232
1421
  return {
1233
1422
  ...incoming,
1234
- ...(typeof profile?.display_name === "string" && profile.display_name
1235
- ? { senderName: profile.display_name }
1236
- : typeof profile?.real_name === "string" && profile.real_name
1237
- ? { senderName: profile.real_name }
1238
- : {}),
1239
- ...(typeof profile?.email === "string"
1423
+ ...(profile?.name ? { senderName: profile.name } : {}),
1424
+ ...(profile?.email
1240
1425
  ? { senderEmail: profile.email, senderVerified: true }
1241
- : {}),
1426
+ : { senderVerified: false }),
1242
1427
  conversationType,
1243
- actorTrust: {
1244
- memberType: isExternal ? "external" : isGuest ? "guest" : "member",
1245
- verified: true,
1246
- },
1428
+ // Conversation hydration must not promote a caller when users.info was
1429
+ // unavailable. Preserve the result of the earlier identity-only hydration
1430
+ // unless this request independently resolved the Slack user.
1431
+ actorTrust: identity
1432
+ ? { memberType: identity.memberType, verified: true }
1433
+ : (incoming.actorTrust ?? {
1434
+ memberType: "unknown",
1435
+ verified: false,
1436
+ }),
1247
1437
  contextMessages: hydratedMessages,
1248
1438
  ...(hydratedFiles.length ? { files: hydratedFiles.slice(0, 20) } : {}),
1249
1439
  platformContext: {