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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

27 KiB

logosctl

The merged CLI: logoscore + lgpd + lgpm in one tool. It runs modules like logoscore does, and it manages packages itself — bundling the package_manager and package_downloader modules, so searching a catalog, installing with dependencies resolved, loading and calling are all one binary's job.

Being validated; not yet the default. It shares the runtime with logoscore but no state — its session lives in ~/.logosctl, so nothing here can disturb a logoscore deployment.

Build instructions, flake outputs and test targets are in the main README. The .#ctl-* outputs are the ones that ship this binary; the package commands need a portable build, because that is what the public catalog ships.

Usage

logosctl runs as a daemon (long-running process) that you drive with client commands to load modules and call methods.

Daemon Mode

Start a daemon, then use client commands to manage modules and call methods.

Sessions

Everything logosctl needs lives in one directory — a session. --config-dir picks which one (default ~/.logosctl, also LOGOSCTL_CONFIG_DIR):

<session>/
├── daemon/
│   ├── config.yaml     # daemon configuration — you write this
│   ├── startup.err     # transient: a --detach child's output before logging is up
│   ├── state.json      # live instance: pid, instance id, bound ports
│   ├── tokens.json     # hashed-at-rest accepted tokens
│   └── tokens/<name>.json
├── client/
│   ├── config.yaml     # dial spec + token_file — you write this for remote use
│   └── auto.json       # local token, rewritten by the daemon each boot
├── modules/            # core modules installed into this session
├── plugins/            # UI plugins installed into this session
├── keyring/            # trusted package-signing keys
├── logs/               # daemon.log -> daemon_<timestamp>.log, rotated
├── cache/downloads/    # fetched .lgx
└── data/               # per-module persistence

A session is portable by default: copy the directory and its packages, catalogs and trust assumptions come with it. Two sessions can hold different package sets and disagree about which signers they trust. The only things left outside are the binary itself and the runtime sockets in $TMPDIR.

That default is not a cage — any of the subdirectories can be redirected:

dirs:
  keyring: ~/.config/logos/trusted-keys   # share trust across sessions
  cache: /var/cache/logos                 # put downloads on a bigger disk
  modules: /opt/logos/modules             # a tree something else manages
  plugins: plugins-custom                 # relative -> stays inside the session
  data: /var/lib/logos/data
  logs: /var/log/logos                    # ship logs where your collector looks

How a value is written decides whether portability survives:

Form Resolves to
plugins-custom <session>/plugins-custom still portable
~/x $HOME/x outside the session
/var/cache/logos as given outside the session

persistence_path is the older spelling of dirs.data and still works; dirs.data wins if both are set.

Modules come from two places: the read-only set bundled beside the binary (capability_module, package_manager, package_downloader) and the writable <session>/modules. On a name collision the session's copy wins, which is how you override a bundled module.

Starting the Daemon

logosctl daemon start              # runs in the foreground
logosctl daemon start --detach     # forks, returns once it is accepting commands
logosctl daemon status
logosctl daemon stop

--detach is not the same as &: backgrounding returns immediately, before the transports have bound, so the next command races the boot. --detach returns only after the daemon has published daemon/state.json, and tells you where it is logging.

Logs

Everything the daemon and its module subprocesses write goes to a rotating file under logs/:

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, oldest dropped
  console: true          # mirror to the terminal (ignored once detached)

Each start writes a new, timestamped file, the same scheme Basecamp uses:

logs/
├── daemon.log -> daemon_20260729_180411.log   # always the current session
├── daemon_20260729_180404.log
├── daemon_20260729_180409.log
└── daemon_20260729_180411.log

logging.file names the log; the start time is inserted into the real file, and that exact name survives as a symlink to whichever file is current — so tail -F logs/daemon.log follows across restarts without anyone working out a stamp.

max_files bounds the directory, not just one session's rotations: the oldest files are pruned at each start, so a daemon restarted a hundred times does not leave a hundred logs. (spdlog's own retention only prunes within a single sink's rotation set, which is why this is enforced separately.)

Capture is pipe-based, not a file redirect, and that is the point: module hosts are separate processes holding inherited descriptors. Redirecting to a file would catch their output but make 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.

The size cap and retention come from spdlog's rotating sink, which liblogos already logs through. Lines arriving from the pipe already carry their own timestamp and level, so they are written verbatim rather than stamped twice.

Configuration

Configuration is a YAML document you install into the session. It is never passed alongside another command, so daemon start and every client command act on the session exactly as it is on disk.

logosctl daemon config set ./node.yaml     # also accepts @file, or - for stdin
logosctl daemon config show

set replaces the file wholesale — there is no merge — and validates the whole document, through the loader the daemon itself uses, before writing anything. A document it rejects is never installed, so the previous config stays intact and the session is never left holding one the daemon would refuse to boot from. Unknown keys are rejected by name, because the loader ignores what it does not recognise and a silently-dropped insecureTcp (the key is insecure_tcp) would leave the daemon running with your intent missing. A key whose value is the wrong type is reported the same way — modules_dirs: /single/path (a scalar where a list belongs) fails with modules_dirs: expected a list of strings, but got a string. rather than being ignored, half-applied, or fatal.

# node.yaml
insecure_tcp: false
dirs:                        # optional; each defaults to <session>/<name>
  keyring: ~/.config/logos/trusted-keys
access_group: logos          # share the daemon with an OS group (see below)
signature_policy: warn       # none | warn | require
modules_dirs:                # extra read-only module directories to scan
  - /opt/logos/modules
ssl:                         # default TLS material for every tcp_ssl listener
  cert: /etc/logos/tls/server.pem
  key:  /etc/logos/tls/server.key
modules:
  core_service:
    - protocol: tcp_ssl
      host: 0.0.0.0
      port: 8645
      codec: json            # json | cbor
  capability_module:
    - protocol: tcp_ssl
      host: 0.0.0.0
      port: 8646
      cert: /etc/logos/tls/other.pem   # overrides the ssl: block for this one
      key:  /etc/logos/tls/other.key

Per-listener keys are protocol, host, port, codec, cert, key, ca_file and verify_peer. cert, key and ca_file are opened as written — unlike dirs:, a relative one resolves against the daemon process's working directory, not the session, so the same config started from a different directory silently fails its TLS handshake (use_certificate_chain_file: No such file or directory). Give them absolute paths.

The top-level ssl: { cert, key, ca } block is the default for every tcp_ssl listener in the document — the usual case, where one certificate covers the whole node. A listener that names its own cert:/key:/ca_file: keeps it; the merge is per field, so a listener that names only a cert: still inherits the block's key:. A tcp_ssl listener left with no certificate from either source is refused at startup rather than bound: it would accept connections and fail every handshake with no shared cipher, which reads like a client fault.

signature_policy: is handed to the session's package manager at daemon start and decides what logosctl package install does with an unsigned package or one signed by a key that is not in the session's keyring/: none skips the check, warn (the default when the key is absent) installs and prints a warning, require refuses the install. It takes effect on the next daemon start, like everything else in this file.

If the value cannot be delivered to the package manager at startup — the module did not load, or its object could not be acquired — the daemon unloads the package manager rather than leave it enforcing its own warn default while this file, logosctl config get and state.json all still say require. Package commands are unavailable for that session, and the reason is on stderr. A failure to set the directories is not treated the same way: every directory fails closed on its own (an install refuses with User modules directory is not set), so that case warns and leaves the manager up.

A local listener is always added to every module, so same-host clients and the daemon's own cross-module calls keep working whatever else you configure. Any tcp/tcp_ssl entries are additional, outward-facing listeners.

Remote clients need capability_module exposed too, not just core_service. Before its first RPC a client performs a requestModule handshake against capability_module. On the same host that rides the free local listener; from another host it has to reach capability_module over the network. Exposing only core_service is the single most common remote-setup mistake — client commands hang at connect time because the handshake never completes.

Plaintext tcp on a non-loopback host puts tokens on the wire in cleartext. The daemon refuses to bind that unless insecure_tcp: true is set. Prefer tcp_ssl, or a TLS terminator in front.

The client side is symmetric:

logosctl client config set ./client.yaml
logosctl client config show
# client.yaml — only needed to reach a daemon on another host
version: 2
token_file: alice.json       # a bare filename, read from <session>/client/
daemon:
  core_service:      { transport: tcp_ssl, host: node.example, port: 8645, ca: /etc/logos/tls/ca.pem, verify_peer: true }
  capability_module: { transport: tcp_ssl, host: node.example, port: 8646, ca: /etc/logos/tls/ca.pem, verify_peer: true }

The accepted top-level keys are exactly version, daemon, token_file and instance_id. token_file must be a plain filename — anything containing a separator or .. is refused and the client fails closed with "token file not found". ca is opened as written, so give it an absolute path for the same reason cert/key need one on the daemon side.

The two documents describe different ends of the same connection, and their key names were never unified. Per listener:

Daemon (modules:) Client (daemon:)
which protocol protocol: transport:
where host:, port:, codec: host:, port:, codec:
CA to trust ca_file: ca:
verify the peer verify_peer: verify_peer:
own certificate cert:, key: — (no client counterpart)

So ca is the client's spelling of the daemon's ca_file, and cert/key are the daemon's server certificate — a client config has no equivalent. The validator names the key it expected, so a mix-up fails at config set rather than at connect time.

For same-host use you do not need this at all: the daemon writes a working client/config.yaml and client/auto.json into the session on every boot.

The two documents are kept separate on purpose: the daemon never reads client/, and the client never reads daemon/. Only the files above are YAML — everything the daemon and the modules own (state.json, tokens.json, the token files, the downloader's catalog config) stays JSON.

Client Commands

Commands are grouped by what they act on. ls is the list verb throughout, show the detail verb.

# Daemon
logosctl daemon start [--detach]     # start the runtime
logosctl daemon stop                 # graceful shutdown
logosctl daemon status               # health, uptime, module summary
logosctl daemon config set FILE      # install/replace the daemon config
logosctl daemon config show          # print it, and where it lives

# Client dial settings (only needed to reach a daemon on another host)
logosctl client config set FILE
logosctl client config show

# Modules — what is running right now
logosctl module ls [--loaded]        # list known / loaded modules
logosctl module show NAME            # methods, events, deps, crash detail
logosctl module load NAME            # + dependencies, always (no opt-out)
logosctl module unload NAME          # + dependents  (--no-dependents to opt out)
logosctl module reload NAME
logosctl module stats                # per-module CPU / memory

# Calling and watching
logosctl call MODULE METHOD [args...]
logosctl watch MODULE [--event NAME]

# Packages — what is on disk
# install/upgrade share one option set:
#   --file X.lgx  --dir D  --version V  --root-hash H  --catalog C
#   -y|--yes  --dry-run  --no-deps  --no-dependents
# remove takes names only:  -y  --dry-run  --no-dependents
logosctl package install NAME|FILE.lgx ...   # a path installs from disk
logosctl package install --dir D             # every .lgx in a directory
logosctl package upgrade NAME|FILE.lgx
logosctl package remove NAME         # + dependents by default
logosctl package ls [--type core|ui]
logosctl package show NAME|FILE.lgx  # installed detail, or inspect an .lgx
logosctl package deps NAME [-r|--recursive] [--reverse]
logosctl package search [QUERY] [--category C] [--catalog C]
logosctl package download NAME [--version V] [--root-hash H] [--catalog C] [-o|--output DIR]

# Catalogs
logosctl catalog ls
logosctl catalog add URL | remove URL | enable URL | disable URL
logosctl catalog refresh

# Trusted signing keys (per session)
logosctl key ls
logosctl key add NAME --did DID [--display-name N] [--url U]
logosctl key remove NAME

# Auth tokens (offline; operates on the session dir)
logosctl token issue --name N [--expires D] [--replace] [--local-only]
logosctl token ls
logosctl token revoke NAME

Shorthands exist for the things you type most: status, stop, call, watch, stats, install, and search work at the top level. list is accepted wherever ls is, info wherever show is, and uninstall for package remove.

install and search are the only package commands aliased to the top level, because they are the only ones with no runtime-module meaning — ls, show and remove would each be ambiguous between a package and a loaded module.

Two defaults are worth stating plainly, because they are the opposite of what some tools do:

  • install does not load. It puts files on disk; module load activates them. Staging a package without starting it is a thing you may well want.
  • remove takes dependents with it, and unload too. Leaving a module running against something that no longer exists is the more surprising outcome. --no-dependents opts out, acting on the named package or module alone and leaving its dependents in place.
  • module load always resolves dependencies. There is no --no-deps on it — that flag exists only on package install / package upgrade, which are about files on disk, not about what is running.
  • An argument ending in .lgx is a path, not a name. install and upgrade read it as a file on disk and skip the catalog entirely, so a local package installs with no catalog configured and no network. --file says so explicitly and --dir takes every .lgx in a directory. Catalog names and local files cannot be mixed in one command — they resolve by different rules, so run them separately.

Installing does not require a daemon restart: the daemon re-scans afterwards, and only modules that were already running are stopped and restarted.

Argument typing

Each positional argument to call is turned into a JSON value using the first rule that matches, so scalars stay ergonomic while lists, maps, and literal strings are all expressible:

Argument form Becomes Example
json:<value> the value parsed as JSON (list / map / number / any nested value) json:[1,2,3], json:{"k":"v"}
json:@<file> the file's contents parsed as JSON json:@payload.json
str:<text> <text> verbatim as a string — no parsing, no coercion str:json:x"json:x", str:42"42"
@<file> the file's raw contents as a string @config.json
true / false a boolean true
a whole number an integer 42
a decimal number a double 3.14
anything else a string hello

json: and str: are the two explicit escapes, mirroring the convention used by jq (--arg / --argjson) and HTTPie (= / :=): the default path never guesses a container, json: opts into parsing, and str: forces a literal string for any value the default rules would otherwise reinterpret (a number-like string, or one that itself starts with json: / str: / @).

Binary (bstr) arguments. JSON has no native byte type, so bytes use the canonical tagged encoding — a JSON object {"_bytes": "<base64url, unpadded>"}. Pass it like any other JSON value with json::

# base64url("hello") == "aGVsbG8"
logosctl call blobstore put 'json:{"_bytes":"aGVsbG8"}'
logosctl call blobstore put 'json:@blob.json'   # {"_bytes":"..."} from a file

Exit Codes

Code Meaning
0 Success
1 General error / daemon not running (for status)
2 No daemon running
3 Module error (not found, load/unload failed)
4 Method error (not found, call failed, timeout)

A session left behind by a daemon that is no longer there is exit 2 as well, and is reported immediately rather than after the RPC deadline — whether the daemon crashed:

$ logosctl module ls --json
{"status":"error","code":"NO_DAEMON","message":"No daemon running (stale state file: pid 51203 is gone)."}

or was stopped normally, which leaves no pid to name and is settled by the socket instead:

$ logosctl module ls --json
{"status":"error","code":"NO_DAEMON","message":"No daemon running (no local endpoint at /tmp/logos_core_service_a1b2c3d4e5f6). A daemon that stopped removes it; start one in this session to get it back."}

Start a daemon (logosctl daemon start) and both are restored; nothing needs cleaning up by hand. A client dialing a remote daemon is unaffected by either check.

module ls and stats report an unanswered RPC as DAEMON_UNREACHABLE (exit 2), never as an empty list — [] means the daemon answered and nothing matched.

Packages

logosctl installs and manages packages itself — it bundles package_manager and package_downloader, the same modules Basecamp uses, so both frontends drive an identical surface. All package commands need a running daemon, because that is where those modules live.

logosctl catalog ls                     # configured catalogs
logosctl catalog add <url>              # add one; also remove/enable/disable
logosctl catalog refresh                # re-fetch every enabled catalog

logosctl search storage                 # search the merged catalog
logosctl install storage_module --dry-run   # show what would change
logosctl install storage_module -y
logosctl package ls                     # what is installed in this session
logosctl package show storage_module    # or a path to an .lgx to inspect it
logosctl package deps storage_module -r
logosctl package upgrade storage_module -y
logosctl package remove storage_module -y

A package you already have on disk needs none of that — no catalog, no network. Give install the path:

logosctl install ./storage_module.lgx -y      # or: --file ./storage_module.lgx
logosctl install --dir ./downloads -y         # every .lgx in a directory
logosctl package show ./storage_module.lgx    # inspect one without installing

install puts files on disk; it does not load anything. Loading is a separate, explicit act:

logosctl install storage_module -y
logosctl module load storage_module

The daemon re-scans after an install, so a freshly installed module is loadable immediately — no restart. What install does restart is anything that was already running and had to be stopped to make way; nothing else is touched.

Dependencies are handled by default in the direction that avoids breakage: install/upgrade pull dependencies in (--no-deps opts out), and remove takes dependents with it (--no-dependents opts out). --dry-run prints the full change table plus which running modules will be stopped, and without -y you are asked to confirm. With no terminal and no -y the operation is refused rather than assumed — a script that forgot --yes should fail loudly.

Package signatures are verified against the session's own keyring:

logosctl key ls
logosctl key add acme --did did:jwk:... --display-name "Acme"
logosctl key remove acme

Because the keyring lives inside the session, two sessions can hold different trust assumptions — and this is deliberately not shared with Basecamp's keyring, so a key trusted in one is not automatically trusted in the other.

Agent / Script Example

# Start daemon (--detach returns only once it is accepting commands,
# so no `&` and no sleep are needed)
logosctl daemon start --detach

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

# Load modules
logosctl module load chat --json

# Discover available methods (with their documentation)
logosctl module show chat --json | jq '.methods[] | {name, description}'

# Discover the events a module emits (with their documentation)
logosctl module show chat --json | jq '.events[] | {name, description}'

# Call a method
logosctl call chat send_message "hello from script" --json

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

# Stream events to a log file
logosctl watch chat --event chat-message --json >> events.log &

Events Example

Modules can emit events that you can listen to in real time. Use watch to subscribe and call to trigger:

# Start the daemon (the modules it scans come from the session's
# modules/ directory and any modules_dirs in daemon/config.yaml)
logosctl daemon start --detach

# Load the module
logosctl module load test_basic_module

# Start watching for events in the background, writing to a file
logosctl watch test_basic_module --event testEvent > events.txt &
WATCH_PID=$!

# Trigger the event from another call
logosctl call test_basic_module emitTestEvent "hello world"

# Check the captured event
cat events.txt

# Clean up
kill $WATCH_PID
logosctl daemon stop

Quick start: load modules and call methods

The daemon starts clean (it scans the module directories but loads nothing on its own). Load modules with module load — transitive dependencies are resolved automatically — then call methods:

# Start a clean daemon on this session's modules
logosctl daemon start --detach

# Load modules (deps resolved automatically)
logosctl module load waku
logosctl module load chat

# Call methods (positional args — see "Argument typing" above)
logosctl call chat send_message hello
logosctl call storage init config 42 true
logosctl call storage loadConfig @config.json           # @file → raw file contents
logosctl call storage setTags 'json:["a","b"]'          # json: → parsed list/map/value
logosctl call storage setLabel 'str:42'                 # str: → literal string "42"

# A throwaway session, isolated from ~/.logosctl
logosctl --config-dir /tmp/test-session daemon start --detach

# Stop the daemon when done
logosctl daemon stop

Daemon startup options:

  -D                    Start the daemon (same as `daemon start`)
  -d, --detach          Fork and return once the daemon is accepting commands
      --config-dir DIR  Which session to run

Everything else — module directories, persistence, transports, the access policy — is configuration, and lives in daemon/config.yaml.

These global flags work before or after any subcommand:

  -h, --help            Show help
      --version         Print the version
  -v, --verbose         Show debug logs
  -j, --json            Force JSON output
      --no-json,--human Force human-readable output even when piped
  -q, --quiet           Suppress non-essential output
      --config-dir DIR  Which session to act on (also LOGOSCTL_CONFIG_DIR)

Access policy

The access_policy key declares, per target module, which caller modules may invoke it. Unlike every other key in this document, its value is a JSON document carried as a string — the daemon hands the text straight to the runtime rather than interpreting it, so it is not modelled as YAML. A YAML block scalar (|) is the readable way to write it:

access_policy: |
  {
    "version": 1,
    "mode": "enforce",
    "restrictions": {
      "package_manager":    { "allowedCallers": ["package_manager_ui"] },
      "package_downloader": { "allowedCallers": ["package_manager_ui"] }
    }
  }

Do not write it as a YAML mapping. access_policy: followed by an indented version: 1 / restrictions: block is read as an object where a string is expected. daemon config set reports it by name (access_policy: expected a string, but got a mapping.) and writes nothing. Quote the JSON, or use the | block above.

The string is handed to the runtime (via logos_core_set_access_policy) before any module is loaded.

Note: the value must be a full policy document. The enforce shorthand is a logoscore flag spelling (--access-policy enforce); here, write the equivalent document — {"version":1,"mode":"enforce","restrictions":{}} — which arms deny-by-default with the allow-lists derived from each module's declared dependencies.

Note: the legacy inline mode (-c "module.method(args)" / --quit-on-finish, which ran calls in a single short-lived process) has been removed. Use a daemon plus logosctl call ... as shown above.