Skip to content

Releases: Sy-D/handraise

0.7.0 — presence, and a confirmed ending

Choose a tag to compare

@Sy-D Sy-D released this 03 Sep 02:16

The handoff now knows whether anybody is on the other side, and the ending is confirmed before the sandbox dies.

Both close gaps this project had written down and lived with: the README's first limitation ("if the human silently closes the tab, the agent can't tell") and the observation logged in every live approval round, where a second viewer of the link saw nothing because the ended lost its race with the kill.

Presence. The relay reports the phone's socket to the agent on every connect, replace and close, and once right after an agent connects, with what an absent agent missed (seen, sinceMs). A human who was there and has been gone for humanGoneGraceMs (default 60 s, floor 5 s) ends the handoff as timeout instead of waiting out timeoutMs. Measured live: an abandoned handoff ended 6 s after the tab closed instead of after a 90 s wait. A phone that comes back inside the grace resets it, which is why 60 s is the default: the preview proxy cuts an idle socket after exactly 60 s and the phone reconnects a second later. An unscanned link waits the full timeoutMs; nobody left.

No new outcome. The wide event tells the two timeouts apart instead: endedEarly: true is somebody who came and left, humanSeen: false is a link that never reached anyone, humanLeftMs says when. ADR 0009 has the argument.

Confirmed endings. The relay acks ended once it has stored it for whoever opens the link next; sendFinal waits up to 2 s (measured ~200 ms), re-sends the ending on a reconnect inside that window, and only then is the sandbox destroyed. Input arriving after the handoff ended never reaches the page.

Also: invalid_option as an eighth error code; HandoffResult.durationMs is the settlement duration, not teardown. Three reviews (two Opus passes, GPT-5.6 Sol), four fix rounds, 322 tests, live e2e green. Full CHANGELOG.

0.6.0 — QR passthrough, typed errors

Choose a tag to compare

@Sy-D Sy-D released this 02 Sep 20:35

QR passthrough, typed errors, and the bench that prices an approval against a takeover.

Scan QR. A device-change check (reCAPTCHA's "scan to verify", WhatsApp Web, authenticator enrolment) draws a QR code and asks for a phone. handraise's human is holding that phone, looking at the code on its screen, and a phone cannot scan itself. Now the phone has a Scan QR key: the agent takes a full-resolution screenshot, decodes it on a worker thread (jsqr plus a bounded PNG decoder over node:zlib; BarcodeDetector does not exist in Solari's Chromium), and hands the phone what it said. http, https and mailto open, shown as the canonical address with the host as the loud part; tel: and otpauth: are decoded and copyable but never one tap away. Measured: a 4K screenshot used to hold the agent's event loop for 2.1 s and now holds it for 1 ms; an 815 KB PNG bomb that cost 873 MB is refused before a byte is inflated. reCAPTCHA itself could not be reproduced and is documented as untested.

Typed errors. Everything raiseHand throws is a HandraiseError with a code — missing_api_key, invalid_mode, empty_action, browser_unusable, relay_start_failed, concurrency_limit, relay_not_ready — and the SDK error as cause with credentials redacted. A broken logger (throwing, throwing getter, async-rejecting) can no longer end a handoff. The relay health poll enforces its deadline per request. The preview token is redacted from every message it could reach, in every encoding a proxy produces.

Measured. bun run bench:mixed: 20 workflows, interleaved takeover and approval, 20/20 completed. An approval costs 1 frame, 25 KB and 5.3 s of relay sandbox; a takeover 14 frames, 142 KB and 10.7 s. Time to visible is the same, because the relay cold start dominates both.

Changed, read this: a page that is already dead now throws browser_unusable before anything is created instead of returning { outcome: "disconnected" } — if you only branched on result.outcome, add a catch. Solari SDK errors are wrapped; the class is on error.cause. HandoffEvent gains two required numbers, qrScans and qrHits. Node 20 is now declared as the floor it always was.

Reviewed by two Opus passes and a GPT-5.6 Sol security pass per PR, with fix rounds; 289 tests; live e2e against Solari including the QR round trip. ADR: docs/adr/0008-qr-passthrough.md. Full CHANGELOG.

0.5.1 — channels (republish of 0.5.0)

Choose a tag to compare

@Sy-D Sy-D released this 02 Sep 16:02

Identical to 0.5.0, which was unpublished from npm minutes after release; npm does not let a removed version number be reused. Release notes: https://github.com/Sy-D/handraise/releases/tag/v0.5.0

0.5.0 — channels

Choose a tag to compare

@Sy-D Sy-D released this 02 Sep 15:39

Channels. 0.4.0 made an approval small enough to fit in a chat message: one screenshot, one sentence, two answers. 0.5.0 gives that message somewhere to go, and lets the answer come back from there.

import { raiseHand } from "handraise"
import { telegram } from "handraise-telegram"

await raiseHand(page, {
  mode: "approval",
  reason: "The agent may not move money without a human",
  action: "Submit EUR 12,430 vendor payment to Acme GmbH",
  channels: [telegram({ botToken, chatId })],
})

Added: channels?: HandoffChannel[] on raiseHand; ChannelHandoff, the view an adapter gets (a union on mode; approvals carry the screenshot bytes and answer("approve" | "deny"), which settles the handoff in-process and returns false if the phone was faster); settled, a promise that resolves with the outcome however the handoff ended, so an adapter knows when to stop; HandoffEvent.answeredVia. Everything is additive; no existing call, type or outcome changes.

Verified against the real Solari API: an approval answered by a channel end to end, plus 204 offline tests including first-answer-wins against the real relay process, a late answer after timeout and after a dead session, a channel that throws, one that never settles, and a compile-negative fixture. Two independent reviews (Opus, GPT-5.6 Sol), four fix rounds. ADR: docs/adr/0007-channels.md.

The first adapter, handraise-telegram, is written against this release and lands as its own package once its live test against the Bot API has run.

0.4.0 — approval mode

Choose a tag to compare

@Sy-D Sy-D released this 02 Sep 13:30

Approval mode. A capability gap ("I can't do this": 2FA, a captcha) and an authority boundary ("I may not do this": submit the payment) were the same call with a different reason. They are now two modes of raiseHand, and an approval needs no takeover: one screenshot, the action in words, yes or no.

const result = await raiseHand(page, {
  mode: "approval",
  reason: "I may not move money without a human",
  action: "Submit EUR 12,430 vendor payment to Acme GmbH",
})
// result.outcome: "approved" | "denied" | "timeout" | "disconnected"

Deny is one tap on the phone; approve takes the 700 ms hold, because in this mode the answer that cannot be undone is yes. The relay enforces the mode: an approval relay routes only approve and deny, opens no CDP session, injects nothing. The webhook body now carries mode and action.

Fixed, all pre-existing in takeover mode too: the phone's Clear key did nothing; a second holder of the link could overturn a queued answer during an agent reconnect; a lost ended could leave the last frame replayable; an answer given while the phone socket was closing was dropped; a finished tab retried a dead relay forever.

Breaking for TypeScript consumers (compile-time only): HandoffOutcome has two new members, HandoffEvent.mode is new and required, RaiseHandOptions is now a union. Details and migration in the CHANGELOG.

Verified against the real Solari API: takeover, approve and deny e2e, 185 tests, two independent reviews. ADR: docs/adr/0006-approval-mode.md.

handraise 0.3.0 — the phone UI, rebuilt from a design-engineering audit

Choose a tag to compare

@Sy-D Sy-D released this 02 Sep 10:25

[0.3.0] - 2026-09-02

The phone UI, rebuilt from a design-engineering audit (spikes/emil-ui-audit.md).
The headline finding: the human could not read the page they were operating —
a 1280×800 frame letterboxed to 29% on a 390px phone, ~1mm glyphs, with zoom
blocked. Remote 16px text now renders at 10.4 CSS px instead of 4.6.

Added

  • Auto-zoom to the focused field. The agent already reports which field
    has focus; the phone now zooms so that field is readable and centred, capped
    at 3×. Pinch to zoom and pan, double-tap to toggle. The first tap is never
    delayed — a second tap within 250ms toggles zoom instead of sending again.
  • Hold to give up. The give-up button is now "I can't do this" and needs a
    700ms press-and-hold; a bare tap explains why. The primary "Hand back" stays
    a single tap. Both are irreversible; only one of them deserved a guard.
  • Input queued across reconnects. Keystrokes typed while the socket is
    reconnecting are held (up to 50) and flushed in order, once; the hint says so.
    A give-up during an outage used to vanish and cost the agent its full timeout.
    Taps and scrolls are deliberately not replayed — the page may have moved.
  • One-time-code autofill. The focus event carries an optional kind
    (otp | password | text), derived from the remote field's attributes
    only — never its value — so the phone's field gets autocomplete=one-time-code
    and iOS can offer the SMS code instead of making the human retype it.
  • Tap feedback on the live view; a ripple where the finger landed.

Changed

  • Key bar regrouped by consequence: ⌫ ⇥ ⏎ together, Clear fenced 124px
    away (was 6px from backspace). Every key ≥44px; disabled contrast 1.96→3.17:1.
    The input has its own full-width row (296px at 320px wide, was 69px).
  • The give-up button lost the UI's only colour; red survives only as the hold's
    progress fill. Both exit buttons are now exactly the same height.
  • Safe-area inset applied once (was twice, ~78px on notched iPhones);
    100dvh + interactive-widget=resizes-content so the bar survives the
    Android keyboard. Header and footer lifted off the stage (was 1.04:1).
  • Overlays speak to the human, say thank you, and fade in instead of snapping.
    The idle dot no longer pulses; only "reconnecting" does.

handraise 0.2.0

Choose a tag to compare

@Sy-D Sy-D released this 02 Sep 09:34

[0.2.0] - 2026-09-01

Everything below shipped after 0.1.0 was published. 0.1.0 is broken and
deprecated
: its bundle used a namespace import for qrcode-terminal, which
resolves to { default: … } under Node's ESM/CJS interop, so generate was
undefined and the QR code — the whole onboarding path — silently failed to
render. Bun-driven tests never saw it because bun's interop differs. CI now
runs the shipped artifact under node after every build.

Added

  • npx handraise — a one-command demo. With only a SOLARI_API_KEY, it
    deploys its own TOTP-walled portal into a sandbox, opens a cloud browser
    against it, raises a hand and prints the QR.
  • handoffQr(url) export — render the handoff QR yourself.
  • Focus feedback on the phone — a ring around the focused field and a
    "Typing into: Password" label, so the human can see where typing lands.
    Only the field's label is read, never its value.
  • A key bar on the phone — backspace, clear, tab, enter as explicit
    buttons. Deletion previously rode on a keydown that virtual keyboards
    often do not send (Android reports keyCode 229), so text already in a
    remote field could not be removed from a phone at all.

Fixed

  • The QR code renders in the published bundle (see above).
  • The QR is no longer printed when it would not fit the terminal — a wrapped
    QR looks like a QR and does not scan.

Changed

  • Quiet by default: the library writes nothing to stdout unless you pass
    logger: consoleLogger or an onEvent handler.

handraise 0.1.0 (deprecated — QR does not render under Node, use 0.2.0)

Choose a tag to compare

@Sy-D Sy-D released this 02 Sep 09:34

Initial release. Deprecated: the published bundle's QR code does not render under Node due to a CJS-interop import; install 0.2.0 or later.