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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,10 @@ Tendwire now exposes a minimal, safety-first command interface:
echo '{"schema_version": 1, "action": "noop"}' | tendwire command --json
```

The connector-facing structured Claude decision payload, semantic
`answer_decision` action, validation, and retry behavior are documented in
[docs/answer_decision.md](docs/answer_decision.md).

The `command --json` subcommand reads exactly one schema-v1 JSON request from
stdin. A proven result is exactly one schema-v2 command envelope on JSON-only
stdout with exit `0`/`1`; unresolved process ambiguity is no stdout envelope
Expand Down
120 changes: 120 additions & 0 deletions docs/answer_decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Remote decision contract

`answer_decision` is Tendwire's semantic connector contract for answering the
current structured Claude decision on one worker. Connectors select public
option references or provide permitted write-in text. They never send pane IDs,
terminal IDs, raw keys, cursor movements, or calibration steps. Tendwire
validates the current prompt and owns the private Herdr calibration.

## Pending payload

A structured decision appears on a `pending_interactions` item as
`meta.decision`:

```json
{
"decision_ref": "decision-<opaque>",
"kind": "single",
"prompt": "Choose a database",
"options": [
{"ref": "1", "label": "Postgres"},
{"ref": "2", "label": "SQLite"}
],
"multi_select": false,
"question_count": 1
}
```

`decision_ref` identifies one exact prompt instance and changes whenever the
prompt revision or its private source binding changes. Option `ref` values are
stable 1-based ordinals for that instance. Connectors must treat the whole
object as current-state data and fail closed when `question_count` is greater
than 1.

Supported shapes are:

- `single`: exactly one option, or nonempty write-in text;
- `plan`: exactly one option;
- `multi`: one or more options from a single question.

Tendwire supports at most 9 digit-addressable option rows. A single-choice
decision may additionally contain one recognized trailing custom/write-in row;
that row is not exposed as an option reference. A source decision with more
than 9 selectable rows is not truncated. Unknown decision kinds, over-bound
decisions, and multi-question decisions do not produce `meta.decision` and
cannot be sent.

## Command request

```json
{
"schema_version": 1,
"action": "answer_decision",
"request_id": "connector-request-123",
"dry_run": false,
"target": {"worker_id": "worker-123"},
"params": {
"decision_ref": "decision-<opaque>",
"selection": {"option_refs": ["2"]}
}
}
```

`selection` contains exactly one of these forms:

- `{"option_refs":[...]}`: every reference must exist in the current stored
options. `single` and `plan` require exactly one unique reference; `multi`
requires one or more unique references.
- `{"text":"..."}`: a nonempty write-in accepted only for `single`.

Omitting `dry_run` means `dry_run: true`. A live answer therefore requires the
caller to set `dry_run: false` explicitly. Dry runs perform no pane operation
and write no mutation receipt.

Before any pane operation, Tendwire freshly proves that the worker exists and
is open, the supplied reference is its current pending decision, the decision
shape is supported, and the selection is valid. A proven live success returns
`ok: true`, `status: "accepted"`, and a `terminal_accepted` disposition.

The four typed validation failures are terminal for that attempted decision:

- `unknown_worker`;
- `decision_not_pending`;
- `invalid_selection`;
- `unsupported_decision`.

All fail before pane input. `unsupported_decision` covers multiple question
groups, more than 9 selectable rows, and unknown kinds.

## Concurrency and retries

Only one request may claim a decision at a time. A competing request receives
`ok: false`, `status: "answer_in_progress"`, with either `no_receipt` (before it
reserved a request) or `in_progress` (when its unsent reservation remains
recoverable). This status is retryable. It never creates a terminal receipt and
never causes a second pane mutation.

If the winning request fails safely before sending, Tendwire releases its
decision claim so a loser may retry. An abandoned pre-send reservation and
claim can be taken over after their leases expire. Once sending may have
started, Tendwire fails closed with `request_state_uncertain` and does not
automatically resend.

`request_id` uses the normal command deduplication contract. Repeating the same
canonical request ID replays its authoritative result without sending again;
reusing it for a different canonical mutation is rejected as
`duplicate_request`.

## Paired adapter requirement

The Tendwire and Herdres versions must be deployed as a matching pair that both
implement this contract. In particular, the paired Herdres adapter must emit a
single-question `AskUserQuestion` with `multiSelect: true` as a structured
`pending_decision` with `mode: "multi"`, `multi_select: true`, 1-based option
IDs, and no custom row. Older adapters that leave that prompt as an unstructured
`pending_interaction` cannot use semantic multi-select answering. Multi-question
prompts remain unstructured and unsupported.

The Claude Code multi-select key behavior is a private backend assumption, not
part of this public contract. Its calibration (live-verified against Claude Code 2.1.211) is isolated in
`src/tendwire/backends/herdr_decision.py` for live verification and retuning.
9 changes: 8 additions & 1 deletion scripts/sqlite_sidecar_race_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,13 @@ def _run_herdres_phase(
if herdres_root.resolve() not in module_path.parents:
raise RuntimeError("herdres_origin_failed")
origin_ok = True
# The paired Herdres owns its turn page size (it changed 50 -> 100 in
# luminexord/herdres 31c3152); derive the expected command sequence from
# the paired checkout instead of hardcoding a value that breaks the
# pairing every time Herdres retunes it.
turn_page_limit = getattr(tendwire_client, "TURN_LIST_PAGE_LIMIT", 50)
if type(turn_page_limit) is not int or turn_page_limit < 1:
raise RuntimeError("herdres_turn_page_limit_invalid")
runtime = source_sync.SyncRuntime(
tendwire=tendwire_client.TendwireClient(timeout=10.0),
telegram=telegram_delivery.TelegramClient(token="", dry_run=True),
Expand Down Expand Up @@ -1043,7 +1050,7 @@ def _run_herdres_phase(
"--schema-version",
"2",
"--limit",
"50",
str(turn_page_limit),
"--json",
],
["--socket-path", str(socket_path), "pending", "--json"])
Expand Down
124 changes: 124 additions & 0 deletions src/tendwire/backends/herdr_decision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Translate semantic Claude decisions into private Herdr pane input.

Calibration assumptions are deliberately confined to this backend module, and
every mapping below was LIVE-VERIFIED against Claude Code 2.1.211 on a real
pane (2026-07-16):

* Single-choice and plan rows carry 1-based decimal shortcuts, and typing the
ordinal alone SELECTS AND SUBMITS the row instantly — no Enter follows. A
trailing Enter would leak into whatever UI appears next, so none is sent.
* The single-choice write-in row ("Type something", at position N + 1) does
NOT respond to its digit. It is reached by pressing Down exactly N times
from the initial cursor on row 1; the focused row is itself a text input,
so Tendwire then sends the write-in prose and submits with Enter.
* Multi-select digits toggle their row ABSOLUTELY without moving the cursor,
Right switches to the Submit tab (a review screen whose default focus is
"Submit answers"), and Enter there submits the selection set.
* Every driven ordinal must stay a single keystroke, so decisions expose at
most 9 real options (PENDING_DECISION_MAX_OPTIONS in herdr_turns).
* Herdr's private ``pane.send_keys`` accepts decimal character keys plus
``Down``, ``Up``, ``Right``, and ``Enter``; write-in prose uses
``pane.send_input`` so Herdr owns terminal text encoding and appends the
final Enter atomically.

These steps are internal calibration data. They are never accepted from a
connector and there is intentionally no public raw-key command action.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal


MULTI_SELECT_CALIBRATION = {
"submit_tab": "Right",
"submit": "Enter",
}


@dataclass(frozen=True)
class HerdrDecisionStep:
"""One private, already-calibrated Herdr pane operation."""

operation: Literal["keys", "text", "input"]
keys: tuple[str, ...] = ()
text: str | None = None

def __post_init__(self) -> None:
if self.operation == "keys":
if not self.keys or self.text is not None:
raise ValueError("key calibration step requires only keys")
elif self.operation == "text":
raise ValueError("text calibration steps are no longer produced")
elif self.operation == "input":
if not isinstance(self.text, str) or not self.text or self.keys != ("Enter",):
raise ValueError("input calibration step requires text plus Enter")
else:
raise ValueError("unsupported decision calibration operation")


def _digit_keys(value: int | str) -> tuple[str, ...]:
text = str(value)
if not text.isdigit() or int(text) < 1:
raise ValueError("decision ordinal must be a positive decimal")
return tuple(text)


def calibrate_decision_steps(
*,
kind: Literal["single", "multi", "plan"],
option_count: int,
option_refs: tuple[str, ...] = (),
text: str | None = None,
) -> tuple[HerdrDecisionStep, ...]:
"""Return private pane operations for one validated semantic selection."""
if (
kind not in {"single", "multi", "plan"}
or not isinstance(option_count, int)
or isinstance(option_count, bool)
or option_count < 1
):
raise ValueError("invalid decision calibration context")
if text is not None:
if kind != "single" or option_refs or not isinstance(text, str) or not text:
raise ValueError("invalid decision write-in calibration")
# The write-in row ignores digits; reach it with Down x N from row 1,
# where the focused row is itself the text input.
return (
HerdrDecisionStep("keys", keys=("Down",) * option_count),
HerdrDecisionStep("input", keys=("Enter",), text=text),
)
if not option_refs or len(option_refs) != len(set(option_refs)):
raise ValueError("decision option refs must be nonempty and unique")
ordinals: list[int] = []
for ref in option_refs:
if not isinstance(ref, str) or not ref.isdigit():
raise ValueError("decision option ref must be a decimal ordinal")
ordinal = int(ref)
if not 1 <= ordinal <= option_count:
raise ValueError("decision option ref is out of range")
ordinals.append(ordinal)
if kind in {"single", "plan"}:
if len(ordinals) != 1:
raise ValueError("single and plan decisions require one option")
# The digit alone selects AND submits; a trailing Enter would leak into
# the next UI (composer, or worse, a modal).
return (HerdrDecisionStep("keys", keys=_digit_keys(ordinals[0])),)

# Digits toggle rows absolutely (cursor-independent); Right reaches the
# Submit tab whose default focus is "Submit answers"; Enter submits.
steps: list[HerdrDecisionStep] = [
HerdrDecisionStep("keys", keys=_digit_keys(ordinal))
for ordinal in sorted(ordinals)
]
steps.append(
HerdrDecisionStep(
"keys",
keys=(
MULTI_SELECT_CALIBRATION["submit_tab"],
MULTI_SELECT_CALIBRATION["submit"],
),
)
)
return tuple(steps)
Loading
Loading