Skip to content

Latest commit

 

History

History
1567 lines (1359 loc) · 107 KB

development.md

File metadata and controls

1567 lines (1359 loc) · 107 KB

ATC development guide

This guide covers the build, architecture, implementation constraints and tests. The README covers installation, configuration and the agent-facing API.

The type-system background explains the theoretical model; the capability design maps it to ATC. The remaining sections describe runtime enforcement, execution, configuration and maintenance.

Building and running

Use JDK 17 or newer and the included Mill launcher. build.mill pins Mill, the Scala nightly compiler and dependencies. Capture checking and safe mode require that compiler; compiler upgrades must pass the capability and sandbox suites.

./mill app.compile
./mill app.test
./mill app.test.testOnly atc.ReplSessionSuite
./mill app.test.testOnly atc.ReplSessionSuite -- '*timeout*'
./mill __.checkFormat
./mill __.reformat
./mill dist
bash tests/atc_test.sh

On Windows, replace ./mill with .\mill.bat. Tests run serially there because Mill's parallel queue can duplicate compiler-intensive suites. If the Mill server cannot start, use ./mill --no-server .... The permission suite starts a local HTTP server and needs permission to bind a loopback socket.

dist writes atc, atc.ps1, atc.cmd, atc.jar, atc-lib.jar and version.txt to out/dist.dest/. The Unix launcher and JARs form the Unix distribution; Windows uses the PowerShell launcher with the same JARs. The batch launcher is a compatibility entry point.

./start.sh and .\start.ps1 rebuild a stale distribution, then load .env and launch ATC. Before the build they read only ATC_SKIP_BUILD from the environment file, so a Mill server the build starts does not keep API keys in its environment. The distribution is stale when a file in build.mill, app/src, app/resources, lib/src or windows is newer than out/dist.dest/build.stamp, which the scripts write after each successful build; the stamp is the reference because Mill does not rewrite a jar whose inputs have the same content. Environment files hold literal KEY=value entries without shell expansion, and a non-empty exported value wins over the file. Application arguments pass through. The build runs in the checkout; ATC keeps the launch directory unless -C overrides it.

Variable Purpose
ATC_MODEL, ATC_CONFIG, ATC_CWD Start-script defaults for -m, -c, -C
ATC_ENV_FILE Alternative environment file
ATC_SKIP_BUILD=1 Skip the start script's rebuild check
ATC_JAVA_OPTS Additional JVM flags for every launcher, applied after the defaults and before the command line's -Xmx/-Xms
-Xmx<size>, -Xms<size> (launcher arguments) JVM heap flags that atc, start.sh, start.ps1 and atc.ps1 remove from the arguments (never the value of an option such as -p) and pass to java last, so they win
ATC_STARTUP_CACHE=0 Run without the JVM startup cache (atc, start.sh)
ATC_DEBUG Enable stack traces and stream/terminal diagnostics when set; logged text has terminal controls removed
ATC_ASCII Select ASCII terminal glyphs when set

JVM settings

Every launcher starts the JVM with -Xms256m -Xmx2g -Xss4m -XX:-UsePerfData (DEFAULT_JVM_OPTS in atc; the same list in start.sh, the dist atc script, atc.ps1 and start.ps1). VERSIONED_JVM_OPTS adds --sun-misc-unsafe-memory-access=allow on Java 23+, because Scala's LazyVals use sun.misc.Unsafe and JEP 471 otherwise prints four warning lines on every run, and --enable-native-access=ALL-UNNAMED on Java 24+, because JLine loads a native terminal library (JEP 472). The dist atc script does not detect the Java version and adds neither. With the dist jar and the echo model on JDK 25, a 100-turn session of reads, writes and commands keeps 130 to 230 MB live and runs down to -Xmx192m, so 2 GB is headroom. The cap also bounds a runaway sandbox computation: one line building a large vector reaches 5 GB under the JVM's quarter-of-RAM default, and under 2 GB it fails in five seconds with an out-of-memory error the session survives. -Xss4m lets the compiler's recursion guard report a 3000-term expression that overflows the 1 MB Linux default stack; stacks are only reserved, so it costs nothing.

The startup cache halves the cold start (2.6 s to 1.3 s, and 30% less CPU over a session, since the JDK 25 cache carries method profiles): an AOT cache on Java 25+ (-XX:AOTCacheOutput= to build, -XX:AOTCache= to use), a dynamic CDS archive on Java 19 to 24 (-XX:ArchiveClassesAtExit= / -XX:SharedArchiveFile=). Both are bound to the exact jars, JDK and JVM options, and a stale one makes the JVM print errors (a CDS archive is not regenerated), so the launchers use one only when it is known to be current. ~/.atc/jars/startup/key.txt (out/dist.dest/startup/ for start.sh) records the JDK's -version output, the release marker, the version-gated JVM options, ATC_JAVA_OPTS and the command line's -Xmx/-Xms, and the cache is current when the key matches and no jar is newer than it. Otherwise one echo-model -p 'run: 1 + 1' run, whose output is discarded, builds the cache with the same options. A build that leaves no file writes the key anyway, so it is not retried until the JDK, release or options change. The cache is an optimization: an unwritable directory or key runs without it, and an interrupted training run removes its temporary directory. The Java version comes from the version " line of java -version, because JAVA_TOOL_OPTIONS, _JAVA_OPTIONS and JDK_JAVA_OPTIONS print a Picked up line before it. atc self uninstall removes the cache with the jars. -XX:TieredStopAtLevel=1 (60% less CPU, 50% slower steady-state compiles) and -XX:+UseSerialGC (200 MB less resident memory, five times the GC time) were measured and left to ATC_JAVA_OPTS.

For development without packaging, use ./mill -i app.run. For manual REPL checks, ./mill app.test.runMain atc.Scratch file.scala evaluates snippets separated by // ---. An echo provider supports local testing: run: <Scala code> invokes the REPL; other requests are echoed. It needs no API key or network connection.

Architecture

Component Responsibility
lib Agent-facing capability types, data types and Interface; compiled with capture checking
app Configuration, models, permissions, host operations, REPL and terminal
app root package Main and Cli parse the command line; App wires the parts together and runs the loop; Setup and FirstRun run the first start; Models holds the catalog and clients; SandboxRepl owns the REPL session
commands/ SlashCommand is the table of commands; Commands parses, dispatches and completes them; ModelCommands, ConfigCommands, ProvidersMenu, SessionCommands and StatusCommands implement them
agent/ Turn loop, completion decisions, history, context estimates, prompts and input prediction
config/ Configuration layers, validation, project trust, key bindings, provider edits and the model catalog
host/ File, process, network and user operations implementing Interface
llm/ Provider-neutral messages and provider adapters
perms/ Policy, permission scopes, path patterns, modes and gitignore visibility
platform/ OS behavior, path normalization and portable path globs
sandbox/ Compiler setup, REPL evaluation, validation, class loading and interruption
ui/ JLine input, streaming output, Markdown and syntax highlighting

One turn follows this path:

App → Agent → ChatModel.complete → CompletionPolicy
                  ↓ tool calls
          ScalaToolRunner → ReplSession → Host
                  ↑ execution result and permission decisions

Agent controls turn state. Conversation owns history and protocol repair; ContextManager owns token estimates and history fitting. ScalaToolRunner decodes run_scala, invokes the REPL, records execution time and renders results through ToolOutput. AgentMessages holds the notices exchanged with the model and the UI.

ToolOutput.renderForModel appends at most one hint keyed on a failed run's output (a safe-mode rejection, a missing capability, a command that could not start) and, after the length cut, one keyed on the snippet itself (codeHint): a \" inside a plain triple-quoted literal stays a backslash and a quote, so a Python docstring written as \"\"\" reaches the file with backslashes. The system prompt's editing rule says the same. Its Environment block says whether a user is present (AgentEnvironment.userPresent, false for a -p run, where ask returns None), so the model does not wait for an answer nobody gives. The REPL echo of a ProcessResult shows the exit code and the length of each stream only (its toString), so a snippet that prints the streams and ends with the value does not send them twice.

Host implements Interface directly through file, process, network and interaction traits; HostPaths holds the path resolution and permission checks they share. App or a test supplies the HostOutput, HostLlm and HostUi ports. The REPL shares library classes with the application, so calls need no serialization layer.

Type-system background

Authority, effects and retained capabilities

A capability is a reference that authorizes an operation. Passing FileSystem to a method provides file access through that object. A using parameter makes the dependency explicit in the method signature, but implicit argument passing alone does not prevent a closure or object from retaining the reference. Capture checking tracks that retention.

In ATC, capability types and runtime permissions answer different questions. The type system determines whether code can read, write, execute or communicate at all. The policy determines which concrete files, commands and hosts an available capability may access. For example, a write can compile in full mode and still fail because its path is locked. A write in read-only mode fails before the policy receives an operation.

Capture sets and subcapturing

T^{c} describes a value that may retain capability c; T^{c, d} permits both. Subcapturing expresses coverage: {c} is covered by {c, d}. Coverage can also follow a reference's declared captures, so a handle typed FileEntry^{fs} is accounted for by fs. For ordinary capturing types, a smaller permitted capture set gives a more specific type. Capture sets describe possible dependencies, not a list of operations already run.

Function types make the distinction explicit:

Type Capabilities retained by the function
A -> B None
A ->{c} B Those covered by c
A => B A general capturing function, written A ->{any} B

See the compiler's capture-checking basics for the subcapturing rules and function notation.

This helper expresses an ATC callback's dependency directly:

def printer(using user: UserIO^): String ->{user} Unit =
  (text: String) => println(text)

The callback retains the supplied user. Its return type is Unit, but that does not make it pure: purity depends on captured capabilities. Converting an effectful method to a function must preserve this dependency. The default-argument regression discussed below follows from it.

Scope and capture polymorphism

T^ abbreviates T^{any}, but any is scoped. It is not one unrestricted global permission that can absorb capabilities introduced in deeper scopes. Scoped capability parameters and restrictions on result captures prevent a callback from returning a resource whose lifetime ends when the callback returns. This includes returning a closure, collection or object that indirectly retains the resource. The upstream scoped-capability rules describe the role of lexical scope.

ATC's requestFiles also preserves the caller's access mode with a capture-set parameter. Its full overload is declared in Interface as:

def requestFiles[T, C^](path: String, access: Access, reason: String)
                      (using UserIO^, FileSystem^{C})
                      (op: (FileSystem^{any.rd, C}) ?=> T): T

C^ abstracts over a capture set. The contextual callback receives an inner file-system given that includes the caller's captures. It therefore retains a full view when the caller has one and remains read-only for a read-only caller. The scoped component prevents the temporary capability from escaping in T. This is ATC's use of explicit capture polymorphism.

Within an initialized ATC REPL, the following returns ordinary text successfully:

val copiedText = requestFiles(".", Access.Read, "inspect project") {
  read("notes.txt")
}

Returning the temporary file system instead is rejected:

val escaped = requestFiles(".", Access.Read, "inspect project") {
  summon[FileSystem^]
}

The first result does not retain the temporary authority. It may still contain confidential information, so lifetime checking alone is insufficient for data protection; Classified provides that separate constraint.

Read-only views and stateful capabilities

ATC's Cap extends Stateful and ExclusiveCapability. For that combination, a bare capability type receives a read-only capture set. x.rd restricts access through x; an update method requires full access. This is an access restriction on the same runtime object, not a copy of the object or proof that nobody else can mutate it.

The ordinary rule that smaller capture sets yield subtypes needs this qualification: stateful read-only sets also express access permissions. A full reference can be used through a read-only view without erasing its tracked dependency. The compiler represents this distinction internally with a reader qualifier. See stateful capabilities.

val ro: FileSystem^{fs.rd} = fs
ro.access("notes.txt").read()           // permitted when the file policy allows it
ro.access("notes.txt").write("changed") // compile error: read-only receiver

Exec and Network extend only ExclusiveCapability. They do not support .rd; a bare Exec is already a full capability. The bare-FileSystem convention does not extend to every capability type. CapabilitySuite tests this distinction.

Safe mode and the trusted implementation

Capture tracking requires APIs to expose their real dependencies. An apparently pure method that secretly reads a global file handle would invalidate that reasoning. Safe mode restricts untracked casts, reflection, unsafe annotations and access to global components. @assumeSafe admits a trusted library definition; it is a responsibility of the library author, not a proof generated for the implementation. @rejectSafe excludes selected definitions from safe code. See the compiler's safe-mode reference.

ATC compiles lib with capture checking, explicit nulls and safe initialization checks. The host implementation in app uses ordinary Scala signatures and Java I/O. Consequently, the implementation of each admitted API must preserve the interface contract manually: use the supplied capability, check the appropriate scope and policy, and keep classified values out of ordinary outputs and exceptions.

The online language documentation follows newer nightlies. The executable examples above were checked against the compiler pinned in build.mill; use ATC's compiler suites to resolve differences during upgrades. Theoretical properties of capture checking do not constitute a formal verification of ATC, its dependencies or its runtime environment.

The capability design

The build bundles lib/src/atc/lib/Interface.scala into the system prompt and /interface. Its comments are API documentation for the model: keep contracts and examples there; keep implementation rationale in this guide or beside host code. Add operations to the interface and host together, then verify their required capabilities.

Mode Machine capabilities supplied by the preamble
readonly io: IOCap, fs: FileSystem^{io.rd}
local io: IOCap^, fs: FileSystem^{io}, ex: Exec^{io}
full the local capabilities plus net: Network^{io}

Every mode also supplies user: UserIO^, which is independent of the machine root, so output, questions, TODO updates and normal chat calls remain available in read-only mode. Command operations require both Exec^ and FileSystem^, including when redirection writes a file.

Capability constructors are private to ATC. Runtime and Derivations provide the sandbox's internal bootstrap API and are marked @rejectSafe. Agent code cannot derive missing capabilities from a root it holds.

The preamble loads each given in a separate REPL round. Each therefore occupies a separate wrapper class: a read-only operation capturing fs does not also capture the full user capability. Keep the givens at the top level; grouping them in an object changes capture checking behavior.

Classified data

ClassifiedImpl stores a Try: every failure of a computation remains confidential. HTTP calls with classified headers or bodies return classified responses, preventing a server from reflecting a secret into ordinary output. Validate public parameters and permissions before inspecting classified values, and keep subsequent failures inside the classified result or user-only output. Public state must not depend on the value either: writeClassified creates its target before inspecting the content, so exists gives the same answer when the computation failed or its text cannot be encoded.

The data-flow argument relies on both parts of the API. map does not expose a plain result, and its callback cannot capture a full output capability. Deriving a Boolean from a secret therefore keeps the Boolean classified too. Allowing println, a mutable file handle, or an untrusted model callback inside map would expose information even if the callback returned a harmless value.

An explicit read-only file system can be used to compute a classified result:

classify("notes.txt").map(path => read(path)(using ro))

The printer callback defined above is rejected in the same position:

classify("private").map(printer) // requires UserIO^, which map cannot capture

Exception handling is part of this boundary. A callback may throw an exception containing a secret, or a fatal error or control throwable whose occurrence depends on one: a stack overflow, a non-local return, a NonLocalReturnControl thrown directly. map and flatMap keep everything a callback throws inside the classified value except the stop signals, ThreadDeath and InterruptedException, which propagate so that an interrupt or a timeout still ends the evaluation. Nothing else a classified callback throws can leave the classified value, so no catch or finally in agent code can observe a secret-dependent failure. Authorized output methods apply the same rule to user-defined rendering, including toString and getMessage: a failure there becomes user-only text and never reaches the model.

classifiedChat(String) is treated as pure. This assumes the configured endpoint is isolated and has no observable effects beyond its result; ATC cannot establish that about an arbitrary endpoint. Timing, termination and resource-consumption side channels are outside the classified-data guarantees.

Use overloads instead of default arguments on capability-taking API methods. Default arguments have allowed method wrappers to lose required captures when passed to Classified.map. CapabilitySuite covers this regression. Changes to callback signatures, capability requirements or preamble structure must retain those tests.

References

The pinned compiler implements experimental capture checking, safe mode and mutable capabilities. ATC's design is derived from TACIT.

Defence in depth

Safe mode and capture checking enforce the static capability boundary. CodeValidator provides early diagnostics for common restricted APIs and unsupported catches. It is a lexical check, with possible false positives and false negatives. Keep it small; accepting code here does not establish that the code is safe. Disabling safe mode removes the compiler's API restrictions.

Every catch arm must be a typed pattern (case e: IllegalStateException =>) of a type that is not fatal. A bare catch-all, a catch of Throwable or Error, and an extractor or identifier arm (case NonFatal(e) =>, whose unapply agent code can define) could catch the ThreadDeath stop signal, so a loop could outlast an interrupt or a timeout. The library catch helpers scala.util.control.Exception, Breaks and NonLocalReturns catch inside trusted code, where these rules cannot see the catch. Safe mode rejects them, and the validator rejects them too for sessions without safe mode.

SandboxLoader shares scala.* and atc.lib.* with the application, excluding compiler implementation packages. Other classes resolve through the JDK platform loader. Class resources are hidden so REPL instrumentation does not redefine the shared API classes.

The host checks permissions at each operation. Scope checks apply even when a locked rule already determines access. Command and host deny rules remain effective inside temporary scopes and after session grants. External commands run with the user's OS privileges; ATC's file and HTTP permissions do not constrain the internals of those commands.

The sandbox

SandboxRepl.warm starts the REPL on a daemon thread as soon as ATC is ready for input (after the resume offer; before the turn of a -p run), so the two seconds the preamble takes to compile overlap the user's typing and the model's answer. SandboxRepl.ensure adopts that session on the first Scala tool call or /run, showing a "starting sandbox" status only when it has to wait, and starts a session itself when none is warming. Agent.turn and ScalaToolRunner take the session by name, so text-only turns never wait for a compiler, and ensure checks cancellation after initialization, before any code runs. SandboxRepl.replace (/reset, /mode, /new) kills the session's processes, drops the live session and a warming one alike, and starts warming the next. A daemon thread closes a dropped warming session once its initialization ends, so the switch does not wait for it.

ReplSession persists definitions until a reset, new session or mode change. Safe mode is imported after the trusted preamble. The compiler's warning for a non-local return (a return inside a lambda) is an error in the REPL, and the validator rejects @nowarn, which would silence it. Compiler diagnostics and runtime results are returned as ExecutionResult; an exception recorded by CappedRendering marks evaluation failure. A string containing the word error in ordinary output does not determine run success.

With an execution timeout configured, evaluation runs on a daemon worker. ExecutionClock excludes nested waits for user input, external commands and model calls that have their own timeouts. Interrupts set the compiler's REPL stop flag and interrupt blocking operations. skipInvalidWrapper advances past a potentially incomplete wrapper class. Completed file, process or network effects are not rolled back after interruption or timeout. A timed-out result still carries what the snippet printed before the limit (the worker's output if it stopped within the two-second grace, else a snapshot of the capture buffer it is still writing to), minus the stop signal's trace, so the model sees the progress made; an interrupted result stays empty, since the turn ends anyway.

Evaluation temporarily replaces System.out and System.err, so a process-wide lock serializes capture. The same lock protects selection of the session's host in Runtime, including lazy preamble initialization. Lock acquisition has a timeout: an evaluation that cannot stop must not block every subsequent request indefinitely. Such a stuck evaluation requires restarting ATC.

After interruption or timeout, the stopped wrapper is excluded from future imports while earlier definitions remain available. The driver may already have advanced objectIndex; advancing it again would create a valid index with no registered wrapper and make later definitions fail with key not found. The recovery path compares the pre-evaluation and returned indexes and invalidates only the stopped wrapper. Run ReplInterruptionSuite separately as well as in the full suite: unrelated compiler sessions can populate Scala's shared wrapper-name table and hide this failure.

REPL output capture retains at most 4 MiB and reports truncation. Top-level value echoes have a separate limit of 2000 characters. User-visible prints also enter the REPL capture; the TUI records a bounded prefix to subtract already-displayed output from result panels. Preserve leading whitespace in capture because subtraction uses exact text. For the same reason only the trace the REPL renders for an uncaught exception loses its host and runtime frames (CappedRendering.renderError); printed text, which can hold a command's own traces, reaches the model unchanged.

/reset and /mode reset the REPL and its spawned processes, /mode with the newly selected capabilities. /new resets that and the conversation, queued notes, usage accounting, task notes, TODOs, retained output and session grants, and clears the window and its scrollback. REPL restarts queue a notice for the model's next turn. /run queues the user's code and its result because definitions are shared with the agent. Closed sessions reject further runs. Input predictions are invalidated after state changes and before shutdown.

The permission model

Policy combines configured file rules, session grants and temporary permission scopes. For a canonical path, matching granting rules supply access and every matching rule imposes an access ceiling. The strictest ceiling wins; unmatched paths have no access. Classification and locking apply if any matching rule enables them. Temporary and session grants may widen access but cannot remove classification or exceed a locked rule.

Policy algebra

Access levels form the finite order None < Read < Write. For canonical path p, let M(p) be all matching rules and G(p) the subset allowed to grant at p. A global or explicit rule can grant anywhere it matches; a project rule can grant only inside its project. Policy.configPerm computes config and Policy.effective the rest:

grant(p)   = maximum explicit access in G(p), default None
ceiling(p) = minimum explicit access in M(p), default Write
config(p)  = min(grant(p), ceiling(p))

scoped(s,p) = maximum matching file grant in s and its ancestors, default None
access(s,p) = config(p)                         when p is locked
              max(config(p), scoped(s,p))       otherwise
effective(s,p) = min(access(s,p), Read)          in read-only mode
                 access(s,p)                    in other modes

A missing rule access field imposes no access ceiling; it may independently set classification or locking. For example, a project-wide write rule plus a read rule on src yields read access in src. An additional classification rule on src/private changes how content may be observed without changing that read level. A temporary write grant may raise the read level only when no matching rule locks it.

matchingRules caches immutable matches, including inherited ancestor matches, in a bounded LRU map. It does not cache temporary grants or mode-dependent decisions, so opening or closing a scope cannot leave stale effective permissions. The scope must be resolved even for locked paths; otherwise a closed capability could remain usable under a locked read rule.

Scope lifecycle

A request* call opens a child scope, and its capability carries that scope's ID. Closing the block closes the scope and kills processes owned by it. Process handles require an open originating scope; runningProcesses filters by scope visibility.

At runtime, a request resolves its parent scope, checks mode and deny restrictions, and asks only for permissions not already held. The child scope holds only what the prompt asked for, and nothing when no prompt was needed, because what is held is not always covered by a grant of the same text: a file grant would also raise every stricter unlocked rule below its path, and ./run.sh is held under *.sh but as a pattern would also allow arguments. AllowOnce applies to the child scope; AllowSession also records the grant on ScopeId.Base. Denial throws before a child scope is opened. The callback runs through Host.inScope, whose finally block terminates scoped processes and closes the scope. The compiler's lifetime checks and the host's scope IDs therefore enforce complementary parts of the same request contract.

Permission requests display numbered command or host rows, and a note row when a command pattern holds *, which allows any arguments and so, for an interpreter, any code. The policy sorts and deduplicates requested patterns so the displayed order agrees with decision notes. Policy.sessionGrants provides the current grant list; /perms revoke selects from it and Policy.revoke removes the selected grant from future checks. Configuration rules and open-scope semantics remain separate. Revoking a command grant does not terminate an existing process; /kill does that. The agent receives a queued notice after a user revokes a grant.

Decision.Revise(instructions) records user feedback without granting access or opening a scope. The pending request throws a permission error. ToolOutput appends the complete, JSON-quoted instructions after the bounded execution output. A revision is distinct from Deny: the model should change its plan and may submit a narrower permission request. Neither decision adds a permanent deny rule; configured deny rules remain unchanged.

File patterns match a path or an ancestor. Component globs match at any depth; relative patterns with separators use the layer's base; absolute patterns and ~ use an absolute path. PathGlob handles slash-based globs on all platforms. Canonicalization resolves symlinks, including dangling write targets. Directory traversal does not follow symlinked directories or Windows junctions. Filesystem checks and subsequent operations are not an OS-level transactional sandbox. On Windows and macOS (Platform.caseInsensitivePaths) every pattern, exact paths and the root of an anchored glob included, compares names case-insensitively, because canonicalization corrects the case of existing names only: a new .ATC or SECRETS is the same directory as .atc or secrets there and must fall under its rule. An agent path on a UNC share other than the working directory's is refused before canonicalization, which would otherwise contact the server even from a read-only file system.

GitIgnore controls visibility in listings and recursive searches, independently of permissions. It reads repository and nested .gitignore files, caches them for the session and always hides .git. An ignored path can still be accessed explicitly if permitted. Since its rules decide which names a listing shows, a .gitignore is read only when the base scope may read its canonical target and the target is not classified. The base scope holds the configured rules and the session grants; request scopes do not count. A symlinked .gitignore is judged by its target. A refusal lasts only until the session grants change, so a .gitignore applies from the first listing after the user grants access for the session.

Host patterns are case-insensitive and normalize numeric IP literals. Deny rules are checked at use, even when a requested pattern was previously approved. Each permission decision is included in the tool result so the model knows whether approval was temporary, session-wide or denied.

Request blocks from the agent's side

requestFiles works in every mode: the file system it lends the block is exactly as capable as the one the caller already holds, so read-only callbacks remain read-only, and command execution, which needs FileSystem^, still compiles only in local and full mode. requestExec widens only Exec^; a command that also needs a file permission that is not configured needs a nested requestFiles block. The result of a request tells the agent what the user decided, so "once" needs another request next time while "for the session" does not. In the pop-up, Tell the agent what to change sends the typed text back instead of a grant: the original request receives nothing and the remaining tool calls of that batch are skipped. Empty or cancelled feedback returns to the menu. Without a menu-capable terminal the prompt accepts exact y/yes and s/session approvals and n/no denials, and any other non-empty answer is Decision.Revise, so "yes, except the deployment command" is feedback, not approval; EOF and an empty answer deny. A -p run never prompts: a request fails unless --approve-all approves it for the session (App.permissionPrompter).

Host operations

Text helpers use UTF-8. TextFiles accepts LF, CRLF and CR and preserves the first newline style and final-newline convention during line edits. Listings return paths relative to the working directory when possible; API paths use / separators on every platform.

sed counts and rewrites in one pass and rejects zero matches. replaceLines returns the replaced text; callers must account for line-number changes between edits. copy and move stream data, check both paths and avoid truncating a file copied onto itself or a hard-link alias. move checks source write permission before copying but is not atomic. replaceExact requires one non-empty literal occurrence and validates it before any write; it does not interpret regex or replacement escapes.

readRange returns at most 1000 lines and cat prints at most 400 numbered lines; both stream the file, keep at most 2000 characters of a line and mark a cut line with ... [+N chars]. readRange and the range form of cat stop after scanning two million characters and then report a read limit, distinct from the end of the file. Plain cat scans up to 64 million characters to count the lines after the window, and both cat forms name the call that shows the next lines.

search uses lazy descendant traversal and FileEntryImpl.scanLines, which closes its stream when the callback stops or a character budget is reached. SearchOptions bounds matched rows, scanned files, lines per file, line prefixes and characters read per file. Regex matching examines retained prefixes only. limited says the search was not exhaustive: a cap left a line, file or match unexamined or unreported. A budget that runs out exactly at the end of the input leaves it false. Listings apply the gitignore visibility rules and sort their entries; traversal does not follow directory symlinks or enter classified trees.

Successful unclassified file operations report FileChange through HostOutput. Snapshots cover files of at most 64000 bytes; binary and larger files get a summary without a text preview. The preview uses common line prefixes and suffixes and one replacement block, capped at 40 lines. Classified writes and deletions never enter this preview path, and changes made by external commands are not captured.

CommandLine parses quoted arguments, pipelines, <, >, >> and 2>&1. It rejects shell control operators and does not expand variables or globs. Explicit argument sequences are passed verbatim. Each pipeline stage and redirected file is checked separately; a redirection must be written on the stage it applies to (< on the first command, >/>> on the last), and anything else is refused like the other shell forms rather than moved. A command needs read access to the directory it runs in and to an input file, and write access to an output file, and none of them may be classified. WindowsExecutable resolves bare commands from absolute PATH entries and validates batch arguments before launch.

Commands do not inherit the variables that hold provider keys (Configuration.keyVariables: the ${VAR}s of key and headers, keyEnv, and the secrets the SDK reads from the environment for a provider without a url, whatever spelling of its api it uses), since a command that prints its environment would hand them to the model. They lose the SDK variables even when a key is configured, because a key whose variable is unset lets the SDK read them. They also lose the ATC_INTERNAL_* variables in which the Windows launchers pass the original command line.

Processes drains stdout and stderr concurrently into buffers of at most 8 Mi characters each; TextSink decodes UTF-8 and BOM-marked UTF-16 output incrementally. Foreground commands retain prefixes; spawned processes retain recent output. Results use the rightmost non-zero pipeline exit code. readUntil consumes through a regex match; on timeout it throws and keeps output unread. waitFor returns None on timeout. Process termination includes pipeline stages and descendants. Shutdown kills registered processes.

parallel(tasks) runs the tasks on a pool of at most eight daemon threads created for that call. A nested call, made from one of those threads, runs its tasks one after another on that thread, because a pool per level would multiply the threads of a recursion and a pool shared by the levels could starve them. The results come back in task order once every task has ended. A failed task does not cut the others short, since their effects must not outlive the snippet; the first failure in task order is rethrown, and a fatal throwable wins over ordinary exceptions so that the REPL's stop signal reaches the evaluation thread. Every instrumented REPL class checks the stop flag on every thread, so an interrupt or timeout stops busy tasks too, and interrupting the calling thread interrupts workers blocked in a host call. Shared host state is thread-safe: the TUI's output methods are synchronized, Policy, the process registry and the TODO list lock their mutable state, and Tui.popupBlock shows questions and permission prompts from several tasks one at a time.

The tasks are declared Seq[() ->{C} A] with a capture-set parameter C^. A function inside a Seq is boxed, and the checker charges a boxed closure's captures only where it is unboxed, which is inside the trusted host; with Seq[() => A], a task could run exec on a secret inside Classified.map. The parameter charges the caller with C, so Classified.map keeps its rules and pure tasks still compile inside it. CapabilitySuite covers both cases.

HTTP operations validate the scheme, host and headers (secret header names too; only their values stay inside the classified boundary), do not follow redirects, and cap response bodies at 8 MiB. httpGet and httpPost throw for status codes of 400 or higher; httpRequest returns raw status and body. Classified request handling retains subsequent transport and response failures within Classified.

Configuration semantics

Layers load in this order: global, nearest project, explicit -c file. A project is the nearest ancestor containing .atc/config.json or .atc/keys.properties. A file named twice, compared by canonical path so that a link to it counts as the same file, retains its first role. Project rules are anchored to the directory containing .atc and grant access only within it; their restrictions apply wherever they match.

Setting Merge rule
Providers Merge by provider name; model entries merge by alias, replacing a repeated alias. A project layer may set only models of a provider a granting layer defines, and turn it off with enabled: false
Model selection, instructions, other ordinary settings Later layer wins
Commands, hosts Concatenate across layers
File rules Retain each rule and its layer base
Deny commands, deny hosts Accumulate across layers
Mode and numeric limits Granting layers set values; project layers may only tighten them
Safe mode, gitignore visibility Project layers may enable, but cannot disable, an enabled restriction

A project layer's commands, hosts and classifiedModel, a webSearch: true at its top level or on a model entry, and its keys.properties reach beyond its own files, and a cloned repository can ship them. Provider web search is an outbound channel that hosts and denyHosts do not govern. ProjectTrust records a SHA-256 fingerprint of exactly these per project root in ~/.atc/trusted-projects.json (owner-only); web search enters it only when on, so fingerprints recorded without it still match. A classifiedModel enters with what the project's providers say about it, the entries of its alias and the providers turned off, so an edit that changes the provider, model id or settings it names is put to the user again. Config.load leaves these grants out while the fingerprint is not recorded, judging that on the same reading of the files that the layer and the keys come from, so every reload agrees with the decision. Setup.load shows every entry they grant, escaped like permission details, and offers Trust, Run without these grants, or Quit; it says whether the config is new or has changed since it was trusted. Trust records the fingerprint of what was shown, so a file that changed meanwhile stays untrusted. A -p run warns and goes without them unless --approve-all is given, which takes them unrecorded. Configs ATC writes itself (--init, the offered starter config) are trusted as they are written, unless the directory already waited for trust, such as for a keys.properties the repository shipped: the prompt then shows the starter config's grants with it. Neither writes the home directory's .atc/config.json, which is the global config, nor through a symbolic link at .atc, its config.json or its .gitignore. Other edits, such as a new model, do not change the fingerprint.

Only explicitly defined project settings narrow a value. executionTimeoutMs defaults to 300000; a JSON null clears it and means no limit. Reading a layer (ConfigValidation.requireExact) refuses a null for a setting that is not optional, which the JSON reader would take as null, 0 or false, and a number that an integer setting would truncate or clamp, such as 1.5 or 1e10. Configuration.rules is the complete rule list; do not build policy from settings.files, which contains only granting-layer entries. Configuration validation checks modes, limits, file rules, provider and model settings and the uniqueness of model references before execution; model and classifiedModel are resolved at the start (see Models and providers).

/config (ConfigCommands) changes only predictInput, notifications, webSearch, autoCompactThreshold and compactKeepRatio. They are outside PolicyKeys, so none can loosen the sandbox, and a project config may set them; one that turns webSearch on needs trust. A change goes into a session layer that Models keeps in memory and appends after the files on each reload, so a /providers edit does not drop it. That layer merges as a narrowing layer: a policy key in it could only tighten, and its other settings win in layer order. Saving to the project config in force or to the global config also writes that file with Config.setTopLevel, and the note on a saved value names a later file that sets the same key and wins at the next start. App.useSettings applies the combined settings to the agent, the notifier and the predictor, and Models.reload switches web search on the cached clients (ChatModel.useWebSearch); clients are not recreated, since a Claude Code client holds a CLI session. The sandbox keeps the policy it started with.

Every config edit ATC makes (/config, /providers, and the /model, /effort and /classifiedmodel saves) goes through ConfigCommands.editConfigs: it writes the files, trusts a project again when it was trusted before, so the user's own edit does not make it ask, and then reloads. When writing or reloading fails, the files get their text back, files it created are deleted, and trust is restored the same way.

Configuration.combine first merges ordinary settings in layer order, then obtains policy settings from granting layers and applies the narrowing layers' restrictions. Numeric restrictions use minimum; enabled safety flags use logical OR. These operations are order-independent for narrowing layers. A field omitted from a project JSON object is not an explicit request for its case-class default, which is why ConfigLayer.defines participates in tightening.

Key bindings are separate from settings. Lookup uses project files, global files, then the live process environment; blank values are skipped. New key files use owner-only POSIX permissions (0600, with a warning at load when group or others can read the file). Windows uses the directory's inherited NTFS ACL, which ATC does not rewrite or audit; keep the file under the private profile and check it with icacls on a shared machine.

Config.setTopLevel preserves surrounding JSON formatting, BOMs and line endings, and Config.editFile leaves a file alone when an edit changes nothing. The ObjectText scanner operates only after JSON validation. Duplicate keys update the final occurrence, matching ujson's lookup, and a removal drops every occurrence. Writes use a temporary file and atomic replacement where supported, preserving POSIX permissions and resolving a configured symlink target.

Configuration reference

The user-facing settings not covered by the README.

Providers. api is anthropic, openai-responses (also DeepSeek and other services through url), openai (Chat Completions: Ollama, vLLM, OpenRouter, …), chatgpt (the models of a ChatGPT plan, signed in through the browser; see Models and providers), claude-code (the models of a Claude plan, through the user's signed-in Claude Code CLI; same section) or echo (keyless, for smoke tests). key is a literal or ${VAR}, and keyEnv names a variable; variables resolve from the project's .atc/keys.properties (once the project is trusted), then ~/.atc/keys.properties, then the environment. When neither key nor keyEnv resolves, the SDK's own variable (ANTHROPIC_API_KEY, OPENAI_API_KEY) applies only when url is unset (Providers.key); a gateway at a url that needs that key names it, as in "key": "${ANTHROPIC_API_KEY}". headers are extra HTTP headers for every request, model lists included; a value may be a ${VAR} or ${ATC_SESSION}, a random id of the conversation (renewed by /new) for gateways that route by session, such as OpenCode ("x-opencode-session": "${ATC_SESSION}"). Requests identify ATC as atc/<version> unless headers sets User-Agent.

reasoningStyle describes a Chat Completions provider (api: openai) that asks for reasoning and returns it in its own way. request is a JSON object merged into the body of a call that reasons, in place of reasoning_effort; a string {effort} in it becomes the model's current effort, and with no effort chosen the member holding it is left out. requestOff is merged into calls that should reason little (next-request prediction). tags names the opening and closing tag around reasoning written into the answer text: what is between them streams as reasoning and stays out of the answer and the history. The Gemini preset uses all three:

"reasoningStyle": {
  "request": { "extra_body": { "google": { "thinking_config":
    { "include_thoughts": true, "thinking_level": "{effort}" } } } },
  "requestOff": { "extra_body": { "google": { "thinking_config": { "thinking_level": "low" } } } },
  "tags": ["<thought>", "</thought>"]
}

A model's own thinking switch still applies beside it. Reasoning in the reasoning_content or reasoning fields of a delta streams as reasoning whatever the style says.

Models. A model is an alias with a provider-specific name and its own settings: contextWindow (the real window, so the conversation is compacted and trimmed to fit), maxTokens, temperature, reasoning, efforts, thinking, reasoningSummary, webSearch, webSearchVersion and displayName. Name a model by its alias, or provider/alias when two providers share one. A provider whose models is empty or absent lists its own, each named provider/model-id: the last list each provider returned is kept in ~/.atc/model-lists.json, a new one is fetched in the background once a session has started, and a provider that cannot be reached, or whose configured key is unset, is skipped. Anthropic's and DeepSeek's lists supply context windows and effort levels (DeepSeek's also the default effort), OpenRouter's and vLLM's the context window. A listed output limit is not taken, since it would be sent with every request and reserved from the window. Without -m or model, a session starts with the model last chosen with /model.

First run. An interactive start without ~/.atc/config.json or -c runs FirstRun: the user chooses one of the atc/providers.json presets, gives its key unless one is already bound (read by Tui.askSecret, masked and kept out of the prompt history) or signs in (chatgpt, see FirstRun.signIn; claude-code needs claude auth login beforehand), and chooses a model from the provider's list, which also checks the key. Setup.load then writes a global config naming only that provider, binds the key in ~/.atc/keys.properties (KeyBindings.bind, owner-only, other lines kept), stores the list and records the model as the last one, so the session starts without another fetch. Configure providers myself writes the starting config with every preset and exits; Not now or Esc runs on the built-in starting config. -p runs and terminals without menus never ask.

Turning providers and models on and off. "enabled": false on a provider or a model hides it; a disabled provider is never listed. /providers edits these switches and the model entries in place (ObjectText.withMember keeps the file's formatting). Choosing models for a provider that lists its own writes the ticked ones as entries with the display name, context window, efforts, default effort (reasoning) and thinking support the list reported, which makes them its shortlist; Offer every model it lists drops the entries again. A listed id with / gets an alias without one (ProviderEdits.aliasFor). A change goes to the last layer holding the longest part of its path (ProviderEdits.owner), because layers merge a model entry as a whole. A removal, such as dropping the entries or turning a provider back on, goes to every layer that holds the member (ProviderEdits.files), because layers merge a provider's models and enabled across files; dropping the entries names those files when it asks. A new provider goes to the global config. The catalog is rebuilt after the reload, and a model in use that the change renamed moves to its new name. A provider or model in use, by the session or as model/classifiedModel, cannot be turned off, and a provider with a model in use keeps its entries. A chatgpt provider also offers Sign in with ChatGPT, which replaces the saved sign-in.

Efforts. reasoning is the effort a session starts with; efforts lists the ones the model accepts (by default every effort its api knows: low to max for Anthropic, none to max for OpenAI). /effort [level] switches the agent model's effort, and default sends no effort. The choice is saved as the top-level effort of the working directory's project config (when it has one, like /model's model), which replaces the starting model's reasoning in later sessions; an effort that model does not take is ignored with a warning. /model starts the new model at its configured effort and removes effort.

Web search. A model's webSearch turns on the provider's own search tool; the top-level webSearch does so for every model that does not set its own. It applies in full mode only (Models.useMode), since read-only and local mode keep the agent off the network. It is best effort: when a provider rejects the tool, the model continues without it for the session.

Notifications. notifications is auto (the default: the terminal's own notifications in kitty, iTerm2, WezTerm, Ghostty and foot, a desktop notification on a local machine, the bell otherwise), system, terminal, bell or off. An alert comes only after a wait in which the user neither typed nor had the terminal focused: ten seconds for a question or permission request, thirty for a finished turn.

File rules and command patterns

Each file rule has a path pattern and may specify access (none|read|write), classified, and locked. Patterns follow gitignore-style conventions. A pattern without / matches a path component anywhere (.env, *.pem, node_modules). A relative pattern containing / is resolved against the working directory, or the project directory in a project configuration, and may contain *, **, ?, […] or {a,b}. Absolute paths and paths beginning with ~/ remain absolute; . denotes the working directory itself. A rule applies to the matched path and its entire subtree. Every matching rule with an access caps the access at that level, so the strictest one wins; no match means no access, and a project rule grants only inside its project (Policy algebra). A path is classified or locked if any matching rule says so.

Classified content is only observable as Classified[String], and a classified directory's structure is classified too (listing it needs childrenClassified/walkClassified; walk/grepRecursive/find do not descend into it). A plain write to a classified path is refused, and so is writeClassified to a non-classified path. Locked means no prompt can widen the rule. "respectGitignore": true (the default) additionally hides what git ignores from listings; that is visibility, not permission, so an ignored file is still readable by name.

commands contains patterns matched against the complete command line. * is a wildcard, and a pattern without * matches by word prefix ("git status" allows git status --short but not git statusx). Its working directory and redirected files still need file access (Host operations), but a pre-approved command runs with the user's privileges and its own file access is not subject to the file rules. hosts are glob patterns on host names; only http/https URLs are accepted and redirects are not followed. denyCommands and denyHosts use the same syntax: a deny rule overrides every allow rule, including a session grant, an open request* scope, and --approve-all. The template's shell denials are bare names: "bash" blocks both bash and bash -c ..., but not an explicit /bin/bash, a wrapper, or a renamed interpreter; no finite deny list can classify every program that might execute code. The project template pre-approves only git commands whose options cannot reach files outside the repository once --output is denied: git diff reads any file when one of two paths lies outside the repository or the directory is not one (git diff .env /dev/null), and git blame through --contents, --ignore-revs-file, -S and their abbreviations, so neither is in the template.

Windows. Use / separators in configuration on every platform: in JSON, "C:/Users/alice/project" (a native backslash starts a JSON escape, so the equivalent form needs doubled backslashes). The file API accepts native Windows input too, but renders Windows separators as /. Every path the user or the agent types (agent paths, -c, -C, /save, /resume) goes through PlatformPath.parse, which expands ~ and on Windows refuses device namespaces, drive-relative paths, alternate data streams, reserved device names and names ending in a dot or space before java.nio sees them. Command availability is platform-specific: ./mill, ls, cat and bash are not normal Windows commands; use an installed executable or the project's mill.bat. exec does not send its command line through a shell; an authorized .cmd/.bat launcher inherently uses the Windows command processor with strict argument checks, and built-ins such as dir or PowerShell cmdlets need an explicitly permitted shell, which grants that shell broad authority. Quote every argument containing spaces; a backslash remains a path separator, not a space escape. External programs choose their own newline and encoding conventions (commonly CRLF, sometimes BOM-marked UTF-16 on Windows).

Models and providers

ModelCatalog resolves provider/alias or an unambiguous bare alias, ignoring case; displayName affects presentation only. A provider without configured models is listed through ChatModel.listModels, at most once per catalog, in parallel across providers and on daemon threads of their own (ModelCatalog.Listing), since a listing blocks on HTTP or on the Claude Code CLI. HTTP listings use Providers.ListTimeout (20 seconds); Claude Code's list takes several CLI control requests, each with the CLI's 60-second timeout. App starts refresh() after the banner, and models (/models) waits for it at most 30 seconds (ModelCatalog.ListWait); a provider whose fetch has not finished keeps its stored list until it does. A failed fetch is only logged with ATC_DEBUG and leaves the stored list in use. Lookups and Tab completion (labels) never wait: they use this session's list when it has arrived, else the one ModelListStore kept (~/.atc/model-lists.json, reused only for the same provider name, api and url), and a lookup else takes provider/model-id as given. Such a model keeps its unknown context window until a later session finds it in the stored list. A bare id that no list has named yet is unknown. A reference that starts with the name of a configured provider, turned off or not, and / names a model of that provider only, never a listed id of another provider such as OpenRouter's anthropic/.... Listed models are always labelled by their full reference, so fetching a list never changes the labels of configured models.

Configuration validation does not check model or classifiedModel. At the start, Models.configured resolves them, and one that names no model is ignored with a warning that names its file: the session falls back to the last model chosen or the first one, and sends classified data to no model. A -m that names no model stops the start. A project layer may add models to the user's providers and turn providers off, so when it changes providers, Models.classified also resolves a classifiedModel set by a granting layer in the granting layers alone (Configuration.granting) and ignores it, with the same warning, unless both name the same provider/alias and model id. ChatModel.effort is mutable per client and read at request time; Models caches one client per reference, so /model sets the chosen client's effort back to ChatModel.defaultEffort (its reasoning).

Web search is best effort. ModelCatalog gives every model without its own webSearch the top-level one, listed models included. SpecModel.withWebSearchFallback resends a streaming request without the tool when the provider answers 400 or 422 naming web search before anything was streamed, and turns the tool off for that client. A gateway that drops the tool silently cannot be detected. ChatModel has streaming complete and one-shot simple operations. Provider adapters normalize stop reasons into CompletionStop; model_context_window_exceeded (Anthropic, Claude Code) counts as truncation, as an output limit does, so the calls of a response cut by the context window are not run.

Msg carries neutral text and tool calls. Assistant messages may also carry a NativeTurn for replay to the exact provider/model reference that produced it. Switching models uses neutral fields and excludes model-bound reasoning data. TokenUsage.input includes cache reads and writes; adapters normalize provider-specific accounting.

Anthropic requests set two cache breakpoints: the system prompt and the last user-role message (user text, a continuation or tool results), so each round reads the previous round's prefix from the cache. Responses requests use stateless replay with encrypted reasoning content. OpenAI-compatible adapters send vendor thinking parameters only when configured. Non-thinking one-shot requests can retry without a guessed reasoning effort when the provider rejects that parameter; unrelated bad requests are not retried by this fallback.

Provider SDK request construction remains in each adapter. Shared configuration and client setup belong in Providers; model selection belongs in ModelCatalog. Every adapter builds its client with Providers.key: without a configured key, a provider with a url gets the placeholder key none, so the SDK's environment credentials reach only the provider's default endpoint. A streaming request has no limit on the whole call, since a long answer can stream for longer than Providers.RequestTimeout (15 minutes); connecting, and each read and write, keep their timeouts, and Ctrl-C cancels the call. One-shot calls keep the 15-minute limit.

A provider's headers are extra HTTP headers for every request to it. KeyBindings.headers resolves ${VAR} values through the key bindings (an unset variable drops the header) and keeps the placeholder ${ATC_SESSION} (ProviderConfig.SessionRef), which Providers.headers(spec) replaces per request with the conversation id, a UUID that Agent.clear() renews. The same call adds User-Agent: atc/<version> unless the config sets one, regardless of header-name case. The set goes on every request, model lists included: the OpenAI-shaped adapters add it with an OkHttp interceptor on the model's client, ahead of the ChatGPT authorization, and the Anthropic adapter with a wrapper around its SDK transport. Provider-specific headers such as OpenCode's x-opencode-session are configured explicitly.

Chat Completions requests set stream_options.include_usage so providers can report token counts for /cost and context calibration. Providers send usage on the finish chunk, in a separate chunk, or both. OpenAIChatModel.ChunkFeed saves the latest usage and feeds it to the SDK accumulator once, after the choices. It ignores empty chunks without usage and extra choice chunks after completion. Auxiliary chat calls also apply configured maxTokens and temperature. ModelSuite checks chunk handling; ProviderRequestSuite checks requests and usage accounting against a local HTTP server.

Some Chat Completions gateways end a stream without a finish_reason, with or without [DONE]. OpenAIChatModel then returns an Incomplete completion containing received answer text and usage, with no tool calls or native replay payload. The agent warns and requests a continuation; after two such attempts in a turn it stops with a failed outcome. Normal provider output limits retain their separate continuation budget. Reasoning is never used as answer text. When a chunk contains both reasoning and answer text, reasoning is displayed first so the answer stays together. Empty deltas and deltas after the finish chunk do not reach the UI.

Gemini's OpenAI-compatible endpoint streams each tool call whole, without the index the SDK accumulator needs, so ChunkFeed numbers such fragments: one with an id starts the next call. Gemini 3 also attaches a thought signature to each call (extra_content), requires it back with the calls of the exchange in progress, and refuses null where it expects text. The accumulator drops extra_content, so ChunkFeed keeps it by call, and OpenAIChatModel.replayable builds the native turn without null fields and with each call's extra_content. Native turns are not saved with a session; Gemini accepts earlier exchanges without signatures, so a resumed conversation still works. Its thoughts, asked for through the preset's reasoningStyle, arrive in the answer text between <thought> tags; OpenAIChatModel.TagSplitter splits them off as the stream arrives, holding back a tail that could begin a tag, and the stored answer and the replayed turn keep only the answer text.

The chatgpt api reaches the models of a ChatGPT plan through the backend the Codex CLI uses (the provider's url, by default https://chatgpt.com/backend-api/codex), signed in the way Codex signs in. ChatGPTAuth.begin starts an OAuth authorization-code grant with PKCE and Codex's client id at auth.openai.com, with a callback server on the loopback interface at port 1455 or 1457, the two callbacks registered for that client. FirstRun.signIn opens the browser (Platform.openBrowser) and prints the address. When the browser cannot reach the callback (atc runs over SSH), the user pastes the address the browser ended on instead, and Login.paste reads the code from it. The state must match either way. The tokens go to ~/.atc/chatgpt-auth.json, replaced atomically and readable only by its owner; the default policy's locked .atc rule keeps the agent out of it. The ChatGPT account id comes from the ID token's https://api.openai.com/auth claim, and the expiry from the access token's exp. ChatGPTModel is the Responses adapter with an OkHttp interceptor that reads the file for every request, so every atc process sees the newest tokens. The interceptor refreshes tokens five minutes before they expire, and once more after a 401. A refresh token can be used once, so a refresh holds a lock on chatgpt-auth.json.lock (created readable only by its owner) and reads the saved tokens again once it has the lock: another process may have refreshed while it waited. When the token endpoint still rejects a refresh token, the saved tokens win if their refresh token differs; otherwise SignInNeeded tells the user to sign in again from /providers. A failure of the sign-in step that is not an IOException, such as a malformed token answer, fails the request as an IOException that names only the exception's class, since its message may quote a token.

The backend streams only, requires instructions and rejects max_output_tokens and temperature. Its closing response.completed carries an empty output, so OpenAIResponsesModel.Accumulator keeps the items of the response.output_item.done events and uses them when the final response has none. The adapter streams one-shot calls too, supplies instructions when a call has none, sends neither setting and sets prompt_cache_key to the conversation id. The backend never returns the reasoning itself, so a model without reasoningSummary asks for auto summaries, as Codex does, and shows them as thinking. Its model list is GET /models?client_version=, filtered to the models marked list, with their context windows and efforts. The backend lists the models that Codex release may use, so ChatGPTModel.ClientVersion follows Codex releases. The preset sends originator: atc and session-id: ${ATC_SESSION}. ChatGPTSuite covers the flow against a local server.

The claude-code api reaches the models of a Claude plan through the claude CLI the user installed and signed in to; ATC never reads its credentials. ClaudeCli starts claude -p in the stream-json protocol of the Claude Agent SDK, in an empty temporary directory (deleted when the process exits, or else when ATC exits), with everything that would act or load context outside ATC turned off: --tools= (no built-in tools; WebSearch alone when webSearch is on), --setting-sources= (no settings files, hooks, plugins or skills), --strict-mcp-config, --disable-slash-commands, --no-session-persistence, and --permission-mode=dontAsk --permission-prompts=none with only ATC's MCP server allowed. The environment disables CLAUDE.md files, auto memory, claude.ai connectors, auto-compaction (ATC compacts) and updates, and drops ANTHROPIC_API_KEY, which would replace the subscription. The system prompt goes in the initialize control request, since it exceeds Windows' command-line limit. The CLI asks the API to omit thinking text by default; the hidden --thinking-display=summarized flag brings back a summary, or the full text from models that give it (Haiku 4.5), which the showThinkingSummaries setting does not do for newer models. ATC's tools are an in-process MCP server ("type": "sdk"): the CLI sends JSON-RPC as mcp_message control requests and every one, notifications included, needs a response.

The CLI runs its own agent loop, and ClaudeCodeModel keeps ATC's loop in charge. A response that calls tools ends at its message_stop and is returned as a completion; the CLI's tools/call requests, matched by _meta.claudecode/toolUseId, stay unanswered until the next complete brings the agent's results. The CLI may call the first tool before later tool_use blocks have streamed, and runs the calls one after another, so a result that arrives first waits for its call. A completion carries a NativeTurn marker, so the next request can tell whether its history extends what the session has seen: the results of the pending calls, or user messages after a finished answer. Any other history (compaction, a context cut, an interrupt, a model switch, a restored session), or a changed system prompt, tool list or effort, ends the process and starts one that gets the history as a transcript. A cancelled request stops its process. One-shot calls run in their own process without tools. The model list comes from the initialize answer, and each model's context window from set_model followed by get_context_usage (rawMaxTokens); none of these makes a model request. A configured model without contextWindow learns its window the same way when a session starts, until the CLI has answered once; the session is published before that question, so an interrupt during it leaves a process that close or the next session ends. On Windows, WindowsExecutable resolves claude from the PATH only, so a claude.exe in the project cannot run in its place. ClaudeCodeSuite covers the protocol against a scripted CLI.

Conversation context and agent loop

The system prompt contains environment data, workflow instructions, capability rules, the API source and configured permissions. Session grants are reported in tool results so they do not change the prompt prefix. Repository-derived scalar values are JSON-quoted; larger instruction and permission blocks are marked as data.

CompletionPolicy selects tool execution, continuation or completion. Calls from truncated or blocked responses are not executed, and the assistant message says so even when it has text. Output limits add Msg.Continuation; server-side pauses can resume directly. A tool call the output limit cut gets its own continuation (AgentMessages.truncatedToolCall: the call did not run, split it), since after a plain continuation models send the same call again, and at most Agent.MaxTruncatedCalls retries. Resume and tool-budget rejection counts bound repeated work. The interactive tool budget may be extended by the user; non-interactive runs stop at it.

TurnOutcome records why the loop ended. Finished means the model produced a final response, not that the user's overall objective was independently verified or that every tool succeeded. Interruption, provider refusal, exhausted limits and empty terminal responses have distinct outcomes. App maps them to the turn's summary line, which also shows its cost and how full the context window is, and to the exit code of a -p run: 0 when finished, 130 when interrupted and 1 otherwise. Uncaught execution errors remain exceptions within the loop and are reported as Failed by the application.

ModelRequest runs at most one unfinished provider call per agent. The provider adapters use SDK asynchronous streams, register their close callbacks before waiting for completion, and accumulate events through the existing SDK accumulators. This permits cancellation before HTTP headers arrive as well as during streaming. The caller blocks until the worker ends or ModelRequest.recheck() finds the request's cancellation predicate true; the threads that make it true (the SIGINT handler through Agent.interrupt, the key thread through Agent.submit) call recheck(), which wakes the caller, closes the stream and interrupts the worker, so nothing is polled during a call. A provider that does not stop prevents another worker from accumulating behind it and produces a clear retry message. Request completion or failure releases the active slot before returning to the caller, even if the worker is still exiting. Cancellation retains the slot while the operation is running. Stream sinks reject late output after the request ends. Provider clients are closed at application shutdown. Interrupted calls may not supply a final token-usage report.

Cancellation tracks the underlying OkHttp call through a request-scoped event listener. It calls Call.cancel() before closing the SDK's buffered reader: closing that reader alone can wait on a lock held by a stalled read. The listener captures the request scope so retries on SDK threads remain cancellable. Temporary SDK clients borrow the model's connection pool and executor through transports whose close is a no-op. Only the owning model closes these resources. This also prevents SDK garbage-collection cleanup from shutting down the executor between tool calls. Provider tests cover authentication, cancellation before headers, cancellation between chunked events, subsequent requests and garbage collection. Error messages include a bounded cause chain; ATC_DEBUG=1 adds the complete stack trace.

When a tool call throws, Agent appends the results of the calls that completed, a failure for that call (its effects may have happened) and a "not run" result for each later call before the exception propagates. After any other failure Conversation closes the open edge with [turn failed: ...] as each pending tool result or as an assistant message, and after a cancellation with the interruption marker. Pending REPL notices are prepended to the next user message. Real user input and internal continuation messages remain distinct for context fitting and prediction.

Input the user submits during a turn enters a concurrent queue. While a model is generating, it cancels that request without a marker; while Scala is running, it waits for the call to return, and the remaining calls of that completion receive skipped results. Conversation.steer then adds the correction as a user message, closing the open edge and inserting an assistant bridge after tool results when needed, so the next model request sees the correction before choosing another operation. When the user interrupts the turn instead, queued text goes back to the prompt as a draft (Agent.takeQueuedInput, Tui.draft), with text typed but not sent on a line after it, rather than starting another turn.

When a permission prompt returns feedback, ScalaToolRunner sets ToolResult.needsReplan from the recorded decision, even if the snippet caught the permission exception. Agent then skips the remaining tool calls in that completion and supplies an error result for each skipped call, preserving the provider's request/result pairing. The next completion can issue revised calls normally. This scheduling flag is internal and is not serialized by provider adapters. It does not roll back earlier effects or stop code that continues inside the same snippet after catching the permission exception.

Context fitting and compaction

ContextManager estimates tokens from text and replay payloads, then calibrates against provider counts. It reserves output capacity and drops complete older exchanges at real user boundaries, computing message sizes and prefix sums once. It never cuts between a tool request and its results or drops part of the latest exchange, and Msg.Continuation is not a boundary, so an automatic resume cannot remove the user request it continues. A cut rewrites the first message, which ends every cached prompt prefix, so it goes down to three quarters of the budget (ContextManager.CutTarget) and the next rounds fit without another one. An unavoidable overflow produces a warning once per user turn. Changing models resets calibration.

The estimator starts at approximately one token per four UTF-16 characters, plus message framing. For a configured context window W, it reserves max(W / 8, configured maximum output tokens); ContextManager.inputAllowance is the window less that reserve and serves fitting, compaction and the automatic compaction threshold. Completed requests with at least 200 input tokens update the provider/estimate ratio, clamped to [0.25, 8.0]. Estimates include only native payloads eligible for replay to the selected model and avoid counting native and neutral assistant content twice. This is an estimate, not provider tokenization.

HostInteraction retains immutable TaskNotes (goal, constraints, completed work and remaining steps), limited to 16000 characters, which /task shows. The agent prompt asks the model to maintain these alongside TODOs. Conversation separately retains the first user request and up to eight recent requests, bounded to 8000 characters each. When history is cut, ContextManager reserves room for a JSON representation of the task notes, bounded recent instructions and TODO state, and inserts it with the cut notice. This leaves the stable system prefix intact. Current user instructions and actual permissions take precedence over working notes.

Agent.compact implements /compact [focus] (/compact preserve debugging findings) with a tool-free request to the selected model through ModelRequest. ContextCompaction serializes neutral transcript data and builds a user/assistant summary exchange, including retained task context. It commits only complete, nonempty summaries smaller than the older prefix they replace; the result is an Agent.CompactOutcome (compacted, nothing to compact, summary not smaller) so /compact can say which. Pending notes, user request tracking, task state, permissions and REPL definitions are unchanged; the usage is listed under context compaction in /cost, and summaries are saved with the session like any message. Before the summary request is sent, the transcript is estimated against the model's input allowance; if it cannot fit, the error suggests a larger model or /new. Ctrl-C cancels the request with history unchanged. A summary is lossy; task notes and files keep what must survive it.

autoCompact runs at the top of every round of Agent's turn loop, after queued input is accepted and before ContextManager.prepare fits the request: before the first request of a turn and between tool rounds, so a long tool loop can be summarized while it runs. It never runs between a tool request and its results, which would make the history invalid, and never after the final answer, where a -p run would pay for a summary it never uses. It compares calibrated next-request usage with contextWindow * autoCompactThreshold, or with the input allowance when that is smaller, since trimming keeps every request within the allowance and a higher threshold would never be reached; without a context window it does not run. The threshold defaults to 0.8, accepts [0, 1], uses zero to disable automatic compaction, merges with later-layer precedence, and is shown and changed by /config. When the exchange in progress is itself summarized (nothing fits the retention budget), a Msg.Continuation (AgentMessages.compactionContinuation) closes the request so that the model continues from the summary instead of being asked to complete an assistant message.

Both manual and automatic compaction use ContextManager.splitForCompaction to retain the largest suffix of whole user exchanges within contextWindow * compactKeepRatio, divided by the current token calibration. The ratio defaults to 0.2, accepts [0, 1], and follows normal layer precedence. Estimates include native payloads replayable by the current model. Tool results and continuations cannot create cut boundaries. An oversized latest exchange is summarized too; an unknown window or zero ratio retains no verbatim suffix. When all history fits, no summary request is made. The replacement summary precedes the untouched suffix, and must be smaller than the older prefix. This budget covers only retained history, not the summary or fixed prompt.

An automatic attempt that fails or produces no smaller summary warns once, leaves the request to ordinary trimming, and is not repeated until calibrated usage has grown by a tenth of the window past the failed attempt (compactRetryAt, reset by any successful compaction, a model switch and /new). Ctrl-C during the summary request interrupts the turn like any other request, with history unchanged; queued input skips the attempt so the next round can accept it first.

Sessions

SessionStore writes versioned JSON snapshots with neutral messages, pending notes, task state and TODOs. It excludes SDK replay payloads, REPL definitions and permission grants. Files are limited to 8 MiB, created exclusively and owner-only on POSIX systems. /save [file] writes a new file and never overwrites one; without a name it writes beside the automatic saves, outside the project, where a transcript cannot be committed with the repository. /resume [file] validates the file before clearing current state, checks tool-call/result pairing, creates fresh permission and REPL state, and adds explicit notices about lost definitions and grants. It retains the currently selected model and never executes saved tool calls; restored state waits for a new user request before any model or tool runs.

Interactive terminal sessions save after every turn and again when the session ends, by /quit, its aliases, Ctrl-D or an error, so a closed terminal or a signal loses at most the turn in progress. SessionStore.autoSavePath hashes the canonical working directory with SHA-256 to select a checkpoint under ~/.atc/sessions/. This works with read-only project directories and keeps different working directories separate. Startup offers to resume that checkpoint; bare /resume opens it later. Empty sessions leave it unchanged, so declining the startup offer and immediately quitting does not erase previous work; /new deletes it, so the next start does not offer the conversation the user discarded. Conversation messages, pending notes from /run, task notes or TODOs make a session non-empty. Scripted runs and redirected input neither save nor offer to resume.

SessionStore.checkpoint writes an exclusive temporary file with the usual size limit and permissions, then replaces the previous save with an atomic move, or a replacement move where the file system has none. Failed writes leave the previous checkpoint intact; temporary files are removed. Concurrent instances in one directory share the checkpoint, so the last successful save becomes the resume point. A missing or invalid checkpoint never prevents startup. ATC installs no shutdown hook, so a forced termination loses the turn in progress.

Input prediction

After each interactive turn, InputPredictor sends recent conversation text to the agent model on one daemon worker, and the main prompt shows the predicted next request as ghost text that Tab or → accepts ("predictInput": false disables it). A generation counter prevents stale publication; at most one job runs and one waits. The model returns [NO_PREDICTION] when it has no useful suggestion; that marker and empty responses never reach the prompt. Predictions are reduced to visible single-line text and reported separately in usage. Answer and key fields show none, so Tab or → cannot insert a prediction there, and the redraw a read leaves on screen has no ghost text, so a suggestion never looks sent after Ctrl-D or Enter.

The terminal

Tui implements AgentUI and owns the turn lifecycle, prose, the TODO panel, input and the framing of pop-ups. It composes parts that live in their own files: Screen (writes that track line boundaries, styles, width, live regions and the spinner; its monitor is the TUI's lock), StatusLine (the footer and the window title), ToolBlock (one tool call's block, its live output, its summary and the /output history), ThinkingView (the reasoning window), Dialogs (what pop-ups show and read, through ListMenu), KeyReader (the key thread during a turn), PromptReader (the JLine line reader and its bindings) and Alerts (notifications and focus tracking). Format holds the short number, duration and plural forms of status and summary lines. Glyphs chooses Unicode or ASCII markers, menu markers and control separators included: ASCII with ATC_ASCII or on a terminal that reports no UTF encoding. Ansi removes terminal controls from external text before display. Keep model-visible capture text unchanged; sanitize only at display boundaries. A -p run uses a dumb UTF-8 terminal.

Continuation handles open brackets, strings and comments for /run. Shift-Enter and backslash followed by Enter insert a newline. An empty line submits a code block; Ctrl-C cancels block input. During a turn, a separate reader collects corrections and unsent draft text and handles escape sequences, stopping on timeout or EOF. Menu reads pause that reader.

Slash-command menus share one pattern, through Tui. A one-shot picker (/model, /effort, /perms revoke) acts on the choice and closes. A menu the user comes back to after each change (/providers, /config) is a menuLoop: its rows are rebuilt every time, so they show the change, and its last row is Done. A menu opened from another is chooseOrBack, whose last row is Back; a confirmation inside a command is such a sub-menu, with one action row. Carrying out an action returns to the looping menu; Back, or an action that did not go through (a refused change, a cancelled checkbox), returns to the menu one level up. Esc goes back one level everywhere, and the footer says so (Esc back); the agent's questions say Esc cancel, and permission requests, which Esc denies, say Esc deny.

Every menu is a ListMenu, drawn in a live region and read in raw mode; MenuState holds what it shows apart from the terminal, and MenuKey decodes keys with KeyReader's escape parsing. A pop-up's menu (a permission request, a question, a confirmation) has no title row: its question stands above it, and its rows and the answer it leaves (› Yes) are indented to the question's text. A slash command's menu shows its title and at most twelve rows, fewer on a short screen, so the whole menu stays shorter than the screen; the window scrolls with the cursor and counts the rows out of view. A longer list is filtered by typing, every word matching. Ticks belong to items, so they survive a change of filter, and the ticked items open first. A menu opens on the value in use or, in a menuLoop, on the row last chosen; when it ends, one line says what was chosen. Menus read focus reports, and a resize redraws them at the new size. A menu turns bracketed paste on while it reads and takes nothing inside a paste as a key, and Enter counts only 300 ms after it opens (ListMenu.EnterDelayNanos), so a newline typed or pasted just before a permission request cannot choose its first row, Allow once. The turn's key reader leaves paste mode after a pop-up, which may have read the paste's end.

While the main prompt holds a single word starting with /, PromptReader lists the matching SlashCommand.table rows in JLine's post area under the buffer, with an exact name preselected. ↑/↓ move the selection, Tab fills in the name (and a space for commands with arguments), and Enter replaces the word with the selected name before accepting. A line that ↑/↓ recalled from history is not listed until it is edited, so the arrows keep browsing history. Answers, block input and dumb terminals get no list. Enter runs the selection, so no alias may be a prefix of another command's name. A line is a command when its first word starts with / and holds no other /: a mistyped name is reported as an unknown command, and a line that starts with an absolute path goes to the agent.

During a turn, Enter submits a correction and unsent text is shown in the status line. Bracketed pastes keep their newlines and are collected without submitting individual lines, and the footer shows the pasted text once, when the paste ends. The footer uses JLine Status, updates on phase and input changes, and reserves a terminal row for the active operation, elapsed time and mode, model and directory. Spinner writes and status updates share the TUI lock. The footer is reserved before the first content line, so adding it does not scroll the banner away. Its activity indicator replaces a separate spinner when the terminal supports a status line. Idle state shows a short mode, model and directory label; menus and answer fields replace it with the applicable keyboard controls.

Every footer update goes through StatusLine.draw, which flushes the terminal writer after JLine's Status.update: JLine leaves the closing synchronized-output sequence (ESC[?2026l) buffered, and a terminal that honours mode 2026 freezes rendering until it arrives (StatusLineSuite checks the closer is written). Before a clear (/new, Ctrl-L), Screen.beforeClear lifts the footer's scroll region and erases its row, because VS Code moves a cleared window into the scrollback and only the rows inside the scroll region.

Resize signals update the footer even while a menu has paused the turn's key reader. Screen measures the terminal once per resize, since the views ask for the width for every line, and the footer is resized to that measurement. JLine's line reader takes the resize signal while it reads, so Tui measures again when a prompt or pop-up returns and redraws what depends on the size if it changed. A live region redraws in place at the new width. Terminals that reflow on a narrower width (most do, VS Code's included) rewrap the region's old rows, so a shrink can leave some of them above the redrawn region.

Streaming text is flushed at the end of each incoming update. Nested rendering helpers share that flush instead of flushing every gutter and style fragment. Live regions compare their rows with the previous view and immediately repaint only changed rows. LiveRegion.redraw cuts every row to one terminal row, measured from column 0 with its gutter and tab stops, with newlines shown as spaces, since a row that wrapped would take two terminal rows and shift every later redraw. Screen.fit stops scanning once the row is full, so a very long output line costs no more than a short one. Live regions erase lines, never to the end of the screen, so updating one cannot erase the footer. Footer work runs on state changes and the input loop's clock; writing text does not redraw the footer. ScreenSuite checks preview growth, replacement, shrinkage and clearing while preserving a footer outside the owned rows. Process events wait while a pop-up is open, and during a turn until a tool block opens, where they join its output, or the turn ends, since written into streaming prose they would land mid-line. Between turns they use LineReader.printAbove while the prompt reads, so they do not overwrite the user's input.

Notifier sends the notifications alert when a turn ends, a permission request or question opens, or the tool budget runs out. Alerts holds it for QuestionDelayMillis, or TurnDelayMillis for a finished turn, and drops it when the user types, answers, presses Ctrl-C or focuses the terminal; nothing is sent while the terminal is known to be focused. Focus reports (ESC[?1004h) are on only while a raw-mode reader runs (the prompt's line reader or KeyReader), because in line mode the terminal driver would echo them as ^[[I. Focus counts only once the terminal has sent a report, since JLine assumes support for every xterm* type and such a terminal may send none. There are no alerts for -p runs or dumb terminals. Under the title atc · <directory>, a turn's alert shows the start of its last prose block, or the outcome with the error or duration when the turn did not finish normally (Alerts.turnText); permission and question alerts show the request or the question. Terminal alerts are OSC 9, 99 (kitty) or 777 sequences, chosen from TERM and TERM_PROGRAM, wrapped for tmux passthrough and written as style text so they leave the line tracking alone. System alerts start osascript, a Base64-encoded PowerShell toast script or notify-send on a daemon thread, passing the text as arguments (after -- for notify-send, so a body starting with - is not an option) or quoted script data, and ring the bell if the command cannot start. Alert text is sanitized, put on one line and capped at 200 characters.

StatusLine.refreshTitle sets the window title (OSC 0) from busy and the pop-up depth whenever the status refreshes, on one line, writing only changes. The previous title is pushed on xterm's title stack (CSI 22;0t) at start and popped (CSI 23;0t) by close; terminals without the stack keep ATC's title until the shell sets its own. The same refresh sends the program status (OSC 7501, the Program Status Protocol) when it changes: idle at start, working with the footer's operation during a turn, blocked with permission, question or auth while a pop-up waits during one, then done or error with the turn's alert text, or idle after an interruption, until the next turn. close clears it. ATC sends it without querying for support, since terminals ignore an OSC they do not know, and does not wrap it for tmux, where the status would belong to the pane rather than the window.

TextLayout wraps complete lines by terminal cell width, preserving ANSI styles and whole Unicode code points. It scans long lines once, with word boundaries preferred over hard breaks. Rows written after a gutter or indent pass its width as origin to TextLayout.wrap and Screen.breakLines, since the terminal counts tab stops from column 0, not from the gutter. Help and banner fields retain aligned continuation lines; when their value column would be too narrow, values move below labels. Tool code, diagnostics, permission details and TODOs use the same wrapping rules.

MarkdownStream incrementally renders supported Markdown and buffers tables until column widths are known. It wraps paragraph, heading, list and quote text at word boundaries to the width, a list item or quote continuing under its text, since the terminal's own wrapping would continue a long line at column 0, outside the prose gutter, and break inside words. The word being written, and the spaces before it, are held until the word ends, since only then is it known whether it fits; without a column limit (tests) text passes through at once. Markdown tables retain their natural columns when they fit. Wider tables cap column widths and wrap individual cells. A table with three or more columns switches to labeled records when wrapping would leave fewer than 24 cells per column on average; tables whose minimum column widths cannot fit use the same fallback, so long identifiers and sentences do not become narrow strips.

Highlight uses the compiler's Scala scanner. A fenced Scala line is highlighted with earlier lines as context: the last eight, or, while Continuation.unclosed finds a comment or triple-quoted string open, up to 200 lines back, because the compiler's colours cannot tell what is open (they reset after the last coloured character).

In the compact view (the default on a terminal), a running tool block is one LiveRegion: the title, up to eight rows of code and the last ten lines of output, both cut further so the region stays shorter than the screen and can always be redrawn. When the call ends the region becomes its summary: the title with the code's first line, the files changed and the verdict, which names how much output it held or, for a failure, the first line of the error (ToolBlock.firstProblem; a compiler heading gives its code or its message, and a java.lang exception its simple name). The same change made to a file several times in a row (one-line edits, one after another) is one row with a count. A pop-up, or any line written outside the block, freezes what the region shows and the block continues below. Output that parallel tasks write while a pop-up is open is held, up to its last million characters, and written when the pop-up closes. Ctrl-O takes the region down and writes the block out in full. The expanded view and a plain terminal write every block in full, with the result panel; the expanded view breaks output lines at the row's end (Screen.breakLines, which keeps the column across chunks), so a long line stays inside the block's gutter.

The TODO panel is drawn once per tool call. A new list is drawn whole; when only statuses changed, the header gives the progress (· 3 of 7 done) and only the items that changed are drawn, so marking one item per call does not repeat the whole list each time. /todos always draws the whole list.

ToolHistory retains up to 20 results within an eight-million-character budget. Each result retains at most two million output characters plus bounded code and file previews; live command output is capped separately at one million characters. A live view keeps its text in a TailBuffer, which grows to half again its cap before cutting back, so small appends do not move the whole text. /output lists the retained results, and /output <n|last> [line] shows one in 200-line windows without evaluating code again, with file-change previews coloured as diffs (ToolHistory.Entry.changesFrom says where they start). Classified terminal-only output is never added to the history, and /new clears it. These records are for inspection and are not part of saved conversations.

PromptReader.readAnswer consumes JLine's preserved input-cancellation interrupt so the next menu can read normally. A plain yes/no confirmation (Tui.confirm) takes only y or yes. Question menus always include a custom-answer option. Questions wrap with their continuation lines under the question text, like permission details. For multiple selections, chosen answers retain their display order and custom text is appended. User input is returned to the host's ask call; only its terminal display is sanitized.

The prompt history is owner-only on POSIX systems and rejects final symlinks. Only the main prompt adds to it; answers and secrets typed into pop-ups stay out.

Testing and conventions

Tests use munit under app/test/src/atc. Extend the suite responsible for the behavior:

  • CapabilitySuite, ModeSuite, SandboxSuite, ReplSessionSuite, CodeValidatorSuite: compiler boundaries, capability requirements, the class loader, REPL state and interruption, and the code validator.
  • ReplInterruptionSuite: recovery after an interrupted evaluation.
  • PolicySuite, PermissionSuite, HostSuite, ClassifiedSuite, GitIgnoreSuite: permission rules and host effects, including classified values, local HTTP requests and ignored paths.
  • HostEditingSuite: literal replacements, bounded reads and searches, file previews and classified exclusions.
  • ProcessesSuite, PlatformProcessSuite, TextFilesSuite, OwnerOnlyFilesSuite: process output, platform behavior and owner-only file writes.
  • ConfigSuite, LayerSuite, ProviderEditsSuite, commands.ConfigCommandsSuite: configuration files, layers and project trust, key bindings, and the edits /providers and /config make.
  • ModelSuite, ModelRequestSuite, ProviderRequestSuite, ProviderCancellationSuite, ChatGPTSuite, ClaudeCodeSuite, LlmJsonSuite: the model catalog, provider requests and cancellation, and each provider's adapter.
  • AgentCoreLoopSuite, AgentLoopSuite, AgentSuite, CompletionPolicySuite, ContextManagerSuite, AgentMessagesSuite: loop decisions, transcript repair, context fitting and compaction, with the real REPL where Scala runs and end to end with the echo model.
  • ToolOutputSuite, PromptsSuite, InputPredictorSuite, SessionStoreSuite: tool results, the system prompt, input prediction and saved conversations.
  • The suites in ui test the terminal, each the class it is named after: TuiSuite, ScreenSuite, StatusLineSuite, PromptReaderSuite, KeyReaderSuite, DialogsSuite, ListMenuSuite, ToolBlockSuite, ToolHistorySuite, RenderSuite, TextLayoutSuite, AlertsSuite and NotifierSuite.
  • MainSuite, FirstRunSuite, commands.SlashCommandSuite, JsonSuite, DebugSuite: command-line parsing, first-run setup, slash commands, the agent-facing JSON type and error reporting.

TestEnv supplies a temporary root with a scripted permission prompter (ScriptedPrompter), recording host ports and the host's capabilities as givens; TestEnv.tempDir creates the temporary directories that the test JVM deletes when it exits. AgentFixtures.scala holds ScriptedModel, RecordingUI and completion, LocalServer stands in for a provider over local HTTP, and ui.TestTerminal builds the terminals of the ui suites. ReplAssertions checks snippets. Prefer ProcessFixture over host shell commands for portable process tests; reserve native commands for PlatformProcessSuite. tests/atc_test.sh uses temporary files and stubbed downloads/Java to test the Unix wrapper and checkout environment loading; tests/atc_test.ps1 does the same for the Windows wrapper without touching the registry.

All Scala modules use explicit null checks where configured; Java APIs may require .nn. Use inline only for small predicates, conversions and wrappers whose expansion removes a closure, such as Debug.log's message, and keep larger methods and capability boundaries ordinary, since an inline parameter is evaluated at each use (see the Scala 3 inline guide). Use Platform and PlatformPath for OS decisions and ScalaSource for generated Scala literals. Keep model/provider escaping, shell quoting and terminal sanitization separate. Scalafmt uses a 120-column configuration that preserves existing layout. Interface.scala and Runtime.scala are excluded because the formatter cannot parse their capture-checking syntax; format them by hand. Preserve tests for capability contracts when editing them.

Wrappers, releases and CI

The Unix atc wrapper installs to ~/.local/bin and keeps the JARs and the startup cache in ~/.atc/jars; ATC_INSTALL_DIR and ATC_CACHE_DIR override those locations. Release downloads require SHA-256 digests for both JARs. Metadata uses jq when available and a field-order-dependent fallback otherwise. A cache marker records release-id|tag; verified updates replace it with the downloaded artifacts. atc setup appends a marked snippet that puts the install directory on PATH to each existing rc file of the user's shell (.zshrc and .zprofile for zsh, .bashrc, .bash_profile and .profile for bash, .profile otherwise), creates the first of them when none exists, skips a file that already has the snippet or names the directory, and warns about a file it cannot write; for fish, which reads none of these files, it prints the fish_add_path command. Uninstall validates the cache root and removes only ATC-owned artifacts, the snippet from all five files and the self-check stamp, keeping configuration and unrelated files. A directory-name prefix alone does not prove ownership, so dev.notes and download.notes stay, in a custom cache location too. Temporary download and development directories are cleaned by the operation that creates them.

Interactive starts look for updates before Java launches. The check needs terminal stdin and stderr and is skipped for ATC_CHECK_UPDATES=0, -p, help, version and initialization flags. The release offer needs a recognized release marker (unknown and development markers are left to atc update). GitHub metadata uses a two-second connection timeout and a five-second total timeout, and lookup failures are ignored. Stable vMAJOR.MINOR.PATCH tags are compared numerically, and a release without both JAR assets is not offered. The prompt defaults to No, and approval installs the release that was shown without another lookup. Download and checksum errors stop that upgrade before Java starts; declining or a failed metadata check starts the current installation. The updater checks critical failures explicitly because Bash conditional callers can disable errexit.

The same check then offers a newer wrapper, at most once a day (the self-check stamp is touched before the check, so a failed download or a declined offer also waits a day) and only when the running script is the installed one at INSTALL_PATH, so a checkout's copy is never overwritten. It stages GitHub's atc with the download and bash -n check of atc self update, compares it with cmp, and on y moves it into place; bash keeps reading the running script from its old inode, so the new wrapper takes effect at the next start. A download that fails skips the offer instead of ending the launch, and an interrupted download or prompt removes the staged file. atc dev <checkout> copies an existing local distribution and records dev|<checkout>; it does not build, and atc update replaces it with a release.

The Windows wrapper, atc.ps1 at the root, follows the Unix one. It runs under Windows PowerShell 5.1 and PowerShell 7 and stays ASCII, since Windows PowerShell reads a script without a byte order mark in the system code page. setup writes the wrapper and an atc.cmd shim (which starts it with -ExecutionPolicy Bypass) to %USERPROFILE%\.atc\bin and appends that directory to the user Path in the registry, keeping the value's REG_EXPAND_SZ type and its unexpanded %VARIABLE% entries, then broadcasts WM_SETTINGCHANGE so new terminals see it. It installs the two JARs and the release's atc.ps1 launcher together in %USERPROFILE%\.atc\jars, requires SHA-256 digests for all three, and runs that launcher, which holds the JVM options. The marker is removed before the verified files are moved into place and written after, and a run without a complete install downloads the latest release first. Windows does not replace a JAR that a running ATC holds open, so an update checks that the files can be opened exclusively before downloading, and the startup offer is skipped while they cannot. The startup check has the Unix conditions. There is no dev command, startup cache or daily wrapper check; atc self update replaces the wrapper after parsing the download. The native Windows launchers pass Unicode application arguments through private ATC_INTERNAL_* environment variables.

CI builds distributions and runs application tests on Linux, macOS and Windows. Linux checks formatting; Unix jobs run the Bash wrapper tests, and the Windows job runs the PowerShell wrapper tests under Windows PowerShell and PowerShell 7. Published release tags must match Versions.atc (with an optional v prefix). The release job builds and uploads the two JARs and Windows launchers after the platform jobs succeed.