shell: patches queue — agent stages, human applies
The composed config is symlinked into the repo and quickshell reloads on
write, so an agent editing QML edits the live shell mid-turn. Stage changes
as patches instead; the human applies and reloads on their own beat.
First in the queue: 0001-resume-offer — the agent-established hook offers
the latest thread instead of silently attaching it. Explicit resume paths
and the e4e6594 amnesia fix are untouched. Untested against a running
shell.
This commit is contained in:
parent
ce6741bf77
commit
aaea09fdd6
2 changed files with 164 additions and 0 deletions
123
surfaces/quickshell/patches/0001-resume-offer.patch
Normal file
123
surfaces/quickshell/patches/0001-resume-offer.patch
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
From 377a7542fef4422e1e3232a0c1d5f93ca6aaf1f5 Mon Sep 17 00:00:00 2001
|
||||||
|
From: souveraine <souveraine@wiuf.net>
|
||||||
|
Date: Mon, 10 Aug 2026 23:04:36 -0400
|
||||||
|
Subject: [PATCH] shell: offer resume instead of forcing it
|
||||||
|
|
||||||
|
---
|
||||||
|
surfaces/quickshell/services/Souveraine.qml | 67 ++++++++++++++++++---
|
||||||
|
1 file changed, 58 insertions(+), 9 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/surfaces/quickshell/services/Souveraine.qml b/surfaces/quickshell/services/Souveraine.qml
|
||||||
|
index fe5e08e..6d27cc1 100644
|
||||||
|
--- a/surfaces/quickshell/services/Souveraine.qml
|
||||||
|
+++ b/surfaces/quickshell/services/Souveraine.qml
|
||||||
|
@@ -44,17 +44,36 @@ Singleton {
|
||||||
|
property var agents: ({})
|
||||||
|
property var agentList: Object.keys(agents)
|
||||||
|
property string currentAgentId: ""
|
||||||
|
- // Auto-resume: whenever the active agent is (re)established — shell start,
|
||||||
|
- // reboot, agent switch — re-attach her latest server-persisted conversation
|
||||||
|
- // so the next message continues the thread instead of minting a fresh one.
|
||||||
|
- // Without this every shell reload starts amnesiac, and the shell reloads
|
||||||
|
- // often (a deploy, a lock, a crash). The server is the source of truth here;
|
||||||
|
- // the surface deliberately keeps no agent → conversation map of its own.
|
||||||
|
+ // On agent (re)establishment — shell start, reboot, agent switch — we look
|
||||||
|
+ // up her latest server-persisted conversation. What we do with it depends
|
||||||
|
+ // on autoResume.
|
||||||
|
+ //
|
||||||
|
+ // autoResume false (default): we *offer* it. `resumeOffered` fires with
|
||||||
|
+ // the thread's id and metadata; nothing is attached. Doing nothing
|
||||||
|
+ // starts fresh, which is what a reload should do. Continuing is one
|
||||||
|
+ // deliberate act (acceptOfferedResume), not a default.
|
||||||
|
+ // autoResume true: legacy behaviour — attach it silently.
|
||||||
|
+ //
|
||||||
|
+ // The distinction matters because the shell reloads often (a deploy, a
|
||||||
|
+ // lock, a crash) and silent re-attachment makes every one of those look
|
||||||
|
+ // like a continuation of a conversation the human may have finished with.
|
||||||
|
+ // Explicit resume paths (/resume, the Face control) are unaffected: they
|
||||||
|
+ // call resumeLatestConversation() with no argument and still load.
|
||||||
|
+ //
|
||||||
|
+ // Note this is NOT the amnesia fix from e4e6594 — that one stopped
|
||||||
|
+ // selectAgent clearing conversationId on an unchanged agent, and stays.
|
||||||
|
// Gated so it never clobbers a live turn or an already-attached thread.
|
||||||
|
+ property bool autoResume: Config.options?.ai?.autoResume ?? false
|
||||||
|
+
|
||||||
|
+ // Emitted when a resumable thread exists and we chose not to attach it.
|
||||||
|
+ // agentId/conversationId identify it; the rest is for drawing the offer.
|
||||||
|
+ signal resumeOffered(string agentId, string conversationId, string title, string updatedAt)
|
||||||
|
+ property string offeredConversationId: ""
|
||||||
|
+
|
||||||
|
onCurrentAgentIdChanged: {
|
||||||
|
if (root.currentAgentId.length > 0 && root.serverUp
|
||||||
|
&& root.conversationId.length === 0 && !root.turnActive) {
|
||||||
|
- root.resumeLatestConversation();
|
||||||
|
+ root.resumeLatestConversation(!root.autoResume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
property string conversationId: ""
|
||||||
|
@@ -214,6 +233,9 @@ Singleton {
|
||||||
|
Process {
|
||||||
|
id: listConversations
|
||||||
|
property string agentId: ""
|
||||||
|
+ // Set by resumeLatestConversation(true): announce the thread rather
|
||||||
|
+ // than loading it.
|
||||||
|
+ property bool offerOnly: false
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
if (listConversations.agentId !== root.currentAgentId) return;
|
||||||
|
@@ -240,7 +262,16 @@ Singleton {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Server sorts by updated_at descending — [0] is her latest.
|
||||||
|
- root._loadConversation(listConversations.agentId, conversations[0].id);
|
||||||
|
+ const latest = conversations[0];
|
||||||
|
+ if (listConversations.offerOnly) {
|
||||||
|
+ // Announce, don't attach. conversationId stays empty, so a
|
||||||
|
+ // send without accepting mints a fresh thread on purpose.
|
||||||
|
+ root.offeredConversationId = latest.id;
|
||||||
|
+ root.resumeOffered(listConversations.agentId, latest.id,
|
||||||
|
+ latest.title ?? "", latest.updated_at ?? "");
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ root._loadConversation(listConversations.agentId, latest.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onExited: exitCode => {
|
||||||
|
@@ -287,8 +318,11 @@ Singleton {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- function resumeLatestConversation() {
|
||||||
|
+ // offerOnly: fetch the latest thread but announce it instead of attaching.
|
||||||
|
+ // Defaults to false so every existing caller keeps its old behaviour.
|
||||||
|
+ function resumeLatestConversation(offerOnly) {
|
||||||
|
if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive) return false;
|
||||||
|
+ listConversations.offerOnly = (offerOnly === true);
|
||||||
|
listConversations.agentId = root.currentAgentId;
|
||||||
|
listConversations.command = [
|
||||||
|
"curl", "-sf", "--max-time", "5",
|
||||||
|
@@ -298,6 +332,21 @@ Singleton {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ // Take up an offer made by resumeOffered. No-op if the offer has gone
|
||||||
|
+ // stale (a turn started, or something else attached in the meantime).
|
||||||
|
+ function acceptOfferedResume() {
|
||||||
|
+ if (root.offeredConversationId.length === 0 || root.turnActive) return false;
|
||||||
|
+ if (root.conversationId.length > 0) return false;
|
||||||
|
+ root._loadConversation(root.currentAgentId, root.offeredConversationId);
|
||||||
|
+ root.offeredConversationId = "";
|
||||||
|
+ return true;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Decline. The thread stays on the server; we simply start fresh.
|
||||||
|
+ function dismissOfferedResume() {
|
||||||
|
+ root.offeredConversationId = "";
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
function _loadConversation(agentId, conversationId) {
|
||||||
|
loadConversation.agentId = agentId;
|
||||||
|
loadConversation.requestedConversationId = conversationId;
|
||||||
|
--
|
||||||
|
2.55.0
|
||||||
|
|
||||||
41
surfaces/quickshell/patches/README.md
Normal file
41
surfaces/quickshell/patches/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# patches/
|
||||||
|
|
||||||
|
Staged shell changes that are **not** applied to the tree.
|
||||||
|
|
||||||
|
## Why this directory exists
|
||||||
|
|
||||||
|
`~/.config/quickshell/souveraine/*.qml` are symlinks into this repo, and
|
||||||
|
quickshell reloads on write. So editing a file here edits the *running* shell,
|
||||||
|
immediately. When the agent is the one editing, that means an edit can take
|
||||||
|
down the process the agent's own turn is running inside — and a shell that
|
||||||
|
fails to compile does not come back on its own (see 29ec9fe: one bad root type
|
||||||
|
failed the whole `qs.services` module).
|
||||||
|
|
||||||
|
So the rule is: **the agent writes patches, the human applies them.** The
|
||||||
|
reload happens on a human's beat, when nothing is in flight, with a known-good
|
||||||
|
tree one `git checkout` away.
|
||||||
|
|
||||||
|
## Applying
|
||||||
|
|
||||||
|
cd ~/Projects/souveraine
|
||||||
|
git am surfaces/quickshell/patches/0001-....patch
|
||||||
|
# then reload the shell yourself and watch it come up
|
||||||
|
|
||||||
|
Verify before trusting:
|
||||||
|
|
||||||
|
cd surfaces/quickshell
|
||||||
|
./deploy.sh
|
||||||
|
timeout 40 qs -c souveraine 2>&1 | grep -E "ERROR|Configuration Loaded"
|
||||||
|
|
||||||
|
If it does not come up, `git reset --hard HEAD~1` and the patch is just a file
|
||||||
|
again.
|
||||||
|
|
||||||
|
## Discipline
|
||||||
|
|
||||||
|
- One patch, one behaviour. Reviewable in a sitting.
|
||||||
|
- The patch's commit message says what it changes and what it deliberately
|
||||||
|
leaves alone.
|
||||||
|
- A patch is **untested against a running shell** unless its notes say
|
||||||
|
otherwise. Say which: verified / reasoned / untested.
|
||||||
|
- Applied patches get deleted from this directory in the same commit that
|
||||||
|
applies them. This directory is a queue, not an archive — git keeps history.
|
||||||
Loading…
Reference in a new issue