@agent-native/core 0.70.3 → 0.72.0

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 (221) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +51 -0
  3. package/corpus/core/docs/content/actions.md +23 -0
  4. package/corpus/core/docs/content/audit-log.md +111 -0
  5. package/corpus/core/docs/content/multi-app-workspace.md +2 -2
  6. package/corpus/core/docs/design/durable-agent-runs.md +458 -4
  7. package/corpus/core/package.json +2 -1
  8. package/corpus/core/src/action.ts +80 -1
  9. package/corpus/core/src/agent/durable-background.ts +192 -0
  10. package/corpus/core/src/agent/production-agent.ts +357 -13
  11. package/corpus/core/src/agent/run-manager.ts +66 -3
  12. package/corpus/core/src/agent/run-store.ts +143 -23
  13. package/corpus/core/src/agent/types.ts +20 -0
  14. package/corpus/core/src/application-state/store.ts +5 -0
  15. package/corpus/core/src/audit/actions/get-audit-event.ts +23 -0
  16. package/corpus/core/src/audit/actions/list-audit-events.ts +65 -0
  17. package/corpus/core/src/audit/cleanup-job.ts +100 -0
  18. package/corpus/core/src/audit/config.ts +91 -0
  19. package/corpus/core/src/audit/index.ts +43 -0
  20. package/corpus/core/src/audit/record.ts +143 -0
  21. package/corpus/core/src/audit/redact.ts +109 -0
  22. package/corpus/core/src/audit/store.ts +244 -0
  23. package/corpus/core/src/audit/types.ts +125 -0
  24. package/corpus/core/src/chat-threads/store.ts +10 -0
  25. package/corpus/core/src/client/AgentPanel.tsx +12 -9
  26. package/corpus/core/src/client/AssistantChat.tsx +6 -0
  27. package/corpus/core/src/client/blocks/library/FileTreeBlock.tsx +72 -14
  28. package/corpus/core/src/client/chat/repo-helpers.ts +38 -0
  29. package/corpus/core/src/db/client.ts +4 -0
  30. package/corpus/core/src/db/widen-columns.ts +75 -0
  31. package/corpus/core/src/deploy/build.ts +96 -0
  32. package/corpus/core/src/deploy/workspace-deploy.ts +121 -0
  33. package/corpus/core/src/mcp/build-server.ts +23 -13
  34. package/corpus/core/src/oauth-tokens/store.ts +6 -0
  35. package/corpus/core/src/provider-api/custom-registry.ts +8 -0
  36. package/corpus/core/src/resources/store.ts +10 -0
  37. package/corpus/core/src/scripts/runner.ts +6 -3
  38. package/corpus/core/src/server/action-discovery.ts +6 -0
  39. package/corpus/core/src/server/action-routes.ts +1 -0
  40. package/corpus/core/src/server/agent-chat-plugin.ts +133 -66
  41. package/corpus/core/src/server/auth.ts +5 -0
  42. package/corpus/core/src/server/core-routes-plugin.ts +14 -0
  43. package/corpus/core/src/server/security-headers.ts +9 -6
  44. package/corpus/core/src/settings/store.ts +6 -0
  45. package/corpus/core/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  46. package/corpus/core/src/usage/store.ts +6 -0
  47. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +19 -0
  48. package/corpus/templates/analytics/AGENTS.md +8 -0
  49. package/corpus/templates/analytics/actions/compose-dashboard.ts +317 -0
  50. package/corpus/templates/analytics/changelog/2026-06-23-build-large-first-party-analytics-dashboards-in-one-fast-cal.md +6 -0
  51. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +574 -0
  52. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +60 -6
  53. package/corpus/templates/calendar/changelog/2026-06-23-added-a-heads-up-explaining-google-s-app-not-verified-screen.md +6 -0
  54. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +20 -43
  55. package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +5 -1
  56. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +3 -51
  57. package/corpus/templates/clips/changelog/2026-06-23-dragging-the-desktop-camera-bubble-now-glides-to-a-stop-at-t.md +6 -0
  58. package/corpus/templates/clips/changelog/2026-06-23-public-clips-now-play-inline-in-slack-connect-a-workspac.md +6 -0
  59. package/corpus/templates/clips/chrome-extension/PERMISSIONS.md +72 -0
  60. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -10
  61. package/corpus/templates/clips/chrome-extension/src/background.ts +163 -32
  62. package/corpus/templates/clips/chrome-extension/src/content-script.ts +301 -24
  63. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +245 -67
  64. package/corpus/templates/clips/chrome-extension/src/overlay.css +77 -43
  65. package/corpus/templates/clips/chrome-extension/src/overlay.html +7 -1
  66. package/corpus/templates/clips/chrome-extension/src/overlay.ts +102 -38
  67. package/corpus/templates/clips/chrome-extension/src/popup.html +62 -5
  68. package/corpus/templates/clips/chrome-extension/src/popup.ts +275 -4
  69. package/corpus/templates/clips/chrome-extension/src/styles.css +34 -0
  70. package/corpus/templates/clips/chrome-extension/vite.config.ts +5 -0
  71. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +21 -16
  72. package/corpus/templates/clips/desktop/src/lib/recorder.ts +10 -17
  73. package/corpus/templates/clips/desktop/src/overlays/bubble.tsx +73 -23
  74. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +0 -6
  75. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +56 -46
  76. package/corpus/templates/clips/desktop/src/styles.css +33 -5
  77. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +113 -0
  78. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  79. package/corpus/templates/clips/server/lib/media-permissions.ts +5 -1
  80. package/corpus/templates/clips/shared/recording-audio.ts +62 -0
  81. package/corpus/templates/clips/shared/recording-core.ts +94 -0
  82. package/dist/action.d.ts +31 -0
  83. package/dist/action.d.ts.map +1 -1
  84. package/dist/action.js +45 -1
  85. package/dist/action.js.map +1 -1
  86. package/dist/agent/context-xray/schema.d.ts +1 -1
  87. package/dist/agent/durable-background.d.ts +60 -0
  88. package/dist/agent/durable-background.d.ts.map +1 -0
  89. package/dist/agent/durable-background.js +144 -0
  90. package/dist/agent/durable-background.js.map +1 -0
  91. package/dist/agent/observational-memory/schema.d.ts +1 -1
  92. package/dist/agent/production-agent.d.ts +20 -0
  93. package/dist/agent/production-agent.d.ts.map +1 -1
  94. package/dist/agent/production-agent.js +320 -14
  95. package/dist/agent/production-agent.js.map +1 -1
  96. package/dist/agent/run-manager.d.ts +48 -0
  97. package/dist/agent/run-manager.d.ts.map +1 -1
  98. package/dist/agent/run-manager.js +45 -3
  99. package/dist/agent/run-manager.js.map +1 -1
  100. package/dist/agent/run-store.d.ts +30 -1
  101. package/dist/agent/run-store.d.ts.map +1 -1
  102. package/dist/agent/run-store.js +138 -24
  103. package/dist/agent/run-store.js.map +1 -1
  104. package/dist/agent/types.d.ts +20 -0
  105. package/dist/agent/types.d.ts.map +1 -1
  106. package/dist/agent/types.js.map +1 -1
  107. package/dist/application-state/store.d.ts.map +1 -1
  108. package/dist/application-state/store.js +5 -0
  109. package/dist/application-state/store.js.map +1 -1
  110. package/dist/audit/actions/get-audit-event.d.ts +11 -0
  111. package/dist/audit/actions/get-audit-event.d.ts.map +1 -0
  112. package/dist/audit/actions/get-audit-event.js +22 -0
  113. package/dist/audit/actions/get-audit-event.js.map +1 -0
  114. package/dist/audit/actions/list-audit-events.d.ts +22 -0
  115. package/dist/audit/actions/list-audit-events.d.ts.map +1 -0
  116. package/dist/audit/actions/list-audit-events.js +61 -0
  117. package/dist/audit/actions/list-audit-events.js.map +1 -0
  118. package/dist/audit/cleanup-job.d.ts +12 -0
  119. package/dist/audit/cleanup-job.d.ts.map +1 -0
  120. package/dist/audit/cleanup-job.js +93 -0
  121. package/dist/audit/cleanup-job.js.map +1 -0
  122. package/dist/audit/config.d.ts +30 -0
  123. package/dist/audit/config.d.ts.map +1 -0
  124. package/dist/audit/config.js +65 -0
  125. package/dist/audit/config.js.map +1 -0
  126. package/dist/audit/index.d.ts +13 -0
  127. package/dist/audit/index.d.ts.map +1 -0
  128. package/dist/audit/index.js +6 -0
  129. package/dist/audit/index.js.map +1 -0
  130. package/dist/audit/record.d.ts +24 -0
  131. package/dist/audit/record.d.ts.map +1 -0
  132. package/dist/audit/record.js +106 -0
  133. package/dist/audit/record.js.map +1 -0
  134. package/dist/audit/redact.d.ts +28 -0
  135. package/dist/audit/redact.d.ts.map +1 -0
  136. package/dist/audit/redact.js +109 -0
  137. package/dist/audit/redact.js.map +1 -0
  138. package/dist/audit/store.d.ts +14 -0
  139. package/dist/audit/store.d.ts.map +1 -0
  140. package/dist/audit/store.js +219 -0
  141. package/dist/audit/store.js.map +1 -0
  142. package/dist/audit/types.d.ts +114 -0
  143. package/dist/audit/types.d.ts.map +1 -0
  144. package/dist/audit/types.js +15 -0
  145. package/dist/audit/types.js.map +1 -0
  146. package/dist/chat-threads/store.d.ts.map +1 -1
  147. package/dist/chat-threads/store.js +10 -0
  148. package/dist/chat-threads/store.js.map +1 -1
  149. package/dist/client/AgentPanel.d.ts.map +1 -1
  150. package/dist/client/AgentPanel.js +2 -2
  151. package/dist/client/AgentPanel.js.map +1 -1
  152. package/dist/client/AssistantChat.d.ts.map +1 -1
  153. package/dist/client/AssistantChat.js +6 -1
  154. package/dist/client/AssistantChat.js.map +1 -1
  155. package/dist/client/blocks/library/FileTreeBlock.d.ts.map +1 -1
  156. package/dist/client/blocks/library/FileTreeBlock.js +37 -4
  157. package/dist/client/blocks/library/FileTreeBlock.js.map +1 -1
  158. package/dist/client/chat/repo-helpers.d.ts +16 -0
  159. package/dist/client/chat/repo-helpers.d.ts.map +1 -1
  160. package/dist/client/chat/repo-helpers.js +40 -0
  161. package/dist/client/chat/repo-helpers.js.map +1 -1
  162. package/dist/db/client.d.ts.map +1 -1
  163. package/dist/db/client.js +3 -0
  164. package/dist/db/client.js.map +1 -1
  165. package/dist/db/widen-columns.d.ts +39 -0
  166. package/dist/db/widen-columns.d.ts.map +1 -0
  167. package/dist/db/widen-columns.js +73 -0
  168. package/dist/db/widen-columns.js.map +1 -0
  169. package/dist/deploy/build.d.ts +29 -0
  170. package/dist/deploy/build.d.ts.map +1 -1
  171. package/dist/deploy/build.js +85 -0
  172. package/dist/deploy/build.js.map +1 -1
  173. package/dist/deploy/workspace-deploy.d.ts.map +1 -1
  174. package/dist/deploy/workspace-deploy.js +108 -0
  175. package/dist/deploy/workspace-deploy.js.map +1 -1
  176. package/dist/mcp/build-server.d.ts.map +1 -1
  177. package/dist/mcp/build-server.js +24 -9
  178. package/dist/mcp/build-server.js.map +1 -1
  179. package/dist/oauth-tokens/store.d.ts.map +1 -1
  180. package/dist/oauth-tokens/store.js +6 -0
  181. package/dist/oauth-tokens/store.js.map +1 -1
  182. package/dist/provider-api/custom-registry.d.ts.map +1 -1
  183. package/dist/provider-api/custom-registry.js +8 -0
  184. package/dist/provider-api/custom-registry.js.map +1 -1
  185. package/dist/resources/store.d.ts.map +1 -1
  186. package/dist/resources/store.js +9 -0
  187. package/dist/resources/store.js.map +1 -1
  188. package/dist/scripts/runner.js +4 -3
  189. package/dist/scripts/runner.js.map +1 -1
  190. package/dist/server/action-discovery.d.ts.map +1 -1
  191. package/dist/server/action-discovery.js +6 -0
  192. package/dist/server/action-discovery.js.map +1 -1
  193. package/dist/server/action-routes.d.ts.map +1 -1
  194. package/dist/server/action-routes.js +1 -0
  195. package/dist/server/action-routes.js.map +1 -1
  196. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  197. package/dist/server/agent-chat-plugin.js +68 -13
  198. package/dist/server/agent-chat-plugin.js.map +1 -1
  199. package/dist/server/auth.d.ts.map +1 -1
  200. package/dist/server/auth.js +5 -0
  201. package/dist/server/auth.js.map +1 -1
  202. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  203. package/dist/server/core-routes-plugin.js +13 -0
  204. package/dist/server/core-routes-plugin.js.map +1 -1
  205. package/dist/server/security-headers.d.ts +8 -5
  206. package/dist/server/security-headers.d.ts.map +1 -1
  207. package/dist/server/security-headers.js +9 -6
  208. package/dist/server/security-headers.js.map +1 -1
  209. package/dist/settings/store.d.ts.map +1 -1
  210. package/dist/settings/store.js +6 -0
  211. package/dist/settings/store.js.map +1 -1
  212. package/dist/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
  213. package/dist/usage/store.d.ts.map +1 -1
  214. package/dist/usage/store.js +5 -0
  215. package/dist/usage/store.js.map +1 -1
  216. package/docs/content/actions.md +23 -0
  217. package/docs/content/audit-log.md +111 -0
  218. package/docs/content/multi-app-workspace.md +2 -2
  219. package/docs/design/durable-agent-runs.md +458 -4
  220. package/package.json +2 -1
  221. package/src/templates/workspace-core/.agents/skills/audit-log/SKILL.md +93 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Types for the framework audit log.
3
+ *
4
+ * The audit log is a durable, complete, access-scoped, append-only record of
5
+ * *who mutated what app data, when, from where, and — when it was the agent —
6
+ * in which run*. It is deliberately distinct from observability (sampled
7
+ * agent-run telemetry) and tracking (fire-and-forget product analytics): audit
8
+ * rows are complete, locally queryable, and scoped to the data they describe.
9
+ *
10
+ * Capture happens automatically at the action-execution seam (`defineAction`):
11
+ * every mutating action records an audit event after it runs. Read-only (GET /
12
+ * `readOnly`) actions are skipped unless they opt in via `audit.onRead`.
13
+ */
14
+
15
+ /** Outcome of an audited action call. */
16
+ export type AuditStatus = "success" | "error" | "denied";
17
+
18
+ /**
19
+ * Who performed the mutation.
20
+ * - `agent` — the in-app assistant / sub-agents / A2A (caller `"tool"`).
21
+ * - `human` — a person via the UI, HTTP, CLI, or an external MCP client.
22
+ * - `system` — no resolved identity (background jobs, schedules).
23
+ */
24
+ export type AuditActorKind = "agent" | "human" | "system";
25
+
26
+ export type AuditVisibility = "private" | "org" | "public";
27
+
28
+ /**
29
+ * The resource an action mutated, plus the ownership used to scope who can
30
+ * read the resulting audit event. Returned by an action's `audit.target`.
31
+ *
32
+ * `ownerEmail` / `orgId` / `visibility` default to the actor's identity when
33
+ * omitted, which is correct for the common case where a user mutates their own
34
+ * data. Provide them explicitly when a user edits a resource owned by someone
35
+ * else so the *owner* sees the change in their audit trail.
36
+ */
37
+ export interface AuditTarget {
38
+ type?: string;
39
+ id?: string;
40
+ ownerEmail?: string | null;
41
+ orgId?: string | null;
42
+ visibility?: AuditVisibility;
43
+ }
44
+
45
+ /** Metadata about the call, passed to `audit.target` / `audit.summary`. */
46
+ export interface AuditCallMeta {
47
+ status: AuditStatus;
48
+ caller: string;
49
+ userEmail?: string;
50
+ orgId?: string | null;
51
+ }
52
+
53
+ /**
54
+ * Per-action audit configuration, set on `defineAction({ audit })`.
55
+ *
56
+ * Audit is **default-on for mutating actions** — you only need this object to
57
+ * *tune* capture (declare the target, customize the summary) or to opt a
58
+ * read-only action in / a noisy action out.
59
+ */
60
+ export interface ActionAuditConfig {
61
+ /**
62
+ * Force audit on (`true`) or off (`false`). Overrides every default,
63
+ * including the read-only skip and the high-frequency denylist. When
64
+ * omitted, mutating actions are audited and read-only actions are not.
65
+ */
66
+ enabled?: boolean;
67
+ /**
68
+ * Audit this action even though it is read-only (GET). Use for sensitive
69
+ * reads worth recording — secret access, bulk export. Ignored when
70
+ * `enabled` is set explicitly.
71
+ */
72
+ onRead?: boolean;
73
+ /** Capture the (redacted) call arguments. Default `true`. */
74
+ recordInputs?: boolean;
75
+ /** Resolve the mutated resource + its ownership for scoped reads. */
76
+ target?: (
77
+ args: any,
78
+ result: unknown,
79
+ meta: AuditCallMeta,
80
+ ) => AuditTarget | null | undefined;
81
+ /** Build a short human-readable summary line for the event. */
82
+ summary?: (args: any, result: unknown, meta: AuditCallMeta) => string;
83
+ }
84
+
85
+ /** A persisted audit event. */
86
+ export interface AuditEvent {
87
+ id: string;
88
+ createdAt: number;
89
+ /** Action name (the registry key, e.g. `delete-recording`). */
90
+ action: string;
91
+ /** Invocation surface: tool | frontend | http | cli | mcp | a2a. */
92
+ caller: string;
93
+ actorKind: AuditActorKind;
94
+ /** Human on whose behalf it ran — populated even for agent calls. */
95
+ actorEmail: string | null;
96
+ orgId: string | null;
97
+ /** Agent conversation thread, when caller is the agent loop. */
98
+ threadId: string | null;
99
+ /** Agent turn that produced the mutation — the unit of one agent response. */
100
+ turnId: string | null;
101
+ targetType: string | null;
102
+ targetId: string | null;
103
+ status: AuditStatus;
104
+ summary: string | null;
105
+ /** Redacted JSON of the call arguments, or null. */
106
+ input: string | null;
107
+ errorCode: string | null;
108
+ /** Denormalized owner used to scope reads (defaults to the actor). */
109
+ ownerEmail: string | null;
110
+ visibility: AuditVisibility;
111
+ }
112
+
113
+ /** Filters for `queryAuditEvents`. */
114
+ export interface AuditQueryFilters {
115
+ targetType?: string;
116
+ targetId?: string;
117
+ actorKind?: AuditActorKind;
118
+ actorEmail?: string;
119
+ status?: AuditStatus;
120
+ threadId?: string;
121
+ turnId?: string;
122
+ action?: string;
123
+ sinceMs?: number;
124
+ limit?: number;
125
+ }
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import { getDbExec, intType } from "../db/client.js";
3
+ import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
3
4
  import {
4
5
  mergeThreadDataForClientSave,
5
6
  normalizeThreadRepository,
@@ -87,6 +88,15 @@ async function ensureTable(): Promise<void> {
87
88
  // Column already exists.
88
89
  }
89
90
  }
91
+ // Widen millisecond-timestamp columns that older deployments created as
92
+ // 32-bit `INTEGER`; on Postgres the `Date.now()` written on every turn
93
+ // overflows int4. No-op once widened / on fresh BIGINT databases.
94
+ await widenIntColumnsToBigInt("chat_threads", [
95
+ "created_at",
96
+ "updated_at",
97
+ "pinned_at",
98
+ "archived_at",
99
+ ]);
90
100
  // Indexes for the hot read paths. Both the sidebar list and the
91
101
  // scoped/per-resource list filter on owner_email (and optionally
92
102
  // scope) and sort by updated_at. Keep these dialect-agnostic (no
@@ -1072,6 +1072,18 @@ function AgentPanelInner({
1072
1072
  </button>
1073
1073
  </DropdownMenuTrigger>
1074
1074
  <DropdownMenuContent align="end" sideOffset={6} className="w-48">
1075
+ {onCollapse && (
1076
+ <>
1077
+ <DropdownMenuItem onSelect={onCollapse}>
1078
+ <IconLayoutSidebarRightCollapse
1079
+ size={14}
1080
+ className="shrink-0"
1081
+ />
1082
+ Collapse sidebar
1083
+ </DropdownMenuItem>
1084
+ <DropdownMenuSeparator />
1085
+ </>
1086
+ )}
1075
1087
  {mode === "chat" && toggleHistory && (
1076
1088
  <DropdownMenuItem onSelect={toggleHistory}>
1077
1089
  <IconHistory size={14} className="shrink-0" />
@@ -1133,15 +1145,6 @@ function AgentPanelInner({
1133
1145
  {isFullscreen ? "Exit fullscreen" : "Fullscreen"}
1134
1146
  </DropdownMenuItem>
1135
1147
  )}
1136
- {onCollapse && (
1137
- <DropdownMenuItem onSelect={onCollapse}>
1138
- <IconLayoutSidebarRightCollapse
1139
- size={14}
1140
- className="shrink-0"
1141
- />
1142
- Collapse sidebar
1143
- </DropdownMenuItem>
1144
- )}
1145
1148
  {((mode === "chat" && activeTabId) ||
1146
1149
  (mode === "cli" && canUseCodeTools && activeCliTab)) && (
1147
1150
  <>
@@ -151,6 +151,7 @@ import {
151
151
  getRepoMessages,
152
152
  getRepoMessage,
153
153
  shouldImportServerThreadData,
154
+ dedupeRepoMessagesById,
154
155
  } from "./chat/repo-helpers.js";
155
156
 
156
157
  export {
@@ -829,6 +830,11 @@ export function clearChatStorage(tabId?: string) {
829
830
  * messages missing these fields crash.
830
831
  */
831
832
  function ensureMessageMetadata(repo: any): any {
833
+ // Drop duplicate message ids before import — assistant-ui's MessageRepository
834
+ // throws "performOp/link: A message with the same id already exists in the
835
+ // parent tree" (Sentry AGENT-NATIVE-BROWSER-2Q) when fed repeated ids. No-op
836
+ // for the normal no-duplicate case. See dedupeRepoMessagesById.
837
+ repo = dedupeRepoMessagesById(repo);
832
838
  if (!repo?.messages || !Array.isArray(repo.messages)) return repo;
833
839
  for (const entry of repo.messages) {
834
840
  // Handle both wrapped ({ message: { ... } }) and flat ({ role, ... }) formats
@@ -119,6 +119,10 @@ interface FolderNode {
119
119
  /** Full slash path of the folder, used as a stable key. */
120
120
  path: string;
121
121
  children: TreeNode[];
122
+ /** Present when the folder was authored as an explicit directory entry (a
123
+ * `path` ending in `/`), carrying its note/metadata + flat `entries` index. */
124
+ entry?: FileTreeEntry;
125
+ index?: number;
122
126
  }
123
127
 
124
128
  type TreeNode = FolderNode | FileLeaf;
@@ -136,6 +140,10 @@ interface FolderBuild {
136
140
  files: FileLeaf[];
137
141
  /** Insertion order of child names (folders + files) for stable rendering. */
138
142
  order: string[];
143
+ /** Set when an explicit directory entry (trailing slash) targets this folder,
144
+ * carrying its note/metadata + flat `entries` index. */
145
+ entry?: FileTreeEntry;
146
+ index?: number;
139
147
  }
140
148
 
141
149
  function makeFolder(name: string, path: string): FolderBuild {
@@ -154,8 +162,15 @@ function buildTree(entries: FileTreeEntry[]): TreeNode[] {
154
162
  entries.forEach((entry, index) => {
155
163
  const segments = entry.path.split("/").filter(Boolean);
156
164
  if (segments.length === 0) return;
157
- const fileName = segments[segments.length - 1] as string;
158
- const folderSegments = segments.slice(0, -1);
165
+
166
+ // A trailing slash marks a DIRECTORY entry: every segment is a folder and
167
+ // the entry's note/metadata attaches to the deepest folder (the last segment
168
+ // does NOT become a file leaf). Because folders are keyed by name, this also
169
+ // merges with any folder the sibling file paths already implied — so
170
+ // `packages/shared/` and `packages/shared/src/…` collapse onto one `shared`
171
+ // folder instead of a duplicate folder + file pair.
172
+ const isDir = /\/\s*$/.test(entry.path);
173
+ const folderSegments = isDir ? segments : segments.slice(0, -1);
159
174
 
160
175
  let cursor = root;
161
176
  let prefix = "";
@@ -170,6 +185,17 @@ function buildTree(entries: FileTreeEntry[]): TreeNode[] {
170
185
  cursor = next;
171
186
  }
172
187
 
188
+ if (isDir) {
189
+ // Attach metadata to the deepest folder (first declaration wins, so an
190
+ // explicit note isn't clobbered by a later bare prefix re-declaration).
191
+ if (!cursor.entry) {
192
+ cursor.entry = entry;
193
+ cursor.index = index;
194
+ }
195
+ return;
196
+ }
197
+
198
+ const fileName = segments[segments.length - 1] as string;
173
199
  cursor.files.push({
174
200
  kind: "file",
175
201
  name: fileName,
@@ -191,6 +217,8 @@ function buildTree(entries: FileTreeEntry[]): TreeNode[] {
191
217
  name: child.name,
192
218
  path: child.path,
193
219
  children: materialize(child),
220
+ entry: child.entry,
221
+ index: child.index,
194
222
  });
195
223
  } else {
196
224
  const file = folder.files[Number(key.slice(2))];
@@ -212,7 +240,16 @@ function compactFolderNode(folder: FolderNode): FolderNode {
212
240
  let path = folder.path;
213
241
  let children = folder.children;
214
242
 
215
- while (children.length === 1 && children[0]?.kind === "folder") {
243
+ // Collapse single-child folder chains (a/b/c) into one row but never across
244
+ // a folder that carries its own directory note, so an explicitly authored
245
+ // directory (e.g. `packages/shared/` with a note) keeps its own row instead of
246
+ // being folded into its parent or child and losing the note.
247
+ while (
248
+ !folder.entry &&
249
+ children.length === 1 &&
250
+ children[0]?.kind === "folder" &&
251
+ !children[0].entry
252
+ ) {
216
253
  const child = children[0];
217
254
  names.push(child.name);
218
255
  path = child.path;
@@ -223,6 +260,8 @@ function compactFolderNode(folder: FolderNode): FolderNode {
223
260
  kind: "folder",
224
261
  name: names.join("/"),
225
262
  path,
263
+ entry: folder.entry,
264
+ index: folder.index,
226
265
  children: compactTree(children),
227
266
  };
228
267
  }
@@ -353,30 +392,49 @@ export function FileTreeRead({
353
392
  const indent = depth * INDENT_STEP;
354
393
  if (node.kind === "folder") {
355
394
  const collapsed = collapsedFolders[node.path] ?? false;
395
+ // An explicit directory entry (trailing-slash path) can be a leaf folder
396
+ // with no children — e.g. `apps/mail/`. It still renders as a folder, just
397
+ // without a toggle chevron. Its authored note shows inline like a file's.
398
+ const expandable = node.children.length > 0;
399
+ const note = node.entry?.note?.trim();
400
+ const open = expandable && !collapsed;
356
401
  return (
357
402
  <div key={`d:${node.path}`}>
358
403
  <button
359
404
  type="button"
360
405
  data-plan-interactive
361
- aria-expanded={!collapsed}
362
- onClick={() => toggleFolder(node.path)}
406
+ disabled={!expandable}
407
+ aria-expanded={expandable ? !collapsed : undefined}
408
+ onClick={expandable ? () => toggleFolder(node.path) : undefined}
363
409
  style={{ paddingLeft: indent + 8 }}
364
- className="flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left text-[13px] transition-colors hover:bg-accent/40"
410
+ className={cn(
411
+ "flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left text-[13px] transition-colors",
412
+ expandable ? "hover:bg-accent/40" : "cursor-default",
413
+ )}
365
414
  >
366
- <IconChevronRight
367
- className={cn(
368
- "size-3.5 shrink-0 text-plan-muted transition-transform",
369
- !collapsed && "rotate-90",
370
- )}
371
- />
372
- {collapsed ? (
373
- <IconFolder className="size-4 shrink-0 text-plan-muted" />
415
+ {expandable ? (
416
+ <IconChevronRight
417
+ className={cn(
418
+ "size-3.5 shrink-0 text-plan-muted transition-transform",
419
+ !collapsed && "rotate-90",
420
+ )}
421
+ />
374
422
  ) : (
423
+ <span className="size-3.5 shrink-0" aria-hidden />
424
+ )}
425
+ {open ? (
375
426
  <IconFolderOpen className="size-4 shrink-0 text-plan-muted" />
427
+ ) : (
428
+ <IconFolder className="size-4 shrink-0 text-plan-muted" />
376
429
  )}
377
430
  <span className="min-w-0 truncate font-medium text-plan-text">
378
431
  {node.name}
379
432
  </span>
433
+ {note && (
434
+ <span className="ml-1 min-w-0 flex-1 truncate text-xs text-plan-muted">
435
+ {note}
436
+ </span>
437
+ )}
380
438
  </button>
381
439
  </div>
382
440
  );
@@ -56,6 +56,44 @@ export function getRepoMessage(entry: RepoEntry): RepoMessage | null {
56
56
  return (entry?.message ?? entry) as RepoMessage | null;
57
57
  }
58
58
 
59
+ /**
60
+ * Collapse duplicate message ids before a repository is handed to
61
+ * `threadRuntime.import()`. assistant-ui's `MessageRepository` throws
62
+ * "MessageRepository(performOp/link): A message with the same id already exists
63
+ * in the parent tree" when the imported messages contain the same id more than
64
+ * once (Sentry AGENT-NATIVE-BROWSER-2Q). Duplicate ids are never valid thread
65
+ * data — they come from optimistic+echo races, streaming reconnect replays, or
66
+ * multi-tab merges — so keep only the LAST occurrence of each id (the most
67
+ * recent, most complete copy). parentId references stay valid because the
68
+ * surviving entry keeps the same id.
69
+ *
70
+ * Returns the input unchanged (same reference) when there are no duplicates, so
71
+ * the overwhelmingly common no-dupe case is a cheap no-op with zero behavioural
72
+ * change for normal threads.
73
+ */
74
+ export function dedupeRepoMessagesById<T extends NormalizedRepo>(
75
+ repo: T | null | undefined,
76
+ ): T | null | undefined {
77
+ if (!repo || !Array.isArray(repo.messages)) return repo;
78
+ const entries = repo.messages;
79
+ const lastIndexById = new Map<string, number>();
80
+ let hasDuplicate = false;
81
+ entries.forEach((entry, index) => {
82
+ const id = getRepoMessage(entry)?.id;
83
+ if (typeof id !== "string" || !id) return;
84
+ if (lastIndexById.has(id)) hasDuplicate = true;
85
+ lastIndexById.set(id, index);
86
+ });
87
+ if (!hasDuplicate) return repo;
88
+ const deduped = entries.filter((entry, index) => {
89
+ const id = getRepoMessage(entry)?.id;
90
+ // Keep id-less entries untouched; for duplicated ids keep only the last.
91
+ if (typeof id !== "string" || !id) return true;
92
+ return lastIndexById.get(id) === index;
93
+ });
94
+ return { ...repo, messages: deduped };
95
+ }
96
+
59
97
  export function isAssistantMessageTerminal(
60
98
  message: RepoMessage | null,
61
99
  ): boolean {
@@ -318,6 +318,10 @@ export function intType(): string {
318
318
  return isPostgres() ? "BIGINT" : "INTEGER";
319
319
  }
320
320
 
321
+ // `widenIntColumnsToBigInt` lives in `./widen-columns.js` (it depends only on
322
+ // `isPostgres`/`getDbExec` from here) so stores can import it without every
323
+ // `vi.mock("./client.js")` test having to stub the export.
324
+
321
325
  // ---------------------------------------------------------------------------
322
326
  // Parameter conversion: ? -> $1, $2, $3
323
327
  // ---------------------------------------------------------------------------
@@ -0,0 +1,75 @@
1
+ /**
2
+ * In-place widening of legacy 32-bit `integer` columns to 64-bit `BIGINT` on
3
+ * Postgres.
4
+ *
5
+ * Lives in its own module (rather than `client.js`) so that stores can import
6
+ * it without every `vi.mock("../db/client.js")` test needing to stub it: the
7
+ * helper resolves `isPostgres()` / `getDbExec()` through `client.js`, so a test
8
+ * that mocks the client to SQLite (`isPostgres: () => false`) makes this a
9
+ * no-op automatically.
10
+ */
11
+
12
+ import { isPostgres, getDbExec, type DbExec } from "./client.js";
13
+
14
+ const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+
16
+ /**
17
+ * Widen pre-existing 32-bit `integer` columns to 64-bit `BIGINT` in place on
18
+ * Postgres. No-op on SQLite, whose `INTEGER` is already 64-bit.
19
+ *
20
+ * Several stores historically created millisecond-timestamp columns (e.g.
21
+ * `agent_runs.started_at`, `application_state.updated_at`) as a literal
22
+ * `INTEGER`. On Postgres that is int4 (max 2,147,483,647), so a millisecond
23
+ * epoch such as 1782269273204 overflows with:
24
+ * `value "1782269273204" is out of range for type integer`
25
+ * The CREATE TABLE source was later switched to `intType()` (BIGINT on PG),
26
+ * but `CREATE TABLE IF NOT EXISTS` cannot re-type a column that already
27
+ * exists, so long-lived Neon databases keep the int4 column and every write
28
+ * into it fails. (Migrations don't hit this — the migration runner rewrites
29
+ * `INTEGER` → `BIGINT` for Postgres; only raw `ensureTable()` CREATE strings
30
+ * that predate `intType()` are affected.)
31
+ *
32
+ * This widens such columns once, then no-ops: it only ALTERs columns whose
33
+ * current type is `integer`, so already-bigint tables are never rewritten.
34
+ * Widening int4 → int8 is additive and non-destructive (no data loss, no
35
+ * narrowing), so it is safe to run unconditionally on every boot.
36
+ *
37
+ * Call from a store's `ensureTable()` right after its `CREATE TABLE IF NOT
38
+ * EXISTS`, passing the millisecond-timestamp columns for that table. Pass the
39
+ * UNQUALIFIED table name (the lookup is scoped to the `public` schema); the
40
+ * `ALTER` resolves the table through the search path.
41
+ */
42
+ export async function widenIntColumnsToBigInt(
43
+ table: string,
44
+ columns: string[],
45
+ // Injectable for tests; production callers use the configured client.
46
+ injectedClient?: DbExec,
47
+ ): Promise<void> {
48
+ if (!isPostgres() || columns.length === 0) return;
49
+ if (!PLAIN_IDENTIFIER.test(table)) return;
50
+ const client = injectedClient ?? getDbExec();
51
+ let int4Columns: Set<string>;
52
+ try {
53
+ const { rows } = await client.execute({
54
+ sql: `SELECT column_name FROM information_schema.columns
55
+ WHERE table_schema = 'public' AND table_name = ? AND data_type = 'integer'`,
56
+ args: [table],
57
+ });
58
+ int4Columns = new Set(rows.map((r) => String(r.column_name)));
59
+ } catch {
60
+ // information_schema unreadable (permissions / non-standard backend) —
61
+ // skip silently and leave the pre-existing behaviour unchanged.
62
+ return;
63
+ }
64
+ for (const col of columns) {
65
+ if (!int4Columns.has(col) || !PLAIN_IDENTIFIER.test(col)) continue;
66
+ try {
67
+ await client.execute(
68
+ `ALTER TABLE ${table} ALTER COLUMN ${col} TYPE BIGINT`,
69
+ );
70
+ } catch {
71
+ // A concurrent boot already widened it, or the role lacks ALTER — both
72
+ // are safe to ignore; a later boot retries if still needed.
73
+ }
74
+ }
75
+ }
@@ -34,6 +34,7 @@ import {
34
34
  } from "./workspace-core.js";
35
35
  import { generateActionRegistryForProject } from "../vite/action-types-plugin.js";
36
36
  import { mcpEmbedStaticAssetRouteRules } from "../shared/mcp-embed-headers.js";
37
+ import { AGENT_CHAT_PROCESS_RUN_PATH } from "../agent/durable-background.js";
37
38
  import {
38
39
  AGENT_NATIVE_SOCIAL_IMAGE_ALT,
39
40
  AGENT_NATIVE_SOCIAL_IMAGE_CACHE_BUSTER,
@@ -1476,6 +1477,85 @@ export function findInstalledResvgPackages(
1476
1477
  .map(([packageName, packageDir]) => ({ packageName, packageDir }));
1477
1478
  }
1478
1479
 
1480
+ /**
1481
+ * Deploy-time gate for emitting the second `-background` Netlify function.
1482
+ * Reads the same env flag the runtime gate uses
1483
+ * (`AGENT_CHAT_DURABLE_BACKGROUND`). Off by default — when off, the deploy
1484
+ * emits exactly one function (today's behavior, byte-for-byte).
1485
+ */
1486
+ export function isDurableBackgroundDeployEnabled(): boolean {
1487
+ const raw = process.env.AGENT_CHAT_DURABLE_BACKGROUND;
1488
+ if (raw == null) return false;
1489
+ const v = raw.trim().toLowerCase();
1490
+ return v === "1" || v === "true" || v === "yes" || v === "on";
1491
+ }
1492
+
1493
+ /**
1494
+ * Single-template Netlify build: emit a SECOND function whose name ends in
1495
+ * `-background`, re-exporting the same `main.mjs` handler bundle, so the chat
1496
+ * `_process-run` POST lands on Netlify's async (15-min) function instead of the
1497
+ * synchronous one. Additive + flag-gated (see `isDurableBackgroundDeployEnabled`).
1498
+ *
1499
+ * Nitro's `netlify` preset emits a single function at
1500
+ * `.netlify/functions-internal/server` (`server.mjs` → `main.mjs`). We copy
1501
+ * that directory to a sibling `<...>-background` function and write an entry
1502
+ * with a `config.path` of the process-run route.
1503
+ *
1504
+ * ⚠️ REAL-DEPLOY VERIFICATION REQUIRED. Whether Netlify routes
1505
+ * `/_agent-native/agent-chat/_process-run` to this `-background` function (and
1506
+ * invokes it asynchronously with the 15-min budget) vs the synchronous Nitro
1507
+ * function cannot be verified in this environment — Nitro owns the primary
1508
+ * function's routing manifest, and the precedence between the two functions for
1509
+ * that path is a Netlify runtime behavior. If routing resolves to the
1510
+ * synchronous function, the run still completes via the existing 40s
1511
+ * soft-timeout path (no durable win, no regression). See
1512
+ * docs/design/durable-agent-runs.md (Open risks #1).
1513
+ */
1514
+ export function emitSingleTemplateNetlifyBackgroundFunction(
1515
+ projectCwd: string,
1516
+ ): void {
1517
+ const functionsDir = path.join(projectCwd, ".netlify", "functions-internal");
1518
+ const serverDir = path.join(functionsDir, "server");
1519
+ if (!fs.existsSync(path.join(serverDir, "main.mjs"))) {
1520
+ // Nitro output layout differs from what we expected — skip rather than
1521
+ // guess. The single-function deploy is unaffected.
1522
+ console.warn(
1523
+ "[build] Durable-background emit skipped: expected Nitro Netlify function " +
1524
+ "at .netlify/functions-internal/server/main.mjs was not found.",
1525
+ );
1526
+ return;
1527
+ }
1528
+ const backgroundName = "server-agent-background";
1529
+ const dest = path.join(functionsDir, backgroundName);
1530
+ fs.rmSync(dest, { recursive: true, force: true });
1531
+ copyDir(serverDir, dest);
1532
+ // Drop the original Nitro entry so our background entry is the entrypoint.
1533
+ fs.rmSync(path.join(dest, "server.mjs"), { force: true });
1534
+
1535
+ const entry = `let cachedHandler;
1536
+
1537
+ export default async function handler(...args) {
1538
+ cachedHandler ??= (await import("./main.mjs")).default;
1539
+ return cachedHandler(...args);
1540
+ }
1541
+
1542
+ export const config = {
1543
+ name: "agent background handler",
1544
+ generator: "agent-native build",
1545
+ path: ${JSON.stringify([AGENT_CHAT_PROCESS_RUN_PATH])},
1546
+ nodeBundler: "none",
1547
+ includedFiles: ["**"],
1548
+ preferStatic: false,
1549
+ };
1550
+ `;
1551
+ fs.writeFileSync(path.join(dest, `${backgroundName}.mjs`), entry);
1552
+ console.log(
1553
+ `[build] Emitted durable-background function "${backgroundName}" ` +
1554
+ `(path ${AGENT_CHAT_PROCESS_RUN_PATH}). REQUIRES real-deploy verification ` +
1555
+ `of Netlify async routing — see docs/design/durable-agent-runs.md.`,
1556
+ );
1557
+ }
1558
+
1479
1559
  function copyInstalledLibsqlNativePackages(serverDir: string | undefined) {
1480
1560
  if (!serverDir || !fs.existsSync(serverDir)) return;
1481
1561
  const nodeModulesRoots = nodeModulesAncestors(cwd);
@@ -1938,6 +2018,22 @@ export default bundle;
1938
2018
  copyInstalledFfmpegStaticPackage(nitro.options.output.serverDir);
1939
2019
  }
1940
2020
 
2021
+ // Durable background agent runs (off by default). Additive ONLY: emits a
2022
+ // SECOND Netlify function whose name ends in `-background` re-exporting the
2023
+ // same handler bundle, so the chat `_process-run` POST lands on Netlify's
2024
+ // async (15-min) function. When the flag is off this is a no-op and the
2025
+ // single-function deploy is byte-for-byte unchanged.
2026
+ if (preset === "netlify" && isDurableBackgroundDeployEnabled()) {
2027
+ try {
2028
+ emitSingleTemplateNetlifyBackgroundFunction(cwd);
2029
+ } catch (err) {
2030
+ console.warn(
2031
+ "[build] Failed to emit durable-background Netlify function (non-fatal):",
2032
+ err instanceof Error ? err.message : err,
2033
+ );
2034
+ }
2035
+ }
2036
+
1941
2037
  // Resolve remaining bare npm imports by bundling them into _libs/.
1942
2038
  // Nitro sometimes leaves small packages as externals even with noExternals.
1943
2039
  if (preset.startsWith("cloudflare") || preset.startsWith("deno")) {