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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions framework/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,38 @@ agent-app <dir> bridge start --foreground # run the loop here; Ctrl-C stops af
agent-app <dir> 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 <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.
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 && 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",
Expand Down
36 changes: 17 additions & 19 deletions framework/cli/src/commands/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
bridgeLockPath,
bridgeLogPath,
clearBridgeRecord,
countWaiting,
DEFAULT_INTERVAL_MS,
DEFAULT_TASK_TIMEOUT_MS,
logTail,
Expand All @@ -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. */
Expand All @@ -80,7 +79,8 @@ function explainNoRoute(app: string, ladder: RungReport[]): void {
async function status(args: string[], app: string, project: Project): Promise<number> {
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;
Expand All @@ -90,14 +90,6 @@ async function status(args: string[], app: string, project: Project): Promise<nu
mode = chosen.route?.mode ?? null;
}

let waiting: number | null = null;
try {
const { client } = await connect(app);
waiting = await countWaiting(client);
} catch {
waiting = null; // the app is not up; that is reported, not fatal
}

const report = {
a2app: true,
app: { id: project.manifest.id, name: project.manifest.name },
Expand All @@ -107,7 +99,8 @@ async function status(args: string[], app: string, project: Project): Promise<nu
supported: mode !== null,
ladder,
running,
tasksWaiting: waiting,
tasksWaiting: agentWork.tasksWaiting,
agentWork,
config: existsSync(harnessesPath()) ? harnessesPath() : null,
profiles: loaded.profiles.map((p) => p.id),
};
Expand All @@ -117,8 +110,11 @@ async function status(args: string[], app: string, project: Project): Promise<nu
if (selection.profile === null) log.warn(selection.why);
else if (mode === null) explainNoRoute(app, ladder);
else if (mode === "subscribe") log.info(`${selection.profile.id} subscribes — start it to be shown the listen command`);
else log.info(`ready: ${selection.profile.id} via ${mode} (${selection.why})`);
if (waiting !== null && waiting > 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));
Expand Down Expand Up @@ -354,6 +350,7 @@ async function start(args: string[], app: string, project: Project): Promise<num
mode: route.mode,
intervalMs,
startedAt: new Date().toISOString(),
baseUrl: client.baseUrl,
...(gatewayPid !== undefined ? { gatewayPid } : {}),
});
}
Expand Down Expand Up @@ -425,8 +422,9 @@ async function stopBridge(project: Project, app: string): Promise<number> {
// 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;
Expand Down
25 changes: 19 additions & 6 deletions framework/cli/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
if (hasFlag(args, "prune")) {
Expand Down Expand Up @@ -41,13 +42,25 @@ export async function run(args: string[]): Promise<number> {
// 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.");
Expand All @@ -63,7 +76,7 @@ export async function run(args: string[]): Promise<number> {
}
if (apps.some((a) => a.bridge !== null)) {
log.info(
"bridge:<harness>/<route> — that app's queue is being watched and can start agent runs here. " +
"bridge:<harness>/<route> — a local bridge process is running; its PID does not verify task delivery. " +
"`agent-app <app> bridge` for detail · `agent-app <app> bridge stop` to end it.",
);
}
Expand Down
8 changes: 7 additions & 1 deletion framework/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -86,6 +87,8 @@ export async function run(args: string[], app: string): Promise<number> {
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(
Expand All @@ -96,6 +99,7 @@ export async function run(args: string[], app: string): Promise<number> {
url: project.baseUrl,
pid: prev?.pid ?? null,
alreadyRunning: true,
agentWork,
...shown,
},
null,
Expand Down Expand Up @@ -201,6 +205,8 @@ export async function run(args: string[], app: string): Promise<number> {
"\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
Expand All @@ -221,7 +227,7 @@ export async function run(args: string[], app: string): Promise<number> {
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,
),
Expand Down
19 changes: 14 additions & 5 deletions framework/cli/src/commands/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,6 +66,14 @@ export async function run(args: string[], app: string): Promise<number> {

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<void> => {
rmSync(servePath, { force: true });
Expand All @@ -84,7 +93,7 @@ export async function run(args: string[], app: string): Promise<number> {

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;
}

Expand All @@ -100,7 +109,7 @@ export async function run(args: string[], app: string): Promise<number> {
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;
}

Expand All @@ -115,13 +124,13 @@ export async function run(args: string[], app: string): Promise<number> {
`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;
}

Expand All @@ -143,7 +152,7 @@ export async function run(args: string[], app: string): Promise<number> {

await clearRecord();
log.ok(`stopped (pid ${pid})`);
log.raw(JSON.stringify({ ok: true, stopped: pid }, null, 2));
reportStopped(pid);
return 0;
});
}
20 changes: 14 additions & 6 deletions framework/cli/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -111,13 +113,14 @@ export function readBridgeRecord(dir: string): BridgeRecord | null {
if (!existsSync(file)) return null;
try {
const raw = readJsonFile<Partial<BridgeRecord>>(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 {
Expand Down Expand Up @@ -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<number | null> {
export async function countWaiting(client: Pick<A2AppClient, "pollTasks">): Promise<number | null> {
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;
}
Expand Down
Loading
Loading