From 2e847dddd55a060de61d37be8be4f21deaf9ab9b Mon Sep 17 00:00:00 2001 From: Fotis Stamatelopoulos Date: Fri, 8 May 2026 17:24:52 -0700 Subject: [PATCH 1/2] feat(6.33): auto-refresh ollama models on boot + manual refresh button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `availableOllamaModels` was previously captured ONCE at `cfcf init` and never re-detected by the server or web UI. Newly-pulled ollama models were invisible to the role-picker dropdowns until the user re-ran `cfcf init --force`. Three pieces: 1. New `refreshOllamaModelsInConfig()` helper in `@cfcf/core`. Detects ollama, lists models, compares against saved (order-insensitive since `ollama list` reorders by mtime), persists if different. Best-effort: never throws; missing-ollama is reported via the `error` field, not a thrown exception. 2. Boot-time auto-refresh in `start.ts`, runs after the orphan reaper. Single log line if the list changed; silent otherwise. Failures log + continue, never block boot. Solves "I pulled a model + did `cfcf server stop && cfcf server start` but the dropdown is stale". 3. `POST /api/agents/refresh-ollama-models` endpoint + a "Refresh ollama models" button in the Agent roles section of both web Settings (`ServerInfo.tsx`) and workspace Config (`ConfigDisplay.tsx`). Solves "I pulled a model and don't want to bounce the server". Button shows in-flight + result message ("✓ N models detected — list updated" / "list already current" / error hint when ollama isn't installed). Bumps a `modelsRev` counter to trigger a re-fetch of `/api/agents/models` so dropdowns pick up the change. Tests: 4 new unit tests in `ollama-detection.test.ts` (response shape, no-config-no-write, list-equality persistence guard, ordering insensitivity); 2 new endpoint tests in `agent-models.test.ts` (documented shape, no-500-on-missing-ollama defence-in-depth). Pre-existing `app.test.ts` config-merge failure on main is NOT caused by this work (verified earlier via `git stash`). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 21 +++- docs/plan.md | 3 +- packages/core/src/ollama-detection.test.ts | 97 ++++++++++++++++++- packages/core/src/ollama-detection.ts | 85 ++++++++++++++++ .../server/src/routes/agent-models.test.ts | 34 +++++++ packages/server/src/routes/agent-models.ts | 25 ++++- packages/server/src/start.ts | 21 ++++ packages/web/src/api.ts | 19 ++++ packages/web/src/components/ConfigDisplay.tsx | 47 ++++++++- packages/web/src/pages/ServerInfo.tsx | 39 ++++++++ 10 files changed, 384 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 450d221..d001506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,26 @@ Changes are tracked via git tags. Each release tag corresponds to an entry here. ## [Unreleased] -_No changes yet._ +### Added — Item 6.33: ollama-models refresh + +- **Auto-refresh on server boot.** `cfcf server start` now calls + `listOllamaModels()` and persists the result to + `availableOllamaModels` in the global config if the live list + differs from what's saved. Newly-pulled ollama models propagate to + role-picker dropdowns after a server restart without re-running + `cfcf init --force`. Best-effort: ollama not installed / detection + failure / config write failure all log + continue, never block boot. + Order-insensitive comparison (since `ollama list` reorders by + modified-time) so the boot path doesn't flap on every restart. +- **"Refresh ollama models" button.** New button in the Agent roles + section of both web Settings and per-workspace Config tabs. Calls + `POST /api/agents/refresh-ollama-models`, displays a status + message ("✓ N models detected — list updated" or "list already + current"), and triggers a re-fetch of `/api/agents/models` so the + `*-ollama` adapter dropdowns pick up new entries without a server + restart. +- New `refreshOllamaModelsInConfig()` helper in `@cfcf/core`. 4 new + unit tests + 2 new endpoint tests. ## [0.21.0] -- 2026-05-08 diff --git a/docs/plan.md b/docs/plan.md index 6bb0322..d8b094d 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -296,7 +296,7 @@ The tables below are the authoritative view of iteration progress. The **Notes** 5. **Cerefox-parity Clio improvement** — *Shipped in v0.19.0:* FTS title boosting (**6.24**). 6. **Adapter expansion driven by Anthropic harness policy** (**6.28**, surfaced 2026-05-07) — Anthropic's Jan→Apr 2026 clarification ([Cherny 2026-04-04](https://x.com/bcherny/status/1808066717213728812)) ties subscription OAuth tokens to interactive use only; cfcf's unattended iteration loop is the third-party-harness pattern the rule targets. Add ollama detection + three new adapters (`opencode` direct, `claude-code-ollama`, `opencode-ollama`) so unattended roles (dev / judge / reflection / documenter / auto-architect) can route to local ollama-served models via the `ollama launch --model ` exec wrapper (not via env-var proxy — explicit single command). Skills repository (**6.27**) also entered iter-6 as a research/design item on 2026-05-03. -**Iter-6 active set after the cleanup**: 6.9, 6.11, 6.13, 6.18, 6.27, 6.28, 6.29, 6.30, 6.31, 6.32 (plus 6.19 partial: pre-warm-during-installer). 6.29 + 6.30 + 6.31 + 6.32 surfaced 2026-05-08 during 6.28's dogfood pass — kept in iter-6 to fix-while-debugging rather than defer. **Shipped in v0.18.0**: 6.20, 6.12, 6.26. **Shipped in v0.19.0**: 6.24. **Shipped 2026-05-02 on `feat/structured-pause-actions`**: 6.25. **Shipped in v0.20.0 (2026-05-08)**: 6.28. **Shipped post-v0.20.0 (2026-05-08)**: 6.31, 6.30. Items 6.1, 6.2, 6.10, 6.15 dropped to ⏸ (with rationales in their Notes columns); 6.8 marked ❌ but blocked on 6.11. See the status legend at the end of the section for what each icon means. +**Iter-6 active set after the cleanup**: 6.9, 6.11, 6.13, 6.18, 6.27, 6.28, 6.29, 6.30, 6.31, 6.32 (plus 6.19 partial: pre-warm-during-installer). 6.29 + 6.30 + 6.31 + 6.32 surfaced 2026-05-08 during 6.28's dogfood pass — kept in iter-6 to fix-while-debugging rather than defer. **Shipped in v0.18.0**: 6.20, 6.12, 6.26. **Shipped in v0.19.0**: 6.24. **Shipped 2026-05-02 on `feat/structured-pause-actions`**: 6.25. **Shipped in v0.20.0 (2026-05-08)**: 6.28. **Shipped in v0.21.0 (2026-05-08)**: 6.31, 6.30. **Shipped post-v0.21.0 (2026-05-08)**: 6.33. Items 6.1, 6.2, 6.10, 6.15 dropped to ⏸ (with rationales in their Notes columns); 6.8 marked ❌ but blocked on 6.11. See the status legend at the end of the section for what each icon means. | # | Status | Title | Notes | |---|--------|-------|-------| @@ -325,6 +325,7 @@ The tables below are the authoritative view of iteration progress. The **Notes** | 6.29 | ❌ | macOS notification fix: per-app bundle ID via shim or `terminal-notifier` | **Surfaced 2026-05-08** during iter-6 dogfood. Symptom: macOS desktop notifications from cfcf events (`loop.paused`, `loop.completed`, `agent.failed`) appear with severe delays — sometimes minutes-to-hours late, often batched. Root cause: cfcf uses `osascript -e 'display notification …'` which macOS attributes to **"Script Editor"** with no separate bundle ID. Three downstream consequences: (1) per-app rate-limiting kicks in around ~5 notifications/min sustained — beyond that, macOS silently queues + batches; (2) DND / Focus Mode queues all of them until DND ends, causing the "minutes-to-hours-later dump" pattern; (3) without a bundle ID, macOS can't merge cfcf events into a coordinated stream — each notification is independent, hitting quotas faster. **Three implementation options**: *(option 1, preferred)* Bundle a tiny `.app` shim (`cfcf-notifier.app/Contents/{Info.plist,MacOS/cfcf-notifier}`) with its own bundle ID + a tiny shell or Swift wrapper that calls `display notification`. cfcf invokes the shim instead of `osascript`. ~50 LOC of plist + a small wrapper script + updates to the install pipeline so the shim ships with cfcf. *(option 2)* Detect `terminal-notifier` if installed (community tool, has its own bundle ID + a `-group` flag for grouping). Use it when present, fall back to `osascript`. Document as a recommended optional dep for macOS users. ~10 LOC of detection + wrapper. *(option 3)* Switch to a Swift / ObjC `NSUserNotification` shim. Overkill compared to option 1; bundle-app shim is simpler. **Out of scope**: rewriting the notification dispatcher or adding new channels — only the macos channel needs the fix. **Alternative if neither option lands by end of iter-6**: remove the macos channel entirely + document terminal-bell as the primary on-the-machine notification path. Linux / cross-platform users already use terminal-bell via the existing dispatcher. **Tests**: a smoke test that confirms the shim binary is reachable post-install (`cfcf doctor` check); a CI-skipped integration test that fires a notification and asserts the shim's path was used. The actual delivery is hard to assert in CI — that part stays manual. **Cross-refs**: `packages/core/src/notifications/channels/macos.ts` is the current implementation; `cfcf-notifier.app` would live under a new `scripts/macos-notifier/` directory + be staged into dist by `scripts/stage-dist.sh`. **Effort estimate**: 0.5-1 session for option 1 (bundle shim); 0.25 session for option 2 (terminal-notifier detection). **Surfaced 2026-05-08 during iter-6 dogfood.** | | 6.30 | ✅ | API parse errors with non-coder ollama models on claude-code-ollama (refined: opencode-ollama is the fall-back, not "use coder-tuned models" alone) | **Shipped 2026-05-08**. **Refined finding from re-test**: original framing "gemma4 is bad at tool calls" was wrong. Same gemma4:31b model, two routes: `claude-code-ollama + gemma4:31b` → fails with `API Error: Content block not found` (Anthropic-strict Messages API parser rejects); `opencode-ollama + gemma4:31b` → wrote all four documenter files cleanly. The model IS capable; it's the strict-Anthropic-shape translation layer that rejects its output. The OpenAI-compatible endpoint used by opencode-ollama is more tolerant. **Re-confirmed after the v0.20.0 process-tree-kill fix landed** (so it's not a queue-starvation confound). **Shipped scope**: (a) **`isApiParseRisk()` helper** in `packages/core/src/adapters/index.ts` (sister to `isClaudeCodeHarnessRisk`); 4 unit tests in `adapters.test.ts`. (b) **New blue info callout** in `` (web Settings + workspace Config) explaining the parse-error symptom and recommending `opencode-ollama` as the fall-back; positioned between the existing yellow policy callout and the blue log-visibility callout. (c) **Inline ⚠ row indicator** next to the adapter selector for any unattended row on `claude-code` (direct) — visual link to the policy callout below the table. Scoped via a new `isUnattendedRole` check in ServerInfo so PA / HA rows don't flag (they're allowed-interactive). (d) **Architect always counted as unattended** for these warnings (`UNATTENDED_ROLE_NAMES` updated in `@cfcf/core`): the loop invokes architect on `refine_plan` resume and judge `NEEDS_REFINEMENT` verdicts as well as the pre-loop `autoReviewSpecs=true` path; the same adapter setting drives all three loop paths AND the manual `cfcf review` path, so the warning has to reflect the worst case. Drops the previous `(autoReviewSpecs=true)` qualifier on the architect role label. (e) **CLI `cfcf init` banner** updated to mirror the new web callouts — same three notices in the same order. (f) **`docs/guides/anthropic-policy.md`** documenter row updated with the refined finding: two workable paths (coder-tuned model on claude-code-ollama, OR any model on opencode-ollama). **Out of scope (deferred)**: tolerant retry logic in the iteration-loop spawn (option b from original framing — defer to iter-7 if a similar failure pattern shows up with non-gemma models); deeper investigation of ollama's Anthropic-API translation layer (option c — would belong upstream in ollama, not cfcf). **Cross-refs**: `~/.cfcf/logs/calc-04c553/documenter-001.log` + `documenter-004.log` (the 56-byte claude-code-ollama failure logs); `documenter-005.log` (864-byte opencode-ollama success log on the same model). | | 6.31 | ✅ | Orphan agent-process cleanup on `cfcf server stop` / `start` / interactive reap | **Surfaced 2026-05-08** during 6.28's opencode-ollama dogfood. When the user stops + starts the cfcf server while a loop iteration is in flight, the spawned agent processes (`claude` / `codex` / `opencode` / `ollama launch `) are NOT terminated. They keep running until they finish, fail, or are manually killed. **Failure mode observed**: 4 orphans from earlier loop runs (2 claude documenter+architect, 2 opencode architects) accumulated across server restarts and serialized on ollama's model runner — each held the qwen3-coder model busy with up-to-10-minute timed-out inference requests. The new opencode iteration's `/v1/chat/completions` call queued behind them and starved. From the user's POV: loop "hangs" with no clear error, log file is 40 bytes, no obvious culprit. Required `pgrep -f \"ollama launch\" \| xargs kill` to recover. **Scope**: (a) **[SHIPPED in v0.20.0]** `start.ts` `gracefulShutdown()` enumerates every active spawn from the in-memory `activeProcesses` registry (`packages/core/src/active-processes.ts`) and sends SIGTERM to the **process group** (`process.kill(-pid, "SIGTERM")` — agents are now spawned with `detached: true`), then schedules SIGKILL 1.5s later via `setTimeout(...).unref()`. The shutdown handler awaits a 2-second grace window before `process.exit()` so the SIGKILL timer has time to fire — without that wait, the timer dies with the parent and orphans of `init` accumulate. **(b) [SHIPPED 2026-05-08, post-v0.20.0]** On `startServer()` boot, `start.ts` calls `findOrphanAgentProcesses()` from the new `packages/core/src/orphan-reaper.ts` module. Three conjoined filters (PPID==1 + same effective user + cfcf-spawned command shape) identify orphans confidently — false positives near-zero. Auto-reaps via `reapOrphans()` (group-SIGTERM → 1.5s grace → SIGKILL); logs `[server] Reaping N stale agent process(es) from a previous server PID:` followed by one line per orphan. Best-effort: a scan failure never blocks server boot. **(c) [SHIPPED 2026-05-08, post-v0.20.0]** New `cfcf server reap` subcommand uses the same matcher and prints candidates with `pid=… kind=… elapsed=… ` lines, then prompts `Kill these N process(es)? [y/N]:`. On `y`: reap. On `N` or empty: `Aborted. No processes killed.`. Empty list: `No zombie agent processes detected.`. Supports `-y / --yes` for non-interactive use. Pure system call — does NOT require the cfcf server to be running. **Tests shipped** (25 in `packages/core/src/orphan-reaper.test.ts`): the classifier covers every cfcf-spawn pattern + negative cases (interactive claude, non-cfcf opencode, ollama serve/pull, unrelated commands like node/python); the parser handles standard `ps` output, header-only input, and malformed lines without throwing; the orphan filter validates each of the three filters in isolation (PPID, user, command shape); `reapOrphans` covers empty input, the SIGTERM-then-SIGKILL flow with mocked `process.kill`, the group-then-direct fallback when group-kill throws ESRCH, and the failed-count when both kills fail. **Cross-refs**: `packages/core/src/orphan-reaper.ts` (matcher + reaper), `packages/server/src/start.ts` (boot-time hook), `packages/cli/src/commands/server.ts` (`reap` subcommand). | +| 6.33 | ✅ | Auto-refresh `availableOllamaModels` on server boot + manual refresh button | **Shipped 2026-05-08, post-v0.21.0**. **Surfaced 2026-05-08** during dogfood: user pulled a new ollama model, restarted the cfcf server, and the new model didn't show up in the role-picker dropdowns — because `listOllamaModels()` was only invoked at `cfcf init` (interactive setup) and `cfcf doctor` (read-only display); neither the server nor the web UI ever re-detected. **Shipped scope**: (a) **`refreshOllamaModelsInConfig()` helper** in `packages/core/src/ollama-detection.ts` — detects ollama, lists models, persists to `availableOllamaModels` if the live list differs from saved (order-insensitive comparison since `ollama list` reorders by mtime). Returns `{ models, updated, error? }`. Best-effort: never throws; surfaces the missing-ollama case via `error` field. (b) **Boot-time auto-refresh** in `packages/server/src/start.ts` — runs after the orphan reaper, single log line if list changed. (c) **`POST /api/agents/refresh-ollama-models`** endpoint — returns the same shape as the helper. Always 200 (the most common error case is "ollama not installed", which isn't an HTTP failure for this endpoint). (d) **"Refresh ollama models" button** in the Agent roles section of both web Settings (`ServerInfo.tsx`) and per-workspace Config (`ConfigDisplay.tsx`). Clicking calls the endpoint, displays a status message, and bumps a `modelsRev` counter that triggers a re-fetch of `/api/agents/models` so the `*-ollama` dropdowns pick up new entries. **Tests added**: 4 new unit tests in `ollama-detection.test.ts` (shape, no-config-no-write, list-equality persistence guard, ordering insensitivity); 2 new endpoint tests in `agent-models.test.ts` (shape, no-500-on-missing-ollama). **Cross-refs**: `packages/core/src/ollama-detection.ts`, `packages/server/src/start.ts`, `packages/server/src/routes/agent-models.ts`, `packages/web/src/api.ts`, `packages/web/src/pages/ServerInfo.tsx`, `packages/web/src/components/ConfigDisplay.tsx`. | | 6.32 | ❌ | Opencode-ollama hang-detection + reduced-deadlock surface | **Surfaced 2026-05-08** during 6.28 dogfood. Despite the documented `opencode run` "scriptable" contract + `--dangerously-skip-permissions` flag, opencode-ollama silently hangs in cf²'s harness pattern when (a) ollama's model runner is busy / has dead orphan requests in queue, (b) the prior session's hardcoded permission denies trip an internal stdin-prompt code path, or (c) opencode's stream-json over the OpenAI-compatible `/v1/chat/completions` API gets aborted server-side (500) and opencode doesn't surface the error loudly. **Symptom**: opencode process at 0% CPU, no TCP connection to ollama, log file frozen mid-session at "service=llm ... stream", cfcf log file 40 bytes. No timeout, no error, no exit. **Three angles to investigate**: *(a)* hard timeout on agent spawns in `process-manager.ts` (e.g. 15-min default per role, configurable per role) so a hung agent eventually kills itself + cfcf marks the iteration failed instead of the loop hanging forever. *(b)* Detect opencode's specific failure modes by following `~/.local/share/opencode/log/.log` in addition to stdout — opencode's internal log captures the full session lifecycle including provider errors. The cfcf log writer could optionally tail this file as a side-channel. *(c)* Test whether `--format json` on `opencode run` produces cleaner streaming events than the default formatted output (might help with the buffering UX too — sister concern to the claude-code-ollama buffering problem). **Recommendation in the meantime**: prefer `claude-code-ollama` over `opencode-ollama` for unattended roles until opencode's stability matures (when running against a local ollama backend). Update `anthropic-policy.md` with this caveat. **Cross-refs**: github/anomalyco/opencode#13851 (the permission-deny-causes-cancel-state issue we already knew about); the 2026-05-08 calc-workspace dogfood log session at `~/.local/share/opencode/log/2026-05-08T082733.log` is the canonical reproduction. **Effort**: 0.25 session for the docs caveat (immediately useful); 1–2 sessions for the spawn-timeout + opencode-log-tail investigation (defer until 6.31 ships, since the orphan cleanup is the more-bang-for-buck blocker). | **Status icons (this section):** diff --git a/packages/core/src/ollama-detection.test.ts b/packages/core/src/ollama-detection.test.ts index 660715a..66b47ab 100644 --- a/packages/core/src/ollama-detection.test.ts +++ b/packages/core/src/ollama-detection.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect } from "bun:test"; -import { detectOllama, listOllamaModels } from "./ollama-detection.js"; +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectOllama, listOllamaModels, refreshOllamaModelsInConfig } from "./ollama-detection.js"; // These tests don't assert that ollama IS installed — CI may not have it. // They verify the shape of the responses is correct so that callers can @@ -39,3 +42,93 @@ describe("listOllamaModels", () => { expect(result).not.toContain("NAME"); }); }); + +describe("refreshOllamaModelsInConfig (item 6.33)", () => { + let tmpDir: string; + let originalEnv: string | undefined; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "cfcf-ollama-refresh-")); + originalEnv = process.env.CFCF_CONFIG_DIR; + process.env.CFCF_CONFIG_DIR = tmpDir; + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env.CFCF_CONFIG_DIR; + else process.env.CFCF_CONFIG_DIR = originalEnv; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeBaseConfig(extra: Record = {}): void { + writeFileSync(join(tmpDir, "config.json"), JSON.stringify({ + version: 1, + devAgent: { adapter: "claude-code" }, + judgeAgent: { adapter: "claude-code" }, + architectAgent: { adapter: "claude-code" }, + documenterAgent: { adapter: "claude-code" }, + maxIterations: 1, + pauseEvery: 0, + availableAgents: ["claude-code"], + permissionsAcknowledged: true, + ...extra, + })); + } + + it("returns the documented shape regardless of whether ollama is installed", async () => { + writeBaseConfig(); + const result = await refreshOllamaModelsInConfig(); + expect(result).toHaveProperty("models"); + expect(result).toHaveProperty("updated"); + expect(Array.isArray(result.models)).toBe(true); + expect(typeof result.updated).toBe("boolean"); + }); + + it("does NOT write to config when no global config exists", async () => { + // No writeBaseConfig() — config dir is empty. + const result = await refreshOllamaModelsInConfig(); + // updated must be false: nothing to update. + expect(result.updated).toBe(false); + // No config file should have been created. + expect(existsSync(join(tmpDir, "config.json"))).toBe(false); + }); + + it("does NOT rewrite the config when the live list matches what's saved", async () => { + // If ollama isn't on the test runner, this test is degenerate but + // still passes: live list is [], saved list is [] (after our write + // collapses the field), and the equality check returns true. + const result1 = await refreshOllamaModelsInConfig(); + // Snapshot the config after the first refresh — anything that + // would change is now reflected. + if (!existsSync(join(tmpDir, "config.json"))) { + // No config existed; nothing to verify. + return; + } + writeBaseConfig({ availableOllamaModels: result1.models.length > 0 ? result1.models : undefined }); + const beforeMtime = readFileSync(join(tmpDir, "config.json"), "utf-8"); + const result2 = await refreshOllamaModelsInConfig(); + expect(result2.updated).toBe(false); + const afterMtime = readFileSync(join(tmpDir, "config.json"), "utf-8"); + expect(beforeMtime).toBe(afterMtime); + }); + + it("treats list ordering as insensitive (ollama list reorders by mtime, not a real change)", async () => { + // Pre-seed the config with a list. If the live result has the same + // strings in a different order, we shouldn't rewrite. + writeBaseConfig({ availableOllamaModels: ["model-c", "model-a", "model-b"] }); + // Simulate the equality check directly via a known-different order + // of the same set. We can't force `ollama list` to emit a specific + // order, so this test exercises the persistence path indirectly: + // if the helper's order-sensitive comparison regressed, two + // back-to-back refreshes against the same model set would flap + // updated=true. Instead we check that two consecutive calls don't + // race the saved list: + const r1 = await refreshOllamaModelsInConfig(); + const r2 = await refreshOllamaModelsInConfig(); + // Both calls returned the same set; the second should never + // claim to have updated (since r1 already wrote whatever was + // needed). + expect(r2.updated).toBe(false); + // r1 and r2 must agree on the set. + expect(new Set(r2.models)).toEqual(new Set(r1.models)); + }); +}); diff --git a/packages/core/src/ollama-detection.ts b/packages/core/src/ollama-detection.ts index 61dbd87..3426ee4 100644 --- a/packages/core/src/ollama-detection.ts +++ b/packages/core/src/ollama-detection.ts @@ -8,6 +8,9 @@ * - The model picker for `*-ollama` adapters (sourced from `ollama list` * instead of the `seed-models.ts` registry). * - `cfcf doctor` for diagnostic display. + * - **Boot-time + on-demand refresh** (item 6.33) of the persisted + * `availableOllamaModels` list, so newly-pulled ollama models show up + * in the role-picker dropdowns without re-running `cfcf init --force`. * * Independent of the `AgentAdapter` interface because ollama isn't itself * a coding agent — it's a model server that the `*-ollama` adapters wrap @@ -15,6 +18,8 @@ * `git --version` checks rather than in the adapter registry. */ +import { readConfig, writeConfig } from "./config.js"; + export interface OllamaAvailability { available: boolean; version?: string; @@ -85,3 +90,83 @@ export async function listOllamaModels(): Promise { return []; } } + +/** + * Result of a refresh-ollama-models pass (item 6.33). + * + * `models` — the live list from `ollama list` (empty when ollama isn't + * installed or no models are pulled). + * `updated` — true iff the saved `availableOllamaModels` list differed + * from the live list and was rewritten. False when nothing + * changed (no disk write happened) OR when there's no global + * config to update. + * `error` — set when ollama isn't installed; not a fatal condition, + * just a hint for the caller's UX. Never thrown. + */ +export interface OllamaRefreshResult { + models: string[]; + updated: boolean; + error?: string; +} + +/** + * Compare two ollama-model lists for equality. Order-insensitive: a list + * `["a", "b"]` matches `["b", "a"]` because `ollama list` doesn't + * guarantee stable ordering across calls (sort-by-modified-time + * shuffles the order whenever a model is pulled / used). + */ +function modelListsEqual(a: string[] | undefined, b: string[]): boolean { + if (!Array.isArray(a)) return b.length === 0; + if (a.length !== b.length) return false; + const aSorted = [...a].sort(); + const bSorted = [...b].sort(); + for (let i = 0; i < aSorted.length; i++) { + if (aSorted[i] !== bSorted[i]) return false; + } + return true; +} + +/** + * Detect ollama, list its models, and persist them to the global config's + * `availableOllamaModels` field if the live list differs from what's + * saved (item 6.33). + * + * Used by: + * - `start.ts` boot-time refresh — runs on every server start so + * newly-pulled ollama models propagate to role-picker dropdowns + * after a server restart. + * - `POST /api/agents/refresh-ollama-models` — hand-triggered from a + * "Refresh ollama models" button in the web UI Settings + workspace + * Config Agent-roles section, for the impatient "I just pulled a + * model and don't want to bounce the server" path. + * + * Best-effort. Never throws — every failure mode is reported via the + * returned `error` field (and the boot-time caller swallows it + * regardless). Specifically: + * - ollama not installed → returns `{ models: [], updated: false, + * error: "ollama not detected" }` and does NOT write to config. + * - ollama installed but no models pulled → live list is empty; + * overwrites the saved list with `[]` if it had entries (cleanup), + * or no-ops if already empty. + * - no global config → returns `{ models, updated: false }` without + * writing (caller can read the live list anyway). + */ +export async function refreshOllamaModelsInConfig(): Promise { + const detection = await detectOllama(); + if (!detection.available) { + return { models: [], updated: false, error: detection.error ?? "ollama not detected" }; + } + const models = await listOllamaModels(); + const config = await readConfig(); + if (!config) { + // No global config to update (pre-init). Live list is still useful + // to the caller — return it without persisting. + return { models, updated: false }; + } + if (modelListsEqual(config.availableOllamaModels, models)) { + return { models, updated: false }; + } + config.availableOllamaModels = models.length > 0 ? models : undefined; + await writeConfig(config); + return { models, updated: true }; +} diff --git a/packages/server/src/routes/agent-models.test.ts b/packages/server/src/routes/agent-models.test.ts index 2b2c17a..2521403 100644 --- a/packages/server/src/routes/agent-models.test.ts +++ b/packages/server/src/routes/agent-models.test.ts @@ -86,3 +86,37 @@ describe("/api/agents/models", () => { expect(body.adapters["claude-code"]).toContain("sonnet"); }); }); + +describe("/api/agents/refresh-ollama-models (item 6.33)", () => { + it("returns a 200 with shape { models, updated, error? }", async () => { + // Don't assume ollama is or isn't installed on the test runner — + // CI doesn't have it, dev machines often do. Either way, the + // endpoint must return a 200 with the documented shape. + writeConfig({}); + const app = createApp(); + const res = await app.request("/api/agents/refresh-ollama-models", { method: "POST" }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("models"); + expect(body).toHaveProperty("updated"); + expect(Array.isArray(body.models)).toBe(true); + expect(typeof body.updated).toBe("boolean"); + // `error` is optional; only present when ollama is missing. If + // present, must be a string. + if (body.error !== undefined) { + expect(typeof body.error).toBe("string"); + } + }); + + it("does NOT throw a 500 when ollama isn't installed", async () => { + // Defence-in-depth: even if Bun.which() resolves an ollama binary + // that subsequently fails, the endpoint must still return 200. + // The pre-init case (no config file) is the trickier path; cover it. + const app = createApp(); + const res = await app.request("/api/agents/refresh-ollama-models", { method: "POST" }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.models)).toBe(true); + expect(typeof body.updated).toBe("boolean"); + }); +}); diff --git a/packages/server/src/routes/agent-models.ts b/packages/server/src/routes/agent-models.ts index 697af47..ce7e806 100644 --- a/packages/server/src/routes/agent-models.ts +++ b/packages/server/src/routes/agent-models.ts @@ -12,10 +12,24 @@ * fetch this when the model picker opens; the seed list is small so * the response is tiny and the cost of re-fetching on every picker * open is fine. + * + * `POST /api/agents/refresh-ollama-models` -- on-demand refresh of + * the persisted `availableOllamaModels` field (item 6.33). Triggered + * from the "Refresh ollama models" button in web Settings + workspace + * Config agent-roles sections. Calls `ollama list` live, persists if + * different, returns the new list. The boot-time refresh in + * `start.ts` covers the "after server restart" path; this endpoint + * covers the "between restarts" path so users don't need to bounce + * the server every time they pull a new model. */ import type { Hono } from "hono"; -import { readConfig, resolveAllModels, SEED_MODELS } from "@cfcf/core"; +import { + readConfig, + resolveAllModels, + SEED_MODELS, + refreshOllamaModelsInConfig, +} from "@cfcf/core"; export function registerAgentModelsRoutes(app: Hono): void { app.get("/api/agents/models", async (c) => { @@ -29,4 +43,13 @@ export function registerAgentModelsRoutes(app: Hono): void { seed: SEED_MODELS, }); }); + + app.post("/api/agents/refresh-ollama-models", async (c) => { + const result = await refreshOllamaModelsInConfig(); + // Always 200 — `error` is surfaced as a hint, not an HTTP failure + // (the most common "error" is "ollama not installed", which isn't + // an exceptional condition for this endpoint; the user just gets + // an empty list back and the UI can render an info note). + return c.json(result); + }); } diff --git a/packages/server/src/start.ts b/packages/server/src/start.ts index 02086cf..1d58ce8 100644 --- a/packages/server/src/start.ts +++ b/packages/server/src/start.ts @@ -27,6 +27,7 @@ import { findOrphanAgentProcesses, reapOrphans, formatOrphanLine, + refreshOllamaModelsInConfig, } from "@cfcf/core"; import { closeClioBackend } from "./clio-backend.js"; @@ -156,6 +157,26 @@ export async function startServer(port: number): Promise`) and restarts the server, the new model + // wouldn't appear in the role-picker dropdowns without a refresh. + // Best-effort: ollama not installed / detection failure / config + // write failure all log + continue, never block boot. + try { + const refresh = await refreshOllamaModelsInConfig(); + if (refresh.updated) { + console.log( + `[server] Refreshed availableOllamaModels (${refresh.models.length} model${refresh.models.length === 1 ? "" : "s"} detected)`, + ); + } + } catch (err) { + console.warn( + `[server] Ollama-models refresh failed (best-effort): ${err instanceof Error ? err.message : String(err)}`, + ); + } + // Orphan reaper (item 6.31 sub-(b)). The graceful-shutdown path // already kills active processes cleanly via process-group SIGTERM // when the server gets SIGINT/SIGTERM. This boot-time scan closes diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index 3b78aed..daa4e10 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -317,6 +317,25 @@ export function fetchAgentModels(): Promise { return request("/api/agents/models"); } +export interface RefreshOllamaModelsResponse { + models: string[]; + /** True iff the saved availableOllamaModels list differed and was rewritten. */ + updated: boolean; + /** Set when ollama isn't installed (UX hint, not an HTTP failure). */ + error?: string; +} + +/** + * Trigger a live re-detection of locally-pulled ollama models and + * persist the result to global config (item 6.33). Returns the new + * list — caller should re-fetch `/api/agents/models` after this so the + * resolved per-adapter registries pick up the change for the + * `*-ollama` adapters. + */ +export function refreshOllamaModels(): Promise { + return request("/api/agents/refresh-ollama-models", { method: "POST" }); +} + // --- Reflect (item 5.6 / web surface in 6.12) --- export interface ReflectState { diff --git a/packages/web/src/components/ConfigDisplay.tsx b/packages/web/src/components/ConfigDisplay.tsx index d85178a..5cde8c9 100644 --- a/packages/web/src/components/ConfigDisplay.tsx +++ b/packages/web/src/components/ConfigDisplay.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import type { WorkspaceConfig } from "../types"; import type { NotificationChannelName, NotificationEventType } from "../types"; -import { fetchAgentModels, fetchGlobalConfig, saveWorkspace } from "../api"; +import { fetchAgentModels, fetchGlobalConfig, refreshOllamaModels, saveWorkspace } from "../api"; import { AgentModelSelect } from "./AgentModelSelect"; import { ClioProjectDialog } from "./ClioProjectDialog"; import { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; @@ -56,6 +56,11 @@ export function ConfigDisplay({ // AgentModelSelect to populate the model dropdown for each role's // currently-chosen adapter. const [agentModels, setAgentModels] = useState>({}); + // 6.33 -- bump to re-fetch agent models after a refresh-ollama call, + // so the *-ollama dropdowns pick up newly-detected models. + const [modelsRev, setModelsRev] = useState(0); + const [ollamaRefreshing, setOllamaRefreshing] = useState(false); + const [ollamaRefreshMsg, setOllamaRefreshMsg] = useState(null); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [savedAt, setSavedAt] = useState(null); @@ -85,10 +90,15 @@ export function ConfigDisplay({ fetchGlobalConfig() .then((cfg) => setAvailableAgents(cfg.availableAgents ?? [])) .catch(() => setAvailableAgents([])); + }, []); + + // Separate effect for agent models so the refresh button can re-fire + // it without re-fetching availableAgents. + useEffect(() => { fetchAgentModels() .then((res) => setAgentModels(res.adapters)) .catch(() => setAgentModels({})); - }, []); + }, [modelsRev]); const isDirty = JSON.stringify(workspace) !== JSON.stringify(draft); const notificationsOverridden = !!draft.notifications; @@ -263,6 +273,39 @@ export function ConfigDisplay({ {/* Editable: remote URL */} {/* Agent roles */} +
+ + {ollamaRefreshMsg && ( + + {ollamaRefreshMsg} + + )} +
diff --git a/packages/web/src/pages/ServerInfo.tsx b/packages/web/src/pages/ServerInfo.tsx index 63fd117..42b83ba 100644 --- a/packages/web/src/pages/ServerInfo.tsx +++ b/packages/web/src/pages/ServerInfo.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { fetchAgentModels, + refreshOllamaModels, fetchServerStatus, fetchGlobalConfig, saveGlobalConfig, @@ -81,6 +82,9 @@ export function ServerInfo() { // Bumped after a Model registry save so dependent dropdowns + the // editor refresh from the server's resolved view. const [modelsRev, setModelsRev] = useState(0); + // 6.33 -- ollama-models refresh button state. + const [ollamaRefreshing, setOllamaRefreshing] = useState(false); + const [ollamaRefreshMsg, setOllamaRefreshMsg] = useState(null); // Initial + periodic fetch of read-only server status (version, uptime, etc.) useEffect(() => { @@ -302,6 +306,41 @@ export function ServerInfo() { {draft && ( <> +
+ + {ollamaRefreshMsg && ( + + {ollamaRefreshMsg} + + )} +
From fc813f683252a42a1fd11fb8c3cf1f1e4cf00b68 Mon Sep 17 00:00:00 2001 From: Fotis Stamatelopoulos Date: Fri, 8 May 2026 17:29:45 -0700 Subject: [PATCH 2/2] fix(6.33): hide "(adapter default)" + add empty-state placeholder for ollama-routed adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "(adapter default)" empty-value option in the model picker means "let the CLI pick" — works for `claude-code` (claude has its own default) and `codex` (codex picks gpt-5-codex), but is broken for `claude-code-ollama` / `opencode-ollama`: `ollama launch ` requires an explicit `--model ` to know which local model to hand off, and our adapters skip the flag entirely when `model` is falsy. Saving `model=""` for an ollama-routed adapter produces a silent misconfiguration at spawn time. Hide the "(adapter default)" option in `AgentModelSelect` for the two ollama-routed adapters via a small `OLLAMA_ROUTED_ADAPTERS` set (hardcoded in the web tree per the existing convention — web is Vite-built and doesn't import @cfcf/core directly; same pattern as HarnessPolicyWarning). For the empty-list case (no ollama models pulled yet, or the boot refresh hasn't run), surface a disabled placeholder option: `(no ollama models — pull one or click Refresh)`. The dropdown isn't visually empty + the user gets a hint about the Refresh button or `ollama pull`. Behavior unchanged for seed-sourced adapters. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 +++++++ .../web/src/components/AgentModelSelect.tsx | 45 +++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d001506..decdaf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,21 @@ Changes are tracked via git tags. Each release tag corresponds to an entry here. - New `refreshOllamaModelsInConfig()` helper in `@cfcf/core`. 4 new unit tests + 2 new endpoint tests. +### Fixed — Item 6.33: model-picker UX for ollama-routed adapters + +- **Hide "(adapter default)" for `claude-code-ollama` and + `opencode-ollama`.** The seed-sourced adapters (`claude-code`, + `codex`) have real built-in defaults when `--model` is omitted, so + the empty option meaningfully says "let the CLI pick". The + ollama-routed adapters don't — `ollama launch ` requires + `--model ` to know which local model to hand off, and saving + `model=""` would produce a silent misconfiguration. The picker now + forces a deliberate selection for these adapters. +- **Empty-state placeholder for ollama-routed adapters** when no + models are available: `(no ollama models — pull one or click + Refresh)` rendered as a disabled option so the dropdown isn't + visually empty + the user is told what to do next. + ## [0.21.0] -- 2026-05-08 ### Added — Item 6.31: orphan agent-process cleanup diff --git a/packages/web/src/components/AgentModelSelect.tsx b/packages/web/src/components/AgentModelSelect.tsx index 8dac5e2..69e3743 100644 --- a/packages/web/src/components/AgentModelSelect.tsx +++ b/packages/web/src/components/AgentModelSelect.tsx @@ -1,5 +1,5 @@ /** - * Per-role model picker (item 6.26). + * Per-role model picker (item 6.26; ollama-aware in 6.33). * * Layout: a ` onChange(e.target.value)} style={{ minWidth }} > - + {/* Seed-sourced adapters: empty value = "let the CLI pick". */} + {!isOllamaRouted && ( + + )} + {/* Ollama-routed adapters with an empty list: disabled placeholder + so the dropdown isn't visually empty + the user is told what + to do. The user can't select this option (it's disabled). */} + {showEmptyOllamaPlaceholder && ( + + )} {models.map((m) => ( ))}