magi-code 0.80.1

Repository-aware CLI coding agent for terminal work
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# Application service protocol v2

The only application-service transport is the persistent private Unix socket on **macOS and Linux**. No Windows service transport is provided; non-service Windows behavior is unchanged. The daemon uses the normal runtime, configuration, credentials and durable sessions.

```sh
/trusted/magi-code daemon start-or-connect --workspace /absolute/project --executable /trusted/magi-code
```

Use a trusted absolute executable. Discovery is JSON, not a conversation stream. Follow the [Unix launch and identity handshake](unix-daemon.md), then initialize wire v2. The [v2 envelopes, operation catalogue, control, outcomes and lifecycle](../rfc/0002-persistent-service-protocol.md) and domain schemas below together define the normative contract without a v1 dependency. The optional [application resources profile](application-resources.md) is negotiated separately; `application_resources_v1` is not application wire v1.

## Retirement and release gate

[#556](https://github.com/magimetal/magi-code/issues/556) approves retirement of stdio wire v1 without compatibility or a deprecation period. Legacy `--app service` is unsupported; use the daemon command above. This decision is not evidence about deployed consumers. Normal Mission Control and maintenance commands are unaffected.

Companion [#25](https://github.com/magimetal/magi-companion/issues/25) must migrate its known v1 source consumer and reusable conformance assertions. Do not ship a v1-removing helper pin with a still-v1-default Companion. After backend merge, verify both changes against the same tested artifact, align packaging/source revisions, verify the artifact and explicitly stage/select its digest. No automatic daemon upgrade, forced restart, incompatible parallel daemon or stored-version deletion. Frontend coordination, signed Release and authorized live checks are **not verified by this documentation change**; see the [release evidence gates](persistent-backend-readiness.md).

## Initialization and decoding

After the Unix identity handshake, send:

```json
{"protocol_version":2,"kind":"request","request_id":"init-1","instance_id":null,"connection_id":null,"session_id":null,"operation_id":null,"control":null,"method":"initialize","payload":{"supported_protocol_versions":[2],"requested_capabilities":[]}}
```

`supported_protocol_versions` is required, nonempty, contains unsigned 16-bit integers and must include `2`. `requested_capabilities` defaults to `[]` when omitted, never when null. Each input list has at most eight entries. Requested operation names are requirements, not subscriptions or an allowlist. The ordinary profile advertises 22 operations and six events; advertisement arrays use the general 64-item bound.

Before any mutation, validate successful initialization, serving identities, expected workspace/state root, wire `2`, transport `unix`, required operations, the **entire advertised event set**, activity flags and required limits against independent local bounds. Never pipeline mutations before preflight. Unknown/unadvertised events or unknown envelope kinds invalidate trusted stream state, even when requested operations exist. Rendering recognized activity is optional; validating its correlation, sequence and replay instructions is not.

Requests are UTF-8 JSONL objects with strict method-specific payloads and no duplicate keys. Record and recursive JSON limits apply before admission. Recoverable request errors receive bounded responses; framing timeout/socket failure disconnects. Disconnect revokes grants but does not cancel accepted turns/settings work. Only explicit `turn.cancel` means Stop; login cleanup is connection-scoped.

Request IDs are reserved through response writing, not turn/login completion. Operation IDs reserve immutable mutation intent through authoritative settlement and retention/tombstone windows. Correlate responses to requests and events to instance, connection, session, operation, grant generation and turn/login. Claims provide atomic snapshots; reconnect never resends uncertain mutations automatically. Use lookup, claim and durable replay as specified in the lifecycle contract.

## Evolution and consumer decoding

These are consumer requirements and rules for future changes, not a new permissive request decoder. Requests remain strict: unknown envelope or operation-payload fields are rejected. Methods and their wire objects remain owned by their service domain; a generic extension parser must not replace method-specific validation.

### Required, optional, absent, and null

- **Required** means the field must be present with the documented JSON type and meaning. Do not default missing terminal status/text, IDs, sequences, booleans, counts, error semantics, or required capability/limit values. Counts are nonnegative integers within both the documented and locally representable range; strings are not numbers, and `0`/`1` are not booleans.
- **Optional** means explicitly documented as optional or conditionally emitted. Omitted `requested_capabilities` means an empty list, while `null` does not. Required nullable v2 envelope slots must be present. Omitted activity `parent_activity_id` means no supplied parent; `null` is not a parent ID.
- **Nullable** is separate from optional. Current output envelopes always include their shown fields, including nullable response identity, `payload`, and `error` slots. Activity `timed_out:null` means unknown, not `false`; nullable tool names and token counts must not become empty strings or zero. Missing a required nullable field is invalid. Do not treat absent and null as interchangeable globally.
- Successful responses require a method-valid payload and `error:null`; errors require `payload:null` and a valid error object. Null identities are allowed only where the documented error/correlation rules permit them, not as a way to accept an unbound success or event. Validate envelope identities against pending requests and payload identities against the attached session/turn/login.
- Initialization fields, capability sets, limits and `activity` are required. Advertising `turn.activity` with `events:false` is inconsistent. Shared sequences and terminal activity/replay metadata are required.

### Unknown output fields and values

Consumers must allow unknown **members** of recognized response/event envelopes, payload objects, capability objects, and limit maps as optional extensions, while fully validating known members. Do not compare an object's key set or key count to a snapshot. Unknown fields cannot replace required fields, override identities, change the success/error distinction, or weaken a known value's validation.

Validate the entire encoded record and payload, nesting, arrays, object field counts, strings, and keys before ignoring any unknown value. Apply independent local decoding/allocation bounds as well as supported advertised bounds. Unknown limit-map entries must still be positive, locally representable integers; unknown capability members may be bounded JSON values, but never imply support for an unknown feature. Known flags remain booleans and known limits retain their units and scope. An unknown advertised operation may be left unused; the event set has the stricter preflight rule above.

Unknown enum values are **not** unknown optional fields. Reject unsupported values in status, kind, activity category/source/transport, readiness, scope, and other semantic enums unless that field explicitly defines a safe fallback. The defined tool transport value `unknown` is a literal accepted value, not permission to accept arbitrary transports. An unknown error code may be represented as a generic unsuccessful response after validating its error shape, bounds, and correlation; it must not become success or trigger an automatic retry. Never guess terminal status or auth cleanup from an unfamiliar value.

Examples (all acceptance cases remain subject to bounds and correlation):

| Received form | Consumer outcome |
| --- | --- |
| Valid response with extra envelope `"future_hint":true`, or valid payload with bounded optional metadata | Accept; ignore only the unknown member. The same extra request field is rejected by the server. |
| Limits contain a new `"max_future_page_items":16` | Accept the extension; still validate all required known limits, not an exact 10/15/16-key count. |
| New limit is `"16"`, zero, negative, or over the local integer range | Reject; an extension is not exempt from limit-value validation. |
| Optional metadata absent, or present as bounded JSON `null` | Accept if no known field's rules are changed. This does not make known nonnullable fields nullable. |
| `provider_auth_ready` absent, `null`, or `"false"` | Reject; a required boolean is missing or mistyped. |
| Activity-capable terminal lacks `assistant_text` or `sequence`, has unknown status, or names another turn | Reject; authoritative settlement cannot be inferred. |
| Valid shared sequence jumps from 2 to 4 | Accept the gap; reject duplicate/regressing sequences and events after terminal. |
| Advertisement adds an unknown event | Reject locally before mutation, even if every requested operation exists. |
| Unknown field contains an over-limit array/string or excessive nesting | Reject the whole record, not just the field. |

### Protocol version versus package version

Application `protocol_version:2` selects the wire contract. `server_version` and Unix discovery/handshake `version` are package strings; Unix admission requires an **exact package match** independently of wire negotiation. Fixture schema and optional profile versions are separate again. Fixture schema 1 does not imply wire 1. None of these checks substitutes for another.

Within a supported profile, bounded optional output members, unused operations and additional limit keys may be added while required meanings stay intact. Known fields cannot be removed, renamed, retyped or have null/unit semantics changed as optional extensions. Unknown semantic enums need an explicit fallback or negotiated profile. New events require complete-set preflight before mutation.

A change to required semantics needs a new protocol version unless an explicit, bounded capability negotiation isolates the changed behavior and defines absence, rejection, and mismatch handling **before** affected work. Merely advertising a flag or accepting an operation name is not such isolation: current initialization does not select event delivery or alternate wire formats. Any future opt-in design must be documented and tested before it is relied upon. Unsupported approvals, steering, MCP server lifecycle, raw tool output, and activity replay remain explicitly `false`; do not advertise them in anticipation of implementation.

## Operations

### `status`

Payload must be `{}`. Required result fields are `service:"ready"`, `provider_auth_ready` (boolean), `instance_id`, `workspace_id`, `state_root_id` (opaque strings), and `connected_clients`, `admitted_operations` (nonnegative integer counts, including cleanup).

`provider_auth_ready` reports whether the configured shared provider can prepare authentication, including a refreshable local Codex credential where applicable. It does not identify the provider and never returns a token, API key, bearer header, account ID, authorization code, provider response, path, or session data. `service: "ready"` describes the running service; it does not mean provider authentication is ready.

The value is evaluated from reloaded settings, current shared auth, and the process environment on every initialized, valid `status` request. It follows the current effective provider selection rather than the startup selection. Missing or invalid credentials report `false`. If settings or protected shared auth cannot be read, the correlated status response uses `internal_error` with its fixed sanitized message. This check never refreshes credentials, mutates auth, or makes a network request.

### `capabilities`

Payload must be `{}`. The result is initialization's complete capability object: `protocol_versions:[2]`, `transports:["unix"]`, operations, events, limits and activity. The in-process core alone advertises no transport.

### Authentication

All auth requests require initialization and `session_id:null`. Unknown payload fields are rejected. Auth uses the resolved `MC_HOME` shared store; clients never read or write it. Mutation IDs and disconnect lifetimes follow the v2 operation catalogue.

#### `auth.status`

Payload: `{}`. The response contains a `providers` array, including `openai-codex`, `anthropic`, and custom providers configured at service startup. Each entry has:

```json
{"provider_id":"openai-codex","auth_method":"oauth","source":"shared_store","readiness":"missing","ready":false}
```

- `auth_method`: `oauth`, `api_key`, or `none`.
- `source`: `shared_store`, `environment`, or `none`. Environment values and variable names are not returned.
- `readiness`: `ready`, `missing`, or `needs_login`; `ready` is the matching boolean. Refreshable Codex credentials count as ready; this does not guarantee a future refresh succeeds.
- Codex uses the shared OAuth record. Anthropic prefers a nonempty `ANTHROPIC_API_KEY`, otherwise its shared API-key record. Custom providers use their configured environment variable or no-auth mode; no-auth providers are ready without credentials.

Every request reads current auth and environment readiness without refreshing or making a network request. Malformed or unreadable storage returns a fixed `internal_error`, not file contents or a path. Unlike `status` and configuration routes, auth operations retain startup provider definitions until restart.

#### Codex login

1. Send `auth.login.start` with `{"provider_id":"openai-codex"}`. Its response contains `login_id`, `provider_id`, and `status:"started"`. Only one login runs per service. Other providers return `provider_unavailable`; an active login returns `auth_busy`.
2. Receive `auth.login.progress` correlated to the **start operation**, with null session, grant generation and live sequence. Payloads contain `login_id`, `provider_id` and `state`. First state `authorization_url` also includes bounded transient `url` and `open_browser:true`. Offer browser opening, display or copying; the service does not launch a browser.
3. `waiting` follows. The existing loopback listener waits up to five minutes. If loopback is unavailable, manual submission remains available. Submit `auth.login.callback` with `{"login_id":"<id>","input":"<final redirect URL or code>"}`. Input is nonempty, at most 4096 bytes, accepted once, and never echoed. A successful response says `status:"submitted"`; it does not mean the callback was valid.
   Success payload is `{"login_id":"<id>","status":"submitted"}`; both fields are required.
4. `callback_received` precedes callback validation; `callback_accepted` follows successful validation. Redirect URLs must match the attempt's OAuth state. Bare codes use the existing manual-code flow and remain tied to the selected login ID.
5. One `auth.login.terminal` settles the login for its initiating connection when connected. It contains `login_id`, `provider_id`, `state` and `cleanup_complete:true` after worker cleanup. Successful exchange/storage produces `state:"exchange_succeeded"`. Lost terminal delivery requires lookup/auth-status reconciliation, not automatic restart.

Terminal failures are fixed labels: `callback_timeout`, `callback_failed`, `exchange_failed`, `auth_changed`, `storage_failed`, or `internal_error`. No underlying provider response or callback text is included. A rejected callback ends that flow; start another login to retry.

Send `auth.login.cancel` with `{"login_id":"<id>"}` to receive `status:"cancellation_requested"`. Wait for the terminal event, normally `state:"cancelled"`; cancellation is not itself a terminal outcome. A storage commit that already won the race may still finish successfully. An in-progress exchange finishes within its existing bounded network timeout before cleanup. Unknown or finished IDs return `unknown_login`; repeated callback submission returns `auth_busy` while the login remains active.
Cancel success payload is `{"login_id":"<id>","status":"cancellation_requested"}`; both fields are required. Logout's `removed` is a required boolean, not a count.

Auth events and callback requests are transient: never put them in client logs or durable session/settings data. The authorization URL is the sole URL-bearing auth event. Access tokens, refresh tokens, API keys, account IDs, authorization codes, raw callback URLs, bearer headers, raw provider responses, and auth-file paths/content are absent from all auth responses and events. Callback input is consumed only inside the login flow.

#### `auth.logout`

Payload: `{"provider_id":"anthropic","confirmed":true}`. Missing or false confirmation returns `confirmation_required`; an unknown provider returns `provider_unavailable`. Success contains `provider_id`, `status:"logged_out"`, `removed`, and `environment_unchanged:true`.

Logout removes only that provider's local record and preserves unrelated records. It does not unset environment variables, revoke remote credentials, or undo credentials already handed to an active turn. New Anthropic turns reread the store; an environment-backed provider can remain ready after local logout. Codex logout also requests cancellation of the service's active login.

Protected auth writes retain permissions, atomic replacement, and cross-process locking. Logout advances the provider generation even if its record is absent. Login commits only against the generation observed at start, so concurrent logout, login, or refresh can cause `auth_changed` instead of overwriting newer state. Fresh login never inherits another account's refresh token. Credential refresh remains internal with its existing generation checks; there is no explicit credential-refresh route. `catalog.refresh` refreshes model discovery, not a client-visible credential record.

### Catalogs and configuration (issue #467)

These routes require initialization, `session_id:null` and strict payloads. They return one correlated response with no change events. One configuration worker bounds overlapping catalog/config work (`configuration_busy`). Wait for settings success before dependent turn admission. Accepted writes/refreshes finish after disconnect; reread effective and saved state after uncertain delivery, never blindly retry.

#### Provider and model catalogs

| Method | Request payload | Success payload |
| --- | --- | --- |
| `catalog.providers` | `{"offset":0,"limit":32}` | `{"entries":[{"provider_id":"anthropic","label":"anthropic"},{"provider_id":"openai-codex","label":"openai-codex"}],"next_offset":null,"refresh":"cache_only"}` |
| `catalog.models` | `{"provider_id":"anthropic","offset":0,"limit":32}` | `{"entries":[<model metadata>],"next_offset":null,"state":"fresh_cache","refresh":"cache_only"}` |
| `catalog.refresh` | `{"provider_id":"anthropic"}` | `{"provider_id":"anthropic","state":"fresh_cache"}` |

Provider entries include the built-ins `anthropic` and `openai-codex`, plus currently configured custom providers, sorted by ID and deduplicated. Model entries preserve cached order and are deduplicated by model ID. `offset` defaults to `0` and must be at most `100000`; `limit` defaults to `32` and must be `1..32`. Follow non-null `next_offset` for another page; an offset beyond the end returns an empty page. Pages are not a retained snapshot: restart paging after refresh or external configuration changes.

Each model entry contains only:

```json
{
  "provider_id": "anthropic",
  "model_id": "claude-example",
  "label": "Example model",
  "available": true,
  "context_window": null,
  "max_output_tokens": null,
  "available_thinking_levels": ["default", "high"]
}
```

The example is illustrative, not a promised model. Token limits are nullable numbers; `null` means unknown. Reasoning choices are model/provider-specific, at most six wire values: `default`, `low`, `medium`, `high`, `x_high`, `max`. Use the returned list rather than inventing choices. Fresh metadata informs that list; stale or missing metadata uses the shared provider/model fallback rules.

Provider/model IDs are nonempty, at most 256 ASCII bytes, restricted to letters, digits, `-._/:`, and rejected or omitted if they look like secrets. These are selection IDs, not the 128-byte envelope correlation IDs. Labels are display-sanitized and limited to 128 bytes, falling back to a bounded ID. Unsafe model IDs are omitted; disabled models remain visible with `available:false`.

`catalog.providers` and `catalog.models` are cache-only: no discovery request or credential refresh. Model states describe the validated cache:

- `fresh_cache`: unexpired cache matching the provider configuration. Only non-disabled entries are `available:true`; this is selection eligibility, not authentication readiness or a guarantee of inference success.
- `stale`: expired matching cache; entries may still be displayed, but all are unavailable for selection.
- `unavailable`: no usable cache, including missing, unreadable, malformed, empty, or configuration-mismatched data. Entries are empty.

`catalog.refresh` explicitly invokes existing provider discovery, which may use network access, shared credentials, and catalog cache writes. It does not change the selected model or settings. Existing fallback behavior can retain stale data: a successful response is not a guarantee of `fresh_cache`. Inspect the returned state, then reread `catalog.models`; a surfaced refresh failure returns `catalog_refresh_failed` without provider details. There is no force flag, progress event, or refresh-cancel route.

#### Read effective settings

`config.get` takes `{}` and returns this bounded projection, not the settings document:

```json
{
  "selection": {"provider_id":"anthropic","model_id":"claude-example"},
  "thinking_level": "high",
  "configured_thinking_level": "high",
  "available_thinking_levels": ["default","high"],
  "fast": false,
  "precedence": ["environment","project","global"],
  "writable_scopes": ["global","project"],
  "global_only": ["fast"]
}
```

`selection` follows current settings and `MC_PROVIDER`/`MC_MODEL` overrides, with the normal provider/model defaults when unset. `configured_thinking_level` is the merged saved choice (or `default`); `thinking_level` is its resolved supported value. Reading configuration does not require a fresh catalog or confirm that the selected model can run. `precedence` describes the layers, not per-field provenance; fast mode is global-only.

#### Persist a scoped change

`config.set` requires `scope` and at least one of `selection`, `thinking_level`, or `fast`:

```json
{
  "scope": "project",
  "selection": {"provider_id":"anthropic","model_id":"claude-example"},
  "thinking_level": "high"
}
```

- `scope` is `global` or `project`. Selection and thinking level support both; `fast` is a boolean allowed only with `global`. A project request containing `fast`, even `false`, fails as a whole.
- `selection` requires both IDs. It must name an enabled model in a fresh matching catalog. Missing/stale catalogs require refresh before selection or reasoning changes. A fast-only write does not require catalog validation.
- A selection change without `thinking_level` resets the saved reasoning choice to `default`. A reasoning-only change validates the current effective selection, including environment overrides and normal defaults, using the same resolution as `config.get`.
- Omitted fields are unchanged, except that selection resets reasoning as above. Explicit `null`, empty mutations, unknown fields, malformed types, and unknown reasoning values return `invalid_payload`; a recognized but unsupported reasoning value returns `unsupported_reasoning`. There is no reset/delete or arbitrary-settings route.

Success is `{"persisted":true,"scope":"project","effective":<config.get projection>}`. The service validates the entire change and resulting projection before committing through the existing locked, atomic settings writer. It preserves unrelated and unknown settings fields. Explicit project selection/reasoning writes pin those fields even when they currently equal inherited values. Validation or persistence failure returns an error, never `persisted:true`.

Global writes use the resolved shared settings scope; project writes use the service's launch project scope, not a session-specific path. They survive service restart and use the same settings as CLI/TUI. Higher-precedence project/environment values can mask a successful global write; inspect `effective` instead of assuming the requested value won. Writes do not change environment variables, credentials, provider definitions, tools, MCP, or session history.

Selection, reasoning and fast settings are captured under the shared configuration lock at turn admission. Running turns retain that capture; startup instructions, skills and provider definitions are not rebuilt. `status` rereads effective selection. There are no configuration push events.

Catalog/config responses omit settings/cache paths, raw settings documents, provider URLs, headers, environment variable names/values, credentials, account identifiers, pricing, raw reasoning metadata, and underlying provider responses/errors. Labels and IDs are filtered display metadata, not access to private configuration. Clients must use these routes rather than inspect local settings or catalog files.

### Sessions

All session requests require initialization and reject unknown payload fields. IDs are opaque, at most 128 bytes, and validated by the server before path resolution. Do not construct IDs or inspect storage files. There are no session lifecycle push events: these operations return ordinary correlated responses.

| Method | Outer `session_id` | Request payload | Success payload |
| --- | --- | --- | --- |
| `session.list` | null | `{"after":null,"limit":32}` | `{"sessions":[{"session_id":"<id>","title":null,"updated_at":"<RFC 3339 timestamp>"}],"next_after":null,"truncated":false,"incomplete":false}` |
| `session.create` | null | `{}` | `{"session_id":"<id>"}` |
| `session.replay` | Required | `{"after":null,"limit":32}` | Replay page below |

`after` and `limit` are optional; default limit is 32, valid range 1..32. Continuations are at most 128 bytes. Create records durably but does not claim. Use `session.claim` for control; there is no `session.open` or `session.close`. Claim/detach and leases follow the v2 lifecycle contract.

#### Listing

Lists are ordered by session ID, not activity time. Pass `next_after` as the next list request's `after`; it identifies the last examined ID, which need not have a returned summary. `truncated:true` means more candidates remain. `incomplete:true` means some candidates could not be represented or read; it is not a continuation flag. Titles are optional, sanitized, and passed through the existing redaction policy.

Each request scans at most 20,000 directory entries, including non-session entries. Exceeding this budget returns `limit_exceeded`, not an arbitrary partial page. Metadata reads are bounded. Listing is not a frozen snapshot: concurrent creation, deletion, or updates can change later pages. Restart without `after` to refresh the list.

#### Durable replay

Replay is read-only and requires no attachment or writer lease. It can read a session held by another service. A conflicting durable append/rotation lock returns `session_busy`; retry later. The response payload is:

```json
{
  "events": [
    {"cursor":"<cursor-1>","kind":"message","role":"user","text":"Question","part":0,"last_part":true},
    {"cursor":"<cursor-2>","kind":"message","role":"assistant","text":"Partial answer","part":0,"last_part":true},
    {"cursor":"<cursor-3>","kind":"terminal","status":"cancelled"}
  ],
  "next_cursor": "<cursor-3>",
  "snapshot_cursor": "<cursor-3>",
  "truncated": false,
  "gap": false,
  "resync_required": false
}
```

The allowlisted projection contains user/assistant `message` items, `terminal` items with `completed`, `cancelled`, `failed`, or `compaction_required`, and compaction `summary` items. A summary has `text`, `part`, and `last_part`, but no `role`. Text is split at UTF-8 boundaries into at most 2,048 bytes per part; `part` starts at zero for each message or summary. Parts can span pages. Cancelled/failed terminal records preserve their accepted `assistant_text`, so replay includes the user and partial assistant messages, not just the terminal label.

Replay reads the recent 2 MiB window, at most 100,000 records, rather than exporting all history. When a valid compaction checkpoint is available in that window, its summary precedes the conversation after its cutoff. Older history may be outside the window. Tool arguments/results, provider records, reasoning, local diagnostics, recovery notes, display metadata, raw JSONL, malformed lines, storage paths, and sidecar internals are not separate frontend fields or events. Projected text uses the existing redaction policy; this is not a guarantee that arbitrary user-written secrets or paths cannot appear in conversational text.

Each page has at most the requested item count and a complete encoded payload of at most 16 KiB, including cursor fields and JSON framing. Its fields mean:

- Each event's `cursor` identifies the accepted projection prefix through that item. Save it only after accepting the item; pass it as replay `after` to continue without accepting the same item twice.
- `next_cursor` is the position after this page, also provided for an empty page. `snapshot_cursor` identifies the end observed for this request, not a retained server snapshot or subscription.
- `truncated:true` means more projected items remain in this snapshot. Continue with `after:next_cursor`. At the current end, `next_cursor` equals `snapshot_cursor`; poll that cursor later for new durable items.
- `resync_required:true` means `after` does not match the current projection: it may be invalid, from another session, or stale after compaction, rewind, corruption, or a moving tail window. The page contains no events and gives the current starting position in `next_cursor`. Discard the cached projection and replay without `after` (or from that starting cursor), following bounded pages.
- `gap:true` reports read/checkpoint diagnostics or mismatched session records, including bounded-window omissions. A usable cursor can coexist with a gap. Show incomplete history; a resync cannot recover records absent from the available window, and the flag may persist.

Cursors survive process restart and append-only changes **when the projected prefix stays unchanged**. They are not live `event_id`, `sequence`, activity, or turn IDs. In-progress durable text may be replaced or regrouped as a turn settles, making a previously accepted prefix stale. Live `turn.assistant_delta` and `turn.activity` events are transient output, not durable replay notifications. Use `turn.terminal.assistant_text` to settle live display, then poll replay to reconcile durable history; do not concatenate live deltas onto replay items. Activity is not replayed, so replay cannot fill live activity gaps.

### `turn.assistant_delta`

Each accepted assistant text delta is sent as an ordered event:

```json
{
  "turn_id": "opaque-turn-1",
  "sequence": 1,
  "text": "partial assistant text"
}
```

Deltas retain the runtime sink order. The service applies the advertised string and total-text bounds rather than silently truncating output.

### Structured activity (issue #468)

`turn.activity` uses the v2 envelope with originating operation ID and current recipient's session/grant generation. Its payload is:

```json
{
  "turn_id": "opaque-turn-1",
  "sequence": 2,
  "activity": {
    "activity_id": "<opaque-activity-id>",
    "kind": "lifecycle",
    "category": "subagent_task",
    "status": "running",
    "details_omitted": true
  }
}
```

Every activity has `activity_id` and `kind`. `parent_activity_id` is included only when supplied by a lifecycle start; otherwise it is omitted. IDs are namespace-prefixed SHA-256 hashes, not raw runtime names or paths. Scope activity display to the turn: IDs can recur across turns, and stream-level context/usage and reasoning updates reuse IDs. An activity ID is not a unique event ID.

The following fields are flattened into `activity`, not nested under a separate data field. Nullable fields are present as `null` when unavailable.

| `kind` | Fields and values |
| --- | --- |
| `lifecycle` | `category`: `tool`, `hook`, `context_injection`, `subagent_batch`, `subagent_task`, `assistant`, `diagnostic`, or `compaction` on start; `activity` on finish. `status`: `queued`, `running`, `writing`, `completed`, `failed`, or `cancelled`. `details_omitted`: always `true`. Correlate finishes by ID, not category. |
| `tool` | `tool_id`: hashed tool name; `tool_name`: allowlisted built-in name or `null`; `server_id`: hashed MCP server name or `null`; `transport`: `local`, `mcp`, or `unknown`; `status`: lifecycle status above (main-agent starts use `running`, results use `completed` or `failed`); `timed_out`: boolean when supplied by a main-agent result, otherwise `null`; `details_omitted`: always `true`. Results are status-only, with optional timeout state, never result content. |
| `reasoning_summary` | `text`: sanitized completed provider summary; `redacted`: whether sanitization changed it; `truncated`: whether the size bound removed text. Raw thinking and reasoning deltas are excluded. |
| `context` | `current_tokens`, `max_tokens`: counts; `reasoning_tokens`: count or `null`; `request_sequence`: runtime request counter; `source`: `fallback_estimate`, `tokenizer_estimate`, `fallback_projection`, `tokenizer_projection`, `last_provider_usage`, `provider_exact`, or `provider_partial`. |
| `usage` | `effective_input`, `output`, `cache_read`: token counts; `cache_known`: whether cache usage is known; `request_sequence`: runtime request counter; `final_usage`: boolean; `source`: `provider_request` or `subagent_aggregate` (whole-run child usage). These request counters are not live event sequences. |
| `compaction` | `status`: `triggered`, `running`, `completed`, `failed`, or `cancelled`; `current_tokens`, `max_tokens`: counts for triggered/completed, otherwise `null`; `replay_required`: `true` for completed/failed/cancelled, otherwise `false`. Refresh durable replay when requested. |

Subagent batches/tasks, their parent links, tool states, context counts, and aggregate usage are supported when emitted by the runtime. Activity delivery is best-effort, so clients must tolerate missing starts, updates, or finishes. Generic compaction lifecycle activity does not carry the richer fields of `kind:"compaction"`.

#### Bounds and privacy

`src/service/activity.rs` constructs this allowlisted projection; it does not serialize runtime display records. Tool arguments, commands, result bodies, raw metadata, arbitrary labels, child previews/output, provider payloads, and underlying error text are excluded. MCP tools expose hashed identities and status, not raw server/tool names, endpoints, or credentials.

Reasoning summaries enter only through the dedicated `provider_reasoning_summary` callback after the provider completion is marked as a verified provider summary. Generic thinking-completion events do not establish that provenance and are not projected. Summary text uses the existing display sanitizer and is capped at 2,048 UTF-8 bytes, without splitting a character. Input over 16,384 bytes is omitted as `text:""`, `truncated:true`, `redacted:false`. Sanitization is not a guarantee against arbitrary user-written secrets or paths; `redacted:false` is not a security certification.

General encoded payload/record limits still apply. Activity uses nonblocking sends to the bounded turn-event queue; overflow drops activity instead of waiting for queue capacity. Assistant text retains its acceptance and terminal-size checks and is not silently truncated or discarded as activity.

#### Activity capabilities

The `activity` object in initialization and capability responses currently contains:

```json
{
  "events": true,
  "approvals": false,
  "steering": false,
  "mcp_server_lifecycle": false,
  "mcp_tool_activity": true,
  "subagents": true,
  "raw_tool_output": false,
  "activity_replay": false,
  "max_summary_bytes": 2048
}
```

These are feature flags, not operation names for `requested_capabilities`. There are no approval or steering routes/events, nor MCP server connection/start/stop lifecycle events. MCP **tool** activity and subagent activity are supported; raw tool output and durable activity replay are not.

## Turn completion and ordering

`turn.start` requires a claimed session, current control and operation ID, with `{"prompt":"nonempty text"}` (non-whitespace required). It returns `{"turn_id":"T","status":"accepted"}` before `turn.started`, whose payload is `{"turn_id":"T","session_id":"S","status":"started","sequence":0}`. Ordered deltas/activity follow, then one terminal after persistence and cleanup. A new claimant may instead receive an atomic running snapshot without the original started event. Turns use the full agent runtime, including built-in/MCP tools and continuation, not a separate provider-only loop.

`turn.cancel` requires session/current control, a new mutation ID and `{"turn_id":"T"}`; success is `{"turn_id":"T","session_id":"S","status":"cancellation_requested"}`. This is not completion. Disconnect/detach never implies Stop.

All terminal fields below are required:

```json
{"turn_id":"T","session_id":"S","status":"completed","assistant_text":"The complete answer.","sequence":4,"activity_dropped":1,"activity_replay_available":false,"replay_required":true,"persistence":"committed","error":null}
```

Status is `completed`, `cancelled` or `failed`. Persistence is `committed` or `failed`. Error is null or a bounded error object. Failed persistence forces failed status, `persistence_failed` and replay required; replay may lack accepted text. Other failed terminals use `internal_error` or `output_limit_exceeded`. Terminal text replaces local assembled text, including accepted partial text; never truncate it or infer saved success from delivery alone.

Per-turn sequence starts at zero and strictly increases for attempted live events; dropped activity consumes sequence and increments `activity_dropped`. Gaps are allowed, regression/duplicates and post-terminal events are not. `live_sequence` independently orders session transitions across claims. Activity is not replayed. Terminal closes activity admission; cleanup, including title persistence, precedes lease release. Disconnected controllers have no terminal delivery guarantee; recover through claim/lookup/replay.

## Errors and validation

Errors use an object with required string fields `code` and `message`, for example `{"code":"invalid_payload","message":"operation payload is invalid"}`. Codes are stable; messages are short, fixed, and bounded by `max_error_message_bytes`. Request data is never included in an error message.

| Code | Fixed message |
| --- | --- |
| `invalid_json` | request is not valid JSON |
| `record_too_large` | request record exceeds the maximum size |
| `invalid_request` | request envelope is invalid |
| `payload_too_large` | request payload exceeds the maximum size |
| `limit_exceeded` | request exceeds a protocol limit |
| `unsupported_version` | protocol version is not supported |
| `unsupported_capability` | requested capability is not supported |
| `unsupported_operation` | operation is not supported |
| `not_initialized` | initialize must complete before this operation |
| `invalid_payload` | operation payload is invalid |
| `duplicate_request_id` | request_id is already in flight |
| `too_many_active_turns` | the service has reached its active turn limit |
| `session_busy` | session has a conflicting writer or is closing |
| `session_unavailable` | session is unavailable |
| `invalid_session_id` | session ID is invalid |
| `unknown_turn` | turn is unknown or already terminal |
| `auth_busy` | authentication operation is already running |
| `unknown_login` | login is unknown or already terminal |
| `provider_unavailable` | provider does not support this operation |
| `confirmation_required` | logout requires explicit confirmation |
| `configuration_unavailable` | configuration could not be read; check settings |
| `configuration_busy` | a configuration operation is running; retry after its response |
| `catalog_refresh_failed` | catalog refresh failed; check provider readiness and retry |
| `settings_write_failed` | settings could not be persisted; check settings and retry |
| `invalid_scope` | scope is invalid or the setting requires global scope |
| `invalid_selection` | provider or model is unknown, disabled, or invalid |
| `unsupported_reasoning` | reasoning choice is unsupported for this model |
| `catalog_unavailable` | model catalog is unavailable; refresh it before selecting |
| `catalog_stale` | model catalog is stale; refresh it before selecting |
| `internal_error` | application service failed to handle the request |

Messages containing “retry” do not authorize automatic mutation replay. Reconcile operation/settings/auth state after uncertainty and require new explicit user intent. Read-only replay may be retried after `session_busy`. The optional resources profile also defines `conflict` ("resource revision conflicts with current state") and `resource_not_found` ("resource is not registered").

A rejected start creates no worker and needs no terminal. Accepted work stays reserved through persistence and cleanup; socket loss is not settlement. Later callback/cancel errors do not replace its terminal. Additional fencing/outcome errors and fixed messages are defined in the v2 lifecycle contract.

Envelope and operation payloads reject unknown fields. Unsupported kinds, invalid types, malformed values, and unsupported methods fail with an error response rather than a panic. The adapter continues after request-level errors when framing can be recovered.

## Advertised limits

Required general limits appear identically in initialization's two maps and capability replies. The [persistent bounds](../rfc/0002-persistent-service-protocol.md#bounds-capacity-and-failure-policy) add required connection, operation, snapshot, queue and time limits. Values are fixed for the process, not inferred from package version.

| Field | Value | Applies to |
| --- | ---: | --- |
| `max_record_bytes` | `65536` | Input JSON bytes before newline; encoded outbound record including newline. Oversized Unix frames close the socket. |
| `max_payload_bytes` | `32768` | Compact UTF-8 JSON bytes for one payload. |
| `max_error_message_bytes` | `512` | One UTF-8 error message. Current messages are shorter and fixed. |
| `max_id_bytes` | `128` | Request, session, turn, and event correlation IDs. |
| `max_name_bytes` | `64` | Methods, capabilities, and event names. |
| `max_string_bytes` | `16384` | Any JSON string or object key, including a prompt or assistant text value. |
| `max_json_depth` | `32` | JSON container nesting and validation work. |
| `max_array_items` | `64` | Items in any JSON array. |
| `max_object_fields` | `64` | Fields in any JSON object. |
| `max_negotiation_items` | `8` | Protocol-version and requested-capability lists during initialization. |
| `max_session_attachments` | `16` | Daemon lease-held actors, including uncontrolled cleanup. |
| `max_session_page_items` | `32` | Maximum/default list or replay page item count. |
| `max_session_replay_page_bytes` | `16384` | Complete encoded replay payload, including events, cursors, flags, and JSON framing. |
| `max_session_replay_window_bytes` | `2097152` | Recent durable replay read window. |
| `max_session_list_scan_entries` | `20000` | Directory entries examined per list request. |
| `max_catalog_page_items` | `32` | Maximum/default provider or model catalog page item count. |

Request limits are checked before dispatch. Oversized input and assistant output are rejected rather than silently truncated; activity summaries use explicit truncation flags and activity queue overflow is reported through sequence gaps and the terminal counter. Session projections use explicit paging, text parts, and gap/resync flags. The service uses bounded input, turn-event, and provider-cleanup workers; cancellation cleanup has a bounded join grace period and a wall-clock limit for abandoned provider reads.

Limit evolution does not disable bounds checking. Validate required keys, positive integer values, units, and consistency between initialization's two limit objects; accept bounded new limit keys without counting known keys. `activity.max_summary_bytes` is a separate feature bound, not a seventeenth general limit. The eight-entry negotiation limit applies only to the two initialization input lists, not operation/event discovery arrays.

Keep local defensive limits before parsing initialization; never allocate based only on untrusted advertised numbers. For requests and configurable page sizes, use the smaller of the applicable server and local limits. For server output, a smaller local cap is safe only if the client can still consume all required output under the accepted profile: sending smaller prompts does not bound terminal text or every event. If a required server guarantee can exceed the client's supported record, payload, string, depth, collection, or feature bound, reject locally before mutation unless a documented request control actually constrains that output. Do not clamp an advertisement and pretend the server agreed. Output beyond an accepted bound is a protocol failure, not permission to truncate authoritative text or skip terminal/error records.

New keys and changed values are acceptable only when these checks still succeed and existing required meanings are preserved. Removing a required limit or changing its type/unit/meaning follows the required-semantic version/negotiation rule above.

## Client boundary

The client should depend only on this wire contract. It must not read `MC_HOME`, settings, auth files, session JSONL, provider payloads, or private runtime files to infer service state. Later service operations may provide other sanitized projections through this protocol.

Destructive session operations, approvals, steering and MCP server lifecycle remain unsupported. Application tools/resources require their separately negotiated profile.

## Conformance and Companion handoff

Use [v2 synthetic vectors](../../tests/fixtures/application_service/persistent_v2/README.md) and isolated actual daemon-process tests alongside independent consumer decoding. Preserve bounded extensions, required types/limits, full-event preflight, correlation, acceptance through authoritative terminals, first-rejection and end-of-trace checks. Synthetic stream checks alone prove neither backend lifecycle nor Swift compatibility. See the [candidate evidence matrix](persistent-backend-readiness.md) for commands and separate platform/packaging gates.

Historical Companion #11 schema-1 traces at `a6c4422253914a1228a2b9cf9a1149828d7f3e3c` contain wire v1. They are provenance, not the v2 contract; migrate reusable assertions with explicit v2 fixture/process provenance under Companion #25. Never use real credentials, user stores or live provider/OAuth traffic for synthetic checks.

---

[Back to feature docs](README.md) · [Back to repository README](../../README.md)