System Architecture

August 12, 2026 · View on GitHub

This document describes how the Go CLI and C# Unity connector communicate, how state is managed, and the data flow for every operation.


Overall Architecture

├──────────────────────┐          HTTP POST         ┌───────────────────────────┐
│   Go CLI Binary        │  ▷───────────◁  │   Unity Editor (C#)         │
│   (thin core)          │  localhost:8090+   │   - HttpServer                │
│                        │                   │   - CommandRouter             │
│  • cmd/               │                   │   - ToolDiscovery             │
│  • internal/          │                   │   - Heartbeat                 │
│  • tools/ (registry)  │                   │   - [HeraTool] classes        │
└──────────────────────┘                   └───────────────────────────┘
           ▲                                                 │
           │                                                 │
           │         ~/.hera-agent-unity/instances/*.json     │
           └──────────────────────────────────────────────┘

Data Flow

1. Initial Connection

  1. Unity Editor opens → HttpServer starts on an available localhost port (8090 default, then 8091–8099)
  2. Heartbeat writes ~/.hera-agent-unity/instances/<md5(projectPath)>.json every 1.0 second
  3. CLI scans the instances directory via internal/client.ScanInstances()
  4. CLI resolves one Editor by exact normalized project path when provided; legacy substring selection must be unique, and --project plus --port must identify the same heartbeat
  5. CLI connects to the heartbeat's current port; the project path remains the identity if a reload or restart binds a different port

2. Command Execution

[Terminal]    hera-agent-unity editor play --wait

     ▷  ① root.go: splitArgs() → strip --port, --project, --timeout flags

     ▷  ② root.go: category="editor", subArgs=["play","--wait"]

     ▷  ③ client.DiscoverInstance() → exact-first, ambiguity-safe target resolution

     ▷  ④ waitForAlive() → fresh-polls instance files until Unity is alive

     ▷  ⑤ editorCmd() → build params: {"action":"play"}  // --wait handled Go-side via waitForState

     ▷  ⑥ client.Send(ctx, ...) → HTTP POST /command (JSON body)

     ▷  ⑦ Unity HttpServer.HandleRequest() → enqueue WorkItem to ConcurrentQueue

     ▷  ⑧ EditorApplication.update(ProcessQueue) → CommandRouter.Dispatch()

     ▷  ⑨ ToolDiscovery.FindHandler("manage_editor") → ManageEditor.HandleCommand

     ▷  ⑩ ManageEditor.play → EditorApplication.isPlaying = true; SuccessResponse returned immediately

     ▷  ⑪ JSON response returned to Go (on failure/timeout, fresh heartbeat ownership is checked before a safe retry)

     ▷  ⑫ Go: if --wait, waitForState(resolve, 60000, "playing", "paused") polls heartbeat until isPlaying observed

     ▷  ⑬ printResponse() with "Entered play mode (confirmed)." message

Core Components

ComponentRoleFile/Folder
Go CLICommand parsing, HTTP request, response outputcmd/, internal/
HTTP ClientUnity instance discovery, polling, timeout handlinginternal/client/client.go
HttpServerUnity-side localhost HTTP listenerAgentConnector/Editor/HttpServer.cs
CommandRouterPrevents concurrent execution (SemaphoreSlim), dispatches to handlersAgentConnector/Editor/CommandRouter.cs
ToolDiscoveryReflection-based tool scanning and schema generationAgentConnector/Editor/ToolDiscovery.cs
HeartbeatWrites instance state JSON files, survives domain reloadsAgentConnector/Editor/Heartbeat.cs

Unity State Machine

[*] → ready          : Unity starts
ready → compiling     : Script modified/added
compiling → ready     : Compile success
compiling → ready     : Compile finishes (inspect `compileErrors` for failure)
ready → entering_playmode : editor play
entering_playmode → playing : EnteredPlayMode event
playing → paused      : editor pause
paused → playing      : editor pause (toggle)
playing → ready       : editor stop
ready → refreshing    : AssetDatabase.Refresh
refreshing → ready    : Complete
ready → stopped      : Unity exits
* → reloading         : beforeAssemblyReload forced heartbeat

States are written to the instance JSON file by Heartbeat.cs. The Go CLI fresh-polls this file via waitForAlive() and waitForReady() during transitions; one-shot command setup keeps the short-lived instance cache.


Domain Reload Survival

Unity's script compilation / domain reload resets static variables and instances. Critical components survive via [InitializeOnLoad] + AssemblyReloadEvents.

ComponentSurvival MechanismNotes
HttpServer[InitializeOnLoad] + afterAssemblyReload += StartAuto-restarts after domain reload
Heartbeat[InitializeOnLoad] re-registers the update callback after reloadResumes writing state files; writes reloading before reload
TestRunnerState[InitializeOnLoad] + afterAssemblyReload += OnAfterAssemblyReloadPreserves the asynchronous test-result path across reloads
CommandRouterStatic class, no stateRe-created each dispatch, uses SemaphoreSlim

Instance File Format

~/.hera-agent-unity/instances/<hash>.json:

{
  "state": "ready",
  "projectPath": "/Users/admin/Unity/MyProject",
  "port": 8090,
  "pid": 12345,
  "unityVersion": "6000.3.5f2",
  "docsVersion": "6000.3",
  "compiler": {
    "cscPath": "/Unity/Editor/Data/DotNetSdkRoslyn/csc.dll",
    "cscKind": "unity_dotnet_sdk_roslyn",
    "cscFound": true,
    "dotnetPath": "/Unity/Editor/Data/NetCoreRuntime/dotnet",
    "dotnetKind": "unity_netcore_runtime",
    "dotnetFound": true
  },
  "timestamp": 1714372800000,
  "compileErrors": false
}
FieldSourceNotes
states_ForcedState ?? Heartbeat.GetState()ready / compiling / entering_playmode / playing / paused / refreshing / reloading / stopped
projectPathApplication.dataPath.Replace("/Assets","")Project root directory
portHttpServer.PortActual listening port
pidProcess.GetCurrentProcess().IdUnity process ID
unityVersionApplication.unityVersionUnity version string
docsVersionUnityVersionCompat.CurrentDocsVersion()Connector documentation bucket
compilerHeartbeat.GetCompilerSummary()Resolved csc/dotnet paths, kinds, and availability
timestampDateTimeOffset.UtcNowUnix epoch milliseconds
compileErrorsEditorUtility.scriptCompilationFailedTrue if last compilation failed

Stale files (PID not running) are auto-deleted by client.ScanInstances().


Concurrent Execution Prevention

CommandRouter uses a static SemaphoreSlim(1, 1) to serialize all commands:

static readonly SemaphoreSlim s_Lock = new(1, 1);

public static async Task<object> Dispatch(string command, JObject parameters)
{
    await s_Lock.WaitAsync();
    try { return await DispatchInternal(command, parameters); }
    finally { s_Lock.Release(); }
}

This prevents race conditions when multiple CLI agents or parallel scripts access the same Unity instance.

HTTP Ingress Contract

The listener remains loopback-only and supports one Editor command stream. It bounds ingress before the main-thread queue: /command accepts up to 1 MiB, /commands up to 4 MiB and 50 command items, and at most 64 requests may be pending execution. Bodies are read incrementally, so an unknown-length request cannot bypass the byte limit.

Ingress rejections are JSON ErrorResponse envelopes with stable HTTP_* codes and their matching HTTP status (400, 403, 404, 405, 413, 429, or 500). The Go client decodes those non-200 envelopes into its normal response types, preserving success, code, message, and data; a malformed non-200 body remains a transport error. Tool-level command failures continue to use their existing response contract. The router's 120-second command-lock acquisition timeout is unchanged.

Test Result Flow

test --mode EditMode and test --mode PlayMode both start the Unity Test Framework asynchronously. Their final envelope is persisted as ~/.hera-agent-unity/status/test-results-<port>-<run_id>.json; the Go CLI polls that file until it is available. This unifies the modes and makes the result delivery resilient to a PlayMode domain reload. There is no test-specific --wait flag; the global --timeout bounds the polling interval.

The Connector creates a run-scoped pending record before returning the asynchronous handle. If the CLI deadline expires while that record remains, the CLI reports TEST_RUN_PENDING rather than treating a stale heartbeat as proof that Unity is unresponsive. test --resume <run_id> bypasses the normal fresh-heartbeat readiness wait, polls the same run-scoped result, and does not send another run_tests mutation. Project/port discovery still selects the Editor identity, so a resume cannot silently move to another open project.

The standalone task list and task status <task_id> commands read this same file bus without entering the normal Unity HTTP readiness path. task list filters pending test/package records by the selected project's fingerprint and current port, then returns opaque handles compatible with the shared Go task store. task status accepts either one of those handles or an MCP Tasks handle and resolves working/completed directly from pending/result files. Listing discovers active work only; retaining the handle is what makes a completed result addressable after Unity clears its pending record.

During CLI/connector upgrades, the connector also writes the legacy port-scoped result file for PlayMode clients that do not yet understand a run_id; current clients prefer the run-scoped file. Current CLIs opt into asynchronous EditMode results with async_results=true; without it, the connector preserves the legacy synchronous EditMode contract.


Security Considerations

The optional MCP surface lives in the Go process, never in Unity. It is default-off and stdio-only: the MCP SDK maps Profile, Compact, or Full tool exposure onto the same normalized catalog, policy, HTTP client, and Connector operation ledger used by Typed CLI calls. stdout is reserved for protocol frames. Older Connectors are Compact-only and every missing safety feature fails closed. See MCP.md for the compatibility matrix.

LayerProtection
NetworkOnly binds to 127.0.0.1 (localhost). No remote access.
CORSBrowser Origin headers are rejected with HTTP 403. Only CLI HTTP clients work.
FileInstance files written to user's home directory. No privileged paths.
CommandFile/Quit menu item is explicitly blocked in ExecuteMenuItem.cs.