Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 mechanism
  • exit_code: CLI exit code indicates success/failure
  • idle_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_received event; 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 run wrapper posts run.finished event 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 Started and Finished
  • Provider hooks post Completed, Failed, or Progress

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.
  • Progress additionally carries seq (int64, monotonic within (run_id, attempt)): it is the one repeated, dedup-exempt kind. The one-shot kinds (Started, Finished, Completed, Failed) carry no seq — their exactly-once semantics come from the central processed_signals dedup 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 → done transition
  • true: Auto-transition work_item to done when Completed signal 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.toml on macOS, ~/.local/share/pam/providers.toml on Linux)
  • Path configurable via catalog_path in the main config file (see 040-cli.md); the main config file location itself is overridable via PAM_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:

  1. Binary resolution: which <bin>
  2. Version check: <bin> <installed_detection_args> (e.g., claude --version)
  3. Exit code 0 → provider available
  4. 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 URL
  • HTTP_PROXY: HTTP proxy URL
  • NO_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 var
  • GOOGLE_API_KEY: Check if gemini-cli uses this or just GEMINI_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):

  1. 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"
    
  2. Environment variables: PAM_GATEWAY_URL, PAM_GATEWAY_API_KEY
  3. 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}/signals endpoint schema
  • Prior art: apply_llm_proxy_envs pattern from the grok-fleet-orchestrator sibling repository (external reference, not part of this repo)