848 lines
28 KiB
C++
848 lines
28 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);
|
|
// Initialize native 9P client for fast operations
|
|
if (!m_9p) {
|
|
m_9p = new Lib9pClient(this);
|
|
}
|
|
if (!m_9p->isConnected()) {
|
|
m_9p->connectDefault();
|
|
}
|
|
refreshSessions();
|
|
ensureRootDataLoaded();
|
|
startAgentConnections();
|
|
});
|
|
connect(m_daemon, &NinePConnection::disconnected, this, [this]() {
|
|
setDaemonConnected(false);
|
|
if (m_9p) {
|
|
m_9p->disconnect();
|
|
}
|
|
stopAgentConnections();
|
|
});
|
|
connect(m_daemon, &NinePConnection::readyRead, this, [this](const QByteArray &data) {
|
|
handleEvent(QString::fromUtf8(data).trimmed());
|
|
});
|
|
|
|
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::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;
|
|
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 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";
|
|
}
|
|
|
|
void Ollie9pClient::setAgentConnected(const QString &key, bool connected)
|
|
{
|
|
const bool cached = m_agentConnected.value(key, false);
|
|
m_agentConnected[key] = connected;
|
|
bool modelChanged = false;
|
|
bool foundInModel = 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) {
|
|
foundInModel = true;
|
|
if (agent.value("connected").toBool() != connected)
|
|
modelChanged = true;
|
|
agent["connected"] = connected;
|
|
}
|
|
agentValue = agent;
|
|
}
|
|
session["agents"] = agents;
|
|
value = session;
|
|
}
|
|
// Ignore stale disconnects for agents no longer in the model (killed).
|
|
if (!foundInModel && !connected)
|
|
return;
|
|
if (cached != connected || modelChanged)
|
|
emit sessionsChanged();
|
|
}
|
|
|
|
void Ollie9pClient::startAgentConnections()
|
|
{
|
|
// Agent connection state now comes from session/idx (refreshSessions)
|
|
// and eventwait events. No per-agent processes needed.
|
|
// Just start streams for the active agent if we have one.
|
|
if (!m_activeSessionId.isEmpty() && !m_agentId.isEmpty()) {
|
|
startActiveAgentStreams();
|
|
}
|
|
}
|
|
|
|
// reconcileAgentConnections updates agent connected state from m_sessions.
|
|
// Connection state is derived from session.connected in session/idx.
|
|
void Ollie9pClient::reconcileAgentConnections()
|
|
{
|
|
if (!m_daemonConnected) return;
|
|
|
|
// Update connected state for all agents based on session data
|
|
for (const QVariant &value : std::as_const(m_sessions)) {
|
|
const QVariantMap session = value.toMap();
|
|
const QString sid = session.value("id").toString();
|
|
const bool isConnected = session.value("connected").toBool();
|
|
|
|
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);
|
|
|
|
bool wasConnected = m_agentConnected.value(key, false);
|
|
m_agentConnected[key] = isConnected;
|
|
|
|
// Emit signal if this is the active agent and connection state changed
|
|
if (key == agentKey(m_activeSessionId, m_agentId)) {
|
|
if (!wasConnected && isConnected) {
|
|
emit sessionConnected();
|
|
startActiveAgentStreams();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void Ollie9pClient::stopAgentConnections()
|
|
{
|
|
// No per-agent connections to stop anymore
|
|
m_agentConnected.clear();
|
|
}
|
|
|
|
// handleEvent processes a single event from eventwait.
|
|
// Event format: "topic [payload]"
|
|
// Topics: session.{sid}.{action}, session.{sid}.agent.{aid}.{action}
|
|
void Ollie9pClient::handleEvent(const QString &eventLine)
|
|
{
|
|
if (eventLine.isEmpty()) return;
|
|
|
|
// Split into topic and payload
|
|
int spaceIdx = eventLine.indexOf(' ');
|
|
QString topic = spaceIdx > 0 ? eventLine.left(spaceIdx) : eventLine;
|
|
QString payload = spaceIdx > 0 ? eventLine.mid(spaceIdx + 1) : QString();
|
|
|
|
QStringList parts = topic.split('.');
|
|
// Expected: session.{sid}.* or session.{sid}.agent.{aid}.*
|
|
|
|
if (parts.size() >= 3 && parts[0] == "session") {
|
|
QString sessionId = parts[1];
|
|
QString action = parts[2];
|
|
|
|
if (action == "agent" && parts.size() >= 5) {
|
|
// Agent event: session.{sid}.agent.{aid}.{action}
|
|
QString agentId = parts[3];
|
|
QString agentAction = parts[4];
|
|
QString key = agentKey(sessionId, agentId);
|
|
|
|
if (agentAction == "state") {
|
|
// Update cached state
|
|
if (!payload.isEmpty() && m_agentStateValues.value(key) != payload) {
|
|
m_agentStateValues[key] = payload;
|
|
emit agentStateChanged(sessionId, agentId, payload);
|
|
// Also update activeState if this is the active agent
|
|
if (sessionId == m_activeSessionId && agentId == m_agentId) {
|
|
m_activeState = payload;
|
|
emit activeStateChanged();
|
|
}
|
|
}
|
|
} else if (agentAction == "new" || agentAction == "kill") {
|
|
// Structural change — need full refresh
|
|
refreshSessions();
|
|
}
|
|
} else if (action == "pause" || action == "resume" ||
|
|
action == "new" || action == "kill" || action == "rename") {
|
|
// Session-level structural change — need full refresh
|
|
refreshSessions();
|
|
}
|
|
}
|
|
}
|
|
|
|
QString Ollie9pClient::agentState(const QString &sessionId, const QString &agentId) const
|
|
{
|
|
return m_agentStateValues.value(agentKey(sessionId, agentId), "idle");
|
|
}
|
|
|
|
void Ollie9pClient::refreshSessions()
|
|
{
|
|
if (!m_daemonConnected) return;
|
|
|
|
// Use native 9P client if available (fast path)
|
|
QByteArray out;
|
|
if (m_9p && m_9p->isConnected()) {
|
|
out = m_9p->read(QStringLiteral("session/idx"));
|
|
} else {
|
|
out = run9p({"read", "session/idx"});
|
|
}
|
|
QString raw = QString::fromUtf8(out);
|
|
|
|
m_sessions.clear();
|
|
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;
|
|
|
|
QVariantMap session;
|
|
session["name"] = parts.value(0);
|
|
session["id"] = parts.value(1);
|
|
session["paused"] = (parts.value(2) == "true");
|
|
session["connected"] = (parts.value(3) == "true");
|
|
session["cwd"] = parts.value(4);
|
|
session["backend"] = parts.value(5);
|
|
session["model"] = parts.value(6);
|
|
|
|
// Parse agents from semicolon-separated list
|
|
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) {
|
|
QVariantMap av;
|
|
av["name"] = agentParts.value(0);
|
|
av["id"] = agentParts.value(1);
|
|
av["state"] = agentParts.value(2);
|
|
av["connected"] = session["connected"]; // Agent inherits session connectivity
|
|
agents.append(av);
|
|
}
|
|
}
|
|
}
|
|
session["agents"] = agents;
|
|
m_sessions.append(session);
|
|
}
|
|
}
|
|
reconcileAgentConnections();
|
|
emit sessionsChanged();
|
|
|
|
// Check if the currently active session/agent still exist
|
|
if (!m_activeSessionId.isEmpty()) {
|
|
bool sessionFound = false;
|
|
bool agentFound = false;
|
|
for (const QVariant &v : std::as_const(m_sessions)) {
|
|
const QVariantMap session = v.toMap();
|
|
if (session.value("id").toString() == m_activeSessionId) {
|
|
sessionFound = true;
|
|
if (!m_agentId.isEmpty()) {
|
|
for (const QVariant &av : session.value("agents").toList()) {
|
|
if (av.toMap().value("id").toString() == m_agentId) {
|
|
agentFound = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (!sessionFound) {
|
|
// 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() && !agentFound) {
|
|
// Agent was killed but session still exists — clear agent only
|
|
stopStreams();
|
|
m_agentId.clear();
|
|
m_activeState = "idle";
|
|
emit activeAgentIdChanged();
|
|
emit activeStateChanged();
|
|
}
|
|
}
|
|
|
|
// 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 agent of first session
|
|
const QString aid = agents.first().toMap().value("id").toString();
|
|
switchAgent(sid, aid);
|
|
} else {
|
|
// No agents - just select the session
|
|
setActiveSessionId(sid);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {};
|
|
// Use immutable IDs — the 9P namespace resolves them via aliases.
|
|
QByteArray out = run9p({"read", "session/" + sessionId + "/agent/" + agentId + "/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 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";
|
|
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::pauseSession(const QString &sessionId)
|
|
{
|
|
if (sessionId.isEmpty()) return false;
|
|
QString path = "session/" + sessionId + "/ctl";
|
|
QProcess proc;
|
|
proc.start(ninepBin(), {"-a", serverAddr(), "write", path});
|
|
proc.waitForStarted(3000);
|
|
proc.write("pause");
|
|
proc.closeWriteChannel();
|
|
proc.waitForFinished(3000);
|
|
if (proc.exitCode() == 0) {
|
|
refreshSessions();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Ollie9pClient::resumeSession(const QString &sessionId)
|
|
{
|
|
if (sessionId.isEmpty()) return false;
|
|
QString path = "session/" + sessionId + "/ctl";
|
|
QProcess proc;
|
|
proc.start(ninepBin(), {"-a", serverAddr(), "write", path});
|
|
proc.waitForStarted(3000);
|
|
proc.write("resume");
|
|
proc.closeWriteChannel();
|
|
proc.waitForFinished(3000);
|
|
if (proc.exitCode() == 0) {
|
|
refreshSessions();
|
|
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 {};
|
|
QByteArray out = run9p({"read", agentPath() + "/cfg"});
|
|
return QString::fromUtf8(out);
|
|
}
|
|
|
|
QStringList Ollie9pClient::getAgents(const QString &sessionId)
|
|
{
|
|
if (sessionId.isEmpty()) return {};
|
|
// Use immutable ID — the 9P namespace resolves it via alias.
|
|
QByteArray out = run9p({"ls", "session/" + sessionId + "/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;
|
|
|
|
// Use cached state from live tracking if available, otherwise default to idle
|
|
m_activeState = m_agentStateValues.value(agentKey(sessionId, agentId), "idle");
|
|
|
|
emit activeSessionIdChanged();
|
|
emit activeAgentIdChanged();
|
|
emit activeStateChanged();
|
|
|
|
startActiveAgentStreams();
|
|
}
|
|
|
|
void Ollie9pClient::loadRootBackends()
|
|
{
|
|
if (m_rootBackendsLoaded) return;
|
|
QByteArray out;
|
|
if (m_9p && m_9p->isConnected()) {
|
|
out = m_9p->read(QStringLiteral("backends"));
|
|
} else {
|
|
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;
|
|
if (m_9p && m_9p->isConnected()) {
|
|
out = m_9p->read(QStringLiteral("agents"));
|
|
} else {
|
|
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::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;
|
|
}
|
|
|
|
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");
|
|
|
|
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;
|
|
// Use immutable ID — the 9P namespace resolves it via alias.
|
|
proc.start(ninepBin(), {"-a", serverAddr(), "rdwr", "session/" + sessionId + "/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();
|
|
if (err.isEmpty())
|
|
err = QStringLiteral("agent creation failed");
|
|
qDebug() << "createAgent failed:" << err;
|
|
return err;
|
|
}
|
|
return QString();
|
|
}
|
|
|
|
bool Ollie9pClient::killAgent(const QString &sessionId, const QString &agentId)
|
|
{
|
|
if (sessionId.isEmpty() || agentId.isEmpty()) return false;
|
|
|
|
// Use immutable IDs — the 9P namespace resolves them via aliases.
|
|
QString path = "session/" + sessionId + "/agent/" + agentId + "/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. Writing the name file triggers
|
|
// the rename. Use the immutable ID to address the session.
|
|
QProcess proc;
|
|
proc.setProgram(bin);
|
|
proc.setArguments({"-a", serverAddr(), "write", "session/" + sessionId + "/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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
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
|
|
// using immutable IDs to address the path.
|
|
QProcess proc;
|
|
proc.setProgram(bin);
|
|
proc.setArguments({"-a", serverAddr(), "write",
|
|
"session/" + sessionId + "/agent/" + agentId + "/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;
|
|
}
|
|
|
|
// With immutable-ID paths, renaming no longer invalidates streams.
|
|
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
|
|
{
|
|
// Use immutable IDs — the 9P namespace resolves them via aliases.
|
|
return "session/" + m_activeSessionId + "/agent/" + m_agentId;
|
|
}
|
|
|
|
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;
|
|
}
|