scitex_agent_container API Reference

Top-level package surface re-exported from scitex_agent_container. Use scitex-agent-container list-python-apis for the authoritative runtime enumeration.

SciTeX Agent Container – Declarative agent management.

Provides a YAML-based framework for defining, managing, and orchestrating AI coding agent instances across container runtimes.

Public surface — CLI-tree-shaped noun submodules:

import scitex_agent_container as sac

sac.agent.list()                  # `sac agent list`
sac.agent.start("head-nas")       # `sac agent start head-nas`
sac.store.clean()                    # `sac store clean`
sac.host.list()                   # `sac host list`
sac.skills.get("02_quick-start")  # `sac dev skills get 02_quick-start`

Each noun submodule (agent, store, host, image, template, account, skills, mcp) re-exports its verbs under bare names that mirror the CLI subcommand tree. The same function objects power both the Python API and the MCP server (per scitex MCP §6 parity).

Lifecycle helpers that take a shared Registry instance live at scitex_agent_container._lifecycle.lifecycle for callers that need them. The submodule verbs go through the CLI for JSON-friendly input/output.

Config

YAML config loading and validation for agent definitions.

Public API:

AgentConfig, load_config, validate_config, resolve_config ContainerSpec, ClaudeSpec, HealthSpec, WatchdogSpec, RestartSpec, SkillsSpec, StartupCommand

RemoteSpec and the spec.remote field were deleted in WI-6 (handoff §6, 2026-05-20). Cross-host placement is via spec.host; the old SSH-dispatch path is retired.

class scitex_agent_container.config.AgentConfig(name, runtime='tui', harness='anthropic', harness_key_is_legacy=False, engines=<factory>, engine_key='', reasoning_effort='', service_tier='', max_context_tokens=None, upstream_deadline_seconds=None, client_abandonment_seconds=None, subscription_provider='', subscription_account='', residency='resident', image='', model='sonnet', workdir='', python_venv='', env=<factory>, env_files=<factory>, screen_name='', labels=<factory>, container=<factory>, claude=<factory>, health=<factory>, watchdog=<factory>, restart=<factory>, autonomous=<factory>, hermes_background_review=False, hermes_failover=<factory>, hermes_yolo=False, hermes_run_budget_seconds=None, hermes_compression=<factory>, opencode_approval_policy='never', opencode_run_budget_seconds=None, opencode_serve_port=None, apptainer=<factory>, hooks=<factory>, listen=<factory>, extensions=<factory>, skills=<factory>, startup_commands=<factory>, startup_prompts=<factory>, exclude_hooks=<factory>, exclude_skills=<factory>, mcp_servers=<factory>, multiplexer='tmux', hosts_spec=<factory>, scheduling=<factory>, config_path='', mounts=<factory>, user='', a2a=<factory>, comms=<factory>, lineage=<factory>, delegation=<factory>, kind='Agent', proxy=None, to_home='./to_home', to_home_layers=None)[source]

Bases: object

Parsed agent configuration from a YAML definition file.

name: str
runtime: str = 'tui'
harness: Literal['anthropic', 'openai', 'codex', 'hermes'] = 'anthropic'
harness_key_is_legacy: bool = False
engines: dict[str, EngineSpec]
engine_key: str = ''
reasoning_effort: str = ''
service_tier: str = ''
max_context_tokens: int | None = None
upstream_deadline_seconds: int | None = None
client_abandonment_seconds: int | None = None
subscription_provider: str = ''
subscription_account: str = ''
residency: Literal['resident', 'one-shot'] = 'resident'
image: str = ''
model: str = 'sonnet'
workdir: str = ''
python_venv: str = ''
env: dict[str, str]
env_files: list[str]
screen_name: str = ''
labels: dict[str, str]
container: ContainerSpec
claude: ClaudeSpec
health: HealthSpec
watchdog: WatchdogSpec
restart: RestartSpec
autonomous: AutonomousSpec
hermes_background_review: bool = False
hermes_failover: HermesFailoverSpec
hermes_yolo: bool = False
hermes_run_budget_seconds: int | None = None
hermes_compression: HermesCompressionSpec
opencode_approval_policy: str = 'never'
opencode_run_budget_seconds: int | None = None
opencode_serve_port: int | None = None
apptainer: ApptainerSpec
hooks: dict[str, list[str]]
listen: list[ListenPort]
extensions: Dict[str, Any]
skills: SkillsSpec
startup_commands: list[StartupCommand]
startup_prompts: list[str]
exclude_hooks: list[str]
exclude_skills: list[str]
mcp_servers: dict[str, dict]
multiplexer: str = 'tmux'
hosts_spec: HostsSpec
scheduling: SchedulingSpec
config_path: str = ''
mounts: list[dict]
user: str = ''
a2a: A2ASpec
comms: CommsSpec
lineage: LineageSpec
delegation: DelegationSpec
kind: str = 'Agent'
proxy: Any = None
to_home: str = './to_home'
to_home_layers: list[str] | None = None
property expanded_workdir: str
__init__(name, runtime='tui', harness='anthropic', harness_key_is_legacy=False, engines=<factory>, engine_key='', reasoning_effort='', service_tier='', max_context_tokens=None, upstream_deadline_seconds=None, client_abandonment_seconds=None, subscription_provider='', subscription_account='', residency='resident', image='', model='sonnet', workdir='', python_venv='', env=<factory>, env_files=<factory>, screen_name='', labels=<factory>, container=<factory>, claude=<factory>, health=<factory>, watchdog=<factory>, restart=<factory>, autonomous=<factory>, hermes_background_review=False, hermes_failover=<factory>, hermes_yolo=False, hermes_run_budget_seconds=None, hermes_compression=<factory>, opencode_approval_policy='never', opencode_run_budget_seconds=None, opencode_serve_port=None, apptainer=<factory>, hooks=<factory>, listen=<factory>, extensions=<factory>, skills=<factory>, startup_commands=<factory>, startup_prompts=<factory>, exclude_hooks=<factory>, exclude_skills=<factory>, mcp_servers=<factory>, multiplexer='tmux', hosts_spec=<factory>, scheduling=<factory>, config_path='', mounts=<factory>, user='', a2a=<factory>, comms=<factory>, lineage=<factory>, delegation=<factory>, kind='Agent', proxy=None, to_home='./to_home', to_home_layers=None)
class scitex_agent_container.config.ClaudeSpec(model='', channels=<factory>, flags=<factory>, raw_options=<factory>, session='fresh', continue_max_age_minutes=None, resume_id='', auto_accept=True, account='', credentials_file='', credentials_files=<factory>, provider=None)[source]

Bases: object

model: str = ''
channels: list[str]
flags: list[str]
raw_options: dict
session: str = 'fresh'
continue_max_age_minutes: int | None = None
resume_id: str = ''
auto_accept: bool = True
account: str = ''
credentials_file: str = ''
credentials_files: list[str]
provider: ProviderSpec | None = None
__init__(model='', channels=<factory>, flags=<factory>, raw_options=<factory>, session='fresh', continue_max_age_minutes=None, resume_id='', auto_accept=True, account='', credentials_file='', credentials_files=<factory>, provider=None)
class scitex_agent_container.config.ContainerSpec(image='scitex-agent-container:latest', volumes=<factory>, network='host', mount_host_claude=False)[source]

Bases: object

image: str = 'scitex-agent-container:latest'
volumes: list[str]
network: str = 'host'
mount_host_claude: bool = False
__init__(image='scitex-agent-container:latest', volumes=<factory>, network='host', mount_host_claude=False)
class scitex_agent_container.config.DelegationSpec(max_concurrent_children=2, worktree_isolation=True)[source]

Bases: object

Bounds for child work created by an agent harness.

Permission remains in LineageSpec; this block only controls the width and filesystem isolation of work that permission allows.

max_concurrent_children: int = 2
worktree_isolation: bool = True
__init__(max_concurrent_children=2, worktree_isolation=True)
exception scitex_agent_container.config.EngineDefaultError[source]

Bases: EngineError

spec.engines does not name exactly one default entry.

exception scitex_agent_container.config.EngineError[source]

Bases: ValueError

Base class for spec.engines faults.

class scitex_agent_container.config.EngineSpec(key, harness=None, model='', provider=None, provider_declared=None, subscription_provider='', subscription_account='', reasoning_effort='', service_tier='', max_context_tokens=None, upstream_deadline_seconds=None, client_abandonment_seconds=None, env=<factory>, is_default=False)[source]

Bases: object

ONE named backend an agent may start on.

Every field is the single-backend surface’s own field, so an engine entry reads as “the harness + claude block I would have written, given a name”. provider_declared keeps the RAW YAML value beside the folded ProviderSpec because the two carry different information: the fold collapses both an unregistered name and the anthropic sentinel to None, and the refusal path has to tell those apart to say WHAT could not be honoured.

THE HARNESS FIELD IS NULLABLE, AND THAT NULL IS THE WHOLE HARNESS/ENGINE SPLIT. It used to default to DEFAULT_AGENT_HARNESS, which meant an entry that said nothing about the harness SAID ANTHROPIC — and apply_engine() then wrote that manufactured value over a spec that had declared harness: codex. One engine definition could therefore not serve a Claude-Code agent and a Codex agent, which is exactly what a fleet engine library has to do. None now means NO OPINION: the engine names the model endpoint, the spec names the program that runs the loop, and neither overwrites the other.

key: str
harness: str | None = None
model: str = ''
provider: ProviderSpec | None = None
provider_declared: Any = None
subscription_provider: str = ''
subscription_account: str = ''
reasoning_effort: str = ''
service_tier: str = ''
max_context_tokens: int | None = None
upstream_deadline_seconds: int | None = None
client_abandonment_seconds: int | None = None
env: dict[str, str]
is_default: bool = False
__init__(key, harness=None, model='', provider=None, provider_declared=None, subscription_provider='', subscription_account='', reasoning_effort='', service_tier='', max_context_tokens=None, upstream_deadline_seconds=None, client_abandonment_seconds=None, env=<factory>, is_default=False)
class scitex_agent_container.config.HealthSpec(enabled=False, interval=30, timeout=5, method='multiplexer-alive')[source]

Bases: object

enabled: bool = False
interval: int = 30
timeout: int = 5
method: str = 'multiplexer-alive'
__init__(enabled=False, interval=30, timeout=5, method='multiplexer-alive')
class scitex_agent_container.config.HookSpec(pre_start=<factory>, post_start=<factory>, pre_stop=<factory>, post_stop=<factory>, on_compact=<factory>, on_restart=<factory>, on_diff=<factory>)[source]

Bases: object

All hook points supported by the container.

Each entry is a list of opaque commands — shell strings or http(s) URLs. The container executes them fire-and-forget; errors are logged but never raised to the caller. Absent keys default to empty lists (feature disabled).

pre_start: list[str]
post_start: list[str]
pre_stop: list[str]
post_stop: list[str]
on_compact: list[str]
on_restart: list[str]
on_diff: list[str]
counts()[source]
Return type:

dict[str, int]

__init__(pre_start=<factory>, post_start=<factory>, pre_stop=<factory>, post_stop=<factory>, on_compact=<factory>, on_restart=<factory>, on_diff=<factory>)
class scitex_agent_container.config.HermesCompressionSpec(threshold=0.8, threshold_tokens=None, target_ratio=0.2, tail_mode='lean', in_place=True)[source]

Bases: object

Hermes compression controls emitted into its materialized profile.

threshold: float = 0.8
threshold_tokens: int | None = None
target_ratio: float = 0.2
tail_mode: str = 'lean'
in_place: bool = True
__init__(threshold=0.8, threshold_tokens=None, target_ratio=0.2, tail_mode='lean', in_place=True)
exception scitex_agent_container.config.HermesFleetBudgetError[source]

Bases: ValueError

A trustworthy shared-engine compaction budget cannot be derived.

class scitex_agent_container.config.HermesFleetContextBudget(threshold_tokens, usable_capacity_tokens, reserved_capacity_tokens, agent_count)[source]

Bases: object

A deterministic per-agent share of recently observed engine KV capacity.

threshold_tokens: int
usable_capacity_tokens: int
reserved_capacity_tokens: int
agent_count: int
__init__(threshold_tokens, usable_capacity_tokens, reserved_capacity_tokens, agent_count)
class scitex_agent_container.config.HostsSpec(host='', hosts=<factory>)[source]

Bases: object

Where an agent should run, in either singleton or multi-instance form.

Mutually exclusive — exactly one of host or hosts may be set:

  • host (singular) — exactly one instance runs:
    • empty / absent: local singleton (runs wherever sac is invoked)

    • string: pinned to that host

    • list: priority order; first available host wins (fallback chain)

  • hosts (plural) — multiple instances run, one per host:
    • “all”: one per fleet host (replaces the old per-host mode)

    • list of host names: one per listed host (subset)

Validator (in _validation.py) enforces mutual exclusion + types. Loader composes effective ids: hosts triggers the <name>-<HOST> suffix; host keeps the bare name.

host: str | list[str] = ''
hosts: str | list[str]
__init__(host='', hosts=<factory>)
class scitex_agent_container.config.ListenPort(port=0, proto='tcp', path='', name='', owner='')[source]

Bases: object

Declaration of a port/socket an external tool binds on behalf of an agent.

The container NEVER binds these — it just validates the shape and echoes them in status --json so orchestrators can see what sidecars are expected to exist. owner is free-form (e.g. "fleet-hub") to identify the plugin that actually listens.

port: int = 0
proto: str = 'tcp'
path: str = ''
name: str = ''
owner: str = ''
__init__(port=0, proto='tcp', path='', name='', owner='')
class scitex_agent_container.config.ProviderSpec(base_url='', auth_token_env='', allowed_tools=<factory>, extra_headers=<factory>, hermes_provider='')[source]

Bases: object

Vendor-agnostic backend override for the Claude SDK session.

When set under spec.claude.provider, the agent’s SDK session talks to an Anthropic-SDK-compatible backend OTHER than Anthropic (DeepSeek, a self-hosted gateway, etc.) using a never-expiring, login-free API key instead of Anthropic OAuth. Lets bulk fleet work run on a cheap backend without burning Max-plan quota.

The runtime injects three env vars into the container at start (see runtimes/_apptainer_provider.py):

  • ANTHROPIC_BASE_URL ← base_url

  • SAC_ANTHROPIC_API_KEY ← the host value of $<auth_token_env> (bridged to ANTHROPIC_API_KEY for the SDK by sac’s existing auth handoff). Fails loud at start if the env var is unset.

  • CLAUDE_CONFIG_DIR ← a clean per-agent dir — the conflict-breaker so the OAuth .credentials.json bind cannot win (apptainer --env is last-wins).

The model id is the provider’s own (e.g. deepseek-chat); the claude-* regex in config._validation is relaxed whenever a provider is declared.

Mutually exclusive with ClaudeSpec.account (OAuth pin) — an API-key backend needs no OAuth, so declaring both is a config error the runtime rejects loudly.

base_url: str = ''

Anthropic-compatible base URL, e.g. https://api.deepseek.com/anthropic. Required when the provider block is present.

auth_token_env: str = ''

NAME of the host env var holding the API key (e.g. DEEPSEEK_API_KEY) — NOT the key value. The operator sources the secret file; sac reads the env var’s value at start and never logs it. Required when the provider block is present.

__init__(base_url='', auth_token_env='', allowed_tools=<factory>, extra_headers=<factory>, hermes_provider='')
allowed_tools: list[str]

Whitelist of built-in Claude Code tools to REGISTER when this provider is active. Maps directly to ClaudeAgentOptions.tools (the SDK --tools <csv> knob, see claude_agent_sdk/_internal/transport/subprocess_cli.py:241-250), which controls the REGISTERED tool set rather than the use-allow list (--allowedTools / --disallowedTools only affect permission, not whether the tool’s JSON-schema ships in the outbound API request body — confirmed empirically by clew bm172 cohort v7 bind-test 2026-06-06).

Why this matters: an Anthropic-API shim (LiteLLM / vLLM / gateway) may not recognize newer Claude Code built-ins (ExitPlanMode, BashOutput, KillShell were added after LiteLLM 1.52.16). When the shim sees such a tool in the outbound tools[] it falls through its pydantic Union to the last subclass (AnthropicComputerTool), which requires display_width_px that the unknown tool’s payload doesn’t have → 422 on every API call → capsule errors all 60 turns. Whitelisting only the shim-recognized tools suppresses the unrecognized builtins at REGISTRATION, so they never enter the outbound body.

Empty list (the default) means “use the runner’s old-stable default whitelist when the provider is active, otherwise leave tools unset and let the CLI’s full default register” — the runner picks the safe shape automatically.

The string-form provider (e.g. provider: mimo) doesn’t carry this field today; the per-registry allowed_tools plumbing is a follow-up. Operators on the string form fall back to the runner’s old-stable default until the registry plumbing lands.

extra_headers: dict[str, str]

Non-secret HTTP headers forwarded by compatible harness adapters.

Header values may use SAC-owned identity templates such as ${sac:agent_id} and ${sac:session_id}. Credentials do not belong here; auth_token_env remains the only provider-secret source.

hermes_provider: str = ''

Native Hermes provider name (e.g. opencode-go), used ONLY by the hermes harness. When set, the hermes profile references Hermes’ own provider (its protocol, session handling and credential resolution) instead of a SAC-built custom OpenAI endpoint — required for relays that speak a non-OpenAI protocol or demand provider-managed session headers (opencode Go relay: x-opencode-session). base_url must be empty in this shape; auth_token_env still names the host env var holding the key, which Hermes resolves from the agent env.

class scitex_agent_container.config.ProxySpec(upstream='', trust='untrusted', redact=<factory>, timeout_s=30.0)[source]

Bases: object

Configuration for kind: AgentProxy agents.

upstream: str = ''

REQUIRED. Full URL to the upstream A2A AgentCard endpoint. Either an explicit .well-known path or a base URL (we’ll fetch <base>/.well-known/agent-card.json if a base is given).

trust: str = 'untrusted'

One of untrusted (default — operator must opt in to anything more permissive), local-mesh (peers on the same private network you control), trusted (cryptographically verified — reserved for future mTLS work). Surfaced on the AgentCard’s x-scitex-agent-container.trust field.

redact: list[str]

Substring tokens; any inbound text field containing one is refused with HTTP 400. Cheap defense-in-depth against accidentally forwarding secrets to an untrusted upstream — NOT a substitute for proper output filtering at the source.

__init__(upstream='', trust='untrusted', redact=<factory>, timeout_s=30.0)
timeout_s: float = 30.0

Per-turn upstream HTTP timeout. Forwarded turns that take longer than this surface as 504 to the caller.

class scitex_agent_container.config.RestartSpec(policy='never', max_retries=3, backoff_initial=30, backoff_max=300, backoff_multiplier=2, prune_on_stop=False)[source]

Bases: object

policy: str = 'never'
max_retries: int = 3
backoff_initial: int = 30
backoff_max: int = 300
backoff_multiplier: int = 2
prune_on_stop: bool = False
__init__(policy='never', max_retries=3, backoff_initial=30, backoff_max=300, backoff_multiplier=2, prune_on_stop=False)
class scitex_agent_container.config.SchedulingSpec(mode='per-host', preferred_host='', fallback_hosts=<factory>)[source]

Bases: object

Fleet-wide scheduling policy for an agent (shared-host layout).

mode controls effective-id composition and launch-skip behavior:
  • per-host (default): agent is started on every host that runs sac agent start <name>; the effective id is <metadata.name>-<HOST> unless the name already ends with -<HOST>.

  • singleton: exactly one instance fleet-wide. The effective id stays as the bare <metadata.name>. Only launched on preferred-host; on other hosts the launch is a no-op.

fallback-hosts is recorded for observability but not acted on automatically — manual failover today.

mode: str = 'per-host'
preferred_host: str = ''
fallback_hosts: list[str]
__init__(mode='per-host', preferred_host='', fallback_hosts=<factory>)
class scitex_agent_container.config.SkillsSpec(required=<factory>, available=<factory>, injection_mode='at-import', match_by=<factory>, match_style='exact')[source]

Bases: object

required: list[str]
available: list[str]
injection_mode: str = 'at-import'
match_by: list[str]
match_style: str = 'exact'
__init__(required=<factory>, available=<factory>, injection_mode='at-import', match_by=<factory>, match_style='exact')
class scitex_agent_container.config.StartupCommand(delay=0, command='')[source]

Bases: object

delay: int = 0
command: str = ''
__init__(delay=0, command='')
exception scitex_agent_container.config.UnknownEngineError[source]

Bases: EngineError

--engine <key> named an engine the spec does not declare.

class scitex_agent_container.config.WatchdogSpec(enabled=False, interval=1.5, resp_y_n='1', resp_y_y_n='2', resp_waiting='/speak-and-call')[source]

Bases: object

enabled: bool = False
interval: float = 1.5
resp_y_n: str = '1'
resp_y_y_n: str = '2'
resp_waiting: str = '/speak-and-call'
__init__(enabled=False, interval=1.5, resp_y_n='1', resp_y_y_n='2', resp_waiting='/speak-and-call')
scitex_agent_container.config.apply_engine(config, engine)[source]

Fold engine onto a resolved AgentConfig, in place.

This is the ONLY place an engine becomes behaviour, and it writes the SAME fields the single-backend surface writes — which is what makes every downstream reader (auth argv, birth certificate, explain) engine-aware for free.

AN ENGINE ENTRY FULLY DETERMINES THE BACKEND TRIPLE, and every one of the three is written UNCONDITIONALLY — including when the entry states nothing (model: "" = “use the runtime default”; provider: None = “no endpoint override”). Writing them conditionally is the obvious-looking version and it is wrong twice over: applying engine B after the loader applied default engine A would leave A’s provider in place, silently pointing the agent at an endpoint B never declared; and a legacy spec.claude.provider would leak into an engine that declares none. Both are exactly the “ran a backend nobody asked for” failure this axis exists to remove. A legacy block that states a value the default engine does not is therefore a CONFLICT, not an inheritance — see legacy_conflict_messages().

env merges ON TOP of spec.apptainer.env — a per-engine env entry is a deliberate override for THIS backend, so it wins over the agent-wide value it was written to displace.

THE TOP-LEVEL MODEL SURFACE FOLLOWS TOO, and it is not decoration. _loaders derives AgentConfig.model and the injected SCITEX_AGENT_CONTAINER_MODEL from the RAW spec.claude.model, which a spec declaring engines: deliberately leaves empty — so without this the whole fleet reported the sonnet default while running something else. Measured over the 119-spec corpus: 117 specs flipped to 'sonnet' / 'Claude Sonnet' on migration while claude.model stayed correct. sac agents list reads config.model (_lifecycle._status), the tmux runner builds --model from it, and sac whoami prints the env — three surfaces lying about one backend.

Return type:

None

scitex_agent_container.config.compose_effective_name(raw_name, hosts_spec, hostname)[source]

Return the effective agent id given dir-derived name + host/hosts + host.

Return type:

str

Rules:
  • If hosts: is set (multi-instance), append -<hostname> so each host’s instance has a unique id. Idempotent — names that already end with -<hostname> are not double-suffixed.

  • Otherwise (host: set, or both empty = local singleton): keep the bare raw_name. Singleton id stays stable across hosts.

scitex_agent_container.config.derive_hermes_fleet_context_budget(*, engine_capacity_tokens, agent_count, capacity_age_seconds, max_capacity_age_seconds=30.0, reserve_ratio=0.25)[source]

Derive a binary-rounded per-agent trigger or refuse untrusted input.

engine_capacity_tokens must be an observed shared KV capacity, not a model context limit. The observation has an explicit age so callers cannot silently reuse capacity across an engine restart or an unreachable probe. Rounding each equal share down to a power of two makes deployed thresholds stable across insignificant capacity changes and leaves at least the stated reserve.

Return type:

HermesFleetContextBudget

scitex_agent_container.config.load_config(path, *, advise=False)[source]

Load and validate a YAML config, returning an AgentConfig.

Only scitex-agent-container/v3 is accepted. Older apiVersions (v1, v2) raise loud validation errors — no backward compatibility.

advise turns on the STYLE advisories (long startup_prompts, and similar authoring lints). It defaults to OFF, and that default is the point: these are lints about how a spec is WRITTEN, not about whether it LOADS, so they must not fire on every command that happens to read specs.

sac agents list loads all ~110 definitions, so a load-time advisory printed 18 WARN lines above the table on every invocation. A warning that appears when you did not ask a question about spec style is one the reader learns to scroll past – which also blinds them to the ones that matter. Advisories therefore surface where they are actionable: sac agents check.

Return type:

AgentConfig

scitex_agent_container.config.resolve_config(name_or_path)[source]

Resolve agent name or path to a config file path.

Every agent lives in its own directory; the config file is always named spec.yaml (or spec.yml). The flat-form <base>/<name>.yaml and the legacy <base>/<name>/<name>.yaml are no longer accepted — sac is dir-as-SSoT.

Search order for short names (no slash, no .yaml/.yml suffix):

  1. Project-local — first .scitex/agent-container/agents/ found walking upward from cwd. Highest priority so checked-in test agents and CI fixtures override globals.

  2. ~/.scitex/agent-container/agents/<name>/spec.yaml (sac install root).

  3. $SCITEX_AGENT_CONTAINER_YAML_DIRS (colon-separated extra dirs, each searched as <dir>/<name>/spec.yaml). Plugin port for downstream orchestrators to inject extra paths without sac knowing about them.

Scope ($SAC_AGENT_SCOPE): if the requested NAME resolves in BOTH a project-local registry AND the fleet and the scope is unset, this raises AmbiguousRegistryScope (a real collision) instead of silently preferring project-local. A name present in only one registry resolves with no error, even when both registry dirs exist. SAC_AGENT_SCOPE=user searches only the fleet; =project only project-local.

Pass an explicit path (with / or .yaml/.yml) to bypass the search entirely.

Return type:

str

scitex_agent_container.config.resolve_hostname(gethostname=<built-in function gethostname>)[source]

Return the canonical host label for this machine.

Resolution order (first non-empty wins):
  1. SCITEX_AGENT_CONTAINER_HOSTNAME env var (manual override).

  2. SCITEX_AGENT_CONTAINER_HOSTNAME env var.

  3. hostname_aliases[short hostname] from shared/config.yaml or ~/.scitex/agent-container/config.yaml.

  4. socket.gethostname() short form (identity fallback).

Parameters:

gethostname (Callable[[], str]) – Callable returning the raw OS hostname. Defaults to socket.gethostname (production). Tests inject a callable returning a fixed string instead of patching socket.

Raises:

RuntimeError – If none of the sources produces a non-empty value. This should be practically impossible (gethostname() returns something on any configured box) but is handled loudly rather than returning the empty string.

Return type:

str

scitex_agent_container.config.select_engine(engines, requested)[source]

The engine THIS start runs on.

requested is the --engine argument (None = not given, so the declared default wins). An unknown key raises UnknownEngineError LISTING the declared keys — it never degrades to the default, because starting on a different backend than the one the operator named is the silent fallback Q3 forbids.

Return type:

EngineSpec | None

scitex_agent_container.config.substitute_hostnames(obj, hostname=None)[source]

Recursively walk a dict/list/str and substitute hostname placeholders.

Non-string leaves (int, bool, None) are returned unchanged. The walk is pure-functional — the input is not mutated; a new structure is returned.

Parameters:
  • obj (Any) – YAML-parsed structure (dict/list/scalar).

  • hostname (str | None) – Override hostname (for tests). If None, calls resolve_hostname().

Return type:

Any

scitex_agent_container.config.validate_config(path)[source]

Validate a config file and return list of errors (empty = valid).

Return type:

list[str]

Runtimes

Modular TUI prompt detection and response for Claude Code.

Each prompt handler defines: - name: identifier for logging - detect(content) -> bool: whether this prompt is visible - respond(send_keys) -> None: keystrokes to accept the prompt - priority: lower = checked first (default 10)

Add new handlers by appending to PROMPT_HANDLERS or calling register_prompt().

class scitex_agent_container.runtimes.prompts.PromptHandler(name, detect, keys=<factory>, priority=10)[source]

Bases: object

A single TUI prompt detector and responder.

name: str
detect: Callable[[str], bool]
keys: list[str]
priority: int = 10
__init__(name, detect, keys=<factory>, priority=10)
scitex_agent_container.runtimes.prompts._detect_bypass_permissions(content)[source]

Bypass Permissions mode prompt with radio selector.

Return type:

bool

Matches:

“1. No, exit” “2. Yes, I accept” “Bypass Permissions” “Enter to confirm”

scitex_agent_container.runtimes.prompts._detect_dev_channels(content)[source]

Development channels loading confirmation.

Return type:

bool

Matches:

“1. I am using this for local development” “2. Exit” “development channels” or “dangerously-load-development-channels” “Enter to confirm”

scitex_agent_container.runtimes.prompts.has_esc_cancel_modal(content)[source]

True iff an on-screen modal treats Esc as CANCEL / EXIT.

The fatal boot bug (card sac-boot-automation-devchannels-modal-continue-compose-buffer): the stale-compose clear sends Escape, but while the --dangerously-load- development-channels confirmation is up (”❯ 1. I am using this for local development … Enter to confirm · Esc to cancel”) an Escape CANCELS the launch → claude exits → the tmux session DIES mid-boot. Any drain step that would send Escape (the compose-buffer clear) MUST first verify no such cancelable modal is on screen.

Detection is deliberately BROAD — a keystroke that kills the session is far costlier than a spurious “don’t-Esc” skip:

  • the explicit Esc to cancel footer any confirm-modal renders, OR

  • the dev-channels modal specifically (its footer wording has varied across Claude Code builds; match the option text too so a reworded footer still guards).

Pure + string-only so the drain-ordering guard is unit-testable without a live TUI.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_thinking_effort(content)[source]

Thinking effort level selector.

Return type:

bool

Matches:

“1. * Medium (recommended)” or similar “thinking” in various casings “Enter to confirm”

scitex_agent_container.runtimes.prompts._detect_skip_permissions_yn(content)[source]

Legacy y/n text prompt for skip-permissions (older Claude Code).

Matches text-based y/n prompts without radio selector.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_mcp_json_edit(content)[source]

Permission prompt when Claude tries to edit .mcp.json (runtime).

Matches “1. Yes” / “1. Proceed” / “1. Allow” + “.mcp.json” + “Enter to confirm”.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_press_enter_continue(content)[source]

Generic ‘Press Enter to continue’ runtime pause (context-window warning, etc).

Uses a strict last-5-lines window to avoid scrollback false positives (per pane-state-patterns.md: classify against last 5 visible lines only). Excluded: active tool calls and numbered radio selectors.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_file_trust(content)[source]

‘Do you trust the files in this folder?’ prompt (first-run or new cwd).

May appear when –dangerously-skip-permissions was not propagated to a subshell. Matches the LEGACY y/n text variant; the new radio-selector variant is handled by _detect_file_trust_radio().

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_file_trust_radio(content)[source]

Radio-selector variant of the file-trust prompt.

Claude Code (>= ~2.1.x) asks “Is this a project you created or one you trust?” with numbered options instead of the legacy y/n text prompt. Appears on the first launch in any un-trusted workdir — including every throwaway tempdir the Haiku integration test uses.

Matches the exact option strings to avoid firing on the bypass-permissions dialog (which also says “Enter to confirm”).

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_external_imports(content)[source]

External CLAUDE.md file imports prompt.

Appears when CLAUDE.md (or .claude/CLAUDE.md) contains @<absolute-path> imports pointing OUTSIDE the agent’s workdir. Triggered by the at-import skill-injection mode (sac PR #74) when skills live in ~/.claude/skills/ or the package source trees rather than the workspace itself.

Return type:

bool

Matches:

“Allow external CLAUDE.md file imports?” “1. Yes, allow external imports” “Enter to confirm”

scitex_agent_container.runtimes.prompts._detect_login_method(content)[source]

First-run login-method picker on a fresh HOME.

Appears when Claude Code can’t find OAuth credentials at ~/.claude/.credentials.json. Even with ANTHROPIC_API_KEY set in env, the 2.1.x CLI still asks which auth mode to use before it checks the env var. Blocks startup until dismissed.

Matches the exact option strings to avoid false positives on any user message that happens to say “login method”.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_theme_selection(content)[source]

First-run theme selection prompt.

Appears only on a fresh HOME (no ~/.claude/ saved theme). On dev machines it never shows, but in CI (a clean ubuntu VM) this is the first thing Claude Code asks. Blocks every downstream startup prompt until acknowledged.

Matches the radio variant: “Choose the text style…” + numbered options starting with “1. Auto (match terminal)”.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_compose_pending_unsent(content)[source]

Detect unsent text sitting in the Claude Code compose buffer.

The classifier in agent_meta._classify_pane_state matches ❯[ \t\xa0]+\S (non-whitespace after the prompt marker on the same line), meaning the user has typed something but not yet pressed Enter. We mirror that pattern here so the prompts system can submit it via a plain Enter keystroke.

The gap MUST include U+00A0 NO-BREAK SPACE: Claude Code’s Ink TUI renders the prompt as ❯\xa0[Pasted text …] (an NBSP, not an ASCII space). The earlier ❯[ \t]+ pattern silently missed it, so a multi-line startup_prompt paste was never detected as pending and the boot-drain / _verify_submitted resend never fired — the agent sat idle with its instructions pasted-but-unsent (proj-scitex-dev 2026-06-23).

Excluded: lines that are just the decorative separator below an empty prompt — those contain only whitespace after ❯.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_resume_session(content)[source]

Long-session resume picker shown by claude --continue / --resume.

When the session being resumed is large, Claude Code interposes a three-way picker before the REPL opens:

This session is <N>h <M>m old and <K>k tokens.
Resuming the full session will consume a substantial portion ...
❯ 1. Resume from summary (recommended)
  2. Resume full session as-is
  3. Don't ask me again

The sac TUI boot uses --continue precisely to resume the FULL prior context, so we always pick option 2 (“Resume full session as-is”). Left unhandled this modal BLOCKS boot: the startup_prompt paste lands inside the picker and the boot-drain hangs (lead-retirement, 2026-06-25). Must out-rank compose-pending-unsent — the modal’s ❯ 1. line also matches that detector — hence priority 1.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_codex_dir_trust(content)[source]

Codex’s first-boot directory-trust picker (harness codex, 2026-09-05).

“Do you trust the contents of this directory? … 1. Yes, continue / 2. No, quit / Press enter to continue” — the cursor already sits on option 1, so Enter alone accepts.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_codex_hooks_review(content)[source]

Codex’s “Hooks need review” picker (harness codex, 2026-09-05).

Shown once sac has copied the fleet’s hooks into CODEX_HOME/hooks.json, and again whenever one of them changes. The hooks ARE the fleet’s own (~/.claude/hooks, the same files the Claude pane runs), so option 2 is the correct answer; option 3 would run the agent without them, silently.

Return type:

bool

scitex_agent_container.runtimes.prompts._CODEX_COMPOSER_MARKERS = ('›', '»')

The Codex composer row and the footer under it, as they look once the boot banner has scrolled away: › Use /skills to list available skills over qwen38-27b default · /home/ywatanabe/proj/local-coder. The footer’s second word is the reasoning effort Codex is running with.

scitex_agent_container.runtimes.prompts._codex_usage_limit_pane(content)[source]

Recognize the latest rendered native hard-cap message, not prose.

A composer remains visible after a failed turn and does not establish recovery. This is a UI observation only: persistent incident admission still needs its fenced session/turn evidence and explicit recovery API.

Return type:

bool

scitex_agent_container.runtimes.prompts.codex_blocking_modal(content)[source]

Recognize native review/login/limit panes without accepting their keys.

Tall hook lists exceed the ordinary modal tail. A fresh bottom composer and model footer exclude a dismissed heading still visible in history.

Return type:

str | None

scitex_agent_container.runtimes.prompts._detect_codex_done(content)[source]

Codex is at its input prompt and no picker remains.

Two shapes, both measured. At boot the “OpenAI Codex (vX)” box with a permissions row (“YOLO mode” when sac turns the sandbox off) is on screen. After the first turn that box has scrolled away for good, and the idle screen is the composer row (›) with the model/effort/cwd footer under it. Until 2026-09-05 only the first shape counted, so every dispatch to a Codex agent after its first turn was refused as “a modal is blocking the input” — measured on handyman-01, which had been idle at its composer.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_hermes_contributor_tier(content)[source]

Hermes’ Meta contributor-tier data-training confirmation (2026-09-23).

“CONTRIBUTOR TIER — TRAINS ON YOUR DATA … Use this model for this invocation? [y/N]” — Hermes asks this at TUI boot when the resolved model is a Meta contributor tier. The fleet runs the contributor tier deliberately (free, full authority, training accepted), so the correct answer is always “y”. Must out-rank everything except the resume picker — it blocks boot the same way.

Return type:

bool

scitex_agent_container.runtimes.prompts._detect_done(content)[source]

Check if the TUI is at its main input prompt (all prompts done).

Claude’s status bar shows “bypass permissions” when ready; Codex has its own banner (_detect_codex_done()).

Return type:

bool

scitex_agent_container.runtimes.prompts.register_prompt(handler)[source]

Add a custom prompt handler to the registry.

Return type:

None

scitex_agent_container.runtimes.prompts._LIVE_WINDOW_LINES = 15

How many non-empty rows of a captured pane count as the LIVE region for modal detection. See _recent_tail() for the false-positive this guards against; sized with headroom over the tallest verified real modal capture (_LIVE_BYPASS_PANE in test_prompts.py, 9 non-empty rows).

scitex_agent_container.runtimes.prompts._recent_tail(content, lines=15)[source]

Return the last lines non-empty rows of content.

Scopes modal detection to the LIVE region of a captured pane instead of the entire accumulated snapshot tmux capture-pane -p (no -S) returns the whole VISIBLE viewport, not just the freshest output. Once Claude Code’s Ink TUI has moved a first-run modal’s rendered text further up the screen — dismissed, but not yet scrolled past the viewport — an unscoped substring match can still fire on it long after the modal is gone, sending its registered digit+``Enter`` into what is now the plain, empty compose box: card sac-tui-stray-1-submitted-on-boot — a spurious "1" gets typed and submitted as a user message during boot (observed with the thinking-effort / file-trust-radio / theme-selection handlers, none of which scoped their match to the live screen).

_LIVE_WINDOW_LINES comfortably covers the tallest verified real modal capture with headroom, while still excluding an EARLIER, already-dismissed modal’s text once enough new output has rendered below it. Mirrors _detect_press_enter_continue()’s existing last-N-lines window (same fix shape, wider here because these handlers need a whole title+options+footer block, not one banner line) and _tui_compose._compose_pending_live()’s bottom-anchored scoping for the analogous Enter-drop bug (sac-tui-enter-drop-on-boot).

Return type:

str

scitex_agent_container.runtimes.prompts.detect_and_respond(content, accepted, send_keys_fn)[source]

Check content against all handlers, respond to the first match.

Parameters:
  • content (str) – Captured pane content.

  • accepted (set[str]) – Set of already-accepted prompt names.

  • send_keys_fn (Callable[..., None]) – Callable to send keystrokes (e.g., mux.send_keys).

Return type:

str | None

Returns:

Name of the matched prompt, or None if no match.

scitex_agent_container.runtimes.prompts.detect(content)[source]

Return the NAME of the first matching prompt handler (by priority), or None when no known modal is on screen. Detect-only — sends nothing.

Pairs with respond_modal() so a drain loop can RESPOND, settle, then re-detect to VERIFY the modal actually cleared — instead of firing keystrokes once and assuming success. Claude’s Ink TUI drops keys sent mid-render, so a fire-and-forget send silently leaves the modal up; the detect/respond/verify cycle is the no-silent-fallback fix.

Matches against _recent_tail() (the LIVE region), not the raw content — see its docstring for the stray-boot-submit false positive this prevents.

Return type:

str | None

scitex_agent_container.runtimes.prompts.respond_modal(name, send_keys_fn)[source]

Send the registered keystrokes for the handler named name.

Returns True iff a handler with that name exists (its keys were sent), False otherwise. The caller MUST verify the modal cleared (re-capture + detect()) and resend on the render race — a single send is not guaranteed to land.

Return type:

bool

scitex_agent_container.runtimes.prompts.is_ready(content)[source]

Check if claude is at the main input prompt (all TUI prompts done).

Return type:

bool