diff --git a/framework/cli/README.md b/framework/cli/README.md
index a7cca4e..271b6d8 100644
--- a/framework/cli/README.md
+++ b/framework/cli/README.md
@@ -79,6 +79,38 @@ agent-app
bridge start --foreground # run the loop here; Ctrl-C stops af
agent-app bridge stop
```
+**Launch and handoff readiness.** Both a fresh `serve` and an already-serving
+result inspect the live app. An app whose code queues agent work gets a
+prominent warning and the exact `bridge start` command if no local bridge is
+running. `serve` still returns success for a healthy server and does not start
+agent runs automatically. Its JSON adds `agentWork`: `queuesAgentWork`, `state`
+(`absent`, `stale`, `running`), `running`, `tasksWaiting`, the live `baseUrl` and
+`bridgeTargetsLive` (true/false, or null for an unknown recorded endpoint).
+`list --json` and `bridge` expose the same inspection; bridge's existing
+`running` and `tasksWaiting` fields remain available. These reads always inspect
+live even when operate commands target a recorded dev instance.
+New bridge records include the endpoint actually selected at startup. A bridge
+still watching dev, or a legacy record with an unknown endpoint, is flagged by
+serve/list/bridge. Restart it after dev is gone before handing off live work.
+
+`list` marks queueing apps as `bridge:missing` or `bridge:stale` and shows waiting
+work. `tasksWaiting` is the count observed in the submitted-task poll, not a
+guaranteed total; `null`/`waiting:unknown` means the queue could not be read.
+Identity is verified before sending the agent credential, and network probes
+have short deadlines including body reads. Detection reuses the static trigger
+scanner: dynamic/custom enqueue code may be missed, so observed waiting work
+also makes an app relevant. Adapter queue support alone does not imply that an
+app queues work.
+
+A missing local bridge does not rule out an external harness polling the queue.
+A live PID or available harness route does not prove successful delivery.
+Creator/modify finish queueing features with a live delivery check and, where
+authorized, a detached `bridge start` that remains up after the launching
+command exits. A one-shot or interactive foreground test is not that handoff.
+`stop` leaves the bridge running and reports how to stop it separately; it can
+resume polling when the app is served again. Reboot startup and crash supervision
+are not provided by this check.
+
Flags: `--harness `, `--interval ` (default 5000), `--task-timeout ` (default 15 min), `--capability ` (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.
diff --git a/framework/cli/package.json b/framework/cli/package.json
index f676470..c529042 100644
--- a/framework/cli/package.json
+++ b/framework/cli/package.json
@@ -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 && node test/bridge.test.mjs && node test/agent-state.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 && node test/agent-state.test.mjs && node test/delivery.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",
diff --git a/framework/cli/src/commands/bridge.ts b/framework/cli/src/commands/bridge.ts
index 3bb8421..c77ce00 100644
--- a/framework/cli/src/commands/bridge.ts
+++ b/framework/cli/src/commands/bridge.ts
@@ -30,7 +30,6 @@ import {
bridgeLockPath,
bridgeLogPath,
clearBridgeRecord,
- countWaiting,
DEFAULT_INTERVAL_MS,
DEFAULT_TASK_TIMEOUT_MS,
logTail,
@@ -47,16 +46,16 @@ import { isPidAlive, killTreeForce, terminateTree, waitForExit } from "../lib/pr
import { loadProject, UsageError, type Project } from "../lib/project.js";
import { connect } from "../lib/target.js";
import { log } from "../lib/log.js";
+import { appCommand, inspectDelivery, inspectLocalBridge, reportDelivery, type RunningBridge } from "../lib/delivery.js";
/** A bridge that is recorded AND actually alive. A record whose process is gone
* is not a running bridge — it is a leftover, and treating it as one would make
* `start` refuse forever after a crash. */
-function runningBridge(dir: string): { pid: number; harness: string; mode: string; startedAt: string } | null {
- const rec = readBridgeRecord(dir);
+function runningBridge(dir: string): RunningBridge | null {
+ const rec = inspectLocalBridge(dir).running;
if (rec === null) return null;
if (rec.pid === process.pid) return null;
- if (!isPidAlive(rec.pid)) return null;
- return { pid: rec.pid, harness: rec.harness, mode: rec.mode, startedAt: rec.startedAt };
+ return rec;
}
/** The five rungs as a person needs to read them when nothing worked. */
@@ -80,7 +79,8 @@ function explainNoRoute(app: string, ladder: RungReport[]): void {
async function status(args: string[], app: string, project: Project): Promise {
const loaded = loadHarnesses();
const selection = await selectProfile(loaded, flag(args, "harness"));
- const running = runningBridge(project.dir);
+ const agentWork = await inspectDelivery(project.dir, project.manifest.id, project.baseUrl);
+ const running = agentWork.running;
let ladder: RungReport[] = [];
let mode: string | null = null;
@@ -90,14 +90,6 @@ async function status(args: string[], app: string, project: Project): Promise p.id),
};
@@ -117,8 +110,11 @@ async function status(args: string[], app: string, project: Project): Promise 0) log.info(`${waiting} task(s) waiting in the queue`);
+ else log.info(`route available: ${selection.profile.id} via ${mode} (${selection.why}); this does not verify delivery`);
+ reportDelivery(agentWork, project.dir);
+ if (!agentWork.queuesAgentWork && agentWork.state === "absent") {
+ log.info(agentWork.tasksWaiting === null ? "waiting work: unknown (live queue could not be read)" : `${agentWork.tasksWaiting} task(s) waiting in the live queue`);
+ }
for (const line of logTail(project.dir)) log.info(` log: ${line}`);
log.raw(JSON.stringify(report, null, 2));
@@ -354,6 +350,7 @@ async function start(args: string[], app: string, project: Project): Promise {
// That task is not lost: it stays `working` until the app's own sweeper
// returns it to the queue, which is the same path a crashed agent takes.
log.info(
- `a task being worked on right now was interrupted; the app returns it to the queue by itself.\n` +
- `The app keeps queueing tasks either way — nothing will claim them until: agent-app ${app} bridge start`,
+ "Any task interrupted by this stop can return to the queue after its claim expires.\n" +
+ `The app can still queue work. Resume this local listener: ${appCommand(project.dir, "bridge start")}\n` +
+ "Another harness may be polling the queue independently.",
);
log.raw(JSON.stringify({ ok: true, stopped: rec.pid }, null, 2));
return 0;
diff --git a/framework/cli/src/commands/list.ts b/framework/cli/src/commands/list.ts
index 6421d3a..6bc26e9 100644
--- a/framework/cli/src/commands/list.ts
+++ b/framework/cli/src/commands/list.ts
@@ -11,6 +11,7 @@ import { hasFlag } from "../lib/args.js";
import { list, prune } from "../lib/registry.js";
import { registryPath } from "../lib/registry.js";
import { log } from "../lib/log.js";
+import { appCommand } from "../lib/delivery.js";
export async function run(args: string[]): Promise {
if (hasFlag(args, "prune")) {
@@ -41,13 +42,25 @@ export async function run(args: string[]): Promise {
// row — an abandoned candidate that only lived in `.a2app/dev.json` was
// invisible here, which is exactly how it stayed abandoned.
const dev = app.dev === null ? "" : app.dev.answering ? ` dev:${app.dev.port}` : " dev:stale";
- // A running bridge means this app can start agent runs on this machine.
- // That is a standing capability, not a detail of one command, so it belongs
- // where someone looks to see what their apps are doing.
- const bridge = app.bridge === null ? "" : ` bridge:${app.bridge.harness}/${app.bridge.mode}`;
+ // Keep local process state separate from app health and queue observations.
+ const work = app.agentWork;
+ const relevant = work !== null && (work.queuesAgentWork || work.state !== "absent");
+ const bridge = app.bridge !== null ? ` bridge:${app.bridge.harness}/${app.bridge.mode}`
+ : relevant ? ` bridge:${work.state === "stale" ? "stale" : "missing"}` : "";
+ const waiting = relevant ? ` waiting:${work.tasksWaiting ?? "unknown"}` : "";
+ const target = app.bridge !== null && work !== null && work.bridgeTargetsLive !== true
+ ? ` bridge-target:${work.bridgeTargetsLive === false ? "other" : "unknown"}` : "";
log.raw(
- `${mark[app.status]} ${app.name.padEnd(width)} ${app.id} :${port.padEnd(5)} ${app.status.padEnd(11)} ${where}${dev}${bridge}`,
+ `${mark[app.status]} ${app.name.padEnd(width)} ${app.id} :${port.padEnd(5)} ${app.status.padEnd(11)} ${where}${dev}${bridge}${waiting}${target}`,
);
+ if (relevant && app.bridge === null) {
+ log.warn(`"${app.name}": ${work.tasksWaiting === null ? "waiting work unknown" : `${work.tasksWaiting} waiting task(s) observed`}; no local bridge. ` +
+ `Delivery is unverified; an external harness may be polling. Start: ${appCommand(app.path, "bridge start")} · diagnose: ${appCommand(app.path, "bridge")}`);
+ }
+ if (target) {
+ log.warn(`"${app.name}": the local bridge ${work?.bridgeTargetsLive === false ? "targets a different endpoint from live" : "has no recorded endpoint"}. ` +
+ `Check ${appCommand(app.path, "bridge")} before treating agent work as operational.`);
+ }
}
if (apps.some((a) => a.status === "unreachable")) {
log.warn("▲ unreachable: another process holds that app's port — stop it, or give the app a different port.");
@@ -63,7 +76,7 @@ export async function run(args: string[]): Promise {
}
if (apps.some((a) => a.bridge !== null)) {
log.info(
- "bridge:/ — that app's queue is being watched and can start agent runs here. " +
+ "bridge:/ — a local bridge process is running; its PID does not verify task delivery. " +
"`agent-app bridge` for detail · `agent-app bridge stop` to end it.",
);
}
diff --git a/framework/cli/src/commands/serve.ts b/framework/cli/src/commands/serve.ts
index 82cc132..0023971 100644
--- a/framework/cli/src/commands/serve.ts
+++ b/framework/cli/src/commands/serve.ts
@@ -34,6 +34,7 @@ import { identifyApp, pollHealth } from "../lib/net.js";
import { killTreeForce, spawnBackgroundShell } from "../lib/proc.js";
import { runShell } from "../lib/shell.js";
import { log } from "../lib/log.js";
+import { inspectDelivery, reportDelivery } from "../lib/delivery.js";
function readServe(file: string): { pid: number } | null {
try {
@@ -86,6 +87,8 @@ export async function run(args: string[], app: string): Promise {
if (servingId === project.manifest.id) {
const prev = readServe(servePath);
log.ok(`already serving on ${project.baseUrl}${prev ? ` (pid ${prev.pid})` : ""}`);
+ const agentWork = await inspectDelivery(project.dir, project.manifest.id, project.baseUrl);
+ reportDelivery(agentWork, project.dir);
const shown = await show();
log.raw(
JSON.stringify(
@@ -96,6 +99,7 @@ export async function run(args: string[], app: string): Promise {
url: project.baseUrl,
pid: prev?.pid ?? null,
alreadyRunning: true,
+ agentWork,
...shown,
},
null,
@@ -201,6 +205,8 @@ export async function run(args: string[], app: string): Promise {
"\n",
);
log.ok(`serving "${project.manifest.name}" on ${project.baseUrl} (pid ${pid})`);
+ const agentWork = await inspectDelivery(project.dir, project.manifest.id, project.baseUrl);
+ reportDelivery(agentWork, project.dir);
// A relaunch after a code change is the moment a tab opened earlier goes
// stale, and the person looking at it has no way to know. The View's update
// watcher tells them; say so here so the loop is visible from the terminal
@@ -221,7 +227,7 @@ export async function run(args: string[], app: string): Promise {
const shown = await show();
log.raw(
JSON.stringify(
- { ok: true, id: project.manifest.id, name: project.manifest.name, url: project.baseUrl, pid, port, ...shown },
+ { ok: true, id: project.manifest.id, name: project.manifest.name, url: project.baseUrl, pid, port, agentWork, ...shown },
null,
2,
),
diff --git a/framework/cli/src/commands/stop.ts b/framework/cli/src/commands/stop.ts
index f066ce2..47e85cb 100644
--- a/framework/cli/src/commands/stop.ts
+++ b/framework/cli/src/commands/stop.ts
@@ -18,6 +18,7 @@ import { withLock } from "../lib/lock.js";
import { identifyApp } from "../lib/net.js";
import { isPidAlive, killTreeForce, terminateTree, waitForExit } from "../lib/proc.js";
import { log } from "../lib/log.js";
+import { appCommand, inspectLocalBridge } from "../lib/delivery.js";
/**
* `stop --dev`: tear down the DEV instance (abandoning the candidate) and
@@ -65,6 +66,14 @@ export async function run(args: string[], app: string): Promise {
return withLock(serveLock, async () => {
const servePath = join(project.dir, ".a2app", "serve.json");
+ const reportStopped = (stopped: number | null): void => {
+ const bridge = inspectLocalBridge(project.dir);
+ if (bridge.running !== null) {
+ log.info(`the local bridge remains running (pid ${bridge.running.pid}) and continues polling; ` +
+ `it can resume when the app returns. Stop it separately: ${appCommand(project.dir, "bridge stop")}`);
+ }
+ log.raw(JSON.stringify({ ok: true, stopped, bridge }, null, 2));
+ };
const clearRecord = async (): Promise => {
rmSync(servePath, { force: true });
@@ -84,7 +93,7 @@ export async function run(args: string[], app: string): Promise {
if (!existsSync(servePath)) {
log.info("not serving (no .a2app/serve.json)");
- log.raw(JSON.stringify({ ok: true, stopped: null }, null, 2));
+ reportStopped(null);
return 0;
}
@@ -100,7 +109,7 @@ export async function run(args: string[], app: string): Promise {
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 1) {
log.warn(`serve.json has no usable pid — clearing the record without killing anything.`);
await clearRecord();
- log.raw(JSON.stringify({ ok: true, stopped: null }, null, 2));
+ reportStopped(null);
return 0;
}
@@ -115,13 +124,13 @@ export async function run(args: string[], app: string): Promise {
`this app is not answering on port ${appPort} (recorded pid ${pid}) — clearing a stale serve record without killing.`,
);
await clearRecord();
- log.raw(JSON.stringify({ ok: true, stopped: null }, null, 2));
+ reportStopped(null);
return 0;
}
} else if (!isPidAlive(pid)) {
log.info(`recorded process (pid ${pid}) is not running — clearing stale record.`);
await clearRecord();
- log.raw(JSON.stringify({ ok: true, stopped: null }, null, 2));
+ reportStopped(null);
return 0;
}
@@ -143,7 +152,7 @@ export async function run(args: string[], app: string): Promise {
await clearRecord();
log.ok(`stopped (pid ${pid})`);
- log.raw(JSON.stringify({ ok: true, stopped: pid }, null, 2));
+ reportStopped(pid);
return 0;
});
}
diff --git a/framework/cli/src/lib/bridge.ts b/framework/cli/src/lib/bridge.ts
index 878414e..ab349f4 100644
--- a/framework/cli/src/lib/bridge.ts
+++ b/framework/cli/src/lib/bridge.ts
@@ -88,6 +88,8 @@ export interface BridgeRecord {
mode: string;
intervalMs: number;
startedAt: string;
+ /** Endpoint fixed at startup; a dev bridge must not be mistaken for live. */
+ baseUrl?: string;
/** pid of a gateway this bridge started, so `stop` can take it down too */
gatewayPid?: number;
}
@@ -111,13 +113,14 @@ export function readBridgeRecord(dir: string): BridgeRecord | null {
if (!existsSync(file)) return null;
try {
const raw = readJsonFile>(file);
- if (typeof raw.pid !== "number") return null;
+ if (typeof raw.pid !== "number" || !Number.isInteger(raw.pid) || raw.pid <= 1) return null;
return {
pid: raw.pid,
- harness: raw.harness ?? "unknown",
- mode: raw.mode ?? "unknown",
+ harness: typeof raw.harness === "string" ? raw.harness : "unknown",
+ mode: typeof raw.mode === "string" ? raw.mode : "unknown",
intervalMs: raw.intervalMs ?? DEFAULT_INTERVAL_MS,
- startedAt: raw.startedAt ?? "",
+ startedAt: typeof raw.startedAt === "string" ? raw.startedAt : "",
+ ...(typeof raw.baseUrl === "string" ? { baseUrl: raw.baseUrl } : {}),
...(typeof raw.gatewayPid === "number" ? { gatewayPid: raw.gatewayPid } : {}),
};
} catch {
@@ -802,11 +805,16 @@ export async function pumpLoop(ctx: BridgeContext, intervalMs: number, stopped:
}
/** How many tasks are waiting right now — used by `bridge` status. */
-export async function countWaiting(client: A2AppClient): Promise {
+export async function countWaiting(client: Pick): Promise {
try {
const res = await client.pollTasks("submitted");
if (!res.ok) return null;
- return parseTasks(res.json).length;
+ const body = res.json as { tasks?: unknown } | null;
+ if (!body || !Array.isArray(body.tasks)) return null;
+ const tasks = parseTasks(body);
+ // A malformed success envelope is not evidence of an empty queue.
+ if (tasks.length !== body.tasks.length) return null;
+ return tasks.length;
} catch {
return null;
}
diff --git a/framework/cli/src/lib/delivery.ts b/framework/cli/src/lib/delivery.ts
new file mode 100644
index 0000000..c122642
--- /dev/null
+++ b/framework/cli/src/lib/delivery.ts
@@ -0,0 +1,133 @@
+/** Read-only launch/handoff inspection. A local bridge PID is not proof of
+ * delivery, and its absence does not rule out an external queue subscriber. */
+import { existsSync } from "node:fs";
+import type { A2AppResponse } from "@a2app/sdk";
+import { scanAgentState } from "./agentState.js";
+import { bridgeRecordPath, countWaiting, readBridgeRecord, type BridgeRecord } from "./bridge.js";
+import { isPidAlive } from "./proc.js";
+import { readAgentToken } from "./project.js";
+import { log } from "./log.js";
+
+export type RunningBridge = Pick;
+export interface LocalBridge {
+ state: "absent" | "stale" | "running";
+ running: RunningBridge | null;
+}
+
+export function inspectLocalBridge(dir: string): LocalBridge {
+ const rec = readBridgeRecord(dir);
+ if (rec === null || !isPidAlive(rec.pid)) {
+ return { state: existsSync(bridgeRecordPath(dir)) ? "stale" : "absent", running: null };
+ }
+ return {
+ state: "running",
+ running: { pid: rec.pid, harness: rec.harness, mode: rec.mode, startedAt: rec.startedAt, ...(rec.baseUrl ? { baseUrl: rec.baseUrl } : {}) },
+ };
+}
+
+export interface DeliveryInspection extends LocalBridge {
+ /** Static trigger detection or observed submitted work; not queue support alone.
+ * Dynamic/custom enqueue implementations may not be found by the scanner. */
+ queuesAgentWork: boolean;
+ /** Number observed in the poll response; null means unavailable, never zero. */
+ tasksWaiting: number | null;
+ /** Always the live endpoint, even when operate commands target dev. */
+ baseUrl: string;
+ /** null for legacy records without an endpoint, or no local process. */
+ bridgeTargetsLive: boolean | null;
+}
+
+/** The SDK timeout currently bounds response headers, not a stalled body.
+ * Status probes need a deadline through the entire read, without leaving an
+ * abandoned fetch alive after the caller has returned. */
+async function readReply(baseUrl: string, path: string, timeoutMs: number, token: string | null = null): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const res = await fetch(`${baseUrl}${path}`, {
+ signal: controller.signal,
+ redirect: "error",
+ headers: { "X-A2App-Agent": "agent-app-status", ...(token ? { "X-A2App-Token": token } : {}) },
+ });
+ const reader = res.body?.getReader();
+ const decoder = new TextDecoder();
+ let body = "";
+ let bytes = 0;
+ if (reader) {
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ bytes += value.byteLength;
+ if (bytes > 1024 * 1024) { controller.abort(); throw new Error("status response exceeds 1 MiB"); }
+ body += decoder.decode(value, { stream: true });
+ }
+ body += decoder.decode();
+ } finally { reader.releaseLock(); }
+ }
+ return { status: res.status, ok: res.ok, body, json: JSON.parse(body) };
+ } finally { clearTimeout(timer); }
+}
+
+/** At most two bounded requests, including response-body reads. Nothing is
+ * claimed, started, cleared or written. Credentials go only to the verified app. */
+export async function inspectDelivery(
+ dir: string,
+ appId: string,
+ baseUrl: string,
+ timeoutMs = 750,
+): Promise {
+ const local = inspectLocalBridge(dir);
+ let queuesAgentWork = false;
+ try {
+ queuesAgentWork = scanAgentState(dir).triggers.length > 0;
+ } catch {
+ // A malformed/unreadable canon must not turn a status courtesy into a
+ // failed launch. A queue read can still establish that work is waiting.
+ }
+ let tasksWaiting: number | null = null;
+ try {
+ const identity = await readReply(baseUrl, "/api/_a2app", timeoutMs);
+ const body = identity.json as { a2app?: boolean; app?: { id?: string } } | null;
+ if (identity.ok && body?.a2app === true && body.app?.id === appId) {
+ tasksWaiting = await countWaiting({
+ pollTasks: () => readReply(baseUrl, "/api/_a2app/tasks?status=submitted", timeoutMs, readAgentToken(dir)),
+ });
+ }
+ } catch {
+ // Missing credential, stopped app, wrong identity or failed queue read:
+ // the queue state is unknown, not an empty queue.
+ }
+ const bridgeTargetsLive = local.running?.baseUrl ? local.running.baseUrl === baseUrl : null;
+ return { ...local, queuesAgentWork: queuesAgentWork || (tasksWaiting ?? 0) > 0, tasksWaiting, baseUrl, bridgeTargetsLive };
+}
+
+/** A copyable command, including paths with spaces/apostrophes. */
+export function appCommand(dir: string, command: string): string {
+ const quoted = process.platform === "win32"
+ ? `'${dir.replace(/'/g, "''")}'`
+ : `'${dir.replace(/'/g, "'\\''")}'`;
+ return `agent-app ${quoted} ${command}`;
+}
+
+export function reportDelivery(info: DeliveryInspection, dir: string): void {
+ if (!info.queuesAgentWork && info.state === "absent") return;
+ if (info.running !== null) {
+ log.info(`local bridge process running (pid ${info.running.pid}) — ${info.running.harness}/${info.running.mode}; task delivery is not verified by process liveness`);
+ if (info.bridgeTargetsLive !== true) {
+ log.warn(
+ `AGENT WORK DELIVERY NOT VERIFIED: ${info.bridgeTargetsLive === false ? `the local bridge watches ${info.running.baseUrl}, rather than live at ${info.baseUrl}` : "the recorded bridge endpoint is unknown"}.\n` +
+ ` After dev is gone, restart it for live: ${appCommand(dir, "bridge stop")} then ${appCommand(dir, "bridge start")}`,
+ );
+ }
+ } else {
+ log.warn(
+ `AGENT WORK DELIVERY NOT VERIFIED: this app ${info.queuesAgentWork ? "queues agent work" : "has a stale bridge record"}, ` +
+ `but no local bridge is running${info.state === "stale" ? " (stale record)" : ""}. Tasks may remain unclaimed.\n` +
+ ` Start a background bridge: ${appCommand(dir, "bridge start")}\n` +
+ ` Check the harness route: ${appCommand(dir, "bridge")}\n` +
+ " A harness polling the queue independently is also valid; no local bridge does not prove no external listener.",
+ );
+ }
+ log.info(info.tasksWaiting === null ? "waiting work: unknown (live queue could not be read)" : `${info.tasksWaiting} waiting task(s) observed in the live queue`);
+}
diff --git a/framework/cli/src/lib/registry.ts b/framework/cli/src/lib/registry.ts
index 1769517..a9ffdd9 100644
--- a/framework/cli/src/lib/registry.ts
+++ b/framework/cli/src/lib/registry.ts
@@ -27,6 +27,7 @@ import { homePath, REGISTRY_FILE, writeFileAtomic } from "./home.js";
import { readDevRecord } from "./instance.js";
import { withHomeLock } from "./lock.js";
import { log } from "./log.js";
+import { inspectDelivery, type DeliveryInspection } from "./delivery.js";
export const REGISTRY_VERSION = 1;
@@ -78,6 +79,8 @@ export interface AppView extends RegistryEntry {
* each one, and a bridge someone started weeks ago is invisible.
*/
bridge: { pid: number; harness: string; mode: string } | null;
+ /** Read-only delivery inspection of live, independent of dev routing. */
+ agentWork: DeliveryInspection | null;
}
export function registryPath(): string {
@@ -276,30 +279,6 @@ function serveRecord(appDir: string): { pid: number; port: number; url: string }
}
}
-/**
- * The bridge record for an app, when its process is still alive.
- *
- * A record whose pid is gone is a leftover, not a bridge — reporting one would
- * tell a user their app can reach an agent when nothing is watching its queue.
- */
-function bridgeRecord(appDir: string): AppView["bridge"] {
- const file = join(appDir, ".a2app", "bridge.json");
- if (!existsSync(file)) return null;
- try {
- const rec = JSON.parse(readFileSync(file, "utf8")) as { pid?: number; harness?: string; mode?: string };
- if (typeof rec.pid !== "number") return null;
- try {
- process.kill(rec.pid, 0);
- } catch (err) {
- // EPERM means it exists but is not ours to signal; anything else means gone.
- if ((err as NodeJS.ErrnoException).code !== "EPERM") return null;
- }
- return { pid: rec.pid, harness: rec.harness ?? "unknown", mode: rec.mode ?? "unknown" };
- } catch {
- return null;
- }
-}
-
/** The app's own manifest, when readable — authoritative over the entry. */
function manifestOf(appDir: string): { id?: string; name?: string; port?: number } | null {
const file = join(appDir, "manifest.json");
@@ -326,7 +305,7 @@ async function devView(dir: string, appId: string): Promise {
export async function view(entry: RegistryEntry): Promise {
const dir = resolve(entry.path);
if (!existsSync(join(dir, "manifest.json"))) {
- return { ...entry, status: "missing", url: null, pid: null, dev: null, bridge: null };
+ return { ...entry, status: "missing", url: null, pid: null, dev: null, bridge: null, agentWork: null };
}
const manifest = manifestOf(dir);
const port = manifest?.port ?? entry.port;
@@ -336,15 +315,19 @@ export async function view(entry: RegistryEntry): Promise {
name: manifest?.name ?? entry.name,
...(port !== undefined ? { port } : {}),
};
- const dev = await devView(dir, fresh.id);
- const bridge = bridgeRecord(dir);
+ const [dev, agentWork] = await Promise.all([
+ devView(dir, fresh.id),
+ inspectDelivery(dir, fresh.id, `http://127.0.0.1:${port ?? 8090}`),
+ ]);
+ const running = agentWork.running;
+ const bridge = running === null ? null : { pid: running.pid, harness: running.harness, mode: running.mode };
if (port === undefined || !(await portInUse(port))) {
- return { ...fresh, status: "stopped", url: null, pid: null, dev, bridge };
+ return { ...fresh, status: "stopped", url: null, pid: null, dev, bridge, agentWork };
}
// Something holds the port — is it this app, or a stranger?
const servingId = await identify(port);
if (servingId === null || servingId !== fresh.id) {
- return { ...fresh, status: "unreachable", url: null, pid: null, dev, bridge };
+ return { ...fresh, status: "unreachable", url: null, pid: null, dev, bridge, agentWork };
}
return {
...fresh,
@@ -353,6 +336,7 @@ export async function view(entry: RegistryEntry): Promise {
pid: serveRecord(dir)?.pid ?? null,
dev,
bridge,
+ agentWork,
};
}
diff --git a/framework/cli/test/delivery-fixture.mjs b/framework/cli/test/delivery-fixture.mjs
new file mode 100644
index 0000000..8380590
--- /dev/null
+++ b/framework/cli/test/delivery-fixture.mjs
@@ -0,0 +1,54 @@
+/** Wire-level app for the delivery lifecycle test; launched by serve, not by
+ * the test runner. State survives stop/serve so later work can be delivered. */
+import { createServer } from "node:http";
+import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
+const stateFile = new URL("./state.json", import.meta.url);
+const requestsFile = new URL("./requests.jsonl", import.meta.url);
+const read = () => JSON.parse(readFileSync(stateFile, "utf8"));
+const save = (state) => writeFileSync(stateFile, JSON.stringify(state));
+const server = createServer(async (req, res) => {
+ const url = new URL(req.url, "http://127.0.0.1");
+ appendFileSync(requestsFile, JSON.stringify({ path: url.pathname, method: req.method, credential: !!req.headers["x-a2app-token"] }) + "\n");
+ const send = (code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
+ let raw = "";
+ for await (const chunk of req) raw += chunk;
+ const body = raw ? JSON.parse(raw) : {};
+ const state = read();
+ const path = url.pathname;
+ if (path === "/api/_a2app" || path === "/.well-known/a2app.json") {
+ return send(200, { a2app: true, protocol: "0.1", adapterVersion: "0.1.0", app: { id: state.appId ?? "delivery_test", name: "Delivery Test" } });
+ }
+ if (path === "/api/_a2app/describe") {
+ return send(200, { a2app: true, level: "root", app: { id: "delivery_test", name: "Delivery Test" }, modules: [], next: [] });
+ }
+ if (path === "/api/_a2app/tasks") {
+ if (state.queueMode === "forbidden") return send(401, { code: "unauthorized" });
+ if (state.queueMode === "malformed") return send(200, { notTasks: [] });
+ if (state.queueMode === "bad-task") return send(200, { tasks: [null] });
+ if (state.queueMode === "hang") return;
+ if (state.queueMode === "hang-body") {
+ res.writeHead(200, { "content-type": "application/json" }); res.write('{"tasks":'); return;
+ }
+ return send(200, { a2app: true, tasks: state.tasks.filter((t) => t.status === (url.searchParams.get("status") ?? "submitted")), pollAfterMs: 100 });
+ }
+ if (path.startsWith("/api/_a2app/tasks/")) {
+ const [id, action] = path.slice("/api/_a2app/tasks/".length).split("/");
+ const task = state.tasks.find((t) => t.id === id);
+ if (!task) return send(404, { code: "task_not_found" });
+ if (!action) return send(200, task);
+ if (action === "claim") {
+ if (task.status !== "submitted") return send(409, { code: "task_not_claimable" });
+ task.status = "working";
+ task.claim = { credentialId: "delivery_test", principal: "owner", claimedAt: new Date().toISOString() };
+ } else if (action === "progress") {
+ if (task.status !== "working") return send(409, { code: "task_not_claimable" });
+ task.progress = { ...task.progress, ...body };
+ } else if (action === "complete") {
+ if (task.status !== "working") return send(409, { code: "task_not_claimable" });
+ task.status = body.status; task.result = body.result ?? null; task.reason = body.reason ?? null;
+ } else return send(404, { code: "usage" });
+ task.updatedAt = new Date().toISOString(); save(state); return send(200, task);
+ }
+ send(404, { code: "usage" });
+});
+server.listen(Number(process.env.PORT), "127.0.0.1");
diff --git a/framework/cli/test/delivery.test.mjs b/framework/cli/test/delivery.test.mjs
new file mode 100644
index 0000000..26c8167
--- /dev/null
+++ b/framework/cli/test/delivery.test.mjs
@@ -0,0 +1,231 @@
+/** #12: launch and handoff must not silently strand later work. These tests
+ * drive the actual CLIs and detached processes against a wire-level app. */
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { createServer } from "node:http";
+import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { delimiter, dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { freeEphemeralPort } from "../dist/lib/net.js";
+import { inspectDelivery, inspectLocalBridge } from "../dist/lib/delivery.js";
+import { isPidAlive, killTreeForce } from "../dist/lib/proc.js";
+import { writeSystemHashes } from "../dist/lib/canon.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const entry = resolve(here, "../dist/agent-app.js");
+const base = mkdtempSync(join(tmpdir(), "a2app-delivery-"));
+const dir = join(base, "app with spaces");
+const home = join(base, "home");
+mkdirSync(join(dir, ".a2app"), { recursive: true }); mkdirSync(home);
+// Pi's own shell tool must reach the CLI built from this checkout, not a
+// globally installed older framework. The shim lives only in this temp tree.
+const bin = join(base, "bin"); mkdirSync(bin);
+const operate = resolve(here, "../dist/a2app.js");
+const shQuote = (s) => `'${s.replace(/'/g, "'\\''")}'`;
+writeFileSync(join(bin, "a2app"), `#!/bin/sh\nexec ${shQuote(process.execPath.replace(/\\/g, "/"))} ${shQuote(operate.replace(/\\/g, "/"))} "$@"\n`, { mode: 0o755 });
+writeFileSync(join(bin, "a2app.cmd"), `@echo off\r\n"${process.execPath}" "${operate}" %*\r\n`);
+const env = { ...process.env, A2APP_HOME: home };
+const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
+env[pathKey] = bin + delimiter + (env[pathKey] ?? "");
+const port = await freeEphemeralPort();
+const baseUrl = `http://127.0.0.1:${port}`;
+const manifest = {
+ id: "delivery_test", name: "Delivery Test", agentAppVersion: "0.1.0", adapterVersion: "0.1.0",
+ authMode: "none", modules: [{ name: "work" }], port,
+ pipeline: { install: "", build: "", start: `"${process.execPath}" server.mjs`, health: "/api/_a2app" },
+};
+writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
+writeFileSync(join(dir, ".agent-token"), "delivery_test_credential\n");
+copyFileSync(join(here, "delivery-fixture.mjs"), join(dir, "server.mjs"));
+writeSystemHashes(dir, ["manifest.json"]);
+const schema = join(dir, "schema.py");
+writeFileSync(schema, 'trigger("review.requested", {}, capability="summarize")\n'); // no human View
+const statePath = join(dir, "state.json");
+const readState = () => JSON.parse(readFileSync(statePath, "utf8"));
+const writeState = (state) => writeFileSync(statePath, JSON.stringify(state));
+writeState({ tasks: [] });
+const profiles = (routes) => writeFileSync(join(home, "harnesses.json"), JSON.stringify({ version: 1, default: "delivery", harnesses: [{ id: "delivery", routes }] }));
+const mock = join(base, "harness.mjs");
+writeFileSync(mock, 'console.log("The requested summary is: Later work was delivered.");\n');
+profiles([{ mode: "headless", command: process.execPath, args: [mock, "{prompt}"] }]);
+const task = (id) => ({
+ id, app: "delivery_test", event: null, status: "submitted", request: { capability: "summarize", payload: { text: "A detached bridge should deliver work queued after its launching session ends." } },
+ claim: null, progress: {}, result: null, reason: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
+});
+function cli(args) {
+ return new Promise((done, reject) => {
+ const child = spawn(process.execPath, [entry, ...args], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
+ let stdout = "", stderr = "";
+ const timer = setTimeout(() => { killTreeForce(child.pid); reject(new Error(`CLI timed out: ${args.join(" ")}\n${stderr}`)); }, 25_000);
+ child.stdout.on("data", (d) => stdout += d); child.stderr.on("data", (d) => stderr += d);
+ child.on("error", (e) => { clearTimeout(timer); reject(e); });
+ child.on("close", (code) => { clearTimeout(timer); done({ code, stdout, stderr }); });
+ });
+}
+async function run(args) {
+ const r = await cli(args); assert.equal(r.code, 0, `${args.join(" ")}\n${r.stderr}`); return r;
+}
+const json = (r) => JSON.parse(r.stdout);
+async function until(fn, timeoutMs = 10_000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) { if (fn()) return; await new Promise((r) => setTimeout(r, 100)); }
+ throw new Error("condition did not become true in time");
+}
+const waiting = (id) => readState().tasks.find((t) => t.id === id);
+let bridgePid = null, servePid = null;
+try {
+ const first = await run([dir, "serve"]); servePid = json(first).pid;
+ assert.equal(json(first).ok, true); assert.equal(json(first).agentWork.queuesAgentWork, true);
+ assert.equal(json(first).agentWork.state, "absent"); assert.equal(json(first).agentWork.tasksWaiting, 0);
+ assert.match(first.stderr, /AGENT WORK DELIVERY NOT VERIFIED/); assert.match(first.stderr, /app with spaces.*bridge start/);
+ assert.match(first.stderr, /external listener/); assert.equal(existsSync(join(dir, ".a2app/bridge.json")), false);
+ const again = await run([dir, "serve"]);
+ assert.equal(json(again).alreadyRunning, true); assert.deepEqual(json(again).agentWork, json(first).agentWork);
+ assert.match(again.stderr, /AGENT WORK DELIVERY NOT VERIFIED/);
+
+ // Exactly the original failure: a one-shot test works, later work is stranded.
+ writeState({ tasks: [task("one_shot")] });
+ await run([dir, "bridge", "start", "--once"]);
+ assert.equal(waiting("one_shot").status, "completed");
+ writeState({ tasks: [task("later")] });
+ const warned = await run([dir, "serve"]);
+ assert.equal(json(warned).agentWork.tasksWaiting, 1); assert.match(warned.stderr, /DELIVERY NOT VERIFIED/);
+ const list = await run(["list"]);
+ assert.match(list.stdout, /bridge:missing.*waiting:1/); assert.match(list.stderr, /external harness may be polling/);
+ const listed = json(await run(["list", "--json"])).apps[0];
+ assert.equal(listed.status, "running"); assert.equal(listed.bridge, null); assert.equal(listed.agentWork.tasksWaiting, 1);
+ const status = json(await run([dir, "bridge"]));
+ assert.equal(status.running, null); assert.equal(status.tasksWaiting, 1); assert.equal(status.agentWork.baseUrl, baseUrl);
+
+ // Subscribe-only routes do not manufacture a local process or claim work.
+ profiles([{ mode: "subscribe", hint: "Keep the harness's own polling loop running" }]);
+ const subscribed = json(await run([dir, "bridge", "start"]));
+ assert.equal(subscribed.started, false); assert.equal(subscribed.mode, "subscribe");
+ assert.equal(json(await run([dir, "bridge"])).running, null); assert.equal(waiting("later").status, "submitted");
+ profiles([{ mode: "headless", command: process.execPath, args: [mock, "{prompt}"] }]);
+
+ // Stale/malformed records stay on disk and are never presented as live.
+ const stalePath = join(dir, ".a2app/bridge.json");
+ for (const record of [{ pid: 0 }, { pid: -1 }, { pid: 1 }, { pid: 1.5 }, { pid: "123" }, null]) {
+ writeFileSync(stalePath, JSON.stringify(record));
+ assert.equal(inspectLocalBridge(dir).state, "stale"); assert.equal(inspectLocalBridge(dir).running, null);
+ }
+ const dead = spawn(process.execPath, ["-e", ""], { windowsHide: true }); const deadPid = dead.pid;
+ await new Promise((r) => dead.on("close", r));
+ writeFileSync(stalePath, JSON.stringify({ pid: deadPid }));
+ assert.equal(inspectLocalBridge(dir).state, "stale");
+ assert.match((await run(["list"])).stdout, /bridge:stale.*waiting:1/);
+ assert.equal(existsSync(stalePath), true); rmSync(stalePath);
+
+ // A failed/malformed/unresponsive queue never looks like zero waiting work.
+ for (const queueMode of ["forbidden", "malformed", "bad-task", "hang", "hang-body"]) {
+ writeState({ tasks: [], queueMode });
+ const started = Date.now();
+ const info = await inspectDelivery(dir, "delivery_test", baseUrl, 80);
+ assert.equal(info.tasksWaiting, null, queueMode); assert.ok(Date.now() - started < 1200, queueMode);
+ }
+ const unknown = json(await run(["list", "--json"])).apps[0].agentWork;
+ assert.equal(unknown.tasksWaiting, null); assert.match((await run(["list"])).stdout, /waiting:unknown/);
+
+ // A stranger on the live port must never receive the app's credential/queue read.
+ writeState({ tasks: [task("stranger")], appId: "different_app" });
+ const before = readFileSync(join(dir, "requests.jsonl"), "utf8").trim().split("\n").length;
+ assert.equal((await inspectDelivery(dir, "delivery_test", baseUrl)).tasksWaiting, null);
+ const seen = readFileSync(join(dir, "requests.jsonl"), "utf8").trim().split("\n").slice(before).map(JSON.parse);
+ assert.equal(seen.length, 1); assert.equal(seen[0].path, "/api/_a2app"); assert.equal(seen[0].credential, false);
+ writeState({ tasks: [task("live_only")] });
+
+ // Status inspects live, even though operate traffic would target dev.
+ let devReads = 0;
+ let devTasks = [task("dev_1"), task("dev_2")];
+ const dev = createServer((req, res) => {
+ if (req.url.includes("tasks")) devReads++;
+ res.setHeader("content-type", "application/json");
+ res.end(JSON.stringify({ a2app: true, app: { id: "delivery_test" }, tasks: devTasks }));
+ });
+ await new Promise((r) => dev.listen(0, "127.0.0.1", r));
+ try {
+ const devPort = dev.address().port;
+ writeFileSync(join(dir, ".a2app/dev.json"), JSON.stringify({ pid: process.pid, port: devPort, url: `http://127.0.0.1:${devPort}`, bootDir: join(dir, ".a2app/dev/test"), startedAt: new Date().toISOString(), healthy: true }));
+ for (const args of [[dir, "serve"], [dir, "bridge"]]) {
+ assert.equal(json(await run(args)).agentWork.tasksWaiting, 1);
+ }
+ const row = json(await run(["list", "--json"])).apps[0];
+ assert.equal(row.agentWork.tasksWaiting, 1); assert.equal(row.dev.answering, true); assert.equal(devReads, 0);
+ // A bridge started against dev is genuinely alive, yet cannot deliver
+ // live work. Persist its actual endpoint so handoff can catch this case.
+ devTasks = [];
+ bridgePid = json(await run([dir, "bridge", "start", "--interval", "250"])).pid;
+ const wrongTarget = await run([dir, "serve"]);
+ assert.equal(json(wrongTarget).agentWork.state, "running");
+ assert.equal(json(wrongTarget).agentWork.bridgeTargetsLive, false);
+ assert.equal(json(wrongTarget).agentWork.running.baseUrl, `http://127.0.0.1:${devPort}`);
+ assert.match(wrongTarget.stderr, /rather than live/);
+ assert.match((await run(["list"])).stdout, /bridge-target:other/);
+ await run([dir, "bridge", "stop"]); bridgePid = null;
+ } finally { rmSync(join(dir, ".a2app/dev.json")); await new Promise((r) => dev.close(r)); }
+
+ // Queue support and capability-free events alone must stay quiet. Waiting
+ // work still counts if custom/dynamic enqueue code eludes the static scanner.
+ writeFileSync(schema, 'trigger("changed", {}, None)\n# trigger("comment", {}, "summarize")\n');
+ writeState({ tasks: [] });
+ const quiet = await run([dir, "serve"]);
+ assert.equal(json(quiet).agentWork.queuesAgentWork, false); assert.doesNotMatch(quiet.stderr, /DELIVERY NOT VERIFIED/);
+ assert.doesNotMatch((await run(["list"])).stdout, /bridge:missing/);
+ writeState({ tasks: [task("dynamic")] });
+ assert.equal(json(await run([dir, "serve"])).agentWork.queuesAgentWork, true);
+ assert.match((await run(["list"])).stdout, /bridge:missing.*waiting:1/);
+
+ // The launching CLI exits; then new work is delivered by the detached bridge.
+ writeState({ tasks: [] });
+ const started = json(await run([dir, "bridge", "start", "--interval", "250"])); bridgePid = started.pid;
+ assert.ok(isPidAlive(bridgePid));
+ const reused = json(await run([dir, "bridge", "start"]));
+ assert.equal(reused.alreadyRunning, true); assert.equal(reused.pid, bridgePid);
+ assert.equal(reused.baseUrl, baseUrl);
+ const bridgePath = join(dir, ".a2app/bridge.json");
+ const currentRecord = readFileSync(bridgePath, "utf8");
+ const legacy = JSON.parse(currentRecord); delete legacy.baseUrl;
+ writeFileSync(bridgePath, JSON.stringify(legacy));
+ const legacyStatus = json(await run([dir, "bridge"]));
+ assert.equal(legacyStatus.agentWork.state, "running");
+ assert.equal(legacyStatus.agentWork.bridgeTargetsLive, null);
+ assert.match((await run(["list"])).stdout, /bridge-target:unknown/);
+ writeFileSync(bridgePath, currentRecord);
+ writeState({ tasks: [task("after_session")] });
+ await until(() => waiting("after_session")?.status === "completed");
+ assert.equal(json(await run([dir, "serve"])).agentWork.running.pid, bridgePid);
+ assert.equal(json(await run([dir, "serve"])).agentWork.bridgeTargetsLive, true);
+ const stopped = await run([dir, "stop"]);
+ assert.match(stopped.stderr, /bridge remains running/); assert.match(stopped.stderr, /bridge stop/);
+ assert.equal(json(stopped).bridge.running.pid, bridgePid); assert.ok(isPidAlive(bridgePid)); servePid = null;
+ writeState({ tasks: [task("after_restart")] });
+ const restarted = json(await run([dir, "serve"])); servePid = restarted.pid;
+ assert.equal(restarted.agentWork.state, "running");
+ await until(() => waiting("after_restart")?.status === "completed");
+ assert.equal(json(await run([dir, "bridge"])).running.pid, bridgePid);
+ await run([dir, "bridge", "stop"]); bridgePid = null;
+
+ // Optional real Pi check: credentials and executable belong to the local
+ // operator. Never required by CI. The bridge starts Pi after its caller exits.
+ const piLauncher = process.env.A2APP_TEST_PI_LAUNCHER;
+ if (piLauncher) {
+ profiles([{ mode: "headless", command: process.execPath, args: [piLauncher, "-p", "{prompt}"] }]);
+ writeState({ tasks: [] });
+ bridgePid = json(await run([dir, "bridge", "start", "--interval", "250", "--task-timeout", "180000"])).pid;
+ writeState({ tasks: [task("real_pi_after_session")] });
+ await until(() => waiting("real_pi_after_session")?.status === "completed", 200_000);
+ const result = waiting("real_pi_after_session");
+ assert.equal(typeof result.result?.summary, "string"); assert.ok(result.result.summary.length > 0);
+ console.log(`Real Pi handoff passed: ${result.result.summary}`);
+ await run([dir, "bridge", "stop"]); bridgePid = null;
+ }
+ console.log("✓ delivery: launch warnings, truthful queue/bridge state, live/dev isolation, detached handoff and restart");
+} finally {
+ if (bridgePid && isPidAlive(bridgePid)) killTreeForce(bridgePid);
+ if (servePid && isPidAlive(servePid)) killTreeForce(servePid);
+ // Cleanup only the temp tree this test created, after managed processes exit.
+ assert.equal(dirname(base), tmpdir());
+ try { rmSync(base, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* Windows log handles may close later. */ }
+}
diff --git a/skills/creator/SKILL.md b/skills/creator/SKILL.md
index 8d99e66..965e255 100644
--- a/skills/creator/SKILL.md
+++ b/skills/creator/SKILL.md
@@ -249,11 +249,13 @@ says whether yours does and how to reach it. If it does not, handle the event
with plain code and record the limitation in `reference/requirements.md` rather
than inventing a mechanism.
-Nothing is delivered until someone is listening: the user runs
-`agent-app bridge start` (the framework triggers their harness) or their
-harness polls `a2app tasks next --wait`. Say so when you ship a feature
-that queues work, or it will look broken. `agent-app bridge` reports
-whether this machine can reach a harness at all.
+Nothing is delivered until someone is listening: a background
+`agent-app bridge start` triggers their harness, or their harness polls
+`a2app tasks next --wait`. For a feature that queues work, delivery is
+part of the handoff, not just a command to mention. Complete the live delivery
+check below. `agent-app bridge` reports the LIVE queue and local bridge,
+plus which harness route is available; a route alone does not mean delivery
+is running.
**The UI that queues work must show that work until it is done.** An agent run
takes seconds to minutes. A button that fires a toast and then goes quiet
@@ -355,6 +357,24 @@ own and render that field the same way.
manifest pipeline as a managed background process, polls health, and prints
the URL — open it, see **Showing the app to the user** below. Never start a
server by hand.
+6. **Check live agent-work delivery.** If any delivered feature queues agent
+ work, run `agent-app bridge` after promote + serve. When app-triggered
+ agent runs are authorized and a delivery route exists, start/reuse plain
+ `agent-app bridge start`, then run `agent-app bridge` again and
+ confirm a running background process after the start command has exited.
+ Check `agentWork.bridgeTargetsLive` is true: a bridge started during testing
+ may still be watching the old dev port. If it is false or unknown, stop that
+ bridge and restart it after dev is gone, then inspect again.
+ A test with `--once`, `--dry-run`, or an interactive `--foreground` process
+ does not satisfy this handoff. Start the persistent bridge only after the
+ dev instance is destroyed: bridge start follows dev routing while dev is up.
+ A subscribe-only harness needs its own continuing polling loop; verify that
+ path where possible rather than expecting a local bridge. Existing user
+ authorization applies; do not ask again just because the build was promoted.
+ If delivery is unavailable, not authorized, or cannot be verified, name the
+ limitation and remedy in the handoff. A healthy server or a live bridge PID
+ alone does not prove the agent feature works. The View-state walk still
+ applies, and no local bridge does not prove there is no external listener.
**Showing the app to the user.** A running app is not a delivered app until the
person can see it. The framework cannot know what your harness can do, so YOU
diff --git a/skills/modify/SKILL.md b/skills/modify/SKILL.md
index 06f5d46..c59a649 100644
--- a/skills/modify/SKILL.md
+++ b/skills/modify/SKILL.md
@@ -139,6 +139,19 @@ agent-app open # and show it to the user (see below)
`promote` applies migrations and launches NOTHING, so a change is not in front of
the user until you serve again. Do not end a modify at `promote`.
+**Check live agent-work delivery before finishing.** If the app has features
+that queue agent work, follow the creator skill's live delivery check after
+promote + serve: inspect `agent-app bridge`, start/reuse plain background
+`bridge start` where agent runs are authorized and a route exists, then confirm
+the process is still running after the command exits. Do not leave only a
+`--once`, `--dry-run`, or interactive foreground test. Start after dev is gone
+so the bridge watches live. A subscribe-only harness needs its own continuing
+polling loop; verify it where possible. Confirm `agentWork.bridgeTargetsLive`
+is true; a bridge left on the dev port needs stopping and restarting after dev
+is gone. Existing authorization still applies.
+Report any unavailable or unverified delivery and its remedy explicitly; a
+successful serve does not establish that the agent feature is operational.
+
**Showing the app to the user.** A running app is not a delivered app until the
person can see it. The framework cannot know what your harness can do, so YOU
decide which of these you are: