Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/architecture/browser-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ calls are rejected rather than queued. The client marks its executor with
`stallRecovery: 'fail_closed'`, so an earlier computer-use loop deadline cannot
trigger the legacy observation retry or idle-action skip behavior.

An action explicitly rejected with `action_rejected` / `not_dispatched` can
recover in the shared study loop. It records the rejection, withholds the rest
of that action batch and takes a fresh observation before asking the participant
what to do next. It never automatically replays input. Other executor failures,
including uncertain outcomes and failed observations, still end the session.

## Wire contract

Each frame is a four-byte unsigned big-endian length followed by strict UTF-8
Expand Down
2 changes: 1 addition & 1 deletion docs/contracts/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Date: 2026-06-02 (current-state note updated 2026-07-14)

Status: reference map for the major contracts shipped through source version
`0.99.0`; it is not an exhaustive inventory of command/result envelopes. Exported types,
`0.99.1`; it is not an exhaustive inventory of command/result envelopes. Exported types,
schema constants, parsers, and validators in `src/` are authoritative. Rows
marked "reserved" name layering intent only — no code emits or validates them
yet. Do not emit a reserved schema.
Expand Down
10 changes: 5 additions & 5 deletions docs/goals/current.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Current Goals

Status date: 2026-09-24. Release baseline: `0.99.0`.
Status date: 2026-09-24. Release baseline: `0.99.1`.

This page guides work on current merged source. Published behavior is described
in the [release notes](../release/0.99.0-local-browser-mac.md).
in the [release notes](../release/0.99.1-action-recovery.md).
The [September 9 history](https://github.com/danielgwilson/humanish/blob/main/docs/goals/current-history-2026-09-09.md)
preserves the former status log; its queues do not supersede this page.

Expand Down Expand Up @@ -88,7 +88,7 @@ requires decision-equivalent retained evidence and a real deletion branch.
No first-party deletion branch has met that gate. Public demonstrations do not
substitute for it.

## Current Program Truth (source `0.99.0`)
## Current Program Truth (source `0.99.1`)

| Surface | Available in merged source | Remaining boundary |
| --- | --- | --- |
Expand Down Expand Up @@ -123,8 +123,8 @@ ordinary TAP/NAT networking. Continue managed-local work from this complete stud
path; the earlier offline owner/service qualification experiments are historical
fixtures, not an installation architecture or a prerequisite queue. Explicit
Linux local labs now use the installed CLI/TUI, with a verified runtime download
before the first live run. Mac support, inbox integration and optional media
remain unfinished. Existing hosted labs retain their behavior.
before the first live run. Mac support is also shipped; inbox integration and
optional media remain unfinished. Existing hosted labs retain their behavior.

## Gates And Deferred Work

Expand Down
5 changes: 4 additions & 1 deletion docs/ramp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Status: public-safe contributor and agent ramp.

Package/source version in this tree: `0.99.0` (2026-09-24). The Observer is phone-usable as a stated requirement (observer/AGENTS.md); interactive primitives start from Base UI. The Observer renderer is the observer/ workspace artifact only; the legacy string-concat renderer was deleted at cutover (#426), and rollback is a version pin to 0.42.0. The containment boundary introduced in
Package/source version in this tree: `0.99.1` (2026-09-24). The Observer is phone-usable as a stated requirement (observer/AGENTS.md); interactive primitives start from Base UI. The Observer renderer is the observer/ workspace artifact only; the legacy string-concat renderer was deleted at cutover (#426), and rollback is a version pin to 0.42.0. The containment boundary introduced in
`0.15.1` remains in force: managed run and output paths bind to validated
physical filesystem identities, and stored provider IDs are evidence, not
cleanup authority. The bundled OSS meta-lab is dry-run only until
Expand Down Expand Up @@ -54,6 +54,9 @@ If a change does not improve one of those loops, it probably belongs elsewhere.

## Current State

The [0.99.1 release note](../release/0.99.1-action-recovery.md) describes
participant recovery from browser actions rejected before dispatch.

The [0.99.0 release note](../release/0.99.0-local-browser-mac.md) describes
local browser studies on supported Apple Silicon Macs through Lima, public ARM
runtime setup, Codex account participants and automatic analysis.
Expand Down
19 changes: 19 additions & 0 deletions docs/release/0.99.1-action-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 0.99.1 — Recover from rejected browser actions

A participant can now recover when the desktop explicitly rejects an action
before sending any input. For example, typing without an editable field focused
previously ended a local browser study with a harness error, even though the
browser remained usable.

The shared execution loop records the rejection, withholds the remaining actions
in that batch, and takes a fresh screenshot before asking the participant what
to do next. This prevents a queued submit from running after rejected typing.
The next turn receives accurate execution acknowledgements and a recovery hint;
rejected actions are not recorded as completed input. The harness does not
replay them automatically.

Uncertain outcomes, transport failures, revoked sessions and failed observations
retain their terminal handling. Existing time and no-progress limits still bound
repeated rejections. Updated runtime images keep the browser-control connection
open for this specific rejection; the shared host-side loop then allows recovery.
Account configuration and desktop setup are unchanged.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "humanish",
"version": "0.99.0",
"version": "0.99.1",
"description": "Open-source-safe CLI for persona simulation, observer review, and public-safe feedback drafts.",
"author": "Daniel G Wilson <daniel@danielgwilson.com>",
"keywords": [
Expand Down
2 changes: 1 addition & 1 deletion site/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Every command, argument, and option in the Humanish CLI.

{/* Generated by scripts/generate-cli-docs.ts from createProgram(). Do not edit by hand. */}

Reference for **Humanish 0.99.0**. Run `npx humanish --version` to check your installation, or `npx humanish <command> --help` for its exact flags.
Reference for **Humanish 0.99.1**. Run `npx humanish --version` to check your installation, or `npx humanish <command> --help` for its exact flags.

Start with the [quickstart](/docs). The examples below use the installed `humanish` binary; prefix them with `npx` from your project if it is not on your PATH.

Expand Down
5 changes: 3 additions & 2 deletions src/browser-control-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ export function createBrowserControlClient(options: BrowserControlClientOptions)
const operation = pending; pending = undefined; operation.dispose();
if (operation.reply!.ok) operation.resolve(operation.reply!);
else {
transport.close(operation.reply!.error.code);
operation.reject(new CuaExecutorError(operation.reply!.error.code, operation.reply!.error.disposition));
const { code, disposition } = operation.reply!.error;
if (operation.request.operation !== "EXECUTE" || code !== "action_rejected" || disposition !== "not_dispatched") transport.close(code);
operation.reject(new CuaExecutorError(code, disposition));
}
};
const transport = new BrowserControlTransport(options.transport, value => {
Expand Down
4 changes: 3 additions & 1 deletion src/browser-control-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ export function attachBrowserControlDispatcher(options: BrowserControlDispatcher
if (closed) return;
try {
await transport.send(reply);
if (!reply.ok) { transport.close(reply.error.code); return; }
if (!reply.ok && (request.operation !== "EXECUTE" || reply.error.code !== "action_rejected" || reply.error.disposition !== "not_dispatched")) {
transport.close(reply.error.code); return;
}
} catch { transport.close("transport_failed"); }
finally { dispose(); busy = false; }
}
Expand Down
29 changes: 21 additions & 8 deletions src/computer-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,7 @@ export async function runComputerUseLoop(options: CuaLoopOptions): Promise<CuaLo

const idleThisTurn = isIdleTurn(turn.actions);
previousExecution = { actions: [] };
let rejectedActionTitle: string | undefined;
for (const [actionIndex, action] of turn.actions.entries()) {
if (signal?.aborted) throw new CuaAbortError();
const actionTitle = describeCuaAction(action);
Expand Down Expand Up @@ -1485,21 +1486,30 @@ export async function runComputerUseLoop(options: CuaLoopOptions): Promise<CuaLo
await executeAction();
}
} catch (error) {
// RECOVERY at the loop boundary (covers ALL action kinds uniformly):
// only a genuine substrate-command failure is recoverable. The real
// @e2b/desktop Sandbox THROWS a CommandExitError on ANY non-zero exit
// (e.g. a Ctrl+Minus keypress exiting 2), so one flaky desktop command
// must not end the whole run. Everything else — a raceSettle deadline
// (CuaDeadlineError) or abort (CuaAbortError), a sandbox-gone failure,
// any non-CommandExitError — is rethrown. A typed executor declaration must also
// bypass command recovery even if an adapter has changed its ordinary Error metadata.
// A declared pre-dispatch action rejection leaves the desktop usable.
// Other typed failures retain their terminal handling, independently of
// the legacy E2B CommandExitError recovery below.
if (isCuaExecutorError(error)) {
previousExecution.actions.push({ index: actionIndex, status: error.disposition });
if (error.disposition === "not_dispatched" && !isIdleAction(action)) {
materialActions -= 1;
counts.materialActions = materialActions;
lastMaterialActionTitle = priorMaterialActionTitle;
}
if (error.code === "action_rejected" && error.disposition === "not_dispatched") {
rejectedActionTitle = actionTitle;
// Later actions may depend on this one (type, then submit). Return
// their full acknowledgement list and let a fresh observation decide.
for (let index = actionIndex + 1; index < turn.actions.length; index++) {
previousExecution.actions.push({ index, status: "not_dispatched" });
}
record({
id: nextId("notice"), kind: "notice", lifecycle: "completed", status: "warn",
title: "action rejected before dispatch",
text: redactNarration(`action: ${actionTitle}; code: action_rejected; disposition: not_dispatched; remaining batch actions not dispatched: ${turn.actions.length - actionIndex - 1}`)
});
break;
}
throw error;
}
if (!isCommandExitError(error)) throw error;
Expand Down Expand Up @@ -1641,6 +1651,9 @@ export async function runComputerUseLoop(options: CuaLoopOptions): Promise<CuaLo
// Recovery may suggest another approach, but must not instruct early abandonment while
// the task still calls for waiting. The counters, time and spend guards own hard stops.
const contextHints: string[] = [];
if (rejectedActionTitle !== undefined) {
contextHints.push(`Your action (${rejectedActionTitle}) was rejected before dispatch. No input from that action or the rest of its batch was sent. Choose your next action from the fresh screenshot; do not assume the rejected action succeeded.`);
}
if (consecutiveNoProgress >= noProgressRecoverySteps && consecutiveNoProgress < noProgressSteps) {
contextHints.push(
`No visible progress for ${consecutiveNoProgress} step(s). ` +
Expand Down
16 changes: 8 additions & 8 deletions src/local-runtime-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ import type { LocalRuntimeRelease } from "./local-runtime.js";

/** Updated with the verified, public runtime artifact before release. */
export const LOCAL_RUNTIME_RELEASE: LocalRuntimeRelease = {
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.24.1/runtime-linux-amd64.tar.gz",
sha256: "8df88a82b9615bf35ddcd4a60b1be6f95f178a254d88aab79871aace4a22ac56",
bytes: 596136865,
image: "sha256:9597abdb46255190640443ea485010d2a45f51699e7cb47372d6ee895d280f5c"
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.24.3/runtime-linux-amd64.tar.gz",
sha256: "936e4bdc5c844347c6d6ae2149f1f96cfcabab0c33c8dd7fb3ae1b74b3b91812",
bytes: 596154336,
image: "sha256:062f7e9eb8096f6e48906ec0d277c3a9d086ef4ebd275aa2efc43edcd86e5349"
};

/** Architecture-specific artifacts; each retains matching sources and notices. */
export const LOCAL_RUNTIME_RELEASES: Partial<Record<"amd64" | "arm64", LocalRuntimeRelease>> = {
amd64: LOCAL_RUNTIME_RELEASE,
arm64: {
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.24.2/runtime-linux-arm64.tar.gz",
sha256: "16f10b21f6436ef39f18fb9615cae7045d3fc2a20798c1e42122d574f58c759b",
bytes: 582695626,
image: "sha256:52cd53169236cd70a445938bda008987f18a97303794ae0ff75cc15bbbd26671"
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.24.4/runtime-linux-arm64.tar.gz",
sha256: "9b396d1d222917ffbac21ae448340fdcc563a9b2f757682d9806248941125cda",
bytes: 582659561,
image: "sha256:ed92f65a1f5b064952ea8b289f6bc1e7b5a9babe15ed1c7efec49e395e86eead"
}
};
15 changes: 12 additions & 3 deletions tests/browser-control-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,18 @@ describe("browser control client and dispatcher", () => {
expect(error).toMatchObject({ code: "action_rejected", disposition: "outcome_uncertain" });
expect(JSON.stringify(f.rightWrites.map(bytes => bytes.toString()))).not.toContain(secret); f.close();
});
it("accepts an explicit genuine driver rejection without claiming completion", async () => {
const f = setup({ executor: { observe: async () => observation(), execute: async () => { throw new CuaExecutorError("action_rejected", "not_dispatched"); } } });
await expect(f.client.executor.execute(click)).rejects.toMatchObject({ code: "action_rejected", disposition: "not_dispatched" }); f.close();
it("keeps the channel usable after a genuine pre-dispatch rejection", async () => {
const execute = vi.fn().mockRejectedValueOnce(new CuaExecutorError("action_rejected", "not_dispatched")).mockResolvedValue(undefined);
const f = setup({ executor: { observe: async () => observation(), execute } });
await expect(f.client.executor.execute(click)).rejects.toMatchObject({ code: "action_rejected", disposition: "not_dispatched" });
await expect(f.client.executor.observe()).resolves.toHaveProperty("screenshot");
await expect(f.client.executor.execute(click)).resolves.toBeUndefined();
expect(execute).toHaveBeenCalledTimes(2); f.close();
});
it("still closes the channel when an observation is rejected before dispatch", async () => {
const f = setup({ executor: { observe: async () => { throw new CuaExecutorError("action_rejected", "not_dispatched"); }, execute: async () => {} } });
await expect(f.client.executor.observe()).rejects.toMatchObject({ code: "action_rejected" });
await expect(f.client.executor.execute(click)).rejects.toMatchObject({ code: "executor_closed" }); f.close();
});
it.each([
request(1, "EXECUTE", { actionId: "action-1", action: click }), request(1, "HELLO", { secret: "extra" }),
Expand Down
57 changes: 51 additions & 6 deletions tests/cua-executor-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type CuaExecutorErrorCode, type CuaExecutorDisposition
} from "../src/cua-executor-error.js";
import {
runComputerUseLoop, type CuaAction, type CuaExecutor, type CuaLoopOptions, type CuaProvider
runComputerUseLoop, type CuaAction, type CuaExecutor, type CuaLoopOptions, type CuaProvider, type CuaTurn
} from "../src/computer-use.js";
import { defaultRedactionHooks } from "../src/redaction.js";

Expand Down Expand Up @@ -97,16 +97,61 @@ describe("executor failure attribution", () => {
expect(JSON.stringify(result)).not.toContain("synthetic-private-value");
});

it("retains an explicit refusal before dispatch without claiming uncertainty", async () => {
it.each([undefined, "fail_closed"] as const)("lets the participant recover from a rejected action with request policy %s", async (requestPolicy) => {
const rejected: CuaAction = { kind: "type", text: "synthetic-private-value" };
const submit: CuaAction = { kind: "keypress", keys: ["Enter"] };
const first: CuaAction = { kind: "click", x: 1, y: 2 };
const recovery: CuaAction[] = [{ kind: "click", x: 3, y: 4 }, { kind: "type", text: "replacement" }];
const turn = (actions: CuaAction[]): CuaTurn => ({ actions, done: actions.length === 0, pendingSafetyChecks: [],
providerRequest: { dispatched: true, usageComplete: false, cleanup: "confirmed" } });
const actor = { ...provider(), ...(requestPolicy ? { requestPolicy } : {}),
nextTurn: vi.fn<CuaProvider["nextTurn"]>()
.mockResolvedValueOnce(turn([first, rejected, submit]))
.mockResolvedValueOnce(turn(recovery))
.mockResolvedValueOnce(turn([])) };
let frame = 0;
const execute = vi.fn(async (action: CuaAction) => {
if (action === rejected) throw new CuaExecutorError("action_rejected", "not_dispatched");
});
const result = await run({ observe: async () => ({ stateSignature: `frame-${++frame}` }), execute }, { provider: actor });
expect(result).toMatchObject({ status: "passed", completionReason: "goal_satisfied" });
expect(execute.mock.calls.map(([action]) => action)).toEqual([first, rejected, ...recovery]);
const next = actor.nextTurn.mock.calls[1]![0];
expect(next.observation.stateSignature).toBe("frame-2");
expect(next.contextHint).toContain("rejected before dispatch");
if (requestPolicy) expect(next.previousExecution?.actions).toEqual([
{ index: 0, status: "completed" }, { index: 1, status: "not_dispatched" }, { index: 2, status: "not_dispatched" }
]);
expect(result.trace.items.filter(item => item.kind === "ui_action")).toHaveLength(3);
expect(result.trace.counts).toMatchObject({ actions: 4, materialActions: 3 });
expect(result.trace.items.find(item => item.title === "action rejected before dispatch")).toMatchObject({
status: "warn", text: expect.stringContaining("remaining batch actions not dispatched: 1")
});
expect(JSON.stringify(result)).not.toContain("synthetic-private-value");
});

it("bounds repeated pre-dispatch rejection without counting it as progress", async () => {
const result = await run({
observe: async () => ({ stateSignature: "fixture" }),
execute: async () => { throw new CuaExecutorError("action_rejected", "not_dispatched"); }
}, { provider: provider([{ kind: "click", x: 1, y: 2 }]) });
}, { provider: provider([{ kind: "click", x: 1, y: 2 }]), noProgressSteps: 2 });
expect(result.completionReason).toBe("gave_up");
expect(result.trace.items.filter(item => item.kind === "ui_action")).toEqual([]);
expect(result.trace.counts.actions).toBe(3);
expect(result.trace.counts.materialActions).toBe(0);
});

it.each([
["action_rejected", "outcome_uncertain"], ["transport_failed", "not_dispatched"],
["session_revoked", "not_dispatched"], ["invalid_request", "not_dispatched"]
] satisfies [CuaExecutorErrorCode, CuaExecutorDisposition][])("still stops for %s / %s", async (code, disposition) => {
const actor = provider([{ kind: "type", text: "private" }]);
const execute = vi.fn(async () => { throw new CuaExecutorError(code, disposition); });
const result = await run({ observe: async () => ({ stateSignature: "fixture" }), execute }, { provider: actor });
expect(result.completionReason).toBe("harness_error");
expect(result.reason).toBe("desktop executor error: action_rejected; disposition: not_dispatched");
expect(execute).toHaveBeenCalledOnce();
expect(actor.nextTurn).toHaveBeenCalledOnce();
expect(result.trace.items.filter(item => item.kind === "ui_action")).toEqual([]);
expect(result.trace.counts.actions).toBe(1); // The participant chose it.
expect(result.trace.counts.materialActions).toBe(0); // The executor refused before dispatch.
});

it("keeps acknowledged actions when the following observation fails, without retrying", async () => {
Expand Down
Loading
Loading