gui: integrate bypass requests into chat UI

Server changes:
- Session tracks pending bypass request and exposes methods
- New 9P files: session/{sid}/bypass (read/write), bypasswait (blocking)
- Publish bypass.request events for GUI listeners

GUI changes:
- Handle bypass.request events from eventwait
- Show inline amber banner with command and cwd
- Approve/Deny buttons resolve via 9P

Desktop notifications still work in parallel for non-GUI usage.
This commit is contained in:
Levi Neely 2026-10-06 11:44:15 +02:00
parent 79738a4a39
commit f2f1f80e4e
5 changed files with 234 additions and 0 deletions

View File

@ -463,6 +463,55 @@ func buildSessionChildren(
return []byte(status + "\n"), status, nil
}, s.GoalSignal),
),
virtfs.FileNode("bypass", 0666,
virtfs.Doc("Bypass request handling. Read: pending request JSON or empty. Write: 'id approve' or 'id deny'."),
virtfs.Read(func() ([]byte, error) {
req := s.BypassPending()
if req == nil {
return nil, nil
}
data, err := json.Marshal(req)
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}),
virtfs.Write(func(data []byte) error {
input := strings.TrimSpace(string(data))
parts := strings.SplitN(input, " ", 2)
if len(parts) != 2 {
return fmt.Errorf("expected 'id approve' or 'id deny'")
}
id, action := parts[0], parts[1]
approved := action == "approve"
return s.ResolveBypass(id, approved)
}),
),
virtfs.FileNode("bypasswait", 0444,
virtfs.Doc("Blocks until a bypass request arrives."),
virtfs.Read(func() ([]byte, error) {
req := s.BypassPending()
if req == nil {
return nil, nil
}
data, err := json.Marshal(req)
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}),
virtfs.BlockOnce(func() ([]byte, string, error) {
req := s.BypassPending()
if req == nil {
return nil, "", nil
}
data, err := json.Marshal(req)
if err != nil {
return nil, "", err
}
return append(data, '\n'), req.ID, nil
}, s.BypassSignal),
),
virtfs.FileNode("stats", 0444,
virtfs.Read(func() ([]byte, error) {
a, err := metrics.AggregateSession(s.ID)

View File

@ -62,6 +62,12 @@ type Session struct {
modelsMu sync.Mutex
modelsCache string
modelsCacheAt time.Time
// Bypass request tracking
bypassMu sync.RWMutex
bypassPending *protocol.BypassRequest
bypassDialFn func() *toolclient.ToolsrvConn
bypassSignalCh chan struct{}
}
func ollieTmpDir() string {
@ -178,6 +184,9 @@ func (s *Session) runBypassLoop(notifyFn BypassNotifyFunc) {
}
}
// Store the pending request for 9P access
s.SetBypassPending((*protocol.BypassRequest)(req), s.DialToolServer)
// Notify (non-blocking) - the notifier will write resolution when user responds
if notifyFn != nil {
notifyFn((*protocol.BypassRequest)(req), s.ID, s.DialToolServer)
@ -247,6 +256,70 @@ func (s *Session) GoalSignal() <-chan struct{} {
return ch
}
// --- Bypass request tracking ---
// SetBypassPending stores a pending bypass request and publishes an event.
func (s *Session) SetBypassPending(req *protocol.BypassRequest, dialFn func() *toolclient.ToolsrvConn) {
s.bypassMu.Lock()
s.bypassPending = req
s.bypassDialFn = dialFn
if s.bypassSignalCh != nil {
close(s.bypassSignalCh)
}
s.bypassSignalCh = make(chan struct{})
s.bypassMu.Unlock()
// Publish event for GUI listeners
PublishEvent("session."+s.ID+".bypass.request", req.ID+"\t"+req.Cmd+"\t"+req.Cwd)
}
// BypassPending returns the current pending bypass request, or nil if none.
func (s *Session) BypassPending() *protocol.BypassRequest {
s.bypassMu.RLock()
defer s.bypassMu.RUnlock()
return s.bypassPending
}
// BypassSignal returns a channel closed when the bypass state changes.
func (s *Session) BypassSignal() <-chan struct{} {
s.bypassMu.Lock()
if s.bypassSignalCh == nil {
s.bypassSignalCh = make(chan struct{})
}
ch := s.bypassSignalCh
s.bypassMu.Unlock()
return ch
}
// ResolveBypass resolves a pending bypass request and clears it.
func (s *Session) ResolveBypass(id string, approved bool) error {
s.bypassMu.Lock()
req := s.bypassPending
dialFn := s.bypassDialFn
if req == nil || req.ID != id {
s.bypassMu.Unlock()
return fmt.Errorf("no pending bypass with id %s", id)
}
s.bypassPending = nil
s.bypassDialFn = nil
if s.bypassSignalCh != nil {
close(s.bypassSignalCh)
}
s.bypassSignalCh = make(chan struct{})
s.bypassMu.Unlock()
// Send resolution to toolsrv
if dialFn != nil {
conn := dialFn()
if conn != nil {
err := conn.ResolveBypass(id, approved, "")
conn.Close()
return err
}
}
return fmt.Errorf("no connection to resolve bypass")
}
// Workflow returns the workflow profile name (default: "none").
func (s *Session) Workflow() string {
s.mu.RLock()

View File

@ -6,6 +6,7 @@ Item {
id: chatPane
property bool sidebarVisible: true
property int maxVisibleBlocks: 80
property var pendingBypass: null // {sessionId, id, cmd, cwd}
signal toggleSidebar()
// Do not opt the chat pane into focus navigation.
activeFocusOnTab: false
@ -133,6 +134,78 @@ Item {
visible: !ollie.activeAgentId || !ollie.daemonConnected
}
// Bypass request notification
Rectangle {
id: bypassBanner
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
height: bypassLayout.implicitHeight + 16
radius: 6
color: "#fff3e0" // amber light
border.color: "#ff9800"
border.width: 1
visible: chatPane.pendingBypass !== null
z: 100
ColumnLayout {
id: bypassLayout
anchors.fill: parent
anchors.margins: 8
spacing: 6
Label {
text: "⚠️ Elevation Request"
font.bold: true
font.pixelSize: theme.fontSize > 0 ? theme.fontSize + 2 : 14
color: "#e65100"
}
Label {
text: chatPane.pendingBypass ? chatPane.pendingBypass.cmd : ""
font.family: "monospace"
font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12
wrapMode: Text.WrapAnywhere
Layout.fillWidth: true
color: "#333"
}
Label {
text: chatPane.pendingBypass ? "cwd: " + chatPane.pendingBypass.cwd : ""
font.pixelSize: theme.fontSize > 0 ? theme.fontSize - 1 : 10
color: "#666"
visible: chatPane.pendingBypass && chatPane.pendingBypass.cwd !== ""
}
RowLayout {
spacing: 8
Layout.topMargin: 4
Button {
text: "Approve"
highlighted: true
onClicked: {
if (chatPane.pendingBypass) {
ollie.resolveBypass(chatPane.pendingBypass.sessionId, chatPane.pendingBypass.id, true)
chatPane.pendingBypass = null
}
}
}
Button {
text: "Deny"
onClicked: {
if (chatPane.pendingBypass) {
ollie.resolveBypass(chatPane.pendingBypass.sessionId, chatPane.pendingBypass.id, false)
chatPane.pendingBypass = null
}
}
}
}
}
}
ListView {
id: chatView
model: chatModel
@ -512,6 +585,17 @@ Connections {
function onActiveAgentIdChanged() {
if (ollie.activeAgentId) chatPane.loadChat()
}
function onBypassRequested(sessionId, id, cmd, cwd) {
// Show bypass request if it's for the active session
if (sessionId === ollie.activeSessionId) {
chatPane.pendingBypass = {
sessionId: sessionId,
id: id,
cmd: cmd,
cwd: cwd
}
}
}
}
}

View File

@ -250,6 +250,18 @@ void Ollie9pClient::handleEvent(const QString &eventLine)
rest == QLatin1String("new") || rest == QLatin1String("kill") ||
rest == QLatin1String("rename")) {
refreshSessions();
} else if (rest.startsWith(QLatin1String("bypass.request"))) {
// Bypass event: payload is "id\tcmd\tcwd"
if (payload.isEmpty()) return;
const int tab1 = payload.indexOf(QLatin1Char('\t'));
if (tab1 < 0) return;
const int tab2 = payload.indexOf(QLatin1Char('\t'), tab1 + 1);
if (tab2 < 0) return;
const QString id = payload.left(tab1).toString();
const QString cmd = payload.mid(tab1 + 1, tab2 - tab1 - 1).toString();
const QString cwd = payload.mid(tab2 + 1).toString();
emit bypassRequested(sessionId.toString(), id, cmd, cwd);
}
}
}
@ -763,6 +775,20 @@ bool Ollie9pClient::renameAgent(const QString &sessionId, const QString &agentId
return false;
}
bool Ollie9pClient::resolveBypass(const QString &sessionId, const QString &id, bool approve)
{
if (sessionId.isEmpty() || id.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
QString path = "session/" + sessionId + "/bypass";
QString data = id + (approve ? " approve" : " deny");
if (m_9p->write(path, data.toUtf8())) {
return true;
}
qDebug() << "resolveBypass failed:" << m_9p->lastError();
return false;
}
// --- Root data loading (lazy) ---
void Ollie9pClient::ensureRootDataLoaded()

View File

@ -90,6 +90,7 @@ public:
Q_INVOKABLE bool killAgent(const QString &sessionId, const QString &agentId);
Q_INVOKABLE bool renameSession(const QString &sessionId, const QString &newName);
Q_INVOKABLE bool renameAgent(const QString &sessionId, const QString &agentId, const QString &newName);
Q_INVOKABLE bool resolveBypass(const QString &sessionId, const QString &id, bool approve);
Q_INVOKABLE QString lastError() const { return m_9p ? m_9p->lastError() : QString(); }
signals:
@ -99,6 +100,7 @@ signals:
void activeStateChanged();
void agentStateChanged(const QString &sessionId, const QString &agentId, const QString &state);
void chatReceived(const QString &text);
void bypassRequested(const QString &sessionId, const QString &id, const QString &cmd, const QString &cwd);
void rootBackendsLoadedChanged();
void availableBackendsChanged();
void rootAgentsLoadedChanged();