[pierre] feat/app-agent: drive web apps through the OPENSWARM_APP bridge

Browser agent gains app-control logic + schema, app-builder skill doc,
run.sh agent-bridge presence check, and app-agent tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
SirKentut
2026-06-15 15:06:10 -07:00
co-authored by Claude Opus 4.8
parent f90975f006
commit a35bfaaf7d
7 changed files with 378 additions and 58 deletions
+184 -9
View File
@@ -9,6 +9,7 @@ Sub-agents appear as visible AgentSession cards on the dashboard.
import asyncio
import json
import logging
import os
import re
import time
from datetime import datetime
@@ -114,6 +115,110 @@ def _app_bridge_expression(tool_name: str, tool_input: dict) -> str:
)
# App-bridge readiness. The template ships window.OPENSWARM_APP from first paint
# but in a "not ready" state until the app calls register(...). On the agent's
# first turn the app may still be mounting (Vite cold-boot is 10-30s), so the
# reads poll briefly for the bridge to come up instead of declaring it absent.
_BRIDGE_READY_WAIT_MS = 8000
_BRIDGE_POLL_INTERVAL_MS = 400
def _parse_bridge_result(result: dict) -> object:
"""Decode the JSON string an app-bridge evaluate returns (it always returns
JSON text and never throws). Returns the decoded value, or None when it is
undecodable or errored at the transport level."""
if not isinstance(result, dict) or "error" in result:
return None
raw = result.get("text")
if raw is None:
return None
try:
return json.loads(raw)
except (TypeError, ValueError):
return None
def _bridge_ready(value: object) -> bool:
"""True when a decoded describe()/getState() value means a registered bridge.
Legacy apps return a plain array (ready); the template stub returns
{'__ready': False} until register() runs; None means no bridge present yet."""
if isinstance(value, list):
return True
if isinstance(value, dict):
return value.get("__ready") is not False and "__error__" not in value
return value is not None
def _app_output_id(browser_id: str) -> str | None:
return browser_id[4:] if browser_id.startswith("app:") else None
def _app_workspace_dir(browser_id: str) -> str | None:
"""Resolve the on-disk workspace folder for an `app:<output_id>` target."""
oid = _app_output_id(browser_id)
if not oid:
return None
try:
from backend.apps.outputs.workspace_io import load_output
from backend.config.paths import OUTPUTS_WORKSPACE_DIR
out = load_output(oid)
if not out or not getattr(out, "workspace_id", None):
return None
return os.path.join(OUTPUTS_WORKSPACE_DIR, out.workspace_id)
except Exception:
return None
def _render_app_controls(describe_value: object) -> tuple[str, str] | None:
"""From a decoded describe() value build (rules_md, controls_md). Returns
None when the value is not a ready, usable describe."""
if isinstance(describe_value, list):
rules, controls = "", describe_value
elif _bridge_ready(describe_value) and isinstance(describe_value, dict):
rules = str(describe_value.get("rules") or "")
controls = describe_value.get("controls") or []
else:
return None
lines = ["# Controls", ""]
for c in controls:
if not isinstance(c, dict):
continue
row = f"- `{c.get('name', '')}`"
if c.get("args"):
row += f" args={json.dumps(c['args'])}"
if c.get("keys"):
row += f" [{c['keys']}]"
if c.get("description"):
row += f": {c['description']}"
lines.append(row)
controls_md = "\n".join(lines) + "\n"
rules_md = (rules.strip() + "\n") if rules.strip() else ""
return rules_md, controls_md
def _persist_app_controls(browser_id: str, describe_value: object) -> None:
"""Cache rules.md + controls.md into the app workspace so the agent reads them
up front and only re-describes when controls change. Best-effort; written
under .openswarm/ so they stay out of the app's own file tree."""
rendered = _render_app_controls(describe_value)
if not rendered:
return
folder = _app_workspace_dir(browser_id)
if not folder or not os.path.isdir(folder):
return
rules_md, controls_md = rendered
try:
cache_dir = os.path.join(folder, ".openswarm")
os.makedirs(cache_dir, exist_ok=True)
with open(os.path.join(cache_dir, "controls.md"), "w", encoding="utf-8") as f:
f.write(controls_md)
if rules_md:
with open(os.path.join(cache_dir, "rules.md"), "w", encoding="utf-8") as f:
f.write(rules_md)
except Exception:
logger.debug("[app-agent] failed to persist controls cache", exc_info=True)
async def execute_browser_tool(
tool_name: str, tool_input: dict, browser_id: str, tab_id: str = "",
) -> dict:
@@ -128,14 +233,28 @@ async def execute_browser_tool(
action = "evaluate"
expr = _app_bridge_expression(tool_name, tool_input)
params = {"expression": expr}
request_id = uuid4().hex
if _trace:
logger.info(f"[app-agent] DISPATCH {tool_name} -> {browser_id} (req {request_id[:8]}) js={expr[:120]}")
result = await ws_manager.send_browser_command(
request_id, action, browser_id, params, tab_id=tab_id,
)
if _trace:
logger.info(f"[app-agent] RESULT {tool_name} <- {browser_id}: {json.dumps(result)[:300]}")
async def _eval_once() -> dict:
rid = uuid4().hex
if _trace:
logger.info(f"[app-agent] DISPATCH {tool_name} -> {browser_id} (req {rid[:8]}) js={expr[:120]}")
r = await ws_manager.send_browser_command(rid, action, browser_id, params, tab_id=tab_id)
if _trace:
logger.info(f"[app-agent] RESULT {tool_name} <- {browser_id}: {json.dumps(r)[:300]}")
return r
result = await _eval_once()
# Reads poll for the bridge to come up (app still mounting on turn 1).
# AppInvoke does not wait: its action either exists right now or it does
# not, and a missing action should surface immediately.
if tool_name in ("AppDescribe", "AppGetState"):
waited = 0
while waited < _BRIDGE_READY_WAIT_MS and not _bridge_ready(_parse_bridge_result(result)):
await asyncio.sleep(_BRIDGE_POLL_INTERVAL_MS / 1000)
waited += _BRIDGE_POLL_INTERVAL_MS
result = await _eval_once()
if tool_name == "AppDescribe":
_persist_app_controls(browser_id, _parse_bridge_result(result))
return result
action = ACTION_MAP.get(tool_name)
@@ -572,10 +691,66 @@ async def run_browser_agent(
)
clear_browser_history(browser_id)
prior_messages = []
# App mode: read the bridge's rules + controls ONCE up front and front-load
# them, so the agent knows the app's purpose and every control before its
# first action (no screenshot fumbling) and need not call AppDescribe again
# until controls change. This is also the runtime bridge gate: if the bridge
# never comes up, fail loudly into the logs + the agent's first message (and,
# under OPENSWARM_REQUIRE_BRIDGE=1, end the run rather than UI-fumble).
app_front_load = ""
if app_mode and not prior_messages:
try:
_dv = _parse_bridge_result(await execute_browser_tool("AppDescribe", {}, browser_id, tab_id))
except Exception:
_dv = None
logger.debug("[app-agent] startup AppDescribe failed", exc_info=True)
_rendered = _render_app_controls(_dv)
if _rendered:
_rules_md, _controls_md = _rendered
_rev = _dv.get("__rev") if isinstance(_dv, dict) else None
_block = [
"\n\n[The app's bridge is live; its rules and controls were read "
"for you. Act directly; do NOT call AppDescribe again unless "
"AppGetState reports a changed __rev.]"
]
if _rules_md.strip():
_block.append("App rules / objective:\n" + _rules_md.strip())
_block.append(_controls_md.strip())
if _rev is not None:
_block.append(f"(controls __rev: {_rev})")
app_front_load = "\n\n".join(_block)
else:
_oid = _app_output_id(browser_id) or browser_id
_msg = (
f"BRIDGE MISSING: window.OPENSWARM_APP not registered - "
f"app '{_oid}' is not agent-operable"
)
logger.error(f"[app-agent] {_msg}")
if os.environ.get("OPENSWARM_REQUIRE_BRIDGE") == "1":
session.status = "completed"
agent_manager._sync_session_close(session)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id, "status": "completed",
"session": session.model_dump(mode="json"),
})
return {
"session_id": session_id, "browser_id": browser_id,
"summary": f"This app is not agent-operable: {_msg}.",
"done": True, "success": False,
"action_log": [], "final_screenshot": None,
}
app_front_load = (
f"\n\n[{_msg}. AppDescribe/AppInvoke will not work. Fall back to "
"driving the UI directly (BrowserListInteractives, "
"BrowserClickIndex, BrowserBatch, BrowserScreenshot). If you "
"cannot operate it, say so in Done with success=false.]"
)
# Front-load the prefetched perception into the first user turn so the model
# can act immediately (only when this is a fresh conversation; a resumed one
# already knows the page). The visible task text stays clean.
first_user_content = task + preloaded_perception if (preloaded_perception and not prior_messages) else task
_front = preloaded_perception or app_front_load
first_user_content = task + _front if (_front and not prior_messages) else task
messages: list[dict] = list(prior_messages) + [{"role": "user", "content": first_user_content}]
# Seed with the front-loaded reads: they really ran and returned content, so a
# read task the agent answers straight from them is NOT a "did nothing" ghost.
+27 -18
View File
@@ -678,13 +678,16 @@ APP_TOOLS_SCHEMA = [
{
"name": "AppDescribe",
"description": (
"Read the app's CURRENT list of actions you can take, straight from the "
"app itself (window.OPENSWARM_APP.describe()). Returns an array of "
"{name, args, description}. The app's controls are DYNAMIC, they appear "
"and disappear as state changes, so call this again after any AppInvoke "
"that could add or remove actions; never assume the list is stable. "
"Returns null if the app does not expose the bridge (then fall back to "
"BrowserListInteractives/BrowserScreenshot)."
"Read the app's rules and CURRENT list of actions, straight from the "
"app itself (window.OPENSWARM_APP.describe()). Returns "
"{rules, controls, __rev}: rules is what the app is and its objective, "
"controls is an array of {name, args, description, keys}, and __rev is "
"a revision number. (Older apps may return a bare array of controls.) "
"These are ALREADY front-loaded into your first message, so you rarely "
"need to call this. The app's controls are DYNAMIC: call this again "
"ONLY when AppGetState reports a changed __rev (e.g. after an AppInvoke "
"added or removed actions). Returns null if the app exposes no bridge "
"(then fall back to BrowserListInteractives/BrowserScreenshot)."
),
"input_schema": {"type": "object", "properties": {}, "required": []},
},
@@ -693,7 +696,10 @@ APP_TOOLS_SCHEMA = [
"description": (
"Read a small JSON snapshot of the app's current state "
"(window.OPENSWARM_APP.getState()). Use it to check what's on screen "
"and to verify an action landed. Returns null if the bridge is absent."
"and to verify an action landed. The snapshot includes __rev, the "
"controls revision: if it differs from the __rev you were given, the "
"controls changed, so call AppDescribe to refresh them. Returns null "
"if the bridge is absent."
),
"input_schema": {"type": "object", "properties": {}, "required": []},
},
@@ -978,22 +984,25 @@ APP_SYSTEM_PROMPT = (
"## How you see and act: the app's own bridge (this is the fast path)\n"
"The app exposes a native bridge, window.OPENSWARM_APP, with three calls you "
"reach through tools:\n"
"- AppDescribe -> the CURRENT list of actions {name, args, description}.\n"
"- AppGetState -> a small JSON snapshot of what's on screen.\n"
"- AppDescribe -> {rules, controls, __rev}: the app's objective and its "
"current actions {name, args, description, keys}.\n"
"- AppGetState -> a small JSON snapshot of what's on screen (includes __rev).\n"
"- AppInvoke(name, args) -> perform one action.\n"
"Always start with AppDescribe to learn the real action names and arg shapes, "
"then AppInvoke them. This reads the app's true structure directly, so you do "
"NOT need screenshots, the DOM, or the accessibility tree.\n"
"The app's rules and controls have ALREADY been read for you and placed in "
"your first message, so you can start invoking actions immediately; you do "
"NOT need screenshots, the DOM, or the accessibility tree, and you usually do "
"NOT need to call AppDescribe at all.\n"
"Only ever call actions that AppDescribe actually returned. You operate the app, "
"you do NOT change it: never invent action names, and never try to add, remove, "
"or redefine the app's available actions or edit its code. If what the user wants "
"isn't reachable through the exposed actions, say so in Done.\n\n"
"## Controls are DYNAMIC\n"
"Actions and state change as you interact (the app adds and removes controls). "
"Never cache the action list: after any AppInvoke that could change what's "
"available, call AppDescribe again before relying on it. Verify outcomes with "
"AppGetState rather than assuming.\n\n"
"## Controls are DYNAMIC, but you only re-read on a __rev change\n"
"Actions can change as you interact (the app adds and removes controls). The "
"front-loaded controls came with a __rev number. Use AppGetState to verify "
"outcomes; if its __rev differs from the one you have, the controls changed, "
"so call AppDescribe ONCE to refresh them. As long as __rev is unchanged, "
"trust the controls you already have and do not re-describe.\n\n"
"## If there is no bridge\n"
"If AppDescribe (or AppGetState) returns null, this app doesn't expose the "
+43 -25
View File
@@ -325,58 +325,76 @@ export const JOBS_LIST = '/api/jobs/list';
---
## Make the app agent-operable the `OPENSWARM_APP` bridge
## Make the app agent-operable: the `OPENSWARM_APP` bridge
An agent can drive this app on the user's behalf (e.g. "graph y=x^2 on my
Desmos app"). It does NOT do that by clicking pixels or scraping the DOM
that's slow and an app's DOM is often a bare `<canvas>`. Instead, expose a
tiny bridge on `window` and the agent reads/acts through it in one fast
`executeJavaScript` call. **Always add this bridge to every app you build.**
Desmos app"). It does NOT do that by clicking pixels or scraping the DOM (slow,
and an app's DOM is often a bare `<canvas>`). Instead it reads and acts through
`window.OPENSWARM_APP`, a bridge the template already ships for you
(`src/agentBridge.ts`, installed before your app mounts).
Set `window.OPENSWARM_APP` with three functions:
**You do not wire up the bridge; you `register()` into it.** Call
`window.OPENSWARM_APP.register({ rules, controls, getState, invoke })` once your
app's core object exists (e.g. in a mount `useEffect`). This is REQUIRED for
every app: the runtime verifies it and the agent's first action fails loudly
with `BRIDGE MISSING` if you forget. Pass:
- `describe()` → the **current** list of actions, `[{ name, args?, description? }]`.
Recompute it live on every call; controls are dynamic, so return only what's
actually available right now.
- `getState()` a **small** JSON snapshot of the app's relevant state (used to
verify an action landed). Keep it compact — this is the latency budget.
- `invoke(name, args)` → perform the named action and return a result (or throw
- `rules` (string) - what the app is and its objective, in plain prose. This is
what the agent reads to understand the app (e.g. "Flappy Bird. Keep the bird
airborne through the pipe gaps; the game ends on a collision.").
- `controls` - an array of `{ name, args?, description?, keys? }`, OR a function
returning that array when controls are dynamic. `keys` is an optional
human-style hint (e.g. `"Space = flap"`). Return only what's available now.
- `getState()` - a **small** JSON snapshot used to verify an action landed. Keep
it compact; this is the latency budget.
- `invoke(name, args)` - perform the named action and return a result (or throw
a string the agent will read).
Keep `args` shapes simple (strings, numbers, booleans, small objects). The agent
only ever calls actions that `describe()` returned; it never edits your code.
only ever calls actions that `controls` listed; it never edits your code. When
dynamic controls change, call `window.OPENSWARM_APP.refresh()` so the agent
knows to re-read them (it bumps the `__rev` the agent watches).
```tsx
// Register once the app's core object exists (e.g. after the calculator mounts).
// `calc` here is the app's own API (Desmos example); use whatever yours exposes.
function registerAgentBridge(calc: any) {
(window as any).OPENSWARM_APP = {
describe() {
const actions = [
window.OPENSWARM_APP!.register({
rules: 'A graphing calculator. Plot and remove expressions like y=x^2 or y=sin(x).',
controls() {
const controls = [
{ name: 'addExpr', args: { latex: 'string' }, description: 'Add a graph expression, e.g. y=x^2' },
{ name: 'clear', description: 'Remove all expressions' },
];
// Dynamic: only offer removeExpr when something is on the graph.
if (calc.getExpressions().length > 0) {
actions.push({ name: 'removeExpr', args: { id: 'string' }, description: 'Remove one expression by id' });
controls.push({ name: 'removeExpr', args: { id: 'string' }, description: 'Remove one expression by id' });
}
return actions;
return controls;
},
getState() {
return { expressions: calc.getExpressions().map((e: any) => ({ id: e.id, latex: e.latex })) };
},
invoke(name: string, args: any = {}) {
if (name === 'addExpr') { const id = String(Date.now()); calc.setExpression({ id, latex: args.latex }); return { id }; }
if (name === 'removeExpr') { calc.removeExpression({ id: args.id }); return { ok: true }; }
if (name === 'clear') { calc.setBlank(); return { ok: true }; }
if (name === 'addExpr') { const id = String(Date.now()); calc.setExpression({ id, latex: args.latex }); window.OPENSWARM_APP!.refresh(); return { id }; }
if (name === 'removeExpr') { calc.removeExpression({ id: args.id }); window.OPENSWARM_APP!.refresh(); return { ok: true }; }
if (name === 'clear') { calc.setBlank(); window.OPENSWARM_APP!.refresh(); return { ok: true }; }
throw `Unknown action: ${name}`;
},
};
});
}
```
If you skip the bridge the agent falls back to slow UI-driving, so apps meant to
be agent-operated should always register it.
### Real-time games need a high-level action, not per-frame controls
An agent acts in discrete tool calls separated by network + model latency
(hundreds of ms to seconds). It physically CANNOT hit frame-timing, so exposing
only `invoke('flap')` makes a reflex game like Flappy Bird understandable but
unwinnable: by the time the agent decides to flap, the bird has already fallen.
For anything real-time, expose a **high-level action** the app executes on its
own tick loop, e.g. `invoke('autopilot', { on: true })` that runs the optimal
input internally, or `invoke('setDifficulty', ...)`. Let the agent set intent;
let the app handle the milliseconds. Non-real-time apps (tools, forms, a
Spotify-style player) don't need this: their actions are already at agent cadence.
---
@@ -18,6 +18,32 @@ FRONTEND_DIR_ABSPATH="$(dirname "$RUN_FRONTEND_ABSPATH")"
cd "$FRONTEND_DIR_ABSPATH"
# --- Agent bridge check -------------------------------------------------------
# Every OpenSwarm app must register window.OPENSWARM_APP so an agent can drive
# it. The plumbing ships in src/agentBridge.ts; what an app can still forget is
# to CALL register(...) with its own actions. Scan the app source for that call.
# Missing -> warn loudly but DON'T block (work-in-progress apps must still load).
# The real gate is the runtime check in the app agent; OPENSWARM_REQUIRE_BRIDGE=1
# turns this into a hard failure for anyone who wants strict mode.
check_agent_bridge() {
local src_dir="$FRONTEND_DIR_ABSPATH/src"
[ -d "$src_dir" ] || return 0
if grep -rEl --include='*.ts' --include='*.tsx' \
'OPENSWARM_APP\.register|window\.OPENSWARM_APP[[:space:]]*=' \
"$src_dir" 2>/dev/null | grep -qv 'agentBridge\.'; then
return 0
fi
echo ""
printf '\033[31m❌ BRIDGE MISSING: window.OPENSWARM_APP not registered - this app is not agent-operable.\033[0m\n'
printf '\033[31m Call window.OPENSWARM_APP.register({ rules, controls, getState, invoke }) when your app mounts (see SKILL.md).\033[0m\n'
echo ""
if [[ "${OPENSWARM_REQUIRE_BRIDGE:-}" == "1" ]]; then
echo "OPENSWARM_REQUIRE_BRIDGE=1 set; refusing to start without the agent bridge."
exit 1
fi
}
check_agent_bridge
# Put the bundled Node on PATH so `npm`, `node`, and the vite child
# processes all resolve even on a machine with no system Node. The
# packaged Electron shell exports OPENSWARM_NODE_PATH (e.g.
+1
View File
@@ -68,6 +68,7 @@ _WALK_SKIP_DIRS = frozenset({
".pytest_cache",
".mypy_cache",
".ruff_cache",
".openswarm",
})
# Cap per-file response size at 256 KB. Hand-written source rarely
+95
View File
@@ -146,3 +146,98 @@ def test_selected_app_context_advertises_app_agent(monkeypatch):
assert ctx is not None
assert "App id (for AppAgent): abc" in ctx
assert "AppAgent(output_id, task)" in ctx
# --- bridge readiness + parsing ---------------------------------------------
def test_parse_bridge_result_decodes_json_text():
assert BA._parse_bridge_result({"text": json.dumps([{"name": "x"}])}) == [{"name": "x"}]
assert BA._parse_bridge_result({"text": "null"}) is None
assert BA._parse_bridge_result({"error": "boom"}) is None
assert BA._parse_bridge_result({"text": "not json"}) is None
assert BA._parse_bridge_result({}) is None
def test_bridge_ready_distinguishes_stub_from_registered():
assert BA._bridge_ready([{"name": "x"}]) is True # legacy array
assert BA._bridge_ready({"controls": [], "__rev": 1}) is True
assert BA._bridge_ready({"__ready": False, "__rev": 0}) is False # template stub
assert BA._bridge_ready({"__error__": "threw"}) is False
assert BA._bridge_ready(None) is False
def test_render_app_controls_array_and_object_forms():
# Legacy array form: no rules, controls rendered.
rules_md, controls_md = BA._render_app_controls([{"name": "clear", "description": "wipe"}])
assert rules_md == ""
assert "- `clear`: wipe" in controls_md
# New object form: rules + keys + args rendered.
rules_md, controls_md = BA._render_app_controls({
"rules": "Flappy Bird. Keep the bird airborne.",
"controls": [{"name": "flap", "keys": "Space = flap", "args": {"force": "number"}, "description": "Flap once"}],
"__rev": 3,
})
assert "Flappy Bird" in rules_md
assert "- `flap`" in controls_md
assert "[Space = flap]" in controls_md
assert '"force"' in controls_md
# Not-ready / absent bridge yields nothing to render.
assert BA._render_app_controls({"__ready": False}) is None
assert BA._render_app_controls(None) is None
# --- AppDescribe waits for a still-booting bridge ----------------------------
def test_app_describe_polls_until_bridge_ready(monkeypatch):
calls = {"n": 0}
ready = {"rules": "r", "controls": [{"name": "x"}], "__rev": 1}
async def _send(request_id, action, browser_id, params, tab_id=""):
calls["n"] += 1
if calls["n"] < 3:
return {"text": json.dumps({"__ready": False, "__rev": 0})}
return {"text": json.dumps(ready)}
async def _no_sleep(_s):
return None
monkeypatch.setattr(BA.ws_manager, "send_browser_command", _send, raising=False)
monkeypatch.setattr(BA.asyncio, "sleep", _no_sleep, raising=True)
monkeypatch.setattr(BA, "_persist_app_controls", lambda *a, **k: None, raising=True)
out = asyncio.run(BA.execute_browser_tool("AppDescribe", {}, "app:abc"))
assert calls["n"] == 3 # polled twice, succeeded on the third
assert BA._parse_bridge_result(out) == ready
def test_app_invoke_does_not_poll(monkeypatch):
calls = {"n": 0}
async def _send(request_id, action, browser_id, params, tab_id=""):
calls["n"] += 1
return {"text": json.dumps({"__ready": False})} # would loop forever if AppInvoke waited
async def _no_sleep(_s):
return None
monkeypatch.setattr(BA.ws_manager, "send_browser_command", _send, raising=False)
monkeypatch.setattr(BA.asyncio, "sleep", _no_sleep, raising=True)
asyncio.run(BA.execute_browser_tool("AppInvoke", {"name": "flap"}, "app:abc"))
assert calls["n"] == 1 # single shot, no readiness wait
def test_app_describe_persists_controls_cache(monkeypatch, tmp_path):
ready = {"rules": "Keep the bird airborne.", "controls": [{"name": "flap", "keys": "Space"}], "__rev": 1}
async def _send(request_id, action, browser_id, params, tab_id=""):
return {"text": json.dumps(ready)}
monkeypatch.setattr(BA.ws_manager, "send_browser_command", _send, raising=False)
monkeypatch.setattr(BA, "_app_workspace_dir", lambda bid: str(tmp_path), raising=True)
asyncio.run(BA.execute_browser_tool("AppDescribe", {}, "app:abc"))
controls = (tmp_path / ".openswarm" / "controls.md").read_text()
rules = (tmp_path / ".openswarm" / "rules.md").read_text()
assert "- `flap`" in controls and "[Space]" in controls
assert "Keep the bird airborne." in rules
+2 -6
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.2.77",
"version": "1.2.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.2.77",
"version": "1.2.79",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "6.8.3",
@@ -567,7 +567,6 @@
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -1435,7 +1434,6 @@
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.8.1",
"builder-util": "26.8.1",
@@ -1584,7 +1582,6 @@
"integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.8.1",
"builder-util": "26.8.1",
@@ -2888,7 +2885,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},