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
8 changes: 5 additions & 3 deletions COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,15 +376,17 @@ check is an error, so it is safe to gate scripts on.
call, no session, no opener launch, no credential refresh, no write. Covers
runtime, workspace, git, transport config, auth config, tools, memory, MCP
registry, persistence, the media output index, the opener, GitHub, and
Protocol-C receipt storage.
Protocol-C receipt storage. Terminal Online readiness is listed as unverified
until an authenticated live probe runs.

**`aether doctor --live`** — proves the paths end to end, right now:
authenticated catalog fetch, a dev session, sequence-numbered frames,
pause/resume/steer acknowledgement, a sandboxed tool write/read/compare/delete
round trip, clean session close, a real browser open confirmed by a loopback
callback, GitHub identity, branch freshness compared **without fetching**, the
MCP broker, and a Protocol-C receipt round trip. Billing is accounted across
the run and reported as `spend.none`; the agent loop runs only when the server
MCP broker, a Protocol-C receipt round trip, and terminal managed-agent
registry, DM, and model/UVT readiness without sending a message. Billing is
accounted across the run and reported as `spend.none`; the agent loop runs only when the server
confirms a non-billable doctor session, and is reported as unproven otherwise.
`--no-ui` skips the browser proof on a headless box (reported as skipped, not
passed).
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ and [operator packet](docs/releases/OPERATOR-PACKET-v0.4.0.md).
After signing in, use `aether agent list` to see the same managed agents as
Aether Online. `aether agent chat` opens the one-column picker; select an agent
to read and send messages in its existing Online conversation.
The list and show commands report fresh account registry, DM, and model/UVT
readiness separately. `aether doctor --live` checks the same read-only Cloud
contract; plain `aether doctor` leaves account readiness unverified. A saved DM
is reported as admitted only when Cloud's message admission says so.

```bash
aether agent list
Expand Down
44 changes: 34 additions & 10 deletions src/commands/managed_agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { randomUUID } from "node:crypto";
import type { Writable } from "node:stream";
import type { AppContext } from "../core/context.js";
import {
ManagedAgentsClient, MANAGED_AGENT_ID, managedAgentError,
ManagedAgentsClient, MANAGED_AGENT_ID, managedAgentError, probeManagedReadiness,
type ManagedReadiness, type ReadinessGate,
type ManagedAgent, type ManagedAgentConfig, type AgentMessage,
} from "../core/managed_agents.js";
import { theme } from "../ui/theme.js";
Expand Down Expand Up @@ -65,6 +66,19 @@ const HELP = [
"New agents start as drafts with zero budget. Configure UVT limits, then activate.",
].join("\n") + "\n";

function readinessLine(readiness: ManagedReadiness): string {
return (["registry", "dm", "model_uvt"] as const).map(key => {
const gate = readiness[key];
return `${key}: ${gate.state} (${gate.code}) · ${gate.reason} ${gate.state === "enabled" ? "" : gate.remedy}`.trim();
}).join("\n") + "\n";
}

function writeGateError(gate: ReadinessGate, ctx: AppContext, out: Writable): void {
out.write(ctx.flags.json
? JSON.stringify({ error: { code: gate.code, message: gate.reason, remedy: gate.remedy } }) + "\n"
: `✗ ${gate.code}: ${sanitizeTerm(gate.reason)} ${sanitizeTerm(gate.remedy)}\n`);
}

function cell(value: string, width: number): string {
const safe = sliceVisible(sanitizeTerm(value).replace(/[\r\n\t]/g, " "), width);
return safe + " ".repeat(Math.max(0, width - visibleWidth(safe)));
Expand Down Expand Up @@ -278,27 +292,33 @@ export async function cmdManagedAgentChat(ctx: AppContext, id: string | undefine
try {
if (!(await ctx.tokens.get())) throw new Error("Sign in with `aether auth login` to sync your agents.");
if (ctx.flags.local) throw new Error("Managed agents require your Aether account. Omit --local to sync with Cloud.");
const readiness = await probeManagedReadiness(ctx.api, signal);
if (readiness.registry.state !== "enabled") { writeGateError(readiness.registry, ctx, deps.err ?? process.stderr); return 1; }
if (readiness.dm.state !== "enabled") { writeGateError(readiness.dm, ctx, deps.err ?? process.stderr); return 1; }
if (ctx.flags.json) out.write(JSON.stringify({ type: "readiness", readiness }) + "\n");
else out.write(readinessLine(readiness));
let agent = id ? await client.get(id, signal) : await pickManagedAgent(await client.list(signal), out, signal);
if (!agent) return 0;
closeSession = await deps.hooks?.beforeChat?.(ctx, agent, surface);
const thread = await client.thread(agent.agent_id, signal);
if (typeof thread["id"] !== "string") throw new Error("Cloud did not return a conversation ID.");
const conversationId = thread["id"];
const send = async (body: string): Promise<void> => {
const send = async (body: string): Promise<boolean> => {
const nonce = randomUUID();
let receipt;
try { receipt = await client.send(agent!.agent_id, conversationId, body, nonce, signal); }
catch (error) {
// No automatic replay with a new nonce: the server may already have saved it.
throw new Error(`${managedAgentError(error)} Delivery is unconfirmed; check the shared conversation before sending again.`);
}
if (ctx.flags.json) out.write(JSON.stringify(receipt) + "\n");
else {
const admission = receipt.admission;
surface.write(theme.dim(`Message saved · ${sanitizeTerm(admission?.state ?? "admission not reported")}${admission?.reason ? ` · ${sanitizeTerm(admission.reason)}` : ""}`) + "\n");
}
const state = receipt.admission?.state ?? "unreported";
const accepted = state === "admitted" || state === "replied";
const code = accepted ? "ADMITTED" : "SEND_NOT_ADMITTED";
if (ctx.flags.json) out.write(JSON.stringify({ type: "message_admission", code, state }) + "\n");
else surface.write(theme.dim(`${accepted ? "Message admitted" : "Message saved; execution not accepted"} · ${sanitizeTerm(state)}`) + "\n");
return accepted;
};
if (prompt.trim()) { await send(prompt); return 0; }
if (prompt.trim()) return await send(prompt) ? 0 : 1;
if (!ctx.flags.json) {
out.write(renderAgent(agent) + theme.dim("Shared Online DM · /refresh · /exit") + "\n");
const help = deps.hooks?.help?.(agent);
Expand Down Expand Up @@ -440,9 +460,13 @@ export async function cmdManagedAgents(ctx: AppContext, argv: string[], deps: Ma
if (ctx.flags.local) throw new Error("Managed agents require your Aether account. Omit --local to sync with Cloud.");
const [verb = "list", id, ...rest] = argv;
if (verb === "chat") return await cmdManagedAgentChat(ctx, id, rest.join(" "), deps);
const readiness = verb === "list" || verb === "show" ? await probeManagedReadiness(ctx.api, deps.signal) : undefined;
if (readiness?.registry.state !== undefined && readiness.registry.state !== "enabled") {
writeGateError(readiness.registry, ctx, deps.err ?? process.stderr); return 1;
}
if (verb === "list") {
const agents = await client.list(deps.signal);
out.write(ctx.flags.json ? JSON.stringify({ agents }) + "\n" : renderManagedAgents(agents, (out as Writable & { columns?: number }).columns ?? 80));
out.write(ctx.flags.json ? JSON.stringify({ readiness, agents }) + "\n" : readinessLine(readiness!) + renderManagedAgents(agents, (out as Writable & { columns?: number }).columns ?? 80));
return 0;
}
let agent: ManagedAgent;
Expand All @@ -467,7 +491,7 @@ export async function cmdManagedAgents(ctx: AppContext, argv: string[], deps: Ma
agent = await client.control(agent, verb as "activate" | "pause" | "resume" | "retire", deps.signal);
}
} else { out.write(HELP); return 2; }
out.write(ctx.flags.json ? JSON.stringify({ agent }) + "\n" : renderAgent(agent));
out.write(ctx.flags.json ? JSON.stringify({ ...(readiness ? { readiness } : {}), agent }) + "\n" : (readiness ? readinessLine(readiness) : "") + renderAgent(agent));
if (verb === "create" && !ctx.flags.json) out.write(theme.dim(`Saved to your account. Configure UVT limits, then activate: aether agent activate ${agent.agent_id}`) + "\n");
return 0;
} catch (error) {
Expand Down
10 changes: 10 additions & 0 deletions src/core/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,16 @@ export function fastCheckSpecs(
severity: "info",
}),
},
{
id: "online.terminal.readiness",
category: "online",
title: "Terminal managed agents",
run: (): CheckOutcome => ({
configured: axis("unknown", { evidence: "account entitlement needs an authenticated Cloud probe" }),
severity: "warning",
remediation: LIVE_HINT,
}),
},
{
id: "tools.schemas",
category: "tools",
Expand Down
23 changes: 22 additions & 1 deletion src/core/doctor_live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { buildDevSessionRequest } from "./envelope.js";
import { decodeSse, type StreamFrame } from "./stream.js";
import { TOOLS } from "./brain_protocol.js";
import { DEV_PROTOCOL_VERSION } from "./brain_cloud.js";
import { probeManagedReadiness, type ReadinessGate } from "./managed_agents.js";
import { appendCustody, readCustodyLog } from "./custody.js";
import { McpClient } from "./mcp.js";
import { diagnosePredatorDrive } from "./predator_drive_readiness.js";
Expand All @@ -57,6 +58,25 @@ import {
const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
const DEFAULT_OPENER_TIMEOUT_MS = 15_000;

function onlineChecks(ctx: AppContext, authed: boolean): Promise<HealthCheck[]> {
const gates = ["registry", "dm", "model_uvt"] as const;
if (!authed) return Promise.resolve(gates.map(name => check({ id: `online.terminal.${name}`, category: "online", title: `Terminal ${name}` }, {
configured: axis("no", { evidence: "AUTH_REQUIRED" }),
reachable: notChecked("signed out"), verified: notChecked("signed out"),
severity: "warning", remediation: "run: aether auth login",
})));
return probeManagedReadiness(ctx.api).then(readiness => gates.map(name => {
const gate: ReadinessGate = readiness[name];
const enabled = gate.state === "enabled";
return check({ id: `online.terminal.${name}`, category: "online", title: `Terminal ${name}` }, {
configured: axis(enabled ? "yes" : gate.state === "disabled" ? "no" : "unknown", { evidence: gate.code }),
reachable: axis(gate.code === "TEMPORARILY_UNAVAILABLE" ? "no" : "yes", { evidence: gate.code }),
verified: axis(enabled ? "yes" : "no", { evidence: gate.code }),
severity: enabled ? "info" : "warning", remediation: enabled ? undefined : gate.remedy,
});
}));
}

export interface LiveOptions {
now?: () => string;
/** Per-probe bound. A broken service must never hang the CLI. */
Expand Down Expand Up @@ -1003,10 +1023,11 @@ export async function liveReport(ctx: AppContext, options: LiveOptions = {}): Pr
const agent = authed
? await agentProbes(ctx, ledger, { ...options, runId, timeoutMs }, sandbox)
: agentUnproven("signed out; the agent loop cannot be exercised");
const online = await onlineChecks(ctx, authed);

return buildReport(
"live",
[authCheck, ...agent, ...independent, ...automationChecks(), spendCheck(ledger)],
[authCheck, ...agent, ...online, ...independent, ...automationChecks(), spendCheck(ledger)],
now(),
);
} finally {
Expand Down
Loading
Loading