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.
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.shOn 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 |
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.
| 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.
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.
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.
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): TC^ 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.
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 receiverExec 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.
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 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.
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 captureException 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.
The pinned compiler implements experimental capture checking, safe mode and mutable capabilities. ATC's design is derived from TACIT.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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/providersand/configmake.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
uitest the terminal, each the class it is named after:TuiSuite,ScreenSuite,StatusLineSuite,PromptReaderSuite,KeyReaderSuite,DialogsSuite,ListMenuSuite,ToolBlockSuite,ToolHistorySuite,RenderSuite,TextLayoutSuite,AlertsSuiteandNotifierSuite. 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.
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.