Skip to content

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_sandbox package 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_REPO points to upstream URL
  • Container can push directly to upstream
  • Git gate (if present) is used as read-only clone accelerator

Gatekeeping Mode

  • CODE_REPO points to the gate server's HTTP endpoint
  • Container's default origin is 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

  1. Host-side resolution (task_runners.py): Each task runner (task_run_headless, task_run_cli) resolves the unrestricted flag via resolve_provider_value("unrestricted", ...) against the effective agent/backend. The resolved boolean is persisted to meta.yml and — if TrueTEROK_UNRESTRICTED=1 is added to the container's environment.

  2. In-container application (headless_providers.py, agents.py): The shell wrapper functions generated by _generate_generic_wrapper() and _generate_claude_wrapper() check $TEROK_UNRESTRICTED at 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.pyresolve_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.pyHeadlessProvider dataclass auto_approve_env: dict[str, str] per provider
Status display tasks.pytask_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/hilfe
  • l1.agent-cli.Dockerfile.template welcome 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:

  1. Set auto_approve_env (for env-var-based approval) on the HeadlessProvider definition, add a config-file block to init-ssh-and-repo.sh (for file-based agents like Claude), or set auto_approve_flags as a last resort (for agents with no env var or managed config, like Codex).
  2. No other changes needed — _apply_unrestricted_env() collects all providers' env vars automatically via collect_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:

make lint      # Check for issues (fast, ~1 second)

If linting fails, auto-fix with:

make format    # Auto-fix lint issues and format code

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:

make tach      # Verify tach.toml boundary rules

To run all checks (equivalent to CI):

make check     # Runs lint + test + tach + security + docstrings + deadcode + reuse

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:

  1. All emojis must be natively wide (East_Asian_Width=W, Emoji_Presentation=Yes). No VS16 (U+FE0F) sequences.
  2. Verify candidates: python3 -c "import unicodedata; print(unicodedata.east_asian_width('🟢'))" → must print W.
  3. Never use emoji literals directly in code. Always define emojis in a central dict whose values carry both emoji and label attributes (e.g. StatusInfo, ModeInfo, ProjectBadge, WorkStatusInfo).
  4. Always render via render_emoji(info) from terok.lib.util.emoji. Pass the dict entry directly — the function reads .emoji and .label itself. No width or label parameter needed at the call site.
  5. Emoji definitions live in terok.lib.core.task_display (STATUS_DISPLAY, MODE_DISPLAY, SECURITY_CLASS_DISPLAY, GPU_DISPLAY) and terok.lib.core.work_status (WORK_STATUS_DISPLAY).
  6. Guard tests in tests/lib/test_emoji.py verify 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-emoji mode.
  7. Emojis are on by default. Pass --no-emoji to terok-tui (TUI) or terok (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)

  1. Open the repo and set up a Python 3.12+ interpreter
  2. Set environment variables:
  3. TEROK_CONFIG_DIR = /path/to/this/repo/examples
  4. Optional: TEROK_STATE_DIR = writable path
  5. For PyCharm Run/Debug configuration:
  6. CLI: Module name = terok.cli, Parameters = projects (or other subcommands)
  7. 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:

  1. YAML instructions key — controls what base to use (bundled default, global, custom, or a mix via _inherit). Absent = bundled default.
  2. Standalone instructions.md file 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

  1. Update version in pyproject.toml to the new version (e.g. 0.5.0)
  2. Commit: release: bump version to 0.5.0
  3. Merge the version bump to master
  4. Go to Releases → New release on GitHub
  5. Create a new tag v0.5.0 targeting master
  6. 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.