@agent-native/core 0.84.18 → 0.84.20

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 (55) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/run-store.ts +180 -13
  5. package/corpus/core/src/client/AssistantChat.tsx +49 -0
  6. package/corpus/core/src/client/active-run-state.ts +30 -0
  7. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +41 -3
  8. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +133 -0
  9. package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +2 -2
  10. package/corpus/core/src/extensions/html-shell.ts +17 -3
  11. package/corpus/core/src/extensions/theme.ts +39 -0
  12. package/corpus/templates/analytics/actions/provider-api-request.ts +8 -1
  13. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -1
  14. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +91 -33
  15. package/corpus/templates/clips/changelog/2026-07-01-chrome-recording-uploads-now-serialize-chunks-to-avoid-resum.md +6 -0
  16. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  17. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +30 -23
  18. package/dist/agent/run-store.d.ts +19 -6
  19. package/dist/agent/run-store.d.ts.map +1 -1
  20. package/dist/agent/run-store.js +163 -13
  21. package/dist/agent/run-store.js.map +1 -1
  22. package/dist/client/AssistantChat.d.ts +2 -0
  23. package/dist/client/AssistantChat.d.ts.map +1 -1
  24. package/dist/client/AssistantChat.js +36 -2
  25. package/dist/client/AssistantChat.js.map +1 -1
  26. package/dist/client/active-run-state.d.ts +3 -0
  27. package/dist/client/active-run-state.d.ts.map +1 -1
  28. package/dist/client/active-run-state.js +24 -0
  29. package/dist/client/active-run-state.js.map +1 -1
  30. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  31. package/dist/client/extensions/EmbeddedExtension.js +32 -3
  32. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  33. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  34. package/dist/client/extensions/ExtensionViewer.js +59 -2
  35. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  36. package/dist/client/extensions/ExtensionsSidebarSection.js +2 -2
  37. package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
  38. package/dist/collab/awareness.d.ts +2 -2
  39. package/dist/collab/awareness.d.ts.map +1 -1
  40. package/dist/collab/routes.d.ts +2 -2
  41. package/dist/extensions/html-shell.d.ts.map +1 -1
  42. package/dist/extensions/html-shell.js +17 -3
  43. package/dist/extensions/html-shell.js.map +1 -1
  44. package/dist/extensions/theme.d.ts +9 -0
  45. package/dist/extensions/theme.d.ts.map +1 -1
  46. package/dist/extensions/theme.js +38 -0
  47. package/dist/extensions/theme.js.map +1 -1
  48. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  49. package/dist/notifications/routes.d.ts +3 -3
  50. package/dist/observability/routes.d.ts +6 -6
  51. package/dist/progress/routes.d.ts +1 -1
  52. package/dist/resources/handlers.d.ts +3 -3
  53. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  54. package/dist/server/transcribe-voice.d.ts +1 -1
  55. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2043
31
- - template files: 4927
31
+ - template files: 4928
@@ -1,5 +1,18 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.84.20
4
+
5
+ ### Patch Changes
6
+
7
+ - 93ed23b: Use a filter icon instead of a gear icon for the Extensions sidebar section sort/filter options trigger.
8
+ - ce62d53: Fix extension iframe theme inheritance and add source-code editor. Extensions now correctly inherit the host app's dark/light theme and CSS custom properties instead of rendering with a white background. Adds a raw HTML/Alpine.js source editor dialog to ExtensionViewer so extensions can be edited directly without going through the AI chat.
9
+
10
+ ## 0.84.19
11
+
12
+ ### Patch Changes
13
+
14
+ - 92b576f: Keep durable background agent runs from being reported as stale after a terminal event was already persisted, and show tool activity during tail reconnects.
15
+
3
16
  ## 0.84.18
4
17
 
5
18
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.84.18",
3
+ "version": "0.84.20",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -29,6 +29,8 @@ export const RUN_STALE_MS = 15_000;
29
29
  * With the normal 15s window the reaper would falsely kill that freshly-
30
30
  * inserted-but-not-yet-heartbeaten row. 90s tolerates a slow background
31
31
  * cold-start while still reaping a genuinely dead background worker promptly.
32
+ * Claimed background workers heartbeat during long work; the stale watchdog is a
33
+ * liveness timeout, not the Netlify background-function execution budget.
32
34
  *
33
35
  * Only applied to rows explicitly marked background-dispatched; ordinary
34
36
  * foreground runs keep the tight 15s window unchanged.
@@ -85,13 +87,14 @@ export const CLAIMED_BACKGROUND_WORKER_FAILED_ERROR_EVENT = {
85
87
  * Grace period before a never-claimed background run (dispatch_mode still
86
88
  * 'background', no worker claim) is treated as a dead handoff and reaped.
87
89
  *
88
- * This is intentionally MUCH tighter than `BACKGROUND_RUN_STALE_MS` (90s). The
89
- * wide 90s window exists ONLY to protect a CLAIMED worker whose heartbeat lags
90
- * during a slow cold start. A run that is still `dispatch_mode = 'background'`
91
- * has, by definition, NO worker — nothing to protect — so once a Netlify
90
+ * This is intentionally tighter than `BACKGROUND_RUN_STALE_MS`. That wider
91
+ * window protects cold-starting or temporarily delayed background dispatches,
92
+ * while claimed workers stay alive by heartbeat/progress updates. A run that is
93
+ * still `dispatch_mode = 'background'` has, by definition, NO worker — nothing
94
+ * to protect — so once a Netlify
92
95
  * background function has had a reasonable cold-start window to claim it and
93
96
  * hasn't, the handoff is dead and should surface promptly instead of leaving
94
- * the user staring at a spinner for 90s. 25s comfortably exceeds a normal
97
+ * the user staring at a spinner for the durable-worker window. 25s comfortably exceeds a normal
95
98
  * Netlify Lambda cold start while still failing fast on a silent worker death.
96
99
  */
97
100
  export const UNCLAIMED_BACKGROUND_RUN_GRACE_MS = 25_000;
@@ -125,6 +128,7 @@ async function ensureRunTables(): Promise<void> {
125
128
  CREATE TABLE IF NOT EXISTS agent_run_events (
126
129
  run_id TEXT NOT NULL,
127
130
  seq ${intType()} NOT NULL,
131
+ event_at ${intType()},
128
132
  event_data TEXT NOT NULL,
129
133
  PRIMARY KEY (run_id, seq)
130
134
  )
@@ -200,6 +204,11 @@ async function ensureRunTables(): Promise<void> {
200
204
  );
201
205
  }
202
206
  await ensureTableExists("agent_run_events", agentRunEventsCreateSql);
207
+ await ensureColumnExists(
208
+ "agent_run_events",
209
+ "event_at",
210
+ `ALTER TABLE agent_run_events ADD COLUMN IF NOT EXISTS event_at ${intType()}`,
211
+ );
203
212
  await ensureTableExists("agent_tool_ledger", agentToolLedgerCreateSql);
204
213
  // Widen millisecond-timestamp columns that older deployments created as
205
214
  // 32-bit `INTEGER`. `insertRun()` writes `Date.now()` into `started_at`
@@ -213,6 +222,7 @@ async function ensureRunTables(): Promise<void> {
213
222
  "heartbeat_at",
214
223
  "last_progress_at",
215
224
  ]);
225
+ await widenIntColumnsToBigInt("agent_run_events", ["event_at"]);
216
226
  await widenIntColumnsToBigInt("agent_tool_ledger", ["completed_at"]);
217
227
  return;
218
228
  }
@@ -278,6 +288,13 @@ async function ensureRunTables(): Promise<void> {
278
288
  }
279
289
  }
280
290
  await client.execute(agentRunEventsCreateSql);
291
+ try {
292
+ await client.execute(
293
+ `ALTER TABLE agent_run_events ADD COLUMN event_at ${intType()}`,
294
+ );
295
+ } catch {
296
+ // Column already exists — ignore
297
+ }
281
298
  await client.execute(agentToolLedgerCreateSql);
282
299
  // Widen millisecond-timestamp columns that older deployments created as
283
300
  // 32-bit `INTEGER`. `insertRun()` writes `Date.now()` into `started_at`
@@ -291,6 +308,7 @@ async function ensureRunTables(): Promise<void> {
291
308
  "heartbeat_at",
292
309
  "last_progress_at",
293
310
  ]);
311
+ await widenIntColumnsToBigInt("agent_run_events", ["event_at"]);
294
312
  await widenIntColumnsToBigInt("agent_tool_ledger", ["completed_at"]);
295
313
  })().catch((err) => {
296
314
  // Retry init on the next call after a failed startup.
@@ -431,6 +449,20 @@ function backgroundAwareStaleCutoffSql(): string {
431
449
  return `(CAST(? AS BIGINT) - CASE WHEN dispatch_mode LIKE 'background%' THEN ${BACKGROUND_RUN_STALE_MS} ELSE ${RUN_STALE_MS} END)`;
432
450
  }
433
451
 
452
+ function terminalRunEventExclusionSql(runIdColumn = "id"): string {
453
+ return `NOT EXISTS (
454
+ SELECT 1 FROM agent_run_events terminal_events
455
+ WHERE terminal_events.run_id = agent_runs.${runIdColumn}
456
+ AND (
457
+ terminal_events.event_data LIKE '{"type":"done"%'
458
+ OR terminal_events.event_data LIKE '{"type":"error"%'
459
+ OR terminal_events.event_data LIKE '{"type":"missing_api_key"%'
460
+ OR terminal_events.event_data LIKE '{"type":"loop_limit"%'
461
+ OR terminal_events.event_data LIKE '{"type":"auto_continue"%'
462
+ )
463
+ )`;
464
+ }
465
+
434
466
  /**
435
467
  * Liveness basis for the stale reapers: the MOST RECENT of `heartbeat_at`
436
468
  * ("process is up", bumped on a 1.5s timer) and `last_progress_at` ("real work
@@ -573,6 +605,7 @@ export async function tryClaimRunSlot(
573
605
  sql: `SELECT id FROM agent_runs
574
606
  WHERE thread_id = ?
575
607
  AND status = 'running'
608
+ AND ${terminalRunEventExclusionSql()}
576
609
  AND COALESCE(heartbeat_at, started_at) >= ?
577
610
  ORDER BY started_at DESC LIMIT 1`,
578
611
  args: [threadId, heartbeatCutoff],
@@ -586,6 +619,7 @@ export async function tryClaimRunSlot(
586
619
  sql: `SELECT id FROM agent_runs
587
620
  WHERE thread_id = ?
588
621
  AND status = 'running'
622
+ AND ${terminalRunEventExclusionSql()}
589
623
  AND COALESCE(heartbeat_at, started_at) >= ${backgroundAwareStaleCutoffSql()}
590
624
  ORDER BY started_at DESC LIMIT 1`,
591
625
  args: [threadId, now],
@@ -646,6 +680,112 @@ export async function setRunTerminalReason(
646
680
  }
647
681
  }
648
682
 
683
+ function terminalStatusForEvent(
684
+ event: AgentChatEvent,
685
+ ): "completed" | "errored" | null {
686
+ if (event.type === "error") return "errored";
687
+ if (
688
+ event.type === "done" ||
689
+ event.type === "missing_api_key" ||
690
+ event.type === "loop_limit" ||
691
+ event.type === "auto_continue"
692
+ ) {
693
+ return "completed";
694
+ }
695
+ return null;
696
+ }
697
+
698
+ function terminalReasonForEvent(event: AgentChatEvent): string | null {
699
+ if (event.type === "auto_continue") return event.reason || "auto_continue";
700
+ if (event.type === "loop_limit") return "loop_limit";
701
+ if (event.type === "missing_api_key") return "missing_api_key";
702
+ if (event.type === "error") return `error:${event.errorCode || "unknown"}`;
703
+ if (event.type === "done") return "done";
704
+ return null;
705
+ }
706
+
707
+ async function getLatestRunEvent(runId: string): Promise<{
708
+ event: AgentChatEvent;
709
+ eventAt: number | null;
710
+ } | null> {
711
+ const client = getDbExec();
712
+ const { rows } = await client.execute({
713
+ sql: `SELECT seq, event_data, event_at FROM agent_run_events WHERE run_id = ? ORDER BY seq DESC LIMIT 1`,
714
+ args: [runId],
715
+ });
716
+ const row = rows[0] as
717
+ | {
718
+ event_at?: number | string | null;
719
+ event_data?: string;
720
+ }
721
+ | undefined;
722
+ const raw = row?.event_data;
723
+ if (!raw) return null;
724
+ try {
725
+ const eventAt = row.event_at == null ? NaN : Number(row.event_at);
726
+ return {
727
+ event: JSON.parse(raw) as AgentChatEvent,
728
+ eventAt: Number.isFinite(eventAt) && eventAt > 0 ? eventAt : null,
729
+ };
730
+ } catch {
731
+ return null;
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Repair a run whose terminal event was durably appended but whose final
737
+ * `agent_runs.status` write lost a race with reconnect/reaper code.
738
+ *
739
+ * The event ledger is the durable transcript users see. If its latest event is
740
+ * terminal, the run is no longer alive and must not be converted into a stale
741
+ * error later. This keeps `agent_runs` and `agent_run_events` from telling two
742
+ * different stories after delayed DB writes or background function teardown.
743
+ */
744
+ export async function reconcileTerminalRunFromEvents(
745
+ runId: string,
746
+ ): Promise<boolean> {
747
+ await ensureRunTables();
748
+ const latest = await getLatestRunEvent(runId);
749
+ if (!latest) return false;
750
+ const status = terminalStatusForEvent(latest.event);
751
+ const terminalReason = terminalReasonForEvent(latest.event);
752
+ if (!status || !terminalReason) return false;
753
+
754
+ const client = getDbExec();
755
+ const errorCode =
756
+ latest.event.type === "error" ? (latest.event.errorCode ?? null) : null;
757
+ const errorDetail =
758
+ latest.event.type === "error"
759
+ ? (latest.event.details || latest.event.error || "").slice(0, 2000) ||
760
+ null
761
+ : null;
762
+ const { rowsAffected } = await client.execute({
763
+ sql: `UPDATE agent_runs
764
+ SET status = ?,
765
+ completed_at = COALESCE(completed_at, ?, ${livenessBasisSql()}),
766
+ error_code = CASE WHEN ? IS NOT NULL THEN ? ELSE error_code END,
767
+ error_detail = CASE WHEN ? IS NOT NULL THEN ? ELSE error_detail END,
768
+ terminal_reason = COALESCE(terminal_reason, ?)
769
+ WHERE id = ?
770
+ AND (
771
+ status = 'running'
772
+ OR (status = 'errored' AND error_code = ?)
773
+ )`,
774
+ args: [
775
+ status,
776
+ latest.eventAt,
777
+ errorCode,
778
+ errorCode,
779
+ errorDetail,
780
+ errorDetail,
781
+ terminalReason,
782
+ runId,
783
+ STALE_RUN_ERROR_EVENT.errorCode,
784
+ ],
785
+ });
786
+ return (rowsAffected ?? 0) > 0;
787
+ }
788
+
649
789
  /**
650
790
  * Diagnostic stage names recorded onto a background run as it moves through the
651
791
  * `_process-run` worker pipeline. Each value is the LAST stage successfully
@@ -774,6 +914,7 @@ export async function reapIfStale(
774
914
  maxStaleMs?: number,
775
915
  ): Promise<boolean> {
776
916
  await ensureRunTables();
917
+ if (await reconcileTerminalRunFromEvents(runId)) return false;
777
918
  const client = getDbExec();
778
919
  const completedAt = Date.now();
779
920
  // Background-dispatched runs get the wider stale window so a slow cold-start
@@ -794,6 +935,7 @@ export async function reapIfStale(
794
935
  terminal_reason = ?
795
936
  WHERE id = ?
796
937
  AND status = 'running'
938
+ AND ${terminalRunEventExclusionSql()}
797
939
  AND ${staleClause}`,
798
940
  args: [
799
941
  completedAt,
@@ -805,6 +947,7 @@ export async function reapIfStale(
805
947
  ],
806
948
  });
807
949
  const reaped = (rowsAffected ?? 0) > 0;
950
+ if (!reaped && (await reconcileTerminalRunFromEvents(runId))) return false;
808
951
  if (reaped) {
809
952
  await safeAppendTerminalRunEvent(
810
953
  runId,
@@ -823,7 +966,7 @@ export async function reapIfStale(
823
966
  * before it can claim), the row stays `background`, never heartbeats again, and
824
967
  * — because dispatch returned 202 — the foreground already returned the SSE
825
968
  * stream, so the existing fast-fail inline fallback never engaged. The run would
826
- * otherwise hang for the full 90s background window and then error opaquely.
969
+ * otherwise hang for the full durable background window and then error opaquely.
827
970
  *
828
971
  * This reaps such a run EARLY and DISTINCTLY: a row that is still unclaimed
829
972
  * (`dispatch_mode = 'background'`) past the tight `UNCLAIMED_BACKGROUND_RUN_GRACE_MS`
@@ -973,8 +1116,8 @@ export async function insertRunEvent(
973
1116
  // run aborts at the same time the producer emits its final event.
974
1117
  // Treat the second write as a no-op so the run completes cleanly.
975
1118
  await client.execute({
976
- sql: `INSERT INTO agent_run_events (run_id, seq, event_data) VALUES (?, ?, ?) ON CONFLICT (run_id, seq) DO NOTHING`,
977
- args: [runId, seq, eventData],
1119
+ sql: `INSERT INTO agent_run_events (run_id, seq, event_at, event_data) VALUES (?, ?, ?, ?) ON CONFLICT (run_id, seq) DO NOTHING`,
1120
+ args: [runId, seq, Date.now(), eventData],
978
1121
  });
979
1122
  }
980
1123
 
@@ -1040,8 +1183,8 @@ export async function getRunByThread(
1040
1183
  await ensureRunTables();
1041
1184
  const client = getDbExec();
1042
1185
  const sql = options?.includeTerminal
1043
- ? `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, dispatch_mode, terminal_reason, diag_stage FROM agent_runs WHERE thread_id = ? ORDER BY started_at DESC LIMIT 1`
1044
- : `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, dispatch_mode, terminal_reason, diag_stage FROM agent_runs WHERE thread_id = ? AND status = 'running' ORDER BY started_at DESC LIMIT 1`;
1186
+ ? `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, dispatch_mode, terminal_reason, diag_stage, error_code FROM agent_runs WHERE thread_id = ? ORDER BY started_at DESC LIMIT 1`
1187
+ : `SELECT id, thread_id, turn_id, status, started_at, heartbeat_at, completed_at, last_progress_at, dispatch_mode, terminal_reason, diag_stage, error_code FROM agent_runs WHERE thread_id = ? AND status = 'running' ORDER BY started_at DESC LIMIT 1`;
1045
1188
  const { rows } = await client.execute({ sql, args: [threadId] });
1046
1189
  if (rows.length === 0) return null;
1047
1190
  const r = rows[0] as {
@@ -1056,7 +1199,15 @@ export async function getRunByThread(
1056
1199
  dispatch_mode?: string | null;
1057
1200
  terminal_reason?: string | null;
1058
1201
  diag_stage?: string | null;
1202
+ error_code?: string | null;
1059
1203
  };
1204
+ const canReconcileFromEvents =
1205
+ r.status === "running" ||
1206
+ (r.status === "errored" &&
1207
+ r.error_code === STALE_RUN_ERROR_EVENT.errorCode);
1208
+ if (canReconcileFromEvents && (await reconcileTerminalRunFromEvents(r.id))) {
1209
+ return getRunByThread(threadId, options);
1210
+ }
1060
1211
  return {
1061
1212
  id: r.id,
1062
1213
  threadId: r.thread_id,
@@ -1212,6 +1363,12 @@ export async function reapAllStaleRuns(): Promise<number> {
1212
1363
  AND ${livenessBasisSql()} < ${backgroundAwareStaleCutoffSql()}`,
1213
1364
  args: [now],
1214
1365
  });
1366
+ for (const row of stale.rows) {
1367
+ const id = (row as { id?: unknown }).id;
1368
+ if (typeof id === "string") {
1369
+ await reconcileTerminalRunFromEvents(id);
1370
+ }
1371
+ }
1215
1372
  const completedAt = Date.now();
1216
1373
  const { rowsAffected } = await client.execute({
1217
1374
  sql: `UPDATE agent_runs
@@ -1221,6 +1378,7 @@ export async function reapAllStaleRuns(): Promise<number> {
1221
1378
  error_detail = ?,
1222
1379
  terminal_reason = ?
1223
1380
  WHERE status = 'running'
1381
+ AND ${terminalRunEventExclusionSql()}
1224
1382
  AND ${livenessBasisSql()} < ${backgroundAwareStaleCutoffSql()}`,
1225
1383
  args: [
1226
1384
  completedAt,
@@ -1272,6 +1430,12 @@ export async function cleanupOldRuns(
1272
1430
  )`,
1273
1431
  args: [now, cutoff],
1274
1432
  });
1433
+ for (const row of stale.rows) {
1434
+ const id = (row as { id?: unknown }).id;
1435
+ if (typeof id === "string") {
1436
+ await reconcileTerminalRunFromEvents(id);
1437
+ }
1438
+ }
1275
1439
  const completedAt = Date.now();
1276
1440
  await client.execute({
1277
1441
  sql: `UPDATE agent_runs
@@ -1280,7 +1444,9 @@ export async function cleanupOldRuns(
1280
1444
  error_code = ?,
1281
1445
  error_detail = ?,
1282
1446
  terminal_reason = ?
1283
- WHERE status = 'running' AND started_at < ?`,
1447
+ WHERE status = 'running'
1448
+ AND ${terminalRunEventExclusionSql()}
1449
+ AND started_at < ?`,
1284
1450
  args: [
1285
1451
  completedAt,
1286
1452
  STALE_RUN_ERROR_EVENT.errorCode,
@@ -1299,6 +1465,7 @@ export async function cleanupOldRuns(
1299
1465
  error_detail = ?,
1300
1466
  terminal_reason = ?
1301
1467
  WHERE status = 'running'
1468
+ AND ${terminalRunEventExclusionSql()}
1302
1469
  AND ${livenessBasisSql()} < ${backgroundAwareStaleCutoffSql()}`,
1303
1470
  args: [
1304
1471
  completedAt,
@@ -1493,7 +1660,7 @@ async function appendTerminalRunEvent(
1493
1660
  }
1494
1661
  const nextSeq = last ? Number(last.seq ?? -1) + 1 : 0;
1495
1662
  await client.execute({
1496
- sql: `INSERT INTO agent_run_events (run_id, seq, event_data) VALUES (?, ?, ?) ON CONFLICT (run_id, seq) DO NOTHING`,
1497
- args: [runId, nextSeq, JSON.stringify(event)],
1663
+ sql: `INSERT INTO agent_run_events (run_id, seq, event_at, event_data) VALUES (?, ?, ?, ?) ON CONFLICT (run_id, seq) DO NOTHING`,
1664
+ args: [runId, nextSeq, Date.now(), JSON.stringify(event)],
1498
1665
  });
1499
1666
  }
@@ -37,9 +37,11 @@ import React, {
37
37
  import { LLM_MISSING_CREDENTIALS_MESSAGE } from "../agent/engine/credential-errors.js";
38
38
  import type { ReasoningEffort } from "../shared/reasoning-effort.js";
39
39
  import {
40
+ getActiveRunActivityTool,
40
41
  getActiveRun,
41
42
  resolveReconnectAfterSeq,
42
43
  setActiveRun,
44
+ updateActiveRunActivity,
43
45
  updateActiveRunSeq,
44
46
  } from "./active-run-state.js";
45
47
  import {
@@ -553,6 +555,23 @@ function reconnectContentFollowKey(content: ContentPart[]): string {
553
555
  return content.map(contentPartFollowKey).join("|");
554
556
  }
555
557
 
558
+ export function reconnectActivityFallbackContent(
559
+ toolName: string | null | undefined,
560
+ ): ContentPart[] {
561
+ const tool = toolName?.trim();
562
+ if (!tool) return [];
563
+ return [
564
+ {
565
+ type: "tool-call",
566
+ toolCallId: `reconnect-activity:${tool}`,
567
+ toolName: tool,
568
+ argsText: "",
569
+ args: {},
570
+ activity: true,
571
+ },
572
+ ];
573
+ }
574
+
556
575
  const RECOVERY_USER_MESSAGE_PREFIXES = [
557
576
  "Continue from where you left off",
558
577
  "Continue from where you stopped",
@@ -1395,6 +1414,9 @@ const AssistantChatInner = forwardRef<
1395
1414
  const [runningActivityLabel, setRunningActivityLabel] = useState<
1396
1415
  string | null
1397
1416
  >(null);
1417
+ const [runningActivityTool, setRunningActivityTool] = useState<string | null>(
1418
+ null,
1419
+ );
1398
1420
  // Delayed-reveal state for the activity label (see ACTIVITY_LABEL_REVEAL_DELAY_MS).
1399
1421
  // `latest` holds the most recent activity label; `surfaced` flips true once the
1400
1422
  // reveal timer fires; `timer` is the pending one-shot reveal.
@@ -1409,6 +1431,8 @@ const AssistantChatInner = forwardRef<
1409
1431
  latestActivityLabelRef.current = null;
1410
1432
  activityLabelSurfacedRef.current = false;
1411
1433
  setRunningActivityLabel(null);
1434
+ setRunningActivityTool(null);
1435
+ updateActiveRunActivity(null);
1412
1436
  }, []);
1413
1437
  const [reconnectContent, setReconnectContent] = useState<ContentPart[]>([]);
1414
1438
  // When stop is clicked during reconnect, keep content visible (don't wipe it)
@@ -1445,6 +1469,18 @@ const AssistantChatInner = forwardRef<
1445
1469
  : isReconnecting && reconnectContent.length > 0
1446
1470
  ? "Reconnecting"
1447
1471
  : "Thinking";
1472
+ const reconnectActivityContent = useMemo(
1473
+ () =>
1474
+ (isReconnecting || reconnectFrozen) && reconnectContent.length === 0
1475
+ ? reconnectActivityFallbackContent(runningActivityTool)
1476
+ : [],
1477
+ [
1478
+ isReconnecting,
1479
+ reconnectFrozen,
1480
+ reconnectContent.length,
1481
+ runningActivityTool,
1482
+ ],
1483
+ );
1448
1484
  const lastBroadcastRunningRef = useRef(isRunning);
1449
1485
  const tiptapRef = useRef<TiptapComposerHandle>(null);
1450
1486
  // Stable ref to the "stop active run" action so addToQueue can abort
@@ -1700,11 +1736,14 @@ const AssistantChatInner = forwardRef<
1700
1736
 
1701
1737
  reconnectRunIdRef.current = runId;
1702
1738
  const afterSeq = resolveReconnectAfterSeq(threadId, runId);
1739
+ const storedActivityTool = getActiveRunActivityTool(threadId, runId);
1740
+ setRunningActivityTool(storedActivityTool);
1703
1741
  setReconnectAfterSeq(afterSeq);
1704
1742
  setActiveRun({
1705
1743
  threadId,
1706
1744
  runId,
1707
1745
  lastSeq: afterSeq > 0 ? afterSeq - 1 : -1,
1746
+ ...(storedActivityTool ? { activityTool: storedActivityTool } : {}),
1708
1747
  });
1709
1748
  setIsReconnecting(true);
1710
1749
  setReconnectFrozen(false);
@@ -2407,7 +2446,10 @@ const AssistantChatInner = forwardRef<
2407
2446
  const label =
2408
2447
  typeof detail?.label === "string" ? detail.label.trim() : "";
2409
2448
  if (!label) return;
2449
+ const tool = typeof detail?.tool === "string" ? detail.tool.trim() : "";
2410
2450
  setIsAutoResuming(false);
2451
+ setRunningActivityTool(tool || null);
2452
+ updateActiveRunActivity(tool || null);
2411
2453
  latestActivityLabelRef.current = label;
2412
2454
  // Already past the delay → keep the visible label current.
2413
2455
  if (activityLabelSurfacedRef.current) {
@@ -3549,6 +3591,13 @@ const AssistantChatInner = forwardRef<
3549
3591
  reconnectContent.length > 0 && (
3550
3592
  <ReconnectStreamMessage content={reconnectContent} />
3551
3593
  )}
3594
+ {(isReconnecting || reconnectFrozen) &&
3595
+ reconnectAfterSeq > 0 &&
3596
+ reconnectActivityContent.length > 0 && (
3597
+ <ReconnectStreamMessage
3598
+ content={reconnectActivityContent}
3599
+ />
3600
+ )}
3552
3601
  {showRunningInUI && (
3553
3602
  <RunningActivityStatus label={runningStatusLabel} />
3554
3603
  )}
@@ -4,6 +4,13 @@ export interface ActiveRunState {
4
4
  threadId: string;
5
5
  runId: string;
6
6
  lastSeq: number;
7
+ activityTool?: string | null;
8
+ }
9
+
10
+ function normalizeActivityTool(toolName: unknown): string | null {
11
+ if (typeof toolName !== "string") return null;
12
+ const tool = toolName.trim();
13
+ return tool || null;
7
14
  }
8
15
 
9
16
  export function setActiveRun(state: ActiveRunState): void {
@@ -30,6 +37,29 @@ export function updateActiveRunSeq(seq: number): void {
30
37
  }
31
38
  }
32
39
 
40
+ export function updateActiveRunActivity(
41
+ toolName: string | null | undefined,
42
+ ): void {
43
+ const state = getActiveRun();
44
+ if (!state) return;
45
+ const activityTool = normalizeActivityTool(toolName);
46
+ if (activityTool) {
47
+ setActiveRun({ ...state, activityTool });
48
+ return;
49
+ }
50
+ const { activityTool: _activityTool, ...nextState } = state;
51
+ setActiveRun(nextState);
52
+ }
53
+
54
+ export function getActiveRunActivityTool(
55
+ threadId: string,
56
+ runId: string,
57
+ ): string | null {
58
+ const stored = getActiveRun();
59
+ if (stored?.threadId !== threadId || stored.runId !== runId) return null;
60
+ return normalizeActivityTool(stored.activityTool);
61
+ }
62
+
33
63
  export function clearActiveRun(): void {
34
64
  try {
35
65
  sessionStorage.removeItem(STORAGE_KEY);
@@ -9,8 +9,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
9
9
  import { useNavigate } from "react-router";
10
10
 
11
11
  import { extensionPath } from "../../extensions/path.js";
12
+ import { THEME_VAR_NAMES } from "../../extensions/theme.js";
12
13
  import { sendToAgentChat } from "../agent-chat.js";
13
14
  import { agentNativePath } from "../api-path.js";
15
+ import { useAppearance } from "../appearance.js";
14
16
  import {
15
17
  Popover,
16
18
  PopoverContent,
@@ -53,6 +55,22 @@ interface Extension {
53
55
  };
54
56
  }
55
57
 
58
+ // Read the host app's *actual* computed theme values for the shared token set
59
+ // (THEME_VAR_NAMES). The iframe ships with a generic baked palette
60
+ // (getThemeVars); syncing the live values keeps embedded extensions visually
61
+ // identical to the surrounding app even when a template overrides the default
62
+ // palette (e.g. analytics uses a neutral-gray dark theme, not near-black).
63
+ function readHostThemeVars(): Record<string, string> {
64
+ if (typeof document === "undefined") return {};
65
+ const computed = getComputedStyle(document.documentElement);
66
+ const vars: Record<string, string> = {};
67
+ for (const name of THEME_VAR_NAMES) {
68
+ const value = computed.getPropertyValue(name).trim();
69
+ if (value) vars[name] = value;
70
+ }
71
+ return vars;
72
+ }
73
+
56
74
  function serializeChatValue(value: unknown): string | undefined {
57
75
  if (value === undefined || value === null) return undefined;
58
76
  if (typeof value === "string") return value;
@@ -112,7 +130,11 @@ export function EmbeddedExtension({
112
130
  onReadyRef.current?.();
113
131
  };
114
132
  const [height, setHeight] = useState<number>(initialHeight);
115
- const [isDark, setIsDark] = useState(false);
133
+ const [isDark, setIsDark] = useState(
134
+ () =>
135
+ typeof document !== "undefined" &&
136
+ document.documentElement.classList.contains("dark"),
137
+ );
116
138
  // (audit H4) Mirror ExtensionViewer's role-aware gating; deny-by-default until
117
139
  // the iframe's render binding announcement arrives.
118
140
  const bridgeContextRef = useRef<BridgePolicyContext>({
@@ -200,11 +222,16 @@ export function EmbeddedExtension({
200
222
  readyFiredRef.current = false;
201
223
  }, [extensionId, extension?.updatedAt]);
202
224
 
225
+ const appearance = useAppearance();
226
+
203
227
  useEffect(() => {
204
228
  const win = iframeRef.current?.contentWindow;
205
229
  if (!win) return;
206
- win.postMessage({ type: "agent-native-theme-update", isDark }, "*");
207
- }, [isDark]);
230
+ win.postMessage(
231
+ { type: "agent-native-theme-update", isDark, vars: readHostThemeVars() },
232
+ "*",
233
+ );
234
+ }, [isDark, appearance]);
208
235
 
209
236
  // Forward slot context whenever it changes. The iframe's own load handler
210
237
  // posts the initial value once it's ready; this effect handles updates.
@@ -372,6 +399,17 @@ export function EmbeddedExtension({
372
399
  { type: "agent-native-slot-context", context: context ?? {} },
373
400
  "*",
374
401
  );
402
+ // Re-assert theme once the iframe document is live. The src bakes in
403
+ // the initial dark state, but this covers the race where isDark
404
+ // settled before the iframe's message listener existed.
405
+ iframeRef.current?.contentWindow?.postMessage(
406
+ {
407
+ type: "agent-native-theme-update",
408
+ isDark,
409
+ vars: readHostThemeVars(),
410
+ },
411
+ "*",
412
+ );
375
413
  // Fallback readiness signal in case the extension never reports a
376
414
  // height (e.g. fixed-height content that skips auto-resize).
377
415
  fireReady();