Agents
Reference for the agent layer, hmz.coganchor.agents, and for every coding agent CLI it drives: what each backend is, how a turn of it is run, and what each setting becomes on its command line. A flow never imports this module; the Agent and Session a flow is handed are views the flow runtime makes over it (hmz.runtime.flowing.harnesses.HarnessDriver), and Flows is the flow-level contract.
Terms
| Term | Definition |
|---|---|
| Backend | One coding agent CLI, named by the command it is installed as (claude, codex, …), or an ACP CLI added on this machine. |
| Profile | hmz.coganchor.backends.Profile: the facts about one backend that are not code (home, logs, ladders, hosts, ways in). backends.PROFILES holds the built-in ones. |
| Agent | An instance of a backend's AgentBase subclass: a backend at one config. Holds sessions. |
| Config | A frozen AgentConfig subclass: model, effort, rung, account, machine, fence, budget, and backend fields. |
| Session | One conversation with the backend, identified by the backend's own id once its first turn lands. |
| Turn | One prompt and everything the CLI does until it answers, ending on exactly one result event. |
| Rung | One of the four permission levels: read-only, workspace-write, auto, bypass. |
| Effort | How hard the model is asked to think, in the backend's own words (Efforts). |
| Moment | A point of a turn a hook may be hung on. |
| Fence | What the agent's processes may reach (The fence). |
| Harness | A backend as the flow runtime drives it: HarnessKind names it, HARNESS_AGENTS gives its flow protocol. |
Backends
hmz.coganchor.agents.DRIVEN maps each built-in backend to its agent and config class.
| Backend | Accepted as | Product | Transport |
|---|---|---|---|
agy | agy, antigravity | Antigravity CLI | one process held open (--input-format stream-json); a print command per shaped or slash-command turn |
claude | claude, claude-code | Claude Code | one claude --print held open per session (stream-json) |
codex | codex | Codex | one codex app-server --stdio per agent, shared by its sessions |
cursor-agent | cursor-agent, cursor-cli | Cursor Agent | one cursor-agent --print per turn |
dsh | dsh, deepseek-harness | DeepSeek Harness | the Python SDK in this process, one runtime per agent |
grok | grok, grok-build, grokbuild | Grok Build | one grok agent stdio held open; grok -p for turns it cannot express |
kimi | kimi, kimi-code | Kimi Code | one kimi web daemon per agent, shared by its sessions |
litellm | litellm | a model, called directly (litellm) | one chat completion per turn from this process, over the session's history; no CLI, no tools |
mcode | mcode, minimax, minimax-code | MiniMax Code | one mcode exec per turn |
mimo | mimo, mimocode, mimo-code | mimocode | one mimo run per turn |
omp | omp, oh-my-pi | Oh My Pi | one omp --mode rpc held open per session |
opencode | opencode | opencode | one opencode run per turn |
pi | pi | pi | one pi --mode rpc held open per session |
qwen | qwen, qwen-code | Qwen Code | one process held open (stream-json); a command per shaped turn |
| an ACP CLI | its name | any Agent Client Protocol agent | one process held open, JSON-RPC over stdio |
| the person | — | — | the interface |
| Backend | Agent class | Config class | Session class | Session base |
|---|---|---|---|---|
agy | AntigravityCLIAgent | AntigravityCLIAgentConfig | AntigravityCLISession | StreamSessionBase |
claude | ClaudeCodeAgent | ClaudeCodeAgentConfig | ClaudeCodeSession | StreamSessionBase |
codex | CodexAgent | CodexAgentConfig | CodexSession | SessionBase |
cursor-agent | CursorAgent | CursorAgentConfig | CursorSession | CommandSessionBase |
dsh | DshAgent | DshAgentConfig | DshSession | SessionBase |
grok | GrokBuildAgent | GrokBuildAgentConfig | GrokBuildSession | StreamSessionBase |
kimi | KimiCodeCLIAgent | KimiCodeCLIAgentConfig | KimiCodeCLISession | SessionBase |
litellm | LiteLLMAgent | LiteLLMAgentConfig | LiteLLMSession | SessionBase |
mcode | MiniMaxCodeAgent | MiniMaxCodeAgentConfig | MiniMaxCodeSession | CommandSessionBase |
mimo | MimoCodeAgent | MimoCodeAgentConfig | MimoCodeSession | CommandSessionBase |
omp | OhMyPiAgent | OhMyPiAgentConfig | OhMyPiSession | StreamSessionBase |
opencode | OpencodeAgent | OpencodeAgentConfig | OpencodeSession | CommandSessionBase |
pi | PiAgent | PiAgentConfig | PiSession | StreamSessionBase |
qwen | QwenCodeAgent | QwenCodeAgentConfig | QwenCodeSession | StreamSessionBase |
| an ACP CLI | AcpAgent | AcpAgentConfig | AcpSession | SessionBase |
| the person | HumanAgent | none (name= only) | HumanSession | SessionBase |
All classes are importable from hmz.coganchor.agents.
Installation and versions
| Backend | Install line (Profile.installs) | Driver written against |
|---|---|---|
agy | none recorded | 1.1.28 – 1.2.12 |
claude | npm i -g @anthropic-ai/claude-code | 2.1.272 – 2.1.284 |
codex | npm i -g @openai/codex | 0.153.4 |
cursor-agent | curl https://cursor.com/install -fsS | bash | not recorded |
dsh | pip install 'deepseek-harness-sdk>=0.1.5rc1,<0.1.6' 'python-dotenv>=1.2.3' (the [dsh] extra) | SDK >=0.1.5rc1,<0.1.6 (backends.DSH_SDK) |
grok | npm i -g @xai-official/grok | 1.0.24 |
kimi | npm i -g @moonshot-ai/kimi-code (plus the [kimi] extra) | 0.42.0 |
litellm | pip install 'litellm>=1.104' (the [litellm] extra) | >=1.104 (backends.LITELLM_SDK) |
mcode | npm i -g @minimax-ai/code | 0.5.9 |
mimo | npm i -g @mimo-ai/cli | not recorded |
omp | curl -fsSL https://omp.sh/install | sh | 18.2.8 |
opencode | npm i -g opencode-ai | not recorded |
pi | npm i -g @earendil-works/pi-coding-agent | 0.84.0 – 0.85.1 |
qwen | npm i -g @qwen-code/qwen-code | not recorded |
- A backend's program is found on
PATH, else in~/.local/bin,/usr/local/bin,/opt/homebrew/bin,/usr/bin,/bin(backends.program). - A turn whose CLI cannot be spawned exits 127 and fails with fault
missing. Under a flow the session is refused first withHarnessNotInstalled: <cli> is not installed here: <install line>; a backend with no recorded line saysinstall <cli> and put it on PATH.
Defined in src/hmz/coganchor/backends.py (PROFILES, installing, program), src/hmz/coganchor/agents/__init__.py (DRIVEN).
Capability matrix
| Flow mixins served | Driver | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Backend | Goal | Loop | Steer | Permission | Subagent | AskUser | Schema held | Fork | Web off | Rungs | Fast tier | Tools | Trace |
agyAntigravity | no | no | no | no | no | no | yes | no | yes | all 4 | no | yes | yes |
claudeClaude Code | yes | yes | yes | yes | yes | yes | yes | +cwd | yes | all 4 | yes | yes | yes |
codexCodex | yes | no | yes | yes | yes | yes | yes | +cwd | yes | all 4 | yes | yes | yes |
cursor-agentCursor Agent | no | no | no | no | yes | no | no | no | no | all 4 | yes | no | no |
dshDeepSeek Harness | yes | no | no | no | no | no | no | no | yes | bypass | no | yes | yes |
grokGrok Build | no | no | no | no | no | no | yes | yes | yes | all 4 | no | held turns | yes |
kimiKimi Code | yes | no | yes | yes | no | yes | no | +cwd | yes | all 4 | no | no | yes |
litellma model, called directly | no | no | no | no | no | no | yes | +cwd | no | all 4 | no | no | yes |
mcodeMiniMax Code | no | no | no | no | yes | no | yes | no | no | no read-only | no | no | yes |
mimomimocode | no | no | no | no | no | no | no | yes | yes | all 4 | no | yes | yes |
ompOh My Pi | no | no | yes | no | no | yes | no | yes | no | all 4 | no | yes | yes |
opencodeopencode | no | no | no | no | no | no | no | yes | yes | all 4 | no | yes | yes |
pipi | no | no | yes | no | no | yes | no | yes | no | all 4 | no | yes | yes |
qwenQwen Code | no | no | no | no | no | no | yes | yes | yes | all 4 | no | yes | yes |
an ACP CLIadded on this machine | no | no | no | no | no | no | no | if served | no | bypass | no | yes | no |
| Column | Source | Meaning |
|---|---|---|
| Goal | GoalCommandAgentMixin in HARNESS_AGENTS | a flow may send /goal <objective>: the CLI's own goal feature (Goals) |
| Loop | LoopCommandAgentMixin | a flow may send /loop <interval> <task>: the CLI's own recurring task (Loops) |
| Steer | SteeringAgentMixin | a flow may put words into a running turn |
| Permission | PermissionRequestHookAgentMixin | a flow may answer the CLI's permission requests |
| Subagent | SubagentStartHookAgentMixin, SubagentStopHookAgentMixin | a flow is told when the CLI starts and ends agents of its own |
| AskUser | AskUserHookAgentMixin | the CLI's questions to its user reach the flow |
| Schema held | SessionBase.shapes | the CLI validates the answer against the schema; elsewhere it is asked for in the prompt and validated after |
| Fork | Profile.forks; +cwd is SessionBase.forks_elsewhere | the CLI can branch a conversation; +cwd into another directory |
| Web off | Profile.searches | web_search=False can be said |
| Rungs | AgentBase.rungs | which rungs the backend takes |
| Fast tier | AgentBase.service_tiers | service_tier="fast" is served |
| Tools | SessionBase.takes_tools; FakeToolAgentMixin | fake tools can be offered, from Python or from a flow's _tools |
| Trace | _READERS in hmz.runtime.tracing.collector | Hmz().epics.trace() reads its logs |
The flow mixins are what a role typed as that harness may declare; the driver columns are what hmz.coganchor.agents does. One moment is reachable from Python and from no flow mixin: grok runs PERMISSION_REQUEST. HumanAgent runs no moment at all.
Agent specs (-a)
An agent is written [ROLE=]CLI[@ACCOUNT]/MODEL[:EFFORT] wherever one is named: -a, a settings file, Runs, the TUI. backends.read parses one item; -a splits a value on , followed by KEY=.
| Part | Rule |
|---|---|
ROLE | Required for -a. A Python identifier: a field of the flow's agent collection. |
CLI | Any alias in Backends, or an added ACP CLI's name. Never contains @. |
ACCOUNT | After @: a provider name. Omitted: the CLI as already signed in. @ with nothing after it is refused. |
MODEL | Everything after the first /, less a trailing :EFFORT. May contain / (kimi-code/k3, opencode/big-pickle) and : (custom_provider:gateway/m). Must be non-empty. |
EFFORT | After the last :, where what follows it is spelled as an effort: words of letters joined by -, _ or a space (high, extra-high, as configured), or nothing. Otherwise -- custom_provider:gateway/m, qwen3:8b -- the : is the model's, and so is everything after it. Left off, or auto, or empty: no rung, stored as "". A model whose own name ends in :<word> is written with its effort after it: qwen3:latest:auto. Checked against the backend's ladder when the agent is built. |
| Input | AgentSpecError (exit 2 from hmz exec) |
|---|---|
no ROLE= | -a 'claude/m:high': expected <role>=<harness>[@<provider>]/<model>[:<effort>] |
| a role that is not an identifier | -a '9x=claude/m:high': '9x' is not a place a flow could declare: what is written before `=` is a field of the tuple of agents the flow declares, so it is a Python identifier |
unknown CLI, no /, empty model | -a 'agent=claude': expected [NAME=]CLI[@PROVIDER]/MODEL[:EFFORT] |
@ with no account | -a 'agent=claude@/m:high': expected an account after @, as in claude@deepseek/MODEL:EFFORT |
| a role given twice | -a: the role 'agent' is given twice |
| an empty item | -a '<value>': an item is empty |
An effort off the ladder, an account that does not exist, or a setting the backend cannot carry is refused when the run builds the role's driver, as HarnessUnrecoverable: <spec>: <reason>, the reason being the AgentConfig refusal.
Defined in src/hmz/coganchor/backends.py (read, named), src/hmz/runtime/flowing/specs.py (parse_agents), src/hmz/runtime/flowing/harnesses.py (open_agent).
AgentConfig
hmz.coganchor.agents.AgentConfig is a frozen, keyword-only dataclass. Every backend config subclasses it; Backend notes lists each subclass's own fields.
| Field | Type | Default | Meaning |
|---|---|---|---|
model | str | required | The model, in the backend's spelling (Models). |
effort | str | required | A rung of the backend's ladder; "" or "auto" for none. |
service_tier | str | "default" | "default" or "fast" (Service tier). |
machine | MachineConfig | None | None | Where turns land (Machines). |
permission | str | "" | A rung, or "" for none said. |
provider | str | "" | The account; "" for the CLI as signed in. |
goals | bool | True | Whether goals and work-past-the-turn tools are available. |
web_search | bool | None | None | Web search: on, off, or unsaid. |
fence | Fence | None | None | What the agent's processes may reach (The fence). |
budget | Budget | None | None | What each turn may spend (Budgets). |
The config is checked twice: by __post_init__ when it is built (ValueError), and by the agent when it is made, reconfigured, or has its effort moved (Unserved, a ValueError; Unfenced, a kind of Unserved).
| Refused | Error and message |
|---|---|
a tier other than default/fast | ValueError: service_tier must be one of default, fast, not 'slow' |
| a rung that is not one of the four | ValueError: permission must be one of read-only, workspace-write, auto, bypass, not 'all' |
| an effort off the ladder | Unserved: claude cannot be asked to think at 'warp'; expected one of ultracode, max, xhigh, high, medium, low |
| a tier the backend does not serve | Unserved: PiAgent does not support service tier 'fast'; expected default |
| a rung the backend does not take | Unserved: DshAgent cannot be held to 'read-only'; expected bypass, or left unsaid for the agent as whoever installed the CLI configured it |
web_search=False on a backend that cannot be told | Unserved: PiAgent has no way of being told not to search the web; web_search must be on for it |
| a fence that cannot be held | Unfenced: … (conditions) |
| backend-specific combinations | listed under each backend in Backend notes |
Budget refuses when other than next-response/immediately (when must be one of next-response, immediately, not 'later'), then other than end/fail, and a negative cap (a budget cannot be less than nothing).
Defined in src/hmz/coganchor/agents/config.py, src/hmz/coganchor/agents/base.py.
Models
model is passed in the CLI's own spelling.
| Backend | A model is | Example |
|---|---|---|
agy, claude, codex, dsh, grok, qwen | the id the CLI or its endpoint serves | claude-opus-5, gpt-5.6-sol, deepseek-v4-flash |
kimi | Kimi Code's provider/id | kimi-code/k3 |
litellm | a litellm model string, provider/id; under an account a bare id, sent to the account's provider -- openai/<id> under an OpenAI key, the protocol under a gateway account | openai/gpt-5, anthropic/claude-sonnet-4-5 |
pi, omp, opencode | provider/id | openai-codex/gpt-5.5, deepseek/deepseek-v4-flash, opencode/big-pickle |
mimo | provider/id; under a gateway account, the id its endpoint serves (sent as humanize/<id>) | xiaomi/mimo-v2.5, gpt-5.6-sol |
cursor-agent | an id from the account's list, the rung and tier written into it | composer-2.5-high-fast |
mcode | minimax/<id>, or custom_provider:<name>/<id> for an added provider; "" for its configured default | minimax/MiniMax-M3 |
| an ACP CLI | as configured |
pi's --provider defaults to google; a bare id is looked up among Gemini models. On a pi gateway account every turn carries --provider humanize-<hash>, so the model is the gateway's own id (Providers › Gateways).
Catalogues
hmz.coganchor.models keeps what each account runs in a cache on this machine (Providers › Model catalogues). It is asked when an account is made, when refreshed from the TUI's models sheet, and by the TUI at start-up, in the background, for each installed backend whose machine's-own catalogue is missing or older than 7 days. It is asked as a turn of that account is taken: under its paths, with its variables and without hushed ones.
| Backend | Asked with | Efforts per model |
|---|---|---|
claude | claude -p --input-format stream-json --output-format stream-json --verbose, sent a control_request list_models | the model's supportedEffortLevels |
codex | codex debug models (models marked for listing) | per model |
kimi | kimi provider list --json | per model; swarm |
litellm | nothing: the catalogue litellm ships (model_prices_and_context_window_backup.json, read without importing it), chat models of the providers it has a way in for, as provider/id | the ladder where litellm says the model reasons; none otherwise |
agy | agy models | the ladder |
grok | grok models | the ladder |
cursor-agent | cursor-agent --list-models | the rungs its listed variants carry (gpt-5.2 at those gpt-5.2-low … are listed for); none for a model with no variants |
pi | pi --list-models | the ladder |
omp | omp models --json | for a model that reasons, the rungs omp lists for it, plus off, or the ladder where it lists none; none for a model that does not reason |
opencode, mimo | <cli> models (lines with a /) | the ladder |
mcode | mcode provider list --json, then MiniMax's own four (minimax/MiniMax-M3, minimax/MiniMax-M3.1-Flash-Preview, minimax/MiniMax-M2.7, minimax/MiniMax-M2.7-highspeed) | only MiniMax-M3.1-Flash-Preview takes rungs |
dsh | nothing: deepseek-v4-flash, deepseek-v4-pro | the ladder |
qwen | nothing: qwen3-coder-plus, qwen3-coder-flash | the ladder |
| an ACP CLI | nothing: one row, as configured | as configured |
Where the account sets the backend's endpoint variable, the endpoint is asked first:
| Backend | Endpoint variable |
|---|---|
agy | GOOGLE_GEMINI_BASE_URL |
claude | ANTHROPIC_BASE_URL |
codex | CODEX_PROVIDER_URL |
dsh | DEEPSEEK_BASE_URL |
grok | GROK_XAI_API_BASE_URL |
kimi | KIMI_MODEL_BASE_URL |
litellm | LITELLM_GATEWAY_URL |
qwen | OPENAI_BASE_URL |
The request is GET {base}/v1/models ({base}/models when the base ends in a version); every listed id is offered at the backend's whole ladder. See Providers › Gateways.
A Claude Code session whose system/init names a model other than the one asked for (not the model, the model dated, an alias opus, sonnet, haiku, default, best, opusplan with or without [1m], nor a catalogue name) emits one notice per session: Claude Code does not know the model '<asked>' and is running <actual>, its own default, in its place. The turn continues.
Defined in src/hmz/coganchor/models.py.
Efforts
The effort is checked against Profile.efforts (plus Profile.beyond) where the agent is made and wherever it is moved. auto is no rung: every backend takes it, and nothing is said to the CLI. An ACP CLI's ladder is the single word as configured and any effort is accepted.
| Backend | Ladder, hardest first | How it is sent |
|---|---|---|
agy | high, medium, low | --effort, only where the model name carries no rung (gemini-3.7-flash-high runs at its own rung) |
claude | ultracode, max, xhigh, high, medium, low | --effort. ultracode (in beyond: taken, never listed) is xhigh with the turn opted into orchestrating a fleet. |
codex | ultra, max, xhigh, high, medium, low | per turn on the app server; ultra/max only on models that list them |
cursor-agent | max, xhigh, extra-high, high, medium, low, minimal, none | written into the model id: <model>-<rung>, where the account lists that id |
dsh | max, high, low, off | written onto the SDK composition |
grok | xhigh, high, medium, low | --effort |
kimi | max, high, medium, low, each also swarm-prefixed | per turn on the daemon; swarmmax is max run as a fleet of subagents (agents.SWARM == "swarm") |
litellm | high, medium, low, minimal, none | reasoning_effort on the call; litellm leaves it off for a model with none |
mcode | max, xhigh, high, medium, low | --effort, only where there is a rung; a rung the account's catalogue does not list for the model is Unserved |
mimo, opencode | xhigh, high, medium, low, minimal | --variant |
omp | max, xhigh, high, medium, low, minimal, off | --thinking, only where there is a rung. off is also in beyond, so it stays on a model's ladder however its catalogue narrows it |
pi | max, xhigh, high, medium, low, minimal, off | --thinking; pi clamps a rung the model lacks to the nearest it has |
qwen | max, xhigh, high, medium, low, none | model.reasoningEffort in its virtual home's copy of settings.json |
Moving the effort while it runs
agent.effort = "low" moves every session of the agent; session.effort = "max" moves one; session.effort = "" returns it to the agent's. agent.config.effort stays as configured. The change takes hold on the next turn; the running turn keeps its effort. The new effort is checked as a configured one is.
| Backend | How a moved effort takes hold |
|---|---|
codex, kimi, opencode, mimo, cursor-agent, mcode, litellm | sent with the next turn |
claude, agy, grok, qwen | the process is restarted and resumes the same conversation |
dsh | the runtime is restarted before the first turn; after it the turn is refused (Unrecoverable), see DeepSeek Harness |
pi, omp | a command to the held process, between turns |
Defined in src/hmz/coganchor/backends.py (Profile.efforts, Profile.beyond, Profile.takes, AUTO, SWARM).
Service tier
| Backend | service_tier="fast" |
|---|---|
claude | fastMode: true in the --settings literal; at default nothing is sent |
codex | the priority service tier |
cursor-agent | the -fast id the account lists for the model (composer-2.5-fast); Unserved where none is listed |
| every other backend | Unserved |
The field records the tier asked for; the provider decides the tier served.
Permission rungs
AgentConfig.permission is one of four rungs, loosest last, or "":
| Rung | Meaning |
|---|---|
read-only | may read anything and change nothing |
workspace-write | may change the workspace it was given |
auto | may reach for anything; what it asks for is granted |
bypass | nothing is asked and nothing is checked |
"" | nothing is said to the CLI: no mode, sandbox or approval flag |
What each rung becomes, in the CLI's own terms (—: the same as the rung to its left; refused: Unserved):
| Backend | read-only | workspace-write | auto | bypass |
|---|---|---|---|---|
agy | plan mode, four read tools | accept-edits mode | skip permissions | — |
claude | plan | acceptEdits | auto | bypassPermissions; acceptEdits where the account or root forbids it; manual with asks |
codex | sandbox read-only | sandbox workspace-write | sandbox workspace-write, approval on-request | sandbox danger-full-access |
cursor-agent | plan | sandbox on | auto-review | sandbox off |
dsh | refused | refused | refused | nothing sent |
grok | three read tools | web search off | every tool | — |
kimi | manual and plan mode | auto | yolo | auto |
litellm | nothing sent | — | — | — |
mcode | refused | full | smart | full |
mimo, opencode | edit, bash denied | web tools denied | nothing denied | — |
omp | always-ask, every approval answered no | yolo | — | — |
pi | four tools withheld | nothing withheld | — | — |
qwen | five tools withheld | web_fetch withheld | nothing withheld | — |
| an ACP CLI | refused | refused | refused | every request granted |
How it is sent:
| Backend | Flags or protocol |
|---|---|
agy | --mode plan --agent hmz-read-only; --mode accept-edits; --dangerously-skip-permissions |
claude | --permission-mode <mode>; at bypass also --permission-prompt-tool stdio, each request answered allow unless a PERMISSION_REQUEST hook refuses |
codex | sandbox and approvalPolicy on thread/start, thread/resume and turn/start; approval never at every rung but auto |
cursor-agent | --mode plan; --force --sandbox enabled; --auto-review; --force --sandbox disabled |
grok | --always-approve, plus --tools read_file,grep,list_dir at read-only and --disable-web-search at workspace-write |
kimi | the session's permission mode on the daemon, and plan mode at read-only |
mcode | --permission full, --permission smart |
mimo, opencode | the MIMOCODE_PERMISSION / OPENCODE_PERMISSION table, plus --dangerously-skip-permissions / --auto |
omp | --approval-mode always-ask at read-only; --approval-mode yolo at workspace-write, auto and bypass |
pi | --exclude-tools bash,edit,write,powershell at read-only |
qwen | --approval-mode yolo, plus --exclude-tools edit,monitor,notebook_edit,run_shell_command,write_file at read-only or --exclude-tools web_fetch at workspace-write |
Per-backend rules:
- agy:
--mode planalone does not stop writes (agy 1.2 writes in it), soread-onlyalso starts the turn as--agent hmz-read-only, an agent file Humanize writes into theagents/of the turn's virtual home whose only tools areview_file,grep_search,find_by_nameandlist_dir. That file exists only on this machine, soread-onlyorweb_search=Falseon another machine isUnserved: agy cannot run at read-only or off the web on another machine: what holds it there is an agent of Humanize's that is a file on this one. Atworkspace-writeedits pass and commands are soft-denied (named indenied_actions). - claude:
bypassisbypassPermissions. An account whose managed settings carry"disableBypassPermissionsMode": "disable"starts that turn atdefaultand says so in itssystem/init; the driver then moves the process toacceptEditswith aset_permission_modecontrol request, starts every later process of the agent on that account atacceptEdits, and says once:claude: this account will not run an agent at bypass, so it runs at acceptEdits, where what it asks for is granted. The same holds where Claude runs as root withoutIS_SANDBOX=1: it exits before the turn with--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons, and the turn is taken again atacceptEdits. WithClaudeCodeAgentConfig.asks,bypassruns atmanualinstead, so that every tool that would change something reaches aPERMISSION_REQUESThook. Whatever is asked atbypassis answeredallowunless such a hook refuses. The CLI still enforces an organisation'sdenylist. - codex: the approval policy is
neverat every rung butauto. On a machine whose requirements forbiddanger-full-access,bypassruns atautoand says once:codex: this machine will not run an agent at bypass, so it runs at auto, where what it asks for is granted.CodexAgentConfig.approvalsreplaces the rung's approval policy. - kimi: Kimi's modes are, loosest last,
manual,yolo,auto. Atread-onlyplan mode vetoesWrite,Edit,TaskStopand the cron tools, andmanualturns everything else,Bashincluded, into an approval the driver answers no;ExitPlanModeis an approval too. KimiautodeniesAskUserQuestion, so atworkspace-writeandbypassno question and noNOTIFICATIONoccur. - mcode: has no rung that changes nothing and no sandbox.
smartis its own reviewer, which fails the turn where it would have asked.read-onlyis refused; a flow holds it read-only with a fence that writes nothing. - mimo, opencode: the rung goes in a permission table (
OPENCODE_PERMISSION/MIMOCODE_PERMISSION); any rung also adds--auto(opencode) or--dangerously-skip-permissions(mimo), unlessunattendedsays otherwise. - omp: no sandbox. At
read-onlyomp asks before every tool it does not class as a read, and the driver answers each such approval no without asking anyone.workspace-write,autoandbypassare one agent. With no rung no flag is sent and omp's own configuration decides; an approval it then asks for reaches the person as a question. - pi: no permission gate and no sandbox;
workspace-write,autoandbypassare one agent. - qwen: always
--approval-mode yolo; its asking modes would leave a held-open session waiting on an approval nothing can answer. Nothing confines an edit to the workspace. - dsh, ACP: the SDK exposes no per-session sandbox or approval control; an ACP CLI's only permission word is a per-call question, which is granted.
Flow permission to rung
Under a flow the rung is derived from the role's Permission by hmz.runtime.flowing.harnessing.rung(), and nothing is put to anybody for approval:
localofREADorNONEgivesread-only;localofALLgivesbypass.- If that is not
read-onlyand the harness is inASKSand a hook in itsASKSset is hung, it becomesauto.ASKSis{kimi: {PERMISSION_REQUEST, ASK_USER}}. - A rung the backend does not take becomes
bypass(dsh,mcode, ACP CLIs).
local | every harness but dsh, mcode, ACP | dsh, mcode, ACP |
|---|---|---|
NONE, READ | read-only | bypass |
ALL | bypass (auto for kimi with a PermissionRequest or AskUser hook hung) | bypass |
user,systemandonlineare held by the fence, not the rung; so islocalondsh,mcodeand ACP CLIs.- Claude Code with a
PermissionRequesthook hung is givenasks=True(harnessing.prompting), sobypassruns atmanualand every tool that would change something reaches the hook. It takes hold from the session's next turn (the process is restarted). - Codex with a
PermissionRequesthook hung runs approval policyuntrustedover its rung's sandbox (harnessing.approvals). With anAskUserhook hung it is started with the featuredefault_mode_request_user_input(harnessing.ASKING_FEATURE). Both take hold from the session's next turn (the app server is restarted). - Codex
read-onlyunder a fence, or where bubblewrap cannot get a user namespace, starts the app server with--enable use_legacy_landlock(asked once per process by runningcodex sandbox … -- true). Where the fence leaves the network on, eachread-onlyturn is sentsandboxPolicy: {"type": "readOnly", "networkAccess": true}. On macOS, where Codex's own Seatbelt sandbox cannot start inside the fence's, eachread-onlyorworkspace-writeturn under a fence is sentsandboxPolicy: {"type": "externalSandbox", "networkAccess": "enabled"|"restricted"}instead, the fence holding the rung. onlinealso decides the CLI's own web tools (Web search).
Defined in src/hmz/runtime/flowing/harnessing.py (rung, ASKS, approvals, prompting, searching, fenced), and each driver's _PERMITTED table.
Web search
web_search | Meaning |
|---|---|
None (default) | nothing said; the CLI searches as it would when run by hand |
True | on, said even to a CLI that ships with search off |
False | off; Unserved on a backend with Profile.searches == False |
| Backend | How it is said |
|---|---|
claude | --disallowedTools WebSearch,WebFetch when off, or when the fence cuts the network |
codex | -c web_search="live" or "disabled"; disabled whenever the fence cuts the network |
dsh | the dsh-web plugin, its providers and dsh-tool-web mounted when on |
grok | --disable-web-search when off, or when the fence cuts the network |
kimi | disabled_tools on each prompt: WebSearch, FetchURL when off, or when the fence cuts the network |
qwen | --exclude-tools web_search,web_fetch when off, or when the fence cuts the network |
opencode | webfetch: deny, websearch: deny in the permission table |
mimo | the same, and codesearch: deny |
agy | --agent hmz-offline (default tools less read_url_content, search_web and the subagent tools) when off or when the fence cuts the network; at read-only, hmz-read-only-web when on |
cursor-agent, mcode, omp, pi, an ACP CLI | cannot be told: False is refused |
Under a flow, online of ALL (the default) is True and NONE is False, on a backend that can be told; elsewhere None. A command the agent runs reaches the network regardless of this setting; only the fence stops that.
The fence
AgentConfig.fence is what the agent's processes may reach: the CLI's own process and every command it runs. A flow sets one on every session from the role's Permission (harnessing.fenced).
hmz.coganchor.fence.Fence (frozen):
| Field | Type | Meaning |
|---|---|---|
read | tuple[str, ...] | absolute paths that may be read, listed and executed |
write | tuple[str, ...] | absolute paths that may be read and changed; a path in both is written |
online | bool | True: the network is unrestricted. False: cut, except through a proxy to hosts |
hosts | tuple[str, ...] | what the proxy passes while online is False: host, *.suffix, or host:port |
tmp | str | the scratch directory, exported as TMPDIR, TMP, TEMP; "" for one made per run |
scopes | tuple[str, ...] | (local, user, system) levels it was drawn from, or () for one drawn path by path |
listen | tuple[int, ...] | TCP ports that may be bound while online is False, on loopback |
Fence.open is true when / is in write and online is true: nothing is put around the CLI.
Fence.of(*, local, user, system, online, workdir, home, cwd="", hosts=(), read=(), write=()):
- Each scope is a root (
systemis/,userishome,localisworkdirandcwd) and a level:readputs the root inread,allinwrite,noneleaves it out. local="all"also writesTEMPORARY(/tmp,/var/tmp) and$TMPDIRwhere it is absolute.user="all"also writes/for any user but root, the files' permissions holding it; for root,TEMPORARY,$TMPDIRand/run/user/0.- The levels must nest,
local >= user >= system: `ValueError: scopes must nest, local >= user= system; got local=read, user=all, system=none
. A level outsidenone/read/all:ValueError: a scope is one of none, read, all, not 'x'`. - Always added to
read:/usr,/bin,/sbin,/lib,/lib32,/lib64,/libx32,/etc/ssl,/etc/ca-certificates,/etc/pki,/etc/resolv.conf,/etc/hosts,/etc/host.conf,/etc/gai.conf,/etc/nsswitch.conf,/etc/passwd,/etc/group,/etc/localtime,/etc/timezone,/etc/os-release,/etc/ld.so.cache,/etc/ld.so.conf,/etc/ld.so.conf.d,/etc/alternatives,/sys, and the Python running Humanize. - Always added to
write:/dev/null,/dev/zero,/dev/full,/dev/random,/dev/urandom,/dev/tty,/dev/pts,/dev/ptmx,/dev/shm,/proc(a thread named by another is named through/proc/self/task/<tid>/comm, which CUDA'scuInitdoes), every/dev/nvidia*,/dev/dri,/dev/kfd,/dev/infiniband.
The agent widens the fence where it spawns a turn (agent.fenced()): its CLI's state and sign-in directories and its session directory to write, and on macOS ~/Library/Keychains, where Claude Code keeps its sign-in and rewrites it as it refreshes; the account's directory; the hosts its model and sign-in are at under the account (Network hosts); the skills it carries; its programs and their install trees to read.
Both a flow's fence and the agent's widening read the hosts from the same environment, providers.composed(provider, profile): this process's own, less the variables the account hushes, plus the ones it sets. A variable the turn runs without, such as an ANTHROPIC_BASE_URL left in the shell under an account that does not set one, opens no host.
Enforcement
agent.natively(fence) returns what the CLI does not enforce itself. That remainder is held from outside by putting python -Pm hmz internal fence --policy=<Fence JSON> -- outermost around the turn:
| Mechanism | Holds |
|---|---|
| Landlock (Linux) | paths, and TCP connect/bind by port (ABI >= 4 where the network is cut) |
| seccomp filter (Linux) | every non-TCP socket family refused |
seccomp user notification + pidfd_getfd (Linux) | with online=False, every bind and listen stops for the wrapper, which allows loopback only (EACCES otherwise) |
| Seatbelt (macOS) | paths (as given and as resolved, /tmp being /private/tmp); with online=False, outgoing TCP to the proxy's port on localhost only, every other outgoing connection and the system resolver's socket refused, and listening allowed on localhost, which Seatbelt also matches for every address and every port |
| proxy on loopback | with online=False, the only way out: handed to the CLI as HTTPS_PROXY and related variables, passing only hosts (ports 443 and 80 for a host without a port) |
The minimum granted whatever the scopes is LINUX_SYSTEM and LINUX_DEVICES on Linux, and DARWIN_SYSTEM (/usr, /bin, /sbin, /System, /private/var/select, /private/var/db/timezone and the handful of files under /etc a program reads to start) and DARWIN_DEVICES on macOS, where the root directory itself is also readable and the pseudo-terminals /dev/ttys* writable. Seatbelt also lets the current user's mds directory under DARWIN_USER_CACHE_DIR be written: the Security framework locks its module database there to initialize TLS, and a CLI refused it fails before its model starts.
No built-in CLI enforces any part natively: every driver's natively returns the whole fence, for the reasons below. litellm returns none of it: a turn is one request from this process and the model has no tool to reach anything with, so nothing is put around it.
| Backend | Why its own confinement is not used |
|---|---|
agy | --sandbox confines commands only (its file tools run outside it), cannot start without unprivileged user namespaces, and its network lists are desktop-app settings |
codex | its sandbox holds the commands its agent runs, not Codex's own process, MCP servers or hooks; online=False also sends -c web_search="disabled", --disable apps, and --disable respect_system_proxy where codex features list names it |
cursor-agent | cursorsandbox wraps shell commands only and needs a user namespace; $XDG_CONFIG_HOME/cursor (else ~/.config/cursor) is granted to write |
dsh | the bundle has no confining shell executor; the runtime the SDK launches is wrapped, its composition file and native module cache (PKG_NATIVE_CACHE_PATH) put in the fence's tmp |
grok | its sandbox profiles write /tmp and /var/tmp, block only commands' network, and fail without user namespaces on 1.0.24 |
kimi | 0.42 has no sandbox; the daemon is fenced once, when it starts |
mimo, opencode | neither confines its shell; the permission table additionally denies edit outside writable paths, read/external_directory outside readable ones (only where system is NONE), and web tools where online is NONE |
omp | no sandbox; no --offline under online NONE |
pi | no gate and no sandbox; --offline under online NONE |
qwen | --sandbox is a container or Seatbelt; its rules hold its own tools only |
| an ACP CLI | nothing is known of it; declared hosts and state under clis in settings.yaml are granted |
When a fence is refused
Unfenced is raised where the config arrives (construction, reconfiguration) when:
| Condition | Message |
|---|---|
this machine has no Landlock (Linux < 5.13, or not in the lsm= list) | <Agent> cannot be held to its permission on this machine: it does not enforce it natively, and fencing it from outside needs Landlock (Linux 5.13 or later, with landlock in its lsm= list) |
macOS, where sandbox-exec cannot apply a profile (Humanize itself already runs inside a sandbox) | … needs Seatbelt, which a process already inside a sandbox cannot apply |
online=False and no Landlock ABI 4, no seccomp notification, or pidfd_getfd refused (a container's default seccomp profile, Yama ptrace_scope >= 2) | … needs Landlock ABI 4 (Linux 6.7 or later) and seccomp to cut the network |
a fence drawn path by path on an agent whose machine is set | <Agent>: a fence drawn path by path cannot be held on another machine |
| a harness on another machine | <Agent>: a fence cannot hold a harness that runs on another machine |
online=False with the anchor's net="remote" | <Agent>: an agent whose own connections are sent to the target cannot have its network cut here |
cursor-agent with online=False | cursor-agent: this permission cuts the network, and cursor-agent's web search and web fetch run on Cursor's own servers, through the same hosts as its model, where nothing here can tell them apart or switch them off; grant it online ALL to use cursor-agent |
mcode with online=False | mcode: this permission cuts the network, and mcode's web search runs on MiniMax's own service, through the same hosts as its model, where nothing here can take it away; grant it online ALL to use mcode |
grok with leader=True and a non-open fence | GrokBuildAgent: a fenced conversation cannot join the leader, which runs its tools outside the fence; set leader to False or None |
Under a flow, Unfenced becomes HarnessSandboxed. An ACP CLI with online NONE and no declared hosts is refused HarnessSandboxed, naming what to declare. For anchored agents, what each machine holds is in Remote execution › A fence on both machines.
Gaps are the kernel's: neither Landlock nor Seatbelt governs connecting to a Unix socket (on macOS, nor asking a system service over Mach); on Linux with the network cut the proxy's port is reachable on any address, by number; and on macOS with the network cut a program may listen on any address and port, Seatbelt telling neither apart.
Network hosts
Profile.hosts: what a fenced turn with online=False still reaches. The account adds its endpoint's host and cloud hosts (Providers › Hosts reachable under a cut network).
| Backend | Hosts |
|---|---|
agy | cloudcode-pa.googleapis.com, daily-cloudcode-pa.googleapis.com, oauth2.googleapis.com, www.googleapis.com, generativelanguage.googleapis.com, lh3.googleusercontent.com |
claude | api.anthropic.com, platform.claude.com, claude.ai |
codex | chatgpt.com, auth.openai.com, api.openai.com |
cursor-agent | *.cursor.sh (a cut network is refused anyway) |
dsh | api.deepseek.com |
grok | cli-chat-proxy.grok.com, auth.x.ai, api.x.ai |
kimi | api.kimi.com, auth.kimi.com, api.kimi.ai, auth.kimi.ai, api.moonshot.ai, api.moonshot.cn |
mcode | agent.minimax.io, agent.minimaxi.com, agent.minimax.cn, api.minimax.io, api.minimaxi.com, account.minimax.io, account.minimax.cn (a cut network is refused anyway) |
mimo | api.xiaomimimo.com, token-plan-cn.xiaomimimo.com, token-plan-sgp.xiaomimimo.com, token-plan-ams.xiaomimimo.com, and for its vendor-key ways api.anthropic.com, api.openai.com, generativelanguage.googleapis.com, api.x.ai, openrouter.ai, api.deepseek.com; plus the options.baseURL of the provider mimocode/mimocode.json (under $XDG_CONFIG_HOME) declares for the model |
omp | api.anthropic.com, platform.claude.com, chatgpt.com, auth.openai.com, api.openai.com, api.github.com, api.individual.githubcopilot.com, api.x.ai, auth.x.ai, api.kimi.com, auth.kimi.com, openrouter.ai, generativelanguage.googleapis.com, api.deepseek.com, api.groq.com, api.mistral.ai; plus the baseUrl of each provider omp's own models.yml (or models.yaml) declares for the model |
opencode | opencode.ai, chatgpt.com, auth.openai.com, api.githubcopilot.com, api.anthropic.com, api.openai.com, generativelanguage.googleapis.com, api.x.ai, openrouter.ai, api.deepseek.com, api.mistral.ai; plus the options.baseURL of the provider opencode/opencode.json (under $XDG_CONFIG_HOME) declares for the model |
pi | api.anthropic.com, platform.claude.com, chatgpt.com, auth.openai.com, api.openai.com, api.github.com, api.individual.githubcopilot.com, api.x.ai, auth.x.ai, api.kimi.com, auth.kimi.com, openrouter.ai, generativelanguage.googleapis.com, api.deepseek.com, api.groq.com, api.mistral.ai; plus the baseUrl of each provider pi's own models.json declares for the model |
qwen | dashscope.aliyuncs.com, dashscope-intl.aliyuncs.com, coding.dashscope.aliyuncs.com, coding-intl.dashscope.aliyuncs.com, token-plan.cn-beijing.maas.aliyuncs.com, token-plan.ap-southeast-1.maas.aliyuncs.com, generativelanguage.googleapis.com, aiplatform.googleapis.com, oauth2.googleapis.com |
| an ACP CLI | its declared hosts |
Defined in src/hmz/coganchor/fence/ (Fence, enforceable, wrapper, proxy, loopback), src/hmz/coganchor/agents/base.py (fenced, natively, _abroad), src/hmz/coganchor/backends.py (Profile.hosts, reachable).
Sessions and turns
agent(prompt) # a turn in a session nobody keeps
session = agent.new(cwd=None) # nothing is opened with the backend yet
session(prompt) # opens the conversation, then resumes it- Both calls return the answer, stripped (or the schema model, with
schema=). session.idraisesRuntimeError: session has not run a turn yetuntil a turn has landed;session.namedisNoneinstead. Onclaude,codex,dsh,kimi,ompandpiit is set as soon as the backend names the session, during the first turn; elsewhere when that turn lands.- A session runs one turn at a time; concurrent callers of one session are serialised.
- An agent holds its sessions weakly; dropping a session closes it.
session.close()ends whatever holds the conversation open.
Failures
A failed turn raises Failed (a subprocess.CalledProcessError) and leaves the session unopened, so the next call retries rather than resuming something that may not exist. The message ends with what the CLI said and, where classified, (<fault>: <fix>).
| Attribute | Meaning |
|---|---|
Failed.fault | one of backends.FAULTS, or "" |
Failed.fix | what to do about it, or "" |
| Fault | Meaning |
|---|---|
contended | two turns at one local store (database is locked, SQLITE_BUSY), or at one sign-in that refreshes itself the other way round (signs in with a token that refreshes itself) |
spent | a quota or balance used up, said in so many words (quota, insufficient balance, credit balance is too low, billing); read ahead of throttled, since it comes as the same 429 |
throttled | too many requests (429, 529, rate limit, resource exhausted, overloaded, usage limit, …) |
refused | the credential (401, 403, unauthorized, invalid api key, not logged in, token expired, token was revoked, forbidden, …) |
unlisted | the model is not this account's (not allowed to access model, can only access models, is not supported when using, …) |
retired | the model is gone (404, model not found, unknown model, …) |
missing | nothing to run: exit 126 or 127 |
sandboxed | the CLI could not start its own sandbox (bwrap: , cannot create … namespace, …) |
unmirrored | the copy of another machine's work a harness here keeps could not be made at its path (cannot keep the local copy of the work at); read first of all, ahead of the Permission denied it usually carries |
fenced | the run's network fence kept it off a host (is not a host this run may reach, the fence proxy's 403); read ahead of the credentials |
killed | a signal, or exit 129–192, or out of memory, SIGKILL, segmentation fault, … |
dropped | the wire (ECONNRESET, broken pipe, fetch failed, 502/503/504, timed out, …); last of all, Codex's stream disconnected before completion and responseStreamDisconnected, which it also wraps other failures in (not on a line that names content_filter) |
Classification (backends.trouble) reads the last 4096 characters of each stream, the backend's own Profile.signs first, then the shared backends.SIGNS in order; the first match wins. Backend-specific signs: dsh needs a DeepSeek API key → refused; grokcouldn't set model.*unknown model id → throttled; mcode sign in to minimax and oauth bearer is not synced → refused. For agy, whose streams say nothing useful, the newest of cli.log / log/cli-*.log written within the turn's duration (at most 300 s, last 64 KiB) is read last (Profile.journal).
| Also raised | When |
|---|---|
Unrecoverable (a Failed) | no retry could change it: a context-window overflow, a session id the backend will not resume, a budget spent with then="fail". Never retried, never carried to another account. |
Stopped | the agent was stopped. Not a CalledProcessError. |
NotImplementedError | the backend lacks the feature (pursue without goals, interject without steering, fork without forks, offers without tools, shelves without a shell) |
suppress=True returns "" (or None with a schema) for a Failed or a schema mismatch, and nothing else.
Forks
session.fork(*, into=None, cwd=None) returns an unopened child that carries the session's history; its first turn performs the fork.
| Backend | Fork mechanism | Into another cwd |
|---|---|---|
claude | --resume <parent> --fork-session | yes: the transcript is copied to where --resume looks |
codex | thread/fork | yes |
kimi | kimi fork | yes |
grok | grok -p --resume <parent> --fork-session | no |
opencode, mimo | run --session <parent> --fork | no |
pi | --fork <parent> | no |
omp | --fork <parent> | no |
qwen | --resume <parent> --fork-session | no |
| an ACP CLI | session/fork, where the agent serves it | no |
litellm | the conversation's file copied under the child's id by its first turn | yes |
agy, cursor-agent, dsh, mcode | none | — |
- Forking a session that has not landed a turn:
RuntimeError: session has not run a turn yet. - A backend with no fork:
NotImplementedError: <cli> has no way of carrying a conversation into a second one.cwd=elsewhere where unsupported:NotImplementedError. into=another agent of the same backend, account and machine; anything else isValueError.- The child carries the session's effort, budget, skills and callbacks.
- A child driven after its parent has taken another turn raises
RuntimeError. session.forkssays whether this backend forks. Under a flow,agent.fork(session), cut by its firstrun(…, env=…); a fork onto another machine isUnsupportedOperation: <cli> cannot fork a session onto another machine. A session whose turn is given another workdir is carried there the same way, as a fork of itself, where the backend forks elsewhere.agent.recall(session_id, kept, cwd=None)returns a session holding a conversation kept somewhere else -- an earlier run'ssessions/<cli>/, or a copy of one -- which takes no turn of its own; itsfork()carries it on. As that fork's first turn starts, every file underkeptwith the conversation's id in its path is copied intoagent.kept()where it sat, with those of every conversation it was cut from up theforked_from_idits first line names (Codex), and the files they came from are left as they were. Each is linked into place whole, and only where nothing is there yet: a fileagent.kept()holds already is never replaced, and one that differs refuses that turn before anything is copied, with aRuntimeErrornaming the CLI, the conversation and the copy already kept, which carrying this one on would replace. A Claude Code fork in another directory is cut from the transcript brought in, copied to where Claude runs for that directory: on an agent whose turns land on another machine, this machine's mirror of it; on one whose Claude runs there, natively or under a harness elsewhere,NotImplementedError. A backend with no fork:NotImplementedError; nothing of the conversation underkept: aRuntimeErrornaming the conversation and where it was looked for.session.keep(into)is the other half: it copies the filesrecallwould bring in -- the conversation's own, lineage and all, and nothing else ofagent.kept()-- intointo, as they stand, leaving them as they were. Before any turn has landed, or with nothing of the conversation kept:RuntimeError; a backend with no fork:NotImplementedError. Under a flow, both are what a session written into a flow's state comes to.
Working directory
agent.new(cwd), agent(prompt, cwd=…), agent.pursue(…, cwd=…), agent.batch(…, cwd=…) and agent.batch_new(n, cwd) open sessions at a directory; None is the directory the process (or flow) is in. session.cwd is the absolute path as the machine it lands on names it. For an anchored agent it is that machine's path and must be inside the anchor's workspace. Before the first turn:
/srv/nowhere: no directory to open a session in
/tmp/elsewhere is not inside /srv/project, which is the workspace this agent's turns land inA project's own programs
Profile.launchers lists the files in a project that make a CLI start a program. Each entry is a path relative to the session's directory, plus dotted key paths into that file (* is any one key or item). A path ending in / is a directory of plugins. An anchored session whose CLI runs on another machine than its work is refused while any of these is set (Remote execution).
| Backend | Files and keys |
|---|---|
claude | .claude/settings.json, .claude/settings.local.json: hooks.*.*.hooks.*.command, statusLine, subagentStatusLine, fileSuggestion, apiKeyHelper, awsAuthRefresh, awsCredentialExport, gcpAuthRefresh, otelHeadersHelper, enabledPlugins.*, extraKnownMarketplaces; .mcp.json: mcpServers.*.command, mcpServers.*.headersHelper |
agy | .agents/mcp_config.json: mcpServers.*.command; .agents/hooks.json: *.*.*.hooks.*.command; .agents/plugins/ |
codex | .codex/config.toml: mcp_servers.*.command, mcp_servers.*.http_headers_helper, hooks; .codex/hooks.json: hooks.*.*.hooks.*.command |
cursor-agent | .cursor/hooks.json: hooks.*.*.command; .cursor/mcp.json: mcpServers.*.command |
grok | .grok/config.toml: mcp_servers.*.command, plugins; .grok/hooks/; .claude/settings.json, .claude/settings.local.json: hooks.*.*.hooks.*.command |
kimi | .kimi-code/mcp.json: mcpServers.*.command |
pi | .pi/settings.json: extensions, packages, npmCommand; .pi/mcp.json: mcpServers.*.command; .pi/extensions/ |
omp | .omp/settings.json, .omp/config.yml: extensions; .omp/mcp.json, .omp/.mcp.json, mcp.json, .mcp.json: mcpServers.*.command; .omp/extensions/; hooks/ and tools/ under .omp, .claude, .codex, .gemini |
qwen | .qwen/settings.json: hooks.*.*.hooks.*.command, mcpServers.*.command, tools.discoveryCommand, tools.callCommand, ui.statusLine.command |
opencode | opencode.json(c), .opencode/opencode.json(c): mcp.*.command, plugin, formatter.*.command, lsp.*.command; .opencode/plugins/, .opencode/plugin/ |
mimo | the same as opencode, named mimocode |
mcode | .mcp.json: mcpServers.*.command, mcpServers.*.headersHelper |
dsh, litellm | none |
Async and batches
aturn, apursue and abatch are awaitable twins with the same arguments; each turn runs on a thread of its own. Two awaited turns on one session run one after the other; on two sessions, concurrently.
agent.batch(prompts, *, suppress=False, schema=…, at_once=0, cwd=None) runs one fresh session per prompt, at_once at a time (0: all at once), answers in order. Without suppress it raises the first failure after every turn has landed. batch_new(count, cwd=None) opens sessions without running a turn.
Schemas
schema=Model (a pydantic model) makes the turn answer with an instance.
| Backend | How the shape is held |
|---|---|
claude, agy, grok, qwen | --json-schema (a separate print/command turn on agy, grok, qwen; a process restart on claude) |
mcode | --output-schema |
codex | outputSchema on the turn |
| every other | the schema is put in the prompt; the answer is validated after |
grok and mcode close every object (additionalProperties: false, every property required, no defaults), as Codex requires. An answer that does not validate raises ValueError; suppress=True returns None.
Events
session.stream(prompt, *, schema=None) yields Event(kind, text, whose="", tokens={}, spent=Usage()).
kind | Meaning |
|---|---|
text | the agent talking; one whole utterance |
reasoning | the agent thinking aloud, where the backend reports it (opencode and mimo with thinking=True) |
tool | a tool reached for; where arguments stream, sent at the first fragment naming the path or command |
subagent, subagent-ends | bracket an agent this one started; whose pairs them |
took | an interjected word is now in front of the model |
result | the answer; exactly one closes each turn; carries tokens (per model) and spent |
failed | the turn closed the other way |
agent.watch(listener) registers listener(agent, session | None, event) for every session, which also sees begins, ends (bracketing a turn), asks (a question) and notice (Humanize itself: a rate limit waited out, an account moved, a turn cut off, a wedged backend ended). With no watcher, a notice is written to stderr. A watcher that raises does not fail the flow.
claude alone announces a tool while its arguments are still being written (session.narrates, --include-partial-messages, ClaudeCodeAgentConfig.partial_messages).
Defined in src/hmz/coganchor/agents/base.py, src/hmz/coganchor/agents/event.py, src/hmz/coganchor/backends.py (FAULTS, SIGNS, trouble, journalled).
Steering
session.interject(text) puts words into the running turn; a took event follows once they are in front of the model. session.steers says whether the backend can.
| Backend | Mechanism | Between turns |
|---|---|---|
claude | a user message written on the held stream | accepted while the process is up; RuntimeError: no turn is running to be talked to before the first turn |
codex | turn/steer | RuntimeError |
kimi | queued on the daemon, then steered in | RuntimeError |
pi, omp | an RPC steer command | accepted while the process is up |
| every other | none: NotImplementedError: <Session> cannot be talked to mid-turn |
An anchored session's process ends with each turn, so an anchored claude, pi or omp hears words only during a turn. Under a flow, SteeringAgentMixin.steer(prompt, session=…, queued=True); queued=False interrupts the turn and continues from the prompt.
Goals
agent.pursue(objective, *, suppress=False, cwd=None) runs the backend's own goal feature: the agent decides when the objective is met; until then each turn that would end starts another, and pursue answers with the last.
| Backend | Goal feature |
|---|---|
claude | /goal |
codex | thread/goal/set |
kimi | the daemon's goal, set each time it is asked |
dsh | the goal service, create_goal tool and round driver mounted into the composition |
| every other | NotImplementedError: <Session> has no goal feature, even under suppress |
type(agent).pursuessays whether the backend has one.goals=FalsemakespursueraiseRuntimeErrorand removes work-past-the-turn tools:codexstarts with--disable goals;claudegets--disallowedTools Agent,ScheduleWakeup, CronCreate,CronDelete,CronList,Workflow;dshmounts no goal service.- A budget and
interruptdo not end a goal;agent.stop()does, and so doescuton a backend whose transport it puts down. - A goal is watched as a turn is: what each of its turns says and reaches for reaches the agent's watchers as it happens (on
codex, bracketed by onebeginsand oneendsfor the whole goal), and on stderr where nothing is watching. - Under a flow,
/goal <objective>as the prompt of a role typed withGoalCommandAgentMixin.
Loops
LoopCommandAgentMixin is served by claude only. A flow prompt /loop <interval> <task> is passed to the CLI unchanged; no other prompt is interpreted, and no Python API corresponds.
Hooks
A hook is a Python callable hung on a moment of a turn:
hung = agent.hooks.on(Moment.PERMISSION_REQUEST, hook, tool="Bash") # a context manager too
hung.off() # idempotenthook(occasion: Occasion) -> Verdict | None. Hooks are on the agent and apply to every session; they may be hung and taken down mid-run.
Moment | When | What a refusing Verdict does |
|---|---|---|
SESSION_START | before a session's first turn | nothing |
USER_PROMPT_SUBMIT | before each prompt is sent | skips the turn; adds is appended to the prompt |
PRE_TOOL_USE | the agent reached for a tool | stops the tool on a backend with a hook seam (below); nothing elsewhere |
SUBAGENT_START, SUBAGENT_STOP | an agent of its own started / came back | nothing |
PERMISSION_REQUEST | the backend asks whether a tool may run | denies it, because as the reason |
NOTIFICATION | the agent stopped to ask its user something | nothing |
STOP | a turn ended | sends the agent on, because as the next prompt |
SESSION_END | a session closed | nothing |
Occasion field | Type | Meaning |
|---|---|---|
moment | Moment | |
agent | str | the agent's id |
session | str | the session id, or "" |
prompt | str | the prompt (USER_PROMPT_SUBMIT) |
tool | str | the tool, or the subagent's name |
about | str | the command or path, or the subagent's task |
under | str | the backend's id for a subagent, pairing start and stop |
input | Mapping[str, Any] | the tool's input where known |
said | str | what was said (NOTIFICATION, SUBAGENT_STOP) |
again | int | how many times this turn has been sent on by STOP |
Verdict(refused=False, because="", adds=""). Two hooks on one moment combine: refused if either refused, every adds added. A hook that raises has said nothing, except Stopped, which propagates.
Moments per backend
agent.moments is what the backend runs; hooks.on for any other raises Unhooked: <agent id> does not run <Moment>.
| Backend | the six common moments | PERMISSION_REQUEST | SUBAGENT_START, SUBAGENT_STOP |
|---|---|---|---|
claude, codex | yes | yes | yes |
cursor-agent, mcode | yes | — | yes |
grok, kimi | yes | yes | — |
agy, dsh, mimo, omp, opencode, pi, qwen, an ACP CLI | yes | — | — |
HumanAgent | — | — | — |
The six common moments are SESSION_START, USER_PROMPT_SUBMIT, PRE_TOOL_USE, NOTIFICATION, STOP, SESSION_END.
Permission requests
A refusal reaches the agent only on a backend that waits for the answer:
| Backend | When it asks | Default answer with no refusing hook |
|---|---|---|
claude | at bypass: --permission-prompt-tool stdio routes every request over the stream | yes; a request at read-only is answered no; with no rung nothing is routed |
codex | through the app server, at auto (on-request) and at any rung given approvals | yes at every rung; no with no rung |
grok | session/request_permission, on the held-open transport only | yes at every rung; no with no rung |
kimi | each approval on the daemon's /approvals: at auto, and at read-only or no rung | yes at auto; no at read-only and with no rung |
A hook can turn a yes into a no, never a no into a yes. Under a flow, Codex's refusal carries no reason, so the reason is steered into the turn instead.
Refusing a tool
PRE_TOOL_USE stops a tool only on a backend that takes a hook table for one run (Profile.hooks):
| Backend | Seam |
|---|---|
claude | --settings, the settings literal on the command line, carrying a PreToolUse hook |
qwen | hooks in its virtual home's copy of settings.json, after the user's own |
- The table runs
hmz internal hook --at <socket>, which relays the call to this process and the verdict back; a refusal means the tool does not run andPERMISSION_REQUESTis not asked. - The table is present only while a
PRE_TOOL_USEhook is hung. A hook hung or removed between turns takes effect next turn; one hung mid-turn is read off the stream for that turn. - An anchored turn gets no table; there, and on every other backend,
PRE_TOOL_USEis read off the stream after the tool was announced, and cannot stop it. - Nothing of the user's configuration is read, written or replaced.
Runtime reports
On backends whose CLI is a Node program (Profile.preloads == "NODE_OPTIONS": kimi, pi, qwen, mimo), hanging a PRE_TOOL_USE hook also preloads a script via NODE_OPTIONS that reports what the runtime did as additional PRE_TOOL_USE occasions:
occasion.tool | occasion.about |
|---|---|
spawn | the command line |
read, write | the path |
connect | host:port |
quiet | the process stopped reporting (100,000 reports, or reports dropped) |
kimi | qwen | pi | mimo | |
|---|---|---|---|---|
| Watched | the daemon (every session of the agent) | the launcher and the bundle it re-execs | the held process | its launcher only |
- Reports arrive after the call, on the socket-reading thread; a verdict does nothing.
- Hang before the first turn:
qwen,pi,mimopick up later hooks on their next turn;kimi's daemon, started once, never does. - The CLI's reads of its own install and calls on descriptors are not reported; a Node program the agent runs reports nothing beyond its
spawn. The preload fails open. - Not installed for an anchored turn. The socket is named by
HMZ_PRELOAD_ATandHMZ_PRELOAD_IN.
Flow hooks
Under a flow, hooks are async functions on the flow agent (agent.on_stop(fn), agent.on_permission_request(fn), …). The harness driver fires SESSION_START, USER_PROMPT_SUBMIT, STOP and SESSION_END itself around each turn, and carries PreToolUse, PermissionRequest, Notification, SubagentStart and SubagentStop from the CLI's threads. A moment waits at most HOOK_TIMEOUT (900 s) for the flow's hook before it is answered as if nothing were hung; a question to the user waits indefinitely. A flow declaring a hook mixin its harness does not serve is refused before its first turn.
Defined in src/hmz/coganchor/agents/hooks.py, src/hmz/coganchor/agents/preload/, src/hmz/runtime/flowing/harnesses.py.
Questions
An agent may stop mid-turn to ask its user; Question(text, options=(), asker="").
| Callback | Called | Returns |
|---|---|---|
agent.ask | when the agent asks | the answer, or None; unset: the backend is told nobody answered |
agent.waiting | as each turn starts | list[str] said while no turn was open, prepended to the turn |
agent.prompting | between turns, by agent.prompted() | the next prompt, or None when there is none; prompted() raises Stopped for a stopped agent |
A watcher sees each question as an asks event. Under a flow, questions go to the role's on_ask_user hook where the role declares AskUserHookAgentMixin (served by claude, codex, kimi, omp, pi); otherwise the backend is told nobody answered.
Stopping and budgets
agent.stop() ends the running turn of every session, closes them, and makes every later call raise Stopped. agent.stopped says whether it has happened. What the turn changed stays.
Budgets
hmz.coganchor.agents.Budget (frozen):
| Field | Default | Meaning |
|---|---|---|
output | 0.0 | output tokens one turn may write; 0 is no cap |
seconds | 0.0 | wall-clock seconds one turn may run (tool time counts); 0 is no cap |
when | "next-response" | next-response: let the answer in progress land; immediately: end the turn where it stands |
then | "end" | end: answer with what was said; fail: raise Unrecoverable |
- Per turn: every turn starts with the whole budget.
Budget()caps nothing. AgentConfig.budgetis the agent's;session.budget = …overrides it for one conversation from its next turn.- Held off the live meter (Usage);
secondsbites with no output arriving. Onagy,grok,qwenand ACP CLIs, whose drivers do not feed the meter,outputnever bites. - A turn ended by its budget has landed: its edits stay and the next turn resumes the session. It is never retried.
- A budget's cut-off wins over a
STOPhook that would send the agent on.
Interrupting
session.interrupt(*, why) ends the running turn and leaves the session usable (no-op with no turn). session.cut(*, why) does the same and, on a backend with cuts_transport, puts the shared transport down, ending every turn on it and any goal; the next turn restarts it and resumes by id, except on dsh, whose next turn is refused (Unrecoverable).
| How the backend holds a turn | interrupt reaches | cut reaches |
|---|---|---|
a command per turn: cursor-agent, mcode, opencode, mimo, and the command turns of agy, grok, qwen | the command and its children | the same |
a held process: claude, pi, omp, the ordinary turns of agy, grok, qwen | that process; pi and omp are first sent abort and given 5 s | the same |
a shared transport: codex (app server), dsh (SDK runtime) | nothing is taken down; the turn stops at the transport's next message | the transport and every turn on it |
kimi (daemon) | the prompt is aborted (POST …/prompts/<id>:abort) | that, then the daemon's whole process tree |
| an ACP CLI | session/cancel | the same |
A cut turn still ends on exactly one result, carrying what had been said; a CLI that sends whole messages only answers "" if cut during its first.
Allowances
A flow run's budget (-p budget.<limit>=) is held by the flow runtime, which hands each turn what remains. For agents driven directly, Ledger(Allowance(hours=0, tokens=0, dollars=0), agents) assigned to agent.allowance stops every agent of the set when any cap is reached.
Allowance field | Unit | 0 |
|---|---|---|
hours | wall-clock hours | no cap |
tokens | millions of output tokens | no cap |
dollars | US dollars (an unpriced model counts as nothing) | no cap |
Checked by SessionBase at both edges of every turn and as each session closes; a turn asked for under a spent allowance raises Stopped. Clones and stand-ins spend the same allowance. ledger.reads() has seconds, output, dollars (None where nothing is priced), floor (money is a lower bound), blind (caps nothing can read); ledger.over() says why, or ""; ledger.spent says whether it has been found over.
Usage and cost
session.spent() and agent.spent() return a Usage: a mapping of kind to tokens, with input, output and total attributes. agents.KINDS is ("input", "output", "cache_read", "cache_write", "reasoning"). A kind a backend does not report is absent.
| Backend | counts | The meter moves |
|---|---|---|
claude | input, output, cache_read, cache_write | on each message |
codex | input, output, cache_read (cached reads taken out of input) | on thread/tokenUsage/updated |
dsh | input, output, cache_read, cache_write | on each finalised assistant message |
litellm | input, output, cache_read, cache_write (cached reads taken out of input) | on the usage of the turn's one request |
pi, omp | input, output, cache_read, cache_write | on each finalised assistant message |
opencode, mimo | all five | on each step |
kimi | input, output, cache_read, cache_write | on each turn.step.completed notification |
cursor-agent, mcode | input, output, cache_read, cache_write | once, on the closing result |
agy | input, output, cache_read, reasoning | never: usage is on the result event only |
grok, qwen | input, output, cache_read, cache_write | never: usage is on the result event only |
| an ACP CLI | none | never |
rate(over=WINDOW): tokens per second by kind, over the lastoverseconds of wall clock (agents.WINDOW == 300); a younger run is measured over its life.juice(over=WINDOW): output tokens per model request;0.0for a window with none.- The
resultevent'sspentis the turn's usage andtokensits per-model totals. hmz.coganchor.prices.cost(usage, model)prices aUsagekind by kind;price(model)returns thePrice. Both read$TMPDIR/humanize-<uid>/prices.json(fetched fromhttps://openllmprices.com/data/prices.json, refreshed after 24 h;HUMANIZE_PRICESpoints elsewhere or turns fetching off withoff) and returnNonefor an unlisted model.- The TUI's running cost reads the CLIs' own logs as they are written, for
claude,codex,dsh,kimiandmcode; for the rest it moves as each turn lands. Each model request is counted once: Claude's rows sharing a message id (one per content block, each with the whole usage) and Codex'stoken_countrows with an unmovedtotal_token_usageare one request. Only rows the log timestamps at or after the moment the run opened that session count: a conversation carried on from an earlier run, or forked from another, keeps that one's rows, and a row with no timestamp is counted. What is shown per model is the higher of what the logs and the backends say, never the sum.
Defined in src/hmz/coganchor/agents/event.py (Usage, KINDS), src/hmz/coganchor/agents/allowance.py, src/hmz/coganchor/prices.py, src/hmz/runtime/watching/tally.py.
Skills
Skills installed for a CLI are the CLI's own; Humanize does not disable them or write its settings. hmz.coganchor.agents.skills.skills(cli) lists what the CLI would load here.
agent.loaded / agent.loads(loaded) are the skills whoever drives the agent brings (under a flow, the role's _skills). session.skills / session.loads(names | None) select which of those one conversation carries, from its next turn; a name not brought is ignored.
A backend whose profile names a user-level skills directory (Profile.lodges) is given the brought skills in its virtual home: linked into that home's skills directory beside links to the user's own, a brought skill taking the place of the user's of the same name. opencode and mimo are told where the brought skills are instead, under skills.paths in their config variable, which they read after their own skill directories. Any other backend gets none. Nothing is ever put in the workspace.
| Backend | Put at | Skill directories read (Profile.works, relative to the workspace) |
|---|---|---|
claude | $CLAUDE_CONFIG_DIR/skills/ in its virtual home | .claude/skills |
codex | $CODEX_HOME/skills/ in its virtual home | .agents/skills, .codex/skills |
grok | $GROK_HOME/skills/ in its virtual home | .grok/skills, .agents/skills, .claude/skills, .cursor/skills |
qwen | $QWEN_HOME/skills/ in its virtual home | .qwen/skills, .agents/skills |
pi, omp | $PI_CODING_AGENT_DIR/skills/ in its virtual home | pi: none read headless (trust-gated); omp: .omp/skills, .agents/skills |
agy | skills/ in its virtual home (--app_data_dir) | .agents/skills |
cursor-agent | $CURSOR_CONFIG_DIR/skills/ in its virtual home | .cursor/skills |
kimi | $KIMI_CODE_HOME/skills/ in its daemon's virtual home | .kimi-code/skills, .agents/skills |
mcode | $MINIMAX_DATA_DIR/skills/ in its virtual home | .minimax/skills, .claude/skills, .agents/skills |
opencode, mimo | skills.paths in OPENCODE_CONFIG_CONTENT / MIMOCODE_CONFIG_CONTENT | .agents/skills and their own (.opencode/skill[s], .mimocode/skill[s], and for opencode .claude/skills) |
dsh, an added CLI | none: the turn runs without them | none |
A virtual home is built per process, so a session told to carry other skills restarts its process and resumes the conversation (
agy), or carries them from its next command (cursor-agent,mcode). A Codex app server or a Kimi daemon is shared by an agent's sessions and carries every skill the agent was given; a change of those starts a new one for the next turn.pireads workspace skills only for a trusted project, which is why brought skills go to its user level; pass further paths withPiAgentConfig.skill_paths(--skill).Under a native anchor, skills are written into the virtual home built on the target; under a supervised anchor a virtual home is built here, where the CLI runs; under a harness on another machine they are carried there and lodged in the virtual home built there.
agyunder a native anchor gets none (its home is moved by a flag, which the anchor does not write), and neither doopencode/mimoanywhere but here (the paths they are told are this machine's).
Virtual homes
A backend whose profile names files it rewrites as it runs (Profile.rewrites) or a user-level skills directory (Profile.lodges) is started, a process at a time, with its home variable (Profile.home_var) naming a directory built for that process on the machine it runs on (hmz.coganchor.homes.build, AgentBase.virtual, AgentBase.homed) and removed once the process is over. A CLI no variable moves names it by a flag instead (Profile.home_flag): agy's --app_data_dir, which takes it relative to ~/.gemini and is given one climbing out of there (Home.argv, put right after the program's name).
| In the virtual home | Is |
|---|---|
each of Profile.rewrites | a copy of the user's file, taken from where the CLI would have read it: under Profile.outside's path while the home variable is unset, and a provider's copy where its account answers that path; a directory starts empty |
Profile.lodges | a directory of links to each of the user's own skills, and to each brought skill |
the session paths (Profile.sessions) the user's home lacks yet | made in the user's home, then linked; one named by a glob, which cannot be, is moved into the user's home as the virtual one is removed |
| the session paths of a turn a run keeps on a machine that cannot supervise it | linked into <epic>/sessions/<cli>/ instead, made there where a directory; a glob links each matching name of the user's home or of that directory, and what the CLI creates under a new matching name is moved there as the virtual home is removed (Where sessions are kept) |
| everything else in the user's home | a link to it |
- What the CLI writes into a copy goes with the home; the user's own files are never written. Credentials, caches and sessions are reached through the links, so a refreshed token and a conversation land where they always do; a supervisor answering session or credential paths answers them under the virtual home too.
- A CLI that saves a file by renaming another over it replaces the link with a file of the virtual home's own. As the home is removed (
Home.drop, andhomes.BACKon a native anchor's target), each link so replaced is written back to what it pointed at -- or to the provider's or account's copy a supervisor answers that path with: a file whole and atomically, keeping the mode the one there had; a directory a new or changed file at a time. Never over a file as new or newer, which another process of the same account saved since: the last to save wins. So a refreshed token (.credentials.json,auth.json,credentials) and a session file a CLI replaces are kept; the copies ofProfile.rewritesstill go. - A path of its home a CLI writes down to read back later (
Profile.records) names the virtual home, which is gone once the process is. As the home is removed (Home.drop), each such path whose file is there is rewritten with the home's links followed, to where the file really is. Codex finds a thread's rollout bythreads.rollout_pathin itsstate_*.sqlite, so this is what lets a Codex conversation be resumed, forked or carried on after its app server has gone. On a native anchor's target, where no Humanize runs,homes.BACKdoes the same with the target'ssqlite3, and leaves them as they are where it has none. - A brought skill sent to a native anchor's target is written a file at a time, each beside itself and renamed into place with its own mode (a script stays executable); one that cannot be written whole is removed rather than left half there.
Profile.securesnames a variable set to the user's own value of the home variable (empty where unset), unless the environment sets it: Claude Code'sCLAUDE_SECURESTORAGE_CONFIG_DIR, which keeps its keychain item and.credentials.jsonwhere they were.- The system level is never replaced, and the project level is the workspace, read as the CLI reads it. What Humanize itself manages for a CLI with no flag or variable for it is merged into the copy at the user level: Qwen Code's effort, hook table and
humanizeMCP server intosettings.json, a gateway account's[model.*]entry into Grok Build'sconfig.toml, a gateway account's provider into pi'smodels.json, and Humanize's agents into agy'sagents/(a directory of the home's own, with links to the user's agents beside them). Each is said as data (Virtual.merges, a session's_merges) and applied as the home is built, by a function its driver registers (homes.merger), so it is applied wherever the home is built; what a merge put in place of a link is never written back. - A fenced agent may write the directory its virtual homes are built in.
- Under a native anchor whose account is projected, the virtual home is that account's: the session paths stay in it, and none of the target user's credentials is linked or copied. Under a native anchor nothing Humanize manages is merged into the copies there.
- A harness on another machine (
AnchorConfig.harness,hmz.coganchor.elsewhere.afar) is started in one built on that machine:AgentBase.homedbuilds none here, and the anchor's options carry what it is made of (AnchorConfig.home, the session's skills and merges) to thehmz internal anchorthere, which builds it from that machine's user's own configuration around the CLI it supervises, keeps it on that machine, and removes it when the CLI exits. The brought skills are carried there first (Remote execution › Where the harness runs). Agrokorpiaccount behind a gateway is still refused there.
| Backend | Profile.rewrites | Profile.lodges | Profile.secures |
|---|---|---|---|
claude | .claude.json (from ~/.claude.json while CLAUDE_CONFIG_DIR is unset), backups, settings.json | skills | CLAUDE_SECURESTORAGE_CONFIG_DIR |
codex | config.toml | skills | none |
grok | config.toml | skills | none |
qwen | settings.json | skills | none |
pi | models.json, settings.json | skills | none |
omp | none | skills | none |
agy | none | skills (and Humanize's agents in agents/, beside links to the user's) | none |
cursor-agent | none | skills | none |
kimi | none | skills | none |
mcode | config.yaml | skills | none |
Fake tools
session.offers([FakeTool(name, about, call, takes=None)]) gives the agent a tool whose call runs call in this process; offers(None) withdraws them. FakeTool is one type, from hmz.coganchor.faking, exported here and by hmz.flows: a flow's _tools are handed to offers as they were declared, with only call wrapped to run on the flow's loop as the flow.
FakeTool field | Meaning |
|---|---|
name | what the agent calls it |
about | what it is for, said to the model |
takes | a pydantic model of its arguments, or None for none |
call | called with the model (or nothing); its return value goes back as text; raising is the tool failing |
- Served where
session.takes_tools:claude(--mcp-config),codex(-c mcp_servers.humanize.…),qwen(mcpServers.humanize,trust: true, in its virtual home's copy ofsettings.json; the user'smcp.allowed/mcp.excludedand folder trust still apply),opencodeandmimo(mcp.humanizemerged intoOPENCODE_CONFIG_CONTENT/MIMOCODE_CONFIG_CONTENT),agy(a plugin,--add-dir <temp>/~humanize-agy-…holding.agents/plugins/humanize/mcp_config.json),grokand an ACP CLI (ahumanizestdio server in themcpServersof the call that opens the conversation),pi(--extension <hmz>/coganchor/agents/pi_tools.cjs, loaded under--no-extensionstoo; tools namedhumanize_<name>),omp(RPCset_host_tools; tools namedhumanize_<name>) anddsh(a@deepseek-ai/dsh-mcp-clientrow in its patch, tools namedmcp__humanize__<name>); elsewhereNotImplementedError: <cli> has no way of being given a tool of a flow's own. - On
grokand an ACP CLI, changing the offered names restarts the process and picks the conversation back up; an ACP CLI serving neithersession/resumenorsession/loadfails that turn instead. grokserves them on held-open turns only: a turn that needsgrok -p(a shape, a fork, and the rest under Grok Build) is refused withUnrecoverablewhile any are offered.- The road is MCP: the CLI is given
hmz internal tools --at <socket>, whichpi's extension starts itself.ompasks for each call back over its own RPC pipe instead. Tools are per agent. - Offering other names between two turns starts
piagain before the next one, and refusesdsh's next turn once its session has taken one;ompis told on the process already up. - A native anchored turn on a non-
localtarget cannot be offered tools (refused), but foromp(AgentBase.pipes_tools), whose calls come back over its RPC pipe wherever it runs.
Fake command lines
session.shelves([FakeCli(name, about, commands)]) puts a program called name on the agent's PATH; every run of it is answered in this process. shelves(None) takes them back. FakeCli and FakeCommand are one type each, from hmz.coganchor.faking, exported here and by hmz.flows: a flow's _clis are handed to shelves as they were declared, with only each command's call wrapped to run as the flow.
class File(BaseModel):
title: str = Field(description="what the ticket says")
points: int = 3
session.shelves([FakeCli("tix", "Track tickets.", (FakeCommand("file", "File one.", file, File),))])
# the agent's shell: tix --help; tix file --help; tix file --title "red build" --points 5FakeCommand field | Meaning |
|---|---|
name | the subcommand typed after the program's name |
about | its line in --help, and its own --help description |
takes | a pydantic model of its options, or None for none |
call | called with the model (or nothing) |
Field of takes | Option |
|---|---|
| any | --field-name FIELD_NAME, help from Field(description=…) and the default |
| no default | required |
bool | --flag / --no-flag |
list[T], tuple[T, ...], set[T] | --flag [V ...] |
Literal[…], an Enum | --flag {a,b} |
The words are parsed by argparse and validated by the model, so a value the model refuses is a usage error like a missing flag.
| Run | Stdout | Stderr | Exit |
|---|---|---|---|
--help, COMMAND --help | the help | 0 | |
| a line the parser or the model refuses | the usage and NAME COMMAND: error: … | 2 | |
call returns a str | it, with a trailing newline | 0 | |
call returns None | 0 | ||
call returns anything else | it as indented JSON (a model as model_dump(mode="json")) | 0 | |
call raises SystemExit(n) | a non-integer n | n (None is 0, text is 1) | |
call raises anything else | NAME: <Type>: <message> | 1 | |
| a command line taken back since the shell found it | NAME: command not found | 127 |
FakeClirefuses anamethat is not a plain file name (letters, digits,.,_,-, not starting with.or-), no commands, and a command named twice (ValueError).- Served wherever there is a shell (
session.takes_clis): every backend butlitellmand the person at the prompt, whereNotImplementedError: <cli> has no shell to run a command line of a flow's own in. - Command lines are per agent, as tools are. The first one shelved makes
agent.shelf: a 0700 directory under$TMPDIRholdingbin/NAME(a#!/bin/shscript runninghmz internal cli) and the socket.binis put first onPATHin every process of the agent from then on, and a process held open from before is started again for its next turn. callruns on a thread of its own per run. Stdin and the working directory are not passed.- Under a fence the directory is readable. Under a supervised anchor it and the Python running Humanize are kept here (
local_execs,local_paths). A native anchored turn on a non-localtarget with any shelved is refused.
Machine and account
AgentConfig.machine decides where turns land; agent.anchor brings the machine up on first read (Machines). Under a flow, the session's environment decides.
AgentConfig.provider names the account (Providers). agent.provider raises ValueError: <id>: no <cli> provider called '<name>' the first time a turn needs a missing account; it never falls back to the machine's own.
When a turn fails
Before a failure reaches the caller, two things are tried in order: retries at the same place, in the same conversation, and then each place of the fallback chain, in order.
Retries
A place is CLI[@ACCOUNT]/MODEL (agent.spec). Retries are set per place in fallbacks in $HUMANIZE_HOME/settings.yaml, by Hmz().fallbacks.retrying(place, tries, policy, timeout) or the Fallback page of /settings. Nothing is retried by default.
| Policy | Waits (base 1 s, each capped at 60 s) |
|---|---|
none | 0 |
constant | 1, 1, 1, … |
linear | 1, 2, 3, … |
exponential | 1, 2, 4, 8, … |
exponential-jitter (default) | uniformly random up to the exponential wait |
fibonacci | 1, 1, 2, 3, 5, … |
Each fault adjusts the place's retries (fallbacks.ANSWERS):
| Fault | Tries (floor) | Wait | Transport reopened | Fix |
|---|---|---|---|---|
throttled | 1 | at least 30 s | no | the service asks it to slow down, with no quota spent; a wait is what answers it |
spent | 1 | at least 30 s | no | this account has spent its quota; another one, or a wait, is what answers it |
refused | none | — | no | that account needs signing in again |
unlisted | none | — | no | that model is not this account's to name; ask it what it runs and name one of those |
retired | none | — | no | the model is gone or was never this account's; another place is what answers it |
contended | 3 | constant | no | two turns of it are sharing one database |
dropped | 1 | the place's | yes | |
fenced | none | — | no | the run's network fence keeps it off that host, which no sign-in answers; give the role online of ALL to let it through |
killed | 1 | constant | yes | the machine it runs on may be out of memory |
missing | none | — | no | |
sandboxed | none | — | no | this machine will not let it sandbox itself; run it without one, or somewhere it can |
unmirrored | none | — | no | that path cannot be made here; use a workdir whose path you can create here, or set the harness of the runtime the work is on to env |
| unclassified | the place's | the place's | no |
Unrecoverable is never retried or carried.
Refused by fallbacks | ValueError |
|---|---|
| a place that does not parse | '<text>' is not a place: expected CLI[@ACCOUNT]/MODEL |
| an unknown policy | '<p>' is not a retry policy: none, constant, linear, exponential, exponential-jitter, fibonacci |
| negative or infinite numbers | tries and seconds are counts, not debts or infinities |
| a place falling back to itself | <place> cannot fall back to itself |
| a place named twice on one chain | <place> is already one of the places <spec> falls back to |
Fallback chain
Hmz().fallbacks.points(place, [other, another]) writes the places a place's turns go to once its retries are spent, in order. fallbacks.chain(place) is [place, *to] for a place a rule is written against, and [place] for any other, including one that is only on somebody else's chain. agent.stands_in() returns the stand-in at the next place, built once and kept; a stand-in holds the rest of the chain it was reached by and carries on along it, never along a rule of its own place. Each stand-in is retried as its own place's rule says. The turn is taken in a new session at the other place, by an agent configured as this one (effort, rung, skills); settings that were measurements of the old model (of_model fields, e.g. Codex overrides) are dropped when the model changes. An account names no fallback of its own: another account of the same CLI is a place on the chain.
Defined in src/hmz/coganchor/fallbacks.py, src/hmz/coganchor/standin.py.
Watchdog
Every blocking read of a turn runs under a clock that restarts whenever the backend says anything.
| Setting | Value |
|---|---|
| Silence allowed | Profile.silence: 900 s; 360 s for dsh |
| Override | HUMANIZE_WATCHDOG=<seconds>; 0 or less disables it |
| Paused | while the turn waits on a person: a permission prompt, a question, a pausing hook |
When the clock runs out, in order:
- Look. A process (or a descendant) burning CPU gets up to 4 more windows; a suspended or defunct one gets none.
- Interrupt the turn; the conversation is untouched.
- Drop the transport, signalling the process tree; on
codexandkimithe shared server goes, ending the agent's other turns too, and that is said first. - Kill what is left and wait.
Each step emits a notice, <backend> …: has said nothing for <n>s but is still working, has said nothing for <n>s, was asked to stop: …, is not answering; ending it[; <id> is picked back up on the next try], did not go when it was asked; killing it and everything under it. The turn then fails as an ordinary Failed, which retries take against the same conversation.
Defined in src/hmz/coganchor/agents/watchdog.py.
State, logs and traces
| Backend | Home (home_var, else default) | Session log (logs, {ident} the session id) | Trace | Subagents in trace |
|---|---|---|---|---|
agy | ~/.gemini/antigravity-cli | conversations/{ident}.db | yes | yes |
claude | $CLAUDE_CONFIG_DIR, ~/.claude | projects/*/{ident}.jsonl, projects/*/{ident}/subagents/**/*.jsonl | yes | yes |
codex | $CODEX_HOME, ~/.codex | sessions/**/rollout-*{ident}.jsonl | yes | yes |
cursor-agent | $CURSOR_CONFIG_DIR, ~/.cursor | none | no | — |
dsh | $DSH_HOME, ~/.dsh | sessions/*/{ident}/session.v*.jsonl | yes | yes |
grok | $GROK_HOME, ~/.grok | sessions/*/{ident}/updates.jsonl | yes | yes |
kimi | $KIMI_CODE_HOME, ~/.kimi-code | server/events/{ident}.jsonl | yes | yes |
litellm | ~/.cache/humanize/litellm | sessions/{ident}.jsonl, written by Humanize | yes | — |
mcode | $MINIMAX_DATA_DIR, ~/.minimax | v2/sessions/*/*/*/*-session_{ident}/messages.jsonl, {ident} URL-safe base64 without padding (Profile.encodes) | yes | no |
mimo | $XDG_DATA_HOME/mimocode, ~/.local/share/mimocode | SQLite mimocode.db | yes | yes |
omp | $PI_CODING_AGENT_DIR, ~/.omp/agent | sessions/*/*{ident}.jsonl | yes | no |
opencode | $XDG_DATA_HOME/opencode, ~/.local/share/opencode | SQLite opencode.db | yes | yes |
pi | $PI_CODING_AGENT_DIR, ~/.pi/agent | sessions/*/*{ident}.jsonl | yes | no |
qwen | $QWEN_HOME, ~/.qwen | projects/*/chats/{ident}.jsonl | yes | no |
| an ACP CLI | none | none | no | — |
- A turn keeps its sessions in the run's directory, laid out as the CLI's home, rather than in the home (Providers › Where sessions are kept). A trace reads both.
agent.openedis the backend's id for every session the agent opened, oldest first;hmz.runtime.tracing.collect(agents={id: agent.opened, …})gathers them. See Tracing.- Paths kept on this machine under a supervised anchor are in Remote execution › What stays on this machine.
Backend notes
Antigravity
agy. Ordinary turns are lines on one held process started with --input-format stream-json; a slash command (a whole first word such as /help) and a shaped turn run as separate --print commands and resume with --conversation <id>. Changed configuration, customisations or flow resources restart the process; an anchored turn always ends it.
| Field | Default | Meaning |
|---|---|---|
add_workspace | True | --add-dir <session dir>, pinning the session's directory |
print_timeout | 86400.0 | --print-timeout seconds (raised from the CLI's 300; since 1.1.28 a timed-out turn exits successfully with a partial answer) |
disable_slash_commands | False | --disable-slash-commands; with read-only: Unserved: agy cannot run at read-only with disable_slash_commands: its plan mode has no effect while expansion is off |
sandbox | False | --sandbox, the CLI's terminal restrictions; not how a fence is held |
- Usage is cumulative per conversation; each result reports its own increment. Shaped answers validate the final
structured_output. - A fence is held from outside in full. With
onlinecut it still reaches its eligibility hosts (includinglh3.googleusercontent.com) and starts a loopback language server on a kernel-chosen port; the turn is started ashmz-offlineregardless ofweb_search. - Credential:
antigravity-oauth-tokenunder its home (onlyHOMEmoves it). - Each process runs in a virtual home named by
--app_data_dir=<relative path>: the flow's skills are in itsskills/, and the agents a turn is started as (hmz-read-only,hmz-read-only-web,hmz-offline) are written into itsagents/beside links to the user's own, so~/.gemini/antigravity-cli/agentsis never written. An account whose arguments carry--app_data_dirof their own runs in that home as before, without the flow's skills; thereread-onlyorweb_search=FalseisUnserved: agy cannot run at read-only or off the web under an account naming its own --app_data_dir: .... agy replacesjetbox_summaries_proto.pb(its conversation summaries index) rather than writing through it, so that index keeps what Humanize's turns wrote to it only for their process. - Fake tools are a plugin
humanizein a temporary directory added with--add-dir(the session directory is then always added too); a change in the offered names restarts the process. agy runs commands in the first workspace directory by path, so the plugin's must sort after the session's: where it would sort before (a session under/workspacewithTMPDIR=/tmp) the turn is refused withUnserved: agy cannot be offered tools in <dir>. Calls needautoorbypass(or the user's ownmcp(...)allow rule); at other rungs agy denies them.
Claude Code
claude. One process per session:
claude --print --input-format stream-json --output-format stream-json --verbose
[--include-partial-messages]
(--session-id <new uuid> | --resume <id> | --resume <parent> --fork-session)
[--permission-mode plan|acceptEdits|auto|bypassPermissions|manual]
[--permission-prompt-tool stdio]
--settings '<json>' --model <model> [--effort <rung>] [--json-schema '<json>']
[--disallowedTools <list>] [--allowedTools <list>] [--mcp-config '<json>']| Field | Default | Meaning |
|---|---|---|
allowed_tools | () | --allowedTools rules; at most 32, sorted, unique, no ,, each at most 4096 characters (ValueError: allowed_tools must be unique sorted Claude tool rules) |
partial_messages | True | --include-partial-messages |
asks | None | at bypass, --permission-mode manual instead of bypassPermissions, so every tool that would change something is asked about. None: while a PERMISSION_REQUEST hook is hung on the agent. A flow sets it True while its own PERMISSION_REQUEST hook is hung and False otherwise |
- Every turn runs with
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1, so background subagents and commands finish inside the turn. --settingscarriesfastMode(fast tier) and thePreToolUsehook table only when needed.--json-schema, a hook table change, an effort change, a change ofasks, or a change of offered tools restarts the process and resumes the conversation.- A result with
is_error, a non-successsubtype, aterminal_reasonother thancompleted, or astop_reasonofmax_tokens,model_context_window_exceeded,pause_turn,tool_deferredortool_use(without structured output) fails the turn. - An anchored session's process ends with each turn.
- Each process runs in a virtual home (
CLAUDE_CONFIG_DIR), so what Claude writes of.claude.jsonandsettings.jsonis dropped with it. Hooks and the flow's MCP server still go through--settingsand--mcp-config.
Codex
codex. One app server per agent, shared by its sessions:
codex app-server [--strict-config] [--disable goals] [--enable|--disable <feature>]...
[--disable apps] [--disable respect_system_proxy] [--enable use_legacy_landlock]
[--disable shell_snapshot]
[-c web_search="live"|"disabled"] --stdio
[-c <override>]... [-c mcp_servers.humanize.command=… -c mcp_servers.humanize.args=…]Threads are thread/start, thread/resume and thread/fork; turns are turn/start (model, effort, rung, approval, service tier, outputSchema); steering is turn/steer. A turn ends on turn/completed, whose status and error say whether it failed; the thread may fall idle first, which ends nothing.
| Field | Default | Meaning |
|---|---|---|
overrides | () | -c pairs; only model_context_window and model_auto_compact_token_limit, positive integers, the second below the first. Dropped when a fallback changes the model. |
features | () | --enable/--disable pairs by codex features list names; goals and names beginning browser_use, computer_use, standalone_web_search, web_search are refused |
strict_config | False | --strict-config |
approvals | "" | approval policy replacing the rung's: untrusted, on-request, on-failure, never |
unsandboxed | () | names of fake command lines the agent shelves that run outside Codex's sandbox, by prefix_rule(pattern=["<name>"], decision="allow") in rules/hmz.rules of the virtual home, beside links to your own rule files; every other command stays in the sandbox |
| Refusal | ValueError message |
|---|---|
| another override key | model is not a Codex override; expected model_auto_compact_token_limit, model_context_window |
| a non-positive value | model_context_window must be a positive integer, not '-1' |
| wrong order | model_auto_compact_token_limit must be below model_context_window |
goals as a feature | goals is the flow's to say, as AgentConfig(goals=...) -- a second place for it would be two answers |
| a web or browser feature | web_search_request is what the agent may reach for, which the flow says as AgentConfig(web_search=...) and AgentConfig(permission=...) where it declares the place -- a feature is not a second place for it |
bad approvals | approvals must be one of '', 'untrusted', 'on-failure', 'on-request', 'never', not 'sometimes' |
- The server reads
overrides,features,strict_config,web_search, the account and the fence once at start; a change starts a new server for the next turn and each conversation is resumed there by id. Model, effort, rung andapprovalsare per call. -p/--profileand--add-dirare not offered;codex app-serverdoes not take them.- Each app server runs in a virtual home (
CODEX_HOME), so the trust Codex writes intoconfig.tomlfor each workspace it opens a writing thread in is dropped with it. The project'sAGENTS.mdand.codex/config.tomlare read as Codex reads them. - No hook table: Codex runs hooks only once trusted in the user's
config.toml. - On another machine, a sandboxed rung whose fence is at least as narrow is sent
sandboxPolicy: externalSandbox(Remote execution). - Supervised with its commands on another machine (any anchor but
native), the server starts with--disable shell_snapshotunlessfeaturesnames it: Codex writes its shell capture into its own home and sources it from each command, which runs on the target, where that home is not. Each command is a login shell on the target instead. - An
openai-gatewayorazureaccount appends-c model_provider=humanize …(Providers › Gateways); abedrockorbedrock-keyone-c model_provider=amazon-bedrock …, and anollamaorlmstudioone-c model_provider=ollamaorlmstudio(Providers › Ways by backend).
Cursor Agent
cursor-agent. One command per turn:
cursor-agent --print --output-format stream-json --workspace <dir> --model <id>
[--resume=<chat id>] [<rung flags>] [--trust] [--stream-partial-output] [--approve-mcps]
[--add-dir <dir>]... -- <prompt>| Field | Default | Meaning |
|---|---|---|
trust | True | --trust: the workspace is trusted without asking |
partial_output | False | --stream-partial-output; the gathered duplicate message is dropped |
approve_mcps | False | --approve-mcps |
add_dirs | () | --add-dir per entry |
--workspaceis the directory on the machinecursor-agentruns on: for work on another machine withcursor-agenthere, the copy of the work kept here, never the far path.- The effort and tier are written into the id:
composer-2.5athighiscomposer-2.5-high, at the fast tiercomposer-2.5-high-fast. A name already carrying a rung is used as is. Bracket syntax (gpt-5.2[effort=low]) is not built. - The id is checked against the account's last catalogue: an unlisted rung is
Unserved, naming those listed. An account never asked refuses nothing. - On another machine,
workspace-writeruns--force --sandbox disabledwhere the fence writes nothing of the home or system. - Each turn runs in a virtual home (
CURSOR_CONFIG_DIR) carrying the flow's skills inskills/beside links to the user's. cursor-agent-local, pointed at an OpenAI-compatible endpoint withCURSOR_LOCAL_AGENT_BASE_URL,CURSOR_LOCAL_AGENT_API_KEYandCURSOR_ENABLE_AUTHLESS=1, takes the id it serves; under a provider,CURSOR_LOCAL_AGENT_API_KEYis hushed unless set.
DeepSeek Harness
dsh. Driven through deepseek-harness-sdk (>=0.1.5rc1,<0.1.6) in this process; no CLI. Models: deepseek-v4-flash, deepseek-v4-pro. Each runtime starts under the SDK's own sdk profile with one patch of Humanize's over it, holding only what the agent's settings change; the effort goes in the handshake.
| Field | Default | Meaning |
|---|---|---|
compaction | True | keeps dsh-compaction-basic and /compact, compacting at 0.8 of the context window; False unmounts them |
session_compression | "none" | the session log's compression, none or zstd (the tally cannot read zstd) |
goalskeeps the goal service,create_goal,/goaland the round driver;Falseunmounts them. Re-read every turn, so a reconfigured agent gets a rebuilt runtime.web_search:Falseunmounts the web; unset keeps the profile's. Under a gateway account the web keepsweb_fetchand loses its DeepSeek search.- Every runtime runs at
DSH_PERMISSION_MODE=danger-full-access(bypass): the SDK answers no approval request, so the fence is what confines it. - Under a fence that cuts the network, a model endpoint on loopback is unreachable: the runtime connects to loopback directly, never through the fence's proxy.
Unrecoverable: the length refusal, a session id the runtime will not resume, and any turn that would need a new runtime for a session that has already taken one.- A session that has taken a turn cannot leave its runtime. The SDK's
--profile sdkserves onlyinitialize,session/promptandshutdown, and a fresh runtime'ssession/promptcreates the session, which its persistence refuses once the log exists; the runtime's own resume is unreachable. So a new effort, account, composition or set of offered tools, acut, a closed transport, an anchored session's second turn and a session reopened in a later process are refused up front; start a new session. Moving onto the runtime's--profile acp, which hassession/resumeand per-sessionmcpServers, is the eventual fix. DSH_HOMEis the kept directory, and sessions go under itssessions(Profile.told).interjectis unsupported:session/promptqueues andsteeris not on the SDK surface.- Offered fake tools insert a
@deepseek-ai/dsh-mcp-clientrow (serverName: humanize,failOnStartupError: true) into the patch; other names restart the runtime before the first turn and are refused after it. - Without an account, the SDK's saved credentials or
DEEPSEEK_API_KEY/DEEPSEEK_BASE_URL.
Grok Build
grok. Ordinary turns are session/prompt on a held grok agent stdio, which takes only a model, an effort, an approval, an agent profile, a plugin directory and the leader. A tool-withholding rung, web_search=False, a network-cutting fence, a shape, a fork, or any field below but leader set away from default sends the turn to grok -p --output-format streaming-json --resume <id> … instead. grok -p takes no MCP server, so such a turn is refused while fake tools are offered.
| Field | Default | Meaning |
|---|---|---|
leader | False | False: a process per conversation; True: join the shared leader; None: [cli] use_leader in config.toml (sent as --no-leader when fenced) |
sandbox | "" | --sandbox <profile> |
max_turns | 0 | --max-turns; 0 uncapped |
subagents | True | False is --no-subagents |
rules | "" | --rules, appended to its system prompt |
- The
-pprompt is one argument (--single=…); Linux caps an argument at 32 pages, so a prompt over 131062 bytes raises before the process starts. --include-partial-messages,--agent-profileand--plugin-dirare not fields.- Every turn is run with
GROK_FOLDER_TRUST=0: Grok Build reads a project's own skills only from a folder it trusts. - Each process runs in a virtual home (
GROK_HOME); a gateway account's hidden[model.*]entry is written into its copy ofconfig.toml, never into the user's. - A gateway account's session that
grok agentopens or loads on a model other than its hidden[model.*]entry (--modelis taken only for a model in the catalogue it fetched) is put on the entry withsession/set_model. - Through a local LiteLLM gateway: see Through LiteLLM.
Kimi Code
kimi. A kimi web daemon per agent (needs the [kimi] extra), driven over REST and its WebSocket notifications: the daemon has a route into a running turn, a per-turn body for the rung, thinking level and swarm width, and question ids.
| Field | Default here | kimi web's own | Meaning |
|---|---|---|---|
port | 0 (free port) | 58627 | |
open_browser | False | opens one | |
log_level | error | silent | one of fatal, error, warn, info, debug, trace; silent is refused (the driver reads Kimi server: <url>/#token=<token>) |
web_title | None | <dir> | Kimi Code | the web UI title |
- Notifications wake the driver's REST polling (1 s without them); heartbeats are answered.
- Usage comes from
turn.step.completed(inputOther,output,inputCacheRead,inputCacheCreation); the larger of the steps' sum and the session aggregate wins per kind. - A turn is over when the session is seen stopped twice, a wait apart, after it was seen to start. One whose
turn.endednotification saysreason: failedfails, with the daemon'serrormessage, rather than answering with nothing. - The fence goes up once, when the daemon starts; another fence or account starts a new daemon. With
online=False, the port is chosen before start and is the only one inlisten, andWebSearch/FetchURLare indisabled_toolsregardless ofweb_search. --add-dir,--skills-dir,--agent,--agent-fileare ignored bykimi weband not offered.- The daemon runs in a virtual home (
KIMI_CODE_HOME) carrying every skill the agent was given; another set of them starts a new daemon.kimi forkruns in the user's own home, whose sessions the virtual one links.
litellm
litellm. A model called directly through litellm in this process: each turn is one streamed litellm.completion over the session's history and the prompt. No CLI, no tools, no filesystem, no hooks of its own; HarnessKind.LITELLM turns take no environment (env must be None, UnsupportedOperation otherwise). LiteLLMAgentConfig adds no fields.
- The history is
sessions/<id>.jsonlunder the agent's kept directory: asessionheader, then amessagerow per message, each answer with itsmodelandusage. Every turn after the first reads it back; a fork's first turn copies its parent's under a new id. - A shape is passed as
response_format(json_schema), soSessionBase.shapesisTrue. - Under an account the credentials are passed on the call:
api_keyfor a key way,api_baseandapi_keyfor a gateway, the cloud's parameters forbedrock,vertex,azure. Without one, litellm reads the environment as it always does (OPENAI_API_KEY, …). drop_params=True: a parameter the provider does not take, such as an effort, is left off.Unrecoverable: litellm'sContextWindowExceededError.- An interrupt or the watchdog closes the stream; the turn answers with what had arrived.
MiniMax Code
mcode. One command per turn, the prompt on stdin:
mcode exec --output-format stream-json --input - --cwd <dir> [--model <model>]
[--session <id>] [--effort <rung>] [--permission full|smart] [--output-schema '<json>']No fields of its own.
--cwdis the directory on the machinemcoderuns on: for work on another machine withmcodehere, the copy of the work kept here, never the far path.- The session is continued with
--sessionand the id its first turn's lines carry. - Each turn runs in a virtual home (
MINIMAX_DATA_DIR) holding a copy ofconfig.yamland the flow's skills inskills/. - Through a local LiteLLM gateway,
--modeliscustom_provider:hmz-PORT-DIGEST/<model>, a provider the agent adds for the gateway withmcode provider addinto the turn's virtual home, before the turn: it goes with that home, so the account's ownconfig.yamlis never written and nothing is removed when the agent stops. See Through LiteLLM. --effortis sent only where the account's catalogue lists the rung for the model (onlyminimax/MiniMax-M3.1-Flash-Previewtakes rungs).read-onlyis refused;web_search=Falseand a network-cutting fence are refused.- The stream reports its
tasktool asSUBAGENT_START/SUBAGENT_STOP; usage arrives on the closing result, and the running cost reads its session log. - Each start creates
<data directory>.lockbeside its data directory: in a virtual home, beside that home, where a fenced agent may write. Wheremcodestarts in the user's own (a harness on another machine), that is~/.minimax.lock, which a fence that reads but does not write the home cannot grant. A fenced turn's supervisor answers that path from the kept session directory (sessions/mcode/minimax.lock), shared by every agent of the run, and for an agent no run drives from$TMPDIR/humanize-<uid>/mcode/minimax.lock, shared by every such agent of the machine. WithHUMANIZE_SESSIONS=offthe directory is still made and holds only that lock. On a machine that cannot supervise a turn the path is not answered, and only a fence that writes the home letsmcodestart. config.yamlandauth/are credential files, so an account holds its own settings.
Oh My Pi
omp. One omp --mode rpc per session, spoken to in pi's RPC protocol:
omp --mode rpc --model <provider/id> [--thinking <rung>]
[--resume <id> | --fork <parent>] [--approval-mode always-ask|yolo]OhMyPiAgentConfig adds no fields; those PiAgentConfig adds are not offered.
- omp takes no session id up front. Ahead of the first prompt of a session with no id, the driver sends
get_state; thesessionIdit answers issession.named, and the session's id once the turn lands. Later processes start with--resume <id>; a fork's first process starts with--fork <parent>and learns its id the same way. - A turn ends on the
agent_endthat is terminal (neitherisTerminalnoryieldedisfalse); omp sends noagent_settled. A prompt omp answers itself, a slash command of its own (agentInvoked: false), also ends the turn. - At
read-only, anextension_ui_requestoffering exactlyApprove,Denyis answeredDenywithout asking anyone. Any other is a question, as on pi. - Offered fake tools are
set_host_tools(loadMode: essential) ahead of the prompt whenever their names change; eachhost_tool_callruns on a thread of its own and is answeredhost_tool_result. A refusedset_host_toolsfails the turn. - No
NODE_COMPILE_CACHEand noNODE_OPTIONSpreload: omp is one Bun-compiled executable, soPRE_TOOL_USEis read off the turn only. - The fence also lets through the
baseUrlof the model's provider inmodels.yml(elsemodels.yaml) in omp's home, and of that provider's models; for a model named without a provider, of every provider declared there. Nothing is sent to omp underonlineNONE. - No credential files: omp keeps its sign-ins in
agent.db, beside its settings, which an account does not copy. An agent with no account runs as this machine's omp sign-in.
opencode and mimocode
opencode and mimo (one program under two names). One command per turn:
opencode run --format json --dir <dir> --model <provider/id> [--variant <effort>]
[--session <id> | --session <parent> --fork] [--agent <name>] [--thinking] [--pure]
[--auto | --dangerously-skip-permissions] <prompt>| Field | Default | Meaning |
|---|---|---|
cli_agent | "" | --agent NAME |
thinking | False | --thinking: reasoning streamed as reasoning events |
pure | False | --pure: no plugins |
unattended | None | --auto (opencode) / --dangerously-skip-permissions (mimo); None: on where a rung is named |
permission_table | None | OPENCODE_PERMISSION / MIMOCODE_PERMISSION carrying the rung and web answer; None: written where either is said; False refused beside a withholding rung or web_search=False (Unserved: permission_table=False withholds the only table this backend hears permission='read-only' in) |
- The table is written for the turn, never to a settings file. Its
readandeditrules are relative to the top of the git checkout the session is in, or/. - Fake tools are a
localserverhumanizeinmcp, merged into theOPENCODE_CONFIG_CONTENT/MIMOCODE_CONFIG_CONTENTthe turn would otherwise have (the account's, else an exported one); the model sees them ashumanize_<name>. The flow's skills the session carries are added toskills.pathsthere, after any it already names. A value that is not a JSON object (JSONC included) fails the turn withValueError. - mimocode reads
~/.claude.jsonat start; where the fence does not let it,MIMOCODE_DISABLE_CLAUDE_CODE=1is set. - Both keep sessions in SQLite; traces read it with a query.
pi
pi. One pi --mode rpc per session:
pi --mode rpc --model <model> --session-id <id> [--provider <gateway>] [--fork <parent>]
[--thinking <rung>]
[--exclude-tools bash,edit,write,powershell] [--no-context-files] [--no-extensions]
[--offline] [--append-system-prompt <s>]... [--skill <path>]...
[--extension <hmz>/coganchor/agents/pi_tools.cjs]| Field | Default | Meaning |
|---|---|---|
compiled | True | NODE_COMPILE_CACHE at $TMPDIR/humanize-<uid>/compiled/pi unless already set; not for anchored turns |
context_files | True | False is --no-context-files |
extensions | True | False is --no-extensions |
offline | False | --offline; forced under online NONE |
append_system_prompt | () | --append-system-prompt per entry (text or path) |
skill_paths | () | --skill per entry |
--session-id rather than --continue, which resumes the newest session in the directory. Each process runs in a virtual home (PI_CODING_AGENT_DIR): a gateway account's provider (humanize-<hash>) is declared in its copy of models.json, never in the user's, and a models.json that is not plain JSON (pi takes comments) fails the turn with ValueError. pi 0.85.1 has no web tools of its own. Offered fake tools add the --extension, which reads the bridge's argv from HMZ_PI_TOOLS and registers each tool under its own name.
Qwen Code
qwen. Ordinary turns are lines on a held process (--input-format stream-json); a shaped turn runs as a separate command (--json-schema is refused with stream-json input). The opening turn is --session-id <new uuid>; later ones --resume <id>; a fork --resume <parent> --fork-session. An id already used in the project is retried under a new one.
| Field | Default | Meaning |
|---|---|---|
headless_defaults | True | general.preventSystemSleep: false and general.enableAutoUpdate: false, where the user's settings.json says nothing of either |
compile_cache | True | NODE_COMPILE_CACHE at $TMPDIR/humanize-<uid>/compiled/qwen unless set; not for anchored turns |
partial_messages | False | --include-partial-messages |
- Each process runs in a virtual home (
QWEN_HOME). The effort (model.reasoningEffort), aPRE_TOOL_USEtable (hooks, after the user's own, withdisableAllHooks: false) and offered fake tools (mcpServers.humanize) are merged into its copy of the user'ssettings.json; the user's own other servers and hooks stay. Asettings.jsonthat is not plain JSON (Qwen takes comments) fails the turn withUnrecoverable. The system layers are the machine's: aQWEN_CODE_SYSTEM_SETTINGS_PATHorQWEN_CODE_SYSTEM_DEFAULTS_PATHof the user's is watched for changes and never set. Settings the driver cannot parse keep a fresh process per turn. - Being the user level, what is merged is outranked by the workspace's own
.qwen/settings.jsonand the system layer: a project that setsdisableAllHooksormodel.reasoningEffortthere gets its own way. A fenced agent may write its virtual home, that copy included; Qwen Code reads it as the process starts. - A change of effort, of the hook table or of which tool names are offered restarts the process.
- Usage counts each assistant message once; the terminal summary fills only missing fields.
A CLI of your own
Any agent speaking the Agent Client Protocol can be added as a backend, from the TUI (/settings accounts → Add a custom CLI) or with backends.remember(name, command). It is written under clis in $HUMANIZE_HOME/settings.yaml and is a backend in every workspace from the next prompt.
clis is a mapping keyed by name; each value is the command (a list of strings), or a mapping:
clis:
my-agent:
command: [my-agent, --acp]
hosts: [api.my-agent.example]
state: [~/.my-agent]| Key | Meaning |
|---|---|
command | argv that starts it |
hosts | hosts reachable under online NONE, spelled as Profile.hosts spells them; none: a network-cutting permission is refused (HarnessSandboxed) naming what to declare |
state | paths it writes its state to, granted whatever user says; none: nothing of the home is writable |
backends.declared(name) returns (hosts, state); backends.forget(name) removes one. An unreadable settings file reads as no added CLIs.
remember refusal | ValueError message |
|---|---|
| no command | an added CLI needs a command to start it with |
| the command is a built-in backend (any alias, by its last path component) | claude is already a backend Humanize drives |
| a name other than the command's own | an added CLI is called what it runs, so my-agent is added as my-agent rather than as foo |
Protocol use: initialize; session/new; session/prompt per turn; session/update notifications for output; session/resume or session/load (whichever the agent advertises) to continue; session/fork; session/cancel. No model, effort or mode is sent (session/set_model, session/set_config_option, session/set_mode are unused): model and effort read as configured. Every permission request is granted, by the option's kind. It cannot be steered, has no goal, reports no usage and has no trace.
AcpAgentConfig fields, off by default:
| Field | Type | Meaning |
|---|---|---|
cli | str | the name it was added under |
command | tuple[str, ...] | overrides the recorded command |
reads_files | bool | serve fs/read_text_file from this machine |
writes_files | bool | serve fs/write_text_file |
terminals | bool | serve terminal/* (start, read, wait, kill) |
mcp_servers | tuple[McpServer, ...] | MCP servers added to the agent's own; fake tools add a humanize one |
The first three are refused for an agent under a native anchor. Under a fence, what this client does for the agent is held to the same fence: a refused file or path is answered with reject_once, and a terminal command runs inside the fence.
Defined in src/hmz/coganchor/backends.py (speaking, declared, remember, forget, _speaks), src/hmz/coganchor/agents/acp.py.
Bundle patching
hmz.coganchor.agents.patching rewrites a copy of a Bun standalone executable (claude, opencode) where no shallower route reaches a behaviour. patched(profile, path, patches) returns the copy's path, or None.
- Never the installed binary: the copy lives in a directory of Humanize's own for one session.
- Fingerprinted against
Profile.bundles(a path glob, a regular expression one module must match, an optional SHA-256); the system tests check them against the installed CLIs. - Any mismatch or failure returns
None, is logged, and the shallower route is used. - Every rewrite is the same length as what it replaces; the module's precompiled bytecode is cleared.
Names, clones and the person
| Attribute | Meaning |
|---|---|
agent.id | the given name=, or a codename; it never changes, and a flow's role is not it |
agent.backend | the backend's name, or an ACP CLI's |
agent.opened | every session id it opened, oldest first |
agent.sessions | the sessions still held |
agent.config | its config |
agent.spec | CLI[@ACCOUNT]/MODEL |
An unnamed agent gets a codename from Amphoreus (Honkai: Star Rail): 29 designations used verbatim while any is free (half the time), otherwise morphemes joined at a capital plus digits. No codename repeats in one process.
agent.clone(*, config=None, name=None, skills=None) makes a fresh agent: same settings unless given, no conversations, spending, watchers or hooks, a new codename unless named; it spends the same allowance. reconfigure(config), runs_on(machine), loads(skills) and disable_goals() change an agent in place; flows have none of them, only derive.
The person as an agent
HumanAgent(name="human") asks the person and answers with what they typed. It runs no model, spends nothing, runs no moment, and emits no begins/ends.
With schema=, the person is asked one question per field:
| In the model | Asked as |
|---|---|
description= | the question (the field name where none) |
Literal[…] | those words as options |
bool | yes / no |
| a default | or - for <default>; - takes it |
list[str] | one comma-separated line |
A refused value is asked again a bounded number of times. Under suppress, an unfinished questionnaire answers None. In a flow the person is an Outworlder role; an away outworlder answers with the model built from its defaults, or raises OutworlderAway for a field without one.
Environment variables
The variables the agent layer reads or sets. Every variable Humanize reads is listed in Environment variables.
| Variable | Read by | Effect |
|---|---|---|
HUMANIZE_HOME | everything | Humanize's home (default ~/.hmz): settings.yaml, providers/; prices.json and compiled/ are in $TMPDIR/humanize-<uid>/ |
HUMANIZE_WATCHDOG | the watchdog | seconds of silence allowed; 0 or less disables it |
HUMANIZE_SESSIONS | session keeping | off, 0 or no: sessions stay in the CLI's home |
HUMANIZE_PRICES | prices | the price list's URL or path; off, 0, no, none or empty disables fetching |
| each backend's home variable | the drivers, traces, providers | see State, logs and traces |
XDG_CONFIG_HOME | credential and skill paths | the config/ root |
Set by drivers on a turn:
| Variable | Backend | Value |
|---|---|---|
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS | claude | 1 |
NODE_OPTIONS, HMZ_PRELOAD_AT, HMZ_PRELOAD_IN | kimi, pi, qwen, mimo | the runtime-report preload, only while a PRE_TOOL_USE hook is hung |
NODE_COMPILE_CACHE | pi, qwen | $TMPDIR/humanize-<uid>/compiled/<cli>, unless already set |
OPENCODE_PERMISSION, MIMOCODE_PERMISSION | opencode, mimo | the permission table |
MIMOCODE_DISABLE_CLAUDE_CODE | mimo | 1 where the fence hides ~/.claude.json |
PKG_NATIVE_CACHE_PATH | dsh | inside the fence's tmp, when fenced |
TMPDIR, TMP, TEMP, HTTPS_PROXY and related | fenced turns | the fence's scratch directory and proxy |
HUMANIZE, HUMANIZE_TARGET, HUMANIZE_WORKSPACE | anchored turns | see Remote execution |
API summary
type Where = str | os.PathLike[str] | None
class AgentBase:
moments: ClassVar[frozenset[Moment]]
pursues: ClassVar[bool]
service_tiers: ClassVar[tuple[str, ...]]
rungs: ClassVar[tuple[str, ...]]
counts: ClassVar[frozenset[str]]
id: str; backend: str; config: AgentConfig; effort: str # settable
spec: str; opened: list[str]; sessions: list[SessionBase]; stopped: bool
anchor: AnchorConfig | None; provider: Provider | None
hooks: Hooks; loaded: tuple[Loaded, ...]; allowance: Ledger | None
ask: Callable[[Question], str | None] | None
waiting: Callable[[], list[str]] | None
prompting: Callable[[], str | None] | None
def __call__(prompt, *, suppress=False, schema=…, cwd: Where = None) -> str | T | None
def pursue(objective, *, suppress=False, cwd: Where = None) -> str
def new(cwd: Where = None) -> SessionBase
async def aturn(...); async def apursue(...)
def batch(prompts, *, suppress=False, schema=…, at_once=0, cwd: Where = None) -> list
async def abatch(...)
def batch_new(count, cwd: Where = None) -> list[SessionBase]
def spent() -> Usage; def rate(over=WINDOW) -> Usage; def juice(over=WINDOW) -> float
def clone(*, config=None, name=None, skills=None) -> Self
def reconfigure(config); def runs_on(machine); def loads(skills); def rename(name)
def disable_goals(); def stop()
def watch(listener: Callable[[AgentBase, SessionBase | None, Event], None])
def asked(question) -> str | None; def prompted() -> str | None
def stands_in() -> AgentBase | None
def node() -> Provider
def environment() -> Mapping[str, str]; def hushed() -> frozenset[str]
def fenced() -> Fence | None; def natively(fence: Fence) -> Fence
class SessionBase:
shapes: ClassVar[bool]; steers: ClassVar[bool]; takes_tools: ClassVar[bool]
narrates: ClassVar[bool]; forks_elsewhere: ClassVar[bool]; cuts_transport: ClassVar[bool]
id: str; named: str | None; cwd: str; forks: bool
effort: str; budget: Budget | None # settable
skills: tuple[str, ...]; tools: tuple[FakeTool, ...]
def __call__(prompt, *, suppress=False, schema=…) -> str | T | None
def stream(prompt, *, schema=None) -> Iterator[Event]
def pursue(objective, *, suppress=False) -> str
def fork(*, into=None, cwd: Where = None) -> SessionBase
async def aturn(...); async def apursue(...)
def interject(text); def interrupt(*, why); def cut(*, why)
def loads(skills | None); def offers(tools | None)
def spent() -> Usage; def rate(over=WINDOW) -> Usage; def juice(over=WINDOW) -> float
def close()
class Failed(subprocess.CalledProcessError): fault: str; fix: str
class Unrecoverable(Failed): ...
class Stopped(Exception): ...
class Unserved(ValueError): ...
class Unfenced(Unserved): ...
class Unhooked(ValueError): ...CommandSessionBase (one command per turn) and StreamSessionBase (one long-lived process fed a line at a time) are the two shapes a driver subclasses; the contract is specs/features/agents.md.