@open-mercato/cezar 0.9.3 → 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.
- package/README.md +165 -36
- package/dist/agent-config/account-identity.d.ts +44 -0
- package/dist/agent-config/account-identity.js +128 -0
- package/dist/agent-config/account-identity.js.map +1 -0
- package/dist/agent-config/catalog.d.ts +8 -0
- package/dist/agent-config/catalog.js +18 -0
- package/dist/agent-config/catalog.js.map +1 -1
- package/dist/agent-config/model-settings/claude.d.ts +2 -0
- package/dist/agent-config/model-settings/claude.js +11 -0
- package/dist/agent-config/model-settings/claude.js.map +1 -0
- package/dist/agent-config/model-settings/codex.d.ts +2 -0
- package/dist/agent-config/model-settings/codex.js +16 -0
- package/dist/agent-config/model-settings/codex.js.map +1 -0
- package/dist/agent-config/model-settings/opencode.d.ts +2 -0
- package/dist/agent-config/model-settings/opencode.js +8 -0
- package/dist/agent-config/model-settings/opencode.js.map +1 -0
- package/dist/agent-config/model-settings/pi.d.ts +12 -0
- package/dist/agent-config/model-settings/pi.js +18 -0
- package/dist/agent-config/model-settings/pi.js.map +1 -0
- package/dist/agent-config/model-settings/shared.d.ts +9 -0
- package/dist/agent-config/model-settings/shared.js +93 -0
- package/dist/agent-config/model-settings/shared.js.map +1 -0
- package/dist/agent-config/model-settings/types.d.ts +9 -0
- package/dist/agent-config/model-settings/types.js +2 -0
- package/dist/agent-config/model-settings/types.js.map +1 -0
- package/dist/agent-config/models.d.ts +9 -0
- package/dist/agent-config/models.js +32 -0
- package/dist/agent-config/models.js.map +1 -0
- package/dist/agent-config/service.js +4 -4
- package/dist/agent-config/service.js.map +1 -1
- package/dist/automations/types.d.ts +4 -0
- package/dist/automations/types.js +2 -1
- package/dist/automations/types.js.map +1 -1
- package/dist/config.d.ts +10 -1
- package/dist/config.js +48 -5
- package/dist/config.js.map +1 -1
- package/dist/contract/agent-config.d.ts +2 -0
- package/dist/contract/agent-profiles.d.ts +347 -0
- package/dist/contract/automations.d.ts +24 -0
- package/dist/contract/events.d.ts +59 -0
- package/dist/contract/github.d.ts +88 -0
- package/dist/contract/health.d.ts +5 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.js +981 -544
- package/dist/contract/projects.d.ts +33 -5
- package/dist/contract/runs.d.ts +247 -0
- package/dist/contract/skills.d.ts +7 -0
- package/dist/contract/workflows.d.ts +6 -0
- package/dist/contract/workspace.d.ts +137 -2
- package/dist/core/agent-env.js +41 -16
- package/dist/core/agent-env.js.map +1 -1
- package/dist/core/agent-model-policy.d.ts +10 -0
- package/dist/core/agent-model-policy.js +36 -0
- package/dist/core/agent-model-policy.js.map +1 -0
- package/dist/core/agent-profiles.d.ts +62 -0
- package/dist/core/agent-profiles.js +90 -0
- package/dist/core/agent-profiles.js.map +1 -0
- package/dist/core/agent-runner.d.ts +37 -6
- package/dist/core/agent-runner.js +35 -3
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/backend-detect.d.ts +5 -5
- package/dist/core/backend-detect.js +44 -7
- package/dist/core/backend-detect.js.map +1 -1
- package/dist/core/claude-cli-runner.js +10 -5
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/codex-app-server-runner.js +13 -3
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/codex-app-server-transport.js +7 -2
- package/dist/core/codex-app-server-transport.js.map +1 -1
- package/dist/core/model-identity.d.ts +23 -11
- package/dist/core/model-identity.js +41 -24
- package/dist/core/model-identity.js.map +1 -1
- package/dist/core/model-presets.d.ts +8 -2
- package/dist/core/model-presets.js +51 -8
- package/dist/core/model-presets.js.map +1 -1
- package/dist/core/opencode-model-catalog.d.ts +32 -0
- package/dist/core/opencode-model-catalog.js +153 -0
- package/dist/core/opencode-model-catalog.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +2 -0
- package/dist/core/opencode-server-runner.js +42 -11
- package/dist/core/opencode-server-runner.js.map +1 -1
- package/dist/core/pi-runner.d.ts +24 -0
- package/dist/core/pi-runner.js +349 -0
- package/dist/core/pi-runner.js.map +1 -0
- package/dist/core/pi-ui-mapper.d.ts +34 -0
- package/dist/core/pi-ui-mapper.js +270 -0
- package/dist/core/pi-ui-mapper.js.map +1 -0
- package/dist/core/provider-auth.d.ts +95 -3
- package/dist/core/provider-auth.js +234 -15
- package/dist/core/provider-auth.js.map +1 -1
- package/dist/core/provider-availability.d.ts +1 -1
- package/dist/core/provider-availability.js +2 -1
- package/dist/core/provider-availability.js.map +1 -1
- package/dist/core/runner-factory.js +3 -0
- package/dist/core/runner-factory.js.map +1 -1
- package/dist/core/shell-env.d.ts +28 -0
- package/dist/core/shell-env.js +56 -0
- package/dist/core/shell-env.js.map +1 -0
- package/dist/core/ui-events.d.ts +1 -1
- package/dist/core/usage-limit.d.ts +42 -0
- package/dist/core/usage-limit.js +209 -0
- package/dist/core/usage-limit.js.map +1 -0
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +53 -5
- package/dist/paths.js +63 -9
- package/dist/paths.js.map +1 -1
- package/dist/planner.js +6 -0
- package/dist/planner.js.map +1 -1
- package/dist/release/snapshot.d.ts +13 -2
- package/dist/release/snapshot.js +25 -1
- package/dist/release/snapshot.js.map +1 -1
- package/dist/runs/agent-tmpdir.d.ts +41 -0
- package/dist/runs/agent-tmpdir.js +183 -0
- package/dist/runs/agent-tmpdir.js.map +1 -0
- package/dist/runs/auto-name.js +6 -1
- package/dist/runs/auto-name.js.map +1 -1
- package/dist/runs/event-history.d.ts +50 -0
- package/dist/runs/event-history.js +613 -0
- package/dist/runs/event-history.js.map +1 -0
- package/dist/runs/run-index.d.ts +19 -0
- package/dist/runs/run-index.js +43 -0
- package/dist/runs/run-index.js.map +1 -0
- package/dist/runs/store.d.ts +78 -19
- package/dist/runs/store.js +175 -43
- package/dist/runs/store.js.map +1 -1
- package/dist/server/forge/github.d.ts +169 -0
- package/dist/server/forge/github.js +511 -0
- package/dist/server/forge/github.js.map +1 -1
- package/dist/server/forge/index.d.ts +9 -0
- package/dist/server/forge/index.js +14 -0
- package/dist/server/forge/index.js.map +1 -1
- package/dist/server/github.d.ts +2 -2
- package/dist/server/github.js +1 -1
- package/dist/server/github.js.map +1 -1
- package/dist/server/open-in-app.d.ts +2 -1
- package/dist/server/open-in-app.js +3 -1
- package/dist/server/open-in-app.js.map +1 -1
- package/dist/server/open-in-terminal.d.ts +46 -1
- package/dist/server/open-in-terminal.js +72 -13
- package/dist/server/open-in-terminal.js.map +1 -1
- package/dist/server/provider-action-gate.js +3 -1
- package/dist/server/provider-action-gate.js.map +1 -1
- package/dist/server/server.d.ts +2199 -428
- package/dist/server/server.js +1171 -139
- package/dist/server/server.js.map +1 -1
- package/dist/server-install/engine.js +5 -18
- package/dist/server-install/engine.js.map +1 -1
- package/dist/server-install/steps.js +6 -2
- package/dist/server-install/steps.js.map +1 -1
- package/dist/skills.js +4 -0
- package/dist/skills.js.map +1 -1
- package/dist/workflows/run.d.ts +202 -2
- package/dist/workflows/run.js +782 -115
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +3 -0
- package/dist/workflows/types.js +11 -2
- package/dist/workflows/types.js.map +1 -1
- package/dist/workspace/agent-accounts.d.ts +153 -0
- package/dist/workspace/agent-accounts.js +304 -0
- package/dist/workspace/agent-accounts.js.map +1 -0
- package/dist/workspace/agent-profiles.d.ts +78 -0
- package/dist/workspace/agent-profiles.js +115 -0
- package/dist/workspace/agent-profiles.js.map +1 -0
- package/dist/workspace/config.d.ts +19 -1
- package/dist/workspace/config.js +56 -2
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/projects-cli.js +54 -4
- package/dist/workspace/projects-cli.js.map +1 -1
- package/dist/workspace/projects.d.ts +19 -1
- package/dist/workspace/projects.js +34 -1
- package/dist/workspace/projects.js.map +1 -1
- package/dist/workspace/semaphore.d.ts +39 -0
- package/dist/workspace/semaphore.js +31 -0
- package/dist/workspace/semaphore.js.map +1 -1
- package/package.json +3 -3
- package/scripts/mock-claude.mjs +20 -0
- package/scripts/mock-pi-rpc.mjs +83 -0
- package/web/dist/assets/{alert-dialog-V9cigyAO.js → alert-dialog-Byp42_IG.js} +1 -1
- package/web/dist/assets/arrow-down-CGDbDYKD.js +1 -0
- package/web/dist/assets/arrow-left-ESCjr1Hq.js +1 -0
- package/web/dist/assets/centered-state-LfAW-igm.js +43 -0
- package/web/dist/assets/chevron-right-Du-P1IUd.js +1 -0
- package/web/dist/assets/{collapsible-BlF-7CeC.js → collapsible-CBiiqDJM.js} +1 -1
- package/web/dist/assets/{commit-list-D2mKElUx.js → commit-list-6PPve7PE.js} +1 -1
- package/web/dist/assets/{compare-variants-CFBY16Rc.js → compare-variants-BDnp6mXJ.js} +1 -1
- package/web/dist/assets/{diff-DE6u5dTe.js → diff-_jK0OWpF.js} +2 -2
- package/web/dist/assets/{diff-stat-B1j6L7C6.js → diff-stat-CIrTu4Ym.js} +1 -1
- package/web/dist/assets/{diff-view-Gb2kk3Le.js → diff-view-COA_5IyY.js} +1 -1
- package/web/dist/assets/dropdown-menu-BofYpFFw.js +1 -0
- package/web/dist/assets/editable-title-_J0zFDl-.js +1 -0
- package/web/dist/assets/{ellipsis-vertical-BbxtXb6s.js → ellipsis-vertical-BeMR-l62.js} +1 -1
- package/web/dist/assets/{file-j5O4NryU.js → file-DY996Pee.js} +1 -1
- package/web/dist/assets/{folder-w9Lwj9x9.js → folder-NO3Ec-TY.js} +1 -1
- package/web/dist/assets/git-toolbar-BqJeUWcD.js +1 -0
- package/web/dist/assets/github-C96HPV9E.js +1 -0
- package/web/dist/assets/{image-preview-CcFdkZXW.js → image-preview-D9cCDfKQ.js} +1 -1
- package/web/dist/assets/index-Bx-grqa9.css +2 -0
- package/web/dist/assets/index-DoVvGDfW.js +7 -0
- package/web/dist/assets/{markdown-q9lm7kQ2.js → markdown-DDzk11zj.js} +1 -1
- package/web/dist/assets/new-task-form-BNLLgC6P.js +1 -0
- package/web/dist/assets/pill-CVM1EmV4.js +1 -0
- package/web/dist/assets/{project-router-DudOmkHf.js → project-router-LRCtP48W.js} +1 -1
- package/web/dist/assets/prompt-templates-DFOvYgtW.js +15 -0
- package/web/dist/assets/{refresh-cw-_C2EGJK5.js → refresh-cw-D1ys6QY2.js} +1 -1
- package/web/dist/assets/repo-git-CzUnsgWY.js +1 -0
- package/web/dist/assets/{run-diff-BLoKEnTx.js → run-diff-BlnqwfSS.js} +2 -2
- package/web/dist/assets/run-header-Bn7cN0Z1.js +1 -0
- package/web/dist/assets/{search-x-BgxaJgro.js → search-x-D6FSpxId.js} +1 -1
- package/web/dist/assets/{skill-empty-hint-D5MOmycv.js → skill-empty-hint-iwpCcqgI.js} +1 -1
- package/web/dist/assets/skills-CezbOaol.js +1 -0
- package/web/dist/assets/{sparkles-ANSka5bH.js → sparkles-MrQrgCI9.js} +1 -1
- package/web/dist/assets/{square-terminal-qojq20cH.js → square-terminal-TTH64lO7.js} +1 -1
- package/web/dist/assets/tab-link-FYksz037.js +1 -0
- package/web/dist/assets/task-changes-ClWlnzBL.js +1 -0
- package/web/dist/assets/{task-commits-yXmDrCoE.js → task-commits-Crbrl86b.js} +1 -1
- package/web/dist/assets/{task-files-BDRmCCPN.js → task-files-uSCWyD0-.js} +2 -2
- package/web/dist/assets/task-thread-DPmamBA1.js +9 -0
- package/web/dist/assets/{textarea-DVO7pg8Y.js → textarea-B9Ahj4E4.js} +1 -1
- package/web/dist/assets/thread-loading-BH_ypQoy.js +1 -0
- package/web/dist/assets/{trash-2-DKcnmg6e.js → trash-2-D8S3V3fu.js} +1 -1
- package/web/dist/assets/{triangle-alert-CPUEVSZ3.js → triangle-alert-bB7p9n5E.js} +1 -1
- package/web/dist/assets/{upload-D5Jrq7QK.js → upload-Bd5oRLPV.js} +1 -1
- package/web/dist/assets/{use-desktop-CXae6uyk.js → use-desktop-BNvbRBeZ.js} +1 -1
- package/web/dist/assets/{use-submit-shortcut-RKJLKdsW.js → use-submit-shortcut-B-xhu4Bf.js} +1 -1
- package/web/dist/assets/utils-BhpbVMwN.js +64 -0
- package/web/dist/assets/{workflows-CGyTdyds.js → workflows-RmfbM9Vy.js} +3 -3
- package/web/dist/assets/{zoomable-image-hcqwUIlQ.js → zoomable-image-BCW6ZBV0.js} +1 -1
- package/web/dist/index.html +24 -23
- package/web/dist/assets/arrow-down-DflHdNlN.js +0 -1
- package/web/dist/assets/arrow-left-IIL4uR-C.js +0 -1
- package/web/dist/assets/centered-state-BkAJVLJE.js +0 -43
- package/web/dist/assets/chevron-right-CxAoD1eD.js +0 -1
- package/web/dist/assets/circle-check-CdwcIbkx.js +0 -1
- package/web/dist/assets/circle-x-Cnc43xJ2.js +0 -1
- package/web/dist/assets/dropdown-menu-Ns-rY6sm.js +0 -1
- package/web/dist/assets/editable-title-BGuiDxFT.js +0 -1
- package/web/dist/assets/git-pull-request-DlSop3Ch.js +0 -1
- package/web/dist/assets/git-toolbar-BdJ8XSxI.js +0 -1
- package/web/dist/assets/github-CCLn2OAU.js +0 -1
- package/web/dist/assets/index-BmHHLbwc.js +0 -6
- package/web/dist/assets/index-u8OAnq0r.css +0 -2
- package/web/dist/assets/new-task-form-BuYxAWN7.js +0 -1
- package/web/dist/assets/pill-t97MvPW_.js +0 -1
- package/web/dist/assets/prompt-templates-C5iO3Lda.js +0 -15
- package/web/dist/assets/repo-git-DqQzj33d.js +0 -1
- package/web/dist/assets/run-header-Dho4eurS.js +0 -1
- package/web/dist/assets/skills-B5g064CC.js +0 -1
- package/web/dist/assets/tab-link-CIcwFJtC.js +0 -1
- package/web/dist/assets/task-changes-yRfsEsnh.js +0 -1
- package/web/dist/assets/task-thread-C9AnIz4Z.js +0 -9
- package/web/dist/assets/thread-loading-DJDl701Z.js +0 -1
- package/web/dist/assets/utils-Wtkd5E6j.js +0 -64
package/dist/server/server.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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({
|
|
@@ -195,7 +257,11 @@ const startRunSchema = z
|
|
|
195
257
|
task: z.string().min(1).max(100_000, 'must be at most 100000 characters'),
|
|
196
258
|
model: z.string().optional(),
|
|
197
259
|
// Agent backend for this task (falls back to config `defaultRunner`).
|
|
198
|
-
runner: z.enum(
|
|
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(),
|
|
199
265
|
// Parallel variants (spec 010): ×2/×3 runs the task as 2–3 competing
|
|
200
266
|
// agents in separate worktrees; the user compares diffs and picks one.
|
|
201
267
|
variants: z.number().int().min(1).max(3).optional(),
|
|
@@ -297,66 +363,6 @@ const appearanceSchema = z.object({
|
|
|
297
363
|
density: z.enum(['comfortable', 'compact', 'ultra']).optional(),
|
|
298
364
|
width: z.enum(['narrow', 'wide']).optional(),
|
|
299
365
|
});
|
|
300
|
-
const providerAuthDismissalsSchema = z
|
|
301
|
-
.object({
|
|
302
|
-
claude: z.string().min(1).max(128).optional(),
|
|
303
|
-
codex: z.string().min(1).max(128).optional(),
|
|
304
|
-
opencode: z.string().min(1).max(128).optional(),
|
|
305
|
-
})
|
|
306
|
-
.strict();
|
|
307
|
-
/** Global GUI state (`~/.cezar/ui-state.json`, step 2.7) — the workspace twin
|
|
308
|
-
* of `uiStateSchema` below, sharing its `.passthrough()` + key-cap + shallow
|
|
309
|
-
* merge-on-write semantics via `parseUiStateBody`. Known keys are the
|
|
310
|
-
* cross-project prefs from the spec's Data Model; everything project-scoped
|
|
311
|
-
* (githubView, prompt templates, dismissed banners…) stays per-repo. */
|
|
312
|
-
const workspaceUiStateSchema = z
|
|
313
|
-
.object({
|
|
314
|
-
appearance: appearanceSchema.optional(),
|
|
315
|
-
notifications: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
|
|
316
|
-
dismissedProviderAuthFailures: providerAuthDismissalsSchema.optional(),
|
|
317
|
-
// LEGACY, like `sidebar` below: both moved into the browser's own
|
|
318
|
-
// localStorage (packages/web/src/lib/{last-location,sidebar-collapse}.ts),
|
|
319
|
-
// because one workspace-wide answer meant the last client to navigate — a
|
|
320
|
-
// phone, a second window — decided where every other client's next launch
|
|
321
|
-
// landed, and whose sidebar groups were shut. Kept named and bounded here
|
|
322
|
-
// so a cockpit from before that change still round-trips and validates.
|
|
323
|
-
lastLocation: z
|
|
324
|
-
.object({
|
|
325
|
-
projectId: z.string().min(1).max(64),
|
|
326
|
-
pathname: z.string().min(1).max(2048).startsWith('/p/'),
|
|
327
|
-
search: z.string().max(4096).startsWith('?').optional(),
|
|
328
|
-
hash: z.string().max(2048).startsWith('#').optional(),
|
|
329
|
-
})
|
|
330
|
-
.strict()
|
|
331
|
-
.optional(),
|
|
332
|
-
// Sidebar per-project collapse map, keyed by project id (slug ≤ 64 chars).
|
|
333
|
-
// Entry-capped like `skillUsage`: the map is written straight to a file the
|
|
334
|
-
// cockpit GETs on every load, so it must stay bounded on every axis.
|
|
335
|
-
sidebar: z
|
|
336
|
-
.object({
|
|
337
|
-
collapsed: z
|
|
338
|
-
.record(z.string().min(1).max(64), z.boolean())
|
|
339
|
-
.refine((map) => Object.keys(map).length <= UI_STATE_MAX_KEYS, {
|
|
340
|
-
message: `sidebar.collapsed must have at most ${UI_STATE_MAX_KEYS} entries`,
|
|
341
|
-
})
|
|
342
|
-
.optional(),
|
|
343
|
-
})
|
|
344
|
-
.passthrough()
|
|
345
|
-
.optional(),
|
|
346
|
-
// The user's curated selection of default (vendor) skills — `open-mercato/skills` — so the
|
|
347
|
-
// catalog is no longer forced in full. GLOBAL (here, not per-repo) because "which skills I
|
|
348
|
-
// want" describes the person, not a checkout, and must not depend on where cezar was launched
|
|
349
|
-
// (multi-project workspace). Tri-state, enforced in `discoverSkills`: an ABSENT key means "not
|
|
350
|
-
// curated" and every default skill still shows (opt-out default — no silent break on upgrade);
|
|
351
|
-
// a PRESENT array (even `[]`) shows only those names. Bounded like the `skillUsage` map: the
|
|
352
|
-
// file is GET/PUT wholesale, so an unbounded array is an unbounded write. Names match
|
|
353
|
-
// `lastTask.ref` (`.min(1).max(200)`). The client PUTs the whole array (shallow top-level merge).
|
|
354
|
-
importedSkills: z
|
|
355
|
-
.array(z.string().min(1).max(200))
|
|
356
|
-
.max(SKILL_USAGE_MAX_ENTRIES)
|
|
357
|
-
.optional(),
|
|
358
|
-
})
|
|
359
|
-
.passthrough();
|
|
360
366
|
const uiStateSchema = z
|
|
361
367
|
.object({
|
|
362
368
|
lastTask: z
|
|
@@ -495,7 +501,7 @@ function foldedLength(task, stack) {
|
|
|
495
501
|
const continueSchema = z.object({
|
|
496
502
|
text: z.string().max(100_000, 'must be at most 100000 characters').optional(),
|
|
497
503
|
images: z.array(imageInputSchema).max(4).optional(),
|
|
498
|
-
runner: z.enum(
|
|
504
|
+
runner: z.enum(RUNNER_IDS).optional(),
|
|
499
505
|
model: z.string().max(200).optional(),
|
|
500
506
|
});
|
|
501
507
|
// Inbox "▶ Run" body (spec 007 / #401 / #413): every field optional, and the whole body is
|
|
@@ -507,7 +513,7 @@ const continueSchema = z.object({
|
|
|
507
513
|
// absent so it never touches `task`.
|
|
508
514
|
const startTodoSchema = z
|
|
509
515
|
.object({
|
|
510
|
-
runner: z.enum(
|
|
516
|
+
runner: z.enum(RUNNER_IDS).optional(),
|
|
511
517
|
model: z.string().max(200).optional(),
|
|
512
518
|
prompt: z
|
|
513
519
|
.string()
|
|
@@ -555,7 +561,6 @@ function capUiStateKeys(data, ctx) {
|
|
|
555
561
|
// generic wrapper leaves the schema type unresolved where `jsonBody` needs it, and Hono answers
|
|
556
562
|
// that by dropping the whole PUT from the route schema rather than erroring — both ui-state PUTs
|
|
557
563
|
// silently vanished from `AppType`. Concrete consts keep them visible to `hc`.
|
|
558
|
-
const workspaceUiStateBody = workspaceUiStateSchema.superRefine(capUiStateKeys);
|
|
559
564
|
const uiStateBody = uiStateSchema.superRefine(capUiStateKeys);
|
|
560
565
|
/**
|
|
561
566
|
* Query schemas for the READ routes — deliberately permissive.
|
|
@@ -668,7 +673,10 @@ export function createApp(deps) {
|
|
|
668
673
|
const bootRoot = deps.repoRoot;
|
|
669
674
|
const bootDataDir = join(bootRoot, '.ai/cezar');
|
|
670
675
|
const modelCatalog = deps.modelCatalog ?? new RunnerModelCatalog({
|
|
671
|
-
adapters: {
|
|
676
|
+
adapters: {
|
|
677
|
+
codex: { discover: () => discoverCodexModels({ cwd: bootRoot }) },
|
|
678
|
+
opencode: { discover: () => discoverOpencodeModels({ cwd: bootRoot }) },
|
|
679
|
+
},
|
|
672
680
|
});
|
|
673
681
|
const providerAuth = deps.providerAuth ?? new ProviderAuthService();
|
|
674
682
|
const workspaceConfig = deps.workspaceConfig ?? {
|
|
@@ -676,14 +684,48 @@ export function createApp(deps) {
|
|
|
676
684
|
mergeWrite: mergeWriteWorkspaceConfig,
|
|
677
685
|
};
|
|
678
686
|
const providerStatus = async (options) => {
|
|
687
|
+
if (providerAuthChecksDisabled()) {
|
|
688
|
+
return applyProviderEnablement(await providerAuth.status(options?.refresh ? { refresh: true } : undefined), []);
|
|
689
|
+
}
|
|
679
690
|
const [discovered, workspace] = await Promise.all([
|
|
680
691
|
providerAuth.status(options?.refresh ? { refresh: true } : undefined),
|
|
681
692
|
workspaceConfig.load(),
|
|
682
693
|
]);
|
|
683
694
|
return applyProviderEnablement(discovered, workspace.disabledProviders);
|
|
684
695
|
};
|
|
685
|
-
|
|
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
|
+
};
|
|
686
726
|
const openTerminal = deps.openTerminal ?? openInTerminal;
|
|
727
|
+
const openFile = deps.openFile ?? openFileInDefaultApp;
|
|
728
|
+
const openApp = deps.openApp ?? openInApp;
|
|
687
729
|
const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService();
|
|
688
730
|
// ---- workspace boot-project identity (multi-project spec) ----------------
|
|
689
731
|
// The boot flow (`initWorkspace` in src/index.ts) registers the boot repo
|
|
@@ -1158,12 +1200,106 @@ export function createApp(deps) {
|
|
|
1158
1200
|
// `GET /api/health` reads a warm value instead of the cold ~1 s compute.
|
|
1159
1201
|
if (deps.socketHub)
|
|
1160
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();
|
|
1161
1238
|
// ---- chained family: host model catalog (workspace-level) ----
|
|
1162
1239
|
const modelsRoutes = new Hono()
|
|
1163
|
-
|
|
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) => {
|
|
1164
1244
|
const query = { data: c.req.valid('query') };
|
|
1165
1245
|
return c.json(await modelCatalog.get(query.data.runner));
|
|
1166
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
|
+
};
|
|
1167
1303
|
// ---- chained family: agent providers (workspace-level) ----
|
|
1168
1304
|
const providersRoutes = new Hono()
|
|
1169
1305
|
.get('/providers/status', queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
|
|
@@ -1205,11 +1341,46 @@ export function createApp(deps) {
|
|
|
1205
1341
|
workspaceEvents.emit('provider-status', row);
|
|
1206
1342
|
return c.json(result);
|
|
1207
1343
|
})
|
|
1208
|
-
.post('/providers/connect', jsonZodValidator(providerConnectSchema, { message: 'provider must be claude, codex, or
|
|
1344
|
+
.post('/providers/connect', jsonZodValidator(providerConnectSchema, { message: 'provider must be claude, codex, opencode, or pi' }), async (c) => {
|
|
1209
1345
|
const body = { data: c.req.valid('json') };
|
|
1210
1346
|
const provider = body.data.provider;
|
|
1211
|
-
|
|
1212
|
-
|
|
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 });
|
|
1213
1384
|
if (!row) {
|
|
1214
1385
|
return c.json({ error: 'Authentication could not be verified. Try again.' }, 500);
|
|
1215
1386
|
}
|
|
@@ -1227,6 +1398,9 @@ export function createApp(deps) {
|
|
|
1227
1398
|
}
|
|
1228
1399
|
let opened = false;
|
|
1229
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.
|
|
1230
1404
|
opened = await openTerminal(bootRoot, command);
|
|
1231
1405
|
}
|
|
1232
1406
|
catch {
|
|
@@ -1237,6 +1411,468 @@ export function createApp(deps) {
|
|
|
1237
1411
|
}
|
|
1238
1412
|
return c.json({ opened: true, command });
|
|
1239
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
|
+
});
|
|
1240
1876
|
// ---- workspace projects (multi-project spec) -----------------------------
|
|
1241
1877
|
// The registered-project list for the cockpit sidebar. Same-origin (unlike
|
|
1242
1878
|
// health), so absolute `root`s are fine here. `listProjects()` TTL-caches
|
|
@@ -1350,7 +1986,21 @@ export function createApp(deps) {
|
|
|
1350
1986
|
const body = { removed: true, id };
|
|
1351
1987
|
return c.json(body);
|
|
1352
1988
|
})
|
|
1353
|
-
|
|
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) => {
|
|
1354
2004
|
if (capabilities().singleProject) {
|
|
1355
2005
|
return c.json(singleProjectRefusal('editing projects'), 409);
|
|
1356
2006
|
}
|
|
@@ -1362,7 +2012,7 @@ export function createApp(deps) {
|
|
|
1362
2012
|
const parsed = { data: c.req.valid('json') };
|
|
1363
2013
|
// `default` is the boot alias the cockpit is allowed to use everywhere else.
|
|
1364
2014
|
const id = raw === 'default' ? await resolveBootProject() : raw;
|
|
1365
|
-
const { maxParallel } = parsed.data;
|
|
2015
|
+
const { maxParallel, tags } = parsed.data;
|
|
1366
2016
|
// Read-first (mirroring DELETE, server.ts:1252-1258): a well-formed but
|
|
1367
2017
|
// unknown id must 404 WITHOUT rewriting the config — otherwise it would both
|
|
1368
2018
|
// do a needless full-config tmp+rename and, on a read-only home, surface the
|
|
@@ -1383,12 +2033,27 @@ export function createApp(deps) {
|
|
|
1383
2033
|
const entry = config.projects.find((p) => p.id === id);
|
|
1384
2034
|
if (!entry)
|
|
1385
2035
|
return; // lost a race with a concurrent remove — answered below
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
+
}
|
|
1392
2057
|
updated = entry;
|
|
1393
2058
|
});
|
|
1394
2059
|
}
|
|
@@ -1650,17 +2315,6 @@ export function createApp(deps) {
|
|
|
1650
2315
|
throw error;
|
|
1651
2316
|
}
|
|
1652
2317
|
});
|
|
1653
|
-
// Edit one field of an existing registry entry (spec
|
|
1654
|
-
// 2026-07-22-per-project-concurrency): the per-project concurrency ceiling.
|
|
1655
|
-
// A PATCH (not PUT) because it touches a single field, and a distinct route
|
|
1656
|
-
// from POST (register-a-folder) to keep register vs. edit semantics clear.
|
|
1657
|
-
// `maxParallel: null` clears the override back to "inherit the workspace
|
|
1658
|
-
// cap". Bounds mirror `workspaceProjectSchema` (config.ts) exactly, so a
|
|
1659
|
-
// value this route accepts can never be degraded away by the next load's
|
|
1660
|
-
// `.catch`.
|
|
1661
|
-
const updateProjectSchema = z.object({
|
|
1662
|
-
maxParallel: z.number().int().min(1).max(16).nullable(),
|
|
1663
|
-
});
|
|
1664
2318
|
// ---- GUI clone (multi-project spec, step 4.3) ----------------------------
|
|
1665
2319
|
// "Add project → Clone from GitHub": clone into the checkout root, then
|
|
1666
2320
|
// register the result through `registerFolder` above (same guards, same
|
|
@@ -1702,16 +2356,24 @@ export function createApp(deps) {
|
|
|
1702
2356
|
maxParallel: config.resources.maxParallel,
|
|
1703
2357
|
maxMonitoringSessions: config.resources.maxMonitoringSessions,
|
|
1704
2358
|
monitoringWakeIntervalMinutes: config.resources.monitoringWakeIntervalMinutes,
|
|
2359
|
+
autoResumeOnUsageLimit: config.resources.autoResumeOnUsageLimit,
|
|
1705
2360
|
memoryLimitMb: config.resources.memoryLimitMb,
|
|
1706
2361
|
worktreeRetentionDefault: config.resources.worktreeRetentionDefault,
|
|
1707
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
|
+
},
|
|
1708
2370
|
});
|
|
1709
2371
|
// ---- chained family: workspace settings + GUI prefs (workspace-level) ----
|
|
1710
2372
|
const workspaceConfigRoutes = new Hono()
|
|
1711
2373
|
.get('/workspace/config', async (c) => c.json(workspaceConfigBody(await loadWorkspaceConfig())))
|
|
1712
2374
|
.put('/workspace/config', jsonZodValidator(() => workspaceConfigUpdateSchema), async (c) => {
|
|
1713
2375
|
const parsed = { data: c.req.valid('json') };
|
|
1714
|
-
const { browseRoot, projectsDir, skillsAutoUpdate, composerDefaults, resources } = parsed.data;
|
|
2376
|
+
const { browseRoot, projectsDir, skillsAutoUpdate, composerDefaults, resources, agentDefaults } = parsed.data;
|
|
1715
2377
|
for (const [configuredRoot, create] of [
|
|
1716
2378
|
[browseRoot, false],
|
|
1717
2379
|
[projectsDir, true],
|
|
@@ -1772,11 +2434,35 @@ export function createApp(deps) {
|
|
|
1772
2434
|
if (resources?.monitoringWakeIntervalMinutes !== undefined) {
|
|
1773
2435
|
config.resources.monitoringWakeIntervalMinutes = resources.monitoringWakeIntervalMinutes;
|
|
1774
2436
|
}
|
|
2437
|
+
if (resources?.autoResumeOnUsageLimit !== undefined) {
|
|
2438
|
+
config.resources.autoResumeOnUsageLimit = resources.autoResumeOnUsageLimit;
|
|
2439
|
+
}
|
|
1775
2440
|
if (resources?.memoryLimitMb !== undefined)
|
|
1776
2441
|
config.resources.memoryLimitMb = resources.memoryLimitMb;
|
|
1777
2442
|
if (resources?.worktreeRetentionDefault !== undefined) {
|
|
1778
2443
|
config.resources.worktreeRetentionDefault = resources.worktreeRetentionDefault;
|
|
1779
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
|
+
}
|
|
1780
2466
|
});
|
|
1781
2467
|
}
|
|
1782
2468
|
catch (err) {
|
|
@@ -1799,7 +2485,7 @@ export function createApp(deps) {
|
|
|
1799
2485
|
// the same point (before the handler, so the cap still precedes any read) and leaves the
|
|
1800
2486
|
// chain's type accumulation alone. Method-agnostic here, which the GET does not mind.
|
|
1801
2487
|
.use('/workspace/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
|
|
1802
|
-
.put('/workspace/ui-state', jsonZodValidator(
|
|
2488
|
+
.put('/workspace/ui-state', jsonZodValidator(setWorkspaceUiStateInputSchema), async (c) => {
|
|
1803
2489
|
const parsed = { data: c.req.valid('json') };
|
|
1804
2490
|
try {
|
|
1805
2491
|
return c.json(await mergeWriteWorkspaceUiState((state) => ({
|
|
@@ -1830,10 +2516,26 @@ export function createApp(deps) {
|
|
|
1830
2516
|
maxParallel: z.number().int().min(1).max(16).optional(),
|
|
1831
2517
|
maxMonitoringSessions: z.number().int().min(0).max(16).optional(),
|
|
1832
2518
|
monitoringWakeIntervalMinutes: z.number().int().min(1).max(60).nullable().optional(),
|
|
2519
|
+
autoResumeOnUsageLimit: z.boolean().optional(),
|
|
1833
2520
|
memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
|
|
1834
2521
|
worktreeRetentionDefault: z.number().int().min(0).max(1000).optional(),
|
|
1835
2522
|
})
|
|
1836
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(),
|
|
1837
2539
|
});
|
|
1838
2540
|
// ---- chained family: filesystem browse (workspace-level) ----
|
|
1839
2541
|
const fsBrowseRoutes = new Hono()
|
|
@@ -2321,19 +3023,43 @@ export function createApp(deps) {
|
|
|
2321
3023
|
const id = c.req.param('id');
|
|
2322
3024
|
// An empty/absent body archives (the common case); a malformed body degrades
|
|
2323
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).
|
|
2324
3029
|
const parsed = { data: c.req.valid('json') };
|
|
2325
3030
|
const run = store.setArchived(id, parsed.data.archived !== false);
|
|
2326
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 });
|
|
2327
3043
|
})
|
|
2328
3044
|
.post('/runs/:id/read', (c) => {
|
|
2329
3045
|
// No body: opening a thread marks it read, full stop. Stamps `seenAt = now` and
|
|
2330
3046
|
// returns the updated record (which also rides the `run` SSE via `touch`).
|
|
2331
3047
|
const run = c.get('project').store.setRead(c.req.param('id'));
|
|
2332
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);
|
|
2333
3056
|
})
|
|
2334
3057
|
.post('/runs', jsonZodValidator(startRunSchema), async (c) => {
|
|
2335
3058
|
const { root: repoRoot, dataDir, manager } = c.get('project');
|
|
2336
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
|
+
}
|
|
2337
3063
|
let workflow;
|
|
2338
3064
|
if (parsed.data.steps) {
|
|
2339
3065
|
// Inline chain (spec 008): an approved plan runs as an ad-hoc workflow.
|
|
@@ -2356,6 +3082,15 @@ export function createApp(deps) {
|
|
|
2356
3082
|
const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
|
|
2357
3083
|
if (blocked)
|
|
2358
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
|
+
}
|
|
2359
3094
|
const images = parsed.data.images?.map((img) => ({
|
|
2360
3095
|
type: 'image',
|
|
2361
3096
|
source: { type: 'base64', media_type: img.mediaType, data: img.data },
|
|
@@ -2364,6 +3099,7 @@ export function createApp(deps) {
|
|
|
2364
3099
|
task: parsed.data.task,
|
|
2365
3100
|
model: parsed.data.model,
|
|
2366
3101
|
runner: parsed.data.runner,
|
|
3102
|
+
agentProfile: parsed.data.agentProfile,
|
|
2367
3103
|
images,
|
|
2368
3104
|
systemPrompt: parsed.data.systemPrompt,
|
|
2369
3105
|
worktree: parsed.data.worktree,
|
|
@@ -2402,6 +3138,27 @@ export function createApp(deps) {
|
|
|
2402
3138
|
const { store } = c.get('project');
|
|
2403
3139
|
const run = store.getRun(c.req.param('id'));
|
|
2404
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`)));
|
|
2405
3162
|
})
|
|
2406
3163
|
// Editable titles (#389). The UI displays `titleSummary ?? title`, so a
|
|
2407
3164
|
// user edit sets BOTH: `title` (the record's own name — the raw task stops
|
|
@@ -2579,7 +3336,7 @@ export function createApp(deps) {
|
|
|
2579
3336
|
})
|
|
2580
3337
|
// "Continue" (spec 003): reopen a finished run's session in-process.
|
|
2581
3338
|
.post('/runs/:id/continue', jsonZodValidator(continueSchema, { absent: ({}) }), async (c) => {
|
|
2582
|
-
const { store, manager } = c.get('project');
|
|
3339
|
+
const { root: repoRoot, store, manager } = c.get('project');
|
|
2583
3340
|
const id = c.req.param('id');
|
|
2584
3341
|
const run = store.getRun(id);
|
|
2585
3342
|
if (!run)
|
|
@@ -2587,6 +3344,9 @@ export function createApp(deps) {
|
|
|
2587
3344
|
// Bounded resume text (#429); an empty/absent body still just re-runs on the
|
|
2588
3345
|
// run's current backend, and a runner/model override reopens on that engine (#401).
|
|
2589
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
|
+
}
|
|
2590
3350
|
const blocked = await providerActionError([providerForExistingRun(run, parsed.data.runner)]);
|
|
2591
3351
|
if (blocked)
|
|
2592
3352
|
return c.json({ error: blocked }, 409);
|
|
@@ -2618,7 +3378,8 @@ export function createApp(deps) {
|
|
|
2618
3378
|
error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE); resume the session from a machine that has the checkout',
|
|
2619
3379
|
}, 409);
|
|
2620
3380
|
}
|
|
2621
|
-
const
|
|
3381
|
+
const sessionStep = [...run.steps].reverse().find((s) => s.sessionId);
|
|
3382
|
+
const sessionId = sessionStep?.sessionId;
|
|
2622
3383
|
if (!sessionId)
|
|
2623
3384
|
return c.json({ error: 'no agent session to resume' }, 409);
|
|
2624
3385
|
const blocked = await providerActionError([providerForExistingRun(run)]);
|
|
@@ -2629,12 +3390,19 @@ export function createApp(deps) {
|
|
|
2629
3390
|
// Fails closed on an id we do not recognise — see resumeCommand (#431).
|
|
2630
3391
|
if (!command)
|
|
2631
3392
|
return c.json({ error: 'the recorded session id has an unexpected shape' }, 409);
|
|
2632
|
-
|
|
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);
|
|
2633
3404
|
if (!opened) {
|
|
2634
|
-
return c.json({
|
|
2635
|
-
error: 'no terminal emulator found',
|
|
2636
|
-
command: `cd '${cwd}' && ${command}`,
|
|
2637
|
-
}, 409);
|
|
3405
|
+
return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
|
|
2638
3406
|
}
|
|
2639
3407
|
return c.json({ opened: true, command });
|
|
2640
3408
|
})
|
|
@@ -2691,7 +3459,7 @@ export function createApp(deps) {
|
|
|
2691
3459
|
}, 409);
|
|
2692
3460
|
}
|
|
2693
3461
|
const filePath = join(run.worktreePath, result.path);
|
|
2694
|
-
const opened = await
|
|
3462
|
+
const opened = await openFile(filePath);
|
|
2695
3463
|
if (!opened)
|
|
2696
3464
|
return c.json({ error: `could not open ${result.path}`, path: filePath }, 409);
|
|
2697
3465
|
return c.json({ opened: true, path: filePath });
|
|
@@ -2714,21 +3482,32 @@ export function createApp(deps) {
|
|
|
2714
3482
|
if (blocked)
|
|
2715
3483
|
return c.json({ error: blocked }, 409);
|
|
2716
3484
|
const engineOwnsSession = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
|
|
2717
|
-
const
|
|
3485
|
+
const sessionStep = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId);
|
|
3486
|
+
const sessionId = sessionStep?.sessionId;
|
|
2718
3487
|
// An id resumeCommand refuses (#431) degrades to a fresh CLI in the worktree,
|
|
2719
3488
|
// exactly like a run that never recorded a session.
|
|
2720
3489
|
const resume = sessionId && cliRunner === (run.runner ?? 'claude') ? resumeCommand(cliRunner, sessionId) : null;
|
|
2721
3490
|
const command = resume ?? cliRunner;
|
|
2722
|
-
|
|
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);
|
|
2723
3505
|
if (!opened) {
|
|
2724
|
-
return c.json({
|
|
2725
|
-
error: 'no terminal emulator found',
|
|
2726
|
-
command: `cd '${dir}' && ${command}`,
|
|
2727
|
-
}, 409);
|
|
3506
|
+
return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
|
|
2728
3507
|
}
|
|
2729
3508
|
return c.json({ opened: true, path: dir, command });
|
|
2730
3509
|
}
|
|
2731
|
-
const opened = await
|
|
3510
|
+
const opened = await openApp(target, dir);
|
|
2732
3511
|
if (!opened)
|
|
2733
3512
|
return c.json({ error: `could not open ${target}`, path: dir }, 409);
|
|
2734
3513
|
return c.json({ opened: true, path: dir });
|
|
@@ -2945,6 +3724,12 @@ export function createApp(deps) {
|
|
|
2945
3724
|
if (!outcome.ok) {
|
|
2946
3725
|
return c.json({ error: outcome.error, manual: `git merge ${run.branch}` }, 409);
|
|
2947
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);
|
|
2948
3733
|
store.updateRun(id, {
|
|
2949
3734
|
pullRequestUrl: outcome.url,
|
|
2950
3735
|
status: 'done',
|
|
@@ -3072,7 +3857,31 @@ export function createApp(deps) {
|
|
|
3072
3857
|
});
|
|
3073
3858
|
// ---- chained family: open-targets (project-scoped) ----
|
|
3074
3859
|
const openTargetsRoutes = new Hono()
|
|
3075
|
-
.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
|
+
});
|
|
3076
3885
|
// Agent screenshots — image blocks the run manager persisted out of tool
|
|
3077
3886
|
// results (persistImage). `basename` pins reads inside the run's own dir.
|
|
3078
3887
|
const IMAGE_TYPES = {
|
|
@@ -3174,6 +3983,9 @@ export function createApp(deps) {
|
|
|
3174
3983
|
const id = c.req.param('id');
|
|
3175
3984
|
const todo = c.get('todo');
|
|
3176
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
|
+
}
|
|
3177
3989
|
if (todo.startedTaskId)
|
|
3178
3990
|
return c.json({ error: 'already started' }, 409);
|
|
3179
3991
|
let task = todoTaskText(todo);
|
|
@@ -3216,14 +4028,28 @@ export function createApp(deps) {
|
|
|
3216
4028
|
});
|
|
3217
4029
|
// ---- chained family: SSE streams (project-scoped) ----
|
|
3218
4030
|
const sseRoutes = new Hono()
|
|
3219
|
-
.get('/runs/:id/events', (c) => {
|
|
3220
|
-
const { store } = c.get('project');
|
|
3221
|
-
const id = c.req.
|
|
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');
|
|
3222
4034
|
if (!store.getRun(id))
|
|
3223
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);
|
|
3224
4050
|
return streamSSENoBuffer(c, async (stream) => {
|
|
3225
4051
|
let replaying = true;
|
|
3226
|
-
let maxSeq =
|
|
4052
|
+
let maxSeq = requestedAfter;
|
|
3227
4053
|
const buffered = [];
|
|
3228
4054
|
// One endpoint, two SSE event names: v1 lines stay `run-event` (the name
|
|
3229
4055
|
// the legacy UI listened to — its default branch JSON-dumped unknown
|
|
@@ -3233,6 +4059,7 @@ export function createApp(deps) {
|
|
|
3233
4059
|
// ride `ui-event`, which only v2-aware clients subscribe to.
|
|
3234
4060
|
// EventSource ignores names it has no listener for.
|
|
3235
4061
|
const writeEvent = (event) => stream.writeSSE({
|
|
4062
|
+
id: String(event.seq),
|
|
3236
4063
|
event: isV2WireEventType(event.type) ? 'ui-event' : 'run-event',
|
|
3237
4064
|
data: JSON.stringify(event),
|
|
3238
4065
|
});
|
|
@@ -3255,9 +4082,15 @@ export function createApp(deps) {
|
|
|
3255
4082
|
store.off('event', onEvent);
|
|
3256
4083
|
store.off('run', onRun);
|
|
3257
4084
|
});
|
|
3258
|
-
|
|
3259
|
-
|
|
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;
|
|
3260
4092
|
await writeEvent(event);
|
|
4093
|
+
maxSeq = event.seq;
|
|
3261
4094
|
}
|
|
3262
4095
|
replaying = false;
|
|
3263
4096
|
for (const event of buffered) {
|
|
@@ -3434,6 +4267,21 @@ export function createApp(deps) {
|
|
|
3434
4267
|
// would be in its temporal dead zone. (The schemas below are all read inside a handler, or
|
|
3435
4268
|
// passed as a thunk, which defers them past that point.)
|
|
3436
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
|
+
};
|
|
3437
4285
|
const prChangesParams = z.object({ number: z.coerce.number().int().positive().safe() });
|
|
3438
4286
|
const prChangesQuery = z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') });
|
|
3439
4287
|
const githubRoutes = new Hono()
|
|
@@ -3477,6 +4325,23 @@ export function createApp(deps) {
|
|
|
3477
4325
|
numbers.push(n);
|
|
3478
4326
|
}
|
|
3479
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 }));
|
|
3480
4345
|
})
|
|
3481
4346
|
.get('/github/prs/:number/merge-state', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), queryZodValidator(refreshQuery), async (c) => {
|
|
3482
4347
|
const { root: repoRoot } = c.get('project');
|
|
@@ -3494,8 +4359,14 @@ export function createApp(deps) {
|
|
|
3494
4359
|
if (!forge?.mergePR)
|
|
3495
4360
|
return c.json({ error: 'GitHub merge is unavailable' }, 409);
|
|
3496
4361
|
const result = await forge.mergePR(parsedNumber.data.number, body.data);
|
|
3497
|
-
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);
|
|
3498
4368
|
return c.json(result);
|
|
4369
|
+
}
|
|
3499
4370
|
return c.json({
|
|
3500
4371
|
error: result.error,
|
|
3501
4372
|
...(result.code ? { code: result.code } : {}),
|
|
@@ -3629,29 +4500,44 @@ export function createApp(deps) {
|
|
|
3629
4500
|
});
|
|
3630
4501
|
// The Settings → Agents knobs in one read (R6 Step 1.5) — an ADDITIVE
|
|
3631
4502
|
// sibling of PUT /api/config below; /api/health keeps its protected shape.
|
|
3632
|
-
const configAnswer = (config) =>
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
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
|
+
};
|
|
3649
4529
|
// ---- chained family: per-repo config (project-scoped) ----
|
|
3650
4530
|
const configRoutes = new Hono()
|
|
3651
|
-
.get('/config', async (c) =>
|
|
4531
|
+
.get('/config', async (c) => {
|
|
4532
|
+
const repoRoot = c.get('project').root;
|
|
4533
|
+
return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
|
|
4534
|
+
})
|
|
3652
4535
|
.put('/config', jsonZodValidator(() => setConfigSchema), async (c) => {
|
|
3653
4536
|
const { root: repoRoot, dataDir } = c.get('project');
|
|
3654
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
|
+
}
|
|
3655
4541
|
const configPath = join(dataDir, 'config.json');
|
|
3656
4542
|
let raw = {};
|
|
3657
4543
|
try {
|
|
@@ -3736,7 +4622,7 @@ export function createApp(deps) {
|
|
|
3736
4622
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
3737
4623
|
}
|
|
3738
4624
|
// Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
|
|
3739
|
-
return c.json(configAnswer(await loadConfig(repoRoot)));
|
|
4625
|
+
return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
|
|
3740
4626
|
});
|
|
3741
4627
|
// Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
|
|
3742
4628
|
// base-branch picker). Merges into the RAW config.json so user keys
|
|
@@ -3746,13 +4632,14 @@ export function createApp(deps) {
|
|
|
3746
4632
|
const modelPresetSchema = z.string().trim().max(200).nullable().optional();
|
|
3747
4633
|
const setConfigSchema = z.object({
|
|
3748
4634
|
baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
|
|
3749
|
-
defaultRunner: z.enum(
|
|
4635
|
+
defaultRunner: z.enum(RUNNER_IDS).optional(),
|
|
3750
4636
|
systemPrompt: z.string().trim().max(20_000, 'must be at most 20000 characters').nullable().optional(),
|
|
3751
4637
|
defaultModels: z
|
|
3752
4638
|
.object({
|
|
3753
4639
|
claude: modelPresetSchema,
|
|
3754
4640
|
codex: modelPresetSchema,
|
|
3755
4641
|
opencode: modelPresetSchema,
|
|
4642
|
+
pi: modelPresetSchema,
|
|
3756
4643
|
})
|
|
3757
4644
|
.optional(),
|
|
3758
4645
|
// Concurrency + memory guard (Settings → Resources). maxParallel clamps to
|
|
@@ -3853,6 +4740,147 @@ export function createApp(deps) {
|
|
|
3853
4740
|
.route('/', repoRoutes)
|
|
3854
4741
|
.route('/', configRoutes)
|
|
3855
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
|
+
});
|
|
3856
4884
|
// Workspace-level families answer for the whole workspace, so they are single-mount: never a
|
|
3857
4885
|
// project-scoped spelling, which would be a second surface to protect with no consumer.
|
|
3858
4886
|
const workspaceV1 = new Hono()
|
|
@@ -3860,10 +4888,12 @@ export function createApp(deps) {
|
|
|
3860
4888
|
.route('/', modelsRoutes)
|
|
3861
4889
|
.route('/', providersRoutes)
|
|
3862
4890
|
.route('/', projectsRoutes)
|
|
4891
|
+
.route('/', agentProfilesRoutes)
|
|
3863
4892
|
.route('/', skillsUpdateRoutes)
|
|
3864
4893
|
.route('/', workspaceConfigRoutes)
|
|
3865
4894
|
.route('/', fsBrowseRoutes)
|
|
3866
4895
|
.route('/', automationChecksRoutes)
|
|
4896
|
+
.route('/', runsIndexRoutes)
|
|
3867
4897
|
.route('/', workspaceEventsRoutes);
|
|
3868
4898
|
// ---- mount ---------------------------------------------------------------
|
|
3869
4899
|
// Scoped first, then the unscoped alias bound to the boot project. The paths are disjoint (no
|
|
@@ -4172,6 +5202,8 @@ export function resumeCommand(runner, sessionId) {
|
|
|
4172
5202
|
return `codex resume ${sessionId}`;
|
|
4173
5203
|
case 'opencode':
|
|
4174
5204
|
return `opencode --session ${sessionId}`;
|
|
5205
|
+
case 'pi':
|
|
5206
|
+
return `pi --session ${sessionId}`;
|
|
4175
5207
|
default:
|
|
4176
5208
|
return `claude --resume ${sessionId}`;
|
|
4177
5209
|
}
|