@volter/supercode-orchestrator 0.4.4

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.
package/README.md ADDED
@@ -0,0 +1,532 @@
1
+ # `@volter/supercode-orchestrator`
2
+
3
+ This is currently a private workspace package. Examples assume the checkout's
4
+ workspace dependencies and a built Volter Harness CLI; the package name is not a claim
5
+ that an installable npm release exists.
6
+
7
+ The orchestrator's runtime over the ontology: one typed operational model
8
+ (`OrchestratorState` = an orchestration value plus a runtime half) whose folder is its serialization.
9
+ `docs/ORCHESTRATOR-IR.md` is normative. The orchestration value and every codec are the ontology's, in
10
+ Rust (`crates/interchange/src/orchestration/`); this package reads and writes its home through the
11
+ harness's orchestration doors (`harness.v1.orchestration.*`), never a codec of its own.
12
+
13
+ ```js
14
+ import { load, save, compile, importFrom, exportTo, closeWorld } from '@volter/supercode-orchestrator';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ const root = process.env.SUPERCODE_ORCHESTRATOR_HOME || join(homedir(), '.supercode/orchestrator');
19
+ const { state, vault } = await load(root); // strict; secrets in `vault`, never `state`
20
+ await save(state, root); // canonical, byte-stable when unchanged
21
+
22
+ const { state: hermesState } = await compile('hermes', process.env.HERMES_HOME); // a home as a state value
23
+ await closeWorld(); // close the shared door when done
24
+ ```
25
+
26
+ The doors are `orchestration.mjs`: `load`/`save`/`compile`/`importFrom`/`exportTo`/`closeWorld` over one
27
+ `SupercodeHarnessClient`. A secret VALUE never crosses the wire — `load` and `compile` answer with
28
+ the `.env` KEY NAMES, and `load` reads the values out of the home's own `.env`. The door is held
29
+ open while a loop runs (`retainWorld`/`releaseWorld`, the loop's `start`/`stop`) and closes itself
30
+ when idle, so a one-shot verb (a test, a migration) exits on its own.
31
+
32
+ Rules the codec keeps (enforced in Rust, observed here):
33
+
34
+ - Unknown keys in an orchestrator-owned block (`worker`) fail the
35
+ load, naming the file and key. Hermes-shaped records keep unmodeled fields in `residue`, verbatim.
36
+ - A file whose decoded record is unchanged is written back byte for byte; a changed record is
37
+ emitted canonically in Hermes's own field order.
38
+ - Secret values live in the vault (`.env` in our folder; inline in a Hermes export because that
39
+ is where Hermes reads them). A value pasted inline into our folder is moved into `.env` on
40
+ first load and the file rewritten with the ref. `JSON.stringify(state)` never contains one.
41
+ - Exporting to Hermes refuses to write Hermes's session store when bindings or obligations changed
42
+ since import (UNI-22); it copies the store byte for byte when they did not.
43
+ - Files the IR does not model are never touched by `save` and are carried by path on a migration.
44
+
45
+ ## OpenClaw, and the two migration verbs (ORC-12)
46
+
47
+ ```js
48
+ import { compile, importFrom, exportTo } from '@volter/supercode-orchestrator';
49
+
50
+ const { state } = await compile('openclaw', process.env.OPENCLAW_STATE_DIR); // a home as a state value
51
+ ```
52
+
53
+ ```sh
54
+ node bin/orchestrator.mjs import --from hermes|openclaw [--home <path>] [--into <root>]
55
+ node bin/orchestrator.mjs export --to hermes|openclaw [--from <root>] [--into <dest>]
56
+ ```
57
+
58
+ `import` (`orchestration.import`) compiles that harness's home and saves ours, credentials along; `export`
59
+ (`orchestration.export`) loads our folder and decompiles it back, printing the tier of every file it wrote.
60
+ Because a migration moves credentials, both are composed inside the harness process, not from the
61
+ value-level doors — a client composing them would have to send the secret over the wire. A refused
62
+ write exits **2** with its reason — a partial migration is never reported as a whole one.
63
+
64
+ What OpenClaw's state directory compiles to, at the pinned `v2026.7.1-2`:
65
+
66
+ - `agents.*` → profiles. The DEFAULT agent (the entry flagged `default`, else the first declared,
67
+ else `main`) is the IR's `default` profile; every other agent keeps its id. Each is rooted at its
68
+ own `agents/<id>/`.
69
+ - `channels.<name>[.accounts.<id>]` → one `ChannelConfig` per row `channels list` shows, keyed by
70
+ that row's name (`slack/T0FIXTURE`); the transport is `extra.kind` and the source blocks ride
71
+ verbatim in `extra.channel_block` / `extra.account_block`.
72
+ - `bindings[]` → `Route`. The IR's match carries the three fields it has a noun for (`channel` →
73
+ `platform`, `guildId` → `guild_id`, `peer.id` → `chat_id` — the projection `routes list` makes);
74
+ `accountId`, `teamId`, `roles`, `peer.kind` and the binding's `type` ride in residue.
75
+ - `hooks.mappings[]` → `WebhookSubscription`s (the inbound-trigger noun `triggers list` reports),
76
+ with the block's shared `token` as each subscription's secret.
77
+ - `cron_jobs` → `Job`. `sessionTarget` has no IR field: it stays in residue verbatim AND surfaces
78
+ as `attach_to_session` when it names an existing conversation (`main` → `agent:<id>:main`,
79
+ `current` → the row's own session key, `session:<id>` → `<id>`; `isolated` opens a fresh one).
80
+ - `cron_run_logs` → `Fire`. `ok | error | skipped` map to `succeeded | failed | unknown` and the
81
+ native word stays in residue. OpenClaw's run log is written once, at finish, and records no
82
+ claim, so the fire's start stands in for `claimed_at` and `residue.__no_claim` says so.
83
+ `delivery_status` / `delivery_error` / `delivered` ride in residue, and the queue entry on the
84
+ same conversation key becomes the fire's `obligation_id`.
85
+ - `delivery_queue_entries` → `Obligation`, partitioned by the agent segment of its session key.
86
+ - each agent's `sessions/*.jsonl` header key → a `Binding`. `agent:<id>:main` is OpenClaw's DM
87
+ COLLAPSE: it becomes `{platform: 'main', kind: 'dm', chat_id: 'main'}` and the collapse is
88
+ recorded in residue, because the IR cannot recover which transport a collapsed DM arrived on.
89
+
90
+ Tiers on the way out:
91
+
92
+ - **byte** — `state/openclaw.sqlite` when every record decoded from it is unchanged (the file is
93
+ copied); any row whose own record is unchanged when the store must be rewritten (its columns go
94
+ back verbatim, and `report.rows` counts them); every unmodeled file under the state directory;
95
+ and `openclaw.json` when its decoded record is unchanged.
96
+ - **semantic** — a CHANGED `openclaw.json`. It is re-emitted as JSON, which openclaw's JSON5
97
+ parser reads, but the source's comments, trailing commas and key order are lost; `report.notes`
98
+ says so every time that path is taken.
99
+ - **refused** — bindings. An OpenClaw conversation's surface lives in its session KEY inside the
100
+ transcript header, so writing them back is behind the UNI-22 stability gate, exactly as
101
+ the Hermes export refuses Hermes's session store. A credential ref the vault cannot resolve is refused
102
+ too, rather than written as its own reference.
103
+
104
+ Secrets never enter the model: any string under a credential-shaped key — openclaw's own rule
105
+ (`channels.rs::is_credential_key`: the lower-cased key ENDS WITH `token|key|secret|password|
106
+ credential`), minus the names it spells that way for non-secrets (`sessionKey`, `store_key`, …) —
107
+ moves to the vault as a `{dotenv}` ref and is re-inlined on export where openclaw reads it.
108
+
109
+ Two limits worth naming. `save` writes our folder in the IR's own serialization, so a home
110
+ imported from another harness loses that harness's file COMMENTS and, for fires, any run-log
111
+ column our Hermes-shaped `cron/executions.db` has no room for (OpenClaw's `delivery_status`,
112
+ `session_key`, `summary`, `model`, token counts): a direct compile-then-decompile of an OpenClaw
113
+ home is byte tier, the same trip through our folder is not. And `import` carries the source home's
114
+ unmodeled files by path, because `save` only ever writes the files the IR owns.
115
+
116
+ ## The loop (ORC-4)
117
+
118
+ ```js
119
+ import { Orchestrator, LoopbackAdapter, events, load } from '@volter/supercode-orchestrator';
120
+ import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
121
+
122
+ const { state, vault } = await load(root);
123
+ const orch = new Orchestrator({ state, vault, client: new SupercodeHarnessClient(), adapters: { loopback: new LoopbackAdapter() } });
124
+ await orch.start(); // adapters connect; inbound messages become events
125
+ const now = new Date().toISOString();
126
+ await orch.dispatch(events.tick(now)); // the clock is an event too
127
+ ```
128
+
129
+ `step(state, event)` is the pure reducer (§4): it mutates the one state value and returns effects
130
+ (§5); the `Orchestrator` performs them over the SDK (`runtimes.start|send_input|respond`) and the
131
+ adapters (`deliver`). A worker runs with the profile folder as cwd and the profile's harness home
132
+ inside it (`activationEnv`), so its AGENTS.md is in context by the ordinary instruction-file walk.
133
+
134
+ ## Cron and webhooks (ORC-5)
135
+
136
+ ```js
137
+ await orch.dispatch(events.tick(now)); // §4.3: expiry, due jobs, obligation retry, breakers
138
+ await orch.tick(); // the same, on the orchestrator's own clock
139
+ // POST /webhooks/<name> on orch.webhookPort (config `gateway.webhook_port`, else any free port)
140
+ ```
141
+
142
+ - A due job is **claimed before it runs**: the fire row (`cron_<job_id>_<YYYYMMDD_HHMMSS>`, which is
143
+ both the fire's id and its session name) is saved before the `runtime_start` effect, and a second
144
+ tick that sees a `claimed`/`running` fire for that job does nothing.
145
+ - The fire runs on a synthetic key `{platform: 'cron', kind: 'dm', chat_id: job_id}` with a
146
+ binding carrying `recurrence`. Its `final` delivers to the job's resolved target — `origin` →
147
+ `job.origin`, `home` → `profile.home`, `explicit` → as given, `local` → `cron/output/<fire_id>.md`
148
+ under the profile — never to the synthetic key. An error or timeout goes to `failure_deliver`,
149
+ else to `deliver` with a `Cron job <id> failed:` prefix.
150
+ - `next_run_at` is recomputed after every fire: `once` is spent (unless a numeric `repeat` remains),
151
+ `interval` counts from the run, `cron` evaluates five fields in `schedule.tz`. `context_from`
152
+ prepends a note naming the session; the transcript is never loaded.
153
+ - The daemon does not die on a failed event: a timer tick, an adapter's inbound, the webhook listener
154
+ or a queued follow-up whose effect fails (a save on a full disk) is logged, where before its
155
+ unhandled rejection ended the process. History is bounded as Hermes bounds it: run files per job
156
+ (`cron.output_retention`, 50), completed one-shots (`cron.completed_retention_days`, 7), 1000
157
+ finished fires, and obligations as its delivery ledger (7 days, 500 rows).
158
+ - Slack, Telegram and Discord blocks from a Hermes home connect through the Chat SDK sidecar with
159
+ Hermes's own `.env` variables and ingress (Socket Mode, polling, the gateway); install the platform
160
+ package (`@chat-adapter/slack`, …) beside the orchestrator.
161
+ - The agent-setup applier (`orchestrator apply plan|apply|adopt|provision --home-id <id> --hermes <home> | --orchestrator <root> --spec <setup.json>`):
162
+ a package's declared jobs and `Inference` reach one home through its own functions (Hermes's cron
163
+ and config functions under its own interpreter; the orchestrator's operator verbs), owned by
164
+ harness id against a base in the applier's own state. A home with no state is plan-only until
165
+ `provision` or `adopt`; `delete` runs only with `--reviewed`.
166
+ - Limits as Hermes sets them: `cron.max_parallel_jobs` / `HERMES_CRON_MAX_PARALLEL` caps the fires in
167
+ flight across the daemon; an `agent.max_turns` a worker cannot take at launch is reported as not
168
+ enforced.
169
+ - A webhook subscription with `deliver_only: true` (Hermes's direct-delivery mode) runs no agent: the
170
+ rendered prompt is delivered as the message.
171
+ - A job's gate runs before any model, as in Hermes: `script` (its stdout leads the prompt, or skips
172
+ the model when empty or `{"wakeAgent": false}`), `no_agent` (the script IS the job), and
173
+ `monitor_script`/`monitor_url` (unchanged output suppresses the run; a change puts a diff in the
174
+ prompt). Scripts live under the profile's `scripts/`.
175
+ - A fire whose explicit delivery target is on a platform no adapter serves is blocked before its
176
+ worker runs (Hermes's delivery preflight), alerting once until a healthy run. Each run resets the
177
+ job's `last_delivery_error`; a delivery that finally fails writes it.
178
+ - A fire whose worker has been silent for `HERMES_CRON_TIMEOUT` seconds (600 by default, 0 for no
179
+ limit, as in Hermes) is closed as a timeout, its failure delivered, and its runtime closed; before,
180
+ a hung fire kept its job from ever running again.
181
+ - A job's `model` (and a profile's, and `/model`'s) reaches the worker's launch (`claude --model`,
182
+ `codex app-server -c model=…`, `gemini -m`). For a harness with no such flag it is refused where it is
183
+ set (`/model`, `jobs.create|update`, `cronjob_create`) and again at the start, rather than run on
184
+ the harness's default. A job's `skills`
185
+ are invoked as Hermes invokes them: each is found through the worker harness's own skill loader, from
186
+ the fire's own working directory, and its `SKILL.md` (with `${HERMES_SKILL_DIR}` resolved) goes ahead
187
+ of the prompt, which follows Hermes's instruction marker; a skill that is not there is named in a
188
+ notice.
189
+ - A schedule nothing can evaluate — a cron expression that does not parse, a `tz` that is not an IANA
190
+ zone (`America/New_Yrok`) — is refused by name at every door a job enters by (`jobs.create|update`,
191
+ `jobs.resume`, `cronjob_create`). One that reached the folder by hand is caught fail-closed: the
192
+ tick that finds it due, or the fire that cannot re-arm it, disables the job (`enabled: false`,
193
+ `next_run_at: null`, the reason in Hermes's `last_error`) and sends one notice to
194
+ `failure_deliver`, else `deliver`, else home. It is never left due.
195
+ - A pending obligation is retried on the tick with a 1 / 2 / 4-minute backoff up to three attempts.
196
+ Three consecutive send failures on one adapter open its breaker for five minutes
197
+ (`state.runtime.adapters[platform].breaker`); while it is open, obligations stay `pending` and
198
+ their `attempts` are untouched. An adapter an `adapter` event has already declared `down`
199
+ (`state.runtime.adapters[platform].state`) is treated the same way before the breaker has counted
200
+ to three, so a single delivery cannot spend its three attempts inside a known outage and die
201
+ `failed` while the transport is still gone.
202
+ - `POST /webhooks/<name>` verifies an HMAC-SHA256 `X-Signature-256` header against the
203
+ subscription's secret **from the vault** and is fail-closed: unverified is dropped and logged.
204
+ A verified call renders `prompt_template` with `{field}` substitution from the JSON payload,
205
+ opens a turn on `{platform: 'webhook', kind: 'dm', chat_id: name}` in the subscription's
206
+ profile, and delivers the answer per `subscription.deliver`.
207
+ - A subscription's `events` filter on the event the SOURCE names: `X-GitHub-Event`, `X-GitLab-Event`,
208
+ `X-Event-Key` (Bitbucket), `X-Event-Type`, else the body's own `event`/`type`/`action`. An entry with
209
+ a dot names the action too (`pull_request.opened` matches `<event>.<payload.action>`); a call naming
210
+ no event is not a subscribed one. A delivery id (`X-GitHub-Delivery`, `X-GitLab-Event-UUID`,
211
+ `Webhook-Id`, `X-Request-Id`) already handled for that subscription — the last 200 are remembered —
212
+ is dropped with an info log, so a redelivery runs once. The memory is runtime state, as in
213
+ Hermes's own adapter: a daemon restart forgets it.
214
+ - A permission prompt raised inside a fire or a webhook turn has nobody to ask, so it is answered
215
+ at once — never left to `worker.permission.timeout_seconds` — with `worker.permission.unattended`
216
+ (`deny`, the default, or `approve`; Hermes's `approvals.cron_mode`), and one line saying what was
217
+ decided goes to the job's `failure_deliver`, else `deliver`, else home (a webhook's: its
218
+ `deliver`). A chat's prompt is still relayed to the chat.
219
+ - A Codex worker asks only when started with an approval policy: where the profile keeps Hermes's
220
+ approvals (`approvals.mode` unset or not `off`) it starts with `approval_policy: untrusted`, so it asks
221
+ before every command and Hermes's rule answers; with approvals off it asks nothing.
222
+
223
+ ```yaml
224
+ worker:
225
+ harness: codex
226
+ permission:
227
+ unattended: approve # deny | approve; what a cron fire or webhook turn's prompts get
228
+ ```
229
+
230
+ Known gap: `Fire.session_id` and `Fire.obligation_id` are live-record fields with no column in
231
+ Hermes's `executions` table, whose schema is frozen. They round-trip by construction instead — the
232
+ fire's id **is** its session name (`fireSessionId`), its obligation is `obl-<fire_id>`, and the
233
+ binding carries `residue.fire_id` — so `load()` still joins a fire to its session and its delivery.
234
+
235
+ ## The model-facing door (ORC-6)
236
+
237
+ One stdio MCP server per worker session, spawned by the worker itself and bound to the conversation
238
+ that spawned it (§7). `Orchestrator.start()` opens a Unix socket at `<root>/orchestrator.sock`
239
+ (`mcp/bridge.mjs`); `startRuntime` mounts `mcp/server.mjs` through whatever door the worker's harness
240
+ publishes (`mcp/mount.mjs`), with the binding in the server's environment — the three variables
241
+ `activationEnv` already sets, plus `SUPERCODE_ORCHESTRATOR_SOCK`.
242
+
243
+ | Harness | Door | What is written |
244
+ |---|---|---|
245
+ | claude-code | `--mcp-config <file>` in the launch arguments | `<profile>/claude-code/mcp-orchestrator-<surface>.json`, `.mcp.json`-shaped |
246
+ | codex | `[mcp_servers.orchestrator]` | appended (idempotently) to `<profile>/codex/config.toml` |
247
+ | opencode | `mcp.orchestrator` | `<profile>/opencode/opencode.json` |
248
+ | pi | none | pi ships no MCP client by design; the mount refuses and says so |
249
+ | hermes, openclaw, any other ACP agent | `session/new`'s `mcpServers` | `harness.v1.runtimes.start`'s `mcp_servers` |
250
+
251
+ Only the flag and ACP doors are named per launch, so only they carry the per-session
252
+ `SUPERCODE_ORCHESTRATOR_SURFACE`; under codex and opencode the server inherits it from the worker
253
+ process the loop already activated for this session.
254
+
255
+ Tools, one per verb, names and argument shapes transcribed from Hermes's `tools/cronjob_tools.py`
256
+ (`cronjob_manage`) and `tools/send_message_tool.py` (`send_message`): `cronjob_create`,
257
+ `cronjob_list`, `cronjob_delete`, `send_message`, `session_search`, `session_handoff`, `memory_show`,
258
+ `skills_list`. Every result names what it did in `ran`.
259
+
260
+ - `cronjob_create` captures `origin` from the binding, never from an argument, so a job created in a
261
+ conversation delivers back to it. Hermes's `name` has no IR field and rides the residue channel.
262
+ - `schedule` takes the IR's typed `Schedule` or the four Hermes string forms that map onto it
263
+ exactly (`30m` / `every 2h`, `in 30m`, five-field cron, an ISO timestamp); natural day/time
264
+ phrasing is refused by name rather than re-implemented.
265
+ - `send_message` defaults to this session's own surface; a target that routes to another profile is
266
+ refused, naming that profile.
267
+ - `memory_show` and `skills_list` proxy `harness.v1.memory.show` / `skills.list` for the worker
268
+ harness against this profile's own config home.
269
+
270
+ `supercode mcp serve` (the coding tools) is a different server for a different purpose and is
271
+ untouched.
272
+
273
+ ## Acceptance (ORC-8)
274
+
275
+ The bar of `docs/ORCHESTRATOR-IR.md` §8 — one scripted week of realistic use replayed against
276
+ Hermes and against this package, compile-diffed, only zero defects done — closed on 2026-09-04 at
277
+ defect 0 (61 scenes, 21/21 expectations, 28 seconds, both subjects on the scripted clock). §8
278
+ carries the record, the defects fixed on the way and the Hermes evidence boundary; the instrument
279
+ was deleted after its run (owner rule: the roadmap's one test run, then gone).
280
+
281
+ ## The two borrowed adapters (ORC-9)
282
+
283
+ Every adapter is the same three methods — `connect(emit)` / `send(target, content)` / `close()` —
284
+ and every one of them reaches the reducer through `inbound` and nothing else (§3: *"An adapter that
285
+ cannot express something as an `inbound` event does not get a private path into the loop"*). A
286
+ `platforms:` block picks its adapter by `extra.adapter` when it names one and by the block's own
287
+ name otherwise:
288
+
289
+ ```yaml
290
+ platforms:
291
+ slack: # one block = one (platform, account)
292
+ enabled: true
293
+ extra:
294
+ adapter: chat-sdk # -> adapters/chat-sdk.mjs
295
+ botToken: ${SLACK_BOT_TOKEN} # a reference, as Hermes writes one; the value lives in .env
296
+ api_server: # -> adapters/api-server.mjs, by the block's name
297
+ enabled: true
298
+ extra:
299
+ host: 127.0.0.1
300
+ port: 8090
301
+ key: ${API_SERVER_KEY}
302
+ rh2: # -> adapters/rh2.mjs: Runhuman Room conversations as a channel
303
+ enabled: true
304
+ token: ${RH2_SESSION_TOKEN} # the bot principal's automation session; a reference loads as a
305
+ # credential only at the block's top level or under extra:
306
+ extra:
307
+ api_url: https://pilot.runhuman.com
308
+ organization: peakhealth
309
+ principal_id: prn_… # the bot's own principal (is_me, mentions)
310
+ ```
311
+
312
+ **The RH2 adapter** (`adapters/rh2.mjs`) holds an RH2 session, follows every active Room the bot is
313
+ a member of (or `extra.rooms`), polls each conversation it may read (`GET …/messages?after=`), turns
314
+ a person's line (`kind: human`, or a trigger's `automation`) into an `inbound` on the key
315
+ `{platform: rh2, kind: channel, chat_id: <roomId>/<conversationId>}`, and posts the reply as an
316
+ `agent`-kind message authored as the bot. Typing is RH2 conversation presence, tried once and
317
+ forgotten if the deployment has no door for it.
318
+
319
+ Both blocks show up in `supercode channels list --harness orchestrator` through the reader that was
320
+ already there — `configured` by key *presence*, never by reading a value.
321
+
322
+ **The Chat SDK sidecar** mounts `chat@4.39.0` (npm package `chat`, repo `vercel/chat`; a
323
+ devDependency of this package, installed alongside `@chat-adapter/state-memory@4.39.0`). The
324
+ platform packages (`@chat-adapter/slack`, …) are **not** dependencies: the one the block names is
325
+ imported lazily at `connect`, and the credential **key names in the block are that factory's own
326
+ option names** — Volter Harness renames nothing and resolves the refs out of the vault at connect, never
327
+ earlier. Surface identity is read off the handler's `Thread` (`adapter.name`, `channelId`, `id`,
328
+ `isDM`, `channelVisibility`) and content off the `Message` (`author.userId/userName/isMe/isBot`,
329
+ `isMention`, `id`, `metadata.dateSent`, `attachments` with `fetchMetadata` as the IR's
330
+ `Attachment.ref`) — ORC-1 F4's table, transcribed. Two decisions the IR left open are made in
331
+ `adapters/chat-sdk.mjs` and nowhere else:
332
+
333
+ - **`kind`** (the surface key's chat type) (F4 finding 4, which named this as a rule the IR "would have to invent"):
334
+ `isDM` → `dm`; else `channelVisibility: private` → `group`; else (`workspace` / `external` /
335
+ `unknown`) → `channel`. `thread` is never produced — a thread is the key's own `thread_id`.
336
+ - **Queueing** (F4 finding 11): the SDK's per-thread concurrency is set to `concurrent`, so §4.1
337
+ step 5's queue is the only queue on a surface. `extra.concurrency` overrides it.
338
+
339
+ `is_me` is now on the event and the reducer drops such an inbound at step 0 (§4.1): a platform that
340
+ replays what we posted is not a turn. `send` returns the platform's handle and `deliver` records it
341
+ as `Obligation.posted.message_id` (§2.6) — the thing §4.2's edit-in-place would edit.
342
+
343
+ **The api_server surface** serves Hermes's own client API on its own `node:http` listener bound to
344
+ `platforms.api_server.extra.host|port` (exactly Hermes's keys, `api_server.py:1515-1519`). It is
345
+ deliberately *not* mounted on the webhook listener: that one binds `gateway.webhook_port`, a
346
+ different key with a different meaning. Auth is `Authorization: Bearer <extra.key>` —
347
+ Hermes's `_check_auth` (`:1916-1968`) — and every route quotes the handler it was matched against.
348
+ Routes: `GET /v1/health` (the one unauthenticated one), `GET /v1/capabilities`,
349
+ `POST /api/sessions`, `POST /api/sessions/{id}/chat`, `GET /api/sessions/{id}/messages`,
350
+ `POST /v1/runs`, `GET /v1/runs/{id}`, `GET /v1/runs/{id}/events`, and
351
+ `POST /v1/runs/{id}/approval|steer|stop`.
352
+
353
+ Everything it makes the loop do is an `inbound`: a chat is one; a run is one on a fresh binding
354
+ `{platform: 'api_server', kind: 'dm', chat_id: <run id>}`; a steer is one queued behind the
355
+ running turn (§4.1 step 5); a stop is `/reset` (§2.7); and an approval is resolved to the worker's
356
+ own option and answered as the **number** §4.2's permission prompt asked for. The run event stream
357
+ is derived from §2.8 runtime state — the turn's text buffer, the pending permission requests, and
358
+ the obligation the turn's `final` produces — because the loop publishes no event bus and ORC-9 does
359
+ not add one.
360
+
361
+ **Where Hermes's shape has a field the IR has no source for, it is returned empty**, never guessed:
362
+
363
+ | route | empty field(s) | why |
364
+ |---|---|---|
365
+ | `POST /api/sessions/{id}/chat` | `usage`, `runtime` | the IR records no token accounting and no provider/model routing |
366
+ | `GET /api/sessions/{id}/messages` | `token_count`, `tool_calls`, `tool_call_id`, `tool_name`, `finish_reason`, `reasoning` | §2.5 keeps no per-message tool or token record; the transcript stays native and is read through `sessions.load` |
367
+ | `POST /api/sessions` (`session` object) | `user_id`, `model`, `tool_call_count`, `input_tokens`, `output_tokens`, `parent_session_id` | same |
368
+ | `GET /v1/runs/{id}` | `model` | the run does not choose a provider model; `profile.worker` does |
369
+
370
+ Four honest divergences, stated rather than hidden:
371
+
372
+ - `platform` reads `supercode-orchestrator`, not `hermes-agent`, on `/v1/health` and
373
+ `/v1/capabilities`: it names the server that answered.
374
+ - `X-Hermes-Session-Key` is **not** an auth credential in Hermes either — `_parse_session_key_header`
375
+ (`:2403-2440`) reads it as the caller-declared conversation scope, and it is read with that
376
+ meaning here. Bearer is the trust boundary.
377
+ - `/v1/runs/{id}/approval` accepts Hermes's `once|session|always|deny` (with
378
+ `approve|approved|allow` aliasing to `once`), but the IR has no standing-grant store: `session`
379
+ and `always` resolve the pending request exactly as `once` does and no grant outlives it.
380
+ - `/v1/chat/completions`, `/v1/responses`, `/api/sessions/{id}/chat/stream`, `/api/jobs/*`,
381
+ idempotency (`Idempotency-Key`) and the `/p/<profile>/` route mirrors are **not** served;
382
+ `/v1/capabilities` reports each as `false` rather than copying Hermes's `true`.
383
+ - A run is `running` from the moment it is accepted (Hermes passes through `queued` first), and
384
+ `/stop` leaves it `cancelled` once the `/reset` lands rather than in `stopping`.
385
+
386
+ Known gaps, named:
387
+
388
+ - `Obligation.posted` lives on the in-memory record only. Hermes's `delivery_obligations` table has
389
+ no column for it and the store is byte-compatible with that schema, so a save/reload loses the
390
+ handle. Adding it is a schema change to §2.6's codec, not an adapter change.
391
+ - `deliver` projects the obligation to `{ text, at }` before calling `send`, so
392
+ `OutboundContent.reply_to` and `attachments` never reach an adapter today even though §2.1
393
+ declares both and the Chat SDK sidecar honours both (`thread.reply`) when a caller supplies them.
394
+ - The production path that builds a real platform adapter from a block (`@chat-adapter/*` →
395
+ `createSlackAdapter(...)`) is **not** exercised by the suite: the tests drive a hand-written
396
+ `Adapter` (the SDK's `packages/chat/src/mock-adapter.ts` shape, transcribed — that file is not
397
+ re-exported from the package entry, is outside its published `files` list, and imports `vitest`).
398
+ Evidence for the sidecar stops at the SDK boundary; the platform packages are extrapolation.
399
+
400
+ ## Access, the daemon's lease, and coming back (ORC-10)
401
+
402
+ ```sh
403
+ node bin/orchestrator.mjs access pair-list --root <home> [--profile <name>]
404
+ node bin/orchestrator.mjs access pair-approve <code> --root <home>
405
+ node bin/orchestrator.mjs access allow|revoke <platform> <user_id> --root <home>
406
+ node bin/orchestrator.mjs access admin-add|admin-remove <platform> <user_id> --root <home>
407
+ ```
408
+
409
+ `supercode orchestrator pair approve|list` and `access allow|revoke|admin add|remove` spawn exactly
410
+ these: the folder has one encoder (`save()`), and it is not in the Rust binary. Each verb loads the
411
+ home, applies the reducer's own `operator` event and saves; a verb the reducer refuses exits
412
+ non-zero with the reducer's own words and writes nothing it did not ask for. They are the COLD path
413
+ — for a running daemon the same verbs go through its socket (ORC-13).
414
+
415
+ - **Access lives in Hermes's own stores, and only there.** A platform is open when the profile's
416
+ own `.env` sets `<PLATFORM>_ALLOW_ALL_USERS=true` (or `GATEWAY_ALLOW_ALL_USERS=true` for every
417
+ platform); its allowlist is `<PLATFORM>_ALLOWED_USERS` plus the pairings Hermes approved
418
+ (`platforms/pairing/<platform>-approved.json`). **A platform with neither is allowlist-only.**
419
+ An unknown sender is answered with a pairing code from Hermes's own `PairingStore` (its expiry,
420
+ rate limit and lockout) and nothing else: no worker starts.
421
+ - **Admins** are each platform's `allow_admin_from` / `group_allow_admin_from` in `config.yaml`:
422
+ the tier whose numeric reply resolves a relayed permission prompt (§4.2). An allowlisted
423
+ non-admin's number is an ordinary message.
424
+ - No other file grants access. `approve`, `allow`, `revoke` and `admin` go through Hermes's store
425
+ and writers, live or cold.
426
+
427
+ The **lease** `<home>/orchestrator.lock` is written by this daemon, not by `supercode orchestrator
428
+ start`: one writer means a launchd/systemd-managed daemon reports the same lease a foreground one
429
+ does. The lock names the process and its machine (hostname, and Linux's boot id). A home whose
430
+ lock names a live process on this machine, since this boot, is refused; a lock naming a process
431
+ that is **gone**, or one another machine or an earlier boot wrote (a home on a volume the
432
+ sandbox before this one mounted), is the mark of a daemon that died, and the daemon dispatches
433
+ `restart` (§4.7) before it accepts anything. Nothing is signalled on the word of a lock from
434
+ elsewhere. `leaseIsLive` and `readLease` are the package's reading of it. A clean stop removes its own lock, so an ordinary start replays nothing.
435
+
436
+ On `restart`: every pending obligation goes out again, and every binding that held a lease is
437
+ re-opened. The lease table is never persisted (§2.8), so a lease start records `runtime_id` on the
438
+ binding's residue; the loop tries `runtimes.attach_existing` where the harness's registry row says
439
+ `attach_existing_process`, and otherwise marks the binding interrupted and tells its surface once
440
+ (`Session interrupted by a restart; send your message again.`). Cron and webhook surfaces get the
441
+ mark without the notice — nobody is reading them.
442
+
443
+ Tests run against the repo's Hermes fixture home and a mock ACP worker, and need
444
+ `target/debug/supercode` built: `npm test` in this directory.
445
+
446
+ ## The operator door (ORC-13)
447
+
448
+ The orchestrator's WRITE door — what `supercode jobs|profiles|sessions <verb> --harness orchestrator`
449
+ drives, the way Hermes's write door is `hermes cron …` (§4.6). Volter Harness writes no file of this
450
+ folder: `save()` here stays the only writer, so the IR's byte-stability and residue rules hold by
451
+ construction.
452
+
453
+ Two doors, **one** implementation (`applyOperator` in `operator.mjs`); only how the event is
454
+ dispatched differs.
455
+
456
+ **Live** — the same socket the model-facing door uses, a second line family beside it, told apart by
457
+ the `op` key:
458
+
459
+ ```
460
+ in {"op":"jobs.create","args":{"prompt":"…","schedule":"every 30m"},"profile":"coder"}
461
+ out {"ok":true,"result":{"ran":"created cron job job_… …","job_id":"…","job":{…}}}
462
+ {"ok":false,"error":"jobs_delete: no job job_x"}
463
+ ```
464
+
465
+ `op` is one of `jobs.create|update|pause|resume|run|delete`, `profiles.create|delete`,
466
+ `sessions.new|reset`, `access.pair_approve`. The verb dispatches its `events.operator(...)` on the
467
+ **loop's own queue**, so an operator write is ordered against inbound messages and ticks like
468
+ anything else and the running loop's state and the folder can never disagree. `result` re-reads the
469
+ affected record out of state after the reducer ran.
470
+
471
+ **Cold** — the same verbs when no daemon is serving the home:
472
+
473
+ ```
474
+ node bin/orchestrator.mjs <op> --root <home> [--profile <p>] [--at <rfc3339>] --json '<args>'
475
+ ```
476
+
477
+ It loads, applies through `applyOperator`, performs the effects the loop would (`save`,
478
+ `remove_profile_dir`), prints the same `{ok, result}` line and exits 0, or 1 on a refusal.
479
+ `crates/harness/src/orchestrator_door.rs` picks between the two: the socket when
480
+ `<root>/orchestrator.lock` names a live pid *and* the socket answers, the CLI otherwise. A refusal on
481
+ the live door is final — only a broken socket falls through, so a verb the daemon already answered is
482
+ never run twice.
483
+
484
+ - **A refusal names its reason.** The reducer refuses by emitting a `warn` log effect and no `save`;
485
+ that sentence becomes `{ok:false, error}` verbatim, and nothing is written.
486
+ - **`sessions.new|reset` name a BINDING by its SURFACE** (`platform|kind|chat_id|thread_id|participant_id`),
487
+ not by a session id — a binding has none of its own. A surface with no live conversation refuses.
488
+ - **`profiles.delete` never unlinks.** It removes the record and emits `remove_profile_dir`, which
489
+ renames the home to `profiles/.trash/<name>-<stamp>/`. A dot-prefixed folder is not a profile to
490
+ either loader, so a trashed home neither loads nor lists.
491
+
492
+
493
+ ## Hosted scheduled tasks
494
+
495
+ A host can retain its existing conversation and deterministic action runtime while
496
+ this package owns schedules, durable fire claims, run output and the operator socket:
497
+
498
+ ```sh
499
+ supercode-orchestrator --root /instance/var/orchestrator \
500
+ --executor-url http://127.0.0.1:5200/os/scheduled-task \
501
+ --executor-token-file /instance/var/executor.token
502
+ ```
503
+
504
+ The executor URL must be loopback HTTP. Its private token file contains at least
505
+ 32 characters. Each authenticated POST carries `{root, profile, job, fire}`; the
506
+ host returns `{accepted: true, profile, fire_id, output, delivery?}` only after
507
+ accepting that exact fire. A delivery receipt is `{platform, chat_id, id}` and
508
+ confirms the submitted task, not a model's eventual answer. The output must say
509
+ which milestone it confirms. `jobs.create` accepts opaque JSON `metadata`, stored
510
+ in `job.residue.host`, and `enabled: false` for initially paused tasks.
511
+
512
+ Hosted execution starts no worker and uses no channel adapter. The host must
513
+ persist an idempotency record before its side effect, return the saved receipt
514
+ on duplicates, and refuse to repeat an uncertain dispatch. The daemon records
515
+ failed/timeout fires without replaying them; a restart advances a stale claim
516
+ once. `callOperator` addresses the live socket, with no cold-write fallback.
517
+ `Orchestrator({executeJob})` is the corresponding in-process API for embedders.
518
+
519
+ The daemon acquires its exclusive lease before loading state. A crash during the
520
+ short acquisition critical section leaves `orchestrator.acquire`; subsequent
521
+ starts fail closed until an operator inspects and removes that guard. Normal
522
+ stops remove only their own lease. Native `jobs.resume` computes the first fire
523
+ for a job that was created paused with no next-run timestamp.
524
+
525
+ ## Hosted daemon lifecycle
526
+
527
+ `stopHostedDaemon({ root, executorUrl?, timeoutMs? })` validates the hosted lease
528
+ and signals this package's daemon through its native shutdown handler. A foreign
529
+ root/executor is refused; the lease is retained until shutdown completes. The
530
+ helper does not clear a live lease or escalate to SIGKILL. Embedders can stop their
531
+ owned clock before disabling the integration and start it again on the next boot.
532
+ Hosted HTTP refusal messages preserve the executor's bounded JSON error reason.