SPB Git

Repository Public

CONTRACT.md 35.1 KB

# Agent OS — engineering contract for phases 0–2 (binding for every workstream)

Read docs/ARCHITECTURE.md first (design rationale, in French). This file is the precise interface contract. Scope of this milestone: foundations, durable kernel, ONE agent per mission (no sub-agents yet — the data model already supports them), Anthropic + OpenAI providers, file/shell/git tools in a Seatbelt sandbox, policy + durable approvals, live console. Out of scope now: sub-agent orchestration, compaction, long-term memory, remote runners, MCP, web browsing.

# 0. Rules for everyone

  • TypeScript strict, ESM, Node ≥ 24, no any without a justification comment. zod on every untrusted input.
  • @aos/shared (packages/shared/src/{core,api}.ts) and db/migrations/*.sql are FROZEN; additive changes only, listed in your final report. New migrations get a new number (011_…, 020_…).
  • No agent framework dependency (no LangChain/LangGraph/CrewAI/AutoGen/Agents SDK/Claude Agent SDK). Model access only through the official SDKs @anthropic-ai/sdk and openai.
  • Never fabricate data in the UI or tests-as-demo. Unknown ⇒ null / "—".
  • Secrets never in logs, events, steps, prompts or the browser. Model API keys are read from env only (ANTHROPIC_API_KEY, OPENAI_API_KEY).
  • Real model calls cost money: tests that call a model must be opt-in (AOS_LIVE_TESTS=1), use the cheapest model (claude-haiku-5-5, gpt-6-luna), tiny prompts, and stay under USD 0.50 in total per workstream. Dev keys: set -a; . ~/.config/agent-os/dev.env; set +a (file exists, mode 0600; never print it).
  • Do not git commit. Do not run plain pnpm install; add dependencies with the lock: while ! mkdir /tmp/aos-pnpm.lock 2>/dev/null; do sleep 2; done; pnpm --filter <pkg> add <dep>; rmdir /tmp/aos-pnpm.lock
  • Local Postgres 17 is running (unix socket /tmp). Tests create their own database aos_test_<random> (createdb), apply db/migrations, and drop it afterwards.
  • Stay inside your directories (ownership table below). Do not touch ~/.claude*, ~/.codex*, or the sibling project claude-remote.
Workstream Owns
kernel packages/kernel/**
providers packages/providers/**
engine+tools+worker packages/engine/**, packages/tools/**, apps/worker/**
server apps/server/**
web apps/web/**

# 1. Processes

text
browser ─HTTPS/WSS→ Caddy (tunnel) → apps/server  :8640   auth, REST, events WS, static console. Reads/writes Postgres
                                       apps/worker           claims runs, executes the engine, journals steps. No listener.
                                       PostgreSQL 17         the only shared state (journal, queue, waits, events)

The server never runs agent code. The worker never serves HTTP. They communicate only through Postgres (tables + LISTEN/NOTIFY aos_events as a doorbell with a slow-poll fallback).

Env (both): DATABASE_URL, AOS_DATA_DIR (artifacts under artifacts/<sha256[0:2]>/<sha256>), AOS_WORKSPACE_ROOT (projects live in <root>/<rel_path>), ANTHROPIC_API_KEY, OPENAI_API_KEY, AOS_LOG_LEVEL. Server: AOS_PORT (8640), AOS_HOST, AOS_PUBLIC_ORIGIN, AOS_SECRET_KEY (64 hex), AOS_TRUSTED_PROXIES, AOS_WEB_DIR. Worker: AOS_WORKER_ID (default hostname-pid), AOS_MAX_CONCURRENT_RUNS.

# 2. Kernel (@aos/kernel) — durable execution on Postgres

Model: deterministic replay over a step journal. A run's logic is an async function (ctx) => ResultEnvelope. Every interaction with the outside world goes through ctx.step(...). When a worker (re)claims a run it calls the function from the top; steps that already have a recorded result return it immediately without executing; execution proceeds for real from the first step without a result. Therefore run logic must be deterministic given step results: no Date.now(), Math.random(), crypto.randomUUID() outside steps — use ctx.now(), ctx.uuid().

ts
export interface StepSpec<Req, Res> {
  kind: string;                 // 'model_call' | 'tool_call' | 'wait' | 'now' | 'uuid' | …
  name: string;
  effect: EffectClass;
  request: Req;                 // JSON-serialisable; hashed (canonical JSON) → request_hash
  /** Optional probe for non_idempotent steps found 'started' after a crash: has the effect happened? */
  reconcile?: (req: Req, idemKey: string) => Promise<{ done: true; result: Res } | { done: false } | { unknown: true }>;
}
export interface RunContext {
  readonly runId: string; readonly missionId: string; readonly parentRunId: string | null;
  readonly contract: DelegationContract; readonly workspaceAbs: string; readonly signal: AbortSignal; // aborted on cancel / lost lease
  step<Req, Res>(spec: StepSpec<Req, Res>, exec: (idemKey: string, signal: AbortSignal) => Promise<{ result: Res; usage?: Partial<Usage> }>): Promise<Res>;
  /** Durable wait. First execution: inserts a `waits` row, emits wait.created, sets run 'waiting', releases the
   *  lease and throws an internal Suspend (never caught by run logic). After resolution the run is re-queued;
   *  on replay this call returns the resolution. Expired ⇒ returns `{expired:true}`. */
  wait<T = unknown>(kind: 'approval' | 'question', payload: Record<string, unknown>, opts?: { timeoutSec?: number }): Promise<T | { expired: true }>;
  sleep(ms: number): Promise<void>;                 // durable timer (wake_at), same suspend mechanism
  now(): Promise<Date>; uuid(): Promise<string>;    // journaled 'pure' steps
  emit(kind: EventKind, payload: Record<string, unknown>, trust?: Trust): Promise<void>; // persisted event (idempotent on replay: skipped while replaying)
  emitTransient(kind: 'model.delta', payload: Record<string, unknown>): void;             // NOTIFY only, not persisted
  ownerMessages(): Promise<{ id: number; text: string }[]>; // unread owner.message events for this run, journaled as a step so replay is stable
  readonly replaying: boolean;                      // true while returning recorded results
  usage(): Usage; budgetLeft(): { usd: number; turns: number; wallSec: number };
}

Step protocol (all in one transaction each, guarded by WHERE id=$run AND fencing_token=$token):

  1. ctx.step allocates seq = next_seq (monotonic in call order — replay makes the same calls in the same order).
  2. Row exists completed ⇒ assert request_hash equal (else throw NonDeterminismError → run failed), return result.
  3. Row exists failed ⇒ rethrow the recorded error (deterministic). (Transient provider errors are retried INSIDE exec by the caller; a step fails only when retries are exhausted.)
  4. Row exists started (crash between intent and result): pure|read|model_call|idempotent_write ⇒ execute again with the same idem_key; non_idempotent ⇒ call reconcile; done ⇒ record result; not done ⇒ execute; unknown or no probe ⇒ 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.
  5. No row ⇒ insert started (intent) + emit step.started; run exec; then in ONE transaction: update step to completed with result/usage, add usage to runs.usage, missions.spent_usd, spend_days, bump next_seq, emit step.completed + run.usage. Failure ⇒ failed + step.failed.
  6. After each step: if cancel_requested ⇒ abort ⇒ run cancelled. If budget (usd/turns/wall) exceeded ⇒ the engine is told via budgetLeft() and must finish with budget_exhausted (kernel hard-stops model_call steps once usd ≤ 0 by throwing BudgetExhaustedError). Daily cap (AosSettings.dailyBudgetUsd vs spend_days) ⇒ same error for every run.

Leases: 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. Heartbeat every 30 s extends the lease (fenced); a failed heartbeat aborts ctx.signal and the worker stops touching the run. A zombie's writes fail the fencing check (LeaseLostError).

Other kernel API: createMission(input) → {missionId, rootRunId} (creates mission + root run queued), createRun(parent, contract, model…) (for later phases), requestCancel(missionId|runId) (cascades to descendants; pending waits → cancelled; waiting runs → cancelled immediately), resolveWait(waitId, resolution, actor) (marks resolved, emits wait.resolved, re-queues the run), expireWaits() (janitor, every 30 s), postOwnerMessage(runId, text), runWorkerLoop({concurrency, logic: (ctx) => Promise<ResultEnvelope>, signal}), migrate(databaseUrl) (applies db/migrations with an advisory lock; exported for server/worker/tests; also a CLI node packages/kernel/dist/migrate-cli.js), listenEvents(cb) (LISTEN + 2 s poll fallback by cursor), artifacts: putArtifact(bytes|stream, {name, mime, runId, stepSeq}) → {sha256,size} (content-addressed, atomic), openArtifact(sha256). Mission status mirrors the root run's status. When logic returns, the envelope is stored in runs.result, status = envelope.status, ended_at set, events emitted. Uncaught logic error ⇒ failed with the message.

Mandatory tests (real Postgres): happy path; replay returns recorded results without re-executing (counter in exec); request-hash mismatch ⇒ NonDeterminismError; chaos: run logic in a CHILD PROCESS that process.kills itself (SIGKILL) at every step boundary in turn (before intent, after intent/before exec, after exec/before commit, after commit) for a 6-step run mixing effects; a second worker resumes; assert each pure/read exec count ≥ 1, each idempotent_write observable effect exactly once (keyed by idem_key), non_idempotent with a reconcile probe executed exactly once, and without a probe ⇒ needs_reconcile + pending question, final result identical to the uninterrupted run; lease expiry + zombie write refused (fencing); two workers never execute the same step concurrently (50 runs × 2 workers); durable wait across worker restarts; wait expiry; cancel while running / waiting; budget hard stop; daily cap; sleep survives restart; owner messages replay-stable.

# 3. Providers (@aos/providers)

ts
export interface ToolDef { name: string; description: string; inputSchema: Record<string, unknown>; } // JSON Schema, strict-compatible
export interface ModelRequest {
  model: string; effort?: Effort; system: string; tools: ToolDef[];
  /** Provider-native transcript so far: opaque array owned by the provider adapter. Append-only. */
  transcript: unknown[];
  maxOutputTokens?: number; signal?: AbortSignal;
  onDelta?: (d: { type: 'text' | 'reasoning'; text: string }) => void;
}
export interface ProviderAdapter {
  readonly id: Provider;
  /** Start a native transcript from the first user message. */
  initTranscript(userText: string): unknown[];
  /** Native entries to append for: an assistant response (`ModelResponse.native`), tool results, a later user/steering message, an operator note. */
  appendToolResults(transcript: unknown[], results: ToolResult[]): unknown[];
  appendUser(transcript: unknown[], text: string): unknown[];
  appendOperatorNote(transcript: unknown[], text: string): unknown[]; // mid-conversation system message where supported, else a clearly delimited user-turn note
  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}
  listModels(): Promise<ModelInfo[]>;               // live from the Models API merged with the static catalogue below
  price(model: string, usage: Usage): number;       // USD from the price table (cache read/write priced separately)
}
export function getProvider(id: Provider): ProviderAdapter;
export const CATALOGUE: ModelInfo[]; export function seedPrices(db): Promise<void>; // fills model_prices

Anthropic adapter — follow the bundled Claude API reference EXACTLY (read before coding): /private/tmp/claude-501/bundled-skills/2.1.322/ac43f8c5e0edf74167af31b9754cc782/claude-api/ → SKILL.md, typescript/claude-api/{README,tool-use,streaming}.md, shared/{tool-use-concepts,prompt-caching,agent-design,error-codes,models,model-migration}.md. Key points: client.messages.stream(...) + .finalMessage(); adaptive thinking (omit thinking or {type:'adaptive'}; never budget_tokens, never disabled), output_config.effort; display:'summarized' so the console can show operational reasoning; NO forced tool_choice; strict: true tools; eager_input_streaming: true on client tools with validation of the parsed input; return ALL tool results of a turn in ONE user message, failed tools as is_error; preserve thinking blocks verbatim (transcript is append-only — never edit earlier entries); handle stop_reason refusal (→ StopReason 'refusal'), pause_turn, max_tokens; enable server-side fallbacks (betas:['server-side-fallback-2026-07-01'], fallbacks:'default') on models that support it; prompt caching: stable prefix (tools → system) with a cache breakpoint on the system prefix + automatic caching of the tail; operator notes as mid-conversation {role:'system'} messages on supporting models; max_tokens ~64000 with streaming; typed error chain (RateLimitError, APIStatusError, APIConnectionError…). Models (exact IDs, no date suffix): claude-fable-5-2 (frontier), claude-opus-5-5 (main, default), claude-sonnet-5-5 (worker), claude-haiku-5-5 (volume). Prices per MTok in/out: 8/40, 4/20, 2/10, 0.10/0.50 (cache read 0.20/0.20/0.10/0.01; 5-min cache write = 1.25× input).

OpenAI adapter — Responses API via the official openai SDK, stateless (no previous_response_id, store:false), streaming, function tools with strict:true, reasoning effort mapped from Effort, reasoning summaries requested and encrypted reasoning items kept in the native transcript and replayed unchanged (include:['reasoning.encrypted_content']), parallel tool calls allowed. Verify every parameter name against the installed SDK's types and the live docs (https://developers.openai.com/api/docs) — do not guess. Models: gpt-6-astra (frontier), gpt-6.1-sol (main), gpt-6.1-terra (worker), gpt-6-luna (volume). Prices 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 with client.models.list(); if an ID is absent mark available:false (never silently substitute).

Tests: unit tests with recorded fixtures for both adapters (transcript building, tool-call parsing incl. parallel calls, usage/price math, error mapping, refusal, truncated tool input); opt-in live tests (AOS_LIVE_TESTS=1): one text turn and one tool round-trip per provider on the volume model, asserting usage > 0 and price > 0, plus listModels() availability of the eight IDs (report which are available).

# 4. Tools (@aos/tools) and sandbox

ts
export 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}>; }
export interface Tool<I = unknown> {
  def: ToolDef; input: z.ZodType<I>;
  effect: EffectClass | ((input: I) => EffectClass);
  risk: RiskClass | ((input: I) => RiskClass);
  parallelSafe: boolean;
  run(input: I, ctx: ToolContext): Promise<{ ok: boolean; output: string; trust?: Trust }>;
  reconcile?: (input: I, ctx: ToolContext) => Promise<{done:true; output:string}|{done:false}|{unknown:true}>;
  describeForApproval?(input: I): { title: string; detail: string; diff?: string };
}
export const TOOLS: Record<string, Tool>;

Tools for this milestone: read_file (line ranges, 2 MiB cap, binary refusal), list_dir, search (ripgrep if present else bounded walk; name or content), edit_file (exact string replace; fails if the string is absent or not unique unless replaceAll; staleness check: fails if the file changed since this run last read/wrote it — track sha per path in the engine state via tool results), write_file (atomic), shell ({command, timeoutSec ≤ 600} run through the sandbox, cwd = workspace, combined output, exit code), git_status, git_diff, git_commit (stages given paths or all, commits with a mission trailer; idempotent: no changes ⇒ ok no-op), git_push (risk external, effect non_idempotent, reconcile = compare remote ref), ask_owner (question → engine turns it into ctx.wait('question')), update_criteria (flip passes of a done criterion WITH evidence; cannot edit criterion text), finish (the only way to end a run: status, summary, evidence, open questions → ResultEnvelope).

Path safety for every file tool: resolve inside workspaceAbs with realpath of the deepest existing ancestor, reject .., absolute paths outside, NUL, symlink escapes; protected paths are never writable by any tool, regardless of policy: .git/ internals (except through git tools), .aos/, AGENTS.md/CLAUDE.md at the project root (read-only instructions), shell rc files, anything outside the workspace.

Sandbox (tier 1, macOS): shell runs /usr/bin/sandbox-exec -p <generated profile> /bin/zsh -lc <command> as a process group with a deny-default-write profile: read allowed except ~/.ssh, ~/.aws, ~/.config, ~/.claude*, ~/.codex*, ~/Library/Keychains, the AOS data dir and env files; write allowed only under the workspace and a per-run temp dir (TMPDIR); network: 'none' ⇒ (deny network*); 'open' ⇒ outbound allowed (inbound denied). Environment passed to the command is a minimal allowlist (PATH, HOME, LANG, TMPDIR, TERM) — never the worker's env (no API keys). taskpolicy -c utility when available. Output cap 1 MiB then kill; timeout ⇒ TERM then KILL of the group. Fail closed: if sandbox-exec is unavailable or the profile fails to apply, the tool returns an error and never runs unsandboxed. On non-macOS (CI) the shell tool is disabled with a clear error. Verify enforcement with real tests: write outside workspace refused, read of a protected dir refused, network blocked with none (e.g. curl -m 3 https://example.com fails), env has no ANTHROPIC_API_KEY, timeout kills children, protected paths unwritable via file tools.

Tool output handling (engine-side helper exported by tools): outputs > 12 000 chars are stored as an artifact; the model sees head 6 000 + tail 3 000 chars and a note with the artifact name/size and how to read more (read_file on .aos/artifacts/<name> is NOT how — provide a read_artifact tool: {name, offset, limit}).

# 5. Engine (@aos/engine) — the agent loop as kernel run logic

export function agentLogic(deps): (ctx: RunContext) => Promise<ResultEnvelope> where deps = providers, tools, policy lookup, settings. Loop:

  1. Build the frozen prefix once: tool list (deterministic order, filtered by contract allowlist), system prompt (role, operating rules, done-criteria protocol, how to finish, sandbox/network facts, project instructions from <workspace>/AGENTS.md or CLAUDE.md if present (≤ 32 KiB) + projects.instructions). No timestamps, no IDs. Record prompt_hash. First user message: objective, boundaries, done criteria (or, when none were given, the instruction to propose 3–8 verifiable criteria via update_criteria semantics → new tool call propose_criteria that asks the owner to confirm through a question wait before any write), context refs.
  2. Each turn: ownerMessages() → append as user steering messages; ctx.step(model_call) (exec calls provider.call, forwards deltas via emitTransient, returns {blocks, stop, usage, model, native}; usage priced with provider.price) → append native to the transcript.
  3. Tool calls of the turn: validate input with zod (invalid ⇒ error result, no execution). Policy decision per call (§6). parallelSafe read tools of the same turn run concurrently (each its own step, steps allocated in call order BEFORE awaiting — keep seq deterministic); others sequentially. Each execution is a ctx.step({kind:'tool_call', effect, request:{name,input}, reconcile}). Results → one batch appended via appendToolResults.
  4. Stop conditions: finish tool ⇒ envelope (status done allowed only if every done criterion passes with evidence — otherwise the tool returns an error explaining what is missing; partial/blocked/failed are always allowed with a reason); stop end_turn without tool calls ⇒ append an operator note asking to continue or call finish (max 2 nudges, then partial); refusal ⇒ blocked with the category; max_tokens ⇒ operator note to continue; budget/turn/wall limits ⇒ budget_exhausted (the engine first gives the model ONE final turn with an operator note to summarise state when ≥ 90 % of the budget is used); cancel ⇒ cancelled.
  5. Stall detection (deterministic): the same tool call (name + canonical input) 3 times in a row with the same result, or 15 turns without any successful write/commit/criterion change ⇒ one operator note ("you appear stuck: re-plan or ask the owner"), second trigger ⇒ ask_owner-style question wait.
  6. Context guard for this milestone (no compaction yet): track input tokens of the last call; at ≥ 70 % of the model's context window add an operator note to wrap up; at ≥ 90 % finish partial with a state summary.
  7. Git checkpointing: if the workspace is a git repo, the mission works on a branch aos/<missionId[0:8]> created at start (step, idempotent); the system prompt asks for a commit after each verified unit of work.

Replay rule: the transcript is rebuilt deterministically from step results on every claim; nothing else is state.

Tests: a scripted fake provider drives full loops without network: tool round-trips, parallel reads, invalid tool input, approval ask → approve / deny / expire, question wait, finish gating on criteria, nudges, stall detection, budget exhaustion, cancel, steering message mid-run, crash/replay in the middle of a tool batch (kill the worker child process, resume, same final transcript), edit staleness. One opt-in live test per provider (AOS_LIVE_TESTS=1, volume model): in a temp git repo with a failing unit test, the agent fixes the bug, runs the test in the sandbox, commits, flips the criterion and finishes done — assert test passes and commit exists.

# 6. Policy and approvals

Decision order deny → ask → allow, evaluated in the engine (never by the model):

  1. hard denies (protected paths, tool not in allowlist, network tool while network='none');
  2. owner policy_rules (project-scoped first, then global): deny > ask > allow;
  3. defaults by risk: read allow · workspace_write allow (sandbox + worktree branch) · network ask · external ask. shell risk: workspace_write when network='none'; network when 'open' (ask once per mission: an approval with remember creates an allow rule for the project). Ask ⇒ ctx.wait('approval', {tool, input, risk, title, detail, diff?}), timeout from settings (default 72 h), expiry = deny. Deny ⇒ tool result ok:false "Denied by owner/policy: " (the model may adapt). Every decision is an event policy.decision. remember:true on approve inserts an allow rule matching the tool (+ commandPrefix for shell: first token(s) of the command).

# 7. Worker (apps/worker)

main.ts: loads env, migrate, seedPrices, runWorkerLoop({concurrency: AOS_MAX_CONCURRENT_RUNS (default 4), logic: agentLogic(...)}), janitor (expireWaits), graceful shutdown (stop claiming, abort signals, leave runs running with expiring leases so another worker resumes — never mark them failed). Structured pino logs without prompts/outputs.

# 8. Server (apps/server) — adapted from Claude Remote

Keep unchanged in behaviour (rename env CR_* → AOS_*, cookie __Host-aos_sid, product name "Agent OS", WebAuthn RP from origin): security/* (password, TOTP, WebAuthn, sessions, CSRF, throttle, guard, setup token), modules/auth, static.ts (CSP with per-document script hashes), audit.ts, janitor.ts, rate-limit buckets, modules/notifications, modules/settings (now AosSettings), OpenAPI. REMOVE: runtime client, terminal WS, sessions, files, tasks, cluster, skills/MCP, agent profiles, branding variants (single brand; GET /auth/status returns branding:{agent:'aos', productName:'Agent OS', agentName:'Agent'}).

New REST under /api/v1 (all authenticated + CSRF on mutations; zod validation; audit on mutations):

Area Endpoints
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)
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)
models GET /models → ModelInfo[] (cached 10 min)
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)
waits GET /waits?status=pending → WaitDto[] · POST /waits/:id/resolve (ResolveWait)
policy GET/POST /policy/rules · DELETE /policy/rules/:id
spend GET /spend → SpendSummary
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.

Server tests: keep and adapt the auth/security suite (every route 401 without session — iterate the route table), plus missions/waits/policy/projects/events (cursor replay, auth, no secret leakage: assert responses never contain the configured API keys), path validation, daily cap refusal, artifact download headers.

# 9. Web (apps/web) — adapted from the Claude Remote console

Keep: design system (light default, dark, system), auth/setup/login flows, app shell, PWA, command palette, notifications, service worker, CSP constraints (static export, webpack build, zod jitless, no inline scripts, self-hosted assets). Remove terminal, sessions, files/editor, tasks, cluster, skills, MCP pages and the agent branding switch. Accent for Agent OS: a third identity distinct from Claude Remote (indigo) and Codex Remote (emerald): amber → orange gradient with matching tokens in both themes (AA contrast), new icon set + manifest ("Agent OS").

Routes: / → /home/; /home/ (needs-you approvals/questions first, running missions with live spend, today's spend vs cap, system health); /missions/ (list, filters, create dialog: project, objective, optional done criteria, model picker from /models grouped by provider with prices, effort, budget, network toggle with a clear risk note); /mission/?id= (header: status, spend/budget bar, criteria checklist with evidence; run tree (single node now, tree-ready); live timeline of the selected run: model turns (text + collapsible operational reasoning summary), tool calls with input, output (collapsed, monospace, artifacts downloadable), diffs for edit_file/git_diff rendered as diffs, policy decisions, waits inline with approve/deny/answer controls; streaming deltas; composer to send a steering message; cancel); /approvals/ (inbox: exact tool call, risk badge, diff, approve / deny / approve-and-remember, answer questions); /projects/; /policy/ (rules CRUD); /spend/ (today vs cap, 7 days, by model — real numbers only); /system/; /settings/ (defaults, budgets, security devices). Phone-first: Home and Approvals must be excellent on a 390-px screen (approve from the lock-screen notification path is out of scope). Events via one shared WS with cursor resume (persist last cursor in memory; on reconnect resume without gaps; on system.notice gap → refetch). Never render remote images or auto-link URLs from model/tool output (plain text; code blocks escaped).

Dev mock (apps/web/dev/mock-server.mjs) updated to this API with a scripted mission (fixture clearly labelled). Playwright (mock): setup/login, create mission, live timeline renders streamed turn + tool call + diff, approval flow from Home on phone viewport, question answer, cancel, spend page, theme, CSP-clean on every route, screenshots (light/dark, phone/desktop) reviewed by the agent.

# 10. As built — deviations and additions (integration, 2026-11-10)

The sections above are the contract the workstreams were given. Where the code differs, the code is the truth; this list is what a reader of §1–§9 must know. Nothing below changes a frozen shape incompatibly.

§1 Env. Also read: NODE_ENV (server), AOS_ENV_FILE (server + worker: a KEY=VALUE file loaded at start, never overriding the environment), AOS_SANDBOX_EXTRA_DENY_READ (engine: comma list of absolute paths the sandboxed shell must not read), AOS_MIGRATIONS_DIR (kernel). Full table: docs/deployment.md.

§2 Kernel.

  • Step kinds journaled in practice: setup (run_meta, prefix, git_branch), owner_messages (request {afterId}, result {messages:[{id,text}]}), model_call, policy (policy_rules, remember_rule), tool_call, wait (name approval | question, request {kind, payload, timeoutSec}, result = the resolution or {expired:true}), timer (sleep), now ({iso}), uuid ({uuid}). now, uuid and owner_messages emit no step.* event.
  • StepSpec.assumedResult? (addition): the result recorded when the owner answers "it ran — continue".
  • Reconcile question payload: {text, options: RECONCILE_OPTIONS, reconcile: true, stepSeq, stepKind, stepName}.
  • step.started / step.completed events carry request / result when the body is ≤ 16 KiB (truncated flag otherwise); run.usage = {usage, budget, missionSpentUsd}; wait.created = {waitId, kind, payload, resolveBy}; wait.resolved = {waitId, kind, status, resolution?, actor?}.
  • Every function takes an optional trailing Kernel handle (createKernel({databaseUrl, dataDir})); listenEvents(cb, {kernel}) returns {ready, cursor(), close()}; openArtifact returns {size, mime, path, stream()}; resolveWait returns {resolved, status, runId}. Migrations 011_workers.sql, 012_kernel_emit_index.sql were added.
  • A step interrupted by cancel / shutdown / lost lease stays started (it has no result); it is not marked failed.

§4 Tools. Tool.run returns ToolRunResult {ok, output, trust?, fileStateOut?, diff?, control?}; ToolContext gains optional fileState, getArtifact, sandbox. Extra tools: propose_criteria, read_artifact. effect / risk may depend on the mission network (shell). A tool_call step records {ok, content, artifact, trust, fileStateOut?, diff?} (control tools: {ok, content, criteria?, changed?}). The shell's exit code is the first line of content: [exit code N] (with timed out…, output exceeded…, cancelled, killed by SIG… before it when they apply).

§5 Engine.

  • model_call request is a digest {model, effort, turn, transcriptSha256, toolNames} (never the transcript); the result is {blocks, stop, usage, model, native, refusal?, nativeStop, attempts}.
  • Each execution of a model step starts with a transient model.delta {reset:true, turn}; deltas carry turn.
  • A tool call that is refused before running (unknown tool, invalid input, policy deny, denied / expired approval) has no step: the refusal is only the model's next context (+ a policy.decision event). ask_owner has no tool_call step either (only its wait step); propose_criteria journals one only when the owner accepts.
  • Criteria changes are announced by system.notice {code:'criteria', message, doneCriteria}.
  • Questions: ask_owner {text, options?, source:'ask_owner'}; criteria proposal {text, detail, options:['Yes — use these criteria'], source:'propose_criteria', proposed} (any reply that is not an acceptance sends the agent back to revise); stall {text, detail, options:['Let it continue','Stop the mission'], source:'stall'}.
  • Model failures: retryable provider errors that outlive the adapter's retries are retried after durable sleeps (30 s, 2 min, 5 min); a missing provider key ends the run blocked.

§6 Policy (owner decision). Order: hard denies → any matching deny rule, project or global → any matching ask → any matching allow → defaults by risk. A global deny beats a project allow. A hard deny also covers every non-read tool while the done criteria are not confirmed. policy.decision payload: {tool, callId, risk, decision, reason, source: 'hard'|'rule'|'default'|'owner'|'expiry', ruleId?, remembered?, rule?}; the event has seq: null. Approval wait payload: {tool, input, risk, title, detail, reason, diff?}.

§8 Server.

  • kernel-adapter.ts / providers-adapter.ts use ordinary typed imports of @aos/kernel / @aos/providers; the adapter test runs in the default suite; the server inserts no event itself.
  • MissionDto gains optional provider, model, effort (of the root run) — @aos/shared, additive.
  • native is removed from model_call step results and from step.completed events before they are sent to the browser (browser-view.ts).
  • StepDto is sent with an extra truncated: boolean. Server-local DTOs (SystemStatus, ArtifactDto, ModelInfoEx, UpdateProject, SteeringMessage) live in apps/server/src/dto.ts. SystemStatus = {collectedAt, version, db{ok,latencyMs}, worker{alive|null,lastSeenAt,source,workers[]}, providers{anthropic,openai}, counts{missionsQueued,missionsRunning,missionsWaiting,pendingWaits,wsClients}}.
  • WebSocket notices are system.notice events with id:-1, missionId:'': {code:'gap', message, latestId} and {code:'auth.expired'} (then close 4401). policy.decision events are replayed over the WebSocket by cursor only; there is no REST endpoint for them.
  • Extra routes: GET /system/audit, GET/PUT /settings, GET /notifications, POST /notifications/read, GET /openapi.json.

§9 Web. The timeline also shows tool calls the model issued that were never executed ("not executed"), derived from the model step's blocks minus the journaled steps; a step still started when its run has ended is shown "interrupted". The dev mock emits the real shapes listed here.

Files main
Menu

Theme