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:
parent
79738a4a39
commit
f2f1f80e4e
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue