Developer Guide¶
This document covers internal architecture and implementation details for contributors and maintainers of terok.
Domain Model Architecture¶
terok's library layer (src/terok/lib/) follows Domain-Driven Design (DDD) conventions with a clear separation between value objects (pure data), entities (identity + behavior), and services (stateful helpers).
Object Graph¶
facade.get_project("myproj") → Project (Aggregate Root)
.config → ProjectConfig (Value Object — dataclass)
.gate → GitGate (Repository + Gateway)
.ssh → SSHManager (Service)
.agents → AgentManager (Strategy + Config Stack)
.create_task(name="x") → Task (Entity)
.get_task("1") → Task (Entity)
.meta → TaskMeta (Value Object)
Key Types¶
| Type | Module | DDD Role | Description |
|---|---|---|---|
Project |
lib.project |
Aggregate Root | Entry point for all project-scoped operations. Wraps ProjectConfig with behavior. |
Task |
lib.task |
Entity | Wraps TaskMeta with lifecycle methods (run, stop, delete, rename, logs). |
ProjectConfig |
lib.core.project_model |
Value Object | Configuration dataclass loaded from project.yml. No behavior. |
TaskMeta |
lib.containers.tasks |
Value Object | Task metadata snapshot (ID, mode, status, workspace path). |
GitGate |
terok_sandbox.git_gate |
Repository + Gateway | Manages the bare git mirror; wraps git CLI. |
SSHManager |
terok_sandbox.ssh |
Service | Generates SSH keypairs and config; keys served via SSH agent proxy. |
AgentManager |
lib.project |
Strategy + Config Stack | Resolves layered agent configuration and provider selection. |
Design Principles¶
Value Objects vs Rich Objects. ProjectConfig and TaskMeta are dataclass data holders — they carry configuration and metadata but have no behavior beyond computed properties. The rich Project and Task objects wrap these and delegate to service functions, providing a natural OOP interface.
Snapshot Semantics. Task captures a point-in-time snapshot of TaskMeta. Mutations (rename(), stop()) update persistent storage but do not refresh the in-memory snapshot. To observe new state, obtain a fresh Task via project.get_task(id). This keeps entities free of implicit I/O.
Lazy Initialization. Project subsystems (gate, ssh, agents) are created on first access, not at construction. This avoids I/O when only a subset of functionality is needed. Since Project uses __slots__, cached_property is not available — the manual pattern (if self._gate is None: ...) is used instead.
Identity-Based Equality. Project.__eq__ compares by project ID; Task.__eq__ compares by (project_id, task_id). Both are hashable, so they work correctly in sets and dicts.
Facade Pattern. lib.facade provides factory functions (get_project, list_projects, derive_project) as the stable entry point. It also re-exports low-level service functions for CLI commands that operate on raw project_id strings.
Module Boundaries¶
Module dependencies are enforced by tach via tach.toml. The key constraints:
- Presentation (CLI, TUI) depends on the facade — never reaches into orchestration or the sandbox directly.
- Orchestration imports directly from the external
terok_sandboxpackage for container lifecycle, shield, gate server, and SSH. - Domain (facade) re-exports sandbox APIs for presentation consumption and adapts them to the project model.
Presentation (CLI, TUI)
└── depends on → domain.facade (stable API boundary)
Domain (facade, Project, Task)
└── depends on → orchestration, terok_sandbox (re-exported for presentation)
Orchestration (task_runners, tasks, environment)
└── depends on → terok_sandbox.* (external package — runtime, shield, gate, SSH)
terok_sandbox (external)
└── depends on → terok_shield (external)
Container Readiness and Log Streaming¶
terok shows the initial container logs to the user when starting task containers and then automatically detaches once a "ready" condition is met. This improves UX but introduces dependencies that developers must be aware of when changing entry scripts or server behavior.
CLI Mode (task run-cli)¶
Readiness is determined from log output. The container initialization script emits marker lines:
- ">> init complete" (from resources/scripts/init-ssh-and-repo.sh)
- "__CLI_READY__" (echoed by the run command just before keeping the container alive)
The host follows logs and detaches when either of these markers appears, or after 60 seconds timeout.
If you modify the init script, ensure a stable readiness line is preserved, or update the detection in src/terok/lib/containers/task_runners.py (task_run_cli) and src/terok/lib/containers/runtime.py (stream_initial_logs).
Timeout Behavior¶
- CLI: detaches after readiness marker or 60s timeout
- Even on timeout, containers remain running in the background. Users can continue watching logs with
podman logs -f <container>.
Key Source Files¶
| File | Purpose |
|---|---|
src/terok/lib/orchestration/task_runners.py |
Host-side logic: task_run_cli, task_run_headless |
terok_sandbox.runtime (external) |
Container state, log streaming: stream_initial_logs, wait_for_exit |
src/terok/resources/scripts/init-ssh-and-repo.sh |
CLI init marker, SSH setup, repo sync |
Important: Changes to startup output or listening ports can affect readiness detection. Keep the readiness semantics stable or adjust terok's detection accordingly.
Container Layer Architecture¶
terok builds project containers in three logical layers:
| Layer | Image Name | Purpose |
|---|---|---|
| L0 | terok-l0:<base-tag> |
Development base (Ubuntu 24.04, git, ssh, dev user) |
| L1 | terok-l1-cli:<base-tag> |
Agent tools (Claude Code, Codex, GitHub Copilot, OpenCode, Mistral Vibe) |
| L2 | <project>:l2-cli |
Project-specific config and user snippets |
L0 and L1 are project-agnostic and cache well; L2 is project-specific.
See container-layers.md for detailed documentation.
Volume Mounts at Runtime¶
When a task container starts, terok mounts:
| Container Path | Host Source | Purpose |
|---|---|---|
/workspace |
<state_dir>/tasks/<project>/<task>/workspace-dangerous |
Per-task workspace |
/home/dev/.codex |
<mounts_dir>/_codex-config |
Codex credentials |
/home/dev/.claude |
<mounts_dir>/_claude-config |
Claude Code credentials |
/home/dev/.vibe |
<mounts_dir>/_vibe-config |
Mistral Vibe credentials |
/home/dev/.blablador |
<mounts_dir>/_blablador-config |
Blablador credentials + isolated OpenCode config (via OPENCODE_CONFIG) |
/home/dev/.config/opencode |
<mounts_dir>/_opencode-config |
Plain OpenCode config (use terok config import-opencode) |
/home/dev/.local/share/opencode |
<mounts_dir>/_opencode-data |
OpenCode data (shared by Blablador and plain OpenCode) |
/home/dev/.local/state |
<mounts_dir>/_opencode-state |
OpenCode/Bun state (shared by both) |
/home/dev/.config/gh |
<mounts_dir>/_gh-config |
GitHub CLI config |
/home/dev/.config/glab-cli |
<mounts_dir>/_glab-config |
GitLab CLI config |
SSH keys are not mounted — the credential proxy's SSH agent serves them over TCP.
See shared-dirs.md for detailed documentation.
Environment Variables Set by terok¶
Core Variables (always set)¶
| Variable | Value | Purpose |
|---|---|---|
PROJECT_ID |
Project ID from config | Identify current project |
TASK_ID |
Numeric task ID | Identify current task |
REPO_ROOT |
/workspace |
Init script clone target |
CLAUDE_CONFIG_DIR |
/home/dev/.claude |
Claude Code config location |
GIT_RESET_MODE |
none (default) |
Controls workspace reset behavior |
TEROK_GIT_AUTHORSHIP |
agent-human (default) |
Maps human/agent identities onto author/committer |
HUMAN_GIT_NAME |
From config or "Nobody" | Human Git identity name |
HUMAN_GIT_EMAIL |
From config or "nobody@localhost" | Human Git identity email |
TEROK_UNRESTRICTED |
1 when unrestricted |
Tells shell wrappers to inject auto-approve flags |
Conditional Variables (based on security mode)¶
| Variable | When Set | Purpose |
|---|---|---|
CODE_REPO |
Always | Git URL (upstream or gate depending on mode) |
GIT_BRANCH |
Always | Target branch name |
CLONE_FROM |
Online mode with gate | Alternate clone source for faster init |
EXTERNAL_REMOTE_URL |
Relaxed gatekeeping | Upstream URL for "external" remote |
Security Modes¶
Online Mode¶
CODE_REPOpoints to upstream URL- Container can push directly to upstream
- Git gate (if present) is used as read-only clone accelerator
Gatekeeping Mode¶
CODE_REPOpoints to the gate server's HTTP endpoint- Container's default
originis the gate, not upstream - Human review required before changes are promoted to upstream
See git-gate-and-security-modes.md for detailed documentation.
Host-Side Service Activation Patterns¶
terok runs several host-side services that containers reach over TCP (Podman cannot securely share Unix sockets into rootless containers). These services use systemd socket activation so they start on demand rather than requiring manual launch, but they follow two distinct patterns dictated by their architectures:
Inetd-style (Accept=yes) — Git Gate¶
The gate handles the git smart-HTTP protocol: each request is
short-lived, stateless, and independent. The systemd socket unit
uses Accept=yes, so systemd itself accepts each TCP connection and
spawns a fresh terok-gate --inetd process with the connection on
stdin. No persistent daemon, no shared state, no concurrency
concerns. "Socket active" genuinely means "serving" — every
connection is handled immediately.
- Unit:
terok-gate.socket(TCP) +terok-gate@.service(instantiated per connection) - When it fits: stateless, short-lived request handlers with no shared state
Persistent daemon (Accept=no) — Credential Proxy¶
The credential proxy is a long-running aiohttp reverse-proxy that
holds an open SQLite credential database, a route table, and an SSH
agent server on a separate port. It serves multiple concurrent
containers simultaneously. The systemd socket unit uses the default
Accept=no: systemd listens on both the Unix socket and the TCP port,
and on the first connection starts the daemon process, handing it the
inherited file descriptors. The daemon then stays running and handles
all subsequent connections itself.
This means there is a brief startup delay (~1–2 s) on the very first container request after a host reboot, after which the proxy is fully warm. The status display reflects this as standby (socket active, service not yet started) vs running (daemon active, TCP ports bound).
- Unit:
terok-credential-proxy.socket(Unix + TCP) +terok-credential-proxy.service - When it fits: stateful daemons with shared resources, concurrent connections, or multiple ports
Why not unify them?¶
Forcing the credential proxy into Accept=yes would spawn a full
Python/aiohttp process per HTTP request, cause SQLite contention across
instances, and orphan the SSH agent port. Forcing the gate into a
persistent daemon would add unnecessary complexity for a service whose
entire protocol is "handle one connection, exit". Each pattern is the
natural fit for its workload.
Why TCP instead of Unix sockets?¶
Podman cannot securely share a Unix socket from the host into a rootless
container. TCP ports on 127.0.0.1 are the pragmatic workaround —
containers reach the host via host.containers.internal:<port>.
This has a real downside: if a port is already occupied, terok would need to pick a different one and propagate the new port to every running container. Unix sockets avoid this entirely (a filesystem path has no collision risk). If Podman (or an alternative) ever gains secure socket sharing, the transport layer should be swapped — the current TCP binding is a bridge, not the target architecture.
Shield (no socket service)¶
terok-shield does not run a host-side service. It operates entirely
via OCI hooks (prestart/poststop) that configure firewall rules when
containers start and stop. No socket activation, no daemon, no TCP port.
Agent Permission Mode Architecture¶
See also: Agent Configuration Compatibility Matrix for a per-agent reference of CLI flags, env vars, config files, and ACP adapter behaviour — including the ACP permission gap and recommended solutions.
Agents can run in unrestricted (fully autonomous) or restricted (vendor-default permissions) mode. The design goal is a single unified code path: the host makes one decision and all agents — regardless of how they accept permission flags — behave consistently.
Decision flow¶
CLI flag (--unrestricted / --restricted)
│ ↓ if not given
Config stack: global → project → preset (resolve_provider_value)
│ ↓ if not configured
Default: unrestricted (True)
│
▼
TEROK_UNRESTRICTED=1 env var ← single decision carrier
│
▼ (per-container, all launch paths — immutable after creation)
Agent-native env vars and config files:
├─ VIBE_AUTO_APPROVE=true (container env)
├─ OPENCODE_PERMISSION='{"*":"allow"}' (container env)
├─ COPILOT_ALLOW_ALL=true (container env)
└─ /etc/claude-code/managed-settings.json (init script)
│
▼ (Codex only — no env var or managed config available)
Shell wrapper injects --yolo when TEROK_UNRESTRICTED=1
Key decision points¶
-
Host-side resolution (
task_runners.py): Each task runner (task_run_headless,task_run_cli) resolves the unrestricted flag viaresolve_provider_value("unrestricted", ...)against the effective agent/backend. The resolved boolean is persisted tometa.ymland — ifTrue—TEROK_UNRESTRICTED=1is added to the container's environment. -
In-container application (
headless_providers.py,agents.py): The shell wrapper functions generated by_generate_generic_wrapper()and_generate_claude_wrapper()check$TEROK_UNRESTRICTEDat runtime. When set to"1", provider-specific flags are injected into the agent's command line (or env vars are exported for env-based agents like OpenCode). When unset, the agent starts with its vendor defaults.
Why TEROK_UNRESTRICTED and not direct flag injection?¶
The host cannot inject CLI flags directly into the agent invocation — it only controls the container's environment. The actual agent binary is launched by a bash wrapper function inside the container (generated at image build time). Using a single env var as the decision carrier keeps the host logic provider-agnostic: it doesn't need to know which flags each agent needs.
Implementation details¶
| Concern | Where | How |
|---|---|---|
| Config resolution | agent_config.py → resolve_provider_value() |
Walks global → project → preset; supports flat values and per-provider dicts |
| Host-side env injection | task_runners.py → _apply_unrestricted_env() |
Sets TEROK_UNRESTRICTED=1 + all auto_approve_env vars from collect_all_auto_approve_env() |
| Meta persistence | task_runners.py |
meta["unrestricted"] written to meta.yml (headless: always; CLI: on start) |
| CLI flag wiring | cli/commands/task.py |
Mutually exclusive --unrestricted / --restricted mapped to tri-state bool \| None |
| Per-container config files | init-ssh-and-repo.sh |
Writes /etc/claude-code/managed-settings.json when TEROK_UNRESTRICTED=1 |
| Codex CLI flag | headless_providers.py → _generate_generic_wrapper() |
Wrapper injects --yolo when TEROK_UNRESTRICTED=1 (no env var or managed config in Codex v0.114.0) |
| Provider env registry | headless_providers.py → HeadlessProvider dataclass |
auto_approve_env: dict[str, str] per provider |
| Status display | tasks.py → task_status(), task_detail.py |
Reads meta["unrestricted"] and shows "unrestricted" / "restricted" |
In-container hilfe¶
The in-container hilfe command is shipped from
src/terok/resources/scripts/hilfe. The CLI login banner in
l1.agent-cli.Dockerfile.template intentionally calls
_TEROK_LOGIN=1 hilfe --kurz, so the short and full help stay centralized.
When you change agent availability, /workspace or /home/dev mount behavior,
update flows, or rebuild terminology, keep these in sync:
src/terok/resources/scripts/hilfel1.agent-cli.Dockerfile.templatewelcome hook- user docs (
docs/usage.md, and related container docs if terminology changed)
Adding a new agent¶
To add permission-mode support for a new agent:
- Set
auto_approve_env(for env-var-based approval) on theHeadlessProviderdefinition, add a config-file block toinit-ssh-and-repo.sh(for file-based agents like Claude), or setauto_approve_flagsas a last resort (for agents with no env var or managed config, like Codex). - No other changes needed —
_apply_unrestricted_env()collects all providers' env vars automatically viacollect_all_auto_approve_env().
Development Workflow¶
Initial Setup¶
# Clone the repository
git clone git@github.com:terok-ai/terok.git
cd terok
# Install all development dependencies
make install-dev
Before You Commit¶
Always run the linter before committing:
If linting fails, auto-fix with:
Tests are written with pytest. The suite is split into tests/unit/ and
tests/integration/, with shared test-only helpers under tests/.
Run tests before pushing (or at least before opening a PR):
make test # Alias for make test-unit
make test-unit # Run the fast suite with coverage (excludes integration tests)
Integration tests live under tests/integration/ and have dedicated targets:
make test-integration-host # Filesystem/process workflows, no podman/network
make test-integration-network # Network-dependent integration tests
make test-integration-podman # Podman-dependent integration tests
make test-integration # All integration tests
make test-integration-map # Generate docs/test_map.md from pytest collection
make test-matrix # Run the cross-distro integration matrix locally
make test-matrix BUILD_ONLY=1 # Build the matrix container images without running tests
make ci-map # Generate docs/ci_map.md from workflow YAML
make test-matrix requires a host that can run nested Podman containers
(--privileged inside the outer container/VM). It is mainly for manual
verification on capable developer machines or dedicated CI runners.
Check module boundaries if you changed cross-module imports:
To run all checks (equivalent to CI):
Security scanning and Sonar report generation also have dedicated targets:
make security # Run Bandit with CI-equivalent settings
make sonar-inputs # Generate coverage + Ruff + Bandit reports under reports/
Available Make Targets¶
| Command | Description | When to Use |
|---|---|---|
make lint |
Check linting and formatting | Before every commit |
make format |
Auto-fix lint issues and format | When lint fails |
make test |
Alias for make test-unit |
Before pushing |
make test-unit |
Run the fast suite with coverage (excludes integration) | Before pushing |
make test-integration-host |
Run host-only integration tests | During integration test development |
make test-integration-network |
Run network integration tests | When touching network-dependent flows |
make test-integration-podman |
Run podman integration tests | When touching container-dependent flows |
make test-integration |
Run all integration tests | Before opening a PR that changes integration flows |
make test-integration-map |
Generate the integration test map page | After reorganizing integration tests |
make test-matrix |
Run the multi-distro integration matrix locally | Before changing matrix/container compatibility |
make test-matrix BUILD_ONLY=1 |
Build the multi-distro matrix images without running tests | When iterating on matrix containerfiles |
make test-matrix NO_CACHE=1 |
Rebuild matrix images from scratch, then run tests | After changing Containerfiles |
make ci-map |
Generate the CI workflow map page | After changing GitHub workflows |
make tach |
Check module boundary rules | After changing imports |
make security |
Run the Bandit SAST scan | Before opening a PR that changes CI/security-sensitive code |
make sonar-inputs |
Generate Sonar-imported reports under reports/ |
When validating Sonar inputs locally |
make docstrings |
Check docstring coverage (95% min) | After adding public APIs |
make deadcode |
Detect unused code | Before opening a PR |
make reuse |
Check REUSE/SPDX license compliance | Before opening a PR |
make check |
Run all checks (lint + test + tach + security + docstrings + deadcode + reuse) | Before opening a PR |
make docs |
Serve documentation locally | When editing docs |
make install-dev |
Install all dependencies | Initial setup |
make clean |
Remove build artifacts | When needed |
Running from Source¶
# Set up environment to use example projects
export TEROK_CONFIG_DIR=$PWD/examples
export TEROK_STATE_DIR=$PWD/tmp/dev-runtime/var-lib-terok
# Run CLI commands
python -m terok.cli projects
python -m terok.cli task new uc
python -m terok.cli generate uc
python -m terok.cli build uc
# Run TUI
python -m terok.tui
TUI Notes¶
Emoji width constraints¶
Terminal emulators and Rich/Textual disagree on the width of emojis that use Variation Selector-16 (U+FE0F). Rich reports 2 cells (per Unicode spec); most terminals render 1 cell. This breaks Textual's layout engine — columns shift, panel edges misalign — and cannot be fixed by padding alone.
Rules:
- All emojis must be natively wide (
East_Asian_Width=W,Emoji_Presentation=Yes). No VS16 (U+FE0F) sequences. - Verify candidates:
python3 -c "import unicodedata; print(unicodedata.east_asian_width('🟢'))"→ must printW. - Never use emoji literals directly in code. Always define emojis in a
central dict whose values carry both
emojiandlabelattributes (e.g.StatusInfo,ModeInfo,ProjectBadge,WorkStatusInfo). - Always render via
render_emoji(info)fromterok.lib.util.emoji. Pass the dict entry directly — the function reads.emojiand.labelitself. Nowidthorlabelparameter needed at the call site. - Emoji definitions live in
terok.lib.core.task_display(STATUS_DISPLAY,MODE_DISPLAY,SECURITY_CLASS_DISPLAY,GPU_DISPLAY) andterok.lib.core.work_status(WORK_STATUS_DISPLAY). - Guard tests in
tests/lib/test_emoji.pyverify all project emojis are natively 2 cells wide — adding a VS16 emoji will fail CI. Tests also verify that all emoji dicts have non-empty labels for--no-emojimode. - Emojis are on by default. Pass
--no-emojitoterok-tui(TUI) orterok(CLI) to replace all emojis with[label]text badges.
See src/terok/lib/util/emoji.py module docstring for full background,
references, and future terminal developments to watch (Kitty text sizing
protocol, Mode 2027, terminal convergence).
IDE Setup (PyCharm/VSCode)¶
- Open the repo and set up a Python 3.12+ interpreter
- Set environment variables:
TEROK_CONFIG_DIR=/path/to/this/repo/examples- Optional:
TEROK_STATE_DIR= writable path - For PyCharm Run/Debug configuration:
- CLI: Module name =
terok.cli, Parameters =projects(or other subcommands) - TUI: Module name =
terok.tui(no args)
Building Wheels¶
# Build wheel
python -m pip install --upgrade build
python -m build
# Install in development mode (editable)
pip install -e .
Agent Instructions Architecture¶
Agent instructions use a "YAML config + standalone file" two-layer pattern:
- YAML
instructionskey — controls what base to use (bundled default, global, custom, or a mix via_inherit). Absent = bundled default. - Standalone
instructions.mdfile in the project root — always appended at the end of whatever the YAML chain resolved. Purely additive.
The _inherit sentinel in a YAML list is replaced with the bundled default content at that position (splicing), rather than being stripped. This lets projects compose instructions as: default + project-specific YAML + file addendum.
Key implementation details:
- resolve_instructions() in instructions.py accepts project_root to locate the standalone file
- has_custom_instructions() checks both YAML key and file existence
- The TUI badge shows three states: default, custom + inherited, custom only
- Task runners pass project_root=project.root to ensure file content is included
This pattern (config key for inheritance control + file for additive content) is recommended for future similar functionality where users need both structured overrides and free-form additions. See PR #272 for the design discussion.
Making a Release¶
Steps¶
- Update
versioninpyproject.tomlto the new version (e.g.0.5.0) - Commit:
release: bump version to 0.5.0 - Merge the version bump to
master - Go to Releases → New release on GitHub
- Create a new tag
v0.5.0targetingmaster - Click Generate release notes, review, and publish
The release workflow triggers on v* tags automatically — it builds the wheel/sdist and attaches them to the GitHub Release.
Version Display¶
Between releases, poetry-dynamic-versioning generates PEP 440 versions from git tags automatically (e.g. 0.4.0.post3.dev0+gabcdef). The TUI title bar shows a shortened form: v0.4.0+ when past a release, v0.4.0 at a tagged release.