Skip to content
Merged
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
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4

- name: Use Node.js 20
# 22, not 20: the react-node blueprint's better-sqlite3 (13.x) requires
# Node >=22, and the conformance harness loads it in-process — on 20 that
# is a segfault, not an error.
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm

- name: Install dependencies
Expand Down Expand Up @@ -54,7 +57,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm -r build
Expand Down
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ apps/expense-tracker/
# both are removed on a clean stop.
**/.a2app/serve.json
**/.a2app/serve.lock
# `agent-app <dir> bridge` bookkeeping: the same three, for the app→agent
# service — its pid record, its lock, and the log its detached process writes.
**/.a2app/bridge.json
**/.a2app/bridge.lock
**/.a2app/bridge.log
# A lock broken after its holder died is renamed aside before being deleted
# (that rename is what makes the break atomic). On Windows the delete can lose a
# race with an open handle and leave the copy behind — inert, never read, and
# never something to commit.
**/.a2app/*.lock.breaking.*
**/data/db.json
**/data/a2app-state.json
a2app-state.json
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ a2app <app> planning cards # one entity: its fields and operations
a2app <app> planning cards <id> # one record, and what its state allows now
a2app <app> data cards create --title "Buy milk" --due tomorrow
a2app <app> --find invoice # search names, get locations
a2app <app> tasks next --wait 60000 # block until the app has work, claim it, print it
```

`<app>` is a directory, a registered id/name, or an `http(s)` URL (a URL operates a remote app, with its identity verified and pinned).
Expand All @@ -81,6 +82,7 @@ agent-app <dir> scaffold # framework files + ownership canon
agent-app <dir> validate # the validation + security gate
agent-app <dir> serve # launch via the manifest pipeline
agent-app <dir> dev / promote # safe-evolve: build on a hidden port, gate, promote with backup
agent-app <dir> bridge start # watch the app's task queue and trigger your harness
agent-app list # every app with its port and live status
```

Expand Down Expand Up @@ -108,6 +110,22 @@ Three pillars: **Describe** (the app publishes its own data model, operations, a

* * *

## 🔁 When the app asks the agent for work

The other direction. An app can put work in its own queue — a card fell overdue, a report is due, a user pressed "ask the agent" — and A2App carries that as an **event** and a **task**. What the protocol cannot do is make an agent turn up, because *triggering* is the one part of this that depends on the harness rather than on the app. The framework supplies that part, and it picks from a ladder, deepest integration first: an inbound endpoint the harness already exposes; the harness's one-shot headless CLI (`claude -p`, `codex exec`, `gemini -p`, `aider --message`) — universal, and the default; a local gateway the framework brings up itself; or, for a harness that can only poll, a listen command the harness runs in its own loop (`a2app <app> tasks next --wait`). When none of those exists, the framework says bi-directional operation is not supported here and names what would change it, rather than starting something that delivers nothing.

The app's side is one call. It declares the event types it may emit, and fires one with a capability and a few ids:

```js
trigger("invoice.needs_review", { invoice: rec.id }, "review")
```

An undeclared type is refused, so what an app can ever ask for is fixed by its author. There is no manifest of instructions to write and deliberately no way to send one: what the agent does is the agent's decision, and the payload reaches it fenced and labelled as data. Send ids — the agent re-reads the record, so a copy would be stale by the time it is read.

Run the other half with `agent-app <dir> bridge start`; `agent-app <dir> bridge` first shows which rung this machine lands on. A task is claimed before it is delivered, so one task runs once no matter how many agents are watching, and every task reaches a terminal state the app can report from. See [framework/cli/](framework/cli/#bridge-the-appagent-direction).

* * *

## 📂 Repository layout

| Directory | Contents |
Expand Down
2 changes: 1 addition & 1 deletion conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Runnable suites: language-agnostic YAML cases plus thin per-language harnesses.
|---|---|
| **A: Core** | discovery, describe, HTTP binding, guard, idempotency, read-back, receipts, security, IAM, errors — **required for a stable release**; skipping a class-A check fails class A |
| **B: CLI** | A + the `a2app` operate-client binding (exit codes, coercion, envelope passthrough) |
| **C: Bidirectional** | B (or A) + events, tasks (full lifecycle incl. cancel + sweeper redelivery), verbs, context |
| **C: Bidirectional** | B (or A) + events, tasks (full lifecycle incl. cancel + sweeper redelivery), the CLI's listen primitive (`tasks next`: claims the next task; an idle queue is exit 0 with no task, never an error), verbs, context |

## Framework artifact classes

Expand Down
6 changes: 5 additions & 1 deletion conformance/harness/src/reference-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ export function buildReferenceApp(): A2App {
params: { card: { type: "ref", entity: "cards", required: true } },
},
],
events: [{ type: "card.due_soon" }],
// Two declared types, because class C drives two independent task
// occurrences: one over HTTP through the full lifecycle, and one taken by
// the CLI's listen primitive. An undeclared type is refused at `trigger`,
// which is the app's own rule and worth exercising by obeying it.
events: [{ type: "card.due_soon" }, { type: "card.handoff" }],
credentialHint: "Read the app's .agent-token file (mode 0600).",
});
}
Expand Down
5 changes: 5 additions & 0 deletions conformance/harness/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ async function main(): Promise<number> {

// Seed a task so class C can drive the lifecycle over HTTP.
ref.app.trigger({ type: "card.due_soon", payload: { card: "card_seed" }, capability: "summarize" });
// And a second, with its own capability, so class C can also exercise the
// CLI's listen primitive (`tasks next`) without racing the HTTP checks for
// the first one. Distinct capability on purpose: the check asserts which task
// it was handed, which a duplicate capability could not tell apart.
ref.app.trigger({ type: "card.handoff", payload: { card: "card_seed" }, capability: "handoff" });

const results: SuiteResult[] = [];
const files = readdirSync(SUITES_DIR).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
Expand Down
20 changes: 20 additions & 0 deletions conformance/suites/class-c-bidi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,23 @@ checks:
expect:
status: 404
match: { code: task_not_found }

# --- the listen primitive -------------------------------------------------
# `tasks next` is how a harness that cannot be triggered from outside still
# takes work: it waits for a claimable task, claims it, prints it, exits. The
# two checks below pin its whole contract, and the second is the one that is
# easy to get wrong.
- name: tasks next takes the next claimable task and claims it
cli: { args: ["<APPDIR>", "tasks", "next", "--capability", "handoff", "--wait", "2000"] }
expect:
exit: 0
match: { claimed: true, task.request.capability: handoff, task.status: working }

- name: an idle queue is exit 0 with no task, never an error
# A listen loop is idle almost all the time. Reporting "nothing to do" as a
# failure would make it indistinguishable from "the app is down" (exit 3),
# which is the one that needs a human.
cli: { args: ["<APPDIR>", "tasks", "next", "--capability", "handoff", "--wait", "300"] }
expect:
exit: 0
match: { task: null, claimed: false }
2 changes: 1 addition & 1 deletion framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Implements the Agent App Framework: the framework CLI (enforcement) and an optio

| Package | npm name | Purpose |
|---|---|---|
| [cli/](cli/) | `agent-app` | Two binaries, one package (spec 5.1). `agent-app`: `scaffold`, `validate`, `toolkit-sync`, `adapter-sync`, `serve`/`stop`, `list`, `global`, `skills`, plus safe-evolve `dev`/`promote`/`backup`/`restore` (walk-verify is a skill run by a verifier agent, not a command). `a2app`: the operate client — a WALK (`a2app <app> [<path…>] [<operation>]`), plus the reserved segments `identity`, `data`, `whoami`, `context`, `tasks`, `events`. No `ops`/`run`: an operation is found on the screen it belongs to and invoked there. Exit codes 0/1/2/3, machine-readable stdout. |
| [cli/](cli/) | `agent-app` | Two binaries, one package (spec 5.1). `agent-app`: `scaffold`, `validate`, `toolkit-sync`, `adapter-sync`, `serve`/`stop`, `list`, `global`, `skills`, `bridge` (the app→agent direction: watch the task queue, trigger the harness), plus safe-evolve `dev`/`promote`/`backup`/`restore` (walk-verify is a skill run by a verifier agent, not a command). `a2app`: the operate client — a WALK (`a2app <app> [<path…>] [<operation>]`), plus the reserved segments `identity`, `data`, `whoami`, `context`, `tasks`, `events`. No `ops`/`run`: an operation is found on the screen it belongs to and invoked there. Exit codes 0/1/2/3, machine-readable stdout. |
| [host/](host/) | `@craftos/agent-app-host` | Optional reference host: launches apps via the manifest `pipeline` block, polls health, runs adapter-sync at every launch, and may add host features (watchdog, build supervision, receipts voice). **A bare agent plus a browser is a complete environment — nothing here is required for compliance.** |

Conformance is proven by the suite ([../conformance/](../conformance/)), never by code review.
58 changes: 57 additions & 1 deletion framework/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,65 @@ Reserved protocol segments (never module names): `data`, `identity`, `whoami`, `
| `agent-app <dir> toolkit-sync` / `adapter-sync` | Re-vendor system files / deliver the adapter, re-record hashes |
| `agent-app <dir> serve` / `stop` | Launch via the manifest pipeline as a health-polled background process, and stop it |
| `agent-app <dir> dev` / `promote` / `backup` / `restore` | Safe-evolve: boot the candidate on a hidden port with a fresh migration-replayed DB → gate + verify → promote with a pre-promote backup |
| `agent-app list` | Every known Agent App with its port and live status (`--json`, `--prune`) |
| `agent-app list` | Every known Agent App with its port and live status, plus a live dev instance or bridge on the app's own row (`--json`, `--prune`) |
| `agent-app global` | The user's cross-app conventions (`GLOBAL_AGENT_APP.md`) |
| `agent-app skills [--install <dir>]` | List the framework skills, or install them into a harness |
| `agent-app <dir> bridge [status\|start\|stop]` | The app→agent direction: watch this app's task queue, claim what appears, and trigger an agent harness by the deepest route it offers — see [Bridge](#bridge-the-appagent-direction) |

* * *

## Bridge: the app→agent direction

A2App gives an app a queue it can put work in (`tasks`, `events`). Nothing in the protocol makes an agent turn up to take it, because *triggering* is the one part that depends on the harness rather than on the app. `bridge` is that part — a per-app, opt-in background service on the machine where the harness lives.

It picks a route by walking a ladder, deepest integration first, and reports which rung it landed on:

| Rung | Route | When it is used |
|---|---|---|
| 1 | `inbound` | the harness already serves an HTTP endpoint that starts a run — deepest, and rare |
| 2 | `headless` | the harness has a one-shot headless CLI (`claude -p`, `codex exec`, `gemini -p`, `aider --message`). Universal, and the **default**: the built-ins below describe it, so most machines land here with nothing configured |
| 3 | `gateway` | the harness is triggered through a local gateway that is not up yet; the bridge starts it and posts to it |
| 4 | `subscribe` | the harness cannot be triggered, only poll. `bridge start` then hands over the listen command instead of daemonizing: `a2app <app> tasks next --wait 60000` |
| 5 | — | none of the above is available. Bi-directional operation is **not supported here**, and `bridge start` says so and names what would change it — it never starts and silently delivers nothing |

```bash
agent-app <dir> bridge # which rung, what is running, how much is waiting
agent-app <dir> bridge start # background service (pid + log in .a2app/)
agent-app <dir> bridge start --once # drain what is claimable now and exit (cron, CI)
agent-app <dir> bridge start --dry-run # print the prompt a task would produce; run nothing
agent-app <dir> bridge start --foreground # run the loop here; Ctrl-C stops after the current task
agent-app <dir> bridge stop
```

Flags: `--harness <id>`, `--interval <ms>` (default 5000), `--task-timeout <ms>` (default 15 min), `--capability <name>` (repeatable — deliver only these).

**Harness profiles** live in `$A2APP_HOME/harnesses.json` (`~/.a2app/harnesses.json`). `claude`, `codex`, `gemini` and `aider` are built in; an entry with the same `id` replaces a built-in outright rather than merging into it, so what the file says is what runs.

```jsonc
{
"version": 1,
"default": "claude",
"harnesses": [
{ "id": "myharness", "routes": [
{ "mode": "inbound", "url": "http://127.0.0.1:7000/run", "health": "http://127.0.0.1:7000/up", "tokenEnv": "MYHARNESS_TOKEN" },
{ "mode": "headless", "command": "myharness", "args": ["run", "--prompt", "{prompt}"], "timeoutMs": 900000 }
] }
]
}
```

A profile lists whichever routes its harness offers, in any order; the ladder decides which one is used. `tokenEnv` names the environment variable holding a token — never the token itself, which would be a credential at rest in a hand-edited file.

A `gateway` route needs `health` as well as `start`: without it there is no way to tell a gateway that is already up from one that needs starting, and starting a second copy of a running one is how ports get fought over. A gateway is a service, so it outlives the pass that started it and later runs reuse it — `bridge stop` takes down only a gateway owned by a long-running `bridge start`, and `--once` says the pid of any gateway it had to start.

**What the bridge guarantees.**

- **One run per task.** A task is claimed before it is delivered, so two bridges — or a bridge and a harness running `tasks next` — can watch one queue and each task still runs once. Whoever claims first owns it; everyone else gets 409 and moves on. The one hole is the app's own safety net: a task handed over an HTTP route stays `working`, and a harness that never reports has it returned to the queue after 60s and delivered again. Suppressing that would strand every task a dead agent was holding, so it is allowed — but the bridge remembers what it handed over and **says so loudly** when one comes back, naming the fix (the harness must call `tasks progress`). A duplicate run nobody is told about is the failure worth preventing.
- **The claim stays alive.** The adapter sweeps a `working` task back to `submitted` after 60s without an update, so a dead agent's work is redelivered. A real run takes minutes, so the bridge sends progress heartbeats while a headless run is in flight. A heartbeat the app refuses means the claim was lost, and the run is killed rather than allowed to finish against a task somebody else now owns.
- **Tasks are closed only where that is knowable.** A headless run is over when the process exits, so the task is closed from its exit code — unless the harness already closed it itself, in which case the harness's own result stands, `input-required` included. An HTTP trigger is different: a 2xx is an *acknowledgement*, the run happens elsewhere, and the task is deliberately left open for the harness to close. A trigger that is *refused* does fail the task, because nothing started.
- **The payload is data.** It is fenced with a per-delivery nonce (so a payload cannot close its own fence), labelled as data rather than instructions, capped with a pointer to `tasks get <id>` for the rest, and the harness is spawned with **no shell** and an argument array — a record titled `"; rm -rf ~` arrives as a title.
- **Nothing starts on its own.** A bridge is per-app and explicitly started. `--dry-run` shows the exact prompts a run would produce and claims nothing.
- **It is quiet enough to leave running.** A bridge writes to `.a2app/bridge.log`, which nothing rotates, so repetition is the enemy: a task filtered out by `--capability` is announced once rather than every poll, and an app that goes down is complained about once (with one line when it comes back) rather than every few seconds. `agent-app list` marks apps with a live bridge as `bridge:<harness>/<route>`, so "which of my apps can start agent runs?" is answerable without visiting each one.

* * *

Expand Down
2 changes: 1 addition & 1 deletion framework/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs && node test/containment.test.mjs && node test/connect.test.mjs",
"test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs && node test/containment.test.mjs && node test/connect.test.mjs && node test/bridge.test.mjs",
"bundle-blueprints": "node scripts/bundle-blueprints.mjs",
"bundle-skills": "node scripts/bundle-skills.mjs",
"bundle": "node scripts/bundle-blueprints.mjs && node scripts/bundle-skills.mjs",
Expand Down
6 changes: 6 additions & 0 deletions framework/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ const COMMANDS: Record<string, CommandMeta> = {
serve: { summary: "Launch the app via its manifest pipeline as a managed background process (health-polled)", surface: "agent-app", scope: "app" },
stop: { summary: "Stop an app launched with `agent-app <app> serve` (--dev stops the dev instance instead)", surface: "agent-app", scope: "app", args: "[--dev]" },
open: { summary: "Open a running app in a browser (--if-needed opens only when no tab is already on it)", surface: "agent-app", scope: "app", args: "[--if-needed] [--print-only]" },
bridge: {
summary: "Watch this app's task queue and trigger an agent harness when work appears (the app→agent direction)",
surface: "agent-app",
scope: "app",
args: "[status|start|stop] [--harness <id>] [--once] [--dry-run]",
},
dev: { summary: "Boot the candidate on a hidden port with a fresh, migration-replayed database; operate commands target it until promote", surface: "agent-app", scope: "app" },
promote: { summary: "Require a fresh gate pass, take the pre-promote backup, apply the new migrations to live, destroy the dev instance", surface: "agent-app", scope: "app" },
backup: { summary: "Take an explicit backup of the live database", surface: "agent-app", scope: "app" },
Expand Down
Loading
Loading