Split session/idx and agent/idx, fix sub-agent tree display

Server:
- session/idx now outputs: session-id, session-name, paused, connected, remote, cwd
- session/{s}/agent/idx now outputs: session-id, agent-id, agent-name, parent-id, depth, state
- Add error if parent agent not found during sub-agent spawn

GUI:
- refreshSessions() reads session index, then agent/idx per session
- Auto-select first top-level agent (depth 0) instead of first in list
- SessionModel tracks agent expansion separately from session expansion
- Agents with children show expand/collapse arrows and are collapsible
- Add hasChildren role to SessionModel

Tools:
- subagent_spawn now passes parent= argument for proper depth tracking
- Remove hardcoded max_depth=5, use server default of 1
- Makefile installs shell script tools from data/tools/

KRunner:
- Update to parse new session/idx format
This commit is contained in:
Levi Neely 2026-08-17 13:03:26 +02:00
parent 958d3d2ddf
commit a17dd683df
13 changed files with 296 additions and 70 deletions

View File

@ -41,6 +41,8 @@ tools:
go build -o $(CONFIG_DIR)/tools/$$name ./$$d; \
done
install -m644 data/tools/*.meta $(CONFIG_DIR)/tools/
for f in data/tools/*; do [ -f "$$f" ] && [ -x "$$f" ] && install -m755 "$$f" $(CONFIG_DIR)/tools/; done; true
cp -a data/tools/_lib $(CONFIG_DIR)/tools/
kde:
cmake -S kde -B kde/build -DCMAKE_INSTALL_PREFIX=$(PREFIX)

View File

@ -494,6 +494,9 @@ func buildSessionChildren(
var parent *agent.Agent
if req.ParentID != "" {
parent = s.FindAgent(req.ParentID)
if parent == nil {
return nil, fmt.Errorf("parent agent %q not found", req.ParentID)
}
}
if parent != nil {

View File

@ -239,6 +239,7 @@ func waitIdle(poll, timeout time.Duration) {
}
// BuildIndex builds the session index content.
// Format: session-id\tsession-name\tpaused\tconnected\tremote\tcwd
func BuildIndex() []byte {
var sb strings.Builder
mu.RLock()
@ -265,31 +266,19 @@ func BuildIndex() []byte {
sess.mu.RLock()
connected := sess.toolsConn != nil && !sess.paused
sess.mu.RUnlock()
var cwd, be, model string
var agentList []string
for _, ag := range sess.Agents() {
if cwd == "" {
cwd = ag.Cwd()
}
if be == "" {
be = ag.BackendName()
model = ag.ModelName()
}
agentList = append(agentList, fmt.Sprintf("%s:%s:%s", ag.Name(), ag.ID(), ag.State()))
}
agents := strings.Join(agentList, ";")
fmt.Fprintf(&sb, "%s\t%s\t%t\t%t\t%s\t%s\t%s\t%s\n",
e.name, sess.ID, e.paused, connected, cwd, be, model, agents)
fmt.Fprintf(&sb, "%s\t%s\t%t\t%t\t%s\t%s\n",
sess.ID, e.name, e.paused, connected, sess.Remote, sess.Cwd())
}
return []byte(sb.String())
}
// BuildAgentIndex builds the agent index for a session.
// Format: session-id\tagent-id\tagent-name\tparent-id\tdepth\tstate
func BuildAgentIndex(sess *Session) []byte {
var sb strings.Builder
for _, ag := range sess.Agents() {
fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%s\n",
ag.Name(), ag.ID(), ag.Cwd(), ag.BackendName(), ag.ModelName())
fmt.Fprintf(&sb, "%s\t%s\t%s\t%s\t%d\t%s\n",
sess.ID, ag.ID(), ag.Name(), ag.ParentID(), ag.Depth(), ag.State())
}
return []byte(sb.String())
}

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# args_json: {"type":"object","required":["prompt"],"properties":{"prompt":{"type":"string","description":"Task prompt for the subagent"},"name":{"type":"string","description":"Subagent display name (optional)"},"profile":{"type":"string","description":"Agent profile (optional, default: default)"},"backend":{"type":"string","description":"Backend (optional)"},"model":{"type":"string","description":"Model (optional)"},"cwd":{"type":"string","description":"Working directory (optional, default: current)"}}}
# args_json: {"type":"object","required":["prompt"],"properties":{"prompt":{"type":"string","description":"Task prompt for the subagent"},"name":{"type":"string","description":"Subagent display name (optional)"},"profile":{"type":"string","description":"Agent profile (optional, default: default)"},"backend":{"type":"string","description":"Backend (optional)"},"model":{"type":"string","description":"Model (optional)"},"cwd":{"type":"string","description":"Working directory (optional, default: current)"},"parent":{"type":"string","description":"Parent agent ID (internal, auto-filled)"}}}
# ollie:prompt
# ## subagent_spawn
#
@ -25,6 +25,7 @@ profile=$(arg_get "profile" "default")
backend=$(arg_get "backend" "")
model=$(arg_get "model" "")
cwd=$(arg_get "cwd" "$(pwd)")
parent=$(arg_get "parent" "${OLLIE_UNAME:-}")
# Escape newlines in prompt for the key=value wire format.
escaped_prompt=$(printf '%s' "$prompt_text" | sed 's/\\/\\\\/g; s/$/\\n/' | tr -d '\n' | sed 's/\\n$//')
@ -32,10 +33,11 @@ escaped_prompt=$(printf '%s' "$prompt_text" | sed 's/\\/\\\\/g; s/$/\\n/' | tr -
# Build the payload.
payload="cwd=${cwd}
profile=${profile}
parent=${OLLIE_UNAME}
timeout=600
prompt=${escaped_prompt}"
[ -n "$parent" ] && payload="${payload}
parent=${parent}"
[ -n "$name" ] && payload="${payload}
name=${name}"
[ -n "$backend" ] && payload="${payload}

106
kde/gui/GoalDialog.qml Normal file
View File

@ -0,0 +1,106 @@
// GoalDialog.qml — Dialog for setting a session goal
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
Dialog {
id: root
title: "Set Goal"
width: 500
height: 300
modal: false
x: (parent ? (parent.width - width) / 2 : 0)
y: (parent ? (parent.height - height) / 2 : 0)
property string sessionId: ""
property string sessionName: ""
background: Item {
Rectangle {
x: 4
y: 6
width: parent.width
height: parent.height
color: Qt.rgba(0, 0, 0, 0.28)
radius: 4
}
Rectangle {
anchors.fill: parent
color: theme.windowColor
border.color: theme.dialogOutlineColor
border.width: 3
radius: 4
Rectangle {
anchors.fill: parent
anchors.margins: 3
color: "transparent"
border.color: theme.lightColor
border.width: 1
radius: 3
}
}
}
header: Rectangle {
implicitHeight: 36
color: theme.buttonColor
Text {
anchors.centerIn: parent
text: root.title
color: theme.buttonTextColor
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.SizeAllCursor
property point pressPosition
onPressed: pressPosition = Qt.point(mouse.x, mouse.y)
onPositionChanged: if (pressed) {
root.x += mouse.x - pressPosition.x
root.y += mouse.y - pressPosition.y
}
}
}
contentItem: ColumnLayout {
spacing: 8
Layout.margins: 16
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
TextArea {
id: goalText
placeholderText: "Describe what you want to accomplish..."
wrapMode: TextEdit.Wrap
}
}
}
footer: RowLayout {
Layout.fillWidth: true
Item { Layout.fillWidth: true }
Button {
text: "OK"
onClicked: {
var text = goalText.text.trim()
if (text !== "" && root.sessionName !== "") {
ollie.setGoal(root.sessionName, text)
}
root.close()
}
}
Button {
text: "Cancel"
onClicked: root.close()
}
}
focus: false
onOpened: {
goalText.text = ""
goalText.forceActiveFocus()
}
}

View File

@ -8,11 +8,13 @@ Item {
property int renamingIndex: -1
property string renamingText: ""
property string killTargetSessionId: ""
property string killTargetSessionName: ""
property string killTargetAgentSessionId: ""
property string killTargetAgentId: ""
signal newSessionRequested()
signal settingsRequested()
signal newAgentRequested(string sessionId)
signal goalRequested(string sessionId, string sessionName)
signal renameFailed(string itemType, string attemptedName)
ColumnLayout {
@ -94,8 +96,10 @@ Item {
if (mouse.button === Qt.RightButton) {
if (type === "session") {
var item = sessionModel.get(parent.modelIndex)
if (item)
if (item) {
tree.killTargetSessionId = item.id
tree.killTargetSessionName = item.name
}
sessionCtxMenu.popup()
} else if (type === "agent") {
var agentItem = sessionModel.get(parent.modelIndex)
@ -128,6 +132,10 @@ Item {
sessionModel.setExpanded(parent.modelIndex, !expanded)
} else if (type === "agent") {
ollie.switchAgent(parentId, agentId)
// Toggle expansion if agent has children
if (hasChildren) {
sessionModel.setExpanded(parent.modelIndex, !expanded)
}
}
}
onDoubleClicked: {
@ -138,6 +146,12 @@ Item {
Menu {
id: sessionCtxMenu
MenuItem {
text: "Goal..."
onClicked: {
tree.goalRequested(tree.killTargetSessionId, tree.killTargetSessionName)
}
}
MenuItem {
text: "New Agent..."
onClicked: {
@ -189,7 +203,7 @@ Item {
Label {
anchors.centerIn: parent
text: type === "session" ? (expanded ? "▼" : "▶") : ""
text: (type === "session" || hasChildren) ? (expanded ? "▼" : "▶") : ""
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.7 : 10
color: theme.textColor
opacity: 0.6

View File

@ -164,6 +164,11 @@ ApplicationWindow {
targetSessionId: ""
}
// --- Goal Dialog ---
GoalDialog {
id: goalDialog
}
SplitView {
anchors.fill: parent
@ -178,6 +183,11 @@ ApplicationWindow {
newAgentDialog.targetSessionId = sessionId
newAgentDialog.open()
}
onGoalRequested: function(sessionId, sessionName) {
goalDialog.sessionId = sessionId
goalDialog.sessionName = sessionName
goalDialog.open()
}
onRenameFailed: function(itemType, attemptedName) {
renameErrorDialog.itemType = itemType
renameErrorDialog.attemptedName = attemptedName

View File

@ -263,6 +263,7 @@ void Ollie9pClient::refreshSessions()
if (!m_daemonConnected) return;
if (!m_9p || !m_9p->isConnected()) return;
// Read session index: session-id\tsession-name\tpaused\tconnected\tremote\tcwd
QByteArray out = m_9p->read(QStringLiteral("session/idx"));
QString raw = QString::fromUtf8(out);
@ -275,12 +276,14 @@ void Ollie9pClient::refreshSessions()
if (!raw.isEmpty()) {
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
QStringList parts = line.split('\t');
// New format: name\tid\tpaused\tconnected\tcwd\tbackend\tmodel\tagents
// Where agents is semicolon-separated: name:id:state;name:id:state
if (parts.size() < 2) continue;
if (parts.size() < 4) continue;
const QString sessionId = parts.value(1);
const QString sessionId = parts.value(0);
const QString sessionName = parts.value(1);
const bool isPaused = (parts.value(2) == "true");
const bool isConnected = (parts.value(3) == "true");
const QString remote = parts.value(4);
const QString cwd = parts.value(5);
const bool isActiveSession = (sessionId == m_activeSessionId);
if (isActiveSession) {
@ -288,32 +291,35 @@ void Ollie9pClient::refreshSessions()
}
QVariantMap session;
session["name"] = parts.value(0);
session["id"] = sessionId;
session["paused"] = (parts.value(2) == "true");
session["name"] = sessionName;
session["paused"] = isPaused;
session["connected"] = isConnected;
session["cwd"] = parts.value(4);
session["backend"] = parts.value(5);
session["model"] = parts.value(6);
session["remote"] = remote;
session["cwd"] = cwd;
// Parse agents from semicolon-separated list
// Read agent index for this session: session-id\tagent-id\tagent-name\tparent-id\tdepth\tstate
QVariantList agents;
QString agentStr = parts.value(7);
if (!agentStr.isEmpty()) {
for (const QString &agentEntry : agentStr.split(';', Qt::SkipEmptyParts)) {
QStringList agentParts = agentEntry.split(':');
if (agentParts.size() >= 3) {
const QString agentId = agentParts.value(1);
QVariantMap av;
av["name"] = agentParts.value(0);
av["id"] = agentId;
av["state"] = agentParts.value(2);
agents.append(av);
QString agentIdxPath = "session/" + sessionId + "/agent/idx";
QByteArray agentOut = m_9p->read(agentIdxPath);
QString agentRaw = QString::fromUtf8(agentOut);
if (!agentRaw.isEmpty()) {
for (const QString &agentLine : agentRaw.split('\n', Qt::SkipEmptyParts)) {
QStringList agentParts = agentLine.split('\t');
if (agentParts.size() < 5) continue;
// Check if this is the active agent
if (isActiveSession && agentId == m_agentId) {
activeAgentFound = true;
}
const QString agentId = agentParts.value(1);
QVariantMap av;
av["id"] = agentId;
av["name"] = agentParts.value(2);
av["parentAgentId"] = agentParts.value(3);
av["depth"] = agentParts.value(4).toInt();
av["state"] = agentParts.value(5);
agents.append(av);
// Check if this is the active agent
if (isActiveSession && agentId == m_agentId) {
activeAgentFound = true;
}
}
}
@ -354,8 +360,17 @@ void Ollie9pClient::refreshSessions()
if (session.value("id").toString() != m_activeSessionId) continue;
const QVariantList agents = session.value("agents").toList();
if (!agents.isEmpty()) {
const QString aid = agents.first().toMap().value("id").toString();
if (!aid.isEmpty()) switchAgent(m_activeSessionId, aid);
// Select first top-level agent (depth 0)
for (const QVariant &av : agents) {
const QVariantMap agent = av.toMap();
if (agent.value("depth").toInt() == 0) {
const QString aid = agent.value("id").toString();
if (!aid.isEmpty()) {
switchAgent(m_activeSessionId, aid);
break;
}
}
}
}
break;
}
@ -367,9 +382,17 @@ void Ollie9pClient::refreshSessions()
const QString sid = firstSession.value("id").toString();
const QVariantList agents = firstSession.value("agents").toList();
if (!agents.isEmpty()) {
// Select first agent of first session
const QString aid = agents.first().toMap().value("id").toString();
switchAgent(sid, aid);
// Select first top-level agent (depth 0)
for (const QVariant &av : agents) {
const QVariantMap agent = av.toMap();
if (agent.value("depth").toInt() == 0) {
const QString aid = agent.value("id").toString();
if (!aid.isEmpty()) {
switchAgent(sid, aid);
break;
}
}
}
} else {
// No agents - just select the session
setActiveSessionId(sid);
@ -603,6 +626,15 @@ QString Ollie9pClient::createSession(const QString &name, const QString &remote,
return QString(); // success
}
void Ollie9pClient::setGoal(const QString &sessionName, const QString &goalText)
{
if (!m_9p || !m_9p->isConnected()) return;
if (sessionName.isEmpty() || goalText.isEmpty()) return;
QString path = "session/" + sessionName + "/goal";
m_9p->write(path, goalText.toUtf8());
}
QString Ollie9pClient::createAgent(const QString &sessionId, const QString &cwd, const QString &backend, const QString &model, const QString &agent, const QString &remote, const QString &agentAlias)
{
if (sessionId.isEmpty() || cwd.isEmpty()) return QStringLiteral("session ID and directory are required");

View File

@ -86,6 +86,7 @@ public:
Q_INVOKABLE QStringList getAvailableModels(const QString &backend) const;
Q_INVOKABLE bool createQuickSession();
Q_INVOKABLE QString createSession(const QString &name, const QString &remote, const QString &workflow = QString());
Q_INVOKABLE void setGoal(const QString &sessionName, const QString &goalText);
Q_INVOKABLE QString createAgent(const QString &sessionId, const QString &cwd, const QString &backend, const QString &model, const QString &agent, const QString &remote, const QString &agentAlias);
Q_INVOKABLE bool killAgent(const QString &sessionId, const QString &agentId);
Q_INVOKABLE bool renameSession(const QString &sessionId, const QString &newName);

View File

@ -4,6 +4,7 @@
<file>SettingsDialog.qml</file>
<file>NewSessionDialog.qml</file>
<file>NewAgentDialog.qml</file>
<file>GoalDialog.qml</file>
<file>ChatBlockDelegate.qml</file>
<file>SessionTree.qml</file>
<file>ChatPane.qml</file>

View File

@ -26,6 +26,7 @@ QVariant SessionModel::data(const QModelIndex &index, int role) const
case LevelRole: return r.level;
case ExpandedRole: return r.expanded;
case ParentExpandedRole: return r.parentExpanded;
case HasChildrenRole: return r.hasChildren;
case StateRole: return r.state;
case ModelRole: return r.model;
case DisplayNameRole: return r.displayName;
@ -40,7 +41,8 @@ QHash<int, QByteArray> SessionModel::roleNames() const
{
return {{TypeRole, "type"}, {IdRole, "id"}, {ParentIdRole, "parentId"},
{AgentIdRole, "agentId"}, {LevelRole, "level"}, {ExpandedRole, "expanded"},
{ParentExpandedRole, "parentExpanded"}, {StateRole, "state"}, {ModelRole, "model"},
{ParentExpandedRole, "parentExpanded"}, {HasChildrenRole, "hasChildren"},
{StateRole, "state"}, {ModelRole, "model"},
{DisplayNameRole, "displayName"}, {BaseNameRole, "baseName"},
{ConnectionColorRole, "connectionColor"}, {PausedRole, "paused"}};
}
@ -65,21 +67,61 @@ void SessionModel::appendSession(const QVariantMap &session)
Row row;
row.type = "session"; row.id = id; row.expanded = expanded; row.paused = paused;
row.state = session.value("state").toString(); row.model = session.value("model").toString();
row.baseName = name;
row.displayName = agents.isEmpty() ? name : name + " (" + QString::number(agents.size()) + ")";
row.connectionColor = connectionColor(session);
m_rows.append(row);
// Build agent tree starting from top-level agents (no parent)
appendAgentTree(id, agents, QString(), 1, expanded, paused);
}
void SessionModel::appendAgentTree(const QString &sessionId, const QVariantList &agents,
const QString &parentAgentId, int level, bool parentExpanded, bool paused)
{
// Find all agents at this level (with matching parentAgentId)
for (const QVariant &value : agents) {
const QVariantMap agent = value.toMap();
const QString agentParent = agent.value("parentAgentId").toString();
// Check if this agent belongs at this level
if (agentParent != parentAgentId) continue;
const QString agentId = agent.value("id").toString();
// Check if this agent has children
bool hasChildren = false;
for (const QVariant &v : agents) {
if (v.toMap().value("parentAgentId").toString() == agentId) {
hasChildren = true;
break;
}
}
// Track expansion for agents with children
bool expanded = true;
if (hasChildren) {
expanded = m_agentExpanded.value(agentId, true);
m_agentExpanded[agentId] = expanded;
}
Row child;
child.type = "agent"; child.parentId = id; child.agentId = agent.value("id").toString();
child.level = 1; child.parentExpanded = expanded;
child.type = "agent";
child.parentId = sessionId;
child.agentId = agentId;
child.parentAgentId = parentAgentId;
child.level = level;
child.parentExpanded = parentExpanded;
child.expanded = expanded;
child.hasChildren = hasChildren;
child.state = agent.value("state").toString();
if (child.state.isEmpty()) child.state = "idle";
child.displayName = agent.value("name").toString(); child.paused = paused;
child.displayName = agent.value("name").toString();
child.paused = paused;
m_rows.append(child);
// Recursively add children of this agent (only if expanded and parent is expanded)
appendAgentTree(sessionId, agents, agentId, level + 1, parentExpanded && expanded, paused);
}
}
@ -121,10 +163,18 @@ void SessionModel::updateVisibility()
void SessionModel::setExpanded(int index, bool expanded)
{
if (index < 0 || index >= m_rows.size() || m_rows[index].type != "session") return;
m_rows[index].expanded = expanded;
m_expanded[m_rows[index].id] = expanded;
updateVisibility();
if (index < 0 || index >= m_rows.size()) return;
const Row &row = m_rows[index];
if (row.type == "session") {
m_rows[index].expanded = expanded;
m_expanded[row.id] = expanded;
} else if (row.type == "agent" && row.hasChildren) {
m_rows[index].expanded = expanded;
m_agentExpanded[row.agentId] = expanded;
} else {
return;
}
rebuild(); // Rebuild to update visibility
}
QVariantMap SessionModel::get(int index) const
@ -133,11 +183,13 @@ QVariantMap SessionModel::get(int index) const
if (index < 0 || index >= m_rows.size()) return result;
const Row &r = m_rows.at(index);
result["type"] = r.type; result["id"] = r.id; result["parentId"] = r.parentId;
result["agentId"] = r.agentId; result["level"] = r.level; result["expanded"] = r.expanded;
result["parentExpanded"] = r.parentExpanded; result["state"] = r.state;
result["agentId"] = r.agentId; result["parentAgentId"] = r.parentAgentId;
result["level"] = r.level; result["expanded"] = r.expanded;
result["parentExpanded"] = r.parentExpanded; result["hasChildren"] = r.hasChildren;
result["state"] = r.state;
result["model"] = r.model; result["displayName"] = r.displayName;
result["baseName"] = r.baseName; result["connectionColor"] = r.connectionColor;
result["paused"] = r.paused;
result["paused"] = r.paused; result["name"] = r.displayName;
return result;
}

View File

@ -22,6 +22,7 @@ public:
LevelRole,
ExpandedRole,
ParentExpandedRole,
HasChildrenRole,
StateRole,
ModelRole,
DisplayNameRole,
@ -51,11 +52,13 @@ private:
struct Row {
QString type;
QString id;
QString parentId;
QString parentId; // session ID for agents
QString agentId;
QString parentAgentId; // parent agent ID for sub-agents
int level = 0;
bool expanded = true;
bool parentExpanded = true;
bool hasChildren = false;
QString state;
QString model;
QString displayName;
@ -66,11 +69,14 @@ private:
static QString connectionColor(const QVariantMap &session);
void appendSession(const QVariantMap &session);
void appendAgentTree(const QString &sessionId, const QVariantList &agents,
const QString &parentAgentId, int level, bool parentExpanded, bool paused);
void updateVisibility();
Ollie9pClient *m_client;
QList<Row> m_rows;
QHash<QString, bool> m_expanded;
QHash<QString, bool> m_expanded; // session expansion
QHash<QString, bool> m_agentExpanded; // agent expansion
};
#endif

View File

@ -98,16 +98,24 @@ void OllieRunner::match(RunnerNS::RunnerContext &context)
} else {
for (const QString &entry : sessions) {
QStringList parts = entry.split('\t');
// New format: session-id\tsession-name\tpaused\tconnected\tremote\tcwd
if (parts.size() < 4) continue;
QString sessionId = parts[0];
QString sessionName = parts[1];
bool paused = (parts[2] == "true");
bool connected = (parts[3] == "true");
QString cwd = parts.value(5);
RunnerNS::QueryMatch match(this);
match.setId("ollie-session-" + parts[0]);
QString state = parts[1] == "idle" ? "○" : "●";
match.setText(state + " " + parts[0].left(14) + " [" + parts[3] + "]");
match.setSubtext(parts[2] + " — " + parts[1]);
match.setId("ollie-session-" + sessionId);
QString state = connected ? "●" : "○";
if (paused) state = "◐";
match.setText(state + " " + sessionName);
match.setSubtext(cwd);
match.setIconName("system-run");
match.setRelevance(0.7);
match.setData(parts[0]);
match.setData(sessionId);
context.addMatch(match);
}
}