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
17 changes: 10 additions & 7 deletions hub/agents/npm/agent-email/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ follows [SemVer](https://semver.org/): the **MAJOR** of the on-the-wire
`SCHEMA_VERSION` is what `checkVersion` enforces at startup, so a contract MAJOR
bump is always at least a package MINOR bump with a migration note.

## [Unreleased]
## 0.2.1

Adds the one-command `playground` launcher and automatic sidecar cleanup, and
makes this README the single canonical agent README (hub + npm). No wire-contract
change (`SCHEMA_VERSION` stays `2.0`).

### Added

- **`npx @amd-gaia/agent-email playground` — one-command launcher.** Fetches the
binary, starts the sidecar, and opens the browser to `/v1/email/playground`,
running until Ctrl+C. `--port <n>` to bind elsewhere, `--no-open` to skip the
browser, `--out <dir>` to choose the binary cache. Makes "try the agent" a single
command instead of fetch → spawn → find-the-URL.
- **Automatic sidecar cleanup (`autoCleanup`, default on).** `startSidecar` /
`spawnSidecar` now reap the frozen sidecar's detached process tree when the host
process exits, crashes (`uncaughtException` / `unhandledRejection`), or is
Expand All @@ -18,12 +27,6 @@ bump is always at least a package MINOR bump with a migration note.
graceful, awaited path. A hard `SIGKILL` of the host process is the one case no
in-process handler can catch.

## 0.2.1

Documentation/packaging release — no client API or wire-contract change
(`SCHEMA_VERSION` stays `2.0`). Republishes so the live hub catalog picks up the
current README.

### Changed

- **This README is now the single canonical agent README** (hub + npm). The
Expand Down
21 changes: 14 additions & 7 deletions hub/agents/npm/agent-email/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @amd-gaia/agent-email

[![npm version](https://img.shields.io/npm/v/@amd-gaia/agent-email?label=version)](https://www.npmjs.com/package/@amd-gaia/agent-email) · contract `SCHEMA_VERSION` **2.0** · last updated **2026-06-23**
[![npm version](https://img.shields.io/npm/v/@amd-gaia/agent-email?label=version)](https://www.npmjs.com/package/@amd-gaia/agent-email) · contract `SCHEMA_VERSION` **2.0** · last updated **2026-06-24**

Embed the **GAIA email agent** in your JS/TS app. It triages, organizes, replies
to, and schedules from Gmail and Outlook — with every email body analyzed
Expand Down Expand Up @@ -223,13 +223,20 @@ timeout surfaces as `HttpError` with `status === 0` (not an HTTP code).

## Playground

Once the sidecar is running, open
One command fetches the binary, starts the sidecar, and opens the playground:

```bash
npx @amd-gaia/agent-email playground
```

It serves a zero-setup, **localhost-only** page at
[http://127.0.0.1:8131/v1/email/playground](http://127.0.0.1:8131/v1/email/playground)
— a zero-setup, **localhost-only** page with a stack-health check, live triage and
draft, and a Connectors panel to connect Gmail/Outlook and try a live send. It's
served same-origin under a strict CSP, so the page can only ever reach your local
sidecar: triage and draft stay on-device, while a `send` transmits to your mail
provider by definition.
— a stack-health check, live triage and draft, and a Connectors panel to connect
Gmail/Outlook and try a live send. It's served same-origin under a strict CSP, so
the page can only ever reach your local sidecar: triage and draft stay on-device,
while a `send` transmits to your mail provider by definition. Press Ctrl+C to stop
(`--port <n>` to bind elsewhere, `--no-open` to skip auto-opening the browser,
`--out <dir>` to choose where the binary is cached).

## Requirements

Expand Down
5 changes: 5 additions & 0 deletions hub/agents/npm/agent-email/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,5 +148,10 @@ A green path looks like: `fetchBinary` succeeds → `startSidecar` resolves →
`triage` 502s, start Lemonade and pull the model, then retry — the rest of your
integration is fine.

To eyeball the agent by hand without writing any code, run
`npx @amd-gaia/agent-email playground` — it fetches the binary, starts the sidecar,
and opens an interactive page where you can fire triage/draft and see a stack-health
check.

For the full endpoint list, lifecycle internals, and connector details, see
`SPEC.md` next to this file.
22 changes: 15 additions & 7 deletions hub/agents/npm/agent-email/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,19 +122,27 @@ control, the steps are exported individually:
- `verifySha256(buf, expected, label)` → throws `IntegrityError` on mismatch.
- `shutdown(sidecar)` → kill the **whole process tree** (`taskkill /F /T` on Windows; detached process-group kill on POSIX). The default auto-reaper does the same on process exit/crash/signal, so only a hard `SIGKILL` of the host can still orphan the child.

## The `fetch` CLI

`fetch` is the supported, build-time path. It resolves
`${process.platform}-${process.arch}`, downloads that platform's artifact from the
base URL in `binaries.lock.json`, **verifies its SHA-256 against the lock and fails
loudly on any mismatch**, writes it to `--out`, and `chmod +x`'s it on POSIX.
## CLI

```bash
npx @amd-gaia/agent-email playground # fetch + run the sidecar, open the playground
npx @amd-gaia/agent-email fetch --out resources
npx @amd-gaia/agent-email version # show manifest + current platform
npx @amd-gaia/agent-email version # show manifest + current platform
npx @amd-gaia/agent-email help
```

`playground` is the zero-to-running shortcut: it `fetchBinary`s into a temp cache
(`--out` to override), `startSidecar`s on `--port` (default 8131), opens the default
browser to `/v1/email/playground` (`--no-open` to skip), and runs until Ctrl+C.
The command owns the sidecar lifecycle itself (`autoCleanup: false`) and shuts it
down on `SIGINT`/`SIGTERM`/`SIGHUP` or on any startup error. Lemonade still has to
be running for live triage — the page itself reports if it isn't.

`fetch` is the supported, build-time path. It resolves
`${process.platform}-${process.arch}`, downloads that platform's artifact from the
base URL in `binaries.lock.json`, **verifies its SHA-256 against the lock and fails
loudly on any mismatch**, writes it to `--out`, and `chmod +x`'s it on POSIX.

| Flag | Meaning |
|------|---------|
| `--out <dir>` | Resources dir to write the verified binary into (**required**) |
Expand Down
151 changes: 139 additions & 12 deletions hub/agents/npm/agent-email/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@
* marker but build-time fetch remains the recommended flow.
*/

import { spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { fetchBinary } from "./fetch.js";
import { shutdown, startSidecar } from "./lifecycle.js";
import { currentPlatformKey, loadLock } from "./platform.js";
import { AgentEmailError } from "./errors.js";

Expand All @@ -25,7 +32,7 @@ interface ParsedArgs {
// Flags that take a value (`--out <dir>`); everything else is a boolean switch.
// Being explicit avoids the footgun where `--base-url --force` silently swallows
// the next flag as a value (or drops the value).
const VALUE_FLAGS = new Set(["out", "base-url", "platform"]);
const VALUE_FLAGS = new Set(["out", "base-url", "platform", "port"]);

function parseArgs(argv: string[]): ParsedArgs {
const out: ParsedArgs = { _: [], flags: {} };
Expand Down Expand Up @@ -54,10 +61,17 @@ function parseArgs(argv: string[]): ParsedArgs {
const HELP = `@amd-gaia/agent-email — GAIA email agent binary fetcher + client

Usage:
agent-email playground [options] Fetch + run the sidecar and open the playground
agent-email fetch --out <dir> [options] Download + SHA-256 verify the binary
agent-email version Print package + lock manifest info
agent-email help Show this help

playground options:
--port <n> Bind port (default 8131)
--out <dir> Where to cache the binary (default: a temp dir)
--base-url <url> Override the download base URL from binaries.lock.json
--no-open Don't auto-open the browser; just print the URL

fetch options:
--out <dir> Resources dir to write the verified binary into (required)
--base-url <url> Override the download base URL from binaries.lock.json
Expand Down Expand Up @@ -109,6 +123,105 @@ async function cmdFetch(args: ParsedArgs): Promise<number> {
return 0;
}

/** Best-effort cross-platform "open this URL in the default browser". */
function openBrowser(url: string): void {
try {
const [cmd, args] =
process.platform === "darwin"
? ["open", [url]]
: process.platform === "win32"
? ["cmd", ["/c", "start", "", url]]
: ["xdg-open", [url]];
const child = spawn(cmd, args as string[], { stdio: "ignore", detached: true });
// A missing opener (headless / container / WSL without wslu) is reported via
// an async 'error' event, NOT a sync throw — swallow it here, or Node re-throws
// it as an uncaughtException and the sidecar auto-reaper tears everything down.
child.on("error", () => {
/* non-fatal: the URL was already printed above */
});
child.unref();
} catch {
/* non-fatal: the URL is printed regardless */
}
}

/**
* Resolve + validate the `--port` flag. Returns the port, or an actionable error
* string for the friendly `exit 2` path. Rejects out-of-range ports and 4001
* (which `spawnSidecar` reserves and would otherwise surface as a generic crash).
*/
export function resolvePlaygroundPort(
raw: string | boolean | undefined,
): { port: number } | { error: string } {
const port = typeof raw === "string" ? Number(raw) : 8131;
if (!Number.isInteger(port) || port <= 0 || port > 65535 || port === 4001) {
return {
error: `--port must be a port in 1..65535 and not 4001 (got ${String(raw)})`,
};
}
return { port };
}

/** Default cache dir for the fetched binary (keeps a throwaway run out of cwd). */
export const DEFAULT_PLAYGROUND_CACHE = path.join(os.tmpdir(), "amd-gaia-agent-email");

async function cmdPlayground(args: ParsedArgs): Promise<number> {
const parsed = resolvePlaygroundPort(args.flags.port);
if ("error" in parsed) {
process.stderr.write(`error: ${parsed.error}\n`);
return 2;
}
const { port } = parsed;
// Cache the binary in a temp dir by default so a throwaway `npx ... playground`
// run doesn't litter the cwd; fetchBinary is a cache-hit on the second run.
const outDir =
typeof args.flags.out === "string" ? args.flags.out : DEFAULT_PLAYGROUND_CACHE;

process.stdout.write(`[agent-email] fetching the sidecar binary -> ${outDir}\n`);
const { binaryPath } = await fetchBinary({
outDir,
baseUrl: typeof args.flags["base-url"] === "string" ? args.flags["base-url"] : undefined,
});

process.stdout.write(`[agent-email] starting the sidecar on 127.0.0.1:${port} ...\n`);
// Own the lifecycle here (autoCleanup off) so the graceful shutdown below
// actually runs — the default auto-reaper would SIGKILL the tree first.
const sidecar = await startSidecar({ binaryPath, port, autoCleanup: false });

// autoCleanup is off, so nothing reaps the sidecar on a throw until the signal
// handlers below are installed. A throw in that window (e.g. EPIPE from a stdout
// write into a closed pipe — `npx … playground | head`) would orphan it, so guard
// the whole post-start region and shut down on the way out.
try {
const url = `http://127.0.0.1:${port}/v1/email/playground`;
process.stdout.write(`\n ▸ Playground: ${url}\n`);
process.stdout.write(` (Lemonade must be running for live triage — the page tells you if it isn't.)\n`);
process.stdout.write(` Press Ctrl+C to stop the sidecar.\n\n`);
if (!args.flags["no-open"]) openBrowser(url);

// Stay alive until interrupted, then shut the sidecar down cleanly. We own all
// the signals the auto-reaper would have handled (it's off, above).
await new Promise<void>((resolve) => {
let stopping = false;
const stop = (): void => {
if (stopping) return; // a second signal shouldn't re-enter shutdown
stopping = true;
process.stdout.write("\n[agent-email] stopping the sidecar ...\n");
void shutdown(sidecar)
.catch(() => undefined)
.finally(resolve);
};
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
process.once(sig, stop);
}
});
return 0;
} catch (e) {
await shutdown(sidecar).catch(() => undefined);
throw e;
}
}

function cmdVersion(): number {
const lock = loadLock();
process.stdout.write(
Expand All @@ -131,6 +244,8 @@ async function main(): Promise<number> {
const args = parseArgs(process.argv.slice(2));
const cmd = args._[0] ?? "help";
switch (cmd) {
case "playground":
return cmdPlayground(args);
case "fetch":
return cmdFetch(args);
case "version":
Expand All @@ -145,14 +260,26 @@ async function main(): Promise<number> {
}
}

main()
.then((code) => process.exit(code))
.catch((e) => {
// Fail loudly with an actionable message; never swallow.
if (e instanceof AgentEmailError) {
process.stderr.write(`[agent-email] ${e.name}: ${e.message}\n`);
} else {
process.stderr.write(`[agent-email] unexpected error: ${(e as Error).stack ?? e}\n`);
}
process.exit(1);
});
/** True when this file is the entry point (so importing it for tests is a no-op). */
function invokedDirectly(): boolean {
if (!process.argv[1]) return false;
try {
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
} catch {
return false;
}
}

if (invokedDirectly()) {
main()
.then((code) => process.exit(code))
.catch((e) => {
// Fail loudly with an actionable message; never swallow.
if (e instanceof AgentEmailError) {
process.stderr.write(`[agent-email] ${e.name}: ${e.message}\n`);
} else {
process.stderr.write(`[agent-email] unexpected error: ${(e as Error).stack ?? e}\n`);
}
process.exit(1);
});
}
40 changes: 40 additions & 0 deletions hub/agents/npm/agent-email/test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
import { describe, expect, it } from "vitest";

import { DEFAULT_PLAYGROUND_CACHE, resolvePlaygroundPort } from "../src/cli.js";

describe("playground --port validation", () => {
it("defaults to 8131 when no value is given", () => {
expect(resolvePlaygroundPort(undefined)).toEqual({ port: 8131 });
// a bare `--port` (no value) parses to boolean true → still the default
expect(resolvePlaygroundPort(true)).toEqual({ port: 8131 });
});

it("accepts a valid port", () => {
expect(resolvePlaygroundPort("3000")).toEqual({ port: 3000 });
expect(resolvePlaygroundPort("65535")).toEqual({ port: 65535 });
});

it.each(["abc", "0", "-1", "70000", "8131.5", "4001"])(
"rejects %s with an actionable error (the friendly exit-2 path)",
(bad) => {
const r = resolvePlaygroundPort(bad);
expect(r).toHaveProperty("error");
expect((r as { error: string }).error).toContain("--port");
},
);

it("explicitly rejects the reserved 4001 (spawnSidecar would RangeError)", () => {
expect(resolvePlaygroundPort("4001")).toEqual({
error: expect.stringContaining("4001"),
});
});
});

describe("playground binary cache", () => {
it("defaults the binary cache to a temp dir (not the cwd)", () => {
expect(DEFAULT_PLAYGROUND_CACHE).toContain("amd-gaia-agent-email");
expect(DEFAULT_PLAYGROUND_CACHE).not.toBe("amd-gaia-agent-email"); // absolute, under tmpdir
});
});
Loading
Loading