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
14 changes: 11 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@ Core runs natively on this machine — no containers, no migrations (the schema
created at boot). Fresh-machine setup is the one-liner (`bootstrap.sh` →
`install.sh`, README.md); from a clone, `./install.sh` does prereqs, scaffold,
deps, the first-run wizard (web password, OpenRouter key, monthly budget cap,
auto-update schedule), the `chief` launcher, and the autostart service
(`--no-service` / `--no-launch` / `--non-interactive` for automation; re-runs are
idempotent). Run in the foreground with `chief run` or
auto-update schedule), the dedicated-account offer, the `chief` launcher, and
the autostart service (`--no-service` / `--no-launch` / `--non-interactive` /
`--single-user` for automation; re-runs are idempotent). By default chief gets
its **own system user** and its own Apple ID (`imessage.mode: dedicated`, which
switches off all four self-DM compensations); declining is first-class and gives
today's single-user install. A dedicated install ends *without* starting the
daemon — chief has no graphical session until its first login, and Messages only
delivers into one. `docs/OPERATIONS.md` has the account plan, the session
mechanism (auto-login vs the documented screen-share reconnect on an encrypted
disk), the boot check, and the **migration checklist with its abort path**.
Run in the foreground with `chief run` or
`uv run python -m chief.entrypoint`; lifecycle via
`chief start|stop|status|update|uninstall`. Upstream cuts tagged releases with
`chief release [major|minor|patch]`; a box **applies** the newest release onto its
Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,18 @@ a web password and a monthly spend cap.

The one-liner installs prerequisites (Homebrew on macOS, apt on Linux), clones
Chief to `~/.local/share/chief` at the latest tagged release, runs the wizard,
installs the `chief` command and an autostart service (launchd or systemd user
unit, skip with `--no-service`), starts the daemon, and opens the web UI.
Running it again updates an existing install instead of wiping it.
offers Chief its **own system account**, installs the `chief` command and an
autostart service (launchd or systemd user unit, skip with `--no-service`),
starts the daemon, and opens the web UI. Running it again updates an existing
install instead of wiping it.

The account offer is interactive-only and easy to decline (`--single-user`, or
just answer no) — declining gives you the single-user install, which stays
first-class. Taking it means Chief runs as its own user with its own Apple ID,
so you text it as an ordinary contact instead of texting yourself, and its
mistakes stop at its own account. Chief then has no login session until you log
in as it once, so the install ends by printing what is left to do rather than
starting the daemon. See [docs/OPERATIONS.md](./docs/OPERATIONS.md).

From a clone, `./install.sh` does the same without the prerequisite step.

Expand All @@ -75,6 +84,7 @@ chief update # apply the newest release onto this box's own edits
chief stop # stop the daemon (chief start brings it back)
chief run # run in the foreground instead of the service
chief wizard # re-run the first-run wizard
chief account # give Chief its own system user (interactive only)
chief uninstall # remove service + launcher; --purge-data removes data too
```

Expand Down Expand Up @@ -108,8 +118,16 @@ Releases are git tags.

## Security and privacy

Chief runs as you, holds your keys, and reads whatever you connect it to.
Chief holds your keys and reads whatever you connect it to.

- By default it runs as **its own system user**, not as you: its shell tool,
self-edit and file access carry its authority, not yours. You can still read
and edit its code (shared group); you cannot read its credentials without
escalating, and it gets no escalation at all. At install time you choose which
of your directories it may read and write — the default is none. If you
decline the account it runs as you, exactly as before.
- One documented exception to that boundary: it keeps **read** access to your
message store, so monitors on your own conversations keep working.
- Single-owner. Unknown senders are logged (metadata only) and never wake the
agent or get a reply.
- Secrets live in `secrets/` (one per file, `chmod 0600`) or env vars, never in
Expand Down
3 changes: 2 additions & 1 deletion bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# --ref TAG install a specific tag instead of the latest release
# --no-service skip the autostart service
# --no-launch do not start the daemon / open the browser at the end
# --single-user skip the dedicated-account offer (chief runs as you)
# --non-interactive no prompts (env: CHIEF_OWNER_PASSWORD,
# CHIEF_OPENROUTER_KEY, CHIEF_BUDGET_CAP)
#
Expand Down Expand Up @@ -160,7 +161,7 @@ main() {
case "$1" in
--dir) dir="$2"; shift ;;
--ref) ref="$2"; shift ;;
--no-service|--no-launch|--non-interactive)
--no-service|--no-launch|--non-interactive|--single-user)
install_flags="$install_flags $1"
;;
-h|--help) usage; exit 0 ;;
Expand Down
13 changes: 12 additions & 1 deletion config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,23 @@ hooks:
disabled: []

# iMessage adapter (macOS only; configured by the build-imessage package).
# owner_handles: the self-chat handle(s) — owner texts run turns, everyone
# owner_handles: the owner's handle(s) — owner texts run turns, everyone
# else is logged + published for monitors.
# mode: which Apple ID chief speaks as. self = the owner's own (today's
# install; chief is texted through the self-chat and compensates for it with
# the 🤖 prefix, twin dedup, self-chat scope and out-of-band send guard).
# dedicated = chief's own Apple ID in its own user session; all four are off.
imessage:
enabled: false
owner_handles: []
poll_seconds: 2
mode: self
# Dedicated mode only: the owner's own chat.db, polled read-only alongside
# chief's so their existing monitors keep working, and chief's own handles,
# whose rows are dropped from that store (chief's replies land there as
# ordinary inbound rows and would otherwise poll back as input).
owner_db_path: ""
self_handles: []

# Per-channel live stream policy. Every turn always emits its coarse activity
# tick (and, for non-web threads, the rich inbound/final); this only governs
Expand Down
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ through the adapter named on the `Message` that started it.
Install/uninstall are **document-driven** — follow the package's `INSTALL.md`
with your file tools, record installs via `chief.registry_apply`, then
`restart`.
- **Standing chief up as its own user** — `src/chief/install/`: `account.py` +
`account_steps.py` (the pure, pinned plan), `session.py` (disk encryption →
auto-login or the documented screen-share reconnect), `dedicated.py` +
`dedicated_ask.py` (ask, show, run), `posture.py` (the boot check, surfaced by
`chief status` and `web/status_routes.py`).
- **Updating yourself** — `src/chief/install/`: `releases.py` (version + tag
resolution), `basepin.py` (the `refs/chief/base` pin and the advance-on-healthy
record), `layer.py` (the three-way `merge-tree` application), `update.py`
Expand Down
23 changes: 23 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ template line.
| `imessage.owner_handles` | `imessage_owner_handles` | `tuple` | `()` | — |
| `imessage.db_path` | `imessage_db_path` | `Path` | `~/Library/Messages/chat.db` | — |
| `imessage.poll_seconds` | `imessage_poll_seconds` | `float` | `2.0` | — |
| `imessage.mode` | `imessage_mode` | `str` | `self` | — |
| `imessage.owner_db_path` | `imessage_owner_db_path` | `Path \| None` | `None` | — |
| `imessage.self_handles` | `imessage_self_handles` | `tuple` | `()` | — |
| `compaction.ratio` | `compaction_ratio` | `float` | `0.95` | — |
| `compaction.keep_recent` | `compaction_keep_recent` | `int` | `20` | — |
| `compaction.default_window` | `compaction_default_window` | `int` | `60000` | — |
Expand All @@ -93,6 +96,26 @@ Notes on specific keys:
closed and no listener is built.
- **`gate.approved` accepts `"*"`** to approve every tool; `gate.never` still wins.
- **`imessage.enabled` also requires `sys.platform == "darwin"`.**
- **`imessage.mode` picks which Apple ID chief speaks as** — `self` (default,
today's install: the owner's own, chief texted through the self-chat) or
`dedicated` (chief's own Apple ID in its own user session). `dedicated` turns
off all four self-DM compensations at once: the self-chat query scope, the
🤖 prefix on replies and its inbound filter, the twin-row dedup, and the
out-of-band `imsg`/`osascript` send guard. A typo is refused at boot, not read
as `self`. `owner_handles` keeps its meaning in both modes — it is still who
chief answers as the owner.
- **`imessage.owner_db_path` + `imessage.self_handles` are the dedicated-mode
dual-store reach.** With its own Apple ID chief has its own `chat.db`, so the
owner's conversations — and the monitors on them — live in a store chief no
longer owns. Setting `owner_db_path` makes chief poll that store read-only
alongside its own; each store carries its own cursor, because rowids are
per-store and one shared cursor swallows everything below the higher rowid.
`self_handles` is the matching safety catch: **chief's own replies land in the
owner's store as ordinary `is_from_me = 0` rows from chief's handle**, and
dedicated mode has already turned off the 🤖 prefix that used to catch them,
so rows from these handles are dropped when read from the owner's store. Set
both or neither. This reach is a deliberate exception to the account boundary
— see docs/SECURITY.md.
- **`quiet_hours` is `"HH:MM-HH:MM"`** and may span midnight; prompt-waking
schedule fires inside the window defer to its end (command schedules run
silently and are never deferred).
Expand Down
28 changes: 22 additions & 6 deletions docs/LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ All converge on `Dispatcher.handle` (`dispatch.py`).
jump to the max ROWID; history is never replayed.**
2. `_poll_loop()` ticks every `poll_seconds` (default 2.0), swallowing per-tick
errors so one bad row can't kill the poller.
3. `poll_once()` → `fetch_rows()` runs `POLL_QUERY` (both in
`imessage_store.py`) against a **read-only** sqlite URI (`mode=ro`) in a
thread, `LIMIT POLL_BATCH_LIMIT`.
3. `poll_once()` walks each configured store (`Store`, `imessage_cursor.py`)
and runs `POLL_QUERY` (`imessage_store.py`) against a **read-only** sqlite
URI (`mode=ro`) in a thread, `LIMIT POLL_BATCH_LIMIT`.
4. Per row, **the cursor is saved BEFORE the turn runs**. This is the
at-most-once invariant: a hard crash mid-turn drops that row rather than
answering it twice. Graceful restarts drain instead (§6).
Expand All @@ -52,12 +52,28 @@ All converge on `Dispatcher.handle` (`dispatch.py`).
`DEDUP_WINDOW_NS` (5s, in-memory).
7. `resolve_approval()` is called **at poll stage, before enqueue** — see §5 for
why that ordering is load-bearing.
8. `_enqueue()` → a per-`thread_key` `asyncio.Queue` with a `_worker` task
spawned lazily. FIFO within a thread, parallel across threads.
9. `_worker()` calls `dispatcher.handle(m, fire_restart=False)`, then
8. `ThreadFifo.put()` (`imessage_fifo.py`) → a per-`thread_key` `asyncio.Queue`
with a worker task spawned lazily. FIFO within a thread, parallel across.
9. That worker calls `dispatcher.handle(m, fire_restart=False)`, then
`restart.fire_if_requested()` *after* the turn — safe, because the cursor is
already durable.

Steps 3, 5 and 6 above describe `imessage.mode: self` — chief on the owner's
Apple ID. Under `mode: dedicated` (chief's own Apple ID, own user session) the
poll runs with an **empty** self-chat scope, so only `is_from_me = 0` rows
qualify; the `BOT_PREFIX` skip and `RecentDedup` are both bypassed, and
`send()` stamps no prefix. The scope is turned off, never repointed at the
owner's handle: that chat is chief's real conversation with the owner, so
scoping it would poll chief's own replies back as owner input.

Dedicated mode also polls a **second** store when `imessage.owner_db_path` is
set — the owner's own `chat.db`, so monitors the owner already relies on keep
working. Each store keeps its own cursor (rowids are per-store). One row class
is dropped from that store and only that store: rows whose sender is in
`imessage.self_handles`. Those are **chief's own replies seen from the owner's
side**, ordinary `is_from_me = 0` inbound rows with no prefix left to mark
them — delivering them would rebuild the echo loop by another route.

### Socket / CLI — `SocketAdapter` (`adapters/socket.py`), name `cli`

Unix socket at `config.socket_path`, newline-delimited JSON. In `{thread, text}`,
Expand Down
Loading
Loading