Provider Adapters
ProviderAdapter Capability Model
Each provider adapter declares its capabilities through a structured interface that determines how the pam exec run wrapper interacts with the provider CLI.
Capability Declaration
Binary resolution: bin field specifies the executable name. Resolved via which <bin> at runtime. If not found, provider is marked unavailable (not fatal error).
Argument template: argv_template is a list of strings with substitution placeholders:
{args}: User-provided command-line arguments{prompt}: Single prompt string (for completion-style APIs){api_key}: API key from environment or secure storage{model}: Model identifier (e.g.,claude-3-5-sonnet-20241022)
Example: ["agent", "serve", "--bind", "127.0.0.1:0", "--secret", "{api_key}"]
Environment normalization: env_map defines how provider-specific environment variables are normalized. Includes proxy base URLs, API keys, and provider-specific settings.
TUI characteristics: Describes terminal behavior for capture interpretation:
line_oriented: Output is line-buffered (e.g., many CLI tools)full_screen: Uses ncurses/termcap (e.g., interactive TUI)raw_mode: Direct character I/O (e.g., password prompts)
This affects how pane text is rendered and whether we apply special handling for escape sequences.
Completion signals: Declares which completion mechanisms the adapter supports:
native_hook: Provider has built-in webhooks or callback mechanismexit_code: CLI exit code indicates success/failureidle_detection: No activity for threshold indicates completion (unreliable, hint only)
Hooks config generation: Template for generating provider-specific hook configuration files (e.g., .anthropic/hooks.json for Claude, .gemini/config.toml for Gemini CLI).
Capability Interface
#![allow(unused)]
fn main() {
pub struct ProviderAdapter {
pub id: String,
pub bin: String,
pub argv_template: Vec<String>,
pub env_map: HashMap<String, String>, // pam var → provider env var
pub tui_type: TuiType,
pub completion_signals: Vec<CompletionSignal>,
pub hooks_config_template: Option<String>,
}
pub enum TuiType {
LineOriented,
FullScreen,
RawMode,
}
pub enum CompletionSignal {
NativeHook,
ExitCode,
IdleDetection,
}
}
Completion-Signal Ladder
Completion detection uses a trust ladder with multiple rungs. Each rung provides increasing confidence that a task has truly completed.
The 4 Rungs
Signal Trust Hierarchy
1. Provider-native hooks (PRIMARY for task-level completion)
- Provider’s own completion mechanism posts to
$PAM_API_URL/api/internal/runs/{id}/signals— the central orchestrator’s URL, injected by the wrapper at spawn time (the hook runs on the worker host but reports to the hub) - Indicates the provider confirms task completion from its perspective
- Emits
run.signal_receivedevent; no run status change - Optionally transitions work_item to
done(config-gated, default off) - Most reliable signal when available
2. Wrapper journal (authoritative for session-level exit)
- The
pam exec runwrapper postsrun.finishedevent with exit code - Indicates the CLI process exited, but not whether it completed its work
- Exit code 0 → run transitions to
exited - Exit code non-zero → run transitions to
failed
3. Manual mark (lowest trust, highest override authority)
- User explicitly marks task as done via UI or CLI
- User provides confidence level: “certain”, “likely”, “unsure”
- Transitions work_item to
done(not run status) - Always overrides other signals
4. Pane-idle heuristic (UI hint ONLY, never transitions status)
- No output in pane for threshold time (default 10 minutes)
- Used only for UI display: “appears idle, may be complete”
- Never used for status transitions (too many false positives)
Normalization into RunSignal Enum
All completion signals normalize into a single RunSignal enum:
#![allow(unused)]
fn main() {
pub enum RunSignal {
Started { attempt: u32, timestamp: DateTime<Utc> },
Finished { attempt: u32, exit_code: i32, timestamp: DateTime<Utc> },
Completed { attempt: u32, result: TaskResult, timestamp: DateTime<Utc> },
Failed { attempt: u32, error: String, timestamp: DateTime<Utc> },
Progress { attempt: u32, seq: i64, message: String, percent: Option<u8> },
}
pub enum TaskResult {
Success,
PartialSuccess { reason: String },
UserRejected,
}
}
Signal sources post to /api/internal/runs/{id}/signals endpoint (worker-token authenticated — callers live on worker hosts):
- Wrapper posts
StartedandFinished - Provider hooks post
Completed,Failed, orProgress
Attempt and sequence fields (wire contract: 030-http-api.md):
- Every variant carries
attempt— the run’s incarnation epoch (runs.attempt). It is allocated centrally at dispatch time (incremented on each restart-policy requeue), injected into the spawn environment, and stamped by the emitting wrapper at emission time; replayed spool entries keep their original numbers — the worker never renumbers at replay. Progressadditionally carriesseq(int64, monotonic within(run_id, attempt)): it is the one repeated, dedup-exempt kind. The one-shot kinds (Started,Finished,Completed,Failed) carry noseq— their exactly-once semantics come from the centralprocessed_signalsdedup table instead.
Manual signals do not use this endpoint: user-initiated actions (run stop, mark-done) arrive over the public API with the user bearer token (e.g., POST /runs/{id}/stop); the orchestrator normalizes them internally into signals with source: "manual". Users never hold worker tokens.
Signal → Event Topic Mapping
Each accepted signal journals exactly one event topic. The topic vocabulary itself is owned by 002-data-model.md §Event Topics — this table only states which signal produces which topic.
run.finished and run.failed do not overlap (normative). run.finished means a wrapper exited — the process ran and reported its exit code, whatever that code was. run.failed is reserved for the two cases where no wrapper exit exists to report: spawn failure, and a provider hook reporting Failed. One rule, no ambiguity: a consumer counting terminal events never sees both topics for the same (run_id, attempt).
Status Transition Rules
Note (ingest guard — single source of truth): this table describes the status transitions themselves. Every signal reaches it only after passing the central ingest guard at the signal-ingest edge — epoch check, dedup, and (attempt, seq) ordering, strictly upstream of both the status write and the event journal / SSE emission. The guard’s normative specification lives in 030-http-api.md (Internal Endpoints → Provider Hook Receiver); a signal dropped there produces no state change and no event.
Note: Work item completion (transition to done) is managed separately via work_item.status_changed events and configuration-gated transitions.
Work Item Auto-Transition Configuration
When a provider hook emits Completed signal, the central orchestrator may optionally auto-transition the associated work item to done status (not the run status—run status stays unchanged with run.signal_received emitted only).
Configuration: auto_mark_done: bool (default: false)
- false: Require manual mark for work_item →
donetransition - true: Auto-transition work_item to
donewhenCompletedsignal received
This configuration is gated because provider hooks are task-level signals while run lifecycle is session-level.
Consequence of the default (deliberate, not a gap): with auto_mark_done: false, a run reaching a terminal state (exited, failed, stopped) does not move its work item — run status and work-item status are decoupled by default. A task typically remains in todo even after its run completes (nothing sets doing on run start either); work-item status is human-owned and moves only by manual action (pam task done, board drag, PATCH status).
Why Pane Scraping is Banned
Screen scraping the tmux pane output to detect completion is explicitly prohibited:
Lesson from grok-fleet-orchestrator ACP: Never treat a wire/screen format you don’t own as a contract. Terminal output is designed for humans, not machines. It can change format, include escape sequences, wrap unexpectedly, or include transient messages that look like completion but aren’t.
False positives: A CLI might output “Done!” as a progress indicator, not final completion. Or output might include “Error:” but then continue successfully.
Fragility: Provider CLI updates can change output format without notice, breaking scrapers.
Correct approach: Use provider-native hooks when available (the completion-signal ladder’s strongest rung). Fall back to manual confirmation, never screen scraping.
Provider Catalog
The provider catalog is a TOML configuration file that defines all available providers. This is NOT stored in the database; it’s runtime configuration that can be edited without database migrations.
Catalog Location
- Default path:
<platform data dir>/providers.toml(~/Library/Application Support/pam/providers.tomlon macOS,~/.local/share/pam/providers.tomlon Linux) - Path configurable via
catalog_pathin the main config file (see 040-cli.md); the main config file location itself is overridable viaPAM_CONFIG - Hot-reload on file change (the worker watches for modifications on its host)
TOML Format Example
# Provider catalog for project-agent-manager
# Each provider defines how pam interacts with a specific AI provider CLI
[providers.claude]
id = "claude"
name = "Anthropic Claude"
bin = "claude"
argv_template = [
"agent", "serve",
"--bind", "127.0.0.1:0",
"--secret", "{api_key}"
]
env_map = """
ANTHROPIC_API_KEY=ANTHROPIC_API_KEY
ANTHROPIC_BASE_URL=ANTHROPIC_BASE_URL
HTTPS_PROXY=HTTPS_PROXY
HTTP_PROXY=HTTP_PROXY
"""
tui_type = "full_screen"
completion_signals = ["native_hook", "exit_code"]
hooks_config_template = """
{
"post_completion_url": "{pam_api_url}/api/internal/runs/{run_id}/signals"
}
"""
installed_detection = ["--version"]
[providers.codex]
id = "codex"
name = "OpenAI Codex"
bin = "codex"
argv_template = [
"agent", "run",
"--model", "{model}",
"--api-key", "{api_key}",
"{args}"
]
env_map = """
OPENAI_API_KEY=OPENAI_API_KEY
OPENAI_BASE_URL=OPENAI_BASE_URL
HTTPS_PROXY=HTTPS_PROXY
HTTP_PROXY=HTTP_PROXY
"""
tui_type = "line_oriented"
completion_signals = ["exit_code"]
hooks_config_template = null
installed_detection = ["--version"]
[providers.grok]
id = "grok"
name = "xAI Grok"
bin = "grok"
argv_template = [
"agent", "serve",
"--bind", "127.0.0.1:0",
"--secret", "{api_key}"
]
env_map = """
GROK_API_KEY=GROK_API_KEY
OPENAI_BASE_URL=OPENAI_BASE_URL
ANTIGRAVITY_BASE_URL=ANTIGRAVITY_BASE_URL
ANTHROPIC_BASE_URL=ANTHROPIC_BASE_URL
FLEET_LLM_GATEWAY_URL=FLEET_LLM_GATEWAY_URL
HTTPS_PROXY=HTTPS_PROXY
HTTP_PROXY=HTTP_PROXY
"""
tui_type = "full_screen"
completion_signals = ["native_hook", "exit_code"]
hooks_config_template = """
{
"completion_webhook": "{pam_api_url}/api/internal/runs/{run_id}/signals"
}
"""
installed_detection = ["--version"]
[providers.gemini]
id = "gemini"
name = "Google Gemini"
bin = "gemini-cli"
argv_template = [
"chat",
"--model", "{model}",
"--api-key", "{api_key}",
"{args}"
]
env_map = """
GEMINI_API_KEY=GEMINI_API_KEY
GEMINI_BASE_URL=GEMINI_BASE_URL
GOOGLE_API_KEY=GOOGLE_API_KEY
HTTPS_PROXY=HTTPS_PROXY
HTTP_PROXY=HTTP_PROXY
"""
tui_type = "line_oriented"
completion_signals = ["exit_code"]
hooks_config_template = null
installed_detection = ["--version"]
# Optional: canonical Phase 4 scope is claude/codex/grok; gemini included only if installed
Runtime Presence Probing
pam provider doctor command checks provider availability:
# Check all providers
pam provider doctor
# Check specific provider
pam provider doctor claude
Detection method:
- Binary resolution:
which <bin> - Version check:
<bin> <installed_detection_args>(e.g.,claude --version) - Exit code 0 → provider
available - Exit code non-zero or binary not found → provider
unavailable
Degradation behavior: If a provider is unavailable:
- Tasks configured for that provider fail to spawn
- UI shows “Provider not available: run
pam provider doctor” - The host continues serving other providers
- No crash, just degraded functionality for that provider
Catalog Hot-Reload
The worker watches the provider catalog file for modifications on its host:
- On change: Reload catalog, validate TOML, update in-memory provider registry
- Existing runs: Continue with old provider config (no mid-run config changes)
- New runs: Use updated provider config
- Errors: Log validation errors, keep old config (don’t break on typo)
Proxy/Gateway Configuration
Proxy and gateway configuration allows provider CLIs to route traffic through a centrally-shared gateway (e.g., a single liteLLM instance reachable over the network, not a per-host local process — ADR-013) or corporate HTTP proxies.
Per-Provider Environment Injection
The pam exec run wrapper injects proxy-related environment variables BEFORE spawning the provider CLI. This ensures:
- All provider CLI traffic goes through the configured gateway
- Proxy settings persist even during interactive attach (user manually attaching to tmux session)
- No need to configure proxy in multiple places
Injection point: Wrapper step (c) in the spawn path, after reading run config and provider catalog, before CLI spawn.
Environment Variable Mappings
Based on grok-fleet-orchestrator’s apply_llm_proxy_envs pattern, adapted for our providers:
Standard HTTP proxy variables (all providers):
HTTPS_PROXY: HTTPS proxy URLHTTP_PROXY: HTTP proxy URLNO_PROXY: Comma-separated bypass list
Uncertain variables (verify at implementation):
GROK_API_KEY: May use different env var name (check docs)ANTIGRAVITY_BASE_URL: Verify if grok CLI uses this or different varGOOGLE_API_KEY: Check if gemini-cli uses this or justGEMINI_API_KEY
Gateway URL Format
When a gateway is configured (e.g., a shared liteLLM instance at a
network-reachable address, not localhost), the wrapper normalizes base
URLs:
# For OpenAI-compatible gateways (codex, grok)
OPENAI_BASE_URL=https://gateway.internal:8080/v1
# For Anthropic (claude)
ANTHROPIC_BASE_URL=https://gateway.internal:8080/v1
# For Gemini (gemini)
GEMINI_BASE_URL=https://gateway.internal:8080
The apply_llm_proxy_envs function from grok-fleet-orchestrator shows the pattern:
#![allow(unused)]
fn main() {
let url_trimmed = url.trim_end_matches('/');
cmd.env("OPENAI_BASE_URL", format!("{url_trimmed}/v1"));
cmd.env("ANTHROPIC_BASE_URL", url_trimmed);
cmd.env("GEMINI_BASE_URL", url_trimmed);
}
Configuration Source
Gateway configuration is NOT in the provider catalog (too user-specific). Instead:
Priority order (highest to lowest):
- User config file:
<platform data dir>/config.toml(macOS:~/Library/Application Support/pam/; Linux:~/.local/share/pam/)[gateway] url = "https://gateway.internal:8080" api_key = "sk-gateway-key" - Environment variables:
PAM_GATEWAY_URL,PAM_GATEWAY_API_KEY - Provider-specific env vars set by user:
ANTHROPIC_BASE_URL, etc.
Core CLI settings (PAM_API_URL, PAM_TOKEN, etc.) now follow the same
config-file-first rule — see
060-configuration.md § Precedence Order
(resolves OQ-16,
071-risks-and-open-questions.md).
Gateway Operations Scope
IN SCOPE for pam:
- Injecting proxy/gateway base URLs into provider CLI environment
- Passing through HTTP(S)_PROXY variables
- Ensuring all CLI traffic respects proxy settings
OUT OF SCOPE:
- Managing the gateway lifecycle (liteLLM, custom gateway start/stop)
- Gateway health checks, load balancing, or failover
- Gateway authentication beyond passing through API keys
- Multi-gateway routing or A/B testing
pam’s role is to point CLIs at a gateway, not to operate the gateway itself.
Interactive Attach Considerations
Important: Because proxy injection happens in the pam exec run wrapper BEFORE the CLI spawn, the settings persist even when a user attaches interactively to the tmux session.
- User attaches:
tmux -L pam attach -t pam-<short_run_id> - Provider CLI is already running with proxy env vars set
- All interactive CLI commands continue using the gateway
- No need for user to configure proxy in their shell
This is a key advantage over post-attach proxy configuration.
Per-Provider Capability Matrix
Legend:
- ✅ Confirmed from grok-fleet-orchestrator code or common knowledge
- ⚠️ Plausible but uncertain (verify at implementation)
- ❌ Known limitation or missing capability
Verification needed at implementation:
- Grok’s exact base URL env var (may be different from
ANTIGRAVITY_BASE_URL) - Gemini CLI’s existence and configuration format
- Codex’s webhook/hook support (if any)
Cross-References
- tmux Executor: Spawn path, environment injection point, observation
- Data Model: providers table vs catalog file decision
- HTTP API Contract:
/api/internal/runs/{id}/signalsendpoint schema - Prior art:
apply_llm_proxy_envspattern from the grok-fleet-orchestrator sibling repository (external reference, not part of this repo)