Shard — Architectural Foundation #
A coding agent harness built around three ideas: an agnostic core that depends on nothing optional, a deterministic conversation compiler for compaction, and a client/server split where a daemon supervises many isolated agent processes and any number of clients (web, orchestrator, SDK) attach over a shared protocol.
This is a living reference. Mark it up as contracts stabilize.
1. Guiding Principles #
- The core is a closed dependency layer. It depends on nothing optional. Optional capabilities depend on the core, never the reverse. Arrows point inward only.
- Everything is a seam. Any behavior a user might want to override sits behind an
interface — tools, permissions, modes, compaction, extraction, providers, code
intelligence, transports. No
if (codeIndex)branches in the turn loop; capability presence is resolved at registration time, not call time. - Deterministic by default; enrichment optional. Default compaction is a pure function over the normalized conversation. CodeIndex/LSP enrich but are never required.
- Data outlives behavior. The session JSONL is append-only and self-describing. Tools, extractors, and prompts can change across versions; old sessions still replay and compact.
- Capabilities compose, they don't special-case. MCP servers, extensions, CodeIndex,
and built-ins all register through one
ToolRegistryand pass through onePermissionGate. - Lineage is never deleted. Compaction reindexes, it never destroys. The agent can
always
recallprior work — this is what makes deterministic compaction safe. - One tool interface, every origin. Built-in, extension, MCP, and CodeIndex-backed
tools are all
Tool. One registry, one gate, one render path, one permission model. - Client/server split. The agent loop lives in a server process; clients are thin
views + controllers over a transport-agnostic protocol.
sharddis to agents whattmuxis to sessions. - Contracts are wire-safe. Every L1 type is serializable data — no functions, no class instances. This discipline is what makes multi-client, detach/reattach, and replay work.
2. Deployment Topology #
graph TD
subgraph Clients[Clients - thin view and controller]
WEB[Web client single agent]
ORCH[Orchestrator web client - all agents]
SDK[SDK - in-process, no socket]
end
subgraph Daemon[shardd - supervisor daemon]
REG[Agent registry list spawn kill attach]
PROXY[Protocol proxy multiplex agents over socket]
STATIC[Serve web UI static assets]
AUTH[Local token auth]
end
subgraph Agents[Agent processes - one per session, isolated]
A1[Agent session 1 owns turn loop tools compactor]
A2[Agent session 2]
A3[Subagent child - headless durable Agent owned by daemon]
end
WEB -->|WebSocket| Daemon
ORCH -->|HTTP| Daemon
SDK -->|in-process direct, no socket| A1
REG --> A1
REG --> A2
JOB[JobCatalog + Coordinator - durable background jobs/runs]
COORD -->|owns jobs/runs, drives children| A3
A1 -.subagent_launch over unix socket.-> JOB
PROXY --> A1
PROXY --> A2
Three process roles, one protocol:
| Role | What it is | Owns |
|---|---|---|
shardd (daemon/supervisor) |
The one thing you run. Thin. | Agent lifecycle, protocol proxy, durable JobCatalog/SubagentJobCoordinator, web serving, auth. Never runs agent logic. |
| Agent process | One per session. | The turn loop, tools, compactor, session JSONL — everything in L2. |
| Client | Web, orchestrator, or SDK. | Rendering, input, permission prompts, UI dialogs. |
Why child processes for agents (not in-process): isolation and crash containment. One agent's bad tool call, OOM, or native segfault must not take down the orchestrator or other agents. This also makes subagents a first-class citizen for free — a subagent is a headless agent process the daemon owns on behalf of a parent, with its own durable Agent, session, gate, grants, and parent-scoped alias (§9).
3. Layered Architecture #
graph TD
subgraph L4[L4 - Integration surfaces]
CLI[CLI entrypoint - subcommand dispatch]
WEB[Web client - SvelteKit plus shadcn-svelte]
ORCH[Orchestrator - aggregation views over registry API]
SDK[SDK - in-process embedding, transport is a no-op subscriber]
DAEMON[shardd supervisor - lifecycle, proxy, auth, static serving]
end
subgraph L3[L3 - First-party optional capabilities]
CODE[CodeIndex] ; LSP[LSP] ; MCP[MCP] ; SUB[Subagent tool]
end
subgraph L2[L2 - Core harness lives in the agent process]
SESSION[AgentSession] ; TREG[ToolRegistry] ; GATE[PermissionGate] ; MODE[ModeController] ; COMP[Compactor] ; PROV[Provider] ; SKILLS[Agent Skills + typed loaders] ; BUS[EventBus]
end
subgraph L1[L1 - Contracts - wire-safe interfaces]
C[Tool PermissionGate Mode Compactor Extractor Provider CodeIntel ExtensionApi EventBus Event RenderDescriptor Decision]
end
subgraph L0[L0 - Foundation types]
T[Message NormalizedBlock ToolResult SessionEntry - all serializable]
end
CLI --> C
WEB --> C
ORCH --> C
SDK --> SESSION
DAEMON --> C
CODE --> C ; LSP --> C ; MCP --> C ; SUB --> C
SESSION --> TREG ; SESSION --> GATE ; SESSION --> MODE ; SESSION --> COMP ; SESSION --> PROV ; SESSION --> SKILLS ; SESSION --> BUS
C --> T
COMP -. optional enrichment via ctx.codeIndex .-> CODE
The dependency rule: Layer N may depend only on N−1 and below. L2 never imports L3.
L3 implements L1 contracts and registers itself into L2 via the ExtensionApi. The single
dashed edge (Compactor → CodeIndex) is the optional enrichment path, resolved through the
L1 CodeIntelligenceProvider interface — the core holds the interface, not the impl.
4. Core Contracts (the seams) #
Each contract below is a registration point. The harness ships default implementations; extensions can replace any of them. Every contract is wire-safe (serializable data only).
4.1 Tool — a capability the model can invoke #
Self-describing, self-executing, optionally self-rendering via a transport-neutral descriptor.
name,description,inputSchema(typebox → JSON-schema-for-model + TS type + validation from one source)isReadOnly: booleanexecute(input, ctx): Promise<ToolResult>- optional render hooks returning
RenderDescriptors (see 4.5)
Registered via registerTool. Every origin (built-in, extension, MCP, CodeIndex-backed)
produces the same shape.
4.2 ToolRegistry #
Name→tool resolution, mode-aware filtering, dedupe. The single source of "what tools exist this turn."
4.3 PermissionGate — async, over-the-wire #
Decides allow / allow_always / deny / ask for a (tool, input) pair before execution.
type Decision =
| { kind: "allow" }
| { kind: "allow_always"; rule: Rule } // persists an allowance
| { kind: "deny"; reason: string }
| { kind: "ask"; prompt: string; id: string }; // emit permission_request, await response
Layered evaluation, first match wins: deny-rules → allow_always grants → mode policy → default ask.
Grants persist to disk per-agent (see §13). See §6 for the multi-client resolution model.
4.4 Mode — a policy bundle, not a code path #
Filters the toolset, contributes a system-prompt section, casts a permission vote, hooks
turn-end (e.g. plan-step extraction). plan/build are default impls; review/debug are
future modes, not code paths. The loop never branches on mode === 'plan'.
Plan → build workflow: in plan mode the model is instructed to end its turn with a
## Implementation Plan section; the mode's pure onTurnEnd extractor returns a checkpoint
intent carrying a PlanArtifact (ordered steps), which the loop persists generically as a
lineage checkpoint (artifactKind plan). Switching to build mode (/mode build) reuses
the latest valid plan on the active branch (## Active Plan prompt section, resolved by
parentId ancestry — an off-branch plan never leaks) with no mode === 'build' branch.
"No plan means no prompt change." The plan survives compaction (append-only lineage).
4.5 RenderDescriptor — transport-neutral rendering #
A tool returns structured data each client renders its own way:
type RenderDescriptor =
| { kind: "text"; text: string }
| { kind: "kv"; pairs: { k: string; v: string }[] }
| { kind: "diff"; path: string; hunks: Hunk[] }
| { kind: "tree"; root: TreeNode }
| { kind: "progress"; label: string; current: number; total?: number }
| { kind: "status"; label: string; tone: "info" | "warn" | "error" };
- Web renders descriptors to Svelte components.
- The core never imports a rendering library.
- Third-party tool extensions render correctly in every client without per-client adapters — they emit descriptors, clients ship renderers for the closed descriptor set. New kinds are additive and versioned.
4.6 Compactor + Extractor — deterministic, self-contained #
Decides when to compact, prepares the boundary, produces a deterministic summary. The
internal seam is the Extractor:
| Aspect | Shape |
|---|---|
| Extractor | (blocks: NormalizedBlock[], ctx: ExtractorContext) => string[] |
ExtractorContext |
{ codeIndex?: CodeIntelligenceProvider; previousSummary?: string; compactionIndex: number } |
ctx.codeIndex is optional. Default extractors operate on blocks alone via regex
(ported from pi-vcc). If a provider is registered, the typeCatalog/symbolChanges
extractors prefer it for real signatures. This single field is the entirety of the
CodeIndex coupling.
4.7 CodeIntelligenceProvider (optional) #
outline / symbol / signatures / invalidate. Powers the code_outline and
code_symbol tools and (optionally) enriches extractors. Source bodies remain the existing
bounded read tool's responsibility. Tree-sitter WASM is the first-party impl; LSP is a
richer impl layered on top. Remove the module and the core still compacts.
4.8 Provider #
Turns (messages, tools, systemPrompt) into an assistant message with tool_calls.
Abstracts OpenAI/Anthropic/local. Router picks by config.
4.9 Specialized typed resource loaders + Agent Skills #
Discovery is NOT a single generic ResourceLoader flattening everything into one
ResourceRecord. Each runtime format owns a specialized, typed loader with its own
format, trust gate, precedence, diagnostics, and bounded output:
| Loader | Kind | Output type | Notes |
|---|---|---|---|
Agent-Skills loader (core/agent-skills) |
SKILL.md packages |
SkillRegistrySnapshot |
Standards-compatible packages; merged by parsed front-matter name; see below. |
ContextFileLoader (core/resources) |
SHARD.md / AGENTS.md |
ContextFileSnapshot |
Layered outer→inner; project files require trust. |
| AgentDefinitionLoader | agents/*.md |
AgentDefinitionSnapshot |
Parsed-name merge; project requires trust. Discovered/exposed; model-facing subagent launch is future scope. |
| PermissionRuleLoader | rules/*.json |
PermissionRuleSnapshot |
Ordered first-match; untrusted project allow rules dropped (deny survives). |
ExtensionLoader (core/extensions) |
executable .ts/.js |
installed via ExtensionHost |
Code, not metadata — project sources require affirmative trust before import. |
Precedence is fixed per loader (typically bundled → user → project; the rule loader is
most-specific-first for first-match). Roots are injected (never hard-coded) so the same
loader serves bundled, user-config, and project layouts. The shared ResourceFilesystem
seam (normalizePath/resolveContained/readFileText) backs the read-only loaders so
tests run hermetically; ExtensionLoader has its own independent fs + importer seams.
Agent Skills lifecycle #
An Agent-Skill is a directory containing a SKILL.md whose YAML front-matter declares a
non-empty name (kebab-case) and description. Optional standard metadata (license,
compatibility, version, author, keywords, allowed-tools, metadata) is retained in
the typed registry for inspection; the startup catalog remains limited to name,
description, and location. Metadata is never trusted for behavior.
Locations (bundled → user → project precedence; project wins on parsed-name collisions, first-match for rules):
- User:
~/.shard/skillsand~/.agents/skills. - Project:
.shard/skillsand.agents/skillsundercwdand each ancestor up to the git root (highest precedence).
Progressive disclosure: discovery produces a metadata-only catalog
(<available_skills> — name: description (location) per skill) composed into the base
system prompt by the canonical bootstrap. The model never sees skill bodies up front; it
calls the built-in load_skill({ name }) tool (registered through ExtensionApi iff
≥1 skill is live — absence behavior). On a successful load load_skill:
- Resolves the name ONLY through the live
SkillRegistry(names, never paths — no traversal vector); re-reads the winningSKILL.mdfrom disk on every call (no cache), reparses + revalidates the current front-matter + identity. - Returns the COMPLETE body (front-matter stripped, never truncated), the absolute skill
directory, and a sorted list of at most 256
scripts//references//assets/resource paths, up to eight directory levels (names only — bodies are never read). The model uses the returned directory as the base when resolving relative references from the body. - Emits a
skill-activationcheckpoint intent; the turn loop persists it generically (linked to the just-appendedtool_result).
Activation durability: while the tool_result carrying the body is still in the
live window, the body is NOT re-injected into the system prompt (no duplication — the model
already sees it). After compaction drops that row, the activation is rehydrated into
the prompt from the checkpoint (## Activated Skills section, same <skill> envelope) —
no silent loss, stable attribution. Active-branch authority is the parentId chain, so an
off-branch activation never leaks. recall recovers the body across compaction
boundaries (the JSONL is append-only). Depth/count/byte bounds and full-body output
consistency are enforced; symlink loops are deduped by canonical realpath (broken links
skipped). The complete rendered load result
is capped at 128 KiB; an oversized skill is an explicit error with NO checkpoint and is
never silently truncated. Rehydrated activated-skill prompt content has a 160 KiB aggregate
cap: newest activations are included first and bodies are never partially truncated.
4.10 EventBus — serializable event stream #
Events are the wire format. Every Event type is plain data (no functions, no Error
objects — serialize messages + codes). The in-process SDK uses an in-process subscriber;
networked clients use the same event stream over a socket. Transport is just another
EventSubscriber.
4.11 ExtensionApi — the single registration surface #
The only integration surface extensions receive:
on(event, handler)registerTool(tool),registerMode(mode),registerCompactor(c),registerExtractor(e)registerCodeIntelligence(p),registerProvider(p),registerSkillLoader(fn)registerMcpTransport(name, factory)ui—select/confirm/input/editor/notify(wire dialogs, see §6)
One seam, everything pluggable. No other integration path exists. First-party optional capabilities (CodeIndex, LSP, MCP, skills, subagents) wire themselves in as bundled extensions; if you don't load them, they don't exist.
5. The Turn Loop #
graph TD
A[Assemble context: resources, mode, system prompt] --> B[turn_start event]
B --> C[Call Provider with messages plus tools plus system prompt]
C --> D[Receive assistant message text plus tool_calls]
D --> E[Append assistant message to JSONL]
E --> F{tool_calls present?}
F -->|yes| G[For each tool_call in order]
G --> H[tool_call event]
H --> I[PermissionGate check]
I -->|deny| J[Record denial as tool_result]
I -->|ask| K[Emit permission_request to all clients, await first response]
I -->|allow| L[Tool execute input ctx]
L --> M[Append tool_result to JSONL, tool_result event]
M --> N{file mutated?}
N -->|yes| O[Notify CodeIndex to invalidate if registered]
N -->|no| P[Next tool_call]
O --> P
J --> P
K --> P
P -->|all calls done| P2{Context at 100% of window?}
P2 -->|yes| S2[Run compaction pipeline mid-turn, rebuild system prompt]
P2 -->|no| C
S2 --> C
F -->|no text-only| Q[turn_end event]
Q --> R{Compactor shouldCompact? above reserve threshold}
R -->|yes| S[Run compaction pipeline, write compaction entry, set live window]
R -->|no| T[Done: await next user turn]
S --> T
Two invariants:
- Mode filtering happens before the call (toolset narrowing) and the gate is defense-in-depth at execution. Neither knows about the other; both can independently block.
- Compaction runs at boundaries, never inside a provider call or tool execution. The
default tier fires between turns — after
turn_end— when the context passes the reserve threshold (~80% of the window): it can wait until the agent goes idle. The emergency tier fires mid-turn, at the loop boundary after a call reports the context at 100% of the window: the next provider call would overflow, so compaction cannot wait for the turn to end. By that boundary every tool_call in the window has its result, so the cut still lands on a safe boundary (a completed tool cycle); the system prompt is rebuilt so the turn's remaining provider calls carry the handoff summary.
6. Multi-Client Model — Broadcast, First-Response-Wins #
Decision (locked): any attached client can answer permission prompts and UI dialogs. This is flexible at the cost of races, which we accept and define crisply.
Semantics #
- A
permission_requestorui_requestis broadcast to all attached clients. Any of them may respond. - First response wins. The daemon proxy dedupes: once a response is accepted for a
request
id, late responses are discarded silently (not errored) — they are simply no longer relevant. - When a request is resolved, the agent emits a
permission_resolved/ui_resolvedevent to all clients so they can dismiss their pending dialogs. No client is left hanging. - Grants are per-agent, not per-client. An
allow_alwaysfrom any client persists and applies regardless of who is attached later. - No primary/promotion protocol. This is the simplicity win of the flexible model — no "who is primary" state to track or hand off.
- Headless policy still applies when zero clients are attached (see §6.2).
Conflict resolution #
If two clients disagree (one allows, one denies), whoever responds first decides. This is the accepted race. It is rare in practice (usually one human at one client) and the alternative — coordination/quorum — costs more than it saves for a local-first tool.
6.1 Wire flow #
sequenceDiagram
participant A as Agent process
participant D as shardd proxy
participant C1 as Client 1
participant C2 as Client 2 Web
A->>D: permission_request id req-7
D->>C1: broadcast permission_request req-7
D->>C2: broadcast permission_request req-7
C2->>D: permission_response req-7 allow_always
D->>A: forward first response, drop late
A->>D: permission_resolved req-7 allow_always
D->>C1: broadcast permission_resolved req-7
D->>C2: broadcast permission_resolved req-7
Note over C1,C2: both dismiss their dialogs
6.2 Headless policy #
When zero clients are attached (subagents, detached agents, SDK-with-no-UI), an
unanswered ask cannot block forever. Apply a per-agent headlessPolicy:
- Default:
denyfor write/mutating tools,allowfor read-only tools. - Configurable per-agent:
deny|allow_if_rule_matches|queue(queue until a client attaches — useful for long-running detached agents a human will rejoin). - Timeout safety: an unanswered
askafter N seconds (configurable, default ~60s) falls back toheadlessPolicy. Prevents a dead agent if a client disconnects mid-prompt. - Client swap (tmux-style detach/reattach): if a client disconnects during an
ask, the request stays open; a reattaching client receives pendingpermission_requests.
6.3 Roles, hierarchy, and the no-responder fallback (subagents) #
Managed subagents add three additive rules on top of the broadcast + first-response-wins model above. They never weaken §6.1–§6.2 for unleased/root agents.
- Attachment roles.
agent.attachtakes aroleofinteractive(default) orobserver. Both receive the event stream; onlyinteractiveconnections answer permission/UI requests. Observers (e.g. a dashboard watching a run) never count as responders. A connection may promote/demote itself on re-attach. - Hierarchical routing. A delegated child's permission/UI request goes to the child's own
interactive clients first; if the child has none, it is routed up the durable parent
chain to the nearest interactive ancestor (retaining the child
agent_idso a response routes back to the child). Parent Runtime may be stopped while its human client stays attached; hierarchy routing uses durable parent links + interactive attachments, not parent process liveness. Pending delegated controls survive client detach/reattach via ancestor sync. - No-responder fallback. Only when there is no interactive responder anywhere in the
hierarchy does the proxy synthesize a bottom-case answer so no request hangs:
permission_request→permission_response{source:'headless', decision:'deny'}(the gate maps it to a terminal deny — §6.2 parity for root/no-parent agents);ui_request→ui_response{value:null}(the bridge emitsui_resolved{value:null}). An internalSubagentJobCoordinatorevent subscription and observer sockets never count as responders.
7. The Protocol — one definition, multiple transports #
Define the protocol once as a set of messages; carry it over whatever transport fits the client. The messages are identical regardless of transport.
| Transport | Used by | Why |
|---|---|---|
| stdio JSONL | daemon ↔ child Runtime (incl. headless subagent children), headless agent, SDK subprocess | Simplest; no port allocation; the private daemon↔Runtime channel |
| Unix socket | Bundled subagent tools ↔ daemon (short-lived job clients) | No network exposure; fast; auto-discovered at ~/.shard/daemon.sock |
| WebSocket | Web client, orchestrator | Bidirectional, low-latency; one persistent connection carries commands + events |
7.1 Framing rule #
Streamed transports (stdio, unix socket): JSONL with LF-only record delimiter. Do not
use Node readline (it splits on U+2028/U+2029, valid inside JSON strings). Split on
\n only. Pin this in the protocol spec — it's the kind of detail that bites in production.
WebSocket transport: one JSON message per WebSocket text frame. WS is already message-framed, so no newline delimiting is needed — the frame boundary is the record boundary. Commands and events share the same connection and the same message envelope shape (see §7.2). Keepalive via WS ping/pong; clients should reconnect with backoff on drop and re-subscribe to the agent stream(s) they were watching.
7.2 Per-agent protocol (one client ↔ one agent) #
Commands (client → agent): prompt, steer (queue mid-turn), subscribe,
permission_response, ui_response, abort, session_op (compact, fork, list),
set_mode.
Events (agent → client, streamed): message_delta (text/tool_call deltas), tool_call,
tool_result, permission_request, permission_resolved, ui_request, ui_resolved,
turn_start/turn_end, compaction, usage (tokens/cost/context), status.
7.3 Orchestrator protocol (client ↔ daemon registry) #
Commands: agent.list, agent.create (config: cwd, model, mode, skills, permissions;
optional opaque session_id adopts a legacy session), agent.kill, agent.attach (returns a
per-agent stream handle the daemon proxies), agent.status, daemon.shutdown.
Events: agent.spawned, agent.exited, agent.status_changed
(idle/streaming/waiting-for-permission/error). AgentInfo.runtime_state is required and
independently reports the Runtime lifecycle (stopped, starting, running, or failed).
The web orchestrator calls agent.list to populate the dashboard, then agent.attach to get
a live event stream for the one it wants to watch — without losing the registry view.
8. Process Model & Lifecycle #
sequenceDiagram
participant U as User
participant W as Web client
participant D as shardd daemon
participant A as Agent process
U->>W: shard web
W->>D: connect WebSocket, daemon boots if down
D-->>W: daemon ready, prints URL
U->>W: open URL, enroll, create agent
W->>D: agent.create cwd model mode
D->>A: spawn shard agent --id --socket
A-->>D: agent.ready
D-->>W: agent.attach stream handle
W->>A: prompt via proxy
A-->>W: streamed events, permission_request, ui_request
W->>A: permission_response, ui_response
A-->>D: turn_end, usage
U->>W: close browser tab
Note over A: agent keeps running, headlessPolicy governs prompts
U->>W: reopen URL, attach to same agent
W->>D: agent.list, agent.attach
D-->>W: live stream for A
Daemon responsibilities (the daemon owns identity; Runtime processes are replaceable):
- Lifecycle — start/stop Runtime generations, fence writers, validate hello/sync, and coalesce concurrent starts. PIDs are diagnostic only and are never durable identity.
- Proxy — multiplex per-agent protocol over the socket; route commands/events and sync. Attach reservations buffer live events until authoritative sync is sent; buffered events are then drained exactly once before atomic promotion to the attached set.
- Registry —
agent.*plus durable manifests under~/.shard/agents/<id>/manifest.json. - Session recovery — retain append-only JSONL and restore it under a writer lock after Runtime exit or daemon restart.
- Static serving/auth — web assets and local token metadata.
- Durable subagent jobs —
JobCatalogpersists job/run records under~/.shard/jobs/<job_id>/, andSubagentJobCoordinatorleases active runs, schedules single/parallel/chain, applies{previous}, runs wait/status/steer/relaunch/stop, and reconciles interrupted runs on restart (§9). Ownership is the durableparent_agent_id; an internal Broker event subscription + inbound lease guard keep result routing + direct-child controls correct.
A daemon restart reloads manifests and lazily restores Agents on attach. Runtime exit and
agent.kill retain the manifest and transcript; a later attach creates a new generation.
Legacy adoption resolves an opaque session id and canonical cwd daemon-side, commits one
binding before startup, and never copies or rewrites transcript JSONL.
9. Subagents (daemon-managed background jobs) #
A subagent is an agent process the daemon spawns headless on behalf of a parent agent. Rather than the parent owning the child's lifecycle, the daemon owns durable Jobs and Runs: the parent submits a job, the daemon validates + commits durable records and aliases before acknowledging, then materializes/drives children asynchronously. The parent never blocks on child startup, model output, or cleanup.
9.1 Tools (six, separate — separate capabilities) #
The parent agent reaches subagents through six bundled tools (L3, src/subagent/**). Each is
an independent allowlist capability — no tool implies another.
| Tool | Mutating? | Returns after |
|---|---|---|
subagent_launch |
yes | the daemon acknowledges the durable job (preallocated IDs + aliases); never waits for child startup/output/cleanup. A terminal child report is pushed into the active parent as a steer or wakes an idle parent as a fresh turn. Validates the whole request before side effects. |
subagent_wait |
no (read-only) | any/all handles reach a terminal state, or timeout elapses. Timeout stops waiting, never work. |
subagent_steer |
yes | the strict steer is drained into the active turn_id (injected) or conflicts. Accepted only while the run is actively executing. |
subagent_relaunch |
yes | a new job_id/run_id is committed on the same durable Agent/session/config/grants/alias. Activе duplicates conflict. |
subagent_status |
no (read-only) | authoritative Agent/job/run snapshots. Omitting targets lists the parent's latest 50 directly-owned jobs (newest first) — the handle/alias recovery path after a parent Runtime restart. |
subagent_stop |
yes | cancellation is accepted (queued → cancelled; active → abort→graceful→kill escalation). |
subagent_launch additionally requires a trusted target definition + launch-policy
eligibility. Management tools are available to daemon-managed agents subject to the
effective tool allowlist even if the current definition snapshot is empty.
9.2 Machine IDs vs friendly aliases #
- Opaque, typed, authoritative IDs: daemon-generated
agent_id,job_id,run_idare the wire/storage identity. Never derive identity from display strings. Prefix families (agent-,job-,run-) make them distinguishable at a glance. - Durable friendly aliases: every accepted initial run gets a reserved alias plus a
preallocated
agent_idbefore launch acknowledgement, copied into the child manifest on materialization. Launch returns recognizabledefinition_name,alias, anddisplay_name(definition_name · alias, e.g.reviewer · Juniper) immediately. - Scope + uniqueness: the alias scope is the durable parent's direct children plus
accepted-but-not-materialized reservations, unique under Unicode NFKC + trim + lowercase.
Empty/control/path-separator/machine-ID-like/oversize values are rejected. Different
parents may each have a
Juniper. - Pool precedence (pinned at acceptance): agent-definition front-matter
names> XDG user configsubagents.names> a stable bundled friendly pool. The resolved pool is pinned into the accepted allocation context so later config edits never rename existing Agents. - Allocation: the first configured base alias with the fewest existing durable sibling
assignments (pool order breaks ties); first use is
Juniper, thenJuniper 2,Juniper 3… An alias is never reused while that durable sibling Agent exists, regardless of Runtime/run state. - Resolution order (all tools): exact typed IDs first, then parent-scoped aliases
case-insensitively. Resolution never crosses ownership boundaries. The display string
reviewer · Juniperis accepted as a convenience only by splitting/validating both components; bare alias is canonical. Ambiguity/corrupt duplicates fail loudly.
9.3 Job/Run model + background orchestration #
- Job = daemon-private durable record (
job_id,parent_agent_id, strategysingle|parallel|chain, ordered run IDs, state, timestamps). Run = one delegated prompt/turn (run_id, job/index/alias, optional childagent_id, state, deadline, latest text, usage, error). Run states:queued|starting|active|completed|failed|timed_out| cancelled|interrupted. Job records live under the daemon home (<home>/jobs/<job_id>/…), are not session entries, and never appear inAgentInfo. Task/result text is local private data — it never enters logs, errors, or list broadcasts. - Atomic acceptance: IDs + the full job record are allocated/committed before the launch
acknowledgement. Invalid requests create no record/Agent/Runtime. Materialization is
idempotent: crash recovery either creates the pinned manifest from the reservation or marks
the unstarted run
interrupted; it never reallocates/renames. - One Agent, one queued/active run globally; the coordinator leases it while active.
- Single/parallel create/start eligible children asynchronously. Chain starts only
the current step; on terminal success it applies exact non-recursive
replaceAll('{previous}', previousText)and schedules the next. First-step{previous}is invalid. Parallel is ordered all-settled (one failure does not cancel siblings; the job is failed if any run fails/times out/cancels, but every result is retained). Chain is fail-fast and recordsstopped_at. - Relaunch is not clone: same
agent_id, session history, grants, system prompt, capability config, launch policy, and alias; new job/run IDs and task. Ownership is direct — a parent tool manages only jobs it launched and children whose manifest carries itsparent_agent_id. - Daemon
SubagentJobCoordinatorconsumes tagged Broker events through an internal subscription (not a socket attachment). It binds each run to the exactturn_idobserved atturn_start, capturesmessage_completeonly while that turn is current, and completes only on the matchingturn_end/terminal error. Stale events are ignored. State is persisted before scheduling successors/cleanup, so stdout pump ordering + turn correlation prevent chain/relaunch misattribution. Coordinator observers never count as interactive clients. - Termination: on terminal success/failure, only the Runtime is stopped after persisting
the run result; the Agent/session/job are retained. Definition
timeout > 0is a per-run whole-lifecycle deadline (schedule/start through terminal cleanup); omitted/0 means no deadline. - Completion reporting: after a terminal transition is persisted, the coordinator pushes
a structured child report into the owning parent's model conversation. An active parent is
steered at its next provider boundary; an idle/stopped parent is restored and started with a
fresh turn.
subagent_wait/subagent_statusremain synchronization and recovery tools, not prerequisites for normal completion delivery. - Restart: terminal records survive daemon and parent restarts; parent Runtime restart
does not affect execution. On daemon process restart, records are loaded but formerly
nonterminal runs are marked
interruptedand dependent chain work fails/cancels rather than replaying a possibly side-effecting prompt; the parent may explicitly relaunch the durable Agent. Automatic exactly-once crash replay is a non-goal for v1.
9.4 Strict steering, leases, and control-channel loss #
- Active-only steer uses a managed child handshake, not check-then-send. The coordinator
sends a strict steer carrying the leased
run_id/turn_id+ a correlation ID.AgentSessionatomically accepts it only for that in-flight turn, resolvesinjectedonly when it is actually drained into that turn at the next provider boundary, and resolvesconflict+ removes it if the turn terminates first. It can never remain in the idle steer queue or leak into a relaunch. - Lease guard: the Broker consults a
SubagentLeaseGuardbefore forwarding commands to a leased child. Directpromptconflicts (would misattribute results); permission/UI responses pass (resolve the child gate); direct humansteer/abortare delegated through the coordinator's strict active-turn / cancellation path and recorded against the run. Direct prompt is therefore blocked while leased. Unleased agents are unaffected (parity). abort(): a parentsubagent_steer/direct human steer and a direct human abort both traverse the handshake and are recorded against the run. Steers are lineage-true user messages, create no runs, do not reset the run deadline, and do not alter result routing. Parent completion/abort aftersubagent_launchacknowledges does not affect jobs; parent abort while insidesubagent_waitcloses/removes only that wait registration.subagent_stopis the explicit background cancellation mechanism.- ToolContext cancellation: the turn loop owns one turn-level AbortController spanning
provider + tool execution, exposed via
ToolContext.signal(additive — distinct from the provider-call controller so the carefully-tuned steer/abort drain ordering is unchanged). It cancels socket calls/wait registrations but does not cancel an already-accepted background job. Terminal reporting is daemon-driven and independent of the launching tool call. If a launch response is lost after commit,subagent_statusrecent-job discovery is the recovery path. - Control-channel loss: daemon control-channel loss closes child stdin; children abort, release writer locks, and exit within a bounded test deadline (an explicit exit is added if event-loop keepalives prevent natural termination).
9.5 Transport + layering #
- Daemon ↔ child Runtime: private stdio JSONL.
- Bundled subagent tools ↔ daemon: short-lived Unix-socket JSONL clients.
subagent_waitkeeps only its wait request/socket open; job execution itself is daemon-owned. src/subagent/**is L3: tool definitions + thin daemon job client on L1 protocol/contracts. It must not import core/daemon/cli/transports. The durableJobCatalog/Coordinatorlive insrc/daemon/**(L4) and consume Broker/Orchestrator services directly — there is no long-lived L3 runner inside the parent Runtime. Core gets only a generic injected bootstrap-contribution seam; L4 injects L3 extensions, andExtensionHostremains the sole registration surface. Boundary lint + tests enforcesrc/subagent/**.
This is the pi subagent design (child subprocess via stdio), generalized to the daemon model and extended with durable ownership, recognizable aliases, and explicit lifecycle controls. The win: subagents get isolation, their own permission gates, their own compaction — they're full agents, not nested loops — while the parent keeps its own transcript free of child progress (wait/status return structured results; delegated permission UI stays control-plane only).
10. Data Model #
Session = append-only JSONL, one entry per line. Each entry has stable id, parentId
(lineage chain), timestamp, type, details.
| Entry type | Purpose |
|---|---|
message |
user/assistant, with content blocks (text/tool_call/tool_result nested by id) |
tool_result |
paired to its call by id; carries isError, exit codes |
compaction |
summary, messageRange: [firstId, lastId], tokensBefore/After, compactor name, index |
checkpoint (optional) |
mode transitions, metadata snapshots |
Lineage = the same JSONL. Compaction partitions it into addressable spans
(compaction:0, compaction:1, …). The live context the model sees =
[latest compaction summary] + [entries after firstKeptEntryId]. Nothing is ever deleted;
recall walks the full file.
11. Compaction Pipeline (deterministic, self-contained) #
Three pure functions — fast (pi-vcc: 30–470ms on sessions with thousands of messages), unit-testable, deterministic:
normalize(messages) → NormalizedBlock[]— flatten the stream; pairtool_call ↔ tool_resultvia a look-ahead index built once and shared across extractors.buildSections(blocks, ctx) → SectionData— run the extractors.formatSummary(sections, previousSummary) → string— render to the section doc.
type NormalizedBlock =
| { kind: "user"; text: string }
| { kind: "assistant"; text: string }
| { kind: "tool_call"; name: string; args: Record<string, unknown> }
| { kind: "tool_result"; name: string; text: string; isError: boolean }
| { kind: "bash"; command: string; output: string; exitCode: number | undefined }
| { kind: "thinking"; text: string };
Section order (stable → volatile, for prompt-cache survival) #
| # | Section | Source | Stability |
|---|---|---|---|
| 1 | Session Goal | first user turns | stable |
| 2 | User Preferences | explicit pref statements | accumulates |
| 3 | Files And Changes | tool_call args | append-only |
| 4 | Commits | bash git output |
append-only |
| 5 | Type Catalog | regex over read results or CodeIndex if registered |
structural |
| 6 | Outstanding Context | error-classifier over recent tail | volatile |
| 7 | Earlier Turns | one-liner per turn | grows downward |
Then ---, then a Brief Transcript of recent turns kept verbatim.
Worth stealing verbatim from pi-vcc #
outstandingContext— classify the last ~25 blocks into priority-tagged lines ([ERROR] [tsc] …,[ERROR] [bash:exit 1] …,[WARN] [tests] …,[INFO] [no matches] …). Surfaces the agent's current failure state into the summary.- Resolution detection — if a
[tsc]error's file was edited after the error in the tail, retag[RESOLVED]. Stops re-fixing fixed issues. - Handoff preamble +
...recall:breadcrumbs — fixed preamble on every summary; breadcrumbs let arecallhit trace to the originating turn.
12. Lineage + recall #
graph LR
A[Full session JSONL never deleted] --> B[compaction 0 covers msg ids 1-200]
A --> C[compaction 1 covers msg ids 201-450]
A --> D[Live window: latest summary plus entries after 450]
E[recall tool] --> A
E --> F[scope: all or latest or compaction N]
E --> G[query plus recent plus expand by index]
recall searches across all compaction boundaries, returns a paginated
index-addressable view, and expand drills into specific entries without re-fetching
everything. This is the feature that makes deterministic compaction viable long-term — the
agent isn't blind to pre-compaction work, it just has to ask.
// recall tool shape
{
scope: "all" | "latest" | "compaction:<n>",
query: string, // keyword/regex over normalized blocks
recent?: number, // default 25 entries from the chosen scope
expand?: number[], // fetch full text of entry indices from the previous page
}
13. Config & Discovery #
User-level storage splits by data class: config (user-authored files a human edits —
config.json, agent definitions, themes) lives under the XDG config dir; state
(runtime data shard writes — sessions, manifests, grants, jobs, sockets, logs) lives under
the ~/.shard state home. Config is never mixed into the state home: the two classes have
different lifetimes, backup stories, and sharing semantics.
~/.config/shard/ # user CONFIG (XDG: $XDG_CONFIG_HOME/shard) — user-authored,
# human-edited files
config.json # providers/models/permissions/compaction + subagents.names
# (the global friendly-alias pool)
agents/ # user agent definitions (*.md) — overrides bundled, yields to
# project; the legacy ~/.shard/agents/*.md location is still
# read as a fallback (the config-dir copy wins collisions)
themes/ # web UI theme files (.json/.toml)
~/.shard/ # user STATE (runtime data; SHARD_HOME override) — written by
# shard itself
daemon.json # { pid, socketPath, httpPort, token }
daemon.sock # unix socket (local clients)
agents.json # legacy registry snapshot (migration input only)
agents/<id>/ # durable manifest.json + session JSONL, forks, and
# permission-grants.json (per-agent allow_always grants)
jobs/<job_id>/ # durable daemon-owned subagent job/run records (manifest.json)
sessions/ # CLI direct-run session transcripts (+ forks/)
usage.jsonl # append-only token-usage log (aggregated into usage stats)
rules/ # user permission rule sets (deny/allow)
skills/ # Agent-Skills packages (SKILL.md)
extensions/ # *.ts/*.js extension modules
worktrees/ # daemon-managed worktrees (managedWorkspaceRoot)
security-v1.json # browser-principal/security store (§14)
~/.agents/skills/ # native user Agent-Skills location (alongside ~/.shard/skills)
.shard/ # project-level (overrides/extends user)
skills/ # project Agent-Skills (requires --trust-project)
agents/ # project agent definitions (requires trust)
rules/ # project permission rules (allow rules require trust)
extensions/ # project extension modules (code; requires trust)
... # same structure ...
.agents/skills/ # native project Agent-Skills location (alongside .shard/skills)
SHARD.md # project context file, layered into the system prompt
AGENTS.md # compatibility context file (ecosystem familiarity)
Discovery order + precedence #
Bundled defaults → user → project (project wins on parsed-name collisions). Each format owns its loader (§4.9); per-loader notes:
- Agent Skills merge by parsed front-matter
name(not filename/dir); cross-layer overrides areinfo, same-scope collisions arewarning. Project skill dirs are scanned undercwdand each ancestor up to the git root (cwdwins). - Context files (
SHARD.mdthenAGENTS.md) layer outer→inner (bundled → user → project least-specific-ancestor →cwd).AGENTS.mdcompatibility is on by default (opt out per-run). - Agent definitions merge by parsed
name; the user layer reads the config-diragents/(authoritative) over the legacy state-home fallback (see the tree above); permission rules compose first-match (projectcwd→ancestors → user → bundled — most-specific first). - Subagent friendly-alias pool resolves
subagents.namesin the XDG config file (~/.config/shard/config.json; a non-empty string list), overridden per-definition by agent-definition front-matternames, falling back to a stable bundled pool. Invalid entries are dropped; the resolved pool is pinned at job acceptance so config edits never rename existing Agents (§9.2). It contains no secrets — safe to print in aconfig show.
Per-run project trust (load-bearing) #
Project-layer instruction/code resources — SHARD.md/AGENTS.md, agent definitions,
allow permission rules, Agent-Skills, and executable extensions — are filtered/skipped by
default (with diagnostics, no code executed). Affirmative trust is per-run, never
persisted or inferred:
shard agent(direct):--trust-project.shard daemon --trust-projectforwards--trust-projectto every spawned agent child.- SDK:
createShard({ resources: { projectTrusted: true } }). Absent → user-layer discovery with project trust off (safe default).
Bundled/user sources are trusted by being explicitly configured. See §14.6 for extension trust specifically.
14. Security #
The daemon is the only network-exposed surface; agent processes speak over a local unix socket or stdio and never bind a port. Hardening therefore concentrates on the daemon's HTTP/WebSocket listener and on what an authenticated client is allowed to do.
14.1 Network binding #
- Default: loopback only. The daemon binds
127.0.0.1:<port>and the unix socket~/.shard/daemon.sock. Never0.0.0.0unless--remoteis explicitly passed. - Unix socket has no network exposure — local job clients use it; no port, no CORS, no token-over-the-wire concerns for the local path.
- Remote mode is opt-in and shipped in saga vr7kgx.9 (three configurations: loopback
default, direct non-loopback bind, and trusted reverse proxy). See §14.3.1 for the transport
matrix, threat model, and trust anchor;
WEB-DEPLOYMENT.mdfor recipes. The TLS / authz considerations in §14.5 still apply.
14.2 Origin allowlist + CORS #
O3 is locked: the daemon serves the web client, so the normal path is single-origin and needs no CORS. CORS config exists for the cases that aren't single-origin. Host and Origin checks are exact serialized-value comparisons; hostname suffixes, alternate loopback names, forwarded host values, and DNS-rebinding aliases are never normalized into an allowlist hit. The HTTP listener must validate the exact loopback Host before routing, and the WS upgrade must require the exact Origin.
- Dev workflow — Vite dev server on
localhost:5173talking to the daemon on its port. - Separately-hosted client — if someone serves the SPA elsewhere later.
- Browser preflight — the WS upgrade and any
fetchto a cross-origin daemon trigger preflight; without an allowlist the browser blocks them.
Config in daemon.json:
{
"allowed_origins": ["http://localhost:5173", "http://127.0.0.1:5173"]
}
Semantics:
- Echo only allowlisted origins in
Access-Control-Allow-Origin. Never use*with credentials — the WS handshake carries the token, so wildcard + credentials is unsafe. - Allow methods
GET, POST, OPTIONS; allow headersAuthorization, Content-Type. - WS upgrade gate: reject the WebSocket handshake (
400) if theOriginheader is absent or not in the allowlist. This is the primary CSRF defense for the socket — browsers always sendOriginon WS upgrades, and a non-browser attacker without the token is rejected at §14.3 anyway. - Default allowlist = the daemon's own origin only, so the out-of-the-box config is safe; dev just requires adding the Vite origin.
14.3 Authentication #
Browser authentication uses an owner-only, versioned durable store under configurable
SHARD_HOME (default ~/.shard). The store contains browser-principal IDs, credential hashes,
and timestamps only; raw credentials, setup tokens, enrollment codes, and WS tickets are never
persisted or logged. Writes use a restrictive directory/file mode and write-temp/fsync/atomic
rename, with explicit errors on corruption or I/O failure.
- Enrollment: live enrollment returns a short-lived display code and a separate high-entropy claim nonce. Codes are rate-limited and a successful claim consumes the pending enrollment. A manually pasted setup token is high entropy, short-lived, and one-time.
- Session cookie: authenticated sessions use a
__Host-HttpOnly, Secure, SameSite=Strict cookie; it is never placed in a URL. Credential comparisons are constant-time. - WS handshake: the daemon issues a short-lived, single-use ticket bound to principal, session, and exact Origin. Reject tickets that are expired, replayed, or bound to another principal/session/origin. The WS Origin gate remains mandatory.
14.3.1 Remote mode (saga vr7kgx.9) #
Remote mode lets a browser on another machine reach the web client. It is opt-in, selected by
shard daemon flags and validated loudly at startup (the daemon never silently degrades into an
unsafe remote configuration). The locked design lives in .saga/plans/vr7kgx.9.md; the
normative protocol text is PROTOCOL §4.1.2 and the deployment recipes are in
WEB-DEPLOYMENT.md.
Transport configurations (D1).
| Config | Bind | Transport | Trust anchor | Cookie |
|---|---|---|---|---|
| Loopback (default) | 127.0.0.1 |
plaintext, loopback-only | none | HttpOnly; SameSite=Strict (no Secure) |
| Direct non-loopback | <ip> |
plaintext, network-facing | none (forwarded headers ignored) | non-Secure (under --allow-insecure-remote) |
| Trusted reverse proxy | 127.0.0.1/<ip> |
TLS at the proxy; plaintext to the daemon | configured upstream peer | Secure + __Host- iff X-Forwarded-Proto: https |
Threat-model summary (remote mode). Loopback's "acceptable only because the daemon is loopback-bound" premise (§4.3) no longer holds once a remote browser can reach the listener:
- Network MITM / passive sniff — mitigated by TLS at the proxy (https path). Under
--allow-insecure-remotethe session cookie is knowingly interceptable on untrusted networks; the operator confines non-TLS remote to a trusted/VPN/SSH-forwarded path and the daemon logs a startup warning. - Credential replay / cross-origin use — mitigated by exact
Originallowlist,SameSite=Strict,Secure(on https),HttpOnly, and the ticket bound to exact serializedOrigin. - DNS rebinding — already mitigated by exact
Host/Originmatch. Remote mode keeps exact-match;expectedHostis the configuredpublicHost, never loosely inferred. - Proxy-header spoofing (new risk the reverse-proxy path introduces) — mitigated by the trusted-proxy trust anchor below.
- CSRF — mitigated by
SameSite=Strict+ exactOriginon every state-changing request. - Stolen cookies on a non-HTTPS transport —
Securecookies are never sent over plaintext; a misconfigured proxy cannot mintSecurecookies for a hijacked plaintext session because the daemon refuses to issue/honor aSecure-form cookie when the effective scheme is nothttps.
Trusted-proxy trust anchor (D2). --trust-proxy <upstream> is opt-in and disabled by
default. When set, forwarded headers are honored only from the configured upstream peer
(loopback = 127.0.0.1/::1, or an exact IP literal); from any other peer forwarded headers
are ignored and the request is treated as direct. X-Forwarded-Proto (or the configured
headerScheme) sets the effective scheme; X-Forwarded-Host (or headerHost) is
cross-checked against the pinned publicHost and rejected on mismatch — it is never used to
derive expectedHost. X-Forwarded-For is never consumed (informational only; the peer
address is the trust anchor). Under a non-loopback direct bind (no proxy) forwarded headers are
ignored entirely, so a network peer cannot influence the scheme/host.
Cookie / origin policy split by effective scheme (D3/D4).
publicHostand the origin allowlist are mandatory whenever--web-hostis non-loopback or--trust-proxyis set; missing either is a loud startup failure. The implicithttp://127.0.0.1:<port>origin is added to the allowlist only in pure loopback mode.- The session cookie is issued
Secure+__Host-iff the issuing request's effective scheme ishttps; otherwise non-Secure. ASecurecookie arriving over a non-httpseffective scheme is rejected at/bootstrap, and enrollment refuses to set aSecurecookie over plaintext. --allow-insecure-remoteis the explicit two-step opt-in required for any non-httpsremote operation; setting--web-hostto a non-loopback address alone does not imply consent.
hug1mc invariant amendment. The hug1mc "any future remote mode must require HTTPS and
Secure cookies" clause is relaxed to permit the explicit, two-step opt-in non-TLS remote mode
above. All other hug1mc invariants (per-browser enrollment, the Sec-WebSocket-Protocol
ticket, exact Origin matching, frame-ancestors 'none', selected-agent-only interactive
attachment) stand unchanged.
14.4 Authorization (v1: single trust level) #
v1 grants a single trust level: anyone with the token can create/kill agents, send prompts, and answer permission requests for any agent. This is acceptable only because the daemon is loopback-bound. The defense-in-depth that still applies:
- The PermissionGate (§4.3) still governs every tool call — a compromised client can make
the agent ask to do things, but writes still route through the gate, the headless policy
(§6.2), and any
allow_alwaysgrants the real user already configured. - Agent processes are sandboxed children — the token grants agent control, not OS escape; the agent is still bound to its cwd and permission rules.
Real multi-tenant authz (per-agent ACLs, per-user identity) is a future item gated on remote mode.
14.5 Transport security #
- Local = plaintext-safe. Loopback traffic never leaves the machine; TLS adds overhead with no benefit locally. The unix-socket path isn't networked at all.
- Remote (shipped, saga vr7kgx.9): the canonical path is a trusted reverse proxy that
terminates TLS and forwards to the loopback daemon; the daemon does not terminate TLS
itself in v1 (a direct daemon-TLS mode remains out of scope). An explicit
--allow-insecure-remoteopt-in permits a deliberate non-TLS remote path on trusted/VPN/SSH-forwarded networks — see §14.3.1 for the cookie-exposure risk and thehug1mcinvariant amendment.
14.6 Extension & subagent trust #
- Extensions run in-process with full agent privileges. Only load extensions from
sources you trust (bundled,
~/.shard/extensions/, or verified packages). An untrusted extension can register tools that bypass the gate's intent — treat extension loading as code execution. - Project extensions require affirmative per-run trust (
--trust-project/ SDKprojectTrusted). Without it, a project extension source is discovered but never imported (atrustdiagnostic, no code runs). Bundled/user sources are trusted by being explicitly configured. Areload(id)re-checks project trust before re-importing (a reload is still code execution). - Canonical containment: every discovered source is realpath-resolved and its canonical path must be contained within its extension directory; the import uses the canonical path, so a symlink swapped after discovery cannot escape (no TOCTOU window). Broken/ cyclic links are deduped or rejected.
- Failure isolation: a broken/untrusted/malformed/throwing source is diagnosed with its source identity and the remaining loads continue; earlier successful installs stand. A failing setup never silently breaks the registry.
- Subagents inherit the daemon's trust boundary but get their own permission gates,
grant stores, headless policies (§6.2–§6.3), and strict capability allowlists (§9). A
subagent cannot exceed the permissions its delegated config grants. Agent definitions are
trusted sources for child system prompts/models/modes/tools/skills/launch policy/
names;--trust-projectgoverns project-layer definitions as usual.
15. Extension Model #
An extension is a .ts/.js module default-exporting function(api: ExtensionApi). Loaded
from ~/.shard/extensions/, project .shard/extensions/, and compatibility extensions/
directories in deterministic bundled → user → project order. The ExtensionApi is the
only integration surface — there is no other way to hook the core.
- Default-export API:
export default (api) => { ... }. The setup may be sync or async (it mayawaitbetween registrations); the host keeps the extension's identity pinned across awaited registrations. It may return a disposer (sync or async), run on host shutdown in reverse install order; disposer errors are swallowed + attributed so one buggy disposer can't starve the rest. - Source attribution + diagnostics: every registration made during an install is
attributed to the installing extension's identity; duplicate/replacement registrations
produce
registered/replaceddiagnostics (the host never silently drops a registration — same-key replaces, last wins). - ExtensionApi-only canonical bootstrap: the default startup path installs bundled
tool/mode/compactor extensions,
recall,load_skill, the resolved provider, and trusted dynamic extensions all throughhost.install— the runtimeToolRegistry/ModeController/compactor/provider are built from the host snapshot only. The core never registers a capability directly into a registry;ExtensionApiis the exclusive seam. - Diagnostic surfaces: the host exposes registration diagnostics, installed-extension
identities, capability resolvers (
resolveTool/resolveProvider/resolveCompactor), and a plainsnapshot()for bootstrap/session assembly.
Bundled tools, modes, compaction, recall, conditional Agent Skills loading, and the provider all enter through this same API. Future optional L3 capabilities such as CodeIndex, LSP, MCP, and subagents must use it as well. This keeps the core honest.
16. Repo Layout #
Single package, strict internal module boundaries, split to a monorepo later. Splitting into packages before contracts stabilize causes churn. Internal boundaries give the architectural guarantee now, with the option to physicalize later without rework.
shard/
├── src/
│ ├── types/ # L0: Message, NormalizedBlock, SessionEntry, ToolResult
│ ├── contracts/ # L1: Tool, PermissionGate, Mode, Compactor, Extractor,
│ │ # Provider, CodeIntelligence, ExtensionApi, EventBus,
│ │ # Event, RenderDescriptor, Decision
│ ├── protocol/ # L1: per-agent + orchestrator message sets, framing
│ ├── core/ # L2: AgentSession, ToolRegistry, built-in tools,
│ │ # default gate/modes/compactor, recall, load_skill,
│ │ # provider impls, agent-skills + typed resource loaders,
│ │ # ExtensionHost/Loader, event bus
│ ├── daemon/ # L4: shardd supervisor - lifecycle, proxy, registry, auth
│ ├── transports/ # L4: stdio, unix-socket, websocket transport impls
│ ├── codeindex/ # L3: tree-sitter CodeIntelligenceProvider + code.* tools
│ ├── lsp/ # L3: LSP bridge layered on codeindex
│ ├── mcp/ # L3: MCP client + Tool adapter
│ ├── subagent/ # L3: subagent tool + process spawning (future)
│ ├── web/ # L4: SvelteKit + shadcn-svelte web client
│ ├── orchestrator/ # L4: aggregation views over the registry API (part of web)
│ ├── sdk/ # L4: embedding API, in-process subscriber
│ └── cli/ # L4: entrypoint
└── tests/
Enforce the boundary with a lint rule (no-restricted-imports): core/ may not import
from codeindex/, lsp/, mcp/, subagent/. Agent Skills + the typed resource loaders
live in core/ (L2) — discovery is a core capability, not an optional L3 module. That rule
is the architecture's enforcement. Additionally, contracts/ and types/ may not
import from anywhere above L1.
17. Decisions #
| # | Decision | Status | Resolution |
|---|---|---|---|
| 1 | Runtime | Locked | Bun — fast startup (daemon + many agent subprocesses), native TS |
| 2 | Package structure | Locked | Single package + lint-enforced boundaries; split later |
| 3 | Schema lib | Locked | Typebox — JSON-schema + TS type + validation from one source |
| 4 | Client rendering | Locked | Clients render transport-neutral RenderDescriptors themselves; the core never imports a rendering library. New descriptor kinds are additive and versioned. |
| 5 | Provider abstraction | Locked | OpenAI-compat lingua franca + native Anthropic |
| 6 | Context file name | Locked | SHARD.md default; optionally also read AGENTS.md |
| 7 | Optional capabilities location | Locked | First-party extensions (CodeIndex, LSP, MCP, skills, subagents), not core |
| 8 | Agent process model | Locked | Child processes; daemon as supervisor |
| 9 | Transports | Locked | One protocol, three transports: stdio + unix socket + WebSocket |
| 10 | Web client stack | Locked | SvelteKit + shadcn-svelte |
| 11 | Multi-client permission policy | Locked | Broadcast to all clients; first-response-wins; late responses discarded |
| 12 | No-client / headless policy | Locked | Default deny writes / allow reads; configurable per-agent (deny / allow_if_rule_matches / queue); ~60s timeout fallback |
| 13 | Auth for web | Locked (v1) | Local token from daemon.json; revisit for remote later |
| 14 | Compaction strategy | Locked | Deterministic, LLM-free, pi-vcc style; LLM compactor a swappable alternative later |
| 15 | CodeIndex as core dependency | Locked | Not a dependency — optional enrichment via ExtractorContext.codeIndex |
| 16 | Web transport | Locked | WebSocket — bidirectional, one connection for commands + events; replaces the SSE+POST split |
| 17 | Web client deployment | Locked | Served by the daemon (single-origin); token flows in the WS handshake; configurable origin allowlist (§14.2) for dev/cross-origin |
Still open (decide when reached in build order) #
| # | Decision | Notes |
|---|---|---|
| O1 | Subagent process transport | Locked — two complementary transports: daemon↔child Runtime is private stdio JSONL (isolation + the pi design generalized); parent subagent tools↔daemon use short-lived Unix-socket JSONL clients (the daemon owns jobs/runs, §9.5). A subagent is never a peer the orchestrator drives directly mid-run; it is a daemon-owned headless child surfaced by management tools/status. |
| O4 | Orchestrator agent discovery metadata | what per-agent info the registry exposes (name, cwd, model, mode, status, usage) |
| O5 | Session forking UX | Locked (saga 3aliod) — fork-to-new-session is the single split primitive (session_op: fork); branch/rewind dropped. Web per-message fork UI: saga 3aliod.1 |
| O6 | Permission rule DSL | exact shape of Rule predicates (regex for bash, glob for paths, etc.) |
18. Build Order #
The daemon and protocol enter the critical path early, because everything downstream is a client over them. CodeIndex and LSP stay on the parallel enrichment track, never blocking the critical path.
- L0–L1: foundation types (all serializable) + contracts, including
RenderDescriptorand wire-safeEvent/Decision. - Protocol spec: define the message set + framing rules. Written doc; the contract between every client and the daemon.
- Agent process + loop: JSONL, one provider,
read/write/bash, EventBus emitting serializable events. - In-process SDK (transport = no-op subscriber). First runnable end-to-end agent, no daemon yet.
- stdio transport — wrap the agent as a headless process speaking the protocol. Also the subagent substrate.
shardddaemon: lifecycle + registry + unix-socket proxy.- Web client over WebSocket (renders
RenderDescriptors). First interactive client against the daemon. ToolRegistry+edit/grep/find/ls+PermissionGate(async, wire-safe) +buildmode.- Deterministic Compactor + compaction entries.
recalltool + lineage. ← compaction now safe to enable.planmode + skills loader + formalizedExtensionApi.- Subagents (headless agent processes via the daemon).
- MCP registry + tool adapter.
- WebSocket transport + web client (single-agent view) — ship with the §14 security baseline: loopback binding, origin allowlist + CORS, token-on-handshake. Security is not a later pass; the web client is the first network-exposed surface.
- Orchestrator web view (registry API + multi-agent dashboard).
- (Parallel, any time after step 9) CodeIndex +
code.*tools → enriches compactor's Type Catalog viactx.codeIndex. - LSP layered on CodeIndex.
Critical path: types → protocol → agent process → daemon → core tools/permissions/compaction → web → orchestrator.
19. Load-Bearing Decisions (the ones that must not erode) #
These six decisions hold the architecture up. If any erodes, revisit the foundation:
- The layer rule — arrows point inward only; L2 never imports L3.
- The extractor-
ctxseam —ExtractorContext.codeIndex?is the entire CodeIndex coupling. - Lineage-never-deleted — compaction reindexes;
recallwalks the full JSONL. - Client/server split — agent loop in a server process; clients are thin over a protocol.
- Wire-safe L1 contracts —
RenderDescriptor, asyncDecision, serializableEvent. - Broadcast + first-response-wins — any client can answer; first wins; late discarded;
all clients get
*_resolved.