- Build:
make build(outputsbin/construct, unsigned local build) - Sign (optional, macOS):
make signormake build-signed - Lint:
make lint(golangci-lint) - Full checks:
make checkor./scripts/checks.sh(fmt -> vet -> lint -> test -> build) - CI alias:
make ci(same asmake check) - Test:
make test(go mod download,go mod verify, thengo test ./...;-racewhen CGO is enabled) - Unit only:
make test-unitorgo test ./internal/... - Integration only:
make test-integration - Single test:
go test -run TestName ./internal/path/... - Single package:
go test -v ./internal/config - Coverage:
make test-coverage(outputscoverage.html)
- Format:
make fmt(go fmt ./..., thengoimports -w .if installed) - Naming: MixedCaps (no underscores), Uppercase=exported, lowercase=unexported
- Errors: Always check, use
fmt.Errorf("context: %w", err)for wrapping - Comments:
// Package nameat top, godoc for exported funcs - Interfaces: Single-method interfaces end with
-ersuffix - Testing:
TestXxxfuncs,BenchmarkXxxfor benches - Line length: No limit, let
gofmtwrap
- PATH is hardcoded and must be kept in sync across these files:
internal/env/env.go(BuildConstructPath)internal/templates/entrypoint.shinternal/templates/docker-compose.ymlinternal/templates/Dockerfile
internal/runtime/runtime.go(GenerateDockerComposeOverride) adds host→container bind-mounts that activate only when the host path exists (no config flag): host global gitignore →/home/construct/.config/git/ignore:ro(getGlobalGitIgnorePath), and host qmd GGUF model cache~/.cache/qmd/models→/home/construct/.cache/qmd/models(getQmdModelsPath, RW so lazily-fetched reranker/generation models write back to the shared host cache).- Every conditional mount must also be added to the
overrideInputsstruct andhashOverrideInputs, ordocker-compose.override.ymlwill not regenerate when the host path appears/disappears. - To add a new auto-mount: write a
getXPath() (string, bool)helper (resolve$HOME/XDG,os.Stat+IsDir), add the field + hash line, and append the mount in BOTH thelinuxanddarwinvolume blocks (linux carriesselinuxSuffix, darwin omits it).
internal/agent/engine.go(terminalIdentityEnvFlags) auto-forwards host terminal-identity markers (KITTY_WINDOW_ID,GHOSTTY_RESOURCES_DIR,TERM_PROGRAM) into the container as-eflags so in-container TUIs / pi extensions can detect the outer terminal (kitty-graphics inline images, etc.).- Wired into BOTH launch paths:
e.buildRunFlags(directcompose run) andstartDaemonBackground(daemoncompose run -d, sodocker execsessions inherit it). TERMis intentionally NOT forwarded (terminfo mismatch risk; identity vars suffice for detection). Users who need it: addTERMtoenv_passthroughand installncurses-term/kitty-terminfoin the image.
- Chromium hardcodes
localhostand*.localhostto127.0.0.1(RFC 6761), bypassing/etc/hosts, DNS,dnsmasq, and--host-resolver-rules. So DNS-layer fixes (extra_hosts, host_aliases) CANNOT make a headless browser (agent-browser) reach host dev sites likehttp://hyperpress.localhost. They only help non-browser tools (curl/git/MCP). - Solution: blind TCP relays on the container's
127.0.0.1→host.docker.internal, launched byinternal/templates/entrypoint.sh(socat, next to the SSH bridge). Blind relay preserves HTTP Host header + TLS SNI, so host vhost routers and certs see the real hostname. - Config:
[sandbox] host_loopback_ports(list of ints, default[80, 443]). Same port both sides. Add non-standard ports (e.g.3000) as needed. Empty list disables. - Plumbing:
internal/config/config.go—HostLoopbackPortsfield + default.internal/runtime/runtime.go(GenerateDockerComposeOverride) — emitsCONSTRUCT_LOOPBACK_PORTSenv +cap_add: NET_BIND_SERVICE(consolidated with strict-modeNET_ADMINvia acapsslice — never write twocap_add:blocks).LoopbackPortsis part ofoverrideInputs/hashOverrideInputsso changing the list regenerates the override.internal/templates/Dockerfile— installslibcap2-bin(provides thesetcapbinary). The file cap is not applied at build time: BuildKit's default sandbox blocks file-capability writes duringdocker build(Invalid file 'setcap' for capability operation). Instead,internal/templates/entrypoint.shrunssetcap cap_net_bind_service+ep /usr/bin/socatas root in its startup block before dropping to the construct user (idempotent + best-effort). The file cap still needs the cap in the bounding set, hencecap_addtoo — both required.
- Platform caveat: macOS host-gateway routes to host
127.0.0.1(reaches loopback-bound dev servers). Linux host-gateway is the bridge IP — host services must bind0.0.0.0/bridge, not127.0.0.1-only. localhostitself is NOT remapped (would shadow127.0.0.1 localhostor break in-container loopback services). For hostlocalhostservices, the relay on port 80/443 covershttp://localhost/https://localhost; for other host-loopback use cases preferhost.docker.internal.
- Daemon flock:
internal/runtime/daemon_lock.go(acquireDaemonLock). Any code that stops, recreates, or makes count-based decisions about the msb daemon must hold the flock while readingLiveSessionCount()AND acting (internal/runtime/idle_watch.goStopMsbDaemonBestEffortis the reference pattern). Count reads outside the lock race concurrent watchers and freshEnsureMsbDaemoncalls. The 250ms "Waiting for another construct invocation" notice measures the acquisition wait only; disarm fires on acquire, never on release. - Daemon recreate labels: the daemon is stamped with
construct.daemon.*labels (mounts_hash,skills_hash) byBuildMsbRunSpec;msbDaemonNeedsRecreate(internal/runtime/backend_msb_run.go) compares them against current config to decide recreate-with-reason. A new daemon-affecting runtime config knob MUST add its label + recreate check, or toggling it on a running daemon stays invisible until manual recreate (the skills_hash gap shipped exactly this way and needed a review round to catch). - Mounts: configured roots (
daemon.mount_paths) + learned roots (requestLearnRoot, capped, oldest evicted) feed ONE combined hash intoconstruct.daemon.mounts_hash. A cwd outside the set returnsErrMsbDaemonWorkdirUnmapped(error, never destructive).ResolveDaemonMountsWithLearnedis a deliberate no-op wrapper overResolveDaemonMounts; do not "fix" it away. - Prepull:
internal/runtime/prepull.gopullsghcr.io/estebanforge/construct-box:latestdetached, spawned only after an ACTUAL self-update (the "already on latest version" no-op returns before the spawn). The deterministic foreground path isconstruct sys prepull. Opt-out:runtime.prepull_image = false. - Design + peer-review trail: docs/VMsv2.md. Dogfood procedures: docs/DOGFOODING-1.16.3.md.
Anything printed BEFORE or DURING agent execution must go to stderr (ui.Info, ui.InfoLn, ui.InfoF in internal/ui). Harnesses spawn agents in RPC modes that stream line-delimited JSON on stdout; any banner printed there corrupts the protocol stream. The interactive attach prompt in engine.go and explicit CLI output (construct agents, help screens) are the only intentional stdout on agent paths.
internal/agent/arg_staging.go stages orchestrator host path args into the construct home before any run path branches (engine.Prepare). When touching agent spawn behavior, keep these invariants: flags per agent live in agentPathFlags; staged files land under .construct-staging/<run-id> (0700); allowed roots are temp trees, the agent host config dir, and the caller cwd; values outside the roots stay untouched; --session values get a Teardown copy-back. Details: docs/HARNESS-STAGING.md.
The microsandbox SDK's FFI bridge is an untagged cgo file, so plain GOOS/GOARCH cross builds (cgo off) drop it and fail with "build constraints exclude all Go files" in internal/ffi. Release artifacts are built where a cgo toolchain exists (.github/workflows/release.yml): darwin on a macOS runner (clang -arch for amd64, native lipo), linux/amd64 native, linux/arm64 via gcc-aarch64-linux-gnu. Do not reintroduce a single-runner cross-compile matrix; make cross-compile remains local-dev only and cannot produce release artifacts for foreign platforms.
The construct-box GHCR image is NOT built by the release workflow. The CLI always pulls construct-box:latest (no version coupling), so image publishes are a separate manual workflow: .github/workflows/image.yml (workflow_dispatch, optional version input pins ghcr.io/estebanforge/construct-box:<version> alongside :latest). Dispatch it when the image definition changes: internal/templates/Dockerfile plus the files it COPYs (entrypoint.sh, update-all.sh, network-filter.sh, clipper, clipboard-x11-sync.sh, osascript, construct-host-exec). Multi-arch (amd64 + arm64 via QEMU) with GitHub Actions cache; dispatches are serialized by a concurrency group.
- NEVER modify the
VERSIONfile - it's managed by GitHub Actions - NEVER modify the
VERSION-BETAfile manually - it's managed by GitHub Actions for prereleases - The release workflow triggers on TAG PUSH (
git push origin <version>). Achore(release)commit alone ships nothing: no tag push means no GitHub release, no artifacts, no VERSION bump, and stable users stay on the old version - When asked to bump version: update
internal/constants/constants.goonly - When asked to add CHANGELOG entry: add new section with current version from constants.go
VERSIONis updated by release workflow for stable tags (e.g.1.3.8)VERSION-BETAis updated by release workflow for prerelease tags (e.g.1.3.9-beta.1)- Version strings and release tags are plain semver/prerelease values with no
vprefix (use1.4.0-beta.3, neverv1.4.0-beta.3) - Keep
internal/constants/constants.goversion exactly aligned with the tag being released (stable or prerelease), ormake releasefailscheck-version - Stable users track
VERSION; beta users trackVERSION-BETAwhenruntime.update_channel = "beta"
- Add package to
internal/templates/packages.tomlunder the correct section ([npm],[bun], or[brew]). - Register agent mount in
internal/agent/agent.go(Name, Slug, ConfigPath). - Register AGENTS.md rules path in
internal/sys/memories.goand updateinternal/sys/memories_test.go(bump count + add assertion). - Add slug to the available agents list in
internal/ui/help.go. - Add slug to post-update verification loop in
internal/templates/update-all.sh. - Add slug to post-install verification loop in
internal/config/packages.go(GenerateInstallScript). - Update docs:
README.md— "Available AGENTS" list + yolo_agents comment.docs/ARCHITECTURE-DESIGN.md— Section 5 agent list.AGENTS.md— Agent Additions Log (below).
- If the agent needs setup commands, add them in
[post_install].commandsininternal/templates/packages.toml. - If the agent requires first-run setup that should not be automated, gate the run in
internal/agent/runner.goand use a marker file under Construct home (e.g.,~/.config/<agent>/.construct_configured) to prompt once and record completion.
Reverse the steps above: remove the package from packages.toml, unregister from agent.go, remove from memories.go + test, remove from help.go, remove from both verification loops (update-all.sh and packages.go), and remove from docs (README.md, ARCHITECTURE-DESIGN.md). Add a removal note to the Agent Additions Log.
- Kilo Code CLI
- Command:
npm install -g @kilocode/cli(run askilocode) - Rules path:
~/.kilocode/rules/AGENTS.md - Files updated:
internal/templates/packages.toml,internal/agent/agent.go,internal/sys/memories.go,internal/sys/memories_test.go,internal/ui/help.go,README.md
- Command:
- Crush CLI
- Command:
npm install -g @charmland/crush(run ascrush) - Rules path:
~/.config/crush/AGENTS.md - Files updated:
internal/templates/packages.toml,internal/agent/agent.go,internal/sys/memories.go,internal/sys/memories_test.go,internal/ui/help.go,internal/templates/update-all.sh,internal/config/packages.go,internal/agent/runner.go,internal/templates/config.toml,README.md,docs/ARCHITECTURE-DESIGN.md
- Command:
- Antigravity CLI (replaced Gemini CLI)
- Command:
curl -fsSL https://antigravity.google/cli/install.sh | bash(run asagy) - Rules path:
~/.antigravity/AGENTS.md - Binary:
~/.local/bin/agy - Install method: curl (not npm)
- Files updated: All Go source files (agent, sys, constants, env, config, runtime, engine, help, shell, packages, tests), all shell templates (entrypoint, update-all, agent-patch, config.toml, clipper, packages.toml), all docs (README, AGENTS, ARCHITECTURE-DESIGN, CONFIGURATION, CLIPBOARD, TODO, PROVIDERS), .gitignore
- Removed:
patch_gemini_paste_wrapper()function from agent-patch.sh (~220 lines), GEMINI.md symlink from entrypoint.sh,@google/gemini-clifrom packages.toml npm section,gemini-cli-mainand.gemini-clipboardfrom .gitignore - Renamed:
GEMINI_API_KEY→ANTIGRAVITY_API_KEYthroughout
- Command: