@open-mercato/cezar 0.9.2 → 0.10.0-pr892.1298

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -38
  3. package/dist/agent-config/account-identity.d.ts +44 -0
  4. package/dist/agent-config/account-identity.js +128 -0
  5. package/dist/agent-config/account-identity.js.map +1 -0
  6. package/dist/agent-config/catalog.d.ts +8 -0
  7. package/dist/agent-config/catalog.js +18 -0
  8. package/dist/agent-config/catalog.js.map +1 -1
  9. package/dist/agent-config/model-settings/claude.d.ts +2 -0
  10. package/dist/agent-config/model-settings/claude.js +11 -0
  11. package/dist/agent-config/model-settings/claude.js.map +1 -0
  12. package/dist/agent-config/model-settings/codex.d.ts +2 -0
  13. package/dist/agent-config/model-settings/codex.js +16 -0
  14. package/dist/agent-config/model-settings/codex.js.map +1 -0
  15. package/dist/agent-config/model-settings/opencode.d.ts +2 -0
  16. package/dist/agent-config/model-settings/opencode.js +8 -0
  17. package/dist/agent-config/model-settings/opencode.js.map +1 -0
  18. package/dist/agent-config/model-settings/pi.d.ts +12 -0
  19. package/dist/agent-config/model-settings/pi.js +18 -0
  20. package/dist/agent-config/model-settings/pi.js.map +1 -0
  21. package/dist/agent-config/model-settings/shared.d.ts +9 -0
  22. package/dist/agent-config/model-settings/shared.js +93 -0
  23. package/dist/agent-config/model-settings/shared.js.map +1 -0
  24. package/dist/agent-config/model-settings/types.d.ts +9 -0
  25. package/dist/agent-config/model-settings/types.js +2 -0
  26. package/dist/agent-config/model-settings/types.js.map +1 -0
  27. package/dist/agent-config/models.d.ts +9 -0
  28. package/dist/agent-config/models.js +32 -0
  29. package/dist/agent-config/models.js.map +1 -0
  30. package/dist/agent-config/service.js +4 -4
  31. package/dist/agent-config/service.js.map +1 -1
  32. package/dist/automations/types.d.ts +4 -0
  33. package/dist/automations/types.js +2 -1
  34. package/dist/automations/types.js.map +1 -1
  35. package/dist/config.d.ts +10 -1
  36. package/dist/config.js +48 -5
  37. package/dist/config.js.map +1 -1
  38. package/dist/contract/agent-config.d.ts +2 -0
  39. package/dist/contract/agent-profiles.d.ts +347 -0
  40. package/dist/contract/automations.d.ts +24 -0
  41. package/dist/contract/events.d.ts +59 -0
  42. package/dist/contract/github.d.ts +88 -0
  43. package/dist/contract/health.d.ts +7 -1
  44. package/dist/contract/index.d.ts +1 -0
  45. package/dist/contract/index.js +1005 -555
  46. package/dist/contract/projects.d.ts +33 -5
  47. package/dist/contract/runs.d.ts +247 -0
  48. package/dist/contract/skills.d.ts +7 -0
  49. package/dist/contract/workflows.d.ts +6 -0
  50. package/dist/contract/workspace.d.ts +137 -2
  51. package/dist/core/agent-env.js +41 -16
  52. package/dist/core/agent-env.js.map +1 -1
  53. package/dist/core/agent-model-policy.d.ts +10 -0
  54. package/dist/core/agent-model-policy.js +36 -0
  55. package/dist/core/agent-model-policy.js.map +1 -0
  56. package/dist/core/agent-profiles.d.ts +62 -0
  57. package/dist/core/agent-profiles.js +90 -0
  58. package/dist/core/agent-profiles.js.map +1 -0
  59. package/dist/core/agent-runner.d.ts +37 -6
  60. package/dist/core/agent-runner.js +35 -3
  61. package/dist/core/agent-runner.js.map +1 -1
  62. package/dist/core/backend-detect.d.ts +5 -5
  63. package/dist/core/backend-detect.js +44 -7
  64. package/dist/core/backend-detect.js.map +1 -1
  65. package/dist/core/claude-cli-runner.js +10 -5
  66. package/dist/core/claude-cli-runner.js.map +1 -1
  67. package/dist/core/codex-app-server-runner.js +13 -3
  68. package/dist/core/codex-app-server-runner.js.map +1 -1
  69. package/dist/core/codex-app-server-transport.js +7 -2
  70. package/dist/core/codex-app-server-transport.js.map +1 -1
  71. package/dist/core/model-identity.d.ts +23 -11
  72. package/dist/core/model-identity.js +41 -24
  73. package/dist/core/model-identity.js.map +1 -1
  74. package/dist/core/model-presets.d.ts +8 -2
  75. package/dist/core/model-presets.js +51 -8
  76. package/dist/core/model-presets.js.map +1 -1
  77. package/dist/core/opencode-model-catalog.d.ts +32 -0
  78. package/dist/core/opencode-model-catalog.js +153 -0
  79. package/dist/core/opencode-model-catalog.js.map +1 -0
  80. package/dist/core/opencode-server-runner.d.ts +2 -0
  81. package/dist/core/opencode-server-runner.js +42 -11
  82. package/dist/core/opencode-server-runner.js.map +1 -1
  83. package/dist/core/pi-runner.d.ts +24 -0
  84. package/dist/core/pi-runner.js +349 -0
  85. package/dist/core/pi-runner.js.map +1 -0
  86. package/dist/core/pi-ui-mapper.d.ts +34 -0
  87. package/dist/core/pi-ui-mapper.js +270 -0
  88. package/dist/core/pi-ui-mapper.js.map +1 -0
  89. package/dist/core/provider-auth.d.ts +95 -3
  90. package/dist/core/provider-auth.js +234 -15
  91. package/dist/core/provider-auth.js.map +1 -1
  92. package/dist/core/provider-availability.d.ts +1 -1
  93. package/dist/core/provider-availability.js +2 -1
  94. package/dist/core/provider-availability.js.map +1 -1
  95. package/dist/core/runner-factory.js +3 -0
  96. package/dist/core/runner-factory.js.map +1 -1
  97. package/dist/core/shell-env.d.ts +28 -0
  98. package/dist/core/shell-env.js +56 -0
  99. package/dist/core/shell-env.js.map +1 -0
  100. package/dist/core/ui-events.d.ts +1 -1
  101. package/dist/core/usage-limit.d.ts +42 -0
  102. package/dist/core/usage-limit.js +209 -0
  103. package/dist/core/usage-limit.js.map +1 -0
  104. package/dist/git-diff-base.d.ts +51 -26
  105. package/dist/git-diff-base.js +163 -37
  106. package/dist/git-diff-base.js.map +1 -1
  107. package/dist/git-worktree.d.ts +15 -6
  108. package/dist/git-worktree.js +10 -3
  109. package/dist/git-worktree.js.map +1 -1
  110. package/dist/index.js +3 -2
  111. package/dist/index.js.map +1 -1
  112. package/dist/paths.d.ts +53 -5
  113. package/dist/paths.js +63 -9
  114. package/dist/paths.js.map +1 -1
  115. package/dist/planner.js +6 -0
  116. package/dist/planner.js.map +1 -1
  117. package/dist/release/snapshot.d.ts +13 -2
  118. package/dist/release/snapshot.js +25 -1
  119. package/dist/release/snapshot.js.map +1 -1
  120. package/dist/runs/agent-tmpdir.d.ts +41 -0
  121. package/dist/runs/agent-tmpdir.js +183 -0
  122. package/dist/runs/agent-tmpdir.js.map +1 -0
  123. package/dist/runs/auto-name.js +6 -1
  124. package/dist/runs/auto-name.js.map +1 -1
  125. package/dist/runs/event-history.d.ts +50 -0
  126. package/dist/runs/event-history.js +613 -0
  127. package/dist/runs/event-history.js.map +1 -0
  128. package/dist/runs/run-index.d.ts +19 -0
  129. package/dist/runs/run-index.js +43 -0
  130. package/dist/runs/run-index.js.map +1 -0
  131. package/dist/runs/store.d.ts +78 -19
  132. package/dist/runs/store.js +175 -43
  133. package/dist/runs/store.js.map +1 -1
  134. package/dist/server/capabilities.d.ts +15 -1
  135. package/dist/server/capabilities.js +16 -1
  136. package/dist/server/capabilities.js.map +1 -1
  137. package/dist/server/forge/github.d.ts +169 -0
  138. package/dist/server/forge/github.js +511 -0
  139. package/dist/server/forge/github.js.map +1 -1
  140. package/dist/server/forge/index.d.ts +9 -0
  141. package/dist/server/forge/index.js +14 -0
  142. package/dist/server/forge/index.js.map +1 -1
  143. package/dist/server/git-changes.d.ts +7 -5
  144. package/dist/server/git-changes.js +15 -10
  145. package/dist/server/git-changes.js.map +1 -1
  146. package/dist/server/github.d.ts +2 -2
  147. package/dist/server/github.js +1 -1
  148. package/dist/server/github.js.map +1 -1
  149. package/dist/server/open-in-app.d.ts +2 -1
  150. package/dist/server/open-in-app.js +3 -1
  151. package/dist/server/open-in-app.js.map +1 -1
  152. package/dist/server/open-in-terminal.d.ts +46 -1
  153. package/dist/server/open-in-terminal.js +72 -13
  154. package/dist/server/open-in-terminal.js.map +1 -1
  155. package/dist/server/provider-action-gate.js +3 -1
  156. package/dist/server/provider-action-gate.js.map +1 -1
  157. package/dist/server/server.d.ts +2206 -434
  158. package/dist/server/server.js +1219 -136
  159. package/dist/server/server.js.map +1 -1
  160. package/dist/server-install/engine.js +5 -18
  161. package/dist/server-install/engine.js.map +1 -1
  162. package/dist/server-install/steps.js +6 -2
  163. package/dist/server-install/steps.js.map +1 -1
  164. package/dist/skills.js +4 -0
  165. package/dist/skills.js.map +1 -1
  166. package/dist/workflows/run.d.ts +229 -9
  167. package/dist/workflows/run.js +877 -147
  168. package/dist/workflows/run.js.map +1 -1
  169. package/dist/workflows/types.d.ts +3 -0
  170. package/dist/workflows/types.js +11 -2
  171. package/dist/workflows/types.js.map +1 -1
  172. package/dist/workspace/agent-accounts.d.ts +153 -0
  173. package/dist/workspace/agent-accounts.js +304 -0
  174. package/dist/workspace/agent-accounts.js.map +1 -0
  175. package/dist/workspace/agent-profiles.d.ts +78 -0
  176. package/dist/workspace/agent-profiles.js +115 -0
  177. package/dist/workspace/agent-profiles.js.map +1 -0
  178. package/dist/workspace/config.d.ts +27 -1
  179. package/dist/workspace/config.js +83 -2
  180. package/dist/workspace/config.js.map +1 -1
  181. package/dist/workspace/projects-cli.js +54 -4
  182. package/dist/workspace/projects-cli.js.map +1 -1
  183. package/dist/workspace/projects.d.ts +19 -1
  184. package/dist/workspace/projects.js +34 -1
  185. package/dist/workspace/projects.js.map +1 -1
  186. package/dist/workspace/semaphore.d.ts +46 -1
  187. package/dist/workspace/semaphore.js +41 -4
  188. package/dist/workspace/semaphore.js.map +1 -1
  189. package/dist/workspace/ui-state.d.ts +3 -2
  190. package/dist/workspace/ui-state.js +3 -2
  191. package/dist/workspace/ui-state.js.map +1 -1
  192. package/package.json +3 -3
  193. package/scripts/mock-claude.mjs +20 -0
  194. package/scripts/mock-pi-rpc.mjs +83 -0
  195. package/web/dist/assets/{alert-dialog-ghffK6g9.js → alert-dialog-Byp42_IG.js} +1 -1
  196. package/web/dist/assets/arrow-down-CGDbDYKD.js +1 -0
  197. package/web/dist/assets/arrow-left-ESCjr1Hq.js +1 -0
  198. package/web/dist/assets/centered-state-LfAW-igm.js +43 -0
  199. package/web/dist/assets/chevron-right-Du-P1IUd.js +1 -0
  200. package/web/dist/assets/{collapsible-B0JVzd0B.js → collapsible-CBiiqDJM.js} +1 -1
  201. package/web/dist/assets/{commit-list-wm6DPHNs.js → commit-list-6PPve7PE.js} +1 -1
  202. package/web/dist/assets/{compare-variants-DbdP4Y5T.js → compare-variants-BDnp6mXJ.js} +1 -1
  203. package/web/dist/assets/{diff-Bizc96qe.js → diff-_jK0OWpF.js} +2 -2
  204. package/web/dist/assets/{diff-stat-Dyfg3ltF.js → diff-stat-CIrTu4Ym.js} +1 -1
  205. package/web/dist/assets/{diff-view-CG8ek6tC.js → diff-view-COA_5IyY.js} +1 -1
  206. package/web/dist/assets/dropdown-menu-BofYpFFw.js +1 -0
  207. package/web/dist/assets/editable-title-_J0zFDl-.js +1 -0
  208. package/web/dist/assets/{ellipsis-vertical-DpGTRqC2.js → ellipsis-vertical-BeMR-l62.js} +1 -1
  209. package/web/dist/assets/{file-DlUGpUlb.js → file-DY996Pee.js} +1 -1
  210. package/web/dist/assets/{folder-BAY6Vddy.js → folder-NO3Ec-TY.js} +1 -1
  211. package/web/dist/assets/git-toolbar-BqJeUWcD.js +1 -0
  212. package/web/dist/assets/github-C96HPV9E.js +1 -0
  213. package/web/dist/assets/{image-preview-BWGk5fPX.js → image-preview-D9cCDfKQ.js} +1 -1
  214. package/web/dist/assets/index-Bx-grqa9.css +2 -0
  215. package/web/dist/assets/index-DoVvGDfW.js +7 -0
  216. package/web/dist/assets/{markdown-BGBOGCA_.js → markdown-DDzk11zj.js} +1 -1
  217. package/web/dist/assets/new-task-form-BNLLgC6P.js +1 -0
  218. package/web/dist/assets/pill-CVM1EmV4.js +1 -0
  219. package/web/dist/assets/{project-router-Cx8NPoqn.js → project-router-LRCtP48W.js} +1 -1
  220. package/web/dist/assets/prompt-templates-DFOvYgtW.js +15 -0
  221. package/web/dist/assets/{refresh-cw-BoGdAykg.js → refresh-cw-D1ys6QY2.js} +1 -1
  222. package/web/dist/assets/repo-git-CzUnsgWY.js +1 -0
  223. package/web/dist/assets/{run-diff-EcOkf3ti.js → run-diff-BlnqwfSS.js} +2 -2
  224. package/web/dist/assets/run-header-Bn7cN0Z1.js +1 -0
  225. package/web/dist/assets/{search-x-X3EpVhgs.js → search-x-D6FSpxId.js} +1 -1
  226. package/web/dist/assets/{skill-empty-hint-CZLALx6l.js → skill-empty-hint-iwpCcqgI.js} +1 -1
  227. package/web/dist/assets/skills-CezbOaol.js +1 -0
  228. package/web/dist/assets/{sparkles-Dt4q7pbG.js → sparkles-MrQrgCI9.js} +1 -1
  229. package/web/dist/assets/{square-terminal-hwBL8XsC.js → square-terminal-TTH64lO7.js} +1 -1
  230. package/web/dist/assets/tab-link-FYksz037.js +1 -0
  231. package/web/dist/assets/task-changes-ClWlnzBL.js +1 -0
  232. package/web/dist/assets/{task-commits-BTTA5k5T.js → task-commits-Crbrl86b.js} +1 -1
  233. package/web/dist/assets/{task-files-Da8_4C9g.js → task-files-uSCWyD0-.js} +2 -2
  234. package/web/dist/assets/task-thread-DPmamBA1.js +9 -0
  235. package/web/dist/assets/{textarea-CUdokhkv.js → textarea-B9Ahj4E4.js} +1 -1
  236. package/web/dist/assets/thread-loading-BH_ypQoy.js +1 -0
  237. package/web/dist/assets/{trash-2-DhVIdWv8.js → trash-2-D8S3V3fu.js} +1 -1
  238. package/web/dist/assets/{triangle-alert-XYT0DGCI.js → triangle-alert-bB7p9n5E.js} +1 -1
  239. package/web/dist/assets/{upload-BKjN59-1.js → upload-Bd5oRLPV.js} +1 -1
  240. package/web/dist/assets/{use-desktop-Bf5LS_P4.js → use-desktop-BNvbRBeZ.js} +1 -1
  241. package/web/dist/assets/{use-submit-shortcut-CzVyoAMd.js → use-submit-shortcut-B-xhu4Bf.js} +1 -1
  242. package/web/dist/assets/utils-BhpbVMwN.js +64 -0
  243. package/web/dist/assets/{workflows-o2QU_pvU.js → workflows-RmfbM9Vy.js} +3 -3
  244. package/web/dist/assets/{zoomable-image-7WyUiaVD.js → zoomable-image-BCW6ZBV0.js} +1 -1
  245. package/web/dist/index.html +24 -23
  246. package/web/dist/assets/arrow-down-Cz8qb71e.js +0 -1
  247. package/web/dist/assets/arrow-left-wcYKQXI_.js +0 -1
  248. package/web/dist/assets/centered-state-ZtnFHA-n.js +0 -43
  249. package/web/dist/assets/chevron-right-G4caF2QV.js +0 -1
  250. package/web/dist/assets/circle-check--KvBxXWp.js +0 -1
  251. package/web/dist/assets/circle-x-C3EFZ3xf.js +0 -1
  252. package/web/dist/assets/dropdown-menu-C1Dlf35P.js +0 -1
  253. package/web/dist/assets/editable-title-5rC_QRBZ.js +0 -1
  254. package/web/dist/assets/git-pull-request-agOG0iGM.js +0 -1
  255. package/web/dist/assets/git-toolbar-DGiNMmtY.js +0 -1
  256. package/web/dist/assets/github-Vp7lxYGQ.js +0 -1
  257. package/web/dist/assets/index-DSxyJpuK.css +0 -2
  258. package/web/dist/assets/index-DiawI6RX.js +0 -6
  259. package/web/dist/assets/new-task-form-BpAw_Dks.js +0 -1
  260. package/web/dist/assets/pill-Dvs-P0xy.js +0 -1
  261. package/web/dist/assets/prompt-templates-CSBYBR1I.js +0 -15
  262. package/web/dist/assets/repo-git-PmX-BNMo.js +0 -1
  263. package/web/dist/assets/run-header-BGiv6_qX.js +0 -1
  264. package/web/dist/assets/skills-CC91ouGH.js +0 -1
  265. package/web/dist/assets/tab-link-DIlR5T07.js +0 -1
  266. package/web/dist/assets/task-changes-XkHDxzQA.js +0 -1
  267. package/web/dist/assets/task-thread-DoNXUsHr.js +0 -9
  268. package/web/dist/assets/thread-loading-B8q8ukXJ.js +0 -1
  269. package/web/dist/assets/utils-DsLuRXwV.js +0 -64
@@ -16,9 +16,16 @@ import { streamSSE } from 'hono/streaming';
16
16
  import { jsonZodValidator, paramZodValidator, queryZodValidator } from './validators.js';
17
17
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
18
18
  import { z } from 'zod';
19
+ import { setWorkspaceUiStateInputSchema, } from '../contract/index.js';
20
+ // A contract VALUE, like `workspaceUiStateSchema` in workspace/migrations.ts — the request
21
+ // schema this route validates with is the same one the client compiles against.
22
+ import { modelDiscoveryRunnerSchema, openProjectInSchema, updateProjectInputSchema, } from '../contract/index.js';
19
23
  import { detectEnvironment } from '../core/backend-detect.js';
24
+ import { RUNNER_IDS } from '../core/agent-runner.js';
25
+ import { AGENT_MODELS_LOCKED_ERROR, agentModelsLocked } from '../core/agent-model-policy.js';
20
26
  import { discoverCodexModels } from '../core/codex-model-catalog.js';
21
- import { PROVIDER_IDS, ProviderAuthService, } from '../core/provider-auth.js';
27
+ import { discoverOpencodeModels } from '../core/opencode-model-catalog.js';
28
+ import { PROVIDER_IDS, ProviderAuthService, providerAuthChecksDisabled, } from '../core/provider-auth.js';
22
29
  import { applyProviderEnablement } from '../core/provider-availability.js';
23
30
  import { RunnerModelCatalog } from '../core/runner-model-catalog.js';
24
31
  import { currentUsage, onUsage } from '../core/process-usage.js';
@@ -30,28 +37,38 @@ import { SkillsUpdateConflictError, SkillsUpdateCoordinator, SkillsUpdateService
30
37
  import { getTeamSkillsCached, refreshTeamSkills, waitForTeamSkills } from '../skills-remote.js';
31
38
  import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../handoff.js';
32
39
  import { markStarted, onTodosChanged, readTodos, removeTodo, todoTaskText } from '../todos.js';
40
+ import { HistoryCursorError, deriveRunContextEvents, readEventsAfterLiveCursor, readRunHistoryPage, validateLiveCursor, } from '../runs/event-history.js';
41
+ import { readRunIndexFromDisk } from '../runs/run-index.js';
33
42
  import { isV2WireEventType } from '../runs/ui-event-sink.js';
43
+ import { runEventsQuerySchema, runHistoryQuerySchema, runIdParamSchema, } from '../contract/index.js';
34
44
  import { removeWorktree, worktreeDiff, worktreeDiffStat, worktreeSizeBytes } from '../git-worktree.js';
35
45
  import { isReclaimable, reclaimWorktrees } from '../runs/retention.js';
36
46
  import { getBranches, getCommit, getDiff, getLog, getRepoInfo, getStatus } from './git.js';
37
47
  import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, imageMimeType, isOsOpenableImage, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
38
48
  import { gatedSkillsRepos, loadConfig, resolveWorktreeRetention } from '../config.js';
39
49
  import { findConfigFile } from '../agent-config/catalog.js';
40
- import { readConfigFile, writeConfigFile } from '../agent-config/files.js';
50
+ import { readConfigFile, statConfigPath, writeConfigFile } from '../agent-config/files.js';
51
+ import { readAgentModelDefaults } from '../agent-config/models.js';
41
52
  import { listAgentConfig } from '../agent-config/service.js';
53
+ import { listConfigFiles } from '../agent-config/catalog.js';
54
+ import { readAccountIdentity } from '../agent-config/account-identity.js';
42
55
  import { PROJECT_ID_RE, defaultWorkspaceConfig, effectiveSkillsAutoUpdate, loadWorkspaceConfig, mergeWriteWorkspaceConfig, effectiveComposerDefault, } from '../workspace/config.js';
43
- import { allocateProjectSlug, listProjects, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
56
+ import { CONTROL_CHARS_RE, DEFAULT_AGENT_ACCOUNT_ID, defaultAgentAccountStore, isAbsoluteConfigDir, loadAgentAccounts, mergeWriteAgentAccounts, } from '../workspace/agent-accounts.js';
57
+ import { defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfileEnvForRoot, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
58
+ import { PROFILE_CAPABLE_PROVIDERS, profileEnv, supportsProfiles } from '../core/agent-profiles.js';
59
+ import { withEnvPrefix } from '../core/shell-env.js';
60
+ import { allocateProjectSlug, listProjects, normalizeProjectTags, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
44
61
  import { mergeWriteWorkspaceUiState, readWorkspaceUiState } from '../workspace/ui-state.js';
45
62
  import { checkoutRepo } from './checkout.js';
46
63
  import { ProjectContextError, ProjectContexts } from './project-context.js';
47
64
  import { reviewGateEnabled } from '../runs/review-gate.js';
48
65
  import { readUiState, uiStatePath } from '../ui-state.js';
49
- import { expandTilde } from '../paths.js';
66
+ import { agentHomePaths, expandTilde } from '../paths.js';
50
67
  import { isLoopbackHostHeader, normalizeHostname, resolveCapabilities } from './capabilities.js';
51
68
  import { createSocketHub } from './ws.js';
52
69
  import { browseDirectory, isInsideBrowseRoot, isLexicallyInsideBrowseRoot, resolveBrowseRoot } from './fs-browse.js';
53
70
  import { parseRemote, resolveForge } from './forge/index.js';
54
- import { fetchGithub, fetchGithubChecks, fetchGithubComments, fetchGithubPrDiff, GithubPrNotFoundError, GH_CHECKS_MAX } from './github.js';
71
+ import { fetchGithub, fetchGithubChecks, fetchGithubComments, fetchGithubPrDiff, fetchGithubRefStatus, forgetRefStatus, readCachedRefStatuses, refNumberFromUrl, GithubPrNotFoundError, GH_CHECKS_MAX, GH_REF_STATUS_MAX } from './github.js';
55
72
  import { ensureLaunchKey } from './launch-key.js';
56
73
  import { openInTerminal } from './open-in-terminal.js';
57
74
  import { agentCliRunner, detectOpenTargets, openFileInDefaultApp, openInApp } from './open-in-app.js';
@@ -66,7 +83,52 @@ import { ASSET_CACHE_CONTROL, BUILD_HINT_HTML, assetContentType, isSafeAssetFile
66
83
  const projectIdSchema = z.union([z.literal('default'), z.string().regex(PROJECT_ID_RE)]);
67
84
  const providerConnectSchema = z.object({
68
85
  provider: z.enum(PROVIDER_IDS),
86
+ /** Which agent account to sign in (spec 2026-07-29-agent-profiles). Absent = the discovered
87
+ * default, which is what every pre-profiles client sends. Without this, "Connect" on a second
88
+ * Claude account would open a login for the FIRST one and report success. */
89
+ profileId: z.string().max(64).optional(),
69
90
  }).strict();
91
+ /** Agent-account bodies (spec 2026-07-29-agent-profiles). Bounds mirror `agentProfileSchema` in
92
+ * src/workspace/config.ts exactly, so a value these accept can never be degraded away by the
93
+ * next load's `.catch`. The id is allocated server-side and is never a request field. */
94
+ const createAgentProfileSchema = z.object({
95
+ provider: z.enum(PROVIDER_IDS),
96
+ label: z.string().trim().max(200).optional(),
97
+ configDir: z.string().trim().min(1).max(4096),
98
+ }).strict();
99
+ /** `POST …/agent-profiles/:id/open` — a catalog id (or `folder`) plus an optional open target. */
100
+ const openAgentAccountFileSchema = z.object({
101
+ file: z.string().min(1).max(200),
102
+ target: z.string().min(1).max(64).optional(),
103
+ }).strict();
104
+ const updateAgentProfileSchema = z.object({
105
+ label: z.string().trim().max(200).optional(),
106
+ configDir: z.string().trim().min(1).max(4096).optional(),
107
+ }).strict().refine((value) => value.label !== undefined || value.configDir !== undefined, 'send label or configDir');
108
+ /** `PUT …/agent-profiles/selection` — which account a project uses for one provider. `null`
109
+ * clears it back to the discovered account. */
110
+ const selectAgentProfileSchema = z.object({
111
+ /** `null` targets the machine-wide default rather than one repo. */
112
+ projectId: z.string().min(1).max(64).nullable(),
113
+ provider: z.enum(PROVIDER_IDS),
114
+ profileId: z.string().max(64).nullable(),
115
+ }).strict();
116
+ /** The hosted-mode refusal, worded like the agent-config one it mirrors. */
117
+ const hostedProfileRefusal = {
118
+ error: 'agent accounts are managed from the machine that owns the checkout (this cockpit runs in hosted mode)',
119
+ };
120
+ /**
121
+ * Allocate a profile id from the label (or, with no label, the folder name).
122
+ *
123
+ * Reuses the project allocator's shape, and deliberately its reserved set too: `default` is the
124
+ * discovered profile here exactly as it is the boot alias there, so a folder called `default/`
125
+ * becomes `default-2` and can never shadow it.
126
+ */
127
+ function allocateAgentProfileId(source, taken) {
128
+ // `allocateProjectSlug` already basenames its argument and enforces the shared
129
+ // `^[a-z0-9][a-z0-9-]{0,63}$` shape, so `~/.claude-klaudiusz` slugs to `claude-klaudiusz`.
130
+ return allocateProjectSlug(source, taken);
131
+ }
70
132
  const providerParamSchema = z.enum(PROVIDER_IDS);
71
133
  const providerEnabledSchema = z.object({ enabled: z.boolean() }).strict();
72
134
  const providerRetrySchema = z.object({
@@ -146,6 +208,8 @@ export function projectRouteManifest(app) {
146
208
  }
147
209
  /** 409 body for the inbox mutators while the follow-up inbox is off (#471). */
148
210
  const FOLLOWUPS_OFF = 'the follow-up inbox is disabled — set CEZ_FOLLOWUPS=1 to enable it';
211
+ /** 409 body for every automations route while GitHub automations are off (#801). */
212
+ const AUTOMATIONS_OFF = 'GitHub automations are disabled — set CEZ_AUTOMATIONS=1 to enable them';
149
213
  /**
150
214
  * The in-process bus for workspace-level SSE events. The registry-mutating
151
215
  * routes (`POST /api/projects` — step 4.2, emits `project-added` for a
@@ -193,7 +257,11 @@ const startRunSchema = z
193
257
  task: z.string().min(1).max(100_000, 'must be at most 100000 characters'),
194
258
  model: z.string().optional(),
195
259
  // Agent backend for this task (falls back to config `defaultRunner`).
196
- runner: z.enum(['claude', 'codex', 'opencode']).optional(),
260
+ runner: z.enum(RUNNER_IDS).optional(),
261
+ // Agent account for this task (spec 2026-07-29-agent-profiles). Falls back to the project's
262
+ // own selection, then the discovered default. Bounded like a profile id in the workspace
263
+ // schema, so a value this route accepts can never be degraded away by the next load.
264
+ agentProfile: z.string().max(64).optional(),
197
265
  // Parallel variants (spec 010): ×2/×3 runs the task as 2–3 competing
198
266
  // agents in separate worktrees; the user compares diffs and picks one.
199
267
  variants: z.number().int().min(1).max(3).optional(),
@@ -295,60 +363,6 @@ const appearanceSchema = z.object({
295
363
  density: z.enum(['comfortable', 'compact', 'ultra']).optional(),
296
364
  width: z.enum(['narrow', 'wide']).optional(),
297
365
  });
298
- const providerAuthDismissalsSchema = z
299
- .object({
300
- claude: z.string().min(1).max(128).optional(),
301
- codex: z.string().min(1).max(128).optional(),
302
- opencode: z.string().min(1).max(128).optional(),
303
- })
304
- .strict();
305
- /** Global GUI state (`~/.cezar/ui-state.json`, step 2.7) — the workspace twin
306
- * of `uiStateSchema` below, sharing its `.passthrough()` + key-cap + shallow
307
- * merge-on-write semantics via `parseUiStateBody`. Known keys are the
308
- * cross-project prefs from the spec's Data Model; everything project-scoped
309
- * (githubView, prompt templates, dismissed banners…) stays per-repo. */
310
- const workspaceUiStateSchema = z
311
- .object({
312
- appearance: appearanceSchema.optional(),
313
- notifications: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
314
- dismissedProviderAuthFailures: providerAuthDismissalsSchema.optional(),
315
- lastLocation: z
316
- .object({
317
- projectId: z.string().min(1).max(64),
318
- pathname: z.string().min(1).max(2048).startsWith('/p/'),
319
- search: z.string().max(4096).startsWith('?').optional(),
320
- hash: z.string().max(2048).startsWith('#').optional(),
321
- })
322
- .strict()
323
- .optional(),
324
- // Sidebar per-project collapse map, keyed by project id (slug ≤ 64 chars).
325
- // Entry-capped like `skillUsage`: the map is written straight to a file the
326
- // cockpit GETs on every load, so it must stay bounded on every axis.
327
- sidebar: z
328
- .object({
329
- collapsed: z
330
- .record(z.string().min(1).max(64), z.boolean())
331
- .refine((map) => Object.keys(map).length <= UI_STATE_MAX_KEYS, {
332
- message: `sidebar.collapsed must have at most ${UI_STATE_MAX_KEYS} entries`,
333
- })
334
- .optional(),
335
- })
336
- .passthrough()
337
- .optional(),
338
- // The user's curated selection of default (vendor) skills — `open-mercato/skills` — so the
339
- // catalog is no longer forced in full. GLOBAL (here, not per-repo) because "which skills I
340
- // want" describes the person, not a checkout, and must not depend on where cezar was launched
341
- // (multi-project workspace). Tri-state, enforced in `discoverSkills`: an ABSENT key means "not
342
- // curated" and every default skill still shows (opt-out default — no silent break on upgrade);
343
- // a PRESENT array (even `[]`) shows only those names. Bounded like the `skillUsage` map: the
344
- // file is GET/PUT wholesale, so an unbounded array is an unbounded write. Names match
345
- // `lastTask.ref` (`.min(1).max(200)`). The client PUTs the whole array (shallow top-level merge).
346
- importedSkills: z
347
- .array(z.string().min(1).max(200))
348
- .max(SKILL_USAGE_MAX_ENTRIES)
349
- .optional(),
350
- })
351
- .passthrough();
352
366
  const uiStateSchema = z
353
367
  .object({
354
368
  lastTask: z
@@ -487,7 +501,7 @@ function foldedLength(task, stack) {
487
501
  const continueSchema = z.object({
488
502
  text: z.string().max(100_000, 'must be at most 100000 characters').optional(),
489
503
  images: z.array(imageInputSchema).max(4).optional(),
490
- runner: z.enum(['claude', 'codex', 'opencode']).optional(),
504
+ runner: z.enum(RUNNER_IDS).optional(),
491
505
  model: z.string().max(200).optional(),
492
506
  });
493
507
  // Inbox "▶ Run" body (spec 007 / #401 / #413): every field optional, and the whole body is
@@ -499,7 +513,7 @@ const continueSchema = z.object({
499
513
  // absent so it never touches `task`.
500
514
  const startTodoSchema = z
501
515
  .object({
502
- runner: z.enum(['claude', 'codex', 'opencode']).optional(),
516
+ runner: z.enum(RUNNER_IDS).optional(),
503
517
  model: z.string().max(200).optional(),
504
518
  prompt: z
505
519
  .string()
@@ -547,7 +561,6 @@ function capUiStateKeys(data, ctx) {
547
561
  // generic wrapper leaves the schema type unresolved where `jsonBody` needs it, and Hono answers
548
562
  // that by dropping the whole PUT from the route schema rather than erroring — both ui-state PUTs
549
563
  // silently vanished from `AppType`. Concrete consts keep them visible to `hc`.
550
- const workspaceUiStateBody = workspaceUiStateSchema.superRefine(capUiStateKeys);
551
564
  const uiStateBody = uiStateSchema.superRefine(capUiStateKeys);
552
565
  /**
553
566
  * Query schemas for the READ routes — deliberately permissive.
@@ -660,7 +673,10 @@ export function createApp(deps) {
660
673
  const bootRoot = deps.repoRoot;
661
674
  const bootDataDir = join(bootRoot, '.ai/cezar');
662
675
  const modelCatalog = deps.modelCatalog ?? new RunnerModelCatalog({
663
- adapters: { codex: { discover: () => discoverCodexModels({ cwd: bootRoot }) } },
676
+ adapters: {
677
+ codex: { discover: () => discoverCodexModels({ cwd: bootRoot }) },
678
+ opencode: { discover: () => discoverOpencodeModels({ cwd: bootRoot }) },
679
+ },
664
680
  });
665
681
  const providerAuth = deps.providerAuth ?? new ProviderAuthService();
666
682
  const workspaceConfig = deps.workspaceConfig ?? {
@@ -668,14 +684,48 @@ export function createApp(deps) {
668
684
  mergeWrite: mergeWriteWorkspaceConfig,
669
685
  };
670
686
  const providerStatus = async (options) => {
687
+ if (providerAuthChecksDisabled()) {
688
+ return applyProviderEnablement(await providerAuth.status(options?.refresh ? { refresh: true } : undefined), []);
689
+ }
671
690
  const [discovered, workspace] = await Promise.all([
672
691
  providerAuth.status(options?.refresh ? { refresh: true } : undefined),
673
692
  workspaceConfig.load(),
674
693
  ]);
675
694
  return applyProviderEnablement(discovered, workspace.disabledProviders);
676
695
  };
677
- const providerActionError = async (required) => unavailableProviderMessage(required, await providerStatus());
696
+ /**
697
+ * The gate: why a run cannot start against `required`, or null when it can.
698
+ *
699
+ * VERIFY BEFORE YOU REFUSE. Auth state is served stale-while-revalidate (see
700
+ * `ProviderAuthService.status`), so a cached "disconnected" may predate a login cezar could not
701
+ * observe — someone running `claude auth login` in a terminal. Refusing on that would lock a user
702
+ * out of their own cockpit with no way back but waiting. So a believed-unavailable provider is
703
+ * re-probed, and only a refusal that survives the fresh answer is returned.
704
+ *
705
+ * The cost lands where it belongs: the common path (connected, warm) pays nothing at all, and the
706
+ * probe is only spawned when cezar is about to say no — a rare, interactive moment. This is also
707
+ * what lets the cache hold a negative for a minute instead of five seconds, which is what made
708
+ * every reader of `GET /providers/status` periodically pay for a CLI spawn.
709
+ *
710
+ * A runtime auth latch is deliberately NOT escaped by this: `withRuntimeFailures` keeps forcing
711
+ * the row disconnected until the user acknowledges that exact incident, so the re-probe cannot
712
+ * talk cezar out of a rejection it actually observed.
713
+ */
714
+ const providerActionError = async (required) => {
715
+ const known = await providerStatus();
716
+ const message = unavailableProviderMessage(required, known);
717
+ if (message === null)
718
+ return null;
719
+ // A DISABLED provider is a settings fact, not a probe result — re-probing it learns nothing and
720
+ // would spawn a CLI to re-read something the user typed.
721
+ const disabled = required.some((provider) => known.providers.find((row) => row.provider === provider)?.enabled === false);
722
+ if (disabled)
723
+ return message;
724
+ return unavailableProviderMessage(required, await providerStatus({ refresh: true }));
725
+ };
678
726
  const openTerminal = deps.openTerminal ?? openInTerminal;
727
+ const openFile = deps.openFile ?? openFileInDefaultApp;
728
+ const openApp = deps.openApp ?? openInApp;
679
729
  const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService();
680
730
  // ---- workspace boot-project identity (multi-project spec) ----------------
681
731
  // The boot flow (`initWorkspace` in src/index.ts) registers the boot repo
@@ -1150,12 +1200,106 @@ export function createApp(deps) {
1150
1200
  // `GET /api/health` reads a warm value instead of the cold ~1 s compute.
1151
1201
  if (deps.socketHub)
1152
1202
  void refreshHealth();
1203
+ /**
1204
+ * Warm the whole of cezar's agent knowledge — the three discovered defaults AND every extra
1205
+ * account — so no reader ever pays the first shell-out.
1206
+ *
1207
+ * Which login each agent is signed into is operating knowledge, not a settings-page detail: the
1208
+ * composer, the action gate, the accounts pane and every run resolution ask for it. So the server
1209
+ * learns it once, at boot, and keeps it (see the asymmetric cache lifetime in
1210
+ * `core/provider-auth.ts`); on-demand refresh rides on `?refresh=1` and on the explicit
1211
+ * invalidations — connect, repoint, remove, runtime rejection.
1212
+ *
1213
+ * Extra accounts are warmed ONE AT A TIME, after the defaults. Each is a CLI spawn, and a machine
1214
+ * with several accounts would otherwise fan out a spawn storm at exactly the moment the browser is
1215
+ * fetching the bundle; nothing is waiting on this, so sequential costs nothing that matters.
1216
+ *
1217
+ * Hosted mode warms only the defaults: the agent-profiles family is refused there, so there are
1218
+ * no accounts to learn about.
1219
+ */
1220
+ const warmAgentKnowledge = async () => {
1221
+ await providerAuth.status().catch(() => { });
1222
+ if (!capabilities().localHandoff)
1223
+ return;
1224
+ const store = await loadAgentAccounts().catch(() => defaultAgentAccountStore());
1225
+ for (const account of listAgentProfiles(store, PROVIDER_IDS)) {
1226
+ if (account.isDefault)
1227
+ continue; // covered by `status()` above
1228
+ await providerAuth
1229
+ .profileStatus(account.provider, { id: account.id, configDir: account.path })
1230
+ .catch(() => { });
1231
+ }
1232
+ };
1233
+ // Same gate as `refreshHealth`, same reason (startServer injects the hub; a bare app in tests does
1234
+ // not, so tests never spawn probes here). Fire-and-forget: a probe that fails leaves that row
1235
+ // cold, which is exactly the state every reader already handles.
1236
+ if (deps.socketHub)
1237
+ void warmAgentKnowledge();
1153
1238
  // ---- chained family: host model catalog (workspace-level) ----
1154
1239
  const modelsRoutes = new Hono()
1155
- .get('/models', queryZodValidator(z.object({ runner: z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).pipe(z.literal('codex')) }), { message: 'runner must be codex' }), async (c) => {
1240
+ // `modelDiscoveryRunnerSchema` is the contract's own list of the runners with an
1241
+ // authoritative host-local catalog (#794), so the client compiles against exactly what this
1242
+ // validates. Claude has no such source: its picker stays on static presets and this 400s.
1243
+ .get('/models', queryZodValidator(z.object({ runner: z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).pipe(modelDiscoveryRunnerSchema) }), { message: 'runner must be codex or opencode' }), async (c) => {
1156
1244
  const query = { data: c.req.valid('query') };
1157
1245
  return c.json(await modelCatalog.get(query.data.runner));
1158
1246
  });
1247
+ /**
1248
+ * Resolve `profileId` (absent = the discovered default) into a concrete account for `provider`.
1249
+ *
1250
+ * A dangling id is an ERROR here rather than the silent fall-back to the default that run
1251
+ * resolution performs. The difference is who is asking: a run is replaying a stored reference
1252
+ * and the default is the only safe answer it can act on, whereas a route is answering a user
1253
+ * who just named an account — telling them "unknown account" is honest, and quietly connecting
1254
+ * or reporting on a different one is not.
1255
+ */
1256
+ const resolveWorkspaceProfile = async (provider, profileId) => {
1257
+ if (profileId === undefined || profileId === DEFAULT_AGENT_ACCOUNT_ID) {
1258
+ return { profile: defaultAgentProfile(provider) };
1259
+ }
1260
+ let accounts;
1261
+ try {
1262
+ accounts = (await loadAgentAccounts()).accounts;
1263
+ }
1264
+ catch {
1265
+ return { error: `unknown ${provider} account: ${profileId}` };
1266
+ }
1267
+ const stored = accounts.find((a) => a.id === profileId && a.provider === provider);
1268
+ if (!stored)
1269
+ return { error: `unknown ${provider} account: ${profileId}` };
1270
+ return { profile: resolveStoredProfile(stored) };
1271
+ };
1272
+ /**
1273
+ * The environment a terminal handoff must carry so the CLI lands on the right account.
1274
+ *
1275
+ * `profileId` is the one RECORDED on the step that owns the session, never the project's
1276
+ * current selection: `claude --resume <id>` reads `<configDir>/sessions`, so a handoff for a
1277
+ * run started on the work account and resumed after the project was switched back would find
1278
+ * nothing and silently open a fresh conversation.
1279
+ */
1280
+ const handoffEnv = async (provider, profileId) => {
1281
+ const resolved = await resolveWorkspaceProfile(provider, profileId);
1282
+ if ('error' in resolved) {
1283
+ return { error: `this session belongs to an account that no longer exists (${profileId})` };
1284
+ }
1285
+ const { profile } = resolved;
1286
+ return { env: profile.isDefault ? {} : profileEnv(provider, profile.path) };
1287
+ };
1288
+ /** The copy-paste fallback shown when no terminal could be opened — same account, spelled for
1289
+ * this platform's shell. `null` when the dir cannot be embedded safely, which is a refusal. */
1290
+ const handoffFallbackCommand = (cwd, command, env) => {
1291
+ const prefixed = withEnvPrefix(command, env, process.platform);
1292
+ return prefixed === null ? null : `cd '${cwd}' && ${prefixed}`;
1293
+ };
1294
+ /** A registered project's realpath'd root, or null when the id is unknown. */
1295
+ const projectRootFor = async (projectId) => {
1296
+ try {
1297
+ return (await loadWorkspaceConfig()).projects.find((p) => p.id === projectId)?.root ?? null;
1298
+ }
1299
+ catch {
1300
+ return null;
1301
+ }
1302
+ };
1159
1303
  // ---- chained family: agent providers (workspace-level) ----
1160
1304
  const providersRoutes = new Hono()
1161
1305
  .get('/providers/status', queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
@@ -1197,11 +1341,46 @@ export function createApp(deps) {
1197
1341
  workspaceEvents.emit('provider-status', row);
1198
1342
  return c.json(result);
1199
1343
  })
1200
- .post('/providers/connect', jsonZodValidator(providerConnectSchema, { message: 'provider must be claude, codex, or opencode' }), async (c) => {
1344
+ .post('/providers/connect', jsonZodValidator(providerConnectSchema, { message: 'provider must be claude, codex, opencode, or pi' }), async (c) => {
1201
1345
  const body = { data: c.req.valid('json') };
1202
1346
  const provider = body.data.provider;
1203
- const command = providerAuth.loginCommand(provider);
1204
- const row = (await providerAuth.status({ refresh: true })).providers.find((candidate) => candidate.provider === provider);
1347
+ // A NAMED account is refused in hosted mode before anything is resolved, exactly like every
1348
+ // sibling route in the agent-profiles family. Checking later would already have read
1349
+ // `~/.cezar/agent-accounts.json`, built a command carrying the account's absolute path (which
1350
+ // both the success body and the hosted 409 echo), and — for a stored account — spawned a
1351
+ // probe. It would also answer `unknown account: <id>` for a wrong id, which is an enumeration
1352
+ // oracle for the very ids the hosted listing withholds. The bare-provider spelling keeps its
1353
+ // existing behaviour: it names no host path and is how the Providers card has always worked.
1354
+ if (body.data.profileId !== undefined
1355
+ && body.data.profileId !== DEFAULT_AGENT_ACCOUNT_ID
1356
+ && !capabilities().localHandoff) {
1357
+ return c.json(hostedProfileRefusal, 409);
1358
+ }
1359
+ // Resolve the account BEFORE anything else: both the command and the status probe below
1360
+ // must describe the same one, or the pane reports on the personal login while the terminal
1361
+ // signs into the work login.
1362
+ const resolved = await resolveWorkspaceProfile(provider, body.data.profileId);
1363
+ if ('error' in resolved)
1364
+ return c.json({ error: resolved.error }, 400);
1365
+ const { profile } = resolved;
1366
+ const command = providerAuth.loginCommand(provider, profile.isDefault ? null : profile.path);
1367
+ // Fail closed: a config dir that cannot be embedded safely in this platform's shell has no
1368
+ // safe degradation. Running the bare command would sign the user into a DIFFERENT account
1369
+ // than the one they clicked, and nothing in the terminal would say so.
1370
+ if (command === null) {
1371
+ return c.json({ error: `This account's folder cannot be used in a terminal command: ${profile.configDir}` }, 409);
1372
+ }
1373
+ // BOTH branches must mean "is this account signed in NOW". The default branch refreshes; the
1374
+ // account branch has to evict first, because `profileStatus` serves the per-account cache and
1375
+ // a connected answer there stands for CONNECTED_TTL_MS. Without this, Connect after a
1376
+ // `claude /logout` answers "already connected", opens nothing, and the user is stuck — and it
1377
+ // would contradict this module's own invariant that opening a login is one of the things
1378
+ // cezar CAN observe and therefore invalidates explicitly rather than waiting out a window.
1379
+ if (!profile.isDefault)
1380
+ providerAuth.forgetProfileStatus(provider, profile.id);
1381
+ const row = profile.isDefault
1382
+ ? (await providerAuth.status({ refresh: true })).providers.find((candidate) => candidate.provider === provider)
1383
+ : await providerAuth.profileStatus(provider, { id: profile.id, configDir: profile.path });
1205
1384
  if (!row) {
1206
1385
  return c.json({ error: 'Authentication could not be verified. Try again.' }, 500);
1207
1386
  }
@@ -1219,6 +1398,9 @@ export function createApp(deps) {
1219
1398
  }
1220
1399
  let opened = false;
1221
1400
  try {
1401
+ // No `env` argument: `loginCommand` already rendered the account's config dir INTO the
1402
+ // command, because this string is also the copy-paste fallback the pane shows. Passing
1403
+ // it again here would set the variable twice.
1222
1404
  opened = await openTerminal(bootRoot, command);
1223
1405
  }
1224
1406
  catch {
@@ -1229,6 +1411,468 @@ export function createApp(deps) {
1229
1411
  }
1230
1412
  return c.json({ opened: true, command });
1231
1413
  });
1414
+ // ---- chained family: agent profiles / accounts (workspace-level) ----------
1415
+ // Extra config dirs for a SECOND login of the same agent CLI (spec
1416
+ // 2026-07-29-agent-profiles). Workspace-level and therefore SINGLE-MOUNT: an account belongs to
1417
+ // the person and the machine, never to a repo, and a project-scoped spelling would be a second
1418
+ // surface to protect with no consumer. Which account a project uses is a field on
1419
+ // `PATCH /api/v1/projects/:projectId` instead.
1420
+ //
1421
+ // Writing is a LOCAL-MACHINE capability, exactly like `PUT /api/v1/agent-config/:id`: a profile
1422
+ // points an agent at a directory on the host, and the listing echoes absolute paths carrying
1423
+ // the username — the same disclosure `/api/v1/health` trims in hosted mode (#431).
1424
+ /**
1425
+ * This agent's own USER-scope config files, resolved inside ONE account's folder.
1426
+ *
1427
+ * Straight from the catalog — the single home of config-file vendor knowledge — with an
1428
+ * `AgentHomePaths` whose slot for this provider is the account's dir. That is what makes a second
1429
+ * login's `settings.json` the file you open rather than the default account's, and it keeps the
1430
+ * ids opaque and stable so the open route below never takes a path from the client.
1431
+ */
1432
+ const accountFiles = async (profile) => {
1433
+ const home = {
1434
+ ...agentHomePaths(),
1435
+ ...(profile.provider === 'claude' ? { claude: profile.path } : {}),
1436
+ ...(profile.provider === 'codex' ? { codex: profile.path } : {}),
1437
+ ...(profile.provider === 'opencode' ? { opencodeConfig: profile.path } : {}),
1438
+ };
1439
+ const defs = listConfigFiles().filter((def) => def.scope === 'user' && def.runners.includes(profile.provider));
1440
+ return Promise.all(defs.map(async (def) => {
1441
+ const path = def.resolve(bootRoot, home);
1442
+ return { id: def.id, label: basename(path), path, exists: (await statConfigPath(path)).exists };
1443
+ }));
1444
+ };
1445
+ /** Build the wire row for one resolved profile: its dir state plus whatever auth is cached. */
1446
+ const agentProfileBody = async (profile) => ({
1447
+ id: profile.id,
1448
+ provider: profile.provider,
1449
+ label: profile.label,
1450
+ configDir: profile.configDir,
1451
+ path: profile.path,
1452
+ ...(await profileDirState(profile.provider, profile.path)),
1453
+ isDefault: profile.isDefault,
1454
+ // CACHED auth only — this listing must never pay a CLI spawn.
1455
+ //
1456
+ // Probing here cost a shell-out per provider PLUS one per extra account: ~0.7s with no extra
1457
+ // accounts and over 2s with a few, on every cold load, for a route whose real job (what
1458
+ // accounts exist) is a JSON read and a handful of stats. `GET /api/v1/health` already
1459
+ // established the rule — it serves whatever the cache holds and never pays a `gh` shell-out.
1460
+ // An absent `status` means "not determined yet", which the cockpit renders as Checking… and
1461
+ // then fills in from the per-account status route below.
1462
+ //
1463
+ // SPREAD, not `status: maybeUndefined`: hono would type the key as always-present while
1464
+ // `JSON.stringify` drops it, which is exactly the drift `contract-parity` catches (AGENTS.md
1465
+ // names this as one of the two recurring mismatches).
1466
+ ...(() => {
1467
+ const cached = profile.isDefault
1468
+ ? providerAuth.peekStatus()?.providers.find((row) => row.provider === profile.provider)
1469
+ : providerAuth.peekProfileStatus(profile.provider, profile.id);
1470
+ return cached ? { status: cached } : {};
1471
+ })(),
1472
+ files: await accountFiles(profile),
1473
+ });
1474
+ /**
1475
+ * Resolve `:id` to an account for the per-account reads below. Unlike `resolveWorkspaceProfile`
1476
+ * this accepts the reserved `default` for a NAMED provider, which these routes cannot infer — so
1477
+ * the id may be `default:<provider>` as well as a stored account id.
1478
+ */
1479
+ const accountById = async (id) => {
1480
+ const [head, tail] = id.split(':');
1481
+ if (head === DEFAULT_AGENT_ACCOUNT_ID) {
1482
+ const provider = PROVIDER_IDS.find((p) => p === tail);
1483
+ return provider ? defaultAgentProfile(provider) : null;
1484
+ }
1485
+ try {
1486
+ const stored = (await loadAgentAccounts()).accounts.find((a) => a.id === id);
1487
+ return stored ? resolveStoredProfile(stored) : null;
1488
+ }
1489
+ catch {
1490
+ return null;
1491
+ }
1492
+ };
1493
+ /** Validate a client-supplied config dir. Returns the error text, or null when it is usable. */
1494
+ const checkProfileDir = (configDir) => {
1495
+ if (CONTROL_CHARS_RE.test(configDir))
1496
+ return 'folder must not contain control characters';
1497
+ const expanded = expandTilde(configDir);
1498
+ // Absolute after expansion: a relative dir would resolve against whatever cwd the agent
1499
+ // happens to be spawned in, which for a task is a throwaway worktree. Through
1500
+ // `isAbsoluteConfigDir`, never a leading-`/` test — see its note: a string test refuses every
1501
+ // real Windows path, and this is the only gate the Add-account dialog has.
1502
+ if (!isAbsoluteConfigDir(expanded))
1503
+ return `folder must be an absolute path: ${configDir}`;
1504
+ return null;
1505
+ };
1506
+ /** Refuse a dir that is already some other account's (or the default's), compared through
1507
+ * `realpath` — two spellings of one directory would be two accounts silently sharing one
1508
+ * session store, and "which one am I logged into?" would stop having an answer. */
1509
+ const conflictingProfile = async (profiles, provider, path, exceptId) => {
1510
+ if (await sameProfileDir(path, defaultAgentProfile(provider).path)) {
1511
+ return 'that is already this agent\'s default folder';
1512
+ }
1513
+ for (const candidate of profiles) {
1514
+ if (candidate.provider !== provider || candidate.id === exceptId)
1515
+ continue;
1516
+ if (await sameProfileDir(path, expandTilde(candidate.configDir))) {
1517
+ return `that folder is already used by "${candidate.label || candidate.id}"`;
1518
+ }
1519
+ }
1520
+ return null;
1521
+ };
1522
+ const agentProfilesRoutes = new Hono()
1523
+ .get('/workspace/agent-profiles', async (c) => {
1524
+ const editable = capabilities().localHandoff;
1525
+ // Hosted mode withholds the listing entirely rather than serving it read-only: the paths
1526
+ // are the host disclosure, so an empty list is the only honest hosted answer.
1527
+ //
1528
+ // ONE body object, never a hosted `return` and a local `return`: two returns let hono
1529
+ // narrow `editable` to the literal `false`/`true` of each branch, and the contract's
1530
+ // honest `z.boolean()` then reads as wider than the route. Same shape as
1531
+ // `listAgentConfig`, which carries the same flag for the same reason.
1532
+ let store = defaultAgentAccountStore();
1533
+ if (editable) {
1534
+ try {
1535
+ store = await loadAgentAccounts();
1536
+ }
1537
+ catch {
1538
+ // an unreadable home degrades to "no extra accounts", never a failed request
1539
+ }
1540
+ }
1541
+ const profiles = editable
1542
+ ? await Promise.all(listAgentProfiles(store, PROVIDER_IDS).map(agentProfileBody))
1543
+ : [];
1544
+ return c.json({
1545
+ editable,
1546
+ profiles,
1547
+ profileCapableProviders: [...PROFILE_CAPABLE_PROVIDERS],
1548
+ // Which account each project uses, keyed by repo root. Served here rather than on the
1549
+ // project registry because it lives in the same file as the accounts it names.
1550
+ selections: editable ? store.selections : {},
1551
+ /** The machine-wide fallback, for repos that have chosen nothing. Withheld in hosted mode
1552
+ * on the same terms as the rest of this family. */
1553
+ defaults: editable ? store.defaults : {},
1554
+ });
1555
+ })
1556
+ .post('/workspace/agent-profiles', jsonZodValidator(() => createAgentProfileSchema), async (c) => {
1557
+ if (!capabilities().localHandoff)
1558
+ return c.json(hostedProfileRefusal, 409);
1559
+ const { provider, configDir, label } = c.req.valid('json');
1560
+ if (!supportsProfiles(provider)) {
1561
+ return c.json({ error: `${provider} cannot carry more than one account` }, 400);
1562
+ }
1563
+ const dirError = checkProfileDir(configDir);
1564
+ if (dirError)
1565
+ return c.json({ error: dirError }, 400);
1566
+ // Read-first, exactly like `POST /projects`: the duplicate check needs `realpath`, and the
1567
+ // merge-write mutator is deliberately SYNCHRONOUS so the read→rename window stays as small
1568
+ // as it is for every other writer of this file. Two processes adding the same folder in
1569
+ // that window would both win — a cosmetic duplicate, not a correctness problem, since the
1570
+ // schema's id dedupe is what keeps resolution deterministic.
1571
+ let existing = [];
1572
+ try {
1573
+ existing = (await loadAgentAccounts()).accounts;
1574
+ }
1575
+ catch {
1576
+ // unreadable store — the merge-write below reports the real failure
1577
+ }
1578
+ const conflict = await conflictingProfile(existing, provider, expandTilde(configDir));
1579
+ if (conflict !== null)
1580
+ return c.json({ error: conflict }, 409);
1581
+ let created;
1582
+ try {
1583
+ await mergeWriteAgentAccounts((store) => {
1584
+ const id = allocateAgentProfileId(label ?? configDir, store.accounts.map((a) => a.id));
1585
+ created = {
1586
+ id,
1587
+ provider,
1588
+ configDir,
1589
+ label: label?.trim() || id,
1590
+ addedAt: new Date().toISOString(),
1591
+ };
1592
+ store.accounts.push(created);
1593
+ });
1594
+ }
1595
+ catch (err) {
1596
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1597
+ }
1598
+ if (!created)
1599
+ return c.json({ error: 'account could not be saved' }, 500);
1600
+ // A brand-new account is the one thing the boot warm could not have known about, so learn it
1601
+ // now rather than only when something asks. Still off the response: the row is returned with
1602
+ // `status` absent (the listing's rule), the pane shows Checking…, and its follow-up request
1603
+ // joins this same in-flight probe.
1604
+ const account = resolveStoredProfile(created);
1605
+ const created201 = await agentProfileBody(account);
1606
+ void providerAuth
1607
+ .profileStatus(account.provider, { id: account.id, configDir: account.path })
1608
+ .catch(() => { });
1609
+ return c.json({ profile: created201 }, 201);
1610
+ })
1611
+ .patch('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => updateAgentProfileSchema), async (c) => {
1612
+ if (!capabilities().localHandoff)
1613
+ return c.json(hostedProfileRefusal, 409);
1614
+ const id = c.req.param('id');
1615
+ const { label, configDir } = c.req.valid('json');
1616
+ if (configDir !== undefined) {
1617
+ const dirError = checkProfileDir(configDir);
1618
+ if (dirError)
1619
+ return c.json({ error: dirError }, 400);
1620
+ }
1621
+ // Read-first (see POST above, and `PATCH /projects`): a well-formed but unknown id must
1622
+ // 404 WITHOUT rewriting the config, and the duplicate check needs async `realpath`.
1623
+ let existing = [];
1624
+ try {
1625
+ existing = (await loadAgentAccounts()).accounts;
1626
+ }
1627
+ catch {
1628
+ // unreadable store — treated as unknown, like DELETE and PATCH /projects
1629
+ }
1630
+ const current = existing.find((a) => a.id === id);
1631
+ if (!current)
1632
+ return c.json({ error: `unknown account: ${id}` }, 404);
1633
+ if (configDir !== undefined) {
1634
+ const conflict = await conflictingProfile(existing, current.provider, expandTilde(configDir), id);
1635
+ if (conflict !== null)
1636
+ return c.json({ error: conflict }, 409);
1637
+ }
1638
+ let updated;
1639
+ try {
1640
+ await mergeWriteAgentAccounts((store) => {
1641
+ const entry = store.accounts.find((a) => a.id === id);
1642
+ if (!entry)
1643
+ return; // lost a race with a concurrent delete — answered below
1644
+ // Mutated in place so `.passthrough()` keys on the row survive.
1645
+ if (label !== undefined)
1646
+ entry.label = label.trim() || entry.id;
1647
+ if (configDir !== undefined)
1648
+ entry.configDir = configDir;
1649
+ updated = entry;
1650
+ });
1651
+ }
1652
+ catch (err) {
1653
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1654
+ }
1655
+ if (!updated)
1656
+ return c.json({ error: `unknown account: ${id}` }, 404);
1657
+ // The dir may have moved under a cached probe — drop THIS account's answer so the response
1658
+ // reports the new folder's state rather than the old one's, and leave every other account's
1659
+ // warm answer alone (it is still true). Then re-learn it in the background, so the server's
1660
+ // knowledge is complete again whether or not a cockpit is open to ask; the pane's own
1661
+ // request for this row joins the same in-flight probe rather than spawning a second.
1662
+ const repointed = resolveStoredProfile(updated);
1663
+ providerAuth.forgetProfileStatus(repointed.provider, repointed.id);
1664
+ const body = await agentProfileBody(repointed);
1665
+ void providerAuth
1666
+ .profileStatus(repointed.provider, { id: repointed.id, configDir: repointed.path })
1667
+ .catch(() => { });
1668
+ return c.json({ profile: body });
1669
+ })
1670
+ /**
1671
+ * One account's authentication state, probed for real.
1672
+ *
1673
+ * Separate from the listing because a probe shells out to an agent CLI: keeping it here lets
1674
+ * the pane paint immediately and fill each row in as its answer lands, instead of every cold
1675
+ * load blocking on N spawns. `refresh=1` drops this account's cached answer and re-probes, for
1676
+ * the "Check again" affordance — the cache itself holds a connected answer for minutes and an
1677
+ * unsettled one for a minute (`cacheTtlFor` in `core/provider-auth.ts`).
1678
+ */
1679
+ .get('/workspace/agent-profiles/:id/status', paramZodValidator(z.object({ id: z.string() })), queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
1680
+ if (!capabilities().localHandoff)
1681
+ return c.json(hostedProfileRefusal, 409);
1682
+ const account = await accountById(c.req.param('id'));
1683
+ if (!account)
1684
+ return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1685
+ const refresh = c.req.valid('query').refresh === '1';
1686
+ // The discovered account's row is the one `GET /api/v1/providers/status` owns, so it comes
1687
+ // from there — enablement included, which a bare probe does not know about.
1688
+ if (account.isDefault) {
1689
+ const all = await providerStatus(refresh ? { refresh: true } : undefined);
1690
+ const row = all.providers.find((candidate) => candidate.provider === account.provider);
1691
+ return c.json({ status: row ?? { provider: account.provider, status: 'unknown' } });
1692
+ }
1693
+ // "Check again" re-probes THIS account only — the other accounts' warm answers are not
1694
+ // invalidated by asking about this one.
1695
+ if (refresh)
1696
+ providerAuth.forgetProfileStatus(account.provider, account.id);
1697
+ return c.json({
1698
+ status: await providerAuth.profileStatus(account.provider, {
1699
+ id: account.id,
1700
+ configDir: account.path,
1701
+ }),
1702
+ });
1703
+ })
1704
+ /**
1705
+ * Who this account is signed in AS — the pane's "Show details".
1706
+ *
1707
+ * A separate, on-demand route rather than a field on the listing, and that is what makes
1708
+ * "hidden by default" real: an email carried by the listing would already be in the response,
1709
+ * the query cache and devtools, whatever the UI chose to render. `provider-auth.ts` keeps
1710
+ * identity out of ITS boundary on purpose; this is the deliberate, local-only, opt-in
1711
+ * exception — not a widening of that rule. Never logged, never persisted.
1712
+ */
1713
+ .get('/workspace/agent-profiles/:id/details', paramZodValidator(z.object({ id: z.string() })), async (c) => {
1714
+ if (!capabilities().localHandoff)
1715
+ return c.json(hostedProfileRefusal, 409);
1716
+ const account = await accountById(c.req.param('id'));
1717
+ if (!account)
1718
+ return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1719
+ return c.json(await readAccountIdentity(account.provider, account.path));
1720
+ })
1721
+ /**
1722
+ * Open one of this account's own config files — or its folder — in a local app.
1723
+ *
1724
+ * `file` is a catalog ID from the account's own `files`, never a path: the client cannot name a
1725
+ * location, so there is no traversal surface here at all (the same rule
1726
+ * `/api/v1/agent-config/:id` follows). `folder` is the one extra keyword, and it resolves to
1727
+ * the account's dir rather than anything the caller spelled.
1728
+ */
1729
+ .post('/workspace/agent-profiles/:id/open', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => openAgentAccountFileSchema), async (c) => {
1730
+ if (!capabilities().localHandoff)
1731
+ return c.json(hostedProfileRefusal, 409);
1732
+ const account = await accountById(c.req.param('id'));
1733
+ if (!account)
1734
+ return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
1735
+ const { file, target } = c.req.valid('json');
1736
+ let path;
1737
+ if (file === 'folder') {
1738
+ path = account.path;
1739
+ }
1740
+ else {
1741
+ const match = (await accountFiles(account)).find((f) => f.id === file);
1742
+ if (!match)
1743
+ return c.json({ error: `unknown file: ${file}` }, 404);
1744
+ path = match.path;
1745
+ }
1746
+ // A file the agent has not written yet has nothing to open; say so rather than hand the OS
1747
+ // a missing path and report success.
1748
+ if (!(await statConfigPath(path)).exists && file !== 'folder') {
1749
+ return c.json({ error: `this account has no ${basename(path)} yet` }, 409);
1750
+ }
1751
+ // `target` names a detected app; absent means the OS default handler. Either way the PATH
1752
+ // came from the catalog, so an editor is only ever pointed inside this account's folder.
1753
+ //
1754
+ // Which targets APPLY is checked here rather than left to the UI, because two of them are
1755
+ // actively wrong rather than merely useless: `terminal` runs `cd <path>`, which fails on a
1756
+ // file, and a `cli:<runner>` handoff would start an agent session inside the config folder.
1757
+ // A route is a surface of its own; it refuses what it cannot do correctly.
1758
+ if (target !== undefined) {
1759
+ if (agentCliRunner(target) !== null) {
1760
+ return c.json({ error: 'agent CLIs open a task worktree, not a config folder' }, 400);
1761
+ }
1762
+ if (target === 'terminal' && file !== 'folder') {
1763
+ return c.json({ error: 'a terminal opens a folder, not a file' }, 400);
1764
+ }
1765
+ if (!detectOpenTargets().some((candidate) => candidate.id === target)) {
1766
+ return c.json({ error: `no such app on this machine: ${target}` }, 400);
1767
+ }
1768
+ }
1769
+ const opened = target === undefined
1770
+ ? await openFile(path)
1771
+ : await openApp(target, path);
1772
+ if (!opened)
1773
+ return c.json({ error: `could not open ${basename(path)}`, path }, 409);
1774
+ return c.json({ opened: true, path });
1775
+ })
1776
+ // Which account a PROJECT uses. On the accounts family rather than `PATCH /api/v1/projects`
1777
+ // because the selection is stored beside the accounts it names — one file, one atomic write,
1778
+ // and nothing about it can be dropped by a cezar version that never heard of accounts.
1779
+ .put('/workspace/agent-profiles/selection', jsonZodValidator(() => selectAgentProfileSchema), async (c) => {
1780
+ if (!capabilities().localHandoff)
1781
+ return c.json(hostedProfileRefusal, 409);
1782
+ const { projectId, provider, profileId } = c.req.valid('json');
1783
+ // `null` writes the MACHINE-WIDE default instead of one repo's selection: the account any
1784
+ // repo that has chosen nothing uses, so a second login is set up once rather than per
1785
+ // checkout. No project to resolve, and therefore no 404 path.
1786
+ let root = null;
1787
+ if (projectId !== null) {
1788
+ const resolvedId = projectId === 'default' ? await resolveBootProject() : projectId;
1789
+ // Keyed by repo ROOT, so the selection survives the registry being rebuilt and needs no
1790
+ // cross-reference into config.json. An unknown project is a 404 rather than an orphan
1791
+ // entry nobody will ever read.
1792
+ root = await projectRootFor(resolvedId);
1793
+ if (root === null)
1794
+ return c.json({ error: `unknown project: ${projectId}` }, 404);
1795
+ }
1796
+ // A user naming an account that does not exist gets told so — the opposite of how RUN
1797
+ // resolution treats a dangling stored id, and deliberately: a run has no better answer
1798
+ // than the default, a person does.
1799
+ if (profileId !== null && profileId !== DEFAULT_AGENT_ACCOUNT_ID) {
1800
+ const account = await resolveWorkspaceProfile(provider, profileId);
1801
+ if ('error' in account)
1802
+ return c.json({ error: account.error }, 400);
1803
+ }
1804
+ let store;
1805
+ try {
1806
+ store = await mergeWriteAgentAccounts((current) => {
1807
+ // One rule, two targets: the machine default is the same per-provider shape as a repo's
1808
+ // selection, so it clears the same way rather than growing its own spelling.
1809
+ const selection = root === null ? current.defaults : current.selections[root] ?? {};
1810
+ // `null` and the reserved `default` both mean "back to the discovered account", which
1811
+ // is stored as ABSENCE — the default id is never written to the file.
1812
+ if (profileId === null || profileId === DEFAULT_AGENT_ACCOUNT_ID)
1813
+ delete selection[provider];
1814
+ else
1815
+ selection[provider] = profileId;
1816
+ // The machine default keeps an emptied object where a repo selection is deleted, and
1817
+ // that asymmetry is the schema's, not an oversight: `defaults` is one FIXED field with
1818
+ // `.default(() => ({}))`, so `mergeWriteAgentAccounts` re-materializes it on the next
1819
+ // write no matter what this one omits (verified). `selections` is a growing MAP, where
1820
+ // an emptied entry is a row per repo ever touched that says nothing. The rule stated
1821
+ // for `agentDefaults.models` — "absence is the same answer" — applies there because
1822
+ // that key is `.optional()`, so deleting it actually sticks.
1823
+ if (root === null)
1824
+ current.defaults = selection;
1825
+ else if (Object.keys(selection).length === 0)
1826
+ delete current.selections[root];
1827
+ else
1828
+ current.selections[root] = selection;
1829
+ });
1830
+ }
1831
+ catch (err) {
1832
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1833
+ }
1834
+ return c.json({ selections: store.selections, defaults: store.defaults });
1835
+ })
1836
+ .delete('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), async (c) => {
1837
+ if (!capabilities().localHandoff)
1838
+ return c.json(hostedProfileRefusal, 409);
1839
+ const id = c.req.param('id');
1840
+ let removed = false;
1841
+ // Captured inside the mutator, because after the write there is nothing left to ask which
1842
+ // provider this account belonged to — and the eviction below is keyed by it.
1843
+ let removedProvider;
1844
+ try {
1845
+ await mergeWriteAgentAccounts((store) => {
1846
+ const before = store.accounts.length;
1847
+ removedProvider = store.accounts.find((a) => a.id === id)?.provider;
1848
+ store.accounts = store.accounts.filter((a) => a.id !== id);
1849
+ removed = store.accounts.length < before;
1850
+ if (!removed)
1851
+ return;
1852
+ // Scrub every reference IN THE SAME MUTATOR — the reason selections share this file.
1853
+ // A two-call delete-then-scrub can be observed mid-way by another cezar process on
1854
+ // this machine, which would then resolve a dangling id; harmless today (it degrades
1855
+ // to the default) but only by luck.
1856
+ for (const [root, selection] of Object.entries(store.selections)) {
1857
+ for (const key of Object.keys(selection)) {
1858
+ if (selection[key] === id)
1859
+ delete selection[key];
1860
+ }
1861
+ if (Object.keys(selection).length === 0)
1862
+ delete store.selections[root];
1863
+ }
1864
+ });
1865
+ }
1866
+ catch (err) {
1867
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
1868
+ }
1869
+ if (!removed)
1870
+ return c.json({ error: `unknown account: ${id}` }, 404);
1871
+ // Only this account's answer: it is about to stop existing, and holding it would let a
1872
+ // re-added account with the same id read the deleted one's state.
1873
+ providerAuth.forgetProfileStatus(removedProvider, id);
1874
+ return c.json({ removed: true, id });
1875
+ });
1232
1876
  // ---- workspace projects (multi-project spec) -----------------------------
1233
1877
  // The registered-project list for the cockpit sidebar. Same-origin (unlike
1234
1878
  // health), so absolute `root`s are fine here. `listProjects()` TTL-caches
@@ -1342,7 +1986,21 @@ export function createApp(deps) {
1342
1986
  const body = { removed: true, id };
1343
1987
  return c.json(body);
1344
1988
  })
1345
- .patch('/projects/:projectId', jsonZodValidator(() => updateProjectSchema), async (c) => {
1989
+ // Edit the per-project registry fields the cockpit owns: the concurrency ceiling (spec
1990
+ // 2026-07-22-per-project-concurrency) and the grouping tags (spec
1991
+ // 2026-08-10-global-tasks-and-project-tags). A PATCH (not PUT) because it touches named
1992
+ // fields and leaves the rest of the entry alone, and a distinct route from POST
1993
+ // (register-a-folder) to keep register vs. edit semantics clear.
1994
+ //
1995
+ // The body schema is the CONTRACT's, passed directly rather than restated behind a thunk:
1996
+ // it is already in scope, and a second copy is a second thing to keep in step. Its bounds
1997
+ // mirror `workspaceProjectSchema` (config.ts) exactly, so a value this route accepts can
1998
+ // never be degraded away by the next load's `.catch`.
1999
+ //
2000
+ // Deliberately NOT the home of the agent-account selection: that lives in
2001
+ // `~/.cezar/agent-accounts.json` beside the accounts it names, so a cezar version that has
2002
+ // never heard of accounts cannot drop it (see workspace/agent-accounts.ts).
2003
+ .patch('/projects/:projectId', jsonZodValidator(updateProjectInputSchema), async (c) => {
1346
2004
  if (capabilities().singleProject) {
1347
2005
  return c.json(singleProjectRefusal('editing projects'), 409);
1348
2006
  }
@@ -1354,7 +2012,7 @@ export function createApp(deps) {
1354
2012
  const parsed = { data: c.req.valid('json') };
1355
2013
  // `default` is the boot alias the cockpit is allowed to use everywhere else.
1356
2014
  const id = raw === 'default' ? await resolveBootProject() : raw;
1357
- const { maxParallel } = parsed.data;
2015
+ const { maxParallel, tags } = parsed.data;
1358
2016
  // Read-first (mirroring DELETE, server.ts:1252-1258): a well-formed but
1359
2017
  // unknown id must 404 WITHOUT rewriting the config — otherwise it would both
1360
2018
  // do a needless full-config tmp+rename and, on a read-only home, surface the
@@ -1375,12 +2033,27 @@ export function createApp(deps) {
1375
2033
  const entry = config.projects.find((p) => p.id === id);
1376
2034
  if (!entry)
1377
2035
  return; // lost a race with a concurrent remove — answered below
1378
- // null clears the override; a number sets it. Mutated in place so
1379
- // `.passthrough()` keys on the entry survive.
1380
- if (maxParallel === null)
1381
- delete entry.maxParallel;
1382
- else
1383
- entry.maxParallel = maxParallel;
2036
+ // Each key is applied only when the body NAMED it: a PATCH that says
2037
+ // nothing about a field must leave it exactly as it was, which is what
2038
+ // keeps the tags editor from clearing a concurrency ceiling (and the
2039
+ // pre-tags `{ maxParallel }` body from clearing tags). null clears;
2040
+ // a value sets. Mutated in place so `.passthrough()` keys survive.
2041
+ if (maxParallel !== undefined) {
2042
+ if (maxParallel === null)
2043
+ delete entry.maxParallel;
2044
+ else
2045
+ entry.maxParallel = maxParallel;
2046
+ }
2047
+ if (tags !== undefined) {
2048
+ // Normalized on the way IN, so every reader — this API, the CLI, the
2049
+ // global Tasks page — sees one spelling per tag and never has to
2050
+ // fold case itself.
2051
+ const normalized = normalizeProjectTags(tags);
2052
+ if (normalized === undefined)
2053
+ delete entry.tags;
2054
+ else
2055
+ entry.tags = normalized;
2056
+ }
1384
2057
  updated = entry;
1385
2058
  });
1386
2059
  }
@@ -1642,17 +2315,6 @@ export function createApp(deps) {
1642
2315
  throw error;
1643
2316
  }
1644
2317
  });
1645
- // Edit one field of an existing registry entry (spec
1646
- // 2026-07-22-per-project-concurrency): the per-project concurrency ceiling.
1647
- // A PATCH (not PUT) because it touches a single field, and a distinct route
1648
- // from POST (register-a-folder) to keep register vs. edit semantics clear.
1649
- // `maxParallel: null` clears the override back to "inherit the workspace
1650
- // cap". Bounds mirror `workspaceProjectSchema` (config.ts) exactly, so a
1651
- // value this route accepts can never be degraded away by the next load's
1652
- // `.catch`.
1653
- const updateProjectSchema = z.object({
1654
- maxParallel: z.number().int().min(1).max(16).nullable(),
1655
- });
1656
2318
  // ---- GUI clone (multi-project spec, step 4.3) ----------------------------
1657
2319
  // "Add project → Clone from GitHub": clone into the checkout root, then
1658
2320
  // register the result through `registerFolder` above (same guards, same
@@ -1694,16 +2356,24 @@ export function createApp(deps) {
1694
2356
  maxParallel: config.resources.maxParallel,
1695
2357
  maxMonitoringSessions: config.resources.maxMonitoringSessions,
1696
2358
  monitoringWakeIntervalMinutes: config.resources.monitoringWakeIntervalMinutes,
2359
+ autoResumeOnUsageLimit: config.resources.autoResumeOnUsageLimit,
1697
2360
  memoryLimitMb: config.resources.memoryLimitMb,
1698
2361
  worktreeRetentionDefault: config.resources.worktreeRetentionDefault,
1699
2362
  },
2363
+ // SPREAD, never `runner: maybeUndefined`: hono would type the key as always-present while
2364
+ // `JSON.stringify` drops it, which is the exact drift `contract-parity` catches. And absent has
2365
+ // to keep meaning "no opinion" here, or the fallback collapses into "always claude".
2366
+ agentDefaults: {
2367
+ ...(config.agentDefaults.runner !== undefined ? { runner: config.agentDefaults.runner } : {}),
2368
+ ...(config.agentDefaults.models !== undefined ? { models: config.agentDefaults.models } : {}),
2369
+ },
1700
2370
  });
1701
2371
  // ---- chained family: workspace settings + GUI prefs (workspace-level) ----
1702
2372
  const workspaceConfigRoutes = new Hono()
1703
2373
  .get('/workspace/config', async (c) => c.json(workspaceConfigBody(await loadWorkspaceConfig())))
1704
2374
  .put('/workspace/config', jsonZodValidator(() => workspaceConfigUpdateSchema), async (c) => {
1705
2375
  const parsed = { data: c.req.valid('json') };
1706
- const { browseRoot, projectsDir, skillsAutoUpdate, composerDefaults, resources } = parsed.data;
2376
+ const { browseRoot, projectsDir, skillsAutoUpdate, composerDefaults, resources, agentDefaults } = parsed.data;
1707
2377
  for (const [configuredRoot, create] of [
1708
2378
  [browseRoot, false],
1709
2379
  [projectsDir, true],
@@ -1764,11 +2434,35 @@ export function createApp(deps) {
1764
2434
  if (resources?.monitoringWakeIntervalMinutes !== undefined) {
1765
2435
  config.resources.monitoringWakeIntervalMinutes = resources.monitoringWakeIntervalMinutes;
1766
2436
  }
2437
+ if (resources?.autoResumeOnUsageLimit !== undefined) {
2438
+ config.resources.autoResumeOnUsageLimit = resources.autoResumeOnUsageLimit;
2439
+ }
1767
2440
  if (resources?.memoryLimitMb !== undefined)
1768
2441
  config.resources.memoryLimitMb = resources.memoryLimitMb;
1769
2442
  if (resources?.worktreeRetentionDefault !== undefined) {
1770
2443
  config.resources.worktreeRetentionDefault = resources.worktreeRetentionDefault;
1771
2444
  }
2445
+ // `null` CLEARS back to "no opinion" — a partial patch cannot say that by omission,
2446
+ // and leaving a stale runner behind would keep overriding repos that never chose.
2447
+ if (agentDefaults?.runner === null)
2448
+ delete config.agentDefaults.runner;
2449
+ else if (agentDefaults?.runner !== undefined)
2450
+ config.agentDefaults.runner = agentDefaults.runner;
2451
+ for (const runner of PROVIDER_IDS) {
2452
+ const model = agentDefaults?.models?.[runner];
2453
+ if (model === undefined)
2454
+ continue;
2455
+ const models = config.agentDefaults.models ?? {};
2456
+ if (model === null)
2457
+ delete models[runner];
2458
+ else
2459
+ models[runner] = model;
2460
+ // An empty object would persist a key that says nothing; absence is the same answer.
2461
+ if (Object.keys(models).length === 0)
2462
+ delete config.agentDefaults.models;
2463
+ else
2464
+ config.agentDefaults.models = models;
2465
+ }
1772
2466
  });
1773
2467
  }
1774
2468
  catch (err) {
@@ -1791,7 +2485,7 @@ export function createApp(deps) {
1791
2485
  // the same point (before the handler, so the cap still precedes any read) and leaves the
1792
2486
  // chain's type accumulation alone. Method-agnostic here, which the GET does not mind.
1793
2487
  .use('/workspace/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
1794
- .put('/workspace/ui-state', jsonZodValidator(workspaceUiStateBody), async (c) => {
2488
+ .put('/workspace/ui-state', jsonZodValidator(setWorkspaceUiStateInputSchema), async (c) => {
1795
2489
  const parsed = { data: c.req.valid('json') };
1796
2490
  try {
1797
2491
  return c.json(await mergeWriteWorkspaceUiState((state) => ({
@@ -1822,10 +2516,26 @@ export function createApp(deps) {
1822
2516
  maxParallel: z.number().int().min(1).max(16).optional(),
1823
2517
  maxMonitoringSessions: z.number().int().min(0).max(16).optional(),
1824
2518
  monitoringWakeIntervalMinutes: z.number().int().min(1).max(60).nullable().optional(),
2519
+ autoResumeOnUsageLimit: z.boolean().optional(),
1825
2520
  memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
1826
2521
  worktreeRetentionDefault: z.number().int().min(0).max(1000).optional(),
1827
2522
  })
1828
2523
  .optional(),
2524
+ // Bounds mirror `src/workspace/config.ts`, so a value this accepts is never degraded away by
2525
+ // the next load's `.catch`. `null` clears a key back to "no opinion".
2526
+ agentDefaults: z
2527
+ .object({
2528
+ runner: z.enum(PROVIDER_IDS).nullable().optional(),
2529
+ models: z
2530
+ .object({
2531
+ claude: z.string().trim().min(1).max(200).nullable().optional(),
2532
+ codex: z.string().trim().min(1).max(200).nullable().optional(),
2533
+ opencode: z.string().trim().min(1).max(200).nullable().optional(),
2534
+ pi: z.string().trim().min(1).max(200).nullable().optional(),
2535
+ })
2536
+ .optional(),
2537
+ })
2538
+ .optional(),
1829
2539
  });
1830
2540
  // ---- chained family: filesystem browse (workspace-level) ----
1831
2541
  const fsBrowseRoutes = new Hono()
@@ -2014,12 +2724,35 @@ export function createApp(deps) {
2014
2724
  return c.json(await planChain(repoRoot, parsed.data.task));
2015
2725
  });
2016
2726
  const manualChecks = new Map();
2727
+ /**
2728
+ * The automations gate (#801): with `CEZ_AUTOMATIONS` unset, every route of the feature
2729
+ * answers 409 before touching a store, a lease or GitHub.
2730
+ *
2731
+ * Written as MIDDLEWARE rather than a line in each handler so the family cannot drift: a route
2732
+ * added to either chain below inherits the gate from its path, where a per-handler check is one
2733
+ * omission away from an ungated endpoint.
2734
+ *
2735
+ * Registered against EXPLICIT paths, never `use('*')`. Both chains are mounted with
2736
+ * `.route('/', …)` alongside a dozen unrelated sub-apps, and `route()` re-registers a sub-app's
2737
+ * middleware under the mount prefix — so a `'*'` here would gate the entire `/api/v1` surface,
2738
+ * including `/health`. The two-line pairing (`/automations` and `/automations/*`) is what makes
2739
+ * a path match both the collection and everything under it.
2740
+ */
2741
+ const requireAutomations = async (c, next) => {
2742
+ if (!capabilities().automations)
2743
+ return c.json({ error: AUTOMATIONS_OFF }, 409);
2744
+ await next();
2745
+ };
2017
2746
  // ---- chained family: GitHub automations (project-scoped) ----
2018
2747
  // Every handler below reads `c.get('project')` — the definitions, their runtime state and the
2019
2748
  // execution log are per-project files — so the family is project-scoped and mounted with the
2020
2749
  // rest of the mirrored table. The one exception is the manual-check read, which touches no
2021
2750
  // project at all; it is its own workspace-level family below.
2022
2751
  const automationsRoutes = new Hono()
2752
+ .use('/automations', requireAutomations)
2753
+ .use('/automations/*', requireAutomations)
2754
+ .use('/automation-log', requireAutomations)
2755
+ .use('/automation-log/*', requireAutomations)
2023
2756
  .get('/automations', async (c) => {
2024
2757
  const { root, automationStore } = c.get('project');
2025
2758
  const forge = resolveForge(await getRepoInfo(root));
@@ -2243,6 +2976,7 @@ export function createApp(deps) {
2243
2976
  // above, keyed by an unguessable id that the project-scoped POST hands back. Mounting it under
2244
2977
  // `/api/v1/p/:projectId` too would be a second spelling of a lookup that consults no project.
2245
2978
  const automationChecksRoutes = new Hono()
2979
+ .use('/automation-checks/*', requireAutomations)
2246
2980
  .get('/automation-checks/:checkId', (c) => {
2247
2981
  const check = manualChecks.get(c.req.param('checkId'));
2248
2982
  return check ? c.json(check) : c.json({ error: 'not found' }, 404);
@@ -2289,19 +3023,43 @@ export function createApp(deps) {
2289
3023
  const id = c.req.param('id');
2290
3024
  // An empty/absent body archives (the common case); a malformed body degrades
2291
3025
  // to `{}` just as before, but a wrong-typed `archived` is now a 400 (#429).
3026
+ // Archiving also retires any pending usage-limit resume, but that rule belongs to
3027
+ // `setArchived` itself — the bulk sweep must obey it too (spec
3028
+ // 2026-08-03-auto-resume-after-usage-limit).
2292
3029
  const parsed = { data: c.req.valid('json') };
2293
3030
  const run = store.setArchived(id, parsed.data.archived !== false);
2294
3031
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3032
+ })
3033
+ // The per-task off switch for that resume (the workspace setting is Settings → Resources).
3034
+ // Idempotent: a run with nothing pending answers 200 too, because "this task will not
3035
+ // resume itself" is equally true either way.
3036
+ .delete('/runs/:id/auto-resume', (c) => {
3037
+ const { store, manager } = c.get('project');
3038
+ const id = c.req.param('id');
3039
+ if (!store.getRun(id))
3040
+ return c.json({ error: 'not found' }, 404);
3041
+ manager.cancelAutoResume(id);
3042
+ return c.json({ cancelled: true });
2295
3043
  })
2296
3044
  .post('/runs/:id/read', (c) => {
2297
3045
  // No body: opening a thread marks it read, full stop. Stamps `seenAt = now` and
2298
3046
  // returns the updated record (which also rides the `run` SSE via `touch`).
2299
3047
  const run = c.get('project').store.setRead(c.req.param('id'));
2300
3048
  return run ? c.json(run) : c.json({ error: 'not found' }, 404);
3049
+ })
3050
+ .post('/runs/:id/unread', (c) => {
3051
+ // The mark-unread twin (#775) — bodyless like its read counterpart: clearing the
3052
+ // receipt is the whole action, so there is nothing to say about it. Sits under
3053
+ // `/runs/:id/`, so the `read-all` registration-order caveat above does not apply.
3054
+ const run = c.get('project').store.setUnread(c.req.param('id'));
3055
+ return run ? c.json(run) : c.json({ error: 'not found' }, 404);
2301
3056
  })
2302
3057
  .post('/runs', jsonZodValidator(startRunSchema), async (c) => {
2303
3058
  const { root: repoRoot, dataDir, manager } = c.get('project');
2304
3059
  const parsed = { data: c.req.valid('json') };
3060
+ if (agentModelsLocked(repoRoot) && parsed.data.model?.trim()) {
3061
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
3062
+ }
2305
3063
  let workflow;
2306
3064
  if (parsed.data.steps) {
2307
3065
  // Inline chain (spec 008): an approved plan runs as an ad-hoc workflow.
@@ -2324,6 +3082,15 @@ export function createApp(deps) {
2324
3082
  const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
2325
3083
  if (blocked)
2326
3084
  return c.json({ error: blocked }, 409);
3085
+ // A composer override names an account the user just picked, so a stale id (deleted since
3086
+ // the page loaded) is answered honestly instead of quietly running on the default — the
3087
+ // opposite of how RESOLUTION treats a dangling reference, and deliberately so: a run
3088
+ // replaying a stored id has no better answer than the default, a user does.
3089
+ if (parsed.data.agentProfile !== undefined) {
3090
+ const account = await resolveWorkspaceProfile(fallback, parsed.data.agentProfile);
3091
+ if ('error' in account)
3092
+ return c.json({ error: account.error }, 400);
3093
+ }
2327
3094
  const images = parsed.data.images?.map((img) => ({
2328
3095
  type: 'image',
2329
3096
  source: { type: 'base64', media_type: img.mediaType, data: img.data },
@@ -2332,6 +3099,7 @@ export function createApp(deps) {
2332
3099
  task: parsed.data.task,
2333
3100
  model: parsed.data.model,
2334
3101
  runner: parsed.data.runner,
3102
+ agentProfile: parsed.data.agentProfile,
2335
3103
  images,
2336
3104
  systemPrompt: parsed.data.systemPrompt,
2337
3105
  worktree: parsed.data.worktree,
@@ -2370,6 +3138,27 @@ export function createApp(deps) {
2370
3138
  const { store } = c.get('project');
2371
3139
  const run = store.getRun(c.req.param('id'));
2372
3140
  return run ? c.json(withUsage(run)) : c.json({ error: 'not found' }, 404);
3141
+ })
3142
+ .get('/runs/:id/history', paramZodValidator(runIdParamSchema), queryZodValidator(runHistoryQuerySchema), async (c) => {
3143
+ const { store, dataDir } = c.get('project');
3144
+ const { id } = c.req.valid('param');
3145
+ if (!store.getRun(id))
3146
+ return c.json({ error: 'not found' }, 404);
3147
+ try {
3148
+ return c.json(await readRunHistoryPage(join(dataDir, 'runs', `${id}.ndjson`), c.req.valid('query').cursor));
3149
+ }
3150
+ catch (error) {
3151
+ if (error instanceof HistoryCursorError)
3152
+ return c.json({ error: error.message }, error.status);
3153
+ throw error;
3154
+ }
3155
+ })
3156
+ .get('/runs/:id/history-context', paramZodValidator(runIdParamSchema), async (c) => {
3157
+ const { store, dataDir } = c.get('project');
3158
+ const { id } = c.req.valid('param');
3159
+ if (!store.getRun(id))
3160
+ return c.json({ error: 'not found' }, 404);
3161
+ return c.json(await deriveRunContextEvents(join(dataDir, 'runs', `${id}.ndjson`)));
2373
3162
  })
2374
3163
  // Editable titles (#389). The UI displays `titleSummary ?? title`, so a
2375
3164
  // user edit sets BOTH: `title` (the record's own name — the raw task stops
@@ -2547,7 +3336,7 @@ export function createApp(deps) {
2547
3336
  })
2548
3337
  // "Continue" (spec 003): reopen a finished run's session in-process.
2549
3338
  .post('/runs/:id/continue', jsonZodValidator(continueSchema, { absent: ({}) }), async (c) => {
2550
- const { store, manager } = c.get('project');
3339
+ const { root: repoRoot, store, manager } = c.get('project');
2551
3340
  const id = c.req.param('id');
2552
3341
  const run = store.getRun(id);
2553
3342
  if (!run)
@@ -2555,6 +3344,9 @@ export function createApp(deps) {
2555
3344
  // Bounded resume text (#429); an empty/absent body still just re-runs on the
2556
3345
  // run's current backend, and a runner/model override reopens on that engine (#401).
2557
3346
  const parsed = { data: c.req.valid('json') };
3347
+ if (agentModelsLocked(repoRoot) && parsed.data.model?.trim()) {
3348
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
3349
+ }
2558
3350
  const blocked = await providerActionError([providerForExistingRun(run, parsed.data.runner)]);
2559
3351
  if (blocked)
2560
3352
  return c.json({ error: blocked }, 409);
@@ -2586,7 +3378,8 @@ export function createApp(deps) {
2586
3378
  error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE); resume the session from a machine that has the checkout',
2587
3379
  }, 409);
2588
3380
  }
2589
- const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
3381
+ const sessionStep = [...run.steps].reverse().find((s) => s.sessionId);
3382
+ const sessionId = sessionStep?.sessionId;
2590
3383
  if (!sessionId)
2591
3384
  return c.json({ error: 'no agent session to resume' }, 409);
2592
3385
  const blocked = await providerActionError([providerForExistingRun(run)]);
@@ -2597,12 +3390,19 @@ export function createApp(deps) {
2597
3390
  // Fails closed on an id we do not recognise — see resumeCommand (#431).
2598
3391
  if (!command)
2599
3392
  return c.json({ error: 'the recorded session id has an unexpected shape' }, 409);
2600
- const opened = await openInTerminal(cwd, command);
3393
+ // The account that OWNS this session, not the project's current one (spec 2026-07-29).
3394
+ const account = await handoffEnv(run.runner ?? 'claude', sessionStep?.profileId);
3395
+ if ('error' in account)
3396
+ return c.json({ error: account.error }, 409);
3397
+ const fallback = handoffFallbackCommand(cwd, command, account.env);
3398
+ // Fail closed for the same reason as the session id: a terminal opened without the
3399
+ // account's config dir resumes nothing and says nothing about why.
3400
+ if (fallback === null) {
3401
+ return c.json({ error: 'this account\'s folder cannot be used in a terminal command' }, 409);
3402
+ }
3403
+ const opened = await openTerminal(cwd, command, account.env);
2601
3404
  if (!opened) {
2602
- return c.json({
2603
- error: 'no terminal emulator found',
2604
- command: `cd '${cwd}' && ${command}`,
2605
- }, 409);
3405
+ return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
2606
3406
  }
2607
3407
  return c.json({ opened: true, command });
2608
3408
  })
@@ -2659,7 +3459,7 @@ export function createApp(deps) {
2659
3459
  }, 409);
2660
3460
  }
2661
3461
  const filePath = join(run.worktreePath, result.path);
2662
- const opened = await openFileInDefaultApp(filePath);
3462
+ const opened = await openFile(filePath);
2663
3463
  if (!opened)
2664
3464
  return c.json({ error: `could not open ${result.path}`, path: filePath }, 409);
2665
3465
  return c.json({ opened: true, path: filePath });
@@ -2682,21 +3482,32 @@ export function createApp(deps) {
2682
3482
  if (blocked)
2683
3483
  return c.json({ error: blocked }, 409);
2684
3484
  const engineOwnsSession = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
2685
- const sessionId = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
3485
+ const sessionStep = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId);
3486
+ const sessionId = sessionStep?.sessionId;
2686
3487
  // An id resumeCommand refuses (#431) degrades to a fresh CLI in the worktree,
2687
3488
  // exactly like a run that never recorded a session.
2688
3489
  const resume = sessionId && cliRunner === (run.runner ?? 'claude') ? resumeCommand(cliRunner, sessionId) : null;
2689
3490
  const command = resume ?? cliRunner;
2690
- const opened = await openInTerminal(dir, command);
3491
+ // BOTH branches carry the account (spec 2026-07-29-agent-profiles): a resume needs the
3492
+ // config dir that holds its session, and a FRESH CLI in this worktree should still open
3493
+ // on the account the project works under — otherwise "Open in → Claude CLI" quietly
3494
+ // hands the user a different subscription than every task in the same project uses.
3495
+ const account = resume
3496
+ ? await handoffEnv(cliRunner, sessionStep?.profileId)
3497
+ : { env: (await resolveProfileEnvForRoot(repoRoot, cliRunner)).env };
3498
+ if ('error' in account)
3499
+ return c.json({ error: account.error }, 409);
3500
+ const fallback = handoffFallbackCommand(dir, command, account.env);
3501
+ if (fallback === null) {
3502
+ return c.json({ error: 'this account\'s folder cannot be used in a terminal command' }, 409);
3503
+ }
3504
+ const opened = await openTerminal(dir, command, account.env);
2691
3505
  if (!opened) {
2692
- return c.json({
2693
- error: 'no terminal emulator found',
2694
- command: `cd '${dir}' && ${command}`,
2695
- }, 409);
3506
+ return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
2696
3507
  }
2697
3508
  return c.json({ opened: true, path: dir, command });
2698
3509
  }
2699
- const opened = await openInApp(target, dir);
3510
+ const opened = await openApp(target, dir);
2700
3511
  if (!opened)
2701
3512
  return c.json({ error: `could not open ${target}`, path: dir }, 409);
2702
3513
  return c.json({ opened: true, path: dir });
@@ -2750,6 +3561,8 @@ export function createApp(deps) {
2750
3561
  return c.json({ error: NO_WORKTREE }, 409);
2751
3562
  const result = await collectChanges(workingDirectory, run.baseBranch ?? 'HEAD', {
2752
3563
  taskBranch: run.branch,
3564
+ // Anchors a repointed worktree at the branch as this run found it (#751).
3565
+ runStartedAt: run.startedAt,
2753
3566
  // A read-only GET against the user's real checkout must never modify its index.
2754
3567
  intentToAdd: run.worktreePath ? undefined : false,
2755
3568
  });
@@ -2911,6 +3724,12 @@ export function createApp(deps) {
2911
3724
  if (!outcome.ok) {
2912
3725
  return c.json({ error: outcome.error, manual: `git merge ${run.branch}` }, 409);
2913
3726
  }
3727
+ // A number the cockpit asked about BEFORE the pull request existed is cached as "this
3728
+ // repository has no such number" — which is exactly what a `CEZ:PR=901` marker declared
3729
+ // ahead of the push looks like. It exists now.
3730
+ const createdNumber = refNumberFromUrl(outcome.url);
3731
+ if (createdNumber !== null)
3732
+ forgetRefStatus(repoRoot, createdNumber);
2914
3733
  store.updateRun(id, {
2915
3734
  pullRequestUrl: outcome.url,
2916
3735
  status: 'done',
@@ -3038,7 +3857,31 @@ export function createApp(deps) {
3038
3857
  });
3039
3858
  // ---- chained family: open-targets (project-scoped) ----
3040
3859
  const openTargetsRoutes = new Hono()
3041
- .get('/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }));
3860
+ .get('/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }))
3861
+ // Open the PROJECT ROOT itself (Settings → "Project folder" → Open with). The run route
3862
+ // above opens a task worktree and needs a run to name one; this is the repo the cockpit is
3863
+ // scoped to, which the scope middleware has already resolved — so no path is accepted from
3864
+ // the client and there is nothing to contain.
3865
+ .post('/open-in', jsonZodValidator(openProjectInSchema), async (c) => {
3866
+ const { root } = c.get('project');
3867
+ if (!capabilities().localHandoff) {
3868
+ return c.json({ error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE)' }, 409);
3869
+ }
3870
+ const { target } = c.req.valid('json');
3871
+ // Refused here rather than left to the menu, on the same principle as the accounts route:
3872
+ // a `cli:<runner>` handoff would START AN AGENT in the checkout everything else runs in a
3873
+ // worktree to protect. Which app APPLIES is a property of the route, not of one client.
3874
+ if (agentCliRunner(target) !== null) {
3875
+ return c.json({ error: 'agent CLIs open a task worktree, not the project folder' }, 400);
3876
+ }
3877
+ if (!detectOpenTargets().some((candidate) => candidate.id === target)) {
3878
+ return c.json({ error: `no such app on this machine: ${target}` }, 400);
3879
+ }
3880
+ const opened = await openApp(target, root);
3881
+ if (!opened)
3882
+ return c.json({ error: `could not open ${target}`, path: root }, 409);
3883
+ return c.json({ opened: true, path: root });
3884
+ });
3042
3885
  // Agent screenshots — image blocks the run manager persisted out of tool
3043
3886
  // results (persistImage). `basename` pins reads inside the run's own dir.
3044
3887
  const IMAGE_TYPES = {
@@ -3140,6 +3983,9 @@ export function createApp(deps) {
3140
3983
  const id = c.req.param('id');
3141
3984
  const todo = c.get('todo');
3142
3985
  const parsed = { data: c.req.valid('json') };
3986
+ if (agentModelsLocked(repoRoot) && parsed.data?.model?.trim()) {
3987
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
3988
+ }
3143
3989
  if (todo.startedTaskId)
3144
3990
  return c.json({ error: 'already started' }, 409);
3145
3991
  let task = todoTaskText(todo);
@@ -3182,14 +4028,28 @@ export function createApp(deps) {
3182
4028
  });
3183
4029
  // ---- chained family: SSE streams (project-scoped) ----
3184
4030
  const sseRoutes = new Hono()
3185
- .get('/runs/:id/events', (c) => {
3186
- const { store } = c.get('project');
3187
- const id = c.req.param('id');
4031
+ .get('/runs/:id/events', paramZodValidator(runIdParamSchema), queryZodValidator(runEventsQuerySchema), async (c) => {
4032
+ const { store, dataDir } = c.get('project');
4033
+ const { id } = c.req.valid('param');
3188
4034
  if (!store.getRun(id))
3189
4035
  return c.json({ error: 'not found' }, 404);
4036
+ const query = c.req.valid('query');
4037
+ const eventsPath = join(dataDir, 'runs', `${id}.ndjson`);
4038
+ if (query.cursor) {
4039
+ try {
4040
+ await validateLiveCursor(eventsPath, query.cursor);
4041
+ }
4042
+ catch (error) {
4043
+ if (error instanceof HistoryCursorError)
4044
+ return c.json({ error: error.message }, error.status);
4045
+ throw error;
4046
+ }
4047
+ }
4048
+ const lastEventId = Number.parseInt(c.req.header('Last-Event-ID') ?? '', 10);
4049
+ const requestedAfter = Math.max(query.afterSeq ?? 0, Number.isSafeInteger(lastEventId) && lastEventId >= 0 ? lastEventId : 0);
3190
4050
  return streamSSENoBuffer(c, async (stream) => {
3191
4051
  let replaying = true;
3192
- let maxSeq = 0;
4052
+ let maxSeq = requestedAfter;
3193
4053
  const buffered = [];
3194
4054
  // One endpoint, two SSE event names: v1 lines stay `run-event` (the name
3195
4055
  // the legacy UI listened to — its default branch JSON-dumped unknown
@@ -3199,6 +4059,7 @@ export function createApp(deps) {
3199
4059
  // ride `ui-event`, which only v2-aware clients subscribe to.
3200
4060
  // EventSource ignores names it has no listener for.
3201
4061
  const writeEvent = (event) => stream.writeSSE({
4062
+ id: String(event.seq),
3202
4063
  event: isV2WireEventType(event.type) ? 'ui-event' : 'run-event',
3203
4064
  data: JSON.stringify(event),
3204
4065
  });
@@ -3221,9 +4082,15 @@ export function createApp(deps) {
3221
4082
  store.off('event', onEvent);
3222
4083
  store.off('run', onRun);
3223
4084
  });
3224
- for (const event of store.readEvents(id)) {
3225
- maxSeq = Math.max(maxSeq, event.seq);
4085
+ const replay = query.cursor
4086
+ ? await readEventsAfterLiveCursor(eventsPath, query.cursor)
4087
+ : { events: store.readEvents(id), boundarySeq: 0 };
4088
+ maxSeq = Math.max(maxSeq, replay.boundarySeq);
4089
+ for (const event of replay.events) {
4090
+ if (event.seq <= maxSeq)
4091
+ continue;
3226
4092
  await writeEvent(event);
4093
+ maxSeq = event.seq;
3227
4094
  }
3228
4095
  replaying = false;
3229
4096
  for (const event of buffered) {
@@ -3400,6 +4267,21 @@ export function createApp(deps) {
3400
4267
  // would be in its temporal dead zone. (The schemas below are all read inside a handler, or
3401
4268
  // passed as a thunk, which defers them past that point.)
3402
4269
  const mergeNumberParams = z.object({ number: z.coerce.number().int().positive() });
4270
+ /** A ref-status list: `null` means malformed (the caller answers 400), `[]` means "not asked
4271
+ * for". Absent and empty are the same request — neither names a number. */
4272
+ const parseRefNumbers = (raw) => {
4273
+ const parts = (raw ?? '').split(',').map((p) => p.trim()).filter(Boolean);
4274
+ if (parts.length > GH_REF_STATUS_MAX)
4275
+ return null;
4276
+ const numbers = [];
4277
+ for (const part of parts) {
4278
+ const n = Number(part);
4279
+ if (!Number.isInteger(n) || n <= 0 || String(n) !== part)
4280
+ return null;
4281
+ numbers.push(n);
4282
+ }
4283
+ return numbers;
4284
+ };
3403
4285
  const prChangesParams = z.object({ number: z.coerce.number().int().positive().safe() });
3404
4286
  const prChangesQuery = z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') });
3405
4287
  const githubRoutes = new Hono()
@@ -3443,6 +4325,23 @@ export function createApp(deps) {
3443
4325
  numbers.push(n);
3444
4326
  }
3445
4327
  return c.json(await fetchGithubChecks(repoRoot, numbers));
4328
+ })
4329
+ // Batched status for the PR/issue chips a task table paints. Additive sibling of
4330
+ // /github/checks and shaped like it: comma-separated positive integers, capped at
4331
+ // GH_REF_STATUS_MAX per kind, malformed input is a 400, and an unreachable forge degrades in
4332
+ // the payload. Unlike /github/checks BOTH keys are optional — a table may hold only issues —
4333
+ // but at least one must name something, or the request asks for nothing.
4334
+ .get('/github/ref-status', queryZodValidator(z.object({ prs: z.string().optional(), issues: z.string().optional() })), async (c) => {
4335
+ const { root: repoRoot } = c.get('project');
4336
+ const { prs, issues } = c.req.valid('query');
4337
+ const parsedPrs = parseRefNumbers(prs);
4338
+ const parsedIssues = parseRefNumbers(issues);
4339
+ if (parsedPrs === null || parsedIssues === null)
4340
+ return c.json({ error: 'invalid ref-status query' }, 400);
4341
+ if (parsedPrs.length === 0 && parsedIssues.length === 0) {
4342
+ return c.json({ error: 'missing prs or issues query' }, 400);
4343
+ }
4344
+ return c.json(await fetchGithubRefStatus(repoRoot, { prs: parsedPrs, issues: parsedIssues }));
3446
4345
  })
3447
4346
  .get('/github/prs/:number/merge-state', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), queryZodValidator(refreshQuery), async (c) => {
3448
4347
  const { root: repoRoot } = c.get('project');
@@ -3460,8 +4359,14 @@ export function createApp(deps) {
3460
4359
  if (!forge?.mergePR)
3461
4360
  return c.json({ error: 'GitHub merge is unavailable' }, 409);
3462
4361
  const result = await forge.mergePR(parsedNumber.data.number, body.data);
3463
- if (result.merged)
4362
+ if (result.merged) {
4363
+ // We just changed this pull request, so what the ref-status cache holds about it is now
4364
+ // known-stale — and its TTL would keep every chip showing the PRE-merge status for up to
4365
+ // a minute after the user watched this server merge it. Forget it; the next reader asks
4366
+ // the forge, gets `merged`, and then stops polling it at all.
4367
+ forgetRefStatus(repoRoot, parsedNumber.data.number);
3464
4368
  return c.json(result);
4369
+ }
3465
4370
  return c.json({
3466
4371
  error: result.error,
3467
4372
  ...(result.code ? { code: result.code } : {}),
@@ -3595,29 +4500,44 @@ export function createApp(deps) {
3595
4500
  });
3596
4501
  // The Settings → Agents knobs in one read (R6 Step 1.5) — an ADDITIVE
3597
4502
  // sibling of PUT /api/config below; /api/health keeps its protected shape.
3598
- const configAnswer = (config) => ({
3599
- baseBranch: config.baseBranch ?? null,
3600
- defaultRunner: config.defaultRunner,
3601
- systemPrompt: config.systemPrompt ?? null,
3602
- defaultModels: config.defaultModels ?? {},
3603
- maxParallel: config.maxParallel,
3604
- memoryLimitMb: config.memoryLimitMb ?? null,
3605
- // Count-based worktree retention (#483): keep the last N finished worktrees
3606
- // on disk. 0 = unlimited. Always materialized (schema default 10).
3607
- worktreeRetention: config.worktreeRetention,
3608
- // Live title updates (task auto-naming spec): tri-state — null means "no
3609
- // config key, the CEZ_TITLE_UPDATES env default (ON) decides".
3610
- liveTitleUpdates: config.liveTitleUpdates ?? null,
3611
- // Optional review gate (#489): tri-state — null means "no config key, the
3612
- // CEZ_REVIEW_GATE env default (OFF) decides".
3613
- reviewGate: config.reviewGate ?? null,
3614
- });
4503
+ const configAnswer = async (repoRoot, config) => {
4504
+ const nativeModels = await readAgentModelDefaults(repoRoot);
4505
+ const modelsLocked = agentModelsLocked(repoRoot);
4506
+ return {
4507
+ baseBranch: config.baseBranch ?? null,
4508
+ defaultRunner: config.defaultRunner,
4509
+ systemPrompt: config.systemPrompt ?? null,
4510
+ // Native defaults seed each runner independently. A Cezar preset remains
4511
+ // selectable unless the operator opts into the fixed-model policy.
4512
+ defaultModels: modelsLocked
4513
+ ? nativeModels
4514
+ : { ...nativeModels, ...(config.defaultModels ?? {}) },
4515
+ modelsLocked,
4516
+ maxParallel: config.maxParallel,
4517
+ memoryLimitMb: config.memoryLimitMb ?? null,
4518
+ // Count-based worktree retention (#483): keep the last N finished worktrees
4519
+ // on disk. 0 = unlimited. Always materialized (schema default 10).
4520
+ worktreeRetention: config.worktreeRetention,
4521
+ // Live title updates (task auto-naming spec): tri-state — null means "no
4522
+ // config key, the CEZ_TITLE_UPDATES env default (ON) decides".
4523
+ liveTitleUpdates: config.liveTitleUpdates ?? null,
4524
+ // Optional review gate (#489): tri-state — null means "no config key, the
4525
+ // CEZ_REVIEW_GATE env default (OFF) decides".
4526
+ reviewGate: config.reviewGate ?? null,
4527
+ };
4528
+ };
3615
4529
  // ---- chained family: per-repo config (project-scoped) ----
3616
4530
  const configRoutes = new Hono()
3617
- .get('/config', async (c) => c.json(configAnswer(await loadConfig(c.get('project').root))))
4531
+ .get('/config', async (c) => {
4532
+ const repoRoot = c.get('project').root;
4533
+ return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
4534
+ })
3618
4535
  .put('/config', jsonZodValidator(() => setConfigSchema), async (c) => {
3619
4536
  const { root: repoRoot, dataDir } = c.get('project');
3620
4537
  const parsed = { data: c.req.valid('json') };
4538
+ if (agentModelsLocked(repoRoot) && parsed.data.defaultModels !== undefined) {
4539
+ return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
4540
+ }
3621
4541
  const configPath = join(dataDir, 'config.json');
3622
4542
  let raw = {};
3623
4543
  try {
@@ -3702,7 +4622,7 @@ export function createApp(deps) {
3702
4622
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
3703
4623
  }
3704
4624
  // Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
3705
- return c.json(configAnswer(await loadConfig(repoRoot)));
4625
+ return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
3706
4626
  });
3707
4627
  // Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
3708
4628
  // base-branch picker). Merges into the RAW config.json so user keys
@@ -3712,13 +4632,14 @@ export function createApp(deps) {
3712
4632
  const modelPresetSchema = z.string().trim().max(200).nullable().optional();
3713
4633
  const setConfigSchema = z.object({
3714
4634
  baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
3715
- defaultRunner: z.enum(['claude', 'codex', 'opencode']).optional(),
4635
+ defaultRunner: z.enum(RUNNER_IDS).optional(),
3716
4636
  systemPrompt: z.string().trim().max(20_000, 'must be at most 20000 characters').nullable().optional(),
3717
4637
  defaultModels: z
3718
4638
  .object({
3719
4639
  claude: modelPresetSchema,
3720
4640
  codex: modelPresetSchema,
3721
4641
  opencode: modelPresetSchema,
4642
+ pi: modelPresetSchema,
3722
4643
  })
3723
4644
  .optional(),
3724
4645
  // Concurrency + memory guard (Settings → Resources). maxParallel clamps to
@@ -3819,6 +4740,147 @@ export function createApp(deps) {
3819
4740
  .route('/', repoRoutes)
3820
4741
  .route('/', configRoutes)
3821
4742
  .route('/', agentConfigRoutes);
4743
+ // ---- chained family: the cross-project run index (workspace-level) -------
4744
+ /**
4745
+ * How many runs each project may contribute, newest first. The index is a FINDER, not a
4746
+ * listing: past the newest couple of hundred per project you are looking for something the
4747
+ * project's own Tasks table answers better, and every extra row is a DOM node the palette's
4748
+ * filter walks on each keystroke. `truncated` names the projects this bit, so a consumer never
4749
+ * has to pretend the list is complete.
4750
+ */
4751
+ const RUNS_INDEX_PER_PROJECT = 200;
4752
+ /** `RunRecord` → the wire row. Optional keys are spread CONDITIONALLY: writing
4753
+ * `titleSummary: run.titleSummary` types a key as always-present that `JSON.stringify` then
4754
+ * drops when it is undefined, which is exactly the drift the parity guard fails on. */
4755
+ /**
4756
+ * Every number a run MENTIONS — a superset of what its chip will show.
4757
+ *
4758
+ * Deliberately not `taskReference()`'s rule: which of a run's references is displayed is the
4759
+ * cockpit's decision (#407, #526), and re-deriving it here would be a second rule that drifts
4760
+ * from the first. This feeds a CACHE READ, which costs nothing per number, so asking about one
4761
+ * the client will not paint is free and asking about one it will is the whole point.
4762
+ */
4763
+ const mentionedReferenceNumbers = (run) => {
4764
+ const numbers = [];
4765
+ for (const url of [run.pullRequestUrl, run.referencedPullRequestUrl, run.referencedIssueUrl]) {
4766
+ const number = url ? refNumberFromUrl(url) : null;
4767
+ if (number !== null)
4768
+ numbers.push(number);
4769
+ }
4770
+ for (const number of [run.prNumber, run.issueNumber, run.markerRefs?.pr, run.markerRefs?.issue]) {
4771
+ if (typeof number === 'number' && Number.isInteger(number) && number > 0)
4772
+ numbers.push(number);
4773
+ }
4774
+ return numbers;
4775
+ };
4776
+ const runIndexEntry = (projectId, run) => {
4777
+ const usage = currentUsage(run.id);
4778
+ return {
4779
+ projectId,
4780
+ id: run.id,
4781
+ title: run.title,
4782
+ ...(run.titleSummary !== undefined ? { titleSummary: run.titleSummary } : {}),
4783
+ ...(run.titleOrigin !== undefined ? { titleOrigin: run.titleOrigin } : {}),
4784
+ status: run.status,
4785
+ ...(run.activity !== undefined ? { activity: run.activity } : {}),
4786
+ createdAt: run.createdAt,
4787
+ ...(run.finishedAt !== undefined ? { finishedAt: run.finishedAt } : {}),
4788
+ ...(run.seenAt !== undefined ? { seenAt: run.seenAt } : {}),
4789
+ archived: run.archived,
4790
+ ...(run.autoResumeAt !== undefined ? { autoResumeAt: run.autoResumeAt } : {}),
4791
+ workflow: run.workflow,
4792
+ ...(run.branch !== undefined ? { branch: run.branch } : {}),
4793
+ ...(run.startedAt !== undefined ? { startedAt: run.startedAt } : {}),
4794
+ // The tracker-reference inputs, verbatim — the cockpit's `taskReference()` owns the rule
4795
+ // that picks between them (see the schema's note).
4796
+ ...(run.pullRequestUrl !== undefined ? { pullRequestUrl: run.pullRequestUrl } : {}),
4797
+ ...(run.referencedPullRequestUrl !== undefined
4798
+ ? { referencedPullRequestUrl: run.referencedPullRequestUrl }
4799
+ : {}),
4800
+ ...(run.prNumber !== undefined ? { prNumber: run.prNumber } : {}),
4801
+ ...(run.issueNumber !== undefined ? { issueNumber: run.issueNumber } : {}),
4802
+ ...(run.referencedIssueUrl !== undefined ? { referencedIssueUrl: run.referencedIssueUrl } : {}),
4803
+ ...(run.markerRefs !== undefined ? { markerRefs: run.markerRefs } : {}),
4804
+ ...(run.costUsd !== undefined ? { costUsd: run.costUsd } : {}),
4805
+ ...(run.peakRssBytes !== undefined ? { peakRssBytes: run.peakRssBytes } : {}),
4806
+ ...(run.peakProcCount !== undefined ? { peakProcCount: run.peakProcCount } : {}),
4807
+ // The live sample, on the same terms as `GET /runs`: process-wide sampler, so a
4808
+ // workspace-level answer can carry it for every project's runs at once.
4809
+ ...(usage ? { usage } : {}),
4810
+ };
4811
+ };
4812
+ /**
4813
+ * `GET /workspace/runs-index` — every registered project's recent tasks in one slim answer, so
4814
+ * ⌘K can find a task without knowing which project it lives in.
4815
+ *
4816
+ * Workspace-level and single-mount for the obvious reason: a project-scoped spelling of "all
4817
+ * projects" is a contradiction. The per-project source is chosen the same way the automation
4818
+ * coordinator's boot fan-out chooses it (`bootContext` for the boot project, `contexts.peek`
4819
+ * for anything this process already owns, disk otherwise) — and never `contexts.context()`,
4820
+ * which would build a context, prune worktrees and `recover()` running agents. Typing in a
4821
+ * search box must not resume work; see `runs/run-index.ts`.
4822
+ */
4823
+ const runsIndexRoutes = new Hono()
4824
+ .get('/workspace/runs-index', async (c) => {
4825
+ let projects = [];
4826
+ try {
4827
+ const selector = capabilities().singleProject
4828
+ ? { projectId: await resolveBootProject() }
4829
+ : undefined;
4830
+ projects = await listProjects(selector);
4831
+ }
4832
+ catch {
4833
+ // unreadable workspace — an empty index, never a 500. The palette degrades to the
4834
+ // active project's own run list, which it holds either way.
4835
+ }
4836
+ const bootId = await resolveBootProject(projects);
4837
+ const runs = [];
4838
+ const truncated = [];
4839
+ // Statuses the server already holds, shipped WITH the rows that carry the references. The
4840
+ // cockpit would otherwise ask for them a beat after the table paints — one round trip per
4841
+ // project, and a visible flash of un-coloured chips before it lands. Cache-only, so this
4842
+ // never touches `gh` and never slows the index down; anything cold stays absent and the
4843
+ // lazy `/github/ref-status` route fills it in.
4844
+ const referenceStatuses = {};
4845
+ for (const project of projects) {
4846
+ // No folder, no runs to read. `not-git` still has an `.ai/cezar` worth indexing.
4847
+ if (project.status === 'missing')
4848
+ continue;
4849
+ const owned = project.id === bootId ? bootContext : contexts.peek(project.id);
4850
+ // `listRuns()` already sorts newest-first; the disk reader returns file order, so both
4851
+ // paths get sorted below rather than trusting either.
4852
+ //
4853
+ // Archived runs are INCLUDED. The active project's rows reach the palette through
4854
+ // `GET /runs`, which has always carried them, and excluding them here would mean a task
4855
+ // is findable while you stand in its project and vanishes the moment you leave — the
4856
+ // exact asymmetry a cross-project finder exists to remove.
4857
+ const recent = (owned ? owned.store.listRuns() : readRunIndexFromDisk(join(project.root, '.ai/cezar'))).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
4858
+ if (recent.length > RUNS_INDEX_PER_PROJECT)
4859
+ truncated.push(project.id);
4860
+ const mentioned = [];
4861
+ for (const run of recent.slice(0, RUNS_INDEX_PER_PROJECT)) {
4862
+ runs.push(runIndexEntry(project.id, run));
4863
+ mentioned.push(...mentionedReferenceNumbers(run));
4864
+ }
4865
+ if (mentioned.length > 0) {
4866
+ const cached = readCachedRefStatuses(project.root, mentioned);
4867
+ // Only when something was actually warm. A project key holding two empty maps is noise
4868
+ // that every consumer would have to look past, and it would blur the one rule this
4869
+ // payload has: absent means nothing is known.
4870
+ if (Object.keys(cached.prs).length > 0 || Object.keys(cached.issues).length > 0) {
4871
+ referenceStatuses[project.id] = cached;
4872
+ }
4873
+ }
4874
+ }
4875
+ runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
4876
+ const body = {
4877
+ runs,
4878
+ perProjectLimit: RUNS_INDEX_PER_PROJECT,
4879
+ truncated,
4880
+ referenceStatuses,
4881
+ };
4882
+ return c.json(body);
4883
+ });
3822
4884
  // Workspace-level families answer for the whole workspace, so they are single-mount: never a
3823
4885
  // project-scoped spelling, which would be a second surface to protect with no consumer.
3824
4886
  const workspaceV1 = new Hono()
@@ -3826,10 +4888,12 @@ export function createApp(deps) {
3826
4888
  .route('/', modelsRoutes)
3827
4889
  .route('/', providersRoutes)
3828
4890
  .route('/', projectsRoutes)
4891
+ .route('/', agentProfilesRoutes)
3829
4892
  .route('/', skillsUpdateRoutes)
3830
4893
  .route('/', workspaceConfigRoutes)
3831
4894
  .route('/', fsBrowseRoutes)
3832
4895
  .route('/', automationChecksRoutes)
4896
+ .route('/', runsIndexRoutes)
3833
4897
  .route('/', workspaceEventsRoutes);
3834
4898
  // ---- mount ---------------------------------------------------------------
3835
4899
  // Scoped first, then the unscoped alias bound to the boot project. The paths are disjoint (no
@@ -3870,6 +4934,11 @@ export function startServer(deps, port) {
3870
4934
  semaphore: deps.semaphore,
3871
4935
  automationStore: (projectId, root) => automationCoordinator.store(projectId, root),
3872
4936
  });
4937
+ // #801: GitHub automations are opt-in. Off, the flag must remove the BEHAVIOR and not merely
4938
+ // the UI — no scheduler, no GitHub polling, no launched runs — so every entry point into the
4939
+ // workspace scheduler below is gated on it. Read per call rather than captured, for the same
4940
+ // reason `capabilities()` is inside `createApp`: tests flip the variable between apps.
4941
+ const automationsEnabled = () => resolveCapabilities(process.env, deps.bindHost).automations;
3873
4942
  let rescheduleAutomations = () => { };
3874
4943
  const app = createApp({
3875
4944
  ...deps,
@@ -3922,7 +4991,14 @@ export function startServer(deps, port) {
3922
4991
  };
3923
4992
  },
3924
4993
  });
3925
- rescheduleAutomations = () => { void automationScheduler.reschedule(); };
4994
+ // The scheduler is inert until `start()` anyway (it constructs `stopped`), but the gate is
4995
+ // stated here rather than inherited from that detail: a definition saved while the flag is off
4996
+ // must not even ask the coordinator to refresh.
4997
+ rescheduleAutomations = () => {
4998
+ if (!automationsEnabled())
4999
+ return;
5000
+ void automationScheduler.reschedule();
5001
+ };
3926
5002
  const unsubscribe = workspaceEvents.on((event, data) => {
3927
5003
  if (event === 'project-added') {
3928
5004
  const project = data.project;
@@ -3932,7 +5008,7 @@ export function startServer(deps, port) {
3932
5008
  const parsed = parseRemote(info?.remote ?? '');
3933
5009
  if (parsed?.host === 'github.com')
3934
5010
  automationProjects.set(project.id, { root: project.root, owner: parsed.owner, repo: parsed.repo });
3935
- return automationScheduler.reschedule();
5011
+ return rescheduleAutomations();
3936
5012
  });
3937
5013
  }
3938
5014
  }
@@ -3943,7 +5019,7 @@ export function startServer(deps, port) {
3943
5019
  if (typeof id === 'string') {
3944
5020
  automationCoordinator.remove(id);
3945
5021
  automationProjects.delete(id);
3946
- void automationScheduler.reschedule();
5022
+ rescheduleAutomations();
3947
5023
  }
3948
5024
  }
3949
5025
  });
@@ -3952,6 +5028,11 @@ export function startServer(deps, port) {
3952
5028
  const all = projects.some((project) => project.root === deps.repoRoot)
3953
5029
  ? projects : [{ id: deps.bootProjectId ?? 'default', root: deps.repoRoot, status: 'ok' }, ...projects];
3954
5030
  coordinator.start(all);
5031
+ // #801: with automations off there is nothing to warm — no remote to resolve, no receipts
5032
+ // to reconcile, and above all no scheduler to start. The skills-update coordinator above is
5033
+ // a separate feature and starts either way.
5034
+ if (!automationsEnabled())
5035
+ return;
3955
5036
  void Promise.all(all.map(async (project) => {
3956
5037
  const parsed = parseRemote((await getRepoInfo(project.root))?.remote ?? '');
3957
5038
  if (parsed?.host === 'github.com')
@@ -4121,6 +5202,8 @@ export function resumeCommand(runner, sessionId) {
4121
5202
  return `codex resume ${sessionId}`;
4122
5203
  case 'opencode':
4123
5204
  return `opencode --session ${sessionId}`;
5205
+ case 'pi':
5206
+ return `pi --session ${sessionId}`;
4124
5207
  default:
4125
5208
  return `claude --resume ${sessionId}`;
4126
5209
  }