-
Notifications
You must be signed in to change notification settings - Fork 12
systems server manager
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.
Ran, Nik
ServerManager is an ObservableObject that wraps the lifecycle of the bundled cli-proxy-api binary. Its responsibilities are:
- Launching the backend as a child
Processbound 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.yamlfrom the bundledconfig.yamltemplate 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-apiprocesses 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.
| 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). |
| 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. |
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()
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.
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.
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
terminationHandlerpostsauthDirectoryChanged(after a 0.5s delay) only when the exit code is0, 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.
getConfigPath() reads the bundled config.yaml, applies string-anchor replacements, and writes ~/.cli-proxy-api/merged-config.yaml:
-
host: 127.0.0.1→host: <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: false→debug: <verboseLogging>. -
logging-to-file: false→logging-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)]
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.
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().
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.
-
AppDelegate(src/Sources/AppDelegate.swift) owns theServerManagerinstance and supervises its lifecycle, starting the backend after the proxy is ready and stopping it on quit. It observesserverStatusChanged. -
SettingsView(src/Sources/SettingsView.swift) callssetProviderEnabled/getConfigPathwhen provider toggles change, andrunAuthCommandto start each provider's OAuth login flow. -
ThinkingProxy(src/Sources/ThinkingProxy.swift) forwards client requests to the backend thatServerManagerruns on127.0.0.1:8318. -
AppPreferencessupplies the values merged into the config (allowRemote,secretKey,bindAddress,verboseLogging).
-
Change start/stop timing (readiness delay, grace period, poll interval): edit the
Timingconstants. -
Add a new login flow: add a case to
AuthCommandwith itsloginFlag, and callrunAuthCommandfrom the UI. -
Add a new merged-config setting: add a string-anchor replacement in
getConfigPath()matching an anchor present in the bundledconfig.yaml, and read the value fromAppPreferences. -
Change provider exclusion behavior: edit
oauthProviderKeysand theoauth-excluded-modelsblock ingetConfigPath(). -
Change log retention: edit
maxLogLines(theRingBuffercapacity).
| 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. |