proc events, bypass shortcuts, Kate session fix

Backend:
- Add proc.start/proc.exit events for background process lifecycle
- Add proc idx ctl command for machine-readable process listing (TSV)
- Add event.pub file for external event publishing
- Add ListProcsIdx to toolclient and toolsrv
- Fix bypass commands to use Setpgid for process group isolation

GUI:
- Add configurable bypass approval shortcuts (Ctrl+Y/Ctrl+N default)
- Add Keyboard Shortcuts section to Settings dialog
- Store shortcuts in theme.conf

Kate:
- Fix 'Start Session Here' - use rdwr for session/new endpoint
This commit is contained in:
Levi Neely 2026-10-07 14:22:44 +02:00
parent b9026bb48b
commit 1c02b50add
18 changed files with 397 additions and 5 deletions

View File

@ -64,6 +64,7 @@ type Agent struct {
save func() // trigger debounced persistence
flush func() // immediately flush persistence
onStateChange func(agentID, state string) // optional callback for state changes
onProcStart func(agentID string, pid int, tool, cmd string) // optional callback for proc start
// Chat log — the rendered output stream, exposed via 9P.
chatMu sync.RWMutex

View File

@ -252,6 +252,10 @@ func (ag *Agent) execOne(ctx context.Context, call backend.ToolCall) (toolResult
isErr = true
} else {
result = fmt.Sprintf("<system-proc-background>\nid=%d cmd=%q\n</system-proc-background>", pid, cmdDesc)
// Notify proc.start via callback
if ag.onProcStart != nil {
ag.onProcStart(ag.id, pid, call.Name, cmdDesc)
}
}
}
ag.emit(Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})

View File

@ -82,6 +82,11 @@ func (ag *Agent) SetOnStateChange(fn func(agentID, state string)) {
ag.onStateChange = fn
}
// SetOnProcStart sets a callback invoked when a background process starts.
func (ag *Agent) SetOnProcStart(fn func(agentID string, pid int, tool, cmd string)) {
ag.onProcStart = fn
}
// setReply sets the agent's last response.
func (ag *Agent) setReply(reply string) {
ag.stateMu.Lock()

View File

@ -245,6 +245,18 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
return nil, base, nil
}),
),
virtfs.FileNode("event.pub", 0220,
virtfs.Doc("Publish event: write topic<tab>payload"),
virtfs.Write(func(data []byte) error {
line := strings.TrimSpace(string(data))
topic, payload, _ := strings.Cut(line, "\t")
if topic == "" {
return fmt.Errorf("empty topic")
}
session.PublishEvent(topic, payload)
return nil
}),
),
virtfs.FileNode("generate", 0666,
virtfs.Doc("One-shot LLM generation"),
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
@ -1105,7 +1117,7 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
{"cwd", "print the working directory", func(args []string) ([]byte, error) {
return []byte(a.Cwd() + "\n"), nil
}},
{"proc", "manage background procs: proc [top|term|kill|out|dismiss <pid>]", func(args []string) ([]byte, error) {
{"proc", "manage background procs: proc [top|idx|term|kill|out|dismiss <pid>]", func(args []string) ([]byte, error) {
ts := a.ToolServer()
if ts == nil {
return nil, fmt.Errorf("no tool server")
@ -1122,6 +1134,12 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
}
subcmd := args[0]
switch subcmd {
case "idx":
data, err := ts.ListProcsIdx()
if err != nil {
return nil, err
}
return data, nil
case "term":
if len(args) < 2 {
return nil, fmt.Errorf("proc term requires pid")
@ -1172,7 +1190,7 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
}
return []byte("ok\n"), nil
default:
return nil, fmt.Errorf("unknown proc subcommand: %s (use: top, term <pid>, kill <pid>, out <pid>, dismiss <pid>)", subcmd)
return nil, fmt.Errorf("unknown proc subcommand: %s (use: top, idx, term <pid>, kill <pid>, out <pid>, dismiss <pid>)", subcmd)
}
}},
{"systemprompt", "print the rendered system prompt", func(_ []string) ([]byte, error) {

View File

@ -21,6 +21,11 @@ func wireAgentEvents(sessID string, ag *agent.Agent) {
topic := "session." + sessID + ".agent." + agentID + ".state"
session.PublishEvent(topic, state)
})
ag.SetOnProcStart(func(agentID string, pid int, tool, cmd string) {
topic := "session." + sessID + ".agent." + agentID + ".proc.start"
payload := fmt.Sprintf("%d\t%s\t%s", pid, tool, cmd)
session.PublishEvent(topic, payload)
})
}
// ctlCmd is one control verb: its name, a one-line description for the

View File

@ -408,6 +408,23 @@ func (c *ToolsrvConn) ListProcs() ([]byte, error) {
return io.ReadAll(fid)
}
// ListProcsIdx returns a machine-readable TSV proc listing.
// Format: pid<TAB>status<TAB>exit_code<TAB>tool<TAB>cmd<TAB>start_unix<TAB>runtime_sec
func (c *ToolsrvConn) ListProcsIdx() ([]byte, error) {
fid, err := c.fsys.Open("proc/idx", plan9.ORDWR)
if err != nil {
return nil, err
}
defer fid.Close()
if _, err := fid.Write([]byte(c.agentID)); err != nil {
return nil, err
}
if _, err := fid.Seek(0, 0); err != nil {
return nil, err
}
return io.ReadAll(fid)
}
// readProcInfo reads info about a single proc.
func (c *ToolsrvConn) readProcInfo(name string) map[string]any {
path := fmt.Sprintf("proc/%s/stat", name)

View File

@ -293,6 +293,16 @@ func executeDirectUnsandboxed(ctx context.Context, cmd, cwd string, env map[stri
}
}
// Set process group so we can signal all children
execCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
execCmd.Cancel = func() error {
if execCmd.Process != nil {
syscall.Kill(-execCmd.Process.Pid, syscall.SIGTERM)
}
return nil
}
execCmd.WaitDelay = 5 * time.Second
var stdout, stderr bytes.Buffer
if streamOut != nil {
// Streaming mode: tee to both the stream and local buffer

View File

@ -518,6 +518,40 @@ func (st *State) ListProcsForAgent(agentID string) string {
return buf.String()
}
// ListProcsIdxForAgent returns a machine-readable TSV list of processes.
// If agentID is empty, returns all processes.
// Format: pid<TAB>status<TAB>exit_code<TAB>tool<TAB>cmd<TAB>start_unix<TAB>runtime_sec
func (st *State) ListProcsIdxForAgent(agentID string) string {
st.procMu.Lock()
defer st.procMu.Unlock()
var buf bytes.Buffer
for pid, p := range st.procs {
p.mu.Lock()
if agentID != "" && p.AgentID != agentID {
p.mu.Unlock()
continue
}
status := "running"
exitCode := -1
if p.Exited {
status = "exited"
exitCode = p.ExitCode
}
startUnix := p.StartTime.Unix()
var runtime float64
if p.Exited && !p.EndTime.IsZero() {
runtime = p.EndTime.Sub(p.StartTime).Seconds()
} else {
runtime = time.Since(p.StartTime).Seconds()
}
tool := p.Tool
cmd := p.Cmd
p.mu.Unlock()
fmt.Fprintf(&buf, "%d\t%s\t%d\t%s\t%s\t%d\t%.1f\n", pid, status, exitCode, tool, cmd, startUnix, runtime)
}
return buf.String()
}
// KillAll sends SIGKILL to all running procs and cancels their contexts.
// Called on shutdown to ensure no orphaned processes.
func (st *State) KillAll() {

View File

@ -195,6 +195,13 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
return []byte(srv.Fs.ListProcsForAgent(agentID)), nil
}),
),
virtfs.FileNode("idx", 0666,
virtfs.Doc("Machine-readable proc index: write agent ID, read TSV (pid status exit_code tool cmd start_unix runtime_sec)"),
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
agentID := strings.TrimSpace(string(data))
return []byte(srv.Fs.ListProcsIdxForAgent(agentID)), nil
}),
),
virtfs.FileNode("new", 0666,
virtfs.Doc("Execute tool: write token + tool + args, read result (blocking)"),
virtfs.Rdwr(func(ctx context.Context, data []byte) ([]byte, error) {

View File

@ -241,13 +241,15 @@ monitor:
}
}
// pushProcInterrupt pushes the completed proc output to the agent's prompt.
// pushProcInterrupt pushes the completed proc output to the agent's prompt
// and publishes a proc.exit event.
func pushProcInterrupt(ctx context.Context, proc *server.Proc) {
// Build the interrupt message
proc.Lock()
output := proc.OutputString()
exitCode := proc.ExitCode
cmd := proc.Cmd
tool := proc.Tool
pid := proc.ID
sid := proc.SessionID
aid := proc.AgentID
@ -290,6 +292,15 @@ func pushProcInterrupt(ctx context.Context, proc *server.Proc) {
}
defer fsys.Close()
// Publish proc.exit event
eventPath := "event.pub"
eventTopic := fmt.Sprintf("session.%s.agent.%s.proc.exit", sid, aid)
eventPayload := fmt.Sprintf("%d\t%d\t%s\t%s", pid, exitCode, tool, cmd)
if evFid, err := fsys.Open(eventPath, plan9.OWRITE); err == nil {
evFid.Write([]byte(eventTopic + "\t" + eventPayload))
evFid.Close()
}
// Write to session/{sid}/agent/{aid}/prompt
promptPath := fmt.Sprintf("session/%s/agent/%s/prompt", sid, aid)
fid, err := fsys.Open(promptPath, plan9.OWRITE)

144
doc/design/proc-window.md Normal file
View File

@ -0,0 +1,144 @@
# Proc Window Feature Design
## Overview
A dedicated window for managing background processes (tools running in background mode). Accessible via context menu on sessions/agents in the tree view.
## User Flow
1. Right-click session/agent → "Processes..."
2. Opens ProcWindow showing all background procs for that scope
3. Window tracks the active session (when main GUI switches session, proc window updates)
4. Can also be "pinned" to a specific session/agent
## UI Components
### ProcWindow.qml
```
┌─────────────────────────────────────────────────────────────┐
│ Processes - {session}/{agent} [📌] [×] │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PID │ Tool │ Status │ Runtime │ Actions │ │
│ ├─────┼───────────┼─────────┼──────────┼─────────────────┤ │
│ │ 5 │ shell │ running │ 2m 34s │ [▶] [■] [×] │ │
│ │ 3 │ web_fetch │ exited │ 45s │ [▶] [🗑] │ │
│ │ 1 │ shell │ exited │ 12s │ [▶] [🗑] │ │
│ └─────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Output for PID 5 [Clear] │
├─────────────────────────────────────────────────────────────┤
│ $ npm install │
│ npm WARN deprecated... │
│ added 123 packages in 45s │
│ █ │
└─────────────────────────────────────────────────────────────┘
```
### Actions
| Icon | Action | Description |
|------|--------|-------------|
| ▶ | Stream | Open output stream (updates in real-time) |
| ■ | Term | Send SIGTERM (graceful stop) |
| × | Kill | Send SIGKILL (force kill) |
| 🗑 | Dismiss | Remove from list (exited procs only) |
### Features
1. **Live refresh**: Subscribe to proc events or poll `proc idx`
2. **Output streaming**: Read proc output via `proc out <pid>`
3. **Session tracking**: Follow active session in main GUI (optional pin)
4. **Multi-agent view**: When opened on session, show all agents' procs
5. **Auto-scroll**: Output pane auto-scrolls unless user scrolls up
## Backend Implementation Status
### ✅ Phase 1: Backend (DONE)
#### Proc Events
- `session.{sid}.agent.{aid}.proc.start` — emitted when background proc spawns
- Payload: `{pid}\t{tool}\t{cmd}`
- `session.{sid}.agent.{aid}.proc.exit` — emitted when background proc exits
- Payload: `{pid}\t{exit_code}\t{tool}\t{cmd}`
#### Machine-Readable Proc Index
- `proc idx` ctl command returns TSV format:
- `pid<TAB>status<TAB>exit_code<TAB>tool<TAB>cmd<TAB>start_unix<TAB>runtime_sec`
- Status is `running` or `exited`
- Exit code is `-1` for running processes
#### Event Publishing Endpoint
- `event.pub` file at root namespace accepts writes: `topic<TAB>payload`
- Used by toolsrv to publish proc.exit events back to olliesrv
### 🔲 Phase 2: GUI (TODO)
1. Create ProcModel (C++ model for proc list)
2. Create ProcWindow.qml
3. Add context menu action to SessionTree.qml
4. Wire up event subscription for live updates
### 🔲 Phase 3: Polish (TODO)
1. Session tracking / pin toggle
2. Output search/filter
3. Keyboard shortcuts
4. Copy output to clipboard
## Files Modified (Phase 1)
- `cmd/olliesrv/internal/agent/agent.go` — added `onProcStart` callback
- `cmd/olliesrv/internal/agent/state.go` — added `SetOnProcStart`
- `cmd/olliesrv/internal/agent/dispatch.go` — call callback on proc start
- `cmd/olliesrv/internal/fs/support.go` — wire callback to publish event
- `cmd/olliesrv/internal/fs/spec.go` — added `event.pub` file, `proc idx` command
- `cmd/olliesrv/internal/toolclient/toolsrv.go` — added `ListProcsIdx`
- `cmd/toolsrv/internal/server/proc.go` — added `ListProcsIdxForAgent`
- `cmd/toolsrv/internal/server/server.go` — added `proc/idx` file
- `cmd/toolsrv/main.go` — publish proc.exit event via event.pub
- `doc/event-topics.md` — documented new proc events
## API Reference
### ctl Commands
```bash
# Human-readable proc list
echo "proc top" | ollie-9p rdwr session/$SID/agent/$AID/ctl
# Machine-readable proc index (TSV)
echo "proc idx" | ollie-9p rdwr session/$SID/agent/$AID/ctl
# Get proc output
echo "proc out 5" | ollie-9p rdwr session/$SID/agent/$AID/ctl
# Signal proc
echo "proc term 5" | ollie-9p rdwr session/$SID/agent/$AID/ctl
echo "proc kill 5" | ollie-9p rdwr session/$SID/agent/$AID/ctl
# Dismiss exited proc
echo "proc dismiss 5" | ollie-9p rdwr session/$SID/agent/$AID/ctl
```
### Event Subscription
```bash
# All proc events for all sessions/agents
echo "session.*.agent.*.proc.>" | ollie-9p rdwrs event
# Proc events for specific session
echo "session.abc123.agent.*.proc.>" | ollie-9p rdwrs event
# Only proc.start events
echo "session.*.agent.*.proc.start" | ollie-9p rdwrs event
```
### proc idx Output Format
```
pid status exit_code tool cmd start_unix runtime_sec
5 running -1 shell make test 1723456789 45.2
3 exited 0 web_fetch fetch docs 1723456700 12.5
```

View File

@ -29,6 +29,13 @@ session.{sessionId}.agent.{agentId}.{type}
| `session.{sid}.agent.{aid}.bypass.request` | `{id}\t{cmd}\t{cwd}` | Bypass approval requested |
| `session.{sid}.agent.{aid}.bypass.resolved` | `{id}\t{action}` | Bypass resolved (approved/denied) |
## Process Events
| Topic | Payload | Description |
|-------|---------|-------------|
| `session.{sid}.agent.{aid}.proc.start` | `{pid}\t{tool}\t{cmd}` | Background process started |
| `session.{sid}.agent.{aid}.proc.exit` | `{pid}\t{exit_code}\t{tool}\t{cmd}` | Background process exited |
## Filter Patterns
Subscribe to specific events using the streaming rdwr pattern:

View File

@ -620,6 +620,22 @@ Connections {
chatPane.hasPendingBypass = false
}
}
// Public functions for keyboard shortcuts
function approveBypass() {
if (chatPane.pendingBypass) {
ollie.resolveBypass(chatPane.pendingBypass.sessionId, chatPane.pendingBypass.agentId, chatPane.pendingBypass.id, true)
chatPane.pendingBypass = null
chatPane.hasPendingBypass = false
}
}
function denyBypass() {
if (chatPane.pendingBypass) {
ollie.resolveBypass(chatPane.pendingBypass.sessionId, chatPane.pendingBypass.agentId, chatPane.pendingBypass.id, false)
chatPane.pendingBypass = null
chatPane.hasPendingBypass = false
}
}
}
}

View File

@ -6,7 +6,7 @@ import QtQuick.Layouts 1.15
Dialog {
id: root
title: "Settings"
width: 380
width: 420
modal: false
x: (parent ? (parent.width - width) / 2 : 0)
y: (parent ? (parent.height - height) / 2 : 0)
@ -117,6 +117,51 @@ Dialog {
Layout.topMargin: 8
}
Label {
text: "Keyboard Shortcuts"
font.bold: true
}
GridLayout {
columns: 2
columnSpacing: 12
rowSpacing: 8
Layout.fillWidth: true
Label { text: "Approve bypass:" }
TextField {
id: approveKeyField
Layout.fillWidth: true
text: theme.approveBypassKey
placeholderText: "e.g. Ctrl+Y"
onEditingFinished: theme.approveBypassKey = text
}
Label { text: "Deny bypass:" }
TextField {
id: denyKeyField
Layout.fillWidth: true
text: theme.denyBypassKey
placeholderText: "e.g. Ctrl+N"
onEditingFinished: theme.denyBypassKey = text
}
}
Label {
text: "Use Qt key sequence format (e.g. Ctrl+Y, Alt+A, F5)"
opacity: 0.5
font.pixelSize: Qt.application.font.pixelSize * 0.85
wrapMode: Text.Wrap
Layout.fillWidth: true
}
Rectangle {
Layout.fillWidth: true
height: 1
color: palette.mid
Layout.topMargin: 8
}
Label {
text: "Font"
font.bold: true

View File

@ -78,6 +78,19 @@ ApplicationWindow {
}
}
// Bypass approval/denial shortcuts (configurable in Settings)
Shortcut {
sequence: theme.approveBypassKey
enabled: theme.approveBypassKey !== "" && chatPane.hasPendingBypass
onActivated: chatPane.approveBypass()
}
Shortcut {
sequence: theme.denyBypassKey
enabled: theme.denyBypassKey !== "" && chatPane.hasPendingBypass
onActivated: chatPane.denyBypass()
}
property bool sidebarVisible: true
property bool quitting: false
@ -210,6 +223,7 @@ ApplicationWindow {
}
ChatPane {
id: chatPane
SplitView.fillWidth: true
SplitView.fillHeight: true
sidebarVisible: root.sidebarVisible

View File

@ -394,6 +394,34 @@ void ThemeManager::setCloseToTray(bool enabled)
emit closeToTrayChanged();
}
QString ThemeManager::approveBypassKey() const
{
return m_approveBypassKey;
}
void ThemeManager::setApproveBypassKey(const QString &key)
{
if (key == m_approveBypassKey)
return;
m_approveBypassKey = key;
saveSettings();
emit shortcutsChanged();
}
QString ThemeManager::denyBypassKey() const
{
return m_denyBypassKey;
}
void ThemeManager::setDenyBypassKey(const QString &key)
{
if (key == m_denyBypassKey)
return;
m_denyBypassKey = key;
saveSettings();
emit shortcutsChanged();
}
void ThemeManager::resetFont()
{
m_fontFamily.clear();
@ -454,6 +482,13 @@ void ThemeManager::loadSettings()
m_fontStyle = settings.value("Font/style", "").toString();
m_closeToTray = settings.value("General/closeToTray", false).toBool();
m_approveBypassKey = settings.value("Shortcuts/approveBypass", "Ctrl+Y").toString();
m_denyBypassKey = settings.value("Shortcuts/denyBypass", "Ctrl+N").toString();
} else {
// Defaults when no config file exists
m_approveBypassKey = "Ctrl+Y";
m_denyBypassKey = "Ctrl+N";
}
}
@ -469,4 +504,6 @@ void ThemeManager::saveSettings()
settings.setValue("Font/size", m_fontSize);
settings.setValue("Font/style", m_fontStyle);
settings.setValue("General/closeToTray", m_closeToTray);
settings.setValue("Shortcuts/approveBypass", m_approveBypassKey);
settings.setValue("Shortcuts/denyBypass", m_denyBypassKey);
}

View File

@ -49,6 +49,10 @@ class ThemeManager : public QObject
// Close-to-tray
Q_PROPERTY(bool closeToTray READ closeToTray WRITE setCloseToTray NOTIFY closeToTrayChanged)
// Shortcut keys
Q_PROPERTY(QString approveBypassKey READ approveBypassKey WRITE setApproveBypassKey NOTIFY shortcutsChanged)
Q_PROPERTY(QString denyBypassKey READ denyBypassKey WRITE setDenyBypassKey NOTIFY shortcutsChanged)
public:
enum Mode {
System, // Follow KDE system color scheme
@ -99,6 +103,12 @@ public:
bool closeToTray() const;
void setCloseToTray(bool enabled);
// Shortcut keys
QString approveBypassKey() const;
void setApproveBypassKey(const QString &key);
QString denyBypassKey() const;
void setDenyBypassKey(const QString &key);
Q_INVOKABLE void resetFont();
Q_INVOKABLE QString chooseFolder(const QString &title, const QString &startDir = QString());
@ -110,6 +120,7 @@ signals:
void paletteChanged();
void fontChanged();
void closeToTrayChanged();
void shortcutsChanged();
private:
QPalette paletteFromScheme(const QString &path) const;
@ -126,6 +137,8 @@ private:
int m_fontSize;
QString m_fontStyle;
bool m_closeToTray;
QString m_approveBypassKey;
QString m_denyBypassKey;
QFileSystemWatcher *m_watcher;
KConfigWatcher::Ptr m_configWatcher;
static constexpr const char *SCHEMES_DIR = "/usr/share/color-schemes";

View File

@ -190,7 +190,11 @@ void OllieKateView::ensureAgent()
if (m_sessionId.isEmpty()) {
QString cwd = projectBaseDir();
if (cwd.isEmpty()) cwd = QDir::homePath();
m_9p->write("session/new", ("name=kate\ncwd=" + cwd + "\n").toUtf8());
QByteArray resp = m_9p->rdwr("session/new", ("name=kate\ncwd=" + cwd + "\n").toUtf8());
if (resp.isEmpty()) {
qWarning() << "Ollie Kate: failed to create session";
return;
}
m_sessionId = "kate";
}