Watch
1
0
Fork
You've already forked souveraine
0

hands: arm the wire before opening the pad

This commit is contained in:
Fimeg 2026-08-17 11:08:36 -04:00
commit 59b52a818b
7 changed files with 210 additions and 148 deletions

View file

@ -23,27 +23,31 @@
* she is still on screen while you work. Casey, 2026-08-15: *"I want her, and
* then below her to have like a trackpad zone / keyboard zone stuff."*
*
* At 760/226 the split was the other way round and the controls had 226px to
* share between a status row, an agent field, the pads and four buttons — so
* the pads were 92px and the last row sat under the dock.
*
* `--dock-reserve` is a number here because the dock claims **no exclusive
* zone** unless it is Pinned (`modules/ii/dock/Dock.qml:321`), so nothing
* shrinks this surface for it and the bottom row would be untappable. Delete
* this the day the dock yields on its own.
* At 760/226 the controls had only 226px. At 320/the-rest the trackpad ate
* nearly half the glass. This split keeps her proportions and gives the pad
* one deliberate working area. The dock now yields only while this
* conditional surface is open; the remaining reserve belongs to the system
* gesture rail, not the dock.
*/
:root { --dock-reserve: 84px; }
body.hid #live2d-widget { height: 320px; }
:root { --gesture-rail-reserve: 32px; }
body.hid #live2d-widget { height: 520px; }
/*
* Keep her proportions when the box shrinks. The canvas fills its element and
* the rig has no letterboxing of its own, so a short full-width box draws the
* same model squashed. The ratio is the full-height box's, 540:760.
*/
body.hid #live2d {
display: block; height: 100%; width: auto;
aspect-ratio: 540 / 760; margin: 0 auto;
}
body.hid #hands {
top: 326px;
top: 526px;
height: auto;
bottom: var(--dock-reserve);
bottom: var(--gesture-rail-reserve);
display: flex;
flex-direction: column;
}
/* The pads take whatever the fixed rows leave, rather than a written-down 92. */
body.hid #track-row,
body.hid #hands.keyboard #keyboard-pane { flex: 1; height: auto; min-height: 92px; }
body.hid #track-row { flex: 1; height: auto; min-height: 180px; }
#live2d { width: 100%; height: 100%; }
#bubble {
position: fixed; left: 8px; right: 8px; top: 12px;
@ -56,8 +60,7 @@
#bubble.up { opacity: 1; }
/*
* The room viewtop left under her, given a purpose. Trackpad first; the
* literal keyboard only replaces it when summoned. The dark sheer gradient
* The room viewtop left under her, given a purpose. The dark sheer gradient
* keeps the controls legible without putting an app-shaped slab around her.
*/
#hands {
@ -100,20 +103,18 @@
#hands-listening { display: none; color: #e8b7d8; }
#hands.listening #hands-listening { display: inline; }
#hands-status { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: rgba(255,255,255,.68); }
.thread-button { width: 30px; min-width: 30px !important; height: 26px !important; padding: 0 !important; border-radius: 10px !important; }
#agent-form, #host-form { height: 40px; display: flex; gap: 6px; }
#agent-input, #host-input {
#agent-form { height: 40px; display: flex; gap: 6px; }
#agent-input {
min-width: 0; flex: 1; height: 40px; padding: 0 13px;
border: 1px solid rgba(255,255,255,.12); border-radius: 14px;
outline: none; color: #fff; background: rgba(0,0,0,.20);
-webkit-user-select: text; user-select: text;
}
#agent-input:focus, #host-input:focus { border-color: rgba(217,180,235,.55); }
#agent-input::placeholder, #host-input::placeholder { color: rgba(255,255,255,.43); }
#agent-input:focus { border-color: rgba(217,180,235,.55); }
#agent-input::placeholder { color: rgba(255,255,255,.43); }
#mic.listening { color: #f0b6db; box-shadow: inset 0 0 0 1px rgba(240,182,219,.45); }
#track-row, #keyboard-pane { height: 92px; margin-top: 6px; }
#track-row { height: 92px; margin-top: 6px; }
#track-row { display: flex; gap: 6px; }
#trackpad, #scrollpad {
position: relative; overflow: hidden;
@ -133,12 +134,6 @@
content: '↕'; position: absolute; inset: 0; display: grid; place-items: center;
color: rgba(255,255,255,.35); font-size: 20px;
}
#keyboard-pane { display: none; }
#keyboard-pane .quick-keys { display: flex; gap: 5px; margin-top: 6px; }
#keyboard-pane .quick-keys button { flex: 1; height: 40px; padding: 0 5px; }
#hands.keyboard #track-row { display: none; }
#hands.keyboard #keyboard-pane { display: block; }
.hands-actions { height: 40px; display: flex; gap: 5px; margin-top: 6px; }
.hands-actions button { flex: 1; height: 40px !important; padding: 0 5px !important; }
#cad.holding {
@ -155,8 +150,6 @@
<span id="hands-dot"></span>
<span id="hands-status">USB hand asleep</span>
<span id="hands-listening">listening</span>
<button class="thread-button" id="resume-thread" title="Resume latest conversation"></button>
<button class="thread-button" id="new-thread" title="Begin a new conversation"></button>
</div>
<form id="agent-form">
<button type="button" id="mic" aria-label="Start or stop listening"></button>
@ -168,24 +161,9 @@
<div id="trackpad" aria-label="Trackpad"></div>
<div id="scrollpad" aria-label="Scroll"></div>
</div>
<div id="keyboard-pane">
<form id="host-form">
<input id="host-input" autocomplete="off" enterkeyhint="send"
placeholder="Type into the attached host…">
<button type="submit" aria-label="Type on host"></button>
</form>
<div class="quick-keys">
<button data-key="escape">Esc</button>
<button data-key="tab">Tab</button>
<button data-key="backspace"></button>
<button data-key="enter">Enter</button>
</div>
</div>
<div class="hands-actions">
<button id="left-click">Left</button>
<button id="right-click">Right</button>
<button id="keyboard-toggle"></button>
<button id="arm-hid">Arm</button>
<button id="cad" title="Hold for Ctrl+Alt+Delete">CAD</button>
</div>
</section>
@ -363,7 +341,7 @@
window.souveraine.hid(message);
}
var agentPending = false, hostPending = false;
var agentPending = false;
window.hands = {
state: function (state) {
state = state || {};
@ -386,8 +364,6 @@
: 'Tell ' + (state.agent || 'your agent') + ' what the other screen needs…';
hands.classList.toggle('thinking', !!state.thinking);
document.getElementById('agent-input').disabled = !!state.thinking || agentPending;
document.getElementById('resume-thread').classList.toggle('on', state.conversation === 'resumed');
document.getElementById('new-thread').classList.toggle('on', state.conversation === 'new');
requestAnimationFrame(publishShape);
},
agentResult: function (result) {
@ -397,14 +373,6 @@
input.disabled = hands.classList.contains('thinking');
if (result.accepted) input.value = '';
else if (result.reason) document.getElementById('hands-status').textContent = result.reason;
},
hostResult: function (result) {
result = result || {};
hostPending = false;
var input = document.getElementById('host-input');
input.disabled = false;
if (result.accepted) input.value = '';
else if (result.reason) document.getElementById('hands-status').textContent = result.reason;
}
};
@ -422,13 +390,6 @@
if (!window.souveraine) return;
window.souveraine.talk(hands.classList.contains('listening') ? 'end' : 'start');
});
document.getElementById('resume-thread').addEventListener('click', function () {
if (window.souveraine) window.souveraine.thread('resume');
});
document.getElementById('new-thread').addEventListener('click', function () {
if (window.souveraine) window.souveraine.thread('new');
});
var trackpad = document.getElementById('trackpad');
var trackDown = false, trackX = 0, trackY = 0, trackTravel = 0, trackAt = 0;
trackpad.addEventListener('pointerdown', function (event) {
@ -478,28 +439,6 @@
document.getElementById('right-click').addEventListener('click', function () {
hid('click', { button: 'right' });
});
document.getElementById('arm-hid').addEventListener('click', function () { hid('arm'); });
var keyboardToggle = document.getElementById('keyboard-toggle');
keyboardToggle.addEventListener('click', function () {
var showing = !hands.classList.contains('keyboard');
hands.classList.toggle('keyboard', showing);
keyboardToggle.classList.toggle('on', showing);
var input = document.getElementById('host-input');
if (showing) input.focus(); else input.blur();
});
document.getElementById('host-form').addEventListener('submit', function (event) {
event.preventDefault();
var input = document.getElementById('host-input');
if (input.value.length === 0 || hostPending) return;
hostPending = true;
input.disabled = true;
hid('type', { text: input.value });
});
Array.prototype.forEach.call(document.querySelectorAll('[data-key]'), function (button) {
button.addEventListener('click', function () { hid('key', { key: button.dataset.key }); });
});
// Ctrl+Alt+Delete is the one quick key a stray tap must not send. Its hold
// is local feedback; only the completed edge crosses to the HID owner.
var cad = document.getElementById('cad'), cadTimer = null, cadPointer = null;

View file

@ -22,6 +22,7 @@ How Souveraine is built, and why. For whoever reads it, human or agent. When thi
**surface**: how the body she lives in moves
- [motion](surface/01-motion.md). One clock, two strategies, and why the shell never transforms a window.
- [session start](surface/02-session-start.md). One package-owned dependency graph from compositor socket to authority to shell.
- [USB Hands](surface/03-usb-hands.md). An armed wire, an optional trackpad, and one acknowledged hand into the attached host.
Coming: the rest of identity (the memory filesystem, the ledgers), the nervous system (schedules, the event log), the consciousness cycle, the runtime, federation, a glossary.

122
saf/surface/03-usb-hands.md Normal file
View file

@ -0,0 +1,122 @@
# USB Hands
The phone may become a keyboard and pointer for the host on its USB cable.
That reach has three states with three owners; collapsing them is what made the
first surface confusing.
## The wire, the surface, the hand
`UsbState.mode` says what sessiond and usb-signaller have made the port. HID or
KVM means the wire is armed. Developer or charging-only means it is not.
QuickShell never writes configfs and the page never opens `/dev/hidg*`.
`HidController.active` says the trackpad surface is open. It is not another
name for an armed port. Power Options is the human place that arms or disarms
the wire; a separate child action opens or closes the trackpad, and opening is
refused until HID or KVM is already armed.
`HidController.ready` says its one persistent `usb-hid-inject stream` helper
has acknowledged the endpoint. Pointer motion is batched to the display clock;
text, keys, clicks, and release reports go through that one writer.
These are deliberately different facts. A composed gadget with no open
surface is safe and useful. An open surface with no composed gadget is a dead
control and is refused. A helper process that has not said `ready` is not
dressed up as a usable hand.
The resulting state graph is small:
```
developer/charging -> no trackpad
HID or KVM armed -> trackpad may be opened
trackpad active -> dock yields; Face routes gestures and words
disarm or lock -> helper releases, trackpad closes
```
Arming the wire alone changes no shell furniture. The dock leaves only while
the conditional trackpad is actually on the glass.
## Authority and mechanism
The path has one owner at each layer:
- QuickShell asks sessiond for `set_usb_mode`; `UsbState` only projects the
answer and never writes the gadget.
- Sessiond records the USB mode action and asks usb-signaller to compose the
port. The supported postures are developer, HID, KVM, and charging-only.
- `HidController` alone owns the live stream helper. The Face page emits
intent into that controller; it never opens `/dev/hidg*` itself.
- The substrate's `body hand` verb returns to QuickShell's `usbHands` IPC. It
does not create a second HID writer merely because the caller is an agent.
The present route is not yet the final authority seam. `attached_identity`
and `probe_owner` are explicit unknowns, and a locally permitted caller can
still reach usb-signaller's system D-Bus mechanism around sessiond. Joining an
unlocked surface and arming it in Power Options are useful local gates, not a
credential or known-peer policy. The seam is complete only when the mutation
mechanism admits the attested/sessiond path alone and sessiond can distinguish
a known scoped peer from an unknown attachment. Charging-only remains the
always-available safe contraction.
Mode failure must return a refusal and leave or restore a usable USB posture.
It must not strand the management lane, a bound UDC, a held key, or a pointer
button. Closing Hands, disarming, and secure lock all release reports and stop
the helper; a mode switch stops the writer before usb-signaller changes the
endpoints beneath it.
## One conversation
Hands is a limb of the existing Face/Souveraine connection, not a compact
second chat client. Opening it resumes the current agent's latest conversation
only when no conversation is attached. Conversation selection and creation
belong to the agent pane, so the Hands header carries no mystery back-arrow or
new-thread button.
The joined surface currently exposes agent text/voice, trackpad, scroll,
left/right click, and a held Ctrl+Alt+Delete. It does not advertise a keyboard
pane merely because an HTML input can emit key reports. A real on-glass
keyboard needs its own QML surface and focus contract before it earns a button.
## The agent's reach
While Hands is joined, the ambient skill points at the substrate's typed
`body` tool: `verb: hand` with `status`, `type`, `key`, `click`, or `pointer`.
It does not teach a second raw `qs` command vocabulary.
A direct request to type ordinary user-authored text is authorization to type
that text, including creative, intimate, or unusual prose. Odd tone is not a
state refusal. The hand still sends nothing Casey did not ask to place on the
attached host, and readiness/state refusals remain visible to the agent.
## Beyond HID
HID can send input and cannot truthfully return a terminal's output. Learned
administration or tunnel payloads therefore belong to a separate, auditable
capability path: named/versioned recipes, an explicit scope and lease, and a
management transport that returns stdout, stderr, and exit status as typed
events. Do not infer success from keystrokes and do not turn an RMM or reverse
tunnel into an opaque keyboard macro.
The NCM control lane can carry that future return path. Its authority,
identity, rollback, and evidence contract must exist before a learned payload
is treated as a skill rather than an unaudited script.
## Proof boundary
Source agreement is only the first line of proof. USB Hands is operational on
a phone only when the signed usb-signaller package contains the persistent
stream helper, the installed Souveraine surface and face assets are the same
revision, and the following motion is observed on glass and on the attached
host:
1. Power Options arms HID while another management path remains reachable.
2. Opening the trackpad starts one helper and receives its `ready` reply.
3. Motion, scroll, left/right click, requested text, a named key, and held
Ctrl+Alt+Delete reach the host without a second writer.
4. A rejected character sends none of that command; leaving and locking leave
no held report.
5. Returning to developer mode restores the control lane cleanly.
KVM adds separate display, storage, NCM, and touch-return acceptance. A green
shell reload, a packaged binary, or the first successful keystroke proves only
its own layer; none of them silently stands in for that cable exercise.

View file

@ -88,6 +88,12 @@ Scope { // Scope
|| GlobalStates.missionPeek)
return Dock.DockState.Hidden;
// USB Hands owns the bottom of home while its trackpad is actually
// open. Arming the wire alone changes no furniture; opening the
// conditional controller makes the dock yield until it closes.
if (HidController.active)
return Dock.DockState.Hidden;
// A visible keyboard owns the bottom edge. This must precede the home
// zone's unconditional PINNED return below; otherwise home stacks the
// dock's exclusive zone under the OSK and charges the display twice.

View file

@ -290,12 +290,16 @@ Scope {
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "keyboard"
title: "USB HID Injection"
detail: UsbState.mode === "hid"
? "Keyboard and pointer link armed"
: "Let this screen become the other machine's hands"
badge: UsbState.mode === "hid" ? "active"
: UsbState.hasMode("hid_mode") ? "wired" : "building"
title: "USB hand"
detail: HidController.active
? "Wire armed · trackpad open"
: HidController.modeReady
? "Keyboard and pointer wire armed"
: "Arm the wire, then open its trackpad"
badge: UsbState.busy ? "switching"
: HidController.active ? "open"
: HidController.modeReady ? "armed"
: UsbState.hasMode("hid_mode") ? "ready" : "building"
inset: 10
expandable: true
expanded: scope.hidOptionsOpen
@ -316,35 +320,41 @@ Scope {
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "cable"
title: "USB control link"
detail: UsbState.mode === "hid"
? "Armed · tap to restore developer USB"
title: HidController.modeReady
? "Disarm USB hand" : "Arm USB hand"
detail: HidController.modeReady
? "Restore developer USB and close its controls"
: "Expose keyboard, pointer and developer network"
badge: UsbState.busy ? "switching"
: UsbState.mode === "hid" ? "active"
: HidController.modeReady ? "armed"
: UsbState.hasMode("hid_mode") ? "ready" : "not installed"
inset: 28
available: UsbState.hasMode("hid_mode") && !UsbState.busy
onTapped: UsbState.setMode(
UsbState.mode === "hid" ? "developer" : "hid")
HidController.modeReady ? "developer" : "hid")
}
SheetLocal.PowerOptionRow {
Layout.fillWidth: true
icon: "keyboard_mouse"
title: "Agent hands"
title: HidController.active
? "Close trackpad" : "Open trackpad"
detail: HidController.active
? "Voice, trackpad and the summoned host keyboard"
: "Join the agent to the wire; trackpad first"
? "Leave the wire armed; put its controls away"
: "Voice, pointer, scroll and clicks"
badge: HidController.ready ? "awake"
: HidController.active ? "arm"
: GlobalStates.screenLockSecure ? "locked" : "open"
: HidController.active ? "waking"
: GlobalStates.screenLockSecure ? "locked" : "closed"
inset: 28
available: UsbState.hasMode("hid_mode")
available: HidController.modeReady && !UsbState.busy
&& !GlobalStates.screenLockSecure
onTapped: {
if (Face.joinHands())
if (HidController.active) {
Face.leaveHands();
scope.dismiss();
} else if (Face.joinHands()) {
scope.dismiss();
}
}
}
}

View file

@ -9,9 +9,9 @@
// does by construction.
//
// USB Hands joins the same limb rather than opening a controller app beside
// her. Its agent field, explicit microphone, trackpad and summoned host
// keyboard occupy the room the compositor already left below her. The HID
// reports still belong to HidController; this service only routes page intent.
// her. Its agent field, explicit microphone and trackpad occupy the room the
// compositor already left below her. The HID reports still belong to
// HidController; this service only routes page intent.
//
// ## Turning her on is joining
//
@ -86,6 +86,12 @@ Singleton {
return true;
}
function leaveHands() {
if (HidController.active)
HidController.close();
root._syncHands();
}
function leave() {
if (root.listening)
root._talk("cancel");
@ -153,8 +159,6 @@ Singleton {
root._talk(msg.phase);
else if (msg.event === "hid")
root._hid(msg);
else if (msg.event === "thread")
root._thread(msg.mode);
else if (msg.event === "ready")
root._syncHands();
else if (msg.event === "dismiss")
@ -193,20 +197,6 @@ Singleton {
})})`);
break;
}
case "arm":
HidController.arm();
break;
}
}
function _thread(mode) {
if (Souveraine.turnActive)
return;
if (mode === "new") {
Souveraine.newConversation();
root._syncHands();
} else if (mode === "resume") {
Souveraine.resumeLatestConversation();
}
}
@ -220,7 +210,6 @@ Singleton {
mode: UsbState.mode,
error: HidController.lastError,
agent: agent?.name ?? Souveraine.currentAgentId ?? "agent",
conversation: Souveraine.conversationId.length > 0 ? "resumed" : "new",
listening: root.listening,
thinking: Souveraine.turnActive
};

View file

@ -35,12 +35,11 @@ Singleton {
// about a USB gadget it is not using.
readonly property string skill: "USB Hands is joined to this conversation. "
+ "Casey is using the phone as a trackpad and voice-first controller for an attached host. "
+ "When he asks you to type or press a key there, use the existing bridge: "
+ "`qs -c souveraine ipc call usbHands sendText TEXT`, "
+ "`qs -c souveraine ipc call usbHands tap KEY`, "
+ "`qs -c souveraine ipc call usbHands key KEY MODIFIERS`, or "
+ "`qs -c souveraine ipc call usbHands click left|right|middle`. "
+ "Ask `qs -c souveraine ipc call usbHands status` rather than assuming the cable is armed. "
+ "When he asks you to type, press a key, or click there, use your existing `body` tool "
+ "with `verb: hand` and the corresponding `action`: type, key, click, or pointer. "
+ "Use `action: status` rather than assuming the cable and surface are ready. "
+ "A direct request to type ordinary user-authored text, including creative, intimate, or unusual prose, "
+ "is sufficient authorization; do not refuse it merely because its tone is odd. "
+ "Do not inject anything Casey did not ask to send to the attached host."
signal controllerChanged()
@ -52,6 +51,12 @@ Singleton {
root.refused(root.lastError);
return false;
}
if (!root.modeReady) {
root.lastError = "Arm HID or KVM from Power Options before opening the trackpad";
root.refused(root.lastError);
root.controllerChanged();
return false;
}
root.lastError = "";
root.active = true;
UsbState.refresh();
@ -83,23 +88,6 @@ Singleton {
}
}
function arm() {
if (!root.active) {
root.lastError = "USB Hands is not joined";
root.refused(root.lastError);
root.controllerChanged();
return false;
}
if (!UsbState.hasMode("hid_mode")) {
root.lastError = "The installed gadget does not advertise HID mode";
root.refused(root.lastError);
root.controllerChanged();
return false;
}
UsbState.setMode("hid");
return true;
}
function _reconcile() {
const wanted = root.permitted && root.modeReady;
if (!wanted) {
@ -295,6 +283,13 @@ Singleton {
onActiveChanged: root._reconcile()
onModeReadyChanged: {
// The trackpad is conditional on an armed wire. If sessiond contracts
// the port back to developer/charging mode, close the surface instead
// of leaving a dead controller on the glass asking to be re-armed.
if (root.active && !root.modeReady) {
root.close();
return;
}
root._reconcile();
root.controllerChanged();
}