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>
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:
- The dial resolves to
QDir::tempPath()/logos_core_service_<instance_id>— the SDK asks for the bare name (LogosInstance::id) and Qt resolves a bareQLocalSocket/QLocalServername against the temp dir. Deriving it the same way is what makes the answer sound rather than a guess. - No file there ⇒ nobody home. A clean shutdown unlinks it.
- 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 andQLocalServer's destructor — which is exactly when the next command gets typed. Presence alone settles nothing, which is why a stat is not enough.ECONNREFUSEDis the same signallogos::isSocketDeaduses 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:
- Daemon starts →
LogosInstance::id()generatesa3f1c8d20b4e→ setsLOGOS_INSTANCE_ID - core_service registers at
local:logos_core_service_a3f1c8d20b4e - Daemon writes
instance_idinto bothdaemon/state.jsonand the auto-emittedclient/config.json - Client reads
instance_idfromclient/config.json→ setsLOGOS_INSTANCE_ID=a3f1c8d20b4ein its own process → nowLogosInstance::id("core_service")returns the matching URL - 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, notprotocol. An unknown value (typo) makestransportFromJsonreturnnullopt, which fails the whole parse (ClientState{},fileOk=false) rather than silently dropping the entry — otherwise a missingcore_service/capability_modulewould surface as an obscure connect error later. - A
versionother than2is rejected with a "relaunch the daemon to regenerate, or hand-edit" message andfileOk=false. codecis validated up front when supplied via--client-codec: anything other thanjsonorcboris 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" bitRpcClient::connectchecks) is true iff at least onedaemonentry parsed andtoken_fileis non-empty.core_serviceandcapability_modulemay 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-free — std::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:
LOGOSCTL_TOKENenvironment variable<configDir>/client/<token_file>(token_filedefaults toauto.jsonwhenclient/config.jsondoesn't override it)
Config dir resolution order:
--config-dir <path>CLI flag (sets process-wide override, mirrors intoLOGOSCTL_CONFIG_DIR)LOGOSCTL_CONFIG_DIRenvironment variable~/.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:
logos_core_init(argc, argv), add module directories,logos_core_start()- Register
core_servicein-process viaLogosAPIProvider::registerObject()(notlogos_core_register_module(), which only maps a plugin name to a file path for on-disk discovery) - Write
~/.logosctl/daemon/state.json(listeners + hashed-token table) and emit~/.logosctl/client/config.json+~/.logosctl/client/auto.jsonfor the local client logos_core_exec()(Qt event loop — blocks)- On SIGINT/SIGTERM:
logos_core_cleanup(), removedaemon/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:
- Connects to daemon via
Client - Calls
core_service.loadModule(name) - 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:
- Connects to daemon via
Client - 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:
- Connects to daemon via
Client - Calls
core_service.listModules(filter)— filter is"loaded"or"all" - Returns all modules with status enum (
loaded | not_loaded | crashed | loading) - Formats and prints result with NAME, VERSION, STATUS, UPTIME columns
- 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:
- Reads
<configDir>/client/config.jsonto learn how to dial. If missing or unparseable, prints "not running" and exits with code 1 (no point trying to connect). - Runs the same
detectStaleSession()guardensureConnected()does, one step earlier: a session whose own daemon's pid is gone reportsnot_runningwith the pid and the reason, exit 1. Earlier and separately because "no daemon" is an answer tostatus, not an error — the shared guard'sNO_DAEMON/ exit 2 would be the wrong shape. - 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). - On RPC timeout / connect refused: reports "not running" with the error reason, exits with code 1.
- 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:
- Connects to daemon via
Client - Calls
core_service.reloadModule(name)— core_service handles the unload/load logic internally, including fallback to plain load if module isn't currently loaded - Returns result with
previous_statusfield
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:
- Connects to daemon via
Client - Calls
core_service.getModuleInfo(name) - For loaded modules: displays name, version, status, PID, uptime, dependencies, and available methods — each method shows its signature and, when documented, a
descriptionsourced from the method's header doc comment (carried in the module'sgetPluginMethodsintrospection) - For crashed modules: displays name, version, status, exit code, crash signal, crashed_at, restart count, last log line, PID before crash
- 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:
- Connects to daemon via
Client - Resolves
@filearguments to file contents - Type-coerces arguments: numeric strings → int/double,
"true"/"false"→ bool, rest → string - Calls
core_service.callModuleMethod(module, method, args)— core_service proxies the call to the target module viaLogosAPIClient - 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:
- Connects to daemon via
Client - Calls
core_service.watchModuleEvents(module, event)— core_service registers an event listener on the target module and forwards events through its own event system - Client subscribes to core_service events via
LogosAPIClient::onEvent() - On each event: prints formatted line (human) or NDJSON line (JSON mode)
- 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:
- Connects to daemon via
Client - Calls
core_service.getModuleStats() - 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:
- Refuses up front if
daemon/state.jsonnames this client's instance and a pid that is no longer alive (NO_DAEMON, exit 2). This isensureConnected()'s guard, shared by every RPC-opening command, butstopis 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 - Connects to daemon via
Client - Reads
daemon/state.jsonfor the daemon's pid before issuing the call — a clean shutdown deletes that file, so afterwards it is unreadable - Calls
core_service.shutdown()(5s deadline; the daemon answers before doing any work, so a slower reply is a lost one) - core_service posts a main-thread timer,
LOGOSCTL_SHUTDOWN_GRACE_MS(default 200ms) later, that drains the event loop and then callsQCoreApplication::quit() - If the RPC response arrives: prints success and exits
- 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, withconfirmed_bynaming 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
-
Event forwarding — The
watchcommand requirescore_serviceto forward events from target modules to CLI clients. The approach is:core_service.watchModuleEvents()registers a listener on the target module viaLogosAPIClient::onEvent(), then re-emits received events throughCoreServiceImpl::emitEvent— thestd::functionhook the runtime installs viasetEventListenerStd(), emitted under the namemodule_event. The CLI client subscribes tocore_serviceevents. 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. -
Stale state file — If the daemon crashes without removing
<configDir>/daemon/state.json(and the auto-emittedclient/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 thestatuscommand 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. -
Crash tracking — The daemon needs to track module crash metadata (exit code, signal, timestamp, restart count, last log line) so that
listModulesandgetModuleInfoon core_service can report it. This may require extending liblogos to expose crash info, or core_service could track it independently by monitoringQProcesssignals. -
callModuleMethod proxy — When
core_serviceproxies calls to target modules viaLogosAPIClient, 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
- Tab completion — Shell completion scripts for bash/zsh/fish.
- TUI mode — Interactive terminal UI with autocomplete (like Obsidian CLI).
- Batch mode — Execute multiple commands from a file (
logosctl batch commands.txt). module-logscommand — Stream or tail module process logs (logosctl module-logs chat --tail 50). Referenced by error messages but not yet specified.- 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
LogosProviderObjectinterface, so the extraction is mostly adding a plugin entry point and a module build. - 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 calllist-modules/statusbut rejectload-module/stop. - Client-cert TLS — The
tcp_ssltransport 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.