Files
Termind/ARCHITECTURE.md
T
Kaede AkinoandClaude Opus 5 99be1d6885 Change a remote file without sending it through the model
Editing a config on a host cost two full copies of it in tokens. `sftp_read`
returns the file inline, and `sftp_write` takes the replacement back as a
`content` string the model has to re-emit in full — so the ordinary "pull it,
change three lines, push it back" job paid for the whole file twice, and
anything over the 128 KB inline cap, or anything that was not UTF-8, was out
of reach entirely. There was also nowhere to keep a draft between turns: a
script the agent was composing lived only in the transcript, which the context
pipeline trims head-and-tail as the run grows.

`AgentScratchWorkspace` is a per-conversation staging directory under
Application Support, inside the sandbox container, deleted with the
conversation that owns it. Application Support rather than Caches because the
system may evict a Caches directory mid-run, and a staging area that can vanish
between two tool calls is worse than none. Paths are relative; `..` is refused
rather than resolved, which is the inverse of `normalizeRemotePath` and the
right inversion — a relative path with `..` in it is never something the agent
needed. Symlinks are refused even when they point back inside, because an alias
within the workspace makes two paths the same file and quietly breaks both the
quota arithmetic and the atomicity `apply(_:to:)` claims. Every resolved path
is then proven to still be under the scope. Writes are bounded per file, per
conversation, and by entry count, and hidden files are counted — a quota you
can evade by naming a file `.big` is not a quota.

It is an actor, and that is load-bearing rather than incidental. Pi serializes
file mutations through an explicit per-path queue; here the actor already
serializes every operation against every other, so a read-modify-write is
atomic for free. That is what lets every local scratch tool stay `.parallel`:
a batch containing one does not have to drag its whole turn serial to be safe.

`scratch_edit` follows Pi's `edit` and is what makes the rest pay off. Every
`old_text` is matched against the file as it was before the call, not against
the result of the preceding edit, so the model never simulates its own changes
to write the second one. Matches must be unique and non-overlapping; anything
ambiguous fails the whole call rather than applying part of it, because a
half-applied edit is the one outcome nobody can reason about. Matching is exact
first, then one line-aligned retry that ignores smart quotes, unicode dashes
and trailing whitespace — without it an edit fails whenever the model retypes a
block with tidied punctuation, which is often. And it returns a unified diff
rather than the new file, which is the entire point: an 8 MB file can now be
changed for the price of the lines that changed.

`scratch_pull` and `scratch_push` move the bytes, and are separate tools rather
than options on `sftp_read` / `sftp_write`. A tool that means two different
things depending on which optional argument is present is harder for a model to
choose correctly and harder to audit, and it would have put two safety levels
inside one descriptor. The SFTP tools are untouched.

`Safety` gains `locallyContained`. The existing two cases state *why* a call may
skip the gate, and "read-only" is the wrong reason for a scratch write — it
changes something, it just cannot change anything outside storage the app owns.
It is auto-allowed in every mode including Ask for approval, because a staging
area you must approve into is not a staging area, and six dialogs for one edit
loop teach the reader to click through the seventh, which is the one that
reaches a host. `isAutoAllowed` is a property of the case rather than an
`== .requiresAuthorization` at the call site, so a fourth case has to make a
decision here instead of defaulting to allowed. Nothing switches exhaustively
over `Safety` and it is not persisted, so there is no migration.

`scratch_push` is the only gated tool here, and it has a problem the SFTP tools
do not: the approval card would show a filename where it used to show the bytes,
leaving the reviewer — human or model — agreeing to something neither had seen.
Its preflight therefore reads the staged file and puts the size, digest and a
redacted twenty-line head into `localReasons`, which both the card and Security
Review's `Local signals:` line already render. It also carries the digest in
`executionMetadata` and rechecks it at execute time: approval suspends the turn,
and what comes back from the workspace afterwards is only the same file if
nothing rewrote it in between.

The schema helpers move to an `AgentToolSchemaBuilding` protocol with defaults
rather than a namespace enum, so a conforming type still calls `descriptor(…)`
and `string(max:)` unqualified and a second tool file costs a conformance
instead of a copy of forty lines.

`UnifiedDiff` is a formatter over `CollectionDifference` — Swift's
`difference(from:)` is already a Myers diff, so no dependency. It trims the
common prefix and suffix before running, because Myers is fast on similar
inputs and quadratic on unrelated ones, and refuses anything past a line limit
in what is left.

Its output was checked against the real tool before any expectation was written
down: for eight cases it is byte-identical to GNU `diff -u`, and `patch -p1`
applies each one and reproduces the target file. That found three bugs reading
the code had not. `firstChangedLine` was nil for every replacement, because the
first changed row of one is the removal and a removal has no line number on the
new side. A trailing-newline-only difference produced an empty diff with
`identical: false` — true and useless. And `LineEnding.detected` reported `.lf`
for every CRLF file, because Swift treats "\r\n" as a single Character and
`firstIndex(of: "\n")` finds nothing in a Windows file; editing one line would
have rewritten every line ending in it. A fourth came from the BOM test:
`String(data:encoding:.utf8)` strips a UTF-8 BOM while decoding, so checking the
decoded String never fired and writing it back dropped those three bytes. It
now reads the raw Data, and the test asserts on bytes for the same reason —
decoding would have reported a pass either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 10:56:13 +08:00

151 lines
8.9 KiB
Markdown

# Termind architecture
Termind intentionally remains one application target and one unit-test target.
Directories describe dependency boundaries even though Swift does not enforce
them as separate modules.
## Dependency direction
- `Domain` contains platform-independent values and rules.
- `Application` contains repositories, feature state, use cases, events, and
service protocols.
- `Infrastructure` implements persistence, security, SSH, SFTP, and LLM clients.
- `Features` contains SwiftUI presentation and sends intents to Application.
- `Platform/macOS` contains AppKit adapters.
- `App` constructs dependencies and owns application/window lifetimes.
Domain must not import SwiftUI, SwiftData, AppKit, Ghostty, CSSH2, or OpenSSL.
Views must not construct network clients, access SwiftData/Keychain directly, or
coordinate cross-domain cleanup. Runtime side effects belong to application
services and durable outbox handlers.
## State lifetimes
- Repository snapshots are application-wide and shared by every window.
- A window owns one top-level shell, quick-open, and context-panel state.
- The shell always starts with two pinned routes: `Vault`, then `Assistant`. Dynamic
tabs after them are SSH/SFTP Workspaces only.
- Vault owns its navigation column, including quick-connect Recents, and routes
for Hosts, Keychain, Snippets, Welcome, and Host Editor. These routes never
enter the Workspace deck.
- Known Hosts management belongs to the Host Editor draft for both new and
existing Hosts. Context-panel Host details are read-only.
- Assistant owns its conversation list through `AssistantLibraryModel`; conversations are
not panes and do not participate in Workspace drag, split, close, or reconnect
lifecycles.
- A workspace owns only SSH/SFTP panes plus its session-scoped assistant
conversation. Workspace content is full width and has no left sidebar.
- Session models own live SSH/SFTP resources and terminal/file-browser state.
- Settings is a native singleton macOS `Settings` scene, outside window routing.
Repository data is persistent truth. Runtime connection state is kept in a
connection registry and is never persisted onto domain records.
## Agent runtime and security
`AgentRuntime` is an actor holding a run's state — transcript snapshot, tool
registry, steering queue, secret broker — and the loop over it. Each phase of
the loop is a separate value it drives:
- `AgentContextPipeline` rewrites what is sent at the model boundary: tool
results reordered to match the calls they answer, orphaned results dropped,
older results trimmed head-and-tail, session context inserted in front of the
turn it describes. It shapes only the request. Run snapshots and stored
history keep everything.
- `AgentTurnDriver` streams one assistant turn and assembles its tool calls.
- `AgentToolScheduler` plans and runs the turn's batch.
- `AgentToolExecutor` takes one call through schema validation, preflight, the
card, hooks, authorization, and execution, in that order.
- `AgentEventChannel` is the single ordered path for events, fanning out to the
UI and to a serial durable recorder.
OpenAI Responses, OpenAI Chat Completions, Anthropic Messages, and Gemini are
adapters at the LLM boundary. Models without tool capability receive no tools.
Stop cancels the active stream and actions that have not started; steering
messages are consumed at the next turn boundary.
A turn's tool calls run concurrently only when every call in it is marked
parallel; one sequential call makes the whole turn serial rather than being
fenced into its own segment. Concurrency is declared per tool and may be
overridden per call by preflight — `ssh_exec` is parallel exactly when
`CommandRiskClassifier` proves the command safe, the same evidence that decides
its safety. MCP tools are parallel by default; a server's destructive hint or
Ask First makes them sequential, so remote metadata can only tighten
concurrency, never grant it. Whatever the order of execution, results reach the
transcript in the order the model asked for them, and every call is answered
even when the turn is cancelled or refused — an assistant turn with unanswered
tool calls is rejected outright on the next request.
Approvals and agent questions are queued and presented one at a time. Run state
is derived from what is outstanding rather than asserted by whichever call moved
last.
Extension points, all additive: `AgentToolProviding` for tool sources,
`AgentLoopHook` for `willExecute` / `didExecute` / `shouldStop`,
`AgentContextTransforming` for the model boundary, `AgentModelStreaming` for
providers. Authorization is deliberately not among them — `AgentApprovalHandling`
is a structural stage of the executor that no configuration can remove, and
hooks run on either side of it.
Tools never receive a reference to the interactive PTY. Server commands use a
dedicated SSH exec channel and file operations use run-scoped SFTP clients.
Vault secrets are resolved behind `AgentHostResolver`. User-entered secrets are
single-use handles bound to run, tool, host, and purpose; plaintext is passed
directly to the executor and is excluded from model input, reviews, events,
logs, and SwiftData. Fetch treats public HTTP(S) reads as locally read-only and
routes private or otherwise non-public unicast targets through the active
permission policy. DNS is revalidated on every redirect; a redirect that first
enters a restricted origin pauses for another authorization decision. Explicit
request headers follow every redirect, including cross-origin redirects, to
match `curl -H -L`; sensitive values are redacted from review, UI, and durable
history. MCP discovery retains schemas and annotations, but remote annotations
are risk hints rather than proof of safety.
`AgentScratchWorkspace` is a per-conversation staging directory under Application
Support, inside the sandbox container, deleted with the conversation that owns it.
Paths are relative, `..` is refused rather than resolved, symlinks are refused even
when they point back inside, and every resolved path is proven to still be under the
scope; writes are bounded per file, per conversation, and by entry count. It is an
actor, so it is also the per-path mutation queue — a read and a write of the same
file in one batch serialize there rather than by taking the whole turn serial, which
is why every scratch tool is parallel. `scratch_pull` and `scratch_push` move bytes
between a host and the workspace without the bytes entering the transcript, so a
remote file can be pulled, edited and pushed back without a copy of it crossing the
model boundary in either direction; `scratch_edit` returns a unified diff rather than
the new file for the same reason. The SFTP tools are unchanged and know nothing about
the workspace. Because a push is approved by filename rather than by content, its
preflight puts the size, digest and a redacted head into the approval reasons, and
binds the approval to that digest through execution-only metadata so a file rewritten
while the user was deciding is refused rather than written.
The permission modes share structural validation but differ at the action gate.
Safety is a property of the call, established locally: `locallyReadOnly` for calls
proven to change nothing, `locallyContained` for calls that change only storage the
app owns, and `requiresAuthorization` for everything else. The first two are allowed
in every mode — a staging area that has to be approved into is not a staging area,
and dialogs nobody can act on teach the reader to click through the one that matters.
- Ask for approval executes locally proven read-only and contained calls and
presents every other action to the user.
- Approve for me sends those same non-read-only actions to Security Review.
Missing credentials, timeout, transport errors, or malformed review JSON fail
closed and are persisted as review failures.
- Full access skips action approval and Security Review while retaining host-key,
secret, schema, host/path, fetch protocol/header/address-classification,
sandbox, and tool-capability boundaries.
Agent conversations, runs, visible messages, tool calls/results, and reviews are
stored in SwiftData after truncation and redaction. Launch marks unfinished runs
`interrupted`; history is restored but remote actions are never resumed or
replayed.
A process that dies mid-batch — force quit, crash, the OS reclaiming the app —
leaves tool calls that nothing answered, which the next request would be
rejected for. Restore closes that in two steps. Results are recorded as each
tool finishes but their transcript rows wait for the run's next snapshot save,
so `loadConversations` first materializes any recorded result whose transcript
row was never written. Whatever is still unanswered is answered at the model
boundary by `AgentUnansweredToolCallRepair`, which says the outcome is unknown
rather than that the tool did not run: the process died after the command was
dispatched, and a model told otherwise would repeat it.