diff --git a/docs/web-ui.md b/docs/web-ui.md
index ff86d9e2..6c88f02a 100644
--- a/docs/web-ui.md
+++ b/docs/web-ui.md
@@ -910,7 +910,7 @@ dashboard's side panel shape. Carries inbox and loose-ends flyouts
diffs, journald logs). Inbox flyout: last 30 messages addressed to
this agent (`AgentRequest::Recent { limit: 30 }`); reply messages
indented with `↳ reply ·` in amber. A `✓ mark all read` button
-appears in the flyout header when the inbox is non-empty (#559);
+appears in the flyout header when the inbox is non-empty;
clicking it confirms then POSTs cross-origin to the core
dashboard's `POST /api/agent/{name}/mark-all-read` — all pending
messages for this agent are acked, the harness won't receive
@@ -923,6 +923,31 @@ question rows carry an inline answer form that POSTs cross-origin to
the core dashboard's `/answer-question/{id}` so the operator answers
*as operator* (see `docs/boundary.md`).
+**Ask → operator inline-answer binding.** When the agent emits
+`mcp__hyperhive__ask(to: "operator", ...)`, the tool_use renderer
+mounts an empty slot (`
`)
+right under the `↳ ask → operator` row in the terminal scrollback
+and pushes a reference into `pendingAskBinds`. The broker assigns
+the question id asynchronously, so the slot waits — and the next
+`/api/loose-ends` refresh runs `reconcileAskBinds()`, which walks
+the slot list and pairs each unbound slot with the first unclaimed
+pending operator-bound question whose `question` text matches the
+slot's stashed `_askQuestion`. On match the slot mounts the
+`buildAnswerForm` (same form shape as the loose-ends flyout —
+POSTs to the core's `/answer-question/{id}` cross-origin). Slots
+stay in the array after binding so the reconciler can flip them
+to a neutral `[resolved]` tag when the question later disappears
+from the pending list. Disappearance can mean answered, cancelled
+by the asker, or TTL-expired — the neutral label avoids
+mis-asserting "✓" on the cancel / expire paths; full resolution
+state is visible via the side-panel history. A defensive prune
+walks the slot list each tick and drops any whose DOM node has
+been removed (e.g. via a future "clear single row" affordance),
+so stale references don't accumulate. Slots whose question never
+arrives (e.g. the agent cancelled the ask, or the question is
+older than the loose-ends retention window) stay empty — the
+operator can still answer via the side panel, no regression.
+
### Live view
Each agent runs an `events::Bus`: a `tokio::sync::broadcast
`
diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js
index 49f7a22c..b181d248 100644
--- a/frontend/packages/agent/src/app.js
+++ b/frontend/packages/agent/src/app.js
@@ -682,17 +682,9 @@ window.marked = marked;
let lastLooseEnds = [];
let lastInbox = [];
- /** #666: ask-operator inline-answer slots awaiting a question id.
- * Each entry is the empty ``
- * element appended to an `ask → operator` row by
- * `renderRichToolUse`. `reconcileAskBinds()` walks the list on
- * every loose-ends refresh, looking up the matching pending
- * question by `kind=question` + `target == 'operator'` (or
- * missing — broker omits target for operator-bound asks) +
- * question text match against the slot's stashed `_askQuestion`.
- * Slots stay in the array even after binding so the reconciler
- * can flip them back to "answered" if the question later
- * resolves. */
+ /** ask → operator inline-answer slot registry. See
+ * docs/web-ui.md::Per-agent page (Ask → operator inline-answer
+ * binding) for the matching algorithm + resolution semantics. */
const pendingAskBinds = [];
function buildLooseEndsList(threads) {
@@ -764,53 +756,32 @@ window.marked = marked;
if (pill) pill.hidden = threads.length === 0;
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
buildLooseEndsList(threads));
- // #666: wire inline answer forms into any `ask → operator` rows
- // that have been waiting for their broker-assigned question id.
+ // Wire inline answer forms into any `ask → operator` rows
+ // waiting on a broker-assigned question id.
reconcileAskBinds();
}
- /** #666: for each `ask → operator` row mounted by renderRichToolUse,
- * look up the matching pending question in `lastLooseEnds` (by
- * target=operator + question text) and inject an inline answer
- * form. If a previously-bound slot's question has been resolved
- * (no longer in the pending list), replace the form with a small
- * `[answered ✓]` tag so the scrollback reflects the closed state.
- * Slots whose question never appears (e.g. agent cancelled the
- * ask, or the question is older than the loose-ends fetch
- * retention) stay empty — the operator can still answer via the
- * side panel, no regression. */
+ /** Walk `pendingAskBinds` against the latest `lastLooseEnds`
+ * snapshot, pair unbound slots with the first unclaimed pending
+ * operator-bound question whose text matches, and flip already-
+ * bound slots to `[resolved]` when their question disappears.
+ * See docs/web-ui.md::Per-agent page (Ask → operator inline-answer
+ * binding) for the full design + edge cases. */
function reconcileAskBinds() {
if (!pendingAskBinds.length) return;
- // Build an index of pending operator-bound questions by text.
- // Note: an agent could in theory ask the same question text
- // twice; the loose-ends list is small and the broker assigns
- // distinct ids — we match the first unbound slot to the first
- // unmatched pending question to keep the pairing stable.
const pending = lastLooseEnds.filter(
(t) => t.kind === 'question' && (!t.target || t.target === 'operator'),
);
- // Defensive prune: drop slots whose DOM node has been removed
- // (e.g. via a future "clear single row" affordance) so we don't
- // process detached references on every refresh.
+ // Defensive prune: drop slots whose DOM node has been removed.
for (let i = pendingAskBinds.length - 1; i >= 0; i--) {
if (!pendingAskBinds[i].isConnected) pendingAskBinds.splice(i, 1);
}
- // Track which pending question ids have already been claimed by
- // an existing bound slot so we don't double-bind on refresh.
const claimed = new Set();
for (const slot of pendingAskBinds) {
if (slot._boundId) claimed.add(slot._boundId);
}
for (const slot of pendingAskBinds) {
if (slot._boundId) {
- // Already bound — check if the question got resolved. The
- // loose-ends endpoint only returns pending questions, so
- // disappearance can mean "answered", "cancelled by asker",
- // or "TTL expired" (argus #668 review). Use the neutral
- // `[resolved]` label rather than a check-mark that would
- // misrepresent the cancel / expire paths; full resolution
- // state is visible via the question's history in the
- // side panel.
const stillPending = pending.some((q) => q.id === slot._boundId);
if (!stillPending && !slot._resolvedShown) {
slot.innerHTML = '';
@@ -819,7 +790,6 @@ window.marked = marked;
}
continue;
}
- // Find the first matching pending question we haven't claimed yet.
const q = pending.find((p) => p.question === slot._askQuestion && !claimed.has(p.id));
if (!q) continue;
claimed.add(q.id);
@@ -833,7 +803,7 @@ window.marked = marked;
// the host dashboard (core backend), never this agent's socket — the
// core is the only place that can stamp `operator` as the answerer.
// Used by both the loose-ends side panel (`buildLooseEndsList`) and
- // the in-stream ask-row binder (`reconcileAskBinds`, #666).
+ // the in-stream ask-row binder (`reconcileAskBinds`).
function buildAnswerForm(id) {
const wrap = el('div', { class: 'answer-form' });
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });