This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/gui/ollie9pclient.cpp

842 lines
29 KiB
C++

#include "ollie9pclient.h"
#include <QDir>
#include <QSet>
#include <QStandardPaths>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDebug>
static QString ninepBin()
{
// plan9port's 9p command
QString bin = QStandardPaths::findExecutable("9p");
if (!bin.isEmpty()) return bin;
QString plan9 = qEnvironmentVariable("PLAN9");
if (!plan9.isEmpty() && QFileInfo::exists(plan9 + "/bin/9p"))
return plan9 + "/bin/9p";
return "9p";
}
static QString serverAddr()
{
QString ns = qEnvironmentVariable("NAMESPACE");
if (ns.isEmpty()) {
// Plan 9 default: /tmp/ns.$USER.$DISPLAY
QString user = qEnvironmentVariable("USER");
QString display = qEnvironmentVariable("DISPLAY");
ns = "/tmp/ns." + user + "." + display;
}
return "unix!" + ns + "/ollie";
}
// Returns the path to ollie-9p binary, or empty string if not found.
static QString ollie9pBin()
{
return QStandardPaths::findExecutable("ollie-9p");
}
Ollie9pClient::Ollie9pClient(QObject *parent)
: QObject(parent)
{
// Chat stream — one-shot, reads agent chat log. Starts/stops with agent switch.
m_chat = new StreamFsm(StreamFsm::Oneshot, this);
connect(m_chat, &StreamFsm::readyRead, this, [this](const QByteArray &data) {
if (!data.isEmpty())
emit chatReceived(QString::fromUtf8(data));
});
connect(m_chat, &StreamFsm::died, this, [this]() {
if (!m_activeSessionId.isEmpty()) {
for (const QVariant &value : std::as_const(m_sessions)) {
const QVariantMap session = value.toMap();
if (session.value("id").toString() != m_activeSessionId) continue;
for (const QVariant &agentValue : session.value("agents").toList()) {
const QString key = agentKey(m_activeSessionId,
agentValue.toMap().value("id").toString());
setAgentConnected(key, false);
}
break;
}
}
emit streamingDone();
});
// State stream — guarded, reads agent statewait. Auto-restarts until
// session/agent is deselected (guard returns false).
m_state = new StreamFsm(StreamFsm::Guarded, this);
m_state->setGuard([this]() {
return !m_activeSessionId.isEmpty() && !m_agentId.isEmpty();
});
connect(m_state, &StreamFsm::readyRead, this, [this](const QByteArray &data) {
QString state = QString::fromUtf8(data).trimmed();
if (!state.isEmpty()) {
m_activeState = state;
emit activeStateChanged();
}
});
// Event stream — connection-aware daemon sentinel. The operation reports
// connected only after the 9P open has completed, then retries slowly
// after the daemon closes or rejects the operation.
m_daemon = new NinePConnection(this);
connect(m_daemon, &NinePConnection::connected, this, [this]() {
setDaemonConnected(true);
refreshSessions();
ensureRootDataLoaded();
startAgentConnections();
});
connect(m_daemon, &NinePConnection::disconnected, this, [this]() {
setDaemonConnected(false);
stopAgentConnections();
});
connect(m_daemon, &NinePConnection::readyRead, this, [this](const QByteArray &) {
refreshSessions();
});
refreshSessions();
ensureRootDataLoaded();
m_daemon->start(ollie9pBin(), {"-a", serverAddr(), "read", "--open-marker", "eventwait"});
}
Ollie9pClient::~Ollie9pClient()
{
stopAgentStreams();
if (m_daemon) m_daemon->stop();
stopAgentConnections();
}
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
// statewait/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::sessionNameForId(const QString &sessionId) const
{
for (const QVariant &value : std::as_const(m_sessions)) {
const QVariantMap session = value.toMap();
if (session.value("id").toString() == sessionId)
return session.value("name").toString();
}
return {};
}
QString Ollie9pClient::agentNameForId(const QString &sessionId, const QString &agentId) const
{
for (const QVariant &value : std::as_const(m_sessions)) {
const QVariantMap session = value.toMap();
if (session.value("id").toString() != sessionId) continue;
for (const QVariant &agentValue : session.value("agents").toList()) {
const QVariantMap agent = agentValue.toMap();
if (agent.value("id").toString() == agentId)
return agent.value("name").toString();
}
}
return {};
}
QString Ollie9pClient::agentKey(const QString &sessionId, const QString &agentId) const
{
return sessionId + "\n" + agentId;
}
QList<Ollie9pClient::AgentRecord> Ollie9pClient::readAgentRecords(const QString &sessionId)
{
QList<AgentRecord> result;
QString sessionName = sessionNameForId(sessionId);
if (sessionName.isEmpty()) sessionName = sessionId;
if (sessionName.isEmpty()) return result;
const QString raw = QString::fromUtf8(run9p({"ls", "session/" + sessionName + "/agent"})).trimmed();
for (const QString &name : raw.split('\n', Qt::SkipEmptyParts)) {
if (name == "new") continue;
const QString path = "session/" + sessionName + "/agent/" + name;
const QString id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed();
AgentRecord r;
r.id = id.isEmpty() ? name : id;
r.name = name;
result.append(r);
}
return result;
}
void Ollie9pClient::setDaemonConnected(bool connected)
{
if (m_daemonConnected == connected) return;
m_daemonConnected = connected;
if (!connected) {
for (auto it = m_agentConnected.begin(); it != m_agentConnected.end(); ++it)
it.value() = false;
}
emit daemonConnectedChanged();
emit sessionsChanged();
}
bool Ollie9pClient::agentConnected(const QString &sessionId, const QString &agentId) const
{
return m_daemonConnected && m_agentConnected.value(agentKey(sessionId, agentId), false);
}
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 QVariantList agents = s.value("agents").toList();
if (agents.isEmpty()) return "gray";
int connected = 0;
for (const QVariant &a : agents)
if (a.toMap().value("connected").toBool()) ++connected;
if (connected == 0) return "red";
if (connected == agents.size()) return "green";
return "yellow";
}
return "gray";
}
void Ollie9pClient::setAgentConnected(const QString &key, bool connected)
{
const bool cached = m_agentConnected.value(key, false);
m_agentConnected[key] = connected;
bool modelChanged = false;
// Update the in-memory model without starting another round of probes.
for (QVariant &value : m_sessions) {
QVariantMap session = value.toMap();
QVariantList agents = session.value("agents").toList();
for (QVariant &agentValue : agents) {
QVariantMap agent = agentValue.toMap();
if (agentKey(session["id"].toString(), agent["id"].toString()) == key) {
if (agent.value("connected").toBool() != connected)
modelChanged = true;
agent["connected"] = connected;
}
agentValue = agent;
}
session["agents"] = agents;
value = session;
}
if (cached != connected || modelChanged)
emit sessionsChanged();
}
void Ollie9pClient::startAgentConnections()
{
reconcileAgentConnections();
}
void Ollie9pClient::reconcileAgentConnections()
{
if (!m_daemonConnected) return;
QSet<QString> wanted;
for (const QVariant &value : std::as_const(m_sessions)) {
const QVariantMap session = value.toMap();
const QString sid = session.value("id").toString();
const QString name = session.value("name").toString();
for (const QVariant &agentValue : session.value("agents").toList()) {
const QVariantMap agent = agentValue.toMap();
const QString aid = agent.value("id").toString();
const QString key = agentKey(sid, aid);
wanted.insert(key);
if (m_agentConnections.contains(key)) continue;
auto *connection = new NinePConnection(this);
m_agentConnections.insert(key, connection);
connect(connection, &NinePConnection::connected, this, [this, key]() {
const bool reconnected = m_seenAgentConnections.contains(key);
m_seenAgentConnections.insert(key);
setAgentConnected(key, true);
if (key == agentKey(m_activeSessionId, m_agentId)) {
if (reconnected)
emit sessionConnected();
startActiveAgentStreams();
}
});
connect(connection, &NinePConnection::disconnected, this, [this, key]() {
setAgentConnected(key, false);
});
connection->start(ollie9pBin(), {"-a", serverAddr(), "read", "--open-marker",
"session/" + name + "/agent/" + agent.value("name").toString() + "/connection"}, 4000, true);
}
}
for (auto it = m_agentConnections.begin(); it != m_agentConnections.end();) {
if (wanted.contains(it.key())) {
++it;
continue;
}
it.value()->stop();
it.value()->deleteLater();
m_agentConnected.remove(it.key());
it = m_agentConnections.erase(it);
}
}
void Ollie9pClient::stopAgentConnections()
{
for (NinePConnection *connection : std::as_const(m_agentConnections))
connection->stop();
qDeleteAll(m_agentConnections);
m_agentConnections.clear();
}
void Ollie9pClient::refreshSessions()
{
if (!m_daemonConnected) return;
QByteArray out = run9p({"read", "session/idx"});
// Don't trim — trailing tabs are significant fields
QString raw = QString::fromUtf8(out);
const QVariantList oldSessions = m_sessions;
m_sessions.clear();
QSet<QString> seen;
QHash<QString, QString> sessionNames;
for (const QVariant &value : oldSessions) {
const QVariantMap session = value.toMap();
sessionNames.insert(session.value("id").toString(), session.value("name").toString());
}
if (!raw.isEmpty()) {
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
QStringList parts = line.split('\t');
// Skip lines where the first field (session name) is empty
if (parts.isEmpty() || parts[0].isEmpty()) continue;
QString sid = parts[0];
if (seen.contains(sid)) continue; // already added (multi-agent)
seen.insert(sid);
QVariantMap session;
// session/idx currently exposes the mutable display name. Read the
// immutable id and retain both values in the GUI model.
QByteArray idOut = run9p({"read", "session/" + sid + "/id"}).trimmed();
QString sessionId = QString::fromUtf8(idOut);
if (sessionId.isEmpty()) sessionId = sid;
sessionNames[sessionId] = sid;
session["id"] = sessionId;
session["name"] = sid;
session["state"] = parts.size() > 1 ? parts[1] : "";
session["cwd"] = parts.size() > 2 ? parts[2] : "";
session["backend"] = parts.size() > 3 ? parts[3] : "";
session["model"] = parts.size() > 4 ? parts[4] : "";
QVariantList agents;
for (const QString &name : QString::fromUtf8(run9p({"ls", "session/" + sid + "/agent"})).trimmed().split('\n', Qt::SkipEmptyParts)) {
if (name == "new") continue;
const QString path = "session/" + sid + "/agent/" + name;
AgentRecord a;
a.id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed();
a.name = name;
if (a.id.isEmpty()) a.id = name;
QVariantMap av; av["id"] = a.id; av["name"] = a.name;
av["connected"] = m_daemonConnected && m_agentConnected.value(agentKey(sessionId, a.id), false);
agents.append(av);
}
session["agents"] = agents;
m_sessions.append(session);
}
}
reconcileAgentConnections();
emit sessionsChanged();
// Auto-select first session if none active
if (m_activeSessionId.isEmpty() && !m_sessions.isEmpty()) {
setActiveSessionId(m_sessions.first().toMap()["id"].toString());
}
}
QString Ollie9pClient::readLog()
{
if (m_activeSessionId.isEmpty()) return {};
QByteArray out = run9p({"read", agentPath() + "/log"});
return QString::fromUtf8(out);
}
QString Ollie9pClient::readLogForSession(const QString &sessionId, const QString &agentId)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return {};
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QString agentName = agentNameForId(sessionId, agentId);
if (agentName.isEmpty()) agentName = agentId;
QByteArray out = run9p({"read", "session/" + sessName + "/agent/" + agentName + "/log"});
return QString::fromUtf8(out);
}
bool Ollie9pClient::submit(const QString &prompt)
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty() || prompt.trimmed().isEmpty()) return false;
QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "write", agentPath() + "/prompt"});
proc.waitForStarted(3000);
proc.write(prompt.toUtf8());
proc.closeWriteChannel();
proc.waitForFinished(5000);
return proc.exitCode() == 0;
}
bool Ollie9pClient::interrupt()
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return false;
QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "write", agentPath() + "/ctl"});
proc.waitForStarted(3000);
proc.write("stop");
proc.closeWriteChannel();
proc.waitForFinished(3000);
return proc.exitCode() == 0;
}
bool Ollie9pClient::kill()
{
if (m_activeSessionId.isEmpty() || m_agentId.isEmpty()) return false;
QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "write", agentPath() + "/ctl"});
proc.waitForStarted(3000);
proc.write("kill");
proc.closeWriteChannel();
proc.waitForFinished(3000);
return proc.exitCode() == 0;
}
bool Ollie9pClient::killSession(const QString &sessionId)
{
if (sessionId.isEmpty()) return false;
// If killing the active session, shut down its streams first
if (sessionId == m_activeSessionId) {
stopStreams();
}
// Resolve the directory name from the immutable session ID
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QString path = "session/" + sessName + "/ctl";
QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "write", path});
proc.waitForStarted(3000);
proc.write("kill");
proc.closeWriteChannel();
proc.waitForFinished(3000);
return proc.exitCode() == 0;
}
QString Ollie9pClient::getConfig()
{
if (m_activeSessionId.isEmpty()) return {};
QByteArray out = run9p({"read", agentPath() + "/cfg"});
return QString::fromUtf8(out);
}
QStringList Ollie9pClient::getAgents(const QString &sessionId)
{
if (sessionId.isEmpty()) return {};
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QByteArray out = run9p({"ls", "session/" + sessName + "/agent"});
QString raw = QString::fromUtf8(out).trimmed();
if (raw.isEmpty()) return {};
QStringList all = raw.split('\n', Qt::SkipEmptyParts);
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;
m_activeState = "idle";
// Read initial state immediately
QByteArray stateOut = run9p({"read", agentPath() + "/state"});
QString stateStr = QString::fromUtf8(stateOut).trimmed();
if (!stateStr.isEmpty())
m_activeState = stateStr;
emit activeSessionIdChanged();
emit activeAgentIdChanged();
emit activeStateChanged();
startActiveAgentStreams();
}
void Ollie9pClient::loadRootBackends()
{
if (m_rootBackendsLoaded) return;
QByteArray out = run9p({"read", "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;
QByteArray out = run9p({"read", "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();
}
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::createSession(const QString &cwd, const QString &name, const QString &backend, const QString &model, const QString &agent, const QString &remote, const QString &agentAlias)
{
if (cwd.isEmpty()) return false;
// Step 1: Create the session (name only) using rdwr (write then read back result)
QString sessName = name;
{
QProcess proc;
QStringList args = {"-a", serverAddr(), "rdwr", "session/new"};
proc.start(ninepBin(), args);
proc.waitForStarted(3000);
QString input = "name=" + sessName + "\n";
proc.write(input.toUtf8());
proc.closeWriteChannel();
proc.waitForFinished(5000);
if (proc.exitCode() != 0) {
QString err = QString::fromUtf8(proc.readAllStandardError()).trimmed();
if (err.isEmpty())
err = QString::fromUtf8(proc.readAllStandardOutput()).trimmed();
qDebug() << "createSession (step 1) failed:" << err;
return false;
}
// Read the returned session name (auto-generated if blank)
QByteArray out = proc.readAllStandardOutput().trimmed();
if (!out.isEmpty())
sessName = QString::fromUtf8(out);
}
// Step 2: Create the agent within the session using rdwr
{
QStringList agentArgs;
agentArgs << "cwd=" + cwd;
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;
QProcess proc;
QStringList args = {"-a", serverAddr(), "rdwr", "session/" + sessName + "/agent/new"};
proc.start(ninepBin(), args);
proc.waitForStarted(3000);
QString input = agentArgs.join(" ") + "\n";
proc.write(input.toUtf8());
proc.closeWriteChannel();
proc.waitForFinished(5000);
if (proc.exitCode() != 0) {
QString err = QString::fromUtf8(proc.readAllStandardError()).trimmed();
if (err.isEmpty())
err = QString::fromUtf8(proc.readAllStandardOutput()).trimmed();
qDebug() << "createSession (step 2) failed:" << err;
// Clean up the session
QProcess killProc;
killProc.start(ninepBin(), {"-a", serverAddr(), "write", "session/" + sessName + "/ctl"});
killProc.waitForStarted(3000);
killProc.write("kill");
killProc.closeWriteChannel();
killProc.waitForFinished(3000);
return false;
}
}
return true;
}
bool Ollie9pClient::createQuickSession()
{
QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "rdwr", "session/new"});
proc.waitForStarted(3000);
proc.write("\n");
proc.closeWriteChannel();
proc.waitForFinished(5000);
return proc.exitCode() == 0;
}
bool 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 false;
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QStringList agentArgs;
agentArgs << "cwd=" + cwd;
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;
QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "rdwr", "session/" + sessName + "/agent/new"});
proc.waitForStarted(3000);
proc.write((agentArgs.join(" ") + "\n").toUtf8());
proc.closeWriteChannel();
proc.waitForFinished(5000);
if (proc.exitCode() != 0) {
QString err = QString::fromUtf8(proc.readAllStandardError()).trimmed();
if (err.isEmpty())
err = QString::fromUtf8(proc.readAllStandardOutput()).trimmed();
qDebug() << "createAgent failed:" << err;
return false;
}
return true;
}
bool Ollie9pClient::killAgent(const QString &sessionId, const QString &agentId)
{
if (sessionId.isEmpty() || agentId.isEmpty()) return false;
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QString agentName = agentNameForId(sessionId, agentId);
if (agentName.isEmpty()) agentName = agentId;
QString path = "session/" + sessName + "/agent/" + agentName + "/ctl";
QProcess proc;
proc.start(ninepBin(), {"-a", serverAddr(), "write", path});
proc.waitForStarted(3000);
proc.write("kill");
proc.closeWriteChannel();
proc.waitForFinished(3000);
return proc.exitCode() == 0;
}
bool Ollie9pClient::renameSession(const QString &sessionId, const QString &newName)
{
if (sessionId.isEmpty() || newName.isEmpty()) return false;
QString bin = ollie9pBin();
if (bin.isEmpty()) {
qDebug() << "renameSession: ollie-9p not found, cannot rename";
return false;
}
// Session names are mutable display names, not directory IDs. Writing the
// name file also avoids relying on Twstat, which is not implemented by all
// 9P frontends.
// Resolve the directory name from the immutable session ID
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QProcess proc;
proc.setProgram(bin);
proc.setArguments({"-a", serverAddr(), "write", "session/" + sessName + "/name"});
proc.start();
if (!proc.waitForStarted(3000)) {
qDebug() << "renameSession failed to start:" << proc.errorString();
return false;
}
proc.write((newName + "\n").toUtf8());
proc.closeWriteChannel();
proc.waitForFinished(5000);
if (proc.exitCode() != 0) {
qDebug() << "renameSession failed:" << QString::fromUtf8(proc.readAllStandardError()).trimmed();
return false;
}
// Stop path-based streams before changing the namespace entry. Otherwise
// Guarded streams can observe the old path disappearing and repeatedly
// restart against it while this synchronous write is in progress.
const bool active = m_activeSessionId == sessionId;
const QString activeAgent = m_agentId;
if (active)
stopAgentStreams();
if (active) {
m_activeSessionId = newName;
emit activeSessionIdChanged();
if (!activeAgent.isEmpty())
switchAgent(newName, activeAgent);
}
refreshSessions();
return true;
}
bool Ollie9pClient::renameAgent(const QString &sessionId, const QString &agentId, const QString &newName)
{
if (sessionId.isEmpty() || agentId.isEmpty() || newName.isEmpty()) return false;
QString bin = ollie9pBin();
if (bin.isEmpty()) {
qDebug() << "renameAgent: ollie-9p not found, cannot rename";
return false;
}
// Agent names are mutable display names. Write the name file directly;
// ctl accepts commands, not name assignments, and the agent directory is
// keyed by its current display name.
const bool active = m_activeSessionId == sessionId && m_agentId == agentId;
if (active)
stopAgentStreams();
// Resolve directory names from immutable IDs
QString sessName = sessionNameForId(sessionId);
if (sessName.isEmpty()) sessName = sessionId;
QString agentName = agentNameForId(sessionId, agentId);
if (agentName.isEmpty()) agentName = agentId;
QProcess proc;
proc.setProgram(bin);
proc.setArguments({"-a", serverAddr(), "write",
"session/" + sessName + "/agent/" + agentName + "/name"});
proc.start();
if (!proc.waitForStarted(3000)) {
qDebug() << "renameAgent failed to start:" << proc.errorString();
return false;
}
proc.write((newName + "\n").toUtf8());
proc.closeWriteChannel();
proc.waitForFinished(5000);
if (proc.exitCode() != 0) {
qDebug() << "renameAgent failed:" << QString::fromUtf8(proc.readAllStandardError()).trimmed();
return false;
}
if (active) {
// switchAgent updates the active ID and restarts the path-based streams.
switchAgent(sessionId, newName);
}
refreshSessions();
return true;
}
// --- Root data loading (lazy) ---
void Ollie9pClient::ensureRootDataLoaded()
{
if (!m_rootBackendsLoaded) loadRootBackends();
if (!m_rootAgentsLoaded) loadRootAgents();
if (!m_rootModelsLoaded) {
QByteArray out = run9p({"read", "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();
QString mo = line.mid(tab + 1).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_activeSessionId.isEmpty() || m_agentId.isEmpty()) return;
const QString srv = serverAddr();
const QString bin = ninepBin();
m_chat->start(bin, {"-a", srv, "read", agentPath() + "/chat"});
m_state->start(bin, {"-a", srv, "read", agentPath() + "/statewait"});
}
void Ollie9pClient::stopAgentStreams()
{
if (m_chat) m_chat->stop();
if (m_state) m_state->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
{
const QString sessionName = sessionNameForId(m_activeSessionId);
const QString agentName = agentNameForId(m_activeSessionId, m_agentId);
return "session/" + (sessionName.isEmpty() ? m_activeSessionId : sessionName)
+ "/agent/" + (agentName.isEmpty() ? m_agentId : agentName);
}
QByteArray Ollie9pClient::run9p(const QStringList &args)
{
QProcess proc;
QStringList fullArgs = {"-a", serverAddr()};
fullArgs.append(args);
proc.start(ninepBin(), fullArgs);
// Drain output while waiting. The log file can be up to 64KB; if the
// parent doesn't read, the child blocks on a full pipe and never exits,
// causing "QProcess: Destroyed while process is still running".
QByteArray out;
while (proc.state() != QProcess::NotRunning) {
if (proc.waitForReadyRead(100))
out += proc.readAllStandardOutput();
}
out += proc.readAllStandardOutput();
return out;
}