# Architecture
`dsh-herdr` is a DSH plugin that treats Herdr as an **external subagent runtime**. This document explains the layering, the lifecycle, and the invariants a contributor must preserve. It is the long form of [README § Architecture](../README.md#architecture-at-a-glance).
## Goals and non-goals
**Goals**
1. A DSH agent can delegate a self-contained task to a specific kind + model in a Herdr pane, and get back a reviewable result.
2. Results are evidence, not claims: exact final message, git diff, tests, session path.
3. The user keeps the terminal: panes are visible, steerable, and remain after the task.
4. The plugin never touches panes it did not create.
5. The core is host-agnostic: only the tool adapter imports DSH.
**Non-goals (0.1.x)**
- Replacing DSH's in-process subagents or workflow engine.
- Full Herdr layout CRUD (tabs/panes/workspaces beyond the dedicated delegation workspace).
- Auto-merge or worktree orchestration (a future opt-in).
- Multi-machine `herdr --machine` forwarding.
- A GUI panel. The tool cards are the UI for now.
## Layers
```mermaid
flowchart TB
subgraph Host["DSH host"]
CTX["ctx.tools · ctx.workingDirectory"]
TOOLS["tools/*
argument validation · cwd · render"]
end
subgraph Core["Plugin core"]
SVC["services/*
spawn · harvest · message · lifecycle · doctor · topology · wait"]
DOM["domain/*
types · errors · result · status · ids · aliases · session · prompt · paths · version"]
PORTS["ports/*
HerdrTransport · TaskStore · AuditLog · GitService"]
end
subgraph Adapters["Adapters"]
CLI["adapters/herdr/*
CLI exec · envelope · normalization"]
FS["adapters/fs/*
file store · audit · memory store"]
GIT["adapters/git/*"]
end
CTX --> TOOLS
TOOLS --> SVC
SVC --> DOM
SVC --> PORTS
PORTS -.implemented by.-> CLI
PORTS -.implemented by.-> FS
PORTS -.implemented by.-> GIT
CLI --> HERDR["herdr CLI → server → panes"]
```
**The dependency rule**: `tools → services → ports ← adapters`, with `domain` importable from every layer. No adapter imports a service. No service imports an adapter. No domain module imports DSH. `src/index.ts` is the only composition root.
Consequences:
- services are unit-testable with `MemoryTaskStore` and a fake transport;
- swapping CLI for a socket client touches exactly one adapter;
- a second host (TUI, headless, another agent) only re-implements `tools/`.
## Module map
| Path | Responsibility |
|---|---|
| `src/index.ts` | Composition root: config → adapters → services → tools. |
| `src/config.ts`, `src/defaults.ts` | Schemastery schema for the loader; pure defaults/merge for tests. |
| `src/host.ts` | The narrow DSH surface (`ctx`, `exec`, logger). No behavior. |
| `src/domain/types.ts` | `TaskRecord`, `AgentResult`, `AgentInfo`, diff/usage types. |
| `src/domain/errors.ts` | Stable error codes + remediation; `toToolError`. |
| `src/domain/status.ts` | `active` / `settled` / `nextAction` predicates. |
| `src/domain/ids.ts` | Clock/id ports, Herdr agent-name rules. |
| `src/domain/prompt.ts` | Task envelope, agent-message envelope, review findings. |
| `src/domain/result-md.ts` | `result.md` frontmatter parser. |
| `src/domain/session.ts` | pi session JSONL parsing and exact final-message extraction. |
| `src/domain/inbox.ts` | `inbox.ndjson` parsing, message types, line offsets. |
| `src/domain/transcript.ts` | Session JSONL → paged user/assistant turns. |
| `src/domain/aliases.ts` | `model-aliases.md` parsing and alias resolution. |
| `src/domain/launch.ts` | Per-kind launch caps and argv. |
| `src/domain/version.ts` | Herdr version parse and hard floor. |
| `src/domain/paths.ts` | `~` expansion, state/task paths, workspace label. |
| `src/ports/transport.ts` | The outbound Herdr contract. |
| `src/ports/store.ts` | Task store, audit, git contracts. |
| `src/adapters/herdr/cli.ts` | Spawn, timeout, abort, envelope, error mapping. |
| `src/adapters/herdr/transport.ts` | Response shapes → domain operations (CLI). |
| `src/adapters/herdr/socket-client.ts` | One-connection-per-request socket client, typed errors. |
| `src/adapters/herdr/socket-transport.ts` | Socket transport with a connect-only CLI fallback. |
| `src/adapters/transport-pool.ts` | Per-machine transport selection. |
| `src/adapters/machine/local-io.ts` | `MachineIo` over node:fs. |
| `src/adapters/machine/ssh-io.ts` | `MachineIo` over `ssh target sh -lc`, stdin writes, quoted paths. |
| `src/adapters/machine/remote-git.ts` | `GitService` over the SSH bridge; patch written locally. |
| `src/adapters/machine/runtime-pool.ts` | Local / mounted / SSH runtime per machine. |
| `src/adapters/fs/store.ts` | Atomic, locked, versioned state file; rotating audit. |
| `src/adapters/fs/memory-store.ts` | Test/embed store. |
| `src/adapters/git/local.ts` | Baseline and bounded diff. |
| `src/services/*` | Use cases (spawn, harvest, message, inbox, transcript, worktree, subagent-provider, reveal, …). No I/O beyond their ports. |
| `src/server/herdr-api.ts` | `/herdr/api` read endpoints plus guarded panel actions. |
| `client/index.js` | Browser half: `Herdr` right-sidebar tab and guide entry. |
| `src/tools/*` | Parameter schemas, cwd resolution, model-facing rendering. |
## Spawn lifecycle
```mermaid
sequenceDiagram
participant D as DSH agent
participant T as herdr_spawn_agent
participant S as SpawnService
participant Store as TaskStore
participant Topo as TopologyService
participant H as Herdr CLI
participant Har as HarvestService
D->>T: prompt, kind, model, cwd
T->>S: spawn(input)
S->>S: gates (enabled, cwd allowlist); wait for a slot (queue)
S->>S: resolve alias → model/thinking; build argv
S->>Store: put(record: starting)
S->>Topo: ensurePane(cwd)
Topo->>H: workspace list/create, tab create
Topo-->>S: paneId, tabId
S->>H: agent start --kind --pane -- argv
S->>H: agent get (boot poll → idle/blocked)
S->>H: agent prompt --wait --until idle/done/blocked
alt blocked
S->>Har: harvest(blocked)
Har-->>D: status=blocked + question
else timeout
Har-->>D: status=running + next action
else settled
S->>Har: harvest()
Har->>H: agent get
Har->>Har: session JSONL / result.md / pane tail
Har->>Har: git diff since baseline
Har-->>D: AgentResult
end
S->>Store: update(status, sessionPath, settledAt)
```
### Timeout and abort semantics
- A **timeout is not a failure**. The pane and agent stay alive; the result says `status=running`, `nextAction=wait`, and `herdr_get_agent_result(wait:true)` resumes waiting. Re-submitting the prompt is forbidden by the tool description and would be a bug.
- **Abort** (`exec.signal`) sends Escape to an owned agent and records `interrupted`. It never kills the pane.
- **Stall** (`agent_prompt_stalled`) is retried once after 2s. A second stall returns `AGENT_STALLED` with the pane id; the record stays `failed` for inspection.
## State model
Two state machines are intentionally separate.
| Herdr `agent_status` | Meaning | Our `TaskStatus` after harvest |
|---|---|---|
| `working` | provider turn running | `running` |
| `blocked` | approval/question UI | `blocked` |
| `idle` / `done` | ready for input | `completed` (fresh message) / `idle` (external observation) |
| `unknown` | recognized but unclassifiable | `running` |
| (agent gone) | pane closed | `gone` |
`TaskStatus` also has `starting`, `failed`, `closed`, `interrupted`. `isActiveStatus` decides the concurrency slot; `isSettledStatus` decides whether `herdr_get_agent_result` should wait.
### Freshness
A pi session file is append-only across turns. Harvest only accepts its last assistant message when `session.mtime >= promptedAt - 1s`. Otherwise the message belongs to an earlier turn and is ignored; the fallback artifacts take over. This is what makes a **follow-up** message safe on the same session file.
## Web panel
The host registers a same-origin `/herdr/api` prefix route (`health`, `fleet`,
`task`, `live`) through `webServer` with dynamic inject, so profiles without a web server
still load. `POST /herdr/api/action` additionally requires
`x-herdr-panel: 1` and a JSON body, then calls `LifecycleService.interrupt` or
`close`; ownership, pane-identity, and active-agent gates are the service's, not
a second implementation. `reveal` opens the record's cwd through a detached
native opener; the path never comes from the request body. `editor` uses the
configured `editorCommand` + `editorArgs` and appends that same cwd, again with
argv and no shell. `live` reads a pane tail through
`transport.readAgent` and never mutates; external agents are therefore
read-only in the panel. The browser half is a hand-written client module
(`lib/client.js`) that registers a `sidebarRightTabs` type plus the
`sidebar.right.pane.tab` slot and renders the fleet, detail, and per-file diff.
Search, the review packet, and cwd copy are client-side compositions of the same
detail payload; the host gains no new read surface for them.
## Native subagent provider
`services/subagent-provider.ts` implements DSH's external-provider shape:
`start(request)` returns `{ id, result, dispose }` immediately. `start` spawns
with `wait:false`; a background settlement waits for Herdr, harvests, and maps
the result onto `{ output, stopReason }`. `dispose()` interrupts and closes the
owned pane. It declares no `agentOptions`, preset, structured-output, or depth
capabilities, and it does not inherit the parent conversation.
## Transports and machines
- **Local:** `transport: auto` tries the socket once; a connect failure degrades
the process to CLI. `socket` is strict; `cli` never touches the socket.
- **Remote:** one CLI transport per `--machine` label, cached by the pool.
Records carry `machine`; services resolve it from the record, so a handle
remains correct after a restart.
- **File access** for a remote machine is a `MachineRuntime`: either an SSH
bridge (`machineTargets: ["label=user@host"]`) or a local mount
(`sharedFilesystem`). Spawn/harvest/inbox/transcript/worktree all go through
it; the diff patch is written locally for the reviewer.
## Persistence
```
~/.dsh/herdr/
├── state.json # schemaVersion 1, tasks + per-machine workspace map
├── state.json.lock # advisory cross-process lock
├── audit.ndjson # append-only events, rotates at 5 MB
├── tasks//
│ ├── prompt.md # the exact envelope sent
│ ├── result.md # written by the child
│ ├── diff.patch # baseline..harvest, bounded
│ └── inbox.ndjson # child → DSH messages
└── worktrees/-/ # isolated:true branches (dsh/)
```
- Writes are atomic (`tmp` + `rename`).
- A cross-process lock (create-exclusive file with PID/timestamp, 15s stale) prevents two DSH processes from losing an update.
- A corrupt state file is renamed to `state.json.corrupt-` and the registry starts empty; losing the registry is recoverable, refusing to start is not.
- Nothing is written into the project checkout.
### Mailbox offsets
`inbox.ndjson` is append-only. Each task record stores `inboxOffset` (the next line index to read), so `herdr_inbox` is replay-free even across DSH restarts. Malformed lines are skipped and counted, never fatal.
### Isolation
`isolated:true` creates a git worktree from `HEAD` before the spawn; the task's `cwd`, baseline, diff, and prompt all use the worktree. The original checkout is untouched. The worktree is not merged or deleted automatically — `herdr_close_agent({remove_worktree:true})` removes it, and a dirty worktree requires `force:true`.
## Invariants
1. **One source of truth per fact.** Herdr owns pane/agent state; our store owns task intent and artifact paths; the child's session owns its words.
2. **Ownership before mutation.** `close`/`interrupt` require a registry record and refuse when the live pane no longer matches the record.
3. **A record exists before a pane.** A crash between the two leaves an inspectable `starting` record, not an orphan.
4. **No silent approval.** `blocked` is a result, never an action.
5. **Bounded evidence.** Result text and patches are truncated by config; the full files remain on disk with a path in the result.
6. **Host boundary is narrow.** Only `src/index.ts`, `src/tools/*`, and `src/types/dsh.d.ts` know DSH exists.
7. **Aliases are policy.** Model resolution reads the user's table; the plugin never invents a provider mapping.
## Extension points
| Want to… | Implement / change |
|---|---|
| Support a socket/SSH transport | `src/ports/transport.ts` + a new adapter; wire in `index.ts` |
| Persist tasks elsewhere | `src/ports/store.ts` (SQLite, remote) |
| Add a kind with native model flags | `src/domain/launch.ts` `kindCaps` + `buildAgentArgs`, with a test |
| Change the review packet | `AgentResult` + `docs/result-contract.md` + renderer test |
| Add a UI panel | A client plugin consuming the same `AgentResult`; no core change |
## Why not MCP or a native subagent provider?
- **MCP** could expose the same verbs, but loses `exec.signal`, the session cwd, tool cards, and the profile config. It remains a reasonable portability path; the domain/services split makes it a new adapter, not a rewrite.
- **Native subagent provider** (`ctx.subagents`) would give in-process cards and cancellation, but requires implementing DSH's provider/session-projection interfaces, which are still moving in the `0.2.x` rc line. The tool surface is the stable contract today; a provider can wrap these services later.