Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 65 additions & 22 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ packages/utils/src/
**Data flow:**
```
~/.archcode/config.json → startup activation or token-protected Setup
→ optional Session auth → providers → registerBuiltinTools → fire-and-forget MCP background load
→ optional Session auth → providers → registerBuiltinTools → live MCP runtime activation
→ Hono Runtime routes → Session-scoped Lead / Automation / HITL routes
→ SessionExecutionManager → ConfiguredAgent → query loop → store → SSE → Web UI

Expand Down Expand Up @@ -297,7 +297,7 @@ partitionToolCalls → global permissions

Every descriptor declares an explicit `outputPolicy`. Registry is the sole Raw-to-Finalized conversion boundary: blocked requests produce no settled result, while settled and synthetic results are finalized exactly once. `ToolOutputFinalizer` owns redaction of output/details and streaming capture redacts before artifact persistence; model, Session/SSE/UI, audit, and logger consume only finalized data. Large one-shot output is recovered through authorized, bounded `output_read` and `output_search` pages rather than a full-output escape hatch.

**Config** (`~/.archcode/config.json`): server-wide `provider.<id>.{npm, name, options, models}` + strict `profiles.{principal,deep,fast}.{model,variant,options}` + optional `memory`, `integrations.github`, and `mcp.servers.<id>.{url, headers, timeout}`. Strict Zod. Provider values are literal; MCP URL/headers and GitHub token resolution retain their environment-variable behavior. Project directories are never searched for configuration.
**Config** (`~/.archcode/config.json`): server-wide `provider.<id>.{npm, name, options, models}` + strict `profiles.{principal,deep,fast}.{model,variant,options}` + optional `memory`, `integrations.github`, and `mcp.{disabledBuiltins,servers}`. Each MCP server entry strictly requires `type: "http" | "stdio"` and `enabled`; HTTP uses `url`/`headers`, while STDIO uses `command`/`args`/`env`. Optional `connectTimeoutMs`, `discoveryTimeoutMs`, and `callTimeoutMs` default to 10,000/30,000/60,000 ms. Provider values are literal; MCP URL/header or STDIO env values and GitHub token resolution retain their environment-variable behavior. Project directories are never searched for configuration.

**Model configuration** (`~/.archcode/config.json`):
- Provider ids and model ids combine as `provider:modelId` (example: `"local:glm-5"`). Do **not** use `provider/model`.
Expand Down Expand Up @@ -398,22 +398,16 @@ All six implement `Agent`: `store: StoreApi<SessionStoreState>`, `run(options)
- `plan-work` writes one ordinary Markdown Plan per Todo under `.archcode/plans/`. Plan has no service, state, ID, API, dedicated page, or Goal link. `execute-plan` is activated only by the Todo-to-work handoff when that file exists.
- `review-work` guides Lead review orchestration. Analyst analysis/review Skills include `analyze-work`, `review-change`, and the reserved `goal-review` final gate.
- A Skill is one package: required `SKILL.md`; optional `scripts/`, `references/`, `assets/`, and other contained resources. Its strict YAML frontmatter accepts `name`, `description`, optional `license`, `compatibility`, and `metadata`; `description` states both method and activation timing.
- Discovery (`skill_list` and available Prompt metadata) returns exactly name, description, and source. Entry activation (`skill_read({ name })`) returns the entry plus sorted resource descriptors; `skill_read({ name, resource })` reads exactly one listed text resource on demand. Binary assets are valid package resources but are not returned by the text-only tool.
- Project `.archcode/skills/<name>/` > user `~/.archcode/skills/<name>/` > embedded builtin is whole-package precedence: bodies and resources never merge or fall through. Reserved lifecycle builtins remain unshadowable and Agent-gated.
- Skill precedence is whole-package and strict: project `.archcode/skills/<name>/` > project `.agents/skills/<name>/` > user `~/.archcode/skills/<name>/` > user `~/.agents/skills/<name>/` > embedded builtin. Bodies and resources never merge or fall through. Reserved lifecycle builtins remain unshadowable and Agent-gated.
- Discovery (`skill_list` and available Prompt metadata) returns exactly name, description, and source. Prompt projection is bounded and reports omitted entries; `skill_list` returns digest-bound metadata pages with cursors for continuation. Entry activation (`skill_read({ name })`) returns the entry plus sorted resource descriptors; `skill_read({ name, resource })` reads exactly one listed text resource on demand. Binary assets are valid package resources but are not returned by the text-only tool.
- Invalid package candidates are surfaced as `SKILL_INVALID_PACKAGE` diagnostics. A winning invalid package fails closed; resolution never falls through to a lower-precedence package. The same winning package is claimed once for one `/skill use` logical Execution; `skill_read` uses that Execution snapshot and resume revalidates its digest.
- Skills remain guidance only: their package metadata and resources cannot grant tools or permissions, execute scripts automatically, change Agent/Profile/MCP/workspace scope/delegation, or grant completion authority. Scripts use only existing Bash permissions.

**MCP visibility by agent:**

| Agent | MCP servers |
|-------|-------------|
| `lead` | `context7`, `exa` |
| `discussion` | — |
| `analyst` | `context7` |
| `build` | — |
| `explore` | — |
| `librarian` | `context7`, `grep.app`, `exa` |

**MCP tool resolution**: `AgentDefinition.mcpTools` lists MCP server names (e.g. `["context7", "exa"]`). `factoryResolveAllowedTools` merges matching `mcp__{server}__*` tools from the registry. MCP tools load in background; agents see them on the next `run()` call after registration. See MCP section below.
**MCP visibility**: User MCP servers are process-global and visible to all six
Agent identities from the current live runtime at the next model-call
boundary. They are not filtered by Agent role and do not add an approval step.
Built-in visibility remains the hardcoded role matrix in the MCP section below;
it is independent of user-server visibility.

**Query loop lifecycle:**
```
Expand Down Expand Up @@ -449,7 +443,11 @@ beforeModelBuild (auto-compact) → toModelMessages → beforeModelCall (auto-in

## Session Store

Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and exactly one immutable `RootSessionSource` on every root: `direct`, `todo { todoId, entry }`, or `automation { automationId, invocationId, todoId }`, where Automation `todoId` is nullable; children never copy a root source. An optional `goal` belongs only to a root Lead Session. Strict identity validation requires Todo `discussion` entry ↔ Discussion Agent and every other root source ↔ Lead Agent. Active Skill bodies are resolved again for every Execution. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map<path, mtime>) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state.
Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and exactly one immutable `RootSessionSource` on every root: `direct`, `todo { todoId, entry }`, or `automation { automationId, invocationId, todoId }`, where Automation `todoId` is nullable; children never copy a root source. An optional `goal` belongs only to a root Lead Session. Strict identity validation requires Todo `discussion` entry ↔ Discussion Agent and every other root source ↔ Lead Agent. Persistent active Skill names are resolved when a new logical Execution is claimed; that Execution then uses immutable package snapshots through suspension and resume. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map<path, mtime>) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state.

Explicit `/skill use` claims the winning Skill package once for one logical
Execution; `skill_read` uses that Execution snapshot and a resumed Execution
revalidates its digest before continuing.

## Context Compaction

Expand Down Expand Up @@ -483,13 +481,58 @@ HITL is a durable project-scoped approval/question queue backed by `.archcode/ru

## MCP

HTTP Streamable only. Built-in: context7, grep.app, exa (hardcoded in `BUILTIN_MCP_SERVERS` and non-overridable). User servers are read from `~/.archcode/config.json → mcp.servers`. Tool names: `mcp__{server}__{tool}`. Failed discovery = warning, not crash.

**Background loading** (non-blocking): `McpManager.startBackgroundDiscovery()` fires-and-forgets at `createRuntime()` — server boots immediately while MCP servers connect in background. Per-server status: `pending → ready(toolCount, warningCount) | failed`; Prompt projection distinguishes `ready`, `ready-zero`, and `partial-warning`. Status is accessible via `AgentRuntime.getMcpServerStatuses()` and `AgentRuntime.subscribeMcpStatusChanges(listener)`.
MCP is a process-global live integration. `McpRuntimeService` is the high-
cohesion owner for resolved configuration, HTTP/STDIO transports, discovery,
tool inventory, status, Test, Reconnect, hot apply, and shutdown. It has no
Session, Execution, Agent, Tool Registry, permission, retry, or persistence
ownership. Tool names are `mcp__{server}__{tool}`; failed discovery is a
per-server warning/failure, not a Runtime crash.

User servers are configured at `~/.archcode/config.json → mcp.servers`. Every
entry requires `type` (`http` or `stdio`) and `enabled`. HTTP uses `url` and
optional `headers`; STDIO uses `command`, optional `args`, and optional `env`.
`connectTimeoutMs`, `discoveryTimeoutMs`, and `callTimeoutMs` default to
10,000/30,000/60,000 ms. `mcp.disabledBuiltins` can disable fixed built-ins
(`context7`, `grep.app`, `exa`) but cannot replace them.

Initial activation is non-blocking: the server publishes `connecting` or
`disabled` before transport work completes. Per-server status is
`disabled → connecting → ready(toolCount, warningCount) | failed`; Prompt
projection maps these to `disabled`, `connecting`, `ready`, `ready-zero`,
`partial-warning`, and `failed`. Status and inventory are available through the
global MCP routes and SSE status events; Settings also offers draft Test and
Reconnect. A Config save commits once, then hot-applies the resolved MCP config;
the independent `mcpApply` result reports whether live apply succeeded.

At each model-call boundary, `ConfiguredAgent` takes a transient live MCP tool
descriptor/status projection for that call. Tool execution uses those exact
descriptors; a later reconnect, disable, or discovery change affects the next
boundary, not a call already handed to the model. The projection exists only
for that model-call boundary.

All six Agent identities receive every configured user-server descriptor at
their next model-call boundary, with no role filter and no additional MCP
approval. Built-in visibility remains the locked role matrix:

| Agent | Built-in MCP servers |
|-------|----------------------|
| `lead` | `context7`, `exa` |
| `discussion` | — |
| `analyst` | `context7` |
| `build` | — |
| `explore` | — |
| `librarian` | `context7`, `grep.app`, `exa` |

**Agent visibility**: agents opt into MCP tools via `mcpTools: ["context7", "exa"]` (server names) in their `AgentDefinition`. `factoryResolveAllowedTools` merges `mcp__{server}__*` tools from `ToolRegistry.listByPrefix()` — picks up tools registered after background load completes. Tools become visible on the next `run()` call (per-message resolution at `ConfiguredAgent.run()` line 189), not mid-message.
The matrix applies only to built-ins. A local read-only Agent can still invoke
a user MCP tool that writes to an external system; local tool read-only status
does not constrain external MCP side effects.

**SSE bridge**: MCP status changes emit `GlobalSSEMcpStatusEvent` (`type: "mcp_status"`) via `globalEventBus` → Web `useMcpStatusStore`. API route: `GET /api/mcp/status` (global, not project-scoped). Web `GlobalSSEProvider` fetches the snapshot on mount and on SSE `reset` events (reconnect) to populate the store even when connecting after MCP servers became ready.
MCP status changes emit `GlobalSSEMcpStatusEvent` (`type: "mcp_status"`) via
`globalEventBus` → Web `useMcpStatusStore`. API routes are global (not
project-scoped): `GET /api/mcp/status`, `GET /api/mcp/inventory`,
`POST /api/mcp/test/:serverName`, and `POST /api/mcp/reconnect/:serverName`.
Web `GlobalSSEProvider` fetches the status snapshot on mount and after an SSE
`reset` so late subscribers still see the current live state.

## Key Dependencies

Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
`SKILL.md` plus optional `scripts/`, `references/`, `assets/`, and other
contained resources. Skill discovery is metadata-only; entry and listed
resources are disclosed progressively.
- Replace the legacy MCP configuration path with a process-global live runtime
supporting required `type` + `enabled` HTTP and STDIO server entries,
independent connect/discovery/call deadlines (10s/30s/60s defaults),
`disabledBuiltins`, hot apply on Settings save, and status/Test/Reconnect
controls. User MCP servers are visible to all six Agent identities; the
built-in visibility matrix remains role-defined.

### Breaking Changes

Expand All @@ -24,6 +30,13 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
paths. `when_to_use`, `allowed_tools`, and all other
top-level frontmatter fields are rejected. There is no migration, fallback,
compatibility reader, or resource merge with lower-precedence packages.
- MCP configuration is a hard cut. Every server entry now requires
`type: "http" | "stdio"` and `enabled`; HTTP uses `url`/`headers`, STDIO
uses `command`/`args`/`env`, and the single `timeout` field is replaced by
`connectTimeoutMs`, `discoveryTimeoutMs`, and `callTimeoutMs`. Built-in
opt-out uses `disabledBuiltins`; user-server role filters and extra MCP
approval assumptions are removed. There is no migration, compatibility
reader, or fallback for the old configuration shape.

## [0.0.8] - 2026-08-04

Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const mockRuntime = {
subscribeSessionRuntimeChanges: mock(() => () => undefined),
subscribeMcpStatusChanges: mock(() => () => undefined),
subscribeModelRuntimeChanges: mock(() => () => undefined),
getMcpServerStatuses: mock(() => new Map()),
getMcpServerStatus: mock(() => ({ servers: {} })),
getMcpServerInventory: mock(() => ({ servers: {} })),
} as unknown as AgentRuntime;

describe("createRuntimeApp", () => {
Expand Down Expand Up @@ -41,6 +42,7 @@ describe("createRuntimeApp", () => {
},
origin: "user_message",
maxSteps: 50,
executionSkills: [],
} });
listener({ type: "event", slug: "proj", sessionId: "session-1", eventId: 2, createdAt: 2, agentName: "lead", payload: {
type: "execution-end",
Expand Down Expand Up @@ -71,7 +73,7 @@ describe("createRuntimeApp", () => {
const observed: GlobalSSEEvent[] = [];
const unsubscribe = globalEventBus.subscribe((event) => observed.push(event));
createRuntimeApp(runtime);
listener!("context7", { state: "ready", toolCount: 1, warningCount: 0 });
listener!("context7", { state: "ready", toolCount: 1, warningCount: 0, connectedAt: 1 });
expect(observed[0]).toMatchObject({ type: "mcp_status", serverName: "context7" });
unsubscribe();
});
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { createMessagesRoutes } from "./routes/messages";
import { createMcpRoutes } from "./routes/mcp";
import { createProjectsRoutes } from "./routes/projects";
import { createSessionsRoutes } from "./routes/sessions";
import { createSkillsRoutes } from "./routes/skills";
import { createTodosRoutes } from "./routes/todos";
import { createToolOutputRoutes } from "./routes/tool-outputs";
import { globalEventBus } from "./events/global-event-bus";
Expand Down Expand Up @@ -64,6 +65,7 @@ export function createRuntimeApp(
const automations = createAutomationsRoutes(serverRuntime);
const todos = createTodosRoutes(serverRuntime);
const sessions = createSessionsRoutes(serverRuntime);
const skills = createSkillsRoutes(serverRuntime);
const messages = createMessagesRoutes(serverRuntime);
const attachments = createAttachmentsRoutes(serverRuntime);
const globalEvents = createGlobalEventsRoutes(globalEventBus, {
Expand All @@ -89,6 +91,7 @@ export function createRuntimeApp(
app.route("/api/projects", todos);
app.route("/api/projects", projectHitl);
app.route("/api/projects/:slug/sessions", sessions);
app.route("/api/projects", skills);
app.route("/api/projects/:slug/sessions/:sessionId", messages);
app.route("/api/projects/:slug/sessions/:sessionId/attachments", attachments);
app.route("/api/projects/:slug/sessions/:sessionId/compression", compression);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type ServerErrorCode =
| "TOOL_OUTPUT_INVALID_PATTERN"
| "TOOL_OUTPUT_SEARCH_TIMEOUT"
| "TOOL_OUTPUT_POLICY_VIOLATION"
| "SKILL_INVENTORY_CHANGED"
| "ATTACHMENT_INVALID"
| "ATTACHMENT_TOO_LARGE"
| "ATTACHMENT_CONFLICT"
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/routes/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ async function createFixture(name: string) {
});
const runtime = {
projectRegistry,
warnings: [],
uploadSessionAttachment: (
input: Parameters<AgentRuntime["uploadSessionAttachment"]>[0],
) => attachments.upload(input),
Expand All @@ -189,7 +188,8 @@ async function createFixture(name: string) {
subscribeMcpStatusChanges: () => () => undefined,
listSessionRuntimeEvents: async () => [],
listHitlSnapshotEvents: async () => [],
getMcpServerStatuses: () => new Map(),
getMcpServerStatus: () => ({ servers: {} }),
getMcpServerInventory: () => ({ servers: {} }),
} as unknown as AgentRuntime;
return {
app: createRuntimeApp(runtime).app,
Expand Down
5 changes: 2 additions & 3 deletions apps/server/src/routes/compression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,14 @@ function createTestRuntime(projectRegistry: ProjectRegistry) {

const runtime = {
projectRegistry,
mcpManager: undefined,
toolRegistry: undefined,
skillService: undefined,
warnings: [],
contextResolver: undefined,
hitl: undefined,
subscribeSessionRuntimeChanges: () => () => undefined,
subscribeMcpStatusChanges: () => () => undefined,
getMcpServerStatuses: () => new Map(),
getMcpServerStatus: () => ({ servers: {} }),
getMcpServerInventory: () => ({ servers: {} }),
createSession: async () => { throw new Error("not implemented"); },
getSessionFile: async () => { throw new Error("not implemented"); },
resolveCompressionOriginalRange: mock(async (workspaceRoot: string, sessionId: string, blockRef: string) => {
Expand Down
Loading