History

Merge origin/main into codething/50e6e9da

Julius Marminge committed 7 months ago

Commit 037a7712 parents 07252e5 f297e30599 files · +71384 −18403Browse files

Unified · Split

599 files
  1. .devcontainer/devcontainer.json +22 −0
  2. .docs/remote-architecture.md +302 −0
  3. .github/workflows/ci.yml +9 −4
  4. .github/workflows/release.yml +181 −47
  5. .gitignore +1 −0
  6. .oxfmtrc.json +3 −1
  7. .plans/18-server-auth-model.md +823 −0
  8. CLAUDE.md +1 −1
  9. KEYBINDINGS.md +2 −0
  10. README.md +8 −0
  11. REMOTE.md +63 −44
  12. apps/desktop/package.json +5 −4
  13. apps/desktop/scripts/dev-electron.mjs +17 −10
  14. apps/desktop/scripts/electron-launcher.mjs +75 −39
  15. apps/desktop/scripts/smoke-test.mjs +1 −1
  16. apps/desktop/scripts/start-electron.mjs +1 −1
  17. apps/desktop/src/appBranding.test.ts +47 −0
  18. apps/desktop/src/appBranding.ts +28 −0
  19. apps/desktop/src/backendPort.test.ts +109 −0
  20. apps/desktop/src/backendPort.ts +83 −0
  21. apps/desktop/src/backendReadiness.test.ts +114 −0
  22. apps/desktop/src/backendReadiness.ts +107 −0
  23. apps/desktop/src/clientPersistence.test.ts +238 −0
  24. apps/desktop/src/clientPersistence.ts +229 −0
  25. apps/desktop/src/confirmDialog.test.ts +1 −1
  26. apps/desktop/src/desktopSettings.test.ts +147 −0
  27. apps/desktop/src/desktopSettings.ts +92 −0
  28. apps/desktop/src/main.ts +802 −101
  29. apps/desktop/src/preload.ts +40 −5
  30. apps/desktop/src/runtimeArch.test.ts +1 −1
  31. apps/desktop/src/serverExposure.test.ts +139 −0
  32. apps/desktop/src/serverExposure.ts +80 −0
  33. apps/desktop/src/serverListeningDetector.test.ts +31 −0
  34. apps/desktop/src/serverListeningDetector.ts +56 −0
  35. apps/desktop/src/syncShellEnvironment.test.ts +191 −7
  36. apps/desktop/src/syncShellEnvironment.ts +85 −12
  37. apps/desktop/src/updateChannels.test.ts +41 −0
  38. apps/desktop/src/updateChannels.ts +18 −0
  39. apps/desktop/src/updateMachine.test.ts +8 −7
  40. apps/desktop/src/updateMachine.ts +8 −2
  41. apps/desktop/src/updateState.test.ts +31 −1
  42. apps/desktop/src/updateState.ts +4 −0
  43. apps/desktop/tsconfig.json +1 −1
  44. apps/desktop/tsdown.config.ts +1 −1
  45. apps/marketing/src/pages/download.astro +5 −4
  46. apps/server/integration/OrchestrationEngineHarness.integration.ts +23 −4
  47. apps/server/integration/TestProviderAdapter.integration.ts +3 −3
  48. apps/server/integration/fixtures/providerRuntime.ts +2 −2
  49. apps/server/integration/orchestrationEngine.integration.test.ts +18 −19
  50. apps/server/integration/providerService.integration.test.ts +29 −41
  51. apps/server/package.json +6 −4
  52. apps/server/scripts/cli.ts +33 −16
  53. apps/server/src/auth/Layers/AuthControlPlane.test.ts +111 −0
  54. apps/server/src/auth/Layers/AuthControlPlane.ts +178 −0
  55. apps/server/src/auth/Layers/BootstrapCredentialService.test.ts +151 −0
  56. apps/server/src/auth/Layers/BootstrapCredentialService.ts +296 −0
  57. apps/server/src/auth/Layers/ServerAuth.test.ts +182 −0
  58. apps/server/src/auth/Layers/ServerAuth.ts +395 −0
  59. apps/server/src/auth/Layers/ServerAuthPolicy.test.ts +114 −0
  60. apps/server/src/auth/Layers/ServerAuthPolicy.ts +44 −0
  61. apps/server/src/auth/Layers/ServerSecretStore.test.ts +263 −0
  62. apps/server/src/auth/Layers/ServerSecretStore.ts +148 −0
  63. apps/server/src/auth/Layers/SessionCredentialService.test.ts +191 −0
  64. apps/server/src/auth/Layers/SessionCredentialService.ts +500 −0
  65. apps/server/src/auth/Services/AuthControlPlane.ts +69 −0
  66. apps/server/src/auth/Services/BootstrapCredentialService.ts +57 −0
  67. apps/server/src/auth/Services/ServerAuth.ts +84 −0
  68. apps/server/src/auth/Services/ServerAuthPolicy.ts +11 −0
  69. apps/server/src/auth/Services/ServerSecretStore.ts +21 −0
  70. apps/server/src/auth/Services/SessionCredentialService.ts +87 −0
  71. apps/server/src/auth/http.ts +254 −0
  72. apps/server/src/auth/utils.test.ts +26 −0
  73. apps/server/src/auth/utils.ts +132 −0
  74. apps/server/src/bin.ts +3 −3
  75. apps/server/src/bootstrap.test.ts +1 −1
  76. apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +14 −4
  77. apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +1 −1
  78. apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +2 −2
  79. apps/server/src/checkpointing/Services/CheckpointStore.ts +3 −3
  80. apps/server/src/checkpointing/Utils.ts +1 −1
  81. apps/server/src/cli-config.test.ts +134 −17
  82. apps/server/src/cli.test.ts +326 −7
  83. apps/server/src/cli.ts +854 −89
  84. apps/server/src/cliAuthFormat.test.ts +88 −0
  85. apps/server/src/cliAuthFormat.ts +190 −0
  86. apps/server/src/codexAppServerManager.test.ts +154 −6
  87. apps/server/src/codexAppServerManager.ts +75 −35
  88. apps/server/src/config.ts +20 −4
  89. apps/server/src/environment/Layers/ServerEnvironment.test.ts +125 −0
  90. apps/server/src/environment/Layers/ServerEnvironment.ts +92 −0
  91. apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +150 −0
  92. apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +106 −0
  93. apps/server/src/environment/Services/ServerEnvironment.ts +12 −0
  94. apps/server/src/git/Layers/CodexTextGeneration.ts +1 −0
  95. apps/server/src/git/Layers/GitCore.test.ts +183 −63
  96. apps/server/src/git/Layers/GitCore.ts +109 −36
  97. apps/server/src/git/Layers/GitHubCli.test.ts +100 −1
  98. apps/server/src/git/Layers/GitHubCli.ts +41 −81
  99. apps/server/src/git/Layers/GitManager.test.ts +188 −3
  100. apps/server/src/git/Layers/GitManager.ts +136 −119
  101. apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +312 −0
  102. apps/server/src/git/Layers/GitStatusBroadcaster.ts +311 −0
  103. apps/server/src/git/Layers/RoutingTextGeneration.ts +3 −3
  104. apps/server/src/git/Services/GitCore.ts +14 −7
  105. apps/server/src/git/Services/GitHubCli.ts +3 −3
  106. apps/server/src/git/Services/GitManager.ts +33 −2
  107. apps/server/src/git/Services/GitStatusBroadcaster.ts +27 −0
  108. apps/server/src/git/Services/TextGeneration.ts +2 −2
  109. apps/server/src/git/githubPullRequests.ts +128 −0
  110. apps/server/src/http.test.ts +27 −0
  111. apps/server/src/http.ts +127 −12
  112. apps/server/src/keybindings.test.ts +20 −3
  113. apps/server/src/keybindings.ts +7 −6
  114. apps/server/src/observability/Layers/Observability.ts +23 −3
  115. apps/server/src/observability/LocalFileTracer.test.ts +3 −3
  116. apps/server/src/observability/LocalFileTracer.ts +17 −9
  117. apps/server/src/observability/Services/BrowserTraceCollector.ts +13 −0
  118. apps/server/src/observability/TraceRecord.ts +202 −15
  119. apps/server/src/open.test.ts +91 −2
  120. apps/server/src/open.ts +50 −124
  121. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +164 −119
  122. apps/server/src/orchestration/Layers/CheckpointReactor.ts +31 −8
  123. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +77 −62
  124. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +627 −302
  125. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +173 −2
  126. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +410 −13
  127. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +860 −245
  128. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +232 −152
  129. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +10 −11
  130. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +45 −49
  131. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +9 −10
  132. apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts +20 −2
  133. apps/server/src/orchestration/Normalizer.ts +26 −5
  134. apps/server/src/orchestration/Services/CheckpointReactor.ts +4 −5
  135. apps/server/src/orchestration/Services/OrchestrationEngine.ts +3 −3
  136. apps/server/src/orchestration/Services/OrchestrationReactor.ts +2 −2
  137. apps/server/src/orchestration/Services/ProjectionPipeline.ts +2 −2
  138. apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +38 −2
  139. apps/server/src/orchestration/Services/ProviderCommandReactor.ts +2 −2
  140. apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts +2 −2
  141. apps/server/src/orchestration/Services/RuntimeReceiptBus.ts +21 −6
  142. apps/server/src/orchestration/commandInvariants.test.ts +24 −26
  143. apps/server/src/orchestration/decider.projectScripts.test.ts +34 −34
  144. apps/server/src/orchestration/http.ts +93 −0
  145. apps/server/src/orchestration/projector.test.ts +4 −4
  146. apps/server/src/orchestration/runtimeLayer.ts +27 −0
  147. apps/server/src/os-jank.test.ts +150 −4
  148. apps/server/src/os-jank.ts +65 −10
  149. apps/server/src/persistence/Errors.ts +2 −0
  150. apps/server/src/persistence/Layers/AuthPairingLinks.ts +209 −0
  151. apps/server/src/persistence/Layers/AuthSessions.ts +279 −0
  152. apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +8 −8
  153. apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +9 −5
  154. apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +4 −4
  155. apps/server/src/persistence/Layers/ProjectionThreads.ts +20 −0
  156. apps/server/src/persistence/Migrations.ts +12 −0
  157. apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts +42 −0
  158. apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts +62 −0
  159. apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts +17 −0
  160. apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts +26 −0
  161. apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts +218 −0
  162. apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts +277 −0
  163. apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts +196 −0
  164. apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts +27 −0
  165. apps/server/src/persistence/NodeSqliteClient.ts +10 −10
  166. apps/server/src/persistence/Services/AuthPairingLinks.ts +76 −0
  167. apps/server/src/persistence/Services/AuthSessions.ts +93 −0
  168. apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +2 −2
  169. apps/server/src/persistence/Services/OrchestrationEventStore.ts +3 −3
  170. apps/server/src/persistence/Services/ProjectionCheckpoints.ts +2 −2
  171. apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +2 −2
  172. apps/server/src/persistence/Services/ProjectionProjects.ts +2 −2
  173. apps/server/src/persistence/Services/ProjectionState.ts +2 −2
  174. apps/server/src/persistence/Services/ProjectionThreadActivities.ts +2 −2
  175. apps/server/src/persistence/Services/ProjectionThreadMessages.ts +2 −2
  176. apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +2 −2
  177. apps/server/src/persistence/Services/ProjectionThreadSessions.ts +2 −2
  178. apps/server/src/persistence/Services/ProjectionThreads.ts +7 −2
  179. apps/server/src/persistence/Services/ProjectionTurns.ts +2 −2
  180. apps/server/src/persistence/Services/ProviderSessionRuntime.ts +2 −2
  181. apps/server/src/process/Layers/TerminalProcessInspector.test.ts +2 −2
  182. apps/server/src/process/Layers/TerminalProcessInspector.ts +6 −6
  183. apps/server/src/process/Layers/WebPortInspector.test.ts +2 −2
  184. apps/server/src/process/Layers/WebPortInspector.ts +3 −3
  185. apps/server/src/process/Services/TerminalProcessInspector.ts +2 −2
  186. apps/server/src/process/Services/WebPortInspector.ts +2 −2
  187. apps/server/src/process/posix.test.ts +1 −1
  188. apps/server/src/process/posix.ts +2 −2
  189. apps/server/src/process/win32.test.ts +1 −1
  190. apps/server/src/process/win32.ts +2 −2
  191. apps/server/src/processRunner.test.ts +1 −1
  192. apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +223 −0
  193. apps/server/src/project/Layers/RepositoryIdentityResolver.ts +151 −0
  194. apps/server/src/project/Services/ProjectFaviconResolver.ts +2 −2
  195. apps/server/src/project/Services/ProjectSetupScriptRunner.ts +2 −2
  196. apps/server/src/project/Services/RepositoryIdentityResolver.ts +12 −0
  197. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +419 −69
  198. apps/server/src/provider/Layers/ClaudeAdapter.ts +243 −92
  199. apps/server/src/provider/Layers/ClaudeProvider.ts +225 −74
  200. apps/server/src/provider/Layers/CodexAdapter.test.ts +11 −15
  201. apps/server/src/provider/Layers/CodexAdapter.ts +18 −21
  202. apps/server/src/provider/Layers/CodexProvider.ts +108 −24
  203. apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +46 −3
  204. apps/server/src/provider/Layers/EventNdjsonLogger.ts +65 −27
  205. apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +4 −2
  206. apps/server/src/provider/Layers/ProviderRegistry.test.ts +263 −7
  207. apps/server/src/provider/Layers/ProviderRegistry.ts +138 −38
  208. apps/server/src/provider/Layers/ProviderService.test.ts +46 −7
  209. apps/server/src/provider/Layers/ProviderService.ts +40 −10
  210. apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +77 −5
  211. apps/server/src/provider/Layers/ProviderSessionDirectory.ts +38 −12
  212. apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +522 −0
  213. apps/server/src/provider/Layers/ProviderSessionReaper.ts +133 −0
  214. apps/server/src/provider/Services/ClaudeAdapter.ts +3 −3
  215. apps/server/src/provider/Services/ClaudeProvider.ts +3 −3
  216. apps/server/src/provider/Services/CodexAdapter.ts +3 −3
  217. apps/server/src/provider/Services/CodexProvider.ts +3 −3
  218. apps/server/src/provider/Services/ProviderAdapterRegistry.ts +2 −2
  219. apps/server/src/provider/Services/ProviderRegistry.ts +2 −2
  220. apps/server/src/provider/Services/ProviderService.ts +3 −3
  221. apps/server/src/provider/Services/ProviderSessionDirectory.ts +11 −2
  222. apps/server/src/provider/Services/ProviderSessionReaper.ts +14 −0
  223. apps/server/src/provider/cliVersion.test.ts +17 −0
  224. apps/server/src/provider/cliVersion.ts +123 −0
  225. apps/server/src/provider/codexAppServer.ts +96 −9
  226. apps/server/src/provider/codexCliVersion.ts +4 −120
  227. apps/server/src/provider/makeManagedServerProvider.ts +3 −2
  228. apps/server/src/provider/providerSnapshot.ts +11 −4
  229. apps/server/src/provider/providerStatusCache.test.ts +136 −0
  230. apps/server/src/provider/providerStatusCache.ts +105 −0
  231. apps/server/src/server.test.ts +2827 −696
  232. apps/server/src/server.ts +157 −87
  233. apps/server/src/serverLifecycleEvents.test.ts +10 −0
  234. apps/server/src/serverLifecycleEvents.ts +2 −2
  235. apps/server/src/serverLogger.ts +1 −1
  236. apps/server/src/serverRuntimeStartup.test.ts +129 −2
  237. apps/server/src/serverRuntimeStartup.ts +143 −58
  238. apps/server/src/serverRuntimeState.ts +77 −0
  239. apps/server/src/serverSettings.test.ts +37 −2
  240. apps/server/src/serverSettings.ts +21 −5
  241. apps/server/src/startupAccess.test.ts +79 −0
  242. apps/server/src/startupAccess.ts +148 −0
  243. apps/server/src/telemetry/Identify.ts +1 −1
  244. apps/server/src/telemetry/Layers/AnalyticsService.ts +2 −2
  245. apps/server/src/telemetry/Services/AnalyticsService.ts +2 −2
  246. apps/server/src/terminal/Layers/BunPTY.ts +5 −2
  247. apps/server/src/terminal/Layers/Manager.test.ts +83 −12
  248. apps/server/src/terminal/Layers/Manager.ts +96 −48
  249. apps/server/src/terminal/Layers/NodePTY.test.ts +1 −1
  250. apps/server/src/terminal/Layers/NodePTY.ts +27 −8
  251. apps/server/src/terminal/Services/Manager.ts +3 −3
  252. apps/server/src/terminal/Services/PTY.ts +2 −2
  253. apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +98 −0
  254. apps/server/src/workspace/Layers/WorkspaceEntries.ts +133 −129
  255. apps/server/src/workspace/Layers/WorkspacePaths.test.ts +18 −0
  256. apps/server/src/workspace/Layers/WorkspacePaths.ts +17 −2
  257. apps/server/src/workspace/Services/WorkspaceEntries.ts +26 −3
  258. apps/server/src/workspace/Services/WorkspaceFileSystem.ts +2 −2
  259. apps/server/src/workspace/Services/WorkspacePaths.ts +20 −3
  260. apps/server/src/ws.ts +1006 −660
  261. apps/server/tsconfig.json +1 −3
  262. apps/server/vitest.config.ts +1 −1
  263. apps/web/index.html +83 −2
  264. apps/web/package.json +3 −2
  265. apps/web/src/authBootstrap.test.ts +495 −0
  266. apps/web/src/branding.test.ts +37 −0
  267. apps/web/src/branding.ts +17 −3
  268. apps/web/src/chat-scroll.test.ts +0 −62
  269. apps/web/src/chat-scroll.ts +0 −24
  270. apps/web/src/clientPersistenceStorage.test.ts +80 −0
  271. apps/web/src/clientPersistenceStorage.ts +194 −0
  272. apps/web/src/commandPaletteStore.ts +32 −0
  273. apps/web/src/components/BranchToolbar.logic.test.ts +93 −1
  274. apps/web/src/components/BranchToolbar.logic.ts +54 −4
  275. apps/web/src/components/BranchToolbar.tsx +83 −137
  276. apps/web/src/components/BranchToolbarBranchSelector.tsx +222 −148
  277. apps/web/src/components/BranchToolbarEnvModeSelector.tsx +98 −0
  278. apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +88 −0
  279. apps/web/src/components/ChatMarkdown.browser.tsx +141 −0
  280. apps/web/src/components/ChatMarkdown.tsx +294 −20
  281. apps/web/src/components/ChatView.browser.tsx +3469 −837
  282. apps/web/src/components/ChatView.logic.test.ts +384 −121
  283. apps/web/src/components/ChatView.logic.ts +77 −24
  284. apps/web/src/components/ChatView.tsx +1118 −2187
  285. apps/web/src/components/CommandPalette.logic.test.ts +166 −0
  286. apps/web/src/components/CommandPalette.logic.ts +359 −0
  287. apps/web/src/components/CommandPalette.tsx +1115 −0
  288. apps/web/src/components/CommandPaletteResults.tsx +116 −0
  289. apps/web/src/components/ComposerPromptEditor.tsx +610 −71
  290. apps/web/src/components/DiffPanel.tsx +35 −22
  291. apps/web/src/components/DiffPanelShell.tsx +4 −2
  292. apps/web/src/components/GitActionsControl.browser.tsx +294 −70
  293. apps/web/src/components/GitActionsControl.logic.test.ts +9 −0
  294. apps/web/src/components/GitActionsControl.logic.ts +10 −0
  295. apps/web/src/components/GitActionsControl.tsx +138 −38
  296. apps/web/src/components/Icons.tsx +139 −0
  297. apps/web/src/components/KeybindingsToast.browser.tsx +193 −28
  298. apps/web/src/components/NoActiveThreadState.tsx +45 −0
  299. apps/web/src/components/PlanSidebar.tsx +20 −6
  300. apps/web/src/components/ProjectFavicon.tsx +14 −7
  301. apps/web/src/components/PullRequestThreadDialog.tsx +7 −3
  302. apps/web/src/components/RightPanelSheet.tsx +30 −0
  303. apps/web/src/components/Sidebar.logic.test.ts +134 −230
  304. apps/web/src/components/Sidebar.logic.ts +26 −62
  305. apps/web/src/components/Sidebar.tsx +2681 −1542
  306. apps/web/src/components/SplashScreen.tsx +9 −0
  307. apps/web/src/components/ThreadStatusIndicators.tsx +241 −0
  308. apps/web/src/components/ThreadTerminalDrawer.browser.tsx +318 −0
  309. apps/web/src/components/ThreadTerminalDrawer.tsx +90 −33
  310. apps/web/src/components/WebSocketConnectionSurface.logic.test.ts +113 −0
  311. apps/web/src/components/WebSocketConnectionSurface.tsx +390 −0
  312. apps/web/src/components/auth/PairingRouteSurface.tsx +195 −0
  313. apps/web/src/components/chat/ChangedFilesTree.test.tsx +2 −2
  314. apps/web/src/components/chat/ChatComposer.tsx +1999 −0
  315. apps/web/src/components/chat/ChatHeader.tsx +14 −1
  316. apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +38 −29
  317. apps/web/src/components/chat/CompactComposerControlsMenu.tsx +7 −3
  318. apps/web/src/components/chat/ComposerCommandMenu.tsx +161 −27
  319. apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx +1 −1
  320. apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +39 −32
  321. apps/web/src/components/chat/ComposerPrimaryActions.test.ts +93 −0
  322. apps/web/src/components/chat/ComposerPrimaryActions.tsx +8 −3
  323. apps/web/src/components/chat/ExpandedImageDialog.tsx +120 −0
  324. apps/web/src/components/chat/MessageCopyButton.tsx +74 −12
  325. apps/web/src/components/chat/MessagesTimeline.browser.tsx +160 −0
  326. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +291 −1
  327. apps/web/src/components/chat/MessagesTimeline.logic.ts +102 −61
  328. apps/web/src/components/chat/MessagesTimeline.test.tsx +93 −47
  329. apps/web/src/components/chat/MessagesTimeline.tsx +551 −421
  330. apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +0 −1037
  331. apps/web/src/components/chat/OpenInPicker.tsx +13 −5
  332. apps/web/src/components/chat/ProposedPlanCard.tsx +5 −2
  333. apps/web/src/components/chat/ProviderModelPicker.browser.tsx +6 −0
  334. apps/web/src/components/chat/TraitsPicker.browser.tsx +35 −25
  335. apps/web/src/components/chat/TraitsPicker.tsx +12 −5
  336. apps/web/src/components/chat/composerMenuHighlight.test.ts +51 −0
  337. apps/web/src/components/chat/composerMenuHighlight.ts +20 −0
  338. apps/web/src/components/chat/composerProviderRegistry.test.tsx +33 −1
  339. apps/web/src/components/chat/composerProviderRegistry.tsx +93 −53
  340. apps/web/src/components/chat/composerSlashCommandSearch.test.ts +68 −0
  341. apps/web/src/components/chat/composerSlashCommandSearch.ts +83 −0
  342. apps/web/src/components/composerFooterLayout.test.ts +0 −99
  343. apps/web/src/components/composerFooterLayout.ts +0 −45
  344. apps/web/src/components/composerInlineChip.ts +3 −0
  345. apps/web/src/components/desktopUpdate.logic.test.ts +1 −0
  346. apps/web/src/components/settings/ConnectionsSettings.tsx +1432 −0
  347. apps/web/src/components/settings/SettingsPanels.browser.tsx +625 −12
  348. apps/web/src/components/settings/SettingsPanels.tsx +200 −158
  349. apps/web/src/components/settings/SettingsSidebarNav.tsx +9 −5
  350. apps/web/src/components/settings/settingsLayout.tsx +122 −0
  351. apps/web/src/components/timelineHeight.test.ts +0 −138
  352. apps/web/src/components/timelineHeight.ts +0 −107
  353. apps/web/src/components/ui/combobox.tsx +15 −0
  354. apps/web/src/components/ui/command.tsx +6 −3
  355. apps/web/src/components/ui/qr-code.test.tsx +27 −0
  356. apps/web/src/components/ui/qr-code.tsx +81 −0
  357. apps/web/src/components/ui/select.tsx +1 −1
  358. apps/web/src/components/ui/toast.logic.test.ts +52 −1
  359. apps/web/src/components/ui/toast.logic.ts +27 −0
  360. apps/web/src/components/ui/toast.tsx +48 −32
  361. apps/web/src/composer-editor-mentions.test.ts +88 −1
  362. apps/web/src/composer-editor-mentions.ts +185 −27
  363. apps/web/src/composer-logic.test.ts +53 −0
  364. apps/web/src/composer-logic.ts +50 −14
  365. apps/web/src/composerDraftStore.test.ts +360 −202
  366. apps/web/src/composerDraftStore.ts +1652 −984
  367. apps/web/src/composerHandleContext.ts +10 −0
  368. apps/web/src/contextMenuFallback.test.ts +221 −0
  369. apps/web/src/contextMenuFallback.ts +115 −47
  370. apps/web/src/diffRouteSearch.ts +1 −1
  371. apps/web/src/editorPreferences.ts +2 −2
  372. apps/web/src/environmentApi.ts +86 −0
  373. apps/web/src/environmentGrouping.test.ts +614 −0
  374. apps/web/src/environments/primary/auth.ts +373 −0
  375. apps/web/src/environments/primary/bootstrap.test.ts +170 −0
  376. apps/web/src/environments/primary/context.ts +120 −0
  377. apps/web/src/environments/primary/index.ts +35 −0
  378. apps/web/src/environments/primary/target.ts +158 −0
  379. apps/web/src/environments/remote/api.test.ts +235 −0
  380. apps/web/src/environments/remote/api.ts +143 −0
  381. apps/web/src/environments/remote/target.ts +86 −0
  382. apps/web/src/environments/runtime/catalog.test.ts +141 −0
  383. apps/web/src/environments/runtime/catalog.ts +341 −0
  384. apps/web/src/environments/runtime/connection.test.ts +249 −0
  385. apps/web/src/environments/runtime/connection.ts +173 −0
  386. apps/web/src/environments/runtime/index.ts +29 −0
  387. apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +105 −0
  388. apps/web/src/environments/runtime/service.test.ts +41 −0
  389. apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +298 −0
  390. apps/web/src/environments/runtime/service.ts +1081 −0
  391. apps/web/src/filePathDisplay.test.ts +41 −0
  392. apps/web/src/filePathDisplay.ts +57 −0
  393. apps/web/src/historyBootstrap.test.ts +1 −1
  394. apps/web/src/hooks/useHandleNewThread.ts +112 −52
  395. apps/web/src/hooks/useSettings.test.ts +0 −16
  396. apps/web/src/hooks/useSettings.ts +101 −177
  397. apps/web/src/hooks/useTheme.ts +78 −5
  398. apps/web/src/hooks/useThreadActions.ts +138 −67
  399. apps/web/src/index.css +73 −6
  400. apps/web/src/keybindings.test.ts +55 −0
  401. apps/web/src/keybindings.ts +23 −0
  402. apps/web/src/lib/chatThreadActions.test.ts +107 −0
  403. apps/web/src/lib/chatThreadActions.ts +98 −0
  404. apps/web/src/lib/contextWindow.test.ts +2 −2
  405. apps/web/src/lib/desktopUpdateReactQuery.test.ts +1 −0
  406. apps/web/src/lib/gitReactQuery.test.ts +30 −42
  407. apps/web/src/lib/gitReactQuery.ts +119 −107
  408. apps/web/src/lib/gitStatusState.test.ts +325 −0
  409. apps/web/src/lib/gitStatusState.ts +287 −0
  410. apps/web/src/lib/projectPaths.test.ts +117 −0
  411. apps/web/src/lib/projectPaths.ts +259 −0
  412. apps/web/src/lib/projectReactQuery.ts +17 −8
  413. apps/web/src/lib/providerReactQuery.test.ts +12 −5
  414. apps/web/src/lib/providerReactQuery.ts +11 −4
  415. apps/web/src/lib/terminalContext.test.ts +1 −1
  416. apps/web/src/lib/terminalStateCleanup.test.ts +21 −14
  417. apps/web/src/lib/terminalStateCleanup.ts +9 −11
  418. apps/web/src/lib/threadSort.test.ts +187 −0
  419. apps/web/src/lib/threadSort.ts +73 −0
  420. apps/web/src/lib/utils.test.ts +1 −2
  421. apps/web/src/lib/utils.ts +6 −42
  422. apps/web/src/lib/windowControlsOverlay.ts +40 −0
  423. apps/web/src/localApi.test.ts +638 −0
  424. apps/web/src/localApi.ts +154 −0
  425. apps/web/src/logicalProject.ts +157 −0
  426. apps/web/src/main.tsx +5 −0
  427. apps/web/src/markdown-links.test.ts +42 −1
  428. apps/web/src/markdown-links.ts +57 −8
  429. apps/web/src/nativeApi.ts +0 −31
  430. apps/web/src/observability/clientTracing.ts +144 −0
  431. apps/web/src/orchestrationEventEffects.test.ts +15 −15
  432. apps/web/src/orchestrationEventEffects.ts +4 −4
  433. apps/web/src/orchestrationRecovery.ts +5 −1
  434. apps/web/src/pairingUrl.ts +33 −0
  435. apps/web/src/pendingUserInput.test.ts +113 −55
  436. apps/web/src/pendingUserInput.ts +63 −15
  437. apps/web/src/projectScripts.ts +1 −1
  438. apps/web/src/providerSkillPresentation.test.ts +57 −0
  439. apps/web/src/providerSkillPresentation.ts +52 −0
  440. apps/web/src/providerSkillSearch.test.ts +59 −0
  441. apps/web/src/providerSkillSearch.ts +105 −0
  442. apps/web/src/pullRequestReference.test.ts +2 −2
  443. apps/web/src/pullRequestReference.ts +1 −1
  444. apps/web/src/rightPanelLayout.ts +2 −0
  445. apps/web/src/routeTree.gen.ts +81 −17
  446. apps/web/src/routes/__root.tsx +116 −341
  447. apps/web/src/routes/_chat.$environmentId.$threadId.tsx +90 −62
  448. apps/web/src/routes/_chat.draft.$draftId.tsx +86 −0
  449. apps/web/src/routes/_chat.index.tsx +2 −26
  450. apps/web/src/routes/_chat.tsx +41 −25
  451. apps/web/src/routes/pair.tsx +40 −0
  452. apps/web/src/routes/settings.connections.tsx +7 −0
  453. apps/web/src/routes/settings.tsx +6 −2
  454. apps/web/src/rpc/client.test.ts +0 −231
  455. apps/web/src/rpc/client.ts +0 −33
  456. apps/web/src/rpc/protocol.ts +163 −13
  457. apps/web/src/rpc/requestLatencyState.test.ts +65 −0
  458. apps/web/src/rpc/requestLatencyState.ts +133 −0
  459. apps/web/src/rpc/serverState.test.ts +32 −6
  460. apps/web/src/rpc/serverState.ts +1 −1
  461. apps/web/src/rpc/transportError.test.ts +24 −0
  462. apps/web/src/rpc/transportError.ts +23 −0
  463. apps/web/src/rpc/wsConnectionState.test.ts +75 −0
  464. apps/web/src/rpc/wsConnectionState.ts +199 −0
  465. apps/web/src/rpc/wsRpcClient.test.ts +104 −0
  466. apps/web/src/rpc/wsRpcClient.ts +90 −41
  467. apps/web/src/rpc/wsTransport.test.ts +1105 −0
  468. apps/web/src/rpc/wsTransport.ts +287 −0
  469. apps/web/src/session-logic.test.ts +210 −45
  470. apps/web/src/session-logic.ts +216 −28
  471. apps/web/src/sidebarProjectGrouping.ts +118 −0
  472. apps/web/src/store.test.ts +550 −349
  473. apps/web/src/store.ts +1192 −388
  474. apps/web/src/storeSelectors.ts +62 −20
  475. apps/web/src/terminal-links.test.ts +104 −0
  476. apps/web/src/terminal-links.ts +112 −1
  477. apps/web/src/terminalStateStore.test.ts +127 −78
  478. apps/web/src/terminalStateStore.ts +165 −127
  479. apps/web/src/threadDerivation.ts +152 −0
  480. apps/web/src/threadRoutes.test.ts +67 −0
  481. apps/web/src/threadRoutes.ts +59 −0
  482. apps/web/src/threadSelectionStore.test.ts +59 −59
  483. apps/web/src/threadSelectionStore.ts +51 −53
  484. apps/web/src/timestampFormat.test.ts +86 −2
  485. apps/web/src/timestampFormat.ts +88 −2
  486. apps/web/src/types.ts +28 −0
  487. apps/web/src/uiStateStore.test.ts +185 −29
  488. apps/web/src/uiStateStore.ts +197 −49
  489. apps/web/src/vite-env.d.ts +2 −2
  490. apps/web/src/worktreeCleanup.test.ts +15 −12
  491. apps/web/src/wsNativeApi.test.ts +0 −358
  492. apps/web/src/wsNativeApi.ts +0 −107
  493. apps/web/src/wsTransport.test.ts +0 −443
  494. apps/web/src/wsTransport.ts +0 −131
  495. apps/web/test/authHttpHandlers.ts +41 −0
  496. apps/web/test/wsRpcHarness.ts +13 −4
  497. apps/web/tsconfig.json +4 −0
  498. apps/web/vite.config.ts +48 −2
  499. apps/web/vitest.browser.config.ts +8 −0
  500. assets/dev/blueprint-icon-composer.icon/Assets/T3.svg +3 −0
  501. assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg +0 −0
  502. assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png +0 −0
  503. assets/dev/blueprint-icon-composer.icon/icon.json +47 −0
  504. assets/dev/blueprint-ios-1024.png +0 −0
  505. assets/dev/blueprint-macos-1024.png +0 −0
  506. assets/dev/blueprint-universal-1024.png +0 −0
  507. assets/dev/blueprint-web-apple-touch-180.png +0 −0
  508. assets/dev/blueprint-web-favicon-16x16.png +0 −0
  509. assets/dev/blueprint-web-favicon-32x32.png +0 −0
  510. assets/dev/blueprint-web-favicon.ico +0 −0
  511. assets/dev/blueprint-windows.ico +0 −0
  512. assets/nightly/blueprint-ios-1024.png +0 −0
  513. assets/nightly/blueprint-macos-1024.png +0 −0
  514. assets/nightly/blueprint-universal-1024.png +0 −0
  515. assets/nightly/blueprint-web-apple-touch-180.png +0 −0
  516. assets/nightly/blueprint-web-favicon-16x16.png +0 −0
  517. assets/nightly/blueprint-web-favicon-32x32.png +0 −0
  518. assets/nightly/blueprint-web-favicon.ico +0 −0
  519. assets/nightly/blueprint-windows.ico +0 −0
  520. bun.lock +208 −27
  521. docs/observability.md +4 −4
  522. docs/release.md +37 −12
  523. package.json +21 −11
  524. packages/client-runtime/package.json +25 −0
  525. packages/client-runtime/src/index.ts +2 −0
  526. packages/client-runtime/src/knownEnvironment.test.ts +92 −0
  527. packages/client-runtime/src/knownEnvironment.ts +53 −0
  528. packages/client-runtime/src/scoped.ts +64 −0
  529. packages/client-runtime/tsconfig.json +4 −0
  530. packages/contracts/package.json +1 −1
  531. packages/contracts/src/auth.ts +266 −0
  532. packages/contracts/src/baseSchemas.ts +7 −2
  533. packages/contracts/src/editor.ts +13 −11
  534. packages/contracts/src/environment.ts +78 −0
  535. packages/contracts/src/filesystem.ts +30 −0
  536. packages/contracts/src/git.test.ts +1 −1
  537. packages/contracts/src/git.ts +50 −2
  538. packages/contracts/src/index.ts +17 −14
  539. packages/contracts/src/ipc.ts +145 −38
  540. packages/contracts/src/keybindings.test.ts +7 −1
  541. packages/contracts/src/keybindings.ts +2 −1
  542. packages/contracts/src/model.ts +20 −10
  543. packages/contracts/src/orchestration.test.ts +18 −1
  544. packages/contracts/src/orchestration.ts +140 −28
  545. packages/contracts/src/project.ts +1 −1
  546. packages/contracts/src/provider.test.ts +1 −1
  547. packages/contracts/src/provider.ts +3 −3
  548. packages/contracts/src/providerRuntime.test.ts +1 −1
  549. packages/contracts/src/providerRuntime.ts +4 −4
  550. packages/contracts/src/rpc.ts +64 −27
  551. packages/contracts/src/server.test.ts +26 −0
  552. packages/contracts/src/server.ts +40 −7
  553. packages/contracts/src/settings.ts +50 −27
  554. packages/contracts/src/terminal.test.ts +1 −1
  555. packages/contracts/src/terminal.ts +3 −3
  556. packages/shared/package.json +16 −0
  557. packages/shared/src/DrainableWorker.test.ts +1 −1
  558. packages/shared/src/KeyedCoalescingWorker.test.ts +1 −1
  559. packages/shared/src/Net.test.ts +1 −1
  560. packages/shared/src/Net.ts +2 −2
  561. packages/shared/src/String.test.ts +1 −1
  562. packages/shared/src/cliArgs.test.ts +134 −0
  563. packages/shared/src/cliArgs.ts +76 −0
  564. packages/shared/src/git.test.ts +135 −0
  565. packages/shared/src/git.ts +202 −1
  566. packages/shared/src/model.test.ts +2 −1
  567. packages/shared/src/model.ts +2 −2
  568. packages/shared/src/path.test.ts +34 −0
  569. packages/shared/src/path.ts +22 −0
  570. packages/shared/src/qrCode.ts +985 −0
  571. packages/shared/src/searchRanking.test.ts +95 −0
  572. packages/shared/src/searchRanking.ts +192 −0
  573. packages/shared/src/serverSettings.test.ts +60 −1
  574. packages/shared/src/serverSettings.ts +34 −2
  575. packages/shared/src/shell.test.ts +331 −1
  576. packages/shared/src/shell.ts +384 −20
  577. scripts/build-desktop-artifact.test.ts +101 −0
  578. scripts/build-desktop-artifact.ts +225 −97
  579. scripts/dev-runner.test.ts +83 −31
  580. scripts/dev-runner.ts +40 −74
  581. scripts/lib/brand-assets.ts +6 −0
  582. scripts/lib/build-target-arch.test.ts +61 −0
  583. scripts/lib/build-target-arch.ts +50 −0
  584. scripts/lib/resolve-catalog.ts +3 −3
  585. scripts/lib/update-manifest.ts +58 −68
  586. scripts/merge-mac-update-manifests.test.ts +0 −108
  587. scripts/merge-update-manifests.test.ts +306 −0
  588. scripts/merge-update-manifests.ts +108 −0
  589. scripts/mock-update-server.test.ts +104 −0
  590. scripts/mock-update-server.ts +147 −37
  591. scripts/release-smoke.ts +247 −3
  592. scripts/resolve-nightly-release.test.ts +32 −0
  593. scripts/resolve-nightly-release.ts +137 −0
  594. scripts/resolve-previous-release-tag.ts +205 −0
  595. scripts/tsconfig.json +2 −4
  596. scripts/update-release-package-versions.test.ts +213 −0
  597. scripts/update-release-package-versions.ts +68 −86
  598. tsconfig.base.json +8 −3
  599. turbo.json +1 −1
.devcontainer/devcontainer.json+22−0
@@ -0,0 +1,22 @@
1+{
2+ "name": "T3 Code Dev",
3+ "image": "debian:bookworm",
4+ "features": {
5+ "ghcr.io/devcontainers-extra/features/bun:1": {},
6+ "ghcr.io/devcontainers/features/node:1": {
7+ "version": "24",
8+ "nodeGypDependencies": true
9+ },
10+ "ghcr.io/devcontainers/features/python:1": {
11+ "version": "3.12"
12+ }
13+ },
14+ "postCreateCommand": {
15+ "bun-install": "bun install --backend=copyfile --frozen-lockfile"
16+ },
17+ "customizations": {
18+ "vscode": {
19+ "extensions": ["oxc.oxc-vscode"]
20+ }
21+ }
22+}
.docs/remote-architecture.md+302−0
@@ -0,0 +1,302 @@
1+# Remote Architecture
2+
3+This document describes the target architecture for first-class remote environments in T3 Code.
4+
5+It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite.
6+
7+## Goals
8+
9+- Treat remote environments as first-class product primitives, not special cases.
10+- Support multiple ways to reach the same environment.
11+- Keep the T3 server as the execution boundary.
12+- Let desktop, mobile, and web all share the same conceptual model.
13+- Avoid introducing a local control plane unless product pressure proves it is necessary.
14+
15+## Non-goals
16+
17+- Replacing the existing WebSocket server boundary with a custom transport protocol.
18+- Making SSH the only remote story.
19+- Syncing provider auth across machines.
20+- Shipping every access method in the first iteration.
21+
22+## High-level architecture
23+
24+T3 already has a clean runtime boundary: the client talks to a T3 server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations.
25+
26+Remote support should preserve that boundary.
27+
28+```text
29+┌──────────────────────────────────────────────┐
30+│ Client (desktop / mobile / web) │
31+│ │
32+│ - known environments │
33+│ - connection manager │
34+│ - environment-aware routing │
35+└───────────────┬──────────────────────────────┘
36+ │
37+ │ resolves one access endpoint
38+ │
39+┌───────────────▼──────────────────────────────┐
40+│ Access method │
41+│ │
42+│ - direct ws / wss │
43+│ - tunneled ws / wss │
44+│ - desktop-managed ssh bootstrap + forward │
45+└───────────────┬──────────────────────────────┘
46+ │
47+ │ connects to one T3 server
48+ │
49+┌───────────────▼──────────────────────────────┐
50+│ Execution environment = one T3 server │
51+│ │
52+│ - environment identity │
53+│ - provider state │
54+│ - projects / threads / terminals │
55+│ - git / filesystem / process runtime │
56+└──────────────────────────────────────────────┘
57+```
58+
59+The important decision is that remoteness is expressed at the environment connection layer, not by splitting the T3 runtime itself.
60+
61+## Domain model
62+
63+### ExecutionEnvironment
64+
65+An `ExecutionEnvironment` is one running T3 server instance.
66+
67+It is the unit that owns:
68+
69+- provider availability and auth state
70+- model availability
71+- projects and threads
72+- terminal processes
73+- filesystem access
74+- git operations
75+- server settings
76+
77+It is identified by a stable `environmentId`.
78+
79+This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here.
80+
81+### KnownEnvironment
82+
83+A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach.
84+
85+It is not server-authored. It is local to a device or client profile.
86+
87+Examples:
88+
89+- a saved LAN URL
90+- a saved public `wss://` endpoint
91+- a desktop-managed SSH host entry
92+- a saved tunneled environment
93+
94+A known environment may or may not know the target `environmentId` before first successful connect.
95+
96+### AccessEndpoint
97+
98+An `AccessEndpoint` is one concrete way to reach a known environment.
99+
100+This is the key abstraction that keeps SSH from taking over the model.
101+
102+A single environment may have many endpoints:
103+
104+- `wss://t3.example.com`
105+- `ws://10.0.0.25:3773`
106+- a tunneled relay URL
107+- a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL
108+
109+The environment stays the same. Only the access path changes.
110+
111+### RepositoryIdentity
112+
113+`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments.
114+
115+It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository.
116+
117+### Workspace / Project
118+
119+The current `Project` model remains environment-local.
120+
121+That means:
122+
123+- a local clone and a remote clone are different projects
124+- they may share a `RepositoryIdentity`
125+- threads still bind to one project in one environment
126+
127+## Access methods
128+
129+Access methods answer one question:
130+
131+How does the client speak WebSocket to a T3 server?
132+
133+They do not answer:
134+
135+- how the server got started
136+- who manages the server process
137+- whether the environment is local or remote
138+
139+### 1. Direct WebSocket access
140+
141+Examples:
142+
143+- `ws://10.0.0.15:3773`
144+- `wss://t3.example.com`
145+
146+This is the base model and should be the first-class default.
147+
148+Benefits:
149+
150+- works for desktop, mobile, and web
151+- no client-specific process management required
152+- best fit for hosted or self-managed remote T3 deployments
153+
154+### 2. Tunneled WebSocket access
155+
156+Examples:
157+
158+- public relay URLs
159+- private network relay URLs
160+- local tunnel products such as pipenet
161+
162+This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay.
163+
164+For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment.
165+
166+This is especially useful when:
167+
168+- the host is behind NAT
169+- inbound ports are unavailable
170+- mobile must reach a desktop-hosted environment
171+- a machine should be reachable without exposing raw LAN or public ports
172+
173+### 3. Desktop-managed SSH access
174+
175+SSH is an access and launch helper, not a separate environment type.
176+
177+The desktop main process can use SSH to:
178+
179+- reach a machine
180+- probe it
181+- launch or reuse a remote T3 server
182+- establish a local port forward
183+
184+After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port.
185+
186+This keeps the renderer transport model consistent with every other access method.
187+
188+## Launch methods
189+
190+Launch methods answer a different question:
191+
192+How does a T3 server come to exist on the target machine?
193+
194+Launch and access should stay separate in the design.
195+
196+### 1. Pre-existing server
197+
198+The simplest launch method is no launch at all.
199+
200+The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint.
201+
202+This should be the first remote mode shipped because it validates the environment model with minimal extra machinery.
203+
204+### 2. Desktop-managed remote launch over SSH
205+
206+This is the main place where Zed is a useful reference.
207+
208+Useful ideas to borrow from Zed:
209+
210+- remote probing
211+- platform detection
212+- session directories with pid/log metadata
213+- reconnect-friendly launcher behavior
214+- desktop-owned connection UX
215+
216+What should be different in T3:
217+
218+- no custom stdio/socket proxy protocol between renderer and remote runtime
219+- no attempt to make the remote runtime look like an editor transport
220+- keep the final client-to-server connection as WebSocket
221+
222+The recommended T3 flow is:
223+
224+1. Desktop connects over SSH.
225+2. Desktop probes the remote machine and verifies T3 availability.
226+3. Desktop launches or reuses a remote T3 server.
227+4. Desktop establishes local port forwarding.
228+5. Renderer connects to the forwarded WebSocket endpoint as a normal environment.
229+
230+### 3. Client-managed local publish
231+
232+This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel.
233+
234+This is useful for:
235+
236+- exposing a desktop-hosted environment to mobile
237+- temporary remote access without changing router or firewall settings
238+
239+This is still a launch concern, not a new environment kind.
240+
241+## Why access and launch must stay separate
242+
243+These concerns are easy to conflate, but separating them prevents architectural drift.
244+
245+Examples:
246+
247+- A manually hosted T3 server might be reached through direct `wss`.
248+- The same server might also be reachable through a tunnel.
249+- An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket.
250+- A local desktop server might be published through a tunnel for mobile.
251+
252+In all of those cases, the `ExecutionEnvironment` is the same kind of thing.
253+
254+Only the launch and access paths differ.
255+
256+## Security model
257+
258+Remote support must assume that some environments will be reachable over untrusted networks.
259+
260+That means:
261+
262+- remote-capable environments should require explicit authentication
263+- tunnel exposure should not rely on obscurity
264+- client-saved endpoints should carry enough auth metadata to reconnect safely
265+
266+T3 already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention.
267+
268+For publicly reachable environments, authenticated access should be treated as required.
269+
270+## Relationship to Zed
271+
272+Zed is a useful reference implementation for managed remote launch and reconnect behavior.
273+
274+The relevant lessons are:
275+
276+- remote bootstrap should be explicit
277+- reconnect should be first-class
278+- connection UX belongs in the client shell
279+- runtime ownership should stay clearly on the remote host
280+
281+The important mismatch is transport shape.
282+
283+Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime.
284+
285+T3 should not copy that part.
286+
287+T3 already has the right runtime boundary:
288+
289+- one T3 server per environment
290+- ordinary HTTP/WebSocket between client and environment
291+
292+So T3 should borrow Zed's launch discipline, not its transport protocol.
293+
294+## Recommended rollout
295+
296+1. First-class known environments and access endpoints.
297+2. Direct `ws` / `wss` remote environments.
298+3. Authenticated tunnel-backed environments.
299+4. Desktop-managed SSH launch and forwarding.
300+5. Multi-environment UI improvements after the base runtime path is proven.
301+
302+This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows.
.github/workflows/ci.yml+9−4
@@ -9,7 +9,8 @@ on: · 8 unmodified lines
99 jobs:
1010 quality:
1111 name: Format, Lint, Typecheck, Test, Browser Test, Build
12− runs-on: blacksmith-4vcpu-ubuntu-2404
12+ runs-on: blacksmith-8vcpu-ubuntu-2404
13+ timeout-minutes: 10
1314 steps:
1415 - name: Checkout
1516 uses: actions/checkout@v6
@@ -70,12 +71,13 @@ jobs: · 54 unmodified lines
7071
7172 - name: Verify preload bundle output
7273 run: |
73− test -f apps/desktop/dist-electron/preload.js
74− grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js
74+ test -f apps/desktop/dist-electron/preload.cjs
75+ grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs
7576
7677 release_smoke:
7778 name: Release Smoke
78− runs-on: ubuntu-24.04
79+ runs-on: blacksmith-8vcpu-ubuntu-2404
80+ timeout-minutes: 10
7981 steps:
8082 - name: Checkout
8183 uses: actions/checkout@v6
@@ -90,5 +92,8 @@ jobs: · 8 unmodified lines
9092 with:
9193 node-version-file: package.json
9294
95+ - name: Install dependencies
96+ run: bun install --frozen-lockfile
97+
9398 - name: Exercise release-only workflow steps
9499 run: node scripts/release-smoke.ts
.github/workflows/release.yml+181−47
@@ -1,14 +1,24 @@
1−name: Release Desktop
1+name: Release
22
33 on:
44 push:
55 tags:
66 - "v*.*.*"
7+ schedule:
8+ - cron: "0 9 * * *"
79 workflow_dispatch:
810 inputs:
11+ channel:
12+ description: "Release channel"
13+ required: false
14+ default: stable
15+ type: choice
16+ options:
17+ - stable
18+ - nightly
919 version:
1020 description: "Release version (for example 1.2.3 or v1.2.3)"
11− required: true
21+ required: false
1222 type: string
1323
1424 permissions:
@@ -18,42 +28,24 @@ permissions: · 3 unmodified lines
1828 jobs:
1929 preflight:
2030 name: Preflight
21− runs-on: ubuntu-24.04
31+ runs-on: blacksmith-8vcpu-ubuntu-2404
32+ timeout-minutes: 10
2233 outputs:
34+ release_channel: ${{ steps.release_meta.outputs.release_channel }}
2335 version: ${{ steps.release_meta.outputs.version }}
2436 tag: ${{ steps.release_meta.outputs.tag }}
37+ release_name: ${{ steps.release_meta.outputs.name }}
38+ short_sha: ${{ steps.release_meta.outputs.short_sha }}
39+ previous_tag: ${{ steps.previous_tag.outputs.previous_tag }}
40+ cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}
2541 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
2642 make_latest: ${{ steps.release_meta.outputs.make_latest }}
2743 ref: ${{ github.sha }}
2844 steps:
2945 - name: Checkout
3046 uses: actions/checkout@v6
31−
32− - id: release_meta
33− name: Resolve release version
34− shell: bash
35− run: |
36− if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
37− raw="${{ github.event.inputs.version }}"
38− else
39− raw="${GITHUB_REF_NAME}"
40− fi
41−
42− version="${raw#v}"
43− if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
44− echo "Invalid release version: $raw" >&2
45− exit 1
46− fi
47−
48− echo "version=$version" >> "$GITHUB_OUTPUT"
49− echo "tag=v$version" >> "$GITHUB_OUTPUT"
50− if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
51− echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
52− echo "make_latest=true" >> "$GITHUB_OUTPUT"
53− else
54− echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
55− echo "make_latest=false" >> "$GITHUB_OUTPUT"
56− fi
47+ with:
48+ fetch-depth: 0
5749
5850 - name: Setup Bun
5951 uses: oven-sh/setup-bun@v2
@@ -68,6 +60,60 @@ jobs: · 8 unmodified lines
6860 - name: Install dependencies
6961 run: bun install --frozen-lockfile
7062
63+ - id: release_meta
64+ name: Resolve release version
65+ shell: bash
66+ env:
67+ DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
68+ DISPATCH_VERSION: ${{ github.event.inputs.version }}
69+ NIGHTLY_DATE: ${{ github.run_started_at }}
70+ NIGHTLY_SHA: ${{ github.sha }}
71+ NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
72+ run: |
73+ if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then
74+ nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
75+
76+ node scripts/resolve-nightly-release.ts \
77+ --date "$nightly_date" \
78+ --run-number "$NIGHTLY_RUN_NUMBER" \
79+ --sha "$NIGHTLY_SHA" \
80+ --github-output
81+
82+ echo "release_channel=nightly" >> "$GITHUB_OUTPUT"
83+ echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"
84+ echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
85+ echo "make_latest=false" >> "$GITHUB_OUTPUT"
86+ else
87+ if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
88+ raw="${DISPATCH_VERSION}"
89+ if [[ -z "$raw" ]]; then
90+ echo "workflow_dispatch stable releases require the version input." >&2
91+ exit 1
92+ fi
93+ else
94+ raw="${GITHUB_REF_NAME}"
95+ fi
96+
97+ version="${raw#v}"
98+ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
99+ echo "Invalid release version: $raw" >&2
100+ exit 1
101+ fi
102+
103+ echo "release_channel=stable" >> "$GITHUB_OUTPUT"
104+ echo "version=$version" >> "$GITHUB_OUTPUT"
105+ echo "tag=v$version" >> "$GITHUB_OUTPUT"
106+ echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT"
107+ echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT"
108+ if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
109+ echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
110+ echo "make_latest=true" >> "$GITHUB_OUTPUT"
111+ else
112+ echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
113+ echo "make_latest=false" >> "$GITHUB_OUTPUT"
114+ fi
115+ fi
116+
71117 - name: Lint
72118 run: bun run lint
73119
@@ -77,34 +123,48 @@ jobs: · 3 unmodified lines
77123 - name: Test
78124 run: bun run test
79125
126+ - id: previous_tag
127+ name: Resolve previous release tag
128+ run: |
129+ node scripts/resolve-previous-release-tag.ts \
130+ --channel "${{ steps.release_meta.outputs.release_channel }}" \
131+ --current-tag "${{ steps.release_meta.outputs.tag }}" \
132+ --github-output
133+
80134 build:
81135 name: Build ${{ matrix.label }}
82136 needs: preflight
83137 runs-on: ${{ matrix.runner }}
138+ timeout-minutes: 30
84139 strategy:
85140 fail-fast: false
86141 matrix:
87142 include:
88143 - label: macOS arm64
89− runner: macos-14
144+ runner: blacksmith-12vcpu-macos-26
90145 platform: mac
91146 target: dmg
92147 arch: arm64
93148 - label: macOS x64
94− runner: macos-15-intel
149+ runner: blacksmith-12vcpu-macos-26
95150 platform: mac
96151 target: dmg
97152 arch: x64
98153 - label: Linux x64
99− runner: ubuntu-24.04
154+ runner: blacksmith-32vcpu-ubuntu-2404
100155 platform: linux
101156 target: AppImage
102157 arch: x64
103158 - label: Windows x64
104− runner: windows-2022
159+ runner: windows-2022 # blacksmith-32vcpu-windows-2025
105160 platform: win
106161 target: nsis
107162 arch: x64
163+ # - label: Windows arm64
164+ # runner: windows-11-arm
165+ # platform: win
166+ # target: nsis
167+ # arch: arm64
108168 steps:
109169 - name: Checkout
110170 uses: actions/checkout@v6
@@ -204,18 +264,31 @@ jobs: · 93 unmodified lines
204264 "release/*.AppImage" \
205265 "release/*.exe" \
206266 "release/*.blockmap" \
207− "release/latest*.yml"; do
267+ "release/*.yml"; do
208268 for file in $pattern; do
209269 cp "$file" release-publish/
210270 done
211271 done
212272
213273 if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then
214− if [[ -f release-publish/latest-mac.yml ]]; then
215− mv release-publish/latest-mac.yml "release-publish/latest-mac-${{ matrix.arch }}.yml"
216− fi
274+ shopt -s nullglob
275+ for manifest in release-publish/*-mac.yml; do
276+ mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml"
277+ done
217278 fi
218279
280+ # Enable if Windows arm64 builds are enabled.
281+ # Windows updater metadata is channel-specific (for example
282+ # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the
283+ # release job can merge matching arm64/x64 manifests back into one
284+ # canonical manifest per channel.
285+ # if [[ "${{ matrix.platform }}" == "win" ]]; then
286+ # shopt -s nullglob
287+ # for manifest in release-publish/*.yml; do
288+ # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml"
289+ # done
290+ # fi
291+
219292 - name: Upload build artifacts
220293 uses: actions/upload-artifact@v7
221294 with:
@@ -226,7 +299,8 @@ jobs: · 4 unmodified lines
226299 publish_cli:
227300 name: Publish CLI to npm
228301 needs: [preflight, build]
229− runs-on: ubuntu-24.04
302+ runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
303+ timeout-minutes: 10
230304 steps:
231305 - name: Checkout
232306 uses: actions/checkout@v6
@@ -254,12 +328,13 @@ jobs: · 21 unmodified lines
254328 run: bun run build --filter=@t3tools/web --filter=t3
255329
256330 - name: Publish CLI package
257− run: node apps/server/scripts/cli.ts publish --tag latest --app-version "${{ needs.preflight.outputs.version }}" --verbose
331+ run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose
258332
259333 release:
260334 name: Publish GitHub Release
261335 needs: [preflight, build, publish_cli]
262− runs-on: ubuntu-24.04
336+ runs-on: blacksmith-8vcpu-ubuntu-2404
337+ timeout-minutes: 10
263338 steps:
264339 - name: Checkout
265340 uses: actions/checkout@v6
@@ -280,18 +355,53 @@ jobs: · 14 unmodified lines
280355
281356 - name: Merge macOS updater manifests
282357 run: |
283− node scripts/merge-mac-update-manifests.ts \
284− release-assets/latest-mac.yml \
285− release-assets/latest-mac-x64.yml
286− rm -f release-assets/latest-mac-x64.yml
358+ shopt -s nullglob
359+ for x64_manifest in release-assets/*-mac-x64.yml; do
360+ arm64_manifest="${x64_manifest%-x64.yml}.yml"
361+ if [[ -f "$arm64_manifest" ]]; then
362+ node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest"
363+ rm -f "$x64_manifest"
364+ fi
365+ done
366+
367+ # - name: Merge Windows updater manifests
368+ # run: |
369+ # shopt -s nullglob
370+ # found_windows_manifest=false
371+ # for x64_manifest in release-assets/*-win-x64.yml; do
372+ # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then
373+ # continue
374+ # fi
375+
376+ # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}"
377+ # output_manifest="${x64_manifest/-win-x64.yml/.yml}"
378+ # if [[ ! -f "$arm64_manifest" ]]; then
379+ # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2
380+ # exit 1
381+ # fi
382+
383+ # found_windows_manifest=true
384+ # node scripts/merge-update-manifests.ts --platform win \
385+ # "$arm64_manifest" \
386+ # "$x64_manifest" \
387+ # "$output_manifest"
388+ # rm -f "$arm64_manifest" "$x64_manifest"
389+ # done
390+
391+ # if [[ "$found_windows_manifest" != true ]]; then
392+ # echo "No Windows updater manifests found to merge." >&2
393+ # exit 1
394+ # fi
287395
288396 - name: Publish release
397+ if: needs.preflight.outputs.previous_tag != ''
289398 uses: softprops/action-gh-release@v2
290399 with:
291400 tag_name: ${{ needs.preflight.outputs.tag }}
292401 target_commitish: ${{ needs.preflight.outputs.ref }}
293− name: T3 Code v${{ needs.preflight.outputs.version }}
402+ name: ${{ needs.preflight.outputs.release_name }}
294403 generate_release_notes: true
404+ previous_tag: ${{ needs.preflight.outputs.previous_tag }}
295405 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
296406 make_latest: ${{ needs.preflight.outputs.make_latest }}
297407 files: |
@@ -300,13 +410,34 @@ jobs: · 2 unmodified lines
300410 release-assets/*.AppImage
301411 release-assets/*.exe
302412 release-assets/*.blockmap
303− release-assets/latest*.yml
413+ release-assets/*.yml
414+ fail_on_unmatched_files: true
415+
416+ - name: Publish first release
417+ if: needs.preflight.outputs.previous_tag == ''
418+ uses: softprops/action-gh-release@v2
419+ with:
420+ tag_name: ${{ needs.preflight.outputs.tag }}
421+ target_commitish: ${{ needs.preflight.outputs.ref }}
422+ name: ${{ needs.preflight.outputs.release_name }}
423+ generate_release_notes: true
424+ prerelease: ${{ needs.preflight.outputs.is_prerelease }}
425+ make_latest: ${{ needs.preflight.outputs.make_latest }}
426+ files: |
427+ release-assets/*.dmg
428+ release-assets/*.zip
429+ release-assets/*.AppImage
430+ release-assets/*.exe
431+ release-assets/*.blockmap
432+ release-assets/*.yml
304433 fail_on_unmatched_files: true
305434
306435 finalize:
307436 name: Finalize release
437+ if: needs.preflight.outputs.release_channel == 'stable'
308438 needs: [preflight, release]
309− runs-on: ubuntu-24.04
439+ runs-on: blacksmith-8vcpu-ubuntu-2404
440+ timeout-minutes: 10
310441 steps:
311442 - id: app_token
312443 name: Mint release app token
@@ -344,6 +475,9 @@ jobs: · 31 unmodified lines
344475 with:
345476 node-version-file: package.json
346477
478+ - name: Install dependencies
479+ run: bun install --frozen-lockfile
480+
347481 - id: update_versions
348482 name: Update version strings
349483 env:
.gitignore+1−0
@@ -21,3 +21,4 @@ apps/web/src/components/__screenshots__ · 20 unmodified lines
2121 .vitest-*
2222 __screenshots__/
2323 .tanstack
24+squashfs-root/
.oxfmtrc.json+3−1
@@ -9,7 +9,9 @@ · 8 unmodified lines
99 "bun.lock",
1010 "*.tsbuildinfo",
1111 "**/routeTree.gen.ts",
12− "apps/web/public/mockServiceWorker.js"
12+ "apps/web/public/mockServiceWorker.js",
13+ "apps/web/src/lib/vendor/qrcodegen.ts",
14+ "*.icon/**"
1315 ],
1416 "sortPackageJson": {}
1517 }
.plans/18-server-auth-model.md+823−0
@@ -0,0 +1,823 @@
1+# Server Auth Model Plan
2+
3+## Purpose
4+
5+Define the long-term server auth architecture for T3 Code before first-class remote environments ship.
6+
7+This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods.
8+
9+This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code.
10+
11+## Primary goals
12+
13+- Make auth server-wide, not WebSocket-only.
14+- Make insecure exposure hard to do accidentally.
15+- Preserve zero-login local desktop UX for desktop-managed environments.
16+- Support browser-native pairing and session auth.
17+- Leave room for native/mobile credentials later without rewriting the server boundary.
18+- Keep auth separate from transport and launch method.
19+
20+## Non-goals
21+
22+- Full multi-user authorization and RBAC.
23+- OAuth / SSO / enterprise identity.
24+- Passkeys or biometric UX in v1.
25+- Syncing auth state across environments.
26+- Designing the full remote environment product in this document.
27+
28+## Core decisions
29+
30+### 1. Auth is a server concern
31+
32+Every privileged surface of the T3 server must go through the same auth policy engine:
33+
34+- HTTP routes
35+- WebSocket upgrades
36+- RPC methods reached through WebSocket
37+
38+The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product.
39+
40+### 2. Pairing and session are different things
41+
42+The system should distinguish:
43+
44+- bootstrap credentials
45+- session credentials
46+
47+Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated.
48+
49+Session credentials are the durable credentials used after pairing.
50+
51+Bootstrap should never become the long-lived request credential.
52+
53+### 3. Auth and transport are separate
54+
55+Auth must not be defined by how the client reached the server.
56+
57+Examples:
58+
59+- local desktop-managed server
60+- LAN `ws://`
61+- public `wss://`
62+- tunneled `wss://`
63+- SSH-forwarded `ws://127.0.0.1:<port>`
64+
65+All of these should feed into the same auth model.
66+
67+### 4. Exposure level changes defaults
68+
69+The more exposed an environment is, the narrower the safe default should be.
70+
71+Safe default expectations:
72+
73+- local desktop-managed: auto-pair allowed
74+- loopback browser access: explicit bootstrap allowed
75+- non-loopback bind: auth required
76+- tunnel/public endpoint: auth required, explicit enablement required
77+
78+### 5. Browser and native clients may use different session credentials
79+
80+The auth model should support more than one session credential type even if only one ships first.
81+
82+Examples:
83+
84+- browser session cookie
85+- native bearer/device token
86+
87+This should be represented in the model now, even if browser cookies are the first implementation.
88+
89+## Target auth domain
90+
91+### Route classes
92+
93+Every route or transport entrypoint should be classified as one of:
94+
95+1. `public`
96+2. `bootstrap`
97+3. `authenticated`
98+
99+#### `public`
100+
101+Unauthenticated by definition.
102+
103+Should be extremely small. Examples:
104+
105+- static shell needed to render the pairing/login UI
106+- favicon/assets required for the pairing screen
107+- a minimal server health/version endpoint if needed
108+
109+#### `bootstrap`
110+
111+Used only to exchange a bootstrap credential for a session.
112+
113+Examples:
114+
115+- Initial bootstrap envelope over file descriptor at startup
116+- `POST /api/auth/bootstrap`
117+- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX
118+
119+#### `authenticated`
120+
121+Everything that reveals machine state or mutates it.
122+
123+Examples:
124+
125+- WebSocket upgrade
126+- orchestration snapshot and events
127+- terminal open/write/close
128+- project search and file writes
129+- git routes
130+- attachments
131+- project favicon lookup
132+- server settings
133+
134+The default stance should be: if it touches the machine, it is authenticated.
135+
136+## Credential model
137+
138+### Bootstrap credentials
139+
140+Initial credential types to model:
141+
142+- `desktop-bootstrap`
143+- `one-time-token`
144+
145+Possible future credential types:
146+
147+- `device-code`
148+- `passkey-assertion`
149+- `external-identity`
150+
151+#### `desktop-bootstrap`
152+
153+Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments.
154+
155+Properties:
156+
157+- launcher-provided
158+- short-lived
159+- one-time or bounded-use
160+- never shown to the user as a reusable password
161+
162+#### `one-time-token`
163+
164+Used for explicit browser/mobile pairing flows.
165+
166+Properties:
167+
168+- short TTL
169+- one-time use
170+- safe to embed in a pairing URL fragment
171+- exchanged for a session credential
172+
173+### Session credentials
174+
175+Initial credential types to model:
176+
177+- `browser-session-cookie`
178+- `bearer-session-token`
179+
180+#### `browser-session-cookie`
181+
182+Primary browser credential.
183+
184+Properties:
185+
186+- signed
187+- `HttpOnly`
188+- bounded lifetime
189+- revocable by server key rotation or session invalidation
190+
191+#### `bearer-session-token`
192+
193+Reserved for native/mobile or non-browser clients.
194+
195+Properties:
196+
197+- opaque token, not a bootstrap secret
198+- long enough lifetime to survive reconnects
199+- stored in secure client storage when available
200+
201+## Auth policy model
202+
203+Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics.
204+
205+### Policy examples
206+
207+#### `DesktopManagedLocalPolicy`
208+
209+Default for desktop-managed local server.
210+
211+Allowed bootstrap methods:
212+
213+- `desktop-bootstrap`
214+
215+Allowed session methods:
216+
217+- `browser-session-cookie`
218+
219+Disabled by default:
220+
221+- `one-time-token`
222+- `bearer-session-token`
223+- password login
224+- public pairing
225+
226+#### `LoopbackBrowserPolicy`
227+
228+Used for browser access on localhost without desktop-managed bootstrap.
229+
230+Allowed bootstrap methods:
231+
232+- `one-time-token`
233+
234+Allowed session methods:
235+
236+- `browser-session-cookie`
237+
238+#### `RemoteReachablePolicy`
239+
240+Used when binding non-loopback or using an explicit remote/tunnel workflow.
241+
242+Allowed bootstrap methods:
243+
244+- `one-time-token`
245+- possibly `desktop-bootstrap` when a desktop shell is brokering access
246+
247+Allowed session methods:
248+
249+- `browser-session-cookie`
250+- `bearer-session-token`
251+
252+#### `UnsafeNoAuthPolicy`
253+
254+Should exist only as an explicit escape hatch.
255+
256+Requirements:
257+
258+- explicit opt-in flag
259+- loud startup warnings
260+- never defaulted automatically
261+
262+## Effect-native service model
263+
264+### `ServerAuth`
265+
266+The main auth facade used by HTTP routes and WebSocket upgrade handling.
267+
268+Responsibilities:
269+
270+- classify requests
271+- authenticate requests
272+- authorize bootstrap attempts
273+- create sessions from bootstrap credentials
274+- enforce policy by environment mode
275+
276+Sketch:
277+
278+```ts
279+export interface ServerAuthShape {
280+ readonly getCapabilities: Effect.Effect<AuthCapabilities>;
281+ readonly authenticateHttpRequest: (
282+ request: HttpServerRequest.HttpServerRequest,
283+ routeClass: RouteAuthClass,
284+ ) => Effect.Effect<AuthContext, AuthError>;
285+ readonly authenticateWebSocketUpgrade: (
286+ request: HttpServerRequest.HttpServerRequest,
287+ ) => Effect.Effect<AuthContext, AuthError>;
288+ readonly exchangeBootstrapCredential: (
289+ input: BootstrapExchangeInput,
290+ ) => Effect.Effect<SessionGrant, AuthError>;
291+}
292+
293+export class ServerAuth extends ServiceMap.Service<ServerAuth, ServerAuthShape>()(
294+ "t3/ServerAuth",
295+) {}
296+```
297+
298+### `BootstrapCredentialService`
299+
300+Owns issuance, storage, validation, and consumption of bootstrap credentials.
301+
302+Responsibilities:
303+
304+- issue desktop bootstrap grants
305+- issue one-time pairing tokens
306+- validate TTL and single-use semantics
307+- consume bootstrap grants atomically
308+
309+Sketch:
310+
311+```ts
312+export interface BootstrapCredentialServiceShape {
313+ readonly issueDesktopBootstrap: (
314+ input: IssueDesktopBootstrapInput,
315+ ) => Effect.Effect<BootstrapCredential>;
316+ readonly issueOneTimeToken: (
317+ input: IssueOneTimeTokenInput,
318+ ) => Effect.Effect<BootstrapCredential>;
319+ readonly consume: (
320+ presented: PresentedBootstrapCredential,
321+ ) => Effect.Effect<ConsumedBootstrapCredential, BootstrapCredentialError>;
322+}
323+```
324+
325+### `SessionCredentialService`
326+
327+Owns creation and validation of authenticated sessions.
328+
329+Responsibilities:
330+
331+- mint cookie sessions
332+- mint bearer sessions
333+- validate active session credentials
334+- revoke sessions if needed later
335+
336+Sketch:
337+
338+```ts
339+export interface SessionCredentialServiceShape {
340+ readonly createBrowserSession: (
341+ input: CreateSessionFromBootstrapInput,
342+ ) => Effect.Effect<BrowserSessionGrant, SessionCredentialError>;
343+ readonly createBearerSession: (
344+ input: CreateSessionFromBootstrapInput,
345+ ) => Effect.Effect<BearerSessionGrant, SessionCredentialError>;
346+ readonly authenticateCookie: (
347+ request: HttpServerRequest.HttpServerRequest,
348+ ) => Effect.Effect<AuthContext, SessionCredentialError>;
349+ readonly authenticateBearer: (
350+ request: HttpServerRequest.HttpServerRequest,
351+ ) => Effect.Effect<AuthContext, SessionCredentialError>;
352+}
353+```
354+
355+### `ServerAuthPolicy`
356+
357+Pure policy/config service that decides which credential types are allowed.
358+
359+Responsibilities:
360+
361+- map runtime mode and bind/exposure settings to allowed auth methods
362+- answer whether a route can be public
363+- answer whether remote exposure requires auth
364+
365+This should stay mostly pure and cheap to test.
366+
367+### `ServerSecretStore`
368+
369+Owns long-lived server signing keys and secrets.
370+
371+Responsibilities:
372+
373+- get or create signing key
374+- rotate signing key
375+- abstract secure OS-backed storage vs filesystem fallback
376+
377+Important:
378+
379+- prefer platform secure storage when available
380+- support hardened filesystem fallback for headless/server-only environments
381+
382+### `BrowserSessionCookieCodec`
383+
384+Focused utility service for cookie encode/decode/signing behavior.
385+
386+This should not own policy. It should only own the cookie format.
387+
388+### `AuthRouteGuards`
389+
390+Thin helper layer used by routes to enforce auth consistently.
391+
392+Responsibilities:
393+
394+- require auth for HTTP route handlers
395+- classify route auth mode
396+- convert auth failures into `401` / `403`
397+
398+This prevents every route from re-implementing the same pattern.
399+
400+Integrates with `HttpRouter.middleware` to enforce auth consistently.
401+
402+## Suggested layer graph
403+
404+```text
405+ServerSecretStore
406+ ├─> BootstrapCredentialService
407+ ├─> BrowserSessionCookieCodec
408+ └─> SessionCredentialService
409+
410+ServerAuthPolicy
411+ ├─> BootstrapCredentialService
412+ ├─> SessionCredentialService
413+ └─> ServerAuth
414+
415+ServerAuth
416+ └─> AuthRouteGuards
417+```
418+
419+Layer naming should follow existing repo style:
420+
421+- `ServerSecretStoreLive`
422+- `BootstrapCredentialServiceLive`
423+- `SessionCredentialServiceLive`
424+- `ServerAuthPolicyLive`
425+- `ServerAuthLive`
426+- `AuthRouteGuardsLive`
427+
428+## High-level implementation examples
429+
430+### Example: WebSocket upgrade auth
431+
432+Current state:
433+
434+- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts)
435+
436+Target shape:
437+
438+```ts
439+const websocketUpgradeAuth = HttpMiddleware.make((httpApp) =>
440+ Effect.gen(function* () {
441+ const request = yield* HttpServerRequest.HttpServerRequest;
442+ const serverAuth = yield* ServerAuth;
443+ yield* serverAuth.authenticateWebSocketUpgrade(request);
444+ return yield* httpApp;
445+ }),
446+);
447+```
448+
449+Then the `/ws` route becomes:
450+
451+```ts
452+export const websocketRpcRouteLayer = HttpRouter.add(
453+ "GET",
454+ "/ws",
455+ rpcWebSocketHttpEffect.pipe(
456+ websocketUpgradeAuth,
457+ Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)),
458+ ),
459+);
460+```
461+
462+This keeps the route itself declarative and makes auth compose like normal HTTP middleware.
463+
464+### Example: authenticated HTTP route
465+
466+For routes like attachments or project favicon:
467+
468+```ts
469+const authenticatedRoute = (routeClass: RouteAuthClass) =>
470+ HttpMiddleware.make((httpApp) =>
471+ Effect.gen(function* () {
472+ const request = yield* HttpServerRequest.HttpServerRequest;
473+ const serverAuth = yield* ServerAuth;
474+ yield* serverAuth.authenticateHttpRequest(request, routeClass);
475+ return yield* httpApp;
476+ }),
477+ );
478+```
479+
480+Then:
481+
482+```ts
483+export const attachmentsRouteLayer = HttpRouter.add(
484+ "GET",
485+ `${ATTACHMENTS_ROUTE_PREFIX}/*`,
486+ serveAttachment.pipe(
487+ authenticatedRoute(RouteAuthClass.Authenticated),
488+ Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)),
489+ ),
490+);
491+```
492+
493+### Example: desktop bootstrap exchange
494+
495+The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel.
496+
497+That grant is then exchanged for a browser cookie session when the renderer loads.
498+
499+Sketch:
500+
501+```ts
502+const pairDesktopRenderer = Effect.gen(function* () {
503+ const bootstrapService = yield* BootstrapCredentialService;
504+ const credential = yield* bootstrapService.issueDesktopBootstrap({
505+ audience: "desktop-renderer",
506+ ttlMs: 30_000,
507+ });
508+ return credential;
509+});
510+```
511+
512+The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid.
513+
514+### Example: one-time pairing URL
515+
516+For browser-driven pairing:
517+
518+```ts
519+const createPairingToken = Effect.gen(function* () {
520+ const bootstrapService = yield* BootstrapCredentialService;
521+ return yield* bootstrapService.issueOneTimeToken({
522+ ttlMs: 5 * 60_000,
523+ audience: "browser",
524+ });
525+});
526+```
527+
528+The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it.
529+
530+## Sequence diagrams
531+
532+These flows are meant to anchor the auth model in concrete user journeys.
533+
534+The important invariant across all of them is:
535+
536+- access method is not the auth method
537+- launch method is not the auth method
538+- bootstrap credential is not the session credential
539+
540+### Normal desktop user
541+
542+This is the default desktop-managed local flow.
543+
544+The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie.
545+
546+```text
547+Participants:
548+ DesktopMain = Electron main
549+ SecretStore = secure local secret backend
550+ T3Server = local backend child process
551+ Frontend = desktop renderer
552+
553+DesktopMain -> SecretStore : getOrCreate("server-signing-key")
554+SecretStore --> DesktopMain : signing key available
555+
556+DesktopMain -> T3Server : spawn server (--bootstrap-fd ...)
557+DesktopMain -> T3Server : send desktop bootstrap envelope
558+note over T3Server : policy = DesktopManagedLocalPolicy
559+note over T3Server : allowed pairing = desktop-bootstrap only
560+
561+Frontend -> DesktopMain : request local bootstrap grant
562+DesktopMain --> Frontend : short-lived desktop bootstrap grant
563+
564+Frontend -> T3Server : POST /api/auth/bootstrap
565+T3Server -> T3Server : validate desktop bootstrap grant
566+T3Server -> T3Server : create browser session
567+T3Server --> Frontend : Set-Cookie: session=...
568+
569+Frontend -> T3Server : GET /ws + authenticated cookie
570+T3Server -> T3Server : validate cookie session
571+T3Server --> Frontend : websocket accepted
572+```
573+
574+### `npx t3` user
575+
576+This is the standalone local server flow.
577+
578+There is no trusted desktop shell here, so pairing should be explicit.
579+
580+```text
581+Participants:
582+ UserShell = npx t3 launcher
583+ T3Server = standalone local server
584+ Browser = browser tab
585+
586+UserShell -> T3Server : start server
587+T3Server -> T3Server : getOrCreate("server-signing-key")
588+note over T3Server : policy = LoopbackBrowserPolicy
589+
590+UserShell -> T3Server : issue one-time pairing token
591+T3Server --> UserShell : pairing URL or pairing token
592+
593+UserShell --> Browser : open /pair?token=...
594+
595+Browser -> T3Server : GET /pair?token=...
596+T3Server -> T3Server : validate one-time token
597+T3Server -> T3Server : create browser session
598+T3Server --> Browser : Set-Cookie: session=...
599+T3Server --> Browser : redirect to app
600+
601+Browser -> T3Server : GET /ws + authenticated cookie
602+T3Server --> Browser : websocket accepted
603+```
604+
605+### Phone user with tunneled host
606+
607+This is the explicit remote access flow for a browser on another device.
608+
609+The tunnel only provides reachability. It must not imply trust.
610+
611+Recommended UX:
612+
613+- desktop shows a QR code
614+- desktop also shows a copyable pairing URL
615+- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access
616+
617+```text
618+Participants:
619+ DesktopUser = user at the host machine
620+ DesktopMain = desktop app
621+ Tunnel = tunnel provider
622+ T3Server = T3 server
623+ PhoneBrowser = mobile browser
624+
625+DesktopUser -> DesktopMain : enable remote access via tunnel
626+DesktopMain -> T3Server : switch policy to RemoteReachablePolicy
627+DesktopMain -> Tunnel : publish local T3 endpoint
628+Tunnel --> DesktopMain : public https/wss URL
629+
630+DesktopMain -> T3Server : issue one-time pairing token
631+T3Server --> DesktopMain : pairing token
632+DesktopMain -> DesktopUser : show QR code / shareable URL
633+
634+DesktopUser -> PhoneBrowser : scan QR / open URL
635+PhoneBrowser -> Tunnel : GET https://public-host/pair?token=...
636+Tunnel -> T3Server : forward request
637+T3Server -> T3Server : validate one-time token
638+T3Server -> T3Server : create mobile browser session
639+T3Server --> PhoneBrowser : Set-Cookie: session=...
640+T3Server --> PhoneBrowser : redirect to app
641+
642+PhoneBrowser -> Tunnel : GET /ws + authenticated cookie
643+Tunnel -> T3Server : forward websocket upgrade
644+T3Server --> PhoneBrowser : websocket accepted
645+```
646+
647+### Phone user with private network
648+
649+This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale.
650+
651+The auth flow should stay the same.
652+
653+```text
654+Participants:
655+ DesktopUser = user at the host machine
656+ T3Server = T3 server
657+ PrivateNet = tailscale / private LAN
658+ PhoneBrowser = mobile browser
659+
660+DesktopUser -> T3Server : enable private-network access
661+T3Server -> T3Server : switch policy to RemoteReachablePolicy
662+DesktopUser -> T3Server : issue one-time pairing token
663+T3Server --> DesktopUser : pairing URL / QR
664+
665+DesktopUser -> PhoneBrowser : open private-network URL
666+PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=...
667+PrivateNet -> T3Server : route request
668+T3Server -> T3Server : validate one-time token
669+T3Server -> T3Server : create mobile browser session
670+T3Server --> PhoneBrowser : Set-Cookie: session=...
671+T3Server --> PhoneBrowser : redirect to app
672+
673+PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie
674+PrivateNet -> T3Server : websocket upgrade
675+T3Server --> PhoneBrowser : websocket accepted
676+```
677+
678+### Desktop user adding new SSH hosts
679+
680+SSH should be treated as launch and reachability plumbing, not as the long-term auth model.
681+
682+The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment.
683+
684+```text
685+Participants:
686+ DesktopUser = local desktop user
687+ DesktopMain = desktop app
688+ SSH = ssh transport/session
689+ RemoteHost = remote machine
690+ RemoteT3 = remote T3 server
691+ Frontend = desktop renderer
692+
693+DesktopUser -> DesktopMain : add SSH host
694+DesktopMain -> SSH : connect to remote host
695+SSH -> RemoteHost : probe environment / verify t3 availability
696+DesktopMain -> SSH : run remote launch command
697+SSH -> RemoteHost : t3 remote launch --json
698+RemoteHost -> RemoteT3 : start or reuse server
699+RemoteT3 --> RemoteHost : port + environment metadata
700+RemoteHost --> SSH : launch result JSON
701+SSH --> DesktopMain : remote server details
702+
703+DesktopMain -> SSH : establish local port forward
704+SSH --> DesktopMain : localhost:FORWARDED_PORT ready
705+
706+note over RemoteT3 : policy = RemoteReachablePolicy
707+note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here
708+
709+Frontend -> DesktopMain : request bootstrap for selected environment
710+DesktopMain --> Frontend : short-lived bootstrap grant
711+
712+Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port
713+RemoteT3 -> RemoteT3 : validate bootstrap grant
714+RemoteT3 -> RemoteT3 : create browser session
715+RemoteT3 --> Frontend : Set-Cookie: session=...
716+
717+Frontend -> RemoteT3 : GET /ws + authenticated cookie
718+RemoteT3 --> Frontend : websocket accepted
719+```
720+
721+## Storage decisions
722+
723+### Server secrets
724+
725+Use a `ServerSecretStore` abstraction.
726+
727+Preferred order (use a layer for each, resolve on startup):
728+
729+1. OS secure storage if available
730+2. hardened filesystem fallback if not
731+
732+The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials.
733+
734+### Client credentials
735+
736+Client-side credential persistence should prefer secure storage when available:
737+
738+- desktop: OS keychain / secure store
739+- mobile: platform secure storage
740+- browser: cookie session for browser auth
741+
742+This concern should stay in the client shell/runtime layer, not the server auth layer.
743+
744+## What to build now
745+
746+These are the parts worth building before remote environments ship:
747+
748+1. `ServerAuth` service boundary.
749+2. route classification and route guards.
750+3. `ServerSecretStore` abstraction.
751+4. bootstrap vs session credential split.
752+5. browser session cookie codec as one session method.
753+6. explicit auth capabilities/config surfaced in contracts.
754+
755+Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained.
756+
757+## What to add as part of first remote-capable auth
758+
759+1. Browser pairing flow using one-time bootstrap token and cookie session.
760+2. Desktop-managed auto-bootstrap for the local desktop-managed environment.
761+3. Auth-required defaults for any non-loopback or explicitly published server.
762+4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks.
763+
764+## What to defer
765+
766+- passkeys / WebAuthn
767+- iCloud Keychain / Face ID-specific UX
768+- multi-user permissions
769+- collaboration roles
770+- OAuth / SSO
771+- polished session management UI
772+- complex device approval flows
773+
774+These can all sit on top of the same bootstrap/session/service split.
775+
776+## Relationship to future remote environments
777+
778+Remote access is one reason this auth model matters, but the auth model should not be remote-shaped.
779+
780+Keep the design focused on:
781+
782+- one T3 server
783+- one auth policy
784+- multiple credential types
785+- multiple future access methods
786+
787+That keeps the server auth model stable even as access methods expand later.
788+
789+## Recommended implementation order
790+
791+### Phase 1
792+
793+- Introduce route auth classes.
794+- Add `ServerAuth` and `AuthRouteGuards`.
795+- Move existing `authToken` check behind `ServerAuth`.
796+- Require auth for all privileged HTTP routes as well as WebSocket.
797+
798+### Phase 2
799+
800+- Add `ServerSecretStore` service with platform-specific layer implementations.
801+ - `layerOSXKeychain`, `layer
802+- Add bootstrap/session split.
803+- Add browser session cookie support.
804+- Add one-time bootstrap exchange endpoint.
805+
806+### Phase 3
807+
808+- Add desktop bootstrap flow on top of the same services.
809+- Make desktop-managed local environments default to bootstrap-only pairing.
810+- Surface auth capabilities in shared contracts and renderer bootstrap.
811+
812+### Phase 4
813+
814+- Add non-browser bearer session support if mobile/native needs it.
815+- Add richer policy modes for remote-reachable environments.
816+
817+## Acceptance criteria
818+
819+- No privileged HTTP or WebSocket path bypasses auth policy.
820+- Local desktop-managed flows still avoid a visible login screen.
821+- Non-loopback or published environments require explicit authenticated pairing by default.
822+- Bootstrap and session credentials are distinct in code and in behavior.
823+- Auth logic is centralized in Effect services/layers rather than route-local branching.
CLAUDE.md+1−1
@@ -1 +1 @@
1−AGENTS.md
1+AGENTS.md
KEYBINDINGS.md+2−0
@@ -23,6 +23,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( · 22 unmodified lines
2323 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
2424 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
2525 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
26+ { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" },
2627 { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
2728 { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },
2829 { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" },
@@ -50,6 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged · 21 unmodified lines
5051 - `terminal.split`: split terminal (in focused terminal context by default)
5152 - `terminal.new`: create new terminal (in focused terminal context by default)
5253 - `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
54+- `commandPalette.toggle`: open or close the global command palette
5355 - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
5456 - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))
5557 - `editor.openFavorite`: open current project/worktree in the last-used editor
README.md+8−0
@@ -49,6 +49,14 @@ Observability guide: [docs/observability.md](./docs/observability.md) · 48 unmodified lines
4949
5050 ## If you REALLY want to contribute still.... read this first
5151
52+Before local development, prepare the environment and install dependencies:
53+
54+```bash
55+# Optional: only needed if you use mise for dev tool management.
56+mise install
57+bun install .
58+```
59+
5260 Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
5361
5462 Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
REMOTE.md+63−44
@@ -1,68 +1,87 @@
1−# Remote Access Setup
1+# Remote Access
22
3−Use this when you want to open T3 Code from another device (phone, tablet, another laptop).
3+Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app.
44
5−## CLI ↔ Env option map
5+## Recommended Setup
66
7−The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:
7+Use a trusted private network that meshes your devices together, such as a tailnet.
88
9−| CLI flag | Env var | Notes |
10−| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ |
11−| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
12−| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
13−| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14−| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
15−| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
16−| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
17−| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. |
18−| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. |
9+That gives you:
1910
20−> TIP: Use the `--help` flag to see all available options and their descriptions.
11+- a stable address to connect to
12+- transport security at the network layer
13+- less exposure than opening the server to the public internet
2114
22−## Security First
15+## Enabling Network Access
2316
24−- Always set `--auth-token` before exposing the server outside localhost.
25− - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`.
26− With `--bootstrap-fd <fd>`, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments.
27−- Treat the token like a password.
28−- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.
17+There are two ways to expose your server for remote connections: from the desktop app or from the CLI.
2918
30−## 1) Build + run server for remote access
19+### Option 1: Desktop App
3120
32−Remote access should use the built web app (not local Vite redirect mode).
21+If you are already running the desktop app and want to make it reachable from other devices:
22+
23+1. Open **Settings** → **Connections**.
24+2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces.
25+3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`).
26+4. Use **Create Link** to generate a pairing link you can share with another device.
27+
28+### Option 2: Headless Server (CLI)
29+
30+Use this when you want to run the server without a GUI, for example on a remote machine over SSH.
31+
32+Run the server with `t3 serve`.
3333
3434 ```bash
35−bun run build
36−TOKEN="$(openssl rand -hex 24)"
37−bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser
35+npx t3 serve --host "$(tailscale ip -4)"
3836 ```
3937
40−Then open on your phone:
38+`t3 serve` starts the server without opening a browser and prints:
4139
42−`http://<your-machine-ip>:3773`
40+- a connection string
41+- a pairing token
42+- a pairing URL
43+- a QR code for the pairing URL
4344
44−Example:
45+From there, connect from another device in either of these ways:
4546
46−`http://192.168.1.42:3773`
47+- scan the QR code on your phone
48+- in the desktop app, enter the full pairing URL
49+- in the desktop app, enter the host and token separately
4750
48−Notes:
51+Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument.
4952
50−- `--host 0.0.0.0` listens on all IPv4 interfaces.
51−- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions.
52−- Ensure your OS firewall allows inbound TCP on the selected port.
53+> Note
54+> The GUIs do not currently support adding projects on remote environments.
55+> For now, use `t3 project ...` on the server machine instead.
56+> Full GUI support for remote project management is coming soon.
5357
54−## 2) Tailnet / Tailscale access
58+## How Pairing Works
5559
56−If you use Tailscale, you can bind directly to your Tailnet address.
60+The remote device does not need a long-lived secret up front.
5761
58−```bash
59−TAILNET_IP="$(tailscale ip -4)"
60−TOKEN="$(openssl rand -hex 24)"
61−bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser
62−```
62+Instead:
63+
64+1. `t3 serve` issues a one-time owner pairing token.
65+2. The remote device exchanges that token with the server.
66+3. The server creates an authenticated session for that device.
67+
68+After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device.
69+
70+## Managing Access Later
71+
72+Use `t3 auth` to manage access after the initial pairing flow.
73+
74+Typical uses:
75+
76+- issue additional pairing credentials
77+- inspect active sessions
78+- revoke old pairing links or sessions
6379
64−Open from any device in your tailnet:
80+Use `t3 auth --help` and the nested subcommand help pages for the full reference.
6581
66−`http://<tailnet-ip>:3773`
82+## Security Notes
6783
68−You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure.
84+- Treat pairing URLs and pairing tokens like passwords.
85+- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly.
86+- Anyone with a valid pairing credential can create a session until that credential expires or is revoked.
87+- Use `t3 auth` to revoke credentials or sessions you no longer trust.
apps/desktop/package.json+5−4
@@ -1,14 +1,15 @@
11 {
22 "name": "@t3tools/desktop",
3− "version": "0.0.15",
3+ "version": "0.0.20",
44 "private": true,
5− "main": "dist-electron/main.js",
5+ "type": "module",
6+ "main": "dist-electron/main.cjs",
67 "scripts": {
78 "dev": "bun run --parallel dev:bundle dev:electron",
89 "dev:bundle": "tsdown --watch",
9− "dev:electron": "bun run scripts/dev-electron.mjs",
10+ "dev:electron": "node scripts/dev-electron.mjs",
1011 "build": "tsdown",
11− "start": "bun run scripts/start-electron.mjs",
12+ "start": "node scripts/start-electron.mjs",
1213 "typecheck": "tsc --noEmit",
1314 "test": "vitest run --passWithNoTests",
1415 "smoke-test": "node scripts/smoke-test.mjs"
apps/desktop/scripts/dev-electron.mjs+17−10
@@ -5,15 +5,24 @@ import { join } from "node:path"; · 4 unmodified lines
55 import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
66 import { waitForResources } from "./wait-for-resources.mjs";
77
8−const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);
9−const devServerUrl = `http://localhost:${port}`;
8+const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
9+if (!devServerUrl) {
10+ throw new Error("VITE_DEV_SERVER_URL is required for desktop development.");
11+}
12+
13+const devServer = new URL(devServerUrl);
14+const port = Number.parseInt(devServer.port, 10);
15+if (!Number.isInteger(port) || port <= 0) {
16+ throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`);
17+}
18+
1019 const requiredFiles = [
11− "dist-electron/main.js",
12− "dist-electron/preload.js",
20+ "dist-electron/main.cjs",
21+ "dist-electron/preload.cjs",
1322 "../server/dist/bin.mjs",
1423 ];
1524 const watchedDirectories = [
16− { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) },
25+ { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) },
1726 { directory: "../server/dist", files: new Set(["bin.mjs"]) },
1827 ];
1928 const forcedShutdownTimeoutMs = 1_500;
@@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; · 3 unmodified lines
2332 await waitForResources({
2433 baseDir: desktopDir,
2534 files: requiredFiles,
35+ tcpHost: devServer.hostname,
2636 tcpPort: port,
2737 });
2838
@@ -59,13 +69,10 @@ function startApp() { · 30 unmodified lines
5969
6070 const app = spawn(
6171 resolveElectronPath(),
62− [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"],
72+ [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"],
6373 {
6474 cwd: desktopDir,
65− env: {
66− ...childEnv,
67− VITE_DEV_SERVER_URL: devServerUrl,
68− },
75+ env: childEnv,
6976 stdio: "inherit",
7077 },
7178 );
apps/desktop/scripts/electron-launcher.mjs+75−39
@@ -6,8 +6,8 @@ import { · 5 unmodified lines
66 cpSync,
77 existsSync,
88 mkdirSync,
9+ mkdtempSync,
910 readFileSync,
10− readdirSync,
1111 rmSync,
1212 statSync,
1313 writeFileSync,
@@ -18,11 +18,14 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines
1818
1919 const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
2020 const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
21−const APP_BUNDLE_ID = "com.t3tools.t3code";
22−const LAUNCHER_VERSION = 1;
21+const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
22+const LAUNCHER_VERSION = 2;
2323
2424 const __dirname = dirname(fileURLToPath(import.meta.url));
2525 export const desktopDir = resolve(__dirname, "..");
26+const repoRoot = resolve(desktopDir, "..", "..");
27+const defaultIconPath = join(desktopDir, "resources", "icon.icns");
28+const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");
2629
2730 function setPlistString(plistPath, key, value) {
2831 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {
@@ -43,6 +46,68 @@ function setPlistString(plistPath, key, value) { · 14 unmodified lines
4346 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
4447 }
4548
49+function runChecked(command, args) {
50+ const result = spawnSync(command, args, { encoding: "utf8" });
51+ if (result.status === 0) {
52+ return;
53+ }
54+
55+ const details = [result.stdout, result.stderr].filter(Boolean).join("\n");
56+ throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim());
57+}
58+
59+function ensureDevelopmentIconIcns(runtimeDir) {
60+ const generatedIconPath = join(runtimeDir, "icon-dev.icns");
61+ mkdirSync(runtimeDir, { recursive: true });
62+
63+ if (!existsSync(developmentMacIconPngPath)) {
64+ return defaultIconPath;
65+ }
66+
67+ const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs;
68+ if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) {
69+ return generatedIconPath;
70+ }
71+
72+ const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-"));
73+ const iconsetDir = join(iconsetRoot, "icon.iconset");
74+ mkdirSync(iconsetDir, { recursive: true });
75+
76+ try {
77+ for (const size of [16, 32, 128, 256, 512]) {
78+ runChecked("sips", [
79+ "-z",
80+ String(size),
81+ String(size),
82+ developmentMacIconPngPath,
83+ "--out",
84+ join(iconsetDir, `icon_${size}x${size}.png`),
85+ ]);
86+
87+ const retinaSize = size * 2;
88+ runChecked("sips", [
89+ "-z",
90+ String(retinaSize),
91+ String(retinaSize),
92+ developmentMacIconPngPath,
93+ "--out",
94+ join(iconsetDir, `icon_${size}x${size}@2x.png`),
95+ ]);
96+ }
97+
98+ runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);
99+ return generatedIconPath;
100+ } catch (error) {
101+ console.warn(
102+ "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.",
103+ error,
104+ );
105+ return defaultIconPath;
106+ } finally {
107+ rmSync(iconsetRoot, { recursive: true, force: true });
108+ }
109+}
110+
46111 function patchMainBundleInfoPlist(appBundlePath, iconPath) {
47112 const infoPlistPath = join(appBundlePath, "Contents", "Info.plist");
48113 setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME);
@@ -55,40 +120,6 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { · 6 unmodified lines
55120 copyFileSync(iconPath, join(resourcesDir, "electron.icns"));
56121 }
57122
58−function patchHelperBundleInfoPlists(appBundlePath) {
59− const frameworksDir = join(appBundlePath, "Contents", "Frameworks");
60− if (!existsSync(frameworksDir)) {
61− return;
62− }
63−
64− for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) {
65− if (!entry.isDirectory() || !entry.name.endsWith(".app")) {
66− continue;
67− }
68− if (!entry.name.startsWith("Electron Helper")) {
69− continue;
70− }
71−
72− const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist");
73− if (!existsSync(helperPlistPath)) {
74− continue;
75− }
76−
77− const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim();
78− const helperName = suffix
79− ? `${APP_DISPLAY_NAME} Helper ${suffix}`
80− : `${APP_DISPLAY_NAME} Helper`;
81− const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-");
82− const helperBundleId = helperIdSuffix
83− ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}`
84− : `${APP_BUNDLE_ID}.helper`;
85−
86− setPlistString(helperPlistPath, "CFBundleDisplayName", helperName);
87− setPlistString(helperPlistPath, "CFBundleName", helperName);
88− setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId);
89− }
90−}
91−
92123 function readJson(path) {
93124 try {
94125 return JSON.parse(readFileSync(path, "utf8"));
@@ -102,7 +133,7 @@ function buildMacLauncher(electronBinaryPath) { · 7 unmodified lines
102133 const runtimeDir = join(desktopDir, ".electron-runtime");
103134 const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`);
104135 const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron");
105− const iconPath = join(desktopDir, "resources", "icon.icns");
136+ const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath;
106137 const metadataPath = join(runtimeDir, "metadata.json");
107138
108139 mkdirSync(runtimeDir, { recursive: true });
@@ -126,7 +157,6 @@ function buildMacLauncher(electronBinaryPath) { · 17 unmodified lines
126157 rmSync(targetAppBundlePath, { recursive: true, force: true });
127158 cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true });
128159 patchMainBundleInfoPlist(targetAppBundlePath, iconPath);
129− patchHelperBundleInfoPlists(targetAppBundlePath);
130160 writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`);
131161
132162 return targetBinaryPath;
@@ -140,5 +170,11 @@ export function resolveElectronPath() { · 7 unmodified lines
140170 return electronBinaryPath;
141171 }
142172
173+ // Dev launches do not need a renamed app bundle badly enough to risk breaking
174+ // Electron helper resource lookup on macOS.
175+ if (isDevelopment) {
176+ return electronBinaryPath;
177+ }
178+
143179 return buildMacLauncher(electronBinaryPath);
144180 }
apps/desktop/scripts/smoke-test.mjs+1−1
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines
55 const __dirname = dirname(fileURLToPath(import.meta.url));
66 const desktopDir = resolve(__dirname, "..");
77 const electronBin = resolve(desktopDir, "node_modules/.bin/electron");
8−const mainJs = resolve(desktopDir, "dist-electron/main.js");
8+const mainJs = resolve(desktopDir, "dist-electron/main.cjs");
99
1010 console.log("\nLaunching Electron smoke test...");
1111
apps/desktop/scripts/start-electron.mjs+1−1
@@ -5,7 +5,7 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; · 4 unmodified lines
55 const childEnv = { ...process.env };
66 delete childEnv.ELECTRON_RUN_AS_NODE;
77
8−const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], {
8+const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], {
99 stdio: "inherit",
1010 cwd: desktopDir,
1111 env: childEnv,
apps/desktop/src/appBranding.test.ts+47−0
@@ -0,0 +1,47 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts";
4+
5+describe("resolveDesktopAppStageLabel", () => {
6+ it("uses Dev in desktop development", () => {
7+ expect(
8+ resolveDesktopAppStageLabel({
9+ isDevelopment: true,
10+ appVersion: "0.0.17-nightly.20260414.1",
11+ }),
12+ ).toBe("Dev");
13+ });
14+
15+ it("uses Nightly for packaged nightly builds", () => {
16+ expect(
17+ resolveDesktopAppStageLabel({
18+ isDevelopment: false,
19+ appVersion: "0.0.17-nightly.20260414.1",
20+ }),
21+ ).toBe("Nightly");
22+ });
23+
24+ it("uses Alpha for packaged stable builds", () => {
25+ expect(
26+ resolveDesktopAppStageLabel({
27+ isDevelopment: false,
28+ appVersion: "0.0.17",
29+ }),
30+ ).toBe("Alpha");
31+ });
32+});
33+
34+describe("resolveDesktopAppBranding", () => {
35+ it("returns a complete desktop branding payload", () => {
36+ expect(
37+ resolveDesktopAppBranding({
38+ isDevelopment: false,
39+ appVersion: "0.0.17-nightly.20260414.1",
40+ }),
41+ ).toEqual({
42+ baseName: "T3 Code",
43+ stageLabel: "Nightly",
44+ displayName: "T3 Code (Nightly)",
45+ });
46+ });
47+});
apps/desktop/src/appBranding.ts+28−0
@@ -0,0 +1,28 @@
1+import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts";
2+
3+import { isNightlyDesktopVersion } from "./updateChannels.ts";
4+
5+const APP_BASE_NAME = "T3 Code";
6+
7+export function resolveDesktopAppStageLabel(input: {
8+ readonly isDevelopment: boolean;
9+ readonly appVersion: string;
10+}): DesktopAppStageLabel {
11+ if (input.isDevelopment) {
12+ return "Dev";
13+ }
14+
15+ return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
16+}
17+
18+export function resolveDesktopAppBranding(input: {
19+ readonly isDevelopment: boolean;
20+ readonly appVersion: string;
21+}): DesktopAppBranding {
22+ const stageLabel = resolveDesktopAppStageLabel(input);
23+ return {
24+ baseName: APP_BASE_NAME,
25+ stageLabel,
26+ displayName: `${APP_BASE_NAME} (${stageLabel})`,
27+ };
28+}
apps/desktop/src/backendPort.test.ts+109−0
@@ -0,0 +1,109 @@
1+import { describe, expect, it, vi } from "vitest";
2+
3+import { resolveDesktopBackendPort } from "./backendPort.ts";
4+
5+describe("resolveDesktopBackendPort", () => {
6+ it("returns the starting port when it is available", async () => {
7+ const canListenOnHost = vi.fn(async (port: number) => port === 3773);
8+
9+ await expect(
10+ resolveDesktopBackendPort({
11+ host: "127.0.0.1",
12+ startPort: 3773,
13+ canListenOnHost,
14+ }),
15+ ).resolves.toBe(3773);
16+
17+ expect(canListenOnHost).toHaveBeenCalledTimes(1);
18+ expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1");
19+ });
20+
21+ it("increments sequentially until it finds an available port", async () => {
22+ const canListenOnHost = vi.fn(async (port: number) => port === 3775);
23+
24+ await expect(
25+ resolveDesktopBackendPort({
26+ host: "127.0.0.1",
27+ startPort: 3773,
28+ canListenOnHost,
29+ }),
30+ ).resolves.toBe(3775);
31+
32+ expect(canListenOnHost.mock.calls).toEqual([
33+ [3773, "127.0.0.1"],
34+ [3774, "127.0.0.1"],
35+ [3775, "127.0.0.1"],
36+ ]);
37+ });
38+
39+ it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => {
40+ const canListenOnHost = vi.fn(async (port: number, host: string) => {
41+ if (port === 3773 && host === "127.0.0.1") return true;
42+ if (port === 3773 && host === "0.0.0.0") return false;
43+ return port === 3774;
44+ });
45+
46+ await expect(
47+ resolveDesktopBackendPort({
48+ host: "127.0.0.1",
49+ requiredHosts: ["0.0.0.0"],
50+ startPort: 3773,
51+ canListenOnHost,
52+ }),
53+ ).resolves.toBe(3774);
54+
55+ expect(canListenOnHost.mock.calls).toEqual([
56+ [3773, "127.0.0.1"],
57+ [3773, "0.0.0.0"],
58+ [3774, "127.0.0.1"],
59+ [3774, "0.0.0.0"],
60+ ]);
61+ });
62+
63+ it("checks overlapping hosts sequentially to avoid self-interference", async () => {
64+ let inFlightCount = 0;
65+ const canListenOnHost = vi.fn(async (_port: number, _host: string) => {
66+ inFlightCount += 1;
67+ const overlapped = inFlightCount > 1;
68+ await Promise.resolve();
69+ inFlightCount -= 1;
70+ return !overlapped;
71+ });
72+
73+ await expect(
74+ resolveDesktopBackendPort({
75+ host: "127.0.0.1",
76+ requiredHosts: ["0.0.0.0", "::"],
77+ startPort: 3773,
78+ maxPort: 3773,
79+ canListenOnHost,
80+ }),
81+ ).resolves.toBe(3773);
82+
83+ expect(canListenOnHost.mock.calls).toEqual([
84+ [3773, "127.0.0.1"],
85+ [3773, "0.0.0.0"],
86+ [3773, "::"],
87+ ]);
88+ });
89+
90+ it("fails when the scan range is exhausted", async () => {
91+ const canListenOnHost = vi.fn(async () => false);
92+
93+ await expect(
94+ resolveDesktopBackendPort({
95+ host: "127.0.0.1",
96+ startPort: 65534,
97+ maxPort: 65535,
98+ canListenOnHost,
99+ }),
100+ ).rejects.toThrow(
101+ "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535",
102+ );
103+
104+ expect(canListenOnHost.mock.calls).toEqual([
105+ [65534, "127.0.0.1"],
106+ [65535, "127.0.0.1"],
107+ ]);
108+ });
109+});
apps/desktop/src/backendPort.ts+83−0
@@ -0,0 +1,83 @@
1+import * as Effect from "effect/Effect";
2+import { NetService } from "@t3tools/shared/Net";
3+
4+export const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
5+const MAX_TCP_PORT = 65_535;
6+
7+export interface ResolveDesktopBackendPortOptions {
8+ readonly host: string;
9+ readonly startPort?: number;
10+ readonly maxPort?: number;
11+ readonly requiredHosts?: ReadonlyArray<string>;
12+ readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>;
13+}
14+
15+const defaultCanListenOnHost = async (port: number, host: string): Promise<boolean> =>
16+ Effect.service(NetService).pipe(
17+ Effect.flatMap((net) => net.canListenOnHost(port, host)),
18+ Effect.provide(NetService.layer),
19+ Effect.runPromise,
20+ );
21+
22+const isValidPort = (port: number): boolean =>
23+ Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;
24+
25+const normalizeHosts = (
26+ host: string,
27+ requiredHosts: ReadonlyArray<string>,
28+): ReadonlyArray<string> =>
29+ Array.from(
30+ new Set(
31+ [host, ...requiredHosts]
32+ .map((candidate) => candidate.trim())
33+ .filter((candidate) => candidate.length > 0),
34+ ),
35+ );
36+
37+async function canListenOnAllHosts(
38+ port: number,
39+ hosts: ReadonlyArray<string>,
40+ canListenOnHost: (port: number, host: string) => Promise<boolean>,
41+): Promise<boolean> {
42+ for (const candidateHost of hosts) {
43+ if (!(await canListenOnHost(port, candidateHost))) {
44+ return false;
45+ }
46+ }
47+
48+ return true;
49+}
50+
51+export async function resolveDesktopBackendPort({
52+ host,
53+ startPort = DEFAULT_DESKTOP_BACKEND_PORT,
54+ maxPort = MAX_TCP_PORT,
55+ requiredHosts = [],
56+ canListenOnHost = defaultCanListenOnHost,
57+}: ResolveDesktopBackendPortOptions): Promise<number> {
58+ if (!isValidPort(startPort)) {
59+ throw new Error(`Invalid desktop backend start port: ${startPort}`);
60+ }
61+
62+ if (!isValidPort(maxPort)) {
63+ throw new Error(`Invalid desktop backend max port: ${maxPort}`);
64+ }
65+
66+ if (maxPort < startPort) {
67+ throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`);
68+ }
69+
70+ const hostsToCheck = normalizeHosts(host, requiredHosts);
71+
72+ // Keep desktop startup predictable across app restarts by probing upward from
73+ // the same preferred port instead of picking a fresh ephemeral port.
74+ for (let port = startPort; port <= maxPort; port += 1) {
75+ if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) {
76+ return port;
77+ }
78+ }
79+
80+ throw new Error(
81+ `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`,
82+ );
83+}
apps/desktop/src/backendReadiness.test.ts+114−0
@@ -0,0 +1,114 @@
1+import { describe, expect, it, vi } from "vitest";
2+
3+import {
4+ BackendReadinessAbortedError,
5+ isBackendReadinessAborted,
6+ waitForHttpReady,
7+} from "./backendReadiness.ts";
8+
9+describe("waitForHttpReady", () => {
10+ it("returns once the backend serves the requested readiness path", async () => {
11+ const fetchImpl = vi
12+ .fn<typeof fetch>()
13+ .mockResolvedValueOnce(new Response(null, { status: 503 }))
14+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
15+
16+ await waitForHttpReady("http://127.0.0.1:3773", {
17+ fetchImpl,
18+ timeoutMs: 1_000,
19+ intervalMs: 0,
20+ });
21+
22+ expect(fetchImpl).toHaveBeenCalledTimes(2);
23+ expect(fetchImpl).toHaveBeenNthCalledWith(
24+ 1,
25+ "http://127.0.0.1:3773/",
26+ expect.objectContaining({ redirect: "manual" }),
27+ );
28+ });
29+
30+ it("retries after a readiness request stalls past the per-request timeout", async () => {
31+ const fetchImpl = vi
32+ .fn<typeof fetch>()
33+ .mockImplementationOnce(
34+ (_input, init) =>
35+ new Promise((_resolve, reject) => {
36+ init?.signal?.addEventListener(
37+ "abort",
38+ () => {
39+ reject(new Error("request timed out"));
40+ },
41+ { once: true },
42+ );
43+ }) as ReturnType<typeof fetch>,
44+ )
45+ .mockResolvedValueOnce(new Response(null, { status: 200 }));
46+
47+ await waitForHttpReady("http://127.0.0.1:3773", {
48+ fetchImpl,
49+ timeoutMs: 100,
50+ intervalMs: 0,
51+ requestTimeoutMs: 1,
52+ });
53+
54+ expect(fetchImpl).toHaveBeenCalledTimes(2);
55+ });
56+
57+ it("aborts an in-flight readiness wait", async () => {
58+ const controller = new AbortController();
59+ const fetchImpl = vi.fn<typeof fetch>().mockImplementation(
60+ () =>
61+ new Promise((_resolve, reject) => {
62+ controller.signal.addEventListener(
63+ "abort",
64+ () => {
65+ reject(new BackendReadinessAbortedError());
66+ },
67+ { once: true },
68+ );
69+ }) as ReturnType<typeof fetch>,
70+ );
71+
72+ const waitPromise = waitForHttpReady("http://127.0.0.1:3773", {
73+ fetchImpl,
74+ timeoutMs: 1_000,
75+ intervalMs: 0,
76+ signal: controller.signal,
77+ });
78+
79+ controller.abort();
80+
81+ await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError);
82+ });
83+
84+ it("recognizes aborted readiness errors", () => {
85+ expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true);
86+ expect(isBackendReadinessAborted(new Error("nope"))).toBe(false);
87+ });
88+
89+ it("supports custom readiness predicates", async () => {
90+ const fetchImpl = vi
91+ .fn<typeof fetch>()
92+ .mockResolvedValueOnce(new Response(null, { status: 200 }))
93+ .mockResolvedValueOnce(new Response(null, { status: 204 }));
94+
95+ await waitForHttpReady("http://127.0.0.1:3773", {
96+ fetchImpl,
97+ timeoutMs: 1_000,
98+ intervalMs: 0,
99+ path: "/api/healthz",
100+ isReady: (response) => response.status === 204,
101+ });
102+
103+ expect(fetchImpl).toHaveBeenNthCalledWith(
104+ 1,
105+ "http://127.0.0.1:3773/api/healthz",
106+ expect.objectContaining({ redirect: "manual" }),
107+ );
108+ expect(fetchImpl).toHaveBeenNthCalledWith(
109+ 2,
110+ "http://127.0.0.1:3773/api/healthz",
111+ expect.objectContaining({ redirect: "manual" }),
112+ );
113+ });
114+});
apps/desktop/src/backendReadiness.ts+107−0
@@ -0,0 +1,107 @@
1+export interface WaitForHttpReadyOptions {
2+ readonly timeoutMs?: number;
3+ readonly intervalMs?: number;
4+ readonly requestTimeoutMs?: number;
5+ readonly fetchImpl?: typeof fetch;
6+ readonly signal?: AbortSignal;
7+ readonly path?: string;
8+ readonly isReady?: (response: Response) => boolean;
9+}
10+
11+const DEFAULT_TIMEOUT_MS = 30_000;
12+const DEFAULT_INTERVAL_MS = 100;
13+const DEFAULT_REQUEST_TIMEOUT_MS = 1_000;
14+
15+export class BackendReadinessAbortedError extends Error {
16+ constructor() {
17+ super("Backend readiness wait was aborted.");
18+ this.name = "BackendReadinessAbortedError";
19+ }
20+}
21+
22+function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
23+ return new Promise<void>((resolve, reject) => {
24+ const timer = setTimeout(() => {
25+ cleanup();
26+ resolve();
27+ }, ms);
28+
29+ const onAbort = () => {
30+ cleanup();
31+ reject(new BackendReadinessAbortedError());
32+ };
33+
34+ const cleanup = () => {
35+ clearTimeout(timer);
36+ signal?.removeEventListener("abort", onAbort);
37+ };
38+
39+ if (signal?.aborted) {
40+ cleanup();
41+ reject(new BackendReadinessAbortedError());
42+ return;
43+ }
44+
45+ signal?.addEventListener("abort", onAbort, { once: true });
46+ });
47+}
48+
49+export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError {
50+ return error instanceof BackendReadinessAbortedError;
51+}
52+
53+export async function waitForHttpReady(
54+ baseUrl: string,
55+ options?: WaitForHttpReadyOptions,
56+): Promise<void> {
57+ const fetchImpl = options?.fetchImpl ?? fetch;
58+ const signal = options?.signal;
59+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
60+ const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
61+ const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
62+ const readinessPath = options?.path ?? "/";
63+ const isReady = options?.isReady ?? ((response: Response) => response.ok);
64+ const deadline = Date.now() + timeoutMs;
65+
66+ for (;;) {
67+ if (signal?.aborted) {
68+ throw new BackendReadinessAbortedError();
69+ }
70+
71+ const requestController = new AbortController();
72+ const requestTimeout = setTimeout(() => {
73+ requestController.abort();
74+ }, requestTimeoutMs);
75+ const abortRequest = () => {
76+ requestController.abort();
77+ };
78+ signal?.addEventListener("abort", abortRequest, { once: true });
79+
80+ try {
81+ const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), {
82+ redirect: "manual",
83+ signal: requestController.signal,
84+ });
85+ if (isReady(response)) {
86+ return;
87+ }
88+ } catch (error) {
89+ if (isBackendReadinessAborted(error)) {
90+ throw error;
91+ }
92+ if (signal?.aborted) {
93+ throw new BackendReadinessAbortedError();
94+ }
95+ // Retry until the backend becomes reachable or the deadline expires.
96+ } finally {
97+ clearTimeout(requestTimeout);
98+ signal?.removeEventListener("abort", abortRequest);
99+ }
100+
101+ if (Date.now() >= deadline) {
102+ throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`);
103+ }
104+
105+ await delay(intervalMs, signal);
106+ }
107+}
apps/desktop/src/clientPersistence.test.ts+238−0
@@ -0,0 +1,238 @@
1+import * as fs from "node:fs";
2+import * as os from "node:os";
3+import * as path from "node:path";
4+
5+import {
6+ EnvironmentId,
7+ type ClientSettings,
8+ type PersistedSavedEnvironmentRecord,
9+} from "@t3tools/contracts";
10+import { afterEach, describe, expect, it } from "vitest";
11+
12+import {
13+ readClientSettings,
14+ readSavedEnvironmentRegistry,
15+ readSavedEnvironmentSecret,
16+ removeSavedEnvironmentSecret,
17+ writeClientSettings,
18+ writeSavedEnvironmentRegistry,
19+ writeSavedEnvironmentSecret,
20+ type DesktopSecretStorage,
21+} from "./clientPersistence.ts";
22+
23+const tempDirectories: string[] = [];
24+
25+afterEach(() => {
26+ for (const directory of tempDirectories.splice(0)) {
27+ fs.rmSync(directory, { recursive: true, force: true });
28+ }
29+});
30+
31+function makeTempPath(fileName: string): string {
32+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-"));
33+ tempDirectories.push(directory);
34+ return path.join(directory, fileName);
35+}
36+
37+function makeSecretStorage(available: boolean): DesktopSecretStorage {
38+ return {
39+ isEncryptionAvailable: () => available,
40+ encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"),
41+ decryptString: (value) => {
42+ const decoded = value.toString("utf8");
43+ if (!decoded.startsWith("enc:")) {
44+ throw new Error("invalid secret");
45+ }
46+ return decoded.slice("enc:".length);
47+ },
48+ };
49+}
50+
51+const clientSettings: ClientSettings = {
52+ confirmThreadArchive: true,
53+ confirmThreadDelete: false,
54+ diffWordWrap: true,
55+ sidebarProjectGroupingMode: "repository_path",
56+ sidebarProjectGroupingOverrides: {
57+ "environment-1:/tmp/project-a": "separate",
58+ },
59+ sidebarProjectSortOrder: "manual",
60+ sidebarThreadSortOrder: "created_at",
61+ timestampFormat: "24-hour",
62+};
63+
64+const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
65+ environmentId: EnvironmentId.make("environment-1"),
66+ label: "Remote environment",
67+ httpBaseUrl: "https://remote.example.com/",
68+ wsBaseUrl: "wss://remote.example.com/",
69+ createdAt: "2026-04-09T00:00:00.000Z",
70+ lastConnectedAt: "2026-04-09T01:00:00.000Z",
71+};
72+
73+describe("clientPersistence", () => {
74+ it("persists and reloads client settings", () => {
75+ const settingsPath = makeTempPath("client-settings.json");
76+
77+ writeClientSettings(settingsPath, clientSettings);
78+
79+ expect(readClientSettings(settingsPath)).toEqual(clientSettings);
80+ });
81+
82+ it("persists and reloads saved environment metadata", () => {
83+ const registryPath = makeTempPath("saved-environments.json");
84+
85+ writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
86+
87+ expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
88+ });
89+
90+ it("persists encrypted saved environment secrets when encryption is available", () => {
91+ const registryPath = makeTempPath("saved-environments.json");
92+ const secretStorage = makeSecretStorage(true);
93+
94+ writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
95+
96+ expect(
97+ writeSavedEnvironmentSecret({
98+ registryPath,
99+ environmentId: savedRegistryRecord.environmentId,
100+ secret: "bearer-token",
101+ secretStorage,
102+ }),
103+ ).toBe(true);
104+
105+ expect(
106+ readSavedEnvironmentSecret({
107+ registryPath,
108+ environmentId: savedRegistryRecord.environmentId,
109+ secretStorage,
110+ }),
111+ ).toBe("bearer-token");
112+
113+ expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({
114+ records: [
115+ {
116+ ...savedRegistryRecord,
117+ encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"),
118+ },
119+ ],
120+ });
121+ });
122+
123+ it("preserves existing secrets when encryption is unavailable", () => {
124+ const registryPath = makeTempPath("saved-environments.json");
125+ const availableSecretStorage = makeSecretStorage(true);
126+
127+ writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
128+
129+ writeSavedEnvironmentSecret({
130+ registryPath,
131+ environmentId: savedRegistryRecord.environmentId,
132+ secret: "bearer-token",
133+ secretStorage: availableSecretStorage,
134+ });
135+
136+ expect(
137+ writeSavedEnvironmentSecret({
138+ registryPath,
139+ environmentId: savedRegistryRecord.environmentId,
140+ secret: "next-token",
141+ secretStorage: makeSecretStorage(false),
142+ }),
143+ ).toBe(false);
144+
145+ expect(
146+ readSavedEnvironmentSecret({
147+ registryPath,
148+ environmentId: savedRegistryRecord.environmentId,
149+ secretStorage: availableSecretStorage,
150+ }),
151+ ).toBe("bearer-token");
152+ });
153+
154+ it("removes saved environment secrets", () => {
155+ const registryPath = makeTempPath("saved-environments.json");
156+ const secretStorage = makeSecretStorage(true);
157+
158+ writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
159+
160+ writeSavedEnvironmentSecret({
161+ registryPath,
162+ environmentId: savedRegistryRecord.environmentId,
163+ secret: "bearer-token",
164+ secretStorage,
165+ });
166+
167+ removeSavedEnvironmentSecret({
168+ registryPath,
169+ environmentId: savedRegistryRecord.environmentId,
170+ });
171+
172+ expect(
173+ readSavedEnvironmentSecret({
174+ registryPath,
175+ environmentId: savedRegistryRecord.environmentId,
176+ secretStorage,
177+ }),
178+ ).toBeNull();
179+ });
180+
181+ it("treats malformed secrets documents as empty", () => {
182+ const registryPath = makeTempPath("saved-environments.json");
183+ fs.writeFileSync(registryPath, "{}\n", "utf8");
184+
185+ expect(
186+ readSavedEnvironmentSecret({
187+ registryPath,
188+ environmentId: savedRegistryRecord.environmentId,
189+ secretStorage: makeSecretStorage(true),
190+ }),
191+ ).toBeNull();
192+
193+ expect(() =>
194+ removeSavedEnvironmentSecret({
195+ registryPath,
196+ environmentId: savedRegistryRecord.environmentId,
197+ }),
198+ ).not.toThrow();
199+ });
200+
201+ it("returns false when writing a secret without metadata", () => {
202+ const registryPath = makeTempPath("saved-environments.json");
203+
204+ expect(
205+ writeSavedEnvironmentSecret({
206+ registryPath,
207+ environmentId: savedRegistryRecord.environmentId,
208+ secret: "bearer-token",
209+ secretStorage: makeSecretStorage(true),
210+ }),
211+ ).toBe(false);
212+ });
213+
214+ it("preserves encrypted secrets when metadata is rewritten", () => {
215+ const registryPath = makeTempPath("saved-environments.json");
216+ const secretStorage = makeSecretStorage(true);
217+
218+ writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
219+
220+ writeSavedEnvironmentSecret({
221+ registryPath,
222+ environmentId: savedRegistryRecord.environmentId,
223+ secret: "bearer-token",
224+ secretStorage,
225+ });
226+
227+ writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
228+
229+ expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
230+ expect(
231+ readSavedEnvironmentSecret({
232+ registryPath,
233+ environmentId: savedRegistryRecord.environmentId,
234+ secretStorage,
235+ }),
236+ ).toBe("bearer-token");
237+ });
238+});
apps/desktop/src/clientPersistence.ts+229−0
@@ -0,0 +1,229 @@
1+import * as FS from "node:fs";
2+import * as Path from "node:path";
3+
4+import {
5+ ClientSettingsSchema,
6+ type ClientSettings,
7+ type PersistedSavedEnvironmentRecord,
8+} from "@t3tools/contracts";
9+import { Predicate } from "effect";
10+import * as Schema from "effect/Schema";
11+
12+interface ClientSettingsDocument {
13+ readonly settings: ClientSettings;
14+}
15+
16+interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord {
17+ readonly encryptedBearerToken?: string;
18+}
19+
20+interface SavedEnvironmentRegistryDocument {
21+ readonly records: readonly PersistedSavedEnvironmentStorageRecord[];
22+}
23+
24+export interface DesktopSecretStorage {
25+ readonly isEncryptionAvailable: () => boolean;
26+ readonly encryptString: (value: string) => Buffer;
27+ readonly decryptString: (value: Buffer) => string;
28+}
29+
30+function readJsonFile<T>(filePath: string): T | null {
31+ try {
32+ if (!FS.existsSync(filePath)) {
33+ return null;
34+ }
35+ return JSON.parse(FS.readFileSync(filePath, "utf8")) as T;
36+ } catch {
37+ return null;
38+ }
39+}
40+
41+function writeJsonFile(filePath: string, value: unknown): void {
42+ const directory = Path.dirname(filePath);
43+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
44+ FS.mkdirSync(directory, { recursive: true });
45+ FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
46+ FS.renameSync(tempPath, filePath);
47+}
48+
49+function isPersistedSavedEnvironmentStorageRecord(
50+ value: unknown,
51+): value is PersistedSavedEnvironmentStorageRecord {
52+ return (
53+ Predicate.isObject(value) &&
54+ typeof value.environmentId === "string" &&
55+ typeof value.label === "string" &&
56+ typeof value.httpBaseUrl === "string" &&
57+ typeof value.wsBaseUrl === "string" &&
58+ typeof value.createdAt === "string" &&
59+ (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") &&
60+ (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string")
61+ );
62+}
63+
64+function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument {
65+ const parsed = readJsonFile<SavedEnvironmentRegistryDocument>(filePath);
66+ if (!Predicate.isObject(parsed)) {
67+ return { records: [] };
68+ }
69+
70+ return {
71+ records: Array.isArray(parsed.records)
72+ ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord)
73+ : [],
74+ };
75+}
76+
77+function toPersistedSavedEnvironmentRecord(
78+ record: PersistedSavedEnvironmentStorageRecord,
79+): PersistedSavedEnvironmentRecord {
80+ return {
81+ environmentId: record.environmentId,
82+ label: record.label,
83+ httpBaseUrl: record.httpBaseUrl,
84+ wsBaseUrl: record.wsBaseUrl,
85+ createdAt: record.createdAt,
86+ lastConnectedAt: record.lastConnectedAt,
87+ };
88+}
89+
90+export function readClientSettings(settingsPath: string): ClientSettings | null {
91+ const raw = readJsonFile<ClientSettingsDocument>(settingsPath)?.settings;
92+ if (!raw) {
93+ return null;
94+ }
95+ try {
96+ return Schema.decodeUnknownSync(ClientSettingsSchema)(raw);
97+ } catch {
98+ return null;
99+ }
100+}
101+
102+export function writeClientSettings(settingsPath: string, settings: ClientSettings): void {
103+ writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument);
104+}
105+
106+export function readSavedEnvironmentRegistry(
107+ registryPath: string,
108+): readonly PersistedSavedEnvironmentRecord[] {
109+ return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) =>
110+ toPersistedSavedEnvironmentRecord(record),
111+ );
112+}
113+
114+export function writeSavedEnvironmentRegistry(
115+ registryPath: string,
116+ records: readonly PersistedSavedEnvironmentRecord[],
117+): void {
118+ const currentDocument = readSavedEnvironmentRegistryDocument(registryPath);
119+ const encryptedBearerTokenById = new Map(
120+ currentDocument.records.flatMap((record) =>
121+ record.encryptedBearerToken
122+ ? [[record.environmentId, record.encryptedBearerToken] as const]
123+ : [],
124+ ),
125+ );
126+ writeJsonFile(registryPath, {
127+ records: records.map((record) => {
128+ const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId);
129+ return encryptedBearerToken
130+ ? {
131+ environmentId: record.environmentId,
132+ label: record.label,
133+ httpBaseUrl: record.httpBaseUrl,
134+ wsBaseUrl: record.wsBaseUrl,
135+ createdAt: record.createdAt,
136+ lastConnectedAt: record.lastConnectedAt,
137+ encryptedBearerToken,
138+ }
139+ : record;
140+ }),
141+ } satisfies SavedEnvironmentRegistryDocument);
142+}
143+
144+export function readSavedEnvironmentSecret(input: {
145+ readonly registryPath: string;
146+ readonly environmentId: string;
147+ readonly secretStorage: DesktopSecretStorage;
148+}): string | null {
149+ const document = readSavedEnvironmentRegistryDocument(input.registryPath);
150+ const encoded = document.records.find(
151+ (record) => record.environmentId === input.environmentId,
152+ )?.encryptedBearerToken;
153+ if (!encoded) {
154+ return null;
155+ }
156+
157+ if (!input.secretStorage.isEncryptionAvailable()) {
158+ return null;
159+ }
160+
161+ try {
162+ return input.secretStorage.decryptString(Buffer.from(encoded, "base64"));
163+ } catch {
164+ return null;
165+ }
166+}
167+
168+export function writeSavedEnvironmentSecret(input: {
169+ readonly registryPath: string;
170+ readonly environmentId: string;
171+ readonly secret: string;
172+ readonly secretStorage: DesktopSecretStorage;
173+}): boolean {
174+ const document = readSavedEnvironmentRegistryDocument(input.registryPath);
175+
176+ if (!input.secretStorage.isEncryptionAvailable()) {
177+ return false;
178+ }
179+
180+ let found = false;
181+
182+ writeJsonFile(input.registryPath, {
183+ records: document.records.map((record) => {
184+ if (record.environmentId !== input.environmentId) {
185+ return record;
186+ }
187+
188+ found = true;
189+ const encryptedBearerToken = input.secretStorage
190+ .encryptString(input.secret)
191+ .toString("base64");
192+ return {
193+ environmentId: record.environmentId,
194+ label: record.label,
195+ httpBaseUrl: record.httpBaseUrl,
196+ wsBaseUrl: record.wsBaseUrl,
197+ createdAt: record.createdAt,
198+ lastConnectedAt: record.lastConnectedAt,
199+ encryptedBearerToken,
200+ } satisfies PersistedSavedEnvironmentStorageRecord;
201+ }),
202+ } satisfies SavedEnvironmentRegistryDocument);
203+ return found;
204+}
205+
206+export function removeSavedEnvironmentSecret(input: {
207+ readonly registryPath: string;
208+ readonly environmentId: string;
209+}): void {
210+ const document = readSavedEnvironmentRegistryDocument(input.registryPath);
211+ if (
212+ !document.records.some(
213+ (record) =>
214+ record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined,
215+ )
216+ ) {
217+ return;
218+ }
219+
220+ writeJsonFile(input.registryPath, {
221+ records: document.records.map((record) => {
222+ if (record.environmentId !== input.environmentId) {
223+ return record;
224+ }
225+
226+ return toPersistedSavedEnvironmentRecord(record);
227+ }),
228+ } satisfies SavedEnvironmentRegistryDocument);
229+}
apps/desktop/src/confirmDialog.test.ts+1−1
@@ -11,7 +11,7 @@ vi.mock("electron", () => ({ · 10 unmodified lines
1111 },
1212 }));
1313
14−import { showDesktopConfirmDialog } from "./confirmDialog";
14+import { showDesktopConfirmDialog } from "./confirmDialog.ts";
1515
1616 describe("showDesktopConfirmDialog", () => {
1717 beforeEach(() => {
apps/desktop/src/desktopSettings.test.ts+147−0
@@ -0,0 +1,147 @@
1+import * as fs from "node:fs";
2+import * as os from "node:os";
3+import * as path from "node:path";
4+
5+import { afterEach, describe, expect, it } from "vitest";
6+
7+import {
8+ DEFAULT_DESKTOP_SETTINGS,
9+ readDesktopSettings,
10+ resolveDefaultDesktopSettings,
11+ setDesktopServerExposurePreference,
12+ setDesktopUpdateChannelPreference,
13+ writeDesktopSettings,
14+} from "./desktopSettings.ts";
15+
16+const tempDirectories: string[] = [];
17+
18+afterEach(() => {
19+ for (const directory of tempDirectories.splice(0)) {
20+ fs.rmSync(directory, { recursive: true, force: true });
21+ }
22+});
23+
24+function makeSettingsPath() {
25+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-"));
26+ tempDirectories.push(directory);
27+ return path.join(directory, "desktop-settings.json");
28+}
29+
30+describe("desktopSettings", () => {
31+ it("returns defaults when no settings file exists", () => {
32+ expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
33+ });
34+
35+ it("defaults packaged nightly builds to the nightly update channel", () => {
36+ expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({
37+ serverExposureMode: "local-only",
38+ updateChannel: "nightly",
39+ updateChannelConfiguredByUser: false,
40+ });
41+ });
42+
43+ it("persists and reloads the configured server exposure mode", () => {
44+ const settingsPath = makeSettingsPath();
45+
46+ writeDesktopSettings(settingsPath, {
47+ serverExposureMode: "network-accessible",
48+ updateChannel: "latest",
49+ updateChannelConfiguredByUser: true,
50+ });
51+
52+ expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
53+ serverExposureMode: "network-accessible",
54+ updateChannel: "latest",
55+ updateChannelConfiguredByUser: true,
56+ });
57+ });
58+
59+ it("preserves the requested network-accessible preference across temporary fallback", () => {
60+ expect(
61+ setDesktopServerExposurePreference(
62+ {
63+ serverExposureMode: "local-only",
64+ updateChannel: "latest",
65+ updateChannelConfiguredByUser: false,
66+ },
67+ "network-accessible",
68+ ),
69+ ).toEqual({
70+ serverExposureMode: "network-accessible",
71+ updateChannel: "latest",
72+ updateChannelConfiguredByUser: false,
73+ });
74+ });
75+
76+ it("persists the requested nightly update channel", () => {
77+ expect(
78+ setDesktopUpdateChannelPreference(
79+ {
80+ serverExposureMode: "local-only",
81+ updateChannel: "latest",
82+ updateChannelConfiguredByUser: false,
83+ },
84+ "nightly",
85+ ),
86+ ).toEqual({
87+ serverExposureMode: "local-only",
88+ updateChannel: "nightly",
89+ updateChannelConfiguredByUser: true,
90+ });
91+ });
92+
93+ it("falls back to defaults when the settings file is malformed", () => {
94+ const settingsPath = makeSettingsPath();
95+ fs.writeFileSync(settingsPath, "{not-json", "utf8");
96+
97+ expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
98+ });
99+
100+ it("falls back to the nightly channel for legacy nightly settings without an update track", () => {
101+ const settingsPath = makeSettingsPath();
102+ fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8");
103+
104+ expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
105+ serverExposureMode: "local-only",
106+ updateChannel: "nightly",
107+ updateChannelConfiguredByUser: false,
108+ });
109+ });
110+
111+ it("migrates legacy implicit stable settings to nightly when running a nightly build", () => {
112+ const settingsPath = makeSettingsPath();
113+ fs.writeFileSync(
114+ settingsPath,
115+ JSON.stringify({
116+ serverExposureMode: "local-only",
117+ updateChannel: "latest",
118+ }),
119+ "utf8",
120+ );
121+
122+ expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
123+ serverExposureMode: "local-only",
124+ updateChannel: "nightly",
125+ updateChannelConfiguredByUser: false,
126+ });
127+ });
128+
129+ it("preserves an explicit stable choice on nightly builds", () => {
130+ const settingsPath = makeSettingsPath();
131+ fs.writeFileSync(
132+ settingsPath,
133+ JSON.stringify({
134+ serverExposureMode: "local-only",
135+ updateChannel: "latest",
136+ updateChannelConfiguredByUser: true,
137+ }),
138+ "utf8",
139+ );
140+
141+ expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
142+ serverExposureMode: "local-only",
143+ updateChannel: "latest",
144+ updateChannelConfiguredByUser: true,
145+ });
146+ });
147+});
apps/desktop/src/desktopSettings.ts+92−0
@@ -0,0 +1,92 @@
1+import * as FS from "node:fs";
2+import * as Path from "node:path";
3+import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts";
4+
5+import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
6+
7+export interface DesktopSettings {
8+ readonly serverExposureMode: DesktopServerExposureMode;
9+ readonly updateChannel: DesktopUpdateChannel;
10+ readonly updateChannelConfiguredByUser: boolean;
11+}
12+
13+export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
14+ serverExposureMode: "local-only",
15+ updateChannel: "latest",
16+ updateChannelConfiguredByUser: false,
17+};
18+
19+export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
20+ return {
21+ ...DEFAULT_DESKTOP_SETTINGS,
22+ updateChannel: resolveDefaultDesktopUpdateChannel(appVersion),
23+ };
24+}
25+
26+export function setDesktopServerExposurePreference(
27+ settings: DesktopSettings,
28+ requestedMode: DesktopServerExposureMode,
29+): DesktopSettings {
30+ return settings.serverExposureMode === requestedMode
31+ ? settings
32+ : {
33+ ...settings,
34+ serverExposureMode: requestedMode,
35+ };
36+}
37+
38+export function setDesktopUpdateChannelPreference(
39+ settings: DesktopSettings,
40+ requestedChannel: DesktopUpdateChannel,
41+): DesktopSettings {
42+ return {
43+ ...settings,
44+ updateChannel: requestedChannel,
45+ updateChannelConfiguredByUser: true,
46+ };
47+}
48+
49+export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings {
50+ const defaultSettings = resolveDefaultDesktopSettings(appVersion);
51+
52+ try {
53+ if (!FS.existsSync(settingsPath)) {
54+ return defaultSettings;
55+ }
56+
57+ const raw = FS.readFileSync(settingsPath, "utf8");
58+ const parsed = JSON.parse(raw) as {
59+ readonly serverExposureMode?: unknown;
60+ readonly updateChannel?: unknown;
61+ readonly updateChannelConfiguredByUser?: unknown;
62+ };
63+ const parsedUpdateChannel =
64+ parsed.updateChannel === "nightly" || parsed.updateChannel === "latest"
65+ ? parsed.updateChannel
66+ : null;
67+ const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
68+ const updateChannelConfiguredByUser =
69+ parsed.updateChannelConfiguredByUser === true ||
70+ (isLegacySettings && parsedUpdateChannel === "nightly");
71+
72+ return {
73+ serverExposureMode:
74+ parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
75+ updateChannel:
76+ updateChannelConfiguredByUser && parsedUpdateChannel !== null
77+ ? parsedUpdateChannel
78+ : defaultSettings.updateChannel,
79+ updateChannelConfiguredByUser,
80+ };
81+ } catch {
82+ return defaultSettings;
83+ }
84+}
85+
86+export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void {
87+ const directory = Path.dirname(settingsPath);
88+ const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`;
89+ FS.mkdirSync(directory, { recursive: true });
90+ FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
91+ FS.renameSync(tempPath, settingsPath);
92+}
apps/desktop/src/main.ts+802−101
@@ -7,18 +7,26 @@ import * as Path from "node:path"; · 6 unmodified lines
77 import {
88 app,
99 BrowserWindow,
10+ type BrowserWindowConstructorOptions,
11+ clipboard,
1012 dialog,
1113 ipcMain,
1214 Menu,
1315 nativeImage,
1416 nativeTheme,
1517 protocol,
18+ safeStorage,
1619 shell,
1720 } from "electron";
18−import type { MenuItemConstructorOptions } from "electron";
19−import * as Effect from "effect/Effect";
21+import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron";
2022 import type {
23+ ClientSettings,
2124 DesktopTheme,
25+ DesktopAppBranding,
26+ DesktopServerExposureMode,
27+ DesktopServerExposureState,
28+ DesktopUpdateChannel,
29+ PersistedSavedEnvironmentRecord,
2230 DesktopUpdateActionResult,
2331 DesktopUpdateCheckResult,
2432 DesktopUpdateState,
@@ -26,12 +34,32 @@ import type { · 1 unmodified line
2634 import { autoUpdater } from "electron-updater";
2735
2836 import type { ContextMenuItem } from "@t3tools/contracts";
29−import { NetService } from "@t3tools/shared/Net";
3037 import { RotatingFileSink } from "@t3tools/shared/logging";
3138 import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
32−import { showDesktopConfirmDialog } from "./confirmDialog";
33−import { syncShellEnvironment } from "./syncShellEnvironment";
34−import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
39+import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts";
40+import {
41+ DEFAULT_DESKTOP_SETTINGS,
42+ readDesktopSettings,
43+ setDesktopServerExposurePreference,
44+ setDesktopUpdateChannelPreference,
45+ writeDesktopSettings,
46+} from "./desktopSettings.ts";
47+import {
48+ readClientSettings,
49+ readSavedEnvironmentRegistry,
50+ readSavedEnvironmentSecret,
51+ removeSavedEnvironmentSecret,
52+ writeClientSettings,
53+ writeSavedEnvironmentRegistry,
54+ writeSavedEnvironmentSecret,
55+} from "./clientPersistence.ts";
56+import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";
57+import { showDesktopConfirmDialog } from "./confirmDialog.ts";
58+import { resolveDesktopServerExposure } from "./serverExposure.ts";
59+import { syncShellEnvironment } from "./syncShellEnvironment.ts";
60+import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";
61+import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts";
62+import { ServerListeningDetector } from "./serverListeningDetector.ts";
3563 import {
3664 createInitialDesktopUpdateState,
3765 reduceDesktopUpdateStateOnCheckFailure,
@@ -43,8 +71,9 @@ import { · 5 unmodified lines
4371 reduceDesktopUpdateStateOnInstallFailure,
4472 reduceDesktopUpdateStateOnNoUpdate,
4573 reduceDesktopUpdateStateOnUpdateAvailable,
46−} from "./updateMachine";
47−import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
74+} from "./updateMachine.ts";
75+import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
76+import { resolveDesktopAppBranding } from "./appBranding.ts";
4877
4978 syncShellEnvironment();
5079
@@ -56,17 +85,35 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; · 5 unmodified lines
5685 const MENU_ACTION_CHANNEL = "desktop:menu-action";
5786 const UPDATE_STATE_CHANNEL = "desktop:update-state";
5887 const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
88+const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
5989 const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
6090 const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
6191 const UPDATE_CHECK_CHANNEL = "desktop:update-check";
62−const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
92+const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
93+const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
94+const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
95+const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
96+const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
97+const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
98+const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
99+const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
100+const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
101+const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
102+const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
63103 const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
64104 const STATE_DIR = Path.join(BASE_DIR, "userdata");
105+const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");
106+const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json");
107+const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json");
65108 const DESKTOP_SCHEME = "t3";
66109 const ROOT_DIR = Path.resolve(__dirname, "../../..");
67110 const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
68−const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
69−const APP_USER_MODEL_ID = "com.t3tools.t3code";
111+const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({
112+ isDevelopment,
113+ appVersion: app.getVersion(),
114+});
115+const APP_DISPLAY_NAME = desktopAppBranding.displayName;
116+const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
70117 const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";
71118 const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";
72119 const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code";
@@ -80,8 +127,72 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); · 7 unmodified lines
80127 const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
81128 const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
82129 const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
83−const DESKTOP_UPDATE_CHANNEL = "latest";
84−const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
130+
131+function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined {
132+ if (typeof rawOptions !== "object" || rawOptions === null) {
133+ return undefined;
134+ }
135+
136+ const { initialPath } = rawOptions as { initialPath?: unknown };
137+ if (typeof initialPath !== "string") {
138+ return undefined;
139+ }
140+
141+ const trimmedPath = initialPath.trim();
142+ if (trimmedPath.length === 0) {
143+ return undefined;
144+ }
145+
146+ if (trimmedPath === "~") {
147+ return OS.homedir();
148+ }
149+
150+ if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) {
151+ return Path.join(OS.homedir(), trimmedPath.slice(2));
152+ }
153+
154+ return Path.resolve(trimmedPath);
155+}
156+const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
157+const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
158+const TITLEBAR_HEIGHT = 40;
159+const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
160+const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
161+const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
162+
163+function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {
164+ const normalizedItems: ContextMenuItem[] = [];
165+
166+ for (const sourceItem of source) {
167+ if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
168+ continue;
169+ }
170+
171+ const normalizedItem: ContextMenuItem = {
172+ id: sourceItem.id,
173+ label: sourceItem.label,
174+ destructive: sourceItem.destructive === true,
175+ disabled: sourceItem.disabled === true,
176+ };
177+
178+ if (sourceItem.children) {
179+ const normalizedChildren = normalizeContextMenuItems(sourceItem.children);
180+ if (normalizedChildren.length === 0) {
181+ continue;
182+ }
183+ normalizedItem.children = normalizedChildren;
184+ }
185+
186+ normalizedItems.push(normalizedItem);
187+ }
188+
189+ return normalizedItems;
190+}
191+
192+type WindowTitleBarOptions = Pick<
193+ BrowserWindowConstructorOptions,
194+ "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
195+>;
85196
86197 type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
87198 type LinuxDesktopNamedApp = Electron.App & {
@@ -91,8 +202,15 @@ type LinuxDesktopNamedApp = Electron.App & { · 3 unmodified lines
91202 let mainWindow: BrowserWindow | null = null;
92203 let backendProcess: ChildProcess.ChildProcess | null = null;
93204 let backendPort = 0;
94−let backendAuthToken = "";
205+let backendBindHost = DESKTOP_LOOPBACK_HOST;
206+let backendBootstrapToken = "";
207+let backendHttpUrl = "";
95208 let backendWsUrl = "";
209+let backendEndpointUrl: string | null = null;
210+let backendAdvertisedHost: string | null = null;
211+let backendReadinessAbortController: AbortController | null = null;
212+let backendInitialWindowOpenInFlight: Promise<void> | null = null;
213+let backendListeningDetector: ServerListeningDetector | null = null;
96214 let restartAttempt = 0;
97215 let restartTimer: ReturnType<typeof setTimeout> | null = null;
98216 let isQuitting = false;
@@ -102,6 +220,8 @@ let desktopLogSink: RotatingFileSink | null = null; · 3 unmodified lines
102220 let backendLogSink: RotatingFileSink | null = null;
103221 let restoreStdIoCapture: (() => void) | null = null;
104222 let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
223+let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());
224+let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode;
105225
106226 let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
107227 const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
@@ -111,7 +231,11 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ · 3 unmodified lines
111231 runningUnderArm64Translation: app.runningUnderARM64Translation === true,
112232 });
113233 const initialUpdateState = (): DesktopUpdateState =>
114− createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo);
234+ createInitialDesktopUpdateState(
235+ app.getVersion(),
236+ desktopRuntimeInfo,
237+ desktopSettings.updateChannel,
238+ );
115239
116240 function logTimestamp(): string {
117241 return new Date().toISOString();
@@ -140,17 +264,128 @@ function readPersistedBackendObservabilitySettings(): { · 22 unmodified lines
140264 }
141265 }
142266
267+function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined {
268+ if (!rawPort) {
269+ return undefined;
270+ }
271+
272+ const parsedPort = Number.parseInt(rawPort, 10);
273+ if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) {
274+ return undefined;
275+ }
276+
277+ return parsedPort;
278+}
279+
280+function resolveDesktopDevServerUrl(): string {
281+ const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
282+ if (!devServerUrl) {
283+ throw new Error("VITE_DEV_SERVER_URL is required in desktop development.");
284+ }
285+
286+ return devServerUrl;
287+}
288+
143289 function backendChildEnv(): NodeJS.ProcessEnv {
144290 const env = { ...process.env };
145291 delete env.T3CODE_PORT;
146− delete env.T3CODE_AUTH_TOKEN;
147292 delete env.T3CODE_MODE;
148293 delete env.T3CODE_NO_BROWSER;
149294 delete env.T3CODE_HOST;
150295 delete env.T3CODE_DESKTOP_WS_URL;
296+ delete env.T3CODE_DESKTOP_LAN_ACCESS;
297+ delete env.T3CODE_DESKTOP_LAN_HOST;
151298 return env;
152299 }
153300
301+function getDesktopServerExposureState(): DesktopServerExposureState {
302+ return {
303+ mode: desktopServerExposureMode,
304+ endpointUrl: backendEndpointUrl,
305+ advertisedHost: backendAdvertisedHost,
306+ };
307+}
308+
309+function getDesktopSecretStorage() {
310+ return {
311+ isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
312+ encryptString: (value: string) => safeStorage.encryptString(value),
313+ decryptString: (value: Buffer) => safeStorage.decryptString(value),
314+ } as const;
315+}
316+
317+function resolveAdvertisedHostOverride(): string | undefined {
318+ const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim();
319+ return override && override.length > 0 ? override : undefined;
320+}
321+
322+async function applyDesktopServerExposureMode(
323+ mode: DesktopServerExposureMode,
324+ options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean },
325+): Promise<DesktopServerExposureState> {
326+ const advertisedHostOverride = resolveAdvertisedHostOverride();
327+ const requestedMode = mode;
328+ let exposure = resolveDesktopServerExposure({
329+ mode,
330+ port: backendPort,
331+ networkInterfaces: OS.networkInterfaces(),
332+ ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
333+ });
334+
335+ if (requestedMode === "network-accessible" && exposure.endpointUrl === null) {
336+ if (options?.rejectIfUnavailable) {
337+ throw new Error("No reachable network address is available for this desktop right now.");
338+ }
339+ exposure = resolveDesktopServerExposure({
340+ mode: "local-only",
341+ port: backendPort,
342+ networkInterfaces: OS.networkInterfaces(),
343+ ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
344+ });
345+ }
346+
347+ desktopServerExposureMode = exposure.mode;
348+ desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode);
349+ backendBindHost = exposure.bindHost;
350+ backendHttpUrl = exposure.localHttpUrl;
351+ backendWsUrl = exposure.localWsUrl;
352+ backendEndpointUrl = exposure.endpointUrl;
353+ backendAdvertisedHost = exposure.advertisedHost;
354+
355+ if (options?.persist) {
356+ writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
357+ }
358+
359+ return getDesktopServerExposureState();
360+}
361+
362+function relaunchDesktopApp(reason: string): void {
363+ writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`);
364+ setImmediate(() => {
365+ isQuitting = true;
366+ clearUpdatePollTimer();
367+ cancelBackendReadinessWait();
368+ void stopBackendAndWaitForExit()
369+ .catch((error) => {
370+ writeDesktopLogHeader(
371+ `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`,
372+ );
373+ })
374+ .finally(() => {
375+ restoreStdIoCapture?.();
376+ if (isDevelopment) {
377+ app.exit(75);
378+ return;
379+ }
380+ app.relaunch({
381+ execPath: process.execPath,
382+ args: process.argv.slice(1),
383+ });
384+ app.exit(0);
385+ });
386+ });
387+}
388+
154389 function writeDesktopLogHeader(message: string): void {
155390 if (!desktopLogSink) return;
156391 desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
@@ -198,6 +433,113 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { · 41 unmodified lines
198433 return null;
199434 }
200435
436+async function waitForBackendHttpReady(
437+ baseUrl: string,
438+ options?: Parameters<typeof waitForHttpReady>[1],
439+): Promise<void> {
440+ cancelBackendReadinessWait();
441+ const controller = new AbortController();
442+ backendReadinessAbortController = controller;
443+
444+ try {
445+ await waitForHttpReady(baseUrl, {
446+ ...options,
447+ signal: controller.signal,
448+ });
449+ } finally {
450+ if (backendReadinessAbortController === controller) {
451+ backendReadinessAbortController = null;
452+ }
453+ }
454+}
455+
456+function cancelBackendReadinessWait(): void {
457+ backendReadinessAbortController?.abort();
458+ backendReadinessAbortController = null;
459+}
460+
461+async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> {
462+ const httpReadyPromise = waitForBackendHttpReady(baseUrl, {
463+ timeoutMs: 60_000,
464+ });
465+ const listeningPromise = backendListeningDetector?.promise;
466+
467+ if (!listeningPromise) {
468+ await httpReadyPromise;
469+ return "http";
470+ }
471+
472+ return await new Promise<"listening" | "http">((resolve, reject) => {
473+ let settled = false;
474+
475+ const settleResolve = (source: "listening" | "http") => {
476+ if (settled) {
477+ return;
478+ }
479+ settled = true;
480+ if (source === "listening") {
481+ cancelBackendReadinessWait();
482+ }
483+ resolve(source);
484+ };
485+
486+ const settleReject = (error: unknown) => {
487+ if (settled) {
488+ return;
489+ }
490+ settled = true;
491+ reject(error);
492+ };
493+
494+ listeningPromise.then(
495+ () => settleResolve("listening"),
496+ (error) => settleReject(error),
497+ );
498+ httpReadyPromise.then(
499+ () => settleResolve("http"),
500+ (error) => {
501+ if (settled && isBackendReadinessAborted(error)) {
502+ return;
503+ }
504+ settleReject(error);
505+ },
506+ );
507+ });
508+}
509+
510+function ensureInitialBackendWindowOpen(): void {
511+ const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
512+ if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) {
513+ return;
514+ }
515+
516+ const nextOpen = waitForBackendWindowReady(backendHttpUrl)
517+ .then((source) => {
518+ writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
519+ if (mainWindow ?? BrowserWindow.getAllWindows()[0]) {
520+ return;
521+ }
522+ mainWindow = createWindow();
523+ writeDesktopLogHeader("bootstrap main window created");
524+ })
525+ .catch((error) => {
526+ if (isBackendReadinessAborted(error)) {
527+ return;
528+ }
529+ writeDesktopLogHeader(
530+ `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
531+ );
532+ console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error);
533+ })
534+ .finally(() => {
535+ if (backendInitialWindowOpenInFlight === nextOpen) {
536+ backendInitialWindowOpenInFlight = null;
537+ }
538+ });
539+
540+ backendInitialWindowOpenInFlight = nextOpen;
541+}
542+
201543 function writeDesktopStreamChunk(
202544 streamName: "stdout" | "stderr",
203545 chunk: unknown,
@@ -275,14 +617,16 @@ function initializePackagedLogging(): void { · 71 unmodified lines
275617 }
276618
277619 function captureBackendOutput(child: ChildProcess.ChildProcess): void {
278− if (!app.isPackaged || backendLogSink === null) return;
279− const writeChunk = (chunk: unknown): void => {
280− if (!backendLogSink) return;
281− const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
282− backendLogSink.write(buffer);
620+ const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => {
621+ stream?.on("data", (chunk: unknown) => {
622+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
623+ backendLogSink?.write(buffer);
624+ backendListeningDetector?.push(buffer);
625+ });
283626 };
284− child.stdout?.on("data", writeChunk);
285− child.stderr?.on("data", writeChunk);
627+
628+ attachStream(child.stdout);
629+ attachStream(child.stderr);
286630 }
287631
288632 initializePackagedLogging();
@@ -542,10 +886,7 @@ function dispatchMenuAction(action: string): void { · 253 unmodified lines
542886 const send = () => {
543887 if (targetWindow.isDestroyed()) return;
544888 targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);
545− if (!targetWindow.isVisible()) {
546− targetWindow.show();
547− }
548− targetWindow.focus();
889+ revealWindow(targetWindow);
549890 };
550891
551892 if (targetWindow.webContents.isLoadingMainFrame()) {
@@ -557,12 +898,15 @@ function dispatchMenuAction(action: string): void { · 5 unmodified lines
557898 }
558899
559900 function handleCheckForUpdatesMenuClick(): void {
901+ const hasUpdateFeedConfig =
902+ readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
560903 const disabledReason = getAutoUpdateDisabledReason({
561904 isDevelopment,
562905 isPackaged: app.isPackaged,
563906 platform: process.platform,
564907 appImage: process.env.APPIMAGE,
565908 disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",
909+ hasUpdateFeedConfig,
566910 });
567911 if (disabledReason) {
568912 console.info("[desktop-updater] Manual update check requested, but updates are disabled.");
@@ -699,6 +1043,18 @@ function resolveResourcePath(fileName: string): string | null { · 130 unmodified lines
6991043 }
7001044
7011045 function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
1046+ if (isDevelopment && process.platform === "darwin" && ext === "png") {
1047+ const developmentDockIconPath = Path.join(
1048+ ROOT_DIR,
1049+ "assets",
1050+ "dev",
1051+ "blueprint-macos-1024.png",
1052+ );
1053+ if (FS.existsSync(developmentDockIconPath)) {
1054+ return developmentDockIconPath;
1055+ }
1056+ }
1057+
7021058 return resolveResourcePath(`icon.${ext}`);
7031059 }
7041060
@@ -766,6 +1122,26 @@ function clearUpdatePollTimer(): void { · 61 unmodified lines
7661122 }
7671123 }
7681124
1125+function revealWindow(window: BrowserWindow): void {
1126+ if (window.isDestroyed()) {
1127+ return;
1128+ }
1129+
1130+ if (window.isMinimized()) {
1131+ window.restore();
1132+ }
1133+
1134+ if (!window.isVisible()) {
1135+ window.show();
1136+ }
1137+
1138+ if (process.platform === "darwin") {
1139+ app.focus({ steal: true });
1140+ }
1141+
1142+ window.focus();
1143+}
1144+
7691145 function emitUpdateState(): void {
7701146 for (const window of BrowserWindow.getAllWindows()) {
7711147 if (window.isDestroyed()) continue;
@@ -778,7 +1154,29 @@ function setUpdateState(patch: Partial<DesktopUpdateState>): void { · 6 unmodified lines
7781154 emitUpdateState();
7791155 }
7801156
1157+function createBaseUpdateState(
1158+ channel: DesktopUpdateChannel,
1159+ enabled: boolean,
1160+): DesktopUpdateState {
1161+ return {
1162+ ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel),
1163+ enabled,
1164+ status: enabled ? "idle" : "disabled",
1165+ };
1166+}
1167+
1168+function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void {
1169+ autoUpdater.channel = channel;
1170+ autoUpdater.allowPrerelease = channel === "nightly";
1171+ autoUpdater.allowDowngrade = channel === "nightly";
1172+ console.info(
1173+ `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`,
1174+ );
1175+}
1176+
7811177 function shouldEnableAutoUpdates(): boolean {
1178+ const hasUpdateFeedConfig =
1179+ readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
7821180 return (
7831181 getAutoUpdateDisabledReason({
7841182 isDevelopment,
@@ -786,6 +1184,7 @@ function shouldEnableAutoUpdates(): boolean { · 1 unmodified line
7861184 platform: process.platform,
7871185 appImage: process.env.APPIMAGE,
7881186 disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",
1187+ hasUpdateFeedConfig,
7891188 }) === null
7901189 );
7911190 }
@@ -869,17 +1268,6 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed · 77 unmodified lines
8691268 }
8701269
8711270 function configureAutoUpdater(): void {
872− const enabled = shouldEnableAutoUpdates();
873− setUpdateState({
874− ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo),
875− enabled,
876− status: enabled ? "idle" : "disabled",
877− });
878− if (!enabled) {
879− return;
880− }
881− updaterConfigured = true;
882−
8831271 const githubToken =
8841272 process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || "";
8851273 if (githubToken) {
@@ -904,12 +1292,16 @@ function configureAutoUpdater(): void { · 18 unmodified lines
9041292 });
9051293 }
9061294
1295+ const enabled = shouldEnableAutoUpdates();
1296+ setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled));
1297+ if (!enabled) {
1298+ return;
1299+ }
1300+ updaterConfigured = true;
1301+
9071302 autoUpdater.autoDownload = false;
9081303 autoUpdater.autoInstallOnAppQuit = false;
909− // Keep alpha branding, but force all installs onto the stable update track.
910− autoUpdater.channel = DESKTOP_UPDATE_CHANNEL;
911− autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE;
912− autoUpdater.allowDowngrade = false;
1304+ applyAutoUpdaterChannel(desktopSettings.updateChannel);
9131305 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
9141306 let lastLoggedDownloadMilestone = -1;
9151307
@@ -923,6 +1315,15 @@ function configureAutoUpdater(): void { · 7 unmodified lines
9231315 console.info("[desktop-updater] Looking for updates...");
9241316 });
9251317 autoUpdater.on("update-available", (info) => {
1318+ if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) {
1319+ console.info(
1320+ `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`,
1321+ );
1322+ setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString()));
1323+ lastLoggedDownloadMilestone = -1;
1324+ return;
1325+ }
1326+
9261327 setUpdateState(
9271328 reduceDesktopUpdateStateOnUpdateAvailable(
9281329 updateState,
@@ -1014,7 +1415,7 @@ function startBackend(): void { · 85 unmodified lines
10141415 return;
10151416 }
10161417
1017− const captureBackendLogs = app.isPackaged && backendLogSink !== null;
1418+ const captureBackendLogs = !isDevelopment;
10181419 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
10191420 cwd: resolveBackendCwd(),
10201421 // In Electron main, process.execPath points to the Electron binary.
@@ -1035,7 +1436,8 @@ function startBackend(): void { · 14 unmodified lines
10351436 noBrowser: true,
10361437 port: backendPort,
10371438 t3Home: BASE_DIR,
1038− authToken: backendAuthToken,
1439+ host: backendBindHost,
1440+ desktopBootstrapToken: backendBootstrapToken,
10391441 ...(backendObservabilitySettings.otlpTracesUrl
10401442 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
10411443 : {}),
@@ -1050,6 +1452,8 @@ function startBackend(): void { · 8 unmodified lines
10501452 scheduleBackendRestart("missing desktop bootstrap pipe");
10511453 return;
10521454 }
1455+ const listeningDetector = new ServerListeningDetector();
1456+ backendListeningDetector = listeningDetector;
10531457 backendProcess = child;
10541458 let backendSessionClosed = false;
10551459 const closeBackendSession = (details: string) => {
@@ -1068,6 +1472,10 @@ function startBackend(): void { · 12 unmodified lines
10681472 });
10691473
10701474 child.on("error", (error) => {
1475+ if (backendListeningDetector === listeningDetector) {
1476+ listeningDetector.fail(error);
1477+ backendListeningDetector = null;
1478+ }
10711479 const wasExpected = expectedBackendExitChildren.has(child);
10721480 if (backendProcess === child) {
10731481 backendProcess = null;
@@ -1080,6 +1488,14 @@ function startBackend(): void { · 6 unmodified lines
10801488 });
10811489
10821490 child.on("exit", (code, signal) => {
1491+ if (backendListeningDetector === listeningDetector) {
1492+ listeningDetector.fail(
1493+ new Error(
1494+ `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`,
1495+ ),
1496+ );
1497+ backendListeningDetector = null;
1498+ }
10831499 const wasExpected = expectedBackendExitChildren.has(child);
10841500 if (backendProcess === child) {
10851501 backendProcess = null;
@@ -1091,9 +1507,13 @@ function startBackend(): void { · 5 unmodified lines
10911507 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
10921508 scheduleBackendRestart(reason);
10931509 });
1510+
1511+ ensureInitialBackendWindowOpen();
10941512 }
10951513
10961514 function stopBackend(): void {
1515+ cancelBackendReadinessWait();
1516+ backendListeningDetector = null;
10971517 if (restartTimer) {
10981518 clearTimeout(restartTimer);
10991519 restartTimer = null;
@@ -1115,6 +1535,7 @@ function stopBackend(): void { · 15 unmodified lines
11151535 }
11161536
11171537 async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
1538+ cancelBackendReadinessWait();
11181539 if (restartTimer) {
11191540 clearTimeout(restartTimer);
11201541 restartTimer = null;
@@ -1167,21 +1588,134 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { · 46 unmodified lines
11671588 }
11681589
11691590 function registerIpcHandlers(): void {
1170− ipcMain.removeAllListeners(GET_WS_URL_CHANNEL);
1171− ipcMain.on(GET_WS_URL_CHANNEL, (event) => {
1172− event.returnValue = backendWsUrl;
1591+ ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL);
1592+ ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => {
1593+ event.returnValue = desktopAppBranding;
1594+ });
1595+
1596+ ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
1597+ ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
1598+ event.returnValue = {
1599+ label: "Local environment",
1600+ httpBaseUrl: backendHttpUrl || null,
1601+ wsBaseUrl: backendWsUrl || null,
1602+ bootstrapToken: backendBootstrapToken || undefined,
1603+ } as const;
1604+ });
1605+
1606+ ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL);
1607+ ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH));
1608+
1609+ ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL);
1610+ ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => {
1611+ if (typeof rawSettings !== "object" || rawSettings === null) {
1612+ throw new Error("Invalid client settings payload.");
1613+ }
1614+
1615+ writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings);
1616+ });
1617+
1618+ ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1619+ ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () =>
1620+ readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH),
1621+ );
1622+
1623+ ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1624+ ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => {
1625+ if (!Array.isArray(rawRecords)) {
1626+ throw new Error("Invalid saved environment registry payload.");
1627+ }
1628+
1629+ writeSavedEnvironmentRegistry(
1630+ SAVED_ENVIRONMENT_REGISTRY_PATH,
1631+ rawRecords as readonly PersistedSavedEnvironmentRecord[],
1632+ );
1633+ });
1634+
1635+ ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1636+ ipcMain.handle(
1637+ GET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1638+ async (_event, rawEnvironmentId: unknown) => {
1639+ if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1640+ return null;
1641+ }
1642+
1643+ return readSavedEnvironmentSecret({
1644+ registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1645+ environmentId: rawEnvironmentId,
1646+ secretStorage: getDesktopSecretStorage(),
1647+ });
1648+ },
1649+ );
1650+
1651+ ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1652+ ipcMain.handle(
1653+ SET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1654+ async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => {
1655+ if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1656+ throw new Error("Invalid saved environment id.");
1657+ }
1658+ if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) {
1659+ throw new Error("Invalid saved environment secret.");
1660+ }
1661+
1662+ return writeSavedEnvironmentSecret({
1663+ registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1664+ environmentId: rawEnvironmentId,
1665+ secret: rawSecret,
1666+ secretStorage: getDesktopSecretStorage(),
1667+ });
1668+ },
1669+ );
1670+
1671+ ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1672+ ipcMain.handle(
1673+ REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1674+ async (_event, rawEnvironmentId: unknown) => {
1675+ if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1676+ return;
1677+ }
1678+
1679+ removeSavedEnvironmentSecret({
1680+ registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1681+ environmentId: rawEnvironmentId,
1682+ });
1683+ },
1684+ );
1685+
1686+ ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL);
1687+ ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState());
1688+
1689+ ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL);
1690+ ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => {
1691+ if (rawMode !== "local-only" && rawMode !== "network-accessible") {
1692+ throw new Error("Invalid desktop server exposure input.");
1693+ }
1694+
1695+ const nextMode = rawMode as DesktopServerExposureMode;
1696+ if (nextMode === desktopServerExposureMode) {
1697+ return getDesktopServerExposureState();
1698+ }
1699+
1700+ const nextState = await applyDesktopServerExposureMode(nextMode, {
1701+ persist: true,
1702+ rejectIfUnavailable: true,
1703+ });
1704+ relaunchDesktopApp(`serverExposureMode=${nextMode}`);
1705+ return nextState;
11731706 });
11741707
11751708 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1176− ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
1709+ ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
11771710 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
1711+ const defaultPath = resolvePickFolderDefaultPath(rawOptions);
1712+ const openDialogOptions: OpenDialogOptions = {
1713+ properties: ["openDirectory", "createDirectory"],
1714+ ...(defaultPath ? { defaultPath } : {}),
1715+ };
11781716 const result = owner
1179− ? await dialog.showOpenDialog(owner, {
1180− properties: ["openDirectory", "createDirectory"],
1181− })
1182− : await dialog.showOpenDialog({
1183− properties: ["openDirectory", "createDirectory"],
1184− });
1717+ ? await dialog.showOpenDialog(owner, openDialogOptions)
1718+ : await dialog.showOpenDialog(openDialogOptions);
11851719 if (result.canceled) return null;
11861720 return result.filePaths[0] ?? null;
11871721 });
@@ -1210,14 +1744,7 @@ function registerIpcHandlers(): void { · 22 unmodified lines
12101744 ipcMain.handle(
12111745 CONTEXT_MENU_CHANNEL,
12121746 async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {
1213− const normalizedItems = items
1214− .filter((item) => typeof item.id === "string" && typeof item.label === "string")
1215− .map((item) => ({
1216− id: item.id,
1217− label: item.label,
1218− destructive: item.destructive === true,
1219− disabled: item.disabled === true,
1220− }));
1747+ const normalizedItems = normalizeContextMenuItems(items);
12211748 if (normalizedItems.length === 0) {
12221749 return null;
12231750 }
@@ -1238,28 +1765,37 @@ function registerIpcHandlers(): void { · 14 unmodified lines
12381765 if (!window) return null;
12391766
12401767 return new Promise<string | null>((resolve) => {
1241− const template: MenuItemConstructorOptions[] = [];
1242− let hasInsertedDestructiveSeparator = false;
1243− for (const item of normalizedItems) {
1244− if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1245− template.push({ type: "separator" });
1246− hasInsertedDestructiveSeparator = true;
1247− }
1248− const itemOption: MenuItemConstructorOptions = {
1249− label: item.label,
1250− enabled: !item.disabled,
1251− click: () => resolve(item.id),
1252− };
1253− if (item.destructive) {
1254− const destructiveIcon = getDestructiveMenuIcon();
1255− if (destructiveIcon) {
1256− itemOption.icon = destructiveIcon;
1768+ const buildTemplate = (
1769+ entries: readonly ContextMenuItem[],
1770+ ): MenuItemConstructorOptions[] => {
1771+ const template: MenuItemConstructorOptions[] = [];
1772+ let hasInsertedDestructiveSeparator = false;
1773+ for (const item of entries) {
1774+ if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1775+ template.push({ type: "separator" });
1776+ hasInsertedDestructiveSeparator = true;
1777+ }
1778+ const itemOption: MenuItemConstructorOptions = {
1779+ label: item.label,
1780+ enabled: !item.disabled,
1781+ };
1782+ if (item.children && item.children.length > 0) {
1783+ itemOption.submenu = buildTemplate(item.children);
1784+ } else {
1785+ itemOption.click = () => resolve(item.id);
1786+ }
1787+ if (item.destructive && (!item.children || item.children.length === 0)) {
1788+ const destructiveIcon = getDestructiveMenuIcon();
1789+ if (destructiveIcon) {
1790+ itemOption.icon = destructiveIcon;
1791+ }
12571792 }
1793+ template.push(itemOption);
12581794 }
1259− template.push(itemOption);
1260− }
1795+ return template;
1796+ };
12611797
1262− const menu = Menu.buildFromTemplate(template);
1798+ const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems));
12631799 menu.popup({
12641800 window,
12651801 ...popupPosition,
@@ -1287,6 +1823,43 @@ function registerIpcHandlers(): void { · 21 unmodified lines
12871823 ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);
12881824 ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);
12891825
1826+ ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL);
1827+ ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => {
1828+ if (rawChannel !== "latest" && rawChannel !== "nightly") {
1829+ throw new Error("Invalid desktop update channel input.");
1830+ }
1831+ if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) {
1832+ throw new Error("Cannot change update tracks while an update action is in progress.");
1833+ }
1834+
1835+ const nextChannel = rawChannel as DesktopUpdateChannel;
1836+
1837+ desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel);
1838+ writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
1839+
1840+ if (nextChannel === updateState.channel) {
1841+ return updateState;
1842+ }
1843+
1844+ const enabled = shouldEnableAutoUpdates();
1845+ setUpdateState(createBaseUpdateState(nextChannel, enabled));
1846+
1847+ if (!enabled || !updaterConfigured) {
1848+ return updateState;
1849+ }
1850+
1851+ applyAutoUpdaterChannel(nextChannel);
1852+ const allowDowngrade = autoUpdater.allowDowngrade;
1853+ // An explicit channel switch should allow the immediate nightly->stable rollback path.
1854+ autoUpdater.allowDowngrade = true;
1855+ try {
1856+ await checkForUpdates("channel-change");
1857+ } finally {
1858+ autoUpdater.allowDowngrade = allowDowngrade;
1859+ }
1860+ return updateState;
1861+ });
1862+
12901863 ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL);
12911864 ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => {
12921865 const result = await downloadAvailableUpdate();
@@ -1337,6 +1910,50 @@ function getIconOption(): { icon: string } | Record<string, never> { · 44 unmodified lines
13371910 return iconPath ? { icon: iconPath } : {};
13381911 }
13391912
1913+function getInitialWindowBackgroundColor(): string {
1914+ return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
1915+}
1916+
1917+function getWindowTitleBarOptions(): WindowTitleBarOptions {
1918+ if (process.platform === "darwin") {
1919+ return {
1920+ titleBarStyle: "hiddenInset",
1921+ trafficLightPosition: { x: 16, y: 18 },
1922+ };
1923+ }
1924+
1925+ return {
1926+ titleBarStyle: "hidden",
1927+ titleBarOverlay: {
1928+ color: TITLEBAR_COLOR,
1929+ height: TITLEBAR_HEIGHT,
1930+ symbolColor: nativeTheme.shouldUseDarkColors
1931+ ? TITLEBAR_DARK_SYMBOL_COLOR
1932+ : TITLEBAR_LIGHT_SYMBOL_COLOR,
1933+ },
1934+ };
1935+}
1936+
1937+function syncWindowAppearance(window: BrowserWindow): void {
1938+ if (window.isDestroyed()) {
1939+ return;
1940+ }
1941+
1942+ window.setBackgroundColor(getInitialWindowBackgroundColor());
1943+ const { titleBarOverlay } = getWindowTitleBarOptions();
1944+ if (typeof titleBarOverlay === "object") {
1945+ window.setTitleBarOverlay(titleBarOverlay);
1946+ }
1947+}
1948+
1949+function syncAllWindowAppearance(): void {
1950+ for (const window of BrowserWindow.getAllWindows()) {
1951+ syncWindowAppearance(window);
1952+ }
1953+}
1954+
1955+nativeTheme.on("updated", syncAllWindowAppearance);
1956+
13401957 function createWindow(): BrowserWindow {
13411958 const window = new BrowserWindow({
13421959 width: 1100,
@@ -1345,12 +1962,12 @@ function createWindow(): BrowserWindow { · 2 unmodified lines
13451962 minHeight: 620,
13461963 show: false,
13471964 autoHideMenuBar: true,
1965+ backgroundColor: getInitialWindowBackgroundColor(),
13481966 ...getIconOption(),
13491967 title: APP_DISPLAY_NAME,
1350− titleBarStyle: "hiddenInset",
1351− trafficLightPosition: { x: 16, y: 18 },
1968+ ...getWindowTitleBarOptions(),
13521969 webPreferences: {
1353− preload: Path.join(__dirname, "preload.js"),
1970+ preload: Path.join(__dirname, "preload.cjs"),
13541971 contextIsolation: true,
13551972 nodeIntegration: false,
13561973 sandbox: true,
@@ -1375,6 +1992,22 @@ function createWindow(): BrowserWindow { · 18 unmodified lines
13751992 menuTemplate.push({ type: "separator" });
13761993 }
13771994
1995+ const externalUrl = getSafeExternalUrl(params.linkURL);
1996+ if (externalUrl) {
1997+ menuTemplate.push(
1998+ { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) },
1999+ { type: "separator" },
2000+ );
2001+ }
2002+
2003+ if (params.mediaType === "image") {
2004+ menuTemplate.push({
2005+ label: "Copy Image",
2006+ click: () => window.webContents.copyImageAt(params.x, params.y),
2007+ });
2008+ menuTemplate.push({ type: "separator" });
2009+ }
2010+
13782011 menuTemplate.push(
13792012 { role: "cut", enabled: params.editFlags.canCut },
13802013 { role: "copy", enabled: params.editFlags.canCopy },
@@ -1401,15 +2034,23 @@ function createWindow(): BrowserWindow { · 20 unmodified lines
14012034 window.setTitle(APP_DISPLAY_NAME);
14022035 emitUpdateState();
14032036 });
1404− window.once("ready-to-show", () => {
1405− window.show();
1406− });
2037+
2038+ let initialRevealScheduled = false;
2039+ const revealInitialWindow = () => {
2040+ if (initialRevealScheduled) {
2041+ return;
2042+ }
2043+ initialRevealScheduled = true;
2044+ revealWindow(window);
2045+ };
2046+
2047+ window.once("ready-to-show", revealInitialWindow);
14072048
14082049 if (isDevelopment) {
1409− void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
2050+ void window.loadURL(resolveDesktopDevServerUrl());
14102051 window.webContents.openDevTools({ mode: "detach" });
14112052 } else {
1412− void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`);
2053+ void window.loadURL(backendHttpUrl);
14132054 }
14142055
14152056 window.on("closed", () => {
@@ -1430,23 +2071,71 @@ configureAppIdentity(); · 14 unmodified lines
14302071
14312072 async function bootstrap(): Promise<void> {
14322073 writeDesktopLogHeader("bootstrap start");
1433− backendPort = await Effect.service(NetService).pipe(
1434− Effect.flatMap((net) => net.reserveLoopbackPort()),
1435− Effect.provide(NetService.layer),
1436− Effect.runPromise,
2074+ const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT);
2075+ if (isDevelopment && configuredBackendPort === undefined) {
2076+ throw new Error("T3CODE_PORT is required in desktop development.");
2077+ }
2078+
2079+ backendPort =
2080+ configuredBackendPort ??
2081+ (await resolveDesktopBackendPort({
2082+ host: DESKTOP_LOOPBACK_HOST,
2083+ startPort: DEFAULT_DESKTOP_BACKEND_PORT,
2084+ requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS,
2085+ }));
2086+ writeDesktopLogHeader(
2087+ configuredBackendPort === undefined
2088+ ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}`
2089+ : `using configured backend port port=${backendPort}`,
2090+ );
2091+ backendBootstrapToken = Crypto.randomBytes(24).toString("hex");
2092+ if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) {
2093+ writeDesktopLogHeader(
2094+ `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`,
2095+ );
2096+ }
2097+ const serverExposureState = await applyDesktopServerExposureMode(
2098+ desktopSettings.serverExposureMode,
2099+ {
2100+ persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
2101+ },
14372102 );
1438− writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);
1439− backendAuthToken = Crypto.randomBytes(24).toString("hex");
1440− const baseUrl = `ws://127.0.0.1:${backendPort}`;
1441− backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;
1442− writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);
2103+ writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`);
2104+ if (serverExposureState.endpointUrl) {
2105+ writeDesktopLogHeader(
2106+ `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`,
2107+ );
2108+ } else if (desktopSettings.serverExposureMode === "network-accessible") {
2109+ writeDesktopLogHeader(
2110+ "bootstrap fell back to local-only because no advertised network host was available",
2111+ );
2112+ }
14432113
14442114 registerIpcHandlers();
14452115 writeDesktopLogHeader("bootstrap ipc handlers registered");
14462116 startBackend();
14472117 writeDesktopLogHeader("bootstrap backend start requested");
1448− mainWindow = createWindow();
1449− writeDesktopLogHeader("bootstrap main window created");
2118+
2119+ if (isDevelopment) {
2120+ mainWindow = createWindow();
2121+ writeDesktopLogHeader("bootstrap main window created");
2122+ void waitForBackendHttpReady(backendHttpUrl)
2123+ .then(() => {
2124+ writeDesktopLogHeader("bootstrap backend ready");
2125+ })
2126+ .catch((error) => {
2127+ if (isBackendReadinessAborted(error)) {
2128+ return;
2129+ }
2130+ writeDesktopLogHeader(
2131+ `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
2132+ );
2133+ console.warn("[desktop] backend readiness check timed out during dev bootstrap", error);
2134+ });
2135+ return;
2136+ }
2137+
2138+ ensureInitialBackendWindowOpen();
14502139 }
14512140
14522141 app.on("before-quit", () => {
@@ -1454,6 +2143,7 @@ app.on("before-quit", () => { · 1 unmodified line
14542143 updateInstallInFlight = false;
14552144 writeDesktopLogHeader("before-quit received");
14562145 clearUpdatePollTimer();
2146+ cancelBackendReadinessWait();
14572147 stopBackend();
14582148 restoreStdIoCapture?.();
14592149 });
@@ -1467,13 +2157,23 @@ app · 7 unmodified lines
14672157 registerDesktopProtocol();
14682158 configureAutoUpdater();
14692159 void bootstrap().catch((error) => {
2160+ if (isBackendReadinessAborted(error) && isQuitting) {
2161+ return;
2162+ }
14702163 handleFatalStartupError("bootstrap", error);
14712164 });
14722165
14732166 app.on("activate", () => {
1474− if (BrowserWindow.getAllWindows().length === 0) {
2167+ const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];
2168+ if (existingWindow) {
2169+ revealWindow(existingWindow);
2170+ return;
2171+ }
2172+ if (isDevelopment) {
14752173 mainWindow = createWindow();
2174+ return;
14762175 }
2176+ ensureInitialBackendWindowOpen();
14772177 });
14782178 })
14792179 .catch((error) => {
@@ -1492,6 +2192,7 @@ if (process.platform !== "win32") { · 12 unmodified lines
14922192 isQuitting = true;
14932193 writeDesktopLogHeader("SIGINT received");
14942194 clearUpdatePollTimer();
2195+ cancelBackendReadinessWait();
14952196 stopBackend();
14962197 restoreStdIoCapture?.();
14972198 app.quit();
apps/desktop/src/preload.ts+40−5
@@ -9,17 +9,51 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; · 8 unmodified lines
99 const MENU_ACTION_CHANNEL = "desktop:menu-action";
1010 const UPDATE_STATE_CHANNEL = "desktop:update-state";
1111 const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12+const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
1213 const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1314 const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1415 const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
15−const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
16+const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
17+const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
18+const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
19+const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
20+const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
21+const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
22+const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
23+const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
24+const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
25+const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
26+const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
1627
1728 contextBridge.exposeInMainWorld("desktopBridge", {
18− getWsUrl: () => {
19− const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
20− return typeof result === "string" ? result : null;
29+ getAppBranding: () => {
30+ const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL);
31+ if (typeof result !== "object" || result === null) {
32+ return null;
33+ }
34+ return result as ReturnType<DesktopBridge["getAppBranding"]>;
2135 },
22− pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
36+ getLocalEnvironmentBootstrap: () => {
37+ const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
38+ if (typeof result !== "object" || result === null) {
39+ return null;
40+ }
41+ return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
42+ },
43+ getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL),
44+ setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings),
45+ getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL),
46+ setSavedEnvironmentRegistry: (records) =>
47+ ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records),
48+ getSavedEnvironmentSecret: (environmentId) =>
49+ ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
50+ setSavedEnvironmentSecret: (environmentId, secret) =>
51+ ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret),
52+ removeSavedEnvironmentSecret: (environmentId) =>
53+ ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
54+ getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
55+ setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
56+ pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
2357 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
2458 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
2559 showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
@@ -36,6 +70,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 10 unmodified lines
3670 };
3771 },
3872 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
73+ setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel),
3974 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
4075 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
4176 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
apps/desktop/src/runtimeArch.test.ts+1−1
@@ -1,6 +1,6 @@
11 import { describe, expect, it } from "vitest";
22
3−import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
3+import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
44
55 describe("resolveDesktopRuntimeInfo", () => {
66 it("detects Rosetta-translated Intel builds on Apple Silicon", () => {
apps/desktop/src/serverExposure.test.ts+139−0
@@ -0,0 +1,139 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts";
4+
5+describe("resolveLanAdvertisedHost", () => {
6+ it("prefers an explicit host override", () => {
7+ expect(
8+ resolveLanAdvertisedHost(
9+ {
10+ en0: [
11+ {
12+ address: "192.168.1.44",
13+ family: "IPv4",
14+ internal: false,
15+ netmask: "255.255.255.0",
16+ cidr: "192.168.1.44/24",
17+ mac: "00:00:00:00:00:00",
18+ },
19+ ],
20+ },
21+ "10.0.0.9",
22+ ),
23+ ).toBe("10.0.0.9");
24+ });
25+
26+ it("returns the first usable non-internal IPv4 address", () => {
27+ expect(
28+ resolveLanAdvertisedHost(
29+ {
30+ lo0: [
31+ {
32+ address: "127.0.0.1",
33+ family: "IPv4",
34+ internal: true,
35+ netmask: "255.0.0.0",
36+ cidr: "127.0.0.1/8",
37+ mac: "00:00:00:00:00:00",
38+ },
39+ ],
40+ en0: [
41+ {
42+ address: "192.168.1.44",
43+ family: "IPv4",
44+ internal: false,
45+ netmask: "255.255.255.0",
46+ cidr: "192.168.1.44/24",
47+ mac: "00:00:00:00:00:00",
48+ },
49+ ],
50+ },
51+ undefined,
52+ ),
53+ ).toBe("192.168.1.44");
54+ });
55+
56+ it("returns null when no usable network address is available", () => {
57+ expect(
58+ resolveLanAdvertisedHost(
59+ {
60+ lo0: [
61+ {
62+ address: "127.0.0.1",
63+ family: "IPv4",
64+ internal: true,
65+ netmask: "255.0.0.0",
66+ cidr: "127.0.0.1/8",
67+ mac: "00:00:00:00:00:00",
68+ },
69+ ],
70+ },
71+ undefined,
72+ ),
73+ ).toBeNull();
74+ });
75+});
76+
77+describe("resolveDesktopServerExposure", () => {
78+ it("keeps the desktop server loopback-only when local-only mode is selected", () => {
79+ expect(
80+ resolveDesktopServerExposure({
81+ mode: "local-only",
82+ port: 3773,
83+ networkInterfaces: {},
84+ }),
85+ ).toEqual({
86+ mode: "local-only",
87+ bindHost: "127.0.0.1",
88+ localHttpUrl: "http://127.0.0.1:3773",
89+ localWsUrl: "ws://127.0.0.1:3773",
90+ endpointUrl: null,
91+ advertisedHost: null,
92+ });
93+ });
94+
95+ it("binds to all interfaces in network-accessible mode", () => {
96+ expect(
97+ resolveDesktopServerExposure({
98+ mode: "network-accessible",
99+ port: 3773,
100+ networkInterfaces: {
101+ en0: [
102+ {
103+ address: "192.168.1.44",
104+ family: "IPv4",
105+ internal: false,
106+ netmask: "255.255.255.0",
107+ cidr: "192.168.1.44/24",
108+ mac: "00:00:00:00:00:00",
109+ },
110+ ],
111+ },
112+ }),
113+ ).toEqual({
114+ mode: "network-accessible",
115+ bindHost: "0.0.0.0",
116+ localHttpUrl: "http://127.0.0.1:3773",
117+ localWsUrl: "ws://127.0.0.1:3773",
118+ endpointUrl: "http://192.168.1.44:3773",
119+ advertisedHost: "192.168.1.44",
120+ });
121+ });
122+
123+ it("stays network-accessible even when no LAN address is currently detectable", () => {
124+ expect(
125+ resolveDesktopServerExposure({
126+ mode: "network-accessible",
127+ port: 3773,
128+ networkInterfaces: {},
129+ }),
130+ ).toEqual({
131+ mode: "network-accessible",
132+ bindHost: "0.0.0.0",
133+ localHttpUrl: "http://127.0.0.1:3773",
134+ localWsUrl: "ws://127.0.0.1:3773",
135+ endpointUrl: null,
136+ advertisedHost: null,
137+ });
138+ });
139+});
apps/desktop/src/serverExposure.ts+80−0
@@ -0,0 +1,80 @@
1+import type { NetworkInterfaceInfo } from "node:os";
2+import type { DesktopServerExposureMode } from "@t3tools/contracts";
3+
4+const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
5+const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
6+
7+export interface DesktopServerExposure {
8+ readonly mode: DesktopServerExposureMode;
9+ readonly bindHost: string;
10+ readonly localHttpUrl: string;
11+ readonly localWsUrl: string;
12+ readonly endpointUrl: string | null;
13+ readonly advertisedHost: string | null;
14+}
15+
16+const normalizeOptionalHost = (value: string | undefined): string | undefined => {
17+ const normalized = value?.trim();
18+ return normalized && normalized.length > 0 ? normalized : undefined;
19+};
20+
21+const isUsableLanIpv4Address = (address: string): boolean =>
22+ !address.startsWith("127.") && !address.startsWith("169.254.");
23+
24+export function resolveLanAdvertisedHost(
25+ networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>,
26+ explicitHost: string | undefined,
27+): string | null {
28+ const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
29+ if (normalizedExplicitHost) {
30+ return normalizedExplicitHost;
31+ }
32+
33+ for (const interfaceAddresses of Object.values(networkInterfaces)) {
34+ if (!interfaceAddresses) continue;
35+
36+ for (const address of interfaceAddresses) {
37+ if (address.internal) continue;
38+ if (address.family !== "IPv4") continue;
39+ if (!isUsableLanIpv4Address(address.address)) continue;
40+ return address.address;
41+ }
42+ }
43+
44+ return null;
45+}
46+
47+export function resolveDesktopServerExposure(input: {
48+ readonly mode: DesktopServerExposureMode;
49+ readonly port: number;
50+ readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
51+ readonly advertisedHostOverride?: string;
52+}): DesktopServerExposure {
53+ const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
54+ const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
55+
56+ if (input.mode === "local-only") {
57+ return {
58+ mode: input.mode,
59+ bindHost: DESKTOP_LOOPBACK_HOST,
60+ localHttpUrl,
61+ localWsUrl,
62+ endpointUrl: null,
63+ advertisedHost: null,
64+ };
65+ }
66+
67+ const advertisedHost = resolveLanAdvertisedHost(
68+ input.networkInterfaces,
69+ input.advertisedHostOverride,
70+ );
71+
72+ return {
73+ mode: input.mode,
74+ bindHost: DESKTOP_LAN_BIND_HOST,
75+ localHttpUrl,
76+ localWsUrl,
77+ endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null,
78+ advertisedHost,
79+ };
80+}
apps/desktop/src/serverListeningDetector.test.ts+31−0
@@ -0,0 +1,31 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { ServerListeningDetector } from "./serverListeningDetector.ts";
4+
5+describe("ServerListeningDetector", () => {
6+ it("resolves when the server logs the listening line", async () => {
7+ const detector = new ServerListeningDetector();
8+
9+ detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n");
10+
11+ await expect(detector.promise).resolves.toBeUndefined();
12+ });
13+
14+ it("resolves when the listening line arrives across multiple chunks", async () => {
15+ const detector = new ServerListeningDetector();
16+
17+ detector.push("[01:23:30.571] INFO (#148): Listen");
18+ detector.push("ing on http://0.0.0.0:7011\n");
19+
20+ await expect(detector.promise).resolves.toBeUndefined();
21+ });
22+
23+ it("rejects when the server exits before logging readiness", async () => {
24+ const detector = new ServerListeningDetector();
25+ const error = new Error("server exited");
26+
27+ detector.fail(error);
28+
29+ await expect(detector.promise).rejects.toBe(error);
30+ });
31+});
apps/desktop/src/serverListeningDetector.ts+56−0
@@ -0,0 +1,56 @@
1+const LISTENING_LOG_FRAGMENT = "Listening on http://";
2+const MAX_BUFFER_CHARS = 8_192;
3+
4+export class ServerListeningDetector {
5+ private buffer = "";
6+ private settled = false;
7+ private readonly resolvePromise: () => void;
8+ private readonly rejectPromise: (error: unknown) => void;
9+ readonly promise: Promise<void>;
10+
11+ constructor() {
12+ let resolvePromise: (() => void) | null = null;
13+ let rejectPromise: ((error: unknown) => void) | null = null;
14+
15+ this.promise = new Promise<void>((resolve, reject) => {
16+ resolvePromise = resolve;
17+ rejectPromise = reject;
18+ });
19+
20+ this.resolvePromise = () => {
21+ if (this.settled) {
22+ return;
23+ }
24+ this.settled = true;
25+ resolvePromise?.();
26+ };
27+ this.rejectPromise = (error) => {
28+ if (this.settled) {
29+ return;
30+ }
31+ this.settled = true;
32+ rejectPromise?.(error);
33+ };
34+ }
35+
36+ push(chunk: unknown): void {
37+ if (this.settled) {
38+ return;
39+ }
40+
41+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
42+ this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`;
43+ if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) {
44+ this.resolvePromise();
45+ return;
46+ }
47+
48+ if (this.buffer.length > MAX_BUFFER_CHARS) {
49+ this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS);
50+ }
51+ }
52+
53+ fail(error: unknown): void {
54+ this.rejectPromise(error);
55+ }
56+}
apps/desktop/src/syncShellEnvironment.test.ts+191−7
@@ -1,16 +1,17 @@
11 import { describe, expect, it, vi } from "vitest";
22
3−import { syncShellEnvironment } from "./syncShellEnvironment";
3+import { syncShellEnvironment } from "./syncShellEnvironment.ts";
44
55 describe("syncShellEnvironment", () => {
66 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
77 const env: NodeJS.ProcessEnv = {
88 SHELL: "/bin/zsh",
9− PATH: "/usr/bin",
9+ PATH: "/Users/test/.local/bin:/usr/bin",
1010 };
1111 const readEnvironment = vi.fn(() => ({
1212 PATH: "/opt/homebrew/bin:/usr/bin",
1313 SSH_AUTH_SOCK: "/tmp/secretive.sock",
14+ HOMEBREW_PREFIX: "/opt/homebrew",
1415 }));
1516
1617 syncShellEnvironment(env, {
@@ -18,9 +19,18 @@ describe("syncShellEnvironment", () => { · 1 unmodified line
1819 readEnvironment,
1920 });
2021
21− expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
22− expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
22+ expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
23+ "PATH",
24+ "SSH_AUTH_SOCK",
25+ "HOMEBREW_PREFIX",
26+ "HOMEBREW_CELLAR",
27+ "HOMEBREW_REPOSITORY",
28+ "XDG_CONFIG_HOME",
29+ "XDG_DATA_HOME",
30+ ]);
31+ expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
2332 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
33+ expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew");
2434 });
2535
2636 it("preserves an inherited SSH_AUTH_SOCK value", () => {
@@ -77,12 +87,68 @@ describe("syncShellEnvironment", () => { · 50 unmodified lines
7787 readEnvironment,
7888 });
7989
80− expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
90+ expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
91+ "PATH",
92+ "SSH_AUTH_SOCK",
93+ "HOMEBREW_PREFIX",
94+ "HOMEBREW_CELLAR",
95+ "HOMEBREW_REPOSITORY",
96+ "XDG_CONFIG_HOME",
97+ "XDG_DATA_HOME",
98+ ]);
8199 expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");
82100 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
83101 });
84102
85− it("does nothing outside macOS and linux", () => {
103+ it("falls back to launchctl PATH on macOS when shell probing does not return one", () => {
104+ const env: NodeJS.ProcessEnv = {
105+ SHELL: "/opt/homebrew/bin/nu",
106+ PATH: "/usr/bin",
107+ };
108+ const readEnvironment = vi
109+ .fn()
110+ .mockImplementationOnce(() => {
111+ throw new Error("unknown flag");
112+ })
113+ .mockImplementationOnce(() => ({}));
114+ const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
115+ const logWarning = vi.fn();
116+
117+ syncShellEnvironment(env, {
118+ platform: "darwin",
119+ readEnvironment,
120+ readLaunchctlPath,
121+ userShell: "/bin/zsh",
122+ logWarning,
123+ });
124+
125+ expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [
126+ "PATH",
127+ "SSH_AUTH_SOCK",
128+ "HOMEBREW_PREFIX",
129+ "HOMEBREW_CELLAR",
130+ "HOMEBREW_REPOSITORY",
131+ "XDG_CONFIG_HOME",
132+ "XDG_DATA_HOME",
133+ ]);
134+ expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [
135+ "PATH",
136+ "SSH_AUTH_SOCK",
137+ "HOMEBREW_PREFIX",
138+ "HOMEBREW_CELLAR",
139+ "HOMEBREW_REPOSITORY",
140+ "XDG_CONFIG_HOME",
141+ "XDG_DATA_HOME",
142+ ]);
143+ expect(readLaunchctlPath).toHaveBeenCalledTimes(1);
144+ expect(logWarning).toHaveBeenCalledWith(
145+ "Failed to read login shell environment from /opt/homebrew/bin/nu.",
146+ expect.any(Error),
147+ );
148+ expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
149+ });
150+
151+ it("does nothing on unsupported platforms", () => {
86152 const env: NodeJS.ProcessEnv = {
87153 SHELL: "C:/Program Files/Git/bin/bash.exe",
88154 PATH: "C:\\Windows\\System32",
@@ -94,7 +160,7 @@ describe("syncShellEnvironment", () => { · 5 unmodified lines
94160 }));
95161
96162 syncShellEnvironment(env, {
97− platform: "win32",
163+ platform: "freebsd",
98164 readEnvironment,
99165 });
100166
@@ -102,4 +168,122 @@ describe("syncShellEnvironment", () => { · 1 unmodified line
102168 expect(env.PATH).toBe("C:\\Windows\\System32");
103169 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
104170 });
171+
172+ it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => {
173+ const env: NodeJS.ProcessEnv = {
174+ PATH: "C:\\Windows\\System32",
175+ APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
176+ LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
177+ USERPROFILE: "C:\\Users\\testuser",
178+ };
179+ const readWindowsEnvironment = vi.fn(() => ({
180+ PATH: "C:\\Custom\\Bin;C:\\Windows\\System32",
181+ }));
182+ const isWindowsCommandAvailable = vi.fn(() => true);
183+
184+ syncShellEnvironment(env, {
185+ platform: "win32",
186+ readWindowsEnvironment,
187+ isWindowsCommandAvailable,
188+ });
189+
190+ expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
191+ expect(env.PATH).toBe(
192+ [
193+ "C:\\Users\\testuser\\AppData\\Roaming\\npm",
194+ "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
195+ "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
196+ "C:\\Users\\testuser\\AppData\\Local\\pnpm",
197+ "C:\\Users\\testuser\\.bun\\bin",
198+ "C:\\Users\\testuser\\scoop\\shims",
199+ "C:\\Custom\\Bin",
200+ "C:\\Windows\\System32",
201+ ].join(";"),
202+ );
203+ expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1);
204+ });
205+
206+ it("loads the PowerShell profile on Windows when node is not available", () => {
207+ const env: NodeJS.ProcessEnv = {
208+ PATH: "C:\\Windows\\System32",
209+ APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
210+ LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
211+ USERPROFILE: "C:\\Users\\testuser",
212+ };
213+ const readWindowsEnvironment = vi.fn(
214+ (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
215+ options?.loadProfile
216+ ? {
217+ PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
218+ FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
219+ FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
220+ }
221+ : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" },
222+ );
223+ const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
224+
225+ syncShellEnvironment(env, {
226+ platform: "win32",
227+ readWindowsEnvironment,
228+ isWindowsCommandAvailable,
229+ });
230+
231+ expect(env.PATH).toBe(
232+ [
233+ "C:\\Profile\\Node",
234+ "C:\\Windows\\System32",
235+ "C:\\Users\\testuser\\AppData\\Roaming\\npm",
236+ "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
237+ "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
238+ "C:\\Users\\testuser\\AppData\\Local\\pnpm",
239+ "C:\\Users\\testuser\\.bun\\bin",
240+ "C:\\Users\\testuser\\scoop\\shims",
241+ "C:\\Custom\\Bin",
242+ ].join(";"),
243+ );
244+ expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm");
245+ expect(env.FNM_MULTISHELL_PATH).toBe(
246+ "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
247+ );
248+ expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
249+ expect(readWindowsEnvironment).toHaveBeenNthCalledWith(
250+ 2,
251+ ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
252+ { loadProfile: true },
253+ );
254+ });
255+
256+ it("preserves baseline Windows env when the profile probe fails", () => {
257+ const env: NodeJS.ProcessEnv = {
258+ PATH: "C:\\Windows\\System32",
259+ APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
260+ USERPROFILE: "C:\\Users\\testuser",
261+ };
262+ const readWindowsEnvironment = vi.fn(
263+ (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => {
264+ if (options?.loadProfile) {
265+ throw new Error("profile load failed");
266+ }
267+ return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" };
268+ },
269+ );
270+ const isWindowsCommandAvailable = vi.fn(() => false);
271+
272+ syncShellEnvironment(env, {
273+ platform: "win32",
274+ readWindowsEnvironment,
275+ isWindowsCommandAvailable,
276+ });
277+
278+ expect(env.PATH).toBe(
279+ [
280+ "C:\\Users\\testuser\\AppData\\Roaming\\npm",
281+ "C:\\Users\\testuser\\.bun\\bin",
282+ "C:\\Users\\testuser\\scoop\\shims",
283+ "C:\\Custom\\Bin",
284+ "C:\\Windows\\System32",
285+ ].join(";"),
286+ );
287+ expect(env.SSH_AUTH_SOCK).toBeUndefined();
288+ });
105289 });
apps/desktop/src/syncShellEnvironment.ts+85−12
@@ -1,36 +1,109 @@
11 import {
2+ listLoginShellCandidates,
3+ mergePathEntries,
4+ readPathFromLaunchctl,
25 readEnvironmentFromLoginShell,
3− resolveLoginShell,
6+ resolveWindowsEnvironment,
7+} from "@t3tools/shared/shell";
8+import type {
9+ CommandAvailabilityOptions,
410 ShellEnvironmentReader,
11+ WindowsShellEnvironmentReader,
512 } from "@t3tools/shared/shell";
613
14+type WindowsCommandAvailabilityChecker = (
15+ command: string,
16+ options?: CommandAvailabilityOptions,
17+) => boolean;
18+
19+const LOGIN_SHELL_ENV_NAMES = [
20+ "PATH",
21+ "SSH_AUTH_SOCK",
22+ "HOMEBREW_PREFIX",
23+ "HOMEBREW_CELLAR",
24+ "HOMEBREW_REPOSITORY",
25+ "XDG_CONFIG_HOME",
26+ "XDG_DATA_HOME",
27+] as const;
28+
29+function logShellEnvironmentWarning(message: string, error?: unknown): void {
30+ console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? ""));
31+}
32+
733 export function syncShellEnvironment(
834 env: NodeJS.ProcessEnv = process.env,
935 options: {
1036 platform?: NodeJS.Platform;
1137 readEnvironment?: ShellEnvironmentReader;
38+ readWindowsEnvironment?: WindowsShellEnvironmentReader;
39+ isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker;
40+ readLaunchctlPath?: typeof readPathFromLaunchctl;
41+ userShell?: string;
42+ logWarning?: (message: string, error?: unknown) => void;
1243 } = {},
1344 ): void {
1445 const platform = options.platform ?? process.platform;
15− if (platform !== "darwin" && platform !== "linux") return;
46+
47+ const logWarning = options.logWarning ?? logShellEnvironmentWarning;
48+ const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;
49+ const shellEnvironment: Partial<Record<string, string>> = {};
1650
1751 try {
18− const shell = resolveLoginShell(platform, env.SHELL);
19− if (!shell) return;
52+ if (platform === "win32") {
53+ const repairedEnvironment = resolveWindowsEnvironment(env, {
54+ ...(options.readWindowsEnvironment
55+ ? { readEnvironment: options.readWindowsEnvironment }
56+ : {}),
57+ ...(options.isWindowsCommandAvailable
58+ ? { commandAvailable: options.isWindowsCommandAvailable }
59+ : {}),
60+ });
61+ for (const [key, value] of Object.entries(repairedEnvironment)) {
62+ if (value !== undefined) {
63+ env[key] = value;
64+ }
65+ }
66+ return;
67+ }
68+
69+ if (platform !== "darwin" && platform !== "linux") return;
2070
21− const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
22− "PATH",
23− "SSH_AUTH_SOCK",
24− ]);
71+ for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
72+ try {
73+ Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES));
74+ if (shellEnvironment.PATH) {
75+ break;
76+ }
77+ } catch (error) {
78+ logWarning(`Failed to read login shell environment from ${shell}.`, error);
79+ }
80+ }
2581
26− if (shellEnvironment.PATH) {
27− env.PATH = shellEnvironment.PATH;
82+ const launchctlPath =
83+ platform === "darwin" && !shellEnvironment.PATH
84+ ? (options.readLaunchctlPath ?? readPathFromLaunchctl)()
85+ : undefined;
86+ const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform);
87+ if (mergedPath) {
88+ env.PATH = mergedPath;
2889 }
2990
3091 if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
3192 env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
3293 }
33− } catch {
34− // Keep inherited environment if shell lookup fails.
94+
95+ for (const name of [
96+ "HOMEBREW_PREFIX",
97+ "HOMEBREW_CELLAR",
98+ "HOMEBREW_REPOSITORY",
99+ "XDG_CONFIG_HOME",
100+ "XDG_DATA_HOME",
101+ ] as const) {
102+ if (!env[name] && shellEnvironment[name]) {
103+ env[name] = shellEnvironment[name];
104+ }
105+ }
106+ } catch (error) {
107+ logWarning("Failed to synchronize the desktop shell environment.", error);
35108 }
36109 }
apps/desktop/src/updateChannels.test.ts+41−0
@@ -0,0 +1,41 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ doesVersionMatchDesktopUpdateChannel,
5+ isNightlyDesktopVersion,
6+ resolveDefaultDesktopUpdateChannel,
7+} from "./updateChannels.ts";
8+
9+describe("isNightlyDesktopVersion", () => {
10+ it("detects packaged nightly versions", () => {
11+ expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true);
12+ });
13+
14+ it("does not flag stable versions as nightly", () => {
15+ expect(isNightlyDesktopVersion("0.0.17")).toBe(false);
16+ });
17+});
18+
19+describe("resolveDefaultDesktopUpdateChannel", () => {
20+ it("defaults stable builds to latest", () => {
21+ expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest");
22+ });
23+
24+ it("defaults nightly builds to nightly", () => {
25+ expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly");
26+ });
27+});
28+
29+describe("doesVersionMatchDesktopUpdateChannel", () => {
30+ it("accepts nightly releases on the nightly channel", () => {
31+ expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true);
32+ });
33+
34+ it("rejects stable releases on the nightly channel", () => {
35+ expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false);
36+ });
37+
38+ it("rejects nightly releases on the stable channel", () => {
39+ expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false);
40+ });
41+});
apps/desktop/src/updateChannels.ts+18−0
@@ -0,0 +1,18 @@
1+import type { DesktopUpdateChannel } from "@t3tools/contracts";
2+
3+const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
4+
5+export function isNightlyDesktopVersion(version: string): boolean {
6+ return NIGHTLY_VERSION_PATTERN.test(version);
7+}
8+
9+export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {
10+ return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest";
11+}
12+
13+export function doesVersionMatchDesktopUpdateChannel(
14+ version: string,
15+ channel: DesktopUpdateChannel,
16+): boolean {
17+ return resolveDefaultDesktopUpdateChannel(version) === channel;
18+}
apps/desktop/src/updateMachine.test.ts+8−7
@@ -11,7 +11,7 @@ import { · 10 unmodified lines
1111 reduceDesktopUpdateStateOnInstallFailure,
1212 reduceDesktopUpdateStateOnNoUpdate,
1313 reduceDesktopUpdateStateOnUpdateAvailable,
14−} from "./updateMachine";
14+} from "./updateMachine.ts";
1515
1616 const runtimeInfo = {
1717 hostArch: "x64",
@@ -23,7 +23,7 @@ describe("updateMachine", () => { · 5 unmodified lines
2323 it("clears transient errors when a check starts", () => {
2424 const state = reduceDesktopUpdateStateOnCheckStart(
2525 {
26− ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
26+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
2727 enabled: true,
2828 status: "error",
2929 message: "network",
@@ -42,7 +42,7 @@ describe("updateMachine", () => { · 12 unmodified lines
4242 it("records a check failure without exposing an action", () => {
4343 const state = reduceDesktopUpdateStateOnCheckFailure(
4444 {
45− ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
45+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
4646 enabled: true,
4747 status: "checking",
4848 },
@@ -58,7 +58,7 @@ describe("updateMachine", () => { · 9 unmodified lines
5858 it("preserves available version on download failure for retry", () => {
5959 const state = reduceDesktopUpdateStateOnDownloadFailure(
6060 {
61− ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
61+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
6262 enabled: true,
6363 status: "downloading",
6464 availableVersion: "1.1.0",
@@ -76,7 +76,7 @@ describe("updateMachine", () => { · 11 unmodified lines
7676 it("transitions to downloaded and then preserves install retry state", () => {
7777 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(
7878 {
79− ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
79+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
8080 enabled: true,
8181 status: "downloading",
8282 availableVersion: "1.1.0",
@@ -98,7 +98,7 @@ describe("updateMachine", () => { · 15 unmodified lines
9898 it("clears stale download state when no update is available", () => {
9999 const state = reduceDesktopUpdateStateOnNoUpdate(
100100 {
101− ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
101+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
102102 enabled: true,
103103 status: "error",
104104 availableVersion: "1.1.0",
@@ -120,7 +120,7 @@ describe("updateMachine", () => { · 15 unmodified lines
120120 it("tracks available, download start, and progress cleanly", () => {
121121 const available = reduceDesktopUpdateStateOnUpdateAvailable(
122122 {
123− ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),
123+ ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
124124 enabled: true,
125125 status: "checking",
126126 },
@@ -131,6 +131,7 @@ describe("updateMachine", () => { · 4 unmodified lines
131131 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);
132132
133133 expect(available.status).toBe("available");
134+ expect(available.channel).toBe("latest");
134135 expect(downloading.status).toBe("downloading");
135136 expect(downloading.downloadPercent).toBe(0);
136137 expect(progress.downloadPercent).toBe(55.5);
apps/desktop/src/updateMachine.ts+8−2
@@ -1,14 +1,20 @@
1−import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts";
1+import type {
2+ DesktopRuntimeInfo,
3+ DesktopUpdateChannel,
4+ DesktopUpdateState,
5+} from "@t3tools/contracts";
26
3−import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";
7+import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts";
48
59 export function createInitialDesktopUpdateState(
610 currentVersion: string,
711 runtimeInfo: DesktopRuntimeInfo,
12+ channel: DesktopUpdateChannel,
813 ): DesktopUpdateState {
914 return {
1015 enabled: false,
1116 status: "disabled",
17+ channel,
1218 currentVersion,
1319 hostArch: runtimeInfo.hostArch,
1420 appArch: runtimeInfo.appArch,
apps/desktop/src/updateState.test.ts+31−1
@@ -6,11 +6,12 @@ import { · 5 unmodified lines
66 getAutoUpdateDisabledReason,
77 nextStatusAfterDownloadFailure,
88 shouldBroadcastDownloadProgress,
9−} from "./updateState";
9+} from "./updateState.ts";
1010
1111 const baseState: DesktopUpdateState = {
1212 enabled: true,
1313 status: "idle",
14+ channel: "latest",
1415 currentVersion: "1.0.0",
1516 hostArch: "x64",
1617 appArch: "x64",
@@ -71,10 +72,37 @@ describe("getAutoUpdateDisabledReason", () => { · 54 unmodified lines
7172 platform: "darwin",
7273 appImage: undefined,
7374 disabledByEnv: false,
75+ hasUpdateFeedConfig: true,
7476 }),
7577 ).toContain("packaged production builds");
7678 });
7779
80+ it("reports packaged local builds without an update feed as disabled", () => {
81+ expect(
82+ getAutoUpdateDisabledReason({
83+ isDevelopment: false,
84+ isPackaged: true,
85+ platform: "darwin",
86+ appImage: undefined,
87+ disabledByEnv: false,
88+ hasUpdateFeedConfig: false,
89+ }),
90+ ).toContain("no update feed");
91+ });
92+
93+ it("allows packaged builds with an update feed", () => {
94+ expect(
95+ getAutoUpdateDisabledReason({
96+ isDevelopment: false,
97+ isPackaged: true,
98+ platform: "darwin",
99+ appImage: undefined,
100+ disabledByEnv: false,
101+ hasUpdateFeedConfig: true,
102+ }),
103+ ).toBeNull();
104+ });
105+
78106 it("reports env-disabled auto updates", () => {
79107 expect(
80108 getAutoUpdateDisabledReason({
@@ -83,6 +111,7 @@ describe("getAutoUpdateDisabledReason", () => { · 2 unmodified lines
83111 platform: "darwin",
84112 appImage: undefined,
85113 disabledByEnv: true,
114+ hasUpdateFeedConfig: true,
86115 }),
87116 ).toContain("T3CODE_DISABLE_AUTO_UPDATE");
88117 });
@@ -95,6 +124,7 @@ describe("getAutoUpdateDisabledReason", () => { · 6 unmodified lines
95124 platform: "linux",
96125 appImage: undefined,
97126 disabledByEnv: false,
127+ hasUpdateFeedConfig: true,
98128 }),
99129 ).toContain("AppImage");
100130 });
apps/desktop/src/updateState.ts+4−0
@@ -34,7 +34,11 @@ export function getAutoUpdateDisabledReason(args: { · 33 unmodified lines
3434 platform: NodeJS.Platform;
3535 appImage?: string | undefined;
3636 disabledByEnv: boolean;
37+ hasUpdateFeedConfig: boolean;
3738 }): string | null {
39+ if (!args.hasUpdateFeedConfig) {
40+ return "Automatic updates are not available because no update feed is configured.";
41+ }
3842 if (args.isDevelopment || !args.isPackaged) {
3943 return "Automatic updates are only available in packaged production builds.";
4044 }
apps/desktop/tsconfig.json+1−1
@@ -3,7 +3,7 @@ · 2 unmodified lines
33 "compilerOptions": {
44 "composite": true,
55 "types": ["node", "electron"],
6− "lib": ["ES2023", "DOM", "esnext.disposable"]
6+ "lib": ["ESNext", "DOM", "esnext.disposable"]
77 },
88 "include": ["src", "tsdown.config.ts"]
99 }
apps/desktop/tsdown.config.ts+1−1
@@ -4,7 +4,7 @@ const shared = { · 3 unmodified lines
44 format: "cjs" as const,
55 outDir: "dist-electron",
66 sourcemap: true,
7− outExtensions: () => ({ js: ".js" }),
7+ outExtensions: () => ({ js: ".cjs" }),
88 };
99
1010 export default defineConfig([
apps/marketing/src/pages/download.astro+5−4
@@ -1,5 +1,6 @@
11 ---
22 import Layout from "../layouts/Layout.astro";
3+import { RELEASES_URL } from "../lib/releases";
34 ---
45
56 <Layout title="Download — T3 Code" description="Download T3 Code for macOS, Windows, or Linux.">
@@ -17,11 +18,11 @@ import Layout from "../layouts/Layout.astro"; · 11 unmodified lines
1718 <h2 class="platform-name">macOS</h2>
1819 </div>
1920 <div class="download-cards">
20− <a class="download-card" data-asset="arm64.dmg" href="#">
21+ <a class="download-card" data-asset="arm64.dmg" href={RELEASES_URL}>
2122 <span class="card-arch">Apple Silicon (arm64)</span>
2223 <span class="card-format">.dmg</span>
2324 </a>
24− <a class="download-card" data-asset="x64.dmg" href="#">
25+ <a class="download-card" data-asset="x64.dmg" href={RELEASES_URL}>
2526 <span class="card-arch">Intel (x64)</span>
2627 <span class="card-format">.dmg</span>
2728 </a>
@@ -35,7 +36,7 @@ import Layout from "../layouts/Layout.astro"; · 7 unmodified lines
3536 <h2 class="platform-name">Windows</h2>
3637 </div>
3738 <div class="download-cards">
38− <a class="download-card" data-asset="x64.exe" href="#">
39+ <a class="download-card" data-asset="x64.exe" href={RELEASES_URL}>
3940 <span class="card-arch">Windows 10, 11</span>
4041 <span class="card-format">.exe</span>
4142 </a>
@@ -49,7 +50,7 @@ import Layout from "../layouts/Layout.astro"; · 7 unmodified lines
4950 <h2 class="platform-name">Linux</h2>
5051 </div>
5152 <div class="download-cards">
52− <a class="download-card" data-asset="x86_64.AppImage" href="#">
53+ <a class="download-card" data-asset="x86_64.AppImage" href={RELEASES_URL}>
5354 <span class="card-arch">x86_64</span>
5455 <span class="card-format">AppImage</span>
5556 </a>
apps/server/integration/OrchestrationEngineHarness.integration.ts+23−4
@@ -26,6 +26,7 @@ import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore · 25 unmodified lines
2626 import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
2727 import { GitCoreLive } from "../src/git/Layers/GitCore.ts";
2828 import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
29+import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts";
2930 import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
3031 import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
3132 import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
@@ -45,10 +46,11 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; · 13 unmodified lines
4546 import { ProviderService } from "../src/provider/Services/ProviderService.ts";
4647 import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
4748 import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
49+import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts";
4850 import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
4951 import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
5052 import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
51−import { RuntimeReceiptBusLive } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
53+import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
5254 import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
5355 import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
5456 import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts";
@@ -297,7 +299,7 @@ export const makeOrchestrationIntegrationHarness = ( · 242 unmodified lines
297299 ProjectionPendingApprovalRepositoryLive,
298300 checkpointStoreLayer,
299301 providerLayer,
300− RuntimeReceiptBusLive,
302+ RuntimeReceiptBusTest,
301303 );
302304 const serverSettingsLayer = ServerSettingsService.layerTest();
303305 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
@@ -320,6 +322,22 @@ export const makeOrchestrationIntegrationHarness = ( · 16 unmodified lines
320322 );
321323 const checkpointReactorLayer = CheckpointReactorLive.pipe(
322324 Layer.provideMerge(runtimeServicesLayer),
325+ Layer.provideMerge(
326+ Layer.succeed(GitStatusBroadcaster, {
327+ getStatus: () => Effect.die("getStatus should not be called in this test"),
328+ refreshLocalStatus: () =>
329+ Effect.succeed({
330+ isRepo: true,
331+ hasOriginRemote: false,
332+ isDefaultBranch: true,
333+ branch: "main",
334+ hasWorkingTreeChanges: false,
335+ workingTree: { files: [], insertions: 0, deletions: 0 },
336+ }),
337+ refreshStatus: () => Effect.die("refreshStatus should not be called in this test"),
338+ streamStatus: () => Stream.empty,
339+ }),
340+ ),
323341 Layer.provideMerge(
324342 WorkspaceEntriesLive.pipe(
325343 Layer.provide(WorkspacePathsLive),
@@ -338,6 +356,7 @@ export const makeOrchestrationIntegrationHarness = ( · 12 unmodified lines
338356 Layer.provideMerge(runtimeServicesLayer),
339357 Layer.provideMerge(orchestrationReactorLayer),
340358 Layer.provide(persistenceLayer),
359+ Layer.provideMerge(RepositoryIdentityResolverLive),
341360 Layer.provideMerge(ServerSettingsService.layerTest()),
342361 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
343362 Layer.provideMerge(NodeServices.layer),
@@ -376,7 +395,7 @@ export const makeOrchestrationIntegrationHarness = ( · 32 unmodified lines
376395 runtime.runPromise(reactor.start().pipe(Scope.provide(scope))),
377396 ).pipe(Effect.orDie);
378397 const receiptHistory = yield* Ref.make<ReadonlyArray<OrchestrationRuntimeReceipt>>([]);
379− yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) =>
398+ yield* Stream.runForEach(runtimeReceiptBus.streamEventsForTest, (receipt) =>
380399 Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid),
381400 ).pipe(Effect.forkIn(scope));
382401 yield* Effect.sleep(10);
@@ -419,7 +438,7 @@ export const makeOrchestrationIntegrationHarness = ( · 36 unmodified lines
419438 ) =>
420439 waitFor(
421440 pendingApprovalRepository
422− .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) })
441+ .getByRequestId({ requestId: ApprovalRequestId.make(requestId) })
423442 .pipe(
424443 Effect.map((row) =>
425444 Option.match(row, {
apps/server/integration/TestProviderAdapter.integration.ts+3−3
@@ -289,7 +289,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter · 288 unmodified lines
289289
290290 state.turnCount += 1;
291291 const turnCount = state.turnCount;
292− const turnId = TurnId.makeUnsafe(`turn-${turnCount}`);
292+ const turnId = TurnId.make(`turn-${turnCount}`);
293293
294294 const response = state.queuedResponses.shift();
295295 if (!response) {
@@ -307,7 +307,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter · 11 unmodified lines
307307 ...(fixtureEvent as Record<string, unknown>),
308308 eventId: randomUUID(),
309309 provider,
310− sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)),
310+ sessionId: RuntimeSessionId.make(String(input.threadId)),
311311 createdAt: nowIso(),
312312 };
313313 rawEvent.threadId = state.snapshot.threadId;
@@ -363,7 +363,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter · 49 unmodified lines
363363 if (deferredTurnCompletedEvents.length === 0) {
364364 yield* emit({
365365 type: "turn.completed",
366− eventId: EventId.makeUnsafe(randomUUID()),
366+ eventId: EventId.make(randomUUID()),
367367 provider,
368368 createdAt: nowIso(),
369369 threadId: state.snapshot.threadId,
apps/server/integration/fixtures/providerRuntime.ts+2−2
@@ -5,14 +5,14 @@ const PROVIDER = "codex" as const; · 4 unmodified lines
55 const SESSION_ID = "fixture-session";
66 const THREAD_ID = "fixture-thread";
77 const TURN_ID = "fixture-turn";
8−const REQUEST_ID = RuntimeRequestId.makeUnsafe("req-1");
8+const REQUEST_ID = RuntimeRequestId.make("req-1");
99
1010 function baseEvent(
1111 eventId: string,
1212 createdAt: string,
1313 ): Pick<LegacyProviderRuntimeEvent, "eventId" | "provider" | "sessionId" | "createdAt"> {
1414 return {
15− eventId: EventId.makeUnsafe(eventId),
15+ eventId: EventId.make(eventId),
1616 provider: PROVIDER,
1717 sessionId: SESSION_ID,
1818 createdAt,
apps/server/integration/orchestrationEngine.integration.test.ts+18−19
@@ -30,14 +30,13 @@ import type { · 29 unmodified lines
3030 } from "../src/orchestration/Services/RuntimeReceiptBus.ts";
3131 import * as NodeServices from "@effect/platform-node/NodeServices";
3232
33−const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
34−const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
35−const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
36−const asApprovalRequestId = (value: string): ApprovalRequestId =>
37− ApprovalRequestId.makeUnsafe(value);
33+const asMessageId = (value: string): MessageId => MessageId.make(value);
34+const asProjectId = (value: string): ProjectId => ProjectId.make(value);
35+const asEventId = (value: string): EventId => EventId.make(value);
36+const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);
3837
3938 const PROJECT_ID = asProjectId("project-1");
40−const THREAD_ID = ThreadId.makeUnsafe("thread-1");
39+const THREAD_ID = ThreadId.make("thread-1");
4140 const FIXTURE_TURN_ID = "fixture-turn";
4241 const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
4342 type IntegrationProvider = ProviderKind;
@@ -112,7 +111,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => · 68 unmodified lines
112111
113112 yield* harness.engine.dispatch({
114113 type: "project.create",
115− commandId: CommandId.makeUnsafe("cmd-project-create"),
114+ commandId: CommandId.make("cmd-project-create"),
116115 projectId: PROJECT_ID,
117116 title: "Integration Project",
118117 workspaceRoot: harness.workspaceDir,
@@ -125,7 +124,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => · 6 unmodified lines
125124
126125 yield* harness.engine.dispatch({
127126 type: "thread.create",
128− commandId: CommandId.makeUnsafe("cmd-thread-create"),
127+ commandId: CommandId.make("cmd-thread-create"),
129128 threadId: THREAD_ID,
130129 projectId: PROJECT_ID,
131130 title: "Integration Thread",
@@ -150,7 +149,7 @@ const startTurn = (input: { · 18 unmodified lines
150149 }) =>
151150 input.harness.engine.dispatch({
152151 type: "thread.turn.start",
153− commandId: CommandId.makeUnsafe(input.commandId),
152+ commandId: CommandId.make(input.commandId),
154153 threadId: THREAD_ID,
155154 message: {
156155 messageId: asMessageId(input.messageId),
@@ -261,7 +260,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 104 unmodified lines
261260
262261 yield* harness.engine.dispatch({
263262 type: "project.create",
264− commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
263+ commandId: CommandId.make("cmd-project-create-real-codex"),
265264 projectId: PROJECT_ID,
266265 title: "Integration Project",
267266 workspaceRoot: harness.workspaceDir,
@@ -274,7 +273,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 6 unmodified lines
274273
275274 yield* harness.engine.dispatch({
276275 type: "thread.create",
277− commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
276+ commandId: CommandId.make("cmd-thread-create-real-codex"),
278277 threadId: THREAD_ID,
279278 projectId: PROJECT_ID,
280279 title: "Integration Thread",
@@ -291,7 +290,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 10 unmodified lines
291290
292291 yield* harness.engine.dispatch({
293292 type: "thread.turn.start",
294− commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
293+ commandId: CommandId.make("cmd-turn-start-real-codex-1"),
295294 threadId: THREAD_ID,
296295 message: {
297296 messageId: asMessageId("msg-real-codex-1"),
@@ -318,7 +317,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 20 unmodified lines
318317
319318 yield* harness.engine.dispatch({
320319 type: "thread.turn.start",
321− commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
320+ commandId: CommandId.make("cmd-turn-start-real-codex-2"),
322321 threadId: THREAD_ID,
323322 message: {
324323 messageId: asMessageId("msg-real-codex-2"),
@@ -583,7 +582,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons · 258 unmodified lines
583582
584583 yield* harness.engine.dispatch({
585584 type: "thread.approval.respond",
586− commandId: CommandId.makeUnsafe("cmd-approval-respond"),
585+ commandId: CommandId.make("cmd-approval-respond"),
587586 threadId: THREAD_ID,
588587 requestId: APPROVAL_REQUEST_ID,
589588 decision: "accept",
@@ -815,7 +814,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git · 225 unmodified lines
815814
816815 yield* harness.engine.dispatch({
817816 type: "thread.checkpoint.revert",
818− commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"),
817+ commandId: CommandId.make("cmd-checkpoint-revert"),
819818 threadId: THREAD_ID,
820819 turnCount: 1,
821820 createdAt: nowIso(),
@@ -875,7 +874,7 @@ it.live( · 53 unmodified lines
875874
876875 yield* harness.engine.dispatch({
877876 type: "thread.checkpoint.revert",
878− commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"),
877+ commandId: CommandId.make("cmd-checkpoint-revert-no-session"),
879878 threadId: THREAD_ID,
880879 turnCount: 0,
881880 createdAt: nowIso(),
@@ -1119,7 +1118,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => · 237 unmodified lines
11191118
11201119 yield* harness.engine.dispatch({
11211120 type: "thread.approval.respond",
1122− commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"),
1121+ commandId: CommandId.make("cmd-claude-approval-respond"),
11231122 threadId: THREAD_ID,
11241123 requestId: APPROVAL_REQUEST_ID,
11251124 decision: "accept",
@@ -1192,7 +1191,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => · 66 unmodified lines
11921191
11931192 yield* harness.engine.dispatch({
11941193 type: "thread.turn.interrupt",
1195− commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"),
1194+ commandId: CommandId.make("cmd-turn-interrupt-claude"),
11961195 threadId: THREAD_ID,
11971196 createdAt: nowIso(),
11981197 });
@@ -1309,7 +1308,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", · 110 unmodified lines
13091308
13101309 yield* harness.engine.dispatch({
13111310 type: "thread.checkpoint.revert",
1312− commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"),
1311+ commandId: CommandId.make("cmd-checkpoint-revert-claude"),
13131312 threadId: THREAD_ID,
13141313 turnCount: 1,
13151314 createdAt: nowIso(),
apps/server/integration/providerService.integration.test.ts+29−41
@@ -86,6 +86,7 @@ const collectEventsDuring = <A, E, R>( · 85 unmodified lines
8686 Effect.forkScoped,
8787 );
8888
89+ yield* Effect.sleep("50 millis");
8990 yield* action;
9091
9192 return yield* Effect.forEach(
@@ -104,7 +105,6 @@ const runTurn = (input: { · 12 unmodified lines
104105 }) =>
105106 Effect.gen(function* () {
106107 yield* input.harness.queueTurnResponse(input.threadId, input.response);
107−
108108 return yield* collectEventsDuring(
109109 input.provider.streamEvents,
110110 input.response.events.length,
@@ -116,21 +116,18 @@ const runTurn = (input: { · 5 unmodified lines
116116 );
117117 });
118118
119−it.effect("replays typed runtime fixture events", () =>
119+it.live("replays typed runtime fixture events", () =>
120120 Effect.gen(function* () {
121121 const fixture = yield* makeIntegrationFixture;
122122
123123 yield* Effect.gen(function* () {
124124 const provider = yield* ProviderService;
125− const session = yield* provider.startSession(
126− ThreadId.makeUnsafe("thread-integration-typed"),
127− {
128− threadId: ThreadId.makeUnsafe("thread-integration-typed"),
129− provider: "codex",
130− cwd: fixture.cwd,
131− runtimeMode: "full-access",
132− },
133− );
125+ const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), {
126+ threadId: ThreadId.make("thread-integration-typed"),
127+ provider: "codex",
128+ cwd: fixture.cwd,
129+ runtimeMode: "full-access",
130+ });
134131 assert.equal((session.threadId ?? "").length > 0, true);
135132
136133 const observedEvents = yield* runTurn({
@@ -149,7 +146,7 @@ it.effect("replays typed runtime fixture events", () => · 12 unmodified lines
149146 }).pipe(Effect.provide(NodeServices.layer)),
150147 );
151148
152−it.effect("replays file-changing fixture turn events", () =>
149+it.live("replays file-changing fixture turn events", () =>
153150 Effect.gen(function* () {
154151 const fixture = yield* makeIntegrationFixture;
155152 const { join } = yield* Path.Path;
@@ -157,15 +154,12 @@ it.effect("replays file-changing fixture turn events", () => · 1 unmodified line
157154
158155 yield* Effect.gen(function* () {
159156 const provider = yield* ProviderService;
160− const session = yield* provider.startSession(
161− ThreadId.makeUnsafe("thread-integration-tools"),
162− {
163− threadId: ThreadId.makeUnsafe("thread-integration-tools"),
164− provider: "codex",
165− cwd: fixture.cwd,
166− runtimeMode: "full-access",
167− },
168− );
157+ const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), {
158+ threadId: ThreadId.make("thread-integration-tools"),
159+ provider: "codex",
160+ cwd: fixture.cwd,
161+ runtimeMode: "full-access",
162+ });
169163 assert.equal((session.threadId ?? "").length > 0, true);
170164
171165 const observedEvents = yield* runTurn({
@@ -188,7 +182,7 @@ it.effect("replays file-changing fixture turn events", () => · 16 unmodified lines
188182 }).pipe(Effect.provide(NodeServices.layer)),
189183 );
190184
191−it.effect("runs multi-turn tool/approval flow", () =>
185+it.live("runs multi-turn tool/approval flow", () =>
192186 Effect.gen(function* () {
193187 const fixture = yield* makeIntegrationFixture;
194188 const { join } = yield* Path.Path;
@@ -196,15 +190,12 @@ it.effect("runs multi-turn tool/approval flow", () => · 1 unmodified line
196190
197191 yield* Effect.gen(function* () {
198192 const provider = yield* ProviderService;
199− const session = yield* provider.startSession(
200− ThreadId.makeUnsafe("thread-integration-multi"),
201− {
202− threadId: ThreadId.makeUnsafe("thread-integration-multi"),
203− provider: "codex",
204− cwd: fixture.cwd,
205− runtimeMode: "full-access",
206− },
207− );
193+ const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), {
194+ threadId: ThreadId.make("thread-integration-multi"),
195+ provider: "codex",
196+ cwd: fixture.cwd,
197+ runtimeMode: "full-access",
198+ });
208199 assert.equal((session.threadId ?? "").length > 0, true);
209200
210201 const firstTurnEvents = yield* runTurn({
@@ -242,7 +233,7 @@ it.effect("runs multi-turn tool/approval flow", () => · 31 unmodified lines
242233 }).pipe(Effect.provide(NodeServices.layer)),
243234 );
244235
245−it.effect("rolls back provider conversation state only", () =>
236+it.live("rolls back provider conversation state only", () =>
246237 Effect.gen(function* () {
247238 const fixture = yield* makeIntegrationFixture;
248239 const { join } = yield* Path.Path;
@@ -250,15 +241,12 @@ it.effect("rolls back provider conversation state only", () => · 1 unmodified line
250241
251242 yield* Effect.gen(function* () {
252243 const provider = yield* ProviderService;
253− const session = yield* provider.startSession(
254− ThreadId.makeUnsafe("thread-integration-rollback"),
255− {
256− threadId: ThreadId.makeUnsafe("thread-integration-rollback"),
257− provider: "codex",
258− cwd: fixture.cwd,
259− runtimeMode: "full-access",
260− },
261− );
244+ const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), {
245+ threadId: ThreadId.make("thread-integration-rollback"),
246+ provider: "codex",
247+ cwd: fixture.cwd,
248+ runtimeMode: "full-access",
249+ });
262250 assert.equal((session.threadId ?? "").length > 0, true);
263251
264252 yield* runTurn({
apps/server/package.json+6−4
@@ -1,6 +1,6 @@
11 {
22 "name": "t3",
3− "version": "0.0.15",
3+ "version": "0.0.20",
44 "license": "MIT",
55 "repository": {
66 "type": "git",
@@ -15,15 +15,17 @@ · 8 unmodified lines
1515 ],
1616 "type": "module",
1717 "scripts": {
18− "dev": "bun run src/bin.ts",
18+ "dev": "node --watch src/bin.ts",
1919 "build": "node scripts/cli.ts build",
20+ "build:bundle": "tsdown",
2021 "start": "node dist/bin.mjs",
2122 "prepare": "effect-language-service patch",
2223 "typecheck": "tsc --noEmit",
23− "test": "vitest run"
24+ "test": "vitest run",
25+ "test:process-reaper": "vitest run src/server.test.ts src/provider/Layers/ClaudeAdapter.test.ts src/provider/Layers/ProviderSessionDirectory.test.ts src/provider/Layers/ProviderSessionReaper.test.ts src/provider/Layers/CodexAdapter.test.ts"
2426 },
2527 "dependencies": {
26− "@anthropic-ai/claude-agent-sdk": "^0.2.77",
28+ "@anthropic-ai/claude-agent-sdk": "^0.2.111",
2729 "@effect/platform-bun": "catalog:",
2830 "@effect/platform-node": "catalog:",
2931 "@effect/sql-sqlite-bun": "catalog:",
apps/server/scripts/cli.ts+33−16
@@ -14,6 +14,22 @@ import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog · 13 unmodified lines
1414 import rootPackageJson from "../../../package.json" with { type: "json" };
1515 import serverPackageJson from "../package.json" with { type: "json" };
1616
17+interface PackageJson {
18+ name: string;
19+ repository: {
20+ type: string;
21+ url: string;
22+ directory: string;
23+ };
24+ bin: Record<string, string>;
25+ type: string;
26+ version: string;
27+ engines: Record<string, string>;
28+ files: string[];
29+ dependencies: Record<string, string>;
30+ overrides: Record<string, string>;
31+}
32+
1733 class CliError extends Data.TaggedError("CliError")<{
1834 readonly message: string;
1935 readonly cause?: unknown;
@@ -131,13 +147,13 @@ const buildCmd = Command.make( · 111 unmodified lines
131147
132148 yield* Effect.log("[cli] Running tsdown...");
133149 yield* runCommand(
134− ChildProcess.make({
150+ ChildProcess.make(process.execPath, ["--run", "build:bundle"], {
135151 cwd: serverDir,
136152 stdout: config.verbose ? "inherit" : "ignore",
137153 stderr: "inherit",
138− // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
154+ // Windows needs shell mode to resolve `.cmd` shims on PATH.
139155 shell: process.platform === "win32",
140− })`bun tsdown`,
156+ }),
141157 );
142158
143159 const webDist = path.join(repoRoot, "apps/web/dist");
@@ -177,7 +193,7 @@ const publishCmd = Command.make( · 33 unmodified lines
177193 const backupPath = `${packageJsonPath}.bak`;
178194
179195 // Assert build assets exist
180− for (const relPath of ["dist/index.mjs", "dist/client/index.html"]) {
196+ for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
181197 const abs = path.join(serverDir, relPath);
182198 if (!(yield* fs.exists(abs))) {
183199 return yield* new CliError({
@@ -187,12 +203,10 @@ const publishCmd = Command.make( · 3 unmodified lines
187203 }
188204
189205 yield* Effect.acquireUseRelease(
190− // Acquire: backup package.json, resolve catalog: deps, strip devDependencies/scripts
206+ // Acquire: backup package.json, resolve catalog dependencies, and strip devDependencies/scripts
191207 Effect.gen(function* () {
192− // Resolve catalog dependencies before any file mutations. If this throws,
193− // acquire fails and no release hook runs, so filesystem must still be untouched.
194208 const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version);
195− const pkg = {
209+ const pkg: PackageJson = {
196210 name: serverPackageJson.name,
197211 repository: serverPackageJson.repository,
198212 bin: serverPackageJson.bin,
@@ -200,19 +214,22 @@ const publishCmd = Command.make( · 1 unmodified line
200214 version,
201215 engines: serverPackageJson.engines,
202216 files: serverPackageJson.files,
203− dependencies: serverPackageJson.dependencies as Record<string, unknown>,
217+ dependencies: resolveCatalogDependencies(
218+ serverPackageJson.dependencies,
219+ rootPackageJson.workspaces.catalog,
220+ "apps/server",
221+ ),
222+ overrides: resolveCatalogDependencies(
223+ rootPackageJson.overrides,
224+ rootPackageJson.workspaces.catalog,
225+ "apps/server",
226+ ),
204227 };
205228
206− pkg.dependencies = resolveCatalogDependencies(
207− pkg.dependencies,
208− rootPackageJson.workspaces.catalog,
209− "apps/server dependencies",
210− );
211−
212229 const original = yield* fs.readFileString(packageJsonPath);
213230 yield* fs.writeFileString(backupPath, original);
214231 yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
215− yield* Effect.log("[cli] Resolved package.json for publish");
232+ yield* Effect.log("[cli] Prepared package.json for publish");
216233
217234 const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir);
218235 return { iconBackups };
apps/server/src/auth/Layers/AuthControlPlane.test.ts+111−0
@@ -0,0 +1,111 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { expect, it } from "@effect/vitest";
3+import { Effect, Layer } from "effect";
4+
5+import type { ServerConfigShape } from "../../config.ts";
6+import { ServerConfig } from "../../config.ts";
7+import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
8+import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
9+import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
10+import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
11+import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
12+import { makeAuthControlPlane } from "./AuthControlPlane.ts";
13+import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
14+
15+const makeServerConfigLayer = (
16+ overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
17+) =>
18+ Layer.effect(
19+ ServerConfig,
20+ Effect.gen(function* () {
21+ const config = yield* ServerConfig;
22+ return {
23+ ...config,
24+ ...overrides,
25+ } satisfies ServerConfigShape;
26+ }),
27+ ).pipe(
28+ Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })),
29+ );
30+
31+const makeAuthControlPlaneLayer = (
32+ overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
33+) =>
34+ Layer.effect(AuthControlPlane, makeAuthControlPlane).pipe(
35+ Layer.provideMerge(BootstrapCredentialServiceLive),
36+ Layer.provideMerge(SessionCredentialServiceLive),
37+ Layer.provideMerge(ServerSecretStoreLive),
38+ Layer.provideMerge(SqlitePersistenceMemory),
39+ Layer.provide(makeServerConfigLayer(overrides)),
40+ );
41+
42+it.layer(NodeServices.layer)("AuthControlPlane", (it) => {
43+ it.effect("creates, lists, and revokes client pairing links", () =>
44+ Effect.gen(function* () {
45+ const authControlPlane = yield* AuthControlPlane;
46+
47+ const created = yield* authControlPlane.createPairingLink({
48+ role: "client",
49+ subject: "one-time-token",
50+ label: "CI phone",
51+ });
52+ const listedBeforeRevoke = yield* authControlPlane.listPairingLinks({ role: "client" });
53+ const revoked = yield* authControlPlane.revokePairingLink(created.id);
54+ const listedAfterRevoke = yield* authControlPlane.listPairingLinks({ role: "client" });
55+
56+ expect(created.role).toBe("client");
57+ expect(created.credential.length).toBeGreaterThan(0);
58+ expect(listedBeforeRevoke).toHaveLength(1);
59+ expect(listedBeforeRevoke[0]?.id).toBe(created.id);
60+ expect(listedBeforeRevoke[0]?.label).toBe("CI phone");
61+ expect(listedBeforeRevoke[0]?.credential).toBe(created.credential);
62+ expect(revoked).toBe(true);
63+ expect(listedAfterRevoke).toHaveLength(0);
64+ }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
65+ );
66+
67+ it.effect("issues bearer sessions and lists them without exposing raw tokens", () =>
68+ Effect.gen(function* () {
69+ const authControlPlane = yield* AuthControlPlane;
70+ const sessionCredentials = yield* SessionCredentialService;
71+
72+ const issued = yield* authControlPlane.issueSession({
73+ label: "deploy-bot",
74+ });
75+ const verified = yield* sessionCredentials.verify(issued.token);
76+ const listedBeforeRevoke = yield* authControlPlane.listSessions();
77+ const revoked = yield* authControlPlane.revokeSession(issued.sessionId);
78+ const listedAfterRevoke = yield* authControlPlane.listSessions();
79+
80+ expect(issued.method).toBe("bearer-session-token");
81+ expect(issued.role).toBe("owner");
82+ expect(issued.client.deviceType).toBe("bot");
83+ expect(issued.client.label).toBe("deploy-bot");
84+ expect(verified.sessionId).toBe(issued.sessionId);
85+ expect(verified.role).toBe("owner");
86+ expect(verified.method).toBe("bearer-session-token");
87+ expect(listedBeforeRevoke).toHaveLength(1);
88+ expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);
89+ expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false);
90+ expect(revoked).toBe(true);
91+ expect(listedAfterRevoke).toHaveLength(0);
92+ }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
93+ );
94+
95+ it.effect("surfaces lastConnectedAt through the listed session view", () =>
96+ Effect.gen(function* () {
97+ const authControlPlane = yield* AuthControlPlane;
98+ const sessionCredentials = yield* SessionCredentialService;
99+
100+ const issued = yield* authControlPlane.issueSession({
101+ label: "remote-ipad",
102+ });
103+ const beforeConnect = yield* authControlPlane.listSessions();
104+ yield* sessionCredentials.markConnected(issued.sessionId);
105+ const afterConnect = yield* authControlPlane.listSessions();
106+
107+ expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
108+ expect(afterConnect[0]?.lastConnectedAt).not.toBeNull();
109+ }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
110+ );
111+});
apps/server/src/auth/Layers/AuthControlPlane.ts+178−0
@@ -0,0 +1,178 @@
1+import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
2+import { DateTime, Effect, Layer } from "effect";
3+
4+import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
5+import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
6+import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
7+import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
8+import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
9+import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts";
10+import {
11+ AuthControlPlane,
12+ AuthControlPlaneError,
13+ DEFAULT_SESSION_SUBJECT,
14+} from "../Services/AuthControlPlane.ts";
15+import type {
16+ AuthControlPlaneShape,
17+ IssuedBearerSession,
18+ IssuedPairingLink,
19+} from "../Services/AuthControlPlane.ts";
20+
21+const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
22+ if (left.role !== right.role) {
23+ return left.role === "owner" ? -1 : 1;
24+ }
25+ if (left.connected !== right.connected) {
26+ return left.connected ? -1 : 1;
27+ }
28+ return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds;
29+};
30+
31+const toAuthControlPlaneError =
32+ (message: string) =>
33+ (cause: unknown): AuthControlPlaneError =>
34+ new AuthControlPlaneError({
35+ message,
36+ cause,
37+ });
38+
39+export const makeAuthControlPlane = Effect.gen(function* () {
40+ const bootstrapCredentials = yield* BootstrapCredentialService;
41+ const sessions = yield* SessionCredentialService;
42+
43+ const createPairingLink: AuthControlPlaneShape["createPairingLink"] = (input) =>
44+ Effect.gen(function* () {
45+ const createdAt = yield* DateTime.now;
46+ const issued = yield* bootstrapCredentials.issueOneTimeToken({
47+ role: input?.role ?? "client",
48+ subject: input?.subject ?? "one-time-token",
49+ ...(input?.ttl ? { ttl: input.ttl } : {}),
50+ ...(input?.label ? { label: input.label } : {}),
51+ });
52+ return {
53+ id: issued.id,
54+ credential: issued.credential,
55+ role: input?.role ?? "client",
56+ subject: input?.subject ?? "one-time-token",
57+ ...(issued.label ? { label: issued.label } : {}),
58+ createdAt: DateTime.toUtc(createdAt),
59+ expiresAt: DateTime.toUtc(issued.expiresAt),
60+ } satisfies IssuedPairingLink;
61+ }).pipe(Effect.mapError(toAuthControlPlaneError("Failed to create pairing link.")));
62+
63+ const listPairingLinks: AuthControlPlaneShape["listPairingLinks"] = (input) =>
64+ bootstrapCredentials.listActive().pipe(
65+ Effect.map((pairingLinks) =>
66+ pairingLinks
67+ .filter((pairingLink) => (input?.role ? pairingLink.role === input.role : true))
68+ .filter((pairingLink) => !input?.excludeSubjects?.includes(pairingLink.subject))
69+ .map((pairingLink) =>
70+ pairingLink.label
71+ ? ({
72+ id: pairingLink.id,
73+ credential: pairingLink.credential,
74+ role: pairingLink.role,
75+ subject: pairingLink.subject,
76+ label: pairingLink.label,
77+ createdAt: pairingLink.createdAt,
78+ expiresAt: pairingLink.expiresAt,
79+ } satisfies AuthPairingLink)
80+ : ({
81+ id: pairingLink.id,
82+ credential: pairingLink.credential,
83+ role: pairingLink.role,
84+ subject: pairingLink.subject,
85+ createdAt: pairingLink.createdAt,
86+ expiresAt: pairingLink.expiresAt,
87+ } satisfies AuthPairingLink),
88+ )
89+ .toSorted(
90+ (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
91+ ),
92+ ),
93+ Effect.mapError(toAuthControlPlaneError("Failed to list pairing links.")),
94+ );
95+
96+ const revokePairingLink: AuthControlPlaneShape["revokePairingLink"] = (id) =>
97+ bootstrapCredentials
98+ .revoke(id)
99+ .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke pairing link.")));
100+
101+ const issueSession: AuthControlPlaneShape["issueSession"] = (input) =>
102+ sessions
103+ .issue({
104+ subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
105+ method: "bearer-session-token",
106+ role: input?.role ?? "owner",
107+ client: {
108+ ...(input?.label ? { label: input.label } : {}),
109+ deviceType: "bot",
110+ },
111+ ...(input?.ttl ? { ttl: input.ttl } : {}),
112+ })
113+ .pipe(
114+ Effect.flatMap((issued) => {
115+ if (issued.method !== "bearer-session-token") {
116+ return Effect.fail(
117+ new AuthControlPlaneError({
118+ message: "CLI session issuance produced an unexpected session method.",
119+ }),
120+ );
121+ }
122+
123+ return Effect.succeed({
124+ sessionId: issued.sessionId,
125+ token: issued.token,
126+ method: "bearer-session-token" as const,
127+ role: issued.role,
128+ subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
129+ client: issued.client,
130+ expiresAt: DateTime.toUtc(issued.expiresAt),
131+ } satisfies IssuedBearerSession);
132+ }),
133+ Effect.mapError(toAuthControlPlaneError("Failed to issue session token.")),
134+ );
135+
136+ const listSessions: AuthControlPlaneShape["listSessions"] = () =>
137+ sessions.listActive().pipe(
138+ Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)),
139+ Effect.mapError(toAuthControlPlaneError("Failed to list sessions.")),
140+ );
141+
142+ const revokeSession: AuthControlPlaneShape["revokeSession"] = (sessionId) =>
143+ sessions
144+ .revoke(sessionId)
145+ .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session.")));
146+
147+ const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = (
148+ sessionId,
149+ ) =>
150+ sessions
151+ .revokeAllExcept(sessionId)
152+ .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions.")));
153+
154+ return {
155+ createPairingLink,
156+ listPairingLinks,
157+ revokePairingLink,
158+ issueSession,
159+ listSessions,
160+ revokeSession,
161+ revokeOtherSessionsExcept,
162+ } satisfies AuthControlPlaneShape;
163+});
164+
165+export const AuthCoreLive = Layer.mergeAll(
166+ BootstrapCredentialServiceLive,
167+ SessionCredentialServiceLive,
168+);
169+
170+export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive);
171+
172+export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive));
173+
174+export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane);
175+
176+export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe(
177+ Layer.provideMerge(AuthRuntimeLive),
178+);
apps/server/src/auth/Layers/BootstrapCredentialService.test.ts+151−0
@@ -0,0 +1,151 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { expect, it } from "@effect/vitest";
3+import { Duration, Effect, Layer } from "effect";
4+import { TestClock } from "effect/testing";
5+
6+import type { ServerConfigShape } from "../../config.ts";
7+import { ServerConfig } from "../../config.ts";
8+import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
9+import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
10+import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
11+
12+const makeServerConfigLayer = (
13+ overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
14+) =>
15+ Layer.effect(
16+ ServerConfig,
17+ Effect.gen(function* () {
18+ const config = yield* ServerConfig;
19+ return {
20+ ...config,
21+ ...overrides,
22+ } satisfies ServerConfigShape;
23+ }),
24+ ).pipe(
25+ Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })),
26+ );
27+
28+const makeBootstrapCredentialLayer = (
29+ overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
30+) =>
31+ BootstrapCredentialServiceLive.pipe(
32+ Layer.provide(SqlitePersistenceMemory),
33+ Layer.provide(makeServerConfigLayer(overrides)),
34+ );
35+
36+it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => {
37+ it.effect("issues pairing tokens in a short manual-entry format", () =>
38+ Effect.gen(function* () {
39+ const bootstrapCredentials = yield* BootstrapCredentialService;
40+ const issued = yield* bootstrapCredentials.issueOneTimeToken();
41+
42+ expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/);
43+ }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
44+ );
45+
46+ it.effect("issues one-time bootstrap tokens that can only be consumed once", () =>
47+ Effect.gen(function* () {
48+ const bootstrapCredentials = yield* BootstrapCredentialService;
49+ const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" });
50+ const first = yield* bootstrapCredentials.consume(issued.credential);
51+ const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));
52+
53+ expect(first.method).toBe("one-time-token");
54+ expect(first.role).toBe("client");
55+ expect(first.subject).toBe("one-time-token");
56+ expect(first.label).toBe("Julius iPhone");
57+ expect(issued.label).toBe("Julius iPhone");
58+ expect(second._tag).toBe("BootstrapCredentialError");
59+ expect(second.message).toContain("Unknown bootstrap credential");
60+ }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
61+ );
62+
63+ it.effect("atomically consumes a one-time token when multiple requests race", () =>
64+ Effect.gen(function* () {
65+ const bootstrapCredentials = yield* BootstrapCredentialService;
66+ const token = yield* bootstrapCredentials.issueOneTimeToken();
67+ const results = yield* Effect.all(
68+ Array.from({ length: 8 }, () =>
69+ Effect.result(bootstrapCredentials.consume(token.credential)),
70+ ),
71+ {
72+ concurrency: "unbounded",
73+ },
74+ );
75+
76+ const successes = results.filter((result) => result._tag === "Success");
77+ const failures = results.filter((result) => result._tag === "Failure");
78+
79+ expect(successes).toHaveLength(1);
80+ expect(failures).toHaveLength(7);
81+ for (const failure of failures) {
82+ expect(failure.failure._tag).toBe("BootstrapCredentialError");
83+ expect(failure.failure.message).toContain("Unknown bootstrap credential");
84+ }
85+ }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
86+ );
87+
88+ it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>
89+ Effect.gen(function* () {
90+ const bootstrapCredentials = yield* BootstrapCredentialService;
91+ const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
92+ const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
93+
94+ expect(first.method).toBe("desktop-bootstrap");
95+ expect(first.role).toBe("owner");
96+ expect(first.subject).toBe("desktop-bootstrap");
97+ expect(second._tag).toBe("BootstrapCredentialError");
98+ expect(second.status).toBe(401);
99+ }).pipe(
100+ Effect.provide(
101+ makeBootstrapCredentialLayer({
102+ desktopBootstrapToken: "desktop-bootstrap-token",
103+ }),
104+ ),
105+ ),
106+ );
107+
108+ it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () =>
109+ Effect.gen(function* () {
110+ const bootstrapCredentials = yield* BootstrapCredentialService;
111+
112+ yield* TestClock.adjust(Duration.minutes(6));
113+ const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
114+
115+ expect(expired._tag).toBe("BootstrapCredentialError");
116+ expect(expired.status).toBe(401);
117+ expect(expired.message).toContain("Bootstrap credential expired");
118+ }).pipe(
119+ Effect.provide(
120+ Layer.merge(
121+ makeBootstrapCredentialLayer({
122+ desktopBootstrapToken: "desktop-bootstrap-token",
123+ }),
124+ TestClock.layer(),
125+ ),
126+ ),
127+ ),
128+ );
129+
130+ it.effect("lists and revokes active pairing links", () =>
131+ Effect.gen(function* () {
132+ const bootstrapCredentials = yield* BootstrapCredentialService;
133+ const first = yield* bootstrapCredentials.issueOneTimeToken();
134+ const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" });
135+
136+ const activeBeforeRevoke = yield* bootstrapCredentials.listActive();
137+ expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id);
138+ expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id);
139+
140+ const revoked = yield* bootstrapCredentials.revoke(first.id);
141+ const activeAfterRevoke = yield* bootstrapCredentials.listActive();
142+ const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential));
143+
144+ expect(revoked).toBe(true);
145+ expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id);
146+ expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id);
147+ expect(revokedConsume.message).toContain("no longer available");
148+ expect(revokedConsume.status).toBe(401);
149+ }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
150+ );
151+});
apps/server/src/auth/Layers/BootstrapCredentialService.ts+296−0
@@ -0,0 +1,296 @@
1+import type { AuthPairingLink } from "@t3tools/contracts";
2+import { DateTime, Duration, Effect, Layer, PubSub, Ref, Stream } from "effect";
3+import { Option } from "effect";
4+
5+import { ServerConfig } from "../../config.ts";
6+import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts";
7+import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts";
8+import {
9+ BootstrapCredentialError,
10+ BootstrapCredentialService,
11+ type BootstrapCredentialChange,
12+ type BootstrapCredentialServiceShape,
13+ type BootstrapGrant,
14+ type IssuedBootstrapCredential,
15+} from "../Services/BootstrapCredentialService.ts";
16+
17+interface StoredBootstrapGrant extends BootstrapGrant {
18+ readonly remainingUses: number | "unbounded";
19+}
20+
21+type ConsumeResult =
22+ | {
23+ readonly _tag: "error";
24+ readonly reason: "not-found" | "expired";
25+ readonly error: BootstrapCredentialError;
26+ }
27+ | {
28+ readonly _tag: "success";
29+ readonly grant: BootstrapGrant;
30+ };
31+
32+const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
33+const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
34+const PAIRING_TOKEN_LENGTH = 12;
35+
36+const generatePairingToken = (): string => {
37+ const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH));
38+
39+ return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join("");
40+};
41+
42+export const makeBootstrapCredentialService = Effect.gen(function* () {
43+ const config = yield* ServerConfig;
44+ const pairingLinks = yield* AuthPairingLinkRepository;
45+ const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
46+ const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
47+
48+ const invalidBootstrapCredentialError = (message: string) =>
49+ new BootstrapCredentialError({
50+ message,
51+ status: 401,
52+ });
53+
54+ const internalBootstrapCredentialError = (message: string, cause: unknown) =>
55+ new BootstrapCredentialError({
56+ message,
57+ status: 500,
58+ cause,
59+ });
60+
61+ const seedGrant = (credential: string, grant: StoredBootstrapGrant) =>
62+ Ref.update(seededGrantsRef, (current) => {
63+ const next = new Map(current);
64+ next.set(credential, grant);
65+ return next;
66+ });
67+
68+ const emitUpsert = (pairingLink: AuthPairingLink) =>
69+ PubSub.publish(changesPubSub, {
70+ type: "pairingLinkUpserted",
71+ pairingLink,
72+ }).pipe(Effect.asVoid);
73+
74+ const emitRemoved = (id: string) =>
75+ PubSub.publish(changesPubSub, {
76+ type: "pairingLinkRemoved",
77+ id,
78+ }).pipe(Effect.asVoid);
79+
80+ if (config.desktopBootstrapToken) {
81+ const now = yield* DateTime.now;
82+ yield* seedGrant(config.desktopBootstrapToken, {
83+ method: "desktop-bootstrap",
84+ role: "owner",
85+ subject: "desktop-bootstrap",
86+ expiresAt: DateTime.add(now, {
87+ milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES),
88+ }),
89+ remainingUses: 1,
90+ });
91+ }
92+
93+ const toBootstrapCredentialError = (message: string) => (cause: unknown) =>
94+ internalBootstrapCredentialError(message, cause);
95+
96+ const listActive: BootstrapCredentialServiceShape["listActive"] = () =>
97+ Effect.gen(function* () {
98+ const now = yield* DateTime.now;
99+ const rows = yield* pairingLinks.listActive({ now });
100+
101+ return rows.map((row) =>
102+ row.label
103+ ? ({
104+ id: row.id,
105+ credential: row.credential,
106+ role: row.role,
107+ subject: row.subject,
108+ label: row.label,
109+ createdAt: row.createdAt,
110+ expiresAt: row.expiresAt,
111+ } satisfies AuthPairingLink)
112+ : ({
113+ id: row.id,
114+ credential: row.credential,
115+ role: row.role,
116+ subject: row.subject,
117+ createdAt: row.createdAt,
118+ expiresAt: row.expiresAt,
119+ } satisfies AuthPairingLink),
120+ );
121+ }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")));
122+
123+ const revoke: BootstrapCredentialServiceShape["revoke"] = (id) =>
124+ Effect.gen(function* () {
125+ const revokedAt = yield* DateTime.now;
126+ const revoked = yield* pairingLinks.revoke({
127+ id,
128+ revokedAt,
129+ });
130+ if (revoked) {
131+ yield* emitRemoved(id);
132+ }
133+ return revoked;
134+ }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")));
135+
136+ const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) =>
137+ Effect.gen(function* () {
138+ const id = crypto.randomUUID();
139+ const credential = generatePairingToken();
140+ const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
141+ const now = yield* DateTime.now;
142+ const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
143+ const issued: IssuedBootstrapCredential = {
144+ id,
145+ credential,
146+ ...(input?.label ? { label: input.label } : {}),
147+ expiresAt,
148+ };
149+ yield* pairingLinks.create({
150+ id,
151+ credential,
152+ method: "one-time-token",
153+ role: input?.role ?? "client",
154+ subject: input?.subject ?? "one-time-token",
155+ label: input?.label ?? null,
156+ createdAt: now,
157+ expiresAt: expiresAt,
158+ });
159+ yield* emitUpsert({
160+ id,
161+ credential,
162+ role: input?.role ?? "client",
163+ subject: input?.subject ?? "one-time-token",
164+ ...(input?.label ? { label: input.label } : {}),
165+ createdAt: now,
166+ expiresAt,
167+ });
168+ return issued;
169+ }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")));
170+
171+ const consume: BootstrapCredentialServiceShape["consume"] = (credential) =>
172+ Effect.gen(function* () {
173+ const now = yield* DateTime.now;
174+ const seededResult: ConsumeResult = yield* Ref.modify(
175+ seededGrantsRef,
176+ (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
177+ const grant = current.get(credential);
178+ if (!grant) {
179+ return [
180+ {
181+ _tag: "error",
182+ reason: "not-found",
183+ error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
184+ },
185+ current,
186+ ];
187+ }
188+
189+ const next = new Map(current);
190+ if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
191+ next.delete(credential);
192+ return [
193+ {
194+ _tag: "error",
195+ reason: "expired",
196+ error: invalidBootstrapCredentialError("Bootstrap credential expired."),
197+ },
198+ next,
199+ ];
200+ }
201+
202+ const remainingUses = grant.remainingUses;
203+ if (typeof remainingUses === "number") {
204+ if (remainingUses <= 1) {
205+ next.delete(credential);
206+ } else {
207+ next.set(credential, {
208+ ...grant,
209+ remainingUses: remainingUses - 1,
210+ });
211+ }
212+ }
213+
214+ return [
215+ {
216+ _tag: "success",
217+ grant: {
218+ method: grant.method,
219+ role: grant.role,
220+ subject: grant.subject,
221+ ...(grant.label ? { label: grant.label } : {}),
222+ expiresAt: grant.expiresAt,
223+ } satisfies BootstrapGrant,
224+ },
225+ next,
226+ ];
227+ },
228+ );
229+
230+ if (seededResult._tag === "success") {
231+ return seededResult.grant;
232+ }
233+ if (seededResult.reason !== "not-found") {
234+ return yield* seededResult.error;
235+ }
236+
237+ const consumed = yield* pairingLinks.consumeAvailable({
238+ credential,
239+ consumedAt: now,
240+ now,
241+ });
242+
243+ if (Option.isSome(consumed)) {
244+ yield* emitRemoved(consumed.value.id);
245+ return {
246+ method: consumed.value.method,
247+ role: consumed.value.role,
248+ subject: consumed.value.subject,
249+ ...(consumed.value.label ? { label: consumed.value.label } : {}),
250+ expiresAt: consumed.value.expiresAt,
251+ } satisfies BootstrapGrant;
252+ }
253+
254+ const matching = yield* pairingLinks.getByCredential({ credential });
255+ if (Option.isNone(matching)) {
256+ return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
257+ }
258+
259+ if (matching.value.revokedAt !== null) {
260+ return yield* invalidBootstrapCredentialError(
261+ "Bootstrap credential is no longer available.",
262+ );
263+ }
264+
265+ if (matching.value.consumedAt !== null) {
266+ return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
267+ }
268+
269+ if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
270+ return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
271+ }
272+
273+ return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
274+ }).pipe(
275+ Effect.mapError((cause) =>
276+ cause instanceof BootstrapCredentialError
277+ ? cause
278+ : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause),
279+ ),
280+ );
281+
282+ return {
283+ issueOneTimeToken,
284+ listActive,
285+ get streamChanges() {
286+ return Stream.fromPubSub(changesPubSub);
287+ },
288+ revoke,
289+ consume,
290+ } satisfies BootstrapCredentialServiceShape;
291+});
292+
293+export const BootstrapCredentialServiceLive = Layer.effect(
294+ BootstrapCredentialService,
295+ makeBootstrapCredentialService,
296+).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive));
apps/server/src/auth/Layers/ServerAuth.test.ts+182−0
@@ -0,0 +1,182 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { expect, it } from "@effect/vitest";
3+import { Effect, Layer } from "effect";
4+
5+import type { ServerConfigShape } from "../../config.ts";
6+import { ServerConfig } from "../../config.ts";
7+import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
8+import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
9+import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts";
10+import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts";
11+import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
12+
13+const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) =>
14+ Layer.effect(
15+ ServerConfig,
16+ Effect.gen(function* () {
17+ const config = yield* ServerConfig;
18+ return {
19+ ...config,
20+ ...overrides,
21+ } satisfies ServerConfigShape;
22+ }),
23+ ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
24+
25+const makeServerAuthLayer = (overrides?: Partial<ServerConfigShape>) =>
26+ ServerAuthLive.pipe(
27+ Layer.provide(SqlitePersistenceMemory),
28+ Layer.provide(ServerSecretStoreLive),
29+ Layer.provide(makeServerConfigLayer(overrides)),
30+ );
31+
32+const makeCookieRequest = (
33+ sessionToken: string,
34+): Parameters<ServerAuthShape["authenticateHttpRequest"]>[0] =>
35+ ({
36+ cookies: {
37+ t3_session: sessionToken,
38+ },
39+ headers: {},
40+ }) as unknown as Parameters<ServerAuthShape["authenticateHttpRequest"]>[0];
41+
42+const requestMetadata = {
43+ deviceType: "desktop" as const,
44+ os: "macOS",
45+ browser: "Chrome",
46+ ipAddress: "192.168.1.23",
47+};
48+
49+it.layer(NodeServices.layer)("ServerAuthLive", (it) => {
50+ it.effect("maps invalid bootstrap credential failures to 401", () =>
51+ Effect.sync(() => {
52+ const error = toBootstrapExchangeAuthError(
53+ new BootstrapCredentialError({
54+ message: "Unknown bootstrap credential.",
55+ status: 401,
56+ }),
57+ );
58+
59+ expect(error.status).toBe(401);
60+ expect(error.message).toBe("Invalid bootstrap credential.");
61+ }),
62+ );
63+
64+ it.effect("maps unexpected bootstrap failures to 500", () =>
65+ Effect.sync(() => {
66+ const error = toBootstrapExchangeAuthError(
67+ new BootstrapCredentialError({
68+ message: "Failed to consume bootstrap credential.",
69+ status: 500,
70+ cause: new Error("sqlite is unavailable"),
71+ }),
72+ );
73+
74+ expect(error.status).toBe(500);
75+ expect(error.message).toBe("Failed to validate bootstrap credential.");
76+ }),
77+ );
78+
79+ it.effect("issues client pairing credentials by default", () =>
80+ Effect.gen(function* () {
81+ const serverAuth = yield* ServerAuth;
82+
83+ const pairingCredential = yield* serverAuth.issuePairingCredential();
84+ const exchanged = yield* serverAuth.exchangeBootstrapCredential(
85+ pairingCredential.credential,
86+ requestMetadata,
87+ );
88+ const verified = yield* serverAuth.authenticateHttpRequest(
89+ makeCookieRequest(exchanged.sessionToken),
90+ );
91+
92+ expect(verified.sessionId.length).toBeGreaterThan(0);
93+ expect(verified.role).toBe("client");
94+ expect(verified.subject).toBe("one-time-token");
95+ }).pipe(Effect.provide(makeServerAuthLayer())),
96+ );
97+
98+ it.effect("issues startup pairing URLs that bootstrap owner sessions", () =>
99+ Effect.gen(function* () {
100+ const serverAuth = yield* ServerAuth;
101+
102+ const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773");
103+ const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token");
104+ const listedPairingLinks = yield* serverAuth.listPairingLinks();
105+ expect(token).toBeTruthy();
106+ expect(
107+ listedPairingLinks.some((pairingLink) => pairingLink.subject === "owner-bootstrap"),
108+ ).toBe(false);
109+
110+ const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? "", requestMetadata);
111+ const verified = yield* serverAuth.authenticateHttpRequest(
112+ makeCookieRequest(exchanged.sessionToken),
113+ );
114+
115+ expect(verified.role).toBe("owner");
116+ expect(verified.subject).toBe("owner-bootstrap");
117+ }).pipe(Effect.provide(makeServerAuthLayer())),
118+ );
119+
120+ it.effect("lists pairing links and revokes other client sessions while keeping the owner", () =>
121+ Effect.gen(function* () {
122+ const serverAuth = yield* ServerAuth;
123+
124+ const ownerExchange = yield* serverAuth.exchangeBootstrapCredential(
125+ "desktop-bootstrap-token",
126+ requestMetadata,
127+ );
128+ const ownerSession = yield* serverAuth.authenticateHttpRequest(
129+ makeCookieRequest(ownerExchange.sessionToken),
130+ );
131+ const pairingCredential = yield* serverAuth.issuePairingCredential({
132+ label: "Julius iPhone",
133+ });
134+ const listedPairingLinks = yield* serverAuth.listPairingLinks();
135+ const clientExchange = yield* serverAuth.exchangeBootstrapCredential(
136+ pairingCredential.credential,
137+ {
138+ ...requestMetadata,
139+ deviceType: "mobile",
140+ os: "iOS",
141+ browser: "Safari",
142+ ipAddress: "192.168.1.88",
143+ },
144+ );
145+ const clientSession = yield* serverAuth.authenticateHttpRequest(
146+ makeCookieRequest(clientExchange.sessionToken),
147+ );
148+ const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId);
149+ const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId);
150+ const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId);
151+
152+ expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id);
153+ expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe(
154+ "Julius iPhone",
155+ );
156+ expect(clientsBeforeRevoke).toHaveLength(2);
157+ expect(
158+ clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current,
159+ ).toBe(true);
160+ expect(
161+ clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current,
162+ ).toBe(false);
163+ expect(
164+ clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
165+ .label,
166+ ).toBe("Julius iPhone");
167+ expect(
168+ clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
169+ .deviceType,
170+ ).toBe("mobile");
171+ expect(revokedCount).toBe(1);
172+ expect(clientsAfterRevoke).toHaveLength(1);
173+ expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId);
174+ }).pipe(
175+ Effect.provide(
176+ makeServerAuthLayer({
177+ desktopBootstrapToken: "desktop-bootstrap-token",
178+ }),
179+ ),
180+ ),
181+ );
182+});
apps/server/src/auth/Layers/ServerAuth.ts+395−0
@@ -0,0 +1,395 @@
1+import {
2+ type AuthBearerBootstrapResult,
3+ type AuthClientSession,
4+ type AuthBootstrapResult,
5+ type AuthPairingCredentialResult,
6+ type AuthSessionState,
7+ type AuthWebSocketTokenResult,
8+} from "@t3tools/contracts";
9+import { DateTime, Effect, Layer, Option } from "effect";
10+import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
11+
12+import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
13+import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
14+import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
15+import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
16+import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
17+import {
18+ ServerAuth,
19+ type AuthenticatedSession,
20+ AuthError,
21+ type ServerAuthShape,
22+} from "../Services/ServerAuth.ts";
23+import {
24+ SessionCredentialError,
25+ SessionCredentialService,
26+} from "../Services/SessionCredentialService.ts";
27+import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts";
28+
29+type BootstrapExchangeResult = {
30+ readonly response: AuthBootstrapResult;
31+ readonly sessionToken: string;
32+};
33+
34+const AUTHORIZATION_PREFIX = "Bearer ";
35+const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken";
36+
37+export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError {
38+ if (cause.status === 500) {
39+ return new AuthError({
40+ message: "Failed to validate bootstrap credential.",
41+ status: 500,
42+ cause,
43+ });
44+ }
45+
46+ return new AuthError({
47+ message: "Invalid bootstrap credential.",
48+ status: 401,
49+ cause,
50+ });
51+}
52+
53+function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
54+ const header = request.headers["authorization"];
55+ if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) {
56+ return null;
57+ }
58+ const token = header.slice(AUTHORIZATION_PREFIX.length).trim();
59+ return token.length > 0 ? token : null;
60+}
61+
62+export const makeServerAuth = Effect.gen(function* () {
63+ const policy = yield* ServerAuthPolicy;
64+ const bootstrapCredentials = yield* BootstrapCredentialService;
65+ const authControlPlane = yield* AuthControlPlane;
66+ const sessions = yield* SessionCredentialService;
67+ const descriptor = yield* policy.getDescriptor();
68+
69+ const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> =>
70+ sessions.verify(token).pipe(
71+ Effect.tapError((cause: SessionCredentialError) =>
72+ Effect.logWarning("Rejected authenticated session credential.").pipe(
73+ Effect.annotateLogs({
74+ reason: cause.message,
75+ }),
76+ ),
77+ ),
78+ Effect.map((session) => ({
79+ sessionId: session.sessionId,
80+ subject: session.subject,
81+ method: session.method,
82+ role: session.role,
83+ ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
84+ })),
85+ Effect.mapError(
86+ (cause) =>
87+ new AuthError({
88+ message: "Unauthorized request.",
89+ status: 401,
90+ cause,
91+ }),
92+ ),
93+ );
94+
95+ const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
96+ const cookieToken = request.cookies[sessions.cookieName];
97+ const bearerToken = parseBearerToken(request);
98+ const credential = cookieToken ?? bearerToken;
99+ if (!credential) {
100+ return Effect.fail(
101+ new AuthError({
102+ message: "Authentication required.",
103+ status: 401,
104+ }),
105+ );
106+ }
107+ return authenticateToken(credential);
108+ };
109+
110+ const getSessionState: ServerAuthShape["getSessionState"] = (request) =>
111+ authenticateRequest(request).pipe(
112+ Effect.map(
113+ (session) =>
114+ ({
115+ authenticated: true,
116+ auth: descriptor,
117+ role: session.role,
118+ sessionMethod: session.method,
119+ ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
120+ }) satisfies AuthSessionState,
121+ ),
122+ Effect.catchTag("AuthError", () =>
123+ Effect.succeed({
124+ authenticated: false,
125+ auth: descriptor,
126+ } satisfies AuthSessionState),
127+ ),
128+ );
129+
130+ const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = (
131+ credential,
132+ requestMetadata,
133+ ) =>
134+ bootstrapCredentials.consume(credential).pipe(
135+ Effect.mapError(toBootstrapExchangeAuthError),
136+ Effect.flatMap((grant) =>
137+ sessions
138+ .issue({
139+ method: "browser-session-cookie",
140+ subject: grant.subject,
141+ role: grant.role,
142+ client: {
143+ ...requestMetadata,
144+ ...(grant.label ? { label: grant.label } : {}),
145+ },
146+ })
147+ .pipe(
148+ Effect.mapError(
149+ (cause) =>
150+ new AuthError({
151+ message: "Failed to issue authenticated session.",
152+ cause,
153+ }),
154+ ),
155+ ),
156+ ),
157+ Effect.map(
158+ (session) =>
159+ ({
160+ response: {
161+ authenticated: true,
162+ role: session.role,
163+ sessionMethod: session.method,
164+ expiresAt: DateTime.toUtc(session.expiresAt),
165+ } satisfies AuthBootstrapResult,
166+ sessionToken: session.token,
167+ }) satisfies BootstrapExchangeResult,
168+ ),
169+ );
170+
171+ const exchangeBootstrapCredentialForBearerSession: ServerAuthShape["exchangeBootstrapCredentialForBearerSession"] =
172+ (credential, requestMetadata) =>
173+ bootstrapCredentials.consume(credential).pipe(
174+ Effect.mapError(toBootstrapExchangeAuthError),
175+ Effect.flatMap((grant) =>
176+ sessions
177+ .issue({
178+ method: "bearer-session-token",
179+ subject: grant.subject,
180+ role: grant.role,
181+ client: {
182+ ...requestMetadata,
183+ ...(grant.label ? { label: grant.label } : {}),
184+ },
185+ })
186+ .pipe(
187+ Effect.mapError(
188+ (cause) =>
189+ new AuthError({
190+ message: "Failed to issue authenticated session.",
191+ cause,
192+ }),
193+ ),
194+ ),
195+ ),
196+ Effect.map(
197+ (session) =>
198+ ({
199+ authenticated: true,
200+ role: session.role,
201+ sessionMethod: "bearer-session-token",
202+ expiresAt: DateTime.toUtc(session.expiresAt),
203+ sessionToken: session.token,
204+ }) satisfies AuthBearerBootstrapResult,
205+ ),
206+ );
207+
208+ const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) =>
209+ authControlPlane
210+ .createPairingLink({
211+ role: input?.role ?? "client",
212+ subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token",
213+ ...(input?.label ? { label: input.label } : {}),
214+ })
215+ .pipe(
216+ Effect.mapError(
217+ (cause) =>
218+ new AuthError({
219+ message: "Failed to issue pairing credential.",
220+ cause,
221+ }),
222+ ),
223+ Effect.map(
224+ (issued) =>
225+ ({
226+ id: issued.id,
227+ credential: issued.credential,
228+ ...(issued.label ? { label: issued.label } : {}),
229+ expiresAt: issued.expiresAt,
230+ }) satisfies AuthPairingCredentialResult,
231+ ),
232+ );
233+
234+ const listPairingLinks: ServerAuthShape["listPairingLinks"] = () =>
235+ authControlPlane
236+ .listPairingLinks({
237+ role: "client",
238+ excludeSubjects: ["owner-bootstrap"],
239+ })
240+ .pipe(
241+ Effect.mapError(
242+ (cause) =>
243+ new AuthError({
244+ message: "Failed to load pairing links.",
245+ cause,
246+ }),
247+ ),
248+ );
249+
250+ const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) =>
251+ authControlPlane.revokePairingLink(id).pipe(
252+ Effect.mapError(
253+ (cause) =>
254+ new AuthError({
255+ message: "Failed to revoke pairing link.",
256+ cause,
257+ }),
258+ ),
259+ );
260+
261+ const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) =>
262+ authControlPlane.listSessions().pipe(
263+ Effect.mapError(
264+ (cause) =>
265+ new AuthError({
266+ message: "Failed to load paired clients.",
267+ cause,
268+ }),
269+ ),
270+ Effect.map((clientSessions) =>
271+ clientSessions.map(
272+ (clientSession): AuthClientSession => ({
273+ ...clientSession,
274+ current: clientSession.sessionId === currentSessionId,
275+ }),
276+ ),
277+ ),
278+ );
279+
280+ const revokeClientSession: ServerAuthShape["revokeClientSession"] = (
281+ currentSessionId,
282+ targetSessionId,
283+ ) =>
284+ Effect.gen(function* () {
285+ if (currentSessionId === targetSessionId) {
286+ return yield* new AuthError({
287+ message: "Use revoke other clients to keep the current owner session active.",
288+ status: 403,
289+ });
290+ }
291+ return yield* authControlPlane.revokeSession(targetSessionId).pipe(
292+ Effect.mapError(
293+ (cause) =>
294+ new AuthError({
295+ message: "Failed to revoke client session.",
296+ cause,
297+ }),
298+ ),
299+ );
300+ });
301+
302+ const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = (
303+ currentSessionId,
304+ ) =>
305+ authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe(
306+ Effect.mapError(
307+ (cause) =>
308+ new AuthError({
309+ message: "Failed to revoke other client sessions.",
310+ cause,
311+ }),
312+ ),
313+ );
314+
315+ const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) =>
316+ issuePairingCredential({ role: "owner" }).pipe(
317+ Effect.map((issued) => {
318+ const url = new URL(baseUrl);
319+ url.pathname = "/pair";
320+ url.searchParams.delete("token");
321+ url.hash = new URLSearchParams([["token", issued.credential]]).toString();
322+ return url.toString();
323+ }),
324+ );
325+
326+ const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) =>
327+ sessions.issueWebSocketToken(session.sessionId).pipe(
328+ Effect.mapError(
329+ (cause) =>
330+ new AuthError({
331+ message: "Failed to issue websocket token.",
332+ cause,
333+ }),
334+ ),
335+ Effect.map(
336+ (issued) =>
337+ ({
338+ token: issued.token,
339+ expiresAt: DateTime.toUtc(issued.expiresAt),
340+ }) satisfies AuthWebSocketTokenResult,
341+ ),
342+ );
343+
344+ const authenticateWebSocketUpgrade: ServerAuthShape["authenticateWebSocketUpgrade"] = (request) =>
345+ Effect.gen(function* () {
346+ const requestUrl = HttpServerRequest.toURL(request);
347+ if (Option.isSome(requestUrl)) {
348+ const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM);
349+ if (websocketToken && websocketToken.trim().length > 0) {
350+ return yield* sessions.verifyWebSocketToken(websocketToken).pipe(
351+ Effect.map((session) => ({
352+ sessionId: session.sessionId,
353+ subject: session.subject,
354+ method: session.method,
355+ role: session.role,
356+ ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
357+ })),
358+ Effect.mapError(
359+ (cause) =>
360+ new AuthError({
361+ message: "Unauthorized request.",
362+ status: 401,
363+ cause,
364+ }),
365+ ),
366+ );
367+ }
368+ }
369+
370+ return yield* authenticateRequest(request);
371+ });
372+
373+ return {
374+ getDescriptor: () => Effect.succeed(descriptor),
375+ getSessionState,
376+ exchangeBootstrapCredential,
377+ exchangeBootstrapCredentialForBearerSession,
378+ issuePairingCredential,
379+ listPairingLinks,
380+ revokePairingLink,
381+ listClientSessions,
382+ revokeClientSession,
383+ revokeOtherClientSessions,
384+ authenticateHttpRequest: authenticateRequest,
385+ authenticateWebSocketUpgrade,
386+ issueWebSocketToken,
387+ issueStartupPairingUrl,
388+ } satisfies ServerAuthShape;
389+});
390+
391+export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe(
392+ Layer.provideMerge(AuthControlPlaneLive),
393+ Layer.provideMerge(AuthCoreLive),
394+ Layer.provideMerge(ServerAuthPolicyLive),
395+);
apps/server/src/auth/Layers/ServerAuthPolicy.test.ts+114−0
@@ -0,0 +1,114 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { expect, it } from "@effect/vitest";
3+import { Effect, Layer } from "effect";
4+
5+import type { ServerConfigShape } from "../../config.ts";
6+import { ServerConfig } from "../../config.ts";
7+import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
8+import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
9+
10+const makeServerAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) =>
11+ ServerAuthPolicyLive.pipe(
12+ Layer.provide(
13+ Layer.effect(
14+ ServerConfig,
15+ Effect.gen(function* () {
16+ const config = yield* ServerConfig;
17+ return {
18+ ...config,
19+ ...overrides,
20+ } satisfies ServerConfigShape;
21+ }),
22+ ).pipe(
23+ Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })),
24+ ),
25+ ),
26+ );
27+
28+it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {
29+ it.effect("uses desktop-managed-local policy for desktop mode", () =>
30+ Effect.gen(function* () {
31+ const policy = yield* ServerAuthPolicy;
32+ const descriptor = yield* policy.getDescriptor();
33+
34+ expect(descriptor.policy).toBe("desktop-managed-local");
35+ expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
36+ expect(descriptor.sessionCookieName).toBe("t3_session_3773");
37+ }).pipe(
38+ Effect.provide(
39+ makeServerAuthPolicyLayer({
40+ mode: "desktop",
41+ port: 3773,
42+ }),
43+ ),
44+ ),
45+ );
46+
47+ it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
48+ Effect.gen(function* () {
49+ const policy = yield* ServerAuthPolicy;
50+ const descriptor = yield* policy.getDescriptor();
51+
52+ expect(descriptor.policy).toBe("remote-reachable");
53+ expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]);
54+ }).pipe(
55+ Effect.provide(
56+ makeServerAuthPolicyLayer({
57+ mode: "desktop",
58+ host: "0.0.0.0",
59+ }),
60+ ),
61+ ),
62+ );
63+
64+ it.effect("uses loopback-browser policy for loopback web hosts", () =>
65+ Effect.gen(function* () {
66+ const policy = yield* ServerAuthPolicy;
67+ const descriptor = yield* policy.getDescriptor();
68+
69+ expect(descriptor.policy).toBe("loopback-browser");
70+ expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
71+ expect(descriptor.sessionCookieName).toBe("t3_session");
72+ }).pipe(
73+ Effect.provide(
74+ makeServerAuthPolicyLayer({
75+ mode: "web",
76+ host: "127.0.0.1",
77+ }),
78+ ),
79+ ),
80+ );
81+
82+ it.effect("uses remote-reachable policy for wildcard web hosts", () =>
83+ Effect.gen(function* () {
84+ const policy = yield* ServerAuthPolicy;
85+ const descriptor = yield* policy.getDescriptor();
86+
87+ expect(descriptor.policy).toBe("remote-reachable");
88+ expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
89+ }).pipe(
90+ Effect.provide(
91+ makeServerAuthPolicyLayer({
92+ mode: "web",
93+ host: "0.0.0.0",
94+ }),
95+ ),
96+ ),
97+ );
98+
99+ it.effect("uses remote-reachable policy for non-loopback web hosts", () =>
100+ Effect.gen(function* () {
101+ const policy = yield* ServerAuthPolicy;
102+ const descriptor = yield* policy.getDescriptor();
103+
104+ expect(descriptor.policy).toBe("remote-reachable");
105+ }).pipe(
106+ Effect.provide(
107+ makeServerAuthPolicyLayer({
108+ mode: "web",
109+ host: "192.168.1.50",
110+ }),
111+ ),
112+ ),
113+ );
114+});
apps/server/src/auth/Layers/ServerAuthPolicy.ts+44−0
@@ -0,0 +1,44 @@
1+import type { ServerAuthDescriptor } from "@t3tools/contracts";
2+import { Effect, Layer } from "effect";
3+
4+import { ServerConfig } from "../../config.ts";
5+import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
6+import { resolveSessionCookieName } from "../utils.ts";
7+import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";
8+
9+export const makeServerAuthPolicy = Effect.gen(function* () {
10+ const config = yield* ServerConfig;
11+ const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host);
12+
13+ const policy =
14+ config.mode === "desktop"
15+ ? isRemoteReachable
16+ ? "remote-reachable"
17+ : "desktop-managed-local"
18+ : isRemoteReachable
19+ ? "remote-reachable"
20+ : "loopback-browser";
21+
22+ const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] =
23+ policy === "desktop-managed-local"
24+ ? ["desktop-bootstrap"]
25+ : config.mode === "desktop" && policy === "remote-reachable"
26+ ? ["desktop-bootstrap", "one-time-token"]
27+ : ["one-time-token"];
28+
29+ const descriptor: ServerAuthDescriptor = {
30+ policy,
31+ bootstrapMethods,
32+ sessionMethods: ["browser-session-cookie", "bearer-session-token"],
33+ sessionCookieName: resolveSessionCookieName({
34+ mode: config.mode,
35+ port: config.port,
36+ }),
37+ };
38+
39+ return {
40+ getDescriptor: () => Effect.succeed(descriptor),
41+ } satisfies ServerAuthPolicyShape;
42+});
43+
44+export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy);
apps/server/src/auth/Layers/ServerSecretStore.test.ts+263−0
@@ -0,0 +1,263 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { expect, it } from "@effect/vitest";
3+import { Cause, Deferred, Effect, FileSystem, Layer, Ref } from "effect";
4+import * as PlatformError from "effect/PlatformError";
5+
6+import { ServerConfig } from "../../config.ts";
7+import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts";
8+import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
9+
10+const makeServerConfigLayer = () =>
11+ ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" });
12+
13+const makeServerSecretStoreLayer = () =>
14+ Layer.provide(ServerSecretStoreLive, makeServerConfigLayer());
15+
16+const PermissionDeniedFileSystemLayer = Layer.effect(
17+ FileSystem.FileSystem,
18+ Effect.gen(function* () {
19+ const fileSystem = yield* FileSystem.FileSystem;
20+
21+ return {
22+ ...fileSystem,
23+ readFile: (path) =>
24+ Effect.fail(
25+ PlatformError.systemError({
26+ _tag: "PermissionDenied",
27+ module: "FileSystem",
28+ method: "readFile",
29+ pathOrDescriptor: path,
30+ description: "Permission denied while reading secret file.",
31+ }),
32+ ),
33+ } satisfies FileSystem.FileSystem;
34+ }),
35+).pipe(Layer.provide(NodeServices.layer));
36+
37+const makePermissionDeniedSecretStoreLayer = () =>
38+ ServerSecretStoreLive.pipe(
39+ Layer.provide(makeServerConfigLayer()),
40+ Layer.provideMerge(PermissionDeniedFileSystemLayer),
41+ );
42+
43+const RenameFailureFileSystemLayer = Layer.effect(
44+ FileSystem.FileSystem,
45+ Effect.gen(function* () {
46+ const fileSystem = yield* FileSystem.FileSystem;
47+
48+ return {
49+ ...fileSystem,
50+ rename: (from, to) =>
51+ Effect.fail(
52+ PlatformError.systemError({
53+ _tag: "PermissionDenied",
54+ module: "FileSystem",
55+ method: "rename",
56+ pathOrDescriptor: `${String(from)} -> ${String(to)}`,
57+ description: "Permission denied while persisting secret file.",
58+ }),
59+ ),
60+ } satisfies FileSystem.FileSystem;
61+ }),
62+).pipe(Layer.provide(NodeServices.layer));
63+
64+const makeRenameFailureSecretStoreLayer = () =>
65+ ServerSecretStoreLive.pipe(
66+ Layer.provide(makeServerConfigLayer()),
67+ Layer.provideMerge(RenameFailureFileSystemLayer),
68+ );
69+
70+const RemoveFailureFileSystemLayer = Layer.effect(
71+ FileSystem.FileSystem,
72+ Effect.gen(function* () {
73+ const fileSystem = yield* FileSystem.FileSystem;
74+
75+ return {
76+ ...fileSystem,
77+ remove: (path, options) =>
78+ Effect.fail(
79+ PlatformError.systemError({
80+ _tag: "PermissionDenied",
81+ module: "FileSystem",
82+ method: "remove",
83+ pathOrDescriptor: String(path),
84+ description: `Permission denied while removing secret file.${options ? " options-set" : ""}`,
85+ }),
86+ ),
87+ } satisfies FileSystem.FileSystem;
88+ }),
89+).pipe(Layer.provide(NodeServices.layer));
90+
91+const makeRemoveFailureSecretStoreLayer = () =>
92+ ServerSecretStoreLive.pipe(
93+ Layer.provide(makeServerConfigLayer()),
94+ Layer.provideMerge(RemoveFailureFileSystemLayer),
95+ );
96+
97+const ConcurrentReadMissFileSystemLayer = Layer.effect(
98+ FileSystem.FileSystem,
99+ Effect.gen(function* () {
100+ const fileSystem = yield* FileSystem.FileSystem;
101+ const readCountRef = yield* Ref.make(0);
102+ const readBarrier = yield* Deferred.make<void>();
103+
104+ return {
105+ ...fileSystem,
106+ readFile: (path) =>
107+ String(path).endsWith("/session-signing-key.bin")
108+ ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe(
109+ Effect.flatMap((count) => {
110+ if (count > 2) {
111+ return fileSystem.readFile(path);
112+ }
113+ return Effect.gen(function* () {
114+ if (count === 2) {
115+ yield* Deferred.succeed(readBarrier, void 0);
116+ }
117+ yield* Deferred.await(readBarrier);
118+ return yield* Effect.failCause(
119+ Cause.fail(
120+ PlatformError.systemError({
121+ _tag: "NotFound",
122+ module: "FileSystem",
123+ method: "readFile",
124+ pathOrDescriptor: String(path),
125+ description: "Secret file does not exist yet.",
126+ }),
127+ ),
128+ );
129+ });
130+ }),
131+ )
132+ : fileSystem.readFile(path),
133+ } satisfies FileSystem.FileSystem;
134+ }),
135+).pipe(Layer.provide(NodeServices.layer));
136+
137+const makeConcurrentCreateSecretStoreLayer = () =>
138+ ServerSecretStoreLive.pipe(
139+ Layer.provide(makeServerConfigLayer()),
140+ Layer.provideMerge(ConcurrentReadMissFileSystemLayer),
141+ );
142+
143+it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => {
144+ it.effect("returns null when a secret file does not exist", () =>
145+ Effect.gen(function* () {
146+ const secretStore = yield* ServerSecretStore;
147+
148+ const secret = yield* secretStore.get("missing-secret");
149+
150+ expect(secret).toBeNull();
151+ }).pipe(Effect.provide(makeServerSecretStoreLayer())),
152+ );
153+
154+ it.effect("reuses an existing secret instead of regenerating it", () =>
155+ Effect.gen(function* () {
156+ const secretStore = yield* ServerSecretStore;
157+
158+ const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
159+ const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
160+
161+ expect(Array.from(second)).toEqual(Array.from(first));
162+ }).pipe(Effect.provide(makeServerSecretStoreLayer())),
163+ );
164+
165+ it.effect("returns the persisted secret when concurrent creators race", () =>
166+ Effect.gen(function* () {
167+ const secretStore = yield* ServerSecretStore;
168+
169+ const [first, second] = yield* Effect.all(
170+ [
171+ secretStore.getOrCreateRandom("session-signing-key", 32),
172+ secretStore.getOrCreateRandom("session-signing-key", 32),
173+ ],
174+ { concurrency: "unbounded" },
175+ );
176+ const persisted = yield* secretStore.get("session-signing-key");
177+
178+ expect(persisted).not.toBeNull();
179+ expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array()));
180+ expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array()));
181+ }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())),
182+ );
183+
184+ it.effect("uses restrictive permissions for the secret directory and files", () =>
185+ Effect.gen(function* () {
186+ const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = [];
187+ const recordingFileSystemLayer = Layer.effect(
188+ FileSystem.FileSystem,
189+ Effect.gen(function* () {
190+ const fileSystem = yield* FileSystem.FileSystem;
191+
192+ return {
193+ ...fileSystem,
194+ makeDirectory: () => Effect.void,
195+ writeFile: () => Effect.void,
196+ rename: () => Effect.void,
197+ chmod: (path, mode) =>
198+ Effect.sync(() => {
199+ chmodCalls.push({ path: String(path), mode });
200+ }),
201+ } satisfies FileSystem.FileSystem;
202+ }),
203+ ).pipe(Layer.provide(NodeServices.layer));
204+
205+ const secretStore = yield* Effect.service(ServerSecretStore).pipe(
206+ Effect.provide(
207+ ServerSecretStoreLive.pipe(
208+ Layer.provide(makeServerConfigLayer()),
209+ Layer.provideMerge(recordingFileSystemLayer),
210+ ),
211+ ),
212+ );
213+
214+ yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3]));
215+
216+ expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe(
217+ true,
218+ );
219+ expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2);
220+ }).pipe(Effect.provide(NodeServices.layer)),
221+ );
222+
223+ it.effect("propagates read failures other than missing-file errors", () =>
224+ Effect.gen(function* () {
225+ const secretStore = yield* ServerSecretStore;
226+
227+ const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32));
228+
229+ expect(error).toBeInstanceOf(SecretStoreError);
230+ expect(error.message).toContain("Failed to read secret session-signing-key.");
231+ expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
232+ expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
233+ }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())),
234+ );
235+
236+ it.effect("propagates write failures instead of treating them as success", () =>
237+ Effect.gen(function* () {
238+ const secretStore = yield* ServerSecretStore;
239+
240+ const error = yield* Effect.flip(
241+ secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])),
242+ );
243+
244+ expect(error).toBeInstanceOf(SecretStoreError);
245+ expect(error.message).toContain("Failed to persist secret session-signing-key.");
246+ expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
247+ expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
248+ }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())),
249+ );
250+
251+ it.effect("propagates remove failures other than missing-file errors", () =>
252+ Effect.gen(function* () {
253+ const secretStore = yield* ServerSecretStore;
254+
255+ const error = yield* Effect.flip(secretStore.remove("session-signing-key"));
256+
257+ expect(error).toBeInstanceOf(SecretStoreError);
258+ expect(error.message).toContain("Failed to remove secret session-signing-key.");
259+ expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
260+ expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
261+ }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())),
262+ );
263+});
apps/server/src/auth/Layers/ServerSecretStore.ts+148−0
@@ -0,0 +1,148 @@
1+import * as Crypto from "node:crypto";
2+
3+import { Effect, FileSystem, Layer, Path, Predicate } from "effect";
4+import * as PlatformError from "effect/PlatformError";
5+
6+import { ServerConfig } from "../../config.ts";
7+import {
8+ SecretStoreError,
9+ ServerSecretStore,
10+ type ServerSecretStoreShape,
11+} from "../Services/ServerSecretStore.ts";
12+
13+export const makeServerSecretStore = Effect.gen(function* () {
14+ const fileSystem = yield* FileSystem.FileSystem;
15+ const path = yield* Path.Path;
16+ const serverConfig = yield* ServerConfig;
17+
18+ yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
19+ yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
20+ Effect.mapError(
21+ (cause) =>
22+ new SecretStoreError({
23+ message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`,
24+ cause,
25+ }),
26+ ),
27+ );
28+
29+ const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
30+
31+ const isPlatformError = (u: unknown): u is PlatformError.PlatformError =>
32+ Predicate.isTagged(u, "PlatformError");
33+
34+ const get: ServerSecretStoreShape["get"] = (name) =>
35+ fileSystem.readFile(resolveSecretPath(name)).pipe(
36+ Effect.map((bytes) => Uint8Array.from(bytes)),
37+ Effect.catch((cause) =>
38+ cause.reason._tag === "NotFound"
39+ ? Effect.succeed(null)
40+ : Effect.fail(
41+ new SecretStoreError({
42+ message: `Failed to read secret ${name}.`,
43+ cause,
44+ }),
45+ ),
46+ ),
47+ );
48+
49+ const set: ServerSecretStoreShape["set"] = (name, value) => {
50+ const secretPath = resolveSecretPath(name);
51+ const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`;
52+ return Effect.gen(function* () {
53+ yield* fileSystem.writeFile(tempPath, value);
54+ yield* fileSystem.chmod(tempPath, 0o600);
55+ yield* fileSystem.rename(tempPath, secretPath);
56+ yield* fileSystem.chmod(secretPath, 0o600);
57+ }).pipe(
58+ Effect.catch((cause) =>
59+ fileSystem.remove(tempPath).pipe(
60+ Effect.ignore,
61+ Effect.flatMap(() =>
62+ Effect.fail(
63+ new SecretStoreError({
64+ message: `Failed to persist secret ${name}.`,
65+ cause,
66+ }),
67+ ),
68+ ),
69+ ),
70+ ),
71+ );
72+ };
73+
74+ const create: ServerSecretStoreShape["set"] = (name, value) => {
75+ const secretPath = resolveSecretPath(name);
76+ return Effect.scoped(
77+ Effect.gen(function* () {
78+ const file = yield* fileSystem.open(secretPath, {
79+ flag: "wx",
80+ mode: 0o600,
81+ });
82+ yield* file.writeAll(value);
83+ yield* file.sync;
84+ yield* fileSystem.chmod(secretPath, 0o600);
85+ }),
86+ ).pipe(
87+ Effect.mapError(
88+ (cause) =>
89+ new SecretStoreError({
90+ message: `Failed to persist secret ${name}.`,
91+ cause,
92+ }),
93+ ),
94+ );
95+ };
96+
97+ const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
98+ get(name).pipe(
99+ Effect.flatMap((existing) => {
100+ if (existing) {
101+ return Effect.succeed(existing);
102+ }
103+
104+ const generated = Crypto.randomBytes(bytes);
105+ return create(name, generated).pipe(
106+ Effect.as(Uint8Array.from(generated)),
107+ Effect.catchTag("SecretStoreError", (error) =>
108+ isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"
109+ ? get(name).pipe(
110+ Effect.flatMap((created) =>
111+ created !== null
112+ ? Effect.succeed(created)
113+ : Effect.fail(
114+ new SecretStoreError({
115+ message: `Failed to read secret ${name} after concurrent creation.`,
116+ }),
117+ ),
118+ ),
119+ )
120+ : Effect.fail(error),
121+ ),
122+ );
123+ }),
124+ );
125+
126+ const remove: ServerSecretStoreShape["remove"] = (name) =>
127+ fileSystem.remove(resolveSecretPath(name)).pipe(
128+ Effect.catch((cause) =>
129+ cause.reason._tag === "NotFound"
130+ ? Effect.void
131+ : Effect.fail(
132+ new SecretStoreError({
133+ message: `Failed to remove secret ${name}.`,
134+ cause,
135+ }),
136+ ),
137+ ),
138+ );
139+
140+ return {
141+ get,
142+ set,
143+ getOrCreateRandom,
144+ remove,
145+ } satisfies ServerSecretStoreShape;
146+});
147+
148+export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore);
apps/server/src/auth/Layers/SessionCredentialService.test.ts+191−0
@@ -0,0 +1,191 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { expect, it } from "@effect/vitest";
3+import { Duration, Effect, Layer } from "effect";
4+import { TestClock } from "effect/testing";
5+
6+import type { ServerConfigShape } from "../../config.ts";
7+import { ServerConfig } from "../../config.ts";
8+import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
9+import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
10+import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
11+import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
12+
13+const makeServerConfigLayer = (
14+ overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
15+) =>
16+ Layer.effect(
17+ ServerConfig,
18+ Effect.gen(function* () {
19+ const config = yield* ServerConfig;
20+ return {
21+ ...config,
22+ ...overrides,
23+ } satisfies ServerConfigShape;
24+ }),
25+ ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));
26+
27+const makeSessionCredentialLayer = (
28+ overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
29+) =>
30+ SessionCredentialServiceLive.pipe(
31+ Layer.provide(SqlitePersistenceMemory),
32+ Layer.provide(ServerSecretStoreLive),
33+ Layer.provide(makeServerConfigLayer(overrides)),
34+ );
35+
36+it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => {
37+ it.effect("issues and verifies signed browser session tokens", () =>
38+ Effect.gen(function* () {
39+ const sessions = yield* SessionCredentialService;
40+ const issued = yield* sessions.issue({
41+ subject: "desktop-bootstrap",
42+ role: "owner",
43+ client: {
44+ label: "Desktop app",
45+ deviceType: "desktop",
46+ os: "macOS",
47+ browser: "Electron",
48+ ipAddress: "127.0.0.1",
49+ },
50+ });
51+ const verified = yield* sessions.verify(issued.token);
52+
53+ expect(verified.method).toBe("browser-session-cookie");
54+ expect(verified.subject).toBe("desktop-bootstrap");
55+ expect(verified.role).toBe("owner");
56+ expect(verified.client.label).toBe("Desktop app");
57+ expect(verified.client.browser).toBe("Electron");
58+ expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString());
59+ }).pipe(Effect.provide(makeSessionCredentialLayer())),
60+ );
61+ it.effect("rejects malformed session tokens", () =>
62+ Effect.gen(function* () {
63+ const sessions = yield* SessionCredentialService;
64+ const error = yield* Effect.flip(sessions.verify("not-a-session-token"));
65+
66+ expect(error._tag).toBe("SessionCredentialError");
67+ expect(error.message).toContain("Malformed session token");
68+ }).pipe(Effect.provide(makeSessionCredentialLayer())),
69+ );
70+ it.effect("verifies session tokens against the Effect clock", () =>
71+ Effect.gen(function* () {
72+ const sessions = yield* SessionCredentialService;
73+ const issued = yield* sessions.issue({
74+ method: "bearer-session-token",
75+ subject: "test-clock",
76+ });
77+ const verified = yield* sessions.verify(issued.token);
78+
79+ expect(verified.method).toBe("bearer-session-token");
80+ expect(verified.subject).toBe("test-clock");
81+ expect(verified.role).toBe("client");
82+ }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
83+ );
84+
85+ it.effect("rejects websocket tokens once the parent session has expired", () =>
86+ Effect.gen(function* () {
87+ const sessions = yield* SessionCredentialService;
88+ const issued = yield* sessions.issue({
89+ method: "bearer-session-token",
90+ subject: "short-lived",
91+ ttl: Duration.seconds(1),
92+ });
93+ const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
94+
95+ yield* TestClock.adjust(Duration.seconds(2));
96+
97+ const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
98+ expect(error.message).toContain("expired");
99+ }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
100+ );
101+
102+ it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>
103+ Effect.gen(function* () {
104+ const sessions = yield* SessionCredentialService;
105+ const owner = yield* sessions.issue({
106+ subject: "desktop-bootstrap",
107+ role: "owner",
108+ client: {
109+ label: "Desktop app",
110+ deviceType: "desktop",
111+ os: "macOS",
112+ browser: "Electron",
113+ },
114+ });
115+ const client = yield* sessions.issue({
116+ subject: "one-time-token",
117+ role: "client",
118+ client: {
119+ label: "Julius iPhone",
120+ deviceType: "mobile",
121+ os: "iOS",
122+ browser: "Safari",
123+ ipAddress: "192.168.1.88",
124+ },
125+ });
126+
127+ yield* sessions.markConnected(client.sessionId);
128+ const beforeRevoke = yield* sessions.listActive();
129+ const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId);
130+ const afterRevoke = yield* sessions.listActive();
131+ const revokedClient = yield* Effect.flip(sessions.verify(client.token));
132+
133+ expect(beforeRevoke).toHaveLength(2);
134+ expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
135+ true,
136+ );
137+ expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe(
138+ "Julius iPhone",
139+ );
140+ expect(
141+ beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType,
142+ ).toBe("desktop");
143+ expect(revokedCount).toBe(1);
144+ expect(afterRevoke).toHaveLength(1);
145+ expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId);
146+ expect(revokedClient.message).toContain("revoked");
147+ }).pipe(Effect.provide(makeSessionCredentialLayer())),
148+ );
149+
150+ it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () =>
151+ Effect.gen(function* () {
152+ const sessions = yield* SessionCredentialService;
153+ const issued = yield* sessions.issue({
154+ subject: "reconnect-test",
155+ method: "bearer-session-token",
156+ });
157+
158+ const beforeConnect = yield* sessions.listActive();
159+ expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
160+
161+ yield* TestClock.adjust(Duration.seconds(1));
162+ yield* sessions.markConnected(issued.sessionId);
163+ const firstConnect = yield* sessions.listActive();
164+ const firstConnectedAt = firstConnect[0]?.lastConnectedAt;
165+
166+ expect(firstConnect[0]?.connected).toBe(true);
167+ expect(firstConnectedAt).not.toBeNull();
168+
169+ yield* TestClock.adjust(Duration.seconds(1));
170+ yield* sessions.markConnected(issued.sessionId);
171+ const stillConnected = yield* sessions.listActive();
172+
173+ expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
174+
175+ yield* sessions.markDisconnected(issued.sessionId);
176+ yield* sessions.markDisconnected(issued.sessionId);
177+ const afterDisconnect = yield* sessions.listActive();
178+
179+ expect(afterDisconnect[0]?.connected).toBe(false);
180+ expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
181+
182+ yield* TestClock.adjust(Duration.seconds(1));
183+ yield* sessions.markConnected(issued.sessionId);
184+ const afterReconnect = yield* sessions.listActive();
185+
186+ expect(afterReconnect[0]?.connected).toBe(true);
187+ expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull();
188+ expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
189+ }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
190+ );
191+});
apps/server/src/auth/Layers/SessionCredentialService.ts+500−0
@@ -0,0 +1,500 @@
1+import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts";
2+import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect";
3+import { Option } from "effect";
4+
5+import { ServerConfig } from "../../config.ts";
6+import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";
7+import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
8+import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
9+import {
10+ SessionCredentialError,
11+ SessionCredentialService,
12+ type IssuedSession,
13+ type SessionCredentialChange,
14+ type SessionCredentialServiceShape,
15+ type VerifiedSession,
16+} from "../Services/SessionCredentialService.ts";
17+import {
18+ base64UrlDecodeUtf8,
19+ base64UrlEncode,
20+ resolveSessionCookieName,
21+ signPayload,
22+ timingSafeEqualBase64Url,
23+} from "../utils.ts";
24+
25+const SIGNING_SECRET_NAME = "server-signing-key";
26+const DEFAULT_SESSION_TTL = Duration.days(30);
27+const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
28+
29+const SessionClaims = Schema.Struct({
30+ v: Schema.Literal(1),
31+ kind: Schema.Literal("session"),
32+ sid: AuthSessionId,
33+ sub: Schema.String,
34+ role: Schema.Literals(["owner", "client"]),
35+ method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),
36+ iat: Schema.Number,
37+ exp: Schema.Number,
38+});
39+type SessionClaims = typeof SessionClaims.Type;
40+
41+const WebSocketClaims = Schema.Struct({
42+ v: Schema.Literal(1),
43+ kind: Schema.Literal("websocket"),
44+ sid: AuthSessionId,
45+ iat: Schema.Number,
46+ exp: Schema.Number,
47+});
48+type WebSocketClaims = typeof WebSocketClaims.Type;
49+
50+const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
51+const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
52+
53+function createDefaultClientMetadata(): AuthClientMetadata {
54+ return {
55+ deviceType: "unknown",
56+ };
57+}
58+
59+function toClientMetadata(record: {
60+ readonly label: string | null;
61+ readonly ipAddress: string | null;
62+ readonly userAgent: string | null;
63+ readonly deviceType: AuthClientMetadata["deviceType"];
64+ readonly os: string | null;
65+ readonly browser: string | null;
66+}): AuthClientMetadata {
67+ return {
68+ ...(record.label ? { label: record.label } : {}),
69+ ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}),
70+ ...(record.userAgent ? { userAgent: record.userAgent } : {}),
71+ deviceType: record.deviceType,
72+ ...(record.os ? { os: record.os } : {}),
73+ ...(record.browser ? { browser: record.browser } : {}),
74+ };
75+}
76+
77+function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession {
78+ return {
79+ ...input,
80+ current: false,
81+ };
82+}
83+
84+export const makeSessionCredentialService = Effect.gen(function* () {
85+ const serverConfig = yield* ServerConfig;
86+ const secretStore = yield* ServerSecretStore;
87+ const authSessions = yield* AuthSessionRepository;
88+ const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
89+ const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
90+ const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
91+ const cookieName = resolveSessionCookieName({
92+ mode: serverConfig.mode,
93+ port: serverConfig.port,
94+ });
95+
96+ const toSessionCredentialError = (message: string) => (cause: unknown) =>
97+ new SessionCredentialError({
98+ message,
99+ cause,
100+ });
101+
102+ const emitUpsert = (clientSession: AuthClientSession) =>
103+ PubSub.publish(changesPubSub, {
104+ type: "clientUpserted",
105+ clientSession,
106+ }).pipe(Effect.asVoid);
107+
108+ const emitRemoved = (sessionId: AuthSessionId) =>
109+ PubSub.publish(changesPubSub, {
110+ type: "clientRemoved",
111+ sessionId,
112+ }).pipe(Effect.asVoid);
113+
114+ const loadActiveSession = (sessionId: AuthSessionId) =>
115+ Effect.gen(function* () {
116+ const row = yield* authSessions.getById({ sessionId });
117+ if (Option.isNone(row) || row.value.revokedAt !== null) {
118+ return Option.none<AuthClientSession>();
119+ }
120+
121+ const connectedSessions = yield* Ref.get(connectedSessionsRef);
122+ return Option.some(
123+ toAuthClientSession({
124+ sessionId: row.value.sessionId,
125+ subject: row.value.subject,
126+ role: row.value.role,
127+ method: row.value.method,
128+ client: toClientMetadata(row.value.client),
129+ issuedAt: row.value.issuedAt,
130+ expiresAt: row.value.expiresAt,
131+ lastConnectedAt: row.value.lastConnectedAt,
132+ connected: connectedSessions.has(row.value.sessionId),
133+ }),
134+ );
135+ });
136+
137+ const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) =>
138+ Ref.modify(connectedSessionsRef, (current) => {
139+ const next = new Map(current);
140+ const wasDisconnected = !next.has(sessionId);
141+ next.set(sessionId, (next.get(sessionId) ?? 0) + 1);
142+ return [wasDisconnected, next] as const;
143+ }).pipe(
144+ Effect.flatMap((wasDisconnected) =>
145+ wasDisconnected
146+ ? DateTime.now.pipe(
147+ Effect.flatMap((lastConnectedAt) =>
148+ authSessions.setLastConnectedAt({
149+ sessionId,
150+ lastConnectedAt,
151+ }),
152+ ),
153+ )
154+ : Effect.void,
155+ ),
156+ Effect.flatMap(() => loadActiveSession(sessionId)),
157+ Effect.flatMap((session) =>
158+ Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
159+ ),
160+ Effect.catchCause((cause) =>
161+ Effect.logError("Failed to publish connected-session auth update.").pipe(
162+ Effect.annotateLogs({
163+ sessionId,
164+ cause,
165+ }),
166+ ),
167+ ),
168+ );
169+
170+ const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) =>
171+ Ref.update(connectedSessionsRef, (current) => {
172+ const next = new Map(current);
173+ const remaining = (next.get(sessionId) ?? 0) - 1;
174+ if (remaining > 0) {
175+ next.set(sessionId, remaining);
176+ } else {
177+ next.delete(sessionId);
178+ }
179+ return next;
180+ }).pipe(
181+ Effect.flatMap(() => loadActiveSession(sessionId)),
182+ Effect.flatMap((session) =>
183+ Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
184+ ),
185+ Effect.catchCause((cause) =>
186+ Effect.logError("Failed to publish disconnected-session auth update.").pipe(
187+ Effect.annotateLogs({
188+ sessionId,
189+ cause,
190+ }),
191+ ),
192+ ),
193+ );
194+
195+ const issue: SessionCredentialServiceShape["issue"] = (input) =>
196+ Effect.gen(function* () {
197+ const sessionId = AuthSessionId.make(crypto.randomUUID());
198+ const issuedAt = yield* DateTime.now;
199+ const expiresAt = DateTime.add(issuedAt, {
200+ milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
201+ });
202+ const claims: SessionClaims = {
203+ v: 1,
204+ kind: "session",
205+ sid: sessionId,
206+ sub: input?.subject ?? "browser",
207+ role: input?.role ?? "client",
208+ method: input?.method ?? "browser-session-cookie",
209+ iat: issuedAt.epochMilliseconds,
210+ exp: expiresAt.epochMilliseconds,
211+ };
212+ const encodedPayload = base64UrlEncode(JSON.stringify(claims));
213+ const signature = signPayload(encodedPayload, signingSecret);
214+ const client = input?.client ?? createDefaultClientMetadata();
215+ yield* authSessions.create({
216+ sessionId,
217+ subject: claims.sub,
218+ role: claims.role,
219+ method: claims.method,
220+ client: {
221+ label: client.label ?? null,
222+ ipAddress: client.ipAddress ?? null,
223+ userAgent: client.userAgent ?? null,
224+ deviceType: client.deviceType,
225+ os: client.os ?? null,
226+ browser: client.browser ?? null,
227+ },
228+ issuedAt,
229+ expiresAt,
230+ });
231+ yield* emitUpsert(
232+ toAuthClientSession({
233+ sessionId,
234+ subject: claims.sub,
235+ role: claims.role,
236+ method: claims.method,
237+ client,
238+ issuedAt,
239+ expiresAt,
240+ lastConnectedAt: null,
241+ connected: false,
242+ }),
243+ );
244+
245+ return {
246+ sessionId,
247+ token: `${encodedPayload}.${signature}`,
248+ method: claims.method,
249+ client,
250+ expiresAt: expiresAt,
251+ role: claims.role,
252+ } satisfies IssuedSession;
253+ }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential.")));
254+
255+ const verify: SessionCredentialServiceShape["verify"] = (token) =>
256+ Effect.gen(function* () {
257+ const [encodedPayload, signature] = token.split(".");
258+ if (!encodedPayload || !signature) {
259+ return yield* new SessionCredentialError({
260+ message: "Malformed session token.",
261+ });
262+ }
263+
264+ const expectedSignature = signPayload(encodedPayload, signingSecret);
265+ if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
266+ return yield* new SessionCredentialError({
267+ message: "Invalid session token signature.",
268+ });
269+ }
270+
271+ const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
272+ Effect.mapError(
273+ (cause) =>
274+ new SessionCredentialError({
275+ message: "Invalid session token payload.",
276+ cause,
277+ }),
278+ ),
279+ );
280+
281+ const now = yield* Clock.currentTimeMillis;
282+ if (claims.exp <= now) {
283+ return yield* new SessionCredentialError({
284+ message: "Session token expired.",
285+ });
286+ }
287+
288+ const row = yield* authSessions.getById({ sessionId: claims.sid });
289+ if (Option.isNone(row)) {
290+ return yield* new SessionCredentialError({
291+ message: "Unknown session token.",
292+ });
293+ }
294+ if (row.value.revokedAt !== null) {
295+ return yield* new SessionCredentialError({
296+ message: "Session token revoked.",
297+ });
298+ }
299+
300+ return {
301+ sessionId: claims.sid,
302+ token,
303+ method: claims.method,
304+ client: toClientMetadata(row.value.client),
305+ expiresAt: DateTime.makeUnsafe(claims.exp),
306+ subject: claims.sub,
307+ role: claims.role,
308+ } satisfies VerifiedSession;
309+ }).pipe(
310+ Effect.mapError((cause) =>
311+ cause instanceof SessionCredentialError
312+ ? cause
313+ : new SessionCredentialError({
314+ message: "Failed to verify session credential.",
315+ cause,
316+ }),
317+ ),
318+ );
319+
320+ const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = (
321+ sessionId,
322+ input,
323+ ) =>
324+ Effect.gen(function* () {
325+ const issuedAt = yield* DateTime.now;
326+ const expiresAt = DateTime.add(issuedAt, {
327+ milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
328+ });
329+ const claims: WebSocketClaims = {
330+ v: 1,
331+ kind: "websocket",
332+ sid: sessionId,
333+ iat: issuedAt.epochMilliseconds,
334+ exp: expiresAt.epochMilliseconds,
335+ };
336+ const encodedPayload = base64UrlEncode(JSON.stringify(claims));
337+ const signature = signPayload(encodedPayload, signingSecret);
338+ return {
339+ token: `${encodedPayload}.${signature}`,
340+ expiresAt,
341+ };
342+ }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token.")));
343+
344+ const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) =>
345+ Effect.gen(function* () {
346+ const [encodedPayload, signature] = token.split(".");
347+ if (!encodedPayload || !signature) {
348+ return yield* new SessionCredentialError({
349+ message: "Malformed websocket token.",
350+ });
351+ }
352+
353+ const expectedSignature = signPayload(encodedPayload, signingSecret);
354+ if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
355+ return yield* new SessionCredentialError({
356+ message: "Invalid websocket token signature.",
357+ });
358+ }
359+
360+ const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
361+ Effect.mapError(
362+ (cause) =>
363+ new SessionCredentialError({
364+ message: "Invalid websocket token payload.",
365+ cause,
366+ }),
367+ ),
368+ );
369+
370+ const now = yield* Clock.currentTimeMillis;
371+ if (claims.exp <= now) {
372+ return yield* new SessionCredentialError({
373+ message: "Websocket token expired.",
374+ });
375+ }
376+
377+ const row = yield* authSessions.getById({ sessionId: claims.sid });
378+ if (Option.isNone(row)) {
379+ return yield* new SessionCredentialError({
380+ message: "Unknown websocket session.",
381+ });
382+ }
383+ if (row.value.expiresAt.epochMilliseconds <= now) {
384+ return yield* new SessionCredentialError({
385+ message: "Websocket session expired.",
386+ });
387+ }
388+ if (row.value.revokedAt !== null) {
389+ return yield* new SessionCredentialError({
390+ message: "Websocket session revoked.",
391+ });
392+ }
393+
394+ return {
395+ sessionId: row.value.sessionId,
396+ token,
397+ method: row.value.method,
398+ client: toClientMetadata(row.value.client),
399+ expiresAt: row.value.expiresAt,
400+ subject: row.value.subject,
401+ role: row.value.role,
402+ } satisfies VerifiedSession;
403+ }).pipe(
404+ Effect.mapError((cause) =>
405+ cause instanceof SessionCredentialError
406+ ? cause
407+ : new SessionCredentialError({
408+ message: "Failed to verify websocket token.",
409+ cause,
410+ }),
411+ ),
412+ );
413+
414+ const listActive: SessionCredentialServiceShape["listActive"] = () =>
415+ Effect.gen(function* () {
416+ const now = yield* DateTime.now;
417+ const connectedSessions = yield* Ref.get(connectedSessionsRef);
418+ const rows = yield* authSessions.listActive({ now });
419+
420+ return rows.map((row) =>
421+ toAuthClientSession({
422+ sessionId: row.sessionId,
423+ subject: row.subject,
424+ role: row.role,
425+ method: row.method,
426+ client: toClientMetadata(row.client),
427+ issuedAt: row.issuedAt,
428+ expiresAt: row.expiresAt,
429+ lastConnectedAt: row.lastConnectedAt,
430+ connected: connectedSessions.has(row.sessionId),
431+ }),
432+ );
433+ }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions.")));
434+
435+ const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) =>
436+ Effect.gen(function* () {
437+ const revokedAt = yield* DateTime.now;
438+ const revoked = yield* authSessions.revoke({
439+ sessionId,
440+ revokedAt,
441+ });
442+ if (revoked) {
443+ yield* Ref.update(connectedSessionsRef, (current) => {
444+ const next = new Map(current);
445+ next.delete(sessionId);
446+ return next;
447+ });
448+ yield* emitRemoved(sessionId);
449+ }
450+ return revoked;
451+ }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session.")));
452+
453+ const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) =>
454+ Effect.gen(function* () {
455+ const revokedAt = yield* DateTime.now;
456+ const revokedSessionIds = yield* authSessions.revokeAllExcept({
457+ currentSessionId: sessionId,
458+ revokedAt,
459+ });
460+ if (revokedSessionIds.length > 0) {
461+ yield* Ref.update(connectedSessionsRef, (current) => {
462+ const next = new Map(current);
463+ for (const revokedSessionId of revokedSessionIds) {
464+ next.delete(revokedSessionId);
465+ }
466+ return next;
467+ });
468+ yield* Effect.forEach(
469+ revokedSessionIds,
470+ (revokedSessionId) => emitRemoved(revokedSessionId),
471+ {
472+ concurrency: "unbounded",
473+ discard: true,
474+ },
475+ );
476+ }
477+ return revokedSessionIds.length;
478+ }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));
479+
480+ return {
481+ cookieName,
482+ issue,
483+ verify,
484+ issueWebSocketToken,
485+ verifyWebSocketToken,
486+ listActive,
487+ get streamChanges() {
488+ return Stream.fromPubSub(changesPubSub);
489+ },
490+ revoke,
491+ revokeAllExcept,
492+ markConnected,
493+ markDisconnected,
494+ } satisfies SessionCredentialServiceShape;
495+});
496+
497+export const SessionCredentialServiceLive = Layer.effect(
498+ SessionCredentialService,
499+ makeSessionCredentialService,
500+).pipe(Layer.provideMerge(AuthSessionRepositoryLive));
apps/server/src/auth/Services/AuthControlPlane.ts+69−0
@@ -0,0 +1,69 @@
1+import type {
2+ AuthClientMetadata,
3+ AuthClientSession,
4+ AuthPairingLink,
5+ AuthSessionId,
6+} from "@t3tools/contracts";
7+import { Data, DateTime, Duration, Effect, Context } from "effect";
8+import type { SessionRole } from "./SessionCredentialService.ts";
9+
10+export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
11+
12+export interface IssuedPairingLink {
13+ readonly id: string;
14+ readonly credential: string;
15+ readonly role: SessionRole;
16+ readonly subject: string;
17+ readonly label?: string;
18+ readonly createdAt: DateTime.Utc;
19+ readonly expiresAt: DateTime.Utc;
20+}
21+
22+export interface IssuedBearerSession {
23+ readonly sessionId: AuthSessionId;
24+ readonly token: string;
25+ readonly method: "bearer-session-token";
26+ readonly role: SessionRole;
27+ readonly subject: string;
28+ readonly client: AuthClientMetadata;
29+ readonly expiresAt: DateTime.Utc;
30+}
31+
32+export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{
33+ readonly message: string;
34+ readonly cause?: unknown;
35+}> {}
36+
37+export interface AuthControlPlaneShape {
38+ readonly createPairingLink: (input?: {
39+ readonly ttl?: Duration.Duration;
40+ readonly label?: string;
41+ readonly role?: SessionRole;
42+ readonly subject?: string;
43+ }) => Effect.Effect<IssuedPairingLink, AuthControlPlaneError>;
44+ readonly listPairingLinks: (input?: {
45+ readonly role?: SessionRole;
46+ readonly excludeSubjects?: ReadonlyArray<string>;
47+ }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthControlPlaneError>;
48+ readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthControlPlaneError>;
49+ readonly issueSession: (input?: {
50+ readonly ttl?: Duration.Duration;
51+ readonly subject?: string;
52+ readonly role?: SessionRole;
53+ readonly label?: string;
54+ }) => Effect.Effect<IssuedBearerSession, AuthControlPlaneError>;
55+ readonly listSessions: () => Effect.Effect<
56+ ReadonlyArray<AuthClientSession>,
57+ AuthControlPlaneError
58+ >;
59+ readonly revokeSession: (
60+ sessionId: AuthSessionId,
61+ ) => Effect.Effect<boolean, AuthControlPlaneError>;
62+ readonly revokeOtherSessionsExcept: (
63+ sessionId: AuthSessionId,
64+ ) => Effect.Effect<number, AuthControlPlaneError>;
65+}
66+
67+export class AuthControlPlane extends Context.Service<AuthControlPlane, AuthControlPlaneShape>()(
68+ "t3/AuthControlPlane",
69+) {}
apps/server/src/auth/Services/BootstrapCredentialService.ts+57−0
@@ -0,0 +1,57 @@
1+import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts";
2+import { Data, DateTime, Duration, Context } from "effect";
3+import type { Effect, Stream } from "effect";
4+
5+export type BootstrapCredentialRole = "owner" | "client";
6+
7+export interface BootstrapGrant {
8+ readonly method: ServerAuthBootstrapMethod;
9+ readonly role: BootstrapCredentialRole;
10+ readonly subject: string;
11+ readonly label?: string;
12+ readonly expiresAt: DateTime.DateTime;
13+}
14+
15+export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{
16+ readonly message: string;
17+ readonly status?: 401 | 500;
18+ readonly cause?: unknown;
19+}> {}
20+
21+export interface IssuedBootstrapCredential {
22+ readonly id: string;
23+ readonly credential: string;
24+ readonly label?: string;
25+ readonly expiresAt: DateTime.Utc;
26+}
27+
28+export type BootstrapCredentialChange =
29+ | {
30+ readonly type: "pairingLinkUpserted";
31+ readonly pairingLink: AuthPairingLink;
32+ }
33+ | {
34+ readonly type: "pairingLinkRemoved";
35+ readonly id: string;
36+ };
37+
38+export interface BootstrapCredentialServiceShape {
39+ readonly issueOneTimeToken: (input?: {
40+ readonly ttl?: Duration.Duration;
41+ readonly role?: BootstrapCredentialRole;
42+ readonly subject?: string;
43+ readonly label?: string;
44+ }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialError>;
45+ readonly listActive: () => Effect.Effect<
46+ ReadonlyArray<AuthPairingLink>,
47+ BootstrapCredentialError
48+ >;
49+ readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
50+ readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialError>;
51+ readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
52+}
53+
54+export class BootstrapCredentialService extends Context.Service<
55+ BootstrapCredentialService,
56+ BootstrapCredentialServiceShape
57+>()("t3/auth/Services/BootstrapCredentialService") {}
apps/server/src/auth/Services/ServerAuth.ts+84−0
@@ -0,0 +1,84 @@
1+import type {
2+ AuthBearerBootstrapResult,
3+ AuthBootstrapResult,
4+ AuthClientMetadata,
5+ AuthClientSession,
6+ AuthCreatePairingCredentialInput,
7+ AuthPairingLink,
8+ AuthPairingCredentialResult,
9+ AuthSessionId,
10+ AuthSessionState,
11+ ServerAuthDescriptor,
12+ ServerAuthSessionMethod,
13+ AuthWebSocketTokenResult,
14+} from "@t3tools/contracts";
15+import { Data, DateTime, Context } from "effect";
16+import type { Effect } from "effect";
17+import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
18+import type { SessionRole } from "./SessionCredentialService.ts";
19+
20+export interface AuthenticatedSession {
21+ readonly sessionId: AuthSessionId;
22+ readonly subject: string;
23+ readonly method: ServerAuthSessionMethod;
24+ readonly role: SessionRole;
25+ readonly expiresAt?: DateTime.DateTime;
26+}
27+
28+export class AuthError extends Data.TaggedError("AuthError")<{
29+ readonly message: string;
30+ readonly status?: 400 | 401 | 403 | 500;
31+ readonly cause?: unknown;
32+}> {}
33+
34+export interface ServerAuthShape {
35+ readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
36+ readonly getSessionState: (
37+ request: HttpServerRequest.HttpServerRequest,
38+ ) => Effect.Effect<AuthSessionState, never>;
39+ readonly exchangeBootstrapCredential: (
40+ credential: string,
41+ requestMetadata: AuthClientMetadata,
42+ ) => Effect.Effect<
43+ {
44+ readonly response: AuthBootstrapResult;
45+ readonly sessionToken: string;
46+ },
47+ AuthError
48+ >;
49+ readonly exchangeBootstrapCredentialForBearerSession: (
50+ credential: string,
51+ requestMetadata: AuthClientMetadata,
52+ ) => Effect.Effect<AuthBearerBootstrapResult, AuthError>;
53+ readonly issuePairingCredential: (
54+ input?: AuthCreatePairingCredentialInput & {
55+ readonly role?: SessionRole;
56+ },
57+ ) => Effect.Effect<AuthPairingCredentialResult, AuthError>;
58+ readonly listPairingLinks: () => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthError>;
59+ readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthError>;
60+ readonly listClientSessions: (
61+ currentSessionId: AuthSessionId,
62+ ) => Effect.Effect<ReadonlyArray<AuthClientSession>, AuthError>;
63+ readonly revokeClientSession: (
64+ currentSessionId: AuthSessionId,
65+ targetSessionId: AuthSessionId,
66+ ) => Effect.Effect<boolean, AuthError>;
67+ readonly revokeOtherClientSessions: (
68+ currentSessionId: AuthSessionId,
69+ ) => Effect.Effect<number, AuthError>;
70+ readonly authenticateHttpRequest: (
71+ request: HttpServerRequest.HttpServerRequest,
72+ ) => Effect.Effect<AuthenticatedSession, AuthError>;
73+ readonly authenticateWebSocketUpgrade: (
74+ request: HttpServerRequest.HttpServerRequest,
75+ ) => Effect.Effect<AuthenticatedSession, AuthError>;
76+ readonly issueWebSocketToken: (
77+ session: AuthenticatedSession,
78+ ) => Effect.Effect<AuthWebSocketTokenResult, AuthError>;
79+ readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect<string, AuthError>;
80+}
81+
82+export class ServerAuth extends Context.Service<ServerAuth, ServerAuthShape>()(
83+ "t3/auth/Services/ServerAuth",
84+) {}
apps/server/src/auth/Services/ServerAuthPolicy.ts+11−0
@@ -0,0 +1,11 @@
1+import type { ServerAuthDescriptor } from "@t3tools/contracts";
2+import { Context } from "effect";
3+import type { Effect } from "effect";
4+
5+export interface ServerAuthPolicyShape {
6+ readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
7+}
8+
9+export class ServerAuthPolicy extends Context.Service<ServerAuthPolicy, ServerAuthPolicyShape>()(
10+ "t3/auth/Services/ServerAuthPolicy",
11+) {}
apps/server/src/auth/Services/ServerSecretStore.ts+21−0
@@ -0,0 +1,21 @@
1+import { Data, Context } from "effect";
2+import type { Effect } from "effect";
3+
4+export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{
5+ readonly message: string;
6+ readonly cause?: unknown;
7+}> {}
8+
9+export interface ServerSecretStoreShape {
10+ readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;
11+ readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
12+ readonly getOrCreateRandom: (
13+ name: string,
14+ bytes: number,
15+ ) => Effect.Effect<Uint8Array, SecretStoreError>;
16+ readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
17+}
18+
19+export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()(
20+ "t3/auth/Services/ServerSecretStore",
21+) {}
apps/server/src/auth/Services/SessionCredentialService.ts+87−0
@@ -0,0 +1,87 @@
1+import type {
2+ AuthClientMetadata,
3+ AuthClientSession,
4+ AuthSessionId,
5+ ServerAuthSessionMethod,
6+} from "@t3tools/contracts";
7+import { Data, DateTime, Duration, Context } from "effect";
8+import type { Effect, Stream } from "effect";
9+
10+export type SessionRole = "owner" | "client";
11+
12+export interface IssuedSession {
13+ readonly sessionId: AuthSessionId;
14+ readonly token: string;
15+ readonly method: ServerAuthSessionMethod;
16+ readonly client: AuthClientMetadata;
17+ readonly expiresAt: DateTime.DateTime;
18+ readonly role: SessionRole;
19+}
20+
21+export interface VerifiedSession {
22+ readonly sessionId: AuthSessionId;
23+ readonly token: string;
24+ readonly method: ServerAuthSessionMethod;
25+ readonly client: AuthClientMetadata;
26+ readonly expiresAt?: DateTime.DateTime;
27+ readonly subject: string;
28+ readonly role: SessionRole;
29+}
30+
31+export type SessionCredentialChange =
32+ | {
33+ readonly type: "clientUpserted";
34+ readonly clientSession: AuthClientSession;
35+ }
36+ | {
37+ readonly type: "clientRemoved";
38+ readonly sessionId: AuthSessionId;
39+ };
40+
41+export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{
42+ readonly message: string;
43+ readonly cause?: unknown;
44+}> {}
45+
46+export interface SessionCredentialServiceShape {
47+ readonly cookieName: string;
48+ readonly issue: (input?: {
49+ readonly ttl?: Duration.Duration;
50+ readonly subject?: string;
51+ readonly method?: ServerAuthSessionMethod;
52+ readonly role?: SessionRole;
53+ readonly client?: AuthClientMetadata;
54+ }) => Effect.Effect<IssuedSession, SessionCredentialError>;
55+ readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
56+ readonly issueWebSocketToken: (
57+ sessionId: AuthSessionId,
58+ input?: {
59+ readonly ttl?: Duration.Duration;
60+ },
61+ ) => Effect.Effect<
62+ {
63+ readonly token: string;
64+ readonly expiresAt: DateTime.DateTime;
65+ },
66+ SessionCredentialError
67+ >;
68+ readonly verifyWebSocketToken: (
69+ token: string,
70+ ) => Effect.Effect<VerifiedSession, SessionCredentialError>;
71+ readonly listActive: () => Effect.Effect<
72+ ReadonlyArray<AuthClientSession>,
73+ SessionCredentialError
74+ >;
75+ readonly streamChanges: Stream.Stream<SessionCredentialChange>;
76+ readonly revoke: (sessionId: AuthSessionId) => Effect.Effect<boolean, SessionCredentialError>;
77+ readonly revokeAllExcept: (
78+ sessionId: AuthSessionId,
79+ ) => Effect.Effect<number, SessionCredentialError>;
80+ readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
81+ readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
82+}
83+
84+export class SessionCredentialService extends Context.Service<
85+ SessionCredentialService,
86+ SessionCredentialServiceShape
87+>()("t3/auth/Services/SessionCredentialService") {}
apps/server/src/auth/http.ts+254−0
@@ -0,0 +1,254 @@
1+import {
2+ type AuthBearerBootstrapResult,
3+ AuthBootstrapInput,
4+ AuthCreatePairingCredentialInput,
5+ AuthRevokeClientSessionInput,
6+ AuthRevokePairingLinkInput,
7+ type AuthWebSocketTokenResult,
8+} from "@t3tools/contracts";
9+import { DateTime, Effect, Schema } from "effect";
10+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
11+
12+import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
13+import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
14+import { deriveAuthClientMetadata } from "./utils.ts";
15+
16+export const respondToAuthError = (error: AuthError) =>
17+ Effect.gen(function* () {
18+ if ((error.status ?? 500) >= 500) {
19+ yield* Effect.logError("auth route failed", {
20+ message: error.message,
21+ cause: error.cause,
22+ });
23+ }
24+ return HttpServerResponse.jsonUnsafe(
25+ {
26+ error: error.message,
27+ },
28+ { status: error.status ?? 500 },
29+ );
30+ });
31+
32+export const authSessionRouteLayer = HttpRouter.add(
33+ "GET",
34+ "/api/auth/session",
35+ Effect.gen(function* () {
36+ const request = yield* HttpServerRequest.HttpServerRequest;
37+ const serverAuth = yield* ServerAuth;
38+ const session = yield* serverAuth.getSessionState(request);
39+ return HttpServerResponse.jsonUnsafe(session, { status: 200 });
40+ }),
41+);
42+
43+const PairingCredentialRequestHeaders = Schema.Struct({
44+ "content-length": Schema.optionalKey(Schema.String),
45+ "content-type": Schema.optionalKey(Schema.String),
46+ "transfer-encoding": Schema.optionalKey(Schema.String),
47+});
48+
49+function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) {
50+ const contentLengthHeader = headers["content-length"];
51+ if (typeof contentLengthHeader === "string") {
52+ const contentLength = Number.parseInt(contentLengthHeader, 10);
53+ if (Number.isFinite(contentLength)) {
54+ return contentLength > 0;
55+ }
56+ }
57+ return typeof headers["transfer-encoding"] === "string";
58+}
59+
60+export const authBootstrapRouteLayer = HttpRouter.add(
61+ "POST",
62+ "/api/auth/bootstrap",
63+ Effect.gen(function* () {
64+ const request = yield* HttpServerRequest.HttpServerRequest;
65+ const serverAuth = yield* ServerAuth;
66+ const sessions = yield* SessionCredentialService;
67+ const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe(
68+ Effect.mapError(
69+ (cause) =>
70+ new AuthError({
71+ message: "Invalid bootstrap payload.",
72+ status: 400,
73+ cause,
74+ }),
75+ ),
76+ );
77+ const result = yield* serverAuth.exchangeBootstrapCredential(
78+ payload.credential,
79+ deriveAuthClientMetadata({ request }),
80+ );
81+
82+ return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe(
83+ HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {
84+ expires: DateTime.toDate(result.response.expiresAt),
85+ httpOnly: true,
86+ path: "/",
87+ sameSite: "lax",
88+ }),
89+ );
90+ }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
91+);
92+
93+export const authBearerBootstrapRouteLayer = HttpRouter.add(
94+ "POST",
95+ "/api/auth/bootstrap/bearer",
96+ Effect.gen(function* () {
97+ const request = yield* HttpServerRequest.HttpServerRequest;
98+ const serverAuth = yield* ServerAuth;
99+ const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe(
100+ Effect.mapError(
101+ (cause) =>
102+ new AuthError({
103+ message: "Invalid bootstrap payload.",
104+ status: 400,
105+ cause,
106+ }),
107+ ),
108+ );
109+ const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession(
110+ payload.credential,
111+ deriveAuthClientMetadata({ request }),
112+ );
113+ return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {
114+ status: 200,
115+ });
116+ }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
117+);
118+
119+export const authWebSocketTokenRouteLayer = HttpRouter.add(
120+ "POST",
121+ "/api/auth/ws-token",
122+ Effect.gen(function* () {
123+ const request = yield* HttpServerRequest.HttpServerRequest;
124+ const serverAuth = yield* ServerAuth;
125+ const session = yield* serverAuth.authenticateHttpRequest(request);
126+ const result = yield* serverAuth.issueWebSocketToken(session);
127+ return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {
128+ status: 200,
129+ });
130+ }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
131+);
132+
133+export const authPairingCredentialRouteLayer = HttpRouter.add(
134+ "POST",
135+ "/api/auth/pairing-token",
136+ Effect.gen(function* () {
137+ const serverAuth = yield* ServerAuth;
138+ const request = yield* HttpServerRequest.HttpServerRequest;
139+ const session = yield* serverAuth.authenticateHttpRequest(request);
140+ if (session.role !== "owner") {
141+ return yield* new AuthError({
142+ message: "Only owner sessions can create pairing credentials.",
143+ status: 403,
144+ });
145+ }
146+ const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe(
147+ Effect.mapError(
148+ (cause) =>
149+ new AuthError({
150+ message: "Invalid pairing credential request headers.",
151+ status: 400,
152+ cause,
153+ }),
154+ ),
155+ );
156+ const payload = hasRequestBody(headers)
157+ ? yield* HttpServerRequest.schemaBodyJson(AuthCreatePairingCredentialInput).pipe(
158+ Effect.mapError(
159+ (cause) =>
160+ new AuthError({
161+ message: "Invalid pairing credential payload.",
162+ status: 400,
163+ cause,
164+ }),
165+ ),
166+ )
167+ : {};
168+ const result = yield* serverAuth.issuePairingCredential(payload);
169+ return HttpServerResponse.jsonUnsafe(result, { status: 200 });
170+ }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
171+);
172+
173+const authenticateOwnerSession = Effect.gen(function* () {
174+ const request = yield* HttpServerRequest.HttpServerRequest;
175+ const serverAuth = yield* ServerAuth;
176+ const session = yield* serverAuth.authenticateHttpRequest(request);
177+ if (session.role !== "owner") {
178+ return yield* new AuthError({
179+ message: "Only owner sessions can manage network access.",
180+ status: 403,
181+ });
182+ }
183+ return { serverAuth, session } as const;
184+});
185+
186+export const authPairingLinksRouteLayer = HttpRouter.add(
187+ "GET",
188+ "/api/auth/pairing-links",
189+ Effect.gen(function* () {
190+ const { serverAuth } = yield* authenticateOwnerSession;
191+ const pairingLinks = yield* serverAuth.listPairingLinks();
192+ return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 });
193+ }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
194+);
195+
196+export const authPairingLinksRevokeRouteLayer = HttpRouter.add(
197+ "POST",
198+ "/api/auth/pairing-links/revoke",
199+ Effect.gen(function* () {
200+ const { serverAuth } = yield* authenticateOwnerSession;
201+ const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe(
202+ Effect.mapError(
203+ (cause) =>
204+ new AuthError({
205+ message: "Invalid revoke pairing link payload.",
206+ status: 400,
207+ cause,
208+ }),
209+ ),
210+ );
211+ const revoked = yield* serverAuth.revokePairingLink(payload.id);
212+ return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 });
213+ }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
214+);
215+
216+export const authClientsRouteLayer = HttpRouter.add(
217+ "GET",
218+ "/api/auth/clients",
219+ Effect.gen(function* () {
220+ const { serverAuth, session } = yield* authenticateOwnerSession;
221+ const clients = yield* serverAuth.listClientSessions(session.sessionId);
222+ return HttpServerResponse.jsonUnsafe(clients, { status: 200 });
223+ }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
224+);
225+
226+export const authClientsRevokeRouteLayer = HttpRouter.add(
227+ "POST",
228+ "/api/auth/clients/revoke",
229+ Effect.gen(function* () {
230+ const { serverAuth, session } = yield* authenticateOwnerSession;
231+ const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe(
232+ Effect.mapError(
233+ (cause) =>
234+ new AuthError({
235+ message: "Invalid revoke client payload.",
236+ status: 400,
237+ cause,
238+ }),
239+ ),
240+ );
241+ const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId);
242+ return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 });
243+ }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
244+);
245+
246+export const authClientsRevokeOthersRouteLayer = HttpRouter.add(
247+ "POST",
248+ "/api/auth/clients/revoke-others",
249+ Effect.gen(function* () {
250+ const { serverAuth, session } = yield* authenticateOwnerSession;
251+ const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId);
252+ return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 });
253+ }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
254+);
apps/server/src/auth/utils.test.ts+26−0
@@ -0,0 +1,26 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { deriveAuthClientMetadata } from "./utils.ts";
4+
5+describe("deriveAuthClientMetadata", () => {
6+ it("labels Electron user agents as Electron instead of Chrome", () => {
7+ const metadata = deriveAuthClientMetadata({
8+ request: {
9+ headers: {
10+ "user-agent":
11+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) t3code/0.0.15 Chrome/136.0.7103.93 Electron/36.3.2 Safari/537.36",
12+ },
13+ source: {
14+ remoteAddress: "::ffff:127.0.0.1",
15+ },
16+ } as never,
17+ });
18+
19+ expect(metadata).toMatchObject({
20+ browser: "Electron",
21+ deviceType: "desktop",
22+ ipAddress: "127.0.0.1",
23+ os: "macOS",
24+ });
25+ });
26+});
apps/server/src/auth/utils.ts+132−0
@@ -0,0 +1,132 @@
1+import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts";
2+import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3+import * as Crypto from "node:crypto";
4+
5+const SESSION_COOKIE_NAME = "t3_session";
6+
7+export function resolveSessionCookieName(input: {
8+ readonly mode: "web" | "desktop";
9+ readonly port: number;
10+}): string {
11+ if (input.mode !== "desktop") {
12+ return SESSION_COOKIE_NAME;
13+ }
14+
15+ return `${SESSION_COOKIE_NAME}_${input.port}`;
16+}
17+
18+export function base64UrlEncode(input: string | Uint8Array): string {
19+ const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);
20+ return buffer.toString("base64url");
21+}
22+
23+export function base64UrlDecodeUtf8(input: string): string {
24+ return Buffer.from(input, "base64url").toString("utf8");
25+}
26+
27+export function signPayload(payload: string, secret: Uint8Array): string {
28+ return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url");
29+}
30+
31+export function timingSafeEqualBase64Url(left: string, right: string): boolean {
32+ const leftBuffer = Buffer.from(left, "base64url");
33+ const rightBuffer = Buffer.from(right, "base64url");
34+ if (leftBuffer.length !== rightBuffer.length) {
35+ return false;
36+ }
37+ return Crypto.timingSafeEqual(leftBuffer, rightBuffer);
38+}
39+
40+function normalizeNonEmptyString(value: string | null | undefined): string | undefined {
41+ if (typeof value !== "string") {
42+ return undefined;
43+ }
44+ const trimmed = value.trim();
45+ return trimmed.length > 0 ? trimmed : undefined;
46+}
47+
48+function normalizeIpAddress(value: string | null | undefined): string | undefined {
49+ const normalized = normalizeNonEmptyString(value);
50+ if (!normalized) {
51+ return undefined;
52+ }
53+ return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
54+}
55+
56+function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType {
57+ if (!userAgent) {
58+ return "unknown";
59+ }
60+
61+ const normalized = userAgent.toLowerCase();
62+ if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) {
63+ return "bot";
64+ }
65+ if (/ipad|tablet/.test(normalized)) {
66+ return "tablet";
67+ }
68+ if (/iphone|android.+mobile|mobile/.test(normalized)) {
69+ return "mobile";
70+ }
71+ return "desktop";
72+}
73+
74+function inferBrowser(userAgent: string | undefined): string | undefined {
75+ if (!userAgent) {
76+ return undefined;
77+ }
78+ const normalized = userAgent.toLowerCase();
79+ if (/edg\//.test(normalized)) return "Edge";
80+ if (/opr\//.test(normalized)) return "Opera";
81+ if (/firefox\//.test(normalized)) return "Firefox";
82+ if (/electron\//.test(normalized)) return "Electron";
83+ if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome";
84+ if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari";
85+ return undefined;
86+}
87+
88+function inferOs(userAgent: string | undefined): string | undefined {
89+ if (!userAgent) {
90+ return undefined;
91+ }
92+ const normalized = userAgent.toLowerCase();
93+ if (/iphone|ipad|ipod/.test(normalized)) return "iOS";
94+ if (/android/.test(normalized)) return "Android";
95+ if (/mac os x|macintosh/.test(normalized)) return "macOS";
96+ if (/windows nt/.test(normalized)) return "Windows";
97+ if (/linux/.test(normalized)) return "Linux";
98+ return undefined;
99+}
100+
101+function readRemoteAddressFromSource(source: unknown): string | undefined {
102+ if (!source || typeof source !== "object") {
103+ return undefined;
104+ }
105+
106+ const candidate = source as {
107+ readonly remoteAddress?: string | null;
108+ readonly socket?: {
109+ readonly remoteAddress?: string | null;
110+ };
111+ };
112+
113+ return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress);
114+}
115+
116+export function deriveAuthClientMetadata(input: {
117+ readonly request: HttpServerRequest.HttpServerRequest;
118+ readonly label?: string;
119+}): AuthClientMetadata {
120+ const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]);
121+ const ipAddress = readRemoteAddressFromSource(input.request.source);
122+ const os = inferOs(userAgent);
123+ const browser = inferBrowser(userAgent);
124+ return {
125+ ...(input.label ? { label: input.label } : {}),
126+ ...(ipAddress ? { ipAddress } : {}),
127+ ...(userAgent ? { userAgent } : {}),
128+ deviceType: inferDeviceType(userAgent),
129+ ...(os ? { os } : {}),
130+ ...(browser ? { browser } : {}),
131+ };
132+}
apps/server/src/bin.ts+3−3
@@ -5,12 +5,12 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines
55 import { Command } from "effect/unstable/cli";
66
77 import { NetService } from "@t3tools/shared/Net";
8−import { cli } from "./cli";
9−import { version } from "../package.json" with { type: "json" };
8+import { cli } from "./cli.ts";
9+import packageJson from "../package.json" with { type: "json" };
1010
1111 const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1212
13−Command.run(cli, { version }).pipe(
13+Command.run(cli, { version: packageJson.version }).pipe(
1414 Effect.scoped,
1515 Effect.provide(CliRuntimeLayer),
1616 NodeRuntime.runMain,
apps/server/src/bootstrap.test.ts+1−1
@@ -10,7 +10,7 @@ import * as Fiber from "effect/Fiber"; · 9 unmodified lines
1010 import { TestClock } from "effect/testing";
1111 import { vi } from "vitest";
1212
13−import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap";
13+import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts";
1414 import { assertNone, assertSome } from "@effect/vitest/utils";
1515
1616 const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null }));
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+14−4
@@ -26,7 +26,7 @@ function makeThreadCheckpointContext(input: { · 25 unmodified lines
2626 worktreePath: input.worktreePath,
2727 checkpoints: [
2828 {
29− turnId: TurnId.makeUnsafe("turn-1"),
29+ turnId: TurnId.make("turn-1"),
3030 checkpointTurnCount: input.checkpointTurnCount,
3131 checkpointRef: input.checkpointRef,
3232 status: "ready",
@@ -40,8 +40,8 @@ function makeThreadCheckpointContext(input: { · 7 unmodified lines
4040
4141 describe("CheckpointDiffQueryLive", () => {
4242 it("computes diffs using canonical turn-0 checkpoint refs", async () => {
43− const projectId = ProjectId.makeUnsafe("project-1");
44− const threadId = ThreadId.makeUnsafe("thread-1");
43+ const projectId = ProjectId.make("project-1");
44+ const threadId = ThreadId.make("thread-1");
4545 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
4646 const hasCheckpointRefCalls: Array<CheckpointRef> = [];
4747 const diffCheckpointsCalls: Array<{
@@ -82,10 +82,15 @@ describe("CheckpointDiffQueryLive", () => { · 34 unmodified lines
8282 Layer.succeed(ProjectionSnapshotQuery, {
8383 getSnapshot: () =>
8484 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
85+ getShellSnapshot: () =>
86+ Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
8587 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
8688 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
89+ getProjectShellById: () => Effect.succeed(Option.none()),
8790 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
8891 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
92+ getThreadShellById: () => Effect.succeed(Option.none()),
93+ getThreadDetailById: () => Effect.succeed(Option.none()),
8994 }),
9095 ),
9196 );
@@ -119,7 +124,7 @@ describe("CheckpointDiffQueryLive", () => { · 27 unmodified lines
119124 });
120125
121126 it("fails when the thread is missing from the snapshot", async () => {
122− const threadId = ThreadId.makeUnsafe("thread-missing");
127+ const threadId = ThreadId.make("thread-missing");
123128
124129 const checkpointStore: CheckpointStoreShape = {
125130 isGitRepository: () => Effect.succeed(true),
@@ -136,10 +141,15 @@ describe("CheckpointDiffQueryLive", () => { · 10 unmodified lines
136141 Layer.succeed(ProjectionSnapshotQuery, {
137142 getSnapshot: () =>
138143 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
144+ getShellSnapshot: () =>
145+ Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
139146 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
140147 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
148+ getProjectShellById: () => Effect.succeed(Option.none()),
141149 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
142150 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
151+ getThreadShellById: () => Effect.succeed(Option.none()),
152+ getThreadDetailById: () => Effect.succeed(Option.none()),
143153 }),
144154 ),
145155 );
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+1−1
@@ -93,7 +93,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { · 92 unmodified lines
9393 const tmp = yield* makeTmpDir();
9494 yield* initRepoWithCommit(tmp);
9595 const checkpointStore = yield* CheckpointStore;
96− const threadId = ThreadId.makeUnsafe("thread-checkpoint-store");
96+ const threadId = ThreadId.make("thread-checkpoint-store");
9797 const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);
9898 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
9999
apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts+2−2
@@ -12,7 +12,7 @@ import type { · 11 unmodified lines
1212 OrchestrationGetTurnDiffInput,
1313 OrchestrationGetTurnDiffResult,
1414 } from "@t3tools/contracts";
15−import { ServiceMap } from "effect";
15+import { Context } from "effect";
1616 import type { Effect } from "effect";
1717
1818 import type { CheckpointServiceError } from "../Errors.ts";
@@ -43,7 +43,7 @@ export interface CheckpointDiffQueryShape { · 24 unmodified lines
4343 /**
4444 * CheckpointDiffQuery - Service tag for checkpoint diff queries.
4545 */
46−export class CheckpointDiffQuery extends ServiceMap.Service<
46+export class CheckpointDiffQuery extends Context.Service<
4747 CheckpointDiffQuery,
4848 CheckpointDiffQueryShape
4949 >()("t3/checkpointing/Services/CheckpointDiffQuery") {}
apps/server/src/checkpointing/Services/CheckpointStore.ts+3−3
@@ -5,12 +5,12 @@ · 4 unmodified lines
55 * workspace thread timeline. It does not store user-facing checkpoint metadata
66 * and does not coordinate provider conversation rollback.
77 *
8− * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed
8+ * Uses Effect `Context.Service` for dependency injection and exposes typed
99 * domain errors for checkpoint storage operations.
1010 *
1111 * @module CheckpointStore
1212 */
13−import { ServiceMap } from "effect";
13+import { Context } from "effect";
1414 import type { Effect } from "effect";
1515
1616 import type { CheckpointStoreError } from "../Errors.ts";
@@ -95,6 +95,6 @@ export interface CheckpointStoreShape { · 78 unmodified lines
9595 /**
9696 * CheckpointStore - Service tag for checkpoint persistence and restore operations.
9797 */
98−export class CheckpointStore extends ServiceMap.Service<CheckpointStore, CheckpointStoreShape>()(
98+export class CheckpointStore extends Context.Service<CheckpointStore, CheckpointStoreShape>()(
9999 "t3/checkpointing/Services/CheckpointStore",
100100 ) {}
apps/server/src/checkpointing/Utils.ts+1−1
@@ -4,7 +4,7 @@ import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; · 3 unmodified lines
44 export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
55
66 export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
7− return CheckpointRef.makeUnsafe(
7+ return CheckpointRef.make(
88 `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`,
99 );
1010 }
apps/server/src/cli-config.test.ts+134−17
@@ -5,8 +5,8 @@ import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect" · 4 unmodified lines
55
66 import { NetService } from "@t3tools/shared/Net";
77 import * as NodeServices from "@effect/platform-node/NodeServices";
8−import { deriveServerPaths } from "./config";
9−import { resolveServerConfig } from "./cli";
8+import { deriveServerPaths } from "./config.ts";
9+import { resolveServerConfig } from "./cli.ts";
1010
1111 it.layer(NodeServices.layer)("cli config resolution", (it) => {
1212 const defaultObservabilityConfig = {
@@ -43,7 +43,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 30 unmodified lines
4343 cwd: Option.none(),
4444 devUrl: Option.none(),
4545 noBrowser: Option.none(),
46− authToken: Option.none(),
4746 bootstrapFd: Option.none(),
4847 autoBootstrapProjectFromCwd: Option.none(),
4948 logWebSocketEvents: Option.none(),
@@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 12 unmodified lines
6261 T3CODE_HOME: baseDir,
6362 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
6463 T3CODE_NO_BROWSER: "true",
65− T3CODE_AUTH_TOKEN: "env-token",
6664 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
6765 T3CODE_LOG_WS_EVENTS: "true",
6866 },
@@ -85,7 +83,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 16 unmodified lines
8583 staticDir: undefined,
8684 devUrl: new URL("http://127.0.0.1:5173"),
8785 noBrowser: true,
88− authToken: "env-token",
86+ startupPresentation: "browser",
87+ desktopBootstrapToken: undefined,
8988 autoBootstrapProjectFromCwd: false,
9089 logWebSocketEvents: true,
9190 });
@@ -106,7 +105,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 14 unmodified lines
106105 cwd: Option.none(),
107106 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
108107 noBrowser: Option.some(true),
109− authToken: Option.some("flag-token"),
110108 bootstrapFd: Option.none(),
111109 autoBootstrapProjectFromCwd: Option.some(true),
112110 logWebSocketEvents: Option.some(true),
@@ -125,7 +123,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 12 unmodified lines
125123 T3CODE_HOME: join(os.tmpdir(), "ignored-base"),
126124 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
127125 T3CODE_NO_BROWSER: "false",
128− T3CODE_AUTH_TOKEN: "ignored-token",
129126 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
130127 T3CODE_LOG_WS_EVENTS: "false",
131128 },
@@ -148,13 +145,77 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 16 unmodified lines
148145 staticDir: undefined,
149146 devUrl: new URL("http://127.0.0.1:4173"),
150147 noBrowser: true,
151− authToken: "flag-token",
148+ startupPresentation: "browser",
149+ desktopBootstrapToken: undefined,
152150 autoBootstrapProjectFromCwd: true,
153151 logWebSocketEvents: true,
154152 });
155153 }),
156154 );
157155
156+ it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () =>
157+ Effect.gen(function* () {
158+ const { join } = yield* Path.Path;
159+ const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags");
160+ const fd = yield* openBootstrapFd({
161+ noBrowser: true,
162+ autoBootstrapProjectFromCwd: true,
163+ logWebSocketEvents: true,
164+ });
165+ const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
166+
167+ const resolved = yield* resolveServerConfig(
168+ {
169+ mode: Option.some("web"),
170+ port: Option.some(8788),
171+ host: Option.some("127.0.0.1"),
172+ baseDir: Option.some(baseDir),
173+ cwd: Option.none(),
174+ devUrl: Option.some(new URL("http://127.0.0.1:4173")),
175+ noBrowser: Option.some(false),
176+ bootstrapFd: Option.none(),
177+ autoBootstrapProjectFromCwd: Option.some(false),
178+ logWebSocketEvents: Option.some(false),
179+ },
180+ Option.none(),
181+ ).pipe(
182+ Effect.provide(
183+ Layer.mergeAll(
184+ ConfigProvider.layer(
185+ ConfigProvider.fromEnv({
186+ env: {
187+ T3CODE_BOOTSTRAP_FD: String(fd),
188+ T3CODE_NO_BROWSER: "true",
189+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
190+ T3CODE_LOG_WS_EVENTS: "true",
191+ },
192+ }),
193+ ),
194+ NetService.layer,
195+ ),
196+ ),
197+ );
198+
199+ expect(resolved).toEqual({
200+ logLevel: "Info",
201+ ...defaultObservabilityConfig,
202+ mode: "web",
203+ port: 8788,
204+ cwd: process.cwd(),
205+ baseDir,
206+ ...derivedPaths,
207+ host: "127.0.0.1",
208+ staticDir: undefined,
209+ devUrl: new URL("http://127.0.0.1:4173"),
210+ noBrowser: false,
211+ startupPresentation: "browser",
212+ desktopBootstrapToken: undefined,
213+ autoBootstrapProjectFromCwd: false,
214+ logWebSocketEvents: false,
215+ });
216+ }),
217+ );
218+
158219 it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () =>
159220 Effect.gen(function* () {
160221 const { join } = yield* Path.Path;
@@ -166,7 +227,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 5 unmodified lines
166227 t3Home: baseDir,
167228 devUrl: "http://127.0.0.1:5173",
168229 noBrowser: true,
169− authToken: "bootstrap-token",
170230 autoBootstrapProjectFromCwd: false,
171231 logWebSocketEvents: true,
172232 otlpTracesUrl: "http://localhost:4318/v1/traces",
@@ -183,7 +243,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 10 unmodified lines
183243 cwd: Option.none(),
184244 devUrl: Option.none(),
185245 noBrowser: Option.none(),
186− authToken: Option.none(),
187246 bootstrapFd: Option.none(),
188247 autoBootstrapProjectFromCwd: Option.none(),
189248 logWebSocketEvents: Option.none(),
@@ -218,7 +277,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 28 unmodified lines
218277 staticDir: undefined,
219278 devUrl: new URL("http://127.0.0.1:5173"),
220279 noBrowser: true,
221− authToken: "bootstrap-token",
280+ startupPresentation: "browser",
281+ desktopBootstrapToken: undefined,
222282 autoBootstrapProjectFromCwd: false,
223283 logWebSocketEvents: true,
224284 });
@@ -242,7 +302,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 17 unmodified lines
242302 cwd: Option.some(customCwd),
243303 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
244304 noBrowser: Option.none(),
245− authToken: Option.none(),
246305 bootstrapFd: Option.none(),
247306 autoBootstrapProjectFromCwd: Option.none(),
248307 logWebSocketEvents: Option.none(),
@@ -285,7 +344,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 36 unmodified lines
285344 t3Home: "/tmp/t3-bootstrap-home",
286345 devUrl: "http://127.0.0.1:5173",
287346 noBrowser: false,
288− authToken: "bootstrap-token",
289347 autoBootstrapProjectFromCwd: false,
290348 logWebSocketEvents: false,
291349 });
@@ -300,7 +358,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 8 unmodified lines
300358 cwd: Option.none(),
301359 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
302360 noBrowser: Option.none(),
303− authToken: Option.some("flag-token"),
304361 bootstrapFd: Option.none(),
305362 autoBootstrapProjectFromCwd: Option.none(),
306363 logWebSocketEvents: Option.none(),
@@ -338,7 +395,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 31 unmodified lines
338395 staticDir: undefined,
339396 devUrl: new URL("http://127.0.0.1:4173"),
340397 noBrowser: true,
341− authToken: "flag-token",
398+ startupPresentation: "browser",
399+ desktopBootstrapToken: undefined,
342400 autoBootstrapProjectFromCwd: true,
343401 logWebSocketEvents: true,
344402 });
@@ -371,7 +429,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 26 unmodified lines
371429 cwd: Option.none(),
372430 devUrl: Option.none(),
373431 noBrowser: Option.none(),
374− authToken: Option.none(),
375432 bootstrapFd: Option.none(),
376433 autoBootstrapProjectFromCwd: Option.none(),
377434 logWebSocketEvents: Option.none(),
@@ -402,7 +459,67 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 24 unmodified lines
402459 staticDir: resolved.staticDir,
403460 devUrl: undefined,
404461 noBrowser: true,
405− authToken: undefined,
462+ startupPresentation: "browser",
463+ desktopBootstrapToken: undefined,
464+ autoBootstrapProjectFromCwd: false,
465+ logWebSocketEvents: false,
466+ });
467+ }),
468+ );
469+
470+ it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>
471+ Effect.gen(function* () {
472+ const { join } = yield* Path.Path;
473+ const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base");
474+ const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
475+
476+ const resolved = yield* resolveServerConfig(
477+ {
478+ mode: Option.some("web"),
479+ port: Option.some(3773),
480+ host: Option.none(),
481+ baseDir: Option.some(baseDir),
482+ cwd: Option.none(),
483+ devUrl: Option.none(),
484+ noBrowser: Option.none(),
485+ bootstrapFd: Option.none(),
486+ autoBootstrapProjectFromCwd: Option.none(),
487+ logWebSocketEvents: Option.none(),
488+ },
489+ Option.none(),
490+ {
491+ startupPresentation: "headless",
492+ },
493+ ).pipe(
494+ Effect.provide(
495+ Layer.mergeAll(
496+ ConfigProvider.layer(
497+ ConfigProvider.fromEnv({
498+ env: {
499+ T3CODE_NO_BROWSER: "false",
500+ T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
501+ },
502+ }),
503+ ),
504+ NetService.layer,
505+ ),
506+ ),
507+ );
508+
509+ expect(resolved).toEqual({
510+ logLevel: "Info",
511+ ...defaultObservabilityConfig,
512+ mode: "web",
513+ port: 3773,
514+ cwd: process.cwd(),
515+ baseDir,
516+ ...derivedPaths,
517+ host: undefined,
518+ staticDir: resolved.staticDir,
519+ devUrl: undefined,
520+ noBrowser: true,
521+ startupPresentation: "headless",
522+ desktopBootstrapToken: undefined,
406523 autoBootstrapProjectFromCwd: false,
407524 logWebSocketEvents: false,
408525 });
apps/server/src/cli.test.ts+326−7
@@ -1,28 +1,164 @@
1+import * as NodeHttp from "node:http";
2+import { mkdtempSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
5+
6+import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
17 import * as NodeServices from "@effect/platform-node/NodeServices";
28 import { NetService } from "@t3tools/shared/Net";
39 import { assert, it } from "@effect/vitest";
410 import * as Effect from "effect/Effect";
511 import * as Layer from "effect/Layer";
12+import * as HttpRouter from "effect/unstable/http/HttpRouter";
13+import * as HttpServer from "effect/unstable/http/HttpServer";
614 import * as CliError from "effect/unstable/cli/CliError";
15+import * as TestConsole from "effect/testing/TestConsole";
716 import { Command } from "effect/unstable/cli";
817
918 import { cli } from "./cli.ts";
19+import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
20+import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
21+import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
22+import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
23+import {
24+ orchestrationDispatchRouteLayer,
25+ orchestrationSnapshotRouteLayer,
26+} from "./orchestration/http.ts";
27+import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
28+import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
29+import {
30+ makePersistedServerRuntimeState,
31+ persistServerRuntimeState,
32+} from "./serverRuntimeState.ts";
33+import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
34+import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
35+import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
1036
1137 const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1238
39+const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
40+const runCliWithRuntime = (args: ReadonlyArray<string>) =>
41+ runCli(args).pipe(Effect.provide(CliRuntimeLayer));
42+
43+const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
44+ Effect.gen(function* () {
45+ const result = yield* effect;
46+ const output =
47+ (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ??
48+ "";
49+ return { result, output };
50+ }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)));
51+
52+const makeCliTestServerConfig = (baseDir: string) =>
53+ Effect.gen(function* () {
54+ const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
55+ return {
56+ logLevel: "Info",
57+ traceMinLevel: "Info",
58+ traceTimingEnabled: true,
59+ traceBatchWindowMs: 200,
60+ traceMaxBytes: 10 * 1024 * 1024,
61+ traceMaxFiles: 10,
62+ otlpTracesUrl: undefined,
63+ otlpMetricsUrl: undefined,
64+ otlpExportIntervalMs: 10_000,
65+ otlpServiceName: "t3-server",
66+ mode: "web",
67+ port: 0,
68+ host: "127.0.0.1",
69+ cwd: process.cwd(),
70+ baseDir,
71+ ...derivedPaths,
72+ staticDir: undefined,
73+ devUrl: undefined,
74+ noBrowser: true,
75+ startupPresentation: "browser",
76+ desktopBootstrapToken: undefined,
77+ autoBootstrapProjectFromCwd: false,
78+ logWebSocketEvents: false,
79+ } satisfies ServerConfigShape;
80+ });
81+
82+const makeProjectPersistenceLayer = (config: ServerConfigShape) =>
83+ Layer.mergeAll(
84+ OrchestrationLayerLive.pipe(
85+ Layer.provideMerge(RepositoryIdentityResolverLive),
86+ Layer.provideMerge(SqlitePersistenceLayerLive),
87+ ),
88+ WorkspacePathsLive,
89+ ).pipe(
90+ Layer.provideMerge(NodeServices.layer),
91+ Layer.provide(Layer.succeed(ServerConfig, config)),
92+ );
93+
94+const readPersistedSnapshot = (baseDir: string) =>
95+ Effect.gen(function* () {
96+ const config = yield* makeCliTestServerConfig(baseDir);
97+ return yield* Effect.gen(function* () {
98+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
99+ return yield* projectionSnapshotQuery.getSnapshot();
100+ }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));
101+ });
102+
103+const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>
104+ Effect.gen(function* () {
105+ const config = yield* makeCliTestServerConfig(baseDir);
106+ const routesLayer = Layer.mergeAll(
107+ orchestrationSnapshotRouteLayer,
108+ orchestrationDispatchRouteLayer,
109+ );
110+ const appLayer = HttpRouter.serve(routesLayer, {
111+ disableListenLog: true,
112+ disableLogger: true,
113+ }).pipe(
114+ Layer.provideMerge(
115+ ServerAuthLive.pipe(
116+ Layer.provideMerge(SqlitePersistenceLayerLive),
117+ Layer.provide(ServerSecretStoreLive),
118+ ),
119+ ),
120+ Layer.provideMerge(makeProjectPersistenceLayer(config)),
121+ Layer.provideMerge(
122+ NodeHttpServer.layer(NodeHttp.createServer, {
123+ host: "127.0.0.1",
124+ port: 0,
125+ }),
126+ ),
127+ Layer.provideMerge(NodeServices.layer),
128+ Layer.provide(Layer.succeed(ServerConfig, config)),
129+ );
130+
131+ return yield* Effect.scoped(
132+ Effect.gen(function* () {
133+ const server = yield* HttpServer.HttpServer;
134+ const address = server.address;
135+ if (typeof address === "string" || !("port" in address)) {
136+ assert.fail(`Expected TCP address, got ${address}`);
137+ }
138+ yield* persistServerRuntimeState({
139+ path: config.serverRuntimeStatePath,
140+ state: makePersistedServerRuntimeState({
141+ config,
142+ port: address.port,
143+ }),
144+ });
145+ return yield* run();
146+ }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))),
147+ );
148+ });
149+
13150 it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
14151 it.effect("accepts the built-in lowercase log-level flag values", () =>
15− Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe(
16− Effect.provide(CliRuntimeLayer),
17− ),
152+ runCliWithRuntime(["--log-level", "debug", "--version"]),
153+ );
154+
155+ it.effect("accepts canonical --no-<flag> boolean negation", () =>
156+ runCliWithRuntime(["--no-log-websocket-events", "--version"]),
18157 );
19158
20159 it.effect("rejects invalid log-level casing before launching the server", () =>
21160 Effect.gen(function* () {
22− const error = yield* Command.runWith(cli, { version: "0.0.0" })([
23− "--log-level",
24− "Debug",
25− ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip);
161+ const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip);
26162
27163 if (!CliError.isCliError(error)) {
28164 assert.fail(`Expected CliError, got ${String(error)}`);
@@ -34,4 +170,187 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { · 5 unmodified lines
34170 assert.equal(error.value, "Debug");
35171 }),
36172 );
173+
174+ it.effect("executes auth pairing subcommands and redacts secrets from list output", () =>
175+ Effect.gen(function* () {
176+ const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-"));
177+
178+ const createdOutput = yield* captureStdout(
179+ runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]),
180+ );
181+ const created = JSON.parse(createdOutput.output) as {
182+ readonly id: string;
183+ readonly credential: string;
184+ };
185+ const listedOutput = yield* captureStdout(
186+ runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
187+ );
188+ const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
189+ readonly id: string;
190+ readonly credential?: string;
191+ }>;
192+
193+ assert.equal(typeof created.id, "string");
194+ assert.equal(typeof created.credential, "string");
195+ assert.equal(created.credential.length > 0, true);
196+ assert.equal(listed.length, 1);
197+ assert.equal(listed[0]?.id, created.id);
198+ assert.equal("credential" in (listed[0] ?? {}), false);
199+ }),
200+ );
201+
202+ it.effect("executes auth session subcommands and redacts secrets from list output", () =>
203+ Effect.gen(function* () {
204+ const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-"));
205+
206+ const issuedOutput = yield* captureStdout(
207+ runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]),
208+ );
209+ const issued = JSON.parse(issuedOutput.output) as {
210+ readonly sessionId: string;
211+ readonly token: string;
212+ readonly role: string;
213+ };
214+ const listedOutput = yield* captureStdout(
215+ runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]),
216+ );
217+ const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
218+ readonly sessionId: string;
219+ readonly token?: string;
220+ readonly role: string;
221+ }>;
222+
223+ assert.equal(typeof issued.sessionId, "string");
224+ assert.equal(typeof issued.token, "string");
225+ assert.equal(issued.role, "owner");
226+ assert.equal(listed.length, 1);
227+ assert.equal(listed[0]?.sessionId, issued.sessionId);
228+ assert.equal(listed[0]?.role, "owner");
229+ assert.equal("token" in (listed[0] ?? {}), false);
230+ }),
231+ );
232+
233+ it.effect("rejects invalid ttl values before running auth commands", () =>
234+ Effect.gen(function* () {
235+ const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe(
236+ Effect.flip,
237+ );
238+
239+ if (!CliError.isCliError(error)) {
240+ assert.fail(`Expected CliError, got ${String(error)}`);
241+ }
242+ if (error._tag !== "ShowHelp") {
243+ assert.fail(`Expected ShowHelp, got ${error._tag}`);
244+ }
245+ assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]);
246+ const ttlError = error.errors[0] as CliError.CliError | undefined;
247+ if (!ttlError || ttlError._tag !== "InvalidValue") {
248+ assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`);
249+ }
250+ assert.equal(ttlError.option, "ttl");
251+ assert.equal(ttlError.value, "soon");
252+ assert.isTrue(ttlError.message.includes("Invalid duration"));
253+ assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes"));
254+ }),
255+ );
256+
257+ it.effect("adds, renames, and removes projects offline through the orchestration engine", () =>
258+ Effect.gen(function* () {
259+ const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-"));
260+ const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-"));
261+
262+ yield* runCliWithRuntime([
263+ "project",
264+ "add",
265+ workspaceRoot,
266+ "--title",
267+ "Alpha",
268+ "--base-dir",
269+ baseDir,
270+ ]);
271+ const afterAdd = yield* readPersistedSnapshot(baseDir);
272+ const addedProject = afterAdd.projects.find(
273+ (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
274+ );
275+ assert.isTrue(addedProject !== undefined);
276+ assert.equal(addedProject?.title, "Alpha");
277+
278+ yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]);
279+ const afterRename = yield* readPersistedSnapshot(baseDir);
280+ const renamedProject = afterRename.projects.find(
281+ (project) => project.id === addedProject?.id,
282+ );
283+ assert.equal(renamedProject?.title, "Beta");
284+ assert.equal(renamedProject?.deletedAt, null);
285+
286+ yield* runCliWithRuntime([
287+ "project",
288+ "remove",
289+ addedProject?.id ?? "",
290+ "--base-dir",
291+ baseDir,
292+ ]);
293+ const afterRemove = yield* readPersistedSnapshot(baseDir);
294+ const removedProject = afterRemove.projects.find(
295+ (project) => project.id === addedProject?.id,
296+ );
297+ assert.isTrue((removedProject?.deletedAt ?? null) !== null);
298+ }),
299+ );
300+
301+ it.effect("routes project commands through a running server when runtime state is present", () =>
302+ Effect.gen(function* () {
303+ const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-"));
304+ const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-"));
305+
306+ yield* withLiveProjectCliServer(baseDir, () =>
307+ Effect.gen(function* () {
308+ yield* runCliWithRuntime([
309+ "project",
310+ "add",
311+ workspaceRoot,
312+ "--title",
313+ "Live Project",
314+ "--base-dir",
315+ baseDir,
316+ ]);
317+ const orchestrationEngine = yield* OrchestrationEngineService;
318+ const readModel = yield* orchestrationEngine.getReadModel();
319+ const addedProject = readModel.projects.find(
320+ (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
321+ );
322+ assert.isTrue(addedProject !== undefined);
323+ assert.equal(addedProject?.title, "Live Project");
324+ }),
325+ );
326+ }),
327+ );
328+
329+ it.effect("rejects dev-url on project commands", () =>
330+ Effect.gen(function* () {
331+ const workspaceRoot = mkdtempSync(
332+ join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"),
333+ );
334+ const error = yield* runCliWithRuntime([
335+ "project",
336+ "add",
337+ workspaceRoot,
338+ "--dev-url",
339+ "http://127.0.0.1:5173",
340+ ]).pipe(Effect.flip);
341+
342+ if (!CliError.isCliError(error)) {
343+ assert.fail(`Expected CliError, got ${String(error)}`);
344+ }
345+ if (error._tag !== "ShowHelp") {
346+ assert.fail(`Expected ShowHelp, got ${error._tag}`);
347+ }
348+ assert.deepEqual(error.commandPath, ["t3", "project", "add"]);
349+ const optionError = error.errors[0] as CliError.CliError | undefined;
350+ if (!optionError || optionError._tag !== "UnrecognizedOption") {
351+ assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`);
352+ }
353+ assert.equal(optionError.option, "--dev-url");
354+ }),
355+ );
37356 });
apps/server/src/cli.ts+854−89
@@ -1,7 +1,35 @@
11 import { NetService } from "@t3tools/shared/Net";
22 import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3−import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect";
3+import {
4+ AuthSessionId,
5+ CommandId,
6+ OrchestrationReadModel,
7+ ProjectId,
8+ type ClientOrchestrationCommand,
9+} from "@t3tools/contracts";
10+import {
11+ Config,
12+ Console,
13+ Duration,
14+ Effect,
15+ Exit,
16+ FileSystem,
17+ Layer,
18+ LogLevel,
19+ Option,
20+ Path,
21+ References,
22+ Schema,
23+ SchemaIssue,
24+ SchemaTransformation,
25+} from "effect";
426 import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
27+import {
28+ FetchHttpClient,
29+ HttpClient,
30+ HttpClientRequest,
31+ HttpClientResponse,
32+} from "effect/unstable/http";
533
634 import {
735 DEFAULT_PORT,
@@ -11,10 +39,32 @@ import { · 3 unmodified lines
1139 ServerConfig,
1240 RuntimeMode,
1341 type ServerConfigShape,
14−} from "./config";
15−import { readBootstrapEnvelope } from "./bootstrap";
16−import { expandHomePath, resolveBaseDir } from "./os-jank";
17−import { runServer } from "./server";
42+ type StartupPresentation,
43+} from "./config.ts";
44+import { readBootstrapEnvelope } from "./bootstrap.ts";
45+import { expandHomePath, resolveBaseDir } from "./os-jank.ts";
46+import { runServer } from "./server.ts";
47+import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts";
48+import {
49+ formatIssuedPairingCredential,
50+ formatIssuedSession,
51+ formatPairingCredentialList,
52+ formatSessionList,
53+} from "./cliAuthFormat.ts";
54+import { AuthControlPlane } from "./auth/Services/AuthControlPlane.ts";
55+import type { AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts";
56+import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
57+import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
58+import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
59+import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
60+import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
61+import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup.ts";
62+import {
63+ clearPersistedServerRuntimeState,
64+ readPersistedServerRuntimeState,
65+} from "./serverRuntimeState.ts";
66+import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
67+import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
1868
1969 const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
2070
@@ -25,7 +75,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ · 4 unmodified lines
2575 t3Home: Schema.optional(Schema.String),
2676 devUrl: Schema.optional(Schema.URLFromString),
2777 noBrowser: Schema.optional(Schema.Boolean),
28− authToken: Schema.optional(Schema.String),
78+ desktopBootstrapToken: Schema.optional(Schema.String),
2979 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
3080 logWebSocketEvents: Schema.optional(Schema.Boolean),
3181 otlpTracesUrl: Schema.optional(Schema.String),
@@ -58,11 +108,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( · 26 unmodified lines
58108 Flag.withDescription("Disable automatic browser opening."),
59109 Flag.optional,
60110 );
61−const authTokenFlag = Flag.string("auth-token").pipe(
62− Flag.withDescription("Auth token required for WebSocket connections."),
63− Flag.withAlias("token"),
64− Flag.optional,
65−);
66111 const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
67112 Flag.withSchema(Schema.Int),
68113 Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
@@ -117,10 +162,6 @@ const EnvServerConfig = Config.all({ · 48 unmodified lines
117162 Config.option,
118163 Config.map(Option.getOrUndefined),
119164 ),
120− authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
121− Config.option,
122− Config.map(Option.getOrUndefined),
123− ),
124165 bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
125166 Config.option,
126167 Config.map(Option.getOrUndefined),
@@ -143,14 +184,15 @@ interface CliServerFlags { · 16 unmodified lines
143184 readonly cwd: Option.Option<string>;
144185 readonly devUrl: Option.Option<URL>;
145186 readonly noBrowser: Option.Option<boolean>;
146− readonly authToken: Option.Option<string>;
147187 readonly bootstrapFd: Option.Option<number>;
148188 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
149189 readonly logWebSocketEvents: Option.Option<boolean>;
150190 }
151191
152−const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>
153− Option.getOrElse(Option.filter(flag, Boolean), () => envValue);
192+interface CliAuthLocationFlags {
193+ readonly baseDir: Option.Option<string>;
194+ readonly devUrl?: Option.Option<URL>;
195+}
154196
155197 const resolveOptionPrecedence = <Value>(
156198 ...values: ReadonlyArray<Option.Option<Value>>
@@ -170,32 +212,49 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st · 13 unmodified lines
170212 export const resolveServerConfig = (
171213 flags: CliServerFlags,
172214 cliLogLevel: Option.Option<LogLevel.LogLevel>,
215+ options?: {
216+ readonly startupPresentation?: StartupPresentation;
217+ readonly forceAutoBootstrapProjectFromCwd?: boolean;
218+ },
173219 ) =>
174220 Effect.gen(function* () {
175221 const { findAvailablePort } = yield* NetService;
176222 const path = yield* Path.Path;
177223 const fs = yield* FileSystem.FileSystem;
178224 const env = yield* EnvServerConfig;
179− const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd;
225+ const normalizedFlags = {
226+ mode: flags.mode ?? Option.none(),
227+ port: flags.port ?? Option.none(),
228+ host: flags.host ?? Option.none(),
229+ baseDir: flags.baseDir ?? Option.none(),
230+ cwd: flags.cwd ?? Option.none(),
231+ devUrl: flags.devUrl ?? Option.none(),
232+ noBrowser: flags.noBrowser ?? Option.none(),
233+ bootstrapFd: flags.bootstrapFd ?? Option.none(),
234+ autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),
235+ logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
236+ } satisfies CliServerFlags;
237+ const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
180238 const bootstrapEnvelope =
181239 bootstrapFd !== undefined
182240 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
183241 : Option.none();
242+ const bootstrap = Option.getOrUndefined(bootstrapEnvelope);
184243
185244 const mode: RuntimeMode = Option.getOrElse(
186245 resolveOptionPrecedence(
187− flags.mode,
246+ normalizedFlags.mode,
188247 Option.fromUndefinedOr(env.mode),
189− Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)),
248+ Option.fromUndefinedOr(bootstrap?.mode),
190249 ),
191250 () => "web",
192251 );
193252
194253 const port = yield* Option.match(
195254 resolveOptionPrecedence(
196− flags.port,
255+ normalizedFlags.port,
197256 Option.fromUndefinedOr(env.port),
198− Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)),
257+ Option.fromUndefinedOr(bootstrap?.port),
199258 ),
200259 {
201260 onSome: (value) => Effect.succeed(value),
@@ -209,24 +268,22 @@ export const resolveServerConfig = ( · 7 unmodified lines
209268 );
210269 const devUrl = Option.getOrElse(
211270 resolveOptionPrecedence(
212− flags.devUrl,
271+ normalizedFlags.devUrl,
213272 Option.fromUndefinedOr(env.devUrl),
214− Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)),
273+ Option.fromUndefinedOr(bootstrap?.devUrl),
215274 ),
216275 () => undefined,
217276 );
218277 const baseDir = yield* resolveBaseDir(
219278 Option.getOrUndefined(
220279 resolveOptionPrecedence(
221− flags.baseDir,
280+ normalizedFlags.baseDir,
222281 Option.fromUndefinedOr(env.t3Home),
223− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
224− Option.fromUndefinedOr(bootstrap.t3Home),
225− ),
282+ Option.fromUndefinedOr(bootstrap?.t3Home),
226283 ),
227284 ),
228285 );
229− const rawCwd = Option.getOrElse(flags.cwd, () => process.cwd());
286+ const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
230287 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
231288 yield* fs.makeDirectory(cwd, { recursive: true });
232289 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
@@ -236,57 +293,42 @@ export const resolveServerConfig = ( · 3 unmodified lines
236293 );
237294 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
238295 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
239− const noBrowser = resolveBooleanFlag(
240− flags.noBrowser,
241− Option.getOrElse(
242− resolveOptionPrecedence(
243− Option.fromUndefinedOr(env.noBrowser),
244− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
245− Option.fromUndefinedOr(bootstrap.noBrowser),
246− ),
247− ),
248− () => mode === "desktop",
249− ),
250− );
251− const authToken = Option.getOrUndefined(
296+ const startupPresentation = options?.startupPresentation ?? "browser";
297+ const isHeadlessStartup = startupPresentation === "headless";
298+ const noBrowser = Option.getOrElse(
252299 resolveOptionPrecedence(
253− flags.authToken,
254− Option.fromUndefinedOr(env.authToken),
255− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
256− Option.fromUndefinedOr(bootstrap.authToken),
257− ),
300+ isHeadlessStartup ? Option.some(true) : Option.none(),
301+ normalizedFlags.noBrowser,
302+ Option.fromUndefinedOr(env.noBrowser),
303+ Option.fromUndefinedOr(bootstrap?.noBrowser),
258304 ),
305+ () => mode === "desktop",
259306 );
260− const autoBootstrapProjectFromCwd = resolveBooleanFlag(
261− flags.autoBootstrapProjectFromCwd,
262− Option.getOrElse(
263− resolveOptionPrecedence(
264− Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
265− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
266− Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),
267− ),
268− ),
269− () => mode === "web",
307+ const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
308+ const autoBootstrapProjectFromCwd = Option.getOrElse(
309+ resolveOptionPrecedence(
310+ Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),
311+ isHeadlessStartup ? Option.some(false) : Option.none(),
312+ normalizedFlags.autoBootstrapProjectFromCwd,
313+ Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
314+ Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd),
270315 ),
316+ () => mode === "web",
271317 );
272− const logWebSocketEvents = resolveBooleanFlag(
273− flags.logWebSocketEvents,
274− Option.getOrElse(
275− resolveOptionPrecedence(
276− Option.fromUndefinedOr(env.logWebSocketEvents),
277− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
278− Option.fromUndefinedOr(bootstrap.logWebSocketEvents),
279− ),
280− ),
281− () => Boolean(devUrl),
318+ const logWebSocketEvents = Option.getOrElse(
319+ resolveOptionPrecedence(
320+ normalizedFlags.logWebSocketEvents,
321+ Option.fromUndefinedOr(env.logWebSocketEvents),
322+ Option.fromUndefinedOr(bootstrap?.logWebSocketEvents),
282323 ),
324+ () => Boolean(devUrl),
283325 );
284326 const staticDir = devUrl ? undefined : yield* resolveStaticDir();
285327 const host = Option.getOrElse(
286328 resolveOptionPrecedence(
287− flags.host,
329+ normalizedFlags.host,
288330 Option.fromUndefinedOr(env.host),
289− Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),
331+ Option.fromUndefinedOr(bootstrap?.host),
290332 ),
291333 () => (mode === "desktop" ? "127.0.0.1" : undefined),
292334 );
@@ -301,19 +343,11 @@ export const resolveServerConfig = ( · 8 unmodified lines
301343 traceMaxFiles: env.traceMaxFiles,
302344 otlpTracesUrl:
303345 env.otlpTracesUrl ??
304− Option.getOrUndefined(
305− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
306− Option.fromUndefinedOr(bootstrap.otlpTracesUrl),
307− ),
308− ) ??
346+ bootstrap?.otlpTracesUrl ??
309347 persistedObservabilitySettings.otlpTracesUrl,
310348 otlpMetricsUrl:
311349 env.otlpMetricsUrl ??
312− Option.getOrUndefined(
313− Option.flatMap(bootstrapEnvelope, (bootstrap) =>
314− Option.fromUndefinedOr(bootstrap.otlpMetricsUrl),
315− ),
316− ) ??
350+ bootstrap?.otlpMetricsUrl ??
317351 persistedObservabilitySettings.otlpMetricsUrl,
318352 otlpExportIntervalMs: env.otlpExportIntervalMs,
319353 otlpServiceName: env.otlpServiceName,
@@ -327,7 +361,8 @@ export const resolveServerConfig = ( · 7 unmodified lines
327361 staticDir,
328362 devUrl,
329363 noBrowser,
330− authToken,
364+ startupPresentation,
365+ desktopBootstrapToken,
331366 autoBootstrapProjectFromCwd,
332367 logWebSocketEvents,
333368 };
@@ -335,7 +370,387 @@ export const resolveServerConfig = ( · 1 unmodified line
335370 return config;
336371 });
337372
338−const commandFlags = {
373+const resolveCliAuthConfig = (
374+ flags: CliAuthLocationFlags,
375+ cliLogLevel: Option.Option<LogLevel.LogLevel>,
376+) =>
377+ resolveServerConfig(
378+ {
379+ mode: Option.none(),
380+ port: Option.none(),
381+ host: Option.none(),
382+ baseDir: flags.baseDir,
383+ cwd: Option.none(),
384+ devUrl: flags.devUrl ?? Option.none(),
385+ noBrowser: Option.none(),
386+ bootstrapFd: Option.none(),
387+ autoBootstrapProjectFromCwd: Option.none(),
388+ logWebSocketEvents: Option.none(),
389+ },
390+ cliLogLevel,
391+ );
392+
393+const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i;
394+
395+const parseDurationInput = (value: string): Duration.Duration | null => {
396+ const trimmed = value.trim();
397+ if (trimmed.length === 0) return null;
398+
399+ const shorthand = DurationShorthandPattern.exec(trimmed);
400+ const normalizedInput = shorthand?.groups
401+ ? (() => {
402+ const amountText = shorthand.groups.value;
403+ const unitText = shorthand.groups.unit;
404+ if (typeof amountText !== "string" || typeof unitText !== "string") {
405+ return null;
406+ }
407+
408+ const amount = Number.parseInt(amountText, 10);
409+ if (!Number.isFinite(amount)) return null;
410+
411+ switch (unitText.toLowerCase()) {
412+ case "ms":
413+ return `${amount} millis`;
414+ case "s":
415+ return `${amount} seconds`;
416+ case "m":
417+ return `${amount} minutes`;
418+ case "h":
419+ return `${amount} hours`;
420+ case "d":
421+ return `${amount} days`;
422+ case "w":
423+ return `${amount} weeks`;
424+ default:
425+ return null;
426+ }
427+ })()
428+ : (trimmed as Duration.Input);
429+
430+ if (normalizedInput === null) return null;
431+
432+ const decoded = Duration.fromInput(normalizedInput as Duration.Input);
433+ return Option.isSome(decoded) ? decoded.value : null;
434+};
435+
436+const DurationFromString = Schema.String.pipe(
437+ Schema.decodeTo(
438+ Schema.Duration,
439+ SchemaTransformation.transformOrFail({
440+ decode: (value) => {
441+ const duration = parseDurationInput(value);
442+ if (duration !== null) {
443+ return Effect.succeed(duration);
444+ }
445+ return Effect.fail(
446+ new SchemaIssue.InvalidValue(Option.some(value), {
447+ message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.",
448+ }),
449+ );
450+ },
451+ encode: (duration) => Effect.succeed(Duration.format(duration)),
452+ }),
453+ ),
454+);
455+
456+const runWithAuthControlPlane = <A, E>(
457+ flags: CliAuthLocationFlags,
458+ run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>,
459+ options?: {
460+ readonly quietLogs?: boolean;
461+ },
462+) =>
463+ Effect.gen(function* () {
464+ const logLevel = yield* GlobalFlag.LogLevel;
465+ const config = yield* resolveCliAuthConfig(flags, logLevel);
466+ const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
467+ return yield* Effect.gen(function* () {
468+ const authControlPlane = yield* AuthControlPlane;
469+ return yield* run(authControlPlane);
470+ }).pipe(
471+ Effect.provide(
472+ Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe(
473+ Layer.provide(Layer.succeed(ServerConfig, config)),
474+ Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
475+ ),
476+ ),
477+ );
478+ });
479+
480+type ProjectMutationTarget = {
481+ readonly id: ProjectId;
482+ readonly title: string;
483+ readonly workspaceRoot: string;
484+};
485+
486+type ProjectCommandExecutionMode = "live" | "offline";
487+type ProjectCliDispatchCommand = Extract<
488+ ClientOrchestrationCommand,
489+ { type: "project.create" | "project.meta.update" | "project.delete" }
490+>;
491+
492+const ProjectCliRuntimeLive = Layer.mergeAll(
493+ WorkspacePathsLive,
494+ OrchestrationLayerLive.pipe(
495+ Layer.provideMerge(RepositoryIdentityResolverLive),
496+ Layer.provideMerge(SqlitePersistenceLayerLive),
497+ ),
498+);
499+
500+const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
501+const OrchestrationHttpErrorResponse = Schema.Struct({
502+ error: Schema.String,
503+});
504+
505+const withProjectCliSessionToken = <A, E, R>(
506+ authControlPlane: AuthControlPlaneShape,
507+ run: (token: string) => Effect.Effect<A, E, R>,
508+) =>
509+ Effect.acquireUseRelease(
510+ authControlPlane.issueSession({
511+ role: "owner",
512+ label: "t3 project cli",
513+ }),
514+ (issued) => run(issued.token),
515+ (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
516+ );
517+
518+const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
519+ effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
520+
521+const runLiveServerRequest = <A, E extends Error, R>(
522+ request: HttpClientRequest.HttpClientRequest,
523+ handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,
524+) =>
525+ Effect.gen(function* () {
526+ const httpClient = yield* HttpClient.HttpClient;
527+ const response = yield* httpClient.execute(request);
528+ return yield* handle(response);
529+ }).pipe(withProjectCliLiveServerTimeout);
530+
531+const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) =>
532+ HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response);
533+
534+const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) =>
535+ HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe(
536+ Effect.map((body) => body.error),
537+ Effect.catch(() => Effect.succeed(null)),
538+ Effect.map((body) => {
539+ if (typeof body === "string" && body.trim().length > 0) {
540+ return body;
541+ }
542+ return `Server request failed with status ${response.status}.`;
543+ }),
544+ );
545+
546+const normalizeWorkspaceRootForProjectCommand = Effect.fn(
547+ "normalizeWorkspaceRootForProjectCommand",
548+)(function* (workspaceRoot: string) {
549+ const workspacePaths = yield* WorkspacePaths;
550+ return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot);
551+});
552+
553+const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
554+ workspaceRoot: string,
555+ explicitTitle?: string,
556+) {
557+ if (explicitTitle !== undefined) {
558+ const trimmed = explicitTitle.trim();
559+ if (trimmed.length > 0) {
560+ return trimmed;
561+ }
562+ return yield* Effect.fail(new Error("Project title cannot be empty."));
563+ }
564+
565+ const path = yield* Path.Path;
566+ const basename = path.basename(workspaceRoot).trim();
567+ return basename.length > 0 ? basename : "project";
568+});
569+
570+const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: {
571+ readonly snapshot: OrchestrationReadModel;
572+ readonly identifier: string;
573+}) {
574+ const trimmedIdentifier = input.identifier.trim();
575+ if (trimmedIdentifier.length === 0) {
576+ return yield* Effect.fail(new Error("Project identifier cannot be empty."));
577+ }
578+
579+ const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);
580+ const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier);
581+ if (exactIdMatch) {
582+ return {
583+ id: exactIdMatch.id,
584+ title: exactIdMatch.title,
585+ workspaceRoot: exactIdMatch.workspaceRoot,
586+ } satisfies ProjectMutationTarget;
587+ }
588+
589+ const normalizedWorkspaceRootResult = yield* Effect.exit(
590+ normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),
591+ );
592+ const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult)
593+ ? normalizedWorkspaceRootResult.value
594+ : null;
595+
596+ const exactWorkspaceMatch =
597+ normalizedWorkspaceRoot === null
598+ ? undefined
599+ : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot);
600+
601+ const resolved = exactWorkspaceMatch;
602+ if (!resolved) {
603+ return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`));
604+ }
605+
606+ return {
607+ id: resolved.id,
608+ title: resolved.title,
609+ workspaceRoot: resolved.workspaceRoot,
610+ } satisfies ProjectMutationTarget;
611+});
612+
613+const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
614+ runLiveServerRequest(
615+ HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe(
616+ HttpClientRequest.acceptJson,
617+ HttpClientRequest.bearerToken(bearerToken),
618+ ),
619+ HttpClientResponse.matchStatus({
620+ "2xx": decodeOrchestrationReadModelResponse,
621+ orElse: (response) =>
622+ readErrorMessageFromResponse(response).pipe(
623+ Effect.flatMap((message) => Effect.fail(new Error(message))),
624+ ),
625+ }),
626+ );
627+
628+const dispatchLiveOrchestrationCommand = (
629+ origin: string,
630+ bearerToken: string,
631+ command: ProjectCliDispatchCommand,
632+) =>
633+ HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe(
634+ HttpClientRequest.acceptJson,
635+ HttpClientRequest.bearerToken(bearerToken),
636+ HttpClientRequest.bodyJson(command),
637+ Effect.flatMap((request) =>
638+ runLiveServerRequest(
639+ request,
640+ HttpClientResponse.matchStatus({
641+ "2xx": () => Effect.void,
642+ orElse: (response) =>
643+ readErrorMessageFromResponse(response).pipe(
644+ Effect.flatMap((message) => Effect.fail(new Error(message))),
645+ ),
646+ }),
647+ ),
648+ ),
649+ );
650+
651+const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
652+ const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
653+ return yield* projectionSnapshotQuery.getSnapshot();
654+});
655+
656+const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
657+ function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) {
658+ const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
659+ if (Option.isNone(runtimeState)) {
660+ return Option.none<{ readonly origin: string }>();
661+ }
662+
663+ const attempt = withProjectCliSessionToken(authControlPlane, (token) =>
664+ fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(
665+ Effect.as({
666+ origin: runtimeState.value.origin,
667+ }),
668+ ),
669+ );
670+
671+ const attempted = yield* Effect.exit(attempt);
672+ if (Exit.isSuccess(attempted)) {
673+ return Option.some(attempted.value);
674+ }
675+
676+ yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
677+ return Option.none<{ readonly origin: string }>();
678+ },
679+);
680+
681+const runProjectMutation = Effect.fn("runProjectMutation")(function* (
682+ flags: CliAuthLocationFlags,
683+ run: (input: {
684+ readonly snapshot: OrchestrationReadModel;
685+ readonly dispatch: (
686+ command: ProjectCliDispatchCommand,
687+ ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
688+ readonly mode: ProjectCommandExecutionMode;
689+ }) => Effect.Effect<
690+ string,
691+ Error,
692+ FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths
693+ >,
694+) {
695+ const logLevel = yield* GlobalFlag.LogLevel;
696+ const config = yield* resolveCliAuthConfig(flags, logLevel);
697+ const minimumLogLevel = config.logLevel;
698+
699+ return yield* Effect.gen(function* () {
700+ const authControlPlane = yield* AuthControlPlane;
701+ const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config);
702+
703+ if (Option.isSome(liveMode)) {
704+ return yield* withProjectCliSessionToken(authControlPlane, (token) =>
705+ Effect.gen(function* () {
706+ const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);
707+ const output = yield* run({
708+ snapshot,
709+ dispatch: (command) =>
710+ dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command),
711+ mode: "live",
712+ });
713+ yield* Console.log(output);
714+ }),
715+ );
716+ }
717+
718+ const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe(
719+ Layer.provide(Layer.succeed(ServerConfig, config)),
720+ Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
721+ );
722+
723+ return yield* Effect.gen(function* () {
724+ const snapshot = yield* getOfflineSnapshot();
725+ const orchestrationEngine = yield* OrchestrationEngineService;
726+ const output = yield* run({
727+ snapshot,
728+ dispatch: (command) => orchestrationEngine.dispatch(command),
729+ mode: "offline",
730+ });
731+ yield* Console.log(output);
732+ }).pipe(Effect.provide(offlineRuntimeLayer));
733+ }).pipe(
734+ Effect.provide(
735+ Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe(
736+ Layer.provideMerge(FetchHttpClient.layer),
737+ Layer.provide(Layer.succeed(ServerConfig, config)),
738+ Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
739+ ),
740+ ),
741+ );
742+});
743+
744+const sharedServerLocationFlags = {
745+ baseDir: baseDirFlag,
746+ devUrl: devUrlFlag,
747+} as const;
748+
749+const projectLocationFlags = {
750+ baseDir: baseDirFlag,
751+} as const;
752+
753+const sharedServerCommandFlags = {
339754 mode: modeFlag,
340755 port: portFlag,
341756 host: hostFlag,
@@ -348,21 +763,371 @@ const commandFlags = { · 6 unmodified lines
348763 ),
349764 devUrl: devUrlFlag,
350765 noBrowser: noBrowserFlag,
351− authToken: authTokenFlag,
352766 bootstrapFd: bootstrapFdFlag,
353767 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
354768 logWebSocketEvents: logWebSocketEventsFlag,
355769 } as const;
356770
357−const rootCommand = Command.make("t3", commandFlags).pipe(
771+const authLocationFlags = sharedServerLocationFlags;
772+
773+const ttlFlag = Flag.string("ttl").pipe(
774+ Flag.withSchema(DurationFromString),
775+ Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."),
776+ Flag.optional,
777+);
778+
779+const jsonFlag = Flag.boolean("json").pipe(
780+ Flag.withDescription("Emit JSON instead of human-readable output."),
781+ Flag.withDefault(false),
782+);
783+
784+const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe(
785+ Flag.withDescription("Role for the issued bearer session."),
786+ Flag.withDefault("owner"),
787+);
788+
789+const labelFlag = Flag.string("label").pipe(
790+ Flag.withDescription("Optional human-readable label."),
791+ Flag.optional,
792+);
793+
794+const subjectFlag = Flag.string("subject").pipe(
795+ Flag.withDescription("Optional session subject."),
796+ Flag.optional,
797+);
798+
799+const baseUrlFlag = Flag.string("base-url").pipe(
800+ Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."),
801+ Flag.optional,
802+);
803+
804+const tokenOnlyFlag = Flag.boolean("token-only").pipe(
805+ Flag.withDescription("Print only the issued bearer token."),
806+ Flag.withDefault(false),
807+);
808+
809+const pairingCreateCommand = Command.make("create", {
810+ ...authLocationFlags,
811+ ttl: ttlFlag,
812+ label: labelFlag,
813+ baseUrl: baseUrlFlag,
814+ json: jsonFlag,
815+}).pipe(
816+ Command.withDescription("Issue a new client pairing token."),
817+ Command.withHandler((flags) =>
818+ runWithAuthControlPlane(
819+ flags,
820+ (authControlPlane) =>
821+ Effect.gen(function* () {
822+ const issued = yield* authControlPlane.createPairingLink({
823+ role: "client",
824+ subject: "one-time-token",
825+ ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
826+ ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
827+ });
828+ const output = formatIssuedPairingCredential(issued, {
829+ json: flags.json,
830+ ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}),
831+ });
832+ yield* Console.log(output);
833+ }),
834+ {
835+ quietLogs: flags.json,
836+ },
837+ ),
838+ ),
839+);
840+
841+const pairingListCommand = Command.make("list", {
842+ ...authLocationFlags,
843+ json: jsonFlag,
844+}).pipe(
845+ Command.withDescription("List active client pairing tokens without revealing their secrets."),
846+ Command.withHandler((flags) =>
847+ runWithAuthControlPlane(
848+ flags,
849+ (authControlPlane) =>
850+ Effect.gen(function* () {
851+ const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" });
852+ yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json }));
853+ }),
854+ {
855+ quietLogs: flags.json,
856+ },
857+ ),
858+ ),
859+);
860+
861+const pairingRevokeCommand = Command.make("revoke", {
862+ ...authLocationFlags,
863+ id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")),
864+}).pipe(
865+ Command.withDescription("Revoke an active client pairing token."),
866+ Command.withHandler((flags) =>
867+ runWithAuthControlPlane(flags, (authControlPlane) =>
868+ Effect.gen(function* () {
869+ const revoked = yield* authControlPlane.revokePairingLink(flags.id);
870+ yield* Console.log(
871+ revoked
872+ ? `Revoked pairing credential ${flags.id}.\n`
873+ : `No active pairing credential found for ${flags.id}.\n`,
874+ );
875+ }),
876+ ),
877+ ),
878+);
879+
880+const pairingCommand = Command.make("pairing").pipe(
881+ Command.withDescription("Manage one-time client pairing tokens."),
882+ Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]),
883+);
884+
885+const sessionIssueCommand = Command.make("issue", {
886+ ...authLocationFlags,
887+ ttl: ttlFlag,
888+ role: sessionRoleFlag,
889+ label: labelFlag,
890+ subject: subjectFlag,
891+ tokenOnly: tokenOnlyFlag,
892+ json: jsonFlag,
893+}).pipe(
894+ Command.withDescription("Issue a bearer session token for headless or remote clients."),
895+ Command.withHandler((flags) =>
896+ runWithAuthControlPlane(
897+ flags,
898+ (authControlPlane) =>
899+ Effect.gen(function* () {
900+ const issued = yield* authControlPlane.issueSession({
901+ role: flags.role,
902+ ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
903+ ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
904+ ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}),
905+ });
906+ yield* Console.log(
907+ formatIssuedSession(issued, {
908+ json: flags.json,
909+ tokenOnly: flags.tokenOnly,
910+ }),
911+ );
912+ }),
913+ {
914+ quietLogs: flags.json || flags.tokenOnly,
915+ },
916+ ),
917+ ),
918+);
919+
920+const sessionListCommand = Command.make("list", {
921+ ...authLocationFlags,
922+ json: jsonFlag,
923+}).pipe(
924+ Command.withDescription("List active sessions without revealing bearer tokens."),
925+ Command.withHandler((flags) =>
926+ runWithAuthControlPlane(
927+ flags,
928+ (authControlPlane) =>
929+ Effect.gen(function* () {
930+ const sessions = yield* authControlPlane.listSessions();
931+ yield* Console.log(formatSessionList(sessions, { json: flags.json }));
932+ }),
933+ {
934+ quietLogs: flags.json,
935+ },
936+ ),
937+ ),
938+);
939+
940+const sessionRevokeCommand = Command.make("revoke", {
941+ ...authLocationFlags,
942+ sessionId: Argument.string("session-id").pipe(
943+ Argument.withDescription("Session id to revoke."),
944+ Argument.withSchema(AuthSessionId),
945+ ),
946+}).pipe(
947+ Command.withDescription("Revoke an active session."),
948+ Command.withHandler((flags) =>
949+ runWithAuthControlPlane(flags, (authControlPlane) =>
950+ Effect.gen(function* () {
951+ const revoked = yield* authControlPlane.revokeSession(flags.sessionId);
952+ yield* Console.log(
953+ revoked
954+ ? `Revoked session ${flags.sessionId}.\n`
955+ : `No active session found for ${flags.sessionId}.\n`,
956+ );
957+ }),
958+ ),
959+ ),
960+);
961+
962+const sessionCommand = Command.make("session").pipe(
963+ Command.withDescription("Manage bearer sessions."),
964+ Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]),
965+);
966+
967+const authCommand = Command.make("auth").pipe(
968+ Command.withDescription("Manage the local auth control plane for headless deployments."),
969+ Command.withSubcommands([pairingCommand, sessionCommand]),
970+);
971+
972+const projectAddCommand = Command.make("add", {
973+ ...projectLocationFlags,
974+ workspaceRoot: Argument.string("path").pipe(
975+ Argument.withDescription("Workspace root to add as a project."),
976+ ),
977+ title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional),
978+}).pipe(
979+ Command.withDescription("Add a project."),
980+ Command.withHandler((flags) =>
981+ runProjectMutation(
982+ flags,
983+ Effect.fn("projectAddMutation")(function* ({
984+ snapshot,
985+ dispatch,
986+ }: {
987+ readonly snapshot: OrchestrationReadModel;
988+ readonly dispatch: (
989+ command: ProjectCliDispatchCommand,
990+ ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
991+ }) {
992+ const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot);
993+ const existingProject = snapshot.projects.find(
994+ (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,
995+ );
996+ if (existingProject) {
997+ return yield* Effect.fail(
998+ new Error(`An active project already exists for '${workspaceRoot}'.`),
999+ );
1000+ }
1001+
1002+ const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title));
1003+ const projectId = ProjectId.make(crypto.randomUUID());
1004+ yield* dispatch({
1005+ type: "project.create",
1006+ commandId: CommandId.make(crypto.randomUUID()),
1007+ projectId,
1008+ title,
1009+ workspaceRoot,
1010+ defaultModelSelection: getAutoBootstrapDefaultModelSelection(),
1011+ createdAt: new Date().toISOString(),
1012+ });
1013+ return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
1014+ }),
1015+ ),
1016+ ),
1017+);
1018+
1019+const projectRemoveCommand = Command.make("remove", {
1020+ ...projectLocationFlags,
1021+ project: Argument.string("project").pipe(
1022+ Argument.withDescription("Project id or workspace root to remove."),
1023+ ),
1024+}).pipe(
1025+ Command.withDescription("Remove a project."),
1026+ Command.withHandler((flags) =>
1027+ runProjectMutation(
1028+ flags,
1029+ Effect.fn("projectRemoveMutation")(function* ({
1030+ snapshot,
1031+ dispatch,
1032+ }: {
1033+ readonly snapshot: OrchestrationReadModel;
1034+ readonly dispatch: (
1035+ command: ProjectCliDispatchCommand,
1036+ ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1037+ }) {
1038+ const project = yield* findActiveProjectTarget({
1039+ snapshot,
1040+ identifier: flags.project,
1041+ });
1042+ yield* dispatch({
1043+ type: "project.delete",
1044+ commandId: CommandId.make(crypto.randomUUID()),
1045+ projectId: project.id,
1046+ });
1047+ return `Removed project ${project.id} (${project.title}).`;
1048+ }),
1049+ ),
1050+ ),
1051+);
1052+
1053+const projectRenameCommand = Command.make("rename", {
1054+ ...projectLocationFlags,
1055+ project: Argument.string("project").pipe(
1056+ Argument.withDescription("Project id or workspace root to rename."),
1057+ ),
1058+ title: Argument.string("title").pipe(Argument.withDescription("New project title.")),
1059+}).pipe(
1060+ Command.withDescription("Rename a project."),
1061+ Command.withHandler((flags) =>
1062+ runProjectMutation(
1063+ flags,
1064+ Effect.fn("projectRenameMutation")(function* ({
1065+ snapshot,
1066+ dispatch,
1067+ }: {
1068+ readonly snapshot: OrchestrationReadModel;
1069+ readonly dispatch: (
1070+ command: ProjectCliDispatchCommand,
1071+ ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1072+ }) {
1073+ const project = yield* findActiveProjectTarget({
1074+ snapshot,
1075+ identifier: flags.project,
1076+ });
1077+ const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title);
1078+ if (nextTitle === project.title) {
1079+ return `Project ${project.id} is already named ${nextTitle}.`;
1080+ }
1081+
1082+ yield* dispatch({
1083+ type: "project.meta.update",
1084+ commandId: CommandId.make(crypto.randomUUID()),
1085+ projectId: project.id,
1086+ title: nextTitle,
1087+ });
1088+ return `Renamed project ${project.id} to ${nextTitle}.`;
1089+ }),
1090+ ),
1091+ ),
1092+);
1093+
1094+const projectCommand = Command.make("project").pipe(
1095+ Command.withDescription("Manage projects."),
1096+ Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]),
1097+);
1098+
1099+const runServerCommand = (
1100+ flags: CliServerFlags,
1101+ options?: {
1102+ readonly startupPresentation?: StartupPresentation;
1103+ readonly forceAutoBootstrapProjectFromCwd?: boolean;
1104+ },
1105+) =>
1106+ Effect.gen(function* () {
1107+ const logLevel = yield* GlobalFlag.LogLevel;
1108+ const config = yield* resolveServerConfig(flags, logLevel, options);
1109+ return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
1110+ });
1111+
1112+const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
3581113 Command.withDescription("Run the T3 Code server."),
1114+ Command.withHandler((flags) => runServerCommand(flags)),
1115+);
1116+
1117+const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(
1118+ Command.withDescription(
1119+ "Run the T3 Code server without opening a browser and print headless pairing details.",
1120+ ),
3591121 Command.withHandler((flags) =>
360− Effect.gen(function* () {
361− const logLevel = yield* GlobalFlag.LogLevel;
362− const config = yield* resolveServerConfig(flags, logLevel);
363− return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
1122+ runServerCommand(flags, {
1123+ startupPresentation: "headless",
1124+ forceAutoBootstrapProjectFromCwd: false,
3641125 }),
3651126 ),
3661127 );
3671128
368−export const cli = rootCommand;
1129+export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(
1130+ Command.withDescription("Run the T3 Code server."),
1131+ Command.withHandler((flags) => runServerCommand(flags)),
1132+ Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),
1133+);
apps/server/src/cliAuthFormat.test.ts+88−0
@@ -0,0 +1,88 @@
1+import { expect, it } from "@effect/vitest";
2+import { DateTime } from "effect";
3+
4+import {
5+ formatIssuedPairingCredential,
6+ formatIssuedSession,
7+ formatPairingCredentialList,
8+ formatSessionList,
9+} from "./cliAuthFormat.ts";
10+
11+it("formats issued pairing credentials with the secret and optional pair URL", () => {
12+ const output = formatIssuedPairingCredential(
13+ {
14+ id: "pairing-1",
15+ credential: "secret-pairing-token",
16+ role: "client",
17+ subject: "one-time-token",
18+ createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")),
19+ expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")),
20+ },
21+ { baseUrl: "https://example.com", json: false },
22+ );
23+
24+ expect(output).toContain("secret-pairing-token");
25+ expect(output).toContain("https://example.com/pair#token=secret-pairing-token");
26+});
27+
28+it("formats pairing listings without exposing the secret token", () => {
29+ const output = formatPairingCredentialList(
30+ [
31+ {
32+ id: "pairing-1",
33+ credential: "secret-pairing-token",
34+ subject: "one-time-token",
35+ label: "Phone",
36+ role: "client",
37+ createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")),
38+ expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")),
39+ },
40+ ],
41+ { json: false },
42+ );
43+
44+ expect(output).toContain("pairing-1");
45+ expect(output).not.toContain("secret-pairing-token");
46+});
47+
48+it("formats issued sessions with the bearer token but omits tokens from listings", () => {
49+ const issuedOutput = formatIssuedSession(
50+ {
51+ sessionId: "session-1" as never,
52+ token: "secret-session-token",
53+ method: "bearer-session-token",
54+ role: "owner",
55+ subject: "cli-issued-session",
56+ client: {
57+ label: "deploy-bot",
58+ deviceType: "bot",
59+ },
60+ expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")),
61+ },
62+ { json: false },
63+ );
64+
65+ const listedOutput = formatSessionList(
66+ [
67+ {
68+ sessionId: "session-1" as never,
69+ method: "bearer-session-token",
70+ role: "owner",
71+ subject: "cli-issued-session",
72+ client: {
73+ label: "deploy-bot",
74+ deviceType: "bot",
75+ },
76+ connected: false,
77+ current: false,
78+ issuedAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")),
79+ expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")),
80+ lastConnectedAt: null,
81+ },
82+ ],
83+ { json: false },
84+ );
85+
86+ expect(issuedOutput).toContain("secret-session-token");
87+ expect(listedOutput).not.toContain("secret-session-token");
88+});
apps/server/src/cliAuthFormat.ts+190−0
@@ -0,0 +1,190 @@
1+import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
2+import { DateTime } from "effect";
3+
4+import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts";
5+
6+const newline = "\n";
7+
8+function serializeOptionalFields(values: ReadonlyArray<string | null | undefined>) {
9+ return values.filter((value): value is string => typeof value === "string" && value.length > 0);
10+}
11+
12+function formatClientMetadata(metadata: AuthClientMetadata): string {
13+ const details = serializeOptionalFields([
14+ metadata.label,
15+ metadata.deviceType !== "unknown" ? metadata.deviceType : undefined,
16+ metadata.os,
17+ metadata.browser,
18+ metadata.ipAddress,
19+ ]);
20+ return details.length > 0 ? details.join(" | ") : "unlabeled client";
21+}
22+
23+function toIsoString(value: DateTime.DateTime | DateTime.Utc): string {
24+ return DateTime.formatIso(DateTime.toUtc(value));
25+}
26+
27+export function formatIssuedPairingCredential(
28+ credential: IssuedPairingLink,
29+ options?: {
30+ readonly json?: boolean;
31+ readonly baseUrl?: string;
32+ },
33+): string {
34+ const pairUrl =
35+ options?.baseUrl != null && options.baseUrl.length > 0
36+ ? (() => {
37+ const url = new URL("/pair", options.baseUrl);
38+ url.searchParams.delete("token");
39+ url.hash = new URLSearchParams([["token", credential.credential]]).toString();
40+ return url.toString();
41+ })()
42+ : undefined;
43+
44+ if (options?.json) {
45+ return `${JSON.stringify(
46+ {
47+ id: credential.id,
48+ credential: credential.credential,
49+ ...(credential.label ? { label: credential.label } : {}),
50+ role: credential.role,
51+ expiresAt: toIsoString(credential.expiresAt),
52+ ...(pairUrl ? { pairUrl } : {}),
53+ },
54+ null,
55+ 2,
56+ )}${newline}`;
57+ }
58+
59+ return (
60+ [
61+ `Issued client pairing token ${credential.id}.`,
62+ `Token: ${credential.credential}`,
63+ ...(pairUrl ? [`Pair URL: ${pairUrl}`] : []),
64+ `Expires at: ${credential.expiresAt}`,
65+ ].join(newline) + newline
66+ );
67+}
68+
69+export function formatPairingCredentialList(
70+ credentials: ReadonlyArray<AuthPairingLink>,
71+ options?: {
72+ readonly json?: boolean;
73+ },
74+): string {
75+ if (options?.json) {
76+ return `${JSON.stringify(
77+ credentials.map((credential) => ({
78+ id: credential.id,
79+ ...(credential.label ? { label: credential.label } : {}),
80+ role: credential.role,
81+ createdAt: toIsoString(credential.createdAt),
82+ expiresAt: toIsoString(credential.expiresAt),
83+ })),
84+ null,
85+ 2,
86+ )}${newline}`;
87+ }
88+
89+ if (credentials.length === 0) {
90+ return `No active pairing credentials.${newline}`;
91+ }
92+
93+ return (
94+ credentials
95+ .map((credential) =>
96+ [
97+ `${credential.id}${credential.label ? ` (${credential.label})` : ""}`,
98+ ` role: ${credential.role}`,
99+ ` created: ${toIsoString(credential.createdAt)}`,
100+ ` expires: ${toIsoString(credential.expiresAt)}`,
101+ ].join(newline),
102+ )
103+ .join(`${newline}${newline}`) + newline
104+ );
105+}
106+
107+export function formatIssuedSession(
108+ session: IssuedBearerSession,
109+ options?: {
110+ readonly json?: boolean;
111+ readonly tokenOnly?: boolean;
112+ },
113+): string {
114+ if (options?.tokenOnly) {
115+ return `${session.token}${newline}`;
116+ }
117+
118+ if (options?.json) {
119+ return `${JSON.stringify(
120+ {
121+ sessionId: session.sessionId,
122+ token: session.token,
123+ method: session.method,
124+ role: session.role,
125+ subject: session.subject,
126+ client: session.client,
127+ expiresAt: toIsoString(session.expiresAt),
128+ },
129+ null,
130+ 2,
131+ )}${newline}`;
132+ }
133+
134+ return (
135+ [
136+ `Issued ${session.role} bearer session ${session.sessionId}.`,
137+ `Token: ${session.token}`,
138+ `Subject: ${session.subject}`,
139+ `Client: ${formatClientMetadata(session.client)}`,
140+ `Expires at: ${toIsoString(session.expiresAt)}`,
141+ ].join(newline) + newline
142+ );
143+}
144+
145+export function formatSessionList(
146+ sessions: ReadonlyArray<AuthClientSession>,
147+ options?: {
148+ readonly json?: boolean;
149+ },
150+): string {
151+ if (options?.json) {
152+ return `${JSON.stringify(
153+ sessions.map((session) => ({
154+ sessionId: session.sessionId,
155+ method: session.method,
156+ role: session.role,
157+ subject: session.subject,
158+ client: session.client,
159+ connected: session.connected,
160+ issuedAt: toIsoString(session.issuedAt),
161+ expiresAt: toIsoString(session.expiresAt),
162+ lastConnectedAt: session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : null,
163+ })),
164+ null,
165+ 2,
166+ )}${newline}`;
167+ }
168+
169+ if (sessions.length === 0) {
170+ return `No active sessions.${newline}`;
171+ }
172+
173+ return (
174+ sessions
175+ .map((session) =>
176+ [
177+ `${session.sessionId} [${session.role}]${session.connected ? " connected" : ""}`,
178+ ` method: ${session.method}`,
179+ ` subject: ${session.subject}`,
180+ ` client: ${formatClientMetadata(session.client)}`,
181+ ` issued: ${toIsoString(session.issuedAt)}`,
182+ ` last connected: ${
183+ session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : "never"
184+ }`,
185+ ` expires: ${toIsoString(session.expiresAt)}`,
186+ ].join(newline),
187+ )
188+ .join(`${newline}${newline}`) + newline
189+ );
190+}
apps/server/src/codexAppServerManager.test.ts+154−6
@@ -15,9 +15,9 @@ import { · 14 unmodified lines
1515 normalizeCodexModelSlug,
1616 readCodexAccountSnapshot,
1717 resolveCodexModelForAccount,
18−} from "./codexAppServerManager";
18+} from "./codexAppServerManager.ts";
1919
20−const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
20+const asThreadId = (value: string): ThreadId => ThreadId.make(value);
2121
2222 function createSendTurnHarness() {
2323 const manager = new CodexAppServerManager();
@@ -111,9 +111,9 @@ function createPendingUserInputHarness() { · 87 unmodified lines
111111 },
112112 pendingUserInputs: new Map([
113113 [
114− ApprovalRequestId.makeUnsafe("req-user-input-1"),
114+ ApprovalRequestId.make("req-user-input-1"),
115115 {
116− requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),
116+ requestId: ApprovalRequestId.make("req-user-input-1"),
117117 jsonRpcId: 42,
118118 threadId: asThreadId("thread_1"),
119119 },
@@ -470,6 +470,154 @@ describe("startSession", () => { · 350 unmodified lines
470470 manager.stopAll();
471471 }
472472 });
473+
474+ it("disposes an existing session before starting a replacement for the same thread", async () => {
475+ const manager = new CodexAppServerManager();
476+ const existingContext = {
477+ session: {
478+ provider: "codex",
479+ status: "ready",
480+ threadId: asThreadId("thread-1"),
481+ runtimeMode: "full-access",
482+ createdAt: "2026-02-10T00:00:00.000Z",
483+ updatedAt: "2026-02-10T00:00:00.000Z",
484+ },
485+ };
486+
487+ (
488+ manager as unknown as {
489+ sessions: Map<ThreadId, typeof existingContext>;
490+ }
491+ ).sessions.set(asThreadId("thread-1"), existingContext);
492+
493+ const disposeSession = vi
494+ .spyOn(
495+ manager as unknown as {
496+ disposeSession: (
497+ context: typeof existingContext,
498+ options?: { readonly emitLifecycleEvent?: boolean },
499+ ) => void;
500+ },
501+ "disposeSession",
502+ )
503+ .mockImplementation(() => {});
504+ const assertSupportedCodexCliVersion = vi
505+ .spyOn(
506+ manager as unknown as {
507+ assertSupportedCodexCliVersion: (input: {
508+ binaryPath: string;
509+ cwd: string;
510+ homePath?: string;
511+ }) => void;
512+ },
513+ "assertSupportedCodexCliVersion",
514+ )
515+ .mockImplementation(() => {});
516+ const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => {
517+ throw new Error("cwd missing");
518+ });
519+
520+ try {
521+ await expect(
522+ manager.startSession({
523+ threadId: asThreadId("thread-1"),
524+ provider: "codex",
525+ binaryPath: "codex",
526+ runtimeMode: "full-access",
527+ }),
528+ ).rejects.toThrow("cwd missing");
529+
530+ expect(disposeSession).toHaveBeenCalledWith(existingContext, {
531+ emitLifecycleEvent: false,
532+ });
533+ expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled();
534+ } finally {
535+ disposeSession.mockRestore();
536+ assertSupportedCodexCliVersion.mockRestore();
537+ processCwd.mockRestore();
538+ (
539+ manager as unknown as {
540+ sessions: Map<ThreadId, typeof existingContext>;
541+ }
542+ ).sessions.clear();
543+ manager.stopAll();
544+ }
545+ });
546+
547+ it("continues replacement start when existing session disposal fails", async () => {
548+ const manager = new CodexAppServerManager();
549+ const existingContext = {
550+ session: {
551+ provider: "codex",
552+ status: "ready",
553+ threadId: asThreadId("thread-1"),
554+ runtimeMode: "full-access",
555+ createdAt: "2026-02-10T00:00:00.000Z",
556+ updatedAt: "2026-02-10T00:00:00.000Z",
557+ },
558+ };
559+
560+ (
561+ manager as unknown as {
562+ sessions: Map<ThreadId, typeof existingContext>;
563+ }
564+ ).sessions.set(asThreadId("thread-1"), existingContext);
565+
566+ const disposeSession = vi
567+ .spyOn(
568+ manager as unknown as {
569+ disposeSession: (
570+ context: typeof existingContext,
571+ options?: { readonly emitLifecycleEvent?: boolean },
572+ ) => void;
573+ },
574+ "disposeSession",
575+ )
576+ .mockImplementation(() => {
577+ throw new Error("dispose failed");
578+ });
579+ const assertSupportedCodexCliVersion = vi
580+ .spyOn(
581+ manager as unknown as {
582+ assertSupportedCodexCliVersion: (input: {
583+ binaryPath: string;
584+ cwd: string;
585+ homePath?: string;
586+ }) => void;
587+ },
588+ "assertSupportedCodexCliVersion",
589+ )
590+ .mockImplementation(() => {});
591+ const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => {
592+ throw new Error("cwd missing");
593+ });
594+
595+ try {
596+ await expect(
597+ manager.startSession({
598+ threadId: asThreadId("thread-1"),
599+ provider: "codex",
600+ binaryPath: "codex",
601+ runtimeMode: "full-access",
602+ }),
603+ ).rejects.toThrow("cwd missing");
604+
605+ expect(disposeSession).toHaveBeenCalledWith(existingContext, {
606+ emitLifecycleEvent: false,
607+ });
608+ expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled();
609+ } finally {
610+ disposeSession.mockRestore();
611+ assertSupportedCodexCliVersion.mockRestore();
612+ processCwd.mockRestore();
613+ (
614+ manager as unknown as {
615+ sessions: Map<ThreadId, typeof existingContext>;
616+ }
617+ ).sessions.clear();
618+ manager.stopAll();
619+ }
620+ });
473621 });
474622
475623 describe("sendTurn", () => {
@@ -743,7 +891,7 @@ describe("respondToUserInput", () => { · 267 unmodified lines
743891
744892 await manager.respondToUserInput(
745893 asThreadId("thread_1"),
746− ApprovalRequestId.makeUnsafe("req-user-input-1"),
894+ ApprovalRequestId.make("req-user-input-1"),
747895 {
748896 scope: "All request methods",
749897 compat: "Keep current envelope",
@@ -780,7 +928,7 @@ describe("respondToUserInput", () => { · 30 unmodified lines
780928
781929 await manager.respondToUserInput(
782930 asThreadId("thread_1"),
783− ApprovalRequestId.makeUnsafe("req-user-input-1"),
931+ ApprovalRequestId.make("req-user-input-1"),
784932 {
785933 scope: [],
786934 },
apps/server/src/codexAppServerManager.ts+75−35
@@ -19,22 +19,22 @@ import { · 18 unmodified lines
1919 ProviderInteractionMode,
2020 } from "@t3tools/contracts";
2121 import { normalizeModelSlug } from "@t3tools/shared/model";
22−import { Effect, ServiceMap } from "effect";
22+import { Effect, Context } from "effect";
2323
2424 import {
2525 formatCodexCliUpgradeMessage,
2626 isCodexCliVersionSupported,
2727 parseCodexCliVersion,
28−} from "./provider/codexCliVersion";
28+} from "./provider/codexCliVersion.ts";
2929 import {
3030 readCodexAccountSnapshot,
3131 resolveCodexModelForAccount,
3232 type CodexAccountSnapshot,
33−} from "./provider/codexAccount";
34−import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer";
33+} from "./provider/codexAccount.ts";
34+import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer.ts";
3535
36−export { buildCodexInitializeParams } from "./provider/codexAppServer";
37−export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount";
36+export { buildCodexInitializeParams } from "./provider/codexAppServer.ts";
37+export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount.ts";
3838
3939 type PendingRequestKey = string;
4040
@@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the · 249 unmodified lines
290290 </collaboration_mode>`;
291291
292292 function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {
293− readonly approvalPolicy: "on-request" | "never";
294− readonly sandbox: "workspace-write" | "danger-full-access";
293+ readonly approvalPolicy: "untrusted" | "on-request" | "never";
294+ readonly sandbox: "read-only" | "workspace-write" | "danger-full-access";
295295 } {
296− if (runtimeMode === "approval-required") {
297− return {
298− approvalPolicy: "on-request",
299− sandbox: "workspace-write",
300− };
296+ switch (runtimeMode) {
297+ case "approval-required":
298+ return {
299+ approvalPolicy: "untrusted",
300+ sandbox: "read-only",
301+ };
302+ case "auto-accept-edits":
303+ return {
304+ approvalPolicy: "on-request",
305+ sandbox: "workspace-write",
306+ };
307+ case "full-access":
308+ return {
309+ approvalPolicy: "never",
310+ sandbox: "danger-full-access",
311+ };
301312 }
302−
303− return {
304− approvalPolicy: "never",
305− sandbox: "danger-full-access",
306− };
307313 }
308314
309315 /**
@@ -433,7 +439,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 123 unmodified lines
433439 private readonly sessions = new Map<ThreadId, CodexSessionContext>();
434440
435441 private runPromise: (effect: Effect.Effect<unknown, never>) => Promise<unknown>;
436− constructor(services?: ServiceMap.ServiceMap<never>) {
442+ constructor(services?: Context.Context<never>) {
437443 super();
438444 this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise;
439445 }
@@ -444,6 +450,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 4 unmodified lines
444450 let context: CodexSessionContext | undefined;
445451
446452 try {
453+ const existingContext = this.sessions.get(threadId);
454+ if (existingContext) {
455+ await Effect.logWarning("codex app-server replacing existing session", {
456+ threadId,
457+ existingStatus: existingContext.session.status,
458+ }).pipe(this.runPromise);
459+ try {
460+ this.disposeSession(existingContext, {
461+ emitLifecycleEvent: false,
462+ });
463+ } catch (error) {
464+ await Effect.logWarning("codex app-server failed to dispose existing session", {
465+ threadId,
466+ existingStatus: existingContext.session.status,
467+ cause: error instanceof Error ? error.message : String(error),
468+ }).pipe(this.runPromise);
469+ }
470+ }
471+
447472 const resolvedCwd = input.cwd ?? process.cwd();
448473
449474 const session: ProviderSession = {
@@ -634,7 +659,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 184 unmodified lines
634659 this.stopSession(threadId);
635660 } else {
636661 this.emitEvent({
637− id: EventId.makeUnsafe(randomUUID()),
662+ id: EventId.make(randomUUID()),
638663 kind: "error",
639664 provider: "codex",
640665 threadId,
@@ -733,7 +758,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 92 unmodified lines
733758 if (!turnIdRaw) {
734759 throw new Error("turn/start response did not include a turn id.");
735760 }
736− const turnId = TurnId.makeUnsafe(turnIdRaw);
761+ const turnId = TurnId.make(turnIdRaw);
737762
738763 this.updateSession(context, {
739764 status: "running",
@@ -834,7 +859,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 94 unmodified lines
834859 });
835860
836861 this.emitEvent({
837− id: EventId.makeUnsafe(randomUUID()),
862+ id: EventId.make(randomUUID()),
838863 kind: "notification",
839864 provider: "codex",
840865 threadId: context.session.threadId,
@@ -873,7 +898,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 32 unmodified lines
873898 });
874899
875900 this.emitEvent({
876− id: EventId.makeUnsafe(randomUUID()),
901+ id: EventId.make(randomUUID()),
877902 kind: "notification",
878903 provider: "codex",
879904 threadId: context.session.threadId,
@@ -895,6 +920,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 15 unmodified lines
895920 return;
896921 }
897922
923+ this.disposeSession(context, {
924+ emitLifecycleEvent: true,
925+ });
926+ }
927+
928+ private disposeSession(
929+ context: CodexSessionContext,
930+ options?: { readonly emitLifecycleEvent?: boolean },
931+ ): void {
932+ if (context.stopping) {
933+ return;
934+ }
935+
898936 context.stopping = true;
899937
900938 for (const pending of context.pending.values()) {
@@ -915,8 +953,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 14 unmodified lines
915953 status: "closed",
916954 activeTurnId: undefined,
917955 });
918− this.emitLifecycleEvent(context, "session/closed", "Session stopped");
919− this.sessions.delete(threadId);
956+ if (options?.emitLifecycleEvent !== false) {
957+ this.emitLifecycleEvent(context, "session/closed", "Session stopped");
958+ }
959+ this.sessions.delete(context.session.threadId);
920960 }
921961
922962 listSessions(): ProviderSession[] {
@@ -1055,7 +1095,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 132 unmodified lines
10551095 : undefined;
10561096
10571097 this.emitEvent({
1058− id: EventId.makeUnsafe(randomUUID()),
1098+ id: EventId.make(randomUUID()),
10591099 kind: "notification",
10601100 provider: "codex",
10611101 threadId: context.session.threadId,
@@ -1128,7 +1168,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 66 unmodified lines
11281168 const requestKind = this.requestKindForMethod(request.method);
11291169 let requestId: ApprovalRequestId | undefined;
11301170 if (requestKind) {
1131− requestId = ApprovalRequestId.makeUnsafe(randomUUID());
1171+ requestId = ApprovalRequestId.make(randomUUID());
11321172 const pendingRequest: PendingApprovalRequest = {
11331173 requestId,
11341174 jsonRpcId: request.id,
@@ -1147,7 +1187,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 12 unmodified lines
11471187 }
11481188
11491189 if (request.method === "item/tool/requestUserInput") {
1150− requestId = ApprovalRequestId.makeUnsafe(randomUUID());
1190+ requestId = ApprovalRequestId.make(randomUUID());
11511191 context.pendingUserInputs.set(requestId, {
11521192 requestId,
11531193 jsonRpcId: request.id,
@@ -1158,7 +1198,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 4 unmodified lines
11581198 }
11591199
11601200 this.emitEvent({
1161− id: EventId.makeUnsafe(randomUUID()),
1201+ id: EventId.make(randomUUID()),
11621202 kind: "request",
11631203 provider: "codex",
11641204 threadId: context.session.threadId,
@@ -1248,7 +1288,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 83 unmodified lines
12481288
12491289 private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void {
12501290 this.emitEvent({
1251− id: EventId.makeUnsafe(randomUUID()),
1291+ id: EventId.make(randomUUID()),
12521292 kind: "session",
12531293 provider: "codex",
12541294 threadId: context.session.threadId,
@@ -1260,7 +1300,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 5 unmodified lines
12601300
12611301 private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void {
12621302 this.emitEvent({
1263− id: EventId.makeUnsafe(randomUUID()),
1303+ id: EventId.make(randomUUID()),
12641304 kind: "error",
12651305 provider: "codex",
12661306 threadId: context.session.threadId,
@@ -1276,7 +1316,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 9 unmodified lines
12761316 message: string,
12771317 ): void {
12781318 this.emitEvent({
1279− id: EventId.makeUnsafe(randomUUID()),
1319+ id: EventId.make(randomUUID()),
12801320 kind: "notification",
12811321 provider: "codex",
12821322 threadId: context.session.threadId,
@@ -1335,7 +1375,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 52 unmodified lines
13351375 const turns = turnsRaw.map((turnValue, index) => {
13361376 const turn = this.readObject(turnValue);
13371377 const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`;
1338− const turnId = TurnId.makeUnsafe(turnIdRaw);
1378+ const turnId = TurnId.make(turnIdRaw);
13391379 const items = this.readArray(turn, "items") ?? [];
13401380 return {
13411381 id: turnId,
@@ -1584,9 +1624,9 @@ function readResumeThreadId(input: { · 242 unmodified lines
15841624 }
15851625
15861626 function toTurnId(value: string | undefined): TurnId | undefined {
1587− return brandIfNonEmpty(value, TurnId.makeUnsafe);
1627+ return brandIfNonEmpty(value, TurnId.make);
15881628 }
15891629
15901630 function toProviderItemId(value: string | undefined): ProviderItemId | undefined {
1591− return brandIfNonEmpty(value, ProviderItemId.makeUnsafe);
1631+ return brandIfNonEmpty(value, ProviderItemId.make);
15921632 }
apps/server/src/config.ts+20−4
@@ -6,13 +6,16 @@ · 5 unmodified lines
66 *
77 * @module ServerConfig
88 */
9−import { Effect, FileSystem, Layer, LogLevel, Path, Schema, ServiceMap } from "effect";
9+import { Effect, FileSystem, Layer, LogLevel, Path, Schema, Context } from "effect";
1010
1111 export const DEFAULT_PORT = 3773;
1212
1313 export const RuntimeMode = Schema.Literals(["web", "desktop"]);
1414 export type RuntimeMode = typeof RuntimeMode.Type;
1515
16+export const StartupPresentation = Schema.Literals(["browser", "headless"]);
17+export type StartupPresentation = typeof StartupPresentation.Type;
18+
1619 /**
1720 * ServerDerivedPaths - Derived paths from the base directory.
1821 */
@@ -21,6 +24,7 @@ export interface ServerDerivedPaths { · 2 unmodified lines
2124 readonly dbPath: string;
2225 readonly keybindingsConfigPath: string;
2326 readonly settingsPath: string;
27+ readonly providerStatusCacheDir: string;
2428 readonly worktreesDir: string;
2529 readonly attachmentsDir: string;
2630 readonly logsDir: string;
@@ -30,6 +34,9 @@ export interface ServerDerivedPaths { · 3 unmodified lines
3034 readonly providerEventLogPath: string;
3135 readonly terminalLogsDir: string;
3236 readonly anonymousIdPath: string;
37+ readonly environmentIdPath: string;
38+ readonly serverRuntimeStatePath: string;
39+ readonly secretsDir: string;
3340 }
3441
3542 /**
@@ -54,7 +61,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { · 18 unmodified lines
5461 readonly staticDir: string | undefined;
5562 readonly devUrl: URL | undefined;
5663 readonly noBrowser: boolean;
57− readonly authToken: string | undefined;
64+ readonly startupPresentation: StartupPresentation;
65+ readonly desktopBootstrapToken: string | undefined;
5866 readonly autoBootstrapProjectFromCwd: boolean;
5967 readonly logWebSocketEvents: boolean;
6068 }
@@ -69,11 +77,13 @@ export const deriveServerPaths = Effect.fn(function* ( · 8 unmodified lines
6977 const attachmentsDir = join(stateDir, "attachments");
7078 const logsDir = join(stateDir, "logs");
7179 const providerLogsDir = join(logsDir, "provider");
80+ const providerStatusCacheDir = join(baseDir, "caches");
7281 return {
7382 stateDir,
7483 dbPath,
7584 keybindingsConfigPath: join(stateDir, "keybindings.json"),
7685 settingsPath: join(stateDir, "settings.json"),
86+ providerStatusCacheDir,
7787 worktreesDir: join(baseDir, "worktrees"),
7888 attachmentsDir,
7989 logsDir,
@@ -83,6 +93,9 @@ export const deriveServerPaths = Effect.fn(function* ( · 3 unmodified lines
8393 providerEventLogPath: join(providerLogsDir, "events.log"),
8494 terminalLogsDir: join(logsDir, "terminals"),
8595 anonymousIdPath: join(stateDir, "anonymous-id"),
96+ environmentIdPath: join(stateDir, "environment-id"),
97+ serverRuntimeStatePath: join(stateDir, "server-runtime.json"),
98+ secretsDir: join(stateDir, "secrets"),
8699 };
87100 });
88101
@@ -100,7 +113,9 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server · 11 unmodified lines
100113 fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }),
101114 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
102115 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
116+ fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }),
103117 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),
118+ fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),
104119 ],
105120 { concurrency: "unbounded" },
106121 );
@@ -109,7 +124,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server · 2 unmodified lines
109124 /**
110125 * ServerConfig - Service tag for server runtime configuration.
111126 */
112−export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(
127+export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShape>()(
113128 "t3/config/ServerConfig",
114129 ) {
115130 static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) =>
@@ -145,10 +160,11 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS · 29 unmodified lines
145160 logWebSocketEvents: false,
146161 port: 0,
147162 host: undefined,
148− authToken: undefined,
163+ desktopBootstrapToken: undefined,
149164 staticDir: undefined,
150165 devUrl,
151166 noBrowser: false,
167+ startupPresentation: "browser",
152168 } satisfies ServerConfigShape;
153169 }),
154170 );
apps/server/src/environment/Layers/ServerEnvironment.test.ts+125−0
@@ -0,0 +1,125 @@
1+import * as nodePath from "node:path";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { expect, it } from "@effect/vitest";
4+import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect";
5+
6+import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts";
7+import { ServerEnvironment } from "../Services/ServerEnvironment.ts";
8+import { ServerEnvironmentLive } from "./ServerEnvironment.ts";
9+
10+const makeServerEnvironmentLayer = (baseDir: string) =>
11+ ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
12+
13+const makeServerConfig = Effect.fn(function* (baseDir: string) {
14+ const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
15+
16+ return {
17+ ...derivedPaths,
18+ logLevel: "Error",
19+ traceMinLevel: "Info",
20+ traceTimingEnabled: true,
21+ traceBatchWindowMs: 200,
22+ traceMaxBytes: 10 * 1024 * 1024,
23+ traceMaxFiles: 10,
24+ otlpTracesUrl: undefined,
25+ otlpMetricsUrl: undefined,
26+ otlpExportIntervalMs: 10_000,
27+ otlpServiceName: "t3-server",
28+ cwd: process.cwd(),
29+ baseDir,
30+ mode: "web",
31+ autoBootstrapProjectFromCwd: false,
32+ logWebSocketEvents: false,
33+ port: 0,
34+ host: undefined,
35+ desktopBootstrapToken: undefined,
36+ staticDir: undefined,
37+ devUrl: undefined,
38+ noBrowser: false,
39+ startupPresentation: "browser",
40+ } satisfies ServerConfigShape;
41+});
42+
43+it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
44+ it.effect("persists the environment id across service restarts", () =>
45+ Effect.gen(function* () {
46+ const fileSystem = yield* FileSystem.FileSystem;
47+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
48+ prefix: "t3-server-environment-test-",
49+ });
50+
51+ const first = yield* Effect.gen(function* () {
52+ const serverEnvironment = yield* ServerEnvironment;
53+ return yield* serverEnvironment.getDescriptor;
54+ }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
55+ const second = yield* Effect.gen(function* () {
56+ const serverEnvironment = yield* ServerEnvironment;
57+ return yield* serverEnvironment.getDescriptor;
58+ }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
59+
60+ expect(first.environmentId).toBe(second.environmentId);
61+ expect(second.capabilities.repositoryIdentity).toBe(true);
62+ }),
63+ );
64+
65+ it.effect("fails instead of overwriting a persisted id when reading the file errors", () =>
66+ Effect.gen(function* () {
67+ const fileSystem = yield* FileSystem.FileSystem;
68+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
69+ prefix: "t3-server-environment-read-error-test-",
70+ });
71+ const serverConfig = yield* makeServerConfig(baseDir);
72+ const environmentIdPath = serverConfig.environmentIdPath;
73+ yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true });
74+ yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n");
75+ const writeAttempts: string[] = [];
76+ const failingFileSystemLayer = FileSystem.layerNoop({
77+ exists: (path) => Effect.succeed(path === environmentIdPath),
78+ readFileString: (path) =>
79+ path === environmentIdPath
80+ ? Effect.fail(
81+ PlatformError.systemError({
82+ _tag: "PermissionDenied",
83+ module: "FileSystem",
84+ method: "readFileString",
85+ description: "permission denied",
86+ pathOrDescriptor: path,
87+ }),
88+ )
89+ : Effect.fail(
90+ PlatformError.systemError({
91+ _tag: "NotFound",
92+ module: "FileSystem",
93+ method: "readFileString",
94+ description: "not found",
95+ pathOrDescriptor: path,
96+ }),
97+ ),
98+ writeFileString: (path) => {
99+ writeAttempts.push(path);
100+ return Effect.void;
101+ },
102+ });
103+
104+ const exit = yield* Effect.gen(function* () {
105+ const serverEnvironment = yield* ServerEnvironment;
106+ return yield* serverEnvironment.getDescriptor;
107+ }).pipe(
108+ Effect.provide(
109+ ServerEnvironmentLive.pipe(
110+ Layer.provide(
111+ Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer),
112+ ),
113+ ),
114+ ),
115+ Effect.exit,
116+ );
117+
118+ expect(Exit.isFailure(exit)).toBe(true);
119+ expect(writeAttempts).toEqual([]);
120+ expect(yield* fileSystem.readFileString(environmentIdPath)).toBe(
121+ "persisted-environment-id\n",
122+ );
123+ }),
124+ );
125+});
apps/server/src/environment/Layers/ServerEnvironment.ts+92−0
@@ -0,0 +1,92 @@
1+import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2+import { Effect, FileSystem, Layer, Path, Random } from "effect";
3+
4+import { ServerConfig } from "../../config.ts";
5+import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
6+import packageJson from "../../../package.json" with { type: "json" };
7+import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
8+
9+function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] {
10+ switch (process.platform) {
11+ case "darwin":
12+ return "darwin";
13+ case "linux":
14+ return "linux";
15+ case "win32":
16+ return "windows";
17+ default:
18+ return "unknown";
19+ }
20+}
21+
22+function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] {
23+ switch (process.arch) {
24+ case "arm64":
25+ return "arm64";
26+ case "x64":
27+ return "x64";
28+ default:
29+ return "other";
30+ }
31+}
32+
33+export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () {
34+ const fileSystem = yield* FileSystem.FileSystem;
35+ const path = yield* Path.Path;
36+ const serverConfig = yield* ServerConfig;
37+
38+ const readPersistedEnvironmentId = Effect.gen(function* () {
39+ const exists = yield* fileSystem
40+ .exists(serverConfig.environmentIdPath)
41+ .pipe(Effect.orElseSucceed(() => false));
42+ if (!exists) {
43+ return null;
44+ }
45+
46+ const raw = yield* fileSystem
47+ .readFileString(serverConfig.environmentIdPath)
48+ .pipe(Effect.map((value) => value.trim()));
49+
50+ return raw.length > 0 ? raw : null;
51+ });
52+
53+ const persistEnvironmentId = (value: string) =>
54+ fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`);
55+
56+ const environmentIdRaw = yield* Effect.gen(function* () {
57+ const persisted = yield* readPersistedEnvironmentId;
58+ if (persisted) {
59+ return persisted;
60+ }
61+
62+ const generated = yield* Random.nextUUIDv4;
63+ yield* persistEnvironmentId(generated);
64+ return generated;
65+ });
66+
67+ const environmentId = EnvironmentId.make(environmentIdRaw);
68+ const cwdBaseName = path.basename(serverConfig.cwd).trim();
69+ const label = yield* resolveServerEnvironmentLabel({
70+ cwdBaseName,
71+ });
72+
73+ const descriptor: ExecutionEnvironmentDescriptor = {
74+ environmentId,
75+ label,
76+ platform: {
77+ os: platformOs(),
78+ arch: platformArch(),
79+ },
80+ serverVersion: packageJson.version,
81+ capabilities: {
82+ repositoryIdentity: true,
83+ },
84+ };
85+
86+ return {
87+ getEnvironmentId: Effect.succeed(environmentId),
88+ getDescriptor: Effect.succeed(descriptor),
89+ } satisfies ServerEnvironmentShape;
90+});
91+
92+export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment());
apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts+150−0
@@ -0,0 +1,150 @@
1+import { afterEach, describe, expect, it } from "@effect/vitest";
2+import { Effect, FileSystem } from "effect";
3+import { vi } from "vitest";
4+
5+vi.mock("../../processRunner.ts", () => ({
6+ runProcess: vi.fn(),
7+}));
8+
9+import { runProcess } from "../../processRunner.ts";
10+import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
11+
12+const mockedRunProcess = vi.mocked(runProcess);
13+const NoopFileSystemLayer = FileSystem.layerNoop({});
14+
15+afterEach(() => {
16+ mockedRunProcess.mockReset();
17+});
18+
19+describe("resolveServerEnvironmentLabel", () => {
20+ it.effect("uses hostname fallback regardless of launch mode", () =>
21+ Effect.gen(function* () {
22+ const result = yield* resolveServerEnvironmentLabel({
23+ cwdBaseName: "t3code",
24+ platform: "win32",
25+ hostname: "macbook-pro",
26+ }).pipe(Effect.provide(NoopFileSystemLayer));
27+
28+ expect(result).toBe("macbook-pro");
29+ }),
30+ );
31+
32+ it.effect("prefers the macOS ComputerName", () =>
33+ Effect.gen(function* () {
34+ mockedRunProcess.mockResolvedValueOnce({
35+ stdout: " Julius's MacBook Pro \n",
36+ stderr: "",
37+ code: 0,
38+ signal: null,
39+ timedOut: false,
40+ });
41+
42+ const result = yield* resolveServerEnvironmentLabel({
43+ cwdBaseName: "t3code",
44+ platform: "darwin",
45+ hostname: "macbook-pro",
46+ }).pipe(Effect.provide(NoopFileSystemLayer));
47+
48+ expect(result).toBe("Julius's MacBook Pro");
49+ expect(mockedRunProcess).toHaveBeenCalledWith(
50+ "scutil",
51+ ["--get", "ComputerName"],
52+ expect.objectContaining({ allowNonZeroExit: true }),
53+ );
54+ }),
55+ );
56+
57+ it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () =>
58+ Effect.gen(function* () {
59+ const result = yield* resolveServerEnvironmentLabel({
60+ cwdBaseName: "t3code",
61+ platform: "linux",
62+ hostname: "buildbox",
63+ }).pipe(
64+ Effect.provide(
65+ FileSystem.layerNoop({
66+ exists: (path) => Effect.succeed(path === "/etc/machine-info"),
67+ readFileString: (path) =>
68+ path === "/etc/machine-info"
69+ ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n')
70+ : Effect.succeed(""),
71+ }),
72+ ),
73+ );
74+
75+ expect(result).toBe("Build Agent 01");
76+ expect(mockedRunProcess).not.toHaveBeenCalled();
77+ }),
78+ );
79+
80+ it.effect("falls back to hostnamectl pretty hostname on Linux", () =>
81+ Effect.gen(function* () {
82+ mockedRunProcess.mockResolvedValueOnce({
83+ stdout: "CI Runner\n",
84+ stderr: "",
85+ code: 0,
86+ signal: null,
87+ timedOut: false,
88+ });
89+
90+ const result = yield* resolveServerEnvironmentLabel({
91+ cwdBaseName: "t3code",
92+ platform: "linux",
93+ hostname: "runner-01",
94+ }).pipe(Effect.provide(NoopFileSystemLayer));
95+
96+ expect(result).toBe("CI Runner");
97+ expect(mockedRunProcess).toHaveBeenCalledWith(
98+ "hostnamectl",
99+ ["--pretty"],
100+ expect.objectContaining({ allowNonZeroExit: true }),
101+ );
102+ }),
103+ );
104+
105+ it.effect("falls back to the hostname when friendly labels are unavailable", () =>
106+ Effect.gen(function* () {
107+ const result = yield* resolveServerEnvironmentLabel({
108+ cwdBaseName: "t3code",
109+ platform: "win32",
110+ hostname: "JULIUS-LAPTOP",
111+ }).pipe(Effect.provide(NoopFileSystemLayer));
112+
113+ expect(result).toBe("JULIUS-LAPTOP");
114+ }),
115+ );
116+
117+ it.effect("falls back to the hostname when the friendly-label command is missing", () =>
118+ Effect.gen(function* () {
119+ mockedRunProcess.mockRejectedValueOnce(new Error("spawn scutil ENOENT"));
120+
121+ const result = yield* resolveServerEnvironmentLabel({
122+ cwdBaseName: "t3code",
123+ platform: "darwin",
124+ hostname: "macbook-pro",
125+ }).pipe(Effect.provide(NoopFileSystemLayer));
126+
127+ expect(result).toBe("macbook-pro");
128+ }),
129+ );
130+
131+ it.effect("falls back to the cwd basename when the hostname is blank", () =>
132+ Effect.gen(function* () {
133+ mockedRunProcess.mockResolvedValueOnce({
134+ stdout: " ",
135+ stderr: "",
136+ code: 0,
137+ signal: null,
138+ timedOut: false,
139+ });
140+
141+ const result = yield* resolveServerEnvironmentLabel({
142+ cwdBaseName: "t3code",
143+ platform: "linux",
144+ hostname: " ",
145+ }).pipe(Effect.provide(NoopFileSystemLayer));
146+
147+ expect(result).toBe("t3code");
148+ }),
149+ );
150+});
apps/server/src/environment/Layers/ServerEnvironmentLabel.ts+106−0
@@ -0,0 +1,106 @@
1+import * as OS from "node:os";
2+
3+import { Effect, FileSystem } from "effect";
4+
5+import { runProcess } from "../../processRunner.ts";
6+
7+interface ResolveServerEnvironmentLabelInput {
8+ readonly cwdBaseName: string;
9+ readonly platform?: NodeJS.Platform;
10+ readonly hostname?: string | null;
11+}
12+
13+function normalizeLabel(value: string | null | undefined): string | null {
14+ const trimmed = value?.trim();
15+ return trimmed && trimmed.length > 0 ? trimmed : null;
16+}
17+
18+function parseMachineInfoValue(raw: string, key: string): string | null {
19+ for (const line of raw.split(/\r?\n/g)) {
20+ const trimmed = line.trim();
21+ if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) {
22+ continue;
23+ }
24+ const value = trimmed.slice(key.length + 1).trim();
25+ if (
26+ (value.startsWith('"') && value.endsWith('"')) ||
27+ (value.startsWith("'") && value.endsWith("'"))
28+ ) {
29+ return normalizeLabel(value.slice(1, -1));
30+ }
31+ return normalizeLabel(value);
32+ }
33+ return null;
34+}
35+
36+const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () {
37+ const fileSystem = yield* FileSystem.FileSystem;
38+ const exists = yield* fileSystem
39+ .exists("/etc/machine-info")
40+ .pipe(Effect.orElseSucceed(() => false));
41+ if (!exists) {
42+ return null;
43+ }
44+
45+ return yield* fileSystem
46+ .readFileString("/etc/machine-info")
47+ .pipe(Effect.orElseSucceed(() => null));
48+});
49+
50+const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* (
51+ command: string,
52+ args: readonly string[],
53+) {
54+ const result = yield* Effect.tryPromise({
55+ try: () =>
56+ runProcess(command, args, {
57+ allowNonZeroExit: true,
58+ }),
59+ catch: () => null,
60+ }).pipe(Effect.orElseSucceed(() => null));
61+
62+ if (!result || result.code !== 0) {
63+ return null;
64+ }
65+
66+ return normalizeLabel(result.stdout);
67+});
68+
69+const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* (
70+ platform: NodeJS.Platform,
71+) {
72+ if (platform === "darwin") {
73+ return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]);
74+ }
75+
76+ if (platform === "linux") {
77+ const machineInfo = normalizeLabel(yield* readLinuxMachineInfo());
78+ if (machineInfo) {
79+ const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME");
80+ if (prettyHostname) {
81+ return prettyHostname;
82+ }
83+ }
84+
85+ return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]);
86+ }
87+
88+ return null;
89+});
90+
91+export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* (
92+ input: ResolveServerEnvironmentLabelInput,
93+) {
94+ const platform = input.platform ?? process.platform;
95+ const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform);
96+ if (friendlyHostLabel) {
97+ return friendlyHostLabel;
98+ }
99+
100+ const hostname = normalizeLabel(input.hostname ?? OS.hostname());
101+ if (hostname) {
102+ return hostname;
103+ }
104+
105+ return normalizeLabel(input.cwdBaseName) ?? "T3 environment";
106+});
apps/server/src/environment/Services/ServerEnvironment.ts+12−0
@@ -0,0 +1,12 @@
1+import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2+import { Context } from "effect";
3+import type { Effect } from "effect";
4+
5+export interface ServerEnvironmentShape {
6+ readonly getEnvironmentId: Effect.Effect<EnvironmentId>;
7+ readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>;
8+}
9+
10+export class ServerEnvironment extends Context.Service<ServerEnvironment, ServerEnvironmentShape>()(
11+ "t3/environment/Services/ServerEnvironment",
12+) {}
apps/server/src/git/Layers/CodexTextGeneration.ts+1−0
@@ -166,6 +166,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 165 unmodified lines
166166 [
167167 "exec",
168168 "--ephemeral",
169+ "--skip-git-repo-check",
169170 "-s",
170171 "read-only",
171172 "--model",
apps/server/src/git/Layers/GitCore.test.ts+183−63
@@ -40,6 +40,24 @@ function writeTextFile( · 39 unmodified lines
4040 });
4141 }
4242
43+function removePath(
44+ targetPath: string,
45+): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
46+ return Effect.gen(function* () {
47+ const fileSystem = yield* FileSystem.FileSystem;
48+ yield* fileSystem.remove(targetPath, { recursive: true, force: true });
49+ });
50+}
51+
52+function makeDirectory(
53+ dirPath: string,
54+): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
55+ return Effect.gen(function* () {
56+ const fileSystem = yield* FileSystem.FileSystem;
57+ yield* fileSystem.makeDirectory(dirPath, { recursive: true });
58+ });
59+}
60+
4361 /** Run a raw git command for test setup (not under test). */
4462 function git(
4563 cwd: string,
@@ -219,7 +237,10 @@ it.layer(TestLayer)("git integration", (it) => { · 173 unmodified lines
219237
220238 const seenChunks: string[][] = [];
221239 const core = yield* makeIsolatedGitCore((input) => {
222− if (input.args.join(" ") !== "check-ignore --no-index -z --stdin") {
240+ if (
241+ input.args.join(" ") !==
242+ "-c core.fsmonitor=false -c core.untrackedCache=false check-ignore --no-index -z --stdin"
243+ ) {
223244 return Effect.fail(
224245 new GitCommandError({
225246 operation: input.operation,
@@ -252,6 +273,35 @@ it.layer(TestLayer)("git integration", (it) => { · 26 unmodified lines
252273 expect(result).toEqual(expectedPaths);
253274 }),
254275 );
276+
277+ it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () =>
278+ Effect.gen(function* () {
279+ const core = yield* makeIsolatedGitCore((input) => {
280+ expect(input.args).toEqual([
281+ "-c",
282+ "core.fsmonitor=false",
283+ "-c",
284+ "core.untrackedCache=false",
285+ "ls-files",
286+ "--cached",
287+ "--others",
288+ "--exclude-standard",
289+ "-z",
290+ ]);
291+ return Effect.succeed({
292+ code: 0,
293+ stdout: "src/index.ts\0README.md\0",
294+ stderr: "",
295+ stdoutTruncated: false,
296+ stderrTruncated: false,
297+ });
298+ });
299+
300+ const result = yield* core.listWorkspaceFiles("/virtual/repo");
301+ expect(result.paths).toEqual(["src/index.ts", "README.md"]);
302+ expect(result.truncated).toBe(false);
303+ }),
304+ );
255305 });
256306
257307 // ── listGitBranches ──
@@ -267,6 +317,21 @@ it.layer(TestLayer)("git integration", (it) => { · 9 unmodified lines
267317 }),
268318 );
269319
320+ it.effect("returns isRepo: false for deleted directories", () =>
321+ Effect.gen(function* () {
322+ const tmp = yield* makeTmpDir();
323+ const deletedDir = path.join(tmp, "deleted-repo");
324+ yield* makeDirectory(deletedDir);
325+ yield* removePath(deletedDir);
326+
327+ const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir });
328+
329+ expect(result.isRepo).toBe(false);
330+ expect(result.hasOriginRemote).toBe(false);
331+ expect(result.branches).toEqual([]);
332+ }),
333+ );
334+
270335 it.effect("returns the current branch with current: true", () =>
271336 Effect.gen(function* () {
272337 const tmp = yield* makeTmpDir();
@@ -617,8 +682,8 @@ it.layer(TestLayer)("git integration", (it) => { · 344 unmodified lines
617682
618683 it.effect("refreshes upstream behind count after checkout when remote branch advanced", () =>
619684 Effect.gen(function* () {
620− const services = yield* Effect.services();
621− const runPromise = Effect.runPromiseWith(services);
685+ const context = yield* Effect.context<never>();
686+ const runPromise = Effect.runPromiseWith(context);
622687
623688 const remote = yield* makeTmpDir();
624689 const source = yield* makeTmpDir();
@@ -761,7 +826,7 @@ it.layer(TestLayer)("git integration", (it) => { · 136 unmodified lines
761826 }),
762827 );
763828
764− it.effect("shares upstream refreshes across worktrees that use the same git common dir", () =>
829+ it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () =>
765830 Effect.gen(function* () {
766831 const ok = (stdout = "") =>
767832 Effect.succeed({
@@ -780,7 +845,9 @@ it.layer(TestLayer)("git integration", (it) => { · 12 unmodified lines
780845 input.args[2] === "--symbolic-full-name" &&
781846 input.args[3] === "@{upstream}"
782847 ) {
783− return ok("origin/main\n");
848+ return ok(
849+ input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n",
850+ );
784851 }
785852 if (input.args[0] === "remote") {
786853 return ok("origin\n");
@@ -791,10 +858,22 @@ it.layer(TestLayer)("git integration", (it) => { · 4 unmodified lines
791858 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
792859 fetchCount += 1;
793860 expect(input.cwd).toBe("/repo");
861+ expect(input.args).toEqual([
862+ "--git-dir",
863+ "/repo/.git",
864+ "fetch",
865+ "--quiet",
866+ "--no-tags",
867+ "origin",
868+ ]);
794869 return ok();
795870 }
796871 if (input.operation === "GitCore.statusDetails.status") {
797− return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n");
872+ return ok(
873+ input.cwd === "/repo/worktrees/pr-123"
874+ ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n"
875+ : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n",
876+ );
798877 }
799878 if (
800879 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
@@ -821,70 +900,80 @@ it.layer(TestLayer)("git integration", (it) => { · 20 unmodified lines
821900 }),
822901 );
823902
824− it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () =>
825− Effect.gen(function* () {
826− const ok = (stdout = "") =>
827− Effect.succeed({
828− code: 0,
829− stdout,
830− stderr: "",
831− stdoutTruncated: false,
832− stderrTruncated: false,
833− });
903+ it.effect(
904+ "briefly backs off failed upstream refreshes across sibling worktrees on one remote",
905+ () =>
906+ Effect.gen(function* () {
907+ const ok = (stdout = "") =>
908+ Effect.succeed({
909+ code: 0,
910+ stdout,
911+ stderr: "",
912+ stdoutTruncated: false,
913+ stderrTruncated: false,
914+ });
834915
835− let fetchCount = 0;
836− const core = yield* makeIsolatedGitCore((input) => {
837− if (
838− input.args[0] === "rev-parse" &&
839− input.args[1] === "--abbrev-ref" &&
840− input.args[2] === "--symbolic-full-name" &&
841− input.args[3] === "@{upstream}"
842− ) {
843− return ok("origin/main\n");
844− }
845− if (input.args[0] === "remote") {
846− return ok("origin\n");
847− }
848− if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {
849− return ok("/repo/.git\n");
850− }
851− if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
852− fetchCount += 1;
916+ let fetchCount = 0;
917+ const core = yield* makeIsolatedGitCore((input) => {
918+ if (
919+ input.args[0] === "rev-parse" &&
920+ input.args[1] === "--abbrev-ref" &&
921+ input.args[2] === "--symbolic-full-name" &&
922+ input.args[3] === "@{upstream}"
923+ ) {
924+ return ok(
925+ input.cwd === "/repo/worktrees/pr-123"
926+ ? "origin/feature/pr-123\n"
927+ : "origin/main\n",
928+ );
929+ }
930+ if (input.args[0] === "remote") {
931+ return ok("origin\n");
932+ }
933+ if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {
934+ return ok("/repo/.git\n");
935+ }
936+ if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
937+ fetchCount += 1;
938+ return Effect.fail(
939+ new GitCommandError({
940+ operation: input.operation,
941+ command: `git ${input.args.join(" ")}`,
942+ cwd: input.cwd,
943+ detail: "simulated fetch timeout",
944+ }),
945+ );
946+ }
947+ if (input.operation === "GitCore.statusDetails.status") {
948+ return ok(
949+ input.cwd === "/repo/worktrees/pr-123"
950+ ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n"
951+ : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n",
952+ );
953+ }
954+ if (
955+ input.operation === "GitCore.statusDetails.unstagedNumstat" ||
956+ input.operation === "GitCore.statusDetails.stagedNumstat"
957+ ) {
958+ return ok();
959+ }
960+ if (input.operation === "GitCore.statusDetails.defaultRef") {
961+ return ok("refs/remotes/origin/main\n");
962+ }
853963 return Effect.fail(
854964 new GitCommandError({
855965 operation: input.operation,
856966 command: `git ${input.args.join(" ")}`,
857967 cwd: input.cwd,
858− detail: "simulated fetch timeout",
968+ detail: "Unexpected git command in refresh failure cooldown test.",
859969 }),
860970 );
861− }
862− if (input.operation === "GitCore.statusDetails.status") {
863− return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n");
864− }
865− if (
866− input.operation === "GitCore.statusDetails.unstagedNumstat" ||
867− input.operation === "GitCore.statusDetails.stagedNumstat"
868− ) {
869− return ok();
870− }
871− if (input.operation === "GitCore.statusDetails.defaultRef") {
872− return ok("refs/remotes/origin/main\n");
873− }
874− return Effect.fail(
875− new GitCommandError({
876− operation: input.operation,
877− command: `git ${input.args.join(" ")}`,
878− cwd: input.cwd,
879− detail: "Unexpected git command in refresh failure cooldown test.",
880− }),
881− );
882− });
971+ });
883972
884− yield* core.statusDetails("/repo/worktrees/main");
885− yield* core.statusDetails("/repo/worktrees/pr-123");
886− expect(fetchCount).toBe(1);
887− }),
973+ yield* core.statusDetails("/repo/worktrees/main");
974+ yield* core.statusDetails("/repo/worktrees/pr-123");
975+ expect(fetchCount).toBe(1);
976+ }),
888977 );
889978
890979 it.effect("throws when branch does not exist", () =>
@@ -949,11 +1038,12 @@ it.layer(TestLayer)("git integration", (it) => { · 58 unmodified lines
9491038 yield* git(source, ["checkout", defaultBranch]);
9501039 yield* git(source, ["branch", "-D", featureBranch]);
9511040
952− yield* (yield* GitCore).checkoutBranch({
1041+ const checkoutResult = yield* (yield* GitCore).checkoutBranch({
9531042 cwd: source,
9541043 branch: `${remoteName}/${featureBranch}`,
9551044 });
9561045
1046+ expect(checkoutResult.branch).toBe("upstream/feature");
9571047 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");
9581048 const realGitCore = yield* GitCore;
9591049 let fetchArgs: readonly string[] | null = null;
@@ -981,7 +1071,6 @@ it.layer(TestLayer)("git integration", (it) => { · 21 unmodified lines
9811071 "--quiet",
9821072 "--no-tags",
9831073 remoteName,
984− `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`,
9851074 ]);
9861075 }),
9871076 );
@@ -1593,6 +1682,37 @@ it.layer(TestLayer)("git integration", (it) => { · 605 unmodified lines
15931682 }),
15941683 );
15951684
1685+ it.effect("returns a non-repo status for deleted directories", () =>
1686+ Effect.gen(function* () {
1687+ const tmp = yield* makeTmpDir();
1688+ const deletedDir = path.join(tmp, "deleted-repo");
1689+ yield* makeDirectory(deletedDir);
1690+ yield* removePath(deletedDir);
1691+ const core = yield* GitCore;
1692+
1693+ const status = yield* core.statusDetails(deletedDir);
1694+ const localStatus = yield* core.statusDetailsLocal(deletedDir);
1695+
1696+ expect(status).toEqual({
1697+ isRepo: false,
1698+ hasOriginRemote: false,
1699+ isDefaultBranch: false,
1700+ branch: null,
1701+ upstreamRef: null,
1702+ hasWorkingTreeChanges: false,
1703+ workingTree: {
1704+ files: [],
1705+ insertions: 0,
1706+ deletions: 0,
1707+ },
1708+ hasUpstream: false,
1709+ aheadCount: 0,
1710+ behindCount: 0,
1711+ });
1712+ expect(localStatus).toEqual(status);
1713+ }),
1714+ );
1715+
15961716 it.effect("computes ahead count against base branch when no upstream is configured", () =>
15971717 Effect.gen(function* () {
15981718 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+109−36
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
2727 type ExecuteGitProgress,
2828 type GitCommitOptions,
2929 type GitCoreShape,
30+ type GitStatusDetails,
3031 type ExecuteGitInput,
3132 type ExecuteGitResult,
3233 } from "../Services/GitCore.ts";
@@ -47,23 +48,39 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; · 14 unmodified lines
4748 const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
4849 const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
4950 const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
51+const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [
52+ "-c",
53+ "core.fsmonitor=false",
54+ "-c",
55+ "core.untrackedCache=false",
56+] as const;
5057 const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
5158 const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
5259 const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
5360 const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
5461 const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
5562 const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
63+const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({
64+ isRepo: false,
65+ hasOriginRemote: false,
66+ isDefaultBranch: false,
67+ branch: null,
68+ upstreamRef: null,
69+ hasWorkingTreeChanges: false,
70+ workingTree: { files: [], insertions: 0, deletions: 0 },
71+ hasUpstream: false,
72+ aheadCount: 0,
73+ behindCount: 0,
74+});
5675
5776 type TraceTailState = {
5877 processedChars: number;
5978 remainder: string;
6079 };
6180
62−class StatusUpstreamRefreshCacheKey extends Data.Class<{
81+class StatusRemoteRefreshCacheKey extends Data.Class<{
6382 gitCommonDir: string;
64− upstreamRef: string;
6583 remoteName: string;
66− upstreamBranch: string;
6784 }> {}
6885
6986 interface ExecuteGitOptions {
@@ -353,6 +370,16 @@ function quoteGitCommand(args: ReadonlyArray<string>): string { · 283 unmodified lines
353370 return `git ${args.join(" ")}`;
354371 }
355372
373+function isMissingGitCwdError(error: GitCommandError): boolean {
374+ const normalized = `${error.detail}\n${error.message}`.toLowerCase();
375+ return (
376+ normalized.includes("no such file or directory") ||
377+ normalized.includes("notfound: filesystem.access") ||
378+ normalized.includes("enoent") ||
379+ normalized.includes("not a directory")
380+ );
381+}
382+
356383 function toGitCommandError(
357384 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
358385 detail: string,
@@ -890,17 +917,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 531 unmodified lines
890917 );
891918 });
892919
893− const fetchUpstreamRefForStatus = (
920+ const fetchRemoteForStatus = (
894921 gitCommonDir: string,
895− upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
922+ remoteName: string,
896923 ): Effect.Effect<void, GitCommandError> => {
897− const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
898924 const fetchCwd =
899925 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
900926 return executeGit(
901− "GitCore.fetchUpstreamRefForStatus",
927+ "GitCore.fetchRemoteForStatus",
902928 fetchCwd,
903− ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
929+ ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],
904930 {
905931 allowNonZeroExit: true,
906932 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
@@ -916,20 +942,15 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 9 unmodified lines
916942 return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
917943 });
918944
919− const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (
920− cacheKey: StatusUpstreamRefreshCacheKey,
945+ const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* (
946+ cacheKey: StatusRemoteRefreshCacheKey,
921947 ) {
922− yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, {
923− upstreamRef: cacheKey.upstreamRef,
924− remoteName: cacheKey.remoteName,
925− upstreamBranch: cacheKey.upstreamBranch,
926− });
948+ yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName);
927949 return true as const;
928950 });
929951
930− const statusUpstreamRefreshCache = yield* Cache.makeWith({
952+ const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, {
931953 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
932− lookup: refreshStatusUpstreamCacheEntry,
933954 // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms.
934955 timeToLive: (exit) =>
935956 Exit.isSuccess(exit)
@@ -944,12 +965,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 8 unmodified lines
944965 if (!upstream) return;
945966 const gitCommonDir = yield* resolveGitCommonDir(cwd);
946967 yield* Cache.get(
947− statusUpstreamRefreshCache,
948− new StatusUpstreamRefreshCacheKey({
968+ statusRemoteRefreshCache,
969+ new StatusRemoteRefreshCacheKey({
949970 gitCommonDir,
950− upstreamRef: upstream.upstreamRef,
951971 remoteName: upstream.remoteName,
952− upstreamBranch: upstream.upstreamBranch,
953972 }),
954973 );
955974 });
@@ -1177,9 +1196,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 221 unmodified lines
11771196 return branchLastCommit;
11781197 });
11791198
1180− const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1181− yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
1182−
1199+ const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) {
11831200 const statusResult = yield* executeGit(
11841201 "GitCore.statusDetails.status",
11851202 cwd,
@@ -1187,7 +1204,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 1 unmodified line
11871204 {
11881205 allowNonZeroExit: true,
11891206 },
1190− );
1207+ ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null)));
1208+
1209+ if (statusResult === null) {
1210+ return NON_REPOSITORY_STATUS_DETAILS;
1211+ }
11911212
11921213 if (statusResult.code !== 0) {
11931214 const stderr = statusResult.stderr.trim();
@@ -1312,6 +1333,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 118 unmodified lines
13121333 };
13131334 });
13141335
1336+ const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")(
1337+ function* (cwd) {
1338+ return yield* readStatusDetailsLocal(cwd);
1339+ },
1340+ );
1341+
1342+ const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1343+ yield* refreshStatusUpstreamIfStale(cwd).pipe(
1344+ Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1345+ Effect.ignoreCause({ log: true }),
1346+ );
1347+ return yield* readStatusDetailsLocal(cwd);
1348+ });
1349+
13151350 const status: GitCoreShape["status"] = (input) =>
13161351 statusDetails(input.cwd).pipe(
13171352 Effect.map((details) => ({
@@ -1610,7 +1645,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 292 unmodified lines
16101645 executeGit(
16111646 "GitCore.listWorkspaceFiles",
16121647 cwd,
1613− ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
1648+ [
1649+ ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS,
1650+ "ls-files",
1651+ "--cached",
1652+ "--others",
1653+ "--exclude-standard",
1654+ "-z",
1655+ ],
16141656 {
16151657 allowNonZeroExit: true,
16161658 timeoutMs: 20_000,
@@ -1628,7 +1670,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 11 unmodified lines
16281670 createGitCommandError(
16291671 "GitCore.listWorkspaceFiles",
16301672 cwd,
1631− ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
1673+ [
1674+ ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS,
1675+ "ls-files",
1676+ "--cached",
1677+ "--others",
1678+ "--exclude-standard",
1679+ "-z",
1680+ ],
16321681 result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed",
16331682 ),
16341683 ),
@@ -1648,7 +1697,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 13 unmodified lines
16481697 const result = yield* executeGit(
16491698 "GitCore.filterIgnoredPaths",
16501699 cwd,
1651− ["check-ignore", "--no-index", "-z", "--stdin"],
1700+ [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"],
16521701 {
16531702 stdin: `${chunk.join("\0")}\0`,
16541703 allowNonZeroExit: true,
@@ -1662,7 +1711,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 7 unmodified lines
16621711 return yield* createGitCommandError(
16631712 "GitCore.filterIgnoredPaths",
16641713 cwd,
1665− ["check-ignore", "--no-index", "-z", "--stdin"],
1714+ [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"],
16661715 result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed",
16671716 );
16681717 }
@@ -1691,6 +1740,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 22 unmodified lines
16911740 timeoutMs: 10_000,
16921741 allowNonZeroExit: true,
16931742 },
1743+ ).pipe(
1744+ Effect.catchIf(isMissingGitCwdError, () =>
1745+ Effect.succeed({
1746+ code: 128,
1747+ stdout: "",
1748+ stderr: "fatal: not a git repository",
1749+ stdoutTruncated: false,
1750+ stderrTruncated: false,
1751+ }),
1752+ ),
16941753 );
16951754
16961755 if (localBranchResult.code !== 0) {
@@ -1973,7 +2032,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 276 unmodified lines
19732032 "GitCore.removeWorktree",
19742033 input.cwd,
19752034 args,
1976− `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`,
2035+ `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`,
19772036 error,
19782037 ),
19792038 ),
@@ -2000,12 +2059,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 20 unmodified lines
20002059 return { branch: targetBranch };
20012060 });
20022061
2003− const createBranch: GitCoreShape["createBranch"] = (input) =>
2004− executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
2005− timeoutMs: 10_000,
2006− fallbackErrorMessage: "git branch create failed",
2007− }).pipe(Effect.asVoid);
2008−
20092062 const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")(
20102063 function* (input) {
20112064 const [localInputExists, remoteExists] = yield* Effect.all(
@@ -2078,9 +2131,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 66 unmodified lines
20782131 timeoutMs: 10_000,
20792132 fallbackErrorMessage: "git checkout failed",
20802133 });
2134+
2135+ const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [
2136+ "branch",
2137+ "--show-current",
2138+ ]).pipe(Effect.map((stdout) => stdout.trim() || null));
2139+
2140+ return { branch };
20812141 },
20822142 );
20832143
2144+ const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) {
2145+ yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
2146+ timeoutMs: 10_000,
2147+ fallbackErrorMessage: "git branch create failed",
2148+ });
2149+ if (input.checkout) {
2150+ yield* checkoutBranch({ cwd: input.cwd, branch: input.branch });
2151+ }
2152+
2153+ return { branch: input.branch };
2154+ });
2155+
20842156 const initRepo: GitCoreShape["initRepo"] = (input) =>
20852157 executeGit("GitCore.initRepo", input.cwd, ["init"], {
20862158 timeoutMs: 10_000,
@@ -2106,6 +2178,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 19 unmodified lines
21062178 execute,
21072179 status,
21082180 statusDetails,
2181+ statusDetailsLocal,
21092182 prepareCommitContext,
21102183 commit,
21112184 pushCurrentBranch,
apps/server/src/git/Layers/GitHubCli.test.ts+100−1
@@ -6,7 +6,7 @@ vi.mock("../../processRunner", () => ({ · 5 unmodified lines
66 runProcess: vi.fn(),
77 }));
88
9−import { runProcess } from "../../processRunner";
9+import { runProcess } from "../../processRunner.ts";
1010 import { GitHubCli } from "../Services/GitHubCli.ts";
1111 import { GitHubCliLive } from "./GitHubCli.ts";
1212
@@ -76,6 +76,105 @@ layer("GitHubCliLive", (it) => { · 63 unmodified lines
7676 }),
7777 );
7878
79+ it.effect("trims pull request fields decoded from gh json", () =>
80+ Effect.gen(function* () {
81+ mockedRunProcess.mockResolvedValueOnce({
82+ stdout: JSON.stringify({
83+ number: 42,
84+ title: " Add PR thread creation \n",
85+ url: " https://github.com/pingdotgg/codething-mvp/pull/42 ",
86+ baseRefName: " main ",
87+ headRefName: "\tfeature/pr-threads\t",
88+ state: "OPEN",
89+ mergedAt: null,
90+ isCrossRepository: true,
91+ headRepository: {
92+ nameWithOwner: " octocat/codething-mvp ",
93+ },
94+ headRepositoryOwner: {
95+ login: " octocat ",
96+ },
97+ }),
98+ stderr: "",
99+ code: 0,
100+ signal: null,
101+ timedOut: false,
102+ });
103+
104+ const result = yield* Effect.gen(function* () {
105+ const gh = yield* GitHubCli;
106+ return yield* gh.getPullRequest({
107+ cwd: "/repo",
108+ reference: "#42",
109+ });
110+ });
111+
112+ assert.deepStrictEqual(result, {
113+ number: 42,
114+ title: "Add PR thread creation",
115+ url: "https://github.com/pingdotgg/codething-mvp/pull/42",
116+ baseRefName: "main",
117+ headRefName: "feature/pr-threads",
118+ state: "open",
119+ isCrossRepository: true,
120+ headRepositoryNameWithOwner: "octocat/codething-mvp",
121+ headRepositoryOwnerLogin: "octocat",
122+ });
123+ }),
124+ );
125+
126+ it.effect("skips invalid entries when parsing pr lists", () =>
127+ Effect.gen(function* () {
128+ mockedRunProcess.mockResolvedValueOnce({
129+ stdout: JSON.stringify([
130+ {
131+ number: 0,
132+ title: "invalid",
133+ url: "https://github.com/pingdotgg/codething-mvp/pull/0",
134+ baseRefName: "main",
135+ headRefName: "feature/invalid",
136+ },
137+ {
138+ number: 43,
139+ title: " Valid PR ",
140+ url: " https://github.com/pingdotgg/codething-mvp/pull/43 ",
141+ baseRefName: " main ",
142+ headRefName: " feature/pr-list ",
143+ headRepository: {
144+ nameWithOwner: " ",
145+ },
146+ headRepositoryOwner: {
147+ login: " ",
148+ },
149+ },
150+ ]),
151+ stderr: "",
152+ code: 0,
153+ signal: null,
154+ timedOut: false,
155+ });
156+
157+ const result = yield* Effect.gen(function* () {
158+ const gh = yield* GitHubCli;
159+ return yield* gh.listOpenPullRequests({
160+ cwd: "/repo",
161+ headSelector: "feature/pr-list",
162+ });
163+ });
164+
165+ assert.deepStrictEqual(result, [
166+ {
167+ number: 43,
168+ title: "Valid PR",
169+ url: "https://github.com/pingdotgg/codething-mvp/pull/43",
170+ baseRefName: "main",
171+ headRefName: "feature/pr-list",
172+ state: "open",
173+ },
174+ ]);
175+ }),
176+ );
177+
79178 it.effect("reads repository clone URLs", () =>
80179 Effect.gen(function* () {
81180 mockedRunProcess.mockResolvedValueOnce({
apps/server/src/git/Layers/GitHubCli.ts+41−81
@@ -1,14 +1,18 @@
1−import { Effect, Layer, Schema } from "effect";
2−import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
1+import { Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2+import { TrimmedNonEmptyString } from "@t3tools/contracts";
33
4−import { runProcess } from "../../processRunner";
4+import { runProcess } from "../../processRunner.ts";
55 import { GitHubCliError } from "@t3tools/contracts";
66 import {
77 GitHubCli,
88 type GitHubRepositoryCloneUrls,
99 type GitHubCliShape,
10− type GitHubPullRequestSummary,
1110 } from "../Services/GitHubCli.ts";
11+import {
12+ decodeGitHubPullRequestJson,
13+ decodeGitHubPullRequestListJson,
14+ formatGitHubJsonDecodeError,
15+} from "../githubPullRequests.ts";
1216
1317 const DEFAULT_TIMEOUT_MS = 30_000;
1418
@@ -63,76 +67,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown · 48 unmodified lines
6367 });
6468 }
6569
66−function normalizePullRequestState(input: {
67− state?: string | null | undefined;
68− mergedAt?: string | null | undefined;
69−}): "open" | "closed" | "merged" {
70− const mergedAt = input.mergedAt;
71− const state = input.state;
72− if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
73− return "merged";
74− }
75− if (state === "CLOSED") {
76− return "closed";
77− }
78− return "open";
79−}
80−
81−const RawGitHubPullRequestSchema = Schema.Struct({
82− number: PositiveInt,
83− title: TrimmedNonEmptyString,
84− url: TrimmedNonEmptyString,
85− baseRefName: TrimmedNonEmptyString,
86− headRefName: TrimmedNonEmptyString,
87− state: Schema.optional(Schema.NullOr(Schema.String)),
88− mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
89− isCrossRepository: Schema.optional(Schema.Boolean),
90− headRepository: Schema.optional(
91− Schema.NullOr(
92− Schema.Struct({
93− nameWithOwner: Schema.String,
94− }),
95− ),
96− ),
97− headRepositoryOwner: Schema.optional(
98− Schema.NullOr(
99− Schema.Struct({
100− login: Schema.String,
101− }),
102− ),
103− ),
104−});
105−
10670 const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
10771 nameWithOwner: TrimmedNonEmptyString,
10872 url: TrimmedNonEmptyString,
10973 sshUrl: TrimmedNonEmptyString,
11074 });
11175
112−function normalizePullRequestSummary(
113− raw: Schema.Schema.Type<typeof RawGitHubPullRequestSchema>,
114−): GitHubPullRequestSummary {
115− const headRepositoryNameWithOwner = raw.headRepository?.nameWithOwner ?? null;
116− const headRepositoryOwnerLogin =
117− raw.headRepositoryOwner?.login ??
118− (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
119− ? (headRepositoryNameWithOwner.split("/")[0] ?? null)
120− : null);
121− return {
122− number: raw.number,
123− title: raw.title,
124− url: raw.url,
125− baseRefName: raw.baseRefName,
126− headRefName: raw.headRefName,
127− state: normalizePullRequestState(raw),
128− ...(typeof raw.isCrossRepository === "boolean"
129− ? { isCrossRepository: raw.isCrossRepository }
130− : {}),
131− ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
132− ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
133− };
134−}
135−
13676 function normalizeRepositoryCloneUrls(
13777 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
13878 ): GitHubRepositoryCloneUrls {
@@ -154,7 +94,7 @@ function decodeGitHubJson<S extends Schema.Top>( · 15 unmodified lines
15494 (error) =>
15595 new GitHubCliError({
15696 operation,
157− detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail,
97+ detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
15898 cause: error,
15999 }),
160100 ),
@@ -194,14 +134,24 @@ const makeGitHubCli = Effect.sync(() => { · 33 unmodified lines
194134 Effect.flatMap((raw) =>
195135 raw.length === 0
196136 ? Effect.succeed([])
197− : decodeGitHubJson(
198− raw,
199− Schema.Array(RawGitHubPullRequestSchema),
200− "listOpenPullRequests",
201− "GitHub CLI returned invalid PR list JSON.",
137+ : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
138+ Effect.flatMap((decoded) => {
139+ if (!Result.isSuccess(decoded)) {
140+ return Effect.fail(
141+ new GitHubCliError({
142+ operation: "listOpenPullRequests",
143+ detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
144+ cause: decoded.failure,
145+ }),
146+ );
147+ }
148+
149+ return Effect.succeed(
150+ decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary),
151+ );
152+ }),
202153 ),
203154 ),
204− Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)),
205155 ),
206156 getPullRequest: (input) =>
207157 execute({
@@ -216,14 +166,24 @@ const makeGitHubCli = Effect.sync(() => { · 8 unmodified lines
216166 }).pipe(
217167 Effect.map((result) => result.stdout.trim()),
218168 Effect.flatMap((raw) =>
219− decodeGitHubJson(
220− raw,
221− RawGitHubPullRequestSchema,
222− "getPullRequest",
223− "GitHub CLI returned invalid pull request JSON.",
169+ Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
170+ Effect.flatMap((decoded) => {
171+ if (!Result.isSuccess(decoded)) {
172+ return Effect.fail(
173+ new GitHubCliError({
174+ operation: "getPullRequest",
175+ detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
176+ cause: decoded.failure,
177+ }),
178+ );
179+ }
180+
181+ return Effect.succeed(
182+ (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success),
183+ );
184+ }),
224185 ),
225186 ),
226− Effect.map(normalizePullRequestSummary),
227187 ),
228188 getRepositoryCloneUrls: (input) =>
229189 execute({
apps/server/src/git/Layers/GitManager.test.ts+188−3
@@ -186,6 +186,24 @@ function makeTempDir( · 185 unmodified lines
186186 });
187187 }
188188
189+function removePath(
190+ targetPath: string,
191+): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
192+ return Effect.gen(function* () {
193+ const fileSystem = yield* FileSystem.FileSystem;
194+ yield* fileSystem.remove(targetPath, { recursive: true, force: true });
195+ });
196+}
197+
198+function makeDirectory(
199+ dirPath: string,
200+): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
201+ return Effect.gen(function* () {
202+ const fileSystem = yield* FileSystem.FileSystem;
203+ yield* fileSystem.makeDirectory(dirPath, { recursive: true });
204+ });
205+}
206+
189207 function runGit(
190208 cwd: string,
191209 args: readonly string[],
@@ -694,6 +712,144 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 502 unmodified lines
694712 }),
695713 );
696714
715+ it.effect("status trims PR metadata returned by gh before publishing it", () =>
716+ Effect.gen(function* () {
717+ const repoDir = yield* makeTempDir("t3code-git-manager-");
718+ yield* initRepo(repoDir);
719+ yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]);
720+ const remoteDir = yield* createBareRemote();
721+ yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
722+ yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]);
723+
724+ const { manager } = yield* makeManager({
725+ ghScenario: {
726+ prListSequence: [
727+ JSON.stringify([
728+ {
729+ number: 14,
730+ title: " Existing PR title \n",
731+ url: " https://github.com/pingdotgg/codething-mvp/pull/14 ",
732+ baseRefName: " main ",
733+ headRefName: "\tfeature/status-trimmed-pr\t",
734+ },
735+ ]),
736+ ],
737+ },
738+ });
739+
740+ const status = yield* manager.status({ cwd: repoDir });
741+
742+ expect(status.pr).toEqual({
743+ number: 14,
744+ title: "Existing PR title",
745+ url: "https://github.com/pingdotgg/codething-mvp/pull/14",
746+ baseBranch: "main",
747+ headBranch: "feature/status-trimmed-pr",
748+ state: "open",
749+ });
750+ }),
751+ );
752+
753+ it.effect("status ignores invalid gh pr list entries and keeps valid ones", () =>
754+ Effect.gen(function* () {
755+ const repoDir = yield* makeTempDir("t3code-git-manager-");
756+ yield* initRepo(repoDir);
757+ yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]);
758+ const remoteDir = yield* createBareRemote();
759+ yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
760+ yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]);
761+
762+ const { manager } = yield* makeManager({
763+ ghScenario: {
764+ prListSequence: [
765+ JSON.stringify([
766+ {
767+ number: 0,
768+ title: "invalid",
769+ url: "https://github.com/pingdotgg/codething-mvp/pull/0",
770+ baseRefName: "main",
771+ headRefName: "feature/invalid",
772+ },
773+ {
774+ number: 15,
775+ title: " Valid PR title ",
776+ url: " https://github.com/pingdotgg/codething-mvp/pull/15 ",
777+ baseRefName: " main ",
778+ headRefName: "\tfeature/status-valid-pr-entry\t",
779+ headRepository: {
780+ nameWithOwner: " ",
781+ },
782+ headRepositoryOwner: {
783+ login: " ",
784+ },
785+ },
786+ ]),
787+ ],
788+ },
789+ });
790+
791+ const status = yield* manager.status({ cwd: repoDir });
792+
793+ expect(status.pr).toEqual({
794+ number: 15,
795+ title: "Valid PR title",
796+ url: "https://github.com/pingdotgg/codething-mvp/pull/15",
797+ baseBranch: "main",
798+ headBranch: "feature/status-valid-pr-entry",
799+ state: "open",
800+ });
801+ }),
802+ );
803+
804+ it.effect("status preserves lowercase merged and closed PR states from gh json", () =>
805+ Effect.gen(function* () {
806+ const repoDir = yield* makeTempDir("t3code-git-manager-");
807+ yield* initRepo(repoDir);
808+ yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]);
809+ const remoteDir = yield* createBareRemote();
810+ yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
811+ yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]);
812+
813+ const { manager } = yield* makeManager({
814+ ghScenario: {
815+ prListSequence: [
816+ JSON.stringify([
817+ {
818+ number: 16,
819+ title: "Closed PR",
820+ url: "https://github.com/pingdotgg/codething-mvp/pull/16",
821+ baseRefName: "main",
822+ headRefName: "feature/status-lowercase-state",
823+ state: "closed",
824+ updatedAt: "2026-01-01T00:00:00.000Z",
825+ },
826+ {
827+ number: 17,
828+ title: "Merged PR",
829+ url: "https://github.com/pingdotgg/codething-mvp/pull/17",
830+ baseRefName: "main",
831+ headRefName: "feature/status-lowercase-state",
832+ state: "merged",
833+ updatedAt: "2026-01-02T00:00:00.000Z",
834+ },
835+ ]),
836+ ],
837+ },
838+ });
839+
840+ const status = yield* manager.status({ cwd: repoDir });
841+
842+ expect(status.pr).toEqual({
843+ number: 17,
844+ title: "Merged PR",
845+ url: "https://github.com/pingdotgg/codething-mvp/pull/17",
846+ baseBranch: "main",
847+ headBranch: "feature/status-lowercase-state",
848+ state: "merged",
849+ });
850+ }),
851+ );
852+
697853 it.effect("status returns an explicit non-repo result for non-git directories", () =>
698854 Effect.gen(function* () {
699855 const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");
@@ -720,6 +876,35 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 20 unmodified lines
720876 }),
721877 );
722878
879+ it.effect("status returns an explicit non-repo result for deleted directories", () =>
880+ Effect.gen(function* () {
881+ const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-");
882+ const cwd = path.join(rootDir, "deleted-repo");
883+ yield* makeDirectory(cwd);
884+ yield* removePath(cwd);
885+ const { manager } = yield* makeManager();
886+
887+ const status = yield* manager.status({ cwd });
888+
889+ expect(status).toEqual({
890+ isRepo: false,
891+ hasOriginRemote: false,
892+ isDefaultBranch: false,
893+ branch: null,
894+ hasWorkingTreeChanges: false,
895+ workingTree: {
896+ files: [],
897+ insertions: 0,
898+ deletions: 0,
899+ },
900+ hasUpstream: false,
901+ aheadCount: 0,
902+ behindCount: 0,
903+ pr: null,
904+ });
905+ }),
906+ );
907+
723908 it.effect("status briefly caches repeated lookups for the same cwd", () =>
724909 Effect.gen(function* () {
725910 const repoDir = yield* makeTempDir("t3code-git-manager-");
@@ -854,7 +1039,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 128 unmodified lines
8541039 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
8551040 );
8561041 }),
857− 12_000,
1042+ 20_000,
8581043 );
8591044
8601045 it.effect(
@@ -962,7 +1147,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 101 unmodified lines
9621147 ),
9631148 ).toBe(false);
9641149 }),
965− 12_000,
1150+ 20_000,
9661151 );
9671152
9681153 it.effect("status returns merged PR state when latest PR was merged", () =>
@@ -1685,7 +1870,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 716 unmodified lines
16851870 false,
16861871 );
16871872 }),
1688− 12_000,
1873+ 20_000,
16891874 );
16901875
16911876 it.effect(
apps/server/src/git/Layers/GitManager.ts+136−119
@@ -1,16 +1,31 @@
11 import { randomUUID } from "node:crypto";
22 import { realpathSync } from "node:fs";
33
4−import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect";
4+import {
5+ Cache,
6+ Duration,
7+ Effect,
8+ Exit,
9+ FileSystem,
10+ Layer,
11+ Option,
12+ Path,
13+ Ref,
14+ Result,
15+} from "effect";
516 import {
617 GitActionProgressEvent,
718 GitActionProgressPhase,
819 GitCommandError,
920 GitRunStackedActionResult,
1021 GitStackedAction,
22+ type GitStatusLocalResult,
23+ type GitStatusRemoteResult,
1124 ModelSelection,
1225 } from "@t3tools/contracts";
1326 import {
27+ detectGitHostingProviderFromRemoteUrl,
28+ mergeGitStatusParts,
1429 resolveAutoFeatureBranchName,
1530 sanitizeBranchFragment,
1631 sanitizeFeatureBranchName,
@@ -23,13 +38,18 @@ import { · 6 unmodified lines
2338 type GitManagerShape,
2439 type GitRunStackedActionOptions,
2540 } from "../Services/GitManager.ts";
26−import { GitCore, GitStatusDetails } from "../Services/GitCore.ts";
41+import { GitCore } from "../Services/GitCore.ts";
42+import type { GitStatusDetails } from "../Services/GitCore.ts";
2743 import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";
2844 import { TextGeneration } from "../Services/TextGeneration.ts";
2945 import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts";
3046 import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
3147 import { ServerSettingsService } from "../../serverSettings.ts";
3248 import type { GitManagerServiceError } from "@t3tools/contracts";
49+import {
50+ decodeGitHubPullRequestListJson,
51+ formatGitHubJsonDecodeError,
52+} from "../githubPullRequests.ts";
3353
3454 const COMMIT_TIMEOUT_MS = 10 * 60_000;
3555 const MAX_PROGRESS_TEXT_LENGTH = 500;
@@ -236,85 +256,6 @@ function matchesBranchHeadContext( · 200 unmodified lines
236256 return true;
237257 }
238258
239−function parsePullRequestList(raw: unknown): PullRequestInfo[] {
240− if (!Array.isArray(raw)) return [];
241−
242− const parsed: PullRequestInfo[] = [];
243− for (const entry of raw) {
244− if (!entry || typeof entry !== "object") continue;
245− const record = entry as Record<string, unknown>;
246− const number = record.number;
247− const title = record.title;
248− const url = record.url;
249− const baseRefName = record.baseRefName;
250− const headRefName = record.headRefName;
251− const state = record.state;
252− const mergedAt = record.mergedAt;
253− const updatedAt = record.updatedAt;
254− const isCrossRepository = record.isCrossRepository;
255− const headRepositoryRecord =
256− typeof record.headRepository === "object" && record.headRepository !== null
257− ? (record.headRepository as Record<string, unknown>)
258− : null;
259− const headRepositoryOwnerRecord =
260− typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null
261− ? (record.headRepositoryOwner as Record<string, unknown>)
262− : null;
263− const headRepositoryNameWithOwner =
264− typeof record.headRepositoryNameWithOwner === "string"
265− ? record.headRepositoryNameWithOwner
266− : typeof headRepositoryRecord?.nameWithOwner === "string"
267− ? headRepositoryRecord.nameWithOwner
268− : null;
269− const headRepositoryOwnerLogin =
270− typeof record.headRepositoryOwnerLogin === "string"
271− ? record.headRepositoryOwnerLogin
272− : typeof headRepositoryOwnerRecord?.login === "string"
273− ? headRepositoryOwnerRecord.login
274− : null;
275− if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
276− continue;
277− }
278− if (
279− typeof title !== "string" ||
280− typeof url !== "string" ||
281− typeof baseRefName !== "string" ||
282− typeof headRefName !== "string"
283− ) {
284− continue;
285− }
286−
287− let normalizedState: "open" | "closed" | "merged";
288− if (
289− (typeof mergedAt === "string" && mergedAt.trim().length > 0) ||
290− state === "MERGED" ||
291− state === "merged"
292− ) {
293− normalizedState = "merged";
294− } else if (state === "OPEN" || state === "open" || state === undefined || state === null) {
295− normalizedState = "open";
296− } else if (state === "CLOSED" || state === "closed") {
297− normalizedState = "closed";
298− } else {
299− continue;
300− }
301−
302− parsed.push({
303− number,
304− title,
305− url,
306− baseRefName,
307− headRefName,
308− state: normalizedState,
309− updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,
310− ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
311− ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
312− ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
313− });
314− }
315− return parsed;
316−}
317−
318259 function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo {
319260 return {
320261 number: summary.number,
@@ -695,26 +636,54 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 374 unmodified lines
695636
696637 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
697638 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
698− const readStatus = Effect.fn("readStatus")(function* (cwd: string) {
699− const details = yield* gitCore.statusDetails(cwd).pipe(
700− Effect.catchIf(isNotGitRepositoryError, () =>
701− Effect.succeed({
702− isRepo: false,
703− hasOriginRemote: false,
704− isDefaultBranch: false,
705− branch: null,
706− upstreamRef: null,
707− hasWorkingTreeChanges: false,
708− workingTree: { files: [], insertions: 0, deletions: 0 },
709− hasUpstream: false,
710− aheadCount: 0,
711− behindCount: 0,
712− } satisfies GitStatusDetails),
713− ),
714− );
639+ const nonRepositoryStatusDetails = {
640+ isRepo: false,
641+ hasOriginRemote: false,
642+ isDefaultBranch: false,
643+ branch: null,
644+ upstreamRef: null,
645+ hasWorkingTreeChanges: false,
646+ workingTree: { files: [], insertions: 0, deletions: 0 },
647+ hasUpstream: false,
648+ aheadCount: 0,
649+ behindCount: 0,
650+ } satisfies GitStatusDetails;
651+ const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
652+ const details = yield* gitCore
653+ .statusDetailsLocal(cwd)
654+ .pipe(
655+ Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
656+ );
657+ const hostingProvider = details.isRepo
658+ ? yield* resolveHostingProvider(cwd, details.branch)
659+ : null;
660+
661+ return {
662+ isRepo: details.isRepo,
663+ ...(hostingProvider ? { hostingProvider } : {}),
664+ hasOriginRemote: details.hasOriginRemote,
665+ isDefaultBranch: details.isDefaultBranch,
666+ branch: details.branch,
667+ hasWorkingTreeChanges: details.hasWorkingTreeChanges,
668+ workingTree: details.workingTree,
669+ } satisfies GitStatusLocalResult;
670+ });
671+ const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, {
672+ capacity: STATUS_RESULT_CACHE_CAPACITY,
673+ timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
674+ });
675+ const invalidateLocalStatusResultCache = (cwd: string) =>
676+ Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd));
677+ const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) {
678+ const details = yield* gitCore
679+ .statusDetails(cwd)
680+ .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null)));
681+ if (details === null || !details.isRepo) {
682+ return null;
683+ }
715684
716685 const pr =
717− details.isRepo && details.branch !== null
686+ details.branch !== null
718687 ? yield* findLatestPr(cwd, {
719688 branch: details.branch,
720689 upstreamRef: details.upstreamRef,
@@ -725,29 +694,37 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 4 unmodified lines
725694 : null;
726695
727696 return {
728− isRepo: details.isRepo,
729− hasOriginRemote: details.hasOriginRemote,
730− isDefaultBranch: details.isDefaultBranch,
731− branch: details.branch,
732− hasWorkingTreeChanges: details.hasWorkingTreeChanges,
733− workingTree: details.workingTree,
734697 hasUpstream: details.hasUpstream,
735698 aheadCount: details.aheadCount,
736699 behindCount: details.behindCount,
737700 pr,
738− };
701+ } satisfies GitStatusRemoteResult;
739702 });
740− const statusResultCache = yield* Cache.makeWith({
703+ const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, {
741704 capacity: STATUS_RESULT_CACHE_CAPACITY,
742− lookup: readStatus,
743705 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
744706 });
745− const invalidateStatusResultCache = (cwd: string) =>
746− Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd));
707+ const invalidateRemoteStatusResultCache = (cwd: string) =>
708+ Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd));
747709
748710 const readConfigValueNullable = (cwd: string, key: string) =>
749711 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
750712
713+ const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* (
714+ cwd: string,
715+ branch: string | null,
716+ ) {
717+ const preferredRemoteName =
718+ branch === null
719+ ? "origin"
720+ : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin");
721+ const remoteUrl =
722+ (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
723+ (yield* readConfigValueNullable(cwd, "remote.origin.url"));
724+
725+ return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null;
726+ });
727+
751728 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
752729 cwd: string,
753730 remoteName: string | null,
@@ -907,13 +884,23 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 153 unmodified lines
907884 continue;
908885 }
909886
910− const parsedJson = yield* Effect.try({
911− try: () => JSON.parse(raw) as unknown,
912− catch: (cause) =>
913− gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
914− });
887+ const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
888+ Effect.flatMap((decoded) => {
889+ if (!Result.isSuccess(decoded)) {
890+ return Effect.fail(
891+ gitManagerError(
892+ "findLatestPr",
893+ `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
894+ decoded.failure,
895+ ),
896+ );
897+ }
915898
916− for (const pr of parsePullRequestList(parsedJson)) {
899+ return Effect.succeed(decoded.success);
900+ }),
901+ );
902+
903+ for (const pr of pullRequests) {
917904 if (!matchesBranchHeadContext(pr, headContext)) {
918905 continue;
919906 }
@@ -1311,9 +1298,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 391 unmodified lines
13111298 };
13121299 });
13131300
1301+ const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) {
1302+ return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd));
1303+ });
1304+ const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")(
1305+ function* (input) {
1306+ return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd));
1307+ },
1308+ );
13141309 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
1315− return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd));
1310+ const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]);
1311+ return mergeGitStatusParts(local, remote);
1312+ });
1313+ const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn(
1314+ "invalidateLocalStatus",
1315+ )(function* (cwd) {
1316+ yield* invalidateLocalStatusResultCache(cwd);
13161317 });
1318+ const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn(
1319+ "invalidateRemoteStatus",
1320+ )(function* (cwd) {
1321+ yield* invalidateRemoteStatusResultCache(cwd);
1322+ });
1323+ const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")(
1324+ function* (cwd) {
1325+ yield* invalidateLocalStatusResultCache(cwd);
1326+ yield* invalidateRemoteStatusResultCache(cwd);
1327+ },
1328+ );
13171329
13181330 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
13191331 function* (input) {
@@ -1488,7 +1500,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 168 unmodified lines
14881500 branch: worktree.worktree.branch,
14891501 worktreePath: worktree.worktree.path,
14901502 };
1491− }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd)));
1503+ }).pipe(Effect.ensuring(invalidateStatus(input.cwd)));
14921504 });
14931505
14941506 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
@@ -1692,7 +1704,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 197 unmodified lines
16921704 });
16931705
16941706 return yield* runAction().pipe(
1695− Effect.ensuring(invalidateStatusResultCache(input.cwd)),
1707+ Effect.ensuring(invalidateStatus(input.cwd)),
16961708 Effect.tapError((error) =>
16971709 Effect.flatMap(Ref.get(currentPhase), (phase) =>
16981710 progress.emit({
@@ -1707,7 +1719,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 8 unmodified lines
17071719 );
17081720
17091721 return {
1722+ localStatus,
1723+ remoteStatus,
17101724 status,
1725+ invalidateLocalStatus,
1726+ invalidateRemoteStatus,
1727+ invalidateStatus,
17111728 resolvePullRequest,
17121729 preparePullRequestThread,
17131730 runStackedAction,
apps/server/src/git/Layers/GitStatusBroadcaster.test.ts+312−0
@@ -0,0 +1,312 @@
1+import { assert, it } from "@effect/vitest";
2+import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect";
3+import type {
4+ GitStatusLocalResult,
5+ GitStatusRemoteResult,
6+ GitStatusResult,
7+ GitStatusStreamEvent,
8+} from "@t3tools/contracts";
9+import { describe } from "vitest";
10+
11+import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts";
12+import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts";
13+import { type GitManagerShape, GitManager } from "../Services/GitManager.ts";
14+
15+const baseLocalStatus: GitStatusLocalResult = {
16+ isRepo: true,
17+ hostingProvider: {
18+ kind: "github",
19+ name: "GitHub",
20+ baseUrl: "https://github.com",
21+ },
22+ hasOriginRemote: true,
23+ isDefaultBranch: false,
24+ branch: "feature/status-broadcast",
25+ hasWorkingTreeChanges: false,
26+ workingTree: { files: [], insertions: 0, deletions: 0 },
27+};
28+
29+const baseRemoteStatus: GitStatusRemoteResult = {
30+ hasUpstream: true,
31+ aheadCount: 0,
32+ behindCount: 0,
33+ pr: null,
34+};
35+
36+const baseStatus: GitStatusResult = {
37+ ...baseLocalStatus,
38+ ...baseRemoteStatus,
39+};
40+
41+function makeTestLayer(state: {
42+ currentLocalStatus: GitStatusLocalResult;
43+ currentRemoteStatus: GitStatusRemoteResult | null;
44+ localStatusCalls: number;
45+ remoteStatusCalls: number;
46+ localInvalidationCalls: number;
47+ remoteInvalidationCalls: number;
48+}) {
49+ const gitManager: GitManagerShape = {
50+ localStatus: () =>
51+ Effect.sync(() => {
52+ state.localStatusCalls += 1;
53+ return state.currentLocalStatus;
54+ }),
55+ remoteStatus: () =>
56+ Effect.sync(() => {
57+ state.remoteStatusCalls += 1;
58+ return state.currentRemoteStatus;
59+ }),
60+ status: () => Effect.die("status should not be called in this test"),
61+ invalidateLocalStatus: () =>
62+ Effect.sync(() => {
63+ state.localInvalidationCalls += 1;
64+ }),
65+ invalidateRemoteStatus: () =>
66+ Effect.sync(() => {
67+ state.remoteInvalidationCalls += 1;
68+ }),
69+ invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"),
70+ resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"),
71+ preparePullRequestThread: () =>
72+ Effect.die("preparePullRequestThread should not be called in this test"),
73+ runStackedAction: () => Effect.die("runStackedAction should not be called in this test"),
74+ };
75+
76+ return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager)));
77+}
78+
79+describe("GitStatusBroadcasterLive", () => {
80+ it.effect("reuses the cached git status across repeated reads", () => {
81+ const state = {
82+ currentLocalStatus: baseLocalStatus,
83+ currentRemoteStatus: baseRemoteStatus,
84+ localStatusCalls: 0,
85+ remoteStatusCalls: 0,
86+ localInvalidationCalls: 0,
87+ remoteInvalidationCalls: 0,
88+ };
89+
90+ return Effect.gen(function* () {
91+ const broadcaster = yield* GitStatusBroadcaster;
92+
93+ const first = yield* broadcaster.getStatus({ cwd: "/repo" });
94+ const second = yield* broadcaster.getStatus({ cwd: "/repo" });
95+
96+ assert.deepStrictEqual(first, baseStatus);
97+ assert.deepStrictEqual(second, baseStatus);
98+ assert.equal(state.localStatusCalls, 1);
99+ assert.equal(state.remoteStatusCalls, 1);
100+ assert.equal(state.localInvalidationCalls, 0);
101+ assert.equal(state.remoteInvalidationCalls, 0);
102+ }).pipe(Effect.provide(makeTestLayer(state)));
103+ });
104+
105+ it.effect("refreshes the cached snapshot after explicit invalidation", () => {
106+ const state = {
107+ currentLocalStatus: baseLocalStatus,
108+ currentRemoteStatus: baseRemoteStatus,
109+ localStatusCalls: 0,
110+ remoteStatusCalls: 0,
111+ localInvalidationCalls: 0,
112+ remoteInvalidationCalls: 0,
113+ };
114+
115+ return Effect.gen(function* () {
116+ const broadcaster = yield* GitStatusBroadcaster;
117+ const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
118+
119+ state.currentLocalStatus = {
120+ ...baseLocalStatus,
121+ branch: "feature/updated-status",
122+ };
123+ state.currentRemoteStatus = {
124+ ...baseRemoteStatus,
125+ aheadCount: 2,
126+ };
127+ const refreshed = yield* broadcaster.refreshStatus("/repo");
128+ const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
129+
130+ assert.deepStrictEqual(initial, baseStatus);
131+ assert.deepStrictEqual(refreshed, {
132+ ...state.currentLocalStatus,
133+ ...state.currentRemoteStatus,
134+ });
135+ assert.deepStrictEqual(cached, {
136+ ...state.currentLocalStatus,
137+ ...state.currentRemoteStatus,
138+ });
139+ assert.equal(state.localStatusCalls, 2);
140+ assert.equal(state.remoteStatusCalls, 2);
141+ assert.equal(state.localInvalidationCalls, 1);
142+ assert.equal(state.remoteInvalidationCalls, 1);
143+ }).pipe(Effect.provide(makeTestLayer(state)));
144+ });
145+
146+ it.effect("refreshes only the cached local snapshot when requested", () => {
147+ const state = {
148+ currentLocalStatus: baseLocalStatus,
149+ currentRemoteStatus: baseRemoteStatus,
150+ localStatusCalls: 0,
151+ remoteStatusCalls: 0,
152+ localInvalidationCalls: 0,
153+ remoteInvalidationCalls: 0,
154+ };
155+
156+ return Effect.gen(function* () {
157+ const broadcaster = yield* GitStatusBroadcaster;
158+ const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
159+
160+ state.currentLocalStatus = {
161+ ...baseLocalStatus,
162+ branch: "feature/local-only-refresh",
163+ hasWorkingTreeChanges: true,
164+ };
165+
166+ const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo");
167+ const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
168+
169+ assert.deepStrictEqual(initial, baseStatus);
170+ assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus);
171+ assert.deepStrictEqual(cached, {
172+ ...state.currentLocalStatus,
173+ ...baseRemoteStatus,
174+ });
175+ assert.equal(state.localStatusCalls, 2);
176+ assert.equal(state.remoteStatusCalls, 1);
177+ assert.equal(state.localInvalidationCalls, 1);
178+ assert.equal(state.remoteInvalidationCalls, 0);
179+ }).pipe(Effect.provide(makeTestLayer(state)));
180+ });
181+
182+ it.effect("streams a local snapshot first and remote updates later", () => {
183+ const state = {
184+ currentLocalStatus: baseLocalStatus,
185+ currentRemoteStatus: baseRemoteStatus,
186+ localStatusCalls: 0,
187+ remoteStatusCalls: 0,
188+ localInvalidationCalls: 0,
189+ remoteInvalidationCalls: 0,
190+ };
191+
192+ return Effect.gen(function* () {
193+ const broadcaster = yield* GitStatusBroadcaster;
194+ const snapshotDeferred = yield* Deferred.make<GitStatusStreamEvent>();
195+ const remoteUpdatedDeferred = yield* Deferred.make<GitStatusStreamEvent>();
196+ yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => {
197+ if (event._tag === "snapshot") {
198+ return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore);
199+ }
200+ if (event._tag === "remoteUpdated") {
201+ return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore);
202+ }
203+ return Effect.void;
204+ }).pipe(Effect.forkScoped);
205+
206+ const snapshot = yield* Deferred.await(snapshotDeferred);
207+ yield* broadcaster.refreshStatus("/repo");
208+ const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred);
209+
210+ assert.deepStrictEqual(snapshot, {
211+ _tag: "snapshot",
212+ local: baseLocalStatus,
213+ remote: null,
214+ } satisfies GitStatusStreamEvent);
215+ assert.deepStrictEqual(remoteUpdated, {
216+ _tag: "remoteUpdated",
217+ remote: baseRemoteStatus,
218+ } satisfies GitStatusStreamEvent);
219+ }).pipe(Effect.provide(makeTestLayer(state)));
220+ });
221+
222+ it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
223+ const state = {
224+ currentLocalStatus: baseLocalStatus,
225+ currentRemoteStatus: baseRemoteStatus,
226+ localStatusCalls: 0,
227+ remoteStatusCalls: 0,
228+ localInvalidationCalls: 0,
229+ remoteInvalidationCalls: 0,
230+ };
231+ let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null;
232+ let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null;
233+ const testLayer = GitStatusBroadcasterLive.pipe(
234+ Layer.provide(
235+ Layer.succeed(GitManager, {
236+ localStatus: () =>
237+ Effect.sync(() => {
238+ state.localStatusCalls += 1;
239+ return state.currentLocalStatus;
240+ }),
241+ remoteStatus: () =>
242+ Effect.sync(() => {
243+ state.remoteStatusCalls += 1;
244+ }).pipe(
245+ Effect.andThen(
246+ remoteStartedDeferred
247+ ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore)
248+ : Effect.void,
249+ ),
250+ Effect.andThen(Effect.never as Effect.Effect<GitStatusRemoteResult | null, never>),
251+ Effect.onInterrupt(() =>
252+ remoteInterruptedDeferred
253+ ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore)
254+ : Effect.void,
255+ ),
256+ ),
257+ status: () => Effect.die("status should not be called in this test"),
258+ invalidateLocalStatus: () =>
259+ Effect.sync(() => {
260+ state.localInvalidationCalls += 1;
261+ }),
262+ invalidateRemoteStatus: () =>
263+ Effect.sync(() => {
264+ state.remoteInvalidationCalls += 1;
265+ }),
266+ invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"),
267+ resolvePullRequest: () =>
268+ Effect.die("resolvePullRequest should not be called in this test"),
269+ preparePullRequestThread: () =>
270+ Effect.die("preparePullRequestThread should not be called in this test"),
271+ runStackedAction: () => Effect.die("runStackedAction should not be called in this test"),
272+ } satisfies GitManagerShape),
273+ ),
274+ );
275+
276+ return Effect.gen(function* () {
277+ const remoteInterrupted = yield* Deferred.make<void>();
278+ const remoteStarted = yield* Deferred.make<void>();
279+ remoteInterruptedDeferred = remoteInterrupted;
280+ remoteStartedDeferred = remoteStarted;
281+
282+ const broadcaster = yield* GitStatusBroadcaster;
283+ const firstSnapshot = yield* Deferred.make<GitStatusStreamEvent>();
284+ const secondSnapshot = yield* Deferred.make<GitStatusStreamEvent>();
285+ const firstScope = yield* Scope.make();
286+ const secondScope = yield* Scope.make();
287+ yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
288+ event._tag === "snapshot"
289+ ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore)
290+ : Effect.void,
291+ ).pipe(Effect.forkIn(firstScope));
292+ yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
293+ event._tag === "snapshot"
294+ ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore)
295+ : Effect.void,
296+ ).pipe(Effect.forkIn(secondScope));
297+
298+ yield* Deferred.await(firstSnapshot);
299+ yield* Deferred.await(secondSnapshot);
300+ yield* Deferred.await(remoteStarted);
301+
302+ assert.equal(state.remoteStatusCalls, 1);
303+
304+ yield* Scope.close(firstScope, Exit.void);
305+ assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true);
306+
307+ yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped);
308+ yield* Deferred.await(remoteInterrupted);
309+ assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true);
310+ }).pipe(Effect.provide(testLayer));
311+ });
312+});
apps/server/src/git/Layers/GitStatusBroadcaster.ts+311−0
@@ -0,0 +1,311 @@
1+import { realpathSync } from "node:fs";
2+
3+import {
4+ Duration,
5+ Effect,
6+ Exit,
7+ Fiber,
8+ Layer,
9+ PubSub,
10+ Ref,
11+ Scope,
12+ Stream,
13+ SynchronizedRef,
14+} from "effect";
15+import type {
16+ GitStatusInput,
17+ GitStatusLocalResult,
18+ GitStatusRemoteResult,
19+ GitStatusStreamEvent,
20+} from "@t3tools/contracts";
21+import { mergeGitStatusParts } from "@t3tools/shared/git";
22+
23+import {
24+ GitStatusBroadcaster,
25+ type GitStatusBroadcasterShape,
26+} from "../Services/GitStatusBroadcaster.ts";
27+import { GitManager } from "../Services/GitManager.ts";
28+
29+const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
30+
31+interface GitStatusChange {
32+ readonly cwd: string;
33+ readonly event: GitStatusStreamEvent;
34+}
35+
36+interface CachedValue<T> {
37+ readonly fingerprint: string;
38+ readonly value: T;
39+}
40+
41+interface CachedGitStatus {
42+ readonly local: CachedValue<GitStatusLocalResult> | null;
43+ readonly remote: CachedValue<GitStatusRemoteResult | null> | null;
44+}
45+
46+interface ActiveRemotePoller {
47+ readonly fiber: Fiber.Fiber<void, never>;
48+ readonly subscriberCount: number;
49+}
50+
51+function normalizeCwd(cwd: string): string {
52+ try {
53+ return realpathSync.native(cwd);
54+ } catch {
55+ return cwd;
56+ }
57+}
58+
59+function fingerprintStatusPart(status: unknown): string {
60+ return JSON.stringify(status);
61+}
62+
63+export const GitStatusBroadcasterLive = Layer.effect(
64+ GitStatusBroadcaster,
65+ Effect.gen(function* () {
66+ const gitManager = yield* GitManager;
67+ const changesPubSub = yield* Effect.acquireRelease(
68+ PubSub.unbounded<GitStatusChange>(),
69+ (pubsub) => PubSub.shutdown(pubsub),
70+ );
71+ const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
72+ Scope.close(scope, Exit.void),
73+ );
74+ const cacheRef = yield* Ref.make(new Map<string, CachedGitStatus>());
75+ const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
76+
77+ const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) {
78+ return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null));
79+ });
80+
81+ const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* (
82+ cwd: string,
83+ local: GitStatusLocalResult,
84+ options?: { publish?: boolean },
85+ ) {
86+ const nextLocal = {
87+ fingerprint: fingerprintStatusPart(local),
88+ value: local,
89+ } satisfies CachedValue<GitStatusLocalResult>;
90+ const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
91+ const previous = cache.get(cwd) ?? { local: null, remote: null };
92+ const nextCache = new Map(cache);
93+ nextCache.set(cwd, {
94+ ...previous,
95+ local: nextLocal,
96+ });
97+ return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
98+ });
99+
100+ if (options?.publish && shouldPublish) {
101+ yield* PubSub.publish(changesPubSub, {
102+ cwd,
103+ event: {
104+ _tag: "localUpdated",
105+ local,
106+ },
107+ });
108+ }
109+
110+ return local;
111+ });
112+
113+ const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* (
114+ cwd: string,
115+ remote: GitStatusRemoteResult | null,
116+ options?: { publish?: boolean },
117+ ) {
118+ const nextRemote = {
119+ fingerprint: fingerprintStatusPart(remote),
120+ value: remote,
121+ } satisfies CachedValue<GitStatusRemoteResult | null>;
122+ const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
123+ const previous = cache.get(cwd) ?? { local: null, remote: null };
124+ const nextCache = new Map(cache);
125+ nextCache.set(cwd, {
126+ ...previous,
127+ remote: nextRemote,
128+ });
129+ return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const;
130+ });
131+
132+ if (options?.publish && shouldPublish) {
133+ yield* PubSub.publish(changesPubSub, {
134+ cwd,
135+ event: {
136+ _tag: "remoteUpdated",
137+ remote,
138+ },
139+ });
140+ }
141+
142+ return remote;
143+ });
144+
145+ const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) {
146+ const local = yield* gitManager.localStatus({ cwd });
147+ return yield* updateCachedLocalStatus(cwd, local);
148+ });
149+
150+ const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) {
151+ const remote = yield* gitManager.remoteStatus({ cwd });
152+ return yield* updateCachedRemoteStatus(cwd, remote);
153+ });
154+
155+ const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) {
156+ const cached = yield* getCachedStatus(cwd);
157+ if (cached?.local) {
158+ return cached.local.value;
159+ }
160+ return yield* loadLocalStatus(cwd);
161+ });
162+
163+ const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) {
164+ const cached = yield* getCachedStatus(cwd);
165+ if (cached?.remote) {
166+ return cached.remote.value;
167+ }
168+ return yield* loadRemoteStatus(cwd);
169+ });
170+
171+ const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* (
172+ input: GitStatusInput,
173+ ) {
174+ const normalizedCwd = normalizeCwd(input.cwd);
175+ const [local, remote] = yield* Effect.all([
176+ getOrLoadLocalStatus(normalizedCwd),
177+ getOrLoadRemoteStatus(normalizedCwd),
178+ ]);
179+ return mergeGitStatusParts(local, remote);
180+ });
181+
182+ const refreshLocalStatus: GitStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn(
183+ "refreshLocalStatus",
184+ )(function* (cwd) {
185+ const normalizedCwd = normalizeCwd(cwd);
186+ yield* gitManager.invalidateLocalStatus(normalizedCwd);
187+ const local = yield* gitManager.localStatus({ cwd: normalizedCwd });
188+ return yield* updateCachedLocalStatus(normalizedCwd, local, { publish: true });
189+ });
190+
191+ const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) {
192+ yield* gitManager.invalidateRemoteStatus(cwd);
193+ const remote = yield* gitManager.remoteStatus({ cwd });
194+ return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
195+ });
196+
197+ const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")(
198+ function* (cwd) {
199+ const normalizedCwd = normalizeCwd(cwd);
200+ const [local, remote] = yield* Effect.all([
201+ refreshLocalStatus(normalizedCwd),
202+ refreshRemoteStatus(normalizedCwd),
203+ ]);
204+ return mergeGitStatusParts(local, remote);
205+ },
206+ );
207+
208+ const makeRemoteRefreshLoop = (cwd: string) => {
209+ const logRefreshFailure = (error: Error) =>
210+ Effect.logWarning("git remote status refresh failed", {
211+ cwd,
212+ detail: error.message,
213+ });
214+
215+ return refreshRemoteStatus(cwd).pipe(
216+ Effect.catch(logRefreshFailure),
217+ Effect.andThen(
218+ Effect.forever(
219+ Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe(
220+ Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))),
221+ ),
222+ ),
223+ ),
224+ );
225+ };
226+
227+ const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) {
228+ yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
229+ const existing = activePollers.get(cwd);
230+ if (existing) {
231+ const nextPollers = new Map(activePollers);
232+ nextPollers.set(cwd, {
233+ ...existing,
234+ subscriberCount: existing.subscriberCount + 1,
235+ });
236+ return Effect.succeed([undefined, nextPollers] as const);
237+ }
238+
239+ return makeRemoteRefreshLoop(cwd).pipe(
240+ Effect.forkIn(broadcasterScope),
241+ Effect.map((fiber) => {
242+ const nextPollers = new Map(activePollers);
243+ nextPollers.set(cwd, {
244+ fiber,
245+ subscriberCount: 1,
246+ });
247+ return [undefined, nextPollers] as const;
248+ }),
249+ );
250+ });
251+ });
252+
253+ const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) {
254+ const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {
255+ const existing = activePollers.get(cwd);
256+ if (!existing) {
257+ return [null, activePollers] as const;
258+ }
259+
260+ if (existing.subscriberCount > 1) {
261+ const nextPollers = new Map(activePollers);
262+ nextPollers.set(cwd, {
263+ ...existing,
264+ subscriberCount: existing.subscriberCount - 1,
265+ });
266+ return [null, nextPollers] as const;
267+ }
268+
269+ const nextPollers = new Map(activePollers);
270+ nextPollers.delete(cwd);
271+ return [existing.fiber, nextPollers] as const;
272+ });
273+
274+ if (pollerToInterrupt) {
275+ yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore);
276+ }
277+ });
278+
279+ const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) =>
280+ Stream.unwrap(
281+ Effect.gen(function* () {
282+ const normalizedCwd = normalizeCwd(input.cwd);
283+ const subscription = yield* PubSub.subscribe(changesPubSub);
284+ const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd);
285+ const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null;
286+ yield* retainRemotePoller(normalizedCwd);
287+
288+ const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid);
289+
290+ return Stream.concat(
291+ Stream.make({
292+ _tag: "snapshot" as const,
293+ local: initialLocal,
294+ remote: initialRemote,
295+ }),
296+ Stream.fromSubscription(subscription).pipe(
297+ Stream.filter((event) => event.cwd === normalizedCwd),
298+ Stream.map((event) => event.event),
299+ ),
300+ ).pipe(Stream.ensuring(release));
301+ }),
302+ );
303+
304+ return {
305+ getStatus,
306+ refreshLocalStatus,
307+ refreshStatus,
308+ streamStatus,
309+ } satisfies GitStatusBroadcasterShape;
310+ }),
311+);
apps/server/src/git/Layers/RoutingTextGeneration.ts+3−3
@@ -9,7 +9,7 @@ · 8 unmodified lines
99 *
1010 * @module RoutingTextGeneration
1111 */
12−import { Effect, Layer, ServiceMap } from "effect";
12+import { Effect, Layer, Context } from "effect";
1313
1414 import {
1515 TextGeneration,
@@ -23,11 +23,11 @@ import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; · 7 unmodified lines
2323 // Internal service tags so both concrete layers can coexist.
2424 // ---------------------------------------------------------------------------
2525
26−class CodexTextGen extends ServiceMap.Service<CodexTextGen, TextGenerationShape>()(
26+class CodexTextGen extends Context.Service<CodexTextGen, TextGenerationShape>()(
2727 "t3/git/Layers/RoutingTextGeneration/CodexTextGen",
2828 ) {}
2929
30−class ClaudeTextGen extends ServiceMap.Service<ClaudeTextGen, TextGenerationShape>()(
30+class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>()(
3131 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
3232 ) {}
3333
apps/server/src/git/Services/GitCore.ts+14−7
@@ -6,11 +6,13 @@ · 5 unmodified lines
66 *
77 * @module GitCore
88 */
9−import { ServiceMap } from "effect";
10−import type { Effect, Scope } from "effect";
9+import { Context } from "effect";
10+import type { Effect } from "effect";
1111 import type {
1212 GitCheckoutInput,
13+ GitCheckoutResult,
1314 GitCreateBranchInput,
15+ GitCreateBranchResult,
1416 GitCreateWorktreeInput,
1517 GitCreateWorktreeResult,
1618 GitInitInput,
@@ -156,6 +158,11 @@ export interface GitCoreShape { · 139 unmodified lines
156158 */
157159 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
158160
161+ /**
162+ * Read detailed working tree / branch status without refreshing remote tracking refs.
163+ */
164+ readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
165+
159166 /**
160167 * Build staged change context for commit generation.
161168 */
@@ -278,14 +285,16 @@ export interface GitCoreShape { · 116 unmodified lines
278285 /**
279286 * Create a local branch.
280287 */
281− readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect<void, GitCommandError>;
288+ readonly createBranch: (
289+ input: GitCreateBranchInput,
290+ ) => Effect.Effect<GitCreateBranchResult, GitCommandError>;
282291
283292 /**
284293 * Checkout an existing branch and refresh its upstream metadata in background.
285294 */
286295 readonly checkoutBranch: (
287296 input: GitCheckoutInput,
288− ) => Effect.Effect<void, GitCommandError, Scope.Scope>;
297+ ) => Effect.Effect<GitCheckoutResult, GitCommandError>;
289298
290299 /**
291300 * Initialize a repository in the provided directory.
@@ -301,6 +310,4 @@ export interface GitCoreShape { · 9 unmodified lines
301310 /**
302311 * GitCore - Service tag for low-level Git repository operations.
303312 */
304−export class GitCore extends ServiceMap.Service<GitCore, GitCoreShape>()(
305− "t3/git/Services/GitCore",
306−) {}
313+export class GitCore extends Context.Service<GitCore, GitCoreShape>()("t3/git/Services/GitCore") {}
apps/server/src/git/Services/GitHubCli.ts+3−3
@@ -5,10 +5,10 @@ · 4 unmodified lines
55 *
66 * @module GitHubCli
77 */
8−import { ServiceMap } from "effect";
8+import { Context } from "effect";
99 import type { Effect } from "effect";
1010
11−import type { ProcessRunResult } from "../../processRunner";
11+import type { ProcessRunResult } from "../../processRunner.ts";
1212 import type { GitHubCliError } from "@t3tools/contracts";
1313
1414 export interface GitHubPullRequestSummary {
@@ -98,6 +98,6 @@ export interface GitHubCliShape { · 83 unmodified lines
9898 /**
9999 * GitHubCli - Service tag for GitHub CLI process execution.
100100 */
101−export class GitHubCli extends ServiceMap.Service<GitHubCli, GitHubCliShape>()(
101+export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()(
102102 "t3/git/Services/GitHubCli",
103103 ) {}
apps/server/src/git/Services/GitManager.ts+33−2
@@ -14,10 +14,12 @@ import { · 13 unmodified lines
1414 GitResolvePullRequestResult,
1515 GitRunStackedActionInput,
1616 GitRunStackedActionResult,
17+ GitStatusLocalResult,
18+ GitStatusRemoteResult,
1719 GitStatusInput,
1820 GitStatusResult,
1921 } from "@t3tools/contracts";
20−import { ServiceMap } from "effect";
22+import { Context } from "effect";
2123 import type { Effect } from "effect";
2224 import type { GitManagerServiceError } from "@t3tools/contracts";
2325
@@ -41,6 +43,35 @@ export interface GitManagerShape { · 17 unmodified lines
4143 input: GitStatusInput,
4244 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
4345
46+ /**
47+ * Read local repository status without remote hosting enrichment.
48+ */
49+ readonly localStatus: (
50+ input: GitStatusInput,
51+ ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>;
52+
53+ /**
54+ * Read remote tracking / PR status for a repository.
55+ */
56+ readonly remoteStatus: (
57+ input: GitStatusInput,
58+ ) => Effect.Effect<GitStatusRemoteResult | null, GitManagerServiceError>;
59+
60+ /**
61+ * Clear any cached local status snapshot for a repository.
62+ */
63+ readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
64+
65+ /**
66+ * Clear any cached remote status snapshot for a repository.
67+ */
68+ readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
69+
70+ /**
71+ * Clear any cached status snapshot for a repository so the next read is fresh.
72+ */
73+ readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
74+
4475 /**
4576 * Resolve a pull request by URL/number against the current repository.
4677 */
@@ -68,6 +99,6 @@ export interface GitManagerShape { · 21 unmodified lines
6899 /**
69100 * GitManager - Service tag for stacked Git workflow orchestration.
70101 */
71−export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()(
102+export class GitManager extends Context.Service<GitManager, GitManagerShape>()(
72103 "t3/git/Services/GitManager",
73104 ) {}
apps/server/src/git/Services/GitStatusBroadcaster.ts+27−0
@@ -0,0 +1,27 @@
1+import { Context } from "effect";
2+import type { Effect, Stream } from "effect";
3+import type {
4+ GitManagerServiceError,
5+ GitStatusInput,
6+ GitStatusLocalResult,
7+ GitStatusResult,
8+ GitStatusStreamEvent,
9+} from "@t3tools/contracts";
10+
11+export interface GitStatusBroadcasterShape {
12+ readonly getStatus: (
13+ input: GitStatusInput,
14+ ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
15+ readonly refreshLocalStatus: (
16+ cwd: string,
17+ ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>;
18+ readonly refreshStatus: (cwd: string) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
19+ readonly streamStatus: (
20+ input: GitStatusInput,
21+ ) => Stream.Stream<GitStatusStreamEvent, GitManagerServiceError>;
22+}
23+
24+export class GitStatusBroadcaster extends Context.Service<
25+ GitStatusBroadcaster,
26+ GitStatusBroadcasterShape
27+>()("t3/git/Services/GitStatusBroadcaster") {}
apps/server/src/git/Services/TextGeneration.ts+2−2
@@ -6,7 +6,7 @@ · 5 unmodified lines
66 *
77 * @module TextGeneration
88 */
9−import { ServiceMap } from "effect";
9+import { Context } from "effect";
1010 import type { Effect } from "effect";
1111 import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
1212
@@ -118,6 +118,6 @@ export interface TextGenerationShape { · 105 unmodified lines
118118 /**
119119 * TextGeneration - Service tag for commit and PR text generation.
120120 */
121−export class TextGeneration extends ServiceMap.Service<TextGeneration, TextGenerationShape>()(
121+export class TextGeneration extends Context.Service<TextGeneration, TextGenerationShape>()(
122122 "t3/git/Services/TextGeneration",
123123 ) {}
apps/server/src/git/githubPullRequests.ts+128−0
@@ -0,0 +1,128 @@
1+import { Cause, Exit, Result, Schema } from "effect";
2+import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
3+import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
4+
5+export interface NormalizedGitHubPullRequestRecord {
6+ readonly number: number;
7+ readonly title: string;
8+ readonly url: string;
9+ readonly baseRefName: string;
10+ readonly headRefName: string;
11+ readonly state: "open" | "closed" | "merged";
12+ readonly updatedAt: string | null;
13+ readonly isCrossRepository?: boolean;
14+ readonly headRepositoryNameWithOwner?: string | null;
15+ readonly headRepositoryOwnerLogin?: string | null;
16+}
17+
18+const GitHubPullRequestSchema = Schema.Struct({
19+ number: PositiveInt,
20+ title: TrimmedNonEmptyString,
21+ url: TrimmedNonEmptyString,
22+ baseRefName: TrimmedNonEmptyString,
23+ headRefName: TrimmedNonEmptyString,
24+ state: Schema.optional(Schema.NullOr(Schema.String)),
25+ mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
26+ updatedAt: Schema.optional(Schema.NullOr(Schema.String)),
27+ isCrossRepository: Schema.optional(Schema.Boolean),
28+ headRepository: Schema.optional(
29+ Schema.NullOr(
30+ Schema.Struct({
31+ nameWithOwner: Schema.String,
32+ }),
33+ ),
34+ ),
35+ headRepositoryOwner: Schema.optional(
36+ Schema.NullOr(
37+ Schema.Struct({
38+ login: Schema.String,
39+ }),
40+ ),
41+ ),
42+});
43+
44+function trimOptionalString(value: string | null | undefined): string | null {
45+ const trimmed = value?.trim() ?? "";
46+ return trimmed.length > 0 ? trimmed : null;
47+}
48+
49+function normalizeGitHubPullRequestState(input: {
50+ state?: string | null | undefined;
51+ mergedAt?: string | null | undefined;
52+}): "open" | "closed" | "merged" {
53+ const normalizedState = input.state?.trim().toUpperCase();
54+ if (
55+ (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) ||
56+ normalizedState === "MERGED"
57+ ) {
58+ return "merged";
59+ }
60+ if (normalizedState === "CLOSED") {
61+ return "closed";
62+ }
63+ return "open";
64+}
65+
66+function normalizeGitHubPullRequestRecord(
67+ raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>,
68+): NormalizedGitHubPullRequestRecord {
69+ const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner);
70+ const headRepositoryOwnerLogin =
71+ trimOptionalString(raw.headRepositoryOwner?.login) ??
72+ (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
73+ ? (headRepositoryNameWithOwner.split("/")[0] ?? null)
74+ : null);
75+
76+ return {
77+ number: raw.number,
78+ title: raw.title,
79+ url: raw.url,
80+ baseRefName: raw.baseRefName,
81+ headRefName: raw.headRefName,
82+ state: normalizeGitHubPullRequestState(raw),
83+ updatedAt:
84+ typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null,
85+ ...(typeof raw.isCrossRepository === "boolean"
86+ ? { isCrossRepository: raw.isCrossRepository }
87+ : {}),
88+ ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
89+ ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
90+ };
91+}
92+
93+const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
94+const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema);
95+const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema);
96+
97+export const formatGitHubJsonDecodeError = formatSchemaError;
98+
99+export function decodeGitHubPullRequestListJson(
100+ raw: string,
101+): Result.Result<
102+ ReadonlyArray<NormalizedGitHubPullRequestRecord>,
103+ Cause.Cause<Schema.SchemaError>
104+> {
105+ const result = decodeGitHubPullRequestList(raw);
106+ if (Result.isSuccess(result)) {
107+ const pullRequests: NormalizedGitHubPullRequestRecord[] = [];
108+ for (const entry of result.success) {
109+ const decodedEntry = decodeGitHubPullRequestEntry(entry);
110+ if (Exit.isFailure(decodedEntry)) {
111+ continue;
112+ }
113+ pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value));
114+ }
115+ return Result.succeed(pullRequests);
116+ }
117+ return Result.fail(result.failure);
118+}
119+
120+export function decodeGitHubPullRequestJson(
121+ raw: string,
122+): Result.Result<NormalizedGitHubPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
123+ const result = decodeGitHubPullRequest(raw);
124+ if (Result.isSuccess(result)) {
125+ return Result.succeed(normalizeGitHubPullRequestRecord(result.success));
126+ }
127+ return Result.fail(result.failure);
128+}
apps/server/src/http.test.ts+27−0
@@ -0,0 +1,27 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
4+
5+describe("http dev routing", () => {
6+ it("treats localhost and loopback addresses as local", () => {
7+ expect(isLoopbackHostname("127.0.0.1")).toBe(true);
8+ expect(isLoopbackHostname("localhost")).toBe(true);
9+ expect(isLoopbackHostname("::1")).toBe(true);
10+ expect(isLoopbackHostname("[::1]")).toBe(true);
11+ });
12+
13+ it("does not treat LAN addresses as local", () => {
14+ expect(isLoopbackHostname("192.168.86.35")).toBe(false);
15+ expect(isLoopbackHostname("10.0.0.24")).toBe(false);
16+ expect(isLoopbackHostname("example.local")).toBe(false);
17+ });
18+
19+ it("preserves path and query when redirecting to the dev server", () => {
20+ const devUrl = new URL("http://127.0.0.1:5173/");
21+ const requestUrl = new URL("http://127.0.0.1:3774/pair?token=test-token");
22+
23+ expect(resolveDevRedirectUrl(devUrl, requestUrl)).toBe(
24+ "http://127.0.0.1:5173/pair?token=test-token",
25+ );
26+ });
27+});
apps/server/src/http.ts+127−12
@@ -1,23 +1,133 @@
11 import Mime from "@effect/platform-node/Mime";
2−import { Effect, FileSystem, Option, Path } from "effect";
3−import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
2+import { Data, Effect, FileSystem, Option, Path } from "effect";
3+import { cast } from "effect/Function";
4+import {
5+ HttpBody,
6+ HttpClient,
7+ HttpClientResponse,
8+ HttpRouter,
9+ HttpServerResponse,
10+ HttpServerRequest,
11+} from "effect/unstable/http";
12+import { OtlpTracer } from "effect/unstable/observability";
413
514 import {
615 ATTACHMENTS_ROUTE_PREFIX,
716 normalizeAttachmentRelativePath,
817 resolveAttachmentRelativePath,
9−} from "./attachmentPaths";
10−import { resolveAttachmentPathById } from "./attachmentStore";
11−import { ServerConfig } from "./config";
12−import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver";
18+} from "./attachmentPaths.ts";
19+import { resolveAttachmentPathById } from "./attachmentStore.ts";
20+import { resolveStaticDir, ServerConfig } from "./config.ts";
21+import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts";
22+import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
23+import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
24+import { ServerAuth } from "./auth/Services/ServerAuth.ts";
25+import { respondToAuthError } from "./auth/http.ts";
26+import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
1327
1428 const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
1529 const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
30+const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
31+const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
32+
33+export const browserApiCorsLayer = HttpRouter.cors({
34+ allowedMethods: ["GET", "POST", "OPTIONS"],
35+ allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
36+ maxAge: 600,
37+});
38+
39+export function isLoopbackHostname(hostname: string): boolean {
40+ const normalizedHostname = hostname
41+ .trim()
42+ .toLowerCase()
43+ .replace(/^\[(.*)\]$/, "$1");
44+ return LOOPBACK_HOSTNAMES.has(normalizedHostname);
45+}
46+
47+export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
48+ const redirectUrl = new URL(devUrl.toString());
49+ redirectUrl.pathname = requestUrl.pathname;
50+ redirectUrl.search = requestUrl.search;
51+ redirectUrl.hash = requestUrl.hash;
52+ return redirectUrl.toString();
53+}
54+
55+const requireAuthenticatedRequest = Effect.gen(function* () {
56+ const request = yield* HttpServerRequest.HttpServerRequest;
57+ const serverAuth = yield* ServerAuth;
58+ yield* serverAuth.authenticateHttpRequest(request);
59+});
60+
61+export const serverEnvironmentRouteLayer = HttpRouter.add(
62+ "GET",
63+ "/.well-known/t3/environment",
64+ Effect.gen(function* () {
65+ const descriptor = yield* Effect.service(ServerEnvironment).pipe(
66+ Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),
67+ );
68+ return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 });
69+ }),
70+);
71+
72+class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{
73+ readonly cause: unknown;
74+ readonly bodyJson: OtlpTracer.TraceData;
75+}> {}
76+
77+export const otlpTracesProxyRouteLayer = HttpRouter.add(
78+ "POST",
79+ OTLP_TRACES_PROXY_PATH,
80+ Effect.gen(function* () {
81+ yield* requireAuthenticatedRequest;
82+ const request = yield* HttpServerRequest.HttpServerRequest;
83+ const config = yield* ServerConfig;
84+ const otlpTracesUrl = config.otlpTracesUrl;
85+ const browserTraceCollector = yield* BrowserTraceCollector;
86+ const httpClient = yield* HttpClient.HttpClient;
87+ const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
88+
89+ yield* Effect.try({
90+ try: () => decodeOtlpTraceRecords(bodyJson),
91+ catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }),
92+ }).pipe(
93+ Effect.flatMap((records) => browserTraceCollector.record(records)),
94+ Effect.catch((cause) =>
95+ Effect.logWarning("Failed to decode browser OTLP traces", {
96+ cause,
97+ bodyJson,
98+ }),
99+ ),
100+ );
101+
102+ if (otlpTracesUrl === undefined) {
103+ return HttpServerResponse.empty({ status: 204 });
104+ }
105+
106+ return yield* httpClient
107+ .post(otlpTracesUrl, {
108+ body: HttpBody.jsonUnsafe(bodyJson),
109+ })
110+ .pipe(
111+ Effect.flatMap(HttpClientResponse.filterStatusOk),
112+ Effect.as(HttpServerResponse.empty({ status: 204 })),
113+ Effect.tapError((cause) =>
114+ Effect.logWarning("Failed to export browser OTLP traces", {
115+ cause,
116+ otlpTracesUrl,
117+ }),
118+ ),
119+ Effect.catch(() =>
120+ Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })),
121+ ),
122+ );
123+ }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
124+);
16125
17126 export const attachmentsRouteLayer = HttpRouter.add(
18127 "GET",
19128 `${ATTACHMENTS_ROUTE_PREFIX}/*`,
20129 Effect.gen(function* () {
130+ yield* requireAuthenticatedRequest;
21131 const request = yield* HttpServerRequest.HttpServerRequest;
22132 const url = HttpServerRequest.toURL(request);
23133 if (Option.isNone(url)) {
@@ -66,13 +176,14 @@ export const attachmentsRouteLayer = HttpRouter.add( · 42 unmodified lines
66176 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
67177 ),
68178 );
69− }),
179+ }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
70180 );
71181
72182 export const projectFaviconRouteLayer = HttpRouter.add(
73183 "GET",
74184 "/api/project-favicon",
75185 Effect.gen(function* () {
186+ yield* requireAuthenticatedRequest;
76187 const request = yield* HttpServerRequest.HttpServerRequest;
77188 const url = HttpServerRequest.toURL(request);
78189 if (Option.isNone(url)) {
@@ -106,7 +217,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( · 27 unmodified lines
106217 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
107218 ),
108219 );
109− }),
220+ }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
110221 );
111222
112223 export const staticAndDevRouteLayer = HttpRouter.add(
@@ -115,16 +226,20 @@ export const staticAndDevRouteLayer = HttpRouter.add( · 2 unmodified lines
115226 Effect.gen(function* () {
116227 const request = yield* HttpServerRequest.HttpServerRequest;
117228 const url = HttpServerRequest.toURL(request);
229+
118230 if (Option.isNone(url)) {
119231 return HttpServerResponse.text("Bad Request", { status: 400 });
120232 }
121233
122234 const config = yield* ServerConfig;
123− if (config.devUrl) {
124− return HttpServerResponse.redirect(config.devUrl.href, { status: 302 });
235+ if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
236+ return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
237+ status: 302,
238+ });
125239 }
126240
127− if (!config.staticDir) {
241+ const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined);
242+ if (!staticDir) {
128243 return HttpServerResponse.text("No static directory configured and no dev URL set.", {
129244 status: 503,
130245 });
@@ -132,7 +247,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( · 1 unmodified line
132247
133248 const fileSystem = yield* FileSystem.FileSystem;
134249 const path = yield* Path.Path;
135− const staticRoot = path.resolve(config.staticDir);
250+ const staticRoot = path.resolve(staticDir);
136251 const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
137252 const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
138253 const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
apps/server/src/keybindings.test.ts+20−3
@@ -2,8 +2,8 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c · 1 unmodified line
22 import * as NodeServices from "@effect/platform-node/NodeServices";
33 import { assert, it } from "@effect/vitest";
44 import { assertFailure } from "@effect/vitest/utils";
5−import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
6−import { ServerConfig } from "./config";
5+import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
6+import { ServerConfig } from "./config.ts";
77
88 import {
99 DEFAULT_KEYBINDINGS,
@@ -13,7 +13,7 @@ import { · 3 unmodified lines
1313 compileResolvedKeybindingRule,
1414 compileResolvedKeybindingsConfig,
1515 parseKeybindingShortcut,
16−} from "./keybindings";
16+} from "./keybindings.ts";
1717 import { KeybindingsConfigError } from "@t3tools/contracts";
1818
1919 const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
@@ -149,6 +149,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => { · 129 unmodified lines
149149 }),
150150 );
151151
152+ it.effect("formats invalid resolved keybinding rules with the custom message", () =>
153+ Effect.sync(() => {
154+ const result = Schema.decodeUnknownExit(ResolvedKeybindingFromConfig)({
155+ key: "mod+shift+d+o",
156+ command: "terminal.new",
157+ });
158+
159+ if (result._tag !== "Failure") {
160+ assert.fail("Expected invalid keybinding decode to fail");
161+ }
162+
163+ const detail = Cause.pretty(result.cause);
164+ assert.isTrue(detail.includes("Invalid keybinding rule"));
165+ assert.isFalse(detail.includes("Invalid data"));
166+ }),
167+ );
168+
152169 it.effect("bootstraps default keybindings when config file is missing", () =>
153170 Effect.gen(function* () {
154171 const fs = yield* FileSystem.FileSystem;
apps/server/src/keybindings.ts+7−6
@@ -19,7 +19,7 @@ import { · 18 unmodified lines
1919 THREAD_JUMP_KEYBINDING_COMMANDS,
2020 type ServerConfigIssue,
2121 } from "@t3tools/contracts";
22−import { Mutable } from "effect/Types";
22+import type { Mutable } from "effect/Types";
2323 import {
2424 Array,
2525 Cache,
@@ -39,12 +39,12 @@ import { · 13 unmodified lines
3939 SchemaIssue,
4040 SchemaTransformation,
4141 Ref,
42− ServiceMap,
42+ Context,
4343 Scope,
4444 Stream,
4545 } from "effect";
4646 import * as Semaphore from "effect/Semaphore";
47−import { ServerConfig } from "./config";
47+import { ServerConfig } from "./config.ts";
4848 import { fromLenientJson } from "@t3tools/shared/schemaJson";
4949
5050 type WhenToken =
@@ -61,6 +61,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ · 10 unmodified lines
6161 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
6262 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
6363 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
64+ { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
6465 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
6566 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
6667 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
@@ -322,7 +323,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( · 255 unmodified lines
322323 Predicate.isNotNull,
323324 () =>
324325 new SchemaIssue.InvalidValue(Option.some(rule), {
325− title: "Invalid keybinding rule",
326+ message: "Invalid keybinding rule",
326327 }),
327328 ),
328329 Effect.map((resolved) => resolved),
@@ -334,7 +335,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( · 5 unmodified lines
334335 if (!key) {
335336 return yield* Effect.fail(
336337 new SchemaIssue.InvalidValue(Option.some(resolved), {
337− title: "Resolved shortcut cannot be encoded to key string",
338+ message: "Resolved shortcut cannot be encoded to key string",
338339 }),
339340 );
340341 }
@@ -521,7 +522,7 @@ export interface KeybindingsShape { · 180 unmodified lines
521522 /**
522523 * Keybindings - Service tag for keybinding configuration operations.
523524 */
524−export class Keybindings extends ServiceMap.Service<Keybindings, KeybindingsShape>()(
525+export class Keybindings extends Context.Service<Keybindings, KeybindingsShape>()(
525526 "t3/keybindings",
526527 ) {}
527528
apps/server/src/observability/Layers/Observability.ts+23−3
@@ -4,6 +4,8 @@ import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/obse · 3 unmodified lines
44 import { ServerConfig } from "../../config.ts";
55 import { ServerLoggerLive } from "../../serverLogger.ts";
66 import { makeLocalFileTracer } from "../LocalFileTracer.ts";
7+import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";
8+import { makeTraceSink } from "../TraceSink.ts";
79
810 const otlpSerializationLayer = OtlpSerialization.layerJson;
911
@@ -16,9 +18,14 @@ export const ObservabilityLive = Layer.unwrap( · 6 unmodified lines
1618 Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled),
1719 );
1820
19− const tracerLayer = Layer.effect(
20− Tracer.Tracer,
21+ const tracerLayer = Layer.unwrap(
2122 Effect.gen(function* () {
23+ const sink = yield* makeTraceSink({
24+ filePath: config.serverTracePath,
25+ maxBytes: config.traceMaxBytes,
26+ maxFiles: config.traceMaxFiles,
27+ batchWindowMs: config.traceBatchWindowMs,
28+ });
2229 const delegate =
2330 config.otlpTracesUrl === undefined
2431 ? undefined
@@ -34,13 +41,26 @@ export const ObservabilityLive = Layer.unwrap( · 9 unmodified lines
3441 },
3542 });
3643
37− return yield* makeLocalFileTracer({
44+ const tracer = yield* makeLocalFileTracer({
3845 filePath: config.serverTracePath,
3946 maxBytes: config.traceMaxBytes,
4047 maxFiles: config.traceMaxFiles,
4148 batchWindowMs: config.traceBatchWindowMs,
49+ sink,
4250 ...(delegate ? { delegate } : {}),
4351 });
52+
53+ return Layer.mergeAll(
54+ Layer.succeed(Tracer.Tracer, tracer),
55+ Layer.succeed(BrowserTraceCollector, {
56+ record: (records) =>
57+ Effect.sync(() => {
58+ for (const record of records) {
59+ sink.push(record);
60+ }
61+ }),
62+ }),
63+ );
4464 }),
4565 ).pipe(Layer.provideMerge(otlpSerializationLayer));
4666
apps/server/src/observability/LocalFileTracer.test.ts+3−3
@@ -5,7 +5,7 @@ import path from "node:path"; · 4 unmodified lines
55 import { assert, describe, it } from "@effect/vitest";
66 import { Effect, Layer, Logger, References, Tracer } from "effect";
77
8−import type { TraceRecord } from "./TraceRecord.ts";
8+import type { EffectTraceRecord } from "./TraceRecord.ts";
99 import { makeLocalFileTracer } from "./LocalFileTracer.ts";
1010
1111 const makeTestLayer = (tracePath: string) =>
@@ -23,13 +23,13 @@ const makeTestLayer = (tracePath: string) => · 11 unmodified lines
2323 Layer.succeed(References.MinimumLogLevel, "Info"),
2424 );
2525
26−const readTraceRecords = (tracePath: string): Array<TraceRecord> =>
26+const readTraceRecords = (tracePath: string): Array<EffectTraceRecord> =>
2727 fs
2828 .readFileSync(tracePath, "utf8")
2929 .trim()
3030 .split("\n")
3131 .filter((line) => line.length > 0)
32− .map((line) => JSON.parse(line) as TraceRecord);
32+ .map((line) => JSON.parse(line) as EffectTraceRecord);
3333
3434 describe("LocalFileTracer", () => {
3535 it.effect("writes nested spans to disk and captures log messages as span events", () =>
apps/server/src/observability/LocalFileTracer.ts+17−9
@@ -2,7 +2,8 @@ import type * as Exit from "effect/Exit"; · 1 unmodified line
22 import { Effect, Option, Tracer } from "effect";
33
44 import { spanToTraceRecord } from "./TraceRecord.ts";
5−import { makeTraceSink } from "./TraceSink.ts";
5+import type { EffectTraceRecord } from "./TraceRecord.ts";
6+import { makeTraceSink, type TraceSink } from "./TraceSink.ts";
67
78 export interface LocalFileTracerOptions {
89 readonly filePath: string;
@@ -10,6 +11,7 @@ export interface LocalFileTracerOptions { · 1 unmodified line
1011 readonly maxFiles: number;
1112 readonly batchWindowMs: number;
1213 readonly delegate?: Tracer.Tracer;
14+ readonly sink?: TraceSink;
1315 }
1416
1517 class LocalFileSpan implements Tracer.Span {
@@ -26,12 +28,16 @@ class LocalFileSpan implements Tracer.Span { · 10 unmodified lines
2628 status: Tracer.SpanStatus;
2729 attributes: Map<string, unknown>;
2830 events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown>]>;
31+ private readonly delegate: Tracer.Span;
32+ private readonly push: (record: EffectTraceRecord) => void;
2933
3034 constructor(
3135 options: Parameters<Tracer.Tracer["span"]>[0],
32− private readonly delegate: Tracer.Span,
33− private readonly push: (record: ReturnType<typeof spanToTraceRecord>) => void,
36+ delegate: Tracer.Span,
37+ push: (record: EffectTraceRecord) => void,
3438 ) {
39+ this.delegate = delegate;
40+ this.push = push;
3541 this.name = delegate.name;
3642 this.spanId = delegate.spanId;
3743 this.traceId = delegate.traceId;
@@ -82,12 +88,14 @@ class LocalFileSpan implements Tracer.Span { · 44 unmodified lines
8288 export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (
8389 options: LocalFileTracerOptions,
8490 ) {
85− const sink = yield* makeTraceSink({
86− filePath: options.filePath,
87− maxBytes: options.maxBytes,
88− maxFiles: options.maxFiles,
89− batchWindowMs: options.batchWindowMs,
90− });
91+ const sink =
92+ options.sink ??
93+ (yield* makeTraceSink({
94+ filePath: options.filePath,
95+ maxBytes: options.maxBytes,
96+ maxFiles: options.maxFiles,
97+ batchWindowMs: options.batchWindowMs,
98+ }));
9199
92100 const delegate =
93101 options.delegate ??
apps/server/src/observability/Services/BrowserTraceCollector.ts+13−0
@@ -0,0 +1,13 @@
1+import { Context } from "effect";
2+import type { Effect } from "effect";
3+
4+import type { TraceRecord } from "../TraceRecord.ts";
5+
6+export interface BrowserTraceCollectorShape {
7+ readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;
8+}
9+
10+export class BrowserTraceCollector extends Context.Service<
11+ BrowserTraceCollector,
12+ BrowserTraceCollectorShape
13+>()("t3/observability/Services/BrowserTraceCollector") {}
apps/server/src/observability/TraceRecord.ts+202−15
@@ -1,29 +1,37 @@
11 import { Cause, Exit, Option, Tracer } from "effect";
22
33 import { compactTraceAttributes } from "./Attributes.ts";
4+import { OtlpResource, OtlpTracer } from "effect/unstable/observability";
45
5−export interface TraceRecord {
6− readonly type: "effect-span";
6+interface TraceRecordEvent {
7+ readonly name: string;
8+ readonly timeUnixNano: string;
9+ readonly attributes: Readonly<Record<string, unknown>>;
10+}
11+
12+interface TraceRecordLink {
13+ readonly traceId: string;
14+ readonly spanId: string;
15+ readonly attributes: Readonly<Record<string, unknown>>;
16+}
17+
18+interface BaseTraceRecord {
719 readonly name: string;
20+ readonly kind: string;
821 readonly traceId: string;
922 readonly spanId: string;
1023 readonly parentSpanId?: string;
1124 readonly sampled: boolean;
12− readonly kind: Tracer.SpanKind;
1325 readonly startTimeUnixNano: string;
1426 readonly endTimeUnixNano: string;
1527 readonly durationMs: number;
1628 readonly attributes: Readonly<Record<string, unknown>>;
17− readonly events: ReadonlyArray<{
18− readonly name: string;
19− readonly timeUnixNano: string;
20− readonly attributes: Readonly<Record<string, unknown>>;
21− }>;
22− readonly links: ReadonlyArray<{
23− readonly traceId: string;
24− readonly spanId: string;
25− readonly attributes: Readonly<Record<string, unknown>>;
26− }>;
29+ readonly events: ReadonlyArray<TraceRecordEvent>;
30+ readonly links: ReadonlyArray<TraceRecordLink>;
31+}
32+
33+export interface EffectTraceRecord extends BaseTraceRecord {
34+ readonly type: "effect-span";
2735 readonly exit:
2836 | {
2937 readonly _tag: "Success";
@@ -38,6 +46,29 @@ export interface TraceRecord { · 8 unmodified lines
3846 };
3947 }
4048
49+interface OtlpTraceRecord extends BaseTraceRecord {
50+ readonly type: "otlp-span";
51+ readonly resourceAttributes: Readonly<Record<string, unknown>>;
52+ readonly scope: Readonly<{
53+ readonly name?: string;
54+ readonly version?: string;
55+ readonly attributes: Readonly<Record<string, unknown>>;
56+ }>;
57+ readonly status?:
58+ | {
59+ readonly code?: string;
60+ readonly message?: string;
61+ }
62+ | undefined;
63+}
64+
65+export type TraceRecord = EffectTraceRecord | OtlpTraceRecord;
66+
67+type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number];
68+type OtlpSpanEvent = OtlpSpan["events"][number];
69+type OtlpSpanLink = OtlpSpan["links"][number];
70+type OtlpSpanStatus = OtlpSpan["status"];
71+
4172 interface SerializableSpan {
4273 readonly name: string;
4374 readonly traceId: string;
@@ -53,7 +84,7 @@ interface SerializableSpan { · 9 unmodified lines
5384 >;
5485 }
5586
56−function formatTraceExit(exit: Exit.Exit<unknown, unknown>): TraceRecord["exit"] {
87+function formatTraceExit(exit: Exit.Exit<unknown, unknown>): EffectTraceRecord["exit"] {
5788 if (Exit.isSuccess(exit)) {
5889 return { _tag: "Success" };
5990 }
@@ -69,7 +100,7 @@ function formatTraceExit(exit: Exit.Exit<unknown, unknown>): TraceRecord["exit"] · 9 unmodified lines
69100 };
70101 }
71102
72−export function spanToTraceRecord(span: SerializableSpan): TraceRecord {
103+export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord {
73104 const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>;
74105 const parentSpanId = Option.getOrUndefined(span.parent)?.spanId;
75106
@@ -98,3 +129,159 @@ export function spanToTraceRecord(span: SerializableSpan): TraceRecord { · 22 unmodified lines
98129 exit: formatTraceExit(status.exit),
99130 };
100131 }
132+
133+const SPAN_KIND_MAP: Record<number, OtlpTraceRecord["kind"]> = {
134+ 1: "internal",
135+ 2: "server",
136+ 3: "client",
137+ 4: "producer",
138+ 5: "consumer",
139+};
140+
141+export function decodeOtlpTraceRecords(
142+ payload: OtlpTracer.TraceData,
143+): ReadonlyArray<OtlpTraceRecord> {
144+ const records: Array<OtlpTraceRecord> = [];
145+
146+ for (const resourceSpan of payload.resourceSpans) {
147+ const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes ?? []);
148+
149+ for (const scopeSpan of resourceSpan.scopeSpans) {
150+ for (const span of scopeSpan.spans) {
151+ records.push(
152+ otlpSpanToTraceRecord({
153+ resourceAttributes,
154+ scopeAttributes: decodeAttributes(
155+ "attributes" in scopeSpan.scope && Array.isArray(scopeSpan.scope.attributes)
156+ ? scopeSpan.scope.attributes
157+ : [],
158+ ),
159+ scopeName: scopeSpan.scope.name,
160+ scopeVersion:
161+ "version" in scopeSpan.scope && typeof scopeSpan.scope.version === "string"
162+ ? scopeSpan.scope.version
163+ : undefined,
164+ span,
165+ }),
166+ );
167+ }
168+ }
169+ }
170+
171+ return records;
172+}
173+
174+function otlpSpanToTraceRecord(input: {
175+ readonly resourceAttributes: Readonly<Record<string, unknown>>;
176+ readonly scopeAttributes: Readonly<Record<string, unknown>>;
177+ readonly scopeName: string | undefined;
178+ readonly scopeVersion: string | undefined;
179+ readonly span: OtlpSpan;
180+}): OtlpTraceRecord {
181+ return {
182+ type: "otlp-span",
183+ name: input.span.name,
184+ traceId: input.span.traceId,
185+ spanId: input.span.spanId,
186+ ...(input.span.parentSpanId ? { parentSpanId: input.span.parentSpanId } : {}),
187+ sampled: true,
188+ kind: normalizeSpanKind(input.span.kind),
189+ startTimeUnixNano: input.span.startTimeUnixNano,
190+ endTimeUnixNano: input.span.endTimeUnixNano,
191+ durationMs:
192+ Number(parseBigInt(input.span.endTimeUnixNano) - parseBigInt(input.span.startTimeUnixNano)) /
193+ 1_000_000,
194+ attributes: decodeAttributes(input.span.attributes),
195+ resourceAttributes: input.resourceAttributes,
196+ scope: {
197+ ...(input.scopeName ? { name: input.scopeName } : {}),
198+ ...(input.scopeVersion ? { version: input.scopeVersion } : {}),
199+ attributes: input.scopeAttributes,
200+ },
201+ events: decodeEvents(input.span.events),
202+ links: decodeLinks(input.span.links),
203+ status: decodeStatus(input.span.status),
204+ };
205+}
206+
207+function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] {
208+ const code = String(input.code);
209+ const message = input.message;
210+
211+ return {
212+ code,
213+ ...(message ? { message } : {}),
214+ };
215+}
216+
217+function decodeEvents(input: ReadonlyArray<OtlpSpanEvent>): ReadonlyArray<TraceRecordEvent> {
218+ return input.map((current) => ({
219+ name: current.name,
220+ timeUnixNano: current.timeUnixNano,
221+ attributes: decodeAttributes(current.attributes),
222+ }));
223+}
224+
225+function decodeLinks(input: ReadonlyArray<OtlpSpanLink>): ReadonlyArray<TraceRecordLink> {
226+ return input.flatMap((current) => {
227+ const traceId = current.traceId;
228+ const spanId = current.spanId;
229+ return {
230+ traceId,
231+ spanId,
232+ attributes: decodeAttributes(current.attributes),
233+ };
234+ });
235+}
236+
237+function decodeAttributes(
238+ input: ReadonlyArray<OtlpResource.KeyValue>,
239+): Readonly<Record<string, unknown>> {
240+ const entries: Record<string, unknown> = {};
241+
242+ for (const attribute of input) {
243+ entries[attribute.key] = decodeValue(attribute.value);
244+ }
245+
246+ return compactTraceAttributes(entries);
247+}
248+
249+function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown {
250+ if (input == null) {
251+ return null;
252+ }
253+ if ("stringValue" in input) {
254+ return input.stringValue;
255+ }
256+ if ("boolValue" in input) {
257+ return input.boolValue;
258+ }
259+ if ("intValue" in input) {
260+ return input.intValue;
261+ }
262+ if ("doubleValue" in input) {
263+ return input.doubleValue;
264+ }
265+ if ("bytesValue" in input) {
266+ return input.bytesValue;
267+ }
268+ if (input.arrayValue) {
269+ return input.arrayValue.values.map((entry) => decodeValue(entry));
270+ }
271+ if (input.kvlistValue) {
272+ return decodeAttributes(input.kvlistValue.values);
273+ }
274+ return null;
275+}
276+
277+function normalizeSpanKind(input: number): OtlpTraceRecord["kind"] {
278+ return SPAN_KIND_MAP[input] || "internal";
279+}
280+
281+function parseBigInt(input: string): bigint {
282+ try {
283+ return BigInt(input);
284+ } catch {
285+ return 0n;
286+ }
287+}
apps/server/src/open.test.ts+91−2
@@ -8,7 +8,7 @@ import { · 7 unmodified lines
88 launchDetached,
99 resolveAvailableEditors,
1010 resolveEditorLaunch,
11−} from "./open";
11+} from "./open.ts";
1212
1313 it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
1414 it.effect("returns commands for command-based editors", () =>
@@ -16,6 +16,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 1 unmodified line
1616 const antigravityLaunch = yield* resolveEditorLaunch(
1717 { cwd: "/tmp/workspace", editor: "antigravity" },
1818 "darwin",
19+ { PATH: "" },
1920 );
2021 assert.deepEqual(antigravityLaunch, {
2122 command: "agy",
@@ -25,6 +26,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
2526 const cursorLaunch = yield* resolveEditorLaunch(
2627 { cwd: "/tmp/workspace", editor: "cursor" },
2728 "darwin",
29+ { PATH: "" },
2830 );
2931 assert.deepEqual(cursorLaunch, {
3032 command: "cursor",
@@ -40,9 +42,20 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 9 unmodified lines
4042 args: ["/tmp/workspace"],
4143 });
4244
45+ const kiroLaunch = yield* resolveEditorLaunch(
46+ { cwd: "/tmp/workspace", editor: "kiro" },
47+ "darwin",
48+ { PATH: "" },
49+ );
50+ assert.deepEqual(kiroLaunch, {
51+ command: "kiro",
52+ args: ["ide", "/tmp/workspace"],
53+ });
54+
4355 const vscodeLaunch = yield* resolveEditorLaunch(
4456 { cwd: "/tmp/workspace", editor: "vscode" },
4557 "darwin",
58+ { PATH: "" },
4659 );
4760 assert.deepEqual(vscodeLaunch, {
4861 command: "code",
@@ -70,6 +83,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 21 unmodified lines
7083 const zedLaunch = yield* resolveEditorLaunch(
7184 { cwd: "/tmp/workspace", editor: "zed" },
7285 "darwin",
86+ { PATH: "" },
7387 );
7488 assert.deepEqual(zedLaunch, {
7589 command: "zed",
@@ -92,6 +106,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 16 unmodified lines
92106 const lineOnly = yield* resolveEditorLaunch(
93107 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },
94108 "darwin",
109+ { PATH: "" },
95110 );
96111 assert.deepEqual(lineOnly, {
97112 command: "cursor",
@@ -101,6 +116,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
101116 const lineAndColumn = yield* resolveEditorLaunch(
102117 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" },
103118 "darwin",
119+ { PATH: "" },
104120 );
105121 assert.deepEqual(lineAndColumn, {
106122 command: "cursor",
@@ -116,9 +132,20 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 9 unmodified lines
116132 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
117133 });
118134
135+ const kiroLineAndColumn = yield* resolveEditorLaunch(
136+ { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "kiro" },
137+ "darwin",
138+ { PATH: "" },
139+ );
140+ assert.deepEqual(kiroLineAndColumn, {
141+ command: "kiro",
142+ args: ["ide", "--goto", "/tmp/workspace/src/open.ts:71:5"],
143+ });
144+
119145 const vscodeLineAndColumn = yield* resolveEditorLaunch(
120146 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },
121147 "darwin",
148+ { PATH: "" },
122149 );
123150 assert.deepEqual(vscodeLineAndColumn, {
124151 command: "code",
@@ -146,6 +173,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 21 unmodified lines
146173 const zedLineAndColumn = yield* resolveEditorLaunch(
147174 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },
148175 "darwin",
176+ { PATH: "" },
149177 );
150178 assert.deepEqual(zedLineAndColumn, {
151179 command: "zed",
@@ -155,6 +183,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
155183 const zedLineOnly = yield* resolveEditorLaunch(
156184 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" },
157185 "darwin",
186+ { PATH: "" },
158187 );
159188 assert.deepEqual(zedLineOnly, {
160189 command: "zed",
@@ -181,11 +210,43 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 20 unmodified lines
181210 }),
182211 );
183212
213+ it.effect("falls back to zeditor when zed is not installed", () =>
214+ Effect.gen(function* () {
215+ const fs = yield* FileSystem.FileSystem;
216+ const path = yield* Path.Path;
217+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
218+ yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
219+ yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
220+
221+ const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
222+ PATH: dir,
223+ });
224+
225+ assert.deepEqual(result, {
226+ command: "zeditor",
227+ args: ["/tmp/workspace"],
228+ });
229+ }),
230+ );
231+
232+ it.effect("falls back to the primary command when no alias is installed", () =>
233+ Effect.gen(function* () {
234+ const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
235+ PATH: "",
236+ });
237+ assert.deepEqual(result, {
238+ command: "zed",
239+ args: ["/tmp/workspace"],
240+ });
241+ }),
242+ );
243+
184244 it.effect("maps file-manager editor to OS open commands", () =>
185245 Effect.gen(function* () {
186246 const launch1 = yield* resolveEditorLaunch(
187247 { cwd: "/tmp/workspace", editor: "file-manager" },
188248 "darwin",
249+ { PATH: "" },
189250 );
190251 assert.deepEqual(launch1, {
191252 command: "open",
@@ -195,6 +256,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
195256 const launch2 = yield* resolveEditorLaunch(
196257 { cwd: "C:\\workspace", editor: "file-manager" },
197258 "win32",
259+ { PATH: "" },
198260 );
199261 assert.deepEqual(launch2, {
200262 command: "explorer",
@@ -204,6 +266,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
204266 const launch3 = yield* resolveEditorLaunch(
205267 { cwd: "/tmp/workspace", editor: "file-manager" },
206268 "linux",
269+ { PATH: "" },
207270 );
208271 assert.deepEqual(launch3, {
209272 command: "xdg-open",
@@ -311,6 +374,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 101 unmodified lines
311374 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
312375
313376 yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n");
377+ yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n");
314378 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");
315379 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");
316380 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
@@ -318,7 +382,32 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 1 unmodified line
318382 PATH: dir,
319383 PATHEXT: ".COM;.EXE;.BAT;.CMD",
320384 });
321− assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]);
385+ assert.deepEqual(editors, ["trae", "kiro", "vscode-insiders", "vscodium", "file-manager"]);
322386 }),
323387 );
388+
389+ it.effect("includes zed when only the zeditor command is installed", () =>
390+ Effect.gen(function* () {
391+ const fs = yield* FileSystem.FileSystem;
392+ const path = yield* Path.Path;
393+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
394+
395+ yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
396+ yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n");
397+ yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
398+ yield* fs.chmod(path.join(dir, "xdg-open"), 0o755);
399+
400+ const editors = resolveAvailableEditors("linux", {
401+ PATH: dir,
402+ });
403+ assert.deepEqual(editors, ["zed", "file-manager"]);
404+ }),
405+ );
406+
407+ it("omits file-manager when the platform opener is unavailable", () => {
408+ const editors = resolveAvailableEditors("linux", {
409+ PATH: "",
410+ });
411+ assert.deepEqual(editors, []);
412+ });
324413 });
apps/server/src/open.ts+50−124
@@ -7,17 +7,17 @@ · 6 unmodified lines
77 * @module Open
88 */
99 import { spawn } from "node:child_process";
10−import { accessSync, constants, statSync } from "node:fs";
11−import { extname, join } from "node:path";
1210
1311 import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
14−import { ServiceMap, Effect, Layer } from "effect";
12+import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";
13+import { Context, Effect, Layer } from "effect";
1514
1615 // ==============================
1716 // Definitions
1817 // ==============================
1918
2019 export { OpenError };
20+export { isCommandAvailable } from "@t3tools/shared/shell";
2121
2222 export interface OpenInEditorInput {
2323 readonly cwd: string;
@@ -29,11 +29,6 @@ interface EditorLaunch { · 5 unmodified lines
2929 readonly args: ReadonlyArray<string>;
3030 }
3131
32−interface CommandAvailabilityOptions {
33− readonly platform?: NodeJS.Platform;
34− readonly env?: NodeJS.ProcessEnv;
35−}
36−
3732 const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
3833
3934 function parseTargetPathAndPosition(target: string): {
@@ -75,6 +70,26 @@ function resolveCommandEditorArgs( · 35 unmodified lines
7570 }
7671 }
7772
73+function resolveEditorArgs(
74+ editor: (typeof EDITORS)[number],
75+ target: string,
76+): ReadonlyArray<string> {
77+ const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
78+ return [...baseArgs, ...resolveCommandEditorArgs(editor, target)];
79+}
80+
81+function resolveAvailableCommand(
82+ commands: ReadonlyArray<string>,
83+ options: CommandAvailabilityOptions = {},
84+): string | null {
85+ for (const command of commands) {
86+ if (isCommandAvailable(command, options)) {
87+ return command;
88+ }
89+ }
90+ return null;
91+}
92+
7893 function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
7994 switch (platform) {
8095 case "darwin":
@@ -86,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { · 5 unmodified lines
86101 }
87102 }
88103
89−function stripWrappingQuotes(value: string): string {
90− return value.replace(/^"+|"+$/g, "");
91−}
92−
93−function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
94− return env.PATH ?? env.Path ?? env.path ?? "";
95−}
96−
97−function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
98− const rawValue = env.PATHEXT;
99− const fallback = [".COM", ".EXE", ".BAT", ".CMD"];
100− if (!rawValue) return fallback;
101−
102− const parsed = rawValue
103− .split(";")
104− .map((entry) => entry.trim())
105− .filter((entry) => entry.length > 0)
106− .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`));
107− return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;
108−}
109−
110−function resolveCommandCandidates(
111− command: string,
112− platform: NodeJS.Platform,
113− windowsPathExtensions: ReadonlyArray<string>,
114−): ReadonlyArray<string> {
115− if (platform !== "win32") return [command];
116− const extension = extname(command);
117− const normalizedExtension = extension.toUpperCase();
118−
119− if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {
120− const commandWithoutExtension = command.slice(0, -extension.length);
121− return Array.from(
122− new Set([
123− command,
124− `${commandWithoutExtension}${normalizedExtension}`,
125− `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`,
126− ]),
127− );
128− }
129−
130− const candidates: string[] = [];
131− for (const extension of windowsPathExtensions) {
132− candidates.push(`${command}${extension}`);
133− candidates.push(`${command}${extension.toLowerCase()}`);
134− }
135− return Array.from(new Set(candidates));
136−}
137−
138−function isExecutableFile(
139− filePath: string,
140− platform: NodeJS.Platform,
141− windowsPathExtensions: ReadonlyArray<string>,
142−): boolean {
143− try {
144− const stat = statSync(filePath);
145− if (!stat.isFile()) return false;
146− if (platform === "win32") {
147− const extension = extname(filePath);
148− if (extension.length === 0) return false;
149− return windowsPathExtensions.includes(extension.toUpperCase());
150− }
151− accessSync(filePath, constants.X_OK);
152− return true;
153− } catch {
154− return false;
155− }
156−}
157−
158−function resolvePathDelimiter(platform: NodeJS.Platform): string {
159− return platform === "win32" ? ";" : ":";
160−}
161−
162−export function isCommandAvailable(
163− command: string,
164− options: CommandAvailabilityOptions = {},
165−): boolean {
166− const platform = options.platform ?? process.platform;
167− const env = options.env ?? process.env;
168− const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
169− const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
170−
171− if (command.includes("/") || command.includes("\\")) {
172− return commandCandidates.some((candidate) =>
173− isExecutableFile(candidate, platform, windowsPathExtensions),
174− );
175− }
176−
177− const pathValue = resolvePathEnvironmentVariable(env);
178− if (pathValue.length === 0) return false;
179− const pathEntries = pathValue
180− .split(resolvePathDelimiter(platform))
181− .map((entry) => stripWrappingQuotes(entry.trim()))
182− .filter((entry) => entry.length > 0);
183−
184− for (const pathEntry of pathEntries) {
185− for (const candidate of commandCandidates) {
186− if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
187− return true;
188− }
189− }
190− }
191− return false;
192−}
193−
194104 export function resolveAvailableEditors(
195105 platform: NodeJS.Platform = process.platform,
196106 env: NodeJS.ProcessEnv = process.env,
@@ -198,8 +108,16 @@ export function resolveAvailableEditors( · 1 unmodified line
198108 const available: EditorId[] = [];
199109
200110 for (const editor of EDITORS) {
201− const command = editor.command ?? fileManagerCommandForPlatform(platform);
202− if (isCommandAvailable(command, { platform, env })) {
111+ if (editor.commands === null) {
112+ const command = fileManagerCommandForPlatform(platform);
113+ if (isCommandAvailable(command, { platform, env })) {
114+ available.push(editor.id);
115+ }
116+ continue;
117+ }
118+
119+ const command = resolveAvailableCommand(editor.commands, { platform, env });
120+ if (command !== null) {
203121 available.push(editor.id);
204122 }
205123 }
@@ -227,7 +145,7 @@ export interface OpenShape { · 21 unmodified lines
227145 /**
228146 * Open - Service tag for browser/editor launch operations.
229147 */
230−export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}
148+export class Open extends Context.Service<Open, OpenShape>()("t3/open") {}
231149
232150 // ==============================
233151 // Implementations
@@ -236,6 +154,7 @@ export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {} · 2 unmodified lines
236154 export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
237155 input: OpenInEditorInput,
238156 platform: NodeJS.Platform = process.platform,
157+ env: NodeJS.ProcessEnv = process.env,
239158 ): Effect.fn.Return<EditorLaunch, OpenError> {
240159 yield* Effect.annotateCurrentSpan({
241160 "open.editor": input.editor,
@@ -247,10 +166,12 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( · 5 unmodified lines
247166 return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });
248167 }
249168
250− if (editorDef.command) {
169+ if (editorDef.commands) {
170+ const command =
171+ resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0];
251172 return {
252− command: editorDef.command,
253− args: resolveCommandEditorArgs(editorDef, input.cwd),
173+ command,
174+ args: resolveEditorArgs(editorDef, input.cwd),
254175 };
255176 }
256177
@@ -270,11 +191,16 @@ export const launchDetached = (launch: EditorLaunch) => · 13 unmodified lines
270191 yield* Effect.callback<void, OpenError>((resume) => {
271192 let child;
272193 try {
273− child = spawn(launch.command, [...launch.args], {
274− detached: true,
275− stdio: "ignore",
276− shell: process.platform === "win32",
277− });
194+ const isWin32 = process.platform === "win32";
195+ child = spawn(
196+ launch.command,
197+ isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args],
198+ {
199+ detached: true,
200+ stdio: "ignore",
201+ shell: isWin32,
202+ },
203+ );
278204 } catch (error) {
279205 return resume(
280206 Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+164−119
@@ -20,6 +20,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; · 19 unmodified lines
2020 import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
2121 import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
2222 import { GitCoreLive } from "../../git/Layers/GitCore.ts";
23+import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
24+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2325 import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2426 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2527 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
@@ -42,8 +44,8 @@ import { ServerConfig } from "../../config.ts"; · 16 unmodified lines
4244 import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts";
4345 import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
4446
45−const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
46−const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
47+const asProjectId = (value: string): ProjectId => ProjectId.make(value);
48+const asTurnId = (value: string): TurnId => TurnId.make(value);
4749
4850 type LegacyProviderRuntimeEvent = {
4951 readonly type: string;
@@ -79,7 +81,7 @@ function createProviderServiceHarness( · 29 unmodified lines
7981 provider: providerName,
8082 status: "ready",
8183 runtimeMode: "full-access",
82− threadId: ThreadId.makeUnsafe("thread-1"),
84+ threadId: ThreadId.make("thread-1"),
8385 cwd: sessionCwd,
8486 createdAt: now,
8587 updatedAt: now,
@@ -96,7 +98,9 @@ function createProviderServiceHarness( · 10 unmodified lines
9698 listSessions,
9799 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
98100 rollbackConversation,
99− streamEvents: Stream.fromPubSub(runtimeEventPubSub),
101+ get streamEvents() {
102+ return Stream.fromPubSub(runtimeEventPubSub);
103+ },
100104 };
101105
102106 const emit = (event: LegacyProviderRuntimeEvent): void => {
@@ -126,7 +130,7 @@ async function waitForThread( · 23 unmodified lines
126130 activities: ReadonlyArray<{ kind: string }>;
127131 }> => {
128132 const readModel = await Effect.runPromise(engine.getReadModel());
129− const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
133+ const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
130134 if (thread && predicate(thread)) {
131135 return thread;
132136 }
@@ -240,6 +244,7 @@ describe("CheckpointReactor", () => { · 107 unmodified lines
240244 readonly threadWorktreePath?: string | null;
241245 readonly providerSessionCwd?: string;
242246 readonly providerName?: ProviderKind;
247+ readonly gitStatusRefreshCalls?: Array<string>;
243248 }) {
244249 const cwd = createGitRepository();
245250 tempDirs.push(cwd);
@@ -254,17 +259,37 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
254259 Layer.provide(OrchestrationProjectionPipelineLive),
255260 Layer.provide(OrchestrationEventStoreLive),
256261 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
262+ Layer.provide(RepositoryIdentityResolverLive),
257263 Layer.provide(SqlitePersistenceMemory),
258264 );
259265
260266 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
261267 prefix: "t3-checkpoint-reactor-test-",
262268 });
269+ const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, {
270+ getStatus: () => Effect.die("getStatus should not be called in this test"),
271+ refreshLocalStatus: (cwd: string) =>
272+ Effect.sync(() => {
273+ options?.gitStatusRefreshCalls?.push(cwd);
274+ }).pipe(
275+ Effect.as({
276+ isRepo: true,
277+ hasOriginRemote: false,
278+ isDefaultBranch: true,
279+ branch: "main",
280+ hasWorkingTreeChanges: false,
281+ workingTree: { files: [], insertions: 0, deletions: 0 },
282+ }),
283+ ),
284+ refreshStatus: () => Effect.die("refreshStatus should not be called in this test"),
285+ streamStatus: () => Stream.empty,
286+ });
263287
264288 const layer = CheckpointReactorLive.pipe(
265289 Layer.provideMerge(orchestrationLayer),
266290 Layer.provideMerge(RuntimeReceiptBusLive),
267291 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
292+ Layer.provideMerge(gitStatusBroadcasterLayer),
268293 Layer.provideMerge(CheckpointStoreLive),
269294 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
270295 Layer.provideMerge(WorkspacePathsLive),
@@ -285,7 +310,7 @@ describe("CheckpointReactor", () => { · 14 unmodified lines
285310 await Effect.runPromise(
286311 engine.dispatch({
287312 type: "project.create",
288− commandId: CommandId.makeUnsafe("cmd-project-create"),
313+ commandId: CommandId.make("cmd-project-create"),
289314 projectId: asProjectId("project-1"),
290315 title: "Test Project",
291316 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
@@ -299,8 +324,8 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
299324 await Effect.runPromise(
300325 engine.dispatch({
301326 type: "thread.create",
302− commandId: CommandId.makeUnsafe("cmd-thread-create"),
303− threadId: ThreadId.makeUnsafe("thread-1"),
327+ commandId: CommandId.make("cmd-thread-create"),
328+ threadId: ThreadId.make("thread-1"),
304329 projectId: asProjectId("project-1"),
305330 title: "Thread",
306331 modelSelection: {
@@ -319,21 +344,21 @@ describe("CheckpointReactor", () => { · 12 unmodified lines
319344 await runtime.runPromise(
320345 checkpointStore.captureCheckpoint({
321346 cwd,
322− checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
347+ checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
323348 }),
324349 );
325350 fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8");
326351 await runtime.runPromise(
327352 checkpointStore.captureCheckpoint({
328353 cwd,
329− checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
354+ checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
330355 }),
331356 );
332357 fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8");
333358 await runtime.runPromise(
334359 checkpointStore.captureCheckpoint({
335360 cwd,
336− checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
361+ checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
337362 }),
338363 );
339364 }
@@ -353,10 +378,10 @@ describe("CheckpointReactor", () => { · 13 unmodified lines
353378 await Effect.runPromise(
354379 harness.engine.dispatch({
355380 type: "thread.session.set",
356− commandId: CommandId.makeUnsafe("cmd-session-set-capture"),
357− threadId: ThreadId.makeUnsafe("thread-1"),
381+ commandId: CommandId.make("cmd-session-set-capture"),
382+ threadId: ThreadId.make("thread-1"),
358383 session: {
359− threadId: ThreadId.makeUnsafe("thread-1"),
384+ threadId: ThreadId.make("thread-1"),
360385 status: "ready",
361386 providerName: "codex",
362387 runtimeMode: "approval-required",
@@ -370,26 +395,26 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
370395
371396 harness.provider.emit({
372397 type: "turn.started",
373− eventId: EventId.makeUnsafe("evt-turn-started-1"),
398+ eventId: EventId.make("evt-turn-started-1"),
374399 provider: "codex",
375400
376401 createdAt: new Date().toISOString(),
377− threadId: ThreadId.makeUnsafe("thread-1"),
402+ threadId: ThreadId.make("thread-1"),
378403 turnId: asTurnId("turn-1"),
379404 });
380405 await waitForGitRefExists(
381406 harness.cwd,
382− checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
407+ checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
383408 );
384409
385410 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
386411 harness.provider.emit({
387412 type: "turn.completed",
388− eventId: EventId.makeUnsafe("evt-turn-completed-1"),
413+ eventId: EventId.make("evt-turn-completed-1"),
389414 provider: "codex",
390415
391416 createdAt: new Date().toISOString(),
392− threadId: ThreadId.makeUnsafe("thread-1"),
417+ threadId: ThreadId.make("thread-1"),
393418 turnId: asTurnId("turn-1"),
394419 payload: { state: "completed" },
395420 });
@@ -401,27 +426,49 @@ describe("CheckpointReactor", () => { · 5 unmodified lines
401426 );
402427 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
403428 expect(
404− gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)),
429+ gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)),
405430 ).toBe(true);
406431 expect(
407− gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)),
432+ gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)),
408433 ).toBe(true);
409434 expect(
410435 gitShowFileAtRef(
411436 harness.cwd,
412− checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
437+ checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
413438 "README.md",
414439 ),
415440 ).toBe("v1\n");
416441 expect(
417442 gitShowFileAtRef(
418443 harness.cwd,
419− checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
444+ checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
420445 "README.md",
421446 ),
422447 ).toBe("v2\n");
423448 });
424449
450+ it("refreshes local git status state on turn completion using the session cwd", async () => {
451+ const gitStatusRefreshCalls: string[] = [];
452+ const harness = await createHarness({
453+ seedFilesystemCheckpoints: false,
454+ gitStatusRefreshCalls,
455+ });
456+
457+ harness.provider.emit({
458+ type: "turn.completed",
459+ eventId: EventId.make("evt-turn-completed-refresh-local-status"),
460+ provider: "codex",
461+ createdAt: new Date().toISOString(),
462+ threadId: ThreadId.make("thread-1"),
463+ turnId: asTurnId("turn-refresh-local-status"),
464+ payload: { state: "completed" },
465+ });
466+
467+ await harness.drain();
468+
469+ expect(gitStatusRefreshCalls).toEqual([harness.cwd]);
470+ });
471+
425472 it("ignores auxiliary thread turn completion while primary turn is active", async () => {
426473 const harness = await createHarness({ seedFilesystemCheckpoints: false });
427474 const createdAt = new Date().toISOString();
@@ -429,10 +476,10 @@ describe("CheckpointReactor", () => { · 1 unmodified line
429476 await Effect.runPromise(
430477 harness.engine.dispatch({
431478 type: "thread.session.set",
432− commandId: CommandId.makeUnsafe("cmd-session-set-primary-running"),
433− threadId: ThreadId.makeUnsafe("thread-1"),
479+ commandId: CommandId.make("cmd-session-set-primary-running"),
480+ threadId: ThreadId.make("thread-1"),
434481 session: {
435− threadId: ThreadId.makeUnsafe("thread-1"),
482+ threadId: ThreadId.make("thread-1"),
436483 status: "running",
437484 providerName: "codex",
438485 runtimeMode: "approval-required",
@@ -446,45 +493,43 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
446493
447494 harness.provider.emit({
448495 type: "turn.started",
449− eventId: EventId.makeUnsafe("evt-turn-started-main"),
496+ eventId: EventId.make("evt-turn-started-main"),
450497 provider: "codex",
451498
452499 createdAt: new Date().toISOString(),
453− threadId: ThreadId.makeUnsafe("thread-1"),
500+ threadId: ThreadId.make("thread-1"),
454501 turnId: asTurnId("turn-main"),
455502 });
456503 await waitForGitRefExists(
457504 harness.cwd,
458− checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
505+ checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
459506 );
460507
461508 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
462509
463510 harness.provider.emit({
464511 type: "turn.completed",
465− eventId: EventId.makeUnsafe("evt-turn-completed-aux"),
512+ eventId: EventId.make("evt-turn-completed-aux"),
466513 provider: "codex",
467514
468515 createdAt: new Date().toISOString(),
469− threadId: ThreadId.makeUnsafe("thread-1"),
516+ threadId: ThreadId.make("thread-1"),
470517 turnId: asTurnId("turn-aux"),
471518 payload: { state: "completed" },
472519 });
473520
474521 await harness.drain();
475522 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
476− const midThread = midReadModel.threads.find(
477− (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
478− );
523+ const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
479524 expect(midThread?.checkpoints).toHaveLength(0);
480525
481526 harness.provider.emit({
482527 type: "turn.completed",
483− eventId: EventId.makeUnsafe("evt-turn-completed-main"),
528+ eventId: EventId.make("evt-turn-completed-main"),
484529 provider: "codex",
485530
486531 createdAt: new Date().toISOString(),
487− threadId: ThreadId.makeUnsafe("thread-1"),
532+ threadId: ThreadId.make("thread-1"),
488533 turnId: asTurnId("turn-main"),
489534 payload: { state: "completed" },
490535 });
@@ -506,10 +551,10 @@ describe("CheckpointReactor", () => { · 15 unmodified lines
506551 await Effect.runPromise(
507552 harness.engine.dispatch({
508553 type: "thread.session.set",
509− commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"),
510− threadId: ThreadId.makeUnsafe("thread-1"),
554+ commandId: CommandId.make("cmd-session-set-capture-claude"),
555+ threadId: ThreadId.make("thread-1"),
511556 session: {
512− threadId: ThreadId.makeUnsafe("thread-1"),
557+ threadId: ThreadId.make("thread-1"),
513558 status: "ready",
514559 providerName: "claudeAgent",
515560 runtimeMode: "approval-required",
@@ -523,24 +568,24 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
523568
524569 harness.provider.emit({
525570 type: "turn.started",
526− eventId: EventId.makeUnsafe("evt-turn-started-claude-1"),
571+ eventId: EventId.make("evt-turn-started-claude-1"),
527572 provider: "claudeAgent",
528573 createdAt: new Date().toISOString(),
529− threadId: ThreadId.makeUnsafe("thread-1"),
574+ threadId: ThreadId.make("thread-1"),
530575 turnId: asTurnId("turn-claude-1"),
531576 });
532577 await waitForGitRefExists(
533578 harness.cwd,
534− checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
579+ checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
535580 );
536581
537582 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
538583 harness.provider.emit({
539584 type: "turn.completed",
540− eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"),
585+ eventId: EventId.make("evt-turn-completed-claude-1"),
541586 provider: "claudeAgent",
542587 createdAt: new Date().toISOString(),
543− threadId: ThreadId.makeUnsafe("thread-1"),
588+ threadId: ThreadId.make("thread-1"),
544589 turnId: asTurnId("turn-claude-1"),
545590 payload: { state: "completed" },
546591 });
@@ -553,7 +598,7 @@ describe("CheckpointReactor", () => { · 6 unmodified lines
553598
554599 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
555600 expect(
556− gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)),
601+ gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)),
557602 ).toBe(true);
558603 });
559604
@@ -564,10 +609,10 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
564609 await Effect.runPromise(
565610 harness.engine.dispatch({
566611 type: "thread.session.set",
567− commandId: CommandId.makeUnsafe("cmd-session-set-missing-baseline-diff"),
568− threadId: ThreadId.makeUnsafe("thread-1"),
612+ commandId: CommandId.make("cmd-session-set-missing-baseline-diff"),
613+ threadId: ThreadId.make("thread-1"),
569614 session: {
570− threadId: ThreadId.makeUnsafe("thread-1"),
615+ threadId: ThreadId.make("thread-1"),
571616 status: "ready",
572617 providerName: "codex",
573618 runtimeMode: "approval-required",
@@ -581,11 +626,11 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
581626
582627 harness.provider.emit({
583628 type: "turn.completed",
584− eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"),
629+ eventId: EventId.make("evt-turn-completed-missing-baseline"),
585630 provider: "codex",
586631
587632 createdAt: new Date().toISOString(),
588− threadId: ThreadId.makeUnsafe("thread-1"),
633+ threadId: ThreadId.make("thread-1"),
589634 turnId: asTurnId("turn-missing-baseline"),
590635 payload: { state: "completed" },
591636 });
@@ -614,10 +659,10 @@ describe("CheckpointReactor", () => { · 22 unmodified lines
614659 await Effect.runPromise(
615660 harness.engine.dispatch({
616661 type: "thread.turn.start",
617− commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"),
618− threadId: ThreadId.makeUnsafe("thread-1"),
662+ commandId: CommandId.make("cmd-turn-start-for-baseline"),
663+ threadId: ThreadId.make("thread-1"),
619664 message: {
620− messageId: MessageId.makeUnsafe("message-user-1"),
665+ messageId: MessageId.make("message-user-1"),
621666 role: "user",
622667 text: "start turn",
623668 attachments: [],
@@ -630,12 +675,12 @@ describe("CheckpointReactor", () => { · 6 unmodified lines
630675
631676 await waitForGitRefExists(
632677 harness.cwd,
633− checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
678+ checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
634679 );
635680 expect(
636681 gitShowFileAtRef(
637682 harness.cwd,
638− checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
683+ checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
639684 "README.md",
640685 ),
641686 ).toBe("v1\n");
@@ -652,10 +697,10 @@ describe("CheckpointReactor", () => { · 10 unmodified lines
652697 await Effect.runPromise(
653698 harness.engine.dispatch({
654699 type: "thread.session.set",
655− commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"),
656− threadId: ThreadId.makeUnsafe("thread-1"),
700+ commandId: CommandId.make("cmd-session-set-missing-provider-cwd"),
701+ threadId: ThreadId.make("thread-1"),
657702 session: {
658− threadId: ThreadId.makeUnsafe("thread-1"),
703+ threadId: ThreadId.make("thread-1"),
659704 status: "running",
660705 providerName: "codex",
661706 runtimeMode: "approval-required",
@@ -670,23 +715,23 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
670715 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
671716 harness.provider.emit({
672717 type: "turn.completed",
673− eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"),
718+ eventId: EventId.make("evt-turn-completed-missing-provider-cwd"),
674719 provider: "codex",
675720
676721 createdAt: new Date().toISOString(),
677− threadId: ThreadId.makeUnsafe("thread-1"),
722+ threadId: ThreadId.make("thread-1"),
678723 turnId: asTurnId("turn-missing-cwd"),
679724 payload: { state: "completed" },
680725 });
681726
682727 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
683728 expect(
684− gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)),
729+ gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)),
685730 ).toBe(true);
686731 expect(
687732 gitShowFileAtRef(
688733 harness.cwd,
689− checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
734+ checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
690735 "README.md",
691736 ),
692737 ).toBe("v2\n");
@@ -699,10 +744,10 @@ describe("CheckpointReactor", () => { · 6 unmodified lines
699744 await Effect.runPromise(
700745 harness.engine.dispatch({
701746 type: "thread.session.set",
702− commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"),
703− threadId: ThreadId.makeUnsafe("thread-1"),
747+ commandId: CommandId.make("cmd-session-set-checkpoint-captured"),
748+ threadId: ThreadId.make("thread-1"),
704749 session: {
705− threadId: ThreadId.makeUnsafe("thread-1"),
750+ threadId: ThreadId.make("thread-1"),
706751 status: "ready",
707752 providerName: "codex",
708753 runtimeMode: "approval-required",
@@ -716,11 +761,11 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
716761
717762 harness.provider.emit({
718763 type: "checkpoint.captured",
719− eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"),
764+ eventId: EventId.make("evt-checkpoint-captured-3"),
720765 provider: "codex",
721766
722767 createdAt: new Date().toISOString(),
723− threadId: ThreadId.makeUnsafe("thread-1"),
768+ threadId: ThreadId.make("thread-1"),
724769 turnId: asTurnId("turn-3"),
725770 turnCount: 3,
726771 status: "completed",
@@ -728,7 +773,7 @@ describe("CheckpointReactor", () => { · 1 unmodified line
728773
729774 await harness.drain();
730775 const readModel = await Effect.runPromise(harness.engine.getReadModel());
731− const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
776+ const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
732777 expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(
733778 false,
734779 );
@@ -749,10 +794,10 @@ describe("CheckpointReactor", () => { · 14 unmodified lines
749794 await Effect.runPromise(
750795 harness.engine.dispatch({
751796 type: "thread.session.set",
752− commandId: CommandId.makeUnsafe("cmd-session-set-non-repo-runtime"),
753− threadId: ThreadId.makeUnsafe("thread-1"),
797+ commandId: CommandId.make("cmd-session-set-non-repo-runtime"),
798+ threadId: ThreadId.make("thread-1"),
754799 session: {
755− threadId: ThreadId.makeUnsafe("thread-1"),
800+ threadId: ThreadId.make("thread-1"),
756801 status: "ready",
757802 providerName: "codex",
758803 runtimeMode: "approval-required",
@@ -766,31 +811,31 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
766811
767812 harness.provider.emit({
768813 type: "turn.completed",
769− eventId: EventId.makeUnsafe("evt-runtime-capture-failure"),
814+ eventId: EventId.make("evt-runtime-capture-failure"),
770815 provider: "codex",
771816
772817 createdAt: new Date().toISOString(),
773− threadId: ThreadId.makeUnsafe("thread-1"),
818+ threadId: ThreadId.make("thread-1"),
774819 turnId: asTurnId("turn-runtime-failure"),
775820 payload: { state: "completed" },
776821 });
777822
778823 harness.provider.emit({
779824 type: "turn.started",
780− eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"),
825+ eventId: EventId.make("evt-turn-started-after-runtime-failure"),
781826 provider: "codex",
782827
783828 createdAt: new Date().toISOString(),
784− threadId: ThreadId.makeUnsafe("thread-1"),
829+ threadId: ThreadId.make("thread-1"),
785830 turnId: asTurnId("turn-after-runtime-failure"),
786831 });
787832
788833 await waitForGitRefExists(
789834 harness.cwd,
790− checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
835+ checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
791836 );
792837 expect(
793− gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)),
838+ gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)),
794839 ).toBe(true);
795840 });
796841
@@ -801,10 +846,10 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
801846 await Effect.runPromise(
802847 harness.engine.dispatch({
803848 type: "thread.session.set",
804− commandId: CommandId.makeUnsafe("cmd-session-set"),
805− threadId: ThreadId.makeUnsafe("thread-1"),
849+ commandId: CommandId.make("cmd-session-set"),
850+ threadId: ThreadId.make("thread-1"),
806851 session: {
807− threadId: ThreadId.makeUnsafe("thread-1"),
852+ threadId: ThreadId.make("thread-1"),
808853 status: "ready",
809854 providerName: "codex",
810855 runtimeMode: "approval-required",
@@ -819,11 +864,11 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
819864 await Effect.runPromise(
820865 harness.engine.dispatch({
821866 type: "thread.turn.diff.complete",
822− commandId: CommandId.makeUnsafe("cmd-diff-1"),
823− threadId: ThreadId.makeUnsafe("thread-1"),
867+ commandId: CommandId.make("cmd-diff-1"),
868+ threadId: ThreadId.make("thread-1"),
824869 turnId: asTurnId("turn-1"),
825870 completedAt: createdAt,
826− checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
871+ checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
827872 status: "ready",
828873 files: [],
829874 checkpointTurnCount: 1,
@@ -833,11 +878,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines
833878 await Effect.runPromise(
834879 harness.engine.dispatch({
835880 type: "thread.turn.diff.complete",
836− commandId: CommandId.makeUnsafe("cmd-diff-2"),
837− threadId: ThreadId.makeUnsafe("thread-1"),
881+ commandId: CommandId.make("cmd-diff-2"),
882+ threadId: ThreadId.make("thread-1"),
838883 turnId: asTurnId("turn-2"),
839884 completedAt: createdAt,
840− checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
885+ checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
841886 status: "ready",
842887 files: [],
843888 checkpointTurnCount: 2,
@@ -848,8 +893,8 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
848893 await Effect.runPromise(
849894 harness.engine.dispatch({
850895 type: "thread.checkpoint.revert",
851− commandId: CommandId.makeUnsafe("cmd-revert-request"),
852− threadId: ThreadId.makeUnsafe("thread-1"),
896+ commandId: CommandId.make("cmd-revert-request"),
897+ threadId: ThreadId.make("thread-1"),
853898 turnCount: 1,
854899 createdAt,
855900 }),
@@ -863,12 +908,12 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
863908 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
864909 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
865910 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
866− threadId: ThreadId.makeUnsafe("thread-1"),
911+ threadId: ThreadId.make("thread-1"),
867912 numTurns: 1,
868913 });
869914 expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");
870915 expect(
871− gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2)),
916+ gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)),
872917 ).toBe(false);
873918 });
874919
@@ -879,10 +924,10 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
879924 await Effect.runPromise(
880925 harness.engine.dispatch({
881926 type: "thread.session.set",
882− commandId: CommandId.makeUnsafe("cmd-session-set-claude"),
883− threadId: ThreadId.makeUnsafe("thread-1"),
927+ commandId: CommandId.make("cmd-session-set-claude"),
928+ threadId: ThreadId.make("thread-1"),
884929 session: {
885− threadId: ThreadId.makeUnsafe("thread-1"),
930+ threadId: ThreadId.make("thread-1"),
886931 status: "ready",
887932 providerName: "claudeAgent",
888933 runtimeMode: "approval-required",
@@ -897,11 +942,11 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
897942 await Effect.runPromise(
898943 harness.engine.dispatch({
899944 type: "thread.turn.diff.complete",
900− commandId: CommandId.makeUnsafe("cmd-diff-claude-1"),
901− threadId: ThreadId.makeUnsafe("thread-1"),
945+ commandId: CommandId.make("cmd-diff-claude-1"),
946+ threadId: ThreadId.make("thread-1"),
902947 turnId: asTurnId("turn-claude-1"),
903948 completedAt: createdAt,
904− checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
949+ checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
905950 status: "ready",
906951 files: [],
907952 checkpointTurnCount: 1,
@@ -911,11 +956,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines
911956 await Effect.runPromise(
912957 harness.engine.dispatch({
913958 type: "thread.turn.diff.complete",
914− commandId: CommandId.makeUnsafe("cmd-diff-claude-2"),
915− threadId: ThreadId.makeUnsafe("thread-1"),
959+ commandId: CommandId.make("cmd-diff-claude-2"),
960+ threadId: ThreadId.make("thread-1"),
916961 turnId: asTurnId("turn-claude-2"),
917962 completedAt: createdAt,
918− checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
963+ checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
919964 status: "ready",
920965 files: [],
921966 checkpointTurnCount: 2,
@@ -926,8 +971,8 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
926971 await Effect.runPromise(
927972 harness.engine.dispatch({
928973 type: "thread.checkpoint.revert",
929− commandId: CommandId.makeUnsafe("cmd-revert-request-claude"),
930− threadId: ThreadId.makeUnsafe("thread-1"),
974+ commandId: CommandId.make("cmd-revert-request-claude"),
975+ threadId: ThreadId.make("thread-1"),
931976 turnCount: 1,
932977 createdAt,
933978 }),
@@ -936,7 +981,7 @@ describe("CheckpointReactor", () => { · 2 unmodified lines
936981 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
937982 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
938983 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
939− threadId: ThreadId.makeUnsafe("thread-1"),
984+ threadId: ThreadId.make("thread-1"),
940985 numTurns: 1,
941986 });
942987 });
@@ -948,10 +993,10 @@ describe("CheckpointReactor", () => { · 5 unmodified lines
948993 await Effect.runPromise(
949994 harness.engine.dispatch({
950995 type: "thread.session.set",
951− commandId: CommandId.makeUnsafe("cmd-session-set-inline-revert"),
952− threadId: ThreadId.makeUnsafe("thread-1"),
996+ commandId: CommandId.make("cmd-session-set-inline-revert"),
997+ threadId: ThreadId.make("thread-1"),
953998 session: {
954− threadId: ThreadId.makeUnsafe("thread-1"),
999+ threadId: ThreadId.make("thread-1"),
9551000 status: "ready",
9561001 providerName: "codex",
9571002 runtimeMode: "approval-required",
@@ -966,11 +1011,11 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
9661011 await Effect.runPromise(
9671012 harness.engine.dispatch({
9681013 type: "thread.turn.diff.complete",
969− commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-1"),
970− threadId: ThreadId.makeUnsafe("thread-1"),
1014+ commandId: CommandId.make("cmd-inline-revert-diff-1"),
1015+ threadId: ThreadId.make("thread-1"),
9711016 turnId: asTurnId("turn-1"),
9721017 completedAt: createdAt,
973− checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
1018+ checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
9741019 status: "ready",
9751020 files: [],
9761021 checkpointTurnCount: 1,
@@ -980,11 +1025,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines
9801025 await Effect.runPromise(
9811026 harness.engine.dispatch({
9821027 type: "thread.turn.diff.complete",
983− commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-2"),
984− threadId: ThreadId.makeUnsafe("thread-1"),
1028+ commandId: CommandId.make("cmd-inline-revert-diff-2"),
1029+ threadId: ThreadId.make("thread-1"),
9851030 turnId: asTurnId("turn-2"),
9861031 completedAt: createdAt,
987− checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
1032+ checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
9881033 status: "ready",
9891034 files: [],
9901035 checkpointTurnCount: 2,
@@ -995,8 +1040,8 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
9951040 await Effect.runPromise(
9961041 harness.engine.dispatch({
9971042 type: "thread.checkpoint.revert",
998− commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-1"),
999− threadId: ThreadId.makeUnsafe("thread-1"),
1043+ commandId: CommandId.make("cmd-sequenced-revert-request-1"),
1044+ threadId: ThreadId.make("thread-1"),
10001045 turnCount: 1,
10011046 createdAt,
10021047 }),
@@ -1004,8 +1049,8 @@ describe("CheckpointReactor", () => { · 1 unmodified line
10041049 await Effect.runPromise(
10051050 harness.engine.dispatch({
10061051 type: "thread.checkpoint.revert",
1007− commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-0"),
1008− threadId: ThreadId.makeUnsafe("thread-1"),
1052+ commandId: CommandId.make("cmd-sequenced-revert-request-0"),
1053+ threadId: ThreadId.make("thread-1"),
10091054 turnCount: 0,
10101055 createdAt,
10111056 }),
@@ -1015,11 +1060,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines
10151060
10161061 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);
10171062 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({
1018− threadId: ThreadId.makeUnsafe("thread-1"),
1063+ threadId: ThreadId.make("thread-1"),
10191064 numTurns: 1,
10201065 });
10211066 expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({
1022− threadId: ThreadId.makeUnsafe("thread-1"),
1067+ threadId: ThreadId.make("thread-1"),
10231068 numTurns: 1,
10241069 });
10251070 });
@@ -1031,8 +1076,8 @@ describe("CheckpointReactor", () => { · 5 unmodified lines
10311076 await Effect.runPromise(
10321077 harness.engine.dispatch({
10331078 type: "thread.checkpoint.revert",
1034− commandId: CommandId.makeUnsafe("cmd-revert-no-session"),
1035− threadId: ThreadId.makeUnsafe("thread-1"),
1079+ commandId: CommandId.make("cmd-revert-no-session"),
1080+ threadId: ThreadId.make("thread-1"),
10361081 turnCount: 1,
10371082 createdAt,
10381083 }),
apps/server/src/orchestration/Layers/CheckpointReactor.ts+31−8
@@ -21,9 +21,10 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; · 20 unmodified lines
2121 import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
2222 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2323 import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
24−import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
25−import { OrchestrationDispatchError } from "../Errors.ts";
24+import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";
25+import type { OrchestrationDispatchError } from "../Errors.ts";
2626 import { isGitRepository } from "../../git/Utils.ts";
27+import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
2728 import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts";
2829
2930 type ReactorInput =
@@ -37,7 +38,7 @@ type ReactorInput = · 7 unmodified lines
3738 };
3839
3940 function toTurnId(value: string | undefined): TurnId | null {
40− return value === undefined ? null : TurnId.makeUnsafe(String(value));
41+ return value === undefined ? null : TurnId.make(String(value));
4142 }
4243
4344 function sameId(left: string | null | undefined, right: string | null | undefined): boolean {
@@ -61,7 +62,7 @@ function checkpointStatusFromRuntime(status: string | undefined): "ready" | "mis · 17 unmodified lines
6162 }
6263
6364 const serverCommandId = (tag: string): CommandId =>
64− CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
65+ CommandId.make(`server:${tag}:${crypto.randomUUID()}`);
6566
6667 const make = Effect.gen(function* () {
6768 const orchestrationEngine = yield* OrchestrationEngineService;
@@ -69,6 +70,7 @@ const make = Effect.gen(function* () { · 1 unmodified line
6970 const checkpointStore = yield* CheckpointStore;
7071 const receiptBus = yield* RuntimeReceiptBus;
7172 const workspaceEntries = yield* WorkspaceEntries;
73+ const gitStatusBroadcaster = yield* GitStatusBroadcaster;
7274
7375 const appendRevertFailureActivity = (input: {
7476 readonly threadId: ThreadId;
@@ -81,7 +83,7 @@ const make = Effect.gen(function* () { · 6 unmodified lines
8183 commandId: serverCommandId("checkpoint-revert-failure"),
8284 threadId: input.threadId,
8385 activity: {
84− id: EventId.makeUnsafe(crypto.randomUUID()),
86+ id: EventId.make(crypto.randomUUID()),
8587 tone: "error",
8688 kind: "checkpoint.revert.failed",
8789 summary: "Checkpoint revert failed",
@@ -106,7 +108,7 @@ const make = Effect.gen(function* () { · 18 unmodified lines
106108 commandId: serverCommandId("checkpoint-capture-failure"),
107109 threadId: input.threadId,
108110 activity: {
109− id: EventId.makeUnsafe(crypto.randomUUID()),
111+ id: EventId.make(crypto.randomUUID()),
110112 tone: "error",
111113 kind: "checkpoint.capture.failed",
112114 summary: "Checkpoint capture failed",
@@ -268,7 +270,7 @@ const make = Effect.gen(function* () { · 155 unmodified lines
268270 input.thread.messages
269271 .toReversed()
270272 .find((entry) => entry.role === "assistant" && entry.turnId === input.turnId)?.id ??
271− MessageId.makeUnsafe(`assistant:${input.turnId}`);
273+ MessageId.make(`assistant:${input.turnId}`);
272274
273275 yield* orchestrationEngine.dispatch({
274276 type: "thread.turn.diff.complete",
@@ -305,7 +307,7 @@ const make = Effect.gen(function* () { · 30 unmodified lines
305307 commandId: serverCommandId("checkpoint-captured-activity"),
306308 threadId: input.threadId,
307309 activity: {
308− id: EventId.makeUnsafe(crypto.randomUUID()),
310+ id: EventId.make(crypto.randomUUID()),
309311 tone: "info",
310312 kind: "checkpoint.captured",
311313 summary: "Checkpoint captured",
@@ -498,6 +500,26 @@ const make = Effect.gen(function* () { · 186 unmodified lines
498500 },
499501 );
500502
503+ const refreshLocalGitStatusFromTurnCompletion = Effect.fn(
504+ "refreshLocalGitStatusFromTurnCompletion",
505+ )(function* (event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>) {
506+ const sessionRuntime = yield* resolveSessionRuntimeForThread(event.threadId);
507+ if (Option.isNone(sessionRuntime)) {
508+ return;
509+ }
510+
511+ yield* gitStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe(
512+ Effect.catch((error) =>
513+ Effect.logWarning("failed to refresh local git status after turn completion", {
514+ threadId: event.threadId,
515+ turnId: event.turnId ?? null,
516+ cwd: sessionRuntime.value.cwd,
517+ detail: error.message,
518+ }),
519+ ),
520+ );
521+ });
522+
501523 const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn(
502524 "ensurePreTurnBaselineFromDomainTurnStart",
503525 )(function* (
@@ -736,6 +758,7 @@ const make = Effect.gen(function* () { · 232 unmodified lines
736758
737759 if (event.type === "turn.completed") {
738760 const turnId = toTurnId(event.turnId);
761+ yield* refreshLocalGitStatusFromTurnCompletion(event);
739762 yield* captureCheckpointFromTurnCompletion(event).pipe(
740763 Effect.catch((error) =>
741764 appendCaptureFailureActivity({
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+77−62
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 OrchestrationEventStore,
2020 type OrchestrationEventStoreShape,
2121 } from "../../persistence/Services/OrchestrationEventStore.ts";
22+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2223 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2324 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
2425 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -31,10 +32,10 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" · 6 unmodified lines
3132 import { ServerConfig } from "../../config.ts";
3233 import * as NodeServices from "@effect/platform-node/NodeServices";
3334
34−const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
35−const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
36−const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
37−const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
35+const asProjectId = (value: string): ProjectId => ProjectId.make(value);
36+const asMessageId = (value: string): MessageId => MessageId.make(value);
37+const asTurnId = (value: string): TurnId => TurnId.make(value);
38+const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
3839
3940 async function createOrchestrationSystem() {
4041 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
@@ -45,6 +46,7 @@ async function createOrchestrationSystem() { · 4 unmodified lines
4546 Layer.provide(OrchestrationProjectionPipelineLive),
4647 Layer.provide(OrchestrationEventStoreLive),
4748 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
49+ Layer.provide(RepositoryIdentityResolverLive),
4850 Layer.provide(SqlitePersistenceMemory),
4951 Layer.provideMerge(ServerConfigLayer),
5052 Layer.provideMerge(NodeServices.layer),
@@ -113,7 +115,7 @@ describe("OrchestrationEngine", () => { · 62 unmodified lines
113115 ],
114116 threads: [
115117 {
116− id: ThreadId.makeUnsafe("thread-bootstrap"),
118+ id: ThreadId.make("thread-bootstrap"),
117119 projectId: asProjectId("project-bootstrap"),
118120 title: "Bootstrap Thread",
119121 modelSelection: {
@@ -142,10 +144,20 @@ describe("OrchestrationEngine", () => { · 22 unmodified lines
142144 Layer.provide(
143145 Layer.succeed(ProjectionSnapshotQuery, {
144146 getSnapshot: () => Effect.succeed(projectionSnapshot),
147+ getShellSnapshot: () =>
148+ Effect.succeed({
149+ snapshotSequence: projectionSnapshot.snapshotSequence,
150+ projects: [],
151+ threads: [],
152+ updatedAt: projectionSnapshot.updatedAt,
153+ }),
145154 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),
146155 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
156+ getProjectShellById: () => Effect.succeed(Option.none()),
147157 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
148158 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
159+ getThreadShellById: () => Effect.succeed(Option.none()),
160+ getThreadDetailById: () => Effect.succeed(Option.none()),
149161 }),
150162 ),
151163 Layer.provide(
@@ -181,7 +193,7 @@ describe("OrchestrationEngine", () => { · 29 unmodified lines
181193 await system.run(
182194 engine.dispatch({
183195 type: "project.create",
184− commandId: CommandId.makeUnsafe("cmd-project-1-create"),
196+ commandId: CommandId.make("cmd-project-1-create"),
185197 projectId: asProjectId("project-1"),
186198 title: "Project 1",
187199 workspaceRoot: "/tmp/project-1",
@@ -195,8 +207,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
195207 await system.run(
196208 engine.dispatch({
197209 type: "thread.create",
198− commandId: CommandId.makeUnsafe("cmd-thread-1-create"),
199− threadId: ThreadId.makeUnsafe("thread-1"),
210+ commandId: CommandId.make("cmd-thread-1-create"),
211+ threadId: ThreadId.make("thread-1"),
200212 projectId: asProjectId("project-1"),
201213 title: "Thread",
202214 modelSelection: {
@@ -213,8 +225,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines
213225 await system.run(
214226 engine.dispatch({
215227 type: "thread.turn.start",
216− commandId: CommandId.makeUnsafe("cmd-turn-start-1"),
217− threadId: ThreadId.makeUnsafe("thread-1"),
228+ commandId: CommandId.make("cmd-turn-start-1"),
229+ threadId: ThreadId.make("thread-1"),
218230 message: {
219231 messageId: asMessageId("msg-1"),
220232 role: "user",
@@ -241,7 +253,7 @@ describe("OrchestrationEngine", () => { · 20 unmodified lines
241253 await system.run(
242254 engine.dispatch({
243255 type: "project.create",
244− commandId: CommandId.makeUnsafe("cmd-project-archive-create"),
256+ commandId: CommandId.make("cmd-project-archive-create"),
245257 projectId: asProjectId("project-archive"),
246258 title: "Project Archive",
247259 workspaceRoot: "/tmp/project-archive",
@@ -255,8 +267,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
255267 await system.run(
256268 engine.dispatch({
257269 type: "thread.create",
258− commandId: CommandId.makeUnsafe("cmd-thread-archive-create"),
259− threadId: ThreadId.makeUnsafe("thread-archive"),
270+ commandId: CommandId.make("cmd-thread-archive-create"),
271+ threadId: ThreadId.make("thread-archive"),
260272 projectId: asProjectId("project-archive"),
261273 title: "Archive me",
262274 modelSelection: {
@@ -274,8 +286,8 @@ describe("OrchestrationEngine", () => { · 11 unmodified lines
274286 await system.run(
275287 engine.dispatch({
276288 type: "thread.archive",
277− commandId: CommandId.makeUnsafe("cmd-thread-archive"),
278− threadId: ThreadId.makeUnsafe("thread-archive"),
289+ commandId: CommandId.make("cmd-thread-archive"),
290+ threadId: ThreadId.make("thread-archive"),
279291 }),
280292 );
281293 expect(
@@ -287,8 +299,8 @@ describe("OrchestrationEngine", () => { · 5 unmodified lines
287299 await system.run(
288300 engine.dispatch({
289301 type: "thread.unarchive",
290− commandId: CommandId.makeUnsafe("cmd-thread-unarchive"),
291− threadId: ThreadId.makeUnsafe("thread-archive"),
302+ commandId: CommandId.make("cmd-thread-unarchive"),
303+ threadId: ThreadId.make("thread-archive"),
292304 }),
293305 );
294306 expect(
@@ -308,7 +320,7 @@ describe("OrchestrationEngine", () => { · 13 unmodified lines
308320 await system.run(
309321 engine.dispatch({
310322 type: "project.create",
311− commandId: CommandId.makeUnsafe("cmd-project-replay-create"),
323+ commandId: CommandId.make("cmd-project-replay-create"),
312324 projectId: asProjectId("project-replay"),
313325 title: "Replay Project",
314326 workspaceRoot: "/tmp/project-replay",
@@ -322,8 +334,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
322334 await system.run(
323335 engine.dispatch({
324336 type: "thread.create",
325− commandId: CommandId.makeUnsafe("cmd-thread-replay-create"),
326− threadId: ThreadId.makeUnsafe("thread-replay"),
337+ commandId: CommandId.make("cmd-thread-replay-create"),
338+ threadId: ThreadId.make("thread-replay"),
327339 projectId: asProjectId("project-replay"),
328340 title: "replay",
329341 modelSelection: {
@@ -340,8 +352,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines
340352 await system.run(
341353 engine.dispatch({
342354 type: "thread.delete",
343− commandId: CommandId.makeUnsafe("cmd-thread-replay-delete"),
344− threadId: ThreadId.makeUnsafe("thread-replay"),
355+ commandId: CommandId.make("cmd-thread-replay-delete"),
356+ threadId: ThreadId.make("thread-replay"),
345357 }),
346358 );
347359
@@ -366,7 +378,7 @@ describe("OrchestrationEngine", () => { · 18 unmodified lines
366378 await system.run(
367379 engine.dispatch({
368380 type: "project.create",
369− commandId: CommandId.makeUnsafe("cmd-project-stream-create"),
381+ commandId: CommandId.make("cmd-project-stream-create"),
370382 projectId: asProjectId("project-stream"),
371383 title: "Stream Project",
372384 workspaceRoot: "/tmp/project-stream",
@@ -390,8 +402,8 @@ describe("OrchestrationEngine", () => { · 17 unmodified lines
390402 yield* Effect.sleep("10 millis");
391403 yield* engine.dispatch({
392404 type: "thread.create",
393− commandId: CommandId.makeUnsafe("cmd-stream-thread-create"),
394− threadId: ThreadId.makeUnsafe("thread-stream"),
405+ commandId: CommandId.make("cmd-stream-thread-create"),
406+ threadId: ThreadId.make("thread-stream"),
395407 projectId: asProjectId("project-stream"),
396408 title: "domain-stream",
397409 modelSelection: {
@@ -406,8 +418,8 @@ describe("OrchestrationEngine", () => { · 8 unmodified lines
406418 });
407419 yield* engine.dispatch({
408420 type: "thread.meta.update",
409− commandId: CommandId.makeUnsafe("cmd-stream-thread-update"),
410− threadId: ThreadId.makeUnsafe("thread-stream"),
421+ commandId: CommandId.make("cmd-stream-thread-update"),
422+ threadId: ThreadId.make("thread-stream"),
411423 title: "domain-stream-updated",
412424 });
413425 eventTypes.push((yield* Queue.take(eventQueue)).type);
@@ -427,7 +439,7 @@ describe("OrchestrationEngine", () => { · 13 unmodified lines
427439 await system.run(
428440 engine.dispatch({
429441 type: "project.create",
430− commandId: CommandId.makeUnsafe("cmd-project-ack-create"),
442+ commandId: CommandId.make("cmd-project-ack-create"),
431443 projectId: asProjectId("project-ack"),
432444 title: "Ack Project",
433445 workspaceRoot: "/tmp/project-ack",
@@ -442,8 +454,8 @@ describe("OrchestrationEngine", () => { · 8 unmodified lines
442454 await system.run(
443455 engine.dispatch({
444456 type: "thread.create",
445− commandId: CommandId.makeUnsafe("cmd-thread-ack-create"),
446− threadId: ThreadId.makeUnsafe("thread-ack"),
457+ commandId: CommandId.make("cmd-thread-ack-create"),
458+ threadId: ThreadId.make("thread-ack"),
447459 projectId: asProjectId("project-ack"),
448460 title: "Ack Thread",
449461 modelSelection: {
@@ -479,8 +491,8 @@ describe("OrchestrationEngine", () => { · 29 unmodified lines
479491 system.run(
480492 engine.dispatch({
481493 type: "thread.create",
482− commandId: CommandId.makeUnsafe("cmd-thread-missing-project"),
483− threadId: ThreadId.makeUnsafe("thread-missing-project"),
494+ commandId: CommandId.make("cmd-thread-missing-project"),
495+ threadId: ThreadId.make("thread-missing-project"),
484496 projectId: asProjectId("project-missing"),
485497 title: "Missing Project Thread",
486498 modelSelection: {
@@ -516,7 +528,7 @@ describe("OrchestrationEngine", () => { · 29 unmodified lines
516528 await system.run(
517529 engine.dispatch({
518530 type: "project.create",
519− commandId: CommandId.makeUnsafe("cmd-project-turn-diff-create"),
531+ commandId: CommandId.make("cmd-project-turn-diff-create"),
520532 projectId: asProjectId("project-turn-diff"),
521533 title: "Turn Diff Project",
522534 workspaceRoot: "/tmp/project-turn-diff",
@@ -530,8 +542,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
530542 await system.run(
531543 engine.dispatch({
532544 type: "thread.create",
533− commandId: CommandId.makeUnsafe("cmd-thread-turn-diff-create"),
534− threadId: ThreadId.makeUnsafe("thread-turn-diff"),
545+ commandId: CommandId.make("cmd-thread-turn-diff-create"),
546+ threadId: ThreadId.make("thread-turn-diff"),
535547 projectId: asProjectId("project-turn-diff"),
536548 title: "Turn diff thread",
537549 modelSelection: {
@@ -548,8 +560,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines
548560 await system.run(
549561 engine.dispatch({
550562 type: "thread.turn.diff.complete",
551− commandId: CommandId.makeUnsafe("cmd-turn-diff-complete"),
552− threadId: ThreadId.makeUnsafe("thread-turn-diff"),
563+ commandId: CommandId.make("cmd-turn-diff-complete"),
564+ threadId: ThreadId.make("thread-turn-diff"),
553565 turnId: asTurnId("turn-1"),
554566 completedAt: createdAt,
555567 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),
@@ -588,7 +600,7 @@ describe("OrchestrationEngine", () => { · 32 unmodified lines
588600
589601 const flakyStore: OrchestrationEventStoreShape = {
590602 append(event) {
591− if (shouldFailFirstAppend && event.commandId === CommandId.makeUnsafe("cmd-flaky-1")) {
603+ if (shouldFailFirstAppend && event.commandId === CommandId.make("cmd-flaky-1")) {
592604 shouldFailFirstAppend = false;
593605 return Effect.fail(
594606 new PersistenceSqlError({
@@ -623,6 +635,7 @@ describe("OrchestrationEngine", () => { · 28 unmodified lines
623635 Layer.provide(OrchestrationProjectionPipelineLive),
624636 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
625637 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
638+ Layer.provide(RepositoryIdentityResolverLive),
626639 Layer.provide(SqlitePersistenceMemory),
627640 Layer.provideMerge(ServerConfigLayer),
628641 Layer.provideMerge(NodeServices.layer),
@@ -634,7 +647,7 @@ describe("OrchestrationEngine", () => { · 5 unmodified lines
634647 await runtime.runPromise(
635648 engine.dispatch({
636649 type: "project.create",
637− commandId: CommandId.makeUnsafe("cmd-project-flaky-create"),
650+ commandId: CommandId.make("cmd-project-flaky-create"),
638651 projectId: asProjectId("project-flaky"),
639652 title: "Flaky Project",
640653 workspaceRoot: "/tmp/project-flaky",
@@ -650,8 +663,8 @@ describe("OrchestrationEngine", () => { · 9 unmodified lines
650663 runtime.runPromise(
651664 engine.dispatch({
652665 type: "thread.create",
653− commandId: CommandId.makeUnsafe("cmd-flaky-1"),
654− threadId: ThreadId.makeUnsafe("thread-flaky-fail"),
666+ commandId: CommandId.make("cmd-flaky-1"),
667+ threadId: ThreadId.make("thread-flaky-fail"),
655668 projectId: asProjectId("project-flaky"),
656669 title: "flaky-fail",
657670 modelSelection: {
@@ -670,8 +683,8 @@ describe("OrchestrationEngine", () => { · 12 unmodified lines
670683 const result = await runtime.runPromise(
671684 engine.dispatch({
672685 type: "thread.create",
673− commandId: CommandId.makeUnsafe("cmd-flaky-2"),
674− threadId: ThreadId.makeUnsafe("thread-flaky-ok"),
686+ commandId: CommandId.make("cmd-flaky-2"),
687+ threadId: ThreadId.make("thread-flaky-ok"),
675688 projectId: asProjectId("project-flaky"),
676689 title: "flaky-ok",
677690 modelSelection: {
@@ -698,7 +711,7 @@ describe("OrchestrationEngine", () => { · 20 unmodified lines
698711 projectEvent: (event) => {
699712 if (
700713 shouldFailRequestedProjection &&
701− event.commandId === CommandId.makeUnsafe("cmd-turn-start-atomic") &&
714+ event.commandId === CommandId.make("cmd-turn-start-atomic") &&
702715 event.type === "thread.turn-start-requested"
703716 ) {
704717 shouldFailRequestedProjection = false;
@@ -719,6 +732,7 @@ describe("OrchestrationEngine", () => { · 14 unmodified lines
719732 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
720733 Layer.provide(OrchestrationEventStoreLive),
721734 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
735+ Layer.provide(RepositoryIdentityResolverLive),
722736 Layer.provide(SqlitePersistenceMemory),
723737 ),
724738 );
@@ -728,7 +742,7 @@ describe("OrchestrationEngine", () => { · 3 unmodified lines
728742 await runtime.runPromise(
729743 engine.dispatch({
730744 type: "project.create",
731− commandId: CommandId.makeUnsafe("cmd-project-atomic-create"),
745+ commandId: CommandId.make("cmd-project-atomic-create"),
732746 projectId: asProjectId("project-atomic"),
733747 title: "Atomic Project",
734748 workspaceRoot: "/tmp/project-atomic",
@@ -742,8 +756,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
742756 await runtime.runPromise(
743757 engine.dispatch({
744758 type: "thread.create",
745− commandId: CommandId.makeUnsafe("cmd-thread-atomic-create"),
746− threadId: ThreadId.makeUnsafe("thread-atomic"),
759+ commandId: CommandId.make("cmd-thread-atomic-create"),
760+ threadId: ThreadId.make("thread-atomic"),
747761 projectId: asProjectId("project-atomic"),
748762 title: "atomic",
749763 modelSelection: {
@@ -760,8 +774,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines
760774
761775 const turnStartCommand = {
762776 type: "thread.turn.start" as const,
763− commandId: CommandId.makeUnsafe("cmd-turn-start-atomic"),
764− threadId: ThreadId.makeUnsafe("thread-atomic"),
777+ commandId: CommandId.make("cmd-turn-start-atomic"),
778+ threadId: ThreadId.make("thread-atomic"),
765779 message: {
766780 messageId: asMessageId("msg-atomic-1"),
767781 role: "user" as const,
@@ -841,7 +855,7 @@ describe("OrchestrationEngine", () => { · 73 unmodified lines
841855 projectEvent: (event) => {
842856 if (
843857 shouldFailProjection &&
844− event.commandId === CommandId.makeUnsafe("cmd-thread-meta-sync-fail")
858+ event.commandId === CommandId.make("cmd-thread-meta-sync-fail")
845859 ) {
846860 shouldFailProjection = false;
847861 return Effect.fail(
@@ -861,6 +875,7 @@ describe("OrchestrationEngine", () => { · 13 unmodified lines
861875 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
862876 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
863877 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
878+ Layer.provide(RepositoryIdentityResolverLive),
864879 Layer.provide(SqlitePersistenceMemory),
865880 ),
866881 );
@@ -870,7 +885,7 @@ describe("OrchestrationEngine", () => { · 3 unmodified lines
870885 await runtime.runPromise(
871886 engine.dispatch({
872887 type: "project.create",
873− commandId: CommandId.makeUnsafe("cmd-project-sync-create"),
888+ commandId: CommandId.make("cmd-project-sync-create"),
874889 projectId: asProjectId("project-sync"),
875890 title: "Sync Project",
876891 workspaceRoot: "/tmp/project-sync",
@@ -884,8 +899,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
884899 await runtime.runPromise(
885900 engine.dispatch({
886901 type: "thread.create",
887− commandId: CommandId.makeUnsafe("cmd-thread-sync-create"),
888− threadId: ThreadId.makeUnsafe("thread-sync"),
902+ commandId: CommandId.make("cmd-thread-sync-create"),
903+ threadId: ThreadId.make("thread-sync"),
889904 projectId: asProjectId("project-sync"),
890905 title: "sync-before",
891906 modelSelection: {
@@ -904,8 +919,8 @@ describe("OrchestrationEngine", () => { · 12 unmodified lines
904919 runtime.runPromise(
905920 engine.dispatch({
906921 type: "thread.meta.update",
907− commandId: CommandId.makeUnsafe("cmd-thread-meta-sync-fail"),
908− threadId: ThreadId.makeUnsafe("thread-sync"),
922+ commandId: CommandId.make("cmd-thread-meta-sync-fail"),
923+ threadId: ThreadId.make("thread-sync"),
909924 title: "sync-after-failed-projection",
910925 }),
911926 ),
@@ -929,8 +944,8 @@ describe("OrchestrationEngine", () => { · 17 unmodified lines
929944 system.run(
930945 engine.dispatch({
931946 type: "thread.turn.start",
932− commandId: CommandId.makeUnsafe("cmd-invariant-missing-thread"),
933− threadId: ThreadId.makeUnsafe("thread-missing"),
947+ commandId: CommandId.make("cmd-invariant-missing-thread"),
948+ threadId: ThreadId.make("thread-missing"),
934949 message: {
935950 messageId: asMessageId("msg-missing"),
936951 role: "user",
@@ -955,7 +970,7 @@ describe("OrchestrationEngine", () => { · 18 unmodified lines
955970 await system.run(
956971 engine.dispatch({
957972 type: "project.create",
958− commandId: CommandId.makeUnsafe("cmd-project-duplicate-create"),
973+ commandId: CommandId.make("cmd-project-duplicate-create"),
959974 projectId: asProjectId("project-duplicate"),
960975 title: "Duplicate Project",
961976 workspaceRoot: "/tmp/project-duplicate",
@@ -970,8 +985,8 @@ describe("OrchestrationEngine", () => { · 8 unmodified lines
970985 await system.run(
971986 engine.dispatch({
972987 type: "thread.create",
973− commandId: CommandId.makeUnsafe("cmd-thread-duplicate-1"),
974− threadId: ThreadId.makeUnsafe("thread-duplicate"),
988+ commandId: CommandId.make("cmd-thread-duplicate-1"),
989+ threadId: ThreadId.make("thread-duplicate"),
975990 projectId: asProjectId("project-duplicate"),
976991 title: "duplicate",
977992 modelSelection: {
@@ -990,8 +1005,8 @@ describe("OrchestrationEngine", () => { · 12 unmodified lines
9901005 system.run(
9911006 engine.dispatch({
9921007 type: "thread.create",
993− commandId: CommandId.makeUnsafe("cmd-thread-duplicate-2"),
994− threadId: ThreadId.makeUnsafe("thread-duplicate"),
1008+ commandId: CommandId.make("cmd-thread-duplicate-2"),
1009+ threadId: ThreadId.make("thread-duplicate"),
9951010 projectId: asProjectId("project-duplicate"),
9961011 title: "duplicate",
9971012 modelSelection: {
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+627−302
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
2020 SqlitePersistenceMemory,
2121 } from "../../persistence/Layers/Sqlite.ts";
2222 import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
23+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2324 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2425 import {
2526 ORCHESTRATION_PROJECTOR_NAMES,
@@ -57,16 +58,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 31 unmodified lines
5758
5859 yield* eventStore.append({
5960 type: "project.created",
60− eventId: EventId.makeUnsafe("evt-1"),
61+ eventId: EventId.make("evt-1"),
6162 aggregateKind: "project",
62− aggregateId: ProjectId.makeUnsafe("project-1"),
63+ aggregateId: ProjectId.make("project-1"),
6364 occurredAt: now,
64− commandId: CommandId.makeUnsafe("cmd-1"),
65+ commandId: CommandId.make("cmd-1"),
6566 causationEventId: null,
66− correlationId: CommandId.makeUnsafe("cmd-1"),
67+ correlationId: CommandId.make("cmd-1"),
6768 metadata: {},
6869 payload: {
69− projectId: ProjectId.makeUnsafe("project-1"),
70+ projectId: ProjectId.make("project-1"),
7071 title: "Project 1",
7172 workspaceRoot: "/tmp/project-1",
7273 defaultModelSelection: null,
@@ -78,17 +79,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
7879
7980 yield* eventStore.append({
8081 type: "thread.created",
81− eventId: EventId.makeUnsafe("evt-2"),
82+ eventId: EventId.make("evt-2"),
8283 aggregateKind: "thread",
83− aggregateId: ThreadId.makeUnsafe("thread-1"),
84+ aggregateId: ThreadId.make("thread-1"),
8485 occurredAt: now,
85− commandId: CommandId.makeUnsafe("cmd-2"),
86+ commandId: CommandId.make("cmd-2"),
8687 causationEventId: null,
87− correlationId: CommandId.makeUnsafe("cmd-2"),
88+ correlationId: CommandId.make("cmd-2"),
8889 metadata: {},
8990 payload: {
90− threadId: ThreadId.makeUnsafe("thread-1"),
91− projectId: ProjectId.makeUnsafe("project-1"),
91+ threadId: ThreadId.make("thread-1"),
92+ projectId: ProjectId.make("project-1"),
9293 title: "Thread 1",
9394 modelSelection: {
9495 provider: "codex",
@@ -104,17 +105,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
104105
105106 yield* eventStore.append({
106107 type: "thread.message-sent",
107− eventId: EventId.makeUnsafe("evt-3"),
108+ eventId: EventId.make("evt-3"),
108109 aggregateKind: "thread",
109− aggregateId: ThreadId.makeUnsafe("thread-1"),
110+ aggregateId: ThreadId.make("thread-1"),
110111 occurredAt: now,
111− commandId: CommandId.makeUnsafe("cmd-3"),
112+ commandId: CommandId.make("cmd-3"),
112113 causationEventId: null,
113− correlationId: CommandId.makeUnsafe("cmd-3"),
114+ correlationId: CommandId.make("cmd-3"),
114115 metadata: {},
115116 payload: {
116− threadId: ThreadId.makeUnsafe("thread-1"),
117− messageId: MessageId.makeUnsafe("message-1"),
117+ threadId: ThreadId.make("thread-1"),
118+ messageId: MessageId.make("message-1"),
118119 role: "assistant",
119120 text: "hello",
120121 turnId: null,
@@ -182,17 +183,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( · 61 unmodified lines
182183
183184 yield* eventStore.append({
184185 type: "thread.message-sent",
185− eventId: EventId.makeUnsafe("evt-attachments"),
186+ eventId: EventId.make("evt-attachments"),
186187 aggregateKind: "thread",
187− aggregateId: ThreadId.makeUnsafe("thread-attachments"),
188+ aggregateId: ThreadId.make("thread-attachments"),
188189 occurredAt: now,
189− commandId: CommandId.makeUnsafe("cmd-attachments"),
190+ commandId: CommandId.make("cmd-attachments"),
190191 causationEventId: null,
191− correlationId: CommandId.makeUnsafe("cmd-attachments"),
192+ correlationId: CommandId.make("cmd-attachments"),
192193 metadata: {},
193194 payload: {
194− threadId: ThreadId.makeUnsafe("thread-attachments"),
195− messageId: MessageId.makeUnsafe("message-attachments"),
195+ threadId: ThreadId.make("thread-attachments"),
196+ messageId: MessageId.make("message-attachments"),
196197 role: "user",
197198 text: "Inspect this",
198199 attachments: [
@@ -248,17 +249,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 49 unmodified lines
248249
249250 yield* eventStore.append({
250251 type: "thread.message-sent",
251− eventId: EventId.makeUnsafe("evt-attachments-safe"),
252+ eventId: EventId.make("evt-attachments-safe"),
252253 aggregateKind: "thread",
253− aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),
254+ aggregateId: ThreadId.make("thread-attachments-safe"),
254255 occurredAt: now,
255− commandId: CommandId.makeUnsafe("cmd-attachments-safe"),
256+ commandId: CommandId.make("cmd-attachments-safe"),
256257 causationEventId: null,
257− correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),
258+ correlationId: CommandId.make("cmd-attachments-safe"),
258259 metadata: {},
259260 payload: {
260− threadId: ThreadId.makeUnsafe("thread-attachments-safe"),
261− messageId: MessageId.makeUnsafe("message-attachments-safe"),
261+ threadId: ThreadId.make("thread-attachments-safe"),
262+ messageId: MessageId.make("message-attachments-safe"),
262263 role: "user",
263264 text: "Inspect this",
264265 attachments: [
@@ -329,16 +330,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 64 unmodified lines
329330
330331 yield* eventStore.append({
331332 type: "project.created",
332− eventId: EventId.makeUnsafe("evt-clear-attachments-1"),
333+ eventId: EventId.make("evt-clear-attachments-1"),
333334 aggregateKind: "project",
334− aggregateId: ProjectId.makeUnsafe("project-clear-attachments"),
335+ aggregateId: ProjectId.make("project-clear-attachments"),
335336 occurredAt: now,
336− commandId: CommandId.makeUnsafe("cmd-clear-attachments-1"),
337+ commandId: CommandId.make("cmd-clear-attachments-1"),
337338 causationEventId: null,
338− correlationId: CommandId.makeUnsafe("cmd-clear-attachments-1"),
339+ correlationId: CommandId.make("cmd-clear-attachments-1"),
339340 metadata: {},
340341 payload: {
341− projectId: ProjectId.makeUnsafe("project-clear-attachments"),
342+ projectId: ProjectId.make("project-clear-attachments"),
342343 title: "Project Clear Attachments",
343344 workspaceRoot: "/tmp/project-clear-attachments",
344345 defaultModelSelection: null,
@@ -350,17 +351,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
350351
351352 yield* eventStore.append({
352353 type: "thread.created",
353− eventId: EventId.makeUnsafe("evt-clear-attachments-2"),
354+ eventId: EventId.make("evt-clear-attachments-2"),
354355 aggregateKind: "thread",
355− aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),
356+ aggregateId: ThreadId.make("thread-clear-attachments"),
356357 occurredAt: now,
357− commandId: CommandId.makeUnsafe("cmd-clear-attachments-2"),
358+ commandId: CommandId.make("cmd-clear-attachments-2"),
358359 causationEventId: null,
359− correlationId: CommandId.makeUnsafe("cmd-clear-attachments-2"),
360+ correlationId: CommandId.make("cmd-clear-attachments-2"),
360361 metadata: {},
361362 payload: {
362− threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
363− projectId: ProjectId.makeUnsafe("project-clear-attachments"),
363+ threadId: ThreadId.make("thread-clear-attachments"),
364+ projectId: ProjectId.make("project-clear-attachments"),
364365 title: "Thread Clear Attachments",
365366 modelSelection: {
366367 provider: "codex",
@@ -376,17 +377,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
376377
377378 yield* eventStore.append({
378379 type: "thread.message-sent",
379− eventId: EventId.makeUnsafe("evt-clear-attachments-3"),
380+ eventId: EventId.make("evt-clear-attachments-3"),
380381 aggregateKind: "thread",
381− aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),
382+ aggregateId: ThreadId.make("thread-clear-attachments"),
382383 occurredAt: now,
383− commandId: CommandId.makeUnsafe("cmd-clear-attachments-3"),
384+ commandId: CommandId.make("cmd-clear-attachments-3"),
384385 causationEventId: null,
385− correlationId: CommandId.makeUnsafe("cmd-clear-attachments-3"),
386+ correlationId: CommandId.make("cmd-clear-attachments-3"),
386387 metadata: {},
387388 payload: {
388− threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
389− messageId: MessageId.makeUnsafe("message-clear-attachments"),
389+ threadId: ThreadId.make("thread-clear-attachments"),
390+ messageId: MessageId.make("message-clear-attachments"),
390391 role: "user",
391392 text: "Has attachments",
392393 attachments: [
@@ -407,17 +408,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 14 unmodified lines
407408
408409 yield* eventStore.append({
409410 type: "thread.message-sent",
410− eventId: EventId.makeUnsafe("evt-clear-attachments-4"),
411+ eventId: EventId.make("evt-clear-attachments-4"),
411412 aggregateKind: "thread",
412− aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),
413+ aggregateId: ThreadId.make("thread-clear-attachments"),
413414 occurredAt: later,
414− commandId: CommandId.makeUnsafe("cmd-clear-attachments-4"),
415+ commandId: CommandId.make("cmd-clear-attachments-4"),
415416 causationEventId: null,
416− correlationId: CommandId.makeUnsafe("cmd-clear-attachments-4"),
417+ correlationId: CommandId.make("cmd-clear-attachments-4"),
417418 metadata: {},
418419 payload: {
419− threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
420− messageId: MessageId.makeUnsafe("message-clear-attachments"),
420+ threadId: ThreadId.make("thread-clear-attachments"),
421+ messageId: MessageId.make("message-clear-attachments"),
421422 role: "user",
422423 text: "",
423424 attachments: [],
@@ -457,16 +458,16 @@ it.layer( · 33 unmodified lines
457458
458459 yield* eventStore.append({
459460 type: "project.created",
460− eventId: EventId.makeUnsafe("evt-overwrite-1"),
461+ eventId: EventId.make("evt-overwrite-1"),
461462 aggregateKind: "project",
462− aggregateId: ProjectId.makeUnsafe("project-overwrite"),
463+ aggregateId: ProjectId.make("project-overwrite"),
463464 occurredAt: now,
464− commandId: CommandId.makeUnsafe("cmd-overwrite-1"),
465+ commandId: CommandId.make("cmd-overwrite-1"),
465466 causationEventId: null,
466− correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),
467+ correlationId: CommandId.make("cmd-overwrite-1"),
467468 metadata: {},
468469 payload: {
469− projectId: ProjectId.makeUnsafe("project-overwrite"),
470+ projectId: ProjectId.make("project-overwrite"),
470471 title: "Project Overwrite",
471472 workspaceRoot: "/tmp/project-overwrite",
472473 defaultModelSelection: null,
@@ -478,17 +479,17 @@ it.layer( · 5 unmodified lines
478479
479480 yield* eventStore.append({
480481 type: "thread.created",
481− eventId: EventId.makeUnsafe("evt-overwrite-2"),
482+ eventId: EventId.make("evt-overwrite-2"),
482483 aggregateKind: "thread",
483− aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
484+ aggregateId: ThreadId.make("thread-overwrite"),
484485 occurredAt: now,
485− commandId: CommandId.makeUnsafe("cmd-overwrite-2"),
486+ commandId: CommandId.make("cmd-overwrite-2"),
486487 causationEventId: null,
487− correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),
488+ correlationId: CommandId.make("cmd-overwrite-2"),
488489 metadata: {},
489490 payload: {
490− threadId: ThreadId.makeUnsafe("thread-overwrite"),
491− projectId: ProjectId.makeUnsafe("project-overwrite"),
491+ threadId: ThreadId.make("thread-overwrite"),
492+ projectId: ProjectId.make("project-overwrite"),
492493 title: "Thread Overwrite",
493494 modelSelection: {
494495 provider: "codex",
@@ -504,17 +505,17 @@ it.layer( · 9 unmodified lines
504505
505506 yield* eventStore.append({
506507 type: "thread.message-sent",
507− eventId: EventId.makeUnsafe("evt-overwrite-3"),
508+ eventId: EventId.make("evt-overwrite-3"),
508509 aggregateKind: "thread",
509− aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
510+ aggregateId: ThreadId.make("thread-overwrite"),
510511 occurredAt: now,
511− commandId: CommandId.makeUnsafe("cmd-overwrite-3"),
512+ commandId: CommandId.make("cmd-overwrite-3"),
512513 causationEventId: null,
513− correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),
514+ correlationId: CommandId.make("cmd-overwrite-3"),
514515 metadata: {},
515516 payload: {
516− threadId: ThreadId.makeUnsafe("thread-overwrite"),
517− messageId: MessageId.makeUnsafe("message-overwrite"),
517+ threadId: ThreadId.make("thread-overwrite"),
518+ messageId: MessageId.make("message-overwrite"),
518519 role: "user",
519520 text: "first image",
520521 attachments: [
@@ -535,17 +536,17 @@ it.layer( · 14 unmodified lines
535536
536537 yield* eventStore.append({
537538 type: "thread.message-sent",
538− eventId: EventId.makeUnsafe("evt-overwrite-4"),
539+ eventId: EventId.make("evt-overwrite-4"),
539540 aggregateKind: "thread",
540− aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
541+ aggregateId: ThreadId.make("thread-overwrite"),
541542 occurredAt: later,
542− commandId: CommandId.makeUnsafe("cmd-overwrite-4"),
543+ commandId: CommandId.make("cmd-overwrite-4"),
543544 causationEventId: null,
544− correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),
545+ correlationId: CommandId.make("cmd-overwrite-4"),
545546 metadata: {},
546547 payload: {
547− threadId: ThreadId.makeUnsafe("thread-overwrite"),
548− messageId: MessageId.makeUnsafe("message-overwrite"),
548+ threadId: ThreadId.make("thread-overwrite"),
549+ messageId: MessageId.make("message-overwrite"),
549550 role: "user",
550551 text: "",
551552 attachments: [
@@ -605,16 +606,16 @@ it.layer( · 53 unmodified lines
605606
606607 yield* appendAndProject({
607608 type: "project.created",
608− eventId: EventId.makeUnsafe("evt-rollback-1"),
609+ eventId: EventId.make("evt-rollback-1"),
609610 aggregateKind: "project",
610− aggregateId: ProjectId.makeUnsafe("project-rollback"),
611+ aggregateId: ProjectId.make("project-rollback"),
611612 occurredAt: now,
612− commandId: CommandId.makeUnsafe("cmd-rollback-1"),
613+ commandId: CommandId.make("cmd-rollback-1"),
613614 causationEventId: null,
614− correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"),
615+ correlationId: CorrelationId.make("cmd-rollback-1"),
615616 metadata: {},
616617 payload: {
617− projectId: ProjectId.makeUnsafe("project-rollback"),
618+ projectId: ProjectId.make("project-rollback"),
618619 title: "Project Rollback",
619620 workspaceRoot: "/tmp/project-rollback",
620621 defaultModelSelection: null,
@@ -626,17 +627,17 @@ it.layer( · 5 unmodified lines
626627
627628 yield* appendAndProject({
628629 type: "thread.created",
629− eventId: EventId.makeUnsafe("evt-rollback-2"),
630+ eventId: EventId.make("evt-rollback-2"),
630631 aggregateKind: "thread",
631− aggregateId: ThreadId.makeUnsafe("thread-rollback"),
632+ aggregateId: ThreadId.make("thread-rollback"),
632633 occurredAt: now,
633− commandId: CommandId.makeUnsafe("cmd-rollback-2"),
634+ commandId: CommandId.make("cmd-rollback-2"),
634635 causationEventId: null,
635− correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"),
636+ correlationId: CorrelationId.make("cmd-rollback-2"),
636637 metadata: {},
637638 payload: {
638− threadId: ThreadId.makeUnsafe("thread-rollback"),
639− projectId: ProjectId.makeUnsafe("project-rollback"),
639+ threadId: ThreadId.make("thread-rollback"),
640+ projectId: ProjectId.make("project-rollback"),
640641 title: "Thread Rollback",
641642 modelSelection: {
642643 provider: "codex",
@@ -662,17 +663,17 @@ it.layer( · 19 unmodified lines
662663 const result = yield* Effect.result(
663664 appendAndProject({
664665 type: "thread.message-sent",
665− eventId: EventId.makeUnsafe("evt-rollback-3"),
666+ eventId: EventId.make("evt-rollback-3"),
666667 aggregateKind: "thread",
667− aggregateId: ThreadId.makeUnsafe("thread-rollback"),
668+ aggregateId: ThreadId.make("thread-rollback"),
668669 occurredAt: now,
669− commandId: CommandId.makeUnsafe("cmd-rollback-3"),
670+ commandId: CommandId.make("cmd-rollback-3"),
670671 causationEventId: null,
671− correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"),
672+ correlationId: CorrelationId.make("cmd-rollback-3"),
672673 metadata: {},
673674 payload: {
674− threadId: ThreadId.makeUnsafe("thread-rollback"),
675− messageId: MessageId.makeUnsafe("message-rollback"),
675+ threadId: ThreadId.make("thread-rollback"),
676+ messageId: MessageId.make("message-rollback"),
676677 role: "user",
677678 text: "Rollback me",
678679 attachments: [
@@ -721,7 +722,7 @@ it.layer( · 42 unmodified lines
721722 const eventStore = yield* OrchestrationEventStore;
722723 const { attachmentsDir } = yield* ServerConfig;
723724 const now = new Date().toISOString();
724− const threadId = ThreadId.makeUnsafe("Thread Revert.Files");
725+ const threadId = ThreadId.make("Thread Revert.Files");
725726 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
726727 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
727728 const otherThreadAttachmentId =
@@ -734,16 +735,16 @@ it.layer( · 6 unmodified lines
734735
735736 yield* appendAndProject({
736737 type: "project.created",
737− eventId: EventId.makeUnsafe("evt-revert-files-1"),
738+ eventId: EventId.make("evt-revert-files-1"),
738739 aggregateKind: "project",
739− aggregateId: ProjectId.makeUnsafe("project-revert-files"),
740+ aggregateId: ProjectId.make("project-revert-files"),
740741 occurredAt: now,
741− commandId: CommandId.makeUnsafe("cmd-revert-files-1"),
742+ commandId: CommandId.make("cmd-revert-files-1"),
742743 causationEventId: null,
743− correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),
744+ correlationId: CorrelationId.make("cmd-revert-files-1"),
744745 metadata: {},
745746 payload: {
746− projectId: ProjectId.makeUnsafe("project-revert-files"),
747+ projectId: ProjectId.make("project-revert-files"),
747748 title: "Project Revert Files",
748749 workspaceRoot: "/tmp/project-revert-files",
749750 defaultModelSelection: null,
@@ -755,17 +756,17 @@ it.layer( · 5 unmodified lines
755756
756757 yield* appendAndProject({
757758 type: "thread.created",
758− eventId: EventId.makeUnsafe("evt-revert-files-2"),
759+ eventId: EventId.make("evt-revert-files-2"),
759760 aggregateKind: "thread",
760761 aggregateId: threadId,
761762 occurredAt: now,
762− commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
763+ commandId: CommandId.make("cmd-revert-files-2"),
763764 causationEventId: null,
764− correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
765+ correlationId: CorrelationId.make("cmd-revert-files-2"),
765766 metadata: {},
766767 payload: {
767768 threadId,
768− projectId: ProjectId.makeUnsafe("project-revert-files"),
769+ projectId: ProjectId.make("project-revert-files"),
769770 title: "Thread Revert Files",
770771 modelSelection: {
771772 provider: "codex",
@@ -781,39 +782,39 @@ it.layer( · 9 unmodified lines
781782
782783 yield* appendAndProject({
783784 type: "thread.turn-diff-completed",
784− eventId: EventId.makeUnsafe("evt-revert-files-3"),
785+ eventId: EventId.make("evt-revert-files-3"),
785786 aggregateKind: "thread",
786787 aggregateId: threadId,
787788 occurredAt: now,
788− commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
789+ commandId: CommandId.make("cmd-revert-files-3"),
789790 causationEventId: null,
790− correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
791+ correlationId: CorrelationId.make("cmd-revert-files-3"),
791792 metadata: {},
792793 payload: {
793794 threadId,
794− turnId: TurnId.makeUnsafe("turn-keep"),
795+ turnId: TurnId.make("turn-keep"),
795796 checkpointTurnCount: 1,
796− checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),
797+ checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-files/turn/1"),
797798 status: "ready",
798799 files: [],
799− assistantMessageId: MessageId.makeUnsafe("message-keep"),
800+ assistantMessageId: MessageId.make("message-keep"),
800801 completedAt: now,
801802 },
802803 });
803804
804805 yield* appendAndProject({
805806 type: "thread.message-sent",
806− eventId: EventId.makeUnsafe("evt-revert-files-4"),
807+ eventId: EventId.make("evt-revert-files-4"),
807808 aggregateKind: "thread",
808809 aggregateId: threadId,
809810 occurredAt: now,
810− commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
811+ commandId: CommandId.make("cmd-revert-files-4"),
811812 causationEventId: null,
812− correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
813+ correlationId: CorrelationId.make("cmd-revert-files-4"),
813814 metadata: {},
814815 payload: {
815816 threadId,
816− messageId: MessageId.makeUnsafe("message-keep"),
817+ messageId: MessageId.make("message-keep"),
817818 role: "assistant",
818819 text: "Keep",
819820 attachments: [
@@ -825,7 +826,7 @@ it.layer( · 5 unmodified lines
825826 sizeBytes: 5,
826827 },
827828 ],
828− turnId: TurnId.makeUnsafe("turn-keep"),
829+ turnId: TurnId.make("turn-keep"),
829830 streaming: false,
830831 createdAt: now,
831832 updatedAt: now,
@@ -834,39 +835,39 @@ it.layer( · 2 unmodified lines
834835
835836 yield* appendAndProject({
836837 type: "thread.turn-diff-completed",
837− eventId: EventId.makeUnsafe("evt-revert-files-5"),
838+ eventId: EventId.make("evt-revert-files-5"),
838839 aggregateKind: "thread",
839840 aggregateId: threadId,
840841 occurredAt: now,
841− commandId: CommandId.makeUnsafe("cmd-revert-files-5"),
842+ commandId: CommandId.make("cmd-revert-files-5"),
842843 causationEventId: null,
843− correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),
844+ correlationId: CorrelationId.make("cmd-revert-files-5"),
844845 metadata: {},
845846 payload: {
846847 threadId,
847− turnId: TurnId.makeUnsafe("turn-remove"),
848+ turnId: TurnId.make("turn-remove"),
848849 checkpointTurnCount: 2,
849− checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"),
850+ checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-files/turn/2"),
850851 status: "ready",
851852 files: [],
852− assistantMessageId: MessageId.makeUnsafe("message-remove"),
853+ assistantMessageId: MessageId.make("message-remove"),
853854 completedAt: now,
854855 },
855856 });
856857
857858 yield* appendAndProject({
858859 type: "thread.message-sent",
859− eventId: EventId.makeUnsafe("evt-revert-files-6"),
860+ eventId: EventId.make("evt-revert-files-6"),
860861 aggregateKind: "thread",
861862 aggregateId: threadId,
862863 occurredAt: now,
863− commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
864+ commandId: CommandId.make("cmd-revert-files-6"),
864865 causationEventId: null,
865− correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
866+ correlationId: CorrelationId.make("cmd-revert-files-6"),
866867 metadata: {},
867868 payload: {
868869 threadId,
869− messageId: MessageId.makeUnsafe("message-remove"),
870+ messageId: MessageId.make("message-remove"),
870871 role: "assistant",
871872 text: "Remove",
872873 attachments: [
@@ -878,7 +879,7 @@ it.layer( · 5 unmodified lines
878879 sizeBytes: 5,
879880 },
880881 ],
881− turnId: TurnId.makeUnsafe("turn-remove"),
882+ turnId: TurnId.make("turn-remove"),
882883 streaming: false,
883884 createdAt: now,
884885 updatedAt: now,
@@ -898,13 +899,13 @@ it.layer( · 13 unmodified lines
898899
899900 yield* appendAndProject({
900901 type: "thread.reverted",
901− eventId: EventId.makeUnsafe("evt-revert-files-7"),
902+ eventId: EventId.make("evt-revert-files-7"),
902903 aggregateKind: "thread",
903904 aggregateId: threadId,
904905 occurredAt: now,
905− commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
906+ commandId: CommandId.make("cmd-revert-files-7"),
906907 causationEventId: null,
907− correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),
908+ correlationId: CorrelationId.make("cmd-revert-files-7"),
908909 metadata: {},
909910 payload: {
910911 threadId,
@@ -930,7 +931,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 19 unmodified lines
930931 const eventStore = yield* OrchestrationEventStore;
931932 const { attachmentsDir } = yield* ServerConfig;
932933 const now = new Date().toISOString();
933− const threadId = ThreadId.makeUnsafe("Thread Delete.Files");
934+ const threadId = ThreadId.make("Thread Delete.Files");
934935 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";
935936 const otherThreadAttachmentId =
936937 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";
@@ -942,16 +943,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 5 unmodified lines
942943
943944 yield* appendAndProject({
944945 type: "project.created",
945− eventId: EventId.makeUnsafe("evt-delete-files-1"),
946+ eventId: EventId.make("evt-delete-files-1"),
946947 aggregateKind: "project",
947− aggregateId: ProjectId.makeUnsafe("project-delete-files"),
948+ aggregateId: ProjectId.make("project-delete-files"),
948949 occurredAt: now,
949− commandId: CommandId.makeUnsafe("cmd-delete-files-1"),
950+ commandId: CommandId.make("cmd-delete-files-1"),
950951 causationEventId: null,
951− correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),
952+ correlationId: CorrelationId.make("cmd-delete-files-1"),
952953 metadata: {},
953954 payload: {
954− projectId: ProjectId.makeUnsafe("project-delete-files"),
955+ projectId: ProjectId.make("project-delete-files"),
955956 title: "Project Delete Files",
956957 workspaceRoot: "/tmp/project-delete-files",
957958 defaultModelSelection: null,
@@ -963,17 +964,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 5 unmodified lines
963964
964965 yield* appendAndProject({
965966 type: "thread.created",
966− eventId: EventId.makeUnsafe("evt-delete-files-2"),
967+ eventId: EventId.make("evt-delete-files-2"),
967968 aggregateKind: "thread",
968969 aggregateId: threadId,
969970 occurredAt: now,
970− commandId: CommandId.makeUnsafe("cmd-delete-files-2"),
971+ commandId: CommandId.make("cmd-delete-files-2"),
971972 causationEventId: null,
972− correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),
973+ correlationId: CorrelationId.make("cmd-delete-files-2"),
973974 metadata: {},
974975 payload: {
975976 threadId,
976− projectId: ProjectId.makeUnsafe("project-delete-files"),
977+ projectId: ProjectId.make("project-delete-files"),
977978 title: "Thread Delete Files",
978979 modelSelection: {
979980 provider: "codex",
@@ -989,17 +990,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 9 unmodified lines
989990
990991 yield* appendAndProject({
991992 type: "thread.message-sent",
992− eventId: EventId.makeUnsafe("evt-delete-files-3"),
993+ eventId: EventId.make("evt-delete-files-3"),
993994 aggregateKind: "thread",
994995 aggregateId: threadId,
995996 occurredAt: now,
996− commandId: CommandId.makeUnsafe("cmd-delete-files-3"),
997+ commandId: CommandId.make("cmd-delete-files-3"),
997998 causationEventId: null,
998− correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),
999+ correlationId: CorrelationId.make("cmd-delete-files-3"),
9991000 metadata: {},
10001001 payload: {
10011002 threadId,
1002− messageId: MessageId.makeUnsafe("message-delete-files"),
1003+ messageId: MessageId.make("message-delete-files"),
10031004 role: "user",
10041005 text: "Delete",
10051006 attachments: [
@@ -1031,13 +1032,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 25 unmodified lines
10311032
10321033 yield* appendAndProject({
10331034 type: "thread.deleted",
1034− eventId: EventId.makeUnsafe("evt-delete-files-4"),
1035+ eventId: EventId.make("evt-delete-files-4"),
10351036 aggregateKind: "thread",
10361037 aggregateId: threadId,
10371038 occurredAt: now,
1038− commandId: CommandId.makeUnsafe("cmd-delete-files-4"),
1039+ commandId: CommandId.make("cmd-delete-files-4"),
10391040 causationEventId: null,
1040− correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),
1041+ correlationId: CorrelationId.make("cmd-delete-files-4"),
10411042 metadata: {},
10421043 payload: {
10431044 threadId,
@@ -1071,16 +1072,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 27 unmodified lines
10711072
10721073 yield* eventStore.append({
10731074 type: "thread.deleted",
1074− eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"),
1075+ eventId: EventId.make("evt-unsafe-thread-delete"),
10751076 aggregateKind: "thread",
1076− aggregateId: ThreadId.makeUnsafe(".."),
1077+ aggregateId: ThreadId.make(".."),
10771078 occurredAt: now,
1078− commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"),
1079+ commandId: CommandId.make("cmd-unsafe-thread-delete"),
10791080 causationEventId: null,
1080− correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"),
1081+ correlationId: CorrelationId.make("cmd-unsafe-thread-delete"),
10811082 metadata: {},
10821083 payload: {
1083− threadId: ThreadId.makeUnsafe(".."),
1084+ threadId: ThreadId.make(".."),
10841085 deletedAt: now,
10851086 },
10861087 });
@@ -1105,16 +1106,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 18 unmodified lines
11051106
11061107 yield* eventStore.append({
11071108 type: "project.created",
1108− eventId: EventId.makeUnsafe("evt-a1"),
1109+ eventId: EventId.make("evt-a1"),
11091110 aggregateKind: "project",
1110− aggregateId: ProjectId.makeUnsafe("project-a"),
1111+ aggregateId: ProjectId.make("project-a"),
11111112 occurredAt: now,
1112− commandId: CommandId.makeUnsafe("cmd-a1"),
1113+ commandId: CommandId.make("cmd-a1"),
11131114 causationEventId: null,
1114− correlationId: CorrelationId.makeUnsafe("cmd-a1"),
1115+ correlationId: CorrelationId.make("cmd-a1"),
11151116 metadata: {},
11161117 payload: {
1117− projectId: ProjectId.makeUnsafe("project-a"),
1118+ projectId: ProjectId.make("project-a"),
11181119 title: "Project A",
11191120 workspaceRoot: "/tmp/project-a",
11201121 defaultModelSelection: null,
@@ -1126,17 +1127,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
11261127
11271128 yield* eventStore.append({
11281129 type: "thread.created",
1129− eventId: EventId.makeUnsafe("evt-a2"),
1130+ eventId: EventId.make("evt-a2"),
11301131 aggregateKind: "thread",
1131− aggregateId: ThreadId.makeUnsafe("thread-a"),
1132+ aggregateId: ThreadId.make("thread-a"),
11321133 occurredAt: now,
1133− commandId: CommandId.makeUnsafe("cmd-a2"),
1134+ commandId: CommandId.make("cmd-a2"),
11341135 causationEventId: null,
1135− correlationId: CorrelationId.makeUnsafe("cmd-a2"),
1136+ correlationId: CorrelationId.make("cmd-a2"),
11361137 metadata: {},
11371138 payload: {
1138− threadId: ThreadId.makeUnsafe("thread-a"),
1139− projectId: ProjectId.makeUnsafe("project-a"),
1139+ threadId: ThreadId.make("thread-a"),
1140+ projectId: ProjectId.make("project-a"),
11401141 title: "Thread A",
11411142 modelSelection: {
11421143 provider: "codex",
@@ -1152,17 +1153,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
11521153
11531154 yield* eventStore.append({
11541155 type: "thread.message-sent",
1155− eventId: EventId.makeUnsafe("evt-a3"),
1156+ eventId: EventId.make("evt-a3"),
11561157 aggregateKind: "thread",
1157− aggregateId: ThreadId.makeUnsafe("thread-a"),
1158+ aggregateId: ThreadId.make("thread-a"),
11581159 occurredAt: now,
1159− commandId: CommandId.makeUnsafe("cmd-a3"),
1160+ commandId: CommandId.make("cmd-a3"),
11601161 causationEventId: null,
1161− correlationId: CorrelationId.makeUnsafe("cmd-a3"),
1162+ correlationId: CorrelationId.make("cmd-a3"),
11621163 metadata: {},
11631164 payload: {
1164− threadId: ThreadId.makeUnsafe("thread-a"),
1165− messageId: MessageId.makeUnsafe("message-a"),
1165+ threadId: ThreadId.make("thread-a"),
1166+ messageId: MessageId.make("message-a"),
11661167 role: "assistant",
11671168 text: "hello",
11681169 turnId: null,
@@ -1176,17 +1177,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 7 unmodified lines
11761177
11771178 yield* eventStore.append({
11781179 type: "thread.message-sent",
1179− eventId: EventId.makeUnsafe("evt-a4"),
1180+ eventId: EventId.make("evt-a4"),
11801181 aggregateKind: "thread",
1181− aggregateId: ThreadId.makeUnsafe("thread-a"),
1182+ aggregateId: ThreadId.make("thread-a"),
11821183 occurredAt: now,
1183− commandId: CommandId.makeUnsafe("cmd-a4"),
1184+ commandId: CommandId.make("cmd-a4"),
11841185 causationEventId: null,
1185− correlationId: CorrelationId.makeUnsafe("cmd-a4"),
1186+ correlationId: CorrelationId.make("cmd-a4"),
11861187 metadata: {},
11871188 payload: {
1188− threadId: ThreadId.makeUnsafe("thread-a"),
1189− messageId: MessageId.makeUnsafe("message-a"),
1189+ threadId: ThreadId.make("thread-a"),
1190+ messageId: MessageId.make("message-a"),
11901191 role: "assistant",
11911192 text: " world",
11921193 turnId: null,
@@ -1232,16 +1233,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 39 unmodified lines
12321233
12331234 yield* eventStore.append({
12341235 type: "project.created",
1235− eventId: EventId.makeUnsafe("evt-empty-1"),
1236+ eventId: EventId.make("evt-empty-1"),
12361237 aggregateKind: "project",
1237− aggregateId: ProjectId.makeUnsafe("project-empty"),
1238+ aggregateId: ProjectId.make("project-empty"),
12381239 occurredAt: now,
1239− commandId: CommandId.makeUnsafe("cmd-empty-1"),
1240+ commandId: CommandId.make("cmd-empty-1"),
12401241 causationEventId: null,
1241− correlationId: CorrelationId.makeUnsafe("cmd-empty-1"),
1242+ correlationId: CorrelationId.make("cmd-empty-1"),
12421243 metadata: {},
12431244 payload: {
1244− projectId: ProjectId.makeUnsafe("project-empty"),
1245+ projectId: ProjectId.make("project-empty"),
12451246 title: "Project Empty",
12461247 workspaceRoot: "/tmp/project-empty",
12471248 defaultModelSelection: null,
@@ -1253,17 +1254,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
12531254
12541255 yield* eventStore.append({
12551256 type: "thread.created",
1256− eventId: EventId.makeUnsafe("evt-empty-2"),
1257+ eventId: EventId.make("evt-empty-2"),
12571258 aggregateKind: "thread",
1258− aggregateId: ThreadId.makeUnsafe("thread-empty"),
1259+ aggregateId: ThreadId.make("thread-empty"),
12591260 occurredAt: now,
1260− commandId: CommandId.makeUnsafe("cmd-empty-2"),
1261+ commandId: CommandId.make("cmd-empty-2"),
12611262 causationEventId: null,
1262− correlationId: CorrelationId.makeUnsafe("cmd-empty-2"),
1263+ correlationId: CorrelationId.make("cmd-empty-2"),
12631264 metadata: {},
12641265 payload: {
1265− threadId: ThreadId.makeUnsafe("thread-empty"),
1266− projectId: ProjectId.makeUnsafe("project-empty"),
1266+ threadId: ThreadId.make("thread-empty"),
1267+ projectId: ProjectId.make("project-empty"),
12671268 title: "Thread Empty",
12681269 modelSelection: {
12691270 provider: "codex",
@@ -1279,17 +1280,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
12791280
12801281 yield* eventStore.append({
12811282 type: "thread.message-sent",
1282− eventId: EventId.makeUnsafe("evt-empty-3"),
1283+ eventId: EventId.make("evt-empty-3"),
12831284 aggregateKind: "thread",
1284− aggregateId: ThreadId.makeUnsafe("thread-empty"),
1285+ aggregateId: ThreadId.make("thread-empty"),
12851286 occurredAt: now,
1286− commandId: CommandId.makeUnsafe("cmd-empty-3"),
1287+ commandId: CommandId.make("cmd-empty-3"),
12871288 causationEventId: null,
1288− correlationId: CorrelationId.makeUnsafe("cmd-empty-3"),
1289+ correlationId: CorrelationId.make("cmd-empty-3"),
12891290 metadata: {},
12901291 payload: {
1291− threadId: ThreadId.makeUnsafe("thread-empty"),
1292− messageId: MessageId.makeUnsafe("assistant-empty"),
1292+ threadId: ThreadId.make("thread-empty"),
1293+ messageId: MessageId.make("assistant-empty"),
12931294 role: "assistant",
12941295 text: "Hello",
12951296 turnId: null,
@@ -1301,17 +1302,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
13011302
13021303 yield* eventStore.append({
13031304 type: "thread.message-sent",
1304− eventId: EventId.makeUnsafe("evt-empty-4"),
1305+ eventId: EventId.make("evt-empty-4"),
13051306 aggregateKind: "thread",
1306− aggregateId: ThreadId.makeUnsafe("thread-empty"),
1307+ aggregateId: ThreadId.make("thread-empty"),
13071308 occurredAt: now,
1308− commandId: CommandId.makeUnsafe("cmd-empty-4"),
1309+ commandId: CommandId.make("cmd-empty-4"),
13091310 causationEventId: null,
1310− correlationId: CorrelationId.makeUnsafe("cmd-empty-4"),
1311+ correlationId: CorrelationId.make("cmd-empty-4"),
13111312 metadata: {},
13121313 payload: {
1313− threadId: ThreadId.makeUnsafe("thread-empty"),
1314− messageId: MessageId.makeUnsafe("assistant-empty"),
1314+ threadId: ThreadId.make("thread-empty"),
1315+ messageId: MessageId.make("assistant-empty"),
13151316 role: "assistant",
13161317 text: " world",
13171318 turnId: null,
@@ -1323,17 +1324,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
13231324
13241325 yield* eventStore.append({
13251326 type: "thread.message-sent",
1326− eventId: EventId.makeUnsafe("evt-empty-5"),
1327+ eventId: EventId.make("evt-empty-5"),
13271328 aggregateKind: "thread",
1328− aggregateId: ThreadId.makeUnsafe("thread-empty"),
1329+ aggregateId: ThreadId.make("thread-empty"),
13291330 occurredAt: now,
1330− commandId: CommandId.makeUnsafe("cmd-empty-5"),
1331+ commandId: CommandId.make("cmd-empty-5"),
13311332 causationEventId: null,
1332− correlationId: CorrelationId.makeUnsafe("cmd-empty-5"),
1333+ correlationId: CorrelationId.make("cmd-empty-5"),
13331334 metadata: {},
13341335 payload: {
1335− threadId: ThreadId.makeUnsafe("thread-empty"),
1336− messageId: MessageId.makeUnsafe("assistant-empty"),
1336+ threadId: ThreadId.make("thread-empty"),
1337+ messageId: MessageId.make("assistant-empty"),
13371338 role: "assistant",
13381339 text: "",
13391340 turnId: null,
@@ -1372,16 +1373,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 32 unmodified lines
13721373
13731374 yield* appendAndProject({
13741375 type: "project.created",
1375− eventId: EventId.makeUnsafe("evt-conflict-1"),
1376+ eventId: EventId.make("evt-conflict-1"),
13761377 aggregateKind: "project",
1377− aggregateId: ProjectId.makeUnsafe("project-conflict"),
1378+ aggregateId: ProjectId.make("project-conflict"),
13781379 occurredAt: "2026-02-26T13:00:00.000Z",
1379− commandId: CommandId.makeUnsafe("cmd-conflict-1"),
1380+ commandId: CommandId.make("cmd-conflict-1"),
13801381 causationEventId: null,
1381− correlationId: CorrelationId.makeUnsafe("cmd-conflict-1"),
1382+ correlationId: CorrelationId.make("cmd-conflict-1"),
13821383 metadata: {},
13831384 payload: {
1384− projectId: ProjectId.makeUnsafe("project-conflict"),
1385+ projectId: ProjectId.make("project-conflict"),
13851386 title: "Project Conflict",
13861387 workspaceRoot: "/tmp/project-conflict",
13871388 defaultModelSelection: null,
@@ -1393,17 +1394,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
13931394
13941395 yield* appendAndProject({
13951396 type: "thread.created",
1396− eventId: EventId.makeUnsafe("evt-conflict-2"),
1397+ eventId: EventId.make("evt-conflict-2"),
13971398 aggregateKind: "thread",
1398− aggregateId: ThreadId.makeUnsafe("thread-conflict"),
1399+ aggregateId: ThreadId.make("thread-conflict"),
13991400 occurredAt: "2026-02-26T13:00:01.000Z",
1400− commandId: CommandId.makeUnsafe("cmd-conflict-2"),
1401+ commandId: CommandId.make("cmd-conflict-2"),
14011402 causationEventId: null,
1402− correlationId: CorrelationId.makeUnsafe("cmd-conflict-2"),
1403+ correlationId: CorrelationId.make("cmd-conflict-2"),
14031404 metadata: {},
14041405 payload: {
1405− threadId: ThreadId.makeUnsafe("thread-conflict"),
1406− projectId: ProjectId.makeUnsafe("project-conflict"),
1406+ threadId: ThreadId.make("thread-conflict"),
1407+ projectId: ProjectId.make("project-conflict"),
14071408 title: "Thread Conflict",
14081409 modelSelection: {
14091410 provider: "codex",
@@ -1419,37 +1420,37 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
14191420
14201421 yield* appendAndProject({
14211422 type: "thread.turn-interrupt-requested",
1422− eventId: EventId.makeUnsafe("evt-conflict-3"),
1423+ eventId: EventId.make("evt-conflict-3"),
14231424 aggregateKind: "thread",
1424− aggregateId: ThreadId.makeUnsafe("thread-conflict"),
1425+ aggregateId: ThreadId.make("thread-conflict"),
14251426 occurredAt: "2026-02-26T13:00:02.000Z",
1426− commandId: CommandId.makeUnsafe("cmd-conflict-3"),
1427+ commandId: CommandId.make("cmd-conflict-3"),
14271428 causationEventId: null,
1428− correlationId: CorrelationId.makeUnsafe("cmd-conflict-3"),
1429+ correlationId: CorrelationId.make("cmd-conflict-3"),
14291430 metadata: {},
14301431 payload: {
1431− threadId: ThreadId.makeUnsafe("thread-conflict"),
1432− turnId: TurnId.makeUnsafe("turn-interrupted"),
1432+ threadId: ThreadId.make("thread-conflict"),
1433+ turnId: TurnId.make("turn-interrupted"),
14331434 createdAt: "2026-02-26T13:00:02.000Z",
14341435 },
14351436 });
14361437
14371438 yield* appendAndProject({
14381439 type: "thread.message-sent",
1439− eventId: EventId.makeUnsafe("evt-conflict-4"),
1440+ eventId: EventId.make("evt-conflict-4"),
14401441 aggregateKind: "thread",
1441− aggregateId: ThreadId.makeUnsafe("thread-conflict"),
1442+ aggregateId: ThreadId.make("thread-conflict"),
14421443 occurredAt: "2026-02-26T13:00:03.000Z",
1443− commandId: CommandId.makeUnsafe("cmd-conflict-4"),
1444+ commandId: CommandId.make("cmd-conflict-4"),
14441445 causationEventId: null,
1445− correlationId: CorrelationId.makeUnsafe("cmd-conflict-4"),
1446+ correlationId: CorrelationId.make("cmd-conflict-4"),
14461447 metadata: {},
14471448 payload: {
1448− threadId: ThreadId.makeUnsafe("thread-conflict"),
1449− messageId: MessageId.makeUnsafe("assistant-conflict"),
1449+ threadId: ThreadId.make("thread-conflict"),
1450+ messageId: MessageId.make("assistant-conflict"),
14501451 role: "assistant",
14511452 text: "done",
1452− turnId: TurnId.makeUnsafe("turn-completed"),
1453+ turnId: TurnId.make("turn-completed"),
14531454 streaming: false,
14541455 createdAt: "2026-02-26T13:00:03.000Z",
14551456 updatedAt: "2026-02-26T13:00:03.000Z",
@@ -1458,22 +1459,22 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines
14581459
14591460 yield* appendAndProject({
14601461 type: "thread.turn-diff-completed",
1461− eventId: EventId.makeUnsafe("evt-conflict-5"),
1462+ eventId: EventId.make("evt-conflict-5"),
14621463 aggregateKind: "thread",
1463− aggregateId: ThreadId.makeUnsafe("thread-conflict"),
1464+ aggregateId: ThreadId.make("thread-conflict"),
14641465 occurredAt: "2026-02-26T13:00:04.000Z",
1465− commandId: CommandId.makeUnsafe("cmd-conflict-5"),
1466+ commandId: CommandId.make("cmd-conflict-5"),
14661467 causationEventId: null,
1467− correlationId: CorrelationId.makeUnsafe("cmd-conflict-5"),
1468+ correlationId: CorrelationId.make("cmd-conflict-5"),
14681469 metadata: {},
14691470 payload: {
1470− threadId: ThreadId.makeUnsafe("thread-conflict"),
1471− turnId: TurnId.makeUnsafe("turn-completed"),
1471+ threadId: ThreadId.make("thread-conflict"),
1472+ turnId: TurnId.make("turn-completed"),
14721473 checkpointTurnCount: 1,
1473− checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-conflict/turn/1"),
1474+ checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-conflict/turn/1"),
14741475 status: "ready",
14751476 files: [],
1476− assistantMessageId: MessageId.makeUnsafe("assistant-conflict"),
1477+ assistantMessageId: MessageId.make("assistant-conflict"),
14771478 completedAt: "2026-02-26T13:00:04.000Z",
14781479 },
14791480 });
@@ -1504,6 +1505,329 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 24 unmodified lines
15041505 }),
15051506 );
15061507
1508+ it.effect("clears stale pending approvals from projected shell summaries", () =>
1509+ Effect.gen(function* () {
1510+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
1511+ const eventStore = yield* OrchestrationEventStore;
1512+ const sql = yield* SqlClient.SqlClient;
1513+ const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1514+ eventStore
1515+ .append(event)
1516+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1517+
1518+ yield* appendAndProject({
1519+ type: "project.created",
1520+ eventId: EventId.make("evt-stale-approval-1"),
1521+ aggregateKind: "project",
1522+ aggregateId: ProjectId.make("project-stale-approval"),
1523+ occurredAt: "2026-02-26T12:30:00.000Z",
1524+ commandId: CommandId.make("cmd-stale-approval-1"),
1525+ causationEventId: null,
1526+ correlationId: CorrelationId.make("cmd-stale-approval-1"),
1527+ metadata: {},
1528+ payload: {
1529+ projectId: ProjectId.make("project-stale-approval"),
1530+ title: "Project Stale Approval",
1531+ workspaceRoot: "/tmp/project-stale-approval",
1532+ defaultModelSelection: null,
1533+ scripts: [],
1534+ createdAt: "2026-02-26T12:30:00.000Z",
1535+ updatedAt: "2026-02-26T12:30:00.000Z",
1536+ },
1537+ });
1538+
1539+ yield* appendAndProject({
1540+ type: "thread.created",
1541+ eventId: EventId.make("evt-stale-approval-2"),
1542+ aggregateKind: "thread",
1543+ aggregateId: ThreadId.make("thread-stale-approval"),
1544+ occurredAt: "2026-02-26T12:30:01.000Z",
1545+ commandId: CommandId.make("cmd-stale-approval-2"),
1546+ causationEventId: null,
1547+ correlationId: CorrelationId.make("cmd-stale-approval-2"),
1548+ metadata: {},
1549+ payload: {
1550+ threadId: ThreadId.make("thread-stale-approval"),
1551+ projectId: ProjectId.make("project-stale-approval"),
1552+ title: "Thread Stale Approval",
1553+ modelSelection: {
1554+ provider: "codex",
1555+ model: "gpt-5-codex",
1556+ },
1557+ runtimeMode: "approval-required",
1558+ interactionMode: "default",
1559+ branch: null,
1560+ worktreePath: null,
1561+ createdAt: "2026-02-26T12:30:01.000Z",
1562+ updatedAt: "2026-02-26T12:30:01.000Z",
1563+ },
1564+ });
1565+
1566+ yield* appendAndProject({
1567+ type: "thread.activity-appended",
1568+ eventId: EventId.make("evt-stale-approval-3"),
1569+ aggregateKind: "thread",
1570+ aggregateId: ThreadId.make("thread-stale-approval"),
1571+ occurredAt: "2026-02-26T12:30:02.000Z",
1572+ commandId: CommandId.make("cmd-stale-approval-3"),
1573+ causationEventId: null,
1574+ correlationId: CorrelationId.make("cmd-stale-approval-3"),
1575+ metadata: {},
1576+ payload: {
1577+ threadId: ThreadId.make("thread-stale-approval"),
1578+ activity: {
1579+ id: EventId.make("activity-stale-approval-requested"),
1580+ tone: "approval",
1581+ kind: "approval.requested",
1582+ summary: "Command approval requested",
1583+ payload: {
1584+ requestId: "approval-request-stale-1",
1585+ requestKind: "command",
1586+ },
1587+ turnId: null,
1588+ createdAt: "2026-02-26T12:30:02.000Z",
1589+ },
1590+ },
1591+ });
1592+
1593+ yield* appendAndProject({
1594+ type: "thread.activity-appended",
1595+ eventId: EventId.make("evt-stale-approval-4"),
1596+ aggregateKind: "thread",
1597+ aggregateId: ThreadId.make("thread-stale-approval"),
1598+ occurredAt: "2026-02-26T12:30:03.000Z",
1599+ commandId: CommandId.make("cmd-stale-approval-4"),
1600+ causationEventId: null,
1601+ correlationId: CorrelationId.make("cmd-stale-approval-4"),
1602+ metadata: {},
1603+ payload: {
1604+ threadId: ThreadId.make("thread-stale-approval"),
1605+ activity: {
1606+ id: EventId.make("activity-stale-approval-failed"),
1607+ tone: "error",
1608+ kind: "provider.approval.respond.failed",
1609+ summary: "Provider approval response failed",
1610+ payload: {
1611+ requestId: "approval-request-stale-1",
1612+ detail: "Unknown pending permission request: approval-request-stale-1",
1613+ },
1614+ turnId: null,
1615+ createdAt: "2026-02-26T12:30:03.000Z",
1616+ },
1617+ },
1618+ });
1619+
1620+ const approvalRows = yield* sql<{
1621+ readonly requestId: string;
1622+ readonly status: string;
1623+ readonly resolvedAt: string | null;
1624+ }>`
1625+ SELECT
1626+ request_id AS "requestId",
1627+ status,
1628+ resolved_at AS "resolvedAt"
1629+ FROM projection_pending_approvals
1630+ WHERE request_id = 'approval-request-stale-1'
1631+ `;
1632+ assert.deepEqual(approvalRows, [
1633+ {
1634+ requestId: "approval-request-stale-1",
1635+ status: "resolved",
1636+ resolvedAt: "2026-02-26T12:30:03.000Z",
1637+ },
1638+ ]);
1639+
1640+ const threadRows = yield* sql<{
1641+ readonly pendingApprovalCount: number;
1642+ }>`
1643+ SELECT pending_approval_count AS "pendingApprovalCount"
1644+ FROM projection_threads
1645+ WHERE thread_id = 'thread-stale-approval'
1646+ `;
1647+ assert.deepEqual(threadRows, [{ pendingApprovalCount: 0 }]);
1648+ }),
1649+ );
1650+
1651+ it.effect("ignores non-stale provider approval response failures", () =>
1652+ Effect.gen(function* () {
1653+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
1654+ const eventStore = yield* OrchestrationEventStore;
1655+ const sql = yield* SqlClient.SqlClient;
1656+ const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1657+ eventStore
1658+ .append(event)
1659+ .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1660+
1661+ yield* appendAndProject({
1662+ type: "project.created",
1663+ eventId: EventId.make("evt-nonstale-approval-1"),
1664+ aggregateKind: "project",
1665+ aggregateId: ProjectId.make("project-nonstale-approval"),
1666+ occurredAt: "2026-02-26T12:45:00.000Z",
1667+ commandId: CommandId.make("cmd-nonstale-approval-1"),
1668+ causationEventId: null,
1669+ correlationId: CorrelationId.make("cmd-nonstale-approval-1"),
1670+ metadata: {},
1671+ payload: {
1672+ projectId: ProjectId.make("project-nonstale-approval"),
1673+ title: "Project Non-Stale Approval",
1674+ workspaceRoot: "/tmp/project-nonstale-approval",
1675+ defaultModelSelection: null,
1676+ scripts: [],
1677+ createdAt: "2026-02-26T12:45:00.000Z",
1678+ updatedAt: "2026-02-26T12:45:00.000Z",
1679+ },
1680+ });
1681+
1682+ yield* appendAndProject({
1683+ type: "thread.created",
1684+ eventId: EventId.make("evt-nonstale-approval-2"),
1685+ aggregateKind: "thread",
1686+ aggregateId: ThreadId.make("thread-nonstale-approval"),
1687+ occurredAt: "2026-02-26T12:45:01.000Z",
1688+ commandId: CommandId.make("cmd-nonstale-approval-2"),
1689+ causationEventId: null,
1690+ correlationId: CorrelationId.make("cmd-nonstale-approval-2"),
1691+ metadata: {},
1692+ payload: {
1693+ threadId: ThreadId.make("thread-nonstale-approval"),
1694+ projectId: ProjectId.make("project-nonstale-approval"),
1695+ title: "Thread Non-Stale Approval",
1696+ modelSelection: {
1697+ provider: "codex",
1698+ model: "gpt-5-codex",
1699+ },
1700+ runtimeMode: "approval-required",
1701+ interactionMode: "default",
1702+ branch: null,
1703+ worktreePath: null,
1704+ createdAt: "2026-02-26T12:45:01.000Z",
1705+ updatedAt: "2026-02-26T12:45:01.000Z",
1706+ },
1707+ });
1708+
1709+ yield* appendAndProject({
1710+ type: "thread.activity-appended",
1711+ eventId: EventId.make("evt-nonstale-approval-3"),
1712+ aggregateKind: "thread",
1713+ aggregateId: ThreadId.make("thread-nonstale-approval"),
1714+ occurredAt: "2026-02-26T12:45:02.000Z",
1715+ commandId: CommandId.make("cmd-nonstale-approval-3"),
1716+ causationEventId: null,
1717+ correlationId: CorrelationId.make("cmd-nonstale-approval-3"),
1718+ metadata: {},
1719+ payload: {
1720+ threadId: ThreadId.make("thread-nonstale-approval"),
1721+ activity: {
1722+ id: EventId.make("activity-nonstale-approval-requested"),
1723+ tone: "approval",
1724+ kind: "approval.requested",
1725+ summary: "Command approval requested",
1726+ payload: {
1727+ requestId: "approval-request-nonstale-existing",
1728+ requestKind: "command",
1729+ },
1730+ turnId: null,
1731+ createdAt: "2026-02-26T12:45:02.000Z",
1732+ },
1733+ },
1734+ });
1735+
1736+ yield* appendAndProject({
1737+ type: "thread.activity-appended",
1738+ eventId: EventId.make("evt-nonstale-approval-4"),
1739+ aggregateKind: "thread",
1740+ aggregateId: ThreadId.make("thread-nonstale-approval"),
1741+ occurredAt: "2026-02-26T12:45:03.000Z",
1742+ commandId: CommandId.make("cmd-nonstale-approval-4"),
1743+ causationEventId: null,
1744+ correlationId: CorrelationId.make("cmd-nonstale-approval-4"),
1745+ metadata: {},
1746+ payload: {
1747+ threadId: ThreadId.make("thread-nonstale-approval"),
1748+ activity: {
1749+ id: EventId.make("activity-nonstale-approval-failed-existing"),
1750+ tone: "error",
1751+ kind: "provider.approval.respond.failed",
1752+ summary: "Provider approval response failed",
1753+ payload: {
1754+ requestId: "approval-request-nonstale-existing",
1755+ detail: "Provider timed out while responding to approval request",
1756+ },
1757+ turnId: TurnId.make("turn-nonstale-failure"),
1758+ createdAt: "2026-02-26T12:45:03.000Z",
1759+ },
1760+ },
1761+ });
1762+
1763+ yield* appendAndProject({
1764+ type: "thread.activity-appended",
1765+ eventId: EventId.make("evt-nonstale-approval-5"),
1766+ aggregateKind: "thread",
1767+ aggregateId: ThreadId.make("thread-nonstale-approval"),
1768+ occurredAt: "2026-02-26T12:45:04.000Z",
1769+ commandId: CommandId.make("cmd-nonstale-approval-5"),
1770+ causationEventId: null,
1771+ correlationId: CorrelationId.make("cmd-nonstale-approval-5"),
1772+ metadata: {},
1773+ payload: {
1774+ threadId: ThreadId.make("thread-nonstale-approval"),
1775+ activity: {
1776+ id: EventId.make("activity-nonstale-approval-failed-missing"),
1777+ tone: "error",
1778+ kind: "provider.approval.respond.failed",
1779+ summary: "Provider approval response failed",
1780+ payload: {
1781+ requestId: "approval-request-nonstale-missing",
1782+ detail: "Provider timed out while responding to approval request",
1783+ },
1784+ turnId: null,
1785+ createdAt: "2026-02-26T12:45:04.000Z",
1786+ },
1787+ },
1788+ });
1789+
1790+ const approvalRows = yield* sql<{
1791+ readonly requestId: string;
1792+ readonly status: string;
1793+ readonly turnId: string | null;
1794+ readonly createdAt: string;
1795+ readonly resolvedAt: string | null;
1796+ }>`
1797+ SELECT
1798+ request_id AS "requestId",
1799+ status,
1800+ turn_id AS "turnId",
1801+ created_at AS "createdAt",
1802+ resolved_at AS "resolvedAt"
1803+ FROM projection_pending_approvals
1804+ WHERE request_id IN (
1805+ 'approval-request-nonstale-existing',
1806+ 'approval-request-nonstale-missing'
1807+ )
1808+ ORDER BY request_id
1809+ `;
1810+ assert.deepEqual(approvalRows, [
1811+ {
1812+ requestId: "approval-request-nonstale-existing",
1813+ status: "pending",
1814+ turnId: null,
1815+ createdAt: "2026-02-26T12:45:02.000Z",
1816+ resolvedAt: null,
1817+ },
1818+ ]);
1819+
1820+ const threadRows = yield* sql<{
1821+ readonly pendingApprovalCount: number;
1822+ }>`
1823+ SELECT pending_approval_count AS "pendingApprovalCount"
1824+ FROM projection_threads
1825+ WHERE thread_id = 'thread-nonstale-approval'
1826+ `;
1827+ assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]);
1828+ }),
1829+ );
1830+
15071831 it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>
15081832 Effect.gen(function* () {
15091833 const projectionPipeline = yield* OrchestrationProjectionPipeline;
@@ -1516,16 +1840,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 6 unmodified lines
15161840
15171841 yield* appendAndProject({
15181842 type: "project.created",
1519− eventId: EventId.makeUnsafe("evt-revert-1"),
1843+ eventId: EventId.make("evt-revert-1"),
15201844 aggregateKind: "project",
1521− aggregateId: ProjectId.makeUnsafe("project-revert"),
1845+ aggregateId: ProjectId.make("project-revert"),
15221846 occurredAt: "2026-02-26T12:00:00.000Z",
1523− commandId: CommandId.makeUnsafe("cmd-revert-1"),
1847+ commandId: CommandId.make("cmd-revert-1"),
15241848 causationEventId: null,
1525− correlationId: CorrelationId.makeUnsafe("cmd-revert-1"),
1849+ correlationId: CorrelationId.make("cmd-revert-1"),
15261850 metadata: {},
15271851 payload: {
1528− projectId: ProjectId.makeUnsafe("project-revert"),
1852+ projectId: ProjectId.make("project-revert"),
15291853 title: "Project Revert",
15301854 workspaceRoot: "/tmp/project-revert",
15311855 defaultModelSelection: null,
@@ -1537,17 +1861,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
15371861
15381862 yield* appendAndProject({
15391863 type: "thread.created",
1540− eventId: EventId.makeUnsafe("evt-revert-2"),
1864+ eventId: EventId.make("evt-revert-2"),
15411865 aggregateKind: "thread",
1542− aggregateId: ThreadId.makeUnsafe("thread-revert"),
1866+ aggregateId: ThreadId.make("thread-revert"),
15431867 occurredAt: "2026-02-26T12:00:01.000Z",
1544− commandId: CommandId.makeUnsafe("cmd-revert-2"),
1868+ commandId: CommandId.make("cmd-revert-2"),
15451869 causationEventId: null,
1546− correlationId: CorrelationId.makeUnsafe("cmd-revert-2"),
1870+ correlationId: CorrelationId.make("cmd-revert-2"),
15471871 metadata: {},
15481872 payload: {
1549− threadId: ThreadId.makeUnsafe("thread-revert"),
1550− projectId: ProjectId.makeUnsafe("project-revert"),
1873+ threadId: ThreadId.make("thread-revert"),
1874+ projectId: ProjectId.make("project-revert"),
15511875 title: "Thread Revert",
15521876 modelSelection: {
15531877 provider: "codex",
@@ -1563,42 +1887,42 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
15631887
15641888 yield* appendAndProject({
15651889 type: "thread.turn-diff-completed",
1566− eventId: EventId.makeUnsafe("evt-revert-3"),
1890+ eventId: EventId.make("evt-revert-3"),
15671891 aggregateKind: "thread",
1568− aggregateId: ThreadId.makeUnsafe("thread-revert"),
1892+ aggregateId: ThreadId.make("thread-revert"),
15691893 occurredAt: "2026-02-26T12:00:02.000Z",
1570− commandId: CommandId.makeUnsafe("cmd-revert-3"),
1894+ commandId: CommandId.make("cmd-revert-3"),
15711895 causationEventId: null,
1572− correlationId: CorrelationId.makeUnsafe("cmd-revert-3"),
1896+ correlationId: CorrelationId.make("cmd-revert-3"),
15731897 metadata: {},
15741898 payload: {
1575− threadId: ThreadId.makeUnsafe("thread-revert"),
1576− turnId: TurnId.makeUnsafe("turn-1"),
1899+ threadId: ThreadId.make("thread-revert"),
1900+ turnId: TurnId.make("turn-1"),
15771901 checkpointTurnCount: 1,
1578− checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/1"),
1902+ checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert/turn/1"),
15791903 status: "ready",
15801904 files: [],
1581− assistantMessageId: MessageId.makeUnsafe("assistant-keep"),
1905+ assistantMessageId: MessageId.make("assistant-keep"),
15821906 completedAt: "2026-02-26T12:00:02.000Z",
15831907 },
15841908 });
15851909
15861910 yield* appendAndProject({
15871911 type: "thread.message-sent",
1588− eventId: EventId.makeUnsafe("evt-revert-4"),
1912+ eventId: EventId.make("evt-revert-4"),
15891913 aggregateKind: "thread",
1590− aggregateId: ThreadId.makeUnsafe("thread-revert"),
1914+ aggregateId: ThreadId.make("thread-revert"),
15911915 occurredAt: "2026-02-26T12:00:02.100Z",
1592− commandId: CommandId.makeUnsafe("cmd-revert-4"),
1916+ commandId: CommandId.make("cmd-revert-4"),
15931917 causationEventId: null,
1594− correlationId: CorrelationId.makeUnsafe("cmd-revert-4"),
1918+ correlationId: CorrelationId.make("cmd-revert-4"),
15951919 metadata: {},
15961920 payload: {
1597− threadId: ThreadId.makeUnsafe("thread-revert"),
1598− messageId: MessageId.makeUnsafe("assistant-keep"),
1921+ threadId: ThreadId.make("thread-revert"),
1922+ messageId: MessageId.make("assistant-keep"),
15991923 role: "assistant",
16001924 text: "kept",
1601− turnId: TurnId.makeUnsafe("turn-1"),
1925+ turnId: TurnId.make("turn-1"),
16021926 streaming: false,
16031927 createdAt: "2026-02-26T12:00:02.100Z",
16041928 updatedAt: "2026-02-26T12:00:02.100Z",
@@ -1607,42 +1931,42 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines
16071931
16081932 yield* appendAndProject({
16091933 type: "thread.turn-diff-completed",
1610− eventId: EventId.makeUnsafe("evt-revert-5"),
1934+ eventId: EventId.make("evt-revert-5"),
16111935 aggregateKind: "thread",
1612− aggregateId: ThreadId.makeUnsafe("thread-revert"),
1936+ aggregateId: ThreadId.make("thread-revert"),
16131937 occurredAt: "2026-02-26T12:00:03.000Z",
1614− commandId: CommandId.makeUnsafe("cmd-revert-5"),
1938+ commandId: CommandId.make("cmd-revert-5"),
16151939 causationEventId: null,
1616− correlationId: CorrelationId.makeUnsafe("cmd-revert-5"),
1940+ correlationId: CorrelationId.make("cmd-revert-5"),
16171941 metadata: {},
16181942 payload: {
1619− threadId: ThreadId.makeUnsafe("thread-revert"),
1620− turnId: TurnId.makeUnsafe("turn-2"),
1943+ threadId: ThreadId.make("thread-revert"),
1944+ turnId: TurnId.make("turn-2"),
16211945 checkpointTurnCount: 2,
1622− checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/2"),
1946+ checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert/turn/2"),
16231947 status: "ready",
16241948 files: [],
1625− assistantMessageId: MessageId.makeUnsafe("assistant-remove"),
1949+ assistantMessageId: MessageId.make("assistant-remove"),
16261950 completedAt: "2026-02-26T12:00:03.000Z",
16271951 },
16281952 });
16291953
16301954 yield* appendAndProject({
16311955 type: "thread.message-sent",
1632− eventId: EventId.makeUnsafe("evt-revert-6"),
1956+ eventId: EventId.make("evt-revert-6"),
16331957 aggregateKind: "thread",
1634− aggregateId: ThreadId.makeUnsafe("thread-revert"),
1958+ aggregateId: ThreadId.make("thread-revert"),
16351959 occurredAt: "2026-02-26T12:00:03.050Z",
1636− commandId: CommandId.makeUnsafe("cmd-revert-6"),
1960+ commandId: CommandId.make("cmd-revert-6"),
16371961 causationEventId: null,
1638− correlationId: CorrelationId.makeUnsafe("cmd-revert-6"),
1962+ correlationId: CorrelationId.make("cmd-revert-6"),
16391963 metadata: {},
16401964 payload: {
1641− threadId: ThreadId.makeUnsafe("thread-revert"),
1642− messageId: MessageId.makeUnsafe("user-remove"),
1965+ threadId: ThreadId.make("thread-revert"),
1966+ messageId: MessageId.make("user-remove"),
16431967 role: "user",
16441968 text: "removed",
1645− turnId: TurnId.makeUnsafe("turn-2"),
1969+ turnId: TurnId.make("turn-2"),
16461970 streaming: false,
16471971 createdAt: "2026-02-26T12:00:03.050Z",
16481972 updatedAt: "2026-02-26T12:00:03.050Z",
@@ -1651,20 +1975,20 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines
16511975
16521976 yield* appendAndProject({
16531977 type: "thread.message-sent",
1654− eventId: EventId.makeUnsafe("evt-revert-7"),
1978+ eventId: EventId.make("evt-revert-7"),
16551979 aggregateKind: "thread",
1656− aggregateId: ThreadId.makeUnsafe("thread-revert"),
1980+ aggregateId: ThreadId.make("thread-revert"),
16571981 occurredAt: "2026-02-26T12:00:03.100Z",
1658− commandId: CommandId.makeUnsafe("cmd-revert-7"),
1982+ commandId: CommandId.make("cmd-revert-7"),
16591983 causationEventId: null,
1660− correlationId: CorrelationId.makeUnsafe("cmd-revert-7"),
1984+ correlationId: CorrelationId.make("cmd-revert-7"),
16611985 metadata: {},
16621986 payload: {
1663− threadId: ThreadId.makeUnsafe("thread-revert"),
1664− messageId: MessageId.makeUnsafe("assistant-remove"),
1987+ threadId: ThreadId.make("thread-revert"),
1988+ messageId: MessageId.make("assistant-remove"),
16651989 role: "assistant",
16661990 text: "removed",
1667− turnId: TurnId.makeUnsafe("turn-2"),
1991+ turnId: TurnId.make("turn-2"),
16681992 streaming: false,
16691993 createdAt: "2026-02-26T12:00:03.100Z",
16701994 updatedAt: "2026-02-26T12:00:03.100Z",
@@ -1673,16 +1997,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines
16731997
16741998 yield* appendAndProject({
16751999 type: "thread.reverted",
1676− eventId: EventId.makeUnsafe("evt-revert-8"),
2000+ eventId: EventId.make("evt-revert-8"),
16772001 aggregateKind: "thread",
1678− aggregateId: ThreadId.makeUnsafe("thread-revert"),
2002+ aggregateId: ThreadId.make("thread-revert"),
16792003 occurredAt: "2026-02-26T12:00:04.000Z",
1680− commandId: CommandId.makeUnsafe("cmd-revert-8"),
2004+ commandId: CommandId.make("cmd-revert-8"),
16812005 causationEventId: null,
1682− correlationId: CorrelationId.makeUnsafe("cmd-revert-8"),
2006+ correlationId: CorrelationId.make("cmd-revert-8"),
16832007 metadata: {},
16842008 payload: {
1685− threadId: ThreadId.makeUnsafe("thread-revert"),
2009+ threadId: ThreadId.make("thread-revert"),
16862010 turnCount: 1,
16872011 },
16882012 });
@@ -1724,10 +2048,10 @@ it.effect("restores pending turn-start metadata across projection pipeline resta · 35 unmodified lines
17242048 Layer.provideMerge(persistenceLayer),
17252049 );
17262050
1727− const threadId = ThreadId.makeUnsafe("thread-restart");
1728− const turnId = TurnId.makeUnsafe("turn-restart");
1729− const messageId = MessageId.makeUnsafe("message-restart");
1730− const sourcePlanThreadId = ThreadId.makeUnsafe("thread-plan-source");
2051+ const threadId = ThreadId.make("thread-restart");
2052+ const turnId = TurnId.make("turn-restart");
2053+ const messageId = MessageId.make("message-restart");
2054+ const sourcePlanThreadId = ThreadId.make("thread-plan-source");
17312055 const sourcePlanId = "plan-source";
17322056 const turnStartedAt = "2026-02-26T14:00:00.000Z";
17332057 const sessionSetAt = "2026-02-26T14:00:05.000Z";
@@ -1738,13 +2062,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta · 4 unmodified lines
17382062
17392063 yield* eventStore.append({
17402064 type: "thread.turn-start-requested",
1741− eventId: EventId.makeUnsafe("evt-restart-1"),
2065+ eventId: EventId.make("evt-restart-1"),
17422066 aggregateKind: "thread",
17432067 aggregateId: threadId,
17442068 occurredAt: turnStartedAt,
1745− commandId: CommandId.makeUnsafe("cmd-restart-1"),
2069+ commandId: CommandId.make("cmd-restart-1"),
17462070 causationEventId: null,
1747− correlationId: CorrelationId.makeUnsafe("cmd-restart-1"),
2071+ correlationId: CorrelationId.make("cmd-restart-1"),
17482072 metadata: {},
17492073 payload: {
17502074 threadId,
@@ -1768,13 +2092,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta · 17 unmodified lines
17682092
17692093 yield* eventStore.append({
17702094 type: "thread.session-set",
1771− eventId: EventId.makeUnsafe("evt-restart-2"),
2095+ eventId: EventId.make("evt-restart-2"),
17722096 aggregateKind: "thread",
17732097 aggregateId: threadId,
17742098 occurredAt: sessionSetAt,
1775− commandId: CommandId.makeUnsafe("cmd-restart-2"),
2099+ commandId: CommandId.make("cmd-restart-2"),
17762100 causationEventId: null,
1777− correlationId: CorrelationId.makeUnsafe("cmd-restart-2"),
2101+ correlationId: CorrelationId.make("cmd-restart-2"),
17782102 metadata: {},
17792103 payload: {
17802104 threadId,
@@ -1846,6 +2170,7 @@ const engineLayer = it.layer( · 65 unmodified lines
18462170 Layer.provide(OrchestrationProjectionPipelineLive),
18472171 Layer.provide(OrchestrationEventStoreLive),
18482172 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
2173+ Layer.provide(RepositoryIdentityResolverLive),
18492174 Layer.provideMerge(SqlitePersistenceMemory),
18502175 Layer.provideMerge(
18512176 ServerConfig.layerTest(process.cwd(), {
@@ -1865,8 +2190,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 13 unmodified lines
18652190
18662191 yield* engine.dispatch({
18672192 type: "project.create",
1868− commandId: CommandId.makeUnsafe("cmd-live-project"),
1869− projectId: ProjectId.makeUnsafe("project-live"),
2193+ commandId: CommandId.make("cmd-live-project"),
2194+ projectId: ProjectId.make("project-live"),
18702195 title: "Live Project",
18712196 workspaceRoot: "/tmp/project-live",
18722197 defaultModelSelection: {
@@ -1903,8 +2228,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 30 unmodified lines
19032228
19042229 yield* engine.dispatch({
19052230 type: "project.create",
1906− commandId: CommandId.makeUnsafe("cmd-scripts-project-create"),
1907− projectId: ProjectId.makeUnsafe("project-scripts"),
2231+ commandId: CommandId.make("cmd-scripts-project-create"),
2232+ projectId: ProjectId.make("project-scripts"),
19082233 title: "Scripts Project",
19092234 workspaceRoot: "/tmp/project-scripts",
19102235 defaultModelSelection: {
@@ -1916,8 +2241,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 5 unmodified lines
19162241
19172242 yield* engine.dispatch({
19182243 type: "project.meta.update",
1919− commandId: CommandId.makeUnsafe("cmd-scripts-project-update"),
1920− projectId: ProjectId.makeUnsafe("project-scripts"),
2244+ commandId: CommandId.make("cmd-scripts-project-update"),
2245+ projectId: ProjectId.make("project-scripts"),
19212246 scripts: [
19222247 {
19232248 id: "script-1",
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+173−2
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 ApprovalRequestId,
33 type ChatAttachment,
44 type OrchestrationEvent,
5+ ThreadId,
56 } from "@t3tools/contracts";
67 import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
78 import * as SqlClient from "effect/unstable/sql/SqlClient";
@@ -86,7 +87,89 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { · 78 unmodified lines
8687 return null;
8788 }
8889 const requestId = (payload as Record<string, unknown>).requestId;
89− return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null;
90+ return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null;
91+}
92+
93+function isStalePendingApprovalFailureDetail(detail: string | null): boolean {
94+ if (detail === null) {
95+ return false;
96+ }
97+ return (
98+ detail.includes("stale pending approval request") ||
99+ detail.includes("unknown pending approval request") ||
100+ detail.includes("unknown pending permission request")
101+ );
102+}
103+
104+function derivePendingUserInputCountFromActivities(
105+ activities: ReadonlyArray<ProjectionThreadActivity>,
106+): number {
107+ const openRequestIds = new Set<string>();
108+ const ordered = [...activities].toSorted(
109+ (left, right) =>
110+ left.createdAt.localeCompare(right.createdAt) ||
111+ left.activityId.localeCompare(right.activityId),
112+ );
113+
114+ for (const activity of ordered) {
115+ const requestId = extractActivityRequestId(activity.payload);
116+ if (requestId === null) {
117+ continue;
118+ }
119+ const payload =
120+ typeof activity.payload === "object" && activity.payload !== null
121+ ? (activity.payload as Record<string, unknown>)
122+ : null;
123+ const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
124+
125+ if (activity.kind === "user-input.requested") {
126+ openRequestIds.add(requestId);
127+ continue;
128+ }
129+
130+ if (activity.kind === "user-input.resolved") {
131+ openRequestIds.delete(requestId);
132+ continue;
133+ }
134+
135+ if (
136+ activity.kind === "provider.user-input.respond.failed" &&
137+ detail !== null &&
138+ (detail.includes("stale pending user-input request") ||
139+ detail.includes("unknown pending user-input request"))
140+ ) {
141+ openRequestIds.delete(requestId);
142+ }
143+ }
144+
145+ return openRequestIds.size;
146+}
147+
148+function deriveHasActionableProposedPlan(input: {
149+ readonly latestTurnId: string | null;
150+ readonly proposedPlans: ReadonlyArray<ProjectionThreadProposedPlan>;
151+}): boolean {
152+ const sorted = [...input.proposedPlans].toSorted(
153+ (left, right) =>
154+ left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId),
155+ );
156+
157+ let latestForTurn: ProjectionThreadProposedPlan | null = null;
158+ if (input.latestTurnId !== null) {
159+ for (let index = sorted.length - 1; index >= 0; index -= 1) {
160+ const plan = sorted[index];
161+ if (plan?.turnId === input.latestTurnId) {
162+ latestForTurn = plan;
163+ break;
164+ }
165+ }
166+ }
167+ if (latestForTurn !== null) {
168+ return latestForTurn.implementedAt === null;
169+ }
170+
171+ const latestPlan = sorted.at(-1) ?? null;
172+ return latestPlan !== null && latestPlan.implementedAt === null;
90173 }
91174
92175 function retainProjectionMessagesAfterRevert(
@@ -432,6 +515,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 339 unmodified lines
432515 }
433516 });
434517
518+ const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* (
519+ threadId: ThreadId,
520+ ) {
521+ const existingRow = yield* projectionThreadRepository.getById({
522+ threadId,
523+ });
524+ if (Option.isNone(existingRow)) {
525+ return;
526+ }
527+
528+ const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([
529+ projectionThreadMessageRepository.listByThreadId({ threadId }),
530+ projectionThreadProposedPlanRepository.listByThreadId({ threadId }),
531+ projectionThreadActivityRepository.listByThreadId({ threadId }),
532+ projectionPendingApprovalRepository.listByThreadId({ threadId }),
533+ ]);
534+
535+ const latestUserMessageAt =
536+ messages
537+ .filter((message) => message.role === "user")
538+ .map((message) => message.createdAt)
539+ .toSorted()
540+ .at(-1) ?? null;
541+
542+ const pendingApprovalCount = pendingApprovals.filter(
543+ (approval) => approval.status === "pending",
544+ ).length;
545+ const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities);
546+ const hasActionableProposedPlan = deriveHasActionableProposedPlan({
547+ latestTurnId: existingRow.value.latestTurnId,
548+ proposedPlans,
549+ });
550+
551+ yield* projectionThreadRepository.upsert({
552+ ...existingRow.value,
553+ latestUserMessageAt,
554+ pendingApprovalCount,
555+ pendingUserInputCount,
556+ hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0,
557+ });
558+ });
559+
435560 const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn(
436561 "applyThreadsProjection",
437562 )(function* (event, attachmentSideEffects) {
@@ -450,6 +575,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 12 unmodified lines
450575 createdAt: event.payload.createdAt,
451576 updatedAt: event.payload.updatedAt,
452577 archivedAt: null,
578+ latestUserMessageAt: null,
579+ pendingApprovalCount: 0,
580+ pendingUserInputCount: 0,
581+ hasActionableProposedPlan: 0,
453582 deletedAt: null,
454583 });
455584 return;
@@ -554,7 +683,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 98 unmodified lines
554683
555684 case "thread.message-sent":
556685 case "thread.proposed-plan-upserted":
557− case "thread.activity-appended": {
686+ case "thread.activity-appended":
687+ case "thread.approval-response-requested":
688+ case "thread.user-input-response-requested": {
558689 const existingRow = yield* projectionThreadRepository.getById({
559690 threadId: event.payload.threadId,
560691 });
@@ -565,6 +696,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 4 unmodified lines
565696 ...existingRow.value,
566697 updatedAt: event.occurredAt,
567698 });
699+ yield* refreshThreadShellSummary(event.payload.threadId);
568700 return;
569701 }
570702
@@ -580,6 +712,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 9 unmodified lines
580712 latestTurnId: event.payload.session.activeTurnId,
581713 updatedAt: event.occurredAt,
582714 });
715+ yield* refreshThreadShellSummary(event.payload.threadId);
583716 return;
584717 }
585718
@@ -595,6 +728,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 9 unmodified lines
595728 latestTurnId: event.payload.turnId,
596729 updatedAt: event.occurredAt,
597730 });
731+ yield* refreshThreadShellSummary(event.payload.threadId);
598732 return;
599733 }
600734
@@ -610,6 +744,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 9 unmodified lines
610744 latestTurnId: null,
611745 updatedAt: event.occurredAt,
612746 });
747+ yield* refreshThreadShellSummary(event.payload.threadId);
613748 return;
614749 }
615750
@@ -1121,6 +1256,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 505 unmodified lines
11211256 });
11221257 return;
11231258 }
1259+ if (event.payload.activity.kind === "provider.approval.respond.failed") {
1260+ const payload =
1261+ typeof event.payload.activity.payload === "object" &&
1262+ event.payload.activity.payload !== null
1263+ ? (event.payload.activity.payload as Record<string, unknown>)
1264+ : null;
1265+ const detail =
1266+ typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
1267+ if (isStalePendingApprovalFailureDetail(detail)) {
1268+ if (Option.isNone(existingRow)) {
1269+ return;
1270+ }
1271+ if (existingRow.value.status === "resolved") {
1272+ return;
1273+ }
1274+ yield* projectionPendingApprovalRepository.upsert({
1275+ requestId,
1276+ threadId: existingRow.value.threadId,
1277+ turnId: existingRow.value.turnId,
1278+ status: "resolved",
1279+ decision: null,
1280+ createdAt: existingRow.value.createdAt,
1281+ resolvedAt: event.payload.activity.createdAt,
1282+ });
1283+ return;
1284+ }
1285+ return;
1286+ }
1287+ // Only approval-requested activities should create pending-approval
1288+ // rows. Other activity kinds that happen to carry a requestId
1289+ // (e.g. user-input.requested / user-input.resolved) must not
1290+ // pollute this projection — they have their own accounting via
1291+ // derivePendingUserInputCountFromActivities.
1292+ if (event.payload.activity.kind !== "approval.requested") {
1293+ return;
1294+ }
11241295 if (Option.isSome(existingRow) && existingRow.value.status === "resolved") {
11251296 return;
11261297 }
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+410−13
@@ -4,18 +4,22 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines
44 import * as SqlClient from "effect/unstable/sql/SqlClient";
55
66 import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
7+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
78 import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
89 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
910 import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
1011
11−const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
12−const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
13−const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
14−const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
15−const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
12+const asProjectId = (value: string): ProjectId => ProjectId.make(value);
13+const asTurnId = (value: string): TurnId => TurnId.make(value);
14+const asMessageId = (value: string): MessageId => MessageId.make(value);
15+const asEventId = (value: string): EventId => EventId.make(value);
16+const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
1617
1718 const projectionSnapshotLayer = it.layer(
18− OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
19+ OrchestrationProjectionSnapshotQueryLive.pipe(
20+ Layer.provideMerge(RepositoryIdentityResolverLive),
21+ Layer.provideMerge(SqlitePersistenceMemory),
22+ ),
1923 );
2024
2125 projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
@@ -58,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 36 unmodified lines
5862 project_id,
5963 title,
6064 model_selection_json,
65+ runtime_mode,
66+ interaction_mode,
6167 branch,
6268 worktree_path,
6369 latest_turn_id,
70+ latest_user_message_at,
71+ pending_approval_count,
72+ pending_user_input_count,
73+ has_actionable_proposed_plan,
6474 created_at,
6575 updated_at,
6676 deleted_at
@@ -70,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 3 unmodified lines
7080 'project-1',
7181 'Thread 1',
7282 '{"provider":"codex","model":"gpt-5-codex"}',
83+ 'full-access',
84+ 'default',
7385 NULL,
7486 NULL,
7587 'turn-1',
88+ '2026-02-24T00:00:04.000Z',
89+ 1,
90+ 0,
91+ 0,
7692 '2026-02-24T00:00:02.000Z',
7793 '2026-02-24T00:00:03.000Z',
7894 NULL
@@ -234,6 +250,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 155 unmodified lines
234250 id: asProjectId("project-1"),
235251 title: "Project 1",
236252 workspaceRoot: "/tmp/project-1",
253+ repositoryIdentity: null,
237254 defaultModelSelection: {
238255 provider: "codex",
239256 model: "gpt-5-codex",
@@ -254,7 +271,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 14 unmodified lines
254271 ]);
255272 assert.deepEqual(snapshot.threads, [
256273 {
257− id: ThreadId.makeUnsafe("thread-1"),
274+ id: ThreadId.make("thread-1"),
258275 projectId: asProjectId("project-1"),
259276 title: "Thread 1",
260277 modelSelection: {
@@ -273,7 +290,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 12 unmodified lines
273290 completedAt: "2026-02-24T00:00:08.000Z",
274291 assistantMessageId: asMessageId("message-1"),
275292 sourceProposedPlan: {
276− threadId: ThreadId.makeUnsafe("thread-1"),
293+ threadId: ThreadId.make("thread-1"),
277294 planId: "plan-1",
278295 },
279296 },
@@ -298,7 +315,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 18 unmodified lines
298315 turnId: asTurnId("turn-1"),
299316 planMarkdown: "# Ship it",
300317 implementedAt: "2026-02-24T00:00:05.500Z",
301− implementationThreadId: ThreadId.makeUnsafe("thread-2"),
318+ implementationThreadId: ThreadId.make("thread-2"),
302319 createdAt: "2026-02-24T00:00:05.000Z",
303320 updatedAt: "2026-02-24T00:00:05.500Z",
304321 },
@@ -326,7 +343,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 21 unmodified lines
326343 },
327344 ],
328345 session: {
329− threadId: ThreadId.makeUnsafe("thread-1"),
346+ threadId: ThreadId.make("thread-1"),
330347 status: "running",
331348 providerName: "codex",
332349 runtimeMode: "approval-required",
@@ -336,6 +353,81 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 3 unmodified lines
336353 },
337354 },
338355 ]);
356+
357+ const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
358+ assert.equal(shellSnapshot.snapshotSequence, 5);
359+ assert.deepEqual(shellSnapshot.projects, [
360+ {
361+ id: asProjectId("project-1"),
362+ title: "Project 1",
363+ workspaceRoot: "/tmp/project-1",
364+ repositoryIdentity: null,
365+ defaultModelSelection: {
366+ provider: "codex",
367+ model: "gpt-5-codex",
368+ },
369+ scripts: [
370+ {
371+ id: "script-1",
372+ name: "Build",
373+ command: "bun run build",
374+ icon: "build",
375+ runOnWorktreeCreate: false,
376+ },
377+ ],
378+ createdAt: "2026-02-24T00:00:00.000Z",
379+ updatedAt: "2026-02-24T00:00:01.000Z",
380+ },
381+ ]);
382+ assert.deepEqual(shellSnapshot.threads, [
383+ {
384+ id: ThreadId.make("thread-1"),
385+ projectId: asProjectId("project-1"),
386+ title: "Thread 1",
387+ modelSelection: {
388+ provider: "codex",
389+ model: "gpt-5-codex",
390+ },
391+ interactionMode: "default",
392+ runtimeMode: "full-access",
393+ branch: null,
394+ worktreePath: null,
395+ latestTurn: {
396+ turnId: asTurnId("turn-1"),
397+ state: "completed",
398+ requestedAt: "2026-02-24T00:00:08.000Z",
399+ startedAt: "2026-02-24T00:00:08.000Z",
400+ completedAt: "2026-02-24T00:00:08.000Z",
401+ assistantMessageId: asMessageId("message-1"),
402+ sourceProposedPlan: {
403+ threadId: ThreadId.make("thread-1"),
404+ planId: "plan-1",
405+ },
406+ },
407+ createdAt: "2026-02-24T00:00:02.000Z",
408+ updatedAt: "2026-02-24T00:00:03.000Z",
409+ archivedAt: null,
410+ session: {
411+ threadId: ThreadId.make("thread-1"),
412+ status: "running",
413+ providerName: "codex",
414+ runtimeMode: "approval-required",
415+ activeTurnId: asTurnId("turn-1"),
416+ lastError: null,
417+ updatedAt: "2026-02-24T00:00:07.000Z",
418+ },
419+ latestUserMessageAt: "2026-02-24T00:00:04.000Z",
420+ hasPendingApprovals: true,
421+ hasPendingUserInput: false,
422+ hasActionableProposedPlan: false,
423+ },
424+ ]);
425+
426+ const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
427+ assert.equal(threadDetail._tag, "Some");
428+ if (threadDetail._tag === "Some") {
429+ assert.deepEqual(threadDetail.value, snapshot.threads[0]);
430+ }
339431 }),
340432 );
341433
@@ -468,7 +560,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 126 unmodified lines
468560 );
469561 assert.equal(firstThreadId._tag, "Some");
470562 if (firstThreadId._tag === "Some") {
471− assert.equal(firstThreadId.value, ThreadId.makeUnsafe("thread-first"));
563+ assert.equal(firstThreadId.value, ThreadId.make("thread-first"));
472564 }
473565 }),
474566 );
@@ -591,12 +683,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 116 unmodified lines
591683 `;
592684
593685 const context = yield* snapshotQuery.getThreadCheckpointContext(
594− ThreadId.makeUnsafe("thread-context"),
686+ ThreadId.make("thread-context"),
595687 );
596688 assert.equal(context._tag, "Some");
597689 if (context._tag === "Some") {
598690 assert.deepEqual(context.value, {
599− threadId: ThreadId.makeUnsafe("thread-context"),
691+ threadId: ThreadId.make("thread-context"),
600692 projectId: asProjectId("project-context"),
601693 workspaceRoot: "/tmp/context-workspace",
602694 worktreePath: "/tmp/context-worktree",
@@ -624,4 +716,309 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 21 unmodified lines
624716 }
625717 }),
626718 );
719+
720+ it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () =>
721+ Effect.gen(function* () {
722+ const snapshotQuery = yield* ProjectionSnapshotQuery;
723+ const sql = yield* SqlClient.SqlClient;
724+
725+ yield* sql`DELETE FROM projection_projects`;
726+ yield* sql`DELETE FROM projection_threads`;
727+ yield* sql`DELETE FROM projection_thread_activities`;
728+ yield* sql`DELETE FROM projection_state`;
729+
730+ yield* sql`
731+ INSERT INTO projection_projects (
732+ project_id,
733+ title,
734+ workspace_root,
735+ default_model_selection_json,
736+ scripts_json,
737+ created_at,
738+ updated_at,
739+ deleted_at
740+ )
741+ VALUES (
742+ 'project-1',
743+ 'Project 1',
744+ '/tmp/project-1',
745+ '{"provider":"codex","model":"gpt-5-codex"}',
746+ '[]',
747+ '2026-04-01T00:00:00.000Z',
748+ '2026-04-01T00:00:01.000Z',
749+ NULL
750+ )
751+ `;
752+
753+ yield* sql`
754+ INSERT INTO projection_threads (
755+ thread_id,
756+ project_id,
757+ title,
758+ model_selection_json,
759+ runtime_mode,
760+ interaction_mode,
761+ branch,
762+ worktree_path,
763+ latest_turn_id,
764+ latest_user_message_at,
765+ pending_approval_count,
766+ pending_user_input_count,
767+ has_actionable_proposed_plan,
768+ created_at,
769+ updated_at,
770+ deleted_at
771+ )
772+ VALUES (
773+ 'thread-1',
774+ 'project-1',
775+ 'Thread 1',
776+ '{"provider":"codex","model":"gpt-5-codex"}',
777+ 'full-access',
778+ 'default',
779+ NULL,
780+ NULL,
781+ NULL,
782+ NULL,
783+ 0,
784+ 0,
785+ 0,
786+ '2026-04-01T00:00:02.000Z',
787+ '2026-04-01T00:00:03.000Z',
788+ NULL
789+ )
790+ `;
791+
792+ yield* sql`
793+ INSERT INTO projection_thread_activities (
794+ activity_id,
795+ thread_id,
796+ turn_id,
797+ tone,
798+ kind,
799+ summary,
800+ payload_json,
801+ sequence,
802+ created_at
803+ )
804+ VALUES
805+ (
806+ 'activity-unsequenced',
807+ 'thread-1',
808+ NULL,
809+ 'info',
810+ 'runtime.note',
811+ 'unsequenced first',
812+ '{"source":"unsequenced"}',
813+ NULL,
814+ '2026-04-01T00:00:06.000Z'
815+ ),
816+ (
817+ 'activity-sequence-2',
818+ 'thread-1',
819+ NULL,
820+ 'info',
821+ 'runtime.note',
822+ 'sequence two',
823+ '{"source":"sequence-2"}',
824+ 2,
825+ '2026-04-01T00:00:04.000Z'
826+ ),
827+ (
828+ 'activity-sequence-1',
829+ 'thread-1',
830+ NULL,
831+ 'info',
832+ 'runtime.note',
833+ 'sequence one',
834+ '{"source":"sequence-1"}',
835+ 1,
836+ '2026-04-01T00:00:05.000Z'
837+ )
838+ `;
839+
840+ const snapshot = yield* snapshotQuery.getSnapshot();
841+ const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
842+
843+ assert.equal(threadDetail._tag, "Some");
844+ if (threadDetail._tag === "Some") {
845+ assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []);
846+ }
847+
848+ assert.deepEqual(snapshot.threads[0]?.activities ?? [], [
849+ {
850+ id: asEventId("activity-unsequenced"),
851+ tone: "info",
852+ kind: "runtime.note",
853+ summary: "unsequenced first",
854+ payload: { source: "unsequenced" },
855+ turnId: null,
856+ createdAt: "2026-04-01T00:00:06.000Z",
857+ },
858+ {
859+ id: asEventId("activity-sequence-1"),
860+ tone: "info",
861+ kind: "runtime.note",
862+ summary: "sequence one",
863+ payload: { source: "sequence-1" },
864+ turnId: null,
865+ sequence: 1,
866+ createdAt: "2026-04-01T00:00:05.000Z",
867+ },
868+ {
869+ id: asEventId("activity-sequence-2"),
870+ tone: "info",
871+ kind: "runtime.note",
872+ summary: "sequence two",
873+ payload: { source: "sequence-2" },
874+ turnId: null,
875+ sequence: 2,
876+ createdAt: "2026-04-01T00:00:04.000Z",
877+ },
878+ ]);
879+ }),
880+ );
881+
882+ it.effect("uses projection_threads.latest_turn_id for targeted thread latest turn queries", () =>
883+ Effect.gen(function* () {
884+ const snapshotQuery = yield* ProjectionSnapshotQuery;
885+ const sql = yield* SqlClient.SqlClient;
886+
887+ yield* sql`DELETE FROM projection_projects`;
888+ yield* sql`DELETE FROM projection_threads`;
889+ yield* sql`DELETE FROM projection_turns`;
890+
891+ yield* sql`
892+ INSERT INTO projection_projects (
893+ project_id,
894+ title,
895+ workspace_root,
896+ default_model_selection_json,
897+ scripts_json,
898+ created_at,
899+ updated_at,
900+ deleted_at
901+ )
902+ VALUES (
903+ 'project-1',
904+ 'Project 1',
905+ '/tmp/project-1',
906+ '{"provider":"codex","model":"gpt-5-codex"}',
907+ '[]',
908+ '2026-04-02T00:00:00.000Z',
909+ '2026-04-02T00:00:01.000Z',
910+ NULL
911+ )
912+ `;
913+
914+ yield* sql`
915+ INSERT INTO projection_threads (
916+ thread_id,
917+ project_id,
918+ title,
919+ model_selection_json,
920+ runtime_mode,
921+ interaction_mode,
922+ branch,
923+ worktree_path,
924+ latest_turn_id,
925+ latest_user_message_at,
926+ pending_approval_count,
927+ pending_user_input_count,
928+ has_actionable_proposed_plan,
929+ created_at,
930+ updated_at,
931+ archived_at,
932+ deleted_at
933+ )
934+ VALUES (
935+ 'thread-1',
936+ 'project-1',
937+ 'Thread 1',
938+ '{"provider":"codex","model":"gpt-5-codex"}',
939+ 'full-access',
940+ 'default',
941+ NULL,
942+ NULL,
943+ 'turn-running',
944+ '2026-04-02T00:00:04.000Z',
945+ 0,
946+ 0,
947+ 0,
948+ '2026-04-02T00:00:02.000Z',
949+ '2026-04-02T00:00:03.000Z',
950+ NULL,
951+ NULL
952+ )
953+ `;
954+
955+ yield* sql`
956+ INSERT INTO projection_turns (
957+ thread_id,
958+ turn_id,
959+ pending_message_id,
960+ source_proposed_plan_thread_id,
961+ source_proposed_plan_id,
962+ assistant_message_id,
963+ state,
964+ requested_at,
965+ started_at,
966+ completed_at,
967+ checkpoint_turn_count,
968+ checkpoint_ref,
969+ checkpoint_status,
970+ checkpoint_files_json
971+ )
972+ VALUES
973+ (
974+ 'thread-1',
975+ 'turn-completed',
976+ 'message-user-1',
977+ NULL,
978+ NULL,
979+ 'message-assistant-1',
980+ 'completed',
981+ '2026-04-02T00:00:05.000Z',
982+ '2026-04-02T00:00:06.000Z',
983+ '2026-04-02T00:00:20.000Z',
984+ 5,
985+ 'checkpoint-5',
986+ 'ready',
987+ '[]'
988+ ),
989+ (
990+ 'thread-1',
991+ 'turn-running',
992+ 'message-user-2',
993+ NULL,
994+ NULL,
995+ NULL,
996+ 'running',
997+ '2026-04-02T00:00:30.000Z',
998+ '2026-04-02T00:00:30.000Z',
999+ NULL,
1000+ NULL,
1001+ NULL,
1002+ NULL,
1003+ '[]'
1004+ )
1005+ `;
1006+
1007+ const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
1008+ assert.equal(threadShell._tag, "Some");
1009+ if (threadShell._tag === "Some") {
1010+ assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running"));
1011+ assert.equal(threadShell.value.latestTurn?.state, "running");
1012+ assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z");
1013+ }
1014+
1015+ const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
1016+ assert.equal(threadDetail._tag, "Some");
1017+ if (threadDetail._tag === "Some") {
1018+ assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running"));
1019+ assert.equal(threadDetail.value.latestTurn?.state, "running");
1020+ assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z");
1021+ }
1022+ }),
1023+ );
6271024 });
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+860−245
@@ -6,16 +6,19 @@ import { · 5 unmodified lines
66 OrchestrationCheckpointFile,
77 OrchestrationProposedPlanId,
88 OrchestrationReadModel,
9+ OrchestrationShellSnapshot,
10+ OrchestrationThread,
911 ProjectScript,
1012 TurnId,
1113 type OrchestrationCheckpointSummary,
1214 type OrchestrationLatestTurn,
1315 type OrchestrationMessage,
16+ type OrchestrationProjectShell,
1417 type OrchestrationProposedPlan,
1518 type OrchestrationProject,
1619 type OrchestrationSession,
17− type OrchestrationThread,
1820 type OrchestrationThreadActivity,
21+ type OrchestrationThreadShell,
1922 ModelSelection,
2023 ProjectId,
2124 ThreadId,
@@ -38,6 +41,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh · 16 unmodified lines
3841 import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
3942 import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
4043 import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
44+import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
4145 import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
4246 import {
4347 ProjectionSnapshotQuery,

1208 more lines aren’t shown here.

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+232−152

This diff is large: its 984 lines aren’t shown here.

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+10−11

This diff is large: its 86 lines aren’t shown here.

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+45−49

This diff is large: its 320 lines aren’t shown here.

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+9−10

This diff is large: its 78 lines aren’t shown here.

apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts+20−2

This diff is large: its 38 lines aren’t shown here.

apps/server/src/orchestration/Normalizer.ts+26−5

This diff is large: its 48 lines aren’t shown here.

apps/server/src/orchestration/Services/CheckpointReactor.ts+4−5

This diff is large: its 20 lines aren’t shown here.

apps/server/src/orchestration/Services/OrchestrationEngine.ts+3−3

This diff is large: its 27 lines aren’t shown here.

apps/server/src/orchestration/Services/OrchestrationReactor.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/orchestration/Services/ProjectionPipeline.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+38−2

This diff is large: its 77 lines aren’t shown here.

apps/server/src/orchestration/Services/ProviderCommandReactor.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/orchestration/Services/RuntimeReceiptBus.ts+21−6

This diff is large: its 38 lines aren’t shown here.

apps/server/src/orchestration/commandInvariants.test.ts+24−26

This diff is large: its 137 lines aren’t shown here.

apps/server/src/orchestration/decider.projectScripts.test.ts+34−34

This diff is large: its 199 lines aren’t shown here.

apps/server/src/orchestration/http.ts+93−0

This diff is large: its 94 lines aren’t shown here.

apps/server/src/orchestration/projector.test.ts+4−4

This diff is large: its 20 lines aren’t shown here.

apps/server/src/orchestration/runtimeLayer.ts+27−0

This diff is large: its 28 lines aren’t shown here.

apps/server/src/os-jank.test.ts+150−4

This diff is large: its 182 lines aren’t shown here.

apps/server/src/os-jank.ts+65−10

This diff is large: its 94 lines aren’t shown here.

apps/server/src/persistence/Errors.ts+2−0

This diff is large: its 8 lines aren’t shown here.

apps/server/src/persistence/Layers/AuthPairingLinks.ts+209−0

This diff is large: its 210 lines aren’t shown here.

apps/server/src/persistence/Layers/AuthSessions.ts+279−0

This diff is large: its 280 lines aren’t shown here.

apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+8−8

This diff is large: its 41 lines aren’t shown here.

apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+9−5

This diff is large: its 49 lines aren’t shown here.

apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+4−4

This diff is large: its 22 lines aren’t shown here.

apps/server/src/persistence/Layers/ProjectionThreads.ts+20−0

This diff is large: its 55 lines aren’t shown here.

apps/server/src/persistence/Migrations.ts+12−0

This diff is large: its 26 lines aren’t shown here.

apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts+42−0

This diff is large: its 43 lines aren’t shown here.

apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts+62−0

This diff is large: its 63 lines aren’t shown here.

apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts+17−0

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts+26−0

This diff is large: its 27 lines aren’t shown here.

apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts+218−0

This diff is large: its 219 lines aren’t shown here.

apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts+277−0

This diff is large: its 278 lines aren’t shown here.

apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts+196−0

This diff is large: its 197 lines aren’t shown here.

apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts+27−0

This diff is large: its 28 lines aren’t shown here.

apps/server/src/persistence/NodeSqliteClient.ts+10−10

This diff is large: its 70 lines aren’t shown here.

apps/server/src/persistence/Services/AuthPairingLinks.ts+76−0

This diff is large: its 77 lines aren’t shown here.

apps/server/src/persistence/Services/AuthSessions.ts+93−0

This diff is large: its 94 lines aren’t shown here.

apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/OrchestrationEventStore.ts+3−3

This diff is large: its 25 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionCheckpoints.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionPendingApprovals.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionProjects.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionState.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreadActivities.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreadMessages.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreadSessions.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreads.ts+7−2

This diff is large: its 36 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionTurns.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProviderSessionRuntime.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/process/Layers/TerminalProcessInspector.test.ts+2−2

This diff is large: its 11 lines aren’t shown here.

apps/server/src/process/Layers/TerminalProcessInspector.ts+6−6

This diff is large: its 39 lines aren’t shown here.

apps/server/src/process/Layers/WebPortInspector.test.ts+2−2

This diff is large: its 11 lines aren’t shown here.

apps/server/src/process/Layers/WebPortInspector.ts+3−3

This diff is large: its 20 lines aren’t shown here.

apps/server/src/process/Services/TerminalProcessInspector.ts+2−2

This diff is large: its 15 lines aren’t shown here.

apps/server/src/process/Services/WebPortInspector.ts+2−2

This diff is large: its 14 lines aren’t shown here.

apps/server/src/process/posix.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/server/src/process/posix.ts+2−2

This diff is large: its 10 lines aren’t shown here.

apps/server/src/process/win32.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/server/src/process/win32.ts+2−2

This diff is large: its 10 lines aren’t shown here.

apps/server/src/processRunner.test.ts+1−1

This diff is large: its 8 lines aren’t shown here.

apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts+223−0

This diff is large: its 224 lines aren’t shown here.

apps/server/src/project/Layers/RepositoryIdentityResolver.ts+151−0

This diff is large: its 152 lines aren’t shown here.

apps/server/src/project/Services/ProjectFaviconResolver.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/project/Services/ProjectSetupScriptRunner.ts+2−2

This diff is large: its 15 lines aren’t shown here.

apps/server/src/project/Services/RepositoryIdentityResolver.ts+12−0

This diff is large: its 13 lines aren’t shown here.

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+419−69

This diff is large: its 631 lines aren’t shown here.

apps/server/src/provider/Layers/ClaudeAdapter.ts+243−92

This diff is large: its 686 lines aren’t shown here.

apps/server/src/provider/Layers/ClaudeProvider.ts+225−74

This diff is large: its 466 lines aren’t shown here.

apps/server/src/provider/Layers/CodexAdapter.test.ts+11−15

This diff is large: its 90 lines aren’t shown here.

apps/server/src/provider/Layers/CodexAdapter.ts+18−21

This diff is large: its 140 lines aren’t shown here.

apps/server/src/provider/Layers/CodexProvider.ts+108−24

This diff is large: its 260 lines aren’t shown here.

apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+46−3

This diff is large: its 73 lines aren’t shown here.

apps/server/src/provider/Layers/EventNdjsonLogger.ts+65−27

This diff is large: its 126 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+4−2

This diff is large: its 13 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderRegistry.test.ts+263−7

This diff is large: its 347 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderRegistry.ts+138−38

This diff is large: its 216 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderService.test.ts+46−7

This diff is large: its 88 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderService.ts+40−10

This diff is large: its 86 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+77−5

This diff is large: its 121 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderSessionDirectory.ts+38−12

This diff is large: its 89 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+522−0

This diff is large: its 523 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderSessionReaper.ts+133−0

This diff is large: its 134 lines aren’t shown here.

apps/server/src/provider/Services/ClaudeAdapter.ts+3−3

This diff is large: its 23 lines aren’t shown here.

apps/server/src/provider/Services/ClaudeProvider.ts+3−3

This diff is large: its 13 lines aren’t shown here.

apps/server/src/provider/Services/CodexAdapter.ts+3−3

This diff is large: its 23 lines aren’t shown here.

apps/server/src/provider/Services/CodexProvider.ts+3−3

This diff is large: its 13 lines aren’t shown here.

apps/server/src/provider/Services/ProviderAdapterRegistry.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/provider/Services/ProviderRegistry.ts+2−2

This diff is large: its 17 lines aren’t shown here.

apps/server/src/provider/Services/ProviderService.ts+3−3

This diff is large: its 26 lines aren’t shown here.

apps/server/src/provider/Services/ProviderSessionDirectory.ts+11−2

This diff is large: its 36 lines aren’t shown here.

apps/server/src/provider/Services/ProviderSessionReaper.ts+14−0

This diff is large: its 15 lines aren’t shown here.

apps/server/src/provider/cliVersion.test.ts+17−0

This diff is large: its 18 lines aren’t shown here.

apps/server/src/provider/cliVersion.ts+123−0

This diff is large: its 124 lines aren’t shown here.

apps/server/src/provider/codexAppServer.ts+96−9

This diff is large: its 176 lines aren’t shown here.

apps/server/src/provider/codexCliVersion.ts+4−120

This diff is large: its 139 lines aren’t shown here.

apps/server/src/provider/makeManagedServerProvider.ts+3−2

This diff is large: its 26 lines aren’t shown here.

apps/server/src/provider/providerSnapshot.ts+11−4

This diff is large: its 63 lines aren’t shown here.

apps/server/src/provider/providerStatusCache.test.ts+136−0

This diff is large: its 137 lines aren’t shown here.

apps/server/src/provider/providerStatusCache.ts+105−0

This diff is large: its 106 lines aren’t shown here.

apps/server/src/server.test.ts+2827−696

This diff is large: its 4066 lines aren’t shown here.

apps/server/src/server.ts+157−87

This diff is large: its 338 lines aren’t shown here.

apps/server/src/serverLifecycleEvents.test.ts+10−0

This diff is large: its 34 lines aren’t shown here.

apps/server/src/serverLifecycleEvents.ts+2−2

This diff is large: its 16 lines aren’t shown here.

apps/server/src/serverLogger.ts+1−1

This diff is large: its 8 lines aren’t shown here.

apps/server/src/serverRuntimeStartup.test.ts+129−2

This diff is large: its 164 lines aren’t shown here.

apps/server/src/serverRuntimeStartup.ts+143−58

This diff is large: its 320 lines aren’t shown here.

apps/server/src/serverRuntimeState.ts+77−0

This diff is large: its 78 lines aren’t shown here.

apps/server/src/serverSettings.test.ts+37−2

This diff is large: its 94 lines aren’t shown here.

apps/server/src/serverSettings.ts+21−5

This diff is large: its 60 lines aren’t shown here.

apps/server/src/startupAccess.test.ts+79−0

This diff is large: its 80 lines aren’t shown here.

apps/server/src/startupAccess.ts+148−0

This diff is large: its 149 lines aren’t shown here.

apps/server/src/telemetry/Identify.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/server/src/telemetry/Layers/AnalyticsService.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/telemetry/Services/AnalyticsService.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/terminal/Layers/BunPTY.ts+5−2

This diff is large: its 19 lines aren’t shown here.

apps/server/src/terminal/Layers/Manager.test.ts+83−12

This diff is large: its 184 lines aren’t shown here.

apps/server/src/terminal/Layers/Manager.ts+96−48

This diff is large: its 299 lines aren’t shown here.

apps/server/src/terminal/Layers/NodePTY.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/server/src/terminal/Layers/NodePTY.ts+27−8

This diff is large: its 56 lines aren’t shown here.

apps/server/src/terminal/Services/Manager.ts+3−3

This diff is large: its 19 lines aren’t shown here.

apps/server/src/terminal/Services/PTY.ts+2−2

This diff is large: its 17 lines aren’t shown here.

apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+98−0

This diff is large: its 117 lines aren’t shown here.

apps/server/src/workspace/Layers/WorkspaceEntries.ts+133−129

This diff is large: its 353 lines aren’t shown here.

apps/server/src/workspace/Layers/WorkspacePaths.test.ts+18−0

This diff is large: its 25 lines aren’t shown here.

apps/server/src/workspace/Layers/WorkspacePaths.ts+17−2

This diff is large: its 36 lines aren’t shown here.

apps/server/src/workspace/Services/WorkspaceEntries.ts+26−3

This diff is large: its 56 lines aren’t shown here.

apps/server/src/workspace/Services/WorkspaceFileSystem.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/workspace/Services/WorkspacePaths.ts+20−3

This diff is large: its 57 lines aren’t shown here.

apps/server/src/ws.ts+1006−660

This diff is large: its 1751 lines aren’t shown here.

apps/server/tsconfig.json+1−3

This diff is large: its 11 lines aren’t shown here.

apps/server/vitest.config.ts+1−1

This diff is large: its 8 lines aren’t shown here.

apps/web/index.html+83−2

This diff is large: its 101 lines aren’t shown here.

apps/web/package.json+3−2

This diff is large: its 25 lines aren’t shown here.

apps/web/src/authBootstrap.test.ts+495−0

This diff is large: its 496 lines aren’t shown here.

apps/web/src/branding.test.ts+37−0

This diff is large: its 38 lines aren’t shown here.

apps/web/src/branding.ts+17−3

This diff is large: its 22 lines aren’t shown here.

apps/web/src/chat-scroll.test.ts+0−62

This diff is large: its 63 lines aren’t shown here.

apps/web/src/chat-scroll.ts+0−24

This diff is large: its 25 lines aren’t shown here.

apps/web/src/clientPersistenceStorage.test.ts+80−0

This diff is large: its 81 lines aren’t shown here.

apps/web/src/clientPersistenceStorage.ts+194−0

This diff is large: its 195 lines aren’t shown here.

apps/web/src/commandPaletteStore.ts+32−0

This diff is large: its 33 lines aren’t shown here.

apps/web/src/components/BranchToolbar.logic.test.ts+93−1

This diff is large: its 115 lines aren’t shown here.

apps/web/src/components/BranchToolbar.logic.ts+54−4

This diff is large: its 77 lines aren’t shown here.

apps/web/src/components/BranchToolbar.tsx+83−137

This diff is large: its 255 lines aren’t shown here.

apps/web/src/components/BranchToolbarBranchSelector.tsx+222−148

This diff is large: its 591 lines aren’t shown here.

apps/web/src/components/BranchToolbarEnvModeSelector.tsx+98−0

This diff is large: its 99 lines aren’t shown here.

apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+88−0

This diff is large: its 89 lines aren’t shown here.

apps/web/src/components/ChatMarkdown.browser.tsx+141−0

This diff is large: its 142 lines aren’t shown here.

apps/web/src/components/ChatMarkdown.tsx+294−20

This diff is large: its 369 lines aren’t shown here.

apps/web/src/components/ChatView.browser.tsx+3469−837

This diff is large: its 5020 lines aren’t shown here.

apps/web/src/components/ChatView.logic.test.ts+384−121

This diff is large: its 674 lines aren’t shown here.

apps/web/src/components/ChatView.logic.ts+77−24

This diff is large: its 172 lines aren’t shown here.

apps/web/src/components/ChatView.tsx+1118−2187

This diff is large: its 4440 lines aren’t shown here.

apps/web/src/components/CommandPalette.logic.test.ts+166−0

This diff is large: its 167 lines aren’t shown here.

apps/web/src/components/CommandPalette.logic.ts+359−0

This diff is large: its 360 lines aren’t shown here.

apps/web/src/components/CommandPalette.tsx+1115−0

This diff is large: its 1116 lines aren’t shown here.

apps/web/src/components/CommandPaletteResults.tsx+116−0

This diff is large: its 117 lines aren’t shown here.

apps/web/src/components/ComposerPromptEditor.tsx+610−71

This diff is large: its 937 lines aren’t shown here.

apps/web/src/components/DiffPanel.tsx+35−22

This diff is large: its 137 lines aren’t shown here.

apps/web/src/components/DiffPanelShell.tsx+4−2

This diff is large: its 13 lines aren’t shown here.

apps/web/src/components/GitActionsControl.browser.tsx+294−70

This diff is large: its 469 lines aren’t shown here.

apps/web/src/components/GitActionsControl.logic.test.ts+9−0

This diff is large: its 16 lines aren’t shown here.

apps/web/src/components/GitActionsControl.logic.ts+10−0

This diff is large: its 24 lines aren’t shown here.

apps/web/src/components/GitActionsControl.tsx+138−38

This diff is large: its 305 lines aren’t shown here.

apps/web/src/components/Icons.tsx+139−0

This diff is large: its 153 lines aren’t shown here.

apps/web/src/components/KeybindingsToast.browser.tsx+193−28

This diff is large: its 333 lines aren’t shown here.

apps/web/src/components/NoActiveThreadState.tsx+45−0

This diff is large: its 46 lines aren’t shown here.

apps/web/src/components/PlanSidebar.tsx+20−6

This diff is large: its 87 lines aren’t shown here.

apps/web/src/components/ProjectFavicon.tsx+14−7

This diff is large: its 42 lines aren’t shown here.

apps/web/src/components/PullRequestThreadDialog.tsx+7−3

This diff is large: its 47 lines aren’t shown here.

apps/web/src/components/RightPanelSheet.tsx+30−0

This diff is large: its 31 lines aren’t shown here.

apps/web/src/components/Sidebar.logic.test.ts+134−230

This diff is large: its 654 lines aren’t shown here.

apps/web/src/components/Sidebar.logic.ts+26−62

This diff is large: its 152 lines aren’t shown here.

apps/web/src/components/Sidebar.tsx+2681−1542

This diff is large: its 4700 lines aren’t shown here.

apps/web/src/components/SplashScreen.tsx+9−0

This diff is large: its 10 lines aren’t shown here.

apps/web/src/components/ThreadStatusIndicators.tsx+241−0

This diff is large: its 242 lines aren’t shown here.

apps/web/src/components/ThreadTerminalDrawer.browser.tsx+318−0

This diff is large: its 319 lines aren’t shown here.

apps/web/src/components/ThreadTerminalDrawer.tsx+90−33

This diff is large: its 322 lines aren’t shown here.

apps/web/src/components/WebSocketConnectionSurface.logic.test.ts+113−0

This diff is large: its 114 lines aren’t shown here.

apps/web/src/components/WebSocketConnectionSurface.tsx+390−0

This diff is large: its 391 lines aren’t shown here.

apps/web/src/components/auth/PairingRouteSurface.tsx+195−0

This diff is large: its 196 lines aren’t shown here.

apps/web/src/components/chat/ChangedFilesTree.test.tsx+2−2

This diff is large: its 18 lines aren’t shown here.

apps/web/src/components/chat/ChatComposer.tsx+1999−0

This diff is large: its 2000 lines aren’t shown here.

apps/web/src/components/chat/ChatHeader.tsx+14−1

This diff is large: its 50 lines aren’t shown here.

apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+38−29

This diff is large: its 111 lines aren’t shown here.

apps/web/src/components/chat/CompactComposerControlsMenu.tsx+7−3

This diff is large: its 39 lines aren’t shown here.

apps/web/src/components/chat/ComposerCommandMenu.tsx+161−27

This diff is large: its 251 lines aren’t shown here.

apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+39−32

This diff is large: its 155 lines aren’t shown here.

apps/web/src/components/chat/ComposerPrimaryActions.test.ts+93−0

This diff is large: its 94 lines aren’t shown here.

apps/web/src/components/chat/ComposerPrimaryActions.tsx+8−3

This diff is large: its 42 lines aren’t shown here.

apps/web/src/components/chat/ExpandedImageDialog.tsx+120−0

This diff is large: its 121 lines aren’t shown here.

apps/web/src/components/chat/MessageCopyButton.tsx+74−12

This diff is large: its 95 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.browser.tsx+160−0

This diff is large: its 161 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.logic.test.ts+291−1

This diff is large: its 301 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.logic.ts+102−61

This diff is large: its 221 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.test.tsx+93−47

This diff is large: its 202 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.tsx+551−421

This diff is large: its 1230 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx+0−1037

This diff is large: its 1038 lines aren’t shown here.

apps/web/src/components/chat/OpenInPicker.tsx+13−5

This diff is large: its 63 lines aren’t shown here.

apps/web/src/components/chat/ProposedPlanCard.tsx+5−2

This diff is large: its 34 lines aren’t shown here.

apps/web/src/components/chat/ProviderModelPicker.browser.tsx+6−0

This diff is large: its 27 lines aren’t shown here.

apps/web/src/components/chat/TraitsPicker.browser.tsx+35−25

This diff is large: its 164 lines aren’t shown here.

apps/web/src/components/chat/TraitsPicker.tsx+12−5

This diff is large: its 48 lines aren’t shown here.

apps/web/src/components/chat/composerMenuHighlight.test.ts+51−0

This diff is large: its 52 lines aren’t shown here.

apps/web/src/components/chat/composerMenuHighlight.ts+20−0

This diff is large: its 21 lines aren’t shown here.

apps/web/src/components/chat/composerProviderRegistry.test.tsx+33−1

This diff is large: its 44 lines aren’t shown here.

apps/web/src/components/chat/composerProviderRegistry.tsx+93−53

This diff is large: its 227 lines aren’t shown here.

apps/web/src/components/chat/composerSlashCommandSearch.test.ts+68−0

This diff is large: its 69 lines aren’t shown here.

apps/web/src/components/chat/composerSlashCommandSearch.ts+83−0

This diff is large: its 84 lines aren’t shown here.

apps/web/src/components/composerFooterLayout.test.ts+0−99

This diff is large: its 110 lines aren’t shown here.

apps/web/src/components/composerFooterLayout.ts+0−45

This diff is large: its 56 lines aren’t shown here.

apps/web/src/components/composerInlineChip.ts+3−0

This diff is large: its 9 lines aren’t shown here.

apps/web/src/components/desktopUpdate.logic.test.ts+1−0

This diff is large: its 8 lines aren’t shown here.

apps/web/src/components/settings/ConnectionsSettings.tsx+1432−0

This diff is large: its 1433 lines aren’t shown here.

apps/web/src/components/settings/SettingsPanels.browser.tsx+625−12

This diff is large: its 686 lines aren’t shown here.

apps/web/src/components/settings/SettingsPanels.tsx+200−158

This diff is large: its 532 lines aren’t shown here.

apps/web/src/components/settings/SettingsSidebarNav.tsx+9−5

This diff is large: its 47 lines aren’t shown here.

apps/web/src/components/settings/settingsLayout.tsx+122−0

This diff is large: its 123 lines aren’t shown here.

apps/web/src/components/timelineHeight.test.ts+0−138

This diff is large: its 139 lines aren’t shown here.

apps/web/src/components/timelineHeight.ts+0−107

This diff is large: its 108 lines aren’t shown here.

apps/web/src/components/ui/combobox.tsx+15−0

This diff is large: its 29 lines aren’t shown here.

apps/web/src/components/ui/command.tsx+6−3

This diff is large: its 26 lines aren’t shown here.

apps/web/src/components/ui/qr-code.test.tsx+27−0

This diff is large: its 28 lines aren’t shown here.

apps/web/src/components/ui/qr-code.tsx+81−0

This diff is large: its 82 lines aren’t shown here.

apps/web/src/components/ui/select.tsx+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/components/ui/toast.logic.test.ts+52−1

This diff is large: its 62 lines aren’t shown here.

apps/web/src/components/ui/toast.logic.ts+27−0

This diff is large: its 35 lines aren’t shown here.

apps/web/src/components/ui/toast.tsx+48−32

This diff is large: its 167 lines aren’t shown here.

apps/web/src/composer-editor-mentions.test.ts+88−1

This diff is large: its 107 lines aren’t shown here.

apps/web/src/composer-editor-mentions.ts+185−27

This diff is large: its 267 lines aren’t shown here.

apps/web/src/composer-logic.test.ts+53−0

This diff is large: its 81 lines aren’t shown here.

apps/web/src/composer-logic.ts+50−14

This diff is large: its 121 lines aren’t shown here.

apps/web/src/composerDraftStore.test.ts+360−202

This diff is large: its 1103 lines aren’t shown here.

apps/web/src/composerDraftStore.ts+1652−984

This diff is large: its 3100 lines aren’t shown here.

apps/web/src/composerHandleContext.ts+10−0

This diff is large: its 11 lines aren’t shown here.

apps/web/src/contextMenuFallback.test.ts+221−0

This diff is large: its 222 lines aren’t shown here.

apps/web/src/contextMenuFallback.ts+115−47

This diff is large: its 188 lines aren’t shown here.

apps/web/src/diffRouteSearch.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/editorPreferences.ts+2−2

This diff is large: its 15 lines aren’t shown here.

apps/web/src/environmentApi.ts+86−0

This diff is large: its 87 lines aren’t shown here.

apps/web/src/environmentGrouping.test.ts+614−0

This diff is large: its 615 lines aren’t shown here.

apps/web/src/environments/primary/auth.ts+373−0

This diff is large: its 374 lines aren’t shown here.

apps/web/src/environments/primary/bootstrap.test.ts+170−0

This diff is large: its 171 lines aren’t shown here.

apps/web/src/environments/primary/context.ts+120−0

This diff is large: its 121 lines aren’t shown here.

apps/web/src/environments/primary/index.ts+35−0

This diff is large: its 36 lines aren’t shown here.

apps/web/src/environments/primary/target.ts+158−0

This diff is large: its 159 lines aren’t shown here.

apps/web/src/environments/remote/api.test.ts+235−0

This diff is large: its 236 lines aren’t shown here.

apps/web/src/environments/remote/api.ts+143−0

This diff is large: its 144 lines aren’t shown here.

apps/web/src/environments/remote/target.ts+86−0

This diff is large: its 87 lines aren’t shown here.

apps/web/src/environments/runtime/catalog.test.ts+141−0

This diff is large: its 142 lines aren’t shown here.

apps/web/src/environments/runtime/catalog.ts+341−0

This diff is large: its 342 lines aren’t shown here.

apps/web/src/environments/runtime/connection.test.ts+249−0

This diff is large: its 250 lines aren’t shown here.

apps/web/src/environments/runtime/connection.ts+173−0

This diff is large: its 174 lines aren’t shown here.

apps/web/src/environments/runtime/index.ts+29−0

This diff is large: its 30 lines aren’t shown here.

apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+105−0

This diff is large: its 106 lines aren’t shown here.

apps/web/src/environments/runtime/service.test.ts+41−0

This diff is large: its 42 lines aren’t shown here.

apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+298−0

This diff is large: its 299 lines aren’t shown here.

apps/web/src/environments/runtime/service.ts+1081−0

This diff is large: its 1082 lines aren’t shown here.

apps/web/src/filePathDisplay.test.ts+41−0

This diff is large: its 42 lines aren’t shown here.

apps/web/src/filePathDisplay.ts+57−0

This diff is large: its 58 lines aren’t shown here.

apps/web/src/historyBootstrap.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/hooks/useHandleNewThread.ts+112−52

This diff is large: its 239 lines aren’t shown here.

apps/web/src/hooks/useSettings.test.ts+0−16

This diff is large: its 17 lines aren’t shown here.

apps/web/src/hooks/useSettings.ts+101−177

This diff is large: its 318 lines aren’t shown here.

apps/web/src/hooks/useTheme.ts+78−5

This diff is large: its 148 lines aren’t shown here.

apps/web/src/hooks/useThreadActions.ts+138−67

This diff is large: its 323 lines aren’t shown here.

apps/web/src/index.css+73−6

This diff is large: its 145 lines aren’t shown here.

apps/web/src/keybindings.test.ts+55−0

This diff is large: its 90 lines aren’t shown here.

apps/web/src/keybindings.ts+23−0

This diff is large: its 37 lines aren’t shown here.

apps/web/src/lib/chatThreadActions.test.ts+107−0

This diff is large: its 108 lines aren’t shown here.

apps/web/src/lib/chatThreadActions.ts+98−0

This diff is large: its 99 lines aren’t shown here.

apps/web/src/lib/contextWindow.test.ts+2−2

This diff is large: its 15 lines aren’t shown here.

apps/web/src/lib/desktopUpdateReactQuery.test.ts+1−0

This diff is large: its 8 lines aren’t shown here.

apps/web/src/lib/gitReactQuery.test.ts+30−42

This diff is large: its 168 lines aren’t shown here.

apps/web/src/lib/gitReactQuery.ts+119−107

This diff is large: its 373 lines aren’t shown here.

apps/web/src/lib/gitStatusState.test.ts+325−0

This diff is large: its 326 lines aren’t shown here.

apps/web/src/lib/gitStatusState.ts+287−0

This diff is large: its 288 lines aren’t shown here.

apps/web/src/lib/projectPaths.test.ts+117−0

This diff is large: its 118 lines aren’t shown here.

apps/web/src/lib/projectPaths.ts+259−0

This diff is large: its 260 lines aren’t shown here.

apps/web/src/lib/projectReactQuery.ts+17−8

This diff is large: its 56 lines aren’t shown here.

apps/web/src/lib/providerReactQuery.test.ts+12−5

This diff is large: its 76 lines aren’t shown here.

apps/web/src/lib/providerReactQuery.ts+11−4

This diff is large: its 51 lines aren’t shown here.

apps/web/src/lib/terminalContext.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/lib/terminalStateCleanup.test.ts+21−14

This diff is large: its 80 lines aren’t shown here.

apps/web/src/lib/terminalStateCleanup.ts+9−11

This diff is large: its 45 lines aren’t shown here.

apps/web/src/lib/threadSort.test.ts+187−0

This diff is large: its 188 lines aren’t shown here.

apps/web/src/lib/threadSort.ts+73−0

This diff is large: its 74 lines aren’t shown here.

apps/web/src/lib/utils.test.ts+1−2

This diff is large: its 7 lines aren’t shown here.

apps/web/src/lib/utils.ts+6−42

This diff is large: its 65 lines aren’t shown here.

apps/web/src/lib/windowControlsOverlay.ts+40−0

This diff is large: its 41 lines aren’t shown here.

apps/web/src/localApi.test.ts+638−0

This diff is large: its 639 lines aren’t shown here.

apps/web/src/localApi.ts+154−0

This diff is large: its 155 lines aren’t shown here.

apps/web/src/logicalProject.ts+157−0

This diff is large: its 158 lines aren’t shown here.

apps/web/src/main.tsx+5−0

This diff is large: its 18 lines aren’t shown here.

apps/web/src/markdown-links.test.ts+42−1

This diff is large: its 56 lines aren’t shown here.

apps/web/src/markdown-links.ts+57−8

This diff is large: its 99 lines aren’t shown here.

apps/web/src/nativeApi.ts+0−31

This diff is large: its 32 lines aren’t shown here.

apps/web/src/observability/clientTracing.ts+144−0

This diff is large: its 145 lines aren’t shown here.

apps/web/src/orchestrationEventEffects.test.ts+15−15

This diff is large: its 101 lines aren’t shown here.

apps/web/src/orchestrationEventEffects.ts+4−4

This diff is large: its 33 lines aren’t shown here.

apps/web/src/orchestrationRecovery.ts+5−1

This diff is large: its 10 lines aren’t shown here.

apps/web/src/pairingUrl.ts+33−0

This diff is large: its 34 lines aren’t shown here.

apps/web/src/pendingUserInput.test.ts+113−55

This diff is large: its 277 lines aren’t shown here.

apps/web/src/pendingUserInput.ts+63−15

This diff is large: its 152 lines aren’t shown here.

apps/web/src/projectScripts.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/providerSkillPresentation.test.ts+57−0

This diff is large: its 58 lines aren’t shown here.

apps/web/src/providerSkillPresentation.ts+52−0

This diff is large: its 53 lines aren’t shown here.

apps/web/src/providerSkillSearch.test.ts+59−0

This diff is large: its 60 lines aren’t shown here.

apps/web/src/providerSkillSearch.ts+105−0

This diff is large: its 106 lines aren’t shown here.

apps/web/src/pullRequestReference.test.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/web/src/pullRequestReference.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/rightPanelLayout.ts+2−0

This diff is large: its 3 lines aren’t shown here.

apps/web/src/routeTree.gen.ts+81−17

This diff is large: its 224 lines aren’t shown here.

apps/web/src/routes/__root.tsx+116−341

This diff is large: its 565 lines aren’t shown here.

apps/web/src/routes/_chat.$environmentId.$threadId.tsxrenamed from apps/web/src/routes/_chat.$threadId.tsx+90−62

This diff is large: its 234 lines aren’t shown here.

apps/web/src/routes/_chat.draft.$draftId.tsx+86−0

This diff is large: its 87 lines aren’t shown here.

apps/web/src/routes/_chat.index.tsx+2−26

This diff is large: its 36 lines aren’t shown here.

apps/web/src/routes/_chat.tsx+41−25

This diff is large: its 123 lines aren’t shown here.

apps/web/src/routes/pair.tsx+40−0

This diff is large: its 41 lines aren’t shown here.

apps/web/src/routes/settings.connections.tsx+7−0

This diff is large: its 8 lines aren’t shown here.

apps/web/src/routes/settings.tsx+6−2

This diff is large: its 22 lines aren’t shown here.

apps/web/src/rpc/client.test.ts+0−231

This diff is large: its 232 lines aren’t shown here.

apps/web/src/rpc/client.ts+0−33

This diff is large: its 34 lines aren’t shown here.

apps/web/src/rpc/protocol.ts+163−13

This diff is large: its 191 lines aren’t shown here.

apps/web/src/rpc/requestLatencyState.test.ts+65−0

This diff is large: its 66 lines aren’t shown here.

apps/web/src/rpc/requestLatencyState.ts+133−0

This diff is large: its 134 lines aren’t shown here.

apps/web/src/rpc/serverState.test.ts+32−6

This diff is large: its 77 lines aren’t shown here.

apps/web/src/rpc/serverState.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/rpc/transportError.test.ts+24−0

This diff is large: its 25 lines aren’t shown here.

apps/web/src/rpc/transportError.ts+23−0

This diff is large: its 24 lines aren’t shown here.

apps/web/src/rpc/wsConnectionState.test.ts+75−0

This diff is large: its 76 lines aren’t shown here.

apps/web/src/rpc/wsConnectionState.ts+199−0

This diff is large: its 200 lines aren’t shown here.

apps/web/src/rpc/wsRpcClient.test.ts+104−0

This diff is large: its 105 lines aren’t shown here.

apps/web/src/rpc/wsRpcClient.tsrenamed from apps/web/src/wsRpcClient.ts+90−41

This diff is large: its 230 lines aren’t shown here.

apps/web/src/rpc/wsTransport.test.ts+1105−0

This diff is large: its 1106 lines aren’t shown here.

apps/web/src/rpc/wsTransport.ts+287−0

This diff is large: its 288 lines aren’t shown here.

apps/web/src/session-logic.test.ts+210−45

This diff is large: its 564 lines aren’t shown here.

apps/web/src/session-logic.ts+216−28

This diff is large: its 349 lines aren’t shown here.

apps/web/src/sidebarProjectGrouping.ts+118−0

This diff is large: its 119 lines aren’t shown here.

apps/web/src/store.test.ts+550−349

This diff is large: its 1300 lines aren’t shown here.

apps/web/src/store.ts+1192−388

This diff is large: its 1931 lines aren’t shown here.

apps/web/src/storeSelectors.ts+62−20

This diff is large: its 89 lines aren’t shown here.

apps/web/src/terminal-links.test.ts+104−0

This diff is large: its 123 lines aren’t shown here.

apps/web/src/terminal-links.ts+112−1

This diff is large: its 134 lines aren’t shown here.

apps/web/src/terminalStateStore.test.ts+127−78

This diff is large: its 399 lines aren’t shown here.

apps/web/src/terminalStateStore.ts+165−127

This diff is large: its 511 lines aren’t shown here.

apps/web/src/threadDerivation.ts+152−0

This diff is large: its 153 lines aren’t shown here.

apps/web/src/threadRoutes.test.ts+67−0

This diff is large: its 68 lines aren’t shown here.

apps/web/src/threadRoutes.ts+59−0

This diff is large: its 60 lines aren’t shown here.

apps/web/src/threadSelectionStore.test.ts+59−59

This diff is large: its 280 lines aren’t shown here.

apps/web/src/threadSelectionStore.ts+51−53

This diff is large: its 173 lines aren’t shown here.

apps/web/src/timestampFormat.test.ts+86−2

This diff is large: its 97 lines aren’t shown here.

apps/web/src/timestampFormat.ts+88−2

This diff is large: its 101 lines aren’t shown here.

apps/web/src/types.ts+28−0

This diff is large: its 61 lines aren’t shown here.

apps/web/src/uiStateStore.test.ts+185−29

This diff is large: its 345 lines aren’t shown here.

apps/web/src/uiStateStore.ts+197−49

This diff is large: its 450 lines aren’t shown here.

apps/web/src/vite-env.d.ts+2−2

This diff is large: its 17 lines aren’t shown here.

apps/web/src/worktreeCleanup.test.ts+15−12

This diff is large: its 80 lines aren’t shown here.

apps/web/src/wsNativeApi.test.ts+0−358

This diff is large: its 359 lines aren’t shown here.

apps/web/src/wsNativeApi.ts+0−107

This diff is large: its 108 lines aren’t shown here.

apps/web/src/wsTransport.test.ts+0−443

This diff is large: its 444 lines aren’t shown here.

apps/web/src/wsTransport.ts+0−131

This diff is large: its 132 lines aren’t shown here.

apps/web/test/authHttpHandlers.ts+41−0

This diff is large: its 42 lines aren’t shown here.

apps/web/test/wsRpcHarness.ts+13−4

This diff is large: its 40 lines aren’t shown here.

apps/web/tsconfig.json+4−0

This diff is large: its 11 lines aren’t shown here.

apps/web/vite.config.ts+48−2

This diff is large: its 85 lines aren’t shown here.

apps/web/vitest.browser.config.ts+8−0

This diff is large: its 22 lines aren’t shown here.

assets/dev/blueprint-icon-composer.icon/Assets/T3.svg+3−0

This diff is large: its 4 lines aren’t shown here.

assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg+0−0

Binary file not shown.

assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png+0−0

Binary file not shown.

assets/dev/blueprint-icon-composer.icon/icon.json+47−0

This diff is large: its 48 lines aren’t shown here.

assets/dev/blueprint-ios-1024.png+0−0

Binary file not shown.

assets/dev/blueprint-macos-1024.png+0−0

Binary file not shown.

assets/dev/blueprint-universal-1024.png+0−0

Binary file not shown.

assets/dev/blueprint-web-apple-touch-180.png+0−0

Binary file not shown.

assets/dev/blueprint-web-favicon-16x16.png+0−0

Binary file not shown.

assets/dev/blueprint-web-favicon-32x32.png+0−0

Binary file not shown.

assets/dev/blueprint-web-favicon.ico+0−0

Binary file not shown.

assets/dev/blueprint-windows.ico+0−0

Binary file not shown.

assets/nightly/blueprint-ios-1024.png+0−0

Binary file not shown.

assets/nightly/blueprint-macos-1024.png+0−0

Binary file not shown.

assets/nightly/blueprint-universal-1024.png+0−0

Binary file not shown.

assets/nightly/blueprint-web-apple-touch-180.png+0−0

Binary file not shown.

assets/nightly/blueprint-web-favicon-16x16.png+0−0

Binary file not shown.

assets/nightly/blueprint-web-favicon-32x32.png+0−0

Binary file not shown.

assets/nightly/blueprint-web-favicon.ico+0−0

Binary file not shown.

assets/nightly/blueprint-windows.ico+0−0

Binary file not shown.

bun.lock+208−27

This diff is large: its 698 lines aren’t shown here.

docs/observability.md+4−4

This diff is large: its 30 lines aren’t shown here.

docs/release.md+37−12

This diff is large: its 85 lines aren’t shown here.

package.json+21−11

This diff is large: its 66 lines aren’t shown here.

packages/client-runtime/package.json+25−0

This diff is large: its 26 lines aren’t shown here.

packages/client-runtime/src/index.ts+2−0

This diff is large: its 3 lines aren’t shown here.

packages/client-runtime/src/knownEnvironment.test.ts+92−0

This diff is large: its 93 lines aren’t shown here.

packages/client-runtime/src/knownEnvironment.ts+53−0

This diff is large: its 54 lines aren’t shown here.

packages/client-runtime/src/scoped.ts+64−0

This diff is large: its 65 lines aren’t shown here.

packages/client-runtime/tsconfig.json+4−0

This diff is large: its 5 lines aren’t shown here.

packages/contracts/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/contracts/src/auth.ts+266−0

This diff is large: its 267 lines aren’t shown here.

packages/contracts/src/baseSchemas.ts+7−2

This diff is large: its 28 lines aren’t shown here.

packages/contracts/src/editor.ts+13−11

This diff is large: its 45 lines aren’t shown here.

packages/contracts/src/environment.ts+78−0

This diff is large: its 79 lines aren’t shown here.

packages/contracts/src/filesystem.ts+30−0

This diff is large: its 31 lines aren’t shown here.

packages/contracts/src/git.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/git.ts+50−2

This diff is large: its 103 lines aren’t shown here.

packages/contracts/src/index.ts+17−14

This diff is large: its 32 lines aren’t shown here.

packages/contracts/src/ipc.ts+145−38

This diff is large: its 286 lines aren’t shown here.

packages/contracts/src/keybindings.test.ts+7−1

This diff is large: its 22 lines aren’t shown here.

packages/contracts/src/keybindings.ts+2−1

This diff is large: its 15 lines aren’t shown here.

packages/contracts/src/model.ts+20−10

This diff is large: its 58 lines aren’t shown here.

packages/contracts/src/orchestration.test.ts+18−1

This diff is large: its 40 lines aren’t shown here.

packages/contracts/src/orchestration.ts+140−28

This diff is large: its 321 lines aren’t shown here.

packages/contracts/src/project.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/provider.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/provider.ts+3−3

This diff is large: its 25 lines aren’t shown here.

packages/contracts/src/providerRuntime.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/providerRuntime.ts+4−4

This diff is large: its 26 lines aren’t shown here.

packages/contracts/src/rpc.ts+64−27

This diff is large: its 232 lines aren’t shown here.

packages/contracts/src/server.test.ts+26−0

This diff is large: its 27 lines aren’t shown here.

packages/contracts/src/server.ts+40−7

This diff is large: its 89 lines aren’t shown here.

packages/contracts/src/settings.ts+50−27

This diff is large: its 143 lines aren’t shown here.

packages/contracts/src/terminal.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/terminal.ts+3−3

This diff is large: its 17 lines aren’t shown here.

packages/shared/package.json+16−0

This diff is large: its 23 lines aren’t shown here.

packages/shared/src/DrainableWorker.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/shared/src/KeyedCoalescingWorker.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/shared/src/Net.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/shared/src/Net.ts+2−2

This diff is large: its 17 lines aren’t shown here.

packages/shared/src/String.test.ts+1−1

This diff is large: its 8 lines aren’t shown here.

packages/shared/src/cliArgs.test.ts+134−0

This diff is large: its 135 lines aren’t shown here.

packages/shared/src/cliArgs.ts+76−0

This diff is large: its 77 lines aren’t shown here.

packages/shared/src/git.test.ts+135−0

This diff is large: its 136 lines aren’t shown here.

packages/shared/src/git.ts+202−1

This diff is large: its 218 lines aren’t shown here.

packages/shared/src/model.test.ts+2−1

This diff is large: its 17 lines aren’t shown here.

packages/shared/src/model.ts+2−2

This diff is large: its 18 lines aren’t shown here.

packages/shared/src/path.test.ts+34−0

This diff is large: its 35 lines aren’t shown here.

packages/shared/src/path.ts+22−0

This diff is large: its 23 lines aren’t shown here.

packages/shared/src/qrCode.ts+985−0

This diff is large: its 986 lines aren’t shown here.

packages/shared/src/searchRanking.test.ts+95−0

This diff is large: its 96 lines aren’t shown here.

packages/shared/src/searchRanking.ts+192−0

This diff is large: its 193 lines aren’t shown here.

packages/shared/src/serverSettings.test.ts+60−1

This diff is large: its 75 lines aren’t shown here.

packages/shared/src/serverSettings.ts+34−2

This diff is large: its 45 lines aren’t shown here.

packages/shared/src/shell.test.ts+331−1

This diff is large: its 352 lines aren’t shown here.

packages/shared/src/shell.ts+384−20

This diff is large: its 450 lines aren’t shown here.

scripts/build-desktop-artifact.test.ts+101−0

This diff is large: its 102 lines aren’t shown here.

scripts/build-desktop-artifact.ts+225−97

This diff is large: its 619 lines aren’t shown here.

scripts/dev-runner.test.ts+83−31

This diff is large: its 256 lines aren’t shown here.

scripts/dev-runner.ts+40−74

This diff is large: its 225 lines aren’t shown here.

scripts/lib/brand-assets.ts+6−0

This diff is large: its 13 lines aren’t shown here.

scripts/lib/build-target-arch.test.ts+61−0

This diff is large: its 62 lines aren’t shown here.

scripts/lib/build-target-arch.ts+50−0

This diff is large: its 51 lines aren’t shown here.

scripts/lib/resolve-catalog.ts+3−3

This diff is large: its 14 lines aren’t shown here.

scripts/lib/update-manifest.tsrenamed from scripts/merge-mac-update-manifests.ts+58−68

This diff is large: its 296 lines aren’t shown here.

scripts/merge-mac-update-manifests.test.ts+0−108

This diff is large: its 109 lines aren’t shown here.

scripts/merge-update-manifests.test.ts+306−0

This diff is large: its 307 lines aren’t shown here.

scripts/merge-update-manifests.ts+108−0

This diff is large: its 109 lines aren’t shown here.

scripts/mock-update-server.test.ts+104−0

This diff is large: its 105 lines aren’t shown here.

scripts/mock-update-server.ts+147−37

This diff is large: its 192 lines aren’t shown here.

scripts/release-smoke.ts+247−3

This diff is large: its 299 lines aren’t shown here.

scripts/resolve-nightly-release.test.ts+32−0

This diff is large: its 33 lines aren’t shown here.

scripts/resolve-nightly-release.ts+137−0

This diff is large: its 138 lines aren’t shown here.

scripts/resolve-previous-release-tag.ts+205−0

This diff is large: its 206 lines aren’t shown here.

scripts/tsconfig.json+2−4

This diff is large: its 13 lines aren’t shown here.

scripts/update-release-package-versions.test.ts+213−0

This diff is large: its 214 lines aren’t shown here.

scripts/update-release-package-versions.ts+68−86

This diff is large: its 179 lines aren’t shown here.

tsconfig.base.json+8−3

This diff is large: its 17 lines aren’t shown here.

turbo.json+1−1

This diff is large: its 12 lines aren’t shown here.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t