ollie/kde/gui/ollie9pclient.cpp

1183 lines
42 KiB
C++

#include "ollie9pclient.h"
#include "lib9pstreamer.h"
#include <QDesktopServices>
#include <QDir>
#include <QSet>
#include <QStandardPaths>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDebug>
Ollie9pClient::Ollie9pClient(QObject *parent)
: QObject(parent)
{
// Initialize native 9P client
m_9p = new Lib9pClient(this);
m_9p->connectDefault();
// Chat stream — guarded, reads agent chat log. Runs continuously while
// an agent is selected; stops on agent switch or daemon disconnect.
m_chat = new Lib9pStreamer(Lib9pStreamer::Guarded, this);
m_chat->setGuard([this]() {
return m_daemonConnected && !m_activeSessionId.isEmpty() && !m_agentId.isEmpty();
});
connect(m_chat, &Lib9pStreamer::dataReady, this, [this](const QByteArray &data) {
if (!data.isEmpty())
emit chatReceived(QString::fromUtf8(data));
});
// Event stream — looping, reads server event for session/agent events.
// This single stream handles all state changes across all agents — no
// teardown/startup overhead on agent switch.
m_event = new Lib9pStreamer(Lib9pStreamer::Looping, this);
connect(m_event, &Lib9pStreamer::dataReady, this, [this](const QByteArray &data) {
// Events may come in batches separated by newlines
QString text = QString::fromUtf8(data);
for (const QString &line : text.split('\n', Qt::SkipEmptyParts)) {
handleEvent(line.trimmed());
}
});
// Heartbeat timer — detects daemon death
m_heartbeatTimer = new QTimer(this);
m_heartbeatTimer->setInterval(2500);
connect(m_heartbeatTimer, &QTimer::timeout, this, &Ollie9pClient::heartbeat);
m_heartbeatTimer->start();
// Initial connection check and data load
heartbeat();
if (m_daemonConnected) {
refreshSessions();
ensureRootDataLoaded();
startEventStream();
}
}
Ollie9pClient::~Ollie9pClient()
{
if (m_heartbeatTimer) m_heartbeatTimer->stop();
stopAgentStreams();
if (m_event) m_event->stop();
}
void Ollie9pClient::setActiveSessionId(const QString &id)
{
qDebug() << "setActiveSessionId" << id << "prev session" << m_activeSessionId << "prev agent" << m_agentId;
// Session selection must be side-effect free for per-agent streams.
// Even re-selecting the same session should clear agent selection and stop
// chat so the UI can stay in a pure "session selected" state.
stopStreams();
m_activeSessionId = id;
m_agentId.clear();
m_activeState = "idle";
if (id.isEmpty()) {
emit activeSessionIdChanged();
emit activeStateChanged();
return;
}
emit activeSessionIdChanged();
emit activeAgentIdChanged();
emit activeStateChanged();
}
QString Ollie9pClient::agentKey(const QString &sessionId, const QString &agentId) const
{
return sessionId + "\n" + agentId;
}
void Ollie9pClient::setDaemonConnected(bool connected)
{
if (m_daemonConnected == connected) return;
m_daemonConnected = connected;
emit daemonConnectedChanged();
emit sessionsChanged();
}
void Ollie9pClient::heartbeat()
{
// Try to read a cheap file to verify daemon is alive
if (!m_9p->isConnected()) {
m_9p->connectDefault();
}
QByteArray result = m_9p->read(QStringLiteral("help"));
bool alive = !result.isEmpty();
if (alive && !m_daemonConnected) {
// Daemon came up — refresh state and start event stream
setDaemonConnected(true);
refreshSessions();
ensureRootDataLoaded();
startEventStream();
if (!m_activeSessionId.isEmpty() && !m_agentId.isEmpty()) {
startActiveAgentStreams();
}
} else if (!alive && m_daemonConnected) {
// Daemon died — clear state and stop streams
setDaemonConnected(false);
stopAgentStreams();
if (m_event) m_event->stop();
m_sessions.clear();
m_activeSessionId.clear();
m_agentId.clear();
m_activeState = "idle";
emit sessionsChanged();
emit activeSessionIdChanged();
emit activeAgentIdChanged();
emit activeStateChanged();
m_9p->disconnect();
}
}
void Ollie9pClient::startEventStream()
{
if (!m_daemonConnected) return;
if (m_event->isRunning()) return;
m_event->start(QStringLiteral("event"));
}
QString Ollie9pClient::sessionConnectionColor(const QString &sessionId) const
{
for (const QVariant &v : m_sessions) {
const QVariantMap s = v.toMap();
if (s.value("id").toString() != sessionId) continue;
const bool paused = s.value("paused").toBool();
const bool connected = s.value("connected").toBool();
// Color logic:
// - Gray: paused (intentionally stopped)
// - Green: not paused AND connected
// - Red: not paused AND disconnected (crashed/broken)
// - Yellow: paused BUT still connected (zombie - shouldn't happen)
if (paused && connected) return "yellow"; // zombie
if (paused) return "gray"; // intentionally paused
if (connected) return "green"; // healthy
return "red"; // crashed/broken
}
return "gray";
}
// handleEvent processes a single event from event stream.
// Event format: "topic payload" where topic is "session.{sid}.{action}" or
// "session.{sid}.agent.{aid}.{action}"
// Optimized for the hot path: agent.state changes
void Ollie9pClient::handleEvent(const QString &eventLine)
{
if (eventLine.isEmpty()) return;
// Split topic and payload (avoid QString allocation where possible)
const int spaceIdx = eventLine.indexOf(QLatin1Char(' '));
const QStringView topic = spaceIdx > 0
? QStringView(eventLine).left(spaceIdx)
: QStringView(eventLine);
const QStringView payload = spaceIdx > 0
? QStringView(eventLine).mid(spaceIdx + 1)
: QStringView();
// Fast path: must start with "session."
if (!topic.startsWith(QLatin1String("session."))) return;
// Parse: session.{sid}.{rest...}
const int firstDot = 7; // length of "session"
const int secondDot = topic.indexOf(QLatin1Char('.'), firstDot + 1);
if (secondDot < 0) return;
const QStringView sessionId = topic.mid(firstDot + 1, secondDot - firstDot - 1);
const QStringView rest = topic.mid(secondDot + 1);
// Check for agent events: "agent.{aid}.{action}"
if (rest.startsWith(QLatin1String("agent."))) {
// Parse agent.{aid}.{action}
const int agentDot = 5; // length of "agent"
const int aidEnd = rest.indexOf(QLatin1Char('.'), agentDot + 1);
if (aidEnd < 0) return;
const QStringView agentId = rest.mid(agentDot + 1, aidEnd - agentDot - 1);
const QStringView agentAction = rest.mid(aidEnd + 1);
if (agentAction == QLatin1String("state")) {
// Hot path: agent state change
if (payload.isEmpty()) return;
// Fast path: check if this is the active agent first (avoids toString for non-active)
const bool isActiveAgent = (sessionId == m_activeSessionId && agentId == m_agentId);
if (isActiveAgent) {
// Super fast path: compare payload directly against current active state
const QString payloadStr = payload.toString();
if (m_activeState == payloadStr) return; // No change
// State changed for active agent
m_activeState = payloadStr;
const QString sidStr = sessionId.toString();
const QString aidStr = agentId.toString();
const QString key = agentKey(sidStr, aidStr);
m_agentStateValues[key] = payloadStr;
emit agentStateChanged(sidStr, aidStr, payloadStr);
emit activeStateChanged();
} else {
// Non-active agent: need to check cache
const QString sidStr = sessionId.toString();
const QString aidStr = agentId.toString();
const QString key = agentKey(sidStr, aidStr);
const QString payloadStr = payload.toString();
// Use iterator to avoid double lookup
auto it = m_agentStateValues.find(key);
if (it != m_agentStateValues.end() && it.value() == payloadStr) return;
m_agentStateValues[key] = payloadStr;
emit agentStateChanged(sidStr, aidStr, payloadStr);
}
} else if (agentAction == QLatin1String("new") || agentAction == QLatin1String("kill")) {
refreshSessions();
} else if (agentAction.startsWith(QLatin1String("proc."))) {
// Process events: proc.start
const QStringView procAction = agentAction.mid(5); // after "proc."
if (procAction == QLatin1String("start")) {
// Process started: payload is "pid\ttool\tcmd"
emit procStarted(sessionId.toString(), agentId.toString(), payload.toString());
}
} else if (agentAction.startsWith(QLatin1String("bypass."))) {
const QStringView bypassAction = agentAction.mid(7); // after "bypass."
if (bypassAction == QLatin1String("request")) {
// Bypass request: payload is "id\tcmd\tcwd" (cmd has \n escaped as \\n)
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();
QString cmd = payload.mid(tab1 + 1, tab2 - tab1 - 1).toString();
cmd.replace(QLatin1String("\\n"), QLatin1String("\n")); // unescape newlines
const QString cwd = payload.mid(tab2 + 1).toString();
// Track pending bypass
const QString key = agentKey(sessionId.toString(), agentId.toString());
m_pendingBypasses[key].insert(id);
emit pendingBypassCountChanged(sessionId.toString(), agentId.toString());
emit bypassRequested(sessionId.toString(), agentId.toString(), id, cmd, cwd);
} else if (bypassAction == QLatin1String("resolved")) {
// Bypass resolved (by CLI or another client): payload is "id\tapproved/denied"
if (payload.isEmpty()) return;
const int tab = payload.indexOf(QLatin1Char('\t'));
if (tab < 0) return;
const QString id = payload.left(tab).toString();
const QString sidStr = sessionId.toString();
const QString aidStr = agentId.toString();
// Remove from pending bypasses
const QString key = agentKey(sidStr, aidStr);
if (m_pendingBypasses.contains(key)) {
m_pendingBypasses[key].remove(id);
if (m_pendingBypasses[key].isEmpty()) {
m_pendingBypasses.remove(key);
}
emit pendingBypassCountChanged(sidStr, aidStr);
}
emit bypassResolved(sidStr, aidStr, id);
}
} else if (agentAction == QLatin1String("clear")) {
// Agent was cleared — emit signal so GUI can refresh chat view
emit agentCleared(sessionId.toString(), agentId.toString());
}
} else {
// Session-level event
if (rest == QLatin1String("pause") || rest == QLatin1String("resume") ||
rest == QLatin1String("new") || rest == QLatin1String("kill") ||
rest == QLatin1String("rename")) {
refreshSessions();
}
}
}
QString Ollie9pClient::agentState(const QString &sessionId, const QString &agentId) const
{
return m_agentStateValues.value(agentKey(sessionId, agentId), "idle");
}
int Ollie9pClient::pendingBypassCount(const QString &sessionId, const QString &agentId) const
{
const QString key = agentKey(sessionId, agentId);
return m_pendingBypasses.value(key).size();
}
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);
m_sessions.clear();
// Track if active session/agent still exist during parsing
bool activeSessionFound = false;
bool activeAgentFound = false;
if (!raw.isEmpty()) {
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
QStringList parts = line.split('\t');
if (parts.size() < 4) continue;
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) {
activeSessionFound = true;
}
QVariantMap session;
session["id"] = sessionId;
session["name"] = sessionName;
session["paused"] = isPaused;
session["connected"] = isConnected;
session["remote"] = remote;
session["cwd"] = cwd;
// Read agent index for this session: session-id\tagent-id\tagent-name\tparent-id\tdepth\tstate
QVariantList agents;
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;
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;
}
}
}
session["agents"] = agents;
m_sessions.append(session);
}
}
emit sessionsChanged();
// Update active session paused state
bool wasPaused = m_activeSessionPaused;
m_activeSessionPaused = isSessionPaused(m_activeSessionId);
if (m_activeSessionPaused != wasPaused) {
emit activeSessionPausedChanged();
}
// Handle active session/agent removal
if (!m_activeSessionId.isEmpty()) {
if (!activeSessionFound) {
// Session was killed — clear both and stop streams
stopStreams();
m_activeSessionId.clear();
m_agentId.clear();
m_activeState = "idle";
emit activeSessionIdChanged();
emit activeAgentIdChanged();
emit activeStateChanged();
} else if (!m_agentId.isEmpty() && !activeAgentFound) {
// Agent was killed but session still exists — clear agent only
stopStreams();
m_agentId.clear();
m_activeState = "idle";
emit activeAgentIdChanged();
emit activeStateChanged();
}
}
// Auto-select an agent for the active session when none is selected.
// This also handles the first refresh after a session was selected before
// its agent list arrived.
if (!m_activeSessionId.isEmpty() && m_agentId.isEmpty()) {
for (const QVariant &value : m_sessions) {
const QVariantMap session = value.toMap();
if (session.value("id").toString() != m_activeSessionId) continue;
const QVariantList agents = session.value("agents").toList();
if (!agents.isEmpty()) {
// 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;
}
}
// Auto-select first session and its first agent if none active
if (m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) {
const QVariantMap firstSession = m_sessions.first().toMap();
const QString sid = firstSession.value("id").toString();
const QVariantList agents = firstSession.value("agents").toList();
if (!agents.isEmpty()) {
// 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);
}
}
}
QString Ollie9pClient::readLog()
{
if (m_activeSessionId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {};
return QString::fromUtf8(m_9p->read(agentPath() + "/log"));
}
QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString &agentId)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {};
// Use immutable IDs — the 9P namespace resolves them via aliases.
// Read JSONL from log.raw for the chat model (not plain text log).
QString path = "session/" + sessionId + "/agent/" + agentId + "/log.raw";
return QString::fromUtf8(m_9p->read(path));
}
bool Ollie9pClient::submit(const QString &prompt)
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty() || prompt.trimmed().isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
return m_9p->write(agentPath() + "/prompt", prompt.toUtf8());
}
bool Ollie9pClient::interrupt()
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
return m_9p->write(agentPath() + "/ctl", "stop");
}
bool Ollie9pClient::kill()
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
return m_9p->write(agentPath() + "/ctl", "kill");
}
bool Ollie9pClient::killSession(const QString &sessionId)
{
if (sessionId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
// If killing the active session, shut down its streams and deselect.
if (sessionId == m_activeSessionId) {
stopStreams();
m_activeSessionId.clear();
m_agentId.clear();
emit activeSessionIdChanged();
emit activeAgentIdChanged();
}
// Use immutable ID — the 9P namespace resolves it via alias.
QString path = "session/" + sessionId + "/ctl";
return m_9p->write(path, "kill");
}
bool Ollie9pClient::pauseSession(const QString &sessionId)
{
if (sessionId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
QString path = "session/" + sessionId + "/ctl";
if (m_9p->write(path, "pause")) {
refreshSessions();
return true;
}
return false;
}
bool Ollie9pClient::resumeSession(const QString &sessionId)
{
if (sessionId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
QString path = "session/" + sessionId + "/ctl";
if (m_9p->write(path, "resume")) {
refreshSessions();
// Restart streams if this is the active session
if (sessionId == m_activeSessionId && !m_agentId.isEmpty()) {
startActiveAgentStreams();
}
return true;
}
return false;
}
bool Ollie9pClient::isSessionPaused(const QString &sessionId) const
{
for (const QVariant &v : m_sessions) {
const QVariantMap s = v.toMap();
if (s.value("id").toString() == sessionId)
return s.value("paused").toBool();
}
return false;
}
QString Ollie9pClient::getConfig()
{
if (m_activeSessionId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {};
return QString::fromUtf8(m_9p->read(agentPath() + "/cfg"));
}
QStringList Ollie9pClient::getAgents(const QString &sessionId)
{
if (sessionId.isEmpty()) return {};
if (!m_9p || !m_9p->isConnected()) return {};
// Use immutable ID — the 9P namespace resolves it via alias.
QString path = "session/" + sessionId + "/agent";
QStringList all = m_9p->ls(path);
QStringList agents;
for (const QString &a : all) {
QString name = a.trimmed();
// "new" is the agent creation file, not an actual agent
if (name != "new")
agents.append(name);
}
return agents;
}
void Ollie9pClient::setActiveAgentId(const QString &agentId)
{
if (m_agentId == agentId) return;
stopStreams();
m_agentId = agentId;
m_activeState = "idle";
emit activeAgentIdChanged();
emit activeStateChanged();
}
void Ollie9pClient::switchAgent(const QString &sessionId, const QString &agentId)
{
qDebug() << "switchAgent" << sessionId << agentId << "prev session" << m_activeSessionId << "prev agent" << m_agentId;
if (m_activeSessionId == sessionId && m_agentId == agentId) return;
stopStreams();
m_activeSessionId = sessionId;
m_agentId = agentId;
// Use cached state from live tracking if available, otherwise default to idle
m_activeState = m_agentStateValues.value(agentKey(sessionId, agentId), "idle");
// Update paused state
bool wasPaused = m_activeSessionPaused;
m_activeSessionPaused = isSessionPaused(sessionId);
emit activeSessionIdChanged();
emit activeAgentIdChanged();
emit activeStateChanged();
if (m_activeSessionPaused != wasPaused) {
emit activeSessionPausedChanged();
}
startActiveAgentStreams();
}
void Ollie9pClient::loadRootBackends()
{
if (m_rootBackendsLoaded) return;
if (!m_9p || !m_9p->isConnected()) return;
QByteArray out = m_9p->read(QStringLiteral("backends"));
QString raw = QString::fromUtf8(out).trimmed();
if (!raw.isEmpty()) {
m_availableBackends = raw.split('\n', Qt::SkipEmptyParts);
m_rootBackendsLoaded = true;
emit availableBackendsChanged();
}
emit rootBackendsLoadedChanged();
}
void Ollie9pClient::loadRootAgents()
{
if (m_rootAgentsLoaded) return;
if (!m_9p || !m_9p->isConnected()) return;
QByteArray out = m_9p->read(QStringLiteral("agents"));
QString raw = QString::fromUtf8(out).trimmed();
if (!raw.isEmpty()) {
m_availableAgents = raw.split('\n', Qt::SkipEmptyParts);
m_rootAgentsLoaded = true;
emit availableAgentsChanged();
} else {
m_availableAgents = QStringList() << QStringLiteral("default");
m_rootAgentsLoaded = true;
emit availableAgentsChanged();
}
emit rootAgentsLoadedChanged();
}
void Ollie9pClient::loadWorkflows()
{
if (m_workflowsLoaded) return;
if (!m_9p || !m_9p->isConnected()) return;
QByteArray out = m_9p->read(QStringLiteral("workflows"));
QString raw = QString::fromUtf8(out).trimmed();
m_availableWorkflows.clear();
m_workflowVariants.clear();
if (!raw.isEmpty()) {
const auto lines = raw.split('\n', Qt::SkipEmptyParts);
for (const auto &line : lines) {
int tab = line.indexOf('\t');
if (tab > 0) {
QString name = line.left(tab);
QString variantStr = line.mid(tab + 1);
m_availableWorkflows << name;
m_workflowVariants[name] = variantStr.split(',', Qt::SkipEmptyParts);
} else {
// Legacy format: just the workflow name
m_availableWorkflows << line;
m_workflowVariants[line] = QStringList() << QStringLiteral("default");
}
}
} else {
m_availableWorkflows = QStringList() << QStringLiteral("none");
m_workflowVariants[QStringLiteral("none")] = QStringList() << QStringLiteral("default");
}
m_workflowsLoaded = true;
emit availableWorkflowsChanged();
emit workflowsLoadedChanged();
}
QStringList Ollie9pClient::variantsForWorkflow(const QString &workflow) const
{
auto it = m_workflowVariants.find(workflow);
if (it != m_workflowVariants.end()) return it.value();
return QStringList() << QStringLiteral("default");
}
QStringList Ollie9pClient::getAvailableModels(const QString &backend) const
{
if (!m_rootModelsLoaded) return {};
auto it = m_rootModels.find(backend);
if (it == m_rootModels.end()) return {};
return it.value().toStringList();
}
bool Ollie9pClient::createQuickSession()
{
if (!m_9p || !m_9p->isConnected()) return false;
QByteArray result = m_9p->rdwr(QStringLiteral("session/new"), "\n");
return !result.isEmpty() || m_9p->lastError().isEmpty();
}
QString Ollie9pClient::createSession(const QString &name, const QString &remote, const QString &cwd, const QString &workflow, const QString &variant, bool yolo)
{
if (!m_9p || !m_9p->isConnected()) return QStringLiteral("not connected");
QStringList args;
if (!name.isEmpty()) args << "name=" + name;
if (!remote.isEmpty()) args << "remote=" + remote;
if (!cwd.isEmpty()) args << "cwd=" + cwd;
if (!workflow.isEmpty()) args << "workflow=" + workflow;
if (!variant.isEmpty() && variant != QStringLiteral("default")) args << "variant=" + variant;
if (yolo) args << "yolo=true";
QByteArray result = m_9p->rdwr(QStringLiteral("session/new"), (args.join(" ") + "\n").toUtf8());
QString err = m_9p->lastError();
if (!err.isEmpty()) {
return err;
}
return QString(); // success
}
QVariantMap Ollie9pClient::readSessionConfig(const QString &sessionName) const
{
QVariantMap result;
if (!m_9p || !m_9p->isConnected() || sessionName.isEmpty()) return result;
QString path = "session/" + sessionName + "/cfg";
QByteArray out = m_9p->read(path);
QString raw = QString::fromUtf8(out).trimmed();
if (raw.isEmpty()) return result;
const auto lines = raw.split('\n', Qt::SkipEmptyParts);
for (const auto &line : lines) {
int eq = line.indexOf('=');
if (eq > 0) {
result[line.left(eq)] = line.mid(eq + 1);
}
}
return result;
}
QString Ollie9pClient::updateSessionConfig(const QString &sessionName, const QString &key, const QString &value)
{
if (!m_9p || !m_9p->isConnected() || sessionName.isEmpty()) return QStringLiteral("not connected");
QString path = "session/" + sessionName + "/cfg";
QString payload = key + "=" + value + "\n";
m_9p->write(path, payload.toUtf8());
return m_9p->lastError();
}
QVariantMap Ollie9pClient::readAgentConfig(const QString &sessionId, const QString &agentId) const
{
QVariantMap result;
if (!m_9p || !m_9p->isConnected() || sessionId.isEmpty() || agentId.isEmpty()) return result;
QString path = "session/" + sessionId + "/agent/" + agentId + "/cfg";
QByteArray out = m_9p->read(path);
QString raw = QString::fromUtf8(out).trimmed();
if (raw.isEmpty()) return result;
const auto lines = raw.split('\n', Qt::SkipEmptyParts);
for (const auto &line : lines) {
int eq = line.indexOf('=');
if (eq > 0) {
result[line.left(eq)] = line.mid(eq + 1);
}
}
return result;
}
QString Ollie9pClient::updateAgentConfig(const QString &sessionId, const QString &agentId, const QString &key, const QString &value)
{
if (!m_9p || !m_9p->isConnected() || sessionId.isEmpty() || agentId.isEmpty()) return QStringLiteral("not connected");
// The agent cfg file only accepts a "name" or "cwd"/"cwdOverride" write;
// backend/model/profile are changed through the agent ctl verbs. Route each
// key to the right place.
if (key == "cwd" || key == "cwdOverride" || key == "displayName" || key == "name") {
// cfg-file writes: name sets display name; cwd sets the per-agent
// working-directory override (empty value clears it).
QString cfgKey = (key == "displayName" || key == "name") ? "name" : key;
QString cfgPath = "session/" + sessionId + "/agent/" + agentId + "/cfg";
m_9p->write(cfgPath, (cfgKey + "=" + value + "\n").toUtf8());
QString cfgErr = m_9p->lastError();
if (!cfgErr.isEmpty()) return cfgErr;
refreshSessions();
return QString();
}
QString ctlPath = "session/" + sessionId + "/agent/" + agentId + "/ctl";
QString verb;
if (key == "backend") verb = "backend";
else if (key == "model") verb = "model";
else if (key == "profile") verb = "agent";
else return QStringLiteral("unknown agent cfg key: ") + key;
m_9p->rdwr(ctlPath, (verb + " " + value + "\n").toUtf8());
QString err = m_9p->lastError();
if (!err.isEmpty()) return err;
refreshSessions();
return QString();
}
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 &backend, const QString &model, const QString &agent, const QString &remote, const QString &agentAlias)
{
if (sessionId.isEmpty()) return QStringLiteral("session ID is required");
if (!m_9p || !m_9p->isConnected()) return QStringLiteral("not connected");
QStringList agentArgs;
if (!backend.isEmpty()) agentArgs << "backend=" + backend;
if (!model.isEmpty()) agentArgs << "model=" + model;
if (!agent.isEmpty()) agentArgs << "agent=" + agent;
if (!remote.isEmpty()) agentArgs << "remote=" + remote;
if (!agentAlias.isEmpty()) agentArgs << "name=" + agentAlias;
// Use immutable ID — the 9P namespace resolves it via alias.
QString path = "session/" + sessionId + "/agent/new";
QByteArray result = m_9p->rdwr(path, (agentArgs.join(" ") + "\n").toUtf8());
QString err = m_9p->lastError();
if (!err.isEmpty()) {
qDebug() << "createAgent failed:" << err;
return err;
}
// Return the new agent's ID (trimmed) so callers can switch to it immediately.
// Format: "agentid\n"
return QString::fromUtf8(result).trimmed();
}
bool Ollie9pClient::killAgent(const QString &sessionId, const QString &agentId)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
// Use immutable IDs — the 9P namespace resolves them via aliases.
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
return m_9p->write(path, "kill");
}
bool Ollie9pClient::renameSession(const QString &sessionId, const QString &newName)
{
if (sessionId.isEmpty() || newName.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
// Session names are mutable display names. Writing the name file triggers
// the rename. Use the immutable ID to address the session.
QString path = "session/" + sessionId + "/name";
if (m_9p->write(path, (newName + "\n").toUtf8())) {
// With immutable-ID paths, renaming no longer invalidates streams or
// the active selection. Just refresh the session list to pick up the
// new display name.
refreshSessions();
return true;
}
qDebug() << "renameSession failed:" << m_9p->lastError();
return false;
}
bool Ollie9pClient::renameAgent(const QString &sessionId, const QString &agentId, const QString &newName)
{
if (sessionId.isEmpty() || agentId.isEmpty() || newName.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
// Agent names are mutable display names. Write the name file directly
// using immutable IDs to address the path.
QString path = "session/" + sessionId + "/agent/" + agentId + "/name";
if (m_9p->write(path, (newName + "\n").toUtf8())) {
// With immutable-ID paths, renaming no longer invalidates streams.
refreshSessions();
return true;
}
qDebug() << "renameAgent failed:" << m_9p->lastError();
return false;
}
bool Ollie9pClient::resolveBypass(const QString &sessionId, const QString &agentId, 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())) {
// Remove from pending bypasses
const QString key = agentKey(sessionId, agentId);
if (m_pendingBypasses.contains(key)) {
m_pendingBypasses[key].remove(id);
if (m_pendingBypasses[key].isEmpty()) {
m_pendingBypasses.remove(key);
}
emit pendingBypassCountChanged(sessionId, agentId);
emit bypassResolved(sessionId, agentId, id);
}
return true;
}
qDebug() << "resolveBypass failed:" << m_9p->lastError();
return false;
}
QVariantList Ollie9pClient::listProcs(const QString &sessionId, const QString &agentId)
{
QVariantList result;
if (sessionId.isEmpty() || agentId.isEmpty()) return result;
if (!m_9p || !m_9p->isConnected()) return result;
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
QByteArray out = m_9p->rdwr(path, "proc idx");
if (out.isEmpty()) return result;
// Format: pid<TAB>status<TAB>exit_code<TAB>tool<TAB>cmd<TAB>start_unix<TAB>runtime_sec
for (const QString &line : QString::fromUtf8(out).split('\n')) {
if (line.isEmpty()) continue;
QStringList parts = line.split('\t');
if (parts.size() < 7) continue;
QVariantMap proc;
proc["pid"] = parts[0].toInt();
proc["status"] = parts[1];
proc["exitCode"] = parts[2].toInt();
proc["tool"] = parts[3];
proc["cmd"] = parts[4];
proc["startUnix"] = parts[5].toLongLong();
proc["runtime"] = parts[6].toDouble();
result.append(proc);
}
return result;
}
bool Ollie9pClient::procTerm(const QString &sessionId, const QString &agentId, int pid)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
QString data = "proc term " + QString::number(pid);
QByteArray out = m_9p->rdwr(path, data.toUtf8());
return out.trimmed() == "ok";
}
bool Ollie9pClient::procKill(const QString &sessionId, const QString &agentId, int pid)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
QString data = "proc kill " + QString::number(pid);
QByteArray out = m_9p->rdwr(path, data.toUtf8());
return out.trimmed() == "ok";
}
QString Ollie9pClient::procOutput(const QString &sessionId, const QString &agentId, int pid)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return QString();
if (!m_9p || !m_9p->isConnected()) return QString();
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
QString data = "proc out " + QString::number(pid);
QByteArray out = m_9p->rdwr(path, data.toUtf8());
return QString::fromUtf8(out);
}
void Ollie9pClient::openDirectory(const QString &path)
{
if (path.isEmpty()) return;
QDesktopServices::openUrl(QUrl::fromLocalFile(path));
}
QString Ollie9pClient::activeSessionName() const
{
if (m_activeSessionId.isEmpty()) return QString();
for (const QVariant &v : m_sessions) {
QVariantMap session = v.toMap();
if (session.value("id").toString() == m_activeSessionId) {
return session.value("name").toString();
}
}
return m_activeSessionId; // fallback to ID
}
QString Ollie9pClient::activeAgentName() const
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return QString();
for (const QVariant &v : m_sessions) {
QVariantMap session = v.toMap();
if (session.value("id").toString() == m_activeSessionId) {
QVariantList agents = session.value("agents").toList();
for (const QVariant &a : agents) {
QVariantMap agent = a.toMap();
if (agent.value("id").toString() == m_agentId) {
return agent.value("name").toString();
}
}
}
}
return m_agentId; // fallback to ID
}
// --- Peer management ---
QStringList Ollie9pClient::listPeers(const QString &sessionId, const QString &agentId)
{
QStringList result;
if (sessionId.isEmpty() || agentId.isEmpty()) return result;
if (!m_9p || !m_9p->isConnected()) return result;
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
QByteArray out = m_9p->rdwr(path, "peers");
QString raw = QString::fromUtf8(out).trimmed();
if (raw.isEmpty() || raw == "(no peers)") return result;
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
result.append(line.trimmed());
}
return result;
}
bool Ollie9pClient::addPeer(const QString &sessionId, const QString &agentId, const QString &peerName)
{
if (sessionId.isEmpty() || agentId.isEmpty() || peerName.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
QString data = "peeradd " + peerName;
QByteArray out = m_9p->rdwr(path, data.toUtf8());
return out.trimmed() == "ok";
}
bool Ollie9pClient::removePeer(const QString &sessionId, const QString &agentId, const QString &peerName)
{
if (sessionId.isEmpty() || agentId.isEmpty() || peerName.isEmpty()) return false;
if (!m_9p || !m_9p->isConnected()) return false;
QString path = "session/" + sessionId + "/agent/" + agentId + "/ctl";
QString data = "peerdel " + peerName;
QByteArray out = m_9p->rdwr(path, data.toUtf8());
return out.trimmed() == "ok";
}
QVariantList Ollie9pClient::listAgentsInSession(const QString &sessionId)
{
QVariantList result;
if (sessionId.isEmpty()) return result;
if (!m_9p || !m_9p->isConnected()) return result;
QString path = "session/" + sessionId + "/agent/idx";
QByteArray out = m_9p->read(path);
QString raw = QString::fromUtf8(out);
if (raw.isEmpty()) return result;
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
QStringList parts = line.split('\t');
if (parts.size() < 3) continue;
QVariantMap agent;
agent["id"] = parts.value(1);
agent["name"] = parts.value(2);
agent["parentAgentId"] = parts.value(3);
agent["depth"] = parts.value(4).toInt();
agent["state"] = parts.value(5);
result.append(agent);
}
return result;
}
QString Ollie9pClient::fetchBlock(const QString &sessionId, const QString &agentId, const QString &blockId)
{
qDebug("fetchBlock: session=%s agent=%s block=%s", qPrintable(sessionId), qPrintable(agentId), qPrintable(blockId));
if (sessionId.isEmpty() || agentId.isEmpty() || blockId.isEmpty()) {
qDebug("fetchBlock: empty parameter");
return QString();
}
if (!m_9p || !m_9p->isConnected()) {
qDebug("fetchBlock: not connected (m_9p=%p connected=%d)", (void*)m_9p, m_9p ? m_9p->isConnected() : 0);
return QString();
}
QString path = QStringLiteral("session/%1/agent/%2/block").arg(sessionId, agentId);
qDebug("fetchBlock: rdwr path=%s", qPrintable(path));
QByteArray out = m_9p->rdwr(path, blockId.toUtf8());
qDebug("fetchBlock: got %lld bytes", (long long)out.size());
return QString::fromUtf8(out);
}
// --- Root data loading (lazy) ---
void Ollie9pClient::ensureRootDataLoaded()
{
if (!m_rootBackendsLoaded) loadRootBackends();
if (!m_rootAgentsLoaded) loadRootAgents();
if (!m_workflowsLoaded) loadWorkflows();
if (!m_rootModelsLoaded && m_9p && m_9p->isConnected()) {
QByteArray out = m_9p->read(QStringLiteral("models"));
QString raw = QString::fromUtf8(out);
if (!raw.isEmpty()) {
for (const QString &line : raw.split('\n')) {
int tab = line.indexOf('\t');
if (tab < 0) continue;
QString be = line.left(tab).trimmed();
// Extract just the model name (second field), not the cost columns
QString rest = line.mid(tab + 1);
int nextTab = rest.indexOf('\t');
QString mo = (nextTab >= 0 ? rest.left(nextTab) : rest).trimmed();
if (!be.isEmpty() && !mo.isEmpty()) {
QStringList list = m_rootModels[be].toStringList();
list.append(mo);
m_rootModels[be] = QVariant(list);
}
}
m_rootModelsLoaded = true;
emit rootModelsLoadedChanged();
}
}
refreshSessions();
}
// --- Streaming ---
void Ollie9pClient::startActiveAgentStreams()
{
if (!m_daemonConnected) return;
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return;
m_chat->start(agentPath() + "/chat.raw");
// Check for pending bypass request on this session
checkPendingBypass();
}
void Ollie9pClient::checkPendingBypass()
{
if (m_activeSessionId.isEmpty()) return;
if (!m_9p || !m_9p->isConnected()) return;
QString path = "session/" + m_activeSessionId + "/bypass";
QByteArray data = m_9p->read(path);
if (data.isEmpty()) return;
// Parse JSON: {"id":"...","cmd":"...","cwd":"..."}
QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) return;
QJsonObject obj = doc.object();
QString id = obj["id"].toString();
QString cmd = obj["cmd"].toString();
QString cwd = obj["cwd"].toString();
if (!id.isEmpty() && !cmd.isEmpty()) {
emit bypassRequested(m_activeSessionId, m_agentId, id, cmd, cwd);
}
}
void Ollie9pClient::stopAgentStreams()
{
if (m_chat) m_chat->stop();
}
void Ollie9pClient::stopStreams()
{
// Daemon and per-agent connectivity monitoring are independent of the
// currently selected agent. Selection changes must not tear them down.
stopAgentStreams();
}
// --- Helpers ---
QString Ollie9pClient::agentPath() const
{
// Use immutable IDs — the 9P namespace resolves them via aliases.
return "session/" + m_activeSessionId + "/agent/" + m_agentId;
}