Force quitting during a tool batch produced a conversation that could not be continued at all, only deleted: `failed to stream request: error: No tool output found for function call call_…`. The assistant turn is persisted as soon as it arrives, its results only when the run next saves a snapshot, so a process that dies in between restores a turn whose calls nothing answers — and every provider rejects that outright rather than degrading. It is the exact mirror of the orphaned result this codebase already repaired, and the same class of fatal. Recorded results come back first. `toolFinished` is durable and lands as each tool finishes, so in the common case the output is sitting in the database while the transcript claims to know nothing about it; `loadConversations` now materializes any result whose transcript row was never written. In a batch of three killed during the third, the first two come back with the output they actually produced. `AgentUnansweredToolCallRepair` answers whatever storage cannot, at the model boundary and at the snapshot seed — the same two places the orphan repair runs, and for the same reason: repairing at the seed writes the fix forward instead of re-deriving it on every future turn. What it says matters as much as that it says something. It does not claim the tool did not run. The process died after the command was dispatched, so whether the remote side executed it is genuinely unknown, and a model told "this never ran" will cheerfully run it again — which is a data-loss bug the first time the call was `rm`. It says the outcome is unknown and to check before repeating. A card caught mid-flight also came back spinning. Every unfinished run is marked interrupted at launch, so `running` is not a state a restored card can legitimately be in, and nothing was ever going to stop that spinner. Worth noting against the previous commit: batching the snapshot save to once per turn rather than once per tool widened this window, and the recovery above is what closes it — results are still recorded one at a time, so nothing a tool produced is lost, only deferred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.1 KiB
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
Domaincontains platform-independent values and rules.Applicationcontains repositories, feature state, use cases, events, and service protocols.Infrastructureimplements persistence, security, SSH, SFTP, and LLM clients.Featurescontains SwiftUI presentation and sends intents to Application.Platform/macOScontains AppKit adapters.Appconstructs 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, thenAssistant. 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
Settingsscene, 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:
AgentContextPipelinerewrites 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.AgentTurnDriverstreams one assistant turn and assembles its tool calls.AgentToolSchedulerplans and runs the turn's batch.AgentToolExecutortakes one call through schema validation, preflight, the card, hooks, authorization, and execution, in that order.AgentEventChannelis 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.
The permission modes share structural validation but differ at the action gate:
- Ask for approval executes locally proven read-only 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.