# NousResearch/hermes-agent · Desktop cron jobs profile scoping fixes

## Open now

- Nothing open on GitHub.

These issues and pull requests address a bug where the desktop app displays cron jobs across all profiles instead of scoping them to the active profile. The work involves fixing cron job listing, calls, and related routing to respect profile boundaries.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #67615. Verify it addresses your case.
- **Recommended:** read PR #67615 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #42651: \[Bug\]: Desktop App shows all cronjobs for every profile (although crons are profile scoped)

## Prior & competing PRs

- **#67493: fix(cron): profile-scope desktop cron calls + resolve provider with the job's effective model**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/src/hermes.ts`, `cron/scheduler.py`, `hermes_cli/web_server.py`, `tests/cron/test_codex_execution_paths.py`, `tests/cron/test_cron_provider_pin.py`, `tests/hermes_cli/test_web_server_cron_profiles.py`
  - fix hunks:
  - `apps/desktop/src/hermes.ts`

    ```diff
    @@ -958,6 +958,7 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
     
     export function getCronJobs(): Promise<CronJob[]> {
       return window.hermesDesktop.api<CronJob[]>({
    +    ...profileScoped(),
         path: '/api/cron/jobs',
         timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
       })
    ```

    ```diff
    @@ -965,12 +966,14 @@ export function getCronJobs(): Promise<CronJob[]> {
     
     export function getCronJob(jobId: string): Promise<CronJob> {
       return window.hermesDesktop.api<CronJob>({
    +    ...profileScoped(),
         path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
       })
     }
     
     export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
       const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
    +    ...profileScoped(),
         path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
       })
     
    ```

    ```diff
    @@ -979,6 +982,7 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise<Session
     
     export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
       return window.hermesDesktop.api<CronJob>({
    +    ...profileScoped(),
         path: '/api/cron/jobs',
         method: 'POST',
         body
    ```

    ```diff
    @@ -987,6 +991,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
     
     export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
       return window.hermesDesktop.api<CronJob>({
    +    ...profileScoped(),
         path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
         method: 'PUT',
         body: { updates }
    ```

    ```diff
    @@ -995,6 +1000,7 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<C
     
     export function pauseCronJob(jobId: string): Promise<CronJob> {
       return window.hermesDesktop.api<CronJob>({
    +    ...profileScoped(),
         path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
         method: 'POST'
       })
    ```

    ```diff
    @@ -1002,6 +1008,7 @@ export function pauseCronJob(jobId: string): Promise<CronJob> {
     
     export function resumeCronJob(jobId: string): Promise<CronJob> {
       return window.hermesDesktop.api<CronJob>({
    +    ...profileScoped(),
         path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
         method: 'POST'
       })
    ```

    ```diff
    @@ -1009,6 +1016,7 @@ export function resumeCronJob(jobId: string): Promise<CronJob> {
     
     export function triggerCronJob(jobId: string): Promise<CronJob> {
       return window.hermesDesktop.api<CronJob>({
    +    ...profileScoped(),
         path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
         method: 'POST'
       })
    ```

    ```diff
    @@ -1016,6 +1024,7 @@ export function triggerCronJob(jobId: string): Promise<CronJob> {
     
     export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
       return window.hermesDesktop.api<{ ok: boolean }>({
    +    ...profileScoped(),
         path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
         method: 'DELETE'
       })
    ```
  - `cron/scheduler.py`

    ```diff
    @@ -3178,6 +3178,11 @@ def run_job(
                 # example DeepSeek) for cron jobs that do not pin provider/model.
                 runtime_kwargs = {
                     "requested": job.get("provider"),
    +                # Derive provider-specific api_mode from the model this job
    +                # will actually run (per-job pin > env > config default), not
    +                # the stale persisted default — mirrors the fallback path
    +                # below, which already passes its fb_model.
    +                "target_model": model,
                 }
                 if job.get("base_url"):
                     runtime_kwargs["explicit_base_url"] = job.get("base_url")
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -11096,11 +11096,31 @@ def _cron_profile_dicts() -> List[Dict[str, Any]]:
             return _fallback_profile_dicts(profiles_mod)
     
     
    +def _cron_default_profile() -> str:
    +    """Profile to target when a cron request carries no explicit ``profile``.
    +
    +    A desktop pool backend runs one process per profile (HERMES_HOME already
    +    scoped), but these cron endpoints deliberately route storage through the
    +    profiles tree via ``_cron_profile_home`` — so a hardcoded ``"default"``
    +    fallback would write a non-default profile's job into ``~/.hermes``.
    +    Resolve the process's own profile instead. ``custom`` (an unrecognized
    +    HERMES_HOME outside the profiles tree) has no profile-dir equivalent, so
    +    it keeps the legacy ``default`` fallback.
    +    """
    +    try:
    +        from hermes_cli.profiles import get_active_profile_name
    +
    +        name = get_active_profile_name()
    +    except Exception:
    +        return "default"
    +    return "default" if name in ("default", "custom") else name
    +
    +
     def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]:
         """Resolve a profile query value to (profile_name, HERMES_HOME)."""
         from hermes_cli import profiles as profiles_mod
     
    -    raw = (profile or "default").strip() or "default"
    +    raw = (profile or _cron_default_profile()).strip() or "default"
         try:
             canon = profiles_mod.normalize_profile_name(raw)
             profiles_mod.validate_profile_name(canon)
    ```

    ```diff
    @@ -11256,7 +11276,7 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit:
         return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit)
     
     
    -def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"):
    +def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None):
         try:
             profile_name, profile_home = _cron_profile_home(profile)
             script = _normalize_dashboard_cron_script(body.script, profile_home)
    ```

    ```diff
    @@ -11295,7 +11315,7 @@ def _create_cron_job_sync(body: CronJobCreate, profile: str = "default"):
     
     
     @app.post("/api/cron/jobs")
    -async def create_cron_job(body: CronJobCreate, profile: str = "default"):
    +async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None):
         return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile)
     
     
    ```
  - `tests/cron/test_codex_execution_paths.py`

    ```diff
    @@ -98,7 +98,7 @@ def test_cron_run_job_codex_path_handles_internal_401_refresh(monkeypatch):
         monkeypatch.setattr(run_agent, "AIAgent", _Codex401ThenSuccessAgent)
         monkeypatch.setattr(
             "hermes_cli.runtime_provider.resolve_runtime_provider",
    -        lambda requested=None: {
    +        lambda requested=None, **kwargs: {
                 "provider": "openai-codex",
                 "api_mode": "codex_responses",
                 "base_url": "https://chatgpt.com/backend-api/codex",
    ```
  - `tests/cron/test_cron_provider_pin.py`

    ```diff
    @@ -334,3 +334,42 @@ def test_no_model_snapshot_backcompat(self, tmp_path):
                 )
             assert agent_constructed is True
             assert success is True
    +
    +
    +class TestRuntimeResolutionTargetModel:
    +    """run_job must resolve the primary provider against the model the job
    +    will actually run (per-job pin > env > config default), so providers with
    +    model-specific api_mode routing (e.g. OpenCode Zen/Go) pick the mode for
    +    the pinned model instead of the stale persisted default."""
    +
    +    def test_primary_resolution_passes_effective_model(self, tmp_path):
    +        job = _base_job(model="my-pinned-model", provider="openrouter")
    +        captured = {}
    +
    +        def _capture(**kwargs):
    +            captured.update(kwargs)
    +            return {
    +                "api_key": "[redacted:secret-assignment]",
    +                "base_url": "https://example.invalid/v1",
    +                "provider": "openrouter",
    +                "api_mode": "chat_completions",
    +            }
    +
    +        fake_db = MagicMock()
    +        with patch("cron.scheduler._hermes_home", tmp_path), \
    +             patch("cron.scheduler._resolve_origin", return_value=None), \
    +             patch("hermes_cli.env_loader.load_hermes_dotenv"), \
    +             patch("hermes_cli.env_loader.reset_secret_source_cache"), \
    +             patch("hermes_state.SessionDB", return_value=fake_db), \
    +             patch(
    +                 "hermes_cli.runtime_provider.resolve_runtime_provider",
    +                 side_effect=_capture,
    +             ), \
    +             patch("run_agent.AIAgent") as mock_agent_cls:
    +            mock_agent = MagicMock()
    +            mock_agent.run_conversation.return_value = {"final_response": "ok"}
    +            mock_agent_cls.return_value = mock_agent
    +            run_job(job)
    +
    +        assert captured.get("target_model") == "my-pinned-model"
    +        assert captured.get("requested") == "openrouter"
    ```
  - `tests/hermes_cli/test_web_server_cron_profiles.py`

    ```diff
    @@ -735,3 +735,53 @@ async def test_cron_profile_validation_errors(isolated_profiles):
         with pytest.raises(HTTPException) as missing:
             await web_server.list_cron_jobs(profile="missing_profile")
         assert missing.value.status_code == 404
    +
    +
    [redacted:email]
    +async def test_create_cron_job_without_profile_uses_backend_own_profile(
    +    isolated_profiles, monkeypatch
    +):
    +    """A pool backend scoped to a named profile must not default creates to
    +    ``~/.hermes`` when the request carries no explicit ``profile`` (the
    +    Desktop app's pre-profileScoped clients sent none)."""
    +    from hermes_cli import web_server
    +
    +    monkeypatch.setenv(
    +        "HERMES_HOME", str(isolated_profiles["worker_alpha"])
    +    )
    +
    +    job = await web_server.create_cron_job(
    +        web_server.CronJobCreate(
    +            prompt="runs in my own profile",
    +            schedule="every 1h",
    +            name="own-profile-job",
    +        ),
    +        profile=None,
    +    )
    +
    +    assert job["profile"] == "worker_alpha"
    +    assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists()
    +    assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists()
    +
    +
    [redacted:email]
    +async def test_create_cron_job_without_profile_defaults_when_unscoped(
    +    isolated_profiles, monkeypatch
    +):
    +    """HERMES_HOME at the default home (or unrecognized) keeps the legacy
    +    ``default`` fallback."""
    +    from hermes_cli import web_server
    +
    +    monkeypatch.setenv("HERMES_HOME", str(isolated_profiles["default"]))
    +
    +    job = await web_server.create_cron_job(
    +        web_server.CronJobCreate(
    +            prompt="runs in default",
    +            schedule="every 1h",
    +            name="default-job",
    +        ),
    +        profile=None,
    +    )
    +
    +    assert job["profile"] == "default"
    +    assert (isolated_profiles["default"] / "cron" / "jobs.json").exists()
    ```
- **#67615: fix(desktop): scope the cron jobs list to the active profile**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/src/app/cron/index.tsx`, `apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx`, `apps/desktop/src/app/session/hooks/use-session-list-actions.ts`, `apps/desktop/src/hermes-cron-scope.test.ts`, `apps/desktop/src/hermes.ts`, `contributors/emails/[redacted:email]`
  - fix hunks:
  - `apps/desktop/src/app/cron/index.tsx`

    ```diff
    @@ -43,6 +43,7 @@ import { requestModelOptions } from '@/lib/model-options'
     import { asText } from '@/lib/text'
     import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
     import { notify, notifyError } from '@/store/notifications'
    +import { $profileScope, ALL_PROFILES } from '@/store/profile'
     
     import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
     import {
    ```

    ```diff
    @@ -293,15 +294,20 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
       const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
       const [deleting, setDeleting] = useState(false)
     
    +  // Jobs live per-profile on disk and the list endpoint aggregates 'all' by
    +  // default — scope the fetch to the sidebar's profile scope so this overlay
    +  // and the sidebar (which share the $cronJobs atom) agree on what's shown.
    +  const profileScope = useStore($profileScope)
    +
       const refresh = useCallback(async () => {
         try {
    -      setCronJobs(await getCronJobs())
    +      setCronJobs(await getCronJobs(profileScope === ALL_PROFILES ? 'all' : profileScope))
         } catch (err) {
           notifyError(err, c.failedLoad)
         } finally {
           setLoading(false)
         }
    -  }, [c])
    +  }, [c, profileScope])
     
       useRefreshHotkey(refresh)
     
    ```
  - `apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx`

    ```diff
    @@ -204,4 +204,25 @@ describe('refreshSessions batches slices into one request', () => {
           })
         )
       })
    +
    +  it('scopes the cron-jobs fetch to the active profile (all → unified view)', async () => {
    +    const { getCronJobs } = await import('@/hermes')
    +    listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
    +
    +    const scoped = renderHook(() => useSessionListActions({ profileScope: 'work' }))
    +
    +    await act(async () => {
    +      await scoped.result.current.refreshCronJobs()
    +    })
    +
    +    expect(getCronJobs).toHaveBeenLastCalledWith('work')
    +
    +    const unified = renderHook(() => useSessionListActions({ profileScope: '__all__' }))
    +
    +    await act(async () => {
    +      await unified.result.current.refreshCronJobs()
    +    })
    +
    +    expect(getCronJobs).toHaveBeenLastCalledWith('all')
    +  })
     })
    ```
  - `apps/desktop/src/app/session/hooks/use-session-list-actions.ts`

    ```diff
    @@ -123,16 +123,19 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
       // Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
       // synchronously (agent tool call or the cron UI), so refreshing here right
       // after an agent turn surfaces a new job immediately; the interval poll keeps
    -  // next-run/state fresh as the scheduler advances them.
    +  // next-run/state fresh as the scheduler advances them. Jobs live per-profile
    +  // on disk and the list endpoint aggregates 'all' by default, so scope the
    +  // fetch to the sidebar's profile scope — a concrete profile sees only its
    +  // own jobs; ALL_PROFILES keeps the unified view.
       const refreshCronJobs = useCallback(async () => {
         try {
    -      const jobs = await getCronJobs()
    +      const jobs = await getCronJobs(profileScope === ALL_PROFILES ? 'all' : profileScope)
     
           setCronJobs(jobs)
         } catch {
           // Non-fatal: the cron section just keeps its last-known jobs.
         }
    -  }, [])
    +  }, [profileScope])
     
       const refreshSessions = useCallback(async () => {
         const requestId = refreshSessionsRequestRef.current + 1
    ```
  - `apps/desktop/src/hermes-cron-scope.test.ts`

    ```diff
    @@ -56,4 +56,19 @@ describe('cron helpers are profile-scoped', () => {
           expect(call[0].profile).toBe('coder')
         }
       })
    +
    +  it('list accepts an explicit ?profile= for endpoint-level filtering', () => {
    +    // profileScoped() routes the backend process; the list endpoint ALSO
    +    // aggregates 'all' by default, so callers pass an explicit profile to
    +    // filter what the endpoint returns (sidebar / cron overlay scoping).
    +    void getCronJobs('worker_alpha')
    +    expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=worker_alpha')
    +
    +    void getCronJobs('all')
    +    expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=all')
    +
    +    // Omitting the arg keeps the legacy unfiltered path.
    +    void getCronJobs()
    +    expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs')
    +  })
     })
    ```
  - `apps/desktop/src/hermes.ts`

    ```diff
    @@ -956,10 +956,17 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
       })
     }
     
    -export function getCronJobs(): Promise<CronJob[]> {
    +// Cron jobs are stored per-profile (<HERMES_HOME>/cron/jobs.json), and the
    +// backend's list endpoint defaults to 'all'. Pass a concrete profile key to
    +// list just that profile's jobs, or 'all' for the unified cross-profile view.
    +// Omitting the arg keeps the legacy 'all' default for non-profile callers.
    +// profileScoped() still rides along for backend-process routing.
    +export function getCronJobs(profile?: string): Promise<CronJob[]> {
    +  const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
    +
       return window.hermesDesktop.api<CronJob[]>({
         ...profileScoped(),
    -    path: '/api/cron/jobs',
    +    path: `/api/cron/jobs${suffix}`,
         timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
       })
     }
    ```
  - `contributors/emails/[redacted:email]`

    ```diff
    @@ -0,0 +1 @@
    +digitalbase
    ```
- **#42654: fix(desktop): scope cron jobs sidebar to active profile**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#67615`, `#67493`

## Fix status

- Merged to main in PR #67615 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-19`.
- Subsystem: `apps/desktop`
