A reply can stop and ask you something

Three features turn out to be one mechanism: a command waiting to be
approved, a question the model wants answered, and "this reply is waiting
for you" are all — stop the generation, put an interactive block in the
bubble, wait for a POST, carry on. So there is one primitive, and the only
thing using it so far is `ask_user`: a model can offer you a few answers
and a box to write your own.

The shell executor is not here yet. This lands first on purpose, because
it is the riskiest machinery in the feature and it is worth having working
before any subprocess exists to complicate it.

Two things about where the pause sits. It pauses a round, not a call: a
round's calls run together under a semaphore, and parking four coroutines
on four separate answers inside that gather would queue them behind each
other invisibly. And Stop had to be taught about it — `cancel` is read
between streamed chunks and there are no chunks while paused, so the
button did nothing at all until `request_stop` learned to resolve the
pause itself.

Also here: a risk class on every tool (read, write, execute), which is
what the four permission modes will be a table over, and the systemd unit
loses ProtectKernelTunables. That last one is not tidying — it
bind-mounts /proc/sys read-only, which stops bubblewrap mounting /proc at
all, and the obvious workaround would expose this process's environment
and with it the encryption key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 19:30:44 +02:00
parent ecb52e9978
commit b39e4eac88
19 changed files with 1662 additions and 17 deletions
+45
View File
@@ -388,6 +388,51 @@
overflow-y: auto;
}
/* --- The model asking you something --------------------------------------- */
/* Attributed to the model on purpose. A card styled like the application is a
card people answer with things they would not tell a chatbot. */
.interaction {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin: var(--sp-3) 0;
padding: var(--sp-4);
border: 1px solid var(--accent);
border-radius: var(--radius-md);
background: var(--surface);
}
.interaction__from {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
color: var(--ink-faint);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.interaction__item { display: flex; flex-direction: column; gap: var(--sp-2); }
.interaction__title { margin: 0; color: var(--ink); font-weight: 500; }
.interaction__detail {
margin: 0;
padding: var(--sp-3);
border-radius: var(--radius-sm);
background: var(--bg-sunken);
font-family: var(--font-mono);
font-size: var(--text-xs);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.interaction__reason { margin: 0; color: var(--ink-muted); font-size: var(--text-xs); }
.interaction__actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
align-items: center;
}
.interaction__write { display: flex; gap: var(--sp-2); flex: 1 1 16rem; min-width: 0; }
.interaction__write .input { flex: 1; min-width: 0; }
/* --- Stop, notes and editing ---------------------------------------------- */
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
.msg__status:empty { display: none; }
+13
View File
@@ -397,6 +397,19 @@ document.addEventListener("lembas:unread", function (event) {
window.lembas.notify(message, { kind: "success", timeout: 6000 });
});
/*
A toast asked for by the server.
Some routes answer 204 because there is nothing to swap, and still have
something to say -- answering a question that has already timed out, for
instance. `HX-Trigger: {"lembas:notify": {"message": …}}` is how they say it.
*/
document.addEventListener("lembas:notify", function (event) {
var detail = event.detail || {};
if (!detail.message || !window.lembas || !window.lembas.notify) return;
window.lembas.notify(detail.message, { kind: detail.kind || "" });
});
/*
Send becomes Stop while a reply is being written.
@@ -0,0 +1,67 @@
{% from "_macros.html" import icon %}
{#
The reply has stopped and is waiting for you.
Two shapes, one mechanism: a question the model asked, and (later) a command
waiting to be allowed. Everything shown here is model output and is escaped
accordingly -- the question text, the options on the buttons and the command
itself all came from a model that may have been reading somebody else's file
a moment ago.
Deliberately attributed to the model rather than styled as if LLeMbas were
asking. A question that looks like it came from the application is a question
people answer with things they would not tell a chatbot.
hx-swap="none" because the SSE stream clears this card the moment the answer
lands; swapping a response in here would fight it.
#}
<div class="interaction interaction--{{ ask.kind }}">
<p class="interaction__from">
{{ icon("sparkle", "icon--sm") }}
<span>The model is asking you</span>
</p>
{% for item in ask.items %}
<div class="interaction__item">
<p class="interaction__title">{{ item.title }}</p>
{% if item.detail %}
<pre class="interaction__detail">{{ item.detail }}</pre>
{% endif %}
{% if item.reason %}
<p class="interaction__reason">{{ item.reason }}</p>
{% endif %}
</div>
{% endfor %}
<form class="interaction__actions"
hx-post="/api/chats/{{ chat_id }}/interaction/{{ ask.id }}"
hx-swap="none">
{% if ask.kind == "question" %}
{% for option in ask.options %}
<button class="btn" type="submit" name="choice" value="{{ option }}">{{ option }}</button>
{% endfor %}
{% if ask.allow_free_text %}
{# Never type="password". A model talked into asking for a credential
must not be handed a field that looks built for one, and a transcript
is not a place to put secrets. #}
<div class="interaction__write">
<input class="input" type="text" name="text" autocomplete="off"
placeholder="Or write your own answer…">
<button class="btn btn--primary" type="submit">
{{ icon("send", "icon--sm") }} Answer
</button>
</div>
{% endif %}
{% else %}
<button class="btn btn--primary" type="submit" name="choice" value="allow">
{{ icon("check", "icon--sm") }} Allow
</button>
<button class="btn" type="submit" name="choice" value="allow_always">
Always allow this
</button>
<button class="btn btn--danger" type="submit" name="choice" value="deny">
{{ icon("x", "icon--sm") }} Don't
</button>
{% endif %}
</form>
</div>
@@ -110,6 +110,14 @@
<div class="tool-activity-list" id="tools-{{ message.id }}"
sse-swap="tools" hx-swap="innerHTML"></div>
{# Where a question from the model, or a command waiting to be allowed,
lands. Unlike the blocks above it this frame is sent on every version
bump including when it is empty, because the card has to disappear the
moment it is answered. The buttons inside are hx-post and they work:
the SSE extension processes what it swaps in. #}
<div class="interaction-slot" id="ask-{{ message.id }}"
sse-swap="ask" hx-swap="innerHTML"></div>
{# The server re-renders the answer as Markdown a few times a second and
replaces this whole block, so formatting appears as the model writes
rather than snapping into place at the end. #}