procyon 0.0.1

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
# Procyon

A terminal development harness for Stellar and Soroban smart contracts, with a language model
driving the tools.

You describe what you want in plain language. Procyon gives the model a set of tools scoped to your
workspace — search the code, read and edit files, build, deploy, invoke, inspect events, look
facts up through MCP — and shows the work as it happens in a terminal UI.

Twelve providers are supported. Claude over Anthropic's own API is the default; DeepSeek, Groq,
OpenRouter, xAI and the rest speak the OpenAI dialect, and Ollama or LM Studio run a model on your
own machine with no account at all. See [Providers](#providers).

```
┌ Chat - 12 messages ────────────────────────┐┌ Status ─────────┐
│ You: why does my transfer fail with        ││ Status: Working │
│   HostError(Storage, MissingValue)?        ││ Network: testnet│
│ System: Using tool: raven__search          ││ Explain: off    │
│ Agent: That variant means a contract read  │└─────────────────┘
│   hit a key that was never written. In     │┌ Project ────────┐
│   your `transfer` the balance is read      ││ Project: demo   │
│   before `initialize` has run…             │└─────────────────┘
└────────────────────────────────────────────┘┌ Account ────────┐
┌ Input (type /help for commands) ───────────┐│ Account: alice  │
│ ▏                                          │└─────────────────┘
└────────────────────────────────────────────┘
```

## Project status

Early and honest about it. The core loop works — streaming responses, tool calling, workspace
confinement, context compaction, MCP, session persistence, twelve providers — and there are 369
tests plus CI. But:

- **Network tool coverage is uneven.** `generate_docs` has a test that fetches a real contract
  interface from testnet. The event tools were validated by hand against testnet RPC but have no
  automated test. The `caatinga_*` flags are verified against the pinned CLI version, but the
  deploy and invoke paths depend on your own project's config and have not been run end to end
  here.
- **There is no interactive approval prompt.** File writes are confined to the workspace but are
  not individually confirmed, and mainnet is a switch rather than a per-operation confirmation.
  See [Safety]#safety.
- **Sub-agents get no tools.** `talk_to` and `party_mode` run each persona with an empty tool
  registry, so a persona can reason and answer but cannot read your files or run a build. Passing
  the parent registry down is the next step there.
- **Skills and MCP servers are discovered once per run**, so adding either means restarting. An
  MCP server's tool set is fixed at that point too: one reconnected later is not re-listed.
- **OAuth needs port 8181 free** for the redirect, and the sign-in must be completed in one run:
  the PKCE verifier is held in memory, so quitting between opening the browser and the redirect
  restarts the flow.
- **No compression or indexing of session logs.** They are plain JSONL and grow with the
  conversation.
- **Diagnostics are opt-in.** Anomalies that are recovered from rather than fatal — bytes replaced
  in a stream, frontmatter this parser cannot model — are recorded only if `PROCYON_LOG` names a
  file. A TUI owns the terminal, so there is nowhere else to print them.
- **The plugin system is minimal** — manifests can register external commands as tools, nothing
  more.

Licensed under [Apache-2.0](LICENSE). Not published to crates.io yet — see [Install](#install).

## Requirements

| | |
|---|---|
| Rust | 1.88 or newer (`ratatui` 0.30 requires it) |
| A provider credential | Required, unless you run a local model. See [Providers]#providers. |
| [`stellar` CLI]https://developers.stellar.org/docs/tools/developer-tools/cli/stellar-cli | Recommended — used for account keys, contract interfaces, and as an invoke fallback |
| Node.js / `npx` | Optional — only for the `caatinga_*` tools, see [Caatinga]#caatinga |
| An MCP server | Optional but strongly recommended, see [Grounding]#grounding |

## Install

Procyon is one binary, `procyon`, that opens a terminal UI. It is not on crates.io yet, so today
you build it from source:

```bash
git clone https://github.com/Dione-b/procyon.git
cd procyon
cargo install --path .
```

That puts `procyon` on your `PATH`, which is how it is meant to be used — the workspace is
whatever directory you launch it from, so the binary has to be callable from your projects rather
than from its own checkout. If you would rather not install it, `cargo build --release` leaves the
binary at `target/release/procyon`.

Either way the first build takes a while: `ratatui`, `reqwest`, `rmcp` and `tera` all compile from
source.

Once it is published, this becomes:

```bash
cargo install procyon
```

## Configure

Procyon reads a `.env` from the working directory, or plain environment variables. Copy the
example and fill it in:

```bash
cp .env.example .env
```

```bash
# The credential for whichever provider you use. Anthropic is the default.
ANTHROPIC_API_KEY=sk-ant-...

# For an OpenAI-dialect provider instead. The variable name depends on the provider —
# DEEPSEEK_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, and so on.
OPENAI_API_KEY=

# Optional: credential for an MCP server declared in config.toml (see below).
PROCYON_MCP_TOKEN=

# Optional: where to append diagnostics. Unset means they are kept in memory only.
PROCYON_LOG=

# Optional: let the signing tools act on the public network, for this run only. Off by default.
PROCYON_ALLOW_MAINNET=
```

`.env` is gitignored. There is also an optional `~/.config/procyon/config.toml`. Every field has
a default, so set only what you want to change:

```toml
default_network = "testnet"          # local | testnet | mainnet
theme = "dark"                       # dark | light
provider = "anthropic"               # see Providers
default_model = "claude-sonnet-5"
max_tokens = 4096
allow_mainnet = false                # signing on the public network — see Safety
# base_url = "..."                   # only for provider = "openai-compatible"
# api_key_env = "MY_KEY"             # variable holding the credential
# anthropic_api_key = "sk-ant-..."   # prefer .env — see Safety

# MCP servers. Their tools are namespaced <name>__<tool>.
# Remote, with browser sign-in (OAuth 2.1 + PKCE):
[[mcp_servers]]
name = "raven"
url = "https://raven.stellar.org/mcp"
auth = "oauth"

# Remote, with a static bearer credential instead:
[[mcp_servers]]
name = "internal"
url = "https://mcp.example.com/mcp"
token_env = "PROCYON_MCP_TOKEN"      # variable name, never the token itself

# Local, over stdio. Inherits this process's environment; no token_env.
[[mcp_servers]]
name = "fs"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "."]
```

Each entry sets `url` **or** `command`, never both — a malformed entry is reported by name at
startup instead of failing as an opaque transport error.

Spellings are lowercase. A malformed config fails before the terminal switches to raw mode, so
you get a readable error naming the file and line rather than a scrambled screen.

## Providers

Two protocols exist as far as the code is concerned: Anthropic's `/v1/messages` and the OpenAI
`/chat/completions` dialect. Everything else is a named profile over the second one — a preset
endpoint and credential variable, so `provider = "groq"` is all the config needs and you never
have to look an endpoint up.

| `provider` | Endpoint | Credential variable |
|---|---|---|
| `anthropic` | `api.anthropic.com` | `ANTHROPIC_API_KEY` |
| `openai` | `api.openai.com/v1` | `OPENAI_API_KEY` |
| `deepseek` | `api.deepseek.com/v1` | `DEEPSEEK_API_KEY` |
| `groq` | `api.groq.com/openai/v1` | `GROQ_API_KEY` |
| `openrouter` | `openrouter.ai/api/v1` | `OPENROUTER_API_KEY` |
| `cerebras` | `api.cerebras.ai/v1` | `CEREBRAS_API_KEY` |
| `fireworks` | `api.fireworks.ai/inference/v1` | `FIREWORKS_API_KEY` |
| `togetherai` | `api.together.xyz/v1` | `TOGETHER_API_KEY` |
| `xai` | `api.x.ai/v1` | `XAI_API_KEY` |
| `ollama` | `localhost:11434/v1` | none needed |
| `lmstudio` | `localhost:1234/v1` | none needed |
| `openai-compatible` | your `base_url` | `OPENAI_API_KEY`, or `api_key_env` |

An explicit `base_url` overrides any profile, so a named provider can still be pointed at a proxy
or a self-hosted gateway without losing its credential preset. An endpoint on `localhost` needs no
credential at all — which is what makes Ollama the shortest path to running Procyon with no
provider account.

Switch at runtime with `/model`; the change takes effect on the next message, and the conversation
carries over. Anthropic-only features degrade rather than break: prompt-cache breakpoints are
Anthropic's, and the OpenAI dialect reports cache hits differently, so the budget estimator reads
whichever shape it is given.

## Run

```bash
cd ~/my-soroban-project
procyon
```

Procyon treats **the directory you launch it from** as the workspace. Every file tool is confined
to it, so launch from your project root. Working on Procyon itself instead of with it?
`cargo run --release` from the checkout does the same thing.

### Keys

| Key | Action |
|---|---|
| `Enter` | Send the message |
| `` `` | Move the input cursor |
| `Home` / `End`, or `Ctrl+A` / `Ctrl+E` | Jump to start / end of the input |
| `Backspace` `Delete` | Delete before / at the cursor |
| `` `` | Scroll the chat one line |
| `PageUp` `PageDown` | Scroll ten lines |
| `Ctrl+B` | Ask the agent to build |
| `Ctrl+T` | Ask the agent to run tests |
| `Ctrl+D` | Ask the agent to deploy |
| `Ctrl+C` | Quit |

Scrolling is anchored: while you are reading back through history, incoming messages do not yank
the view. Scroll to the bottom and it resumes following automatically.

### Commands

| Command | Action |
|---|---|
| `/help` | List commands and shortcuts |
| `/status` | Connection, network, and active account |
| `/project` | Current project and network |
| `/network <local\|testnet\|mainnet>` | Switch the active network |
| `/explain` | Toggle explain mode — the agent narrates each step before taking it |
| `/model` | Current provider and model, with suggestions for that provider |
| `/model set <provider> <model>` | Switch both at once |
| `/model provider <name>` | Switch provider only |
| `/model model <name>` | Switch model only |
| `/clear` | Clear the chat |

## Sessions

Every conversation is written to an append-only log as it happens, so nothing is lost if Procyon
or the machine goes down.

```bash
procyon                  # new session
procyon --sessions       # list sessions for this directory
procyon --resume         # resume the most recent one
procyon --resume <id>    # resume a specific one
```

Logs live under `~/.local/share/procyon/sessions/<project>/<id>.jsonl`, one file per session with
a self-describing header on line 1 — listing reads only that line, so it stays instant however
long the conversations get.

What is stored is the sequence of events, not the messages; the transcript is derived by folding
them. That is what makes crash recovery work: the log is flushed to disk before each request to
the model and before each tool runs, so on resume Procyon knows a tool *may* have acted. Every
`tool_use` left unanswered by the crash gets a synthetic result saying so — distinguishing "it
started, verify before retrying" from "it never ran, safe to retry" — because a transcript with an
unanswered call is rejected by the API outright.

A record torn in half by a crash is discarded and physically truncated, and a log written by a
newer Procyon is refused with an upgrade message rather than half-read.

Compaction is part of the log too: the checkpoint is recorded with how many messages it replaced,
so a resumed session rebuilds the compacted history rather than the long one it stood in for.

## Tools

Twenty-nine built-in tools plus `spawn_agent`, and whatever your MCP servers expose. The agent
picks them; you do not call them directly.

**Navigating your code** — `list_dir`, `glob`, `grep`, `read_file`

**Changing your code** — `write_file`, `edit_file`

**Projects** — `project_init` (scaffolds a project, optionally with a token contract),
`project_info`

**Build, deploy, invoke** — `caatinga_build`, `caatinga_deploy`, `caatinga_read`,
`caatinga_invoke`, `caatinga_doctor`, `stellar_invoke` — see [Caatinga](#caatinga)

**Accounts** — `account_create`, `account_list`, `account_balance`

**Testing and inspection** — `run_tests`, `get_contract_events`, `filter_contract_events`,
`generate_bindings`, `generate_docs`

**Skills and personas** — `list_skills`, `run_skill`, `list_personas`, `talk_to`, `party_mode`,
`spawn_agent` — see [Skills and personas](#skills-and-personas)

**Housekeeping** — `list_plugins`, `check_update`

### Generated contracts

`project_init` with the `token` template scaffolds a Soroban contract that compiles, carries a
`#[contracterror]` enum, and ships with five passing tests. Build it for WebAssembly with:

```bash
cargo build --release --target wasm32v1-none
```

`soroban-sdk` 27 rejects `wasm32-unknown-unknown` — the older target that most tutorials still
mention — so that flag matters.

## Caatinga

Deploys go through [`@caatinga/cli`](https://www.npmjs.com/package/@caatinga/cli), which sits
between "contract is written" and "contract is deployed with typed bindings". The `caatinga_*`
tools **only run in a project that has a `caatinga.config.ts`** — every one of them takes a
contract *name* from that config, so without it there is nothing to resolve, and they say so
instead of failing inside npx. For a one-off contract in a project Caatinga does not manage, the
`stellar` CLI is the right path and `stellar_invoke` is there for it.

What the tools deliberately do *not* accept is as important as what they do:

| Not a parameter | Where it comes from instead |
|---|---|
| A wasm path | `caatinga.config.ts`, via the contract name |
| A contract id | `caatinga.artifacts.json`, the versioned source of truth per network |
| A bindings output directory or language | `caatinga.config.ts` |

That is the whole point of the integration. A contract id copied out of a deploy log is stale the
next time anything is redeployed, and bindings written somewhere the config does not expect are
bindings the app never imports. `caatinga_deploy` reports the ids it recorded by reading them back
from the artifacts, so there is nothing to copy.

Deploying without naming a contract deploys **all** of them in dependency order — Caatinga derives
that order from `dependsOn`, and it also regenerates bindings, runs wiring hooks and syncs frontend
env afterwards. `dry_run` estimates the cost without submitting anything.

**Signing is always by identity alias**, such as `alice`. Every tool that takes a `source` rejects
a secret key, a seed phrase or a raw address before spawning anything, and the rejection never
echoes the value back: a secret on a command line reaches the process list, the error text, the
model's context and the session log on disk. Key material stays in the `stellar` CLI keystore.

Reach for `caatinga_read` rather than `caatinga_invoke` whenever you only need to read a value — it
simulates, signs nothing and submits nothing. And `caatinga_doctor` is the first thing to run when
something fails for a reason that is not in the contract, since most of those are environment
drift.

The CLI version is pinned in Procyon rather than floating, so the same Procyon keeps behaving the
same way. The flags were verified against the pinned version; the paths themselves have not been
run end to end here, which is the caveat in [Project status](#project-status).

## Skills and personas

A **skill** is a directory holding a `SKILL.md`: YAML frontmatter with a `name` and `description`,
then a Markdown body of instructions. The body is loaded as context on demand, which is what makes
a skill cheap — a hundred installed skills cost you two lines of description each in the prompt,
not their full text.

Skills are discovered from four places, first match by name winning:

```
~/.claude/skills/<name>/SKILL.md          # global, shared with Claude Code
~/.config/procyon/skills/<name>/SKILL.md  # global, Procyon's own
.procyon/skills/<name>/SKILL.md           # project
.stellar-build/skills/<name>/SKILL.md     # project
```

A skill that also carries a `customize.toml` with an `[agent]` section is a **persona**: a name,
title, icon, role, identity, communication style and principles, assembled into a system prompt.
`talk_to` runs one as a sub-agent with its own conversation; `party_mode` runs several in parallel
and returns each perspective, five at a time, with a 120-second deadline per persona.

```
You: party mode — should this contract be upgradeable?

🏗 Tyler (2 round trips, 4.1s):
  Upgradeability is a governance question before it is a technical one…

💻 Elliot (1 round trip, 2.8s):
  If you do, the storage layout has to be versioned from day one…
```

Sub-agents run on the same provider and model as the main loop unless told otherwise, and each
request they make is bounded — 120 seconds by default, so a provider that accepts a connection and
then goes quiet fails with a message instead of hanging. They currently run with an empty tool
registry; see [Project status](#project-status).

Malformed frontmatter is reported and the skill is skipped, never fatal. Sequences are understood
in both YAML spellings — `tools: [a, b]` and the indented `- a` form — because a `SKILL.md` any
YAML parser accepts should not vanish from the registry over its punctuation.

## Grounding

The difference between an agent that *sounds* knowledgeable about Stellar and one that *is*
comes down to whether it can check.

**Workspace context, rebuilt every turn.** Before each request Procyon tells the model where it
is: the workspace path, the current project and version, the default network, registered
contracts and their addresses, configured accounts, which of `stellar`/`npx` are installed, and
which MCP servers are connected. It is rebuilt per turn, because the agent changes those things
as it works.

**Lookup instead of recall, over MCP.** Procyon is an MCP client, over streamable HTTP for
remote servers and stdio for local ones. Declare a server in `config.toml` and its tools join the
registry at startup, namespaced `<name>__<tool>`. The namespace is load-bearing, not cosmetic:
the reference filesystem server exposes `read_file` and `write_file`, which would otherwise
shadow Procyon's own. The system prompt tells the model to look Stellar facts up rather than
recall them, and to say so plainly when no source is connected.

[Stellar Raven](https://raven.stellar.org) is the intended companion: `search` and `execute` over
a catalog of Stellar ecosystem services and skills. Declare it with `auth = "oauth"` and sign in
once:

```bash
procyon --authorize raven
```

That runs the OAuth 2.1 authorization-code flow with PKCE: it discovers the server's metadata,
registers Procyon as a client dynamically, opens your browser, and receives the redirect on
`http://127.0.0.1:8181/callback`. Credentials are stored per server under
`~/.local/share/procyon/oauth/<name>.json` with mode 0600, since the file holds a refresh token.
Access tokens are refreshed automatically; you only see the browser again when the refresh token
itself expires.

Servers that issue static bearer credentials are supported too, via `token_env`.

A server that fails to connect is reported in the
chat and skipped; an outage never stops Procyon from starting. A stdio server's stderr is piped
and its last lines are quoted back on a failed handshake, since that message is usually the only
explanation for why a local server died.

Connections recover on their own at two levels. The transport handles an expired HTTP session
(404) by replaying the handshake once and retrying the in-flight call. Above that, Procyon drops a
connection whose tool call fails at the transport layer — a healthy server reports tool errors in
the payload, so a transport error means the link itself is broken — and the next call rebuilds it.
That covers a stdio child exiting and an HTTP host restarting. Repeated failures back off for a
few seconds and report the cause rather than dialing in a loop.

With no server connected, Procyon still runs. It just says so in the system prompt, and the agent
has no way to look anything up.

## Safety

**Workspace confinement.** Every file tool resolves paths against the launch directory and
rejects anything outside it. The check is not merely textual: a path that does not exist yet but
sits behind a symlinked parent pointing out of the tree is rejected too. The Stellar docs are a
second, read-only root with the same guard rather than a hole in the first.

**Secrets stay out of the repo.** `.gitignore` covers `.env` and `.procyon/`. Procyon never reads
or stores Stellar secret keys — they remain in the `stellar` CLI keystore, referenced by name.
Provider and MCP credentials are named by variable in `config.toml`, never written into it:
`api_key_env` and `token_env` hold the name of the variable, not the value.

The one exception is the legacy `anthropic_api_key` field, kept for configs written before
`provider` existed. Procyon does not write `config.toml` itself today, so if you hand-create one
containing that field, **its permissions are yours to set** (`chmod 600`). Prefer `.env` or an
environment variable. The chmod-0600 path exists in the code but only runs once a settings writer
is wired up.

**Mainnet is off until you turn it on.** Every tool that signs and submits refuses the public
network unless `allow_mainnet = true` is in `config.toml`, or `PROCYON_ALLOW_MAINNET=1` is in the
environment for a single run. This is a real gate, not a prompt instruction: the agent cannot grant
it to itself mid-conversation, and cannot argue its way past it. The refusal names what you would
have to set, and nothing is submitted.

Those same tools also require the network as an **explicit argument** — there is no default. A
signing operation that left it implicit would inherit a default Procyon cannot see (Caatinga's
lives in `caatinga.config.ts`), so "unspecified" would be indistinguishable from "mainnet", and a
gate with that hole in it is decoration. Requiring it also puts the target in the session log.

Two limits worth knowing. The gate matches the network by name — `mainnet`, `public`, `pubnet`,
`main` — so a custom network in `caatinga.config.ts` that points at the public network under
another name is outside what it can detect. And it is a switch, not an approval prompt: once
enabled it stays enabled for the session, because a tool runs with no channel to ask the UI
anything. Per-operation confirmation is still missing, and reads are deliberately ungated —
`caatinga_read` simulates, so it costs nothing.

**Long conversations.** At 80% of the context window Procyon summarizes the older turns into a
checkpoint and keeps the recent ones verbatim. The cut point is chosen so no tool call is ever
separated from its result; if no safe cut exists, it refuses to compact rather than corrupt the
transcript.

## Architecture

```mermaid
flowchart LR
    subgraph main["main thread"]
        UI["Ratatui UI<br/>render + keys"]
    end
    subgraph stdin["blocking thread"]
        READER["event reader"]
    end
    subgraph task["tokio task"]
        AGENT["agent loop<br/>context · budget · tools"]
    end

    READER -->|events| UI
    UI -->|UserCommand| AGENT
    AGENT -->|AgentUpdate| UI
    AGENT --> LLM["provider dispatch"]
    LLM -->|/v1/messages| ANTHROPIC["Anthropic"]
    LLM -->|/chat/completions| OPENAI["OpenAI dialect<br/>11 providers"]
    AGENT --> TOOLS["tool registry"]
    TOOLS --> FS["workspace<br/>(confined)"]
    TOOLS --> DOCS["Stellar docs<br/>(read-only)"]
    TOOLS --> CLI["stellar / npx"]
    TOOLS --> RPC["Stellar RPC<br/>Horizon"]
    TOOLS --> SUB["sub-agents<br/>personas · party"]
```

A dedicated blocking thread owns stdin, because a cancelled `spawn_blocking` read would swallow
keystrokes. The UI and the agent never share state — they exchange `UserCommand` and
`AgentUpdate` over unbounded mpsc channels.

The module boundary that matters most is between the agent's vocabulary and any provider's wire
format:

| Module | Holds |
|---|---|
| `agent` | `Message`, `ContentPart`, `ToolDefinition`, `TokenUsage` — no wire format anywhere |
| `anthropic`, `openai` | one client, one `wire`, one `stream` each |
| `llm` | provider dispatch, and the one shared HTTP connection pool |
| `sse` | event framing and the read loop both adapters share |
| `registries` | skills and personas, discovered once per process |

`ContentPart` is what the session log persists, so its serialized shape is a durability
commitment — a resumed log has to deserialize what an earlier run wrote. A provider's request and
event shapes answer to whatever that API asks for this month. Keeping the two apart is what lets a
vendor revision stay a change to one `wire.rs`.

The two event grammars genuinely differ — Anthropic streams indexed blocks, the OpenAI dialect
streams choice deltas — so each adapter keeps its own state machine. What they share is the
reading: frame the chunk, hand over each payload, drain the decoder when the connection ends, stop
when the UI hangs up.

## Development

```bash
cargo test                 # 369 tests, no network, no toolchain
cargo test -- --ignored    # 8 tests needing the network or an installed toolchain
cargo clippy --all-targets -- -D warnings
cargo fmt --all --check
```

The eight ignored tests are kept out of the fast suite because each needs something the machine
may not have:

| Test | Needs |
|---|---|
| `scaffolded_contract_builds_and_passes_its_own_tests` | Cargo + the `wasm32v1-none` target; downloads `soroban-sdk` |
| `project_init_tool_end_to_end` | Same, and drives `project_init` itself rather than the scaffold helper |
| `fetches_a_real_contract_spec_from_testnet` | Network + the `stellar` CLI |
| `raven_live_lists_and_calls_search` | A reachable MCP server and `PROCYON_MCP_TOKEN` |
| `raven_oauth_live_connects_with_stored_credentials` | A server authorized with `--authorize` |
| `stdio_live_lists_filesystem_tools` | Node; launches a real stdio MCP server over `npx` |
| `stdio_live_reconnects_after_the_server_dies` | Node; kills a live server and checks recovery |
| `dump_real_prompt` | Nothing, but prints the assembled system prompt — run with `--nocapture` |

CI runs three jobs: lint, tests, and one that scaffolds the token template and compiles it for
`wasm32v1-none` — the template is otherwise a file nobody builds. The network-dependent tests stay
out of CI on purpose, to keep it from going red on someone else's outage.

Dead code that is genuinely waiting on a later milestone carries a targeted `#[allow(dead_code)]`
with a reason, so `-D warnings` stays meaningful and any new warning is a real regression.

### Adding a tool

Implement `Tool` in a module under `src/tools/`, then register it in `agent_task`:

```rust
#[async_trait]
impl Tool for MyTool {
    fn name(&self) -> &str { "my_tool" }
    fn description(&self) -> &str { "What it does, written for the model" }
    fn input_schema(&self) -> Value { json!({ "type": "object", "properties": {} }) }
    async fn execute(&self, input: Value) -> Result<String, String> { todo!() }
}
```

Two rules worth internalizing: resolve any caller-supplied path through
`tools::paths::resolve_in_workspace`, and never block the runtime — use `tokio::fs`,
`tokio::process`, or `spawn_blocking`, or the UI freezes while your tool runs.

## License

[Apache-2.0](LICENSE). The license grants patent rights alongside the copyright ones, which is
why it is the choice here rather than MIT: Procyon drives deploys and signs nothing itself, but it
sits next to code that handles value.