Skip to content

Commit 22cbcd1

Browse files
authored
feat(agent-email): one-command playground launcher + live hub launch card (#1846)
## Why this matters Trying the email agent meant a multi-step dance — fetch the binary, spawn the sidecar, find the localhost URL, open it. This makes it **one command** (`npx @amd-gaia/agent-email playground`) and surfaces it on the hub agent page with a copy-the-command card whose **Open playground** button goes live the moment the local sidecar is reachable. A static web page **can't start a local process**, so this is the meet-in-the-middle: a one-command launcher in the CLI + a smart button on the page that detects when the sidecar is up. ## What's in it - **npm CLI** — new `playground` command: `fetchBinary` → `startSidecar` → open the default browser to `/v1/email/playground` → run until Ctrl+C (auto-reaped on exit). Flags: `--port` (default 8131), `--out` (binary cache dir), `--no-open`. - **Hub agent page** — a Playground card: the one-liner with a copy button, plus an **Open playground** button gated by a **live liveness probe** of the local sidecar (a `no-cors` fetch resolves if it's up, rejects if not; `127.0.0.1` is a trustworthy origin so the HTTPS hub may probe/link it). Shown only for npm agents that declare a `playground_url` (from #1839). - **Docs synced** (README/SPEC/SKILL/CHANGELOG) per the doc-sync rule (#1842). ## Test plan - [ ] npm: `cd hub/agents/npm/agent-email && npm run build && npm test` (36); `node dist/cli.js help` lists `playground` - [ ] Website: `cd website && npx astro check` (0 errors) + `npx vitest run` (19) + build - [ ] Hub agent page: Playground card shows the command (copy works) + an Open button that activates only once the local sidecar responds - [ ] End-to-end (needs the published binary): `npx @amd-gaia/agent-email playground` fetches, starts the sidecar, and opens the playground
1 parent f2d4eb1 commit 22cbcd1

7 files changed

Lines changed: 323 additions & 34 deletions

File tree

hub/agents/npm/agent-email/CHANGELOG.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ follows [SemVer](https://semver.org/): the **MAJOR** of the on-the-wire
55
`SCHEMA_VERSION` is what `checkVersion` enforces at startup, so a contract MAJOR
66
bump is always at least a package MINOR bump with a migration note.
77

8-
## [Unreleased]
8+
## 0.2.1
9+
10+
Adds the one-command `playground` launcher and automatic sidecar cleanup, and
11+
makes this README the single canonical agent README (hub + npm). No wire-contract
12+
change (`SCHEMA_VERSION` stays `2.0`).
913

1014
### Added
1115

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

21-
## 0.2.1
22-
23-
Documentation/packaging release — no client API or wire-contract change
24-
(`SCHEMA_VERSION` stays `2.0`). Republishes so the live hub catalog picks up the
25-
current README.
26-
2730
### Changed
2831

2932
- **This README is now the single canonical agent README** (hub + npm). The

hub/agents/npm/agent-email/README.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @amd-gaia/agent-email
22

3-
[![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**
3+
[![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**
44

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

224224
## Playground
225225

226-
Once the sidecar is running, open
226+
One command fetches the binary, starts the sidecar, and opens the playground:
227+
228+
```bash
229+
npx @amd-gaia/agent-email playground
230+
```
231+
232+
It serves a zero-setup, **localhost-only** page at
227233
[http://127.0.0.1:8131/v1/email/playground](http://127.0.0.1:8131/v1/email/playground)
228-
— a zero-setup, **localhost-only** page with a stack-health check, live triage and
229-
draft, and a Connectors panel to connect Gmail/Outlook and try a live send. It's
230-
served same-origin under a strict CSP, so the page can only ever reach your local
231-
sidecar: triage and draft stay on-device, while a `send` transmits to your mail
232-
provider by definition.
234+
— a stack-health check, live triage and draft, and a Connectors panel to connect
235+
Gmail/Outlook and try a live send. It's served same-origin under a strict CSP, so
236+
the page can only ever reach your local sidecar: triage and draft stay on-device,
237+
while a `send` transmits to your mail provider by definition. Press Ctrl+C to stop
238+
(`--port <n>` to bind elsewhere, `--no-open` to skip auto-opening the browser,
239+
`--out <dir>` to choose where the binary is cached).
233240

234241
## Requirements
235242

hub/agents/npm/agent-email/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,5 +148,10 @@ A green path looks like: `fetchBinary` succeeds → `startSidecar` resolves →
148148
`triage` 502s, start Lemonade and pull the model, then retry — the rest of your
149149
integration is fine.
150150

151+
To eyeball the agent by hand without writing any code, run
152+
`npx @amd-gaia/agent-email playground` — it fetches the binary, starts the sidecar,
153+
and opens an interactive page where you can fire triage/draft and see a stack-health
154+
check.
155+
151156
For the full endpoint list, lifecycle internals, and connector details, see
152157
`SPEC.md` next to this file.

hub/agents/npm/agent-email/SPEC.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,19 +122,27 @@ control, the steps are exported individually:
122122
- `verifySha256(buf, expected, label)` → throws `IntegrityError` on mismatch.
123123
- `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.
124124

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

132127
```bash
128+
npx @amd-gaia/agent-email playground # fetch + run the sidecar, open the playground
133129
npx @amd-gaia/agent-email fetch --out resources
134-
npx @amd-gaia/agent-email version # show manifest + current platform
130+
npx @amd-gaia/agent-email version # show manifest + current platform
135131
npx @amd-gaia/agent-email help
136132
```
137133

134+
`playground` is the zero-to-running shortcut: it `fetchBinary`s into a temp cache
135+
(`--out` to override), `startSidecar`s on `--port` (default 8131), opens the default
136+
browser to `/v1/email/playground` (`--no-open` to skip), and runs until Ctrl+C.
137+
The command owns the sidecar lifecycle itself (`autoCleanup: false`) and shuts it
138+
down on `SIGINT`/`SIGTERM`/`SIGHUP` or on any startup error. Lemonade still has to
139+
be running for live triage — the page itself reports if it isn't.
140+
141+
`fetch` is the supported, build-time path. It resolves
142+
`${process.platform}-${process.arch}`, downloads that platform's artifact from the
143+
base URL in `binaries.lock.json`, **verifies its SHA-256 against the lock and fails
144+
loudly on any mismatch**, writes it to `--out`, and `chmod +x`'s it on POSIX.
145+
138146
| Flag | Meaning |
139147
|------|---------|
140148
| `--out <dir>` | Resources dir to write the verified binary into (**required**) |

hub/agents/npm/agent-email/src/cli.ts

Lines changed: 139 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@
1313
* marker but build-time fetch remains the recommended flow.
1414
*/
1515

16+
import { spawn } from "node:child_process";
17+
import { realpathSync } from "node:fs";
18+
import os from "node:os";
19+
import path from "node:path";
20+
import { fileURLToPath } from "node:url";
21+
1622
import { fetchBinary } from "./fetch.js";
23+
import { shutdown, startSidecar } from "./lifecycle.js";
1724
import { currentPlatformKey, loadLock } from "./platform.js";
1825
import { AgentEmailError } from "./errors.js";
1926

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

3037
function parseArgs(argv: string[]): ParsedArgs {
3138
const out: ParsedArgs = { _: [], flags: {} };
@@ -54,10 +61,17 @@ function parseArgs(argv: string[]): ParsedArgs {
5461
const HELP = `@amd-gaia/agent-email — GAIA email agent binary fetcher + client
5562
5663
Usage:
64+
agent-email playground [options] Fetch + run the sidecar and open the playground
5765
agent-email fetch --out <dir> [options] Download + SHA-256 verify the binary
5866
agent-email version Print package + lock manifest info
5967
agent-email help Show this help
6068
69+
playground options:
70+
--port <n> Bind port (default 8131)
71+
--out <dir> Where to cache the binary (default: a temp dir)
72+
--base-url <url> Override the download base URL from binaries.lock.json
73+
--no-open Don't auto-open the browser; just print the URL
74+
6175
fetch options:
6276
--out <dir> Resources dir to write the verified binary into (required)
6377
--base-url <url> Override the download base URL from binaries.lock.json
@@ -109,6 +123,105 @@ async function cmdFetch(args: ParsedArgs): Promise<number> {
109123
return 0;
110124
}
111125

126+
/** Best-effort cross-platform "open this URL in the default browser". */
127+
function openBrowser(url: string): void {
128+
try {
129+
const [cmd, args] =
130+
process.platform === "darwin"
131+
? ["open", [url]]
132+
: process.platform === "win32"
133+
? ["cmd", ["/c", "start", "", url]]
134+
: ["xdg-open", [url]];
135+
const child = spawn(cmd, args as string[], { stdio: "ignore", detached: true });
136+
// A missing opener (headless / container / WSL without wslu) is reported via
137+
// an async 'error' event, NOT a sync throw — swallow it here, or Node re-throws
138+
// it as an uncaughtException and the sidecar auto-reaper tears everything down.
139+
child.on("error", () => {
140+
/* non-fatal: the URL was already printed above */
141+
});
142+
child.unref();
143+
} catch {
144+
/* non-fatal: the URL is printed regardless */
145+
}
146+
}
147+
148+
/**
149+
* Resolve + validate the `--port` flag. Returns the port, or an actionable error
150+
* string for the friendly `exit 2` path. Rejects out-of-range ports and 4001
151+
* (which `spawnSidecar` reserves and would otherwise surface as a generic crash).
152+
*/
153+
export function resolvePlaygroundPort(
154+
raw: string | boolean | undefined,
155+
): { port: number } | { error: string } {
156+
const port = typeof raw === "string" ? Number(raw) : 8131;
157+
if (!Number.isInteger(port) || port <= 0 || port > 65535 || port === 4001) {
158+
return {
159+
error: `--port must be a port in 1..65535 and not 4001 (got ${String(raw)})`,
160+
};
161+
}
162+
return { port };
163+
}
164+
165+
/** Default cache dir for the fetched binary (keeps a throwaway run out of cwd). */
166+
export const DEFAULT_PLAYGROUND_CACHE = path.join(os.tmpdir(), "amd-gaia-agent-email");
167+
168+
async function cmdPlayground(args: ParsedArgs): Promise<number> {
169+
const parsed = resolvePlaygroundPort(args.flags.port);
170+
if ("error" in parsed) {
171+
process.stderr.write(`error: ${parsed.error}\n`);
172+
return 2;
173+
}
174+
const { port } = parsed;
175+
// Cache the binary in a temp dir by default so a throwaway `npx ... playground`
176+
// run doesn't litter the cwd; fetchBinary is a cache-hit on the second run.
177+
const outDir =
178+
typeof args.flags.out === "string" ? args.flags.out : DEFAULT_PLAYGROUND_CACHE;
179+
180+
process.stdout.write(`[agent-email] fetching the sidecar binary -> ${outDir}\n`);
181+
const { binaryPath } = await fetchBinary({
182+
outDir,
183+
baseUrl: typeof args.flags["base-url"] === "string" ? args.flags["base-url"] : undefined,
184+
});
185+
186+
process.stdout.write(`[agent-email] starting the sidecar on 127.0.0.1:${port} ...\n`);
187+
// Own the lifecycle here (autoCleanup off) so the graceful shutdown below
188+
// actually runs — the default auto-reaper would SIGKILL the tree first.
189+
const sidecar = await startSidecar({ binaryPath, port, autoCleanup: false });
190+
191+
// autoCleanup is off, so nothing reaps the sidecar on a throw until the signal
192+
// handlers below are installed. A throw in that window (e.g. EPIPE from a stdout
193+
// write into a closed pipe — `npx … playground | head`) would orphan it, so guard
194+
// the whole post-start region and shut down on the way out.
195+
try {
196+
const url = `http://127.0.0.1:${port}/v1/email/playground`;
197+
process.stdout.write(`\n ▸ Playground: ${url}\n`);
198+
process.stdout.write(` (Lemonade must be running for live triage — the page tells you if it isn't.)\n`);
199+
process.stdout.write(` Press Ctrl+C to stop the sidecar.\n\n`);
200+
if (!args.flags["no-open"]) openBrowser(url);
201+
202+
// Stay alive until interrupted, then shut the sidecar down cleanly. We own all
203+
// the signals the auto-reaper would have handled (it's off, above).
204+
await new Promise<void>((resolve) => {
205+
let stopping = false;
206+
const stop = (): void => {
207+
if (stopping) return; // a second signal shouldn't re-enter shutdown
208+
stopping = true;
209+
process.stdout.write("\n[agent-email] stopping the sidecar ...\n");
210+
void shutdown(sidecar)
211+
.catch(() => undefined)
212+
.finally(resolve);
213+
};
214+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
215+
process.once(sig, stop);
216+
}
217+
});
218+
return 0;
219+
} catch (e) {
220+
await shutdown(sidecar).catch(() => undefined);
221+
throw e;
222+
}
223+
}
224+
112225
function cmdVersion(): number {
113226
const lock = loadLock();
114227
process.stdout.write(
@@ -131,6 +244,8 @@ async function main(): Promise<number> {
131244
const args = parseArgs(process.argv.slice(2));
132245
const cmd = args._[0] ?? "help";
133246
switch (cmd) {
247+
case "playground":
248+
return cmdPlayground(args);
134249
case "fetch":
135250
return cmdFetch(args);
136251
case "version":
@@ -145,14 +260,26 @@ async function main(): Promise<number> {
145260
}
146261
}
147262

148-
main()
149-
.then((code) => process.exit(code))
150-
.catch((e) => {
151-
// Fail loudly with an actionable message; never swallow.
152-
if (e instanceof AgentEmailError) {
153-
process.stderr.write(`[agent-email] ${e.name}: ${e.message}\n`);
154-
} else {
155-
process.stderr.write(`[agent-email] unexpected error: ${(e as Error).stack ?? e}\n`);
156-
}
157-
process.exit(1);
158-
});
263+
/** True when this file is the entry point (so importing it for tests is a no-op). */
264+
function invokedDirectly(): boolean {
265+
if (!process.argv[1]) return false;
266+
try {
267+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
268+
} catch {
269+
return false;
270+
}
271+
}
272+
273+
if (invokedDirectly()) {
274+
main()
275+
.then((code) => process.exit(code))
276+
.catch((e) => {
277+
// Fail loudly with an actionable message; never swallow.
278+
if (e instanceof AgentEmailError) {
279+
process.stderr.write(`[agent-email] ${e.name}: ${e.message}\n`);
280+
} else {
281+
process.stderr.write(`[agent-email] unexpected error: ${(e as Error).stack ?? e}\n`);
282+
}
283+
process.exit(1);
284+
});
285+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
import { describe, expect, it } from "vitest";
4+
5+
import { DEFAULT_PLAYGROUND_CACHE, resolvePlaygroundPort } from "../src/cli.js";
6+
7+
describe("playground --port validation", () => {
8+
it("defaults to 8131 when no value is given", () => {
9+
expect(resolvePlaygroundPort(undefined)).toEqual({ port: 8131 });
10+
// a bare `--port` (no value) parses to boolean true → still the default
11+
expect(resolvePlaygroundPort(true)).toEqual({ port: 8131 });
12+
});
13+
14+
it("accepts a valid port", () => {
15+
expect(resolvePlaygroundPort("3000")).toEqual({ port: 3000 });
16+
expect(resolvePlaygroundPort("65535")).toEqual({ port: 65535 });
17+
});
18+
19+
it.each(["abc", "0", "-1", "70000", "8131.5", "4001"])(
20+
"rejects %s with an actionable error (the friendly exit-2 path)",
21+
(bad) => {
22+
const r = resolvePlaygroundPort(bad);
23+
expect(r).toHaveProperty("error");
24+
expect((r as { error: string }).error).toContain("--port");
25+
},
26+
);
27+
28+
it("explicitly rejects the reserved 4001 (spawnSidecar would RangeError)", () => {
29+
expect(resolvePlaygroundPort("4001")).toEqual({
30+
error: expect.stringContaining("4001"),
31+
});
32+
});
33+
});
34+
35+
describe("playground binary cache", () => {
36+
it("defaults the binary cache to a temp dir (not the cwd)", () => {
37+
expect(DEFAULT_PLAYGROUND_CACHE).toContain("amd-gaia-agent-email");
38+
expect(DEFAULT_PLAYGROUND_CACHE).not.toBe("amd-gaia-agent-email"); // absolute, under tmpdir
39+
});
40+
});

0 commit comments

Comments
 (0)