adbrent 0.3.3

One Android device per AI agent: an adb device lock for parallel Claude Code and other coding-agent sessions
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>adbrent</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2313202B'/%3E%3Crect x='10' y='5' width='12' height='22' rx='3' fill='none' stroke='%23F4C534' stroke-width='2.5'/%3E%3C/svg%3E">
<style>
/* Everything is in this file: the page asks nothing of any other server. */
:root {
  --bench: #E8EDEF; --surface: #F8FAFB; --ink: #13202B; --muted: #4E5E6B; --line: #C3CED6;
  --tape: #F4C534; --tape-ink: #13202B; --free: #177A50; --stop: #B5371F; --warn: #8A5A00; --note: #1F5F99;
  --focus: #13202B;
  --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root {
    --bench: #0E161C; --surface: #162029; --ink: #E7EEF2; --muted: #96A6B1; --line: #2A3843;
    --free: #4CC38A; --stop: #FF8266; --warn: #E3B341; --note: #8FC7F2; --focus: #F4C534;
  }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bench); color: var(--ink); font: 400 16px/1.5 var(--sans); padding: 26px 20px 56px; -webkit-font-smoothing: antialiased; }
main { max-width: 1040px; margin: 0 auto; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 4px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

header { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.brand { font: 800 20px/1 var(--sans); letter-spacing: -0.01em; }
.version { font: 400 13px var(--mono); color: var(--muted); }
.live { margin-left: auto; font-size: 14px; color: var(--muted); }
.live::before { content: ""; display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--free); margin-right: 8px; }
.live.down { color: var(--stop); font-weight: 600; } .live.down::before { background: var(--stop); }
h1 { font: 800 clamp(1.7rem, 4vw, 2.5rem)/1.05 var(--sans); letter-spacing: -0.025em; margin: 30px 0 6px; text-wrap: balance; }
.sub { color: var(--muted); min-height: 1.5em; }
h2 { font: 700 1.1rem/1.2 var(--sans); }
.head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 38px 0 12px; }
.head .pick { margin-left: auto; display: flex; gap: 6px; }

.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
.empty { padding: 22px 20px; color: var(--muted); }
.empty code { font-family: var(--mono); font-size: 14px; color: var(--ink); }

.dev { display: grid; grid-template-columns: minmax(150px, 1.1fr) 170px minmax(0, 2fr) auto; gap: 16px; align-items: center; padding: 16px 20px; border-top: 1px solid var(--line); }
.dev:first-child { border-top: 0; }
.model { font-weight: 650; overflow-wrap: anywhere; }
.serial { font: 400 12px/1.4 var(--mono); color: var(--muted); overflow-wrap: anywhere; }
.tag { display: inline-block; font: 700 12px/1 var(--mono); padding: 6px 8px; border-radius: 3px; border: 1.5px dashed var(--muted); color: var(--muted); }
.tag.free { border-color: var(--free); color: var(--free); }
.tag.outside { border-color: var(--warn); color: var(--warn); }
.tag.off { border-style: solid; }
.tag.held { background: var(--tape); color: var(--tape-ink); border: 1.5px solid var(--tape-ink); transform: rotate(-1.5deg); box-shadow: 1.5px 2px 0 var(--ink); }
.what { min-width: 0; overflow-wrap: anywhere; }
.what .more { color: var(--muted); font-size: 14px; }
.what .more .time { font-family: var(--mono); font-size: 13px; }
.act { display: flex; gap: 6px; justify-content: flex-end; min-width: 108px; }

button { font: 600 13px var(--sans); border-radius: 7px; min-height: 34px; padding: 6px 12px; cursor: pointer; color: var(--ink); background: transparent; border: 1px solid var(--line); }
button:hover { border-color: var(--ink); }
button.danger { color: var(--stop); } button.danger:hover { border-color: var(--stop); }
button.sure { color: #fff; background: #B5371F; border-color: #B5371F; }
button[aria-pressed="true"] { color: var(--surface); background: var(--ink); border-color: var(--ink); }

.ev { display: grid; grid-template-columns: 78px 150px 190px minmax(0, 1fr); gap: 14px; align-items: baseline; padding: 11px 20px; border-top: 1px solid var(--line); font-size: 14px; }
.ev:first-child { border-top: 0; }
.ev time, .ev .who { font: 400 12.5px/1.5 var(--mono); color: var(--muted); overflow-wrap: anywhere; }
.ev .kind { font-weight: 650; }
.ev .kind.no { color: var(--stop); } .ev .kind.warn { color: var(--warn); } .ev .kind.note { color: var(--note); }
.ev .text { overflow-wrap: anywhere; }
.ev .text .on { font-family: var(--mono); font-size: 12.5px; color: var(--muted); }
.ev.feedback .text { font-style: italic; }

footer { margin-top: 28px; color: var(--muted); font-size: 14px; }
footer code { font-family: var(--mono); font-size: 13px; color: var(--ink); }

@media (max-width: 760px) {
  .dev { grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }
  .dev .what { grid-column: 1 / -1; } .act { grid-column: 1 / -1; justify-content: flex-start; min-width: 0; }
  .ev { grid-template-columns: 1fr; gap: 2px; }
}
</style>
</head>
<body>
<main>
  <header>
    <span class="brand">adbrent</span><span class="version" id="version"></span>
    <span class="live" id="live" role="status">connecting</span>
  </header>

  <h1 id="summary">Looking for devices</h1>
  <p class="sub" id="sub"></p>

  <div class="head"><h2 id="devices-title">Devices</h2></div>
  <div class="panel" id="devices" role="list" aria-labelledby="devices-title"><div class="empty">Loading</div></div>

  <div class="head">
    <h2 id="log-title">What agents ran into</h2>
    <div class="pick" role="group" aria-label="Show">
      <button type="button" id="all" aria-pressed="true">Everything</button>
      <button type="button" id="notes" aria-pressed="false">Agent feedback</button>
    </div>
  </div>
  <div class="panel" id="log" role="list" aria-labelledby="log-title"><div class="empty">Loading</div></div>

  <footer>This page is served to this machine only, and stops when you stop <code>adbrent serve</code>. Keep a device away from agents with <code>adbrent exclude &lt;serial&gt;</code>.</footer>
  <p class="sr" id="say" aria-live="polite"></p>
</main>

<script>
(() => {
  const $ = id => document.getElementById(id);
  const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
  const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
  const span = s => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${s % 60}s` : s < 86400 ? `${Math.floor(s / 3600)}h ${Math.floor(s % 3600 / 60)}m` : `${Math.floor(s / 86400)}d ${Math.floor(s % 86400 / 3600)}h`;

  let state = null;     // the last answer from adbrent
  let skew = 0;         // its clock minus ours, so times tick without asking again
  let drawn = "";       // what is on screen, to redraw only when something changed
  let only = "all";     // which log entries to show
  let armed = null;     // the serial whose take-back is waiting to be confirmed
  let disarm = 0;
  const now = () => Math.floor(Date.now() / 1000) + skew;

  function describe(d) {
    if (d.rented_by) return ["held", "rented"];
    if (d.state !== "device") return ["", d.state];
    if (d.excluded) return ["off", "off limits"];
    if (d.in_use_outside_adbrent.length) return ["outside", "in use outside"];
    return ["free", "free"];
  }

  function device(d) {
    const [cls, label] = describe(d);
    const row = el("div", "dev"); row.setAttribute("role", "listitem");
    const name = el("div");
    name.append(el("div", "model", d.model || d.serial), el("div", "serial", d.serial));
    const state = el("div"); state.append(el("span", `tag ${cls}`, label));
    const what = el("div", "what");
    if (d.rented_by) {
      what.append(el("div", "", d.rented_by));
      const more = el("div", "more");
      const time = el("span", "time"); time.dataset.since = d.since;
      more.append("for ", time, d.shared_with ? `, shared with ${plural(d.shared_with, "other session")}` : "");
      what.append(more);
    } else if (d.state !== "device") {
      what.append(el("div", "more", d.state === "absent" ? "Rented, but not connected right now." : "Connected, but not ready for adb."));
    } else if (d.excluded) {
      what.append(el("div", "more", "You excluded it. Agents cannot see or use it."));
    } else if (d.in_use_outside_adbrent.length) {
      what.append(el("div", "", "Something outside adbrent is running on it"), el("div", "more", d.in_use_outside_adbrent.join("; ")));
    } else {
      what.append(el("div", "more", "The next agent that runs adb can have it."));
    }
    const act = el("div", "act");
    if (d.rented_by) {
      const button = (text, cls, key, go) => { const b = el("button", cls, text); b.type = "button"; b.dataset.key = `${key}:${d.serial}`; b.onclick = go; act.append(b); };
      if (armed === d.serial) {
        button("Yes, take it back", "sure", "sure", () => takeBack(d));
        button("Keep", "", "keep", () => { armed = null; render(); });
      } else {
        button("Take back", "danger", "take", () => { armed = d.serial; clearTimeout(disarm); disarm = setTimeout(() => { armed = null; render(); }, 8000); render(`sure:${d.serial}`); });
      }
    }
    row.append(name, state, what, act);
    return row;
  }

  function entry(e) {
    const kind = e.kind.startsWith("refused") ? "no" : e.kind === "forced" ? "warn" : e.kind === "feedback" ? "note" : "";
    const row = el("div", "ev" + (e.kind === "feedback" ? " feedback" : "")); row.setAttribute("role", "listitem");
    const time = el("time"); time.dataset.t = e.t;
    const text = el("div", "text");
    if (e.serial) text.append(el("span", "on", e.serial + (e.detail ? "  " : "")));
    text.append(e.detail || "");
    row.append(time, el("div", `kind ${kind}`, e.kind), el("div", "who", e.who), text);
    return row;
  }

  function render(focusKey) {
    if (!state) return;
    const log = state.log.filter(e => only === "all" || e.kind === "feedback");
    const next = JSON.stringify([state.devices, log, only, armed]);
    if (next === drawn && !focusKey) return tick();
    drawn = next;
    const focused = focusKey || (document.activeElement && document.activeElement.dataset ? document.activeElement.dataset.key : null);

    $("version").textContent = `v${state.version}`;
    const here = state.devices.filter(d => d.state !== "absent");
    const rented = state.devices.filter(d => d.rented_by).length;
    const outside = here.filter(d => !d.rented_by && d.in_use_outside_adbrent.length).length;
    const off = here.filter(d => !d.rented_by && d.excluded).length;
    const free = here.filter(d => !d.rented_by && !d.excluded && d.state === "device" && !d.in_use_outside_adbrent.length).length;
    $("summary").textContent = here.length ? `${rented} of ${plural(here.length, "device")} rented` : "No devices connected";
    $("sub").textContent = here.length
      ? [`${free} free`, outside ? `${outside} in use outside adbrent` : "", off ? `${off} off limits` : ""].filter(Boolean).join(", ") + "."
      : "Plug in a device or start an emulator. It appears here as soon as adb sees it.";

    $("devices").replaceChildren(...(state.devices.length ? state.devices.map(device) : [el("div", "empty", "Nothing to show yet.")]));
    const none = only === "all"
      ? "Nothing yet. Refusals, forced rentals and agent feedback appear here."
      : "No feedback yet. Agents leave it with `adbrent feedback`.";
    $("log").replaceChildren(...(log.length ? log.map(entry) : [el("div", "empty", none)]));
    tick();
    if (focused) {
      const again = document.querySelector(`[data-key="${focused}"]`) || document.querySelector(`[data-key="take:${focused.split(":")[1]}"]`);
      if (again) again.focus();
    }
  }

  // Durations and "ago" times move every second, without redrawing or asking again.
  function tick() {
    document.querySelectorAll("[data-since]").forEach(e => { e.textContent = span(Math.max(0, now() - e.dataset.since)); });
    document.querySelectorAll("[data-t]").forEach(e => { e.textContent = `${span(Math.max(0, now() - e.dataset.t))} ago`; });
  }

  async function takeBack(d) {
    armed = null; clearTimeout(disarm);
    const reply = await fetch(`/api/release/${encodeURIComponent(d.serial)}`, { method: "POST", headers: { "X-Adbrent": "1" } }).then(r => r.json()).catch(() => null);
    $("say").textContent = reply && reply.released ? `${d.model || d.serial} was taken back and is free.` : `${d.model || d.serial} could not be taken back.`;
    await refresh();
  }

  async function refresh() {
    try {
      const fresh = await (await fetch("/api/state")).json();
      skew = fresh.now - Math.floor(Date.now() / 1000);
      state = fresh;
      $("live").className = "live"; $("live").textContent = "live";
      render();
    } catch (error) {
      $("live").className = "live down"; $("live").textContent = "adbrent serve is not running";
    }
  }

  const pick = which => { only = which; $("all").setAttribute("aria-pressed", which === "all"); $("notes").setAttribute("aria-pressed", which === "feedback"); render(); };
  $("all").dataset.key = "pick:all"; $("notes").dataset.key = "pick:feedback";
  $("all").onclick = () => pick("all"); $("notes").onclick = () => pick("feedback");
  refresh();
  setInterval(refresh, 3000);
  setInterval(tick, 1000);
})();
</script>
</body>
</html>