Skip to content

systems server manager

Nik edited this page May 30, 2026 · 2 revisions

Backend supervisor

ServerManager (src/Sources/ServerManager.swift) is the component that owns the bundled cli-proxy-api backend process. It starts and stops the backend, captures its log output, generates the merged config the backend reads, drives the OAuth login flows, and cleans up orphaned processes left behind by crashes.

Active contributors

Ran, Nik

Purpose

ServerManager is an ObservableObject that wraps the lifecycle of the bundled cli-proxy-api binary. Its responsibilities are:

  • Launching the backend as a child Process bound to a generated config file, and stopping it cleanly (SIGTERM, then SIGKILL after a grace period).
  • Capturing the backend's stdout/stderr into a fixed-size in-memory log buffer.
  • Generating ~/.cli-proxy-api/merged-config.yaml from the bundled config.yaml template plus user preferences and provider exclusions.
  • Running the per-provider OAuth login commands (-claude-login, -codex-login, -antigravity-login, -kimi-login).
  • Tracking which providers are enabled and reflecting that into the config without a restart.
  • Killing orphaned cli-proxy-api processes from previous crashes.

The user-facing TCP proxy lives in a separate component; see Thinking proxy. ServerManager only manages the backend on 127.0.0.1:8318.

Directory layout

Path Role
src/Sources/ServerManager.swift The supervisor itself, plus the private RingBuffer and the AuthCommand enum.
src/Sources/Resources/config.yaml Bundled backend config template that is merged at runtime.
src/Sources/Resources/cli-proxy-api The bundled backend binary that ServerManager launches.
src/Sources/AppPreferences.swift UserDefaults-backed preferences read during config merge (allowRemote, secretKey, bindAddress, verboseLogging).
~/.cli-proxy-api/merged-config.yaml Generated merged config the backend actually reads (written 0o600).

Key abstractions

Abstraction Description
RingBuffer<Element> Private fixed-capacity circular buffer used for the log buffer. Capacity is maxLogLines (1000). When full, the oldest line is overwritten as new lines are appended. elements() returns the lines oldest-to-newest.
process The Process? handle for the running backend. nil when stopped.
isRunning @Published flag observed by the UI; updated on the main thread.
enabledProviders @Published [String: Bool] keyed by ServiceType.rawValue. A didSet persists it to UserDefaults under "enabledProviders".
oauthProviderKeys Static [ServiceType: String] mapping each provider to its config key (claude, codex, antigravity, kimi, cursor) used when writing oauth-excluded-models.
Timing Private constants: readinessCheckDelay (1.0s), gracefulTerminationTimeout (2.0s), terminationPollInterval (0.05s).
AuthCommand Enum (claudeLogin, codexLogin, antigravityLogin, kimiLogin) whose loginFlag maps to the CLI flag passed to the backend for that login flow.
onLogUpdate Optional callback invoked with the current log lines whenever a new line is appended.

How it works

Starting and stopping

start(completion:) runs its work on the dedicated serial processQueue (off the main thread) because the orphan cleanup shells out to pgrep/pkill and may Thread.sleep when it finds a stale backend. It first calls killOrphanedProcesses(), resolves bundledBinaryPath(), and calls getConfigPath() to produce the merged config. It then launches the backend with -config <merged-config>, wiring stdout and stderr through Pipe readability handlers into addLog. The completion handler is dispatched back to the main thread. The process terminationHandler clears the pipe handlers (to avoid retain cycles on the file handles), sets isRunning = false, logs the exit code, and posts serverStatusChanged. After launch, it waits readinessCheckDelay (1.0s) before checking the process is still running and reporting success through the completion handler.

stop(completion:) runs on a dedicated serial queue (processQueue). It sends SIGTERM via process.terminate(), polls every terminationPollInterval (0.05s) up to gracefulTerminationTimeout (2.0s), and if the process is still alive sends SIGKILL via kill(pid, SIGKILL). It then waitUntilExit(), clears process, sets isRunning = false, logs, and posts serverStatusChanged on the main thread.

sequenceDiagram
    participant A as AppDelegate
    participant SM as ServerManager
    participant CFG as getConfigPath()
    participant P as cli-proxy-api

    A->>SM: start(completion:)
    SM->>SM: killOrphanedProcesses()
    SM->>SM: bundledBinaryPath()
    SM->>CFG: merge config.yaml + prefs + exclusions
    CFG-->>SM: ~/.cli-proxy-api/merged-config.yaml
    SM->>P: Process.run() -config <merged>
    SM->>SM: isRunning = true
    Note over SM: wait readinessCheckDelay (1.0s)
    SM->>P: still running?
    P-->>SM: yes
    SM-->>A: completion(true), post serverStatusChanged
    Note over SM,P: later: stop()
    A->>SM: stop(completion:)
    SM->>P: SIGTERM (terminate)
    Note over SM: poll up to 2.0s grace
    alt still running
        SM->>P: SIGKILL
    end
    SM->>SM: process = nil, isRunning = false
    SM-->>A: post serverStatusChanged, completion()
Loading

Resolving the binary

bundledBinaryPath() returns Bundle.main.resourcePath joined with cli-proxy-api, or nil if the resource directory or binary is missing. Both start and runAuthCommand use it.

Capturing logs

addLog(_:) runs on the main thread, prefixes each message with a localized medium-style timestamp, appends to the RingBuffer, and invokes onLogUpdate with the current line list. getLogs() returns the current buffer contents. Lifecycle events (✓ Server started, Server stopped with code:, orphan cleanup) are all logged this way.

Running OAuth login commands

runAuthCommand(_:completion:) launches the bundled binary with --config <bundled config.yaml> plus the command's loginFlag. The auth flow deliberately uses the bundled (unmerged) config because user overrides aren't needed for login itself. It wires stdout/stderr/stdin pipes and the current process environment.

  • For .codexLogin, a background timer fires after 12s and writes a newline to the process's stdin if it is still running, to keep the Codex login waiting for its browser callback instead of blocking on the manual prompt.
  • The process terminationHandler posts authDirectoryChanged (after a 0.5s delay) only when the exit code is 0, so the UI refreshes once the credential file is written.
  • Success detection is heuristic: after a 1.0s wait, if the process is still running it is treated as success (the browser is open). If it has already exited, stdout is inspected — "Opening browser" or "Attempting to open URL" is treated as success; otherwise the stderr/stdout text (or a generic failure message) is returned as an error.

Generating the merged config

getConfigPath() reads the bundled config.yaml, applies string-anchor replacements, and writes ~/.cli-proxy-api/merged-config.yaml:

  • host: 127.0.0.1host: <bindAddress> (replaces only the first anchor; logs a warning if the anchor is missing so silent config drift is visible).
  • allow-remote: false allow-remote: <allowRemote>.
  • secret-key: "" # Leave empty to disable management API secret-key: "<secretKey>".
  • debug: falsedebug: <verboseLogging>.
  • logging-to-file: falselogging-to-file: <verboseLogging>.

It then appends an oauth-excluded-models: block for any provider that is toggled off, listing each disabled provider's config key with a "*" wildcard. The merged file is written atomically and chmoded to 0o600. If the write fails it falls back to returning the bundled config path.

graph TD
    A[Bundled config.yaml] --> B[Read template]
    B --> C[Replace host anchor with bindAddress]
    C --> D[Replace allow-remote / secret-key]
    D --> E[Replace debug / logging-to-file with verboseLogging]
    E --> F{Any provider disabled?}
    F -->|yes| G[Append oauth-excluded-models block]
    F -->|no| H[Skip]
    G --> I[Atomic write 0o600]
    H --> I
    I --> J[(~/.cli-proxy-api/merged-config.yaml)]
Loading

Provider enable/disable

isProviderEnabled(_:) returns enabledProviders[serviceType.rawValue], defaulting to true when unset. setProviderEnabled(_:enabled:) updates the dictionary (persisted via the didSet), logs the change, and calls getConfigPath() to regenerate the merged config. Because cli-proxy-api hot-reloads its config file, toggling a provider does not require a restart.

Killing orphaned processes

killOrphanedProcesses() sweeps each name in orphanProcessNames (cli-proxy-api plus the legacy cli-proxy-api-plus, so an orphan from a pre-migration build can't keep holding port 8318). For each it runs /usr/bin/pgrep -x <name> — matching the exact process name rather than -f against the full command line, because a -f cli-proxy-api pattern would also match unrelated processes that merely reference ~/.cli-proxy-api/ (e.g. tail -f ~/.cli-proxy-api/logs/...). If pgrep exits 0 (matches found), it logs the PIDs and runs /usr/bin/pkill -9 -x <name>, then sleeps 0.5s for cleanup. Exit code 1 (no matches) is treated as normal and not logged. It is called on start, and from deinit alongside stop().

Bundled config defaults

The bundled src/Sources/Resources/config.yaml template sets the backend's defaults, including port: 8318, host: 127.0.0.1, disable-cooling: true, routing.strategy: "round-robin" with session-affinity: true, the quota-exceeded switches, and request-timeout: "10m". These values and their rationale are documented in Configuration.

Integration points

  • AppDelegate (src/Sources/AppDelegate.swift) owns the ServerManager instance and supervises its lifecycle, starting the backend after the proxy is ready and stopping it on quit. It observes serverStatusChanged.
  • SettingsView (src/Sources/SettingsView.swift) calls setProviderEnabled / getConfigPath when provider toggles change, and runAuthCommand to start each provider's OAuth login flow.
  • ThinkingProxy (src/Sources/ThinkingProxy.swift) forwards client requests to the backend that ServerManager runs on 127.0.0.1:8318.
  • AppPreferences supplies the values merged into the config (allowRemote, secretKey, bindAddress, verboseLogging).

Entry points for modification

  • Change start/stop timing (readiness delay, grace period, poll interval): edit the Timing constants.
  • Add a new login flow: add a case to AuthCommand with its loginFlag, and call runAuthCommand from the UI.
  • Add a new merged-config setting: add a string-anchor replacement in getConfigPath() matching an anchor present in the bundled config.yaml, and read the value from AppPreferences.
  • Change provider exclusion behavior: edit oauthProviderKeys and the oauth-excluded-models block in getConfigPath().
  • Change log retention: edit maxLogLines (the RingBuffer capacity).

Key source files

File Role
src/Sources/ServerManager.swift The supervisor, RingBuffer, and AuthCommand.
src/Sources/Resources/config.yaml Bundled backend config template merged at runtime.
src/Sources/AppPreferences.swift Preferences read during the config merge.
src/Sources/AuthPaths.swift Source of truth for the auth directory location.
src/Sources/NotificationNames.swift serverStatusChanged / authDirectoryChanged constants posted by ServerManager.

Related pages

Clone this wiki locally