diff --git a/harness-plugins/hermes/README.md b/harness-plugins/hermes/README.md index 153e466..d1bbe7d 100644 --- a/harness-plugins/hermes/README.md +++ b/harness-plugins/hermes/README.md @@ -1,10 +1,62 @@ # Hermes plugin -A real Hermes plugin. Hermes plugins are **Python**: a `plugin.yaml` manifest plus an `__init__.py` with `register(ctx)` that calls `ctx.register_tool(...)`. This plugin registers 11 tools that build and operate Agent Apps by shelling the framework CLIs. +A Hermes plugin (`plugin.yaml` + `register(ctx)`, Python) whose dashboard tab is the same **Agent Apps manager** the OpenClaw and dsh plugins ship, plus the build + operate tools, the six framework skills, and the `/agent-app` command. -- **Manifest:** [plugin.yaml](plugin.yaml) — `kind: backend`, `provides_tools: [...]`. -- **Code:** [__init__.py](__init__.py) — `register(ctx)` → `ctx.register_tool(name=, toolset="agent_app", schema=, handler=, emoji=)`; handlers return the CLI output (a guard rejection is returned to the model, not swallowed). +## Surfaces -Drop this directory into `~/.hermes/plugins/`. Set `A2APP_CLI` (operate, default `a2app`) and `AGENT_APP_CLI` (build/evolve, default `agent-app`); a `…/cli.js` entry is run with Node. No shell is used, so field values can't inject. +- **Agent Apps dashboard tab** ([dashboard/](dashboard/)): a browser-style tab strip with one tab per Agent App (status dot; the name greys out when the app is offline) and a **New +** tab. An app tab embeds the running app; an offline app shows **Launch**; the active tab's **⋯** menu offers Pause / Launch, Open in browser, Show session, and Delete (two-step confirm; `agent-app remove --yes` backs data up before deleting). The **New +** form (name, requirement, stack, optional port) starts the build directly. +- **Per-app session panel**: each app has its own Hermes session (id `agent-app--`). The right panel renders its transcript and a composer. Build progress shows up there as it happens, and evolve or operate requests are messages into that session. A failed turn shows why (for example, no model configured). +- **Tools**: the seventeen `agent_app_*` tools of the shared engine, in the toolset `agent_app`, each shelling a real `agent-app` or `a2app` verb. A relative `dir` resolves against the agent's terminal directory. +- **Skills**: the six framework skills, registered as plugin skills (`agent-app:creator`, `agent-app:modify`, ...) and loaded with `skill_view`. Hermes keeps plugin skills out of the `` index and gives them no slash command, so the plugin also registers a system-prompt section that routes any app request to the right one. +- **`/agent-app `**: hands the request to the agent. In the classic CLI the command injects it as the next turn. On messaging platforms a `pre_gateway_dispatch` hook rewrites `/agent-app ` (or `/agent_app`, for Telegram) into the request before dispatch. The TUI and the dashboard chat give plugin commands no way to start a turn, so there the command says to send the request as a normal message, which the routing section still covers. +- **`hermes agent-app `**: a passthrough to the CLIs, routed by verb (build/evolve verbs to `agent-app`, the rest to `a2app`), output streamed and exit code propagated. +- **Bridge route**: on load the plugin registers Hermes with `agent-app bridge`, the service that starts a harness when an app queues work, as a `hermes` profile in `$A2APP_HOME/harnesses.json`: `hermes --cli chat -Q -q {prompt}`, one turn in the app's directory. The framework has no built-in Hermes profile, so without this the bridge cannot see Hermes. Written once; an existing `hermes` entry (your own) is kept. -**Verified:** the plugin registers 11 tools and its handlers drive the real CLI — `agent_app_build` scaffolds an app and `agent_app_validate` runs the real gate (framework files + Python self-test + ownership all pass). +## How it works + +`register(ctx)` ([__init__.py](__init__.py)) runs in every Hermes process and registers the tools, skills, routing section, command, gateway hook, passthrough, and bridge route. The tab is a separate half: `hermes dashboard` imports [dashboard/plugin_api.py](dashboard/plugin_api.py) at startup and mounts its `router` at `/api/plugins/agent-app/`, where the dashboard's own session-token auth covers it. The page ([dashboard/manager.js](dashboard/manager.js)) is a classic script on the dashboard plugin SDK and calls that API with the SDK's `fetchJSON`. + +A session turn is one subprocess, `hermes --cli chat -Q --resume --source tool --in --query-file -`, with the message on stdin. This is how Hermes runs its own background workers (the kanban dispatcher), so a turn uses your configured model, toolsets, and approval policy, and nothing depends on agent internals. Turns on one session run one at a time, in order. The `tool` source keeps these sessions out of Hermes's session lists; they live in the tab. Each turn's output is appended to `$HERMES_HOME/plugin-data/agent-app/logs/.log`, and the transcript is read back from the session database. New apps are created under `$A2APP_HOME/apps/` (default `~/.a2app/apps`), as in the other managers. App status comes from `agent-app list --json`, polled by the page. + +[engine.py](engine.py) is a Python port of the shared TypeScript engine ([../integration-starter/](../integration-starter/)): same tool names, schemas, and argv, same build kickoff prompt, same harness-file rules. No shell is used. On Windows an npm `.cmd` shim is read for the script it runs and Node is started on that script directly, so a field value holding `& | ^ < > %` or quotes reaches the CLI intact. + +**Embedding:** an app listens on the Hermes machine's loopback interface, so the tab embeds it when the dashboard is open over http on that machine (`http://127.0.0.1:9119` by default). Opened from another host or over HTTPS, the tab shows the app's URL in place of a frame that could not load. + +**Hermes Desktop** does not load dashboard plugins (it has its own plugin SDK), so the tab appears in `hermes dashboard`. The tools, skills, command, and passthrough work on every Hermes surface. + +## Build and install + +Requires the framework CLIs on `PATH` (`npm i -g agent-app-framework`; from a cloned repo, `pnpm -r build` then `npm link` in `framework/cli`). The Hermes home is `$HERMES_HOME`, default `~/.hermes` (`%LOCALAPPDATA%\hermes` on native Windows). + +From a cloned repo, [scripts/build.py](scripts/build.py) stages a self-contained `dist/` (the plugin plus the six skills from the repo-root [../../skills](../../skills)) and `--install` copies it to `$HERMES_HOME/plugins/agent-app`: + +```bash +python harness-plugins/hermes/scripts/build.py --install +hermes plugins enable agent-app --no-allow-tool-override +hermes dashboard +``` + +With Hermes's own installer, from GitHub: + +```bash +hermes plugins install CraftOS-dev/Agent-App/harness-plugins/hermes --enable +``` + +That clones this directory, which holds no copy of the skills, so the plugin uses the ones the installed CLI ships (`agent-app skills --path`). + +Hermes plugins are opt-in: nothing loads until `agent-app` is in `plugins.enabled`, which `hermes plugins enable` writes, and it takes effect in the next session. Restart a running `hermes dashboard` after enabling, because the dashboard mounts plugin APIs at startup. + +## Configuration + +- `AGENT_APP_CLI` (build/evolve, default `agent-app`) and `A2APP_CLI` (operate, default `a2app`): the binaries to shell. A value ending in `.js`/`.mjs`/`.cjs` is run with Node directly. +- `A2APP_HOME`: the framework home (default `~/.a2app`), where new apps and `harnesses.json` go. +- `HERMES_BIN`: the `hermes` the tab runs session turns with. Default: the dashboard's own interpreter (`python -m hermes_cli.main`), which is the same install and profile. +- Approvals: session turns and bridge runs are single-query runs, so a command Hermes flags as dangerous follows `approvals.single_query_mode` (default `deny`: the command is blocked and the agent is told). The plugin grants nothing on your behalf. + +## Tests + +```bash +python harness-plugins/hermes/test/test_plugin.py +``` + +Standard library plus Node (the fake CLIs are `.js` entries); the dashboard API tests also need FastAPI and are skipped without it. The suite loads the plugin the way Hermes's loader does and the backend the way the dashboard does, with fakes for the plugin context and the `hermes` binary, and checks argv and kickoff prompts against the compiled TypeScript engine when `@a2app/integration-starter` is built. diff --git a/harness-plugins/hermes/__init__.py b/harness-plugins/hermes/__init__.py index 33f1558..0bced95 100644 --- a/harness-plugins/hermes/__init__.py +++ b/harness-plugins/hermes/__init__.py @@ -1,183 +1,221 @@ -"""Agent App Framework plugin for Hermes. - -A native Hermes backend plugin (Hermes plugins are Python). It registers tools -that build and operate Agent Apps by shelling the framework CLIs, so anything an -agent does over the A2App protocol, Hermes can do through these tools. Drop this -directory into `~/.hermes/plugins/`; set `A2APP_CLI` (operate, default `a2app`) and `AGENT_APP_CLI` -(build/evolve, default `agent-app`) to override the binaries. - -Every tool shells the real CLI and returns its output verbatim — a guard -rejection (invalid enum, relative date, …) is useful data, so it is returned to -the model rather than swallowed. +"""Agent App Framework plugin for Hermes: the agent side. + +`register(ctx)` wires the framework into Hermes through its own plugin API: + + - the build + operate tools (`agent_app_*`, toolset `agent_app`), each + shelling a real `agent-app` or `a2app` verb; + - the six framework skills, as plugin skills (`agent-app:creator`, ...); + - a system-prompt section that routes any app request to the owning skill, + which is how plugin skills are found at all (Hermes keeps them out of the + `` index and gives them no slash commands); + - the `/agent-app ` command, which hands the request to the agent: + `ctx.inject_message` in the classic CLI, a `pre_gateway_dispatch` rewrite + on messaging platforms; + - `hermes agent-app `, a passthrough to both CLIs; + - the `hermes` route in `$A2APP_HOME/harnesses.json`, so + `agent-app bridge` can start Hermes when an app queues work. + +The Agent Apps dashboard tab lives beside this, in `dashboard/` (Hermes loads +dashboard plugins in the dashboard process, not through `register`). """ +from __future__ import annotations + +import argparse +import logging import os -import subprocess - -CLI = os.environ.get("A2APP_CLI", "a2app") -# Build/evolve is a second binary; the operate client rejects build verbs by -# design (framework spec 5.1), so each verb is routed to its owner. -FRAMEWORK_CLI = os.environ.get("AGENT_APP_CLI", "agent-app") -FRAMEWORK_VERBS = { - "scaffold", "import", "validate", "toolkit-sync", "adapter-sync", "serve", "stop", - "list", "global", "skills", "dev", "promote", "backup", "restore", +import re +import sys + +from . import engine + +logger = logging.getLogger(__name__) + +# How `agent-app bridge` starts Hermes when an app queues work: one +# turn, run in the app's directory, then exit (`-Q` exits after answering +# even on a TTY; `--cli` keeps a `display.interface: tui` default from taking +# over a run nobody is watching). The framework has no built-in Hermes +# profile, so without this entry the bridge cannot see Hermes. Dangerous +# commands follow the user's `approvals.single_query_mode` (deny by default); +# this route grants nothing on their behalf. +HERMES_PROFILE = { + "id": "hermes", + "name": "Hermes", + "routes": [{"mode": "headless", "command": "hermes", "args": ["--cli", "chat", "-Q", "-q", engine.PROMPT_PLACEHOLDER]}], } -# The closed set of verbs that address every app rather than one, and so take no -# app argument. Closed is what makes _verb exact: it never has to inspect a -# positional to guess what it is. -REGISTRY_VERBS = {"list", "global", "skills"} - - -def _verb(argv: list[str]) -> str: - """The verb in an app-first argv. - - Both CLIs are written ` [args]`, so the verb is the - SECOND element — except for a registry verb, which takes no app and - therefore stands alone in first position (framework spec 5.1). - """ - if argv and argv[0] in REGISTRY_VERBS: - return argv[0] - return argv[1] if len(argv) > 1 else "" - - -def _run(argv: list[str]) -> str: - # Run a JS entry (…/cli.js) with node; never use a shell, so field values - # reach the CLI as literal arguments. - exe = FRAMEWORK_CLI if _verb(argv) in FRAMEWORK_VERBS else CLI - cmd = ["node", exe, *argv] if exe.endswith((".js", ".mjs", ".cjs")) else [exe, *argv] - try: - proc = subprocess.run(cmd, capture_output=True, text=True) - except FileNotFoundError: - return f"framework CLI not found (looked for {exe!r}). Install it with `npm i -g agent-app`, or set A2APP_CLI / AGENT_APP_CLI to the binary paths." - out = ((proc.stdout or "") + (proc.stderr or "")).strip() - return out or f"(exit {proc.returncode})" - - -# --------------------------------------------------------------- handlers - -def _describe(args: dict, **_kw) -> str: - # Describe is navigational: the path names ONE place in the app. - segments = [p for p in str(args.get("path", "")).split("/") if p] - return _run([str(args["dir"]), *segments]) - - -def _list(args: dict, **_kw) -> str: - a = [str(args["dir"]), "data", str(args["entity"]), "list"] - if args.get("filter"): - a += ["--filter", str(args["filter"])] - if args.get("sort"): - a += ["--sort", str(args["sort"])] - if args.get("limit") is not None: - a += ["--limit", str(args["limit"])] - return _run(a) - - -def _get(args: dict, **_kw) -> str: - return _run([str(args["dir"]), "data", str(args["entity"]), "get", str(args["id"])]) - - -def _create(args: dict, **_kw) -> str: - import json - return _run([str(args["dir"]), "data", str(args["entity"]), "create", "--json", json.dumps(args.get("fields") or {})]) - - -def _update(args: dict, **_kw) -> str: - import json - return _run([str(args["dir"]), "data", str(args["entity"]), "update", str(args["id"]), "--json", json.dumps(args.get("fields") or {})]) - - -def _delete(args: dict, **_kw) -> str: - return _run([str(args["dir"]), "data", str(args["entity"]), "delete", str(args["id"])]) +# One line per skill for the routing section; the full descriptions are long +# and the section has a hard size limit. +_SKILL_ROLES = { + "creator": "build a NEW app", + "modify": "change an EXISTING app", + "operator": "use, read, or run an app (no code changes)", + "importer": "adopt existing software", + "walk-verify": "independent verification, never by the builder", + "connect": "a published app you do not own", +} -def _find(args: dict, **_kw) -> str: - return _run([str(args["dir"]), "--find", str(args["term"])]) - - -def _run_operation(args: dict, **_kw) -> str: - a = [str(args["dir"]), *[p for p in str(args["path"]).split("/") if p], str(args["operation"])] - for key, value in (args.get("fields") or {}).items(): - a += [f"--{key}", str(value)] - if args.get("approve"): - a += ["--approve", str(args["approve"])] - return _run(a) - - -def _poll_tasks(args: dict, **_kw) -> str: - if args.get("status"): - return _run([str(args["dir"]), "tasks", "--status", str(args["status"])]) - return _run([str(args["dir"]), "tasks"]) - - -def _build(args: dict, **_kw) -> str: - a = [str(args["dir"]), "scaffold"] - if args.get("blueprint"): - a += ["--blueprint", str(args["blueprint"])] - if args.get("name"): - a += ["--name", str(args["name"])] - return _run(a) - - -def _validate(args: dict, **_kw) -> str: - return _run([str(args["dir"]), "validate", "--no-build"] if args.get("noBuild") else [str(args["dir"]), "validate"]) - - -# --------------------------------------------------------------- schemas - -_STR = {"type": "string"} -_DIR = {"type": "string", "description": "Agent App project directory"} -_ENTITY = {"type": "string", "description": "entity / collection name"} - - -def _schema(name: str, description: str, properties: dict, required: list[str]) -> dict: - return { - "name": name, - "description": description, - "parameters": {"type": "object", "properties": properties, "required": required}, - } +# The prefix a routed /agent-app request carries. A leading slash would send +# the injected text back through command dispatch. +ROUTED_PREFIX = "Agent App Framework request (/agent-app):" + +USAGE = ( + "Usage: /agent-app \n" + 'e.g. "/agent-app build a CRM", "/agent-app add a report to my expense app", ' + '"/agent-app operate atlas-erp"' +) + +# Where a plugin command cannot start a turn (the TUI, and the dashboard +# chat): say what to do instead of failing silently. +NO_TURN_HERE = ( + "This interface does not let a plugin command start a turn. Send the request as a " + 'normal message instead, e.g. "build a CRM as an Agent App": the Agent App skills ' + "are loaded and the request is routed to the right one." +) + +# `/agent-app ...` on a messaging platform. Telegram allows no hyphen in a +# command, and appends `@botname` in groups. +_GATEWAY_COMMAND = re.compile(r"^/agent[-_]app(?:@\S+)?(?:\s+(.*))?$", re.IGNORECASE | re.DOTALL) + + +def routed_prompt(request: str) -> str: + return f"{ROUTED_PREFIX} {request}" + + +def guidance(skills: list[tuple[str, object, dict]], namespace: str, skills_home: str | None) -> str: + """The system-prompt section that makes the framework reachable.""" + lines = [ + "## Agent App Framework", + "", + "An Agent App is a self-contained full-stack web app operated through its A2App adapter " + "(the `a2app` CLI), never by driving its UI. Build and evolve go through the `agent-app` CLI. " + "When a message asks to build, change, run, import, verify, or connect to an app (a message " + f'beginning "{ROUTED_PREFIX}" came from the /agent-app command), load the matching framework ' + "skill with skill_view and follow it end to end:", + ] + for name, _path, meta in skills: + role = _SKILL_ROLES.get(name) or str(meta.get("description", ""))[:120] + lines.append(f"- `{namespace}:{name}`: {role}") + lines += [ + "", + "Do not build or operate an app from general knowledge outside these skills. " + "`agent-app list` locates every known Agent App, and a command accepts a registered app id " + "or name wherever it takes a directory. The `agent_app_*` tools wrap the same CLIs.", + ] + if skills_home: + lines.append( + f"The skills' shared files (QUALITY.md, index.json) are in `{skills_home}`; read them " + "with your file tools when a skill cites them." + ) + return "\n".join(lines) + + +def _tool_handler(tool: engine.Tool): + def handler(args: dict, **_kw) -> str: + # A relative `dir` means what it means in the agent's terminal, whose + # working directory Hermes exports as TERMINAL_CWD (a remote terminal + # backend's path does not exist here, and is ignored). + cwd = os.environ.get("TERMINAL_CWD") or None + if cwd is not None and not os.path.isdir(cwd): + cwd = None + return tool.call(args if isinstance(args, dict) else {}, cwd=cwd).text() + + return handler + + +def _cli_setup(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "argv", + nargs=argparse.REMAINDER, + metavar="ARGS", + help="arguments for agent-app (build/evolve verbs) or a2app (operate); routed by verb", + ) + + +def _cli_run(args: argparse.Namespace) -> int: + argv = list(getattr(args, "argv", None) or []) + if argv and argv[0] == "--": + argv = argv[1:] + if not argv: + sys.stderr.write( + "usage: hermes agent-app [] [] | | list\n" + "Build/evolve verbs run agent-app; everything else runs a2app.\n" + ) + return 2 + return engine.run_passthrough(engine.bin_for(argv), argv) -_TOOLS = [ - (_schema("agent_app_describe", - 'Describe ONE place in an Agent App. `path` is empty for the root (its modules), "sales" for a ' - 'module, "sales/invoices" for an entity, "sales/invoices/INV-1" for a record and the operations ' - "its state allows. Every response names the legal next moves. No call returns the whole model.", - {"dir": _DIR, "path": _STR}, ["dir"]), _describe, "🔎"), - (_schema("agent_app_list", "List records of an entity (optional filter/sort/limit).", - {"dir": _DIR, "entity": _ENTITY, "filter": _STR, "sort": _STR, "limit": {"type": "integer"}}, ["dir", "entity"]), _list, "📋"), - (_schema("agent_app_get", "Fetch one record by id.", - {"dir": _DIR, "entity": _ENTITY, "id": _STR}, ["dir", "entity", "id"]), _get, "🔍"), - (_schema("agent_app_create", "Create a record; the app's guard validates it and rejections are returned verbatim.", - {"dir": _DIR, "entity": _ENTITY, "fields": {"type": "object"}}, ["dir", "entity", "fields"]), _create, "➕"), - (_schema("agent_app_update", "Update a record by id.", - {"dir": _DIR, "entity": _ENTITY, "id": _STR, "fields": {"type": "object"}}, ["dir", "entity", "id", "fields"]), _update, "✏️"), - (_schema("agent_app_delete", "Delete a record by id.", - {"dir": _DIR, "entity": _ENTITY, "id": _STR}, ["dir", "entity", "id"]), _delete, "🗑️"), - # No agent_app_operations: no global operation list exists. An operation is - # found on the screen it belongs to and invoked at the path identifying it. - (_schema("agent_app_find", - "Search entity, operation and module names across the app; returns their locations.", - {"dir": _DIR, "term": _STR}, ["dir", "term"]), _find, "🔎"), - (_schema("agent_app_run_operation", - "Invoke a declared operation at the path that identifies it. A destructive op returns " - "approval_required with a key; pass `approve` to execute.", - {"dir": _DIR, "path": _STR, "operation": _STR, "fields": {"type": "object"}, "approve": _STR}, - ["dir", "path", "operation"]), _run_operation, "▶️"), - (_schema("agent_app_poll_tasks", "Poll the app-to-agent task queue (default status: submitted).", - {"dir": _DIR, "status": _STR}, ["dir"]), _poll_tasks, "📥"), - (_schema("agent_app_build", "Scaffold a new Agent App from a blueprint.", - {"dir": _DIR, "blueprint": _STR, "name": _STR}, ["dir"]), _build, "🏗️"), - (_schema("agent_app_validate", "Run the validation + security gate.", - {"dir": _DIR, "noBuild": {"type": "boolean"}}, ["dir"]), _validate, "✅"), -] +def _on_pre_gateway_dispatch(event=None, **_kw): + text = getattr(event, "text", None) + if not isinstance(text, str): + return None + match = _GATEWAY_COMMAND.match(text.strip()) + if match is None: + return None + request = (match.group(1) or "").strip() + if not request: + return None # a bare command reaches its handler, which replies with usage + return {"action": "rewrite", "text": routed_prompt(request)} def register(ctx) -> None: """Called once by the Hermes plugin loader.""" - for schema, handler, emoji in _TOOLS: + namespace = getattr(getattr(ctx, "manifest", None), "name", None) or "agent-app" + + registration = engine.register_harness_profile(HERMES_PROFILE) + if registration["status"] == "registered": + logger.info("agent-app: %s", registration["detail"]) + elif registration["status"] == "refused": + logger.warning("agent-app: %s", registration["detail"]) + + for tool in engine.a2app_tools(): ctx.register_tool( - name=schema["name"], + name=tool.name, toolset="agent_app", - schema=schema, - handler=handler, - emoji=emoji, + schema=tool.schema(), + handler=_tool_handler(tool), + emoji=tool.emoji, ) + + skills_home = engine.skills_dir() + skills = engine.framework_skills(skills_home) if skills_home is not None else [] + for name, path, meta in skills: + try: + ctx.register_skill(name, path, description=meta["description"], frontmatter=meta) + except (ValueError, OSError) as exc: + logger.warning("agent-app: skill %s not registered: %s", name, exc) + if not skills: + logger.warning( + "agent-app: the framework skills were not found (no staged skills/ and `agent-app skills --path` " + "gave none). %s", + engine.INSTALL_HINT, + ) + + ctx.register_system_prompt_section( + "agent-app", + guidance(skills, namespace, str(skills_home) if skills_home is not None else None), + ) + + def slash(raw_args: str): + request = (raw_args or "").strip() + if not request: + return USAGE + if ctx.inject_message(routed_prompt(request)): + return None + return NO_TURN_HERE + + ctx.register_command( + "agent-app", + handler=slash, + description="Build, evolve, or operate an Agent App: routes the request to the right framework skill.", + args_hint="", + argument_mode="text", + ) + ctx.register_hook("pre_gateway_dispatch", _on_pre_gateway_dispatch) + + ctx.register_cli_command( + name="agent-app", + help="Run the Agent App Framework CLIs (build/evolve/operate an Agent App)", + setup_fn=_cli_setup, + handler_fn=_cli_run, + description="Passthrough to agent-app (build/evolve verbs) and a2app (operate), routed by verb.", + ) diff --git a/harness-plugins/hermes/dashboard/manager.css b/harness-plugins/hermes/dashboard/manager.css new file mode 100644 index 0000000..448e967 --- /dev/null +++ b/harness-plugins/hermes/dashboard/manager.css @@ -0,0 +1,145 @@ +/* + * The Agent Apps dashboard tab. Every rule is scoped under .aa: the dashboard + * loads plugin CSS into the whole page. Colours, fonts and radii come from the + * dashboard's own theme tokens (the --color-* set its shadcn components use), + * so the tab follows whichever theme is active; the fallbacks are the default + * theme's values. Tailwind classes are not used because the dashboard only + * generates the ones its own sources mention. + */ +.aa { + --aa-fg: var(--color-foreground, #ffe6cb); + --aa-muted: var(--color-muted-foreground, color-mix(in srgb, #ffe6cb 60%, transparent)); + --aa-border: var(--color-border, color-mix(in srgb, #ffe6cb 15%, transparent)); + --aa-border-strong: color-mix(in srgb, var(--aa-fg) 24%, transparent); + --aa-base: var(--background-base, #041c1c); + --aa-card: var(--color-card, color-mix(in srgb, #ffe6cb 4%, #041c1c)); + --aa-hover: color-mix(in srgb, var(--aa-fg) 8%, transparent); + --aa-subtle: color-mix(in srgb, var(--aa-fg) 5%, transparent); + --aa-primary: var(--color-primary, var(--aa-fg)); + --aa-primary-fg: var(--color-primary-foreground, var(--aa-base)); + --aa-ring: var(--color-ring, var(--aa-fg)); + --aa-ok: var(--color-success, #4ade80); + --aa-warn: var(--color-warning, #ffbd38); + --aa-danger: var(--color-destructive, #fb2c36); + --aa-radius: var(--radius-lg, 0.5rem); + --aa-radius-sm: var(--radius-sm, 4px); + --aa-font: var(--theme-font-sans, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); + --aa-mono: var(--theme-font-mono, ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace); + --aa-ease: cubic-bezier(0.16, 1, 0.3, 1); + + display: flex; + flex-direction: column; + min-height: 420px; + border: 1px solid var(--aa-border); + border-radius: var(--aa-radius); + overflow: hidden; + background: var(--aa-base); + color: var(--aa-fg); + font: 400 14px/1.55 var(--aa-font); + -webkit-font-smoothing: antialiased; +} +.aa *, .aa *::before, .aa *::after { box-sizing: border-box; } +.aa [hidden] { display: none !important; } +.aa button { font: inherit; color: inherit; cursor: pointer; } +.aa :focus-visible { outline: 2px solid var(--aa-ring); outline-offset: 2px; } + +/* ── Tab strip ── */ +.aa-tabbar { display: flex; align-items: flex-end; padding: 6px 8px 0; border-bottom: 1px solid var(--aa-border); flex: none; overflow-x: auto; scrollbar-width: none; } +.aa-tabbar::-webkit-scrollbar { display: none; } +.aa-tab { position: relative; display: flex; align-items: center; gap: 7px; max-width: 200px; min-width: 0; height: 30px; padding: 0 11px; border: 1px solid transparent; border-bottom: none; border-radius: 8px 8px 0 0; background: transparent; color: var(--aa-muted); font-size: 12px; font-weight: 550; white-space: nowrap; } +.aa-tab:not(.active):not(:hover) + .aa-tab:not(.active):not(:hover)::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 1px; background: var(--aa-border); } +.aa-tab:hover { color: var(--aa-fg); background: var(--aa-subtle); } +.aa-tab.active { background: var(--aa-card); border-color: var(--aa-border); color: var(--aa-fg); box-shadow: 0 1px 0 var(--aa-card); } +.aa-tab.offline .aa-tab-name { opacity: 0.55; } +.aa-tab.active.offline .aa-tab-name { opacity: 0.75; } +.aa-tab-name { overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.aa-tab-more { flex: none; display: none; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--aa-radius-sm); color: var(--aa-muted); line-height: 1; font-size: 12px; } +.aa-tab.active .aa-tab-more { display: inline-flex; } +.aa-tab-more:hover { background: var(--aa-hover); color: var(--aa-fg); } +.aa-newtab { margin-left: auto; flex: none; font-weight: 600; } + +.aa-dot { flex: none; display: inline-block; width: 6px; height: 6px; border-radius: 9999px; } +.aa-dot.ok { background: var(--aa-ok); box-shadow: 0 0 6px color-mix(in srgb, var(--aa-ok) 50%, transparent); } +.aa-dot.warn { background: var(--aa-warn); } +.aa-dot.off { background: var(--aa-muted); opacity: 0.5; } +.aa-dot.busy { background: var(--aa-warn); animation: aa-pulse 1.4s ease-in-out infinite; } +@keyframes aa-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } + +/* ── Layout ── */ +.aa-main { flex: 1; display: flex; min-height: 0; } +.aa-view { flex: 1; min-width: 0; position: relative; background: var(--aa-card); } +.aa-frames { position: absolute; inset: 0; } +.aa-frames iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: none; background: #fff; } +.aa-center { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; overflow: auto; } +.aa-card { width: 100%; max-width: 520px; border: 1px solid var(--aa-border); background: var(--aa-base); border-radius: var(--aa-radius); padding: 24px; } +.aa-card h2 { margin: 0 0 4px; font-size: 15px; font-weight: 600; color: var(--aa-fg); } +.aa-sub { font-size: 13px; color: var(--aa-muted); margin: 0 0 16px; line-height: 1.5; } +.aa-meta { font: 12px/1.6 var(--aa-mono); color: var(--aa-muted); word-break: break-all; margin: 0 0 16px; } +.aa-row { display: flex; gap: 10px; flex-wrap: wrap; } + +/* ── Buttons ── */ +.aa-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--aa-border); background: var(--aa-subtle); padding: 8px 14px; border-radius: var(--aa-radius); font-size: 13px; font-weight: 500; text-decoration: none; color: var(--aa-fg); word-break: break-all; } +.aa-btn:hover { background: var(--aa-hover); border-color: var(--aa-border-strong); } +.aa-btn:disabled { opacity: 0.55; cursor: default; } +.aa-btn.primary { border-color: var(--aa-primary); background: var(--aa-primary); color: var(--aa-primary-fg); } +.aa-btn.primary:hover { opacity: 0.9; } + +/* ── Menu ── */ +.aa-menu { position: fixed; z-index: 60; min-width: 180px; padding: 4px; border: 1px solid var(--aa-border-strong); border-radius: var(--aa-radius); background: var(--aa-base); box-shadow: 0 1px 2px rgb(0 0 0 / 0.18), 0 8px 24px rgb(0 0 0 / 0.28); animation: aa-menu-in 140ms var(--aa-ease); } +@keyframes aa-menu-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } +.aa-menu button { display: flex; width: 100%; align-items: center; min-height: 28px; border: none; border-radius: var(--aa-radius-sm); background: transparent; padding: 0 10px; font-size: 13px; text-align: left; } +.aa-menu button:hover { background: var(--aa-hover); } +.aa-menu button.danger { color: var(--aa-danger); } +.aa-menu button.danger:hover { background: color-mix(in srgb, var(--aa-danger) 10%, transparent); } +.aa-menu hr { border: none; border-top: 1px solid var(--aa-border); margin: 4px 2px; } + +/* ── New-app form ── */ +.aa-form { width: 100%; max-width: 560px; } +.aa-form h1 { font-size: 22px; font-weight: 650; letter-spacing: -0.02em; line-height: 1.2; margin: 0 0 4px; color: var(--aa-fg); } +.aa-lead { font-size: 12px; color: var(--aa-muted); margin: 0 0 20px; } +.aa-form label { display: block; margin: 14px 0 6px; font-size: 13px; font-weight: 600; } +.aa-input { width: 100%; padding: 7px 10px; border-radius: var(--aa-radius); color: var(--aa-fg); border: 1px solid var(--aa-border); background: var(--aa-base); font: inherit; } +.aa-input::placeholder { color: var(--aa-muted); } +.aa-input:focus-visible { outline: none; border-color: var(--aa-border-strong); box-shadow: 0 0 0 2px color-mix(in srgb, var(--aa-ring) 25%, transparent); } +select.aa-input option { background: var(--aa-base); color: var(--aa-fg); } +textarea.aa-input { min-height: 120px; resize: vertical; } +.aa-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.aa-go { width: 100%; margin-top: 20px; padding: 11px 14px; font-weight: 600; } +.aa-err { margin-top: 12px; font-size: 13px; font-weight: 600; color: var(--aa-danger); white-space: pre-wrap; } +.aa-buildstamp { margin: 16px 0 0; font-size: 11px; color: var(--aa-muted); opacity: 0.7; } + +/* ── Session panel ── */ +.aa-resizer { flex: none; width: 5px; margin: 0 -2px; cursor: col-resize; z-index: 5; touch-action: none; } +.aa-resizer:hover, .aa-resizer.dragging { background: color-mix(in srgb, var(--aa-fg) 25%, transparent); } +.aa-side { width: 380px; min-width: 280px; max-width: 70%; flex: none; display: flex; flex-direction: column; border-left: 1px solid var(--aa-border); background: var(--aa-base); } +.aa-side-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--aa-border); flex: none; } +.aa-side-title { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; } +.aa-side-sub { font-size: 11px; color: var(--aa-muted); flex: none; } +.aa-side-close { flex: none; border: none; background: transparent; color: var(--aa-muted); width: 22px; height: 22px; border-radius: var(--aa-radius-sm); padding: 0; line-height: 1; } +.aa-side-close:hover { background: var(--aa-hover); color: var(--aa-fg); } +.aa-log { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; } +.aa-msg { max-width: 100%; font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; } +.aa-msg.user { align-self: flex-end; max-width: 88%; background: var(--aa-subtle); border: 1px solid var(--aa-border); border-radius: 14px 14px 4px 14px; padding: 8px 12px; } +.aa-msg.assistant { align-self: flex-start; max-width: 95%; } +.aa-toolsline { display: flex; align-items: center; gap: 6px; color: var(--aa-muted); font-size: 11px; font-weight: 550; } +.aa-toolsline::before { content: "\2261"; font-size: 13px; opacity: 0.7; } +.aa-toolrow { max-width: 100%; } +.aa-toolrow summary { cursor: pointer; color: var(--aa-muted); font-size: 11px; font-weight: 550; user-select: none; } +.aa-toolrow summary:hover { color: var(--aa-fg); } +.aa-toolrow pre, .aa-status pre { font: 11px/1.5 var(--aa-mono); background: var(--aa-card); border: 1px solid var(--aa-border); border-radius: var(--aa-radius-sm); padding: 8px 10px; margin: 6px 0 0; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word; } +.aa-log-empty { margin: auto; text-align: center; color: var(--aa-muted); font-size: 13px; } +.aa-status { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-top: 1px solid var(--aa-border); font-size: 12px; color: var(--aa-muted); } +.aa-status.error { display: block; color: var(--aa-danger); } +.aa-status-title { font-weight: 600; } +.aa-status.error pre { color: var(--aa-fg); max-height: 160px; } +.aa-composer { padding: 10px 12px 12px; border-top: 1px solid var(--aa-border); flex: none; } +.aa-composer-box { display: flex; align-items: flex-end; gap: 8px; border: 1px solid var(--aa-border); background: var(--aa-card); border-radius: 14px; padding: 7px 7px 7px 12px; } +.aa-composer-box:focus-within { border-color: var(--aa-border-strong); box-shadow: 0 0 0 2px color-mix(in srgb, var(--aa-ring) 18%, transparent); } +/* max-height = 6 rows: 6 x (13px x 1.45) + 4px padding top and bottom */ +.aa-composer-box textarea { flex: 1; min-width: 0; max-height: 122px; resize: none; border: none; background: transparent; color: var(--aa-fg); font: inherit; font-size: 13px; line-height: 1.45; padding: 4px 0; outline: none; } +.aa-composer-box textarea::placeholder { color: var(--aa-muted); } +.aa-send { flex: none; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 9999px; border: 1px solid var(--aa-primary); background: var(--aa-primary); color: var(--aa-primary-fg) !important; padding: 0; } +.aa-send:disabled { cursor: default; border-color: var(--aa-border); background: var(--aa-hover); color: var(--aa-muted) !important; } +.aa-send svg { display: block; } +.aa-spinner { width: 22px; height: 22px; border: 2px solid var(--aa-border-strong); border-top-color: var(--aa-fg); border-radius: 9999px; animation: aa-spin 0.8s linear infinite; margin: 0 auto 14px; } +@keyframes aa-spin { to { transform: rotate(360deg); } } diff --git a/harness-plugins/hermes/dashboard/manager.js b/harness-plugins/hermes/dashboard/manager.js new file mode 100644 index 0000000..03862be --- /dev/null +++ b/harness-plugins/hermes/dashboard/manager.js @@ -0,0 +1,546 @@ +/** + * The Agent Apps dashboard tab: the page. + * + * A classic script (the dashboard loads plugin entries with a plain