Files
Termind/ARCHITECTURE.md
T
Kaede AkinoandClaude Opus 5 bfbebc7b80 Answer tool calls that a killed process left hanging
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>
2026-08-26 09:36:53 +08:00

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

  • 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.

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.