Metadata-Version: 2.4
Name: crystalia-tower
Version: 0.0.8
Summary: Agent messaging tower - an ATC-style triage tower for agent and human callsigns
Author-email: Vlad Korolev <vlad@v-lad.org>
License-Expression: MIT
License-File: LICENSE
Keywords: agent,callsign,cli,coordination,inbox,messaging,multi-agent,tower
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications
Classifier: Topic :: Utilities
Requires-Python: >=3.13
Requires-Dist: cryptography>=42
Requires-Dist: rich>=13.0
Requires-Dist: sentry-sdk>=1.44.1
Requires-Dist: textual>=0.80
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: coverage>=7.0; extra == 'dev'
Requires-Dist: mypy==2.3.0; extra == 'dev'
Requires-Dist: pytest-xdist>=3.6; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Description-Content-Type: text/markdown

# crystalia-tower

An ATC-style triage tower for agent and human callsigns.

Work performed by an agent session is invisible until a human relays it, which
makes the human the transport wire between concurrently-running sessions.
`crystalia-tower` replaces that wire: every participant -- agent or human --
is a node with a callsign, an inbox, and presence. Messages are addressed, typed
from a fixed eight-verb vocabulary, and carry refs rather than bulk.

The thing it is for: an agent queues "need your ruling", the human answers, the
agent unblocks -- with no live coordinator session and no copy-paste.

## Install

```bash
pip install crystalia-tower
```

Python 3.13 or newer. Installs four console scripts: `ctower`, `ctower-cab`,
`ctower-launch` and `ctower-serve`.

## Quickstart

A tower is a directory on some host. `init` is the only verb that will not guess
where it is; the rest read `CRYSTALIA_TOWER_HOME`, or take `--tower`. That flag
takes the directory, or a URL to a tower being served over the network.

```bash
ctower init --tower ~/towers/demo
export CRYSTALIA_TOWER_HOME=~/towers/demo
export CRYSTALIA_TOWER_CALLSIGN=vlad

# hand work to an agent callsign that need not exist yet
ctower send ASSIGN gatto-1 unit=DEMO note='look at the failing test'

# the agent's side: what is waiting, and what it owes a readback
ctower check --callsign gatto-1
ctower ack <msg-id> --callsign gatto-1

# the tower, both axes, every strip
ctower status
ctower roll
```

`check --wait 300` blocks until traffic lands instead of polling, and exits 0
with `interrupted: true` in the envelope if a signal cuts the wait short --
a killed wait found *nothing out*, and that is not the same as a quiet tower.

## The eight verbs

`ASSIGN` `REPORT` `WILCO` `ROGER` `STANDBY` `UNABLE` `SAY-AGAIN` `MAYDAY`

The set is closed. A ninth token is a validation failure, not an extension
point. A message carries `unit=`, `status=`, `reason=`, `ref=` and `note=`;
`note=` bytes are stored byte-identically, never parsed, never interpolated
into a shell, and never handed to the crash reporter. Against a tower served
over the network they travel to that tower, and nowhere else.

## The four commands

| Command | What it is |
|---------|------------|
| `ctower` | the CLI: `send`, `inbox`, `check`, `ack`, `status`, `init`, `roll` |
| `ctower-cab` | a terminal UI over your own inbox and the strip tower |
| `ctower-launch` | start an agent process with its queued traffic handed over as its brief |
| `ctower-serve` | serve one tower over HTTP so other hosts can reach it |

## Machine-readable output

Every verb takes `--json` and emits a versioned envelope. The seven schemas that
pin it ship in the source distribution under `docs/json-schema/v1/`, one per
verb, identified as `urn:crystalia-tower:json-schema:v1:<verb>`. The
envelope is a public API: it is pinned by test, not described by prose.

Exit codes are frozen: `0` success, `2` usage, `3` validation, `4` unknown
callsign, `5` unknown msg-id, `6` permission, `7` tower unavailable, `8` launch
collision. `ctower-launch` adds `1` for a failure to start the child -- the
tower committed its work and what failed was the exec -- and otherwise exits
with the child's own code, reporting a signalled child as 128+N. Three
non-happy outcomes are exit `0` by design: a `--wait` deadline expiring, a
message to a callsign never seen before, and a `launch` over an empty queue.

## Reaching a tower from another host

**One host needs nothing.** The tower is a directory and every participant
reaches it through the filesystem.

**Several hosts need a server.** `ctower-serve` puts one tower behind HTTP;
clients then pass a URL where they would have passed the directory.

```bash
# on the tower's host, once
ctower init --tower ~/towers/demo                        # prints the tower's id
ssh-keygen -t ed25519 -f ~/keys/agent-1 -N ''            # one keypair per client
cat ~/keys/agent-1.pub >> ~/towers/demo/authorized_keys
ctower-serve --tower ~/towers/demo --bind 0.0.0.0:8765 \
    --authorized-keys ~/towers/demo/authorized_keys --allow 10.0.0.0/8

# on a client host: the private key goes beside the config file
mkdir -p ~/.config/crystalia-tower
install -m 600 ~/keys/agent-1 ~/.config/crystalia-tower/id_ed25519
ctower roll --callsign agent-1 --tower 'http://tower.internal:8765#t-...'
```

`--authorized-keys` is an OpenSSH `authorized_keys` file -- ed25519 public keys,
one per line -- and it is re-read on a four-hour timer, so adding a line or
striking one out takes effect without a restart, though not at once.

**Deleting that file revokes every key on it** at the next re-read, and the
server says so on stderr. Any other read failure -- a permission change, a bad
disk -- keeps the keys it already had and warns instead, because a host failing
is not an operator revoking, and one bad read should not lock everyone out for
four hours. The accepted cost of that split: **a storage mount that disappears
looks exactly like a deletion and will evict the fleet.** Keep the file on
local disk. Deletion takes effect at the next re-read, so **restart the server
when a revocation is urgent** -- it reads the file once at startup.

**`--bind` is only the
listening socket; `--allow` is the guard, and it is what refuses a caller.**
Binding `0.0.0.0` behind a correct
`--allow` is not weaker than binding one interface.

`-N ''` is not a shortcut: **a passphrase-protected key cannot be used here.**
Nothing on the command line or in the config can supply the password, so the
client refuses it with exit 3 and says so. Protect the file with permissions --
`install -m 600`, as above.

The fragment on that URL is the tower's own identity, minted by `init` and
printed at startup. It pins *which* tower, not where it is. **Pin it.** Without
one, the client asks the address who it is and binds to whatever answers; with
one, a different tower answering at a familiar address is refused rather than
quietly accepted. The fragment is not transmitted as part of the URL, but the
pinned id is sent on every request and the signature covers it -- it is private
from the address, not from the tower. A URL may come from `--tower` or
`CRYSTALIA_TOWER_HOME` and never from a config file -- config is per-user, and a
URL there would follow you into somebody else's tower.

Every request is signed with that client key, and the fingerprint recorded
against each row is the one the server verified, never one the caller asserted.
A callsign is not authenticated -- it is not among the signed octets -- so a
fingerprint says which key holder wrote a row and never which callsign. A key
that is not admitted is refused with exit `6`, naming the fingerprint it saw,
and nothing is written. A tower that does not answer at all is exit `7`.
A refusal and an outage are deliberately different codes, because only one of
them is worth retrying.

**Traffic is pull-only.** No notifier, no cron, no hook and no watcher sits on
the message path -- you learn you have mail by asking, over a URL exactly as
over a directory. `ctower-serve` answers requests and never initiates one. The
single narrow exception is crash telemetry, which is on by default and switched
off with `CRYSTALIA_TOWER_SENTRY_DISABLED=1`. It never carries `note=` bytes.

## Working on the source

The source distribution carries the tests and the schemas.

```bash
uv sync --extra dev
uv run pytest
uv run ruff check src tests
uv run mypy src/crystalia_tower
```

Always use `uv run`; never `source .venv/bin/activate`.

Two invariants are easy to break and are enforced by lint rather than by
review: `sqlite3` may be imported only under
`src/crystalia_tower/store/sqlite/`, and `subprocess` only by
`src/crystalia_tower/launch.py`. Nothing is swallowed either -- a caught
exception is re-raised, translated to a named error type, or surfaced. There is
no fourth option.

## Licence

MIT. See `LICENSE`.
