Files
logos-logoscore-cli/docs/spec.md
T
e48fc7fef3 Add logosctl alongside logoscore: one CLI for daemon, modules and packages (#76)
* feat(core_service): add refreshModules and cascade unload by default

Two runtime prerequisites for the logosctl merge.

refreshModules() wraps logos_core_refresh_modules(), which liblogos
documents as "call after installing new modules so they become
discoverable". Basecamp calls it on the package_manager install event,
which is why installing a module there needs no restart. core_service
did not expose it, so a CLI that installs a package had no way to make
the daemon see it short of a restart.

unloadModule() now takes withDependents and the CLI defaults it to true
(--no-dependents opts out). logos_core_unload_module already accepted
the flag; core_service hardcoded false, which left dependents running
against an unloaded provider. The result now carries dependents_unloaded
so the cascade is reported rather than silent.

The dispatch entry defaults a missing second argument to true, so a
one-argument unloadModule call keeps working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(flake): bundle package_manager and package_downloader

logoscore bundled only capability_module, so it could authenticate but
not manage packages — that was lgpd's and lgpm's job, as separate
binaries. Bundling the two package modules is what lets one binary do
the whole job.

Same trio logos-basecamp bundles, assembled the same way (map the
install bundler over the module libs), so the CLI and the GUI drive an
identical module surface rather than the CLI being a reduced sibling.
Only the package manager ships a distinct lib-portable; the other two
are variant-agnostic, matching basecamp's split.

Verified against a real daemon: all three are discovered with no module
configuration, both package modules load, and package_downloader
resolves the live default catalog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(daemon): make the config dir a self-contained session

The daemon knew about ~/.logoscore only as a place to keep its own
state files; packages, trust material and persistence lived elsewhere
or nowhere. Now the config dir is the whole world for a session:

  <configDir>/modules   installed core modules (writable)
  <configDir>/plugins   installed UI plugins
  <configDir>/keyring   trusted signing keys
  <configDir>/cache     downloaded .lgx
  <configDir>/data      module persistence

so copying the directory carries the session's packages and its trust
assumptions with it, and two sessions can disagree about both.

<configDir>/modules joins the search path beside the bundled dir.
Without it an installed module would sit on disk that the daemon could
never see, and install-then-load could not work at all.

The bundled package modules are loaded at boot and pointed at these
directories -- the same four setters basecamp calls -- because every
package command is an RPC into them. All best-effort: a daemon that
cannot manage packages is still fully usable for loading and calling
modules, so none of it aborts startup.

Verified live: a bare daemon creates the tree, loads all three modules,
and reports the embedded packages via getInstalledPackages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(package): daemon-side install, upgrade and remove

Adds the mutating package operations the CLI never had, orchestrated
inside the daemon and exposed as core_service.planPackageOperation /
applyPackageOperation, plus the `package`, `catalog` and `key` command
groups on top.

Why daemon-side: package_manager gates destructive work behind a
listener-ack protocol with a 3-second deadline. Driving that from a
short-lived client would mean holding an event subscription open,
interleaving it with outbound calls, and winning a three-second race
across the RPC boundary. In-process the ack cannot lose that race, and
every client command stays thin and stateless.

plan/apply is split so `--dry-run` and the confirmation prompt see
exactly what apply will do -- the same dependency-change table basecamp
shows, including which running modules get stopped. Without -y and
without a TTY the operation is refused rather than assumed-yes, so a
script that forgot --yes fails loudly instead of silently uninstalling.

install/upgrade take dependencies, remove takes dependents, both by
default. Installing never loads: it puts files on disk, and only
modules already running beforehand are restarted afterwards.

Verified against the live catalog on a portable build: install
openmetrics; install chat_module pulling delivery_module in order;
re-install as a no-op; install then load with no daemon restart
(refreshModules); and removing delivery_module cascading through
chat_module with both stopped first.

One trap worth naming: LogosList{vec} does not wrap a std::vector the
way it wraps a scalar -- it yields an empty args array, and the module
sees a zero-argument call it cannot dispatch. The batch uninstall
builds its argument explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(config): replace the flag surface with a YAML session document

Configuration was ~20 flags plus two hand-written mini-grammars: a
`NAME=PROTOCOL[,k=v...]` parser that existed only to squeeze a nested
structure through a flag, and a per-flag defaults<config<CLI merge. Both
are gone. main.cpp drops from 943 to 531 lines.

Configuration now lives in the session:

  <configDir>/daemon/config.yaml   written by `daemon config set`
  <configDir>/client/config.yaml   written by `client config set`

and is never passed alongside an unrelated command, so `daemon start`
and every client command take the session exactly as it is on disk.
--config-dir is the one surviving flag, because it selects *which*
session to act on and so cannot itself live inside one.

The split is by audience: files a human edits are YAML, files the
daemon and modules own stay JSON (state.json, tokens, the auto token).
Converting through nlohmann::json means the existing validated
daemonConfigFromJson / clientStateFromJson keep doing the schema work.

Two traps fixed while wiring it up, both of the accept-then-ignore kind
that leaves an operator with no explanation:

  - A bare `modules: {core_service: [ ... ]}` sequence was silently
    skipped (only the `{transports: [...]}` spelling parsed). It is now
    accepted as shorthand.
  - Unknown top-level keys are rejected by `config set` and the error
    names the correct spelling, so `insecureTcp` no longer looks like it
    worked when the key is `insecure_tcp`.

Module search paths remain configurable via the `modules_dirs` key,
which is what replaces -m for tests and dev loops.

The eight CLI tests that covered deleted flags are rewritten against
the new surface: malformed YAML rejected without clobbering the
existing config, unknown keys named, set/show round-trip, and absent
config treated as defaults rather than an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): logosctl, with docker-style command groups

Renames the binary and reorganises ~20 flat hyphenated commands into
groups: daemon, client, module, token, package, catalog, key, plus
top-level aliases for the four verbs that cannot be confused with a
runtime module (status, call, watch, stats) and the two package verbs
with no module meaning (install, search).

The hyphenated names survive as internal dispatch tokens but are hidden
from --help: `module load X` is rewritten to `load-module X` in argv
before CLI11 parses. The rewrite happens in argv rather than via nested
CLI11 subcommands because daemonSub->fallthrough() pushes a nested
subcommand's unmatched arguments up to the top level, where they are
rejected ("The following argument was not expected: show").

`module` is no longer an alias for the verbose call syntax -- it is the
group. Use `call`.

Also implements --detach, which was specified but missing. It re-execs
rather than continuing in the forked child: macOS refuses to let a
process that has already initialised CoreFoundation keep running after
fork(), and the Qt/liblogos link pulls CoreFoundation in before main.
The child redirects stdio to <configDir>/daemon/daemon.log -- without
that the shell never sees EOF and `daemon start --detach` appears to
hang -- and the parent returns only once state.json exists, so the next
command cannot race the boot.

Env vars and the default session directory rename to LOGOSCTL_*
and ~/.logosctl. User-facing messages now name the group grammar rather
than the internal tokens.

Verified on the portable build: daemon start --detach returns in ~3s
with a working daemon; catalog ls, search, install --dry-run, install,
package ls, module ls/load/show, upgrade (no-op), and remove of a
loaded module all behave. 18/18 CLI tests, unit tests unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: rewrite for logosctl, sessions and YAML config

The README documented a flag surface that no longer exists
(--persist-config, --module-transport, --modules-dir, the seven
--client-* flags) and had no account of sessions or packages at all.

Replaces the daemon/transport/persist-config sections with: what a
session directory is and why it is portable, `daemon config set` and
the YAML schema, and the package/catalog/keyring commands. Keeps the
two hard-won warnings that are still true -- a remote daemon must expose
capability_module as well as core_service, and plaintext tcp on a
non-loopback host needs an explicit opt-in.

Doctests are renamed and rewritten around sessions: the daemon spec no
longer passes -m but seeds ./session/modules, and uses
`daemon start --detach` instead of backgrounding with & (which returned
before the transports bound and raced the first command).

Also fixes the stats table: the MODULE column was a fixed 12 characters,
so a real name like "test_basic_module" ran straight into the PID with
no separator.

Verified the rewritten daemon-doctest sequence by hand against a dev
build: seed session, start --detach, module ls/load, call, stats, stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* doctests: use --detach and the session log

`daemon start --detach` already returns only once the daemon is
accepting commands and sends its output to <session>/daemon/daemon.log,
so the `sh -c '... > logs.txt 2>&1 &'` wrapper is not just redundant --
it hid the output the specs then tried to cat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: ship logosctl alongside logoscore instead of replacing it

logosctl is new and unvalidated; logoscore is what people depend on
today. Replacing one with the other in a single step meant every
consumer had to move at once, on trust. Shipping both means logosctl
can be validated in real use first, and logoscore removed afterwards.

Both binaries build from this repo over one shared runtime -- daemon,
core_service, client, output. They differ only in main.cpp and a
Config::Flavor the front-end sets, which selects the config directory,
the env var consulted for an override, the config file names, and the
format they are written in.

The isolation is the point, so it is deliberate and tested:

  logoscore  ~/.logoscore   LOGOSCORE_CONFIG_DIR  daemon/config.json
  logosctl   ~/.logosctl    LOGOSCTL_CONFIG_DIR   daemon/config.yaml

A logosctl session cannot disturb a logoscore deployment. Reading needs
no branch -- YAML is a superset of JSON, so one parser handles both --
only writing differs.

logoscore is behaviourally unchanged, which took two specific
decisions:

  - The session directory and the package-module bootstrap are gated on
    the modern flavor. Auto-loading two extra modules would change what
    `status` and `list-modules` report, and logoscore's doc-tests assert
    those exact counts.
  - The bundled package modules live in modules-pkg/ rather than
    modules/, because logoscore scans the latter and would otherwise
    report two modules it never had.

Verified: `logoscore --help` is the old flat surface with all four flag
families intact; a logoscore daemon reports loaded:1 not_loaded:0 and
creates no session directories; both daemons run at once with separate
state.

Its doc-tests are restored unchanged. logosctl gets its own, including
a new logosctl-packages spec covering the capability that motivated the
merge -- search, dry-run, install, load, remove -- verified end to end
against the live catalog.

122/129 unit tests, 18/18 CLI tests. The 7 OutputTest failures are
pre-existing on master.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build: give logosctl its own flake outputs

Building both binaries into one package meant `nix build` and `.#cli`
started handing out logosctl too, which is the opposite of keeping the
two apart while the new one is validated.

Now each output ships exactly one binary:

  .#cli  .#cli-bundle-dir  .#cli-appimage   ->  logoscore
  .#ctl  .#ctl-bundle-dir  .#ctl-appimage   ->  logosctl
  .#     (default)                          ->  logoscore

So anything already pointing at the default or at `.#cli` -- including
every doc-test across the workspace that does
`nix build github:logos-co/logos-logoscore-cli` -- keeps getting the
tool it gets today, and logosctl is strictly opt-in.

They still compile together, since they share everything but main.cpp;
only the packaging is split. modules-pkg/ ships solely in the ctl
outputs, because logoscore never scans it.

logoscore's desktop entry and icon are restored, and logosctl gets its
own. The logosctl doc-tests now build .#ctl / .#ctl-bundle-dir.

Verified: every output builds and ships only its own binary; both
portable bundles run and report the module set expected of each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(config): let each session subdirectory be redirected

The session directory being self-contained is what makes it portable, and
that should stay the default -- but it was also the only option, which
made reasonable setups impossible: sharing one keyring across sessions,
putting the .lgx cache on a bigger disk, or pointing at a modules tree
something else manages.

A `dirs:` block now redirects any of them:

    dirs:
      keyring: ~/.config/logos/trusted-keys
      cache: /var/cache/logos
      modules: /opt/logos/modules
      plugins: plugins-custom
      data: /var/lib/logos/data

The form of the value decides whether portability survives, which is the
part worth knowing:

    plugins-custom  -> <session>/plugins-custom   still portable
    ~/x             -> $HOME/x                    outside the session
    /var/cache/...  -> as given                   outside the session

`~` is handled because it is the natural thing to write in a config file
and would otherwise resolve to <session>/~/... , which exists nowhere.

Overrides resolve once, when set, so relocating a session afterwards
cannot silently drag an absolute path along with it. Only the daemon
applies them, and it does so before anything asks Config for a path.

persistence_path is folded into dirs.data -- it was already the same
setting under an older name -- so the two no longer need choosing
between at the point of use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(daemon): rotating log file with configurable size and retention

--detach used to dup2 stdout/stderr straight onto daemon/daemon.log,
which grew without bound and had no rotation. A long-lived daemon needs
better than that.

There is now a logs/ directory, like Basecamp's, and a logging block:

    logging:
      enabled: true          # false -> no log file at all
      file: daemon.log       # inside dirs.logs
      max_size_mb: 10        # rotate past this; 0 = never rotate
      max_files: 5           # keep this many in total
      console: true          # mirror to the terminal

dirs.logs joins the overridable session directories, so logs can be
shipped somewhere a collector already watches.

Capture is pipe-based rather than a file redirect, and that is the
load-bearing decision: module hosts are separate processes holding
inherited descriptors. Redirecting to a file catches their output but
makes rotation impossible -- renaming a file out from under a child that
has it open just keeps filling the old inode. A pipe puts one reader in
charge, so rotation is safe and subprocess output still lands in the
log. Same shape as basecamp's LogRedirector, which solved this already.

The size cap and retention come from spdlog's rotating sink rather than
being hand-rolled; liblogos already logs through spdlog. Lines arriving
from the pipe already carry their own timestamp and level, so the sink
uses a raw pattern instead of stamping them twice.

Two bugs found while testing it:

  - Draining raced shutdown. stop() cleared the running flag before
    restoring the descriptors, so a reader holding data would process
    it, loop, see the flag clear and exit -- dropping whatever was still
    in the pipe. The last lines before a shutdown are exactly the ones
    worth keeping. EOF is now the only stop condition.
  - --detach reported the wrong path. The parent prints before the child
    has read the config, so it guessed the default and lied to anyone
    who had redirected dirs.logs or renamed the file. It now reads the
    same config the child will.

Verified live: default, disabled, and redirected-with-custom-filename
all behave and are reported accurately. Four unit tests cover capture
of both streams, no double-stamping, rotation with retention, and
disabled-is-not-an-error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(daemon): timestamp log files, and bound the directory

Adopts basecamp's naming -- each start writes its own
daemon_<yyyymmdd_HHMMSS>.log -- so a session's output is one file you can
point at, instead of every run appending into the same daemon.log.

Two things beyond copying basecamp:

  - `logging.file` survives as a symlink to whichever file is current, so
    `tail -F logs/daemon.log` follows across restarts and nobody has to
    work out a stamp. It also means --detach can report a path that is
    always valid; previously it had to guess one, and guessed wrong for
    anyone who had redirected dirs.logs.

  - max_files now bounds the *directory*, pruning oldest-first at each
    start. spdlog's retention only prunes within one sink's rotation set,
    and every start opens a new stamped base name, so without this a
    daemon restarted a hundred times would leave a hundred logs behind.
    basecamp has exactly that problem.

Verified live: three restarts leave three stamped files with the symlink
tracking the newest; five restarts with max_files: 2 leave two.

Two new tests cover the naming and the symlink resolving to the current
session, and the cross-session pruning. The rotation test needed fixing
too -- it counted the symlink as a log file, which predated the symlink
existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: ignore suffixed nix out-links

.gitignore listed `result` but not `result-*`, so every out-link from a
targeted build -- `nix build '.#ctl' -o result-ctl`, `-o result-tests`,
and so on -- was untracked-but-not-ignored, and `git add -A` committed
them as symlinks into /nix/store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: keep releasing logoscore, and release logosctl beside it

The earlier rename left the release workflow building the `cli-*`
outputs -- which are logoscore -- while naming every artifact
`logosctl-*`. A release/** push would have shipped logoscore binaries
under the wrong name, and stopped releasing logoscore under its own.

Both are now built and published as separate, correctly-named assets:

  logoscore-{x86_64,aarch64}-linux.tar.gz   from .#cli-appimage
  logoscore-aarch64-macos.tar.gz            from .#cli-bundle-dir
  logosctl-{x86_64,aarch64}-linux.tar.gz    from .#ctl-appimage
  logosctl-aarch64-macos.tar.gz             from .#ctl-bundle-dir

logoscore's asset names are exactly what they were, which matters:
release sets fetch this repo and expect a bundle containing
`bin/logoscore`. Each tool builds from its own flake outputs, so an
asset labelled logoscore contains logoscore and nothing else.

Both jobs gained a tool matrix with fail-fast disabled, so a failure in
the under-validation logosctl cannot block a logoscore release. The
release job now collects artifacts by pattern instead of naming each
one, so retiring logoscore later means deleting a matrix entry rather
than unpicking a download list.

Release notes lead with logoscore as the tool to use, and say the two
share no state so installing logosctl cannot disturb an existing setup.

The doc-tests workflow globs doctests/*.test.yaml, which now covers both
suites, so it is no longer named after one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: run both tools' suites, in parallel

While both binaries ship, both get tested. logoscore had no automated
coverage on this branch at all -- only its doc-tests -- so a change to
the shared runtime could regress the tool people actually use and
nothing would say so.

tests/test_cli_logoscore.cpp and tests/test_integration_logoscore.cpp
are copies of the suites frozen against logoscore's surface. Copies
rather than a parameterised shared suite on purpose: the two surfaces
genuinely differ, and this way retiring logoscore is a delete rather
than an unpick.

checks.tests-logosctl and checks.tests-logoscore are separate
derivations, so nix builds them concurrently; checks.tests aggregates
both, keeping `nix build .#checks.<sys>.tests` working for CI while now
covering both tools.

It immediately earned its keep, catching three regressions:

  - The integration harness still passed -m, which logosctl no longer
    accepts, so its daemon never started and seven integration tests
    were failing on this branch. It now writes the modules_dirs config
    the daemon reads.

  - `logoscore --version` reported "logosctl version ...". The version
    banner had been renamed wholesale; each front-end now names itself.
    Exactly the sort of thing nobody notices until a bug report cites
    the wrong tool.

  - The new log sink only mirrored to the console when stdout was a
    TTY, so `logoscore -D > logs.txt` -- which the doc-tests do --
    produced an empty file. Mirroring now follows the configured
    setting, pipe or terminal alike, and the log file is gated to
    logosctl so logoscore's output behaviour is untouched.

Both suites green: logosctl 138 unit + 18 CLI + 18 integration,
logoscore 20 CLI + 18 integration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(package): honour -o, and stop parsing command lines backwards

`package download -o DIR` accepted the flag and threw it away -- the
argument was parsed into a variable and then explicitly discarded with
`(void)outDir;`. The file went to $TMPDIR regardless. The config's
`dirs.cache` had the same problem from the other end: the directory was
created and documented as holding downloads, and nothing ever wrote to
it.

The cause was the same for both. package_downloader takes no
destination, so the file lands in $TMPDIR on the DAEMON's filesystem --
which is where the move has to happen too. Doing it client-side would
work only for a local daemon. So `downloadPackage` joins the daemon-side
package operations: it downloads, then moves the result into the
requested directory, or into the session's cache/downloads when no -o
was given. The client resolves a relative -o against its own working
directory first, so a local daemon does what the user typed; against a
remote one the path is remote, and a bad one fails loudly rather than
quietly writing elsewhere.

Writing the first test for it turned up something worse. CLI11's
`parse(std::vector<std::string>&)` consumes the vector from the BACK --
only the rvalue overload reverses for you -- so passing natural order
parses the command line backwards. `watch` and `issue-token` did reverse
first; nothing else did. It goes unnoticed with one positional and flags
(order does not matter), and is quietly wrong the moment an option takes
a value, because the option pairs with the token to its LEFT:

  package download pkg -o dir   ->  name="dir", output="pkg"
  package install a b --version 1.0
                                ->  names=["1.0","b"], version="a"

So `package install`, `search --category`, and `download -o` all
misparsed. Every site now goes through one `parseArgs` helper that
reverses, which fixes the broken ones, is a no-op for the harmless ones,
and removes the trap for the next command.

PackageCommand had no unit tests at all, which is why a discarded flag
survived review. Four now cover download; the two asserting -o reaches
the daemon fail against the old code.

142 unit + 18 CLI + 18 integration green for logosctl, 20 + 18 for
logoscore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: one README about the repo, one document per tool

The README had grown into a logosctl manual with a banner on top telling
logoscore users that everything below did not apply to them, and pointing
them at doc-test YAML for their actual documentation. Since logoscore is
still the tool to use, its documentation should not be the thing you are
told to skip.

So: README.md covers what is true of both -- what the repo is, the two
binaries and how they differ, the flake outputs, the test targets,
dependency resolution, platforms -- and hands off to one document per
tool.

  docs/logoscore.md   the usage material, unchanged, as its own document
  docs/logosctl.md    sessions, config, logs, packages, examples

Writing logosctl's own document exposed a gap: it had no command
reference at all. The rewrite dropped the client-command list, argument
typing and exit codes, and left behind a "see Argument typing below"
pointing at a section that no longer existed. All three are back, with
the command list written against the grammar that is actually
implemented (checked against normalizeGroupVerbs and the subcommand
dispatch, not from memory), plus the two defaults worth stating up front
-- install does not load, remove takes dependents.

Also fixes stale copy that survived the earlier rewrite: `load-module`
where logosctl says `module load`, and a "multiple module directories"
caption over a --config-dir example, from a flag logosctl does not have.

Deleting logoscore later is now deleting one file and a table row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(daemon): make TLS configurable again, and say why startup failed

Three bugs, all found by running the doc-tests I had just rewritten
instead of trusting them.

**tcp_ssl could not be configured at all.** `transportFromJson` never
read `cert` or `key`. That was harmless while those arrived via
`--module-transport ...,cert=...,key=...`, parsed by the CLI mini-grammar
-- but that grammar is gone, and the config file is now the only place to
set them. So every tcp_ssl listener bound with no certificate: the daemon
started, reported itself healthy, accepted connections, and failed every
handshake with "no shared cipher (SSL routines)". The client just saw
"core_service not reachable".

The stripping was deliberate but applied one layer too high: cert and key
have no business in state.json, which clients read, but the config file is
where an operator *authors* them. `transportToJson` now takes
`includeSecrets` -- true writing the config, false writing state.json. A
test asserts the round-trip, and another asserts the key path never
appears in state.json.

**`--detach` swallowed the reason startup failed.** Config validation runs
before LogSink opens the log, and the child's stderr went to /dev/null, so
a rejected config produced "daemon exited during startup. See
<path>/logs/daemon.log" -- naming a file that had never been created. The
child's early output now goes to a startup file the parent reads and
prints on failure, removed either way. LogSink takes those descriptors
over as soon as it starts, so the file only ever holds pre-logging output.

**The plaintext-TCP guard advertised a flag that does not exist.** It said
"pass --insecure-tcp"; logosctl has no such flag. It now names the config
key, `insecure_tcp: true`.

Verified end to end against a real daemon: plaintext guard refuses and
says why, loopback TCP binds and serves `status`/`module ls` from a
separate client session, TLS serves the same over 6443/6444, and dropping
the CA while keeping verify_peer still fails closed.

144 unit + 18 CLI + 18 integration green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(flake): give autoPatchelf the libraries both binaries now link

Every Linux build failed:

  auto-patchelf could not satisfy dependency libyaml-cpp.so.0.8
  wanted by .../bin/.logoscore-wrapped

The packaging derivations listed only Qt in buildInputs, which is what
autoPatchelfHook resolves DT_NEEDED entries against. yaml_json.cpp and the
log sink are in the shared sources, so *both* binaries link yaml-cpp and
spdlog -- including logoscore, which is why its Linux build broke too on a
branch that was supposed to leave it alone.

macOS does not patchelf, so this was invisible locally and in the macOS
CI jobs; only the Linux matrix caught it, and it took down the AppImage
builds, the CI job, and every Linux doc-test with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(doctests): bring the logosctl specs up to what logosctl does

Nineteen doc-test steps were failing. None of them were runtime bugs in
the specs' own right -- they were specs still describing an older
logosctl, which is its own kind of failure: a doc-test that lies is worse
than no doc-test.

  transports      Still drove `--module-transport` and hand-written
                  client/config.json. The flags had been dropped from the
                  `run:` lines but no config step replaced them, so the
                  daemon never bound TCP at all and every step after it
                  failed. Rewritten around `daemon config set` /
                  `client config set` with YAML documents, for both the
                  plaintext and TLS halves.

  daemon          Read the log at session/daemon/daemon.log; logs moved to
                  session/logs/. The crash-recovery step passed `-m`,
                  which logosctl does not accept, so its daemon never
                  started and the step reported LEAKED against a worker
                  that had never existed.

  modules-bundle  Asserted all three modules in result/modules. The
                  package modules live in modules-pkg/ so that logoscore's
                  modules/ stays byte-identical -- which the spec is now
                  the place that explains.

  packages        Expected the interactive wording ("dry run",
                  "Installed:"). Doc-tests are not a terminal, so every
                  command renders JSON. The install was working the whole
                  time; only the assertions were wrong. They now match the
                  JSON, and the prose says why it is JSON.

Rewriting the transports spec is what turned up the TLS and --detach bugs
fixed in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(config): a typo must not abort the daemon, and a key must not lie

Three defects in the YAML config path, all found by building a Python
client against this CLI and checking its assumptions against the binary
rather than the docs.

**A config typo aborted the process.**

  printf 'version: 2\nmodules_dirs: /single/path\n' > bad.yaml
  logosctl --config-dir ./s daemon config set ./bad.yaml
  => libc++abi: terminating due to uncaught exception ...
     [json.exception.type_error.302] type must be array, but is string

nlohmann's `json::value(key, default)` THROWS when the key is present
with the wrong type, every config read used it, and nothing caught it.
So it was not one key -- it was every key in both readers. A scalar
where a list belongs is an ordinary mistake and it killed the binary.

Now a type-checked reader (src/json_schema.h) records
"<dotted.path>: expected <what>, but got <what>" and the document is
refused whole, the same shape as the existing unknown-key error:

  {"code":"INVALID_CONFIG",
   "message":"modules_dirs: expected a list of strings, but got a string."}

Both readers went through it, including two paths that could abort the
daemon mid-boot rather than at `config set`.

**`config set` validated after writing.** A schema-invalid document was
installed and then reported as an error, leaving the session holding a
config the daemon would refuse to boot from. Validation now happens
entirely in memory first, on both the daemon and client sides -- the
client side had no schema validation at all -- and the write is
temp-file + rename instead of truncate-in-place.

That exposed a fourth: `yaml_json::dump` emitted numeric-looking strings
bare, so `port: "6001"` came back as the number 6001. The bytes
validated were not the bytes written.

**Two keys were accepted, stored, and never applied.**

`signature_policy` sat on the allowlist and was written verbatim to
config.yaml but was never even parsed. An operator setting `require` got
no enforcement and no warning. It is now parsed with a strict allowlist
and pushed into package_manager at boot beside setKeyringDirectory --
the module has had setSignaturePolicy all along. Unset issues no RPC, so
the module keeps its own default instead of having it restated.

The top-level `ssl: {cert, key, ca}` block was parsed into DaemonConfig
and read by nobody; only per-listener cert/key reached the transport
set. Configuring TLS the obvious way therefore produced listeners with
no certificate and "no shared cipher" on every handshake -- the same
failure fixed one layer down last commit. It is now a session-wide
default that per-listener values override.

Also: docs advertised `module load --no-deps`, which does not exist --
`module load` takes only a positional name and always resolves
dependencies. Corrected, along with the rest of the command reference,
verified against the binary.

logoscore is untouched: 20 CLI + 18 integration, exactly as before.
logosctl 171 unit (was 144) + 25 CLI (was 18) + 18 integration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(detach): re-exec the launcher, not the ELF it hides

`daemon start --detach` was dead on Linux portable builds. The daemon
exited immediately with status 127, no output, and no log file -- so the
only diagnostic was "daemon exited during startup. See <path>", naming a
file that had never been created.

strace, on a real Linux box, said it in one line:

  execve(".../bin/.logosctl.elf", [...]) = -1 ENOENT
  exit_group(127)

A portable bundle installs the CLI as a launcher script beside a hidden
companion:

  bin/logosctl        the launcher, a shell script
  bin/.logosctl.elf   the real ELF

The launcher exists because that ELF cannot be started on its own: its
PT_INTERP names a dynamic loader that is not on the host, so the launcher
runs it through a known-good ld.so instead. The ENOENT is the kernel
reporting the missing *interpreter* -- the ELF is right there.

--detach re-execs itself (it has to: macOS forbids running a forked
process that has initialized CoreFoundation), and it re-exec'd
executablePath(), which is that ELF.

My first attempt preferred argv[0], reasoning that it is what the caller
actually typed. That was wrong, and the trace showed it failing
identically: the launcher execs ld.so with the ELF, ld.so drops itself
from argv, and the program sees the ELF as argv[0] too. Neither source
of truth names the launcher.

So the mapping is applied to whatever candidate we end up with, using the
convention the launcher script itself documents -- the install dir is the
one holding the hidden companion `.$BASE.elf`. `bin/.logosctl.elf` maps
back to `bin/logosctl`. argv[0] is still preferred over
executablePath() (it is what was invoked, and it is right when a bare
name resolves through PATH), and it is absolutised, since the daemon may
run from a different directory.

Only this combination was ever broken: portable AND Linux AND --detach.
macOS bundles a real binary with qt.conf and no launcher, Linux dev
builds are ordinary ELFs, and the foreground -D path never re-execs. The
one doc-test that uses the portable bundle is the packages spec, and
cachix served a permanent 522 for one of its store paths from the day it
was written -- so its 14 cascading failures read as infrastructure until
the cache recovered and the real failure surfaced underneath.

Verified on Linux against the same bundle that failed: daemon starts
detached, all three bundled modules load, `daemon stop` returns ok.

Also here, and what made the diagnosis possible: --detach now prints the
TAIL of the daemon log rather than its path. The startup file only holds
output from before LogSink takes the descriptors, so a daemon that dies
after logging is up left it empty and the reason unread. That there was
no log at all is what pointed at exec.

179 unit tests (8 new, covering the launcher mapping and its edges: no
sibling, an ordinary foo.elf, a non-executable candidate, absent argv[0]).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build: bundle logosctl/logoscore as headless Qt programs

* build: bump nix-bundle-dir and nix-bundle-appimage to main

Picks up the merged trampoline drop: per-arch psABI PT_INTERP, DT_RPATH,
qtCliApp for headless Qt, and the AppImage consumer that already tracks
the same pin. nix-bundle-dir 4fd87d1 (PR tip) → cb9afc8; appimage
8fcc56b → 04a3cf8.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 21:49:15 -03:00

48 KiB

Logosctl CLI Specification

Overview

The logosctl CLI is the primary interface for operating the Logos Core runtime. It manages the lifecycle of a daemon process that hosts independently developed modules (plugins), and provides commands to load modules, call methods, watch events, and inspect runtime state.

The CLI follows a daemon + client architecture. A long-running daemon process hosts the module runtime, and short-lived client commands connect to it to perform operations.

Design Goals

  1. Human-friendly — Readable output, discoverable commands, helpful error messages with recovery suggestions.
  2. Agent-friendly — Structured JSON output, non-interactive operation, streaming events as NDJSON, deterministic exit codes. An AI agent using a bash tool should be able to operate the full lifecycle without any interactive prompts or ambiguous output.
  3. Composable — Each command does one thing and works well in pipelines. Output goes to stdout, diagnostics to stderr.
  4. Daemon-oriented — A long-running daemon owns the modules; clients connect to it. The daemon starts clean (-m/--persistence-path configure startup with -D); modules are loaded via load-module.

Architecture

                    ┌──────────────────────────┐
                    │     logosctl daemon     │
                    │                          │
                    │  ┌────────────────────┐  │
                    │  │    core_service    │  │
                    │  │ (in-process module)│  │
                    │  └─────────▲──────────┘  │
                    │            │             │
                    │     Qt Remote Objects    │
                    │            │             │
                    └────────────┼─────────────┘
                               │
              ┌────────────────┼────────────────┐
              │                │                │
     ┌────────▼──────┐ ┌──────▼───────┐ ┌──────▼───────┐
     │ logosctl     │ │ logosctl    │ │ logosctl    │
     │ load-module   │ │ call chat   │ │ watch chat   │
     │ waku          │ │ send "hi"   │ │ --event msg  │
     └───────────────┘ └──────────────┘ └──────────────┘
         (exits)          (exits)        (streams)

Daemon (logosctl daemon start):

  • Starts the Logos Core runtime and Qt event loop.
  • Discovers modules in configured directories.
  • Writes ~/.logosctl/daemon/state.json (live runtime state — instance_id, pid, started_at, resolved transports) on startup, removed on clean shutdown.
  • Maintains ~/.logosctl/daemon/tokens.json (hashed-at-rest accepted-token list — survives restarts).
  • Persists ~/.logosctl/daemon/config.json (operator preferences) only when the operator passed --persist-config.
  • Auto-issues an auto token for the local same-host client and emits ~/.logosctl/client/config.json + ~/.logosctl/client/auto.json on the first boot into an empty config dir.

Client commands (all other subcommands):

  • Read ~/.logosctl/client/config.json to learn how to dial the daemon and which token file to load.
  • Connect to the daemon's core_service module via RPC using the token.
  • Execute the requested operation, print the result, and exit.
  • If no daemon is running, exit with code 2 and a clear error message.

Command Structure

logosctl [global-flags] <command> [command-flags] [args...]

Global Flags

Flag Short Description
--json -j Output as JSON. Default when stdout is not a TTY.
--modules-dir <path> -m Module search directory (daemon mode only, repeatable).
--config-dir <path> Override the config directory (default: ~/.logosctl; also LOGOSCTL_CONFIG_DIR). Client commands must pass the same value as the daemon they target. The directory contains the daemon/ and client/ subtrees (see Authentication).
--quiet -q Suppress non-essential output.
--verbose -v Show debug/info/warning logs (suppressed by default).
--help -h Show help.
--version Show version.

Daemon-side transport flags

The daemon defaults to a local Unix socket only for each well-known module (core_service, capability_module). To expose either over the network, pass one or more --module-transport flags; each opens an additional listener that gets advertised in daemon/state.json's resolved block.

Local is always present. Every module the operator configures (and the two well-known ones) implicitly gets a LocalSocket listener prepended to its resolved transport set, in addition to whatever the operator named via --module-transport. The operator's TCP / TCP+SSL flags add additional outside-facing surfaces; they don't replace the same-host LocalSocket. This keeps the same-host code paths (the parent's capability_module handshake, the SDK's auto-requestModule flow inside LogosAPIClient, cross-module getClient(name) calls) working over LocalSocket regardless of which network transport the operator chose. daemon/state.json's resolved.modules.<name>.transports[] always lists the LocalSocket entry first, followed by operator-named entries in the order they were typed.

Flag Applies to Description
--module-transport NAME=PROTOCOL[,k=v...] daemon Repeatable. NAME is any module the daemon will load (well-known or user-configured). PROTOCOL is local, tcp, or tcp_ssl. Each occurrence adds one listener to the named module. If the flag is omitted entirely, every well-known module gets a single local listener; if it's passed without a local entry for NAME, a local listener is added implicitly so same-host callers always work.
--insecure-tcp daemon Allow tcp (plaintext) listeners on a non-loopback host. Without this flag, the daemon refuses to bind such a listener because tokens travel in cleartext.

The k=v pairs after the protocol configure the listener:

Key Used by Description
host tcp, tcp_ssl Bind address. Defaults to 127.0.0.1 for tcp.
port tcp, tcp_ssl Port (0 = auto-assign).
codec tcp, tcp_ssl Wire codec: json (default, debuggable) or cbor (compact).
cert tcp_ssl Server cert PEM file.
key tcp_ssl Server private key PEM file.
ca tcp_ssl CA cert PEM file.
verify_peer tcp_ssl true / false — require client cert verification.

Each well-known module needs its own listener so the host-side client can dial each. Examples:

# TCP — plaintext, good for localhost or trusted networks. Local
# listeners are added implicitly; just name the TCP one for each
# module that needs an outside-facing surface.
--module-transport core_service=tcp,host=127.0.0.1,port=6000
--module-transport capability_module=tcp,host=127.0.0.1,port=6001

# TCP + TLS — wire-encrypted; cert + key required, CA optional. Local
# listeners are still added implicitly.
--module-transport "core_service=tcp_ssl,host=0.0.0.0,port=6443,cert=/p/c.pem,key=/p/k.pem,ca=/p/ca.pem"
--module-transport "capability_module=tcp_ssl,host=0.0.0.0,port=6444,cert=/p/c.pem,key=/p/k.pem,ca=/p/ca.pem"

# Per-module: applies to user modules too. The operator's TCP listener
# is the additional outside-facing surface; same-host callers still
# reach `my_module` over LocalSocket without extra configuration.
--module-transport my_module=tcp,host=127.0.0.1,port=6010

Client-side dial spec

Client commands never read daemon-only files (daemon/config.json, daemon/tokens.json). They read <configDir>/client/config.json, which holds the dial spec (endpoint, host, port, codec, cert/key/ca/verify_peer for TLS) and a token_file pointing at the raw-token file alongside. (status consults daemon/state.json for a fast same-host liveness check via kill(pid, 0), but never opens daemon-only secrets.)

The daemon auto-emits client/config.json + client/auto.json for the local same-host case on the first boot into an empty config dir — local clients work out of the box with no manual setup. Subsequent boots leave an existing client/config.json alone (so an operator-written remote-client config isn't clobbered). For remote clients (port-forwarded containers, NAT, SSH tunnels) hand-write client/config.json with the right host:port for each module and reference a token_file whose contents was copied from a daemon/tokens/<name>.json on the daemon host.


Commands

daemon / -D

Start the daemon process.

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

Starts the Logos Core runtime in the foreground. Startup and shutdown messages go to stdout (so > logs.txt captures them); debug/info/warning logs go to stderr and are suppressed unless --verbose is passed. Writes ~/.logosctl/daemon/state.json on startup (and on the first fresh boot also emits ~/.logosctl/client/config.json + ~/.logosctl/client/auto.json for the local client), removes state.json on clean shutdown.

The daemon scans the configured module directories for available plugins and makes them available for loading via client commands.

load-module <name>

Load a module into the running daemon.

logosctl module load <name>

Resolves and loads the named module and all its dependencies. The module must be discoverable in one of the directories configured when the daemon was started.

unload-module <name>

Unload a module from the running daemon.

logosctl module unload <name>

list-modules

List available or loaded modules.

logosctl module ls [--loaded]

Without flags, lists all known (discovered) modules. With --loaded, lists only currently loaded modules.

Each module has a status: loaded, not_loaded, crashed, or loading. When a module has crashed, the output includes uptime (or - if not running) and crash metadata is available via module-info.

status

Show overall daemon and module health.

logosctl daemon status

Displays daemon state (PID, uptime, version, instance ID, configured listeners) and a summary of all modules with their status. This is the single "dashboard" command — it shows everything at a glance so agents don't need to chain multiple commands.

When the daemon is not running, exits with code 2 and suggests how to start it.

reload-module <name>

Unload and re-load a module.

logosctl module reload <name>

Performs an unload followed by a load in a single operation. Useful for recovering crashed modules or picking up configuration changes. If the module is not currently loaded, it falls back to a plain load (rather than erroring), reducing edge cases for agents that just want a module running.

module-info <name>

Show detailed information about a specific module.

logosctl module show <name>

Displays extended metadata: version, status, dependencies, available methods, emitted events, process info (PID, uptime), and crash details if applicable. Methods and events each carry their description (from the module's header doc comments) when documented. This is the deep-inspection counterpart to list-modules.

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

Call a method on a loaded module.

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

Invokes the named method on the specified module. Arguments are positional. Use the @file prefix to read a parameter value from a file.

Arguments are automatically type-coerced: numeric strings become integers or doubles, "true"/"false" become booleans, and everything else remains a string. This allows method signatures with typed parameters to match correctly.

logosctl call chat send_message "hello"
logosctl call storage load_config @config.json
logosctl call math twoArgs "hello" 2          # "hello" as string, 2 as integer
logosctl call config setBool "flag" true       # "flag" as string, true as boolean

Alternative syntax (explicit form for readability):

logosctl module <name> method <method> [args...]
logosctl module chat method send_message "hello"

Both forms are equivalent. call is the short form; module ... method ... is the verbose form.

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

Watch events from a loaded module.

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

Streams events to stdout as they arrive. Without --event, streams all events from the module. Runs until interrupted (SIGINT / SIGTERM).

logosctl watch chat --event chat-message
logosctl watch chat --event chat-message >> events.log &
logosctl watch chat --event chat-message --json | jq .

stats

Show resource usage for loaded modules.

logosctl stats

Displays CPU and memory usage for each loaded module process.

stop

Stop the running daemon.

logosctl daemon stop

Sends a shutdown request to the daemon via core_service. The daemon performs a clean shutdown: unloads all modules, removes daemon/state.json, and exits. The client prints a confirmation message and exits.

If the daemon exits before the RPC response arrives (expected behavior), the client treats the connection loss as a successful shutdown.

Human:

$ logosctl daemon stop
Daemon stopped.

JSON:

$ logosctl daemon stop --json
{"status":"ok","message":"Daemon shutting down."}

info <module>

Alias for module-info <module>. See module-info above for full details.

logosctl module show <module>

Displays version, dependencies, available methods, and crash details (if applicable) for the named module.

issue-token --name <name>

Issue a new named token and write it to <configDir>/daemon/tokens/<name>.json.

logosctl token issue --name <name> [--replace] [--expires <dur>] [--local-only]

Appends an entry to <configDir>/daemon/tokens.json["tokens"] (a {name, hash, issued_at, expires_at, local_only} row, hashes are SHA-256 hex) and writes a companion raw-value file at daemon/tokens/<name>.json for distribution. Without --replace, the command refuses to overwrite an existing token with the same name so a stale credential isn't silently invalidated; pass --replace to rotate.

--expires <dur> sets a TTL after which the daemon rejects the token (e.g. 30d, 12h). --local-only marks the token as valid only over LocalSocket, so even a compromised TCP listener can't replay it.

After copying daemon/tokens/<name>.json to the client host (typically into the client's <configDir>/client/), the operator may delete the daemon-side raw file — the daemon validates against the in-memory map seeded from tokens.json["tokens"]'s hashes, not the raw file. Distribute the raw file the way you'd distribute a private key; do not commit it to version control.

This command operates directly on the config dir on disk; it doesn't need the daemon to be running. Operator-issued tokens take effect on the next daemon restart (SIGHUP-driven reload is a follow-up).

revoke-token <name>

Remove a named token from <configDir>/daemon/tokens.json["tokens"].

logosctl token revoke <name>

After this returns, any RPC presenting the revoked token is rejected by the daemon with an authentication error. The on-disk daemon/tokens/<name>.json file is also removed so clients that still have it can't mistake it for valid.

list-tokens

List all tokens currently issued against this config dir.

logosctl token ls

Shows token name, issued-at timestamp, expires-at, and the local-only flag — never the plaintext token, which only lives in the daemon/tokens/<name>.json file at the moment of issuance. Lost a token? Rotate it with issue-token --replace.


Authentication

How Tokens Work

Logos Core uses UUID-based tokens for authentication. Every module loaded into the runtime receives a unique token generated by the core. These tokens are used to authorize RPC calls between components.

The CLI needs a token to authenticate with the daemon's core_service. This token is called the client token and is generated by the daemon on startup.

Token Lifecycle

1. DAEMON STARTS
   logosctl daemon start --detach
   → Daemon mints an "auto" token (local_only=true) for the local client
   → Hash + metadata persisted into ~/.logosctl/daemon/tokens.json["tokens"]
   → Raw value emitted to ~/.logosctl/client/auto.json
   → ~/.logosctl/client/config.json written so local clients dial correctly

2. CLIENT CONNECTS
   logosctl module load waku
   → Reads ~/.logosctl/client/config.json (dial spec + token_file)
   → Loads the raw token from the file token_file points at
   → Sends token with RPC request to `core_service`
   → `core_service` validates the token's hash against tokens.json["tokens"]
   → Request authorized, module loads

3. REMOTE / PROGRAMMATIC ACCESS
   LOGOSCTL_TOKEN=<token> logosctl module load waku
   → Token from env var overrides the one in client/config.json's token_file
   → Useful when client/ isn't writable (remote, containers, CI)

Token Resolution Order

When a client command runs, the token is resolved in this order (first match wins):

Priority Source Example
1 LOGOSCTL_TOKEN env var LOGOSCTL_TOKEN=abc123 logosctl module ls
2 <configDir>/client/<token_file> the path is whatever client/config.json says (defaults to auto.json)

A named token issued by logosctl token issue --name alice produces <configDir>/daemon/tokens/alice.json on the daemon host. To use it as a client on a different machine, copy the file into the client host's <configDir>/client/ and reference it via token_file in client/config.json. Once copied, the operator may delete the daemon-side raw file — validation keeps working because the hash is what the daemon checks.

Obtaining a Token

Local usage (same machine): No manual token management needed. At boot the daemon auto-issues an auto token (with local_only=true, so it can't be used over TCP), writes the hash into daemon/tokens.json["tokens"], and emits the raw value into client/auto.json alongside a local-default client/config.json. Local client commands just work.

Remote or programmatic usage: Issue a named token on the daemon host and move it to the client host:

# On the machine running the daemon:
logosctl token issue --name alice
cat ~/.logosctl/daemon/tokens/alice.json
# Output: 550e8400-e29b-41d4-a716-446655440000

# On the remote machine or in a script:
export LOGOSCTL_TOKEN=550e8400-e29b-41d4-a716-446655440000
logosctl module ls --json

# Or persist by copying the file alongside a hand-written client/config.json:
mkdir -p ~/.logosctl/client
scp daemon-host:~/.logosctl/daemon/tokens/alice.json ~/.logosctl/client/alice.json
# then edit ~/.logosctl/client/config.json so token_file = "alice.json"

CI / containers: Pass the token as an environment variable at runtime:

docker run -e LOGOSCTL_TOKEN=$TOKEN myimage logosctl module ls --json

Daemon files (config / state / tokens)

The daemon dir splits by lifetime into three files:

  • daemon/state.json — live runtime state. Written every boot (after transports actually bind), removed on clean shutdown.
  • daemon/config.json — operator preferences. Written ONLY when the operator passed --persist-config; otherwise absent.
  • daemon/tokens.json — hashed-at-rest accepted-token list. Independent of the running daemon's lifetime.

daemon/state.json

{
  "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
  }
}
  • instance_id is a 12-char UUID prefix the client uses with LogosInstance::id() to reconstruct the same registry URL the daemon published (local:logos_core_service_<id>).
  • pid lets co-resident clients detect a stale state file (kill(pid, 0) == ESRCH after a hard crash).
  • config_source records where the running daemon's config came from: cli (any --module-transport/--insecure-tcp/etc. flag was passed), config.json (loaded from disk only), or defaults.
  • resolved.modules is the post-bind transport set: port: 0 in config.json becomes the actually-bound port here.
  • resolved mirrors the shape of daemon/config.json (same field set, minus version).

daemon/config.json (operator preferences)

Same shape as state.json's resolved block, plus version. Reflects operator intentport: 0 stays 0 (auto-pick) — not the resolved post-bind values. Written only when --persist-config is passed.

daemon/tokens.json

{
  "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 }
  ]
}

One entry per issued token: {name, hash, issued_at, expires_at, local_only}. Hashes are SHA-256 hex; raw values live only in daemon/tokens/<name>.json at issue time. Independent of the running daemon's lifetime — survives restarts.

These three files are daemon-owned; the client never reads config.json or tokens.json, and only consults state.json for a fast same-host liveness check via kill(pid, 0). The client reads <configDir>/client/config.json to learn how to dial. Liveness — is the daemon actually answering? — falls through to the first RPC (e.g. status), so a connect failure surfaces via the same code path as any other method call.


Output Design

Every command produces output in one of two modes: human (default when stdout is a TTY) or JSON (when --json is passed or stdout is piped/redirected).

load-module

Human:

$ logosctl module load waku
Loaded module: waku (v0.1.0)
  Dependencies loaded: store

JSON:

$ logosctl module load waku --json
{"status":"ok","module":"waku","version":"0.1.0","dependencies_loaded":["store"]}

Error (human):

$ logosctl module load nonexistent
Error: Module 'nonexistent' not found.
  Known modules: waku, chat, delivery, store
  Scan additional directories with: logosctl daemon start -m /path/to/modules

Error (JSON):

$ logosctl module load nonexistent --json
{"status":"error","code":"MODULE_NOT_FOUND","message":"Module 'nonexistent' not found.","known_modules":["waku","chat","delivery","store"]}

unload-module

Human:

$ logosctl module unload waku
Unloaded module: waku

JSON:

$ logosctl module unload waku --json
{"status":"ok","module":"waku"}

list-modules

Human:

$ logosctl module ls
NAME        VERSION   STATUS      UPTIME
waku        v0.1.0    loaded      2h 14m
chat        v0.2.0    crashed     -
delivery    v0.1.0    not loaded  -
store       v0.3.0    loaded      2h 14m

$ logosctl module ls --loaded
NAME        VERSION   STATUS    UPTIME
waku        v0.1.0    loaded    2h 14m
store       v0.3.0    loaded    2h 14m

JSON:

$ logosctl module ls --json
[
  {"name":"waku","version":"0.1.0","status":"loaded","uptime_seconds":8040},
  {"name":"chat","version":"0.2.0","status":"crashed","exit_code":139,"crashed_at":"2026-03-23T14:22:01Z","crash_reason":"SIGSEGV"},
  {"name":"delivery","version":"0.1.0","status":"not_loaded"},
  {"name":"store","version":"0.3.0","status":"loaded","uptime_seconds":8040}
]

Note: the status field is an enum of loaded | not_loaded | crashed | loading. Crash metadata (exit_code, crashed_at, crash_reason) only appears when status is crashed — the JSON doesn't bloat clean entries with null crash fields.

status

Human:

$ logosctl daemon status
Logosctl Daemon
  Status:       running
  PID:          12847
  Uptime:       4h 32m
  Version:      v0.5.0
  Instance ID:  a3f1...c8d2
  State file:   /Users/iuri/.logosctl/daemon/state.json

Modules: 3 loaded, 1 crashed, 1 not loaded
  waku        v0.1.0    loaded      2h 14m
  chat        v0.2.0    crashed     -
  delivery    v0.1.0    not loaded  -
  store       v0.3.0    loaded      4h 32m
  payments    v0.1.0    loaded      4h 32m

JSON:

$ logosctl daemon status --json
{
  "daemon": {
    "status": "running",
    "pid": 12847,
    "version": "0.5.0"
  },
  "modules_summary": {
    "loaded": 3,
    "crashed": 1,
    "not_loaded": 1
  },
  "modules": [
    {"name":"waku","version":"0.1.0","status":"loaded","uptime_seconds":8040},
    {"name":"chat","version":"0.2.0","status":"crashed","exit_code":139,"crashed_at":"2026-03-23T14:22:01Z"},
    {"name":"delivery","version":"0.1.0","status":"not_loaded"},
    {"name":"store","version":"0.3.0","status":"loaded","uptime_seconds":16320},
    {"name":"payments","version":"0.1.0","status":"loaded","uptime_seconds":16320}
  ]
}

When daemon is not running:

$ logosctl daemon status
Logosctl Daemon
  Status:       not running

No daemon state file at /Users/iuri/.logosctl/daemon/state.json
Run "logosctl daemon start" to start the daemon.

$ echo $?
1
$ logosctl daemon status --json
{
  "daemon": {
    "status": "not_running"
  }
}
$ echo $?
1

reload-module

Human:

$ logosctl module reload chat
Unloading chat...  done
Loading chat...    done
Module "chat" reloaded successfully (v0.2.0, pid 51203)

JSON:

$ logosctl module reload chat --json
{
  "action": "reload",
  "module": "chat",
  "version": "0.2.0",
  "status": "loaded",
  "pid": 51203,
  "previous_status": "crashed",
  "duration_ms": 340
}

When reload fails:

$ logosctl module reload chat
Unloading chat...  done
Loading chat...    failed

Error: module "chat" failed to start (exit code 1)
  Last log: "Config file not found: /etc/logosctl/chat.toml"

Run "logosctl module-logs chat --tail 20" for details.

$ echo $?
3
$ logosctl module reload chat --json
{
  "action": "reload",
  "module": "chat",
  "status": "error",
  "error": "module failed to start",
  "exit_code": 1,
  "last_log_line": "Config file not found: /etc/logosctl/chat.toml"
}
$ echo $?
3

Reload a module that isn't loaded (behaves like load):

$ logosctl module reload delivery
Module "delivery" is not loaded. Loading...
Loading delivery...  done
Module "delivery" loaded successfully (v0.1.0, pid 51210)

module-info

Human:

$ logosctl module show chat
Name:          chat
Version:       v0.2.0
Status:        loaded
PID:           23457
Uptime:        2h 14m
Dependencies:  waku, store

Methods:
  send_message(text: QString) -> QString
      Sends a chat message to the active channel.
  get_history() -> QJsonArray
      Returns the message history for the active channel.
  set_nickname(name: QString) -> bool
  get_status() -> QString

Events:
  message_received(from: QString, body: QString)
      Emitted when a new message arrives on the active channel.
  connection_changed(online: bool)

Each method line shows name(param: type, …) -> returnType. When a method carries documentation, its description is printed on the following line(s), indented — a multi-line doc comment keeps its line breaks, one indented line each. The description originates from the doc comment written directly above the method's declaration in the module's header (see the module-builder docs); methods without a doc comment simply omit it.

The Events section lists the events the module emits, in the same name(param: type, …) form — but with no return type, since events are fire-and-forget. An event's description (from the doc comment above its logos_events: declaration) is printed indented beneath it, exactly as for methods. The section is omitted when the module declares no events.

Crashed module:

$ logosctl module show chat
Name:          chat
Version:       v0.2.0
Status:        crashed
Exit Code:     139 (SIGSEGV)
Crashed At:    2026-03-23T14:22:01Z
Restart Count: 3
Last Log:      "Segmentation fault in message_handler.cpp:142"

JSON:

$ logosctl module show chat --json
{
  "name": "chat",
  "version": "0.2.0",
  "status": "loaded",
  "pid": 23457,
  "uptime_seconds": 8040,
  "dependencies": ["waku", "store"],
  "methods": [
    {"name": "send_message", "signature": "send_message(QString)", "returnType": "QString", "isInvokable": true, "description": "Sends a chat message to the active channel.", "parameters": [{"name": "text", "type": "QString"}]},
    {"name": "get_history", "signature": "get_history()", "returnType": "QJsonArray", "isInvokable": true, "description": "Returns the message history for the active channel.", "parameters": []},
    {"name": "set_nickname", "signature": "set_nickname(QString)", "returnType": "bool", "isInvokable": true, "parameters": [{"name": "name", "type": "QString"}]},
    {"name": "get_status", "signature": "get_status()", "returnType": "QString", "isInvokable": true, "parameters": []}
  ],
  "events": [
    {"name": "message_received", "signature": "message_received(QString,QString)", "description": "Emitted when a new message arrives on the active channel.", "parameters": [{"name": "from", "type": "QString"}, {"name": "body", "type": "QString"}]},
    {"name": "connection_changed", "signature": "connection_changed(bool)", "parameters": [{"name": "online", "type": "bool"}]}
  ]
}

The methods array is the module's getPluginMethods introspection, emitted verbatim. Each entry carries name, signature, returnType, isInvokable, parameters (each {name, type}), and — when the method is documented — description (sourced from the method's header doc comment).

The events array is the module's getPluginEvents introspection. Each entry carries name, signature, parameters (each {name, type}), and — when the event is documented — description. There is no returnType/isInvokable: events are void. Modules with no declared events report an empty array (legacy provider modules always do).

Crashed module (JSON):

$ logosctl module show chat --json
{
  "name": "chat",
  "version": "0.2.0",
  "status": "crashed",
  "exit_code": 139,
  "crash_signal": "SIGSEGV",
  "crashed_at": "2026-03-23T14:22:01Z",
  "restart_count": 3,
  "last_log_line": "Segmentation fault in message_handler.cpp:142",
  "pid_before_crash": 48291
}

call

Human:

$ logosctl call chat send_message "hello world"
message sent (id: msg_4a7b2c)

$ logosctl call math add 2 3
5

In human mode, scalar results (strings, numbers, booleans) are printed as plain values. Structured results (objects, arrays) are printed as indented JSON. Null results produce no output.

JSON:

$ logosctl call chat send_message "hello world" --json
{"status":"ok","module":"chat","method":"send_message","result":"message sent (id: msg_4a7b2c)"}

When the method returns structured data:

$ logosctl call chat get_history --json
{"status":"ok","module":"chat","method":"get_history","result":[{"id":"msg_4a7b2c","from":"alice","text":"hello","timestamp":"2026-03-23T14:30:01Z"},{"id":"msg_5d8e3f","from":"bob","text":"hi there","timestamp":"2026-03-23T14:30:05Z"}]}

LogosResult return values:

Methods declared to return LogosResult (the common ok/error wrapper) are serialised as:

{"success": <bool>, "value": <any>, "error": <any>}

value is whatever the method stuffed in on success; error is whatever it stuffed in on failure; the unused side is null. Same shape regardless of whether the daemon-module hop went over the local socket (QRO), TCP, or TCP+SSL — pick the transport you like, assertions stay identical.

$ logosctl call account create_account --json
{"status":"ok","module":"account","method":"create_account",
 "result":{"success":true,"value":{"id":"42","name":"alice"},"error":null}}

$ logosctl call account create_account --json    # duplicate name
{"status":"ok","module":"account","method":"create_account",
 "result":{"success":false,"value":null,"error":"name already taken"}}

Error (human):

$ logosctl call chat nonexistent_method
Error: Method 'nonexistent_method' not found on module 'chat'.
  Available methods: send_message, get_history, set_nickname, get_status

Error (JSON):

$ logosctl call chat nonexistent_method --json
{"status":"error","code":"METHOD_NOT_FOUND","message":"Method 'nonexistent_method' not found on module 'chat'.","available_methods":["send_message","get_history","set_nickname","get_status"]}

Timeout error (JSON):

$ logosctl call chat slow_operation --json
{"status":"error","code":"TIMEOUT","message":"Call to chat.slow_operation timed out after 30s."}

watch

Streams continuously until interrupted. Each event is printed as it arrives.

Human:

$ logosctl watch chat --event chat-message
[14:30:01] chat :: chat-message
  from: alice
  text: hello world

[14:30:05] chat :: chat-message
  from: bob
  text: hi there

[14:31:12] chat :: chat-message
  from: alice
  text: how are you?
^C

JSON (NDJSON — one self-contained JSON object per line):

$ logosctl watch chat --event chat-message --json
{"timestamp":"2026-03-23T14:30:01Z","module":"chat","event":"chat-message","data":{"from":"alice","text":"hello world"}}
{"timestamp":"2026-03-23T14:30:05Z","module":"chat","event":"chat-message","data":{"from":"bob","text":"hi there"}}
{"timestamp":"2026-03-23T14:31:12Z","module":"chat","event":"chat-message","data":{"from":"alice","text":"how are you?"}}

All events from a module (no --event filter):

$ logosctl watch chat --json
{"timestamp":"2026-03-23T14:30:01Z","module":"chat","event":"chat-message","data":{"from":"alice","text":"hello"}}
{"timestamp":"2026-03-23T14:30:02Z","module":"chat","event":"user-joined","data":{"user":"bob"}}
{"timestamp":"2026-03-23T14:30:05Z","module":"chat","event":"chat-message","data":{"from":"bob","text":"hi"}}
{"timestamp":"2026-03-23T14:30:06Z","module":"chat","event":"typing","data":{"user":"alice"}}

stats

Human:

$ logosctl stats
MODULE      PID     CPU%    MEMORY
waku        23456   2.1%    48.3 MB
chat        23457   0.4%    22.1 MB
store       23458   0.1%    15.7 MB

JSON:

$ logosctl stats --json
[
  {"name":"waku","pid":23456,"cpu_percent":2.1,"memory_mb":48.3},
  {"name":"chat","pid":23457,"cpu_percent":0.4,"memory_mb":22.1},
  {"name":"store","pid":23458,"cpu_percent":0.1,"memory_mb":15.7}
]

info

Alias for module-info. See the module-info output section above for all output examples including human, JSON, and crashed module variants.

No daemon running

Human:

$ logosctl module ls
Error: No running logosctl daemon.
  Start one with: logosctl daemon start
  Start with modules: logosctl daemon start -m /path/to/modules

JSON:

$ logosctl module ls --json
{"status":"error","code":"NO_DAEMON","message":"No running logosctl daemon. Start one with: logosctl daemon start"}

Output Rules

  • Primary output (results, data) goes to stdout.
  • Debug, info, and warning logs go to stderr and are suppressed by default. Pass --verbose to show them.
  • Critical and fatal errors always go to stderr.
  • In JSON mode, colors are disabled and only structured data goes to stdout.
  • JSON mode auto-activates when stdout is not a TTY (piped or redirected), so agents and scripts get JSON by default without needing --json.
  • Daemon startup/shutdown messages go to stdout, so logosctl daemon start > logs.txt captures them correctly.

Error Handling

Exit Codes

Code Meaning When
0 Success Operation completed
1 General error Unexpected failure, invalid arguments
2 Connection error No daemon running, daemon unreachable
3 Module error Module not found, failed to load/unload
4 Method error Method not found, invocation failed, timeout

JSON Error Envelope

All errors in JSON mode follow this structure:

{
  "status": "error",
  "code": "ERROR_CODE",
  "message": "Human-readable description with recovery suggestion."
}

Error codes: NO_DAEMON, DAEMON_UNREACHABLE, MODULE_NOT_FOUND, MODULE_LOAD_FAILED, MODULE_NOT_LOADED, METHOD_NOT_FOUND, METHOD_FAILED, TIMEOUT, AUTH_FAILED, INVALID_ARGS.


Daemon + client workflow

Module method calls go through a running daemon. Start a clean daemon with -D (it loads no modules on its own), then load modules and call methods with client subcommands:

# Start a clean daemon scanning /path
logosctl daemon start -m /path &
logosctl module load waku          # deps resolved automatically
logosctl module load chat
logosctl call chat send_message "hello"

The legacy inline mode (-c "module.method(args)" / --quit-on-finish, which started the core, ran calls in one short-lived process, and exited) has been removed, as has -l/--load-modules (the daemon now starts clean — load via load-module). -m/--persistence-path apply only to daemon startup (-D); a subcommand operates in client mode and connects to a running daemon.


AI Agent Workflow

This section describes how an AI agent (such as Claude Code, Cursor, or similar tools that execute bash commands via a tool-use interface) would interact with the logosctl CLI.

How Agents Use This CLI

AI agents interact with CLIs by executing bash commands and parsing stdout. They cannot handle interactive prompts, colored output, or ambiguous formatting. The logosctl CLI is designed for this:

  • JSON by default when piped. Since agents capture stdout programmatically (not via a TTY), JSON mode activates automatically. No need to remember --json.
  • Deterministic exit codes. Agents check $? after each command to decide whether to proceed or handle an error. Each error category has a distinct code.
  • Structured errors. When something fails, the JSON error includes a code field the agent can branch on, and a message field with recovery instructions the agent can follow.
  • No interactive prompts. Every operation completes without requiring user input.
  • Self-describing. logosctl module show <module> --json tells the agent what methods are available and what parameters they take, without needing external documentation.

Example: Agent Preflight — Health Check Before Doing Work

Before performing any operation, an agent checks daemon health and ensures required modules are running:

# Step 1: Is the daemon alive?
if ! logosctl daemon status --json | jq -e '.daemon.status == "running"' > /dev/null 2>&1; then
  echo "daemon not running, starting..."
  logosctl daemon start --detach &
  sleep 2
fi

# Step 2: Check if the module I need is healthy
MODULE_STATUS=$(logosctl daemon status --json | jq -r '.modules[] | select(.name=="chat") | .status')

case "$MODULE_STATUS" in
  "loaded")     echo "ready" ;;
  "crashed")    logosctl module reload chat ;;
  "not_loaded") logosctl module load chat ;;
  *)            echo "unknown state: $MODULE_STATUS" ; exit 1 ;;
esac

Example: Agent Detects and Recovers a Crashed Module

# Agent checks module health
STATUS=$(logosctl module ls --json | jq -r '.[] | select(.name=="chat") | .status')

if [ "$STATUS" = "crashed" ]; then
  # Get crash details for decision-making
  CRASH_INFO=$(logosctl module show chat --json)
  RESTARTS=$(echo "$CRASH_INFO" | jq '.restart_count')

  if [ "$RESTARTS" -lt 5 ]; then
    logosctl module reload chat
  else
    echo "chat module crashed $RESTARTS times, escalating"
    # agent decides to alert or investigate logs
    logosctl module-logs chat --tail 50
  fi
fi

Example: Agent Builds and Tests a Chat Application

This is a realistic sequence an AI agent would execute when asked to "set up and test the chat module":

# Step 1: Start the daemon and verify it's running
logosctl daemon start --detach &
sleep 2
logosctl daemon status --json | jq -e '.daemon.status == "running"' > /dev/null
# Agent confirms daemon is up via exit code 0.

# Step 2: Check what modules are available
logosctl module ls --json
# Agent parses:
# [
#   {"name":"waku","version":"0.1.0","status":"not_loaded"},
#   {"name":"chat","version":"0.2.0","status":"not_loaded"},
#   {"name":"store","version":"0.3.0","status":"not_loaded"}
# ]
# Agent reads the array and identifies "chat" is available.

# Step 3: Load the chat module
logosctl module load chat
# Agent parses:
# {"status":"ok","module":"chat","version":"0.2.0","dependencies_loaded":["waku","store"]}
# Agent confirms status is "ok" and notes that waku and store were auto-loaded.

# Step 4: Discover what methods are available
logosctl module show chat --json
# Agent parses:
# {
#   "name": "chat",
#   "version": "0.2.0",
#   "status": "loaded",
#   "pid": 23457,
#   "uptime_seconds": 5,
#   "dependencies": ["waku", "store"],
#   "methods": [
#     {"name": "send_message", "signature": "send_message(QString)", "returnType": "QString", "isInvokable": true, "description": "Sends a chat message to the active channel.", "parameters": [{"name": "text", "type": "QString"}]},
#     {"name": "get_history", "signature": "get_history()", "returnType": "QJsonArray", "isInvokable": true, "description": "Returns the message history for the active channel.", "parameters": []},
#     {"name": "get_status", "signature": "get_status()", "returnType": "QString", "isInvokable": true, "parameters": []}
#   ],
#   "events": [
#     {"name": "message_received", "signature": "message_received(QString,QString)", "description": "Emitted when a new message arrives on the active channel.", "parameters": [{"name": "from", "type": "QString"}, {"name": "body", "type": "QString"}]}
#   ]
# }
# Agent now knows send_message takes a text param and returns a string, and —
# from each method's "description" — what it does, without any external docs.
# The "events" array tells it which events it can watch (and what they mean).

# Step 5: Call a method
logosctl call chat send_message "hello from agent"
# Agent parses:
# {"status":"ok","module":"chat","method":"send_message","result":"message sent (id: msg_9x8y7z)"}
# Agent confirms status is "ok".

# Step 6: Verify the message was stored
logosctl call chat get_history
# Agent parses:
# {"status":"ok","module":"chat","method":"get_history","result":[{"id":"msg_9x8y7z","from":"agent","text":"hello from agent","timestamp":"2026-03-23T14:30:01Z"}]}
# Agent verifies the message appears in history.

# Step 7: Check overall system health
logosctl daemon status --json | jq '.modules_summary'
# Agent parses:
# {"loaded": 3, "crashed": 0, "not_loaded": 0}
# All modules healthy. Agent can also check per-module resource usage via `logosctl stats`.

Example: Agent Handles Errors

When an agent encounters an error, the structured output lets it self-correct:

# Agent tries to call a method on a module that isn't loaded
logosctl call delivery send_package "pkg_123"
# Exit code: 3
# {"status":"error","code":"MODULE_NOT_LOADED","message":"Module 'delivery' is not loaded. Load it with: logosctl module load delivery"}

# Agent reads the error code "MODULE_NOT_LOADED" and the recovery instruction.
# It follows the suggestion:
logosctl module load delivery
# {"status":"ok","module":"delivery","version":"0.1.0","dependencies_loaded":[]}

# Now retries the original call:
logosctl call delivery send_package "pkg_123"
# {"status":"ok","module":"delivery","method":"send_package","result":"package pkg_123 queued"}

Example: Agent Monitors Events

An agent can watch for events to react to real-time activity:

# Start watching in background, capture output to a file
logosctl watch chat --event chat-message > /tmp/chat_events.log &
WATCH_PID=$!

# ... agent does other work ...

# Later, check what events arrived
cat /tmp/chat_events.log
# {"timestamp":"2026-03-23T14:30:01Z","module":"chat","event":"chat-message","data":{"from":"alice","text":"hello"}}
# {"timestamp":"2026-03-23T14:30:05Z","module":"chat","event":"chat-message","data":{"from":"bob","text":"hi there"}}

# Agent can parse each line independently (NDJSON).
# Each line is valid JSON, so standard tools work:
# cat /tmp/chat_events.log | head -1 | jq '.data.from'
# → "alice"

# Cleanup
kill $WATCH_PID

Why These Patterns Matter for Agents

Pattern Why it helps agents
JSON auto-detection (non-TTY) Agent doesn't need to remember --json — it gets structured output automatically
Exit codes per error category Agent can branch: if exit_code == 2, start daemon; if exit_code == 3, load module
Error messages with recovery commands Agent can extract and execute the suggested fix directly
status as single dashboard One command gives daemon health + all module states — no need to chain multiple commands
module-info with method signatures + descriptions Agent discovers available operations and their intent — it reads each method's schema and description to construct calls without external docs
module-info with crash metadata Agent can programmatically distinguish OOM (137/SIGKILL) from segfault (139/SIGSEGV) from clean error (non-zero)
reload-module on unloaded module Falls back to load instead of erroring — reduces edge cases for agents that just want a module running
NDJSON streaming Agent processes events line-by-line without buffering the full stream
No interactive prompts Agent never hangs waiting for input it can't provide
Consistent JSON envelope (status, code) Agent uses the same parsing logic for all commands

Sequence Flows

Starting the Daemon and Loading Modules

1. START DAEMON
   logosctl daemon start -m /path/to/modules
   → Core initializes
   → Daemon mints "auto" token (local_only=true) for the local client
   → Scans /path/to/modules for available plugins
   → Writes ~/.logosctl/daemon/state.json (instance + resolved listeners)
   → Writes ~/.logosctl/daemon/tokens.json (hashed accepted-token list)
   → Emits ~/.logosctl/client/config.json + ~/.logosctl/client/auto.json
   → Runs event loop (foreground)

2. LOAD MODULES
   logosctl module load waku
   → Reads ~/.logosctl/client/config.json (dial spec + token_file)
   → Loads token from the file token_file points at
   → Connects to daemon's `core_service` via RPC with token
   → Daemon resolves dependencies for "waku"
   → Daemon loads dependencies first, then waku
   → Client prints result and exits

3. CALL METHODS
   logosctl call chat send_message "hello"
   → Reads dial spec + token from ~/.logosctl/client/
   → Connects to daemon
   → Invokes chat.send_message("hello") via RPC
   → Prints return value to stdout
   → Exits

4. WATCH EVENTS
   logosctl watch chat --event chat-message --json >> events.log &
   → Connects to daemon with token
   → Registers event listener for chat::chat-message
   → Streams NDJSON to stdout (redirected to events.log)
   → Runs until killed

5. STOP DAEMON
   logosctl daemon stop
   → Client sends shutdown RPC to core_service
   → Daemon schedules quit (with brief delay to send RPC response)
   → Daemon unloads all modules
   → Removes ~/.logosctl/daemon/state.json (tokens.json + config.json survive)
   → Exits

   Alternatively: Ctrl+C / kill <pid> / SIGTERM
   → Signal handler triggers QCoreApplication::quit()
   → Same cleanup as above