Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .claude/hooks/session-start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash
# Install deps + wire prek git hooks so cloud commits run the same checks as local.
# Scoped to remote (web/cloud) sessions; remove the guard to run locally too.
set -euo pipefail
[ "${CLAUDE_CODE_REMOTE:-}" != "true" ] && exit 0
cd "${CLAUDE_PROJECT_DIR:-.}"

# rustup installs cargo under ~/.cargo/bin; prek installs under ~/.local/bin.
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
line='export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"'
if [ -n "${CLAUDE_ENV_FILE:-}" ] && ! grep -qF "$line" "$CLAUDE_ENV_FILE" 2>/dev/null; then
echo "$line" >> "$CLAUDE_ENV_FILE"
fi

# Install deps (Rust toolchain + fetch crates). Source cargo env after a fresh
# rustup install so `cargo` is on PATH for this run.
if ! command -v cargo >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
fi
cargo fetch

# Install prek (Rust binary, language-agnostic), then wire the git hooks.
command -v prek >/dev/null 2>&1 || curl -LsSf https://prek.j178.dev/install.sh | sh
prek install
exit 0
15 changes: 15 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
}
]
}
]
}
}
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: AI writing check
run: scripts/check_ai_writing.sh
- name: Format
run: cargo fmt --all --check
- name: Clippy
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/folder-size.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Folder Size Check
on:
workflow_dispatch:
pull_request:
paths:
- '**.rs'
jobs:
check-folder-sizes:
name: Folder File Count Limit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- name: Check for oversized folders
run: |
if [ -n "${{ github.event.pull_request.base.sha }}" ]; then
mapfile -t files < <(git diff --name-only --diff-filter=d "${{ github.event.pull_request.base.sha }}...HEAD")
[ "${#files[@]}" -eq 0 ] && { echo "No files changed."; exit 0; }
scripts/check_folder_sizes.sh "${files[@]}"
else
scripts/check_folder_sizes.sh --all
fi
22 changes: 22 additions & 0 deletions .github/workflows/large-files.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Large File Check
on:
workflow_dispatch:
pull_request:
paths:
- '**.rs'
jobs:
check-file-sizes:
name: Source File Line Limit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- name: Check for large source files
run: |
if [ -n "${{ github.event.pull_request.base.sha }}" ]; then
mapfile -t files < <(git diff --name-only --diff-filter=d "${{ github.event.pull_request.base.sha }}...HEAD")
[ "${#files[@]}" -eq 0 ] && { echo "No files changed."; exit 0; }
scripts/check_large_files.sh "${files[@]}"
else
scripts/check_large_files.sh --all
fi
52 changes: 26 additions & 26 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# stdiod Architecture
# stdiod - Architecture

`edison-stdiod` is a small, long-lived daemon that runs on a user's machine. It
maintains a single authenticated outbound connection to a backend, supervises a
Expand All @@ -16,14 +16,14 @@ contract is described here.
- **Subprocesses run locally.** Every MCP stdio server the daemon manages is
spawned as a child process on the user's machine. Nothing is spawned remotely.
- **The backend is the source of truth.** The daemon stores almost no durable
state of its own it connects, fetches the desired set of servers, and
state of its own - it connects, fetches the desired set of servers, and
reconciles its running children against it.

## Components

`edison-stdiod` is a single binary that is both the long-lived service and the
control CLI. Its responsibilities described here by role, independent of how
the source happens to be arranged on disk are:
control CLI. Its responsibilities - described here by role, independent of how
the source happens to be arranged on disk - are:

```
control commands ┌──────────────────────────────┐
Expand All @@ -43,15 +43,15 @@ the source happens to be arranged on disk — are:
Edison backend local MCP servers
```

- **Control surface** the CLI subcommands a user runs to authenticate,
- **Control surface** - the CLI subcommands a user runs to authenticate,
register the OS service, manage servers, and inspect state. They persist
configuration; they do not carry MCP traffic.
- **Supervisor** the long-lived run loop: connect, fetch desired state,
- **Supervisor** - the long-lived run loop: connect, fetch desired state,
reconcile running children against it, and supervise.
- **Tunnel transport** the single outbound WebSocket and its framing. It is
- **Tunnel transport** - the single outbound WebSocket and its framing. It is
MCP-agnostic: MCP frames are forwarded as opaque bytes (see
[MCP-agnostic by design](#mcp-agnostic-by-design)).
- **Child supervision** spawning each desired server as a subprocess and
- **Child supervision** - spawning each desired server as a subprocess and
pumping its stdio to and from the tunnel.

Cross-cutting concerns sit beneath all of the above: the thin HTTP client for
Expand Down Expand Up @@ -79,7 +79,7 @@ reverse tunnel because:

- One authentication check, one stateful connection, lowest latency.
- Server-initiated frames (desired-state pushes, credential invalidations) are
natural the backend can talk to the daemon at any time.
natural - the backend can talk to the daemon at any time.
- It reuses the same outbound TLS:443 posture that already traverses corporate
firewalls, with no third-party tunnelling dependency.

Expand All @@ -94,13 +94,13 @@ Defined as JSON Schema at `schema/tunnel-protocol.json`. Frames are JSON with a
`hostname`, `label`, `os`, `client_version`, `currently_running: [server_id]`.
Sent immediately after the socket is established.
- `server_hello` (backend → daemon): `protocol_version` plus a **full
desired-state snapshot**
desired-state snapshot** -
`servers: [{server_id, name, command, args, env, working_dir, enabled}]`.
If the daemon's `protocol_version` is below the minimum the backend supports,
the upgrade is refused with a `needs_upgrade` close code; the daemon records
`needs_upgrade=true` in `state.json` and stops retrying until the binary is
updated.
- `desired_state_update` (backend → daemon): steady-state delta
- `desired_state_update` (backend → daemon): steady-state delta -
`added` / `updated` / `removed` server lists.
- `device_status` (daemon → backend): periodic snapshot of which children are
running and their last health timestamp.
Expand All @@ -113,7 +113,7 @@ Defined as JSON Schema at `schema/tunnel-protocol.json`. Frames are JSON with a
- `fetch_logs_request` / `fetch_logs_response`: an operator-initiated, bounded
(default 200 lines) pull of a child's recent `stdout`/`stderr`. Never streamed
continuously, to keep bandwidth predictable.
- `ping` / `pong` (both directions): heartbeat see
- `ping` / `pong` (both directions): heartbeat - see
[Disconnect handling](#disconnect-handling).

The `request_id` on `fetch_logs_*` is a control-layer correlation id, distinct
Expand All @@ -123,7 +123,7 @@ from the JSON-RPC `id` carried inside MCP frames.

- `mcp_frame` (both directions): a JSON-RPC frame addressed to or originating
from a specific child. Fields: `server_id` and `frame` (the JSON-RPC body
verbatim request, response, or notification).
verbatim - request, response, or notification).
- `tunnel_error` (both directions): a structured, non-JSON-RPC error
(subprocess crashed, unknown server, transport fault). Carries the inner
JSON-RPC `id` it relates to when applicable, so the receiver can fail the
Expand All @@ -132,14 +132,14 @@ from the JSON-RPC `id` carried inside MCP frames.
A single symmetric frame type captures every MCP interaction because JSON-RPC's
own envelope already distinguishes requests (`id` + `method`), responses (`id` +
`result`/`error`), and notifications (`method`, no `id`). JSON-RPC `id`s are
scoped to the originator, so the inner `id` is the correlation key no outer
scoped to the originator, so the inner `id` is the correlation key - no outer
`request_id` is needed for MCP traffic.

### MCP-agnostic by design

The transport is **MCP-agnostic**: the daemon's `tunnel` module treats every
`frame` field as opaque bytes and never inspects its contents. This is a
load-bearing invariant any temptation to sniff a method name or peek at
load-bearing invariant - any temptation to sniff a method name or peek at
`params` inside the daemon is a smell; that logic belongs above the transport,
on the backend.

Expand All @@ -151,7 +151,7 @@ Concrete consequences:
- **Bidirectional notifications** (e.g. `notifications/cancelled`,
`notifications/progress`) are just notification-shaped `mcp_frame`s.
- **MCP version bumps and new methods** require no changes anywhere in the
daemon `initialize` negotiation happens between the backend and the stdio
daemon - `initialize` negotiation happens between the backend and the stdio
server, both outside the transport.

## Child-process supervision
Expand All @@ -178,7 +178,7 @@ waiting for a response that never arrives. The WebSocket itself stays open and
other children on the same device are unaffected; the supervisor then decides
whether and when to respawn the dead child per the latest desired state. This
was the one behaviour the early spike could not derive from "treat MCP frames as
opaque" alone it is a deliberate active signal the daemon must produce.
opaque" alone - it is a deliberate active signal the daemon must produce.

## Persistence and survival

Expand All @@ -205,7 +205,7 @@ The daemon keeps almost nothing durable; the backend is the source of truth.
~/.config/edison-stdiod/
config.toml backend URL, device_id, api_key, secret
state.json atomic writes; consumed by the desktop tray UI
~/Library/Logs/edison-stdiod/ (macOS platform-equivalent paths elsewhere)
~/Library/Logs/edison-stdiod/ (macOS - platform-equivalent paths elsewhere)
daemon.log rotated daily
child-<name>.log per-child stdout/stderr capture
```
Expand Down Expand Up @@ -254,7 +254,7 @@ The daemon keeps almost nothing durable; the backend is the source of truth.
Every (re)connect runs the same protocol:

1. Daemon sends `client_hello { device_id, currently_running: [...] }`.
2. Backend replies `server_hello { servers: [...] }` a full desired-state
2. Backend replies `server_hello { servers: [...] }` - a full desired-state
snapshot for this device.
3. Daemon diffs:
- Start any enabled server not currently running.
Expand All @@ -268,15 +268,15 @@ Every (re)connect runs the same protocol:
Every outbound `mcp_frame` carries a JSON-RPC `id` used as the correlation key.
On socket close, all outstanding calls are failed cleanly (the backend surfaces
a `device_offline`-style JSON-RPC error to the caller); there are no automatic
retries the calling agent decides whether to retry.
retries - the calling agent decides whether to retry.

## CLI

The same binary is the daemon and the control CLI:

- `edison-stdiod login --backend <url> --api-key <key>` store credentials.
- `edison-stdiod install` / `uninstall` manage the OS service unit.
- `edison-stdiod run` run the daemon (normally invoked by the service unit).
- `edison-stdiod server …` add / list / remove locally-defined servers.
- `edison-stdiod status` show connection and per-child state.
- `edison-stdiod logs` tail daemon / child logs.
- `edison-stdiod login --backend <url> --api-key <key>` - store credentials.
- `edison-stdiod install` / `uninstall` - manage the OS service unit.
- `edison-stdiod run` - run the daemon (normally invoked by the service unit).
- `edison-stdiod server …` - add / list / remove locally-defined servers.
- `edison-stdiod status` - show connection and per-child state.
- `edison-stdiod logs` - tail daemon / child logs.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ cargo fmt --all --check && \

- For ordinary bugs and feature requests, open a
[GitHub issue](https://github.com/Edison-Watch/stdiod/issues).
- For **security vulnerabilities**, do **not** open a public issue follow
- For **security vulnerabilities**, do **not** open a public issue - follow
[`SECURITY.md`](./SECURITY.md) instead.

## License
Expand Down
Loading