@sigrun/sigrun 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
package/README.md CHANGED
@@ -1,3 +1,692 @@
1
- # Temporary Holding Version
1
+ # Sigrun
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sigrun is a durable personal-assistant harness built on
4
+ [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable):
5
+ long-lived conversations over a SQLite store (surviving restarts, with
6
+ resumable generation), an HTTP API plus web and terminal UIs, and an
7
+ extension system that installs extra tools, providers, prompt sections,
8
+ and capabilities at boot.
9
+
10
+ - **Durable** — conversations, submissions, and schedules live in one
11
+ SQLite database (`SIGRUN_STORAGE_PATH`); a restart resumes where it
12
+ stopped.
13
+ - **pi-durable core** — the harness primitives (conversations, inbox
14
+ steering/follow-up modes, retries, streaming, compaction) come from
15
+ pi-durable; sigrun is the deployable shell around them.
16
+ - **Extensions** — separate npm packages loaded from `SIGRUN_EXTENSIONS` (or the settings file's
17
+ top-level `extensions` list); a failing extension is quarantined and
18
+ reported, never a boot abort.
19
+ - **Web UI** — a static bundle served from `SIGRUN_WEBUI_BUNDLE` (or the
20
+ settings file's `server.webuiBundle`; default `webui/dist`); API-only
21
+ deploys simply omit it. The web UI's visual design is adapted from
22
+ [hermes-webui](https://github.com/nesquena/hermes-webui) by nesquena and
23
+ contributors — see THIRD_PARTY_NOTICES.md.
24
+ - **Terminal UI** — a pi-idiom terminal client over the same HTTP + SSE
25
+ API the web UI consumes (feature parity by design): conversation
26
+ sidebar, streaming conversation pane, fork, the interaction and
27
+ ambient seams, slash commands. See [Launch modes](#launch-modes).
28
+
29
+ ## Install
30
+
31
+ The published package is `@sigrun/sigrun` (the CLI command stays
32
+ `sigrun`):
33
+
34
+ ```bash
35
+ npm i -g @sigrun/sigrun
36
+ sigrun run # server + terminal client on loopback
37
+ ```
38
+
39
+ Node `>=22.19.0`. The web UI ships inside the package; the terminal
40
+ client is the optional companion package (`npm i -g @sigrun/tui` —
41
+ `sigrun run` offers an actionable install message when absent).
42
+
43
+ - **Docker** — `docker/` holds a compose reference:
44
+ `cd docker && cp .env.example .env && docker compose up --build`.
45
+ - **Helm** — `charts/sigrun` is the reference Kubernetes deployment
46
+ (a durable PVC, the config dir, and the image).
47
+
48
+ ## Quickstart
49
+
50
+ **A model and a credential come first.** `sigrun serve` refuses to boot
51
+ without a default model — `SIGRUN_DEFAULT_MODEL` (or `defaultModel` in
52
+ `settings.json`) — and every model needs a provider credential before it
53
+ can answer. So the real first run is: pick a provider, set the model,
54
+ then start the server.
55
+
56
+ The smallest configuration that boots, using any [OI]-compatible
57
+ endpoint:
58
+
59
+ ```bash
60
+ export SIGRUN_DEFAULT_MODEL=openai/gpt-4o-mini # provider/model-id
61
+ export OPENAI_API_KEY=sk-...
62
+ export OPENAI_BASE_URL=https://api.openai.com/v1 # any compatible endpoint
63
+
64
+ sigrun serve # listens on :3000
65
+ ```
66
+
67
+ (`SIGRUN_MODEL` still works as a deprecated alias for
68
+ `SIGRUN_DEFAULT_MODEL` — one release cycle, with a boot warning.)
69
+
70
+ Or install a provider plugin and let it supply the catalog — then the
71
+ only configuration is which of its models to default to:
72
+
73
+ ```bash
74
+ # settings.json: { "extensions": ["@sigrun/provider-ollama@^0.1.0"] }
75
+ export SIGRUN_DEFAULT_MODEL=ollama/glm-5.3-flash
76
+ sigrun serve
77
+ ```
78
+
79
+ **Then claim the install.** The first request opens an onboarding gate:
80
+ the server prints a setup code to its log, and the web UI (or
81
+ `sigrun token create`) uses it to claim the first admin account — every
82
+ later user is an invite. Nothing else is required; the first message in
83
+ a new conversation creates it.
84
+
85
+ ```bash
86
+ grep 'setup code' <the server log> # sigrun setup code: N42E-HB2G
87
+ ```
88
+
89
+ Open `http://localhost:3000/` (web UI) or drive the API:
90
+
91
+ ```bash
92
+ curl localhost:3000/v1/meta
93
+ curl -X POST localhost:3000/v1/conversations -H 'content-type: application/json' -d '{}'
94
+ ```
95
+
96
+ From a source checkout the dev shape is `npm install && npm start`
97
+ (`tsx src/index.ts`, same server).
98
+
99
+ ## Launch modes
100
+
101
+ The `sigrun` binary (`package.json` `bin`, run via tsx) has three
102
+ entrypoints:
103
+
104
+ | Command | Behavior |
105
+ |---|---|
106
+ | `sigrun [--user <name>]` | Workstation mode: boot the harness + server **in-process** and launch the terminal UI against the loopback port — the server binds **loopback (`127.0.0.1`) only**, so the naive-identity API never answers the LAN. The agent dies with the TUI — when the TUI exits, server and harness shut down with it. |
107
+ | `sigrun serve` | Headless: boot the API + web UI statics, no TUI. This is exactly the `npm start` / `src/index.ts` composition the Docker image and Helm chart run. Binds all interfaces (`0.0.0.0`) — the container default; workstation `sigrun` (run) binds loopback only. |
108
+ | `sigrun attach [--server <url>] [--user <name>]` | TUI only, against a harness it can reach — the remote-client mode (e.g. attaching to a cluster or tailnet deployment). |
109
+
110
+ Resolution chains for `attach` (and for the workstation-mode user):
111
+
112
+ - **server**: `--server` → `tui.json`'s `server` →
113
+ `http://localhost:$SIGRUN_PORT` (default `3000`)
114
+ - **user**: `--user` → `SIGRUN_USER` → `tui.json`'s `user`
115
+
116
+ `tui.json` lives in the config directory (`SIGRUN_CONFIG_DIR`, default
117
+ `~/.config/sigrun`) next to `settings.json`, and holds the TUI client's
118
+ own small config: `{ "user": "...", "server": "..." }` (both optional;
119
+ a malformed file is a boot error). The configured `user` rides every
120
+ request as the `X-Sigrun-User` header and is stamped as the
121
+ conversation's `owner` on create/fork/import (the identity seam — naive
122
+ mode, no auth flow yet; omitting the user omits the header and behavior
123
+ is unchanged).
124
+
125
+ ## Configuration
126
+
127
+ Sigrun is configured by environment variables and a settings file at
128
+ `~/.config/sigrun/settings.json` (`SIGRUN_CONFIG_DIR` relocates the
129
+ directory — `SIGRUN_CONFIG_DIR` itself stays env-only, since it locates
130
+ the file). Precedence, per setting:
131
+
132
+ 1. **Environment variable** (always wins — and an *invalid* env value is
133
+ a boot error even if the file has a valid one).
134
+ 2. **`settings.json`** field (applies only where the env var is absent).
135
+ 3. **Code default** (listed in the table below).
136
+
137
+ When an env var and the settings file both set a knob, the env var wins
138
+ — this is documented behavior, not enforced: sigrun never warns about or
139
+ detects env/file conflicts.
140
+
141
+ On first boot (settings file absent), sigrun writes only what the
142
+ environment explicitly set — the env-sourced `server` fields plus
143
+ `extensions` and `defaultModel` when env supplied them — into
144
+ `settings.json`; defaults are never persisted, so later releases'
145
+ default changes reach existing installs (the file records intent, not
146
+ reality). The full resolved server section is visible instead in
147
+ `GET /v1/settings`'s `effective` block: every flattened server key
148
+ (`server.port`, `server.compaction.reserveTokens`, …) with its resolved
149
+ value and its source — `"env"`, `"file"`, or `"default"`. On later
150
+ boots the file is never rewritten by bootstrap: edit it, and env vars
151
+ still override it per field.
152
+
153
+ | Variable | Type | Default | File key | Purpose |
154
+ |---|---|---|---|---|
155
+ | `SIGRUN_DEFAULT_MODEL` | `provider/model-id` | — (**required**: env or file) | `defaultModel` | Boot default model; bare `model-id` implies the OpenAI-compat provider. `SIGRUN_MODEL` is a deprecated alias (boot warning; one release cycle). |
156
+ | `OPENAI_API_KEY` | string | — | `auth.json` (see below) | Credential for the env-driven OpenAI-compatible provider; the `auth.json` fallback after file entries. |
157
+ | `OPENAI_BASE_URL` | URL | OpenAI | `defaultProvider.baseUrl` | Endpoint for the env-driven provider (any compatible host). |
158
+ | `SIGRUN_PORT` | positive int | `3000` | `server.port` | HTTP listen port. |
159
+ | `SIGRUN_STORAGE_PATH` | path | `<dataDir>/sigrun.db` | `server.storagePath` | SQLite database location. Relative values resolve against the data root (absolute values verbatim; the cwd-relative `data/sigrun.db` default is retired). |
160
+ | `SIGRUN_WORKSPACE_PATH` | path | `<dataDir>/workspace` | `server.workspacePath` | Root of the static local workspace every conversation's tools run against. The default rides the data root (chart/compose deploys set the path explicitly); it is never persisted on first boot. |
161
+ | `SIGRUN_DATA_DIR` | path | `$XDG_DATA_HOME/sigrun` or `~/.local/share/sigrun` | `server.dataDir` | Data root (the DB default and extension data dirs). Env/file values win verbatim. |
162
+ | `SIGRUN_CACHE_DIR` | path | `$XDG_CACHE_HOME/sigrun` or `~/.cache/sigrun` | `server.cacheDir` | Cache root (regenerable; never ensured at boot). Env/file values win verbatim. |
163
+ | `SIGRUN_STATE_DIR` | path | `$XDG_STATE_HOME/sigrun` or `~/.local/state/sigrun` | `server.stateDir` | State root (operational bookkeeping). Env/file values win verbatim. |
164
+ | `SIGRUN_EXTENSIONS` | csv | *(none)* | `extensions` (top-level list) | Deploy-time extension list (string entries; env wins over the file). |
165
+ | — | — | *(none)* | `gitDir` is always `<configDir>/git` | Git extension checkouts clone here (pi layout). Not env-configurable. |
166
+ | `SIGRUN_WEBUI_BUNDLE` | path | `webui/dist` | `server.webuiBundle` | Web UI bundle directory; explicit-but-missing = boot error, default-missing = API-only. |
167
+ | `SIGRUN_CONFIG_DIR` | path | `~/.config/sigrun` | *env-only* | The settings file's own directory. |
168
+ | `SIGRUN_COMPACTION_ENABLED` | `true`/`false` | `true` | `server.compaction.enabled` | Context compaction on/off. |
169
+ | `SIGRUN_COMPACTION_RESERVE_TOKENS` | positive int | `16384` | `server.compaction.reserveTokens` | Headroom reserved for the model's reply. |
170
+ | `SIGRUN_COMPACTION_KEEP_RECENT_TOKENS` | positive int | `20000` | `server.compaction.keepRecentTokens` | Recent context retained verbatim through a compaction. |
171
+ | `SIGRUN_COMPACTION_BACKGROUND_TOKENS` | non-negative int | `32768` | `server.compaction.backgroundTokens` | Background compaction threshold; `0` disables background compaction. |
172
+ | `SIGRUN_BACKUP_PATH` | path | *(unset)* | `server.backup.path` | Path backup target (mutually exclusive with the S3 target). |
173
+ | `SIGRUN_BACKUP_S3_BUCKET` | string | *(unset)* | `server.backup.s3.bucket` | S3 backup bucket. |
174
+ | `SIGRUN_BACKUP_S3_PREFIX` | string | *(unset)* | `server.backup.s3.prefix` | Key prefix within the bucket. |
175
+ | `SIGRUN_BACKUP_S3_ENDPOINT` | URL | AWS default | `server.backup.s3.endpoint` | Custom S3 endpoint (S3-compatible stores). |
176
+
177
+ ### settings.json schema
178
+
179
+ ```jsonc
180
+ {
181
+ "defaultModel": "openai/gpt-4o-mini", // non-empty provider/model-id; bare id = openai provider
182
+ "extensions": [ // the desired install+load state — see Extensions
183
+ "@acme/sigrun-ext-example@^0.1.0", // npm specifier
184
+ "git:github.com/obra/superpowers@main", // git skills-only package: its SKILL.md
185
+ // dirs load; its .ts pi extension is skipped
186
+ { "source": "./dev-checkout", "enabled": false } // object form
187
+ ],
188
+ "defaultProvider": {
189
+ "baseUrl": "https://api.openai.com/v1" // non-empty URL
190
+ },
191
+ "server": {
192
+ "port": 3000, // positive integer
193
+ "workspacePath": "/home/sigrun", // non-empty path
194
+ "storagePath": "data/sigrun.db", // non-empty path; relative resolves against the data root
195
+ "dataDir": "/var/lib/sigrun", // non-empty path (SIGRUN_DATA_DIR; verbatim)
196
+ "cacheDir": "/var/cache/sigrun", // non-empty path (SIGRUN_CACHE_DIR; verbatim)
197
+ "stateDir": "/var/lib/sigrun/state", // non-empty path (SIGRUN_STATE_DIR; verbatim)
198
+ "webuiBundle": "webui/dist", // non-empty path
199
+ "compaction": {
200
+ "enabled": true,
201
+ "reserveTokens": 16384, // positive integer
202
+ "keepRecentTokens": 20000, // positive integer
203
+ "backgroundTokens": 32768 // non-negative integer (0 = off)
204
+ },
205
+ "backup": { // target: { "path": "/backups" } XOR { "s3": { "bucket": "...", "prefix": "...", "endpoint": "..." } }
206
+ "staleAfterHours": 36 // positive number: health.backup degrades past this marker age
207
+ }
208
+ }
209
+ }
210
+ ```
211
+
212
+ The file also carries the live harness settings (`steeringMode`,
213
+ `followUpMode`, `retry`, `stream`, `defaultTools`, `perModelThinking`)
214
+ and is read fresh at every resolution.
215
+
216
+ ### auth.json
217
+
218
+ API keys live in `<config-dir>/auth.json` — the same directory as
219
+ `settings.json` — keyed by provider name, never in `settings.json`:
220
+
221
+ ```jsonc
222
+ {
223
+ "default": { "apiKey": "sk-..." }, // the resolved default provider's key
224
+ "openai": { "apiKey": "sk-..." } // a provider's own entry
225
+ }
226
+ ```
227
+
228
+ The env-driven OpenAI-compatible provider resolves its key in this
229
+ order: `auth.json`'s `"default"` entry → the resolved provider's own
230
+ `auth.json` entry → the `OPENAI_API_KEY` environment variable (compat
231
+ fallback, still honored). `"default"` is used only by the provider of
232
+ the resolved default model; every other provider reads its own entry.
233
+ With no key anywhere the provider registers unconfigured — requests
234
+ fail loud, boot does not. The file is created
235
+ with mode `0600` (owner-only) whenever sigrun writes it. Key material is
236
+ never logged, echoed, or served by the API (`GET /v1/providers` reports
237
+ key *presence* only).
238
+
239
+ **Restart requirement:** `server.*`, `extensions`,
240
+ `defaultModel`, and `defaultProvider` changes — whether by hand edit or
241
+ `PATCH /v1/settings` — apply on the **next boot** only; the live
242
+ settings (`steeringMode`, `retry`, `stream`, `defaultTools`,
243
+ `perModelThinking`, `skills.sectionMaxBytes`) take effect immediately on
244
+ the next submission.
245
+
246
+ ## Authentication
247
+
248
+ One pipeline authenticates every request into a *principal* (user,
249
+ admin flag, method) before any route runs. `/healthz` and `/readyz` are
250
+ always public; the browser surface, `/v1/onboarding*`, and login/logout
251
+ are public too; everything else needs a resolved principal. An
252
+ unclaimed server (empty user directory) answers 403 `setup_required` on
253
+ every gated route — onboarding is the only usable surface until the
254
+ first claim.
255
+
256
+ **Defaults.** The effective method list derives from the listen host:
257
+ a loopback bind (`127.0.0.1` / `[::1]` — `sigrun` workstation mode and
258
+ loopback `serve`) requires nothing (the naive single-operator path,
259
+ where an optional `X-Sigrun-User` header names the acting user); any
260
+ other bind requires `token`.
261
+
262
+ **The override.** `SIGRUN_AUTH` (csv of `token` and/or `proxy`; `none`
263
+ = the empty list) or the settings file's `server.auth.methods` sets the
264
+ list explicitly — explicit always wins, including `[]` on a non-
265
+ loopback bind, which boots with a warning that any reachable client is
266
+ trusted. The related env knobs: `SIGRUN_AUTH_TRUSTED_PROXIES` (CIDR
267
+ csv), `SIGRUN_PROXY_SECRET` and `SIGRUN_API_TOKEN` (credentials —
268
+ env-only, never in `settings.json`). Every auth setting is
269
+ restart-required.
270
+
271
+ **Tokens.** API tokens are `sgr_`-prefixed secrets shown once at
272
+ creation (the store keeps only SHA-256 digests) and are self-service:
273
+ an authenticated user lists and mints their own via
274
+ `GET`/`POST /v1/auth/tokens` (`DELETE /v1/auth/tokens/{id}` revokes;
275
+ admins may revoke anyone's). A token whose user was deleted is dead.
276
+ A browser exchanges a token for a `sigrun_session` cookie at
277
+ `POST /v1/auth/login` — invalid attempts are delayed and rate-limited —
278
+ and `GET /v1/auth/whoami` returns the resolved principal.
279
+
280
+ **Proxy method (forward auth).** A trusted reverse proxy vouches for
281
+ the caller through headers (Authelia's `Remote-*` defaults,
282
+ configurable) when the request comes from a peer inside
283
+ `server.auth.proxy.trustedProxies` (CIDRs). Setting
284
+ `SIGRUN_PROXY_SECRET` additionally requires that shared secret in the
285
+ `secretHeader` on every request. Scope the CIDR list to exactly the
286
+ proxy and nothing else: every peer in `trustedProxies` can forge any
287
+ identity, including an admin.
288
+
289
+ **Onboarding (the first claim).** An unclaimed server serves
290
+ `GET /v1/onboarding` + `POST /v1/onboarding/claim`. On a loopback bind
291
+ or through a trusted proxy identity the claim needs no code; otherwise
292
+ the one-shot setup code is required — printed once in the log at boot
293
+ and written to `<cacheDir>/setup-code` (in the Helm chart: the pod log
294
+ and `/cache/setup-code`; `SIGRUN_SETUP_CODE` pins it instead). The
295
+ claimant becomes the first admin and owns the reserved root
296
+ conversation. Later users join by invite code (`POST /v1/users/invites`,
297
+ admin) or by trusted-proxy auto-provision; admins can transfer or remove
298
+ users (`PATCH`/`DELETE /v1/users/{user}` — deletion transfers their
299
+ conversations).
300
+
301
+ **Sharing.** Conversations are private to their owner by default. The
302
+ owner can share one — household-wide or with named users, `read` or
303
+ `write` — through the web UI's ⋯ menu or the TUI's `/share`; `write`
304
+ lets a recipient submit, answer, and fork, but only the owner can
305
+ re-share. Household sharing defaults to `read`.
306
+
307
+ ## Backup
308
+
309
+ Set a target (`SIGRUN_BACKUP_PATH` or `SIGRUN_BACKUP_S3_BUCKET`+`SIGRUN_BACKUP_S3_PREFIX`,
310
+ via env or `server.backup` in `settings.json`) and schedule the backup
311
+ script — the chart ships a nightly CronJob; bare deployments run
312
+ `npx tsx src/backup/run-backup.ts` (or the built `dist/backup/run-backup.js`)
313
+ with `SIGRUN_STORAGE_PATH` pointing at the database. Each run takes an
314
+ online-safe `sqlite3 .backup`, ships it to the target (`sigrun-<UTC
315
+ timestamp>.db`), and folds in any enabled `SIGRUN_BACKUP_SECTIONS`
316
+ tarballs (config/secrets/data/state — see
317
+ [`docs/extending.md`](docs/extending.md)).
318
+
319
+ **S3 credentials** come from the standard AWS chain — `AWS_ACCESS_KEY_ID`,
320
+ `AWS_SECRET_ACCESS_KEY` and `AWS_REGION` in the container env (or an AWS
321
+ config file); sigrun adds no credential settings of its own. Set
322
+ `SIGRUN_BACKUP_S3_ENDPOINT` for an S3-compatible store (MinIO, RustFS):
323
+ the client then uses path-style addressing, which those stores require.
324
+
325
+ **Retention differs by target kind:**
326
+
327
+ - **Path targets** retain in-process: each run prunes `sigrun-*.db`
328
+ files older than 30 days from the target directory (mtime-based;
329
+ non-matching files are never touched).
330
+ - **S3 targets are pruned by the BUCKET, not the script** — configure a
331
+ lifecycle rule expiring the backup objects after 30 days. The rule's
332
+ filter must match your key layout: object keys are
333
+ `<SIGRUN_BACKUP_S3_PREFIX>sigrun-….db` (plus `<section>-….tar.gz` when
334
+ sections are enabled — cover those too, or drop the filter on a
335
+ dedicated bucket):
336
+
337
+ ```json
338
+ {
339
+ "Rules": [
340
+ {
341
+ "ID": "sigrun-backup-retention",
342
+ "Status": "Enabled",
343
+ "Filter": { "Prefix": "sigrun-" },
344
+ "Expiration": { "Days": 30 }
345
+ }
346
+ ]
347
+ }
348
+ ```
349
+
350
+ Applied with `aws s3api put-bucket-lifecycle-configuration --bucket
351
+ <bucket> --lifecycle-configuration file://rule.json` (adjust `Prefix`
352
+ to your `SIGRUN_BACKUP_S3_PREFIX`, or remove `Filter` on a dedicated
353
+ bucket). S3-compatible stores accept the same rule shape through
354
+ their own tooling, with `--endpoint-url` selecting yours.
355
+
356
+ **Backup honesty (`GET /v1/meta`):** a configured target reports
357
+ `health.backup: "configured"` only while backups actually SUCCEED — the
358
+ script writes `<stateDir>/backup/last-success.json` after each
359
+ successful run, and `/v1/meta` reads it per request: absent, corrupt,
360
+ or older than `server.backup.staleAfterHours` (default 36 h) reports
361
+ `"degraded"`, with the last success's timestamp in `backup.lastSuccessAt`.
362
+ This requires the server and the CronJob to resolve the SAME state root
363
+ — set `SIGRUN_STATE_DIR` on both (the chart does); with it unset on the
364
+ job, the script warns and skips the marker (the backup itself still
365
+ succeeds), and the badge honestly reads `"degraded"`.
366
+
367
+ ## Monitoring
368
+
369
+ `GET /metrics` serves a Prometheus text exposition (scrape-compatible
370
+ plain text, one `# HELP`/`# TYPE` pair per family). Access follows the
371
+ auth pipeline: a principal is required unless `SIGRUN_METRICS_PUBLIC`
372
+ (`server.metrics.public`) is set — the Helm chart sets it so the
373
+ ServiceMonitor can scrape. `/healthz` and `/readyz` stay public probes
374
+ (`sigrun_readiness 1`, or 503 with `{ready: false, checks: {storage:
375
+ "failed"}}` — the same storage check as the `sigrun_ready` gauge
376
+ below, so the probe and the alert cannot disagree).
377
+
378
+ **Metrics** (families with nothing to report — no jobs, unconfigured
379
+ backup — are omitted rather than zeroed):
380
+
381
+ | family | meaning |
382
+ |---|---|
383
+ | `sigrun_up` | 1 when `harness.inspect()` answers; 0 on a closed or storage-unreachable session (the scrape itself still answers 200). |
384
+ | `sigrun_ready` | 1 when the readiness storage check (the root-conversation read `/readyz` gates on) succeeds; 0 when it fails — fires even while the process and the scrape stay up. |
385
+ | `sigrun_live_runs` | Conversations with a placed-but-unsettled input submission. |
386
+ | `sigrun_inflight_submission_oldest_seconds` | Age of the oldest in-flight submission's admission (0 when none; in-process clock — a pre-restart run is invisible until it settles). |
387
+ | `sigrun_tasks_live{status}` | Live tasks by scheduler state (running/ready/waiting/completing/blocked, 0s included). |
388
+ | `sigrun_provider_ready{provider,status}` | 1 per provider at its current auth-readiness status (`ready`, `degraded`, `invalid`, `not_ready`, `not_configured`). |
389
+ | `sigrun_extensions{status}` | Configured extensions by load outcome (`ok` / `quarantined`). |
390
+ | `sigrun_job_last_run_timestamp_seconds{job,outcome}` | Unix time of each job's last run, by outcome. |
391
+ | `sigrun_job_next_run_timestamp_seconds{job}` | Unix time of each job's next scheduled fire. |
392
+ | `sigrun_job_runs_total{outcome}` (counter) | Job runs settled by outcome since process start (resets on restart; the durable history lives in the run records). |
393
+ | `sigrun_backup_last_success_timestamp_seconds` | Unix time of the last successful backup marker; omitted when backup is unconfigured or no marker exists. |
394
+
395
+ **Alerts** (the chart's `monitoring.prometheusRule.enabled` toggle ships these):
396
+
397
+ | alert | fires when |
398
+ |---|---|
399
+ | `SigrunNotReady` | `up == 0 or sigrun_up == 0 or sigrun_ready == 0` for 10m — scrape death, harness inspection failing, or the readiness storage check failing (10m tolerates a restart). |
400
+ | `SigrunNoScrape` | The `up{job="sigrun"}` series is absent for 30m — the ServiceMonitor/Prometheus wiring itself broke. |
401
+ | `SigrunSubmissionWedged` | `sigrun_inflight_submission_oldest_seconds > 1800` for 5m — a live run is making no progress. |
402
+ | `SigrunProviderNotReady` | A provider sits at a non-ready auth status for 15m. |
403
+ | `SigrunJobFailed` | `increase(sigrun_job_runs_total{outcome="failed"}[1h]) > 0` — a scheduled job failed. |
404
+ | `SigrunBackupStale` | The backup marker is older than 36h while backup is enabled (the rule is absent on an unbacked deploy). |
405
+
406
+ ## Extensions
407
+
408
+ One list in `settings.json` — **`extensions`** — is the desired
409
+ install-and-load state. Every entry is both installed (or fetched) at
410
+ boot and loaded into the registry; removing an entry uninstalls it.
411
+ Entry grammar:
412
+
413
+ | entry | what happens |
414
+ |---|---|
415
+ | `"name"` / `"name@range"` / `"@scope/name@range"` | npm install into the packages dir, then load |
416
+ | `"file:/abs/path"` | npm `file:` install (symlinked source — the dev-checkout shape), then load |
417
+ | `"./rel/path"` / `"/abs/path"` | load in place (nothing installed) |
418
+ | `"git:host/path"` / `"https://…git"`, optional `@ref` | clone into `<config-dir>/git/<…>`, reconcile to the ref, then load |
419
+ | `{ "source": …, "entry"?: "./x.js", "ref"?: "v1.2", "enabled"?: false }` | the explicit form: entry-point override, ref override, or install-but-don't-load |
420
+
421
+ ```jsonc
422
+ {
423
+ "extensions": [
424
+ "@acme/sigrun-ext-example@^0.1.0",
425
+ "git:github.com/obra/superpowers@main",
426
+ { "source": "./my-dev-checkout", "enabled": false }
427
+ ]
428
+ }
429
+ ```
430
+
431
+ - **Packages dir** — `<config-dir>/packages/`, a real npm root (a
432
+ synthetic private `package.json` + `node_modules/`, pi's
433
+ `~/.pi/agent/npm` shape). Boot diffs the declared npm/file entries
434
+ against the dir's own manifest: missing or range-changed → `npm
435
+ install`, removed → `npm uninstall`, no diff → no subprocess. Install
436
+ failures (or the 120s timeout) fail the boot, naming the specifier.
437
+ - **Git dir** — `<config-dir>/git/`, pi's host/path layout. Absent →
438
+ clone; present with a declared `ref` → `git fetch origin <ref>` +
439
+ checkout `FETCH_HEAD`; present without a ref → fetch only (the
440
+ checkout stays where you left it). Entry discovery: the object form's
441
+ `entry` → a `sigrun` manifest (`{"sigrun": {"extension": "./index.js"}}`)
442
+ → a pi-compat manifest (the first non-`.ts` `pi.extensions` entry; each
443
+ `.ts` entry is skipped with one warning — it cannot load under plain
444
+ node) → `dist/index.js`.
445
+ - **Skills from packages** — a package may ship skills instead of (or
446
+ beside) extension code: manifest `sigrun.skills` or pi-compat
447
+ `pi.skills` (`["./skills"]`, the same SKILL.md format), or a
448
+ factory-result `skills` field (absolute dirs). A package that declares
449
+ skills and has no loadable entry is a valid **skills-only** package
450
+ (`git:github.com/obra/superpowers` is exactly this shape): outcome `ok`,
451
+ nothing imported. A declared entry that does not resolve still
452
+ quarantines. Discovery merges three roots — the workspace's `skills/`,
453
+ the user dir `<config-dir>/skills/`, and package roots in install order
454
+ — with workspace > user > packages precedence (a collision keeps the
455
+ higher root and logs). The section cap is `skills.sectionMaxBytes`
456
+ (default 4096, live).
457
+ - **Resolution** — npm/file names resolve against the packages dir
458
+ first (the deployment's declared version wins over anything the image
459
+ bundles); `git` entries resolve to their discovered checkout entry;
460
+ then bare import resolution (dev checkouts, image-bundled packages).
461
+ - **Disabled entries** — `{enabled: false}` keeps the directory state
462
+ (installed, cloned) but is never loaded. Re-enable by flipping the
463
+ boolean and restarting.
464
+
465
+ `SIGRUN_EXTENSIONS` overrides the list (env wins, per Configuration's
466
+ rule): comma-separated, and the same grammar per chunk — including
467
+ JSON-object entries.
468
+
469
+ **Restart to change:** everything extension-shaped is boot-time. Docker
470
+ compose (`SIGRUN_CONFIG_DIR=/data/config` on the `sigrun-data` volume —
471
+ see `docker/`): edit `settings.json` in the volume, `docker compose
472
+ restart`. Kubernetes (the chart defaults the config dir onto the same
473
+ PVC as the database): edit the file (`kubectl exec`, or your GitOps
474
+ seeding), `kubectl rollout restart`. Source: edit
475
+ `~/.config/sigrun/settings.json`, restart the process. No image rebuild,
476
+ no manual npm.
477
+
478
+ **Security notes:** `npm install` executes the installed packages'
479
+ lifecycle scripts — operator-configured specifiers carry the same trust
480
+ as the extension code they name (full tool authority in the harness).
481
+ Git sources execute nothing at install (clone/fetch/checkout): the
482
+ extension code loads through the upstream discovery, and git extensions
483
+ must be dependency-light — their repos are loaded directly, NOT
484
+ npm-installed, so a loadable entry (`.js` dist, a sigrun manifest, or a
485
+ ts-loadable source under the tsx-started process) is the contract.
486
+ Load failures quarantine in `GET /v1/meta` (`extensions[].status`) and
487
+ never abort the boot — install failures fail loud at boot.
488
+
489
+ **Writing an extension:** an extension is a plain npm package — a
490
+ default-export factory returning `{ extension }` — and every file it
491
+ touches has exactly one place to live: five stores (settings, secrets,
492
+ data, state, cache) under XDG-respected roots, scoped by the extension's
493
+ slug, with one resolution chain and a fixed secrets precedence (stored
494
+ wins, env bootstraps). The author-facing contract — the store taxonomy,
495
+ the directory map, the slug rule, the `host.stores` handle, uninstall
496
+ and backup posture — is [`docs/extending.md`](docs/extending.md).
497
+
498
+ ## Built-in extensions
499
+
500
+ Three extensions are built into core — **codemode**, **MCP**, and
501
+ **tool search** — mirroring what pi 1.3.0 ships built-in. They load by
502
+ default on every boot, with no settings entry, and resolve from core's
503
+ own `node_modules` (they are core dependencies, never installed into
504
+ the packages dir — `extensions` has nothing to install for them, unless
505
+ a same-named package is deliberately installed to replace one; see
506
+ below).
507
+ A default install therefore has pi's tool surface without configuring
508
+ anything. On `GET /v1/extensions` they report under the names
509
+ `codemode`, `sigrun-mcp`, and `tool-search`.
510
+
511
+ **The tools stay off until needed** (pi parity): `codemode` and
512
+ `tool_search` are not in a new conversation's model-offered tool set by
513
+ default. They join the offered set when an enabled MCP server's
514
+ `exposure` needs them, or when `defaultTools` names them explicitly.
515
+
516
+ - **The auto-enable rule** — with an enabled MCP server whose `exposure`
517
+ is `codemode` (the default), the `codemode` tool is offered; with
518
+ `deferred`, `tool_search` is. `"autoEnableCodemode": false` turns
519
+ both arms off. An explicit `defaultTools` list always wins over the
520
+ automatic decision (auto-enable only narrows the default set, never a
521
+ named list).
522
+ - **Turning one off** — `"builtinExtensions": {"codemode": false,
523
+ "mcp": false, "toolSearch": false}`. Each defaults to `true`; each is
524
+ restart-required. A disabled built-in disappears from
525
+ `GET /v1/extensions` entirely — no row, not even a skipped one — and
526
+ its config is then ignored.
527
+ - **Replacing one** — installing a package that resolves to the same
528
+ package name as a built-in replaces it: the installed copy loads, the
529
+ built-in is skipped, boot logs one warning naming both, and
530
+ `GET /v1/extensions` shows the built-in's row with `"builtin": true`
531
+ and a `replacedBy` field naming the configured specifier.
532
+ - **Quarantine** — a built-in that fails to load is quarantined like
533
+ any other extension: boot continues, the outcome is reported (status
534
+ `quarantined`, in `GET /v1/meta` alongside the configured set).
535
+
536
+ The MCP servers themselves live in the MCP config file — the
537
+ `@sigrun/ext-mcp` package's README documents its format, including each
538
+ server's `exposure` and the `SIGRUN_MCP_CONFIG` override.
539
+
540
+ ```jsonc
541
+ {
542
+ "extensions": [ // configured extensions install + load as above
543
+ "@acme/sigrun-ext-example@^0.1.0"
544
+ ],
545
+ "builtinExtensions": { // all three default to true; restart required
546
+ "codemode": true,
547
+ "mcp": true,
548
+ "toolSearch": false
549
+ }
550
+ }
551
+ ```
552
+
553
+ ## Skills and prompt templates
554
+
555
+ Two text-expansion surfaces ship in core, pi parity: a **skill
556
+ invocation** and a **prompt template**, both expanded SERVER-SIDE at
557
+ admission (in `submitOp`, before content-part resolution) — so the
558
+ webui, the TUI, the HTTP API, `host.conversations.submit`, and job
559
+ prompts all behave identically. Expansion happens when the submitted
560
+ text starts with `/`; everything else is admitted verbatim.
561
+
562
+ **`/skill:<name> [args]`** expands to the skill's full body:
563
+
564
+ ```text
565
+ <skill name="greet" location="skills/greet/SKILL.md">
566
+ References are relative to skills/greet.
567
+
568
+ Say hello to the user warmly.
569
+ </skill>
570
+
571
+ hej
572
+ ```
573
+
574
+ (the blank line and the arguments only when arguments were given; the
575
+ body is the SKILL.md with frontmatter stripped). An unknown skill
576
+ passes through unchanged — never an error. A skill with
577
+ `disable-model-invocation: true` stays out of the model's skill list
578
+ but still expands here. Every skill appears in the `/` menu as
579
+ `skill:<name>` with its description.
580
+
581
+ **Prompt templates** are Markdown files whose filename is the command
582
+ (`review.md` → `/review`). Frontmatter `description` (else the first
583
+ non-empty line) feeds the `/` menu, optional `argument-hint` hints the
584
+ arguments, and the body substitutes shell-quoted arguments:
585
+ `$1`, `$2`, …; `$@` and `$ARGUMENTS` (all arguments, space-joined);
586
+ `${1:-default}`; `${@:-default}`; `${@:N}`; `${@:N:L}`.
587
+ `/review "API compatibility"` passes ONE argument. An unterminated
588
+ quote is a usage error, not a silent swallow.
589
+
590
+ Both are discovered from the same three roots, first-wins in this
591
+ order (the skills convention's precedence):
592
+
593
+ 1. the workspace — `skills/` and `prompts/` under the conversation
594
+ workspace;
595
+ 2. the user dir — `<config-dir>/skills/` and `<config-dir>/prompts/`;
596
+ 3. packages — each installed package's `skills/` (manifest or
597
+ factory-declared) and `prompts/` directories.
598
+
599
+ Both lists are re-scanned live (each `/v1/commands` request and each
600
+ expansion) — drop a file in and it works, no restart. A template whose
601
+ name collides with a core or extension command is skipped with one
602
+ warning: commands keep their meaning. The transcript shows what you
603
+ typed — the submission's user entry carries
604
+ `{kind: "sigrun.user.expanded", data: {typed, command}}` while the
605
+ model receives the expanded text.
606
+
607
+ ## Jobs
608
+
609
+ A job is a schedule plus an action, durable by construction — one
610
+ SQLite-backed record per job, one immutable revision per change, and
611
+ one record per run. Jobs are the only time-based scheduler in sigrun
612
+ (extensions that need a timer declare a job with a `task` action).
613
+
614
+ **Three sources:**
615
+
616
+ | source | where it is declared | id | mutable via the API |
617
+ |---|---|---|---|
618
+ | `api` | `POST /v1/jobs` | `api:<uuid>` | yes (owner or admin) |
619
+ | `file` | the settings file's `jobs[]` list | `file:<name>` | no — edit the file |
620
+ | `extension` | an installed extension's factory result | `ext:<slug>:<name>` | no |
621
+
622
+ `file` and `extension` declarations reconcile into job records at
623
+ boot and on change; the declaration is the source of truth, so the API
624
+ is read-only for them (`PATCH`/`DELETE` return `409 job_declared`).
625
+
626
+ **The shape** (spec §8.2):
627
+
628
+ ```jsonc
629
+ {
630
+ "name": "morning-brief",
631
+ "schedule": { "cron": "0 7 * * *", "timezone": "America/Toronto" }, // or { "every": "15m" } or { "at": "<ISO 8601>" }
632
+ "action": { "kind": "prompt", "prompt": "Summarize …", "model": "bifrost/glm-5.3-flash",
633
+ "conversation": "new" }, // or a fixed conversation id
634
+ "overlap": "skip", // default; "queue" admits anyway (the conversation's inbox queues it)
635
+ "catchUp": "once", // default; "none" skips fires missed while the server was down
636
+ "enabled": true
637
+ }
638
+ ```
639
+
640
+ A `prompt` action submits its prompt as a conversation — `"new"`
641
+ creates one per fire (ownerless, `source: "cron"`), a fixed id submits
642
+ into that conversation (the job's `model`/`thinking` apply only to
643
+ `"new"`). A `task` action creates a pi-durable task owned by the
644
+ schedule task (deleting the job cascades): `{"kind": "task",
645
+ "task": "<registered task name>", "input": …}`.
646
+
647
+ **Runs:** a cron run is an ORDINARY CONTINUABLE CONVERSATION — open the
648
+ run conversation in either UI and keep talking; it is never a
649
+ disposable side effect. `overlap: "skip"` (the default) records a
650
+ `skipped` run when the previous fire is still live; `catchUp: "once"`
651
+ (the default) fires once after downtime, `"none"` skips missed fires
652
+ entirely. Runs carry the job `version` that fired them, so an outcome
653
+ always maps to the exact definition it ran.
654
+
655
+ **Run now:** `POST /v1/jobs/{id}/run` (write access) fires immediately
656
+ — the same hook external triggers (CI, Alertmanager) use with a token.
657
+ In the web UI the Jobs panel (the clock icon in the header) lists jobs
658
+ with a “Run now” button; in the TUI `/jobs` prints the table (name,
659
+ next run, last outcome).
660
+
661
+ ## API sketch
662
+
663
+ - `GET /v1/meta` — version, extensions, health, auth posture
664
+ (`methods`, `claimed`), capabilities.
665
+ - `GET`/`PATCH /v1/settings` — the settings file (see above).
666
+ - `GET /v1/jobs`, `POST /v1/jobs`, `PATCH`/`DELETE /v1/jobs/{id}` —
667
+ the jobs surface (see [Jobs](#jobs)); `POST /v1/jobs/{id}/run` fires
668
+ now; `GET /v1/jobs/{id}/runs` and `GET /v1/jobs/{id}/revisions` are
669
+ the history (newest first).
670
+ - `POST /v1/conversations`, `POST /v1/conversations/{id}/submissions`,
671
+ `GET /v1/conversations/{id}` — the durable conversation surface
672
+ (SSE streaming views included).
673
+ - `GET /v1/providers` — per-provider readiness, statuses only (never
674
+ credentials); `GET /v1/auth/check` is the deprecated alias.
675
+ - `GET /v1/onboarding`, `POST /v1/onboarding/claim` — the unclaimed
676
+ state and the first-user claim.
677
+ - `POST /v1/auth/login`, `POST /v1/auth/logout`, `GET /v1/auth/whoami` —
678
+ the browser session and the resolved principal.
679
+ - `GET`/`POST /v1/auth/tokens`, `DELETE /v1/auth/tokens/{id}` —
680
+ self-service API tokens.
681
+ - `GET /v1/users`, `PATCH`/`DELETE /v1/users/{user}`,
682
+ `POST /v1/users/invites` — the user directory and invite codes
683
+ (admin-gated).
684
+ - `GET /healthz`, `GET /readyz` — probes.
685
+
686
+ ## Development
687
+
688
+ ```bash
689
+ npm test # full suite (vitest)
690
+ npm run typecheck # tsc --noEmit
691
+ npm start # tsx src/index.ts
692
+ ```