@k2b/cloud 0.6.0-rc.0 → 0.7.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 (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/store.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { parseAiTodoPlan } from "./todo-contracts";
1
2
  import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
2
3
  import type { Usage } from "@k2b/nessi/ai";
3
- import { sql } from "bun";
4
+ import { sql, type SQL } from "bun";
4
5
  import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
5
6
  import { logger } from "../services/logging";
6
7
  import { toPgTextArray } from "../services/postgres";
@@ -89,6 +90,7 @@ const isSearchCapabilityError = (error: unknown): boolean =>
89
90
  typeof error === "object" && error !== null && "code" in error && SEARCH_CAPABILITY_ERROR_CODES.has(String(error.code));
90
91
 
91
92
  type ConversationRow = {
93
+ activity?: AiConversation["activity"];
92
94
  id: string;
93
95
  short_id: string;
94
96
  title: string;
@@ -98,8 +100,14 @@ type ConversationRow = {
98
100
  keywords: string[] | null;
99
101
  pinned_at: Date | string | null;
100
102
  archived_at: Date | string | null;
103
+ done: boolean | null;
104
+ last_used_at: Date | string;
105
+ is_done?: boolean;
106
+ has_active_schedule?: boolean;
107
+ background_received_at?: Date | string | null;
101
108
  last_viewed_at: Date | string | null;
102
109
  latest_turn_status?: AiTurnStatus | null;
110
+ latest_browser_pending?: boolean;
103
111
  latest_turn_error?: string | null;
104
112
  latest_turn_completed_at?: Date | string | null;
105
113
  enrich_fail_count: number | null;
@@ -157,6 +165,9 @@ type MessageRow = {
157
165
  };
158
166
 
159
167
  type TurnRow = {
168
+ deadline?: Date | string | null;
169
+ run_budget_ms?: number | null;
170
+ cancel_requested_at?: Date | string | null;
160
171
  id: string;
161
172
  short_id: string;
162
173
  conversation_id: string;
@@ -360,7 +371,7 @@ const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage =>
360
371
  deliveredAt: row.delivered_at ? iso(row.delivered_at) : null,
361
372
  });
362
373
 
363
- const interChatMessageSelect = sql`
374
+ const interChatMessageSelect = () => sql`
364
375
  SELECT message.id, message.short_id, message.source_conversation_id,
365
376
  source.short_id AS source_chat_id, source.title AS source_title,
366
377
  message.source_turn_id, source_turn.short_id AS source_turn_short_id, message.source_call_id,
@@ -470,16 +481,55 @@ const parseCapabilityActionReview = (value: unknown): CapabilityActionReview | u
470
481
 
471
482
  const fieldSource = (value: string | null): AiConversation["titleSource"] => (value === "auto" || value === "user" ? value : "default");
472
483
 
473
- const conversationRunStatus = (status: AiTurnStatus | null | undefined): AiConversation["runStatus"] => {
484
+ // Pins stay active; otherwise explicit choices win and automatic completion never hides pending work.
485
+ const effectiveDone = () => sql`(conversation.pinned_at IS NULL AND COALESCE(conversation.done,
486
+ conversation.last_used_at <= now() - interval '7 days'
487
+ AND NOT EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active')
488
+ AND NOT EXISTS (SELECT 1 FROM ai.turns active_turn
489
+ WHERE active_turn.conversation_id = conversation.id
490
+ AND NOT COALESCE(active_turn.run_config ? 'background', false)
491
+ AND active_turn.status IN ('queued', 'running', 'waiting_for_action'))
492
+ AND NOT EXISTS (SELECT 1 FROM ai.queued_messages pending WHERE pending.conversation_id = conversation.id AND pending.status IN ('pending','failed'))))`;
493
+
494
+ const browserWorkPending = () => sql`(
495
+ latest.status = 'waiting_for_action'
496
+ AND EXISTS (
497
+ SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(latest.live_blocks) = 'array' THEN latest.live_blocks ELSE '[]'::jsonb END) block
498
+ WHERE block->>'kind' = 'tool' AND block->>'status' = 'awaiting_client' AND block->>'frontendMode' = 'client'
499
+ )
500
+ AND NOT EXISTS (
501
+ SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(latest.live_blocks) = 'array' THEN latest.live_blocks ELSE '[]'::jsonb END) block
502
+ WHERE block->>'kind' = 'tool' AND (
503
+ block->>'status' = 'awaiting_approval'
504
+ OR (block->>'status' = 'awaiting_client' AND COALESCE(block->>'frontendMode', '') <> 'client')
505
+ )
506
+ )
507
+ )`;
508
+
509
+ const conversationRunStatus = (status: AiTurnStatus | null | undefined, browserPending = false): AiConversation["runStatus"] => {
474
510
  if (status === "queued") return "queued";
475
511
  if (status === "running") return "running";
476
- if (status === "waiting_for_action") return "needs_attention";
512
+ if (status === "waiting_for_action") return browserPending ? "waiting_for_browser" : "needs_attention";
477
513
  if (status === "failed") return "failed";
478
514
  return "idle";
479
515
  };
480
516
 
517
+ // Bun SQL may return historical JSON payloads as string-encoded JSON.
518
+ const todoMessageJson = () => sql`(CASE WHEN jsonb_typeof(message) = 'string' THEN (message #>> '{}')::jsonb ELSE message END)`;
519
+ const todoMetaJson = () => sql`(CASE WHEN jsonb_typeof(meta) = 'string' THEN (meta #>> '{}')::jsonb ELSE meta END)`;
520
+
521
+ // A scheduled result arriving during an interactive turn becomes context on the next turn.
522
+ const visibleToTurn = (turnId?: string) => sql`(
523
+ ${turnId ?? null}::text IS NULL OR NOT COALESCE(meta ? 'scheduledTask', false) OR seq < ALL (
524
+ SELECT input_message.seq FROM ai.messages input_message
525
+ WHERE input_message.loop_id = ${turnId ?? null} AND input_message.role = 'user'
526
+ )
527
+ )`;
528
+
481
529
  const rowToConversation = (row: ConversationRow): AiConversation => ({
530
+ ...(row.activity ? {activity:row.activity} : {}),
482
531
  id: row.id,
532
+ hasActiveSchedule: Boolean(row.has_active_schedule),
483
533
  shortId: row.short_id,
484
534
  title: row.title,
485
535
  titleSource: fieldSource(row.title_source),
@@ -488,9 +538,17 @@ const rowToConversation = (row: ConversationRow): AiConversation => ({
488
538
  keywords: row.keywords ?? [],
489
539
  pinnedAt: row.pinned_at ? iso(row.pinned_at) : null,
490
540
  archivedAt: row.archived_at ? iso(row.archived_at) : null,
491
- runStatus: conversationRunStatus(row.latest_turn_status),
541
+ done: row.done,
542
+ isDone: row.pinned_at ? false : row.is_done ?? row.done ?? (
543
+ new Date(row.last_used_at).getTime() <= Date.now() - 7 * 86400000
544
+ && !row.has_active_schedule
545
+ && !["queued", "running", "waiting_for_action"].includes(row.latest_turn_status ?? "")
546
+ ),
547
+ lastUsedAt: iso(row.last_used_at),
548
+ runStatus: conversationRunStatus(row.latest_turn_status, row.latest_browser_pending),
492
549
  runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
493
550
  unreadCompletion:
551
+ Boolean(row.background_received_at && (!row.last_viewed_at || new Date(row.background_received_at).getTime() > new Date(row.last_viewed_at).getTime())) ||
494
552
  row.latest_turn_status === "completed" &&
495
553
  Boolean(row.latest_turn_completed_at) &&
496
554
  (!row.last_viewed_at || new Date(row.latest_turn_completed_at!).getTime() > new Date(row.last_viewed_at).getTime()),
@@ -566,6 +624,9 @@ const rowToMessage = (row: MessageRow): AiStoredMessage => {
566
624
  };
567
625
 
568
626
  const rowToTurn = (row: TurnRow): AiTurn => ({
627
+ deadline: row.deadline ? iso(row.deadline) : null,
628
+ runBudgetMs: row.run_budget_ms ?? null,
629
+ cancelRequestedAt: row.cancel_requested_at ? iso(row.cancel_requested_at) : null,
569
630
  id: row.id,
570
631
  shortId: row.short_id,
571
632
  conversationId: row.conversation_id,
@@ -591,7 +652,7 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
591
652
  conversationId: row.conversation_id,
592
653
  callId: row.call_id,
593
654
  name: row.tool_name,
594
- args: parseJsonValue(row.args),
655
+ args: row.args,
595
656
  mode: row.frontend_mode ?? "client",
596
657
  }
597
658
  : {
@@ -600,7 +661,7 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
600
661
  conversationId: row.conversation_id,
601
662
  callId: row.call_id,
602
663
  name: row.tool_name,
603
- args: parseJsonValue(row.args),
664
+ args: row.args,
604
665
  message: row.message ?? undefined,
605
666
  review: parseCapabilityActionReview(row.review),
606
667
  allowAlways: row.allow_always,
@@ -613,7 +674,7 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
613
674
  kind: row.kind,
614
675
  status: row.status,
615
676
  name: row.tool_name,
616
- args: parseJsonValue(row.args),
677
+ args: row.args,
617
678
  message: row.message ?? undefined,
618
679
  review: parseCapabilityActionReview(row.review),
619
680
  approvalScope: row.approval_scope,
@@ -649,14 +710,17 @@ const loadConversationSummary = async (input: {
649
710
  const rows = await sql<ConversationRow[]>`
650
711
  SELECT
651
712
  conversation.*,
713
+ ${effectiveDone()} AS is_done,
714
+ EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
652
715
  latest.status AS latest_turn_status,
716
+ ${browserWorkPending()} AS latest_browser_pending,
653
717
  latest.error AS latest_turn_error,
654
718
  latest.completed_at AS latest_turn_completed_at
655
719
  FROM ai.conversations conversation
656
720
  LEFT JOIN LATERAL (
657
- SELECT status, error, completed_at
721
+ SELECT status, error, completed_at, live_blocks
658
722
  FROM ai.turns
659
- WHERE conversation_id = conversation.id
723
+ WHERE conversation_id = conversation.id AND NOT COALESCE(run_config ? 'background', false)
660
724
  ORDER BY created_at DESC, id DESC
661
725
  LIMIT 1
662
726
  ) latest ON TRUE
@@ -666,7 +730,15 @@ const loadConversationSummary = async (input: {
666
730
  AND (${Boolean(input.archived)}::boolean = (conversation.archived_at IS NOT NULL))
667
731
  LIMIT 1
668
732
  `;
669
- return rows[0] ? rowToConversation(rows[0]) : null;
733
+ if (!rows[0]) return null;
734
+ const plans = await sql<{ seq: number; plan: unknown }[]>`
735
+ SELECT seq, CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
736
+ FROM ai.messages WHERE conversation_id = ${rows[0].id}
737
+ AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
738
+ ORDER BY seq DESC, id DESC LIMIT 1
739
+ `;
740
+ const plan = parseAiTodoPlan(plans[0]?.plan);
741
+ return { ...rowToConversation(rows[0]), ...(plan ? { todoPlan: { seq: Number(plans[0]!.seq), todos: plan.todos } } : {}) };
670
742
  };
671
743
 
672
744
  const firstText = (message: Message): string => {
@@ -745,14 +817,14 @@ const insertMessageLocked = async (
745
817
  ${seq},
746
818
  ${input.kind ?? "message"},
747
819
  ${input.message.role},
748
- ${JSON.stringify(input.message)}::jsonb,
820
+ ${JSON.stringify(input.message)}::text::jsonb,
749
821
  ${messageSearchText(input.message)},
750
822
  ${input.loopId ?? null},
751
823
  ${input.modelProfileId ?? null},
752
824
  ${providerModel},
753
- ${usage ? JSON.stringify(usage) : null}::jsonb,
825
+ ${usage ? JSON.stringify(usage) : null}::text::jsonb,
754
826
  ${stopReason},
755
- ${input.meta ? JSON.stringify(input.meta) : null}::jsonb
827
+ ${input.meta ? JSON.stringify(input.meta) : null}::text::jsonb
756
828
  )
757
829
  RETURNING *
758
830
  `,
@@ -776,6 +848,7 @@ const insertMessageLocked = async (
776
848
 
777
849
  /** Append a turn-owned (assistant/tool_result/summary) message, guarded by lease ownership in one statement. */
778
850
  const appendTurnOwnedMessage = async (input: {
851
+ background?: boolean;
779
852
  conversationId: string;
780
853
  turnId: string;
781
854
  leaseOwner: string;
@@ -785,12 +858,14 @@ const appendTurnOwnedMessage = async (input: {
785
858
  loopId: string | null;
786
859
  modelProfileId?: string | null;
787
860
  meta?: AiStoredMessage["meta"];
788
- }): Promise<boolean> => {
861
+ }): Promise<AiStoredMessage | null> => {
862
+ return sql.begin(async tx => {
863
+ if (!input.background) await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId}::uuid FOR UPDATE`;
789
864
  const { usage, providerModel, stopReason } = messageColumns(input.message);
790
- const rows = await withAiShortId(
865
+ const rows = await withAiShortIdForDb(tx,
791
866
  "idx_ai_messages_conversation_short_id",
792
- (shortId) => sql<{ id: string }[]>`
793
- INSERT INTO ai.messages (
867
+ (attempt, shortId) => attempt<MessageRow[]>`
868
+ INSERT INTO ${sql(input.background ? "ai.task_messages" : "ai.messages")} (
794
869
  short_id,
795
870
  conversation_id,
796
871
  seq,
@@ -808,18 +883,18 @@ const appendTurnOwnedMessage = async (input: {
808
883
  ${shortId},
809
884
  ${input.conversationId},
810
885
  CASE
811
- WHEN ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
812
- ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq > 0)
886
+ WHEN NOT ${Boolean(input.background)} AND ${input.kind === 'summary'} AND ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
887
+ ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ${sql(input.background ? "ai.task_messages" : "ai.messages")} WHERE conversation_id = ${input.conversationId} AND seq > 0 AND (NOT ${Boolean(input.background)} OR loop_id = ${input.turnId}))
813
888
  END,
814
889
  ${input.kind ?? "message"},
815
890
  ${input.message.role},
816
- ${JSON.stringify(input.message)}::jsonb,
891
+ ${JSON.stringify(input.message)}::text::jsonb,
817
892
  ${input.loopId},
818
893
  ${input.modelProfileId ?? null},
819
894
  ${providerModel},
820
- ${usage ? JSON.stringify(usage) : null}::jsonb,
895
+ ${usage ? JSON.stringify(usage) : null}::text::jsonb,
821
896
  ${stopReason},
822
- ${input.meta ? JSON.stringify(input.meta) : null}::jsonb
897
+ ${input.meta ? JSON.stringify(input.meta) : null}::text::jsonb
823
898
  WHERE EXISTS (
824
899
  SELECT 1
825
900
  FROM ai.turns
@@ -828,14 +903,15 @@ const appendTurnOwnedMessage = async (input: {
828
903
  AND status IN ('running', 'waiting_for_action')
829
904
  AND lease_owner = ${input.leaseOwner}
830
905
  )
831
- RETURNING id
906
+ RETURNING *
832
907
  `,
833
908
  );
834
909
  if (rows[0]) {
835
- await sql`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
836
- return true;
910
+ if (!input.background) await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
911
+ return rowToMessage(rows[0]);
837
912
  }
838
- return false;
913
+ return null;
914
+ });
839
915
  };
840
916
 
841
917
  const toolMessageMeta = (
@@ -857,6 +933,69 @@ const toolMessageMeta = (
857
933
  return Object.keys(toolPresentations).length > 0 ? { toolPresentations } : null;
858
934
  };
859
935
 
936
+ export const saveAiDraftInTransaction = async (
937
+ tx: SQL,
938
+ input: Parameters<AiConversationService["saveDraft"]>[0],
939
+ ): ReturnType<AiConversationService["saveDraft"]> => {
940
+ const [conversation] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]>`
941
+ SELECT draft_content, draft_revision, draft_updated_at
942
+ FROM ai.conversations
943
+ WHERE id = ${input.conversationId}::uuid
944
+ AND created_by_user_id = ${input.ownerUserId}::uuid
945
+ AND archived_at IS NULL
946
+ FOR UPDATE
947
+ `;
948
+ if (!conversation) return { ok: false as const, reason: "not_found" as const };
949
+ if (Number(conversation.draft_revision) !== input.expectedRevision) {
950
+ return { ok: false as const, reason: "conflict" as const };
951
+ }
952
+ const [sameDraft] = await tx<{ same: boolean }[]>`
953
+ SELECT ${JSON.stringify(input.content)}::text::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::text::jsonb AS same
954
+ `;
955
+ if (sameDraft?.same) {
956
+ return {
957
+ ok: true as const,
958
+ draft: {
959
+ content: input.content,
960
+ revision: Number(conversation.draft_revision),
961
+ updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
962
+ },
963
+ };
964
+ }
965
+
966
+ for (const part of input.content) {
967
+ if (part.type !== "file") continue;
968
+ const [file] = await tx<{ exists: boolean }[]>`
969
+ SELECT EXISTS (
970
+ SELECT 1 FROM ai.files
971
+ WHERE conversation_id = ${input.conversationId}::uuid
972
+ AND path = ${part.path}
973
+ AND media_type = ${part.mediaType}
974
+ AND size = ${part.size}
975
+ AND version = ${part.version}
976
+ ) AS exists
977
+ `;
978
+ if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
979
+ }
980
+
981
+ const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
982
+ UPDATE ai.conversations
983
+ SET draft_content = ${JSON.stringify(input.content)}::text::jsonb,
984
+ draft_revision = draft_revision + 1,
985
+ draft_updated_at = now()
986
+ WHERE id = ${input.conversationId}::uuid
987
+ RETURNING draft_content, draft_revision, draft_updated_at
988
+ `;
989
+ return {
990
+ ok: true as const,
991
+ draft: {
992
+ content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
993
+ revision: Number(saved!.draft_revision),
994
+ updatedAt: iso(saved!.draft_updated_at),
995
+ },
996
+ };
997
+ };
998
+
860
999
  export const aiConversations: AiConversationService = {
861
1000
  createConversation: async (input) => {
862
1001
  const rows = await withAiShortId(
@@ -880,7 +1019,7 @@ export const aiConversations: AiConversationService = {
880
1019
  ${input.title?.trim() || "New chat"},
881
1020
  ${input.description?.trim() ?? ""},
882
1021
  ${input.projectId ?? null}::uuid,
883
- ${JSON.stringify(input.draft ?? [])}::jsonb,
1022
+ ${JSON.stringify(input.draft ?? [])}::text::jsonb,
884
1023
  ${input.draft?.length ? 1 : 0},
885
1024
  ${toPgTextArray(input.preloadTools ?? [])}::text[],
886
1025
  ${input.draft?.length ? new Date() : null},
@@ -920,11 +1059,12 @@ export const aiConversations: AiConversationService = {
920
1059
  await tx`
921
1060
  INSERT INTO ai.messages (
922
1061
  short_id, conversation_id, seq, kind, role, message, search_text, loop_id,
923
- model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
1062
+ model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta
924
1063
  )
925
1064
  SELECT
926
1065
  short_id, ${target.id}::uuid, seq, kind, role, message, search_text, loop_id,
927
- model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
1066
+ model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason,
1067
+ CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
928
1068
  FROM ai.messages
929
1069
  WHERE conversation_id = ${input.sourceConversationId}::uuid
930
1070
  AND compacted_at IS NULL
@@ -932,8 +1072,8 @@ export const aiConversations: AiConversationService = {
932
1072
  ORDER BY seq ASC
933
1073
  `;
934
1074
  await tx`
935
- INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
936
- SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, updated_at
1075
+ INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
1076
+ SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
937
1077
  FROM ai.files WHERE conversation_id = ${input.sourceConversationId}::uuid
938
1078
  `;
939
1079
  return rowToConversation(target);
@@ -950,23 +1090,29 @@ export const aiConversations: AiConversationService = {
950
1090
  const rows = await withConversationSearchBackend(query, async (backend) => {
951
1091
  const order = query
952
1092
  ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
953
- : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1093
+ : input.done === true
1094
+ ? sql`conversation.last_used_at DESC, conversation.id DESC`
1095
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
954
1096
  return sql<ConversationRow[]>`
955
1097
  SELECT
956
1098
  conversation.*,
1099
+ ${effectiveDone()} AS is_done,
1100
+ EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
957
1101
  latest.status AS latest_turn_status,
1102
+ ${browserWorkPending()} AS latest_browser_pending,
958
1103
  latest.error AS latest_turn_error,
959
1104
  latest.completed_at AS latest_turn_completed_at
960
1105
  FROM ai.conversations conversation
961
1106
  LEFT JOIN LATERAL (
962
- SELECT status, error, completed_at
1107
+ SELECT status, error, completed_at, live_blocks
963
1108
  FROM ai.turns
964
- WHERE conversation_id = conversation.id
1109
+ WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
965
1110
  ORDER BY created_at DESC, id DESC
966
1111
  LIMIT 1
967
1112
  ) latest ON TRUE
968
1113
  WHERE conversation.created_by_user_id = ${input.ownerUserId}
969
1114
  AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
1115
+ AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
970
1116
  AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
971
1117
  AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
972
1118
  AND (${refs.length === 0}::boolean OR NOT EXISTS (
@@ -993,10 +1139,12 @@ export const aiConversations: AiConversationService = {
993
1139
  OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
994
1140
  ))
995
1141
  AND (${status}::text IS NULL
996
- OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
997
- OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
1142
+ OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
1143
+ OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
998
1144
  OR (${status} = 'failed' AND latest.status = 'failed')
999
- OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
1145
+ OR (${status} = 'unread' AND (
1146
+ (latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
1147
+ OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
1000
1148
  ORDER BY ${order}
1001
1149
  LIMIT ${limit}
1002
1150
  `;
@@ -1005,35 +1153,33 @@ export const aiConversations: AiConversationService = {
1005
1153
  },
1006
1154
 
1007
1155
  listSidebarConversations: async (input) => {
1008
- const unassignedLimit = Math.min(Math.max(input.unassignedLimit ?? 15, 1), 50);
1009
- const perProjectLimit = Math.min(Math.max(input.perProjectLimit ?? 10, 1), 50);
1010
1156
  const rows = await sql<ConversationRow[]>`
1011
- WITH ranked AS (
1012
- SELECT
1013
- conversation.*,
1014
- latest.status AS latest_turn_status,
1015
- latest.error AS latest_turn_error,
1016
- latest.completed_at AS latest_turn_completed_at,
1017
- row_number() OVER (
1018
- PARTITION BY conversation.project_id
1019
- ORDER BY conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id
1020
- ) AS sidebar_rank
1021
- FROM ai.conversations conversation
1022
- LEFT JOIN LATERAL (
1023
- SELECT status, error, completed_at
1024
- FROM ai.turns
1025
- WHERE conversation_id = conversation.id
1026
- ORDER BY created_at DESC, id DESC
1027
- LIMIT 1
1028
- ) latest ON TRUE
1029
- WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
1030
- AND conversation.archived_at IS NULL
1031
- )
1032
- SELECT *
1033
- FROM ranked
1034
- WHERE (project_id IS NULL AND sidebar_rank <= ${unassignedLimit})
1035
- OR (project_id IS NOT NULL AND sidebar_rank <= ${perProjectLimit})
1036
- ORDER BY pinned_at DESC NULLS LAST, updated_at DESC, created_at DESC, id
1157
+ SELECT conversation.*, ${effectiveDone()} AS is_done,
1158
+ EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
1159
+ latest.status AS latest_turn_status, ${browserWorkPending()} AS latest_browser_pending,
1160
+ latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at,
1161
+ jsonb_build_object('completed', COALESCE(progress.completed,0), 'total', COALESCE(progress.total,0),
1162
+ 'step', progress.step, 'tool', ai.sidebar_tool_label(latest.live_blocks)->>'label') AS activity
1163
+ FROM ai.conversations conversation
1164
+ LEFT JOIN LATERAL (
1165
+ SELECT status, error, completed_at, live_blocks FROM ai.turns
1166
+ WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id ORDER BY created_at DESC, id DESC LIMIT 1
1167
+ ) latest ON TRUE
1168
+ LEFT JOIN LATERAL (
1169
+ SELECT CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
1170
+ FROM ai.messages WHERE conversation_id = conversation.id
1171
+ AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
1172
+ ORDER BY seq DESC, id DESC LIMIT 1
1173
+ ) working ON TRUE
1174
+ LEFT JOIN LATERAL (
1175
+ SELECT count(*) FILTER (WHERE task->>'status' = 'completed')::int AS completed,
1176
+ count(*) FILTER (WHERE task->>'status' <> 'cancelled')::int AS total,
1177
+ max(task->>'content') FILTER (WHERE task->>'status' = 'in_progress') AS step
1178
+ FROM jsonb_array_elements(CASE WHEN jsonb_typeof(working.plan->'todos') = 'array' THEN working.plan->'todos' ELSE '[]'::jsonb END) task
1179
+ ) progress ON TRUE
1180
+ WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
1181
+ AND conversation.archived_at IS NULL AND NOT ${effectiveDone()}
1182
+ ORDER BY conversation.pinned_at DESC NULLS LAST, conversation.last_used_at DESC, conversation.id
1037
1183
  `;
1038
1184
  return rows.map(rowToConversation);
1039
1185
  },
@@ -1047,23 +1193,29 @@ export const aiConversations: AiConversationService = {
1047
1193
  const rows = await withConversationSearchBackend(query, async (backend) => {
1048
1194
  const order = query
1049
1195
  ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
1050
- : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1196
+ : input.done === true
1197
+ ? sql`conversation.last_used_at DESC, conversation.id DESC`
1198
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1051
1199
  return sql<ConversationRow[]>`
1052
1200
  SELECT
1053
1201
  conversation.*,
1202
+ ${effectiveDone()} AS is_done,
1203
+ EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
1054
1204
  latest.status AS latest_turn_status,
1205
+ ${browserWorkPending()} AS latest_browser_pending,
1055
1206
  latest.error AS latest_turn_error,
1056
1207
  latest.completed_at AS latest_turn_completed_at
1057
1208
  FROM ai.conversations conversation
1058
1209
  LEFT JOIN LATERAL (
1059
- SELECT status, error, completed_at
1210
+ SELECT status, error, completed_at, live_blocks
1060
1211
  FROM ai.turns
1061
- WHERE conversation_id = conversation.id
1212
+ WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
1062
1213
  ORDER BY created_at DESC, id DESC
1063
1214
  LIMIT 1
1064
1215
  ) latest ON TRUE
1065
1216
  WHERE conversation.created_by_user_id = ${input.ownerUserId}
1066
1217
  AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
1218
+ AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
1067
1219
  AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
1068
1220
  AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
1069
1221
  AND (${pattern}::text IS NULL
@@ -1079,10 +1231,12 @@ export const aiConversations: AiConversationService = {
1079
1231
  OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
1080
1232
  ))
1081
1233
  AND (${status}::text IS NULL
1082
- OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
1083
- OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
1234
+ OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
1235
+ OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
1084
1236
  OR (${status} = 'failed' AND latest.status = 'failed')
1085
- OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
1237
+ OR (${status} = 'unread' AND (
1238
+ (latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
1239
+ OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
1086
1240
  ORDER BY ${order}
1087
1241
  LIMIT ${perPage}
1088
1242
  OFFSET ${offset}
@@ -1092,14 +1246,15 @@ export const aiConversations: AiConversationService = {
1092
1246
  SELECT COUNT(*) AS total
1093
1247
  FROM ai.conversations conversation
1094
1248
  LEFT JOIN LATERAL (
1095
- SELECT status, completed_at
1249
+ SELECT status, completed_at, live_blocks
1096
1250
  FROM ai.turns
1097
- WHERE conversation_id = conversation.id
1251
+ WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
1098
1252
  ORDER BY created_at DESC, id DESC
1099
1253
  LIMIT 1
1100
1254
  ) latest ON TRUE
1101
1255
  WHERE conversation.created_by_user_id = ${input.ownerUserId}
1102
1256
  AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
1257
+ AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
1103
1258
  AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
1104
1259
  AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
1105
1260
  AND (${pattern}::text IS NULL
@@ -1115,10 +1270,12 @@ export const aiConversations: AiConversationService = {
1115
1270
  OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
1116
1271
  ))
1117
1272
  AND (${status}::text IS NULL
1118
- OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
1119
- OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
1273
+ OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
1274
+ OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
1120
1275
  OR (${status} = 'failed' AND latest.status = 'failed')
1121
- OR (${status} = 'unread' AND latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity')))
1276
+ OR (${status} = 'unread' AND (
1277
+ (latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
1278
+ OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
1122
1279
  `;
1123
1280
  const total = Number(countRows[0]?.total ?? 0);
1124
1281
  return {
@@ -1138,68 +1295,7 @@ export const aiConversations: AiConversationService = {
1138
1295
  return loadConversationSummary(input);
1139
1296
  },
1140
1297
 
1141
- saveDraft: async (input) =>
1142
- sql.begin(async (tx) => {
1143
- const [conversation] = await tx<
1144
- { draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]
1145
- >`
1146
- SELECT draft_content, draft_revision, draft_updated_at
1147
- FROM ai.conversations
1148
- WHERE id = ${input.conversationId}::uuid
1149
- AND created_by_user_id = ${input.ownerUserId}::uuid
1150
- AND archived_at IS NULL
1151
- FOR UPDATE
1152
- `;
1153
- if (!conversation) return { ok: false as const, reason: "not_found" as const };
1154
- if (Number(conversation.draft_revision) !== input.expectedRevision) {
1155
- return { ok: false as const, reason: "conflict" as const };
1156
- }
1157
- const [sameDraft] = await tx<{ same: boolean }[]>`
1158
- SELECT ${JSON.stringify(input.content)}::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::jsonb AS same
1159
- `;
1160
- if (sameDraft?.same) {
1161
- return {
1162
- ok: true as const,
1163
- draft: {
1164
- content: input.content,
1165
- revision: Number(conversation.draft_revision),
1166
- updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
1167
- },
1168
- };
1169
- }
1170
-
1171
- for (const part of input.content) {
1172
- if (part.type !== "file") continue;
1173
- const [file] = await tx<{ exists: boolean }[]>`
1174
- SELECT EXISTS (
1175
- SELECT 1 FROM ai.files
1176
- WHERE conversation_id = ${input.conversationId}::uuid
1177
- AND path = ${part.path}
1178
- AND media_type = ${part.mediaType}
1179
- AND size = ${part.size}
1180
- AND version = ${part.version}
1181
- ) AS exists
1182
- `;
1183
- if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
1184
- }
1185
-
1186
- const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
1187
- UPDATE ai.conversations
1188
- SET draft_content = ${JSON.stringify(input.content)}::jsonb,
1189
- draft_revision = draft_revision + 1,
1190
- draft_updated_at = now()
1191
- WHERE id = ${input.conversationId}::uuid
1192
- RETURNING draft_content, draft_revision, draft_updated_at
1193
- `;
1194
- return {
1195
- ok: true as const,
1196
- draft: {
1197
- content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
1198
- revision: Number(saved!.draft_revision),
1199
- updatedAt: iso(saved!.draft_updated_at),
1200
- },
1201
- };
1202
- }),
1298
+ saveDraft: async (input) => sql.begin((tx) => saveAiDraftInTransaction(tx, input)),
1203
1299
 
1204
1300
  getLoadedTools: async (input) => {
1205
1301
  const rows = await sql<{ loaded_tools: string[] | null }[]>`
@@ -1420,7 +1516,7 @@ export const aiConversations: AiConversationService = {
1420
1516
  },
1421
1517
 
1422
1518
  createInterChatMessage: async (input) => {
1423
- const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect}
1519
+ const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect()}
1424
1520
  WHERE message.idempotency_key = ${input.idempotencyKey}
1425
1521
  AND message.source_conversation_id = ${input.sourceConversationId}::uuid
1426
1522
  AND message.actor_user_id = ${input.actorUserId}::uuid
@@ -1465,21 +1561,22 @@ export const aiConversations: AiConversationService = {
1465
1561
  RETURNING id
1466
1562
  `,
1467
1563
  );
1468
- const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
1564
+ const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect()} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
1469
1565
  return { ok: true, message: rowToInterChatMessage(rows[0]!) };
1470
1566
  },
1471
1567
 
1472
1568
  listPendingInterChatMessages: async (input = {}) => {
1473
1569
  const limit = Math.min(Math.max(Math.floor(input.limit ?? 50), 1), 100);
1474
1570
  const rows = await sql<InterChatMessageRow[]>`
1475
- ${interChatMessageSelect}
1571
+ ${interChatMessageSelect()}
1476
1572
  WHERE message.status = 'pending'
1477
1573
  AND (${input.targetConversationId ?? null}::uuid IS NULL OR message.target_conversation_id = ${input.targetConversationId ?? null}::uuid)
1478
1574
  AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR NOT EXISTS (
1479
1575
  SELECT 1
1480
1576
  FROM ai.turns active_turn
1481
1577
  WHERE active_turn.conversation_id = message.target_conversation_id
1482
- AND active_turn.status IN ('queued', 'running', 'waiting_for_action')
1578
+ AND NOT COALESCE(active_turn.run_config ? 'background', false)
1579
+ AND active_turn.status IN ('queued', 'running', 'waiting_for_action')
1483
1580
  ))
1484
1581
  AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR message.id = (
1485
1582
  SELECT oldest.id
@@ -1508,7 +1605,7 @@ export const aiConversations: AiConversationService = {
1508
1605
  deliverInterChatMessage: async (input) => {
1509
1606
  const delivered = await sql.begin(async (tx) => {
1510
1607
  const rows = await tx<InterChatMessageRow[]>`
1511
- ${interChatMessageSelect}
1608
+ ${interChatMessageSelect()}
1512
1609
  WHERE message.id = ${input.messageId}::uuid
1513
1610
  FOR UPDATE OF message
1514
1611
  `;
@@ -1534,6 +1631,7 @@ export const aiConversations: AiConversationService = {
1534
1631
  const [active] = await tx<{ id: string }[]>`
1535
1632
  SELECT id FROM ai.turns
1536
1633
  WHERE conversation_id = ${message.target_conversation_id}::uuid
1634
+ AND NOT COALESCE(run_config ? 'background', false)
1537
1635
  AND status IN ('queued', 'running', 'waiting_for_action')
1538
1636
  LIMIT 1
1539
1637
  `;
@@ -1548,6 +1646,7 @@ export const aiConversations: AiConversationService = {
1548
1646
  RETURNING *
1549
1647
  `,
1550
1648
  );
1649
+ await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${message.target_conversation_id}::uuid`;
1551
1650
  const turn = rowToTurn(turnRows[0]!);
1552
1651
  const targetMeta: NonNullable<AiStoredMessage["meta"]> = {
1553
1652
  agentMessage: {
@@ -1579,7 +1678,7 @@ export const aiConversations: AiConversationService = {
1579
1678
  SET status = 'delivered', target_turn_id = ${turn.id}, target_message_id = ${messageRow.id}, delivered_at = now()
1580
1679
  WHERE id = ${message.id}::uuid
1581
1680
  `;
1582
- const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${message.id}::uuid LIMIT 1`;
1681
+ const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect()} WHERE message.id = ${message.id}::uuid LIMIT 1`;
1583
1682
  return { delivered: true as const, message: rowToInterChatMessage(updated[0]!), turn };
1584
1683
  });
1585
1684
  return delivered;
@@ -1624,12 +1723,16 @@ export const aiConversations: AiConversationService = {
1624
1723
  `;
1625
1724
  if (!conversations[0]) return { ok: false as const, reason: "not_found" as const };
1626
1725
 
1726
+ if (input.onlyUnassigned && conversations[0].project_id) return { ok: false as const, reason: "already_assigned" as const };
1727
+
1627
1728
  const [activity] = await tx<{ exists: boolean }[]>`
1628
1729
  SELECT EXISTS (
1629
1730
  SELECT 1 FROM ai.turns
1630
1731
  WHERE conversation_id = ${input.conversationId}::uuid
1631
1732
  AND status IN ('queued', 'running', 'waiting_for_action')
1632
- ) AS exists
1733
+ ) OR (${Boolean(input.onlyUnassigned)}::boolean AND EXISTS (
1734
+ SELECT 1 FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed')
1735
+ )) AS exists
1633
1736
  `;
1634
1737
  if (activity?.exists) return { ok: false as const, reason: "active_turn" as const };
1635
1738
 
@@ -1659,6 +1762,34 @@ export const aiConversations: AiConversationService = {
1659
1762
  return rows[0] ? loadConversationSummary(input) : null;
1660
1763
  },
1661
1764
 
1765
+ setConversationDone: async (input) => {
1766
+ const result = await sql.begin(async (tx) => {
1767
+ // Same lock as turn submission: completion cannot race a new message.
1768
+ const [conversation] = await tx<{ id: string }[]>`
1769
+ SELECT id FROM ai.conversations
1770
+ WHERE id = ${input.conversationId}::uuid AND created_by_user_id = ${input.ownerUserId}::uuid
1771
+ AND archived_at IS NULL FOR UPDATE
1772
+ `;
1773
+ if (!conversation) return { ok: false as const, reason: "not_found" as const };
1774
+ if (input.done) {
1775
+ const [active] = await tx<{ id: string }[]>`
1776
+ SELECT id FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid
1777
+ AND NOT COALESCE(run_config ? 'background', false) AND status IN ('queued', 'running', 'waiting_for_action') LIMIT 1
1778
+ `;
1779
+ const [pending] = await tx`SELECT id FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') LIMIT 1`;
1780
+ if (active || pending) return { ok: false as const, reason: "active_turn" as const };
1781
+ }
1782
+ await tx`UPDATE ai.conversations
1783
+ SET done = ${input.done}::boolean,
1784
+ last_used_at = CASE WHEN ${input.done}::boolean IS FALSE THEN now() ELSE last_used_at END
1785
+ WHERE id = ${input.conversationId}::uuid`;
1786
+ return { ok: true as const };
1787
+ });
1788
+ if (!result.ok) return result;
1789
+ const conversation = await loadConversationSummary(input);
1790
+ return conversation ? { ok: true as const, conversation } : { ok: false as const, reason: "not_found" as const };
1791
+ },
1792
+
1662
1793
  archiveConversation: async (input) => {
1663
1794
  const rows = await sql<{ id: string }[]>`
1664
1795
  UPDATE ai.conversations
@@ -1672,6 +1803,7 @@ export const aiConversations: AiConversationService = {
1672
1803
  WHERE conversation_id = ai.conversations.id
1673
1804
  AND status IN ('queued', 'running', 'waiting_for_action')
1674
1805
  )
1806
+ AND NOT EXISTS (SELECT 1 FROM ai.queued_messages WHERE conversation_id = ai.conversations.id AND status IN ('pending','failed'))
1675
1807
  RETURNING id
1676
1808
  `;
1677
1809
  return Boolean(rows[0]);
@@ -1727,7 +1859,7 @@ export const aiConversations: AiConversationService = {
1727
1859
  AND EXISTS (SELECT 1 FROM ai.messages m WHERE m.conversation_id = c.id)
1728
1860
  AND NOT EXISTS (
1729
1861
  SELECT 1 FROM ai.turns t
1730
- WHERE t.conversation_id = c.id AND t.status IN ('queued', 'running', 'waiting_for_action')
1862
+ WHERE t.conversation_id = c.id AND NOT COALESCE(t.run_config ? 'background', false) AND t.status IN ('queued', 'running', 'waiting_for_action')
1731
1863
  )
1732
1864
  ORDER BY c.updated_at ASC
1733
1865
  LIMIT ${limit}
@@ -2075,7 +2207,7 @@ export const aiConversations: AiConversationService = {
2075
2207
  listTurnMessages: async (input) => {
2076
2208
  const rows = await sql<MessageRow[]>`
2077
2209
  SELECT *
2078
- FROM ai.messages
2210
+ FROM (SELECT * FROM ai.messages UNION ALL SELECT * FROM ai.task_messages) messages
2079
2211
  WHERE conversation_id = ${input.conversationId}
2080
2212
  AND loop_id = ${input.loopId}
2081
2213
  AND (${input.includeCompacted ?? false} OR compacted_at IS NULL)
@@ -2139,7 +2271,8 @@ export const aiConversations: AiConversationService = {
2139
2271
  usage,
2140
2272
  stop_reason,
2141
2273
  loop_aggregate,
2142
- loop_done_reason
2274
+ loop_done_reason,
2275
+ meta
2143
2276
  )
2144
2277
  SELECT
2145
2278
  short_id,
@@ -2155,7 +2288,8 @@ export const aiConversations: AiConversationService = {
2155
2288
  usage,
2156
2289
  stop_reason,
2157
2290
  loop_aggregate,
2158
- loop_done_reason
2291
+ loop_done_reason,
2292
+ CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
2159
2293
  FROM ai.messages
2160
2294
  WHERE conversation_id = ${input.sourceConversationId}
2161
2295
  AND compacted_at IS NULL
@@ -2184,13 +2318,15 @@ export const aiConversations: AiConversationService = {
2184
2318
 
2185
2319
  setLatestAssistantLoopAggregate: async (input) => {
2186
2320
  const loopId = input.loopId ?? null;
2321
+ const config = loopId ? await aiConversations.getTurnRunConfig({ conversationId: input.conversationId, turnId: loopId }) : null;
2322
+ const table = config?.kind === "chat" && config.background ? "ai.task_messages" : "ai.messages";
2187
2323
  await sql`
2188
- UPDATE ai.messages
2189
- SET loop_aggregate = ${JSON.stringify(input.aggregate)}::jsonb,
2324
+ UPDATE ${sql(table)}
2325
+ SET loop_aggregate = ${JSON.stringify(input.aggregate)}::text::jsonb,
2190
2326
  loop_done_reason = ${input.doneReason}
2191
2327
  WHERE id = (
2192
2328
  SELECT id
2193
- FROM ai.messages
2329
+ FROM ${sql(table)}
2194
2330
  WHERE conversation_id = ${input.conversationId}
2195
2331
  AND compacted_at IS NULL
2196
2332
  AND kind = 'message'
@@ -2214,9 +2350,18 @@ export const aiConversations: AiConversationService = {
2214
2350
  WHERE conversation_id = ${input.conversationId}
2215
2351
  AND compacted_at IS NULL
2216
2352
  AND seq <= ${checkpointSeq}
2353
+ AND ${visibleToTurn(input.turnId)}
2217
2354
  `;
2218
2355
  if ((rows[0]?.count ?? 0) === 0) return;
2219
2356
 
2357
+ const plans = await tx<{ plan: unknown }[]>`
2358
+ SELECT CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
2359
+ FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq <= ${checkpointSeq}
2360
+ AND ${visibleToTurn(input.turnId)}
2361
+ AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
2362
+ ORDER BY seq DESC, id DESC LIMIT 1
2363
+ `;
2364
+ const todoPlan = parseAiTodoPlan(plans[0]?.plan);
2220
2365
  const archived = await tx<{ count: number }[]>`
2221
2366
  WITH archived AS (
2222
2367
  UPDATE ai.messages
@@ -2224,6 +2369,7 @@ export const aiConversations: AiConversationService = {
2224
2369
  WHERE conversation_id = ${input.conversationId}
2225
2370
  AND compacted_at IS NULL
2226
2371
  AND seq <= ${checkpointSeq}
2372
+ AND ${visibleToTurn(input.turnId)}
2227
2373
  RETURNING id
2228
2374
  )
2229
2375
  SELECT COUNT(*)::int AS count FROM archived
@@ -2232,41 +2378,32 @@ export const aiConversations: AiConversationService = {
2232
2378
  await insertMessageLocked(
2233
2379
  {
2234
2380
  conversationId: input.conversationId,
2235
- message: input.summary,
2381
+ message: todoPlan && input.summary.role === "assistant" ? { ...input.summary, content: [...input.summary.content, { type: "text", text: `Working plan at this checkpoint:\n${JSON.stringify(todoPlan)}` }] } : input.summary,
2236
2382
  kind: "summary",
2237
2383
  seq: checkpointSeq,
2238
2384
  loopId: null,
2239
2385
  modelProfileId: input.modelProfileId ?? null,
2240
- meta: { compactedCount: archived[0]?.count ?? 0 },
2386
+ meta: { compactedCount: archived[0]?.count ?? 0, ...(todoPlan ? { todoPlan } : {}) },
2241
2387
  },
2242
2388
  tx,
2243
2389
  );
2244
2390
  });
2245
2391
  },
2246
2392
 
2247
- createCompactionTurn: async (input) => {
2248
- const rows = await withAiShortId(
2249
- "idx_ai_turns_conversation_short_id",
2250
- (shortId) => sql<TurnRow[]>`
2251
- INSERT INTO ai.turns (
2252
- short_id,
2253
- conversation_id,
2254
- model_profile_id,
2255
- status,
2256
- run_config
2257
- )
2258
- VALUES (
2259
- ${shortId},
2260
- ${input.conversationId},
2261
- ${input.modelProfileId},
2262
- 'queued',
2263
- (${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb
2264
- )
2393
+ createCompactionTurn: async (input) => sql.begin(async (tx) => {
2394
+ const [conversation] = await tx<{ id: string }[]>`
2395
+ SELECT id FROM ai.conversations WHERE id = ${input.conversationId}::uuid AND archived_at IS NULL FOR UPDATE
2396
+ `;
2397
+ if (!conversation) throw new Error("Conversation not found.");
2398
+ const rows = await withAiShortIdForDb(tx, "idx_ai_turns_conversation_short_id", (attempt, shortId) => attempt<TurnRow[]>`
2399
+ INSERT INTO ai.turns (short_id, conversation_id, model_profile_id, status, run_config)
2400
+ VALUES (${shortId}, ${input.conversationId}, ${input.modelProfileId}, 'queued',
2401
+ (${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb)
2265
2402
  RETURNING *
2266
- `,
2267
- );
2403
+ `);
2404
+ await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${input.conversationId}::uuid`;
2268
2405
  return rowToTurn(rows[0]!);
2269
- },
2406
+ }),
2270
2407
 
2271
2408
  submitChatTurn: async (input) => {
2272
2409
  return await sql.begin(async (tx) => {
@@ -2274,6 +2411,15 @@ export const aiConversations: AiConversationService = {
2274
2411
  SELECT draft_revision, project_id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE
2275
2412
  `;
2276
2413
  if (!conversation) throw new Error("Conversation not found.");
2414
+ if (input.queuedMessageId) {
2415
+ const [head] = await tx<{id:string;status:string}[]>`SELECT id, status FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') ORDER BY position LIMIT 1`;
2416
+ const [busy] = await tx`SELECT id FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid AND NOT COALESCE(run_config ? 'background', false) AND status IN ('queued','running','waiting_for_action') LIMIT 1`;
2417
+ if (!head || head.id !== input.queuedMessageId || head.status !== 'pending' || busy) throw new Error("Queued message is no longer ready.");
2418
+ }
2419
+ if (!input.queuedMessageId) {
2420
+ const [queued] = await tx`SELECT id FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') LIMIT 1`;
2421
+ if (queued) throw new Error("Queued messages must be sent first.");
2422
+ }
2277
2423
  if (input.expectedDraftRevision !== undefined && Number(conversation.draft_revision) !== input.expectedDraftRevision) {
2278
2424
  throw new Error("Conversation draft changed before the turn was submitted.");
2279
2425
  }
@@ -2309,13 +2455,19 @@ export const aiConversations: AiConversationService = {
2309
2455
  RETURNING *
2310
2456
  `,
2311
2457
  );
2458
+ // Queue acceptance already recorded the user's interaction time.
2459
+ if (!input.queuedMessageId) await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${input.conversationId}::uuid`;
2312
2460
  const turn = rowToTurn(turnRows[0]!);
2313
2461
  const attachedFiles = input.runConfig.files?.attached ?? [];
2314
2462
  for (const file of attachedFiles) {
2315
- const copied = input.retrySourceTurnId
2463
+ const copied = input.queuedMessageId
2464
+ ? await tx<{path:string}[]>`INSERT INTO ai.turn_files(turn_id,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version)
2465
+ SELECT ${turn.id}::uuid,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version FROM ai.queued_message_files
2466
+ WHERE message_id = ${input.queuedMessageId}::uuid AND path = ${file.path} RETURNING path`
2467
+ : input.retrySourceTurnId
2316
2468
  ? await tx<{ path: string }[]>`
2317
- INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
2318
- SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
2469
+ INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
2470
+ SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
2319
2471
  FROM ai.turn_files
2320
2472
  WHERE turn_id = ${input.retrySourceTurnId}::uuid
2321
2473
  AND path = ${file.path}
@@ -2325,8 +2477,8 @@ export const aiConversations: AiConversationService = {
2325
2477
  RETURNING path
2326
2478
  `
2327
2479
  : await tx<{ path: string }[]>`
2328
- INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
2329
- SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
2480
+ INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
2481
+ SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
2330
2482
  FROM ai.files
2331
2483
  WHERE conversation_id = ${input.conversationId}::uuid
2332
2484
  AND path = ${file.path}
@@ -2342,6 +2494,7 @@ export const aiConversations: AiConversationService = {
2342
2494
  conversationId: input.conversationId,
2343
2495
  message: input.userMessage,
2344
2496
  loopId: turn.id,
2497
+ meta: input.expectedDraftRevision === undefined ? undefined : { submittedDraftRevision: input.expectedDraftRevision },
2345
2498
  },
2346
2499
  tx,
2347
2500
  );
@@ -2375,6 +2528,10 @@ export const aiConversations: AiConversationService = {
2375
2528
  WHERE id = ${input.conversationId}::uuid
2376
2529
  `;
2377
2530
  }
2531
+ if (input.queuedMessageId) {
2532
+ await tx`UPDATE ai.queued_messages SET status = 'submitted', submission = '{}'::jsonb, content = '[]'::jsonb WHERE id = ${input.queuedMessageId}::uuid`;
2533
+ await tx`DELETE FROM ai.queued_message_files WHERE message_id = ${input.queuedMessageId}::uuid`;
2534
+ }
2378
2535
  return { turn, message: rowToMessage(messageRow) };
2379
2536
  });
2380
2537
  },
@@ -2390,6 +2547,12 @@ export const aiConversations: AiConversationService = {
2390
2547
  return rows[0]?.run_config ? parseJsonValue<AiTurnRunConfig>(rows[0].run_config) : null;
2391
2548
  },
2392
2549
 
2550
+ getLatestTurn: async (input) => {
2551
+ const [row] = await sql<TurnRow[]>`SELECT * FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid AND NOT COALESCE(run_config ? 'background', false)
2552
+ ORDER BY created_at DESC, id DESC LIMIT 1`;
2553
+ return row ? rowToTurn(row) : null;
2554
+ },
2555
+
2393
2556
  getTurn: async (input) => {
2394
2557
  const rows = await sql<TurnRow[]>`
2395
2558
  SELECT *
@@ -2417,6 +2580,7 @@ export const aiConversations: AiConversationService = {
2417
2580
  SELECT *
2418
2581
  FROM ai.turns
2419
2582
  WHERE conversation_id = ${input.conversationId}
2583
+ AND NOT COALESCE(run_config ? 'background', false)
2420
2584
  AND status IN ('queued', 'running', 'waiting_for_action')
2421
2585
  ORDER BY created_at DESC
2422
2586
  LIMIT 1
@@ -2431,7 +2595,7 @@ export const aiConversations: AiConversationService = {
2431
2595
 
2432
2596
  claimTurn: async (input) => {
2433
2597
  const leaseMs = boundedMs(input.leaseMs, 60_000, 5_000, 5 * 60_000);
2434
- const runBudgetMs = boundedMs(input.runBudgetMs, 10 * 60_000, 10_000, 60 * 60_000);
2598
+ const runBudgetMs = Number.isFinite(input.runBudgetMs) && input.runBudgetMs >= 0 ? input.runBudgetMs : 30 * 60_000;
2435
2599
  const maxAttempts = Math.max(1, Math.floor(input.maxAttempts));
2436
2600
  const rows = await sql<TurnRow[]>`
2437
2601
  UPDATE ai.turns
@@ -2440,9 +2604,11 @@ export const aiConversations: AiConversationService = {
2440
2604
  lease_owner = ${input.leaseOwner},
2441
2605
  lease_expires_at = now() + (${leaseMs} * interval '1 millisecond'),
2442
2606
  heartbeat_at = now(),
2607
+ run_budget_ms = COALESCE(run_budget_ms, ${runBudgetMs}),
2443
2608
  deadline = CASE
2444
- WHEN ${input.from} = 'waiting' THEN now() + (${runBudgetMs} * interval '1 millisecond')
2445
- ELSE COALESCE(deadline, now() + (${runBudgetMs} * interval '1 millisecond'))
2609
+ WHEN COALESCE(run_budget_ms, ${runBudgetMs}) = 0 THEN NULL
2610
+ WHEN ${input.from} = 'waiting' OR deadline IS NULL THEN now() + (COALESCE(run_budget_ms, ${runBudgetMs}) * interval '1 millisecond')
2611
+ ELSE deadline
2446
2612
  END
2447
2613
  WHERE id = ${input.turnId}
2448
2614
  AND conversation_id = ${input.conversationId}
@@ -2686,7 +2852,7 @@ export const aiConversations: AiConversationService = {
2686
2852
  UPDATE ai.turns
2687
2853
  SET status = 'failed',
2688
2854
  completed_at = now(),
2689
- error = 'AI turn exceeded its execution budget.',
2855
+ error = 'Run time limit reached. You can continue the task in a new message.',
2690
2856
  lease_owner = NULL,
2691
2857
  lease_expires_at = NULL,
2692
2858
  live_blocks = NULL
@@ -2853,14 +3019,14 @@ export const aiConversations: AiConversationService = {
2853
3019
  ${input.callId},
2854
3020
  ${input.kind},
2855
3021
  ${input.name},
2856
- ${JSON.stringify(input.args ?? null)}::jsonb,
3022
+ ${JSON.stringify(input.args ?? null)}::text::jsonb,
2857
3023
  ${input.message ?? null},
2858
- ${input.review ? JSON.stringify(input.review) : null}::jsonb,
3024
+ ${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
2859
3025
  ${input.approvalScope},
2860
3026
  ${input.allowAlways},
2861
3027
  ${input.frontendMode ?? null},
2862
3028
  ${input.resolvedEvent ? "resolved" : "pending"},
2863
- ${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::jsonb,
3029
+ ${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::text::jsonb,
2864
3030
  CASE WHEN ${Boolean(input.resolvedEvent)} THEN now() ELSE NULL END
2865
3031
  )
2866
3032
  ON CONFLICT (turn_id, call_id)
@@ -2928,7 +3094,7 @@ export const aiConversations: AiConversationService = {
2928
3094
  const rows = await sql<PendingActionRow[]>`
2929
3095
  UPDATE ai.pending_actions
2930
3096
  SET status = 'resolved',
2931
- resolved_event = ${JSON.stringify(input.event)}::jsonb,
3097
+ resolved_event = ${JSON.stringify(input.event)}::text::jsonb,
2932
3098
  resolved_at = now()
2933
3099
  WHERE conversation_id = ${input.conversationId}
2934
3100
  AND turn_id = ${input.turnId}
@@ -3054,7 +3220,21 @@ export const aiConversations: AiConversationService = {
3054
3220
  createSessionStore: (input): SessionStore => ({
3055
3221
  load: async (): Promise<StoreEntry[]> => {
3056
3222
  // The loop must only ever see the active model context, never archived history.
3057
- const rows = await aiConversations.listContextMessages({ conversationId: input.conversationId });
3223
+ if (input.background && input.turnId) {
3224
+ const own = await aiConversations.listTurnMessages({ conversationId: input.conversationId, loopId: input.turnId });
3225
+ const context = input.background.context;
3226
+ return [...context, ...own.map(row => ({ seq: context.length + row.seq, kind: row.kind, message: row.message }))];
3227
+ }
3228
+ // Results delivered after this turn started belong to the next turn's context.
3229
+ // Compare durable message sequences rather than wall-clock timestamps.
3230
+ const rows = input.turnId
3231
+ ? (await sql<MessageRow[]>`
3232
+ SELECT * FROM ai.messages
3233
+ WHERE conversation_id = ${input.conversationId}::uuid AND compacted_at IS NULL
3234
+ AND ${visibleToTurn(input.turnId)}
3235
+ ORDER BY seq ASC
3236
+ `).map(rowToMessage)
3237
+ : await aiConversations.listContextMessages({ conversationId: input.conversationId });
3058
3238
  return rows.map((row) => ({
3059
3239
  seq: row.seq,
3060
3240
  kind: row.kind,
@@ -3077,19 +3257,21 @@ export const aiConversations: AiConversationService = {
3077
3257
 
3078
3258
  if (input.turnId && input.leaseOwner) {
3079
3259
  const appended = await appendTurnOwnedMessage({
3260
+ background: Boolean(input.background),
3080
3261
  conversationId: input.conversationId,
3081
3262
  turnId: input.turnId,
3082
3263
  leaseOwner: input.leaseOwner,
3083
3264
  message,
3084
3265
  kind: opts?.kind,
3085
3266
  seq: opts?.seq,
3086
- loopId: opts?.kind === "summary" ? null : input.turnId,
3267
+ loopId: opts?.kind === "summary" && !input.background ? null : input.turnId,
3087
3268
  modelProfileId: input.modelProfileId,
3088
3269
  meta,
3089
3270
  });
3090
3271
  if (!appended) {
3091
3272
  throw new Error("AI turn lost its lease while writing a message.");
3092
3273
  }
3274
+ await input.onMessage?.(appended);
3093
3275
  return;
3094
3276
  }
3095
3277