Skip to content

feat(ops): scheduled peak-hours scaling for the Render web service#846

Closed
jahooma wants to merge 870 commits into
mainfrom
freebuff/can-you-investigate-the-health-of-our-web-backend--thmr6ypfpl05oz
Closed

feat(ops): scheduled peak-hours scaling for the Render web service#846
jahooma wants to merge 870 commits into
mainfrom
freebuff/can-you-investigate-the-health-of-our-web-backend--thmr6ypfpl05oz

Conversation

@jahooma

@jahooma jahooma commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Problem

The web service (the API/completions backend — not freebuff/web) runs a flat 6 instances 24/7 with no autoscaling, while load swings ~2x on a predictable daily curve. From the 2026-07 observability work (event_loop_lag, chat_completion_concurrency):

off-peak (02–07Z) peak (12–17Z)
event-loop utilization avg / max ~0.40 / 0.62 0.73–0.76 / 0.93
max concurrent chat requests ~43 ~86

The same 6 pods absorb the whole swing (dcount(host) per hour is exactly 6). There's no render.yaml in the repo — the service is dashboard/API-managed. Service id srv-crm8estds78s73e7evd0 (derived from prod pod hostnames).

Approach — scheduled instance floor

Load is clock-predictable and the bottleneck (event-loop saturation → ingress queue) leads container CPU, so Render's CPU-target autoscaling reacts late. A time-based floor is more precise. This drives numInstances on a per-UTC-hour curve via Render's manual scale endpoint.

The curve (re-derived over 14 days)

Data caveat: the utilization metric only went live ~07-03 23Z, so there's exactly one full util-day (Sat 07-04) — which ran ~65% above normal load. Sizing straight off it over-fits that spike (peaks at 14). Instead we calibrate util ≈ 0.115 + 5.2e-5·load from 07-04 and project onto the normal 14-day hourly load shape (scripts/ops/derive-scale-curve.ts). A typical day peaks at 7–8:

00–04: 6   05–07: 7   08: 8   09: 7   10: 8   11–14: 7   15–17: 8   18–22: 7   23: 6
  • Weekday vs weekend: negligible — total load within ~2%, per-hour counts differ by ≤1, so a single curve is used.
  • Trade-off: this sizes for normal load. On a normal day the flat-6 fleet already only hits ~0.50 util; the worst queueing we saw was the 07-04 spike. Spike days will still see elevated queue at this floor — for spike protection, raise the peak or use native autoscaling (documented as Strategy B).

Setup required to activate

  1. Add repo secret RENDER_API_KEY (Render API key with access to the web service).
  2. Optionally set repo variable RENDER_WEB_SERVICE_ID (defaults to the prod web service).
  3. Native autoscaling must be OFF on the service — POST /v1/services/{id}/scale is silently ignored when autoscaling is enabled. The two strategies are mutually exclusive (see docs/web-scaling.md).

Until RENDER_API_KEY is set, the workflow is a safe no-op (skips, like the referral/bot sweeps).

Files

  • scripts/ops/scale-web.ts — the scaler (has --dry-run, --instances, --hour).
  • scripts/ops/derive-scale-curve.ts — reproducible curve derivation from Axiom.
  • .github/workflows/web-peak-scale.yml — hourly cron (idempotent, self-correcting) + workflow_dispatch override.
  • scripts/logs/web-health.ts — reusable web-saturation probe (bun scripts/logs/web-health.ts 24h).
  • docs/web-scaling.md — full write-up, both strategies, validation steps.

Validation

scale-web.ts verified end-to-end in --dry-run across the curve; derive-scale-curve.ts run against 14d of prod Axiom data.

Follow-up (separate PR)

A scheduled Axiom saturation alert (queue p95 / utilization thresholds) as a backstop — kept out of this PR to keep it focused.

🤖 Generated with Claude Code

jahooma and others added 30 commits June 27, 2026 16:54
…loads (#347)

Two related UX wins to reduce friction on returning sessions:

1. **Project Recents** — promote `lastProject: string` to a small MRU
list
(`recentProjects: string[]`, capped at 8) in
`~/.config/freebuff-desktop/state.json`.
Old state files migrate automatically. The orchestrator picks the most
recent
on launch; `openProject` pushes to the MRU; a new `GET
/api/project/recents`
   endpoint lets the UI render a **Recents** strip at the top of the
ProjectPicker so returning users can re-open a previous project with one
click. Entries that fail to open show an inline error rather than
silently
   disappearing from the list.

2. **Draft persistence** — mirror the per-tab composer + queue drafts to
   localStorage (`freebuff:drafts`) on every edit. On `init()` they're
   rehydrated against the live thread list (dead thread ids are pruned).
   `closeTab` clears a closed tab's draft from both in-memory state and
   disk so we don't accumulate phantom entries across restarts.

Together: an in-progress prompt in any tab survives an app restart, and
the
picker skips the folder drill-down for any project you've used recently.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
#350)

- Replace "Freebuff Desktop" / "@codebuff/freebuff-desktop" with
"Freebuff"
in the macOS app menu, About panel, Windows AppUserModelId, window
title,
  and electron-builder productName.
- Add scripts/build-icon.ts (mac-only via sips/iconutil) that builds
  build/icon.png (512×512), icon.icns, and a hand-assembled multi-res
  PNG-payload icon.ico. Wire it into `app` and `prepackage` so dev and
  packaged builds pick up the right icon, and point BrowserWindow at
  build/icon.png in dev so the OS no longer shows Electron's default.
- Swap the "New thread" text placeholder in ThreadView for the freebuff
  wordmark SVG, sized to keep the same optical weight as the old title.
- Drop the "App icon" line from the README caveats now that it's
shipped.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

Co-authored-by: Codebuff <noreply@codebuff.com>
…351)

Two commits on this branch:

1. `feat(freebuff-desktop): tab icon shows PR state + last-turn outcome`
— tab row used to only distinguish running vs idle. Now it also
reflects:
- PR state inferred from `gh pr` lifecycle verbs during the turn (open /
merged / closed-without-merge)
- The last-turn outcome in engine memory (amber stop square / red alert
triangle), reset when the next turn starts so a clean run clears the
banner

2. `fix(freebuff-desktop): clarify PR-state transition comment in test`
— the prior PR-state ordering test had a misleading comment; the most
recent lifecycle verb always wins.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
…buff glyph (#352)

## Summary
- Untitled tabs now show the 13px freebuff glyph instead of the literal
`New thread` text.
- Same swap in the tab overflow menu.
- The empty-workspace view (`<Workspace />` with no active thread) drops
the `No threads open` + `Press ⌘T…` copy and shows just the freebuff
glyph, matching ThreadView's in-thread placeholder.
- Swap the bundled freebuff-logo.svg itself: it was still the vly
star-mark; replace with the Freebuff mascot — terminal guy inside a
rounded-rectangle frame, brand cyan.

## Why
The literal placeholders duplicated affordances already covered
elsewhere:
- `⌘T` keyboard overlay already announces how to start a thread.
- ThreadView's in-thread placeholder already shows the wordmark.
- A left-clipped icon read as a broken image; flex-centering it lets a
tiny glyph stand in cleanly.

The old logo was wrong brand for this surface.

## Files
- `freebuff-desktop/src/app/ui/App.tsx` — empty workspace view
- `freebuff-desktop/src/app/ui/components/TabBar.tsx` — tab + overflow
menu
- `freebuff-desktop/src/app/ui/components/freebuff-logo.svg` — mascot
swap
- `freebuff-desktop/src/app/ui/styles/app.css` — `.tab-glyph`,
`.tab-title.untitled`, drops `.welcome-title`

## Test plan
- [ ] Fresh worktree, no tabs: wordmark visible, nothing else.
- [ ] Start a thread, leave title empty: tab shows centered glyph, hover
title says `New thread`.
- [ ] Type a title: glyph swaps to text; close button still aligned.
- [ ] Open overflow menu with an untitled + a titled thread: menu
entries match.
- [ ] Logo reads as the new mascot at both the empty-state and tab-glyph
sizes.

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
…go glyph (#353)

## What

- **Model picker moves out of the tab.** The per-thread agent/model
picker (Codebuff vs. Claude Code) used to live inside each browser tab
as a tiny `compact` pill. It now sits in the first header bar — next to
the project directory picker and the preview / "Set up preview" controls
— as a full pill showing the agent name + model label.
- **Tabs are cleaner.** Each tab is now just status icon + title + close
button, so titles get the room back.
- **Dropdown anchoring.** The full-pill menu is normally right-anchored;
in the header the picker sits on the left, so I added a `.thread-head
.agent-menu` rule to open it down/left and keep the 320px popover inside
the window.
- **Dead-code cleanup.** With the tab no longer using it, the `compact`
mode (prop, JSX branch, and `.agent-selector.compact` CSS) is removed
entirely, and the stale "per-tab" docstring/tooltip is refreshed to
"per-thread".
- **Real logo glyph.** The new-tab placeholder glyph (and the welcome
screens that import the same asset) now use the real freebuff brand mark
instead of the hand-drawn "terminal-guy" mascot.

## Why

The picker was cramped inside the tab and competed with the thread title
for space. The header bar is where the other thread-scoped controls
already live, so it's a more natural home. It's still scoped per-thread
— just relocated.

## Files

- `ThreadView.tsx` — renders `AgentPicker` in `.thread-head`, wired to
the current thread's harness.
- `TabBar.tsx` — removed the in-tab `AgentPicker` and its now-unused
store selectors/import.
- `AgentSelector.tsx` — dropped `compact`; always renders the full pill;
refreshed docs.
- `app.css` — removed `.agent-selector.compact` styles; added header
dropdown anchoring.
- `freebuff-logo.svg` — replaced with the real brand mark.

## Notes for reviewers

- Typecheck passes (`bun run typecheck`).
- I couldn't drive a live browser preview here because the desktop UI
needs its Electron backend (SSE state + `window.freebuffDesktop` bridge)
to render meaningfully; the change is a JSX relocation + asset swap.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- Sticky last-prompt bar: only show once the last user message scrolls
out
of view, drop the "Your prompt" label (bubble only), keep 3-line clamp,
and make it clickable to jump back to the original prompt in the
transcript.
- Add a floating "scroll to latest" pill that appears only when scrolled
up,
widening to a "New messages" indicator when the assistant streams while
the
  user is scrolled away from the tail.
- Reset scroll-tracking state and snap to the tail on thread switch,
since the
ThreadView instance (and scroll DOM node) is reused across threads and
would
otherwise bleed pinned/at-bottom/new-message state from the previous
thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What

Refuse sign-ins whose email belongs to a blocked domain, checked in the
NextAuth `signIn` callback **before** the adapter's `createUser` runs —
so a blocked domain never creates a user row. Applies to both Google and
GitHub.

## Why

`gkmaill.com` is a typo-squat of gmail: a single operator stood up ~55
auto-generated Google accounts from 2 IPs to farm free agent runs
(~4k/day). Those accounts were banned, but banning is reactive — the
operator can re-register. This closes the door at signup.

## Changes

- `BLOCKED_EMAIL_DOMAINS` constant in `packages/auth/src/constants.ts`
(seeded with `gkmaill.com`; extending is a one-line edit).
- Pure `isBlockedEmailDomain()` helper in `link-guard.ts` — exact
registrable-domain match, case/whitespace tolerant, substring-safe.
- Wired as gate 0 of the `signIn` callback in `create-auth-options.ts`,
with a warn log (no PII).
- 6 unit tests alongside the existing `link-guard` gate tests.

## Notes / scope

- Exact-domain match by design — it won't catch subdomains or the
operator's *next* typo-squat; adding one is a one-line constant edit.
- Fails safe (`return false` → generic access-denied), consistent with
the existing unverified-Google gate.

## Test

`bun test packages/auth/src/__tests__/link-guard.test.ts` → 16 pass.
Package typechecks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What

Operational tooling + playbook for two free-mode abuse classes this
investigation surfaced, added to the existing
`docs/freebuff-abuse-detection.md` (which previously only covered
endpoint/proxy and idle-session abuse).

## Referral-program farming

The GLM 5.2 referral reward qualifies a referred friend on a single
bright-line — **GitHub account ≥12 months old** — with no requirement
that they ever use the product. That's trivially farmed with aged
dormant GitHub accounts (0 repos / 0 followers / never ran the agent,
often bulk-created on one day with email mirroring the login). At the
time of investigation, **122 of 550 (22%) qualified GLM referrals were
backed by such accounts.**

## Scripts

All read-only, or **dry-run-first** for the actioning ones. Targets are
derived from SQL predicates — **no account identifiers are hardcoded**.

| Script | Purpose |
|---|---|
| `glm-referral-investigate.ts` | Referral health, qualified vs pending,
did referrers use the benefit, concentration. Start here. |
| `glm-referral-farms.ts` | Per-referrer farm scoring (burst velocity,
dormant/dead referreds, inactive referrer). |
| `glm-referral-clawback.ts` | Ironclad dormant-account farms: ban
operator + socks, clawback. `--commit` to apply. |
| `glm-referral-burst-ban.ts` | Bursty operators: ban operator **only**
(referreds spared), clawback. `--commit` to apply. |
| `signups-last-24h.ts` | New-signup triage: provenance + legitimacy
signals. |

## Clawback mechanism (documented)

Distinct from banning: `referral.qualified_at = NULL` (drops
`getReferralScore`) + `referral_v2.revoked_at = now()`. The burn-once
ledger is left **consumed** so the GitHub identities can't re-farm.

## Open follow-up

Root-cause fix (not in this PR): require the referred friend to actually
run the agent (or have a non-dormant GitHub profile) before the referral
qualifies.

## Notes

- Doc additions are redacted (no emails — enforced by the doc's own
hygiene rule).
- All five scripts have been run against prod this session (the
actioning ones dry-run-first).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#357)

## What

Adds a release flow so users can **download the compiled Freebuff
Desktop Electron app for each platform**, modeled on the CLI's GitHub
Actions release flow.

A new `workflow_dispatch` workflow:
1. Bumps `freebuff-desktop/package.json` and tags
`freebuff-desktop-v<ver>`.
2. Builds in a per-OS matrix — **macos-14 (Apple Silicon),
macos-15-intel (Intel), windows-latest, ubuntu-latest** — each running
`prepackage` (Vite UI → icons → bundled Bun runtime → `Bun.build`
orchestrator with `NEXT_PUBLIC_*` baked in) then `electron-builder`.
3. Publishes the `.dmg` / `.exe` / `.AppImage` to a public GitHub
Release on `CodebuffAI/codebuff-community` (same place the CLI ships, so
download URLs are public).

Reuses existing secrets (`CODEBUFF_GITHUB_TOKEN` + the `NEXT_PUBLIC_*`
client vars via `generate-ci-env.ts`) — no new secrets needed.

## Changes

- **`.github/workflows/freebuff-desktop-release.yml`** — the release
workflow.
- **`freebuff-desktop/scripts/build-icon.ts`** — reuse the committed
`build/icon.{png,icns,ico}` on non-macOS hosts (`sips`/`iconutil` are
macOS-only), so the Linux/Windows runners (and any non-mac contributor
running `bun run dist`) can package.
- **`freebuff-desktop/package.json`** — explicit per-platform `icon` +
predictable `artifactName`
(`Freebuff-<ver>-{mac|win|linux}-<arch>.<ext>`) so the two mac DMGs
don't collide on upload; `pack:ci` + `release` scripts.
- **`freebuff-desktop/scripts/release.ts`** + root **`release:desktop`**
— local workflow dispatcher, mirroring `release:cli` /
`release:freebuff`.

## Verification

Ran the full pipeline locally on macOS: `prepackage` (UI built, icons
generated, Bun staged, orchestrator bundled with 15 env vars) →
`electron-builder` → a valid **144MB `Freebuff-0.0.0-mac-arm64.dmg`** +
`Freebuff.app`. Build outputs are gitignored.

## Not in scope (pre-existing gaps before a downloaded build is usable
by an end user)

These are documented in the desktop README and are **not** part of the
release wiring:

1. **Auth** — the packaged orchestrator needs `CODEBUFF_API_KEY`;
there's no login flow yet, so `electron/main.cjs` must supply it. This
is the real blocker for a stranger running the app.
2. **Code signing / notarization** — builds are unsigned, so macOS
Gatekeeper and Windows SmartScreen will warn (the release notes include
the right-click→Open workaround). Needs an Apple Developer cert + an
Authenticode cert.
3. **Download page** — nothing on the web app links to the installers
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What

Gives the **freebuff-desktop** thread agent the same exploration
subagents the free `base2` orchestrator uses, and renders each spawn as
a **collapsible, nestable box** in the transcript — mirroring
`freebuff.com/chat` and the CLI.

Subagents wired (curated, free-safe set): **file-picker → file-lister**
(nested), **code-searcher**, **researcher-web**, **researcher-docs**,
**basher**.

## How

- **`core/parts.ts`** — new `agent` Part kind holding nested `blocks`;
`foldAgentEvent` is now agent-aware, routing streamed
text/reasoning/tool events by `agentId`/`parentAgentId` and nesting
subagents arbitrarily. Shared by the server (persist) and client (live
stream) so a reloaded transcript matches the live one. Persisted as JSON
in the existing `parts_json` column — **no migration**.
- **`codebuff-harness.ts`** — forwards subagent stream events
(`subagent_start`/`finish`, `subagent_chunk`, ancestor-scoped
reasoning), passes the subagent definition closure to `run({
agentDefinitions })`, and hides spawn/flow-control tool noise.
- **`thread-agent.ts`** — adds `spawn_agents` + `spawnableAgents` + the
subagent set, with prompt guidance to delegate exploration.
- **UI** — new recursive `PartsView` + memoized collapsible `AgentBox`
(default collapsed; running spinner + accent border; tool rows render
inline inside boxes). `Message.tsx` delegates to `PartsView`; the
store's reasoning toggle/collapse recurse into nested boxes via a shared
`mapReasoning` primitive.

> Scope: Codebuff harness only — subagents are a base2 construct. The
Claude Code harness still uses its own Task tool (flat row).

## Testing

Verified **end-to-end with real MiniMax M3 runs**: 3-level nesting (turn
→ file-picker → file-lister), parallel spawns, live running-spinner
state, and reload persistence — all in the browser preview. Added
subagent fold/persist unit tests. **99 tests pass**, typecheck clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What

Adds an `accessTier` property to the cross-surface `message_sent`
PostHog DAU event so combined DAU can be broken down by free-mode access
tier (full vs limited) in a single query — no new event needed.

The tier was already computed in scope at every emit site; it just
wasn't being passed through.

## Changes

- **Web** — `freebuff/web/convex/coding_agent/cli_agent/trigger.ts`:
`accessTier: gates.accessTier` (`"full" | "limited" | "blocked"`)
- **Chat** — `freebuff/web/src/app/api/chat/stream/route.ts`:
`accessTier` from `getChatAccessTier()` (`"full" | "limited"`)
- **CLI** — `cli/src/commands/router.ts`: read from the session store,
defaulting to `'unknown'` when the session hasn't loaded yet (e.g. very
first prompt). Adds the `useFreebuffSessionStore` import.

## Notes

- Tier value set isn't perfectly symmetric: web can emit `blocked`
(though blocked users generally don't reach this point), CLI/chat are
`full`/`limited`, and CLI may emit `unknown` pre-session-load.
- Typechecks clean for both `cli` and `freebuff/web`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ions (#358)

## What

Adds a full **Freebuff** model switcher to the desktop app, backed by
real free-mode access-tier detection and parallel-tab session
management.

- **Access tier** (`full`/`limited`) is read from `GET
/api/v1/freebuff/session` exactly like the CLI; the picker is gated via
the shared `getFreebuffModelsForAccessTier(tier)`.
- **Parallel tabs**: new gated **desktop multi-session** backend — a
sibling `free_session_desktop` table (migration `0074`) keyed by
`(user_id, active_instance_id)` so tabs don't supersede each other. A
**partial unique index** enforces *one active premium-bucket session per
user* (premium models **+ MiniMax M3**) as a race-safe DB invariant,
kept separate from the daily-quota predicate so M3/GLM never burn the
5/day quota. Other tabs use unlimited models (DeepSeek Flash, MiMo 2.5);
per-user total capped at 8.
- **CLI/web are untouched** — the multi-session path only activates on
the `x-freebuff-multi-session` header / `freebuff_multi_session`
metadata flag.
- **Desktop**: device-code login (token persisted),
`FreebuffSessionManager` for lazy per-tab admission/release, per-thread
model wiring through the harness with `cost_mode:'free'`, `ThreadEngine`
premium-slot accounting, and a model picker + sign-in gate in the UI.
All user-facing copy says "Freebuff".

One behavior note: the hosted agent now requires sign-in (or
`CODEBUFF_API_KEY` in dev) since it runs as a real free-mode user; the
first tab claims the premium slot, others default to an unlimited model.

## Verification

- `web`, `packages/internal`, `freebuff-desktop` all typecheck clean
- 324 tests pass across the affected suites (incl. new multi-session
unit tests: premium cap, slot-frees-on-release, per-user cap, instance
validation, flag-absent = unchanged)
- Migration `0074` applied to local DB; partial unique index verified
(2nd active premium insert → `23505`, unlimited unbounded)
- Desktop server smoke test: `/api/state` returns the `freebuff`
snapshot block; auth routes respond

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…pact (#361)

Documents Freebuff Desktop multi-session mode (#358) in the
abuse-detection playbook.

Adds a **"Desktop multi-session inflates the per-IP count"** subsection
to the Mitigation gap section, covering:

1. **Gated** — multi-session needs the `x-freebuff-multi-session` header
/ `freebuff_multi_session='1'` flag; CLI/web stay one-session-per-user.
2. **Premium-bucket cap is a DB invariant** — partial unique index
`uniq_free_session_desktop_premium_active` (migration 0074).
3. **Enforcement prerequisite** — since `countActiveSessionsForIpHash`
now sums both `free_session` and `free_session_desktop`, the per-IP cap
must count distinct `user_id`s (or exclude desktop rows) before flipping
from log-only to enforcement.

Docs only — no code changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Problem

In freebuff-desktop, logging out cleared auth but never released the
user's per-tab free-mode (multi-session) sessions, so they lingered
server-side until they expired/swept.

## Fix

- Add a thin `ThreadEngine.releaseFreebuffSessions()` that delegates to
the existing `FreebuffSessionManager.releaseAll()` (already on the
`FreebuffSessions` interface).
- Call it from `/api/auth/logout` **before** `logoutAuth()` clears the
token — the session DELETE needs a valid bearer, so ordering is
load-bearing (releasing after clearing the token would silently no-op
and leave the rows to expire).

Best-effort: a failed release just leaves rows to expire/sweep as
before. Non-logout paths are untouched.

## Verification

- `bun run typecheck` — clean
- `bun test freebuff-desktop/` — 99 pass / 0 fail

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ode to suggest follow-ups (#363)

## What

Two improvements to the Freebuff Desktop **suggested follow-up prompts**
flow.

### 1. Expandable suggestions (UX)
A suggested follow-up was shown as a single truncated line, so the user
couldn't read the full prompt that would be sent. `SuggestionRow` now
toggles in place:
- Click the row to expand; the full prompt wraps over a few lines
(`pre-wrap`), and a chevron caret rotates.
- The toggle only appears when there's hidden text — a label distinct
from the prompt, or a prompt long enough to truncate
(`TRUNCATING_PROMPT_LEN`). Short suggestions stay static (no caret, not
clickable).

### 2. Claude Code harness encourages `suggest_prompts` (agent)
The Claude Code harness previously passed no system prompt. It now
appends follow-up guidance to Claude Code's preset system prompt
(`systemPrompt: { type:'preset', preset:'claude_code', append }`),
mirroring the Codebuff thread agent's `THREAD_SYSTEM_PROMPT`. The
guidance asks for 1–3 high-signal follow-ups and to skip rather than
emit noise. A cross-reference comment links the two so they stay in
sync.

## Verification
- Ran the desktop server with the Claude Code (Opus 4.8) harness against
a throwaway repo and gave it two real tasks. It completed the work and
produced high-signal, non-overlapping follow-ups (e.g. "add tests", "add
a `.d.ts` declaration file") — no noisy/speculative suggestions.
- Built the UI and confirmed in a browser preview: expandable rows
expand to the wrapped full prompt and the caret rotates; the short
suggestion correctly has no toggle; no console errors.
- `typecheck` passes; the 9 claude-code-harness tests pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What

Adds the analytics + logging layer Freebuff Desktop was missing,
matching the conventions of the CLI / web / chat surfaces.

> Auth was **already shipped** — device-code GitHub/Google sign-in
(reusing the CLI's web endpoints), credentials stored in
`~/.config/freebuff-desktop` independent of the CLI's
`~/.config/manicode`. This PR is the analytics/logging follow-up.

## Changes

- **`src/app/analytics.ts`** (new) — PostHog client (public
`NEXT_PUBLIC_POSTHOG_*` keys), sends only in prod like the CLI.
`distinct_id` = canonical Postgres user id when signed in (held in
memory; set at init/login, cleared on logout) else a stable per-install
anon id. `identifyOnLogin` aliases anon → user so pre-auth activity
joins the account. Routes through the shared `shouldTrackAnalyticsEvent`
sampling gate.
- **`src/app/log-shipper.ts`** (new) — mirrors events to Axiom via `POST
/api/logs` (the desktop is a client, so it ships rather than holding the
ingest token). `flushAnalytics()` drains both PostHog + the shipper; the
server wires a single shutdown hook.
- **Events** — reuses `message_sent` (`surface: 'desktop'`,
`accessTier`) for cross-surface DAU; adds
`desktop.{app_launched,login,logout,thread_created,project_opened,turn_completed,harness_changed,model_changed,skill_run}`
to the shared enum + always-track list.
- **Emit points** — `server.ts` (launch, login/logout,
project/harness/model changes, shutdown flush) and `thread-engine.ts`
(`message_sent` on send/skill, `thread_created`, `turn_completed` with
duration/toolCalls/outcome).

## Notes

- For packaged builds, the public `NEXT_PUBLIC_POSTHOG_API_KEY` must be
baked in for prod events to flow (code no-ops gracefully if absent).
- No sign-out button in the UI yet (only `api.logout()` exists) —
possible follow-up.

## Verification

- `bun run typecheck` (both desktop tsconfigs) ✅
- `common` typecheck ✅
- `bun test` — 99 pass / 0 fail ✅
- Runtime smoke test of init/track/identify/reset/flush ✅

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary

Small, safe cleanup of dead code in the Freebuff Desktop REST client
`api` object ([api.ts](freebuff-desktop/src/app/ui/lib/api.ts)). Removes
methods with **zero callers** in `src/` (verified via grep):

- **`api.deleteThread`** — the other `deleteThread` references are the
engine/core-store methods and the server route handler, none routed
through the client `api`.
- **`api.getAuthStatus`** — defined, never called.
- **`api.logout`** — never called; `logout` in
`server.ts`/`login-store.ts` is a separate server-side function.
- **`label` param of `api.enqueuePrompt`** — the only caller (store →
QueuePanel) never passes it. Dropped from the signature and request
body.

The server routes themselves are left intact; only the unused client
wrappers are removed.

## Intentionally kept

`ThreadData.messages` `acts`/`text` fields are **not** removed. They are
still read by `partsFromPersisted()`
([parts.ts:284-288](freebuff-desktop/src/core/parts.ts:284)), which
`store.ensureLoaded` calls
([store.ts:498](freebuff-desktop/src/app/ui/store/store.ts:498)) to
reconstruct the legacy persisted message layout. `text` is a required
param there, so trimming these would break the typecheck.

## Verification

- `bun run typecheck` ✅
- `bun run ui:build` ✅

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…366)

## Summary
Fixes the red `build-and-check` caused by an inherited exhaustiveness
error on the `premium_slot_taken` session status.

Adds `case 'premium_slot_taken':` to the `return null` group in the
status switch in `cli/src/hooks/use-freebuff-session.ts`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ndfathered) (#315)

**Phase 3** — moves the GLM entitlement read to the unified model,
grandfathered so nobody loses access. Design:
[docs/referrals.md](docs/referrals.md).

## Read switch
`getGlmReferralEntitlement` now computes `min(max(legacyGlmScore,
fullQualified), CAP)`:
- `fullQualified` comes from `getReferralStats` (the new `referral_v2`
model).
- `max()` with the legacy glm score **grandfathers** existing referrers
— no one loses GLM as the source of truth moves.
- **Safe to ship before the backfill:** an empty `referral_v2` makes
`fullQualified` 0, so the max falls back to the legacy score and
behavior is unchanged. The new model takes over as it gets populated
(dual-write from Phase 2 + the backfill below).

## Backfill — `scripts/backfill-referral-v2.ts`
Populates `referral_v2` from the legacy `referral` rows +
`free_session_admit` history:
- Deduped to each referred user's **earliest referrer** (one row per
`referred_id`).
- Activation tier: **full** if a `glm`/`cli` referral completed
(grandfather) or a full-tier admit exists; **limited** if a web
completion or limited admit; otherwise not activated.
- Reuses the Phase 2 write functions (`recordReferralV2Attribution` /
`recordReferralV2Activation`); idempotent; **dry-run by default**.

## Notes
- The grandfather `max()` also preserves the legacy "+1 if you were
yourself referred" GLM perk during the transition — whether the unified
model should keep that perk is a **Phase 4 decision** (the new
`fullQualified` counts only referrals you *made*).
- The local `tsc`/stale-`main` symlink can't see `referral_v2`; CI
validates on a fresh checkout.

## Still to do
- **Region-gate removal** (decision #5) — let limited-region referrers
*use* GLM earned via full-access referrals (CLI banner + session
admission). Phase 3b.
- **Phase 2b** — carry the referral code through the auth flow
(`onCreateUser` hook found) for cookie-independent attribution.
- **Phase 4** — drop the legacy `referral` table + `program`; decide the
"+1 referred" perk.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Quieter new-thread chrome for Freebuff Desktop + a wider work sidebar.

- Fade the new-thread logo (opacity 0.92 → 0.18) and add a "New thread"
  title plus the project/worktree path beneath it.
- Collapse the home dir to `~` and middle-truncate the path so it stays
on
one line (full path in the tooltip); click it to copy the full path with
  a brief "Copied" confirmation.
- Double the default skills/queue sidebar width (360px → 720px);
existing
  users keep their saved width.
- Mute the stop button from red to a gray-scale raised style.
- Render tool names in gray (--muted) instead of accent green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…l dedup, type dedup, migration hardening) (#368)

## What

Tier 1 of a foundations-cleanup pass on Freebuff Desktop (from a full
code review). Pure refactor + test hardening — no behavior change.
**−447 net lines.**

### 1. Delete dead task-graph worktree machinery (`core/worktree.ts`,
410→208 lines)
The thread model drives PRs/merges through the agent's own shell
commands, so the dependent-task restack/merge/PR helpers on
`WorktreeManager` (`mergeParentBranches`, `integrationBaseSha`,
`restack`, `rebaseOntoDefault`, `resetTo`/`resetToDefault`,
`pushAndOpenPr`, `squashMerge`, `localSquashMerge`,
`syncDefaultFromRemote`, `commitAll`, `diffAgainstDefault`,
`workingDiff`, `hasRemote`) + `RebaseResult`/`mergeAllOrAbort` had
**zero callers**. Removed them and their tests, rewrote the stale
spec-numbered header, and added direct `closeOut` coverage (previously
untested).

### 2. Define custom tools once, share across harnesses (new
`agents/thread-tools.ts`)
`suggest_prompts` / `write_doc` / `browser_check` were defined twice
(Codebuff `getCustomToolDefinition` vs Anthropic MCP `tool()`) with
copy-pasted descriptions, schemas, and logic. Extracted one SDK-neutral
`THREAD_TOOL_SPECS`; each harness wraps it with a thin adapter.
Collapsed the **triplicated** follow-up guidance into one
`SUGGEST_PROMPTS_GUIDANCE` constant, dropped the dead
`DOC_NAMES.includes` checks, and derived the `write_doc` enum from
`DOC_NAMES`.

### 3. Re-export core types in the UI instead of mirroring
(`ui/lib/types.ts`)
`Thread`/`QueueItem`/`Skill`/`SkillSearchResult`/`HarnessId` +
lane/state enums were hand-duplicated and had already drifted
(`lastSeenHead` optional vs required). Now re-exported from `core/types`
so a backend field change is a compile error in the UI. Renderer-only
shapes stay local.

### 4. Harden store migrations (`core/store.ts`)
Replaced the numbered `table_info` snapshots with a
`hasColumn(table,col)` helper queried fresh per check — the v10
`freebuff_model` check was reading a stale pre-`ALTER` snapshot. Added
upgrade-path tests: build a real pre-v10 DB, reopen via `Store`, assert
data survives, columns are added, `autorun`→`auto_queue_suggestions`
carries over, removed budget schema is gone, and the upgraded shape
**matches a fresh DB**.

## Test plan
- `bun test` — 94 pass / 0 fail
- `bun run typecheck` — clean (both tsconfigs)
- `bun run ui:build` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…dedup store (#369)

Maintainability cleanup of the Freebuff Desktop renderer. No behavior
change beyond adding **Escape-to-close** on the project/settings modals.

## What changed
- **Typed Electron bridge** — new `lib/bridge.ts` with a
`FreebuffDesktopBridge` interface + `declare global`, replacing the
duplicated `bridge(): any` helper and the raw `(window as
any).freebuffDesktop` accesses across 4 components. A renamed preload
method is now a compile error.
- **`useDismissable` hook** — collapses the verbatim
outside-click/Escape effect in `AgentSelector` (×2) and `TabBar`, and
adds the previously-missing Escape-to-close to `ProjectPicker` and
`SettingsModal`. Listener subscription is stabilized via a ref so it
doesn't re-subscribe on every render.
- **ThreadView split** (326 → ~125 lines) — extracted `useScrollPin`,
`useFileDrop`, and a `ThreadHeader` component.
- **`patchThread` store helper** — dedups the copy-pasted optimistic
thread-update dance (`setThreadHarness`, `setThreadModel`, `stopTurn`,
`setAutoQueueSuggestions`).

## Deliberately not done
- Full Zustand slice split of the store — high churn, speculative
benefit; took the concrete `patchThread` win instead.
- Server `server.ts` router-table rewrite — local-only 127.0.0.1 server;
the error-envelope unification would break the response contract the
client depends on.

## Verification
- `bun run typecheck` (both tsconfigs) ✅
- `vite build` ✅
- `bun test src/core/store.test.ts` ✅

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…guard, Electron nav, resilient DB reads) (#370)

## What

Security + correctness hardening on Freebuff Desktop (Tier 3 of the
foundations review). Follows #368.

### Security

**Block cross-origin requests to `/api`** (`server.ts` + new
`origin-guard.ts`)
The orchestrator binds 127.0.0.1, but that's not a boundary: any web
page could `fetch('http://127.0.0.1:<port>/api/run', {method:'POST'})`
and execute shell in the open repo (CORS blocks reading the response,
but the side effect already ran) — a **local RCE via CSRF**, and a
forged Host (DNS rebinding) doesn't help since the real attacker Origin
is still stamped on. Added a loopback-Origin guard as an early return in
the fetch handler. Same-origin renderer calls, the dev Vite proxy, and
non-browser clients pass; a present non-loopback Origin is rejected.
**Verified end-to-end** — evil-Origin `POST /api/run` → `403`,
legitimate calls → `200`.

**Pin the renderer to the trusted app origin** (`electron/main.cjs`)
Tightened `setWindowOpenHandler` so only the exact app origin opens
in-app (with the preload bridge); everything else is denied and opened
in the system browser (http(s) only). Added a `will-navigate` guard so a
top-level navigation can't carry the preload surface to an untrusted
document.

### Correctness / resilience

**Guard `JSON.parse` on stored columns** (`store.ts`)
The DB is in-repo and user-editable; one corrupt
`parts_json`/`acts_json`/`skills_json`/`run_config` value would throw
and fail the whole transcript / workflow list / project load. Reads now
go through `safeParse(json, fallback)` and degrade to defaults (covered
by a corrupt-column test).

**Validate Claude Code `previousState` + drop dead plumbing**
(`claude-code-harness.ts`, `freebuff-session-manager.ts`)
Added an `isClaudeState` shape guard so a mismatched state starts a
fresh session rather than feeding a foreign object to `resume`. Removed
the unused `rateLimitsByModel` field/extraction/import (`fetchTier`'s
return has no consumer).

## Test plan
- `bun test` — 100 pass / 0 fail (incl. new origin-guard +
corrupt-column tests)
- `bun run typecheck` — clean (both tsconfigs)
- `bun run ui:build` — clean
- Live server smoke test of the origin guard (403 on cross-origin
`/api/run`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ase token, core type-safety, UI keys, README) (#371)

## What

Continued foundations cleanup — the next batch of remaining review
findings. No behavior change beyond the bug fixes called out.

- **Electron: reattach instead of respawn on macOS `activate`** —
`boot()` spawned a second orchestrator when a window was closed while
the orchestrator stayed alive, leaking the first. Now reuses the live
one.
- **release script: `fetch` instead of token-in-shell** — the GitHub PAT
was interpolated into a `curl` string run via `execSync` (leaks via
`ps`, breaks on metachars). Dispatch via `fetch()` with the token in a
header.
- **Core type-safety** — `buildUpdate` takes a per-table column
allowlist (derived from the patch types via `satisfies`) and throws on
an unknown key instead of silently emitting `SET nonexistent_col = …`;
`SkillStore.list()` narrows nulls instead of a lying `!`;
`skill-registry` coerces the untyped response through `unknown` instead
of `any[]`.
- **UI** — fold/items part groups keyed by their first part's stable id
(not array index), so `FoldedActivity`'s open/closed state survives
streaming; monotonic toast id instead of `Date.now()+random`.
- **README** — rewritten to the actual thread/queue/harness model (it
still described `graph.ts`/`orchestrator.ts`/`pipeline.ts` and a
task-graph tool surface that no longer exist).

## Test plan
- `bun test` — 101 pass / 0 fail (incl. new buildUpdate-allowlist test)
- `bun run typecheck` — clean (both tsconfigs)
- `bun run ui:build` — clean
- `node --check electron/main.cjs` + `bun build scripts/release.ts` —
clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…on tests (#372)

## What

Hardens the orchestrator HTTP/SSE surface and gives it real test
coverage (it had none).

- **SSE heartbeat** — the event stream now sends a comment frame (`:
ping`, ignored by EventSource) every 25s. Without traffic a half-open
socket (laptop sleep, proxy drop) sat dead in `subscribers`
indefinitely; the heartbeat's enqueue throws on a dead controller and
cleans up the subscriber + interval, and keeps intermediaries from
dropping an idle stream.
- **`/healthz`** — a trivial, dependency-free liveness probe. The
Electron shell's readiness wait now polls it instead of the heavier
`/api/state` (no longer couples readiness to serializing the full engine
snapshot).
- **Integration tests** — spawn the real `bun server.ts` against a
throwaway git repo and assert: `/healthz` liveness, the cross-origin
guard (403 evil-Origin vs 200 same-origin), a `POST /api/threads` → `GET
/api/threads` round-trip, unknown-route 404s, and SSE initial-state
backfill. Stable across repeated runs.

## Scoping note

I deliberately did **not** rewrite the request handler into a router
table. It's a large restructure of ~30 working routes on the hot path,
and the method-enforcement inconsistency it would fix is low-severity
here (the `/api` surface is origin-guarded and the renderer always uses
the correct method). The new integration tests make that refactor safe
to do later as a focused change if desired.

## Test plan
- `bun test` — 108 pass / 0 fail (incl. 7 new server integration tests),
stable across repeated runs
- `bun run typecheck` — clean (both tsconfigs)
- `node --check electron/main.cjs` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What

Mirrors the freebuff.com/chat thread-title behavior in the desktop app.
When a thread's first message lands, the tab shows the **prompt prefix
as an immediate placeholder**, then **swaps in a short LLM topic title**
once it comes back — best-effort, in parallel with the turn.

| before | after |
|---|---|
| `how do I add a dark mode toggle to my tailwind site that rem…` |
**Dark Mode Toggle Persistence** |

## How

- **New [`src/app/title.ts`](freebuff-desktop/src/app/title.ts)** —
`buildTitleAgent` / `sanitizeTitle` / `runTitleCompletion`, lifted from
the chat title agent (same 3–6 word, same-language, no-PII rules,
single-step, no tools).
- **`ThreadEngine.generateThreadTitle`** fires best-effort from
`postMessage`, in parallel with the turn. The swap **reuses the existing
`thread` SSE event** (no new event type) and only applies if the title
is still the placeholder we set (a manual rename / concurrent update
wins).
- `generateTitle` is **injectable via `EngineOptions`** so tests stub it
without touching the SDK.

## Key decision — metered, not free mode

A standalone free-mode title call is rejected by the free-mode gate
(`403 free_mode_invalid_agent_model`): free mode is locked to the
allowlisted freebuff agent hierarchy + the "You are Buffy" CLI system
marker, which a one-off title agent can't satisfy (and spoofing the
marker is an explicit ban risk). Chat sidesteps this by metering its
title under a **service account**, which the desktop has no equivalent
of — so the desktop runs the title as a **normal metered request on the
user's account** (a single cheap DeepSeek-Flash completion). Credit-less
free-mode-only users gracefully keep the placeholder.

## Verification

- **End-to-end against a local backend** (browser preview of the desktop
web stack): created a fresh thread, typed a message, watched the tab go
placeholder → **"Dark Mode Toggle Persistence"** in ~4s, no server
errors.
- 102 desktop unit tests (incl. 2 new: swap +
manual-rename-not-clobbered) and both typechecks green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…urns across app restart (#374)

## Problem

Quitting Freebuff Desktop dropped a thread's in-flight work. The
orchestrator is a Bun process the Electron shell kills on quit, and its
turn state lived only in memory. Two symptoms:

1. **Conversation context was lost.** The agent's carried context
(Codebuff `RunState` / Claude Code `sessionId`) lived only in the
in-memory `threadState` map. The transcript persisted, but the agent's
actual memory didn't — so the next turn after a restart ran blank ("the
agent didn't know what I was talking about").
2. **Running turns didn't resume.** The constructor reset orphaned
`running` turns but never restarted the pump, and an in-flight *typed*
prompt had no persisted record at all — so nothing picked it back up.

## Fix

- Persist the agent's carried context to new `threads.harness_state` (+
`harness_state_id`) columns, mirrored at every save/clear site via
`saveThreadState`/`dropThreadState`, and restore it into memory on
startup. → context survives restart.
- Persist a typed turn's prompt to a new `threads.pending_prompt` column
while it runs. On startup, for threads that were **running** at quit,
requeue any claimed queue item and/or re-inject the pending prompt, then
kick the pump → turns auto-resume. Gated on `wasRunning` so an idle
thread you deliberately **Stopped** isn't revived (the in-memory
`interrupted` flag is gone after restart).
- Schema bumped 10→11 (additive nullable columns, follows the existing
v9/v10 migration pattern). These columns are kept off the `Thread`
domain type behind dedicated accessors — they're engine-internal
recovery state (a possibly-large opaque blob), not domain fields the UI
renders.

### Bonus
Fixed `server.ts` swallowing `SIGTERM` — registering a signal listener
overrides the runtime's default "terminate", so quit hung until
Electron's 3s `SIGKILL` fallback (and could leave a port-holding
zombie). Now flushes analytics then `process.exit(0)`, exiting in
~400ms.

## Testing

- 4 new restart tests (a second engine on the same on-disk DB simulates
a relaunch): context restore, typed-turn resume, queued-turn resume, and
"don't revive a stopped idle thread". All 42 store+engine tests pass;
package typecheck clean.
- **Verified end-to-end for real** against the local Claude Code harness
(fresh instance, own repo): told it a passphrase → quit → relaunched →
asked for it back → it answered correctly; started a multi-file task →
hard-killed the server mid-turn → relaunched with no new prompt → the
turn auto-resumed and finished, the agent noting *"both files already
exist from my previous step."*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
victorxheng and others added 27 commits July 3, 2026 17:02
#482)

## Summary

Users sometimes see this in the CLI, ending the whole agent run with a
raw error + stack trace:

> Agent run error: The socket connection was closed unexpectedly. For
more information, pass `verbose: true` in the second argument to fetch()

Axiom shows **~12,500 occurrences from ~2,600 distinct users in the last
7 days** (`data contains 'socket connection was closed'`, services
`cli`).

**Root cause:** Bun's fetch throws these as plain `Error`s (`code:
ECONNRESET`/`ConnectionClosed`) when the TCP connection to the web
server drops (consistent with load-balancer resets under heavy load —
these requests never reach web server logs). The AI SDK only
auto-retries `APICallError` with `isRetryable: true`; its network-error
wrapper only recognizes undici's `TypeError: fetch failed`, so Bun's
socket errors got zero retries and surfaced raw.

**Changes:**
- `common/src/util/error.ts`: new `isTransientNetworkError()` (Bun
socket-close message, ECONNRESET/ConnectionClosed/etc. codes, undici
"fetch failed"; walks RetryError wrappers and cause chains) +
user-facing `TRANSIENT_NETWORK_ERROR_USER_MESSAGE`
- `sdk/src/impl/model-provider.ts`: the Codebuff backend model's fetch
now rethrows transient connection failures as retryable `APICallError`s,
so `streamText`'s built-in exponential backoff (2 retries) absorbs brief
blips
- `packages/agent-runtime/src/run-agent-step.ts` + `sdk/src/run.ts`:
when retries are exhausted, show "Connection interrupted... Your
progress is saved. Please try sending your message again." instead of
the raw error and stack trace

## Test plan

- [x] New unit tests for `isTransientNetworkError` (common) — pass
- [x] New loop-agent-steps test: socket error → friendly message, no
"Agent run error:" prefix, no raw Bun message — pass
- [x] Full test suites pass: common (461), sdk (400), agent-runtime
(445)
- [x] Typecheck passes for common, sdk, agent-runtime
- [ ] Post-merge: monitor Axiom for reduced socket-close failure volume
(retries should absorb most)

Not directly verifiable in production on demand — the failure is a
transient network condition. Behavior verified via unit tests simulating
the exact Bun error shape observed in Axiom payloads.

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary

- The `next js 16 upgrade` commit (9673a64) broke the web typecheck on
`main`: Next 16's `revalidateTag()` requires a `cacheLife` profile as a
second argument, so CI's `build-and-check` has been failing on every
push since (including #482, which doesn't touch `web`).
- Uses `{ expire: 0 }` on all 6 call sites in
`web/src/lib/revalidation.ts` to preserve the pre-16 immediate-expiry
behavior (these run from admin actions/webhooks that need fresh data,
not stale-while-revalidate).

## Test plan

- [x] `revalidation.ts` compiles clean against Next 16.2.10
- [ ] CI `build-and-check` green on main after merge

Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <cursoragent@cursor.com>
Turbopack's standalone copier skips symlinked workspace externals, so the
chat stream route's runtime import("@codebuff/sdk") hit ERR_MODULE_NOT_FOUND
in prod once Render pruned the root node_modules. Copy the SDK plus its
transitive dependency closure into the standalone tree and verify the import
resolves from inside it at build time. Also raise the Render cache stash cap
so the Turbopack filesystem cache (~3-5GB) survives between deploys.

Co-authored-by: Cursor <cursoragent@cursor.com>
Empty commit to exercise Render build-cache restore after the 8GB stash
cap fix; expect restored .next/cache and sub-minute compile on this run.

Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary
- Fixes an order-dependent flake in `local-agents.test.ts`
(`getLoadedAgentsData returns agent info when agents exist` and `handles
empty .agents directory`) that only reproduced under the full `bun test`
suite, never in isolation.
- Root cause: `trim-chat-logs.test.ts`, `chat-history.test.ts`, and
`trace-writer.test.ts` each called `mock.module('../../project-files',
...)` without ever restoring it. Bun's `mock.module` replacement is
**process-global**, not scoped to the mocking file, so whichever mock
happened to still be active when `local-agents.test.ts` ran later in the
same process caused `findAgentsDirectory()`'s `getProjectRoot()` call to
return a bogus directory — which silently fell back to a
`process.cwd()`-based path and returned a resolved (`/private/var/...`)
string instead of the test's own unresolved tempdir string.
- Tried restoring the real module via `afterAll(() => mock.module(path,
() => real))` first; verified empirically that this doesn't reliably
work here since Bun evaluates test files' top-level `mock.module` calls
largely upfront, so a later file's "real" capture can itself already be
an earlier file's un-restored fake.
- Actual fix follows the project's documented convention
(`docs/testing.md`: "prefer DI over module mocking... avoid
`mock.module()` for functions"): added optional injected parameters
instead of mocking `project-files`.
- `chat-history.ts`: `getAllChats`, `deleteChatSession`,
`trimOversizedChatLogs` now accept an optional `dataDir` param (default:
real `getProjectDataDir()`).
- `trace-writer.ts`: `createTraceWriter` now accepts an optional
`resolveTraceFilePath` resolver (default: real implementation).
- The three test files now pass their tempdir/resolver directly instead
of mocking global state, so no cross-file leak is possible anymore.

## Test plan
- [x] `cd cli && NODE_ENV=production bun test` — 2476 pass / 0 fail, run
5x consecutively with no flake
- [x] Isolated runs of the four touched test files still pass
- [x] `bun run typecheck` — no new errors (2 pre-existing, unrelated
errors in `packages/agent-runtime`)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…mand tests (#486)

## Summary

Two CLI test files still called `mock.module()` on **function** exports,
which is process-global in Bun (not scoped to the mocking file) and can
leak into unrelated test files that run later in the same `bun test`
invocation — the same bug class fixed in #485. This hardens against that
leak (no flake observed yet; prevention).

- `prompt-builders.test.ts` mocked `../../utils/chatgpt-oauth` to stub
`getChatGptOAuthStatus`.
- `router-connect-chatgpt.test.ts` mocked
`../../components/chatgpt-connect-banner` to stub
`handleChatGptAuthCode`.

Per `docs/testing.md` ("prefer dependency injection over module mocking;
avoid `mock.module()` for functions"), the fix threads optional injected
params through the functions under test instead:

- **`prompt-builders.ts`**: `buildPlanBasePrompt` /
`buildReviewBasePrompt` / `buildPlanPrompt` / `buildReviewPrompt` /
`buildReviewPromptFromArgs` now accept an optional `isChatGptConnected?:
() => boolean` (default: real `getChatGptOAuthStatus().connected`).
- **`router.ts`**: `routeUserPrompt` accepts an optional
`exchangeChatGptAuthCode` (default: real `handleChatGptAuthCode`), typed
as `typeof handleChatGptAuthCode` so the injected type can't drift from
the source function.

The one remaining `mock.module()` call (`CHATGPT_OAUTH_ENABLED`) mocks a
**constant**, which the convention explicitly permits. All production
call sites are unchanged — the new params are optional, appended at the
end, and default to the real implementation.

## Test plan

- [x] `cd cli && bun run typecheck` — clean
- [x] `NODE_ENV=production bun test` run 5x — no new failures; the 16
pre-existing `send-message.test.ts` failures are unrelated (identical
pass/fail before/after, verified via `git stash`)
- [x] Both touched test files pass in isolation and in the full suite
- [x] All call sites (`chat.tsx`, `review-screen.tsx`,
`command-registry.ts`, `router.ts`) traced — none pass the new optional
params, so behavior is unchanged

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary

- Replace the bright accent-green tint on the `Premium` chip in the
model picker dropdown with a neutral `--muted` gray so it sits in the
same register as the model description text.
- Nudge the `In use` chip down to `--faint` so the two states remain
visually distinguishable.

## Context

The `Premium` label was the loudest element on each row of the dropdown,
pulling the eye to the chip instead of the model's title. With both
chips now in grayscale, the row's main label reads first and the chip
recedes into metadata territory.

## Changes

- `freebuff-desktop/src/app/ui/styles/app.css` — `.model-badge`
color/background switched from `var(--accent)` to `var(--muted)`;
`.model-badge.muted` color/background switched from `var(--muted)` to
`var(--faint)`. Comment updated to explain the rationale.

## Risk

Pure CSS, one file. No JS/TS changes; no behavioral impact. Existing
semantics (`<span class="model-badge">Premium</span>` / `<span
class="model-badge muted">In use</span>`) are untouched, both branches
of `.model-badge.muted` stay in the styling file.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
## What

Adds a persistent **Beta** badge to the Freebuff Desktop tab bar so
users understand the app is not fully stable yet.

- Pinned to the **far left** of the tab bar (after the macOS traffic
lights, before the tab strip). It lives outside the scrollable `.tabs`
container, so it's always visible regardless of how many tabs are open
or scrolled.
- **Amber** tone (`#d8a14a`, same hue as the "stopped" status icon) —
deliberately *not* the red `--danger` color, so it reads as "caution /
in progress" rather than "something broke."
- Hover tooltip: *"Freebuff Desktop is in beta — expect rough edges and
the occasional bug."*
- Uppercase, no-drag, non-selectable — consistent with the rest of the
tab-bar chrome.

## Verification

`tsc --noEmit` passes clean. Change is purely presentational (one span +
one CSS rule).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary

The CLI/SDK file tools — `write_file`, `str_replace`, `apply_patch`,
`read_files`, `list_directory`, and `code_search` — previously rejected
any path outside the project directory you started in. They now operate
on **any file on the system** the process has permission to access:
absolute paths are honored as-is, relative paths resolve against the
project root.

## Changes

- **`resolveFilePath()`** — new unrestricted sibling of
`resolveFilePathWithinProject()` in `path-utils.ts`. Returns `fullPath`,
a display-friendly `relativePath` (project-relative when inside the
project, otherwise the absolute path), and `isWithinProject`.
- **`change-file.ts`** (`write_file`/`str_replace`) — drops the "outside
the project directory" rejection.
- **`apply-patch.ts`** — removes the `hasTraversal()` guard and resolves
via `resolveFilePath()`; this also fixes a latent bug where
`path.join(cwd, path)` mangled absolute paths.
- **`read-files.ts`** — reads out-of-project files; **skips the
project-scoped gitignore check for files outside the project**. (Running
it on an out-of-project absolute path mis-joins the path via
`path.join(projectRoot, filePath)` and could wrongly mark files such as
`/other/node_modules/...` as `IGNORED`.)
- **`list-directory.ts`** / **`code-search.ts`** — remove the "outside
the project" guards.

## Tests

Updated the affected suites to assert the new behavior instead of the
old rejections, and added a regression test that an out-of-project path
containing an ignored segment (`node_modules`) is still read. All 81
tests across the 5 affected suites pass; `tsc --noEmit` clean.

## Note

This intentionally removes a safety boundary: agents can now
read/write/list/search anywhere the process has filesystem permissions
(including `~/.ssh`, `~/.aws/credentials`, system files). Flagging in
case we'd rather gate it behind a flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ibution (#484)

Follow-ups to #469, from post-deploy verification of the 2026-07
saturation work.

## Post-deploy state (why these)

The queue-time metric immediately paid off: even **off-peak**,
chat-completion `queueMs` (ingress→handler) is **p50 656ms, p99 12–17s**
while event-loop utilization is only ~50% and real lag ~20ms. So the web
tier is **connection/ingress-bound, not CPU-bound.** Two ambiguities
remained, which this PR closes:

### 1. Is `queueMs` real queueing or just large-body upload?
`X-Request-Start` is stamped when headers arrive, so `queueMs` includes
the time to receive the request body. Chat completion bodies (full
message history) are large, so a big `queueMs` could be a slow upload
rather than instance backpressure. This logs `contentBytes`
(Content-Length) next to `queueMs` so we can separate them — if
`queueMs` correlates with body size it's upload; if not, it's genuine
queueing (which the p99 tail, matching the Render dashboard's climbing
p90, already suggests).

### 2. Are the multi-second event-loop stalls GC?
`event_loop_lag.maxMs` spikes to 1.8–3.8s a few times/hour — a whole
instance frozen. GC runs synchronously on the main thread, so a long
major (mark-sweep-compact) GC on the large heap is the prime suspect.
The per-minute metric now carries `gcCount / gcTotalMs / gcMaxMs /
gcMajorCount / gcMajorMs` (PerformanceObserver on `'gc'`). If `gcMaxMs`
tracks `maxMs`, the stalls are GC (→ tune heap / reduce large transient
allocations); if not, it's synchronous app work (e.g. `JSON.stringify`
of a huge payload on the hot path).

## Tests
- New `web/src/util/__tests__/request-queue-time.test.ts` — both header
helpers (epoch formats, nginx `t=`, ns, clamps; Content-Length
valid/zero/negative/absent).
- `request-metrics.test.ts` — asserts `queueMs`/`contentBytes` emitted
when present, omitted when absent, and that a ≥5s queue overrides
sampling.
- web typecheck clean for touched files (note: pre-existing
`src/lib/revalidation.ts` errors from the Next.js 16 upgrade are
unrelated to this change).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…w Zod (#489)

## Problem

Prod web logs showed ~750 `Anthropic token count API error` + paired
`Failed to count tokens` per 2h (message service=web), body:
`tools.15.custom.input_schema.type: Field required`.

**Root cause (deeper than one bad tool):**
[`run-agent-step.ts`](../blob/james/fix-token-count-tool-schema/packages/agent-runtime/src/run-agent-step.ts)
built the `tools` payload for Anthropic's `count_tokens` API by passing
each tool's `inputSchema` — a **raw Zod v4 schema** — straight through.
`JSON.stringify` of a Zod schema emits Zod internals
(`{"def":{...,"shape":...},"type":...}`), not JSON Schema. A `z.object`
coincidentally serializes with a top-level `"type":"object"` (so most
tools passed), but any tool whose top-level schema is a
**union/intersection** serializes without a top-level `type`, which
Anthropic rejects with `...input_schema.type: Field required` — failing
token counting for **every step of every paid run** carrying that tool.
As a latent bug, token counts were also being computed against Zod `def`
blobs rather than real schemas.

Free/freebuff runs already stopped calling this endpoint (local estimate
in the `isFreeMode` branch), so the remaining flood was the paid
Codebuff path.

## Changes

1. **Source fix** — `run-agent-step.ts`: new exported
`toTokenCountInputSchema()` converts Zod → JSON Schema via
`z.toJSONSchema(s, {io:'input'})` (with a permissive fallback), strips
`$schema`, and backfills a top-level `type: 'object'` (unions become
`{anyOf:[...]}` with no `type`). `toolsForTokenCount` is built once per
run (before the step loop), so the conversion cost is negligible.

2. **Defensive backstop + status + log trim** —
`web/src/app/api/v1/token-count/_post.ts`:
- `normalizeToolSchemasForAnthropic()` backfills `type: 'object'` on any
object schema missing it, before the upstream call.
- Catch now returns **422** for upstream 4xx (deterministic,
non-retryable) vs **500** for transient failures, via a new
`UpstreamTokenCountError` carrying the upstream status.
- Error log drops the ~0.5MB `messages` array (the Axiom ingest-cost
driver); keeps `status`, `errorText`, `model`, `anthropicModelId`,
`toolCount`, `toolNames`.

3. **Tests** — 5 new for the source helper (Zod object → clean JSON
Schema, union backfill, plain-object missing `type` backfilled, existing
`type` untouched, null→undefined) and 6 new for the route normalization.

## Validation

- `web` + `agent-runtime` typecheck clean.
- token-count route tests: 58/58 pass · source-helper tests: 5/5 pass ·
existing `run-agent-step-tools` tests: 6/6 pass.
- **Post-deploy (manual):** query the Axiom `freebuff` dataset for
`"Anthropic token count API error"` and confirm it trends toward zero.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#491)

Follow-up to #489 addressing two review comments.

## 1. Narrow the upstream-4xx → 422 remap (substantive)

The catch in `postTokenCount` mapped the entire `[400,500)` range to a
client `422`. That mislabels a transient upstream **429** (rate limit)
and **401/403** (auth) as a non-retryable client error. (It's
functionally inert today — `callTokenCountAPI` never retries token-count
and falls back to a local estimate on any error — but the label is still
wrong and would bite a future retrying client.)

Now only genuine malformed-request statuses (**400/422**, e.g. a bad
tool schema) → `422`; everything else (5xx, 429, 401/403) stays `500`.

## 2. Backfill `type: null` / `type: ''`, not just `undefined` (low
severity)

Both the web backstop `normalizeToolSchemasForAnthropic` and the source
helper `toTokenCountInputSchema` only treated `type === undefined` as
missing. A schema with `type: null` or `type: ''` would slip through. A
valid JSON Schema `type` is always a non-empty string (or array), so
both now treat null/empty as absent and backfill `type: 'object'`.

## Tests
Added cases for the null/empty-`type` backfill in both suites. All pass
(`agent-runtime` 6/6, `web` token-count 59/59); typecheck clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Opening a new tab (the "+" button, /new, reopening a closed tab, or a
folder pick that routes through newThread) now auto-focuses that tab's
composer textarea so the user can type immediately.

The Workspace keeps a single ThreadView/Composer instance and swaps the
threadId prop on tab switches (no remount), so a plain autoFocus won't
fire. Instead newThread/rehydrateLast set a one-shot pendingFocusId in
the store; the Composer focuses when it matches its threadId and clears
it via consumeComposerFocus. Routing through the signal means focus is
grabbed only for newly-opened tabs, not on manual switches back to an
existing tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What

Adds **Codex (GPT-5.5)** as a third pluggable agent harness in Freebuff
Desktop, alongside Claude Code and the hosted Freebuff agent. It drives
the user's **local, ChatGPT-authenticated Codex CLI** via
`@openai/codex-sdk` — reusing `~/.codex` login with no key plumbing,
exactly like the Claude Code harness reuses the Anthropic subscription.


## How it works

- **Model picker** gains a CODEX group (GPT-5.5). The per-thread pick
persists (schema **v16** `codex_model`). The `-codex` model variants are
rejected because they 400 on ChatGPT-account auth.
- **Rendering**: Codex's JSONL events fold into the shared `parts` model
— reasoning, assistant messages, and tool calls (`command_execution` /
`file_change` / `mcp_tool_call` / `web_search`) render in stream order
with friendly labels (`formatTool`).
- **Auth**: reuses `~/.codex/auth.json` (strips
`OPENAI_API_KEY`/`CODEX_API_KEY` so the subscription login wins).
Signed-out → a `codex-auth` recovery card (Open Terminal + copy `codex
login`), mirroring the Claude Code card.
- **Freebuff custom tools** (`suggest_prompts` / `write_doc` /
`browser_check`): Codex has no in-process tool transport, so each turn
hosts a tiny **dependency-free localhost streamable-HTTP MCP server**
bound to that turn's `toolDeps`, wired via `config.mcp_servers`. (The
MCP SDK is deliberately avoided — the monorepo pins zod v4 and the SDK
needs v3.)
- **Not bundled**: the ~240 MB platform binary is too heavy to ship, so
we use the user's **installed** codex. When none is available, the
picker shows the Codex agent **disabled** ("Not installed", with an
install tooltip) and the agent route rejects the pick.
`FREEBUFF_CODEX_DISABLED=1` is an ops/test kill-switch.

## Autonomy

Runs with `sandbox = danger-full-access` + `approval = never` — the
analog of Claude Code's `bypassPermissions` (each thread is an isolated
worktree). `workspace-write` invokes Codex's own macOS seatbelt, which
SIGKILLs under nested sandboxing.

## Verification

Driven end-to-end against a real, isolated orchestrator (fresh `HOME`,
scratch repo):
- ✅ CODEX picker group renders; GPT-5.5 selectable & persists; invalid
model rejected (400).
- ✅ A real turn ran on Codex — tool calls + assistant messages render in
order; file created.
- ✅ `suggest_prompts` MCP tool round-trips → suggestion parked in the
thread queue.
- ✅ Resume carries context across turns.
- ✅ Signed-out CLI → `codex-auth` recovery card.
- ✅ Unavailable → Codex option `disabled=True` + route 400; available →
`disabled=False` + 200.
- ✅ **226 desktop tests pass**; both tsconfigs typecheck; UI builds.

## Notes / follow-ups

- Adds `@openai/codex-sdk` (which pulls the platform `@openai/codex`
binary in dev).
- In dev, `@openai/codex` is in `node_modules` so the SDK self-resolves;
a packaged build with no installed codex → the disabled flow applies.
- `scripts/codex-smoke.ts` added as a dev harness (mirrors
`claude-smoke.ts`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The `web` service runs a flat 6 instances 24/7 with no autoscaling, so the
predictable daily load peak (12-17Z) drives event-loop utilization to ~0.75
avg / 0.93 max and multi-second ingress queueing. Add a scheduled instance-floor
scaler that scales the fleet out across the day.

- scripts/ops/scale-web.ts: POSTs numInstances for the current UTC hour on a
  curve (peak 8), via Render's manual scale API.
- scripts/ops/derive-scale-curve.ts: reproducibly re-derives the curve from
  Axiom (util↔load calibration × multi-day load shape; weekday/weekend split).
- .github/workflows/web-peak-scale.yml: runs it hourly (idempotent).
- scripts/logs/web-health.ts: reusable web saturation probe.
- docs/web-scaling.md: strategy, curve derivation, caveats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jahooma

jahooma commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Opened against the public mirror by mistake (wrong remote). Re-opening against CodebuffAI/freebuff-private.

@jahooma jahooma closed this Jul 5, 2026
@jahooma jahooma deleted the freebuff/can-you-investigate-the-health-of-our-web-backend--thmr6ypfpl05oz branch July 5, 2026 00:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants