Repository Public
1# Agent OS — engineering contract for phases 0–2 (binding for every workstream)23Read `docs/ARCHITECTURE.md` first (design rationale, in French). This file is the precise interface contract.4Scope of this milestone: foundations, durable kernel, ONE agent per mission (no sub-agents yet — the data model5already supports them), Anthropic + OpenAI providers, file/shell/git tools in a Seatbelt sandbox, policy +6durable approvals, live console. Out of scope now: sub-agent orchestration, compaction, long-term memory,7remote runners, MCP, web browsing.89## 0. Rules for everyone1011- TypeScript strict, ESM, Node ≥ 24, no `any` without a justification comment. zod on every untrusted input.12- `@aos/shared` (`packages/shared/src/{core,api}.ts`) and `db/migrations/*.sql` are FROZEN; additive changes13 only, listed in your final report. New migrations get a new number (`011_…`, `020_…`).14- No agent framework dependency (no LangChain/LangGraph/CrewAI/AutoGen/Agents SDK/Claude Agent SDK).15 Model access only through the official SDKs `@anthropic-ai/sdk` and `openai`.16- Never fabricate data in the UI or tests-as-demo. Unknown ⇒ null / "—".17- Secrets never in logs, events, steps, prompts or the browser. Model API keys are read from env only18 (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`).19- Real model calls cost money: tests that call a model must be opt-in (`AOS_LIVE_TESTS=1`), use the cheapest20 model (`claude-haiku-5-5`, `gpt-6-luna`), tiny prompts, and stay under USD 0.50 in total per workstream.21 Dev keys: `set -a; . ~/.config/agent-os/dev.env; set +a` (file exists, mode 0600; never print it).22- Do not `git commit`. Do not run plain `pnpm install`; add dependencies with the lock:23 `while ! mkdir /tmp/aos-pnpm.lock 2>/dev/null; do sleep 2; done; pnpm --filter <pkg> add <dep>; rmdir /tmp/aos-pnpm.lock`24- Local Postgres 17 is running (unix socket /tmp). Tests create their own database `aos_test_<random>`25 (`createdb`), apply `db/migrations`, and drop it afterwards.26- Stay inside your directories (ownership table below). Do not touch `~/.claude*`, `~/.codex*`, or the27 sibling project `claude-remote`.2829| Workstream | Owns |30|---|---|31| kernel | `packages/kernel/**` |32| providers | `packages/providers/**` |33| engine+tools+worker | `packages/engine/**`, `packages/tools/**`, `apps/worker/**` |34| server | `apps/server/**` |35| web | `apps/web/**` |3637## 1. Processes3839```40browser ─HTTPS/WSS→ Caddy (tunnel) → apps/server :8640 auth, REST, events WS, static console. Reads/writes Postgres41 apps/worker claims runs, executes the engine, journals steps. No listener.42 PostgreSQL 17 the only shared state (journal, queue, waits, events)43```4445The server never runs agent code. The worker never serves HTTP. They communicate only through Postgres46(tables + `LISTEN/NOTIFY aos_events` as a doorbell with a slow-poll fallback).4748Env (both): `DATABASE_URL`, `AOS_DATA_DIR` (artifacts under `artifacts/<sha256[0:2]>/<sha256>`),49`AOS_WORKSPACE_ROOT` (projects live in `<root>/<rel_path>`), `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,50`AOS_LOG_LEVEL`. Server: `AOS_PORT` (8640), `AOS_HOST`, `AOS_PUBLIC_ORIGIN`, `AOS_SECRET_KEY` (64 hex),51`AOS_TRUSTED_PROXIES`, `AOS_WEB_DIR`. Worker: `AOS_WORKER_ID` (default hostname-pid), `AOS_MAX_CONCURRENT_RUNS`.5253## 2. Kernel (`@aos/kernel`) — durable execution on Postgres5455Model: **deterministic replay over a step journal**. A run's logic is an async function `(ctx) => ResultEnvelope`.56Every interaction with the outside world goes through `ctx.step(...)`. When a worker (re)claims a run it calls57the function from the top; steps that already have a recorded result return it immediately without executing;58execution proceeds for real from the first step without a result. Therefore run logic must be deterministic59given step results: no `Date.now()`, `Math.random()`, `crypto.randomUUID()` outside steps — use `ctx.now()`,60`ctx.uuid()`.6162```ts63export interface StepSpec<Req, Res> {64 kind: string; // 'model_call' | 'tool_call' | 'wait' | 'now' | 'uuid' | …65 name: string;66 effect: EffectClass;67 request: Req; // JSON-serialisable; hashed (canonical JSON) → request_hash68 /** Optional probe for non_idempotent steps found 'started' after a crash: has the effect happened? */69 reconcile?: (req: Req, idemKey: string) => Promise<{ done: true; result: Res } | { done: false } | { unknown: true }>;70}71export interface RunContext {72 readonly runId: string; readonly missionId: string; readonly parentRunId: string | null;73 readonly contract: DelegationContract; readonly workspaceAbs: string; readonly signal: AbortSignal; // aborted on cancel / lost lease74 step<Req, Res>(spec: StepSpec<Req, Res>, exec: (idemKey: string, signal: AbortSignal) => Promise<{ result: Res; usage?: Partial<Usage> }>): Promise<Res>;75 /** Durable wait. First execution: inserts a `waits` row, emits wait.created, sets run 'waiting', releases the76 * lease and throws an internal Suspend (never caught by run logic). After resolution the run is re-queued;77 * on replay this call returns the resolution. Expired ⇒ returns `{expired:true}`. */78 wait<T = unknown>(kind: 'approval' | 'question', payload: Record<string, unknown>, opts?: { timeoutSec?: number }): Promise<T | { expired: true }>;79 sleep(ms: number): Promise<void>; // durable timer (wake_at), same suspend mechanism80 now(): Promise<Date>; uuid(): Promise<string>; // journaled 'pure' steps81 emit(kind: EventKind, payload: Record<string, unknown>, trust?: Trust): Promise<void>; // persisted event (idempotent on replay: skipped while replaying)82 emitTransient(kind: 'model.delta', payload: Record<string, unknown>): void; // NOTIFY only, not persisted83 ownerMessages(): Promise<{ id: number; text: string }[]>; // unread owner.message events for this run, journaled as a step so replay is stable84 readonly replaying: boolean; // true while returning recorded results85 usage(): Usage; budgetLeft(): { usd: number; turns: number; wallSec: number };86}87```8889Step protocol (all in one transaction each, guarded by `WHERE id=$run AND fencing_token=$token`):90911. `ctx.step` allocates `seq = next_seq` (monotonic in call order — replay makes the same calls in the same order).922. Row exists `completed` ⇒ assert `request_hash` equal (else throw `NonDeterminismError` → run `failed`), return result.933. Row exists `failed` ⇒ rethrow the recorded error (deterministic). (Transient provider errors are retried94 INSIDE `exec` by the caller; a step fails only when retries are exhausted.)954. Row exists `started` (crash between intent and result):96 `pure|read|model_call|idempotent_write` ⇒ execute again with the same `idem_key`;97 `non_idempotent` ⇒ call `reconcile`; `done` ⇒ record result; `not done` ⇒ execute; `unknown` or no probe ⇒98 mark `needs_reconcile`, create an approval-like wait `{kind:'question', text:'Step N (<name>) may or may not have run…', options:['it ran — continue','it did not run — run it','abort']}` and suspend.995. No row ⇒ insert `started` (intent) + emit `step.started`; run `exec`; then in ONE transaction: update step to100 `completed` with result/usage, add usage to `runs.usage`, `missions.spent_usd`, `spend_days`, bump `next_seq`,101 emit `step.completed` + `run.usage`. Failure ⇒ `failed` + `step.failed`.1026. After each step: if `cancel_requested` ⇒ abort ⇒ run `cancelled`. If budget (usd/turns/wall) exceeded ⇒103 the engine is told via `budgetLeft()` and must finish with `budget_exhausted` (kernel hard-stops model_call104 steps once usd ≤ 0 by throwing `BudgetExhaustedError`). Daily cap (`AosSettings.dailyBudgetUsd` vs `spend_days`)105 ⇒ same error for every run.106107Leases: `claimRun(workerId)` = `UPDATE … WHERE id = (SELECT id FROM runs WHERE status='queued' AND (wake_at IS NULL OR wake_at<=now()) OR (status='running' AND lease_expires_at<now()) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) SET status='running', lease_owner, lease_expires_at=now()+90s, fencing_token=fencing_token+1`.108Heartbeat every 30 s extends the lease (fenced); a failed heartbeat aborts `ctx.signal` and the worker stops109touching the run. A zombie's writes fail the fencing check (`LeaseLostError`).110111Other kernel API: `createMission(input) → {missionId, rootRunId}` (creates mission + root run queued),112`createRun(parent, contract, model…)` (for later phases), `requestCancel(missionId|runId)` (cascades to descendants;113pending waits → cancelled; waiting runs → cancelled immediately), `resolveWait(waitId, resolution, actor)`114(marks resolved, emits `wait.resolved`, re-queues the run), `expireWaits()` (janitor, every 30 s),115`postOwnerMessage(runId, text)`, `runWorkerLoop({concurrency, logic: (ctx) => Promise<ResultEnvelope>, signal})`,116`migrate(databaseUrl)` (applies `db/migrations` with an advisory lock; exported for server/worker/tests; also a CLI117`node packages/kernel/dist/migrate-cli.js`), `listenEvents(cb)` (LISTEN + 2 s poll fallback by cursor),118artifacts: `putArtifact(bytes|stream, {name, mime, runId, stepSeq}) → {sha256,size}` (content-addressed, atomic),119`openArtifact(sha256)`.120Mission status mirrors the root run's status. When logic returns, the envelope is stored in `runs.result`,121status = envelope.status, `ended_at` set, events emitted. Uncaught logic error ⇒ `failed` with the message.122123Mandatory tests (real Postgres): happy path; replay returns recorded results without re-executing (counter in124exec); request-hash mismatch ⇒ NonDeterminismError; **chaos**: run logic in a CHILD PROCESS that `process.kill`s125itself (SIGKILL) at every step boundary in turn (before intent, after intent/before exec, after exec/before126commit, after commit) for a 6-step run mixing effects; a second worker resumes; assert each `pure/read` exec127count ≥ 1, each `idempotent_write` observable effect exactly once (keyed by idem_key), `non_idempotent` with a128reconcile probe executed exactly once, and without a probe ⇒ `needs_reconcile` + pending question, final129result identical to the uninterrupted run; lease expiry + zombie write refused (fencing); two workers never130execute the same step concurrently (50 runs × 2 workers); durable wait across worker restarts; wait expiry;131cancel while running / waiting; budget hard stop; daily cap; `sleep` survives restart; owner messages replay-stable.132133## 3. Providers (`@aos/providers`)134135```ts136export interface ToolDef { name: string; description: string; inputSchema: Record<string, unknown>; } // JSON Schema, strict-compatible137export interface ModelRequest {138 model: string; effort?: Effort; system: string; tools: ToolDef[];139 /** Provider-native transcript so far: opaque array owned by the provider adapter. Append-only. */140 transcript: unknown[];141 maxOutputTokens?: number; signal?: AbortSignal;142 onDelta?: (d: { type: 'text' | 'reasoning'; text: string }) => void;143}144export interface ProviderAdapter {145 readonly id: Provider;146 /** Start a native transcript from the first user message. */147 initTranscript(userText: string): unknown[];148 /** Native entries to append for: an assistant response (`ModelResponse.native`), tool results, a later user/steering message, an operator note. */149 appendToolResults(transcript: unknown[], results: ToolResult[]): unknown[];150 appendUser(transcript: unknown[], text: string): unknown[];151 appendOperatorNote(transcript: unknown[], text: string): unknown[]; // mid-conversation system message where supported, else a clearly delimited user-turn note152 call(req: ModelRequest): Promise<ModelResponse>; // streaming under the hood; retries 429/5xx/overloaded with backoff (honour retry-after), max ~6 attempts; throws ProviderError{retryable,status,code}153 listModels(): Promise<ModelInfo[]>; // live from the Models API merged with the static catalogue below154 price(model: string, usage: Usage): number; // USD from the price table (cache read/write priced separately)155}156export function getProvider(id: Provider): ProviderAdapter;157export const CATALOGUE: ModelInfo[]; export function seedPrices(db): Promise<void>; // fills model_prices158```159160Anthropic adapter — follow the bundled Claude API reference EXACTLY (read before coding):161`/private/tmp/claude-501/bundled-skills/2.1.322/ac43f8c5e0edf74167af31b9754cc782/claude-api/` → `SKILL.md`,162`typescript/claude-api/{README,tool-use,streaming}.md`, `shared/{tool-use-concepts,prompt-caching,agent-design,error-codes,models,model-migration}.md`.163Key points: `client.messages.stream(...)` + `.finalMessage()`; adaptive thinking (omit `thinking` or164`{type:'adaptive'}`; never `budget_tokens`, never `disabled`), `output_config.effort`; `display:'summarized'` so the165console can show operational reasoning; NO forced `tool_choice`; `strict: true` tools; `eager_input_streaming: true`166on client tools with validation of the parsed input; return ALL tool results of a turn in ONE user message, failed167tools as `is_error`; preserve thinking blocks verbatim (transcript is append-only — never edit earlier entries);168handle `stop_reason` `refusal` (→ StopReason 'refusal'), `pause_turn`, `max_tokens`; enable server-side fallbacks169(`betas:['server-side-fallback-2026-07-01']`, `fallbacks:'default'`) on models that support it; prompt caching:170stable prefix (tools → system) with a cache breakpoint on the system prefix + automatic caching of the tail;171operator notes as mid-conversation `{role:'system'}` messages on supporting models; `max_tokens` ~64000 with172streaming; typed error chain (`RateLimitError`, `APIStatusError`, `APIConnectionError`…).173Models (exact IDs, no date suffix): `claude-fable-5-2` (frontier), `claude-opus-5-5` (main, default),174`claude-sonnet-5-5` (worker), `claude-haiku-5-5` (volume). Prices per MTok in/out: 8/40, 4/20, 2/10, 0.10/0.50175(cache read 0.20/0.20/0.10/0.01; 5-min cache write = 1.25× input).176177OpenAI adapter — **Responses API** via the official `openai` SDK, stateless (no `previous_response_id`,178`store:false`), streaming, function tools with `strict:true`, reasoning effort mapped from `Effort`, reasoning179summaries requested and encrypted reasoning items kept in the native transcript and replayed unchanged180(`include:['reasoning.encrypted_content']`), parallel tool calls allowed. Verify every parameter name against181the installed SDK's types and the live docs (https://developers.openai.com/api/docs) — do not guess.182Models: `gpt-6-astra` (frontier), `gpt-6.1-sol` (main), `gpt-6.1-terra` (worker), `gpt-6-luna` (volume).183Prices per MTok in/out: 10/50, 2/10, 1/5, 0.10/0.50 (cached input 1.00/0.10/0.05/0.01). Verify IDs with184`client.models.list()`; if an ID is absent mark `available:false` (never silently substitute).185186Tests: unit tests with recorded fixtures for both adapters (transcript building, tool-call parsing incl.187parallel calls, usage/price math, error mapping, refusal, truncated tool input); opt-in live tests188(`AOS_LIVE_TESTS=1`): one text turn and one tool round-trip per provider on the volume model, asserting usage > 0189and price > 0, plus `listModels()` availability of the eight IDs (report which are available).190191## 4. Tools (`@aos/tools`) and sandbox192193```ts194export interface ToolContext { runId: string; missionId: string; workspaceAbs: string; network: 'none'|'open'; dataDir: string; signal: AbortSignal; idemKey: string; putArtifact(name: string, data: Buffer|string, mime?: string): Promise<{sha256:string,size:number}>; }195export interface Tool<I = unknown> {196 def: ToolDef; input: z.ZodType<I>;197 effect: EffectClass | ((input: I) => EffectClass);198 risk: RiskClass | ((input: I) => RiskClass);199 parallelSafe: boolean;200 run(input: I, ctx: ToolContext): Promise<{ ok: boolean; output: string; trust?: Trust }>;201 reconcile?: (input: I, ctx: ToolContext) => Promise<{done:true; output:string}|{done:false}|{unknown:true}>;202 describeForApproval?(input: I): { title: string; detail: string; diff?: string };203}204export const TOOLS: Record<string, Tool>;205```206207Tools for this milestone: `read_file` (line ranges, 2 MiB cap, binary refusal), `list_dir`, `search`208(ripgrep if present else bounded walk; name or content), `edit_file` (exact string replace; fails if the string209is absent or not unique unless `replaceAll`; **staleness check**: fails if the file changed since this run last210read/wrote it — track sha per path in the engine state via tool results), `write_file` (atomic), `shell`211(`{command, timeoutSec ≤ 600}` run through the sandbox, cwd = workspace, combined output, exit code),212`git_status`, `git_diff`, `git_commit` (stages given paths or all, commits with a mission trailer; idempotent:213no changes ⇒ ok no-op), `git_push` (risk `external`, effect `non_idempotent`, reconcile = compare remote ref),214`ask_owner` (question → engine turns it into `ctx.wait('question')`), `update_criteria` (flip `passes` of a215done criterion WITH evidence; cannot edit criterion text), `finish` (the only way to end a run: status, summary,216evidence, open questions → `ResultEnvelope`).217218Path safety for every file tool: resolve inside `workspaceAbs` with realpath of the deepest existing ancestor,219reject `..`, absolute paths outside, NUL, symlink escapes; **protected paths are never writable by any tool,220regardless of policy**: `.git/` internals (except through git tools), `.aos/`, `AGENTS.md`/`CLAUDE.md` at the221project root (read-only instructions), shell rc files, anything outside the workspace.222223Sandbox (tier 1, macOS): `shell` runs `/usr/bin/sandbox-exec -p <generated profile> /bin/zsh -lc <command>`224as a process group with a deny-default-write profile: read allowed except `~/.ssh`, `~/.aws`, `~/.config`,225`~/.claude*`, `~/.codex*`, `~/Library/Keychains`, the AOS data dir and env files; write allowed only under the226workspace and a per-run temp dir (`TMPDIR`); `network: 'none'` ⇒ `(deny network*)`; `'open'` ⇒ outbound allowed227(inbound denied). Environment passed to the command is a minimal allowlist (`PATH`, `HOME`, `LANG`, `TMPDIR`,228`TERM`) — never the worker's env (no API keys). `taskpolicy -c utility` when available. Output cap 1 MiB then229kill; timeout ⇒ TERM then KILL of the group. **Fail closed**: if `sandbox-exec` is unavailable or the profile230fails to apply, the tool returns an error and never runs unsandboxed. On non-macOS (CI) the shell tool is231disabled with a clear error. Verify enforcement with real tests: write outside workspace refused, read of a232protected dir refused, network blocked with `none` (e.g. `curl -m 3 https://example.com` fails), env has no233`ANTHROPIC_API_KEY`, timeout kills children, protected paths unwritable via file tools.234235Tool output handling (engine-side helper exported by tools): outputs > 12 000 chars are stored as an artifact;236the model sees head 6 000 + tail 3 000 chars and a note with the artifact name/size and how to read more237(`read_file` on `.aos/artifacts/<name>` is NOT how — provide a `read_artifact` tool: `{name, offset, limit}`).238239## 5. Engine (`@aos/engine`) — the agent loop as kernel run logic240241`export function agentLogic(deps): (ctx: RunContext) => Promise<ResultEnvelope>` where deps = providers, tools,242policy lookup, settings. Loop:2432441. Build the frozen prefix once: tool list (deterministic order, filtered by contract allowlist), system prompt245 (role, operating rules, done-criteria protocol, how to finish, sandbox/network facts, project instructions from246 `<workspace>/AGENTS.md` or `CLAUDE.md` if present (≤ 32 KiB) + `projects.instructions`). No timestamps, no IDs.247 Record `prompt_hash`. First user message: objective, boundaries, done criteria (or, when none were given, the248 instruction to propose 3–8 verifiable criteria via `update_criteria` semantics → new tool call `propose_criteria`249 that asks the owner to confirm through a `question` wait before any write), context refs.2502. Each turn: `ownerMessages()` → append as user steering messages; `ctx.step(model_call)` (exec calls251 `provider.call`, forwards deltas via `emitTransient`, returns `{blocks, stop, usage, model, native}`; usage252 priced with `provider.price`) → append `native` to the transcript.2533. Tool calls of the turn: validate input with zod (invalid ⇒ error result, no execution). Policy decision per254 call (§6). `parallelSafe` read tools of the same turn run concurrently (each its own step, steps allocated in255 call order BEFORE awaiting — keep seq deterministic); others sequentially. Each execution is a256 `ctx.step({kind:'tool_call', effect, request:{name,input}, reconcile})`. Results → one batch appended via257 `appendToolResults`.2584. Stop conditions: `finish` tool ⇒ envelope (status `done` allowed only if every done criterion `passes` with259 evidence — otherwise the tool returns an error explaining what is missing; `partial`/`blocked`/`failed` are always260 allowed with a reason); stop `end_turn` without tool calls ⇒ append an operator note asking to continue or call261 `finish` (max 2 nudges, then `partial`); `refusal` ⇒ `blocked` with the category; `max_tokens` ⇒ operator note262 to continue; budget/turn/wall limits ⇒ `budget_exhausted` (the engine first gives the model ONE final turn with263 an operator note to summarise state when ≥ 90 % of the budget is used); cancel ⇒ `cancelled`.2645. Stall detection (deterministic): the same tool call (name + canonical input) 3 times in a row with the same265 result, or 15 turns without any successful write/commit/criterion change ⇒ one operator note ("you appear266 stuck: re-plan or ask the owner"), second trigger ⇒ `ask_owner`-style question wait.2676. Context guard for this milestone (no compaction yet): track input tokens of the last call; at ≥ 70 % of the268 model's context window add an operator note to wrap up; at ≥ 90 % finish `partial` with a state summary.2697. Git checkpointing: if the workspace is a git repo, the mission works on a branch `aos/<missionId[0:8]>`270 created at start (step, idempotent); the system prompt asks for a commit after each verified unit of work.271272Replay rule: the transcript is rebuilt deterministically from step results on every claim; nothing else is state.273274Tests: a scripted fake provider drives full loops without network: tool round-trips, parallel reads, invalid tool275input, approval ask → approve / deny / expire, question wait, `finish` gating on criteria, nudges, stall276detection, budget exhaustion, cancel, steering message mid-run, crash/replay in the middle of a tool batch277(kill the worker child process, resume, same final transcript), edit staleness. One opt-in live test per provider278(`AOS_LIVE_TESTS=1`, volume model): in a temp git repo with a failing unit test, the agent fixes the bug, runs the279test in the sandbox, commits, flips the criterion and finishes `done` — assert test passes and commit exists.280281## 6. Policy and approvals282283Decision order **deny → ask → allow**, evaluated in the engine (never by the model):2841. hard denies (protected paths, tool not in allowlist, network tool while `network='none'`);2852. owner `policy_rules` (project-scoped first, then global): `deny` > `ask` > `allow`;2863. defaults by risk: `read` allow · `workspace_write` allow (sandbox + worktree branch) · `network` ask ·287 `external` ask. `shell` risk: `workspace_write` when `network='none'`; `network` when `'open'` (ask once per288 mission: an approval with `remember` creates an allow rule for the project).289Ask ⇒ `ctx.wait('approval', {tool, input, risk, title, detail, diff?})`, timeout from settings (default 72 h),290expiry = deny. Deny ⇒ tool result `ok:false` "Denied by owner/policy: <note>" (the model may adapt). Every291decision is an event `policy.decision`. `remember:true` on approve inserts an `allow` rule matching the tool292(+ `commandPrefix` for shell: first token(s) of the command).293294## 7. Worker (`apps/worker`)295296`main.ts`: loads env, `migrate`, `seedPrices`, `runWorkerLoop({concurrency: AOS_MAX_CONCURRENT_RUNS (default 4),297logic: agentLogic(...)})`, janitor (`expireWaits`), graceful shutdown (stop claiming, abort signals, leave runs298`running` with expiring leases so another worker resumes — never mark them failed). Structured pino logs without299prompts/outputs.300301## 8. Server (`apps/server`) — adapted from Claude Remote302303Keep unchanged in behaviour (rename env `CR_*` → `AOS_*`, cookie `__Host-aos_sid`, product name "Agent OS",304WebAuthn RP from origin): `security/*` (password, TOTP, WebAuthn, sessions, CSRF, throttle, guard, setup token),305`modules/auth`, `static.ts` (CSP with per-document script hashes), `audit.ts`, `janitor.ts`, rate-limit buckets,306`modules/notifications`, `modules/settings` (now `AosSettings`), OpenAPI. REMOVE: runtime client, terminal WS,307sessions, files, tasks, cluster, skills/MCP, agent profiles, branding variants (single brand; `GET /auth/status`308returns `branding:{agent:'aos', productName:'Agent OS', agentName:'Agent'}`).309310New REST under `/api/v1` (all authenticated + CSRF on mutations; zod validation; audit on mutations):311312| Area | Endpoints |313|---|---|314| health | `GET /health` → `{ok, version, agent:'aos'}` (public) · `GET /system/status` (db latency, worker liveness = any lease heartbeat or `workers` heartbeat row < 60 s old — add a small `workers(id, last_seen, version)` table via a new migration, written by the worker — provider key presence booleans, counts) |315| projects | `GET/POST /projects` · `GET/PATCH/DELETE /projects/:id` (DELETE unregisters only). `create` = mkdir + `git init`; `existing`; `clone` (URL whitelist https/ssh/git@/gitsrv:, `GIT_TERMINAL_PROMPT=0`, argv arrays). Paths confined to `AOS_WORKSPACE_ROOT` (same path-safety rules) |316| models | `GET /models` → `ModelInfo[]` (cached 10 min) |317| missions | `GET /missions?status=&projectId=` · `POST /missions` (`CreateMission`; refuses when today's spend ≥ daily cap; default model/effort/budget from settings) · `GET /missions/:id` · `POST /missions/:id/cancel` · `GET /missions/:id/runs` → `RunDto[]` · `GET /runs/:id` · `GET /runs/:id/steps?after=&limit=` → `StepDto[]` (request/result bodies truncated to 20 000 chars each with a `truncated` flag) · `POST /runs/:id/message` `{text}` (steering) · `GET /runs/:id/artifacts` · `GET /artifacts/:sha256?name=` (download, attachment, nosniff) |318| waits | `GET /waits?status=pending` → `WaitDto[]` · `POST /waits/:id/resolve` (`ResolveWait`) |319| policy | `GET/POST /policy/rules` · `DELETE /policy/rules/:id` |320| spend | `GET /spend` → `SpendSummary` |321| events | WS `/api/v1/events?cursor=<lastEventId>&missionId=<optional>` — same auth as Claude Remote's WS (cookie, Origin, re-validation). On connect: replays persisted events with `id > cursor` (bounded 5 000, then `system.notice` "gap, refetch"), then live events from `listenEvents`; transient `model.delta` forwarded with `id:-1`. JSON `AosEvent` per frame. Slow consumers dropped. |322323Server tests: keep and adapt the auth/security suite (every route 401 without session — iterate the route table),324plus missions/waits/policy/projects/events (cursor replay, auth, no secret leakage: assert responses never contain325the configured API keys), path validation, daily cap refusal, artifact download headers.326327## 9. Web (`apps/web`) — adapted from the Claude Remote console328329Keep: design system (light default, dark, system), auth/setup/login flows, app shell, PWA, command palette,330notifications, service worker, CSP constraints (static export, webpack build, zod jitless, no inline scripts,331self-hosted assets). Remove terminal, sessions, files/editor, tasks, cluster, skills, MCP pages and the agent332branding switch. Accent for Agent OS: a third identity distinct from Claude Remote (indigo) and Codex Remote333(emerald): **amber → orange** gradient with matching tokens in both themes (AA contrast), new icon set + manifest334("Agent OS").335336Routes: `/` → `/home/`; `/home/` (needs-you approvals/questions first, running missions with live spend,337today's spend vs cap, system health); `/missions/` (list, filters, create dialog: project, objective, optional done338criteria, model picker from `/models` grouped by provider with prices, effort, budget, network toggle with a clear339risk note); `/mission/?id=` (header: status, spend/budget bar, criteria checklist with evidence; **run tree**340(single node now, tree-ready); **live timeline** of the selected run: model turns (text + collapsible operational341reasoning summary), tool calls with input, output (collapsed, monospace, artifacts downloadable), diffs for342`edit_file`/`git_diff` rendered as diffs, policy decisions, waits inline with approve/deny/answer controls;343streaming deltas; composer to send a steering message; cancel); `/approvals/` (inbox: exact tool call, risk badge,344diff, approve / deny / approve-and-remember, answer questions); `/projects/`; `/policy/` (rules CRUD);345`/spend/` (today vs cap, 7 days, by model — real numbers only); `/system/`; `/settings/` (defaults, budgets,346security devices). Phone-first: Home and Approvals must be excellent on a 390-px screen (approve from the lock-screen347notification path is out of scope). Events via one shared WS with cursor resume (persist last cursor in memory;348on reconnect resume without gaps; on `system.notice` gap → refetch). Never render remote images or auto-link349URLs from model/tool output (plain text; code blocks escaped).350351Dev mock (`apps/web/dev/mock-server.mjs`) updated to this API with a scripted mission (fixture clearly labelled).352Playwright (mock): setup/login, create mission, live timeline renders streamed turn + tool call + diff, approval353flow from Home on phone viewport, question answer, cancel, spend page, theme, CSP-clean on every route,354screenshots (light/dark, phone/desktop) reviewed by the agent.355356## 10. As built — deviations and additions (integration, 2026-11-10)357358The sections above are the contract the workstreams were given. Where the code differs, the code is the359truth; this list is what a reader of §1–§9 must know. Nothing below changes a frozen shape incompatibly.360361**§1 Env.** Also read: `NODE_ENV` (server), `AOS_ENV_FILE` (server + worker: a `KEY=VALUE` file loaded at362start, never overriding the environment), `AOS_SANDBOX_EXTRA_DENY_READ` (engine: comma list of absolute363paths the sandboxed shell must not read), `AOS_MIGRATIONS_DIR` (kernel). Full table: `docs/deployment.md`.364365**§2 Kernel.**366- Step kinds journaled in practice: `setup` (`run_meta`, `prefix`, `git_branch`), `owner_messages`367 (request `{afterId}`, result `{messages:[{id,text}]}`), `model_call`, `policy` (`policy_rules`,368 `remember_rule`), `tool_call`, `wait` (name `approval` | `question`, request `{kind, payload, timeoutSec}`,369 result = the resolution or `{expired:true}`), `timer` (`sleep`), `now` (`{iso}`), `uuid` (`{uuid}`).370 `now`, `uuid` and `owner_messages` emit no `step.*` event.371- `StepSpec.assumedResult?` (addition): the result recorded when the owner answers "it ran — continue".372- Reconcile question payload: `{text, options: RECONCILE_OPTIONS, reconcile: true, stepSeq, stepKind, stepName}`.373- `step.started` / `step.completed` events carry `request` / `result` when the body is ≤ 16 KiB374 (`truncated` flag otherwise); `run.usage` = `{usage, budget, missionSpentUsd}`;375 `wait.created` = `{waitId, kind, payload, resolveBy}`; `wait.resolved` = `{waitId, kind, status, resolution?, actor?}`.376- Every function takes an optional trailing `Kernel` handle (`createKernel({databaseUrl, dataDir})`);377 `listenEvents(cb, {kernel})` returns `{ready, cursor(), close()}`; `openArtifact` returns `{size, mime, path, stream()}`;378 `resolveWait` returns `{resolved, status, runId}`. Migrations `011_workers.sql`, `012_kernel_emit_index.sql` were added.379- A step interrupted by cancel / shutdown / lost lease stays `started` (it has no result); it is not marked failed.380381**§4 Tools.** `Tool.run` returns `ToolRunResult {ok, output, trust?, fileStateOut?, diff?, control?}`;382`ToolContext` gains optional `fileState`, `getArtifact`, `sandbox`. Extra tools: `propose_criteria`,383`read_artifact`. `effect` / `risk` may depend on the mission network (`shell`). A `tool_call` step records384`{ok, content, artifact, trust, fileStateOut?, diff?}` (control tools: `{ok, content, criteria?, changed?}`).385The shell's exit code is the first line of `content`: `[exit code N]` (with `timed out…`, `output exceeded…`,386`cancelled`, `killed by SIG…` before it when they apply).387388**§5 Engine.**389- `model_call` request is a digest `{model, effort, turn, transcriptSha256, toolNames}` (never the transcript);390 the result is `{blocks, stop, usage, model, native, refusal?, nativeStop, attempts}`.391- Each execution of a model step starts with a transient `model.delta {reset:true, turn}`; deltas carry `turn`.392- A tool call that is refused before running (unknown tool, invalid input, policy deny, denied / expired393 approval) has **no** step: the refusal is only the model's next context (+ a `policy.decision` event).394 `ask_owner` has no `tool_call` step either (only its `wait` step); `propose_criteria` journals one only395 when the owner accepts.396- Criteria changes are announced by `system.notice {code:'criteria', message, doneCriteria}`.397- Questions: `ask_owner` `{text, options?, source:'ask_owner'}`; criteria proposal398 `{text, detail, options:['Yes — use these criteria'], source:'propose_criteria', proposed}` (any reply that399 is not an acceptance sends the agent back to revise); stall `{text, detail, options:['Let it continue','Stop the mission'], source:'stall'}`.400- Model failures: retryable provider errors that outlive the adapter's retries are retried after durable401 sleeps (30 s, 2 min, 5 min); a missing provider key ends the run `blocked`.402403**§6 Policy (owner decision).** Order: hard denies → any matching `deny` rule, project **or** global →404any matching `ask` → any matching `allow` → defaults by risk. A global deny beats a project allow.405A hard deny also covers every non-read tool while the done criteria are not confirmed.406`policy.decision` payload: `{tool, callId, risk, decision, reason, source: 'hard'|'rule'|'default'|'owner'|'expiry', ruleId?, remembered?, rule?}`;407the event has `seq: null`. Approval wait payload: `{tool, input, risk, title, detail, reason, diff?}`.408409**§8 Server.**410- `kernel-adapter.ts` / `providers-adapter.ts` use ordinary typed imports of `@aos/kernel` / `@aos/providers`;411 the adapter test runs in the default suite; the server inserts no event itself.412- `MissionDto` gains optional `provider`, `model`, `effort` (of the root run) — `@aos/shared`, additive.413- `native` is removed from `model_call` step results and from `step.completed` events before they are sent414 to the browser (`browser-view.ts`).415- `StepDto` is sent with an extra `truncated: boolean`. Server-local DTOs (`SystemStatus`, `ArtifactDto`,416 `ModelInfoEx`, `UpdateProject`, `SteeringMessage`) live in `apps/server/src/dto.ts`.417 `SystemStatus` = `{collectedAt, version, db{ok,latencyMs}, worker{alive|null,lastSeenAt,source,workers[]}, providers{anthropic,openai}, counts{missionsQueued,missionsRunning,missionsWaiting,pendingWaits,wsClients}}`.418- WebSocket notices are `system.notice` events with `id:-1`, `missionId:''`: `{code:'gap', message, latestId}` and419 `{code:'auth.expired'}` (then close 4401). `policy.decision` events are replayed over the WebSocket by cursor420 only; there is no REST endpoint for them.421- Extra routes: `GET /system/audit`, `GET/PUT /settings`, `GET /notifications`, `POST /notifications/read`, `GET /openapi.json`.422423**§9 Web.** The timeline also shows tool calls the model issued that were never executed ("not executed"),424derived from the model step's `blocks` minus the journaled steps; a step still `started` when its run has425ended is shown "interrupted". The dev mock emits the real shapes listed here.426