Files
Dario Gabriel LipicarandClaude Opus 5 0f0be25959 fix(client): fail at once when the daemon is gone, and stop reporting that as data
Against a session whose daemon is no longer there, `logosctl module ls` waited
22 seconds and then printed `[]` and exited 0. Not "failed slowly" -- reported
success, with an empty module list, about a daemon that did not exist. `stats`
did the same. `call`, `package`, `catalog` and `key` waited the same 20 seconds
before reporting RPC_FAILED. Only `stop` and `status` were quick, because #100
gave them a guard the other fourteen commands never got.

The mechanism is the one #100 diagnosed. A LocalSocket client "connects" to a
socket path with no listener without complaint, QtRO surfaces no transport
error for an absent peer, and the request is therefore neither answered nor
refused -- so it waits out Timeout(20000) (logos-protocol, cpp/logos_mode.h)
and a dead daemon is indistinguishable from a slow one until the deadline
fires. Connecting is not the check it looks like.

A session outlives its daemon in two shapes, and they need different evidence.

CRASHED SESSION. daemon/state.json is still on disk naming a pid that is gone.
This is #100's check, and it was copied into stop_command and status_command.
It now lives in one place -- detectStaleSession(), called from
Command::ensureConnected() -- which is the single door every RPC-opening
command goes through, so all of them inherit it instead of the two that had it
hand-written. StatusCommand still calls the helper itself, one step earlier,
because its answer to "no daemon" is a status report rather than an error.

#100's instance_id gate is preserved exactly: the guard fires only when the
state file describes the daemon THIS client dials. A remote client can have a
co-resident daemon's leftovers sitting in its own session directory, and its
dial spec carries no instance_id at all, so an empty one never matches. The
liveness syscall now runs before the client-config read, so the common path
(daemon running) does not parse client/config.yaml twice per command.

STOPPED SESSION. The tidier way to get here, and the one the pid guard cannot
see: a clean `daemon stop` REMOVES daemon/state.json, leaving client/config.yaml
and the token behind with no pid left to find dead. Every command still waited
the full 20s. RpcClient::connect() now asks the socket instead, before it
builds a LogosAPIClient (localEndpointProvablyAbsent, src/local_endpoint.h):
the dial resolves to QDir::tempPath()/logos_core_service_<instance_id>, because
the SDK asks for the bare name (LogosInstance::id) and Qt resolves a bare
QLocalSocket/QLocalServer name against the temp dir. Deriving it the same way
is what makes the answer sound rather than a guess.

A stat alone is NOT enough, which cost this patch a wrong first draft. The
socket file outlives the daemon: a hard kill leaves it, and a clean stop leaves
it for the window between the shutdown reply and QLocalServer's destructor --
which is exactly when the next command gets typed. Measured through the new
CLI sweep, stat-only vs stat-plus-connect over the same abandoned socket: 85.3s
(every command timed out) vs 0.8s. So presence settles nothing and being
REFUSED does; ECONNREFUSED is the same signal logos::isSocketDead uses to
decide a socket is safe for the daemon's boot reaper to unlink. That function
is not reused directly only because it sits behind the logos-protocol link,
which logosctl_testlib deliberately does without.

The check fails closed on everything short of proof: a socket that accepts us,
any other connect() error, a path too long for sun_path, a non-socket inode, a
tcp/tcp_ssl dial, an empty instance_id, Windows (named pipes, no inode).
Refusing a reachable daemon would be far worse than the wait being removed.

AN UNANSWERED QUERY IS NOT AN EMPTY ONE. The exit-0 half is a separate defect
and survives independently of the timing: listModules() and getModuleStats()
answered a failed RPC with LogosList::array(), the only two calls in the client
that reported failure as data. Both now return optional<LogosList>, and the
commands report DAEMON_UNREACHABLE with exit 2. `status` had the same shape by
a different route -- RpcClient::getStatus synthesises a not_running report and
marks it `rpc_error`, and that report has a "daemon" key, so it reached the
success branch and exited 0 while printing "not running". It exits 1 now, as
docs/project.md always said it did.

`status` also connects directly rather than through ensureConnected(): that
helper PRINTS a NO_DAEMON envelope, and letting it do so put two JSON documents
on stdout for one command, which no `jq` invocation survives.

Nothing opts out of the guard. `watch` is the one command with a case for
waiting -- a daemon that has not started yet is a reasonable thing to watch for
-- but it does no waiting today: it connects once and gives up, so failing in
milliseconds is what it already meant to do. The four commands the issue listed
that are NOT covered (`token issue|revoke|list`, `daemon|client config`) never
call ensureConnected at all: they read and write the session's own files and
have no daemon to be absent.

TESTS.
  * CLITest.{Crashed,CleanlyStopped}Session_EveryRpcCommandFailsAtOnce and
    SocketLeftOverWithNoListener_EveryRpcCommandFailsAtOnce: all 17 commands
    against all three shapes, end-to-end, killed at 5s so exit 124 means the
    command was still waiting. Driven against the pre-fix binary via
    $LOGOSCTL_BINARY these fail with 124 on 15 of 17 commands, 80.3s.
  * CLITest.*_StatusReportsNotRunningAtOnce: exit 1, names the pid where there
    is one, and exactly one JSON document.
  * CommandTest.EveryRpcCommand_*: the 17 commands x 4 session shapes, against
    a mock, asserting on connectAttempts/rpcCalls -- a guard that fired is
    visible as the ABSENCE of contact. Three of the four shapes are the
    controls: live pid, foreign instance_id, and no state file at all must
    still dial.
  * LocalEndpointTest.*: the path derivation against QDir::tempPath(), plus a
    verdict for each shape the path can be in -- missing, socket with no
    listener, LIVE listener, and a regular file wearing the name.
  * CommandTest.{ListModules,Stats}_{UnansweredRpc,AnsweredWithNothing}_* and
    Status_{UnansweredRpc,LiveDaemon}_*: both sides of the empty-vs-unanswered
    line. CommandTest had no Status_ coverage at all, which is how exit 0
    survived.

Before/after over the shipped binaries, same stale session, macOS:
  module ls   exit 0 after 22s printing []   ->  exit 2 in <1s, names the pid
  stats       exit 0 after 20s printing []   ->  exit 2 in <1s
  status      exit 0 after 20s               ->  exit 1 in <1s
  call/package/catalog/key  20s, RPC_FAILED  ->  exit 2 in <1s
and against a cleanly stopped session, where nothing was fast before, all of
the above are now under a second too.

Live-daemon behaviour is unchanged and checked: 249 unit + 30 CLI + 25
integration tests pass for logosctl and 20 CLI + 24 integration for logoscore
via `nix build .#checks.<sys>.tests-logosctl` / `-logoscore`. The 25
integration tests drive real daemons through logosctl, so a wrong socket path
would fail them loudly rather than silently refusing live sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:33:56 -03:00

70 KiB

Logosctl CLI — Project Description

Overview

The logosctl CLI is a standalone application that provides the command-line interface for the Logos Core runtime. It depends on liblogos, a C library that provides the core runtime (plugin discovery, loading, dependency resolution, event loop). The CLI is responsible for:

  • Running as a daemon that hosts the liblogos runtime
  • Providing client commands that talk to the daemon via RPC

This project will live in its own repository. liblogos is consumed as an external C library dependency.

Project Structure

logos-logoscore-cli/
├── src/                              # All CLI source code
│   ├── main.cpp                      # logosctl entry point — detects mode, dispatches
│   ├── main_legacy.cpp               # logoscore entry point — same daemon/client/core_service
│   │                                 # code below, frozen surface (see the README)
│   ├── config.cpp/h                  # Token + config file resolution
│   ├── paths.cpp/h                   # Executable/bundle-relative path resolution (no Qt)
│   │
│   ├── daemon/                       # Daemon path (logosctl daemon start)
│   │   ├── daemon.cpp/h              # Start core, register core_service, run event loop,
│   │   │                             # open each --module-transport listener
│   │   ├── daemon_state.cpp/h        # DaemonConfig (config.json) + DaemonRuntimeState
│   │   │                             # (state.json) — operator preferences (writes only
│   │   │                             # on --persist-config) + live runtime state.
│   │   ├── access_policy_arg.cpp/h   # Resolve --access-policy into the JSON document
│   │   │                             # handed to logos_core_set_access_policy()
│   │   ├── log_sink.cpp/h            # Pipe-based capture of daemon + module-host
│   │   │                             # stdout/stderr into a rotating log file
│   │   ├── port_allocator.cpp/h      # Reserve an ephemeral TCP port before spawning a child
│   │   └── token_store.cpp/h         # Named-token table — TokensFile owns daemon/tokens.json
│   │                                 # (hashed entries) + raw daemon/tokens/<name>.json
│   │
│   ├── client/                       # Client path (all subcommands)
│   │   ├── client.cpp/h              # Client interface + RpcClient — connect to the
│   │   │                             # daemon's core_service via LogosAPIClient
│   │   ├── client_state.cpp/h        # Read/write <configDir>/client/config.json (dial spec)
│   │   ├── output.cpp/h              # Output formatter (human / JSON / NDJSON)
│   │   └── commands/                 # Subcommand implementations
│   │       ├── command.cpp/h         # Base command class
│   │       ├── status_command.cpp/h
│   │       ├── load_module_command.cpp/h
│   │       ├── unload_module_command.cpp/h
│   │       ├── reload_module_command.cpp/h
│   │       ├── list_modules_command.cpp/h
│   │       ├── module_info_command.cpp/h
│   │       ├── call_command.cpp/h
│   │       ├── watch_command.cpp/h
│   │       ├── stats_command.cpp/h
│   │       ├── stop_command.cpp/h
│   │       ├── package_command.cpp/h        # install / remove / update (plan + apply)
│   │       ├── catalog_command.cpp/h        # Browse + download from the online catalog
│   │       ├── config_command.cpp/h         # Inspect/edit the config tree
│   │       ├── issue_token_command.cpp/h    # Mints named tokens (daemon/tokens/<name>.json)
│   │       ├── revoke_token_command.cpp/h   # Revokes by name
│   │       └── list_tokens_command.cpp/h    # Lists issued tokens (name + metadata, no plaintext)
│   │
│   └── core_service/                 # Built-in module — CLI ↔ daemon RPC gateway
│       ├── core_service_impl.h       # Plain C++ class deriving LogosProviderObject; its
│       │                             # public methods ARE the API (no marker macro — there
│       │                             # used to be LOGOS_PROVIDER/LOGOS_METHOD here)
│       ├── core_service_impl.cpp     # Method implementations (delegates to liblogos C API)
│       ├── package_ops.cpp/h         # Daemon-side plan/apply for package operations
│       ├── metadata.json             # Plugin metadata
│       └── core_service_dispatch.cpp # Hand-written callMethodStd/getMethodsStd dispatch
│                                     # (no core_service_loader.h: the daemon registers the
│                                     # object in-process, it is never discovered as a plugin)
│
├── tests/
│   ├── test_commands.cpp             # Subcommands against a mock Client
│   ├── test_mode_detection.cpp       # Mode detection, subcommand dispatch
│   ├── test_output.cpp               # Output formatter tests
│   ├── test_daemon_state.cpp         # daemon/state.json + tokens round-trip
│   ├── test_token_store.cpp          # Token issue / revoke / list / persistence
│   ├── test_config.cpp               # Token + config-dir resolution
│   ├── test_paths.cpp                # Executable/bundle path resolution
│   ├── test_port_allocator.cpp       # Ephemeral-port allocation
│   ├── test_access_policy_arg.cpp    # --access-policy argument resolution
│   ├── test_log_sink.cpp             # Log capture + rotation
│   ├── test_cli.cpp                  # End-to-end logosctl CLI
│   ├── test_integration.cpp          # logosctl against a live daemon
│   ├── test_cli_logoscore.cpp        # End-to-end logoscore CLI (frozen duplicate)
│   └── test_integration_logoscore.cpp # logoscore against a live daemon (frozen duplicate)
│
├── docs/
│   ├── index.md                      # Doc index
│   ├── spec.md                       # CLI specification (user-facing behavior)
│   ├── project.md                    # This file (implementation details)
│   ├── logoscore.md                  # logoscore user guide
│   └── logosctl.md                   # logosctl user guide
│
├── doctests/                         # Executable documentation specs
├── CMakeLists.txt                    # Build configuration
├── flake.nix                         # Nix flake
└── nix/                              # Nix build modules

Dependencies

Dependency Type Purpose
liblogos C library (external) Core runtime: plugin discovery, loading, dependency resolution, event loop, process stats
logos-cpp-sdk C++ library (external) Qt-free SDK surface the CLI links directly (logos_sdk), incl. logos::transportSetToJsonString
logos-protocol C++ library (external) Transport + provider protocol: LogosProviderObject, ModuleProxy, TokenManager
logos-qt-host (in logos-plugin-qt) C++ library (external) The Qt host runtime the daemon and its in-process core service are built on: LogosAPI, LogosAPIClient, LogosAPIProvider. Comes from logos-plugin-qt, not logos-qt-sdk
Qt6 Core Framework Event loop, JSON handling, process management
Qt6 RemoteObjects Framework IPC between daemon and module host processes
CMake 3.14+ Build system
Google Test Test framework
Nix Package manager Reproducible builds

No code generator is in this build. core_service used to be listed here as depending on logos-cpp-generator to emit a LOGOS_METHOD dispatch table; that marker macro and that generator mode are gone from this repo's path — core_service_dispatch.cpp is hand-written (see Build integration below).

liblogos C API surface used

The CLI uses these functions from liblogos (declared in logos_core.h):

Function Used by
logos_core_init(argc, argv) Daemon
logos_core_add_modules_dir(path) Daemon
logos_core_start() Daemon
logos_core_exec() Daemon
logos_core_cleanup() Daemon
logos_core_load_module(name, true) Daemon, core_service
logos_core_unload_module(name, false) core_service
logos_core_get_known_modules() core_service
logos_core_get_loaded_modules() core_service
logos_core_get_modules_info() core_service
logos_core_get_module_stats() core_service

CLI Execution Paths

The logosctl binary detects its mode from the first argument and dispatches to one of two paths:

logosctl daemon start / daemon         →  Daemon path    (long-running, hosts modules)
logosctl <subcommand>        →  Client path    (short-lived, talks to daemon)

Detection logic (main.cpp)

Before mode detection, main() scans argv for -v/--verbose and installs a custom Qt message handler that suppresses debug/info/warning logs unless verbose is set.

if argv contains "-D" or "daemon"      → daemon path
else if argv[1] is a known subcommand  → client path
else if argv contains -m/-p (no -D)    → error (inline mode removed)
else                                   → print help

Daemon Path (logosctl daemon start)

main.cpp
  → Daemon::start(modulesDirs, persistencePath, transportInfos)
    1. Generate instance ID, set LOGOS_INSTANCE_ID env var
       Refuse to start if a live daemon already owns this config-dir: read
       daemon/state.json and, if its pid is still alive (kill(pid,0)), exit 1.
       A stale state.json from a crashed daemon (pid gone) is not a live
       owner and is overwritten normally. This runs before logos_core_init so
       a duplicate launch fails fast instead of spawning module hosts first.
    2. logos_core_init(argc, argv)
    3. logos_core_add_modules_dir() for each -m path
    4. logos_core_start()                         // discover modules
    5. Register core_service (and capability_module) in-process via
       LogosAPI/LogosAPIProvider. For each --module-transport NAME=PROTOCOL[,k=v]
       flag, pass the resolved TransportInfo to LogosAPIProvider so it opens
       one listener on the named module (local + tcp + tcp_ssl can coexist).
    6. Mint the auto token, hash it into tokens.json["tokens"], emit raw
       value into client/auto.json, register hashes with TokenManager
    7. Write <configDir>/daemon/state.json       // resolved listeners + instance_id
       (Tokens already persisted to daemon/tokens.json by step 6.)
       Write <configDir>/client/config.json      // local-default dial spec (first-boot only)
       If --persist-config: write <configDir>/daemon/config.json (operator intent)
    8. Print startup message to stdout
    9. logos_core_exec()                          // Qt event loop (blocks)
   10. On SIGINT/SIGTERM or shutdown RPC:
       logos_core_cleanup()
       Remove daemon/state.json (tokens.json + config.json survive)
       exit(0)

The daemon path calls the liblogos C API directly. It owns the runtime and hosts all modules, including the built-in core_service module. Startup/shutdown messages go to stdout (so > logs.txt works); debug logs go to stderr and are suppressed unless --verbose is passed.

Multi-transport. --module-transport is repeatable, scoped per module (well-known or user-configured). When the daemon exposes a module over several transports at once, each one becomes an entry under that module's transports array in daemon/state.json, and the provider maintains one listener per entry.

Local is always present. Every configured module — well-known or user — implicitly carries a LocalSocket listener prepended to its resolved set, even when the operator only passed --module-transport NAME=tcp,.... The operator's TCP / TCP+SSL flags add additional outside-facing listeners; they don't replace the same-host LocalSocket. This is what keeps module ↔ module traffic working on the local socket in every configuration: the parent's notifyCapabilityModule handshake, the SDK's auto-requestModule flow inside LogosAPIClient, and any cross-module getClient(name) calls all default to LocalSocket and have no plumbing to discover the operator's chosen TCP endpoint — forcing a LocalSocket listener alongside whatever else the operator named keeps those paths working without fan-out. The advertised transports[] array always lists the LocalSocket entry first, followed by operator-named entries in the order they were typed.

Plaintext-TCP guard. Plaintext tcp listeners on a non-loopback host expose tokens in cleartext. The daemon refuses to bind such a listener unless --insecure-tcp was passed.

IPv6 bind targets. Ephemeral-port allocation (used when a transport asks the kernel to pick a port) binds on the address family that matches the host: an IPv6 literal such as :: or ::1 allocates on AF_INET6, IPv4 literals on AF_INET. (Previously the allocator was IPv4-only and returned 0 for any IPv6 host, aborting daemon startup for IPv6 TCP transports.)

Strict port parsing. A --module-transport ...,port=<n> value must be a whole valid integer — trailing garbage (6000x) or hex (0x1F90) is a hard error (exit 1), not a silently-wrong or auto-allocated 0 port.

Named tokens. TokenStore (owned by the daemon) persists the issued-token table inside <configDir>/daemon/tokens.json["tokens"] ({name, hash, issued_at, expires_at, local_only} rows; hashes are SHA-256 hex). The auto token's hash lands there too at boot, with its raw value emitted to client/auto.json. Named tokens from issue-token --name <n> are additionally written to daemon/tokens/<n>.json for distribution to a specific client; once copied to the target host, the daemon-side raw file may be deleted because validation runs against the in-memory map seeded from the hashes.

Client Path (logosctl <subcommand>)

main.cpp
  → Client::connect()
    1. Read <configDir>/client/config.json (dial spec + instance_id + token_file)
    2. Set LOGOS_INSTANCE_ID env var from instance_id
       → now LogosInstance::id("core_service") returns the correct registry URL
    3. Read the raw token from token_file (or LOGOSCTL_TOKEN env var if set)
    4. Build LogosTransportConfig from the dial spec (endpoint/host/port/codec
       and cert/key/ca/verify_peer for TLS) — applied per-connection only,
       never installed as a process-wide default (the SDK's LogosAPIProvider
       reads the global default to bind its own server socket, so flipping
       the default would try to bind a TLS server with no cert/key and abort)
    5. Create LogosAPIClient targeting "core_service" with that explicit
       transport config (LogosAPI itself stays on the local-socket default)
    6. Authenticate with token
  → Command::execute(args)
    1. Call a core_service method by name via LogosAPIClient
    2. Format result (human / JSON)
    3. Print to stdout, exit

Client commands never call liblogos C API functions. They talk exclusively to the daemon's core_service module via the SDK's RPC mechanism, using whatever dial spec client/config.json provides. This means the client path depends only on logos-cpp-sdk, not on liblogos.

Liveness is not a general pre-check — a PID probe is meaningless for a daemon in a container or across NAT, so the first RPC is what surfaces a connect failure, through the same timeout/error path as any other method.

The one exception is the case where that story breaks down: a session directory whose own daemon is gone. Command::ensureConnected() calls detectStaleSession() first, and refuses (NO_DAEMON, exit 2) when daemon/state.json names this client's instance_id and a pid that kill(pid, 0) says is gone. Every RPC-opening command inherits it, because they all reach the wire through ensureConnected().

That is worth a disk read because connecting proves nothing: a LocalSocket client succeeds against a socket path with no listener, QtRO reports nothing for an absent peer, and the request is therefore neither answered nor refused — the command waits out Timeout(20000) (logos-protocol, cpp/logos_mode.h) and only then reports a failure state.json could have named at once.

The instance_id gate is what keeps this local-only check safe for remote clients: a co-resident daemon's leftover state.json describes someone else's process, and a remote dial spec carries no instance_id at all, so the guard stays silent and the command dials normally. Same for a session with no state.json. DaemonRuntimeStateFile::read().fileOk still means only "file exists and parses" — the guard pairs it with the pid probe and the id match.

The tidier way to end up with no daemon is a clean daemon stop, and the pid guard cannot see it: that path removes daemon/state.json, leaving client/config.yaml and the token behind with no pid to check. So RpcClient::connect() asks the socket instead, via logosctl::localEndpointProvablyAbsent (src/local_endpoint.h), before it builds a LogosAPIClient:

  1. The dial resolves to QDir::tempPath()/logos_core_service_<instance_id> — the SDK asks for the bare name (LogosInstance::id) and Qt resolves a bare QLocalSocket/QLocalServer name against the temp dir. Deriving it the same way is what makes the answer sound rather than a guess.
  2. No file there ⇒ nobody home. A clean shutdown unlinks it.
  3. File there but connect() is refused ⇒ nobody home. The file outlives the daemon: a hard kill leaves it, and a clean stop leaves a window between the shutdown reply and QLocalServer's destructor — which is exactly when the next command gets typed. Presence alone settles nothing, which is why a stat is not enough. ECONNREFUSED is the same signal logos::isSocketDead uses to decide a socket is safe for the daemon's boot reaper to unlink.

Everything else — a socket that accepts us, any other connect() error, a path too long for sun_path, a non-socket inode, Windows (named pipes), a tcp/tcp_ssl dial, an empty instance_id — fails closed and dials normally. Refusing a reachable daemon would be far worse than the wait this removes.

Inline Path (removed)

The legacy inline path (logosctl -m -l -c "module.method(args)" --quit-on-finish) started the core in the same short-lived process, loaded modules, executed the -c calls directly via the C API, and exited. It has been removed — use a daemon (-D) plus load-module / call client subcommands instead. The daemon starts clean; -m/--persistence-path configure daemon startup only (the -l/--load-modules autoload flag was also removed).


CoreService Module

The core_service module is the RPC gateway between CLI clients and the daemon. It is a proper Logos module — it implements the same LogosProviderObject interface the runtime calls on every module — but it lives in the CLI codebase (not in liblogos) because it is the CLI's concern — it exists to serve CLI clients.

Its business methods are Qt-free: they take and return std::string / LogosMap / LogosList / StdLogosResult, and the Qt side of LogosProviderObject is satisfied by trivial delegates in core_service_dispatch.cpp. Its plain public methods are its API — see Definition below for what replaced the old marker-macro spelling.

Why a module?

  • Uses the same SDK API as any other module — no special plumbing
  • CLI clients connect to it via LogosAPIClient, same as module-to-module communication
  • Auth tokens work the same way (TokenManager validates the client token)
  • Events can be forwarded using the standard event system
  • If needed in the future, it could be extracted into a standalone plugin

Definition

Files: src/core_service/core_service_impl.h

#include <logos_provider_object.h>

class CoreServiceImpl : public LogosProviderObject
{
public:
    // Emitted events go out through this hook, installed by
    // setEventListenerStd() (see core_service_dispatch.cpp).
    std::function<void(const std::string& eventName,
                       const std::string& data)> emitEvent;

    // Module lifecycle
    StdLogosResult loadModule(const std::string& name);
    // withDependents cascades the unload to dependents, leaves-first.
    StdLogosResult unloadModule(const std::string& name, bool withDependents);
    StdLogosResult reloadModule(const std::string& name);

    // Re-scan the module directories so packages installed since boot
    // become discoverable without restarting the daemon.
    LogosMap refreshModules();

    // Package operations, split plan/apply so the client can prompt.
    LogosMap planPackageOperation(const std::string& op,
                                  const LogosList& names, const LogosMap& opts);
    LogosMap applyPackageOperation(const std::string& op,
                                   const LogosList& names, const LogosMap& opts);
    LogosMap downloadPackage(const std::string& name, const LogosMap& opts);

    // Queries
    LogosList listModules(const std::string& filter);
    LogosMap  getStatus();
    LogosMap  getModuleInfo(const std::string& name);
    LogosList getModuleStats();

    // Proxied call — delegates to target module
    StdLogosResult callModuleMethod(const std::string& module,
                                    const std::string& method,
                                    const LogosList& args);

    // Event forwarding
    bool watchModuleEvents(const std::string& module,
                           const std::string& eventName);

    // Daemon lifecycle
    LogosMap shutdown();

    void onInit(LogosAPI* api);

    // LogosProviderObject — Qt side (trivial delegates to the std bridge)
    QVariant   callMethod(const QString& methodName, const QVariantList& args) override;
    QJsonArray getMethods() override;
    QString    providerName() const override;
    QString    providerVersion() const override;
    void       setEventListener(EventCallback callback) override;
    bool       informModuleToken(const QString& moduleName, const QString& token) override;
    void       init(void* apiInstance) override;

    // LogosProviderObject — universal (Qt-free) dispatch
    nlohmann::json callMethodStd(const std::string& methodName,
                                 const nlohmann::json& args) override;
    std::vector<LogosMethodMetadata> getMethodsStd() override;
    void setEventListenerStd(UniversalEventCallback callback) override;

private:
    EventCallback m_eventCallback;
    LogosAPI* m_api = nullptr;
};

There is no marker macro on these declarations. (There used to be a LOGOS_PROVIDER(...) line and a LOGOS_METHOD prefix on each callable method, scanned by a code generator; neither is used here any more.)

How each method works

Method What it does (daemon-side)
loadModule(name) Calls logos_core_load_module(name, true). Returns {"status":"ok","module":"...","version":"...","dependencies_loaded":[...]}
unloadModule(name, withDependents) Calls logos_core_unload_module(name, withDependents). With withDependents (the CLI default) liblogos cascades the unload to every module that depends on name, leaves-first, so nothing is left talking to a dead provider. Returns {"status":"ok","module":"...","dependents_unloaded":[...]}
refreshModules() Re-scans the daemon's module directories so a package installed since boot becomes loadable without a restart — this is what lets install be followed by load in one session
planPackageOperation(op, names, opts) / applyPackageOperation(op, names, opts) Daemon-side plan/apply for install / remove / update (see src/core_service/package_ops.h). The split exists so the client can show what would change and prompt; --dry-run stops after the plan
downloadPackage(name, opts) Fetches a .lgx without installing it. Daemon-side because the downloader drops the file in the daemon's $TMPDIR, so the move to the requested directory has to happen on that host
reloadModule(name) Checks if loaded/crashed → unload if needed → load. Returns result with previous_status. Non-destructive on failure: if the module was loaded before and the reload's load step fails, it attempts to restore the prior instance and reports restored: true/false plus an explanatory error rather than leaving the module down
listModules(filter) Calls logos_core_get_modules_info() (name + loaded flag + embedded metadata per module). Emits version from metadata + status enum. Returns JSON array
getStatus() Reads daemon state (PID, uptime, version) + calls listModules("all"). Returns {"daemon":{...},"modules_summary":{...},"modules":[...]}
getModuleInfo(name) Pulls the module's entry from logos_core_get_modules_info() (version from embedded metadata, dependencies, dependents) and, for loaded modules, methods/events via SDK introspection over RPC. Returns extended JSON
getModuleStats() Calls logos_core_get_module_stats(). Returns CPU/memory per module
callModuleMethod(module, method, args) Uses m_api->getClient(module)->invokeRemoteMethod() to proxy the call to the target module. Returns the result. LogosResult return values are unpacked into {success, value, error} here so that the JSON shape is identical regardless of whether the daemon-module hop went over the local socket (QRO) or the plain-C++ transport (tcp / tcp_ssl).
watchModuleEvents(module, event) Registers an event listener on the target module via m_api->getClient(module)->onEvent(). Forwards received events by calling emitEvent() on core_service, which the CLI client receives over its own event subscription
shutdown() Schedules QCoreApplication::quit() after a 200ms delay (to allow the RPC response to be sent), then the daemon performs its normal cleanup (unload modules, remove daemon/state.json, exit)

Loader — there isn't one

core_service is not discovered as a plugin, so it has no Q_PLUGIN_METADATA loader class. (This section used to document a src/core_service/core_service_loader.h holding a CoreServiceLoader : QObject, PluginInterface, LogosProviderPlugin with a createProviderObject() factory. That file no longer exists.) The daemon constructs CoreServiceImpl itself and hands it to the provider — see Registration (daemon-side) below.

Metadata

Files: src/core_service/metadata.json

Declarative identity only — nothing in the build reads it, because there is no plugin to attach it to. CoreServiceImpl::name() / ::version() return the same values in code.

{
  "name": "core_service",
  "version": "1.0.0",
  "type": "core",
  "category": "management",
  "description": "RPC gateway for CLI client commands"
}

Registration (daemon-side)

The daemon registers core_service as an in-process module during startup, before entering the event loop:

// In Daemon::start()
auto* coreServiceApi = new LogosAPI("core_service", coreTransports);
auto* coreServiceImpl = new CoreServiceImpl();
coreServiceImpl->init(coreServiceApi);

auto* provider = coreServiceApi->getProvider();
// Accept operator-issued named tokens, not just the boot `auto` token.
// Installed before registerObject so the proxy is validated from its
// first published call.
provider->setTokenValidator(...);
provider->registerObject("core_service", static_cast<LogosProviderObject*>(coreServiceImpl));

This registers the module directly into the runtime using the Qt host classes (LogosAPI, LogosAPIProvider) without directory scanning. The daemon also saves a client token via TokenManager::instance().saveToken("cli_client", token) so CLI clients can authenticate.

Build integration

The core_service_dispatch.cpp file provides a hand-written callMethodStd() dispatch table and getMethodsStd() metadata for CoreServiceImpl, plus the trivial Qt-side delegates (callMethod, getMethods, setEventListener, …) that bridge to them. Dynamically loaded modules get this glue generated for them from their header; core_service is written by hand because it is statically linked into the daemon binary and never goes through a module build.

The dispatch wraps argument coercion in a try/catch: a malformed RPC (e.g. a number where a string arg is expected, which makes args[i].get<std::string>() throw nlohmann::json::type_error) is converted into a structured {status:"error", code:"INVALID_ARGS", message:...} response instead of an uncaught exception that would propagate through the Qt event loop and terminate the whole daemon. This keeps one authenticated client from crashing the daemon with a single bad argument.

// core_service_dispatch.cpp — maps method names to CoreServiceImpl methods
nlohmann::json CoreServiceImpl::callMethodStd(const std::string& methodName,
                                              const nlohmann::json& args) {
  try {
    if (methodName == "loadModule" && args.size() >= 1)
        return stdLogosResultToJson(loadModule(args[0].get<std::string>()));
    if (methodName == "shutdown") return shutdown();
    // ... etc
    return nullptr;
  } catch (const std::exception& e) { /* -> INVALID_ARGS envelope */ }
}

Components

main.cpp (entry point)

Files: src/main.cpp

Purpose: Detect execution mode and dispatch to the appropriate path.

API:

Function Description
detectMode(argc, argv) -> Mode Returns Daemon or Client
main(argc, argv) -> int Dispatch to Daemon::start or a Client command

Daemon

Files: src/daemon/daemon.cpp/h

Purpose: Manage the daemon lifecycle: start liblogos, register core_service, write the daemon state file, emit the local-default client config, handle signals for clean shutdown.

API:

Method Description
Daemon::start(modulesDirs) -> int Init liblogos, register core_service, write daemon/state.json + emit client/config.json and client/auto.json, run event loop
Daemon::setupSignalHandlers() Handle SIGINT/SIGTERM for clean shutdown

DaemonConfigFile + DaemonRuntimeStateFile

Files: src/daemon/daemon_state.cpp/h

Purpose: Manage the two daemon-side config-tree files. DaemonConfigFile reads/writes <configDir>/daemon/config.json (operator preferences, written only when --persist-config is passed). DaemonRuntimeStateFile writes <configDir>/daemon/state.json on every successful boot and removes it at shutdown. Both files are daemon-owned; the client never reads config.json, and only consults state.json for a fast same-host liveness check.

API:

Method Description
DaemonConfigFile::read() -> optional<DaemonConfig> Parse daemon/config.json. Returns nullopt if missing or schema-mismatched.
DaemonConfigFile::write(cfg) Atomic write of operator-intent values. port: 0 stays 0 (resolved values live in state.json).
DaemonRuntimeStateFile::write(state) Atomic write of resolved live state (instance_id, pid, started_at, resolved.modules with actually-bound ports). The temp file staged before the atomic rename is per-writer-unique (<path>.tmp.<pid>.<seq>) so concurrent writers can't truncate/rename the same temp and corrupt the result.
DaemonRuntimeStateFile::read() -> DaemonRuntimeState Parse state.json. fileOk is true iff the file exists with a non-empty instance_id — says nothing about liveness; pair with kill(pid, 0) for that.
DaemonRuntimeStateFile::remove() Remove state.json. Called from clean shutdown / aboutToQuit hook.

state.json format (lifecycle: created at boot, removed at shutdown):

{
  "version": 2,
  "instance_id": "a3f1c8d20b4e",
  "pid": 12345,
  "started_at": "2026-03-23T14:00:00Z",
  "config_source": "cli",
  "resolved": {
    "modules_dirs": ["/path/to/modules"],
    "persistence_path": "/var/lib/logosctl",
    "modules": {
      "core_service": {
        "transports": [
          { "protocol": "local" },
          { "protocol": "tcp",     "host": "0.0.0.0", "port": 6000, "codec": "json" },
          { "protocol": "tcp_ssl", "host": "0.0.0.0", "port": 6443,
            "codec": "cbor", "ca_file": "/etc/logosctl/ca.pem",
            "verify_peer": true }
        ]
      },
      "capability_module": {
        "transports": [
          { "protocol": "local" },
          { "protocol": "tcp", "host": "127.0.0.1", "port": 6001, "codec": "json" }
        ]
      }
    },
    "ssl": { "cert": "", "key": "", "ca": "" },
    "insecure_tcp": false
  }
}

config.json format (lifecycle: written only on --persist-config): same as state.json's resolved block + version. Reflects operator intent (port: 0 stays 0).

tokens.json format (lifecycle: independent — survives daemon restarts):

{
  "version": 2,
  "tokens": [
    { "name": "auto",  "hash": "<sha256-hex>", "issued_at": "...", "expires_at": null, "local_only": true },
    { "name": "alice", "hash": "<sha256-hex>", "issued_at": "...", "expires_at": "...", "local_only": false }
  ]
}

TokenStore

Files: src/daemon/token_store.cpp/h

Purpose: In-memory map of issued client tokens, persisted into the daemon state file. Plaintext tokens are never stored on the daemon side — only SHA-256 hashes in tokens.json["tokens"]. The raw value of each named token lives only in daemon/tokens/<name>.json at the moment of issuance, for the operator to copy off.

API:

Method Description
TokenStore() Default-constructed; paths come from Config::* (the process-global config dir). Seeds itself from tokens.json["tokens"]. Tests isolate state via LOGOSCTL_CONFIG_DIR / Config::setConfigDir.
issueToken(name, expires, localOnly, replace) -> IssueResult { status, token } Mint a new token. status is one of Ok / InvalidName / AlreadyExists / IoError; token is set only on Ok. The CLI keys exit-code distinct error categories off this status so operators don't see "name collision" for permission failures. Fails closed rather than corrupting state: a CSPRNG failure (empty raw token) returns IoError and persists nothing; an on-disk tokens.json whose schema version this build doesn't support returns IoError and is left byte-for-byte intact (instead of being rewritten at the current version, wiping operator tokens). On --replace the new raw token is staged to daemon/tokens/<name>.json.new and promoted only after tokens.json commits, so a failed write never destroys the still-valid prior raw token.
revokeToken(name) -> RevokeStatus Remove the name entry from tokens.json["tokens"] and delete daemon/tokens/<name>.json. Returns Ok / InvalidName / NotFound / IoError. Like issueToken, refuses (IoError) to rewrite an unsupported-schema-version tokens.json.
listTokens() -> vector<IssuedToken> Enumerate {name, issued_at, expires_at, local_only} — never plaintext, never the digest.
lookupByToken(token) -> optional<Entry> Daemon-side: validate an incoming token against the in-memory digest map (also enforces expires_at and local_only). Fails closed on an empty token — hashToken("") is a fixed digest, so an empty credential is rejected before consulting the store and can never match a corrupt empty-hash entry.

The on-disk digest is a SHA-256 hex string — collision-resistant by design so two distinct tokens can never validate to the same name. The only place the raw token ever lives is in daemon/tokens/<name>.json at the moment of issuance; treat that file like a private key. After the operator copies it to the client host (typically into the client's <configDir>/client/), the daemon-side raw file may be deleted — validation keeps working because the hash is what the daemon checks. The state file and per-token files are written with mode 0600.

How the client finds the daemon:

The logos-cpp-sdk uses LogosInstance::id(moduleName) to build registry URLs in the format local:logos_{moduleName}_{instanceId}. The instance ID is a 12-char UUID prefix shared by all processes in the same daemon tree (via the LOGOS_INSTANCE_ID env var). Child processes (like logos_host) inherit it automatically.

The CLI client is not a child process of the daemon — it's a separate invocation. So it cannot inherit the env var. Instead:

  1. Daemon starts → LogosInstance::id() generates a3f1c8d20b4e → sets LOGOS_INSTANCE_ID
  2. core_service registers at local:logos_core_service_a3f1c8d20b4e
  3. Daemon writes instance_id into both daemon/state.json and the auto-emitted client/config.json
  4. Client reads instance_id from client/config.json → sets LOGOS_INSTANCE_ID=a3f1c8d20b4e in its own process → now LogosInstance::id("core_service") returns the matching URL
  5. Client connects via LogosAPIClient → reaches the correct daemon

The client/config.json carries instance_id rather than a hardcoded registry URL so the client can reconstruct URLs using the same LogosInstance::id() function the SDK uses internally.

The token is generated on daemon startup, hashed into tokens.json["tokens"], and emitted raw to client/auto.json for the local-default client to pick up. Client commands read it automatically via client/config.json's token_file pointer. For remote/CI usage, the token can also be passed via LOGOSCTL_TOKEN env var.

ClientStateFile

Files: src/client/client_state.cpp/h

Purpose: Read/write <configDir>/client/config.json — the client's dial spec. This is the only daemon-tree file a client command ever opens during normal RPC (it never touches daemon/state.json, daemon/config.json, or daemon/tokens.json). The daemon auto-emits one for the local-default client at boot; remote clients hand-write it (or generate it via the --client-* flags + --persist-config).

client/config.json format (version must equal kClientStateSchemaVersion, currently 2):

{
  "version": 2,
  "token_file": "dario.json",     // filename inside <configDir>/client/ holding
                                  // the raw token ({"token":"<raw>",...}); read by
                                  // readTokenFile(), which extracts the "token" field
  "instance_id": "a3f1c8d20b4e",  // optional; required ONLY for the LocalSocket dial
                                  // path (registry name local:logos_<module>_<id>).
                                  // Omitted for remote tcp / tcp_ssl clients.
  "daemon": {                     // per-module dial spec; map key = module name
    "core_service": {             // mandatory — RpcClient::connect fails without it
      "transport": "tcp",         // "local" | "tcp" | "tcp_ssl" (strict allowlist)
      "host": "192.168.1.20",     // tcp / tcp_ssl
      "port": 8645,               // tcp / tcp_ssl (0..65535)
      "codec": "json"             // "json" (default) | "cbor"
    },
    "capability_module": {        // required for any remote client: the client's own
      "transport": "tcp",         // LogosAPIClient does a requestModule handshake
      "host": "192.168.1.20",     // against capability_module before reaching
      "port": 8646                // core_service (client.cpp wires this via
    }                             // LogosAPI::setCapabilityModuleTransport)
  }
}

For tcp_ssl, each module entry also accepts "ca": "<path>" and "verify_peer": true|false.

Parsing contract (ClientStateFile::read):

  • The per-module field is transport, not protocol. An unknown value (typo) makes transportFromJson return nullopt, which fails the whole parse (ClientState{}, fileOk=false) rather than silently dropping the entry — otherwise a missing core_service/capability_module would surface as an obscure connect error later.
  • A version other than 2 is rejected with a "relaunch the daemon to regenerate, or hand-edit" message and fileOk=false.
  • codec is validated up front when supplied via --client-codec: anything other than json or cbor is a hard error (exit 1) at flag-merge time, rather than being stored verbatim and silently coerced to JSON at dial time (which would defeat the "connect fails on codec mismatch" guarantee).
  • fileOk (the "usable for dialing" bit RpcClient::connect checks) is true iff at least one daemon entry parsed and token_file is non-empty.
  • core_service and capability_module may target different ports — they're independent daemon listeners. The --client-* CLI flags apply one transport shape to both modules, so divergent ports require hand-editing this file.

API:

Method Description
ClientStateFile::read() -> ClientState Parse client/config.json (or return the in-process override set by setOverride). fileOk=false on missing file, bad version, or invalid transport entry.
ClientStateFile::write(state) -> bool Serialize a ClientState back to client/config.json (used by the --persist-config path). Writes transport/host/port/codec (+ ca/verify_peer for tcp_ssl), and instance_id only when non-empty.
ClientStateFile::setOverride(opt) Inject a CLI-flag-merged ClientState that read() returns verbatim — lets --client-* flags affect a run without writing to disk.
ClientStateFile::readTokenFile(filename) -> string Read <configDir>/client/<filename> and return its "token" field. Empty string if missing/malformed. When --token-file is passed explicitly, main now validates the content with this up front: a file that exists but yields an empty token (missing/empty token field, or unparseable JSON) is a hard error (exit 1) pointing at the bad file, instead of being accepted and surfacing later as "No authentication token" at connect time.

Client

Files: src/client/client.cpp/h

Purpose: Connect to the daemon's core_service module via LogosAPIClient and invoke its methods by name.

Client is an abstract interface (so tests can substitute a mock); RpcClient is the real implementation and is a thin wrapper around LogosAPIClient. Its surface is Qt-freestd::string in, LogosMap / LogosList out — and each method maps 1:1 to a core_service method:

API:

Method core_service method called
Client::connect() -> bool Read <configDir>/client/config.json, set LOGOS_INSTANCE_ID from instance_id, build LogosTransportConfig from the dial spec, load token from token_file (or LOGOSCTL_TOKEN env), create LogosAPIClient targeting "core_service", authenticate
Client::isConnected() -> bool
Client::lastError() -> std::string — (last connect/RPC failure reason)
Client::loadModule(name) -> LogosMap core_service.loadModule(name)
Client::unloadModule(name, withDependents) -> LogosMap core_service.unloadModule(name, withDependents)
Client::reloadModule(name) -> LogosMap core_service.reloadModule(name)
Client::refreshModules() -> LogosMap core_service.refreshModules()
Client::planPackageOperation(op, names, opts) -> LogosMap core_service.planPackageOperation(...)
Client::applyPackageOperation(op, names, opts) -> LogosMap core_service.applyPackageOperation(...)
Client::downloadPackage(name, opts) -> LogosMap core_service.downloadPackage(name, opts)
Client::listModules(filter) -> LogosList core_service.listModules(filter)
Client::getStatus() -> LogosMap core_service.getStatus()
Client::getModuleInfo(name) -> LogosMap core_service.getModuleInfo(name)
Client::getModuleStats() -> LogosList core_service.getModuleStats()
Client::callModuleMethod(module, method, args) -> LogosMap core_service.callModuleMethod(module, method, args)
Client::shutdown() -> LogosMap core_service.shutdown()
Client::watchModuleEvents(module, event, callback) -> bool core_service.watchModuleEvents(module, event) + event subscription

Implementation pattern:

LogosMap RpcClient::loadModule(const std::string& name) {
    nlohmann::json ret = d->invoke("loadModule", nlohmann::json::array({name}));
    if (ret.is_object()) return ret;
    return LogosMap{{"status","error"},{"code","RPC_FAILED"}, /* ... */};
}

Output

Files: src/client/output.cpp/h

Purpose: Format output for human or JSON consumption. Detects TTY status for automatic mode selection.

API:

Method Description
Output::isTTY() -> bool Check if stdout is a terminal
Output::isJsonMode() -> bool Check if JSON output is active (flag or non-TTY)
Output::printSuccess(data) Print success result (human table or JSON)
Output::printError(code, message) Print error to stderr (human) or JSON to stdout
Output::printList(items) Print a list (table or JSON array)
Output::printEvent(event) Print a single event (formatted line or NDJSON)

Config

Files: src/config.cpp/h

Purpose: Read authentication credentials from environment variables and the client's dial-spec file.

API:

Method Description
Config::getToken() -> QString Token resolution: only LOGOSCTL_TOKEN env var. Filesystem fallback (client/<token_file>) lives in ClientStateFile::readTokenFile since it requires parsing the client config.
Config::configDir() -> QString Resolve config dir: explicit setter (--config-dir) → LOGOSCTL_CONFIG_DIR env → ~/.logosctl
Config::setConfigDir(QString) Process-wide override set from main when --config-dir is passed
Config::daemonConfigPath() / daemonStatePath() / daemonTokensPath() / daemonTokensDir() Daemon-side path helpers under <configDir>/daemon/
Config::clientConfigPath() / clientDir() / clientTokenPath(filename) Client-side path helpers under <configDir>/client/. clientTokenPath rejects any filename that isn't a plain name (contains /, \, or ..) and resolves it to an in-client/ sentinel, so an operator-influenced token_file value can't escape the dir to read an arbitrary file as a credential.

The client dial spec lives in client/config.json and is loaded via ClientStateFile::read() (not Config). See the ClientStateFile section above for the schema and parsing contract.

Token resolution order:

  1. LOGOSCTL_TOKEN environment variable
  2. <configDir>/client/<token_file> (token_file defaults to auto.json when client/config.json doesn't override it)

Config dir resolution order:

  1. --config-dir <path> CLI flag (sets process-wide override, mirrors into LOGOSCTL_CONFIG_DIR)
  2. LOGOSCTL_CONFIG_DIR environment variable
  3. ~/.logosctl (default)

Parallel daemons run side-by-side when invoked with distinct --config-dir values; client commands must target the daemon by passing the same --config-dir. Two daemons may not share a config-dir: startup reads daemon/state.json and refuses (exit 1) if its recorded pid is still alive, since both would write the same state.json and either one's clean shutdown would unlink it out from under the other. A stale state.json left by a crashed daemon (pid no longer alive) is ignored and overwritten.

Command Base Class

Files: src/client/commands/command.cpp/h

Purpose: Base class for all client subcommand implementations.

API:

Method Description
Command::execute(args) -> int Run the command, return exit code
Command::client() -> Client& Access the core_service client
Command::output() -> Output& Access the output formatter
Command::ensureConnected() -> int Refuse a stale session, else connect. 0 on success; prints NO_DAEMON and returns 2 otherwise. The single door every RPC-opening command goes through
detectStaleSession() -> optional<StaleSession> Free function. "This session's daemon is provably gone", from daemon/state.json's pid and instance_id. nullopt for a live daemon, a foreign instance, or no state file — see Client Path

The companion check lives one layer down, in RpcClient::connect():

Function Description
logosctl::localEndpointProvablyAbsent(module, instanceId, pathOut) src/local_endpoint.h, header-only. "A local dial cannot reach anyone": the socket file is missing, or it is there and refuses. Fails closed on everything else. Covers the cleanly-stopped session the pid guard cannot see

CLI Commands

All client-path commands connect to the daemon's core_service module via LogosAPIClient and call its methods by name. They never call liblogos C API functions directly.

logosctl daemon

Start the daemon process. This is the only command that runs the daemon path.

logosctl daemon start [--modules-dir <path>]...
logosctl daemon [--modules-dir <path>]...

Behavior:

  1. logos_core_init(argc, argv), add module directories, logos_core_start()
  2. Register core_service in-process via LogosAPIProvider::registerObject() (not logos_core_register_module(), which only maps a plugin name to a file path for on-disk discovery)
  3. Write ~/.logosctl/daemon/state.json (listeners + hashed-token table) and emit ~/.logosctl/client/config.json + ~/.logosctl/client/auto.json for the local client
  4. logos_core_exec() (Qt event loop — blocks)
  5. On SIGINT/SIGTERM: logos_core_cleanup(), remove daemon/state.json, exit

Exit codes: 0 on clean shutdown, 1 on error.

logosctl module load

Load a module into the running daemon.

logosctl module load <name>

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.loadModule(name)
  3. Prints result and exits

Exit codes: 0 on success, 2 if no daemon, 3 if module not found or load failed.

logosctl module unload

Unload a module from the running daemon.

logosctl module unload <name>

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.unloadModule(name)

Exit codes: 0 on success, 2 if no daemon, 3 if module not found or unload failed.

logosctl module ls

List available or loaded modules.

logosctl module ls [--loaded]

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.listModules(filter) — filter is "loaded" or "all"
  3. Returns all modules with status enum (loaded | not_loaded | crashed | loading)
  4. Formats and prints result with NAME, VERSION, STATUS, UPTIME columns
  5. Crash metadata (exit_code, crashed_at, crash_reason) is included in JSON for crashed modules

An unanswered RPC is reported as DAEMON_UNREACHABLE, not as an empty list. Client::listModules returns optional<LogosList> for exactly this reason: it used to answer a failed call with LogosList::array(), so against a daemon that was not running this printed [] and exited 0 — the one outcome a script cannot argue with, since a healthy session with nothing loaded says the same thing. stats had the identical bug and the identical fix.

Exit codes: 0 on success (including an empty list), 2 if no daemon or the daemon did not answer.

logosctl daemon status

Show overall daemon and module health.

logosctl daemon status

Behavior:

  1. Reads <configDir>/client/config.json to learn how to dial. If missing or unparseable, prints "not running" and exits with code 1 (no point trying to connect).
  2. Runs the same detectStaleSession() guard ensureConnected() does, one step earlier: a session whose own daemon's pid is gone reports not_running with the pid and the reason, exit 1. Earlier and separately because "no daemon" is an answer to status, not an error — the shared guard's NO_DAEMON / exit 2 would be the wrong shape.
  3. Otherwise tries to connect and call core_service.getStatus(). The RPC call IS the liveness check — there's no separate cheap probe, because no cheap probe is correct across every transport (local Unix socket vs remote TCP across NAT is a meaningless question for PID-based liveness).
  4. On RPC timeout / connect refused: reports "not running" with the error reason, exits with code 1.
  5. On success: displays daemon info (PID, uptime, version, instance ID) and all module statuses with summary counts.

status connects directly rather than through ensureConnected(), because that helper prints a NO_DAEMON error envelope on failure and this command's answer to "no daemon" is a status report — going through it would put two JSON documents on stdout for one command.

Exit codes: 0 on success, 1 if daemon not running (uses 1 not 2 because the status command itself succeeded — it's reporting the state, not failing to connect). A synthesized "not running" report — the one RpcClient::getStatus returns when the RPC produced no reply, marked with rpc_error — counts as not running and exits 1; it used to reach the success branch and exit 0, so the text said one thing and the exit code said another.

logosctl module reload

Unload and re-load a module.

logosctl module reload <name>

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.reloadModule(name) — core_service handles the unload/load logic internally, including fallback to plain load if module isn't currently loaded
  3. Returns result with previous_status field

Exit codes: 0 on success, 2 if no daemon, 3 if module not found or reload failed.

logosctl module show

Show detailed information about a specific module.

logosctl module show <name>

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.getModuleInfo(name)
  3. For loaded modules: displays name, version, status, PID, uptime, dependencies, and available methods — each method shows its signature and, when documented, a description sourced from the method's header doc comment (carried in the module's getPluginMethods introspection)
  4. For crashed modules: displays name, version, status, exit code, crash signal, crashed_at, restart count, last log line, PID before crash
  5. For not-loaded modules: displays name, version, status, dependencies

Exit codes: 0 on success, 2 if no daemon, 3 if module not found.

logosctl call

Call a method on a loaded module.

logosctl call <module> <method> [args...]

Alternative syntax:

logosctl module <name> method <method> [args...]

Behavior:

  1. Connects to daemon via Client
  2. Resolves @file arguments to file contents
  3. Type-coerces arguments: numeric strings → int/double, "true"/"false" → bool, rest → string
  4. Calls core_service.callModuleMethod(module, method, args) — core_service proxies the call to the target module via LogosAPIClient
  5. In human mode: prints scalar results as plain values, structured results as indented JSON, null produces no output. In JSON mode: prints the full result envelope.

Exit codes: 0 on success, 2 if no daemon, 3 if module not loaded, 4 if method not found or call failed.

logosctl watch

Watch events from a loaded module.

logosctl watch <module> [--event <name>]

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.watchModuleEvents(module, event) — core_service registers an event listener on the target module and forwards events through its own event system
  3. Client subscribes to core_service events via LogosAPIClient::onEvent()
  4. On each event: prints formatted line (human) or NDJSON line (JSON mode)
  5. Runs until SIGINT/SIGTERM

Exit codes: 0 on clean shutdown, 2 if no daemon, 3 if module not loaded.

logosctl stats

Show resource usage for loaded modules.

logosctl stats

Behavior:

  1. Connects to daemon via Client
  2. Calls core_service.getModuleStats()
  3. Formats as table (human) or JSON array

As with module ls, an unanswered RPC is DAEMON_UNREACHABLE rather than an empty stats list.

Exit codes: 0 on success (including an empty list), 2 if no daemon or the daemon did not answer.

logosctl daemon stop

Stop the running daemon via RPC.

logosctl daemon stop

Behavior:

  1. Refuses up front if daemon/state.json names this client's instance and a pid that is no longer alive (NO_DAEMON, exit 2). This is ensureConnected()'s guard, shared by every RPC-opening command, but stop is the one that would otherwise turn the silence into a success: steps 6-7 below read a missing reply plus a dead pid as a clean shutdown, and against a session that was already stale both are true before the command says anything
  2. Connects to daemon via Client
  3. Reads daemon/state.json for the daemon's pid before issuing the call — a clean shutdown deletes that file, so afterwards it is unreadable
  4. Calls core_service.shutdown() (5s deadline; the daemon answers before doing any work, so a slower reply is a lost one)
  5. core_service posts a main-thread timer, LOGOSCTL_SHUTDOWN_GRACE_MS (default 200ms) later, that drains the event loop and then calls QCoreApplication::quit()
  6. If the RPC response arrives: prints success and exits
  7. If it does not, the client asks whether the daemon actually died, for up to 15s: by watching the pid from step 3, or — for a remote daemon, where there is no local pid — by re-probing getStatus. Gone ⇒ success, with confirmed_by naming the evidence. Still there ⇒ RPC_FAILED

Exit codes: 0 on success (including when daemon exits before response), 2 if no daemon (or a stale session), 3 if the daemon neither replied nor exited.

logosctl info

Alias for module-info. Delegates to module-info command.

logosctl module show <module>

Behavior: Same as module-info <module> — see above.

Exit codes: 0 on success, 2 if no daemon, 3 if module not found.


Call Chain: CLI → core_service → liblogos

Client commands never call liblogos functions directly. The full call chain is:

CLI client                    core_service (daemon-side)              liblogos C API
─────────                     ─────────────────────────               ──────────────
logosctl module load waku
  → Client::loadModule("waku")
    → LogosAPIClient::invokeRemoteMethod(
        "core_service", "loadModule", "waku")
      ───── IPC (Qt Remote Objects) ─────→
                                          CoreServiceImpl::loadModule("waku")
                                            → logos_core_load_module("waku", true)
                                            → build result JSON
      ←──── IPC (return value) ──────────
    → Output::printSuccess(result)
    → exit(0)

Daemon path — liblogos usage

Only the daemon path calls liblogos C API functions directly:

Daemon operation liblogos functions
Start core logos_core_init, logos_core_add_modules_dir, logos_core_start
Register core_service LogosAPI, LogosAPIProvider::registerObject (Qt host runtime, logos-qt-host)
Run event loop logos_core_exec
Shutdown logos_core_cleanup

Client path — core_service method mapping

Client commands call core_service methods, which delegate to liblogos internally:

CLI command core_service method liblogos function called internally
load-module loadModule(name) logos_core_load_module(name, true)
unload-module unloadModule(name, withDependents) logos_core_unload_module(name, withDependents) — liblogos does the leaves-first cascade
reload-module reloadModule(name) logos_core_unload_module(name, false) + logos_core_load_module(name, true)
list-modules listModules(filter) logos_core_get_known_modules, logos_core_get_loaded_modules
status getStatus() reads daemon state + listModules
module-info getModuleInfo(name) plugin metadata + methods introspection
call callModuleMethod(module, method, args) LogosAPIClient::invokeRemoteMethod (proxied to target module)
watch watchModuleEvents(module, event) LogosAPIClient::onEvent (forwarded)
stats getModuleStats() logos_core_get_module_stats
stop shutdown() QTimer::singleShot(200, ..., &QCoreApplication::quit)
info alias for module-info

Build

Nix

nix build

# The logosctl binary is at:
./result/bin/logosctl

# Run daemon
./result/bin/logosctl daemon start -m /path/to/modules

Examples

Basic Usage

# Start the daemon with module directories
logosctl daemon start --detach &

# Check daemon health
logosctl daemon status

# Load modules
logosctl module load waku
logosctl module load chat

# List loaded modules (with status and uptime)
logosctl module ls --loaded

# Get detailed module info
logosctl module show chat

# Call a method
logosctl call chat send_message "hello world"

# Reload a crashed module
logosctl module reload chat

# Watch events
logosctl watch chat --event chat-message

# Get stats
logosctl stats

# Stop daemon
logosctl daemon stop

Agent / Script Usage

# Start daemon
logosctl daemon start --detach &
sleep 2

# Preflight: verify daemon is running
logosctl daemon status --json | jq -e '.daemon.status == "running"' > /dev/null

# Check what's available and their state
logosctl module ls --json
# [
#   {"name":"waku","version":"0.1.0","status":"not_loaded"},
#   {"name":"chat","version":"0.2.0","status":"not_loaded"}
# ]

# Load modules (JSON output for parsing)
logosctl module load waku --json
# {"status":"ok","module":"waku","version":"0.1.0","dependencies_loaded":["store"]}

logosctl module load chat --json
# {"status":"ok","module":"chat","version":"0.2.0","dependencies_loaded":[]}

# Discover methods before calling
logosctl module show chat --json | jq '.methods[].name'
# "send_message"
# "get_history"
# "get_status"

# Call method and parse result
RESULT=$(logosctl call chat send_message "hello" --json)
echo "$RESULT" | jq -r '.result'

# Handle crashed modules
MODULE_STATUS=$(logosctl daemon status --json | jq -r '.modules[] | select(.name=="chat") | .status')
if [ "$MODULE_STATUS" = "crashed" ]; then
  logosctl module show chat --json | jq '{exit_code, crash_signal, restart_count}'
  logosctl module reload chat --json
fi

# Stream events to log file
logosctl watch chat --event chat-message --json >> events.log &
WATCH_PID=$!

# Check overall health before cleanup
logosctl daemon status --json | jq '.modules_summary'
# {"loaded": 3, "crashed": 0, "not_loaded": 0}

# Cleanup
kill $WATCH_PID
logosctl daemon stop

Using Environment Variables for Auth

# Set token via environment
export LOGOSCTL_TOKEN=xyz123

# Or inline per-command
LOGOSCTL_TOKEN=xyz123 logosctl module load waku

# Or via the client/ tree (point client/config.json's token_file at a JSON
# file the daemon emitted — useful for remote clients). The file is a
# {"version":1,"name":"alice","token":"<raw>","issued_at":"<iso>"}
# object that `issue-token --name alice` writes to
# <daemon-host>/.logosctl/daemon/tokens/alice.json. Copy it across
# (scp / ansible / cloud-secret-fetch) and reference it from
# client/config.json's token_file:
mkdir -p ~/.logosctl/client
scp daemon-host:~/.logosctl/daemon/tokens/alice.json ~/.logosctl/client/
# then ensure ~/.logosctl/client/config.json's token_file = "alice.json"
logosctl module load waku

Piping and Composition

# Filter loaded modules
logosctl module ls --json | jq '[.[] | select(.status == "loaded")]'

# Find crashed modules
logosctl module ls --json | jq '[.[] | select(.status == "crashed")]'

# Watch events and filter
logosctl watch chat --event chat-message --json | jq 'select(.data.from == "alice")'

# Monitor module health with status dashboard
watch -n 5 'logosctl daemon status --json | jq "{daemon: .daemon.status, modules: .modules_summary}"'

# Monitor resource usage
watch -n 5 'logosctl stats --json | jq ".[] | {name, cpu_percent, memory_mb}"'

# Auto-reload crashed modules
logosctl module ls --json | jq -r '.[] | select(.status == "crashed") | .name' | while read mod; do
  logosctl module reload "$mod" --json
done

Tests

Test File Coverage
test_commands.cpp All subcommand implementations via mock client: load/unload/reload module, list-modules, status, module-info, call, stats, watch, stop. Tests both success and error paths, JSON and human output modes.
test_mode_detection.cpp Mode detection (daemon/client/help/version), known subcommands list, argument parsing.
test_output.cpp Output formatter (human/JSON), TTY detection, printSuccess/printError/printRaw.
test_daemon_state.cpp Round-trip daemon/state.json — instance_id, pid, modulesDirs, per-module transports entries (local/tcp/tcp_ssl, codec defaulting), and the tokens array (name, hash, issued_at, expires_at, local_only). fileOk is independent of the pid (it's a parse check, not liveness).
test_token_store.cpp Token issuance (including --expires and --local-only), duplicate-name rejection (unless --replace), revocation, list, persistence round-trip. Confirms tokens.json["tokens"] stores hashes only; plaintext lives in daemon/tokens/<name>.json. Fail-closed invariants: an empty token never authenticates, issueToken Ok implies a non-empty token, a failed --replace preserves the prior raw token, and issuing against an unsupported-schema-version file refuses instead of clobbering it.
test_config.cpp Token resolution order (env var → client/<token_file>); client/config.json parsing; clientTokenPath accepts plain filenames and rejects path-traversal (../, absolute, sub-dirs).
test_port_allocator.cpp Ephemeral-port allocation: bad host returns 0, an IPv6 any-address (::) allocates a port, consecutive allocations are distinct.
test_access_policy_arg.cpp --access-policy resolution: the enforce alias expands to the deny-by-default document, the alias beats the file branch, inline JSON and file paths pass through unchanged, and a bad path / malformed JSON fails with a reason rather than degrading to "no policy".
test_log_sink.cpp Pipe-based stdout/stderr capture into the rotating daemon log.
test_paths.cpp Executable / bundle-relative path resolution (paths.h).
test_cli.cpp End-to-end CLI tests: help, version, no-args, client commands without daemon, daemon startup with --verbose; rejection of an invalid --module-transport port, an invalid --client-codec, and a --token-file that carries no usable token.
test_integration.cpp Daemon-backed integration: a real logosctl daemon against a real module directory, driven through the client subcommands — error paths, the full test_basic_module API surface, event subscription via watch, and many simultaneous clients on one daemon.
test_cli_logoscore.cpp / test_integration_logoscore.cpp The same two suites frozen against logoscore's surface, so shared-runtime changes can't regress the tool people actually use. They get deleted with the binary.

Known Issues

  1. Event forwarding — The watch command requires core_service to forward events from target modules to CLI clients. The approach is: core_service.watchModuleEvents() registers a listener on the target module via LogosAPIClient::onEvent(), then re-emits received events through CoreServiceImpl::emitEvent — the std::function hook the runtime installs via setEventListenerStd(), emitted under the name module_event. The CLI client subscribes to core_service events. This creates a relay chain (target module → core_service → CLI client) which adds latency. An alternative would be having the CLI client connect directly to the target module, but that bypasses the core_service gateway pattern.

  2. Stale state file — If the daemon crashes without removing <configDir>/daemon/state.json (and the auto-emitted client/ tree), the files stay on disk. Clients no longer pre-probe PID liveness (that only works for local daemons); instead the first RPC fails with a connect error and the status command turns that into a "not running" report. The only cost of a stale file is that the first attempt after a crash wastes one RPC timeout; in practice that's fine.

  3. Crash tracking — The daemon needs to track module crash metadata (exit code, signal, timestamp, restart count, last log line) so that listModules and getModuleInfo on core_service can report it. This may require extending liblogos to expose crash info, or core_service could track it independently by monitoring QProcess signals.

  4. callModuleMethod proxy — When core_service proxies calls to target modules via LogosAPIClient, it needs the target module's auth token. The daemon's TokenManager has all tokens, but core_service must obtain them. This may require core_service to have a privileged token or to be pre-authorized for all modules.

Future Improvements

  1. Tab completion — Shell completion scripts for bash/zsh/fish.
  2. TUI mode — Interactive terminal UI with autocomplete (like Obsidian CLI).
  3. Batch mode — Execute multiple commands from a file (logosctl batch commands.txt).
  4. module-logs command — Stream or tail module process logs (logosctl module-logs chat --tail 50). Referenced by error messages but not yet specified.
  5. Extract core_service — If core_service grows, it could be extracted into a standalone plugin loaded from disk rather than statically linked. It already implements the plain LogosProviderObject interface, so the extraction is mostly adding a plugin entry point and a module build.
  6. Capability-scoped tokens — Today all tokens are admin-equivalent. Named tokens (issue-token --name …) create separate identities but each one is still fully authorised against the daemon. A scope/capability system would let e.g. a read-only token call list-modules / status but reject load-module / stop.
  7. Client-cert TLS — The tcp_ssl transport today authenticates the daemon to the client (server cert); mutual TLS + client-cert auth would be a natural extension once we have scoped tokens, and subsumes the token-file distribution problem for many deployments.