From d580834c9ed6c92f5a299665e1bb3cc988122b37 Mon Sep 17 00:00:00 2001
From: Richard Abrich <774615+abrichr@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:45:25 -0400
Subject: [PATCH 1/3] feat(approval): show who decided each save
Needs you's technical details now list who answered each save of an item:
a person in Run it or on their phone (signed in or not), or a named agent,
rule or system with the first characters of the grant a person signed. A
delegate row without a grant hash, on a person's route, or a phone answer
without AAL2 reads as unverified, and rows that aren't answers are left out.
Display only: what counts as approved is unchanged.
---
.../openadapt_desktop/approval_mode.py | 77 ++++++++++++
packages/desktop/src/lib/approvalMode.test.ts | 40 +++++++
packages/desktop/src/lib/approvalMode.ts | 31 +++++
packages/desktop/src/lib/types.ts | 16 +++
packages/desktop/src/screens/NeedsYou.tsx | 13 ++
.../tests/test_engine/test_item_approvers.py | 111 ++++++++++++++++++
6 files changed, 288 insertions(+)
create mode 100644 packages/desktop/tests/test_engine/test_item_approvers.py
diff --git a/packages/desktop/openadapt_desktop/approval_mode.py b/packages/desktop/openadapt_desktop/approval_mode.py
index c76afee17..6fb5db18b 100644
--- a/packages/desktop/openadapt_desktop/approval_mode.py
+++ b/packages/desktop/openadapt_desktop/approval_mode.py
@@ -608,6 +608,81 @@ def _check(value: Any) -> dict[str, Any] | None:
)
+#: How an answer reached the run: a person in Desktop's Run it or on their
+#: phone (a Cloud-signed decision), or a delegate by its own route.
+PERSON_ROUTES = frozenset({"desktop_run_it", "cloud_phone"})
+DELEGATE_ROUTES = frozenset({"local_cli", "webhook", "mcp"})
+DELEGATE_KINDS = frozenset({"rule", "agent", "system"})
+
+
+def approvers(raw: Any) -> list[dict[str, Any]]:
+ """Who decided each save of an item, from Flow's ``approvals`` rows.
+
+ Closed codes and opaque ids only. A person row names the route and, when
+ signed in, the Cloud user id. A delegate row names the agent, rule or
+ system and the ``sha256:`` of the person-signed grant it acted under; a
+ delegate row without one reads as ``unverified``. A row Desktop can't
+ read is left out, never shown as a person.
+ """
+
+ out: list[dict[str, Any]] = []
+ rows = raw if isinstance(raw, list) else []
+ for row in rows[:64]:
+ if not isinstance(row, dict):
+ continue
+ decision = row.get("decision")
+ if decision not in ("approved", "declined"):
+ continue
+ route = row.get("route", "desktop_run_it")
+ decided_by = row.get("decided_by", "human")
+ entry: dict[str, Any] = {
+ "decision": decision,
+ "route": route if route in PERSON_ROUTES | DELEGATE_ROUTES else "unknown",
+ "kind": "person",
+ "id": None,
+ "policy_ref": None,
+ "signed_in": False,
+ "verified": True,
+ }
+ if decided_by == "automation":
+ approver = row.get("approver")
+ approver = approver if isinstance(approver, dict) else {}
+ kind = approver.get("kind")
+ approver_id = approver.get("id")
+ policy_ref = approver.get("policy_ref")
+ entry.update(
+ kind=kind if kind in DELEGATE_KINDS else "unknown",
+ id=approver_id
+ if isinstance(approver_id, str) and _OPAQUE_ID.fullmatch(approver_id)
+ else None,
+ policy_ref=policy_ref
+ if isinstance(policy_ref, str) and _PREFIXED_SHA256.fullmatch(policy_ref)
+ else None,
+ )
+ entry["verified"] = (
+ entry["kind"] != "unknown"
+ and entry["id"] is not None
+ and entry["policy_ref"] is not None
+ and route in DELEGATE_ROUTES
+ )
+ elif decided_by == "human":
+ approver_id = row.get("approver_id")
+ entry["signed_in"] = row.get("approver_kind") == "cloud_user"
+ entry["id"] = (
+ approver_id
+ if isinstance(approver_id, str) and _OPAQUE_ID.fullmatch(approver_id)
+ else None
+ )
+ entry["verified"] = route in PERSON_ROUTES and (
+ route != "cloud_phone"
+ or (entry["signed_in"] and row.get("auth_method") == "cloud_aal2")
+ )
+ else:
+ continue
+ out.append(entry)
+ return out
+
+
def _unconfirmed(approval: Mapping[str, Any], reason: str) -> dict[str, Any]:
"""The same block, now needing a person and never counting as without one."""
@@ -720,6 +795,7 @@ def every_check_passed(group: str, required: frozenset[str]) -> bool:
if isinstance(block.get("saves_without_person"), int)
else 0
),
+ "approvers": approvers(block.get("approvals")),
}
@@ -806,6 +882,7 @@ def parse_stored(value: Any) -> dict[str, Any] | None:
"SwitchApprovalError",
"SwitchEnvelope",
"accept_switch",
+ "approvers",
"blockers",
"confirm_no_person",
"flow_switch_api",
diff --git a/packages/desktop/src/lib/approvalMode.test.ts b/packages/desktop/src/lib/approvalMode.test.ts
index bab54a1af..d24defcfe 100644
--- a/packages/desktop/src/lib/approvalMode.test.ts
+++ b/packages/desktop/src/lib/approvalMode.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
+ approverSentence,
confirmedButNeedsLook,
notAvailableLine,
offAutomatically,
@@ -155,3 +156,42 @@ describe("shared switch cases (openadapt_desktop/work_items.py reads the same fi
).toBe(item.confirmed_but_needs_look);
});
});
+
+describe("who decided each save", () => {
+ const person = {
+ decision: "approved" as const,
+ route: "desktop_run_it" as const,
+ kind: "person" as const,
+ id: "aaaaaaaa-0000-4000-8000-000000000003",
+ policy_ref: null,
+ signed_in: true,
+ verified: true,
+ };
+
+ it("names a person in Run it or on their phone", () => {
+ expect(approverSentence(person)).toBe("Approved by a signed-in person in Run it");
+ expect(approverSentence({ ...person, route: "cloud_phone" })).toBe(
+ "Approved by a signed-in person on their phone",
+ );
+ expect(approverSentence({ ...person, decision: "declined", signed_in: false })).toBe(
+ "Declined by a person signed out of Cloud in Run it",
+ );
+ });
+
+ it("names a delegate and the grant a person signed", () => {
+ const agent = {
+ ...person,
+ route: "local_cli" as const,
+ kind: "agent" as const,
+ id: "agent:approver-bot",
+ policy_ref: `sha256:${"e".repeat(64)}`,
+ signed_in: false,
+ };
+ expect(approverSentence(agent)).toBe(
+ "Approved by agent agent:approver-bot, under a grant a person signed (eeeeeeeeeeee…)",
+ );
+ expect(approverSentence({ ...agent, policy_ref: null, verified: false })).toMatch(
+ /can't verify, so a person should look/,
+ );
+ });
+});
diff --git a/packages/desktop/src/lib/approvalMode.ts b/packages/desktop/src/lib/approvalMode.ts
index 365f5f2fd..5a1efe6fd 100644
--- a/packages/desktop/src/lib/approvalMode.ts
+++ b/packages/desktop/src/lib/approvalMode.ts
@@ -9,6 +9,7 @@
import type {
ApprovalModeState,
ItemApproval,
+ ItemApprover,
OffReason,
RequirementResult,
SwitchState,
@@ -274,3 +275,33 @@ export function whyItCameToYou(
}
return null;
}
+
+/** The first characters of a grant hash, enough to match the grant a person signed. */
+export function shortGrant(policyRef: string | null | undefined): string | null {
+ const match = /^sha256:([0-9a-f]{64})$/.exec(String(policyRef ?? ""));
+ return match ? match[1].slice(0, 12) : null;
+}
+
+/**
+ * Who decided one save, in plain words: a person in Run it or on their
+ * phone, or a named agent, rule or system and the grant a person signed.
+ */
+export function approverSentence(approver: ItemApprover): string {
+ const verb = approver.decision === "approved" ? "Approved" : "Declined";
+ if (approver.kind === "person") {
+ const where =
+ approver.route === "cloud_phone"
+ ? "on their phone"
+ : approver.route === "desktop_run_it"
+ ? "in Run it"
+ : "by an unknown route";
+ const who = approver.signed_in ? "a signed-in person" : "a person signed out of Cloud";
+ return `${verb} by ${who} ${where}`;
+ }
+ const grant = shortGrant(approver.policy_ref);
+ if (!approver.verified || !approver.id || !grant) {
+ return `${verb} by a delegate this computer can't verify, so a person should look`;
+ }
+ const kind = approver.kind === "rule" ? "rule" : approver.kind === "system" ? "system" : "agent";
+ return `${verb} by ${kind} ${approver.id}, under a grant a person signed (${grant}…)`;
+}
diff --git a/packages/desktop/src/lib/types.ts b/packages/desktop/src/lib/types.ts
index abf40e899..2b3c1de55 100644
--- a/packages/desktop/src/lib/types.ts
+++ b/packages/desktop/src/lib/types.ts
@@ -221,6 +221,22 @@ export interface ItemApproval {
failed_check: { code: string; passed: boolean; detail: string } | null;
post_checks: { verified: boolean; whole_run_clean: boolean };
saves_without_person: number;
+ /** Who decided each save, oldest first (absent from older engines). */
+ approvers?: ItemApprover[];
+}
+
+/** One answer to a save: a person, or a delegate a person named in a signed grant. */
+export interface ItemApprover {
+ decision: "approved" | "declined";
+ route: "desktop_run_it" | "cloud_phone" | "local_cli" | "webhook" | "mcp" | "unknown";
+ kind: "person" | "agent" | "rule" | "system" | "unknown";
+ /** A Cloud user id for a signed-in person, or the delegate's opaque id. */
+ id: string | null;
+ /** sha256: of the person-signed grant a delegate acted under. */
+ policy_ref: string | null;
+ signed_in: boolean;
+ /** False when the row doesn't hold together (a delegate without a grant, say). */
+ verified: boolean;
}
/** Why "Only ask a person about problems" turned off (design 7.6). */
diff --git a/packages/desktop/src/screens/NeedsYou.tsx b/packages/desktop/src/screens/NeedsYou.tsx
index 13c3bdadf..95df31537 100644
--- a/packages/desktop/src/screens/NeedsYou.tsx
+++ b/packages/desktop/src/screens/NeedsYou.tsx
@@ -9,6 +9,7 @@ import { CMD, engineInvoke, engineTry } from "../lib/engine";
import { attentionCopy } from "../lib/attention";
import {
SWITCH_LABEL,
+ approverSentence,
confirmedButNeedsLook,
offAutomatically,
offReasonSentence,
@@ -235,6 +236,18 @@ function AttentionCard({
.filter(Boolean)
.join(" · ")}
+ {(item.approval.approvers ?? []).length > 0 && (
+ <>
+
Decided by
+
+
+ {(item.approval.approvers ?? []).map((approver, index) => (
+ - {approverSentence(approver)}
+ ))}
+
+
+ >
+ )}
>
)}
{item.halt_step && (
diff --git a/packages/desktop/tests/test_engine/test_item_approvers.py b/packages/desktop/tests/test_engine/test_item_approvers.py
new file mode 100644
index 000000000..224ba17ad
--- /dev/null
+++ b/packages/desktop/tests/test_engine/test_item_approvers.py
@@ -0,0 +1,111 @@
+"""Who decided each save: the Desktop projection of Flow's approval rows.
+
+A person in Run it or on their phone, or a delegate (an agent, rule or
+system) named in a grant a person signed. A row that doesn't hold together
+reads as unverified, and a row that isn't an answer is left out.
+"""
+
+from __future__ import annotations
+
+from openadapt_desktop.approval_mode import approvers, item_approval
+
+_GRANT = "sha256:" + "e" * 64
+
+
+def _row(**extra: object) -> dict:
+ row = {
+ "step_id": "s4",
+ "request_sha256": "sha256:" + "a" * 64,
+ "answer_sha256": "sha256:" + "b" * 64,
+ "decision": "approved",
+ "decline_reason": None,
+ "decided_by": "human",
+ "route": "desktop_run_it",
+ "approver_kind": "cloud_user",
+ "approver_id": "aaaaaaaa-0000-4000-8000-000000000003",
+ "auth_method": "cloud_aal2",
+ }
+ row.update(extra)
+ return row
+
+
+_DELEGATE = {
+ "decided_by": "automation",
+ "route": "local_cli",
+ "approver_kind": "unauthenticated",
+ "approver_id": None,
+ "auth_method": "none",
+ "approver": {
+ "schema_version": "openadapt.approver-identity/v1",
+ "kind": "agent",
+ "auth": "local_cli",
+ "id": "agent:approver-bot",
+ "auth_method": "none",
+ "policy_ref": _GRANT,
+ "on_behalf_of": None,
+ },
+}
+
+
+def test_the_record_names_who_decided_each_save() -> None:
+ rows = approvers(
+ [
+ _row(),
+ _row(route="cloud_phone"),
+ _row(**_DELEGATE),
+ _row(
+ decision="declined",
+ decline_reason="wrong_values",
+ approver_kind="unauthenticated",
+ approver_id=None,
+ auth_method="none",
+ ),
+ ]
+ )
+ assert [r["route"] for r in rows] == [
+ "desktop_run_it",
+ "cloud_phone",
+ "local_cli",
+ "desktop_run_it",
+ ]
+ assert rows[0] == {
+ "decision": "approved",
+ "route": "desktop_run_it",
+ "kind": "person",
+ "id": "aaaaaaaa-0000-4000-8000-000000000003",
+ "policy_ref": None,
+ "signed_in": True,
+ "verified": True,
+ }
+ assert rows[1]["verified"] is True
+ assert rows[2]["kind"] == "agent"
+ assert rows[2]["id"] == "agent:approver-bot"
+ assert rows[2]["policy_ref"] == _GRANT
+ assert rows[2]["verified"] is True
+ assert rows[3]["signed_in"] is False
+
+
+def test_a_delegate_without_a_signed_grant_or_on_a_persons_route_is_not_verified() -> None:
+ no_grant = dict(_DELEGATE, approver=dict(_DELEGATE["approver"], policy_ref=None))
+ on_person_route = dict(_DELEGATE, route="desktop_run_it")
+ phone_without_aal2 = _row(route="cloud_phone", auth_method="cloud_aal1")
+ rows = approvers([_row(**no_grant), _row(**on_person_route), phone_without_aal2])
+ assert [r["verified"] for r in rows] == [False, False, False]
+ # Rows that aren't answers at all are left out, never shown as a person.
+ assert approvers([{"decision": "maybe"}, "x", _row(decided_by="software")]) == []
+ assert approvers(None) == []
+
+
+def test_the_item_projection_carries_the_approvers() -> None:
+ block = {
+ "schema_version": "openadapt.approval-mode-item/v1",
+ "mode": "each_item",
+ "decision": "approved",
+ "needs_person": False,
+ "pre_checks": [{"code": "each_item", "passed": True, "detail": "approved_by_person"}],
+ "approvals": [_row()],
+ }
+ projected = item_approval(block)
+ assert projected is not None
+ assert projected["approvers"][0]["kind"] == "person"
+ assert projected["approvers"][0]["route"] == "desktop_run_it"
From af7e35654c42f2e8e0fd79e02fcada0e2aefcb03 Mon Sep 17 00:00:00 2001
From: Richard Abrich <774615+abrichr@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:17:23 -0400
Subject: [PATCH 2/3] fix(approvers): the phone route is the decision portal,
not a Cloud relay
---
packages/desktop/openadapt_desktop/approval_mode.py | 9 +++------
packages/desktop/src/lib/approvalMode.test.ts | 2 +-
packages/desktop/src/lib/approvalMode.ts | 2 +-
packages/desktop/src/lib/types.ts | 2 +-
.../tests/test_engine/test_item_approvers.py | 13 +++++++++----
5 files changed, 15 insertions(+), 13 deletions(-)
diff --git a/packages/desktop/openadapt_desktop/approval_mode.py b/packages/desktop/openadapt_desktop/approval_mode.py
index 6fb5db18b..b7d39e8b3 100644
--- a/packages/desktop/openadapt_desktop/approval_mode.py
+++ b/packages/desktop/openadapt_desktop/approval_mode.py
@@ -609,8 +609,8 @@ def _check(value: Any) -> dict[str, Any] | None:
#: How an answer reached the run: a person in Desktop's Run it or on their
-#: phone (a Cloud-signed decision), or a delegate by its own route.
-PERSON_ROUTES = frozenset({"desktop_run_it", "cloud_phone"})
+#: phone (the runner's decision portal), or a delegate by its own route.
+PERSON_ROUTES = frozenset({"desktop_run_it", "decision_portal"})
DELEGATE_ROUTES = frozenset({"local_cli", "webhook", "mcp"})
DELEGATE_KINDS = frozenset({"rule", "agent", "system"})
@@ -673,10 +673,7 @@ def approvers(raw: Any) -> list[dict[str, Any]]:
if isinstance(approver_id, str) and _OPAQUE_ID.fullmatch(approver_id)
else None
)
- entry["verified"] = route in PERSON_ROUTES and (
- route != "cloud_phone"
- or (entry["signed_in"] and row.get("auth_method") == "cloud_aal2")
- )
+ entry["verified"] = route in PERSON_ROUTES
else:
continue
out.append(entry)
diff --git a/packages/desktop/src/lib/approvalMode.test.ts b/packages/desktop/src/lib/approvalMode.test.ts
index d24defcfe..21fdf5895 100644
--- a/packages/desktop/src/lib/approvalMode.test.ts
+++ b/packages/desktop/src/lib/approvalMode.test.ts
@@ -170,7 +170,7 @@ describe("who decided each save", () => {
it("names a person in Run it or on their phone", () => {
expect(approverSentence(person)).toBe("Approved by a signed-in person in Run it");
- expect(approverSentence({ ...person, route: "cloud_phone" })).toBe(
+ expect(approverSentence({ ...person, route: "decision_portal" })).toBe(
"Approved by a signed-in person on their phone",
);
expect(approverSentence({ ...person, decision: "declined", signed_in: false })).toBe(
diff --git a/packages/desktop/src/lib/approvalMode.ts b/packages/desktop/src/lib/approvalMode.ts
index 5a1efe6fd..f3e851527 100644
--- a/packages/desktop/src/lib/approvalMode.ts
+++ b/packages/desktop/src/lib/approvalMode.ts
@@ -290,7 +290,7 @@ export function approverSentence(approver: ItemApprover): string {
const verb = approver.decision === "approved" ? "Approved" : "Declined";
if (approver.kind === "person") {
const where =
- approver.route === "cloud_phone"
+ approver.route === "decision_portal"
? "on their phone"
: approver.route === "desktop_run_it"
? "in Run it"
diff --git a/packages/desktop/src/lib/types.ts b/packages/desktop/src/lib/types.ts
index 2b3c1de55..deead620c 100644
--- a/packages/desktop/src/lib/types.ts
+++ b/packages/desktop/src/lib/types.ts
@@ -228,7 +228,7 @@ export interface ItemApproval {
/** One answer to a save: a person, or a delegate a person named in a signed grant. */
export interface ItemApprover {
decision: "approved" | "declined";
- route: "desktop_run_it" | "cloud_phone" | "local_cli" | "webhook" | "mcp" | "unknown";
+ route: "desktop_run_it" | "decision_portal" | "local_cli" | "webhook" | "mcp" | "unknown";
kind: "person" | "agent" | "rule" | "system" | "unknown";
/** A Cloud user id for a signed-in person, or the delegate's opaque id. */
id: string | null;
diff --git a/packages/desktop/tests/test_engine/test_item_approvers.py b/packages/desktop/tests/test_engine/test_item_approvers.py
index 224ba17ad..60b613509 100644
--- a/packages/desktop/tests/test_engine/test_item_approvers.py
+++ b/packages/desktop/tests/test_engine/test_item_approvers.py
@@ -51,7 +51,12 @@ def test_the_record_names_who_decided_each_save() -> None:
rows = approvers(
[
_row(),
- _row(route="cloud_phone"),
+ _row(
+ route="decision_portal",
+ approver_kind="unauthenticated",
+ approver_id=None,
+ auth_method="none",
+ ),
_row(**_DELEGATE),
_row(
decision="declined",
@@ -64,7 +69,7 @@ def test_the_record_names_who_decided_each_save() -> None:
)
assert [r["route"] for r in rows] == [
"desktop_run_it",
- "cloud_phone",
+ "decision_portal",
"local_cli",
"desktop_run_it",
]
@@ -88,8 +93,8 @@ def test_the_record_names_who_decided_each_save() -> None:
def test_a_delegate_without_a_signed_grant_or_on_a_persons_route_is_not_verified() -> None:
no_grant = dict(_DELEGATE, approver=dict(_DELEGATE["approver"], policy_ref=None))
on_person_route = dict(_DELEGATE, route="desktop_run_it")
- phone_without_aal2 = _row(route="cloud_phone", auth_method="cloud_aal1")
- rows = approvers([_row(**no_grant), _row(**on_person_route), phone_without_aal2])
+ unknown_route = _row(route="cloud_phone")
+ rows = approvers([_row(**no_grant), _row(**on_person_route), unknown_route])
assert [r["verified"] for r in rows] == [False, False, False]
# Rows that aren't answers at all are left out, never shown as a person.
assert approvers([{"decision": "maybe"}, "x", _row(decided_by="software")]) == []
From 525113cb2d6b4fe051d1439841dc7f4b597d913e Mon Sep 17 00:00:00 2001
From: abrichr
Date: Sun, 18 Oct 2026 18:22:27 -0400
Subject: [PATCH 3/3] chore: release 0.21.0
Co-Authored-By: Claude Opus 5.5
---
packages/desktop/CHANGELOG.md | 14 ++++++++++++++
packages/desktop/openadapt_desktop/__init__.py | 2 +-
packages/desktop/package-lock.json | 4 ++--
packages/desktop/package.json | 2 +-
packages/desktop/pyproject.toml | 2 +-
packages/desktop/src-tauri/Cargo.lock | 2 +-
packages/desktop/src-tauri/Cargo.toml | 2 +-
packages/desktop/src-tauri/tauri.conf.json | 2 +-
packages/desktop/uv.lock | 2 +-
9 files changed, 23 insertions(+), 9 deletions(-)
diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md
index 5a4fdd4d4..9ebd38c1c 100644
--- a/packages/desktop/CHANGELOG.md
+++ b/packages/desktop/CHANGELOG.md
@@ -1,6 +1,20 @@
# CHANGELOG
+## v0.21.0 (2026-10-18)
+
+### Features
+
+- **approval**: show who decided each save
+
+### Bug Fixes
+
+- **approvers**: the phone route is the decision portal, not a Cloud relay
+- **desktop**: release only on a commit whose desktop jobs passed
+- **desktop**: price an S3 endpoint as R2 by its host; scope JS/Rust CodeQL
+
+
+
## v0.20.0 (2026-10-18)
### Documentation
diff --git a/packages/desktop/openadapt_desktop/__init__.py b/packages/desktop/openadapt_desktop/__init__.py
index 694600fdf..601b30681 100644
--- a/packages/desktop/openadapt_desktop/__init__.py
+++ b/packages/desktop/openadapt_desktop/__init__.py
@@ -21,4 +21,4 @@
+-- backends/ Storage backend plugins
"""
-__version__ = "0.20.0"
+__version__ = "0.21.0"
diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json
index 524e0a5cd..33d7accac 100644
--- a/packages/desktop/package-lock.json
+++ b/packages/desktop/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openadapt-desktop",
- "version": "0.20.0",
+ "version": "0.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openadapt-desktop",
- "version": "0.20.0",
+ "version": "0.21.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 08cecdd2c..360ff12e6 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "openadapt-desktop",
- "version": "0.20.0",
+ "version": "0.21.0",
"description": "Installed companion for OpenAdapt authoring, teaching, and local pairing",
"private": true,
"type": "module",
diff --git a/packages/desktop/pyproject.toml b/packages/desktop/pyproject.toml
index ef0f15f54..29faee9cc 100644
--- a/packages/desktop/pyproject.toml
+++ b/packages/desktop/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "openadapt-desktop"
-version = "0.20.0"
+version = "0.21.0"
description = "The OpenAdapt desktop app and local engine: record a data-entry task, run it on a list, and check each saved record"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock
index cecc29d04..36c76d231 100644
--- a/packages/desktop/src-tauri/Cargo.lock
+++ b/packages/desktop/src-tauri/Cargo.lock
@@ -2449,7 +2449,7 @@ dependencies = [
[[package]]
name = "openadapt-desktop"
-version = "0.20.0"
+version = "0.21.0"
dependencies = [
"hex",
"reqwest",
diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml
index b39416e0d..24a4da70f 100644
--- a/packages/desktop/src-tauri/Cargo.toml
+++ b/packages/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "openadapt-desktop"
-version = "0.20.0"
+version = "0.21.0"
description = "Installed companion for OpenAdapt authoring, teaching, and local pairing"
authors = ["OpenAdapt AI"]
license = "MIT"
diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json
index 822ae9561..aea2772db 100644
--- a/packages/desktop/src-tauri/tauri.conf.json
+++ b/packages/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicoverbruggen/tauri-v2-schema/main/schema.json",
"productName": "OpenAdapt Desktop",
- "version": "0.20.0",
+ "version": "0.21.0",
"identifier": "ai.openadapt.desktop",
"build": {
"frontendDist": "../dist",
diff --git a/packages/desktop/uv.lock b/packages/desktop/uv.lock
index fdb884233..307424ab4 100644
--- a/packages/desktop/uv.lock
+++ b/packages/desktop/uv.lock
@@ -1159,7 +1159,7 @@ wheels = [
[[package]]
name = "openadapt-desktop"
-version = "0.20.0"
+version = "0.21.0"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },