#include "ollie9pclient.h" #include #include #include #include #include #include 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::agentKey(const QString &sessionId, const QString &agentId) const { return sessionId + "\n" + agentId; } QList Ollie9pClient::readAgentRecords(const QString &sessionId) { QList result; if (sessionId.isEmpty()) return result; // Use immutable ID — the 9P namespace resolves it via alias. const QString raw = QString::fromUtf8(run9p({"ls", "session/" + sessionId + "/agent"})).trimmed(); for (const QString &name : raw.split('\n', Qt::SkipEmptyParts)) { if (name == "new") continue; const QString path = "session/" + sessionId + "/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(); } // reconcileAgentConnections ensures one NinePConnection per known agent. // Connections use immutable session/agent IDs in their 9P paths. The server // namespace lists directories by mutable display name, but accepts the // immutable UUID as an alias during path walks. This means connection paths // remain valid across session/agent renames without reconnection. void Ollie9pClient::reconcileAgentConnections() { if (!m_daemonConnected) return; QSet wanted; for (const QVariant &value : std::as_const(m_sessions)) { const QVariantMap session = value.toMap(); const QString sid = session.value("id").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); }); // Use immutable IDs in path — aliases in the 9P namespace resolve // these to the correct session/agent regardless of display name. connection->start(ollie9pBin(), {"-a", serverAddr(), "read", "--open-marker", "session/" + sid + "/agent/" + aid + "/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); m_sessions.clear(); QSet seen; 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 format: name\tstate\tcwd\tbackend\tmodel\tagentName\tid // The immutable ID is in field 6 (0-indexed), avoiding a round-trip. QString sessionId = parts.size() > 6 ? parts[6] : ""; if (sessionId.isEmpty()) 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/" + sessionId + "/agent"})).trimmed().split('\n', Qt::SkipEmptyParts)) { if (name == "new") continue; const QString path = "session/" + sessionId + "/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 {}; // 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 first if (sessionId == m_activeSessionId) { stopStreams(); } // 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; } 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; 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::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; }