Build the canonical identity from the stat getCanonicalFile already
takes, instead of a second lstat of the same inode. One syscall less,
and mode, mtime and identity now describe the same moment rather than
two adjacent ones.
Assert in the adoption race tests that the foreign writer never fired.
It writes only when the canonical path is observed empty, so a zero
count states the invariant the fix establishes - the path is never left
without a regular file - instead of only checking the final content.
Built [OnSteroids](https://onsteroids.ai)
Adoption moved the canonical settings.json aside with rename() and left
the path empty until publication, roughly 100 ms later. Claude Code or a
second `ccs` starting inside that window found no file and seeded an
empty placeholder; publication then failed with EEXIST because link() is
no-replace, and the rollback published a backup and unlinked the claim,
destroying the only remaining copy of the user's settings. Recovering
meant digging through sidecar files by hand.
Publish by replacement instead: write a temp file next to the canonical
inode and rename() it over the target, so the path always holds a regular
file and no placeholder can be seeded. A compare-and-swap guard on
(ino, mtime, size) runs immediately before the rename and refuses to
publish when the canonical inode changed since it was read, so a writer
that got there first is still never clobbered. The pre-image backup is
published before the replacement, keeping the old content recoverable if
publication is interrupted.
Drops the canonical claim entirely along with restoreCanonicalClaim, and
folds the two identical sidecar publishers into one helper.
recoverOrphanedCanonicalClaim stays, since claims written by older
versions may still be on disk.
New tests cover both writers seen in the incident: Claude Code seeding
`{}` with a trailing newline, and a second `ccs` seeding the 2-byte
variant from shared-dir-linker. Four tests that pinned the claim-based
design were rewritten, among them `preserves a canonical write that
lands during no-replace publication`, whose intent is now enforced by
the CAS guard instead of by an EEXIST from a no-replace link.
Built [OnSteroids](https://onsteroids.ai)
resolveImageAnalysisRuntimeStatus defaulted to DEFAULT_IMAGE_ANALYSIS_CONFIG
when callers omitted the config argument. That constant ships empty
profile_backends and a gemini fallback_backend, so the launch paths that call
it without a config (settings profile dispatch and headless delegation) never
saw user-configured mappings.
A profile mapped to another backend still resolved to gemini, failed the
Gemini auth check, and silently dropped to native Read. Profiles whose model
has no vision support could not read images at all, even with a reachable
CLIProxy and an authenticated backend.
getImageAnalysisHookEnv already reads the saved config, so the launch env and
the runtime status disagreed on the same launch: CCS_IMAGE_ANALYSIS_BACKEND_ID
carried the mapped backend while the status object reported native-read.
Default to getImageAnalysisConfig() so both read the same source. Callers that
pass an explicit config keep their existing behavior.
Co-Authored-By: Claude <noreply@anthropic.com>
Add a named 'orcarouter' preset to the shared provider catalog mirroring
the existing OpenRouter entry: OpenAI-compatible base URL
https://api.orcarouter.ai/v1, default model openai/gpt-5.5, sk-orca-...
key placeholder, and a dashboard icon. Regenerate the throw-error
baseline for the line shift in provider-preset-catalog.ts.
Co-Authored-By: Claude <noreply@anthropic.com>
`ccs bar stop` deletes bar.json, so a bar.json-only sticky port is lost on
every stop/start cycle: the next launch reverted to 3000 and the probe could
no longer find a server still running on the previously chosen port.
resolveBarPort now falls back to the --port recorded in launch.json (written
by launch, not deleted by stop), which restores both the sticky port and
probe discovery after a stop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cached row whose next_reset has passed describes the previous quota
window — the quota snapped back at the boundary, so serving it for the rest
of the 10-min TTL shows wrong percentages and an already-elapsed reset time
in the bar. Both the per-profile TTL short-circuit and the rotating-slot
eligibility now mark such rows stale. Guarded by cachedAt < resetAt so a
post-reset payload that still reports a past reset cannot refetch-loop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the two collector-side root causes of #1601:
1. Claude per-profile credential reads were file-only, but on macOS Claude
Code stores the OAuth token for an isolated CLAUDE_CONFIG_DIR in a
per-directory Keychain item ("Claude Code-credentials-<sha256(dir)[0..8]>").
The .credentials.json file never exists, so every isolated profile was
parked with needsReauth:true forever. The reader now falls back to that
Keychain item (file-first, same security-CLI read the shipped global
fallback already performs; TTL-gated so it is not on every /summary).
2. Non-default profiles were cache-only forever, so they could never leave
the parked state even with valid credentials. getNativeAccountRows now
gives each surface ONE rotating live slot: the stalest eligible
non-default profile is refreshed per pass, skipping profiles inside
breaker/reauth cooldowns. Every account converges to real quota within a
few polls while the per-pass upstream budget stays constant (<= 2 calls
per surface regardless of profile count). Codex named profiles with valid
auth but sparse payloads now yield an active quota-less row instead of a
false needsReauth row.
Non-default rows keep paused:true (dimmed) even when freshly refreshed so
only the default renders active and rows do not flicker between polls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ccs bar always forced the dashboard onto port 3000 (first free of a
hardcoded candidate list), which collides with other local dev servers, and
bar.json was rewritten to 3000 on every launch.
- `ccs bar [launch] --port N` runs the server on exactly N: reuses a live
server already on N, moves a running server from another port (SIGTERM via
server.pid, wait for exit), errors clearly when N is busy or the value is
invalid.
- The chosen port is persisted into launch.json args, so the Swift app
self-starts the server on the same port.
- Without --port, launch and serve now try the port recorded in bar.json
first (sticky), so the server keeps coming back on the port the user last
chose instead of reverting to 3000.
- Bare flags (`ccs bar --port N`) route to the launch subcommand; --port is
documented in `ccs bar --help`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude Code sends the system prompt as the top-level `system` field and,
separately, sends skill/plugin listings as `role: "system"` entries inside
`messages` (#1459 made the transformer accept those). `transform()`
unconditionally prepends the top-level field, so once both are present the
OpenAI-compat payload ends up with two `system` messages that are not
adjacent. `coalesceMessages` only merges consecutive same-role messages and
explicitly skips `system`, so it cannot fix this.
Strict OpenAI-compatible backends (LiteLLM among them) reject that shape with:
400 A 'system' message can only appear at index 0 of the messages array.
Add `hoistSystemMessages`, run before `coalesceMessages`, which extracts every
`system` message in encounter order and reinserts a single merged one at
index 0. Content-preserving, no behavior change when at most one system
message is present.
The same atomic-rename divergence occurs in the plugins subtree: a
plugin install inside a session rewrites plugins/installed_plugins.json,
replacing the instance-level symlink with a regular file. The per-launch
relink then discarded it, so the plugin was effectively uninstalled on
every relaunch while settings.json still marked it enabled — sessions
then fail with 'Unknown skill: <plugin>:<skill>'.
Move adoptDivergedFileContent to fs-helpers (avoids a circular import)
and apply it to file-type plugin entries before re-linking.
Claude Code saves settings.json atomically (temp file + rename), which
replaces the managed shared symlink with a regular file holding the
user's latest changes (see #57). The launch-time relink then deleted
that file without reading it, silently reverting plugin enables and any
other in-session settings change on every profile relaunch.
Adopt the diverged file's content into the canonical ~/.claude file
(with a .bak-ccs-adopt backup) before restoring the symlink, at both
the shared-level and instance-level reconciliation points.
Fixes#1681