@celestea/core 2.7.1

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/contracts/data-files/checkpoint.schema.json +111 -0
  4. package/contracts/data-files/cli-main-jsonl-precompact.schema.json +27 -0
  5. package/contracts/data-files/cli-main-jsonl.schema.json +22 -0
  6. package/contracts/data-files/fallbacks.schema.json +71 -0
  7. package/contracts/data-files/index.json +124 -0
  8. package/contracts/data-files/pricing.schema.json +65 -0
  9. package/contracts/data-files/prompts.schema.json +130 -0
  10. package/contracts/data-files/providers.schema.json +177 -0
  11. package/contracts/data-files/registry-tsv.schema.json +74 -0
  12. package/contracts/data-files/session.schema.json +51 -0
  13. package/contracts/data-files/usage-ledger.schema.json +112 -0
  14. package/contracts/data-files/workspaces.schema.json +63 -0
  15. package/contracts/endpoints.json +4390 -0
  16. package/contracts/probe-evidence.json +219 -0
  17. package/contracts/route-table.snapshot.json +377 -0
  18. package/contracts/scope-hash-vectors.json +273 -0
  19. package/contracts/session-event.schema.json +441 -0
  20. package/contracts/sse-events.json +202 -0
  21. package/contracts/tools.json +730 -0
  22. package/dist/agent.d.ts +65 -0
  23. package/dist/agent.js +36 -0
  24. package/dist/celestea-home.d.ts +63 -0
  25. package/dist/celestea-home.js +96 -0
  26. package/dist/celestea-sources.d.ts +53 -0
  27. package/dist/celestea-sources.js +61 -0
  28. package/dist/context.d.ts +33 -0
  29. package/dist/context.js +55 -0
  30. package/dist/contracts/index.d.ts +234 -0
  31. package/dist/contracts/index.js +159 -0
  32. package/dist/errors.d.ts +16 -0
  33. package/dist/errors.js +22 -0
  34. package/dist/event-bus.d.ts +60 -0
  35. package/dist/event-bus.js +100 -0
  36. package/dist/index.d.ts +66 -0
  37. package/dist/index.js +66 -0
  38. package/dist/injection.d.ts +61 -0
  39. package/dist/injection.js +27 -0
  40. package/dist/json.d.ts +34 -0
  41. package/dist/json.js +127 -0
  42. package/dist/llm.d.ts +34 -0
  43. package/dist/llm.js +41 -0
  44. package/dist/memory.d.ts +72 -0
  45. package/dist/memory.js +123 -0
  46. package/dist/message.d.ts +189 -0
  47. package/dist/message.js +252 -0
  48. package/dist/plugin.d.ts +38 -0
  49. package/dist/plugin.js +49 -0
  50. package/dist/projection.d.ts +67 -0
  51. package/dist/projection.js +168 -0
  52. package/dist/question.d.ts +154 -0
  53. package/dist/question.js +82 -0
  54. package/dist/redact.d.ts +40 -0
  55. package/dist/redact.js +185 -0
  56. package/dist/repo.d.ts +14 -0
  57. package/dist/repo.js +87 -0
  58. package/dist/sandbox.d.ts +182 -0
  59. package/dist/sandbox.js +78 -0
  60. package/dist/session-event.d.ts +57 -0
  61. package/dist/session-event.js +425 -0
  62. package/dist/session-log.d.ts +71 -0
  63. package/dist/session-log.js +66 -0
  64. package/dist/skill-catalog.d.ts +29 -0
  65. package/dist/skill-catalog.js +52 -0
  66. package/dist/skills.d.ts +116 -0
  67. package/dist/skills.js +273 -0
  68. package/dist/sse-bus.d.ts +40 -0
  69. package/dist/sse-bus.js +105 -0
  70. package/dist/stream.d.ts +115 -0
  71. package/dist/stream.js +52 -0
  72. package/dist/tool-surface.d.ts +45 -0
  73. package/dist/tool-surface.js +98 -0
  74. package/dist/tool.d.ts +77 -0
  75. package/dist/tool.js +15 -0
  76. package/dist/turn-id.d.ts +37 -0
  77. package/dist/turn-id.js +76 -0
  78. package/dist/types.d.ts +396 -0
  79. package/dist/types.js +58 -0
  80. package/package.json +27 -0
@@ -0,0 +1,4390 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Celestea Studio HTTP API contract (61 endpoints)",
4
+ "generatedAt": "2026-09-10T01:20:00Z",
5
+ "phase": "P0 contract freeze",
6
+ "source": {
7
+ "doc": "/src/celestea_studio-ts/docs/feature-session-grants.md",
8
+ "routes": "frozen from the retired backend implementation (original source file deleted 2026-09-15); kept as contracts/route-table.snapshot.json (38 route() declarations = 43 method+path, minus 4 static = 39 API endpoints)",
9
+ "liveServer": "http://127.0.0.1:3777 (read-only probes only)",
10
+ "routeSnapshot": "contracts/route-table.snapshot.json",
11
+ "routeTable": "W725 added GET /api/sessions/{id}/context (43 -> 44); W516 added 4 TypeScript-only endpoints (GET|POST|DELETE /api/sessions/{id}/grants + GET /api/sessions/{id}/grants/confirm-token); the retired backend has no counterpart (see contracts/route-table.snapshot.json tsOnlyRoutes). W783 added GET /api/questions + POST /api/questions/{id}/answer (47 -> 49). W785 added GET /api/usage/ledger (49 -> 50). W791 added POST /api/sessions/{id}/mode (50 -> 51; P1 session working mode, TS-only). W9 added the six permission endpoints (51 -> 57). W860 added GET|PUT /api/sessions/{id}/tools and GET /api/plugins (57 -> 60). W870 added PUT /api/sessions/{id}/model (60 -> 61; the session-scoped model switch the statusline picker targets, TS-only). W881 removed the retired backend's archived HTTP contract (docs/archive/frontend/api-contract.md) from the public repo, so the endpoints it documented now reference their own frozen entry here."
12
+ },
13
+ "conventions": {
14
+ "responseBody": "JSON (application/json) except SSE and static assets",
15
+ "errorBody": "{\"ok\": false, \"error\": \"<verbatim source string>\"} (a few handlers return only {\"error\": ...})",
16
+ "requestBody": "POST endpoints declaring Json<T> reject missing/non-JSON/missing-required before the handler (415/400/422)",
17
+ "sessionId": "\"<workspace>/<session>\"; '/' in path params MUST be %2F encoded",
18
+ "singleConcurrency": "every write endpoint that swaps a generation or compacts grabs the busy slot and returns 409 while a turn runs",
19
+ "keySafety": "no response ever contains api_key"
20
+ },
21
+ "errorCodes": {
22
+ "400": "invalid parameters (empty, format, non-absolute/nonexistent path, id shape, active-session protection)",
23
+ "404": "unknown workspace/session/provider/prompt",
24
+ "405": "path exists but method mismatch (framework default)",
25
+ "409": "conflict: turn running (busy slot), duplicate registration, target exists, already archived",
26
+ "415": "request body missing / not JSON (framework body-parser rejection)",
27
+ "422": "field type mismatch (framework body-parser rejection)",
28
+ "500": "persist failure / compose failure / IO failure",
29
+ "502": "worker tool hard dispatch failure ONLY (src/api.rs:419-433)"
30
+ },
31
+ "count": 64,
32
+ "endpoints": [
33
+ {
34
+ "id": "get_health",
35
+ "method": "GET",
36
+ "path": "/api/health",
37
+ "group": "health",
38
+ "rustHandler": "get_health",
39
+ "docRef": "contracts/endpoints.json#get_health",
40
+ "request": {
41
+ "kind": "none",
42
+ "fields": []
43
+ },
44
+ "response": {
45
+ "status": 200,
46
+ "shape": "object",
47
+ "fields": [
48
+ {
49
+ "name": "ok",
50
+ "type": "boolean",
51
+ "required": false
52
+ },
53
+ {
54
+ "name": "name",
55
+ "type": "string",
56
+ "required": false,
57
+ "note": "constant \"celestea-studio\""
58
+ },
59
+ {
60
+ "name": "model",
61
+ "type": "string",
62
+ "required": false
63
+ },
64
+ {
65
+ "name": "base_url",
66
+ "type": "string",
67
+ "required": false
68
+ },
69
+ {
70
+ "name": "bind",
71
+ "type": "string",
72
+ "required": false,
73
+ "note": "the ACTUAL listening address host:port (follows the server/CLI --bind/--port; --port 0 reports the ephemeral port); DEFAULT_BIND is only the default"
74
+ },
75
+ {
76
+ "name": "version",
77
+ "type": "string",
78
+ "required": false,
79
+ "note": "W887: derived Studio version (single source = git tag via scripts/version.mjs; falls back to apps/web/package.json). PURE ADDITION."
80
+ },
81
+ {
82
+ "name": "capabilities",
83
+ "type": "object{grants:bool,context:bool,session_mode:bool}",
84
+ "required": false,
85
+ "note": "W729: `session_mode: true` = sessions carry a mode; absent/false means the client must not offer a mode selector"
86
+ }
87
+ ]
88
+ },
89
+ "errors": [],
90
+ "notes": [
91
+ "Always 200; no error branch.",
92
+ "W729: capabilities.session_mode = true (P0: read-only announcement; session_mode_tools arrives with P1).",
93
+ "W887: version is the same derived value the frontend build injects (git tag; package.json fallback)."
94
+ ],
95
+ "probe": {
96
+ "checked": true,
97
+ "mode": "read-only GET",
98
+ "server": "http://127.0.0.1:3777"
99
+ }
100
+ },
101
+ {
102
+ "id": "get_status",
103
+ "method": "GET",
104
+ "path": "/api/status",
105
+ "group": "health",
106
+ "rustHandler": "api::get_status",
107
+ "docRef": "contracts/endpoints.json#get_status",
108
+ "request": {
109
+ "kind": "none",
110
+ "fields": []
111
+ },
112
+ "response": {
113
+ "status": 200,
114
+ "shape": "object",
115
+ "fields": [
116
+ {
117
+ "name": "model",
118
+ "type": "string",
119
+ "required": false
120
+ },
121
+ {
122
+ "name": "reasoning_effort",
123
+ "type": "string|null",
124
+ "required": false
125
+ },
126
+ {
127
+ "name": "steps",
128
+ "type": "integer",
129
+ "required": false
130
+ },
131
+ {
132
+ "name": "tokens_per_sec",
133
+ "type": "number",
134
+ "required": false,
135
+ "note": "W218/W754/W763: characters per second (approximate token rate, ~1:1 in the UI) averaged over ACTIVE intervals only — no-flow breaks (> 1s between deltas) are excluded. Responsive 5s window rate while the window carries output, else the current turn's active-interval mean, so it stays positive after a stall or after the turn ends; 0 = this turn has not streamed yet (TTFT)."
136
+ },
137
+ {
138
+ "name": "context_usage",
139
+ "type": "object{used:int,window:int,ratio:number,estimated:bool,method:'usage_prompt_tokens'|'assembled_estimate'|'none'|'session_event_chars',projected:bool,window_source:'profile'|'fallback'|'unknown'}",
140
+ "required": false
141
+ },
142
+ {
143
+ "name": "usage",
144
+ "type": "object{prompt_tokens,completion_tokens,total_tokens,cache_read,cache_hit_ratio,reasoning_tokens,total:<same shape>}",
145
+ "required": false
146
+ },
147
+ {
148
+ "name": "session",
149
+ "type": "string|null",
150
+ "required": false,
151
+ "note": "from workspaces.active_session()"
152
+ },
153
+ {
154
+ "name": "mode",
155
+ "type": "\"standard\"|\"execution\"",
156
+ "required": false,
157
+ "note": "W729: mode of the QUERIED session (absent key on disk = \"standard\")"
158
+ },
159
+ {
160
+ "name": "model_covered",
161
+ "type": "boolean",
162
+ "required": false,
163
+ "note": "W870: true = the \"model\" above is THIS session's own session.json.model override (so the session does NOT follow the global default); false = the global default is in force. A PURE ADDITION — the statusline's model picker reads it to say 「本会话已固定模型」; a client that does not see the key shows no such line."
164
+ },
165
+ {
166
+ "name": "cost",
167
+ "type": "object{session_total:number|null,turn_total:number|null,attempts:integer,currency:string,priced_by:'table'|'unpriced',unpriced_models:array<string>,records:integer,cost_complete:boolean}",
168
+ "required": false,
169
+ "optional": true,
170
+ "note": "E-P1 (capability 3, W785): engine-side cost estimate from the append-only usage ledger; absent when the ledger is off or the adapter has none."
171
+ },
172
+ {
173
+ "name": "recovery",
174
+ "type": "object{session:string|null,recovered_turns:array<string>,dangling_turns:integer,degraded:boolean,last_outcome:string|null}",
175
+ "required": false,
176
+ "note": "E-P1 (capability 1, W787): the QUERIED session's checkpoint view — the turn ids this engine closed after a crash (checkpoint.json `repaired[]`), the `turn_start` rows still open, whether the session log refused a write (writeErrorCount>0) and the phase of the log's last `turn_end`. PURE ADDITION to an existing endpoint (P1 adds NO endpoint); an adapter without checkpointing answers the empty block, so the key is always present. The block describes the session's LIVE generation: a session with no composed instance answers the empty block (a status poll never builds an engine) — the durable record of a repair is the sidecar's `repaired[]` and the `session_repaired` audit line."
177
+ }
178
+ ]
179
+ },
180
+ "errors": [],
181
+ "notes": [
182
+ "Always 200.",
183
+ "W729: `mode` is the queried session's creation-time mode; P0 has no switch endpoint, so it only ever equals what POST /api/sessions was given.",
184
+ "W755 context_usage口径 (aligned with the DSH host's contextPressure): `method:usage_prompt_tokens` = the REAL prompt of the latest provider usage frame (input + cache, never output), with `projected:true` when the model-visible growth measured since that sample has been added on top (so the number answers for the NEXT request, DSH projectedTokens); `method:assembled_estimate` = no frame yet, so `used` is the token estimate of the loop's OWN next request (system + post-trim history + tool schemas, the same assembly GET /api/sessions/{id}/context serves); `method:none` = nothing measurable (`used:0`) and the client must show \"unknown\" instead of a ratio.",
185
+ "W755: `method:session_event_chars` is RETIRED — it divided the session log's CHARACTER count by a TOKEN window (~4x over-report; fixtures/live/status.json held used:565437 / ratio:0.5654 where the real assembly is 155,698 / 0.1557). The literal stays in the union only so a consumer can recognise an older build's payload; this backend never emits it.",
186
+ "W755: `estimated` says whether `used` is the visible-surface estimate; `projected` says whether the real-prompt anchor was extended by measured growth. They are different questions and both may be false.",
187
+ "W755: `window_source:profile` is the only case where `ratio` is a real measurement. A session whose profile declares no capacity reports `window:0, ratio:0, window_source:fallback` — the 1000000 CONTEXT_WINDOW_FALLBACK is a DISPLAY default only and never a denominator (the client must not draw a ring when window is 0); `unknown` means the configured value was not usable at all. DSH refuses to render occupancy without a resolved capacity.",
188
+ "W755: `usage.total` is a cross-step, repeatedly-billed sum (every step re-sends the whole prompt), NOT an occupancy reading — never divide it by a context window. Occupancy is `context_usage` only.",
189
+ "W785: `cost` is a PURE ADDITION and is present ONLY when the queried session's rows exist in the process ledger (`session_total`/`turn_total` are `null` — never 0 — when nothing was priced).",
190
+ "W787: `recovery.degraded` is true when the session log's own `writeErrorCount()>0` (disk and memory diverged, G1-6) OR the sidecar's sticky `degraded.log_write_errors>0`; the sticky value keeps a past fork visible after a healthy restart.",
191
+ "W787: `recovery.dangling_turns` counts `turn_start` rows minus `turn_end` rows in the LIVE log (the same rule as `analyzeReplay().danglingTurns`), and `recovered_turns` lists the `checkpoint.json` `repaired[]` turn ids — a repaired crash therefore reports `dangling_turns:0` together with a non-empty `recovered_turns`."
192
+ ],
193
+ "probe": {
194
+ "checked": true,
195
+ "mode": "read-only GET",
196
+ "server": "http://127.0.0.1:3777"
197
+ }
198
+ },
199
+ {
200
+ "id": "get_tools",
201
+ "method": "GET",
202
+ "path": "/api/tools",
203
+ "group": "health",
204
+ "rustHandler": "api::get_tools",
205
+ "docRef": "contracts/endpoints.json#get_tools",
206
+ "request": {
207
+ "kind": "query",
208
+ "fields": [
209
+ {
210
+ "name": "session",
211
+ "type": "string",
212
+ "required": false,
213
+ "note": "W791: answer for THIS session's own generation; absent/blank = the focused (active) session, and the detached default generation when nothing is active"
214
+ }
215
+ ],
216
+ "note": "W791 (P1): the optional `session` query selects whose tool face is described. An `execution` session reports its folded face."
217
+ },
218
+ "response": {
219
+ "status": 200,
220
+ "shape": "object",
221
+ "fields": [
222
+ {
223
+ "name": "tools",
224
+ "type": "array<{name:string,description:string}>",
225
+ "required": false,
226
+ "note": "schemas() of the queried session's generation; sorted by name; up to 14 tools in standard mode (read_image is mounted only when the session has an attachment store), the 8 execution-mode names in execution mode"
227
+ }
228
+ ]
229
+ },
230
+ "errors": [],
231
+ "notes": [
232
+ "Always 200 (an unresolvable `session` falls back to the default generation, never an error branch).",
233
+ "W791 (P1): the names answer the SAME face `{{tools}}` renders for that session in `GET /api/config` (M9); the execution fold hides the four SDK tools and `ask_user_question` (direct `dispatch` answers `tool_unavailable_in_mode`)."
234
+ ],
235
+ "probe": {
236
+ "checked": true,
237
+ "mode": "read-only GET",
238
+ "server": "http://127.0.0.1:3777"
239
+ }
240
+ },
241
+ {
242
+ "id": "get_events",
243
+ "method": "GET",
244
+ "path": "/api/events",
245
+ "group": "dialog",
246
+ "rustHandler": "get_events",
247
+ "docRef": "contracts/endpoints.json#get_events",
248
+ "request": {
249
+ "kind": "none",
250
+ "fields": []
251
+ },
252
+ "response": {
253
+ "status": 200,
254
+ "shape": "text/event-stream",
255
+ "fields": [
256
+ {
257
+ "name": "data",
258
+ "type": "string",
259
+ "required": false,
260
+ "note": "SSE envelope JSON: {turn:u64,seq:u64,payload:object}"
261
+ }
262
+ ],
263
+ "contentType": "text/event-stream"
264
+ },
265
+ "errors": [],
266
+ "notes": [
267
+ "KeepAlive enabled; bus capacity 512; slow clients get event `status` payload {phase:'lagged',hint:'slow client, skipped events',statusline:{...}} and the stream continues.",
268
+ "Event names (8): text, thinking, tool, tool_result, turn_end, done, status, compact. See contracts/sse-events.json."
269
+ ],
270
+ "probe": {
271
+ "checked": true,
272
+ "mode": "read-only passive connect",
273
+ "server": "http://127.0.0.1:3777",
274
+ "headersObserved": {
275
+ "content-type": "text/event-stream",
276
+ "cache-control": "no-cache",
277
+ "transfer-encoding": "chunked"
278
+ },
279
+ "eventsObserved": 0,
280
+ "reason": "no turn running and P0 forbids POST /api/turn (would append to production cli-main.jsonl)"
281
+ }
282
+ },
283
+ {
284
+ "id": "post_turn",
285
+ "method": "POST",
286
+ "path": "/api/turn",
287
+ "group": "dialog",
288
+ "rustHandler": "post_turn",
289
+ "docRef": "contracts/endpoints.json#post_turn",
290
+ "request": {
291
+ "kind": "json",
292
+ "fields": [
293
+ {
294
+ "name": "input",
295
+ "type": "string",
296
+ "required": true,
297
+ "note": "non-empty after trim UNLESS at least one attachment is present"
298
+ },
299
+ {
300
+ "name": "mode",
301
+ "type": "string",
302
+ "required": false,
303
+ "note": "W847 optional delivery lane on a BUSY session: \"steer\" (default) = inject into the RUNNING turn at its next step boundary (200, placement \"steering\", injected true); \"queue\" = park for the NEXT turn start (200, placement \"queued\", injected false). Omitted = byte-identical to the pre-W847 request. Any other value is a 400, never silently defaulted. Ignored on the idle path (the input IS the new turn)."
304
+ },
305
+ {
306
+ "name": "attachments",
307
+ "type": "array",
308
+ "required": false,
309
+ "note": "W804 P0 optional inline image attachments, no upload endpoint: each item is {data:<base64 without the data: prefix>, name?:<original filename>}; the server magic-byte sniffs PNG/JPEG/WebP/GIF, writes <session-dir>/attachments/<sha256>.<ext> and puts ONLY content-addressed refs in the log (never base64). Empty input is legal when at least one attachment is present."
310
+ }
311
+ ]
312
+ },
313
+ "response": {
314
+ "status": 202,
315
+ "shape": "object",
316
+ "fields": [
317
+ {
318
+ "name": "turn",
319
+ "type": "integer",
320
+ "required": false
321
+ },
322
+ {
323
+ "name": "status",
324
+ "type": "string",
325
+ "required": false,
326
+ "note": "constant \"started\""
327
+ },
328
+ {
329
+ "name": "placement",
330
+ "type": "\"context\"",
331
+ "required": false,
332
+ "note": "W515/W847: the idle turn's own input IS the context, so this 202 body carries placement:\"context\" (the busy-lane 200 answers are described in the notes below)."
333
+ }
334
+ ]
335
+ },
336
+ "errors": [
337
+ {
338
+ "status": 400,
339
+ "error": "input must not be empty"
340
+ },
341
+ {
342
+ "status": 400,
343
+ "error": "invalid mode: {v} (expected \"steer\" or \"queue\")"
344
+ },
345
+ {
346
+ "status": 409,
347
+ "error": "a turn is already running"
348
+ }
349
+ ],
350
+ "notes": [
351
+ "Grabs the busy slot, emits status:start, spawns execute_turn and returns immediately; all results go over SSE.",
352
+ "W847: on a BUSY session the optional \"mode\" picks the delivery lane. \"steer\" (default, and the omitted request) injects into the RUNNING turn at its next step boundary and answers 200 {ok:true,injected:true,turn,pending,placement:\"steering\",duplicate:false}; \"queue\" parks the input on the next-turn lane for the NEXT turn start and answers 200 with injected:false and placement:\"queued\" (the running turn is never touched). On an IDLE session mode is ignored: the input becomes the new turn and the 202 body carries placement:\"context\". An invalid mode is a 400 with the explicit message above, never a silent default."
353
+ ],
354
+ "probe": {
355
+ "checked": false,
356
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
357
+ }
358
+ },
359
+ {
360
+ "id": "post_cancel",
361
+ "method": "POST",
362
+ "path": "/api/cancel",
363
+ "group": "dialog",
364
+ "rustHandler": "post_cancel",
365
+ "docRef": "contracts/endpoints.json#post_cancel",
366
+ "request": {
367
+ "kind": "none",
368
+ "fields": []
369
+ },
370
+ "response": {
371
+ "status": 200,
372
+ "shape": "object",
373
+ "fields": [
374
+ {
375
+ "name": "ok",
376
+ "type": "boolean",
377
+ "required": false,
378
+ "note": "always true"
379
+ },
380
+ {
381
+ "name": "cancelled",
382
+ "type": "boolean",
383
+ "required": false,
384
+ "note": "true = cancel signal sent; false = idle"
385
+ }
386
+ ]
387
+ },
388
+ "errors": [],
389
+ "notes": [
390
+ "Cooperative cancel; body ignored."
391
+ ],
392
+ "probe": {
393
+ "checked": true,
394
+ "mode": "read-only error-branch probe",
395
+ "server": "http://127.0.0.1:3777"
396
+ }
397
+ },
398
+ {
399
+ "id": "post_clear",
400
+ "method": "POST",
401
+ "path": "/api/clear",
402
+ "group": "dialog",
403
+ "rustHandler": "workspaces::post_clear",
404
+ "docRef": "contracts/endpoints.json#post_clear",
405
+ "request": {
406
+ "kind": "none",
407
+ "fields": []
408
+ },
409
+ "response": {
410
+ "status": 200,
411
+ "shape": "object",
412
+ "fields": [
413
+ {
414
+ "name": "ok",
415
+ "type": "boolean",
416
+ "required": false
417
+ },
418
+ {
419
+ "name": "cleared",
420
+ "type": "boolean",
421
+ "required": false
422
+ },
423
+ {
424
+ "name": "session",
425
+ "type": "string|null",
426
+ "required": false
427
+ }
428
+ ]
429
+ },
430
+ "errors": [],
431
+ "notes": [
432
+ "Truncates cli-main.jsonl, clears in-memory events, resets turn counter. No backup, no 409 guard, does not affect worker sessions (pitfalls P11)."
433
+ ],
434
+ "probe": {
435
+ "checked": false,
436
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
437
+ }
438
+ },
439
+ {
440
+ "id": "get_config",
441
+ "method": "GET",
442
+ "path": "/api/config",
443
+ "group": "config",
444
+ "rustHandler": "api::get_config",
445
+ "docRef": "contracts/endpoints.json#get_config",
446
+ "request": {
447
+ "kind": "none",
448
+ "fields": []
449
+ },
450
+ "response": {
451
+ "status": 200,
452
+ "shape": "object",
453
+ "fields": [
454
+ {
455
+ "name": "model",
456
+ "type": "string",
457
+ "required": false
458
+ },
459
+ {
460
+ "name": "base_url",
461
+ "type": "string",
462
+ "required": false
463
+ },
464
+ {
465
+ "name": "max_steps",
466
+ "type": "integer",
467
+ "required": false
468
+ },
469
+ {
470
+ "name": "max_parallel_tool_calls",
471
+ "type": "integer",
472
+ "required": false
473
+ },
474
+ {
475
+ "name": "reasoning_effort",
476
+ "type": "string|null",
477
+ "required": false
478
+ },
479
+ {
480
+ "name": "max_output_tokens",
481
+ "type": "integer|null",
482
+ "required": false
483
+ },
484
+ {
485
+ "name": "context_window",
486
+ "type": "integer",
487
+ "required": false
488
+ },
489
+ {
490
+ "name": "system_prompt",
491
+ "type": "string",
492
+ "required": false
493
+ },
494
+ {
495
+ "name": "api_key_env",
496
+ "type": "string",
497
+ "required": false
498
+ },
499
+ {
500
+ "name": "available",
501
+ "type": "object{models:array<{id,name,provider,provider_id,active:bool,reasoning:bool}>,efforts:array<string>}",
502
+ "required": false
503
+ }
504
+ ]
505
+ },
506
+ "errors": [],
507
+ "notes": [
508
+ "Always 200. Never contains api_key. available.models is rebuilt from the live providers store on every read.",
509
+ "W750: available.models carries ONE row per (provider, model) pair — de-duplication is per provider, never global, because the same model id can be served by two providers over different endpoints (the provider decides where the request goes).",
510
+ "W750: each row carries provider_id (the provider's stable id, for switches) beside provider (the display name), plus active = this exact pair is the composed one (same model id AND same endpoint as the live profile; when no endpoint matches, a model id is marked active only if it is unambiguous)."
511
+ ],
512
+ "probe": {
513
+ "checked": true,
514
+ "mode": "read-only GET",
515
+ "server": "http://127.0.0.1:3777"
516
+ }
517
+ },
518
+ {
519
+ "id": "post_config",
520
+ "method": "POST",
521
+ "path": "/api/config",
522
+ "group": "config",
523
+ "rustHandler": "api::post_config",
524
+ "docRef": "contracts/endpoints.json#post_config",
525
+ "request": {
526
+ "kind": "json",
527
+ "fields": [
528
+ {
529
+ "name": "model",
530
+ "type": "string",
531
+ "required": false
532
+ },
533
+ {
534
+ "name": "reasoning_effort",
535
+ "type": "string|null",
536
+ "required": ""
537
+ },
538
+ {
539
+ "name": "base_url",
540
+ "type": "string",
541
+ "required": false
542
+ },
543
+ {
544
+ "name": "api_key",
545
+ "type": "string",
546
+ "required": false,
547
+ "note": "only into process env; never persisted/logged/echoed"
548
+ },
549
+ {
550
+ "name": "max_output_tokens",
551
+ "type": "number",
552
+ "required": false
553
+ },
554
+ {
555
+ "name": "context_window",
556
+ "type": "number",
557
+ "required": false
558
+ },
559
+ {
560
+ "name": "max_steps",
561
+ "type": "number",
562
+ "required": false
563
+ },
564
+ {
565
+ "name": "system_prompt",
566
+ "type": "string",
567
+ "required": false
568
+ }
569
+ ],
570
+ "note": "all fields optional; trim-empty model = not provided; reasoning_effort ''/'off' = clear; base_url '' = clear override; max_output_tokens 0 = clear cap; context_window 0 = disable trimming; max_steps 0 = 400, otherwise max(n,4096); system_prompt '' = clear in-memory override"
571
+ },
572
+ "response": {
573
+ "status": 200,
574
+ "shape": "object",
575
+ "fields": [
576
+ {
577
+ "name": "(same shape as GET /api/config)",
578
+ "type": "object",
579
+ "required": false
580
+ }
581
+ ]
582
+ },
583
+ "errors": [
584
+ {
585
+ "status": 409,
586
+ "error": "turn in progress; config applies between turns"
587
+ },
588
+ {
589
+ "status": 400,
590
+ "error": "invalid model name '{m}': character {bad:?} is not allowed (only [A-Za-z0-9._-:/@]; no spaces, brackets or control characters)"
591
+ },
592
+ {
593
+ "status": 400,
594
+ "error": "invalid model name: '{m}' exceeds 128 characters"
595
+ },
596
+ {
597
+ "status": 400,
598
+ "error": "model '{target}' is not a reasoning model; reasoning_effort is unavailable"
599
+ },
600
+ {
601
+ "status": 400,
602
+ "error": "base_url must be an http:// or https:// URL"
603
+ },
604
+ {
605
+ "status": 400,
606
+ "error": "max_output_tokens must be <= u32::MAX"
607
+ },
608
+ {
609
+ "status": 400,
610
+ "error": "max_steps must be >= 1"
611
+ },
612
+ {
613
+ "status": 500,
614
+ "error": "compose failed: {e}"
615
+ }
616
+ ],
617
+ "notes": [
618
+ "Unknown model ids are treated as reasoning-capable."
619
+ ],
620
+ "probe": {
621
+ "checked": false,
622
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
623
+ }
624
+ },
625
+ {
626
+ "id": "get_sessions",
627
+ "method": "GET",
628
+ "path": "/api/sessions",
629
+ "group": "sessions",
630
+ "rustHandler": "workspaces::get_sessions",
631
+ "docRef": "contracts/endpoints.json#get_sessions",
632
+ "request": {
633
+ "kind": "query",
634
+ "fields": [
635
+ {
636
+ "name": "archived",
637
+ "type": "string",
638
+ "required": false,
639
+ "note": "W791: `1` or `true` lists ONLY the archived sessions (each row carries `archived:true`); absent/`0`/`false`/anything else = the default listing, unchanged"
640
+ }
641
+ ],
642
+ "note": "W791 (B) / W880: the archived listing is the canonical `<CELESTEA_HOME>/workspaces/<ws>/archive/` source (legacy `<ws>/.celestea/archive/` and `<ws>/.celestea-archived/` are still read); the default body keeps its frozen shape (no `archived` key)."
643
+ },
644
+ "response": {
645
+ "status": 200,
646
+ "shape": "object",
647
+ "fields": [
648
+ {
649
+ "name": "sessions",
650
+ "type": "array<{id,workspace,title,model:string|null,mode:'standard'|'execution',size:int,modified:int,active:bool,kind?:'worker',archived?:true}>",
651
+ "required": false,
652
+ "note": "`archived:true` appears ONLY on the `?archived=1` listing (W791); the default listing never carries the key"
653
+ },
654
+ {
655
+ "name": "active_session",
656
+ "type": "string|null",
657
+ "required": false
658
+ }
659
+ ]
660
+ },
661
+ "errors": [],
662
+ "notes": [
663
+ "No `ok` field. Three sources: workspace session dirs + engine in-memory worker sessions. Sorted by id ascending.",
664
+ "W729: every row carries `mode` (worker rows included); a session without session.json.mode reads as \"standard\".",
665
+ "W791 / W880: `?archived=1` reverses the scanner's dot-dir blind spot: the row set becomes the canonical `<CELESTEA_HOME>/workspaces/<ws>/archive/` directories holding `cli-main.jsonl` (legacy `<ws>/.celestea-archived/` still read), in the SAME shape and sort order as the default rows plus `archived:true` (id/workspace/title/model/mode/size/modified derive identically, `active` is always false, and worker rows are not part of that listing). Deleting an archived session (`POST /api/sessions/batch-delete`) and restoring it (`POST /api/sessions/{id}/unarchive`) both address it by its unchanged id.",
666
+ "W779: the row `title` is the DISPLAY name — `session.json.title` when the session declared one, else the directory name with its `-{secs}.{nanos}[-N]` creation suffix stripped (`main-1789192174.492000000` -> `main`). The `id` keeps the raw directory name."
667
+ ],
668
+ "probe": {
669
+ "checked": true,
670
+ "mode": "read-only GET",
671
+ "server": "http://127.0.0.1:3777"
672
+ }
673
+ },
674
+ {
675
+ "id": "post_sessions",
676
+ "method": "POST",
677
+ "path": "/api/sessions",
678
+ "group": "sessions",
679
+ "rustHandler": "workspaces::post_session_create",
680
+ "docRef": "contracts/endpoints.json#post_sessions",
681
+ "request": {
682
+ "kind": "json",
683
+ "fields": [
684
+ {
685
+ "name": "workspace",
686
+ "type": "string",
687
+ "required": false,
688
+ "note": "defaults to active id segment 1, else first registered workspace basename"
689
+ },
690
+ {
691
+ "name": "title",
692
+ "type": "string",
693
+ "required": true,
694
+ "note": "W779: stored verbatim (trimmed, CJK/spaces preserved) as session.json.title — the GUI displays THIS, never the directory name"
695
+ },
696
+ {
697
+ "name": "model",
698
+ "type": "string",
699
+ "required": false
700
+ },
701
+ {
702
+ "name": "prompt",
703
+ "type": "string",
704
+ "required": false
705
+ },
706
+ {
707
+ "name": "mode",
708
+ "type": "\"standard\"|\"execution\"",
709
+ "required": false,
710
+ "note": "W729: session working mode; omitted = standard AND not written to session.json (K8)"
711
+ }
712
+ ]
713
+ },
714
+ "response": {
715
+ "status": 200,
716
+ "shape": "object",
717
+ "fields": [
718
+ {
719
+ "name": "ok",
720
+ "type": "boolean",
721
+ "required": false
722
+ },
723
+ {
724
+ "name": "id",
725
+ "type": "string",
726
+ "required": false,
727
+ "note": "\"{ws}/{sanitized title}-{secs}.{nanos}\""
728
+ }
729
+ ]
730
+ },
731
+ "errors": [
732
+ {
733
+ "status": 404,
734
+ "error": "unknown workspace '{ws_name}'"
735
+ },
736
+ {
737
+ "status": 400,
738
+ "error": "invalid model: {e}"
739
+ },
740
+ {
741
+ "status": 400,
742
+ "error": "invalid mode: {v}"
743
+ },
744
+ {
745
+ "status": 400,
746
+ "error": "invalid prompt: {e}"
747
+ },
748
+ {
749
+ "status": 400,
750
+ "error": "title must not be empty"
751
+ },
752
+ {
753
+ "status": 400,
754
+ "error": "title '{title}' sanitizes to the hidden name '{base}'"
755
+ },
756
+ {
757
+ "status": 404,
758
+ "error": "workspace path '{path}' is not accessible"
759
+ },
760
+ {
761
+ "status": 500,
762
+ "error": "create failed: {e}"
763
+ },
764
+ {
765
+ "status": 500,
766
+ "error": "meta write failed: {e}"
767
+ }
768
+ ],
769
+ "notes": [
770
+ "Does NOT activate. session.json written only when model/prompt non-empty.",
771
+ "W729: `mode` is optional and creation-time only (运行期切换/P1); it is written to session.json only when non-default."
772
+ ],
773
+ "probe": {
774
+ "checked": false,
775
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
776
+ }
777
+ },
778
+ {
779
+ "id": "get_session_messages",
780
+ "method": "GET",
781
+ "path": "/api/sessions/{id}/messages",
782
+ "group": "sessions",
783
+ "rustHandler": "workspaces::get_session_messages",
784
+ "docRef": "contracts/endpoints.json#get_session_messages",
785
+ "request": {
786
+ "kind": "none",
787
+ "fields": []
788
+ },
789
+ "response": {
790
+ "status": 200,
791
+ "shape": "object",
792
+ "fields": [
793
+ {
794
+ "name": "ok",
795
+ "type": "boolean",
796
+ "required": false
797
+ },
798
+ {
799
+ "name": "session",
800
+ "type": "string",
801
+ "required": false,
802
+ "note": "echo of the raw id"
803
+ },
804
+ {
805
+ "name": "messages",
806
+ "type": "array<user|assistant|thinking|tool message>",
807
+ "required": false,
808
+ "note": "see contracts/session-event.schema.json #projection"
809
+ }
810
+ ]
811
+ },
812
+ "errors": [
813
+ {
814
+ "status": 400,
815
+ "error": "invalid session id '{id}': expected '<workspace>/<session>'"
816
+ },
817
+ {
818
+ "status": 404,
819
+ "error": "unknown workspace '{ws_name}'"
820
+ },
821
+ {
822
+ "status": 400,
823
+ "error": "invalid session id '{id}'"
824
+ },
825
+ {
826
+ "status": 404,
827
+ "error": "unknown session '{id}'"
828
+ }
829
+ ],
830
+ "notes": [
831
+ "Parses cli-main.jsonl and STOPS at the first unparsable line (torn tail dropped). `worker:<sid>` reads the engine in-memory SessionRegistry."
832
+ ],
833
+ "probe": {
834
+ "checked": true,
835
+ "mode": "read-only error-branch probe",
836
+ "server": "http://127.0.0.1:3777"
837
+ }
838
+ },
839
+ {
840
+ "id": "post_session_activate",
841
+ "method": "POST",
842
+ "path": "/api/sessions/{id}/activate",
843
+ "group": "sessions",
844
+ "rustHandler": "workspaces::post_session_activate",
845
+ "docRef": "contracts/endpoints.json#post_session_activate",
846
+ "request": {
847
+ "kind": "none",
848
+ "fields": []
849
+ },
850
+ "response": {
851
+ "status": 200,
852
+ "shape": "object",
853
+ "fields": [
854
+ {
855
+ "name": "ok",
856
+ "type": "boolean",
857
+ "required": false
858
+ },
859
+ {
860
+ "name": "active_session",
861
+ "type": "string",
862
+ "required": false
863
+ }
864
+ ]
865
+ },
866
+ "errors": [
867
+ {
868
+ "status": 409,
869
+ "error": "turn in progress; activate applies between turns"
870
+ },
871
+ {
872
+ "status": 400,
873
+ "error": "invalid session model: {e}"
874
+ },
875
+ {
876
+ "status": 500,
877
+ "error": "compose failed: {e}"
878
+ },
879
+ {
880
+ "status": 500,
881
+ "error": "cannot persist active session: {e}"
882
+ }
883
+ ],
884
+ "notes": [
885
+ "On compose failure the env has already been rewritten and is NOT rolled back (pitfalls P12)."
886
+ ],
887
+ "probe": {
888
+ "checked": false,
889
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
890
+ }
891
+ },
892
+ {
893
+ "id": "post_session_rename",
894
+ "method": "POST",
895
+ "path": "/api/sessions/{id}/rename",
896
+ "group": "sessions",
897
+ "rustHandler": "workspaces::post_session_rename",
898
+ "docRef": "contracts/endpoints.json#post_session_rename",
899
+ "request": {
900
+ "kind": "json",
901
+ "fields": [
902
+ {
903
+ "name": "new_title",
904
+ "type": "string",
905
+ "required": true
906
+ }
907
+ ]
908
+ },
909
+ "response": {
910
+ "status": 200,
911
+ "shape": "object",
912
+ "fields": [
913
+ {
914
+ "name": "ok",
915
+ "type": "boolean",
916
+ "required": false
917
+ },
918
+ {
919
+ "name": "id",
920
+ "type": "string",
921
+ "required": false
922
+ }
923
+ ]
924
+ },
925
+ "errors": [
926
+ {
927
+ "status": 409,
928
+ "error": "turn in progress; rename applies between turns"
929
+ },
930
+ {
931
+ "status": 400,
932
+ "error": "new title must not be empty"
933
+ },
934
+ {
935
+ "status": 400,
936
+ "error": "title '{...}' sanitizes to the hidden name '{base}'"
937
+ },
938
+ {
939
+ "status": 500,
940
+ "error": "move failed: {e}"
941
+ },
942
+ {
943
+ "status": 500,
944
+ "error": "meta write failed: {e}"
945
+ },
946
+ {
947
+ "status": 500,
948
+ "error": "compose failed: {e}"
949
+ },
950
+ {
951
+ "status": 500,
952
+ "error": "cannot persist active session: {e}"
953
+ }
954
+ ],
955
+ "notes": [
956
+ "Busy lock is taken only for the ACTIVE session. Same name = no-op success."
957
+ ],
958
+ "probe": {
959
+ "checked": false,
960
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
961
+ }
962
+ },
963
+ {
964
+ "id": "post_session_branch",
965
+ "method": "POST",
966
+ "path": "/api/sessions/{id}/branch",
967
+ "group": "sessions",
968
+ "rustHandler": "workspaces::post_session_branch",
969
+ "docRef": "contracts/endpoints.json#post_session_branch",
970
+ "request": {
971
+ "kind": "json",
972
+ "fields": [
973
+ {
974
+ "name": "title",
975
+ "type": "string",
976
+ "required": false,
977
+ "note": "defaults to '{source}-分支'"
978
+ }
979
+ ]
980
+ },
981
+ "response": {
982
+ "status": 200,
983
+ "shape": "object",
984
+ "fields": [
985
+ {
986
+ "name": "ok",
987
+ "type": "boolean",
988
+ "required": false
989
+ },
990
+ {
991
+ "name": "id",
992
+ "type": "string",
993
+ "required": false
994
+ }
995
+ ]
996
+ },
997
+ "errors": [
998
+ {
999
+ "status": 400,
1000
+ "error": "title must not be empty"
1001
+ },
1002
+ {
1003
+ "status": 400,
1004
+ "error": "title sanitizes to the hidden name '{base}'"
1005
+ },
1006
+ {
1007
+ "status": 500,
1008
+ "error": "create failed: {e}"
1009
+ },
1010
+ {
1011
+ "status": 500,
1012
+ "error": "copy failed: {e}"
1013
+ },
1014
+ {
1015
+ "status": 500,
1016
+ "error": "meta write failed: {e}"
1017
+ }
1018
+ ],
1019
+ "notes": [
1020
+ "Copies cli-main.jsonl (+ session.json if present). Never activates."
1021
+ ],
1022
+ "probe": {
1023
+ "checked": false,
1024
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1025
+ }
1026
+ },
1027
+ {
1028
+ "id": "post_session_compact",
1029
+ "method": "POST",
1030
+ "path": "/api/sessions/{id}/compact",
1031
+ "group": "sessions",
1032
+ "rustHandler": "compact::post_session_compact",
1033
+ "docRef": "contracts/endpoints.json#post_session_compact",
1034
+ "request": {
1035
+ "kind": "none",
1036
+ "fields": []
1037
+ },
1038
+ "response": {
1039
+ "status": 200,
1040
+ "shape": "object",
1041
+ "fields": [
1042
+ {
1043
+ "name": "ok",
1044
+ "type": "boolean",
1045
+ "required": false
1046
+ },
1047
+ {
1048
+ "name": "compacted",
1049
+ "type": "boolean",
1050
+ "required": false
1051
+ },
1052
+ {
1053
+ "name": "kept_turns",
1054
+ "type": "integer",
1055
+ "required": false,
1056
+ "note": "present only when compacted:true",
1057
+ "optional": true
1058
+ },
1059
+ {
1060
+ "name": "note",
1061
+ "type": "string",
1062
+ "required": false
1063
+ }
1064
+ ]
1065
+ },
1066
+ "errors": [
1067
+ {
1068
+ "status": 409,
1069
+ "error": "turn 进行中,无法压缩"
1070
+ },
1071
+ {
1072
+ "status": 400,
1073
+ "error": "invalid session model: {e}"
1074
+ },
1075
+ {
1076
+ "status": 400,
1077
+ "error": "<from session_dir_for>"
1078
+ },
1079
+ {
1080
+ "status": 404,
1081
+ "error": "<from session_dir_for>"
1082
+ },
1083
+ {
1084
+ "status": 500,
1085
+ "error": "读取会话日志失败:{e}"
1086
+ },
1087
+ {
1088
+ "status": 500,
1089
+ "error": "摘要请求缺少可用密钥:{e}"
1090
+ },
1091
+ {
1092
+ "status": 500,
1093
+ "error": "摘要请求失败:{e}"
1094
+ },
1095
+ {
1096
+ "status": 500,
1097
+ "error": "摘要请求失败:HTTP {status}:{head 300}"
1098
+ },
1099
+ {
1100
+ "status": 500,
1101
+ "error": "摘要响应读取失败:{e}"
1102
+ },
1103
+ {
1104
+ "status": 500,
1105
+ "error": "摘要响应不是 JSON({e});body 头部:{head 200}"
1106
+ },
1107
+ {
1108
+ "status": 500,
1109
+ "error": "摘要响应缺少 choices[0].message.content"
1110
+ },
1111
+ {
1112
+ "status": 500,
1113
+ "error": "内部错误:压缩计划为空"
1114
+ },
1115
+ {
1116
+ "status": 500,
1117
+ "error": "备份失败 '...':{e}"
1118
+ },
1119
+ {
1120
+ "status": 500,
1121
+ "error": "临时文件创建失败 '...':{e}"
1122
+ },
1123
+ {
1124
+ "status": 500,
1125
+ "error": "临时文件写入失败 '...':{e}"
1126
+ },
1127
+ {
1128
+ "status": 500,
1129
+ "error": "临时文件 fsync 失败 '...':{e}"
1130
+ },
1131
+ {
1132
+ "status": 500,
1133
+ "error": "原子替换失败 '...':{e}"
1134
+ },
1135
+ {
1136
+ "status": 500,
1137
+ "error": "compose failed: {e}"
1138
+ }
1139
+ ],
1140
+ "notes": [
1141
+ "COMPACT_THRESHOLD=8 (needs >=9 complete turns), COMPACT_KEEP_TURNS=4. All summary error strings are redacted of api keys. On success broadcasts SSE `compact` with turn=0.",
1142
+ "Fields marked optional:true appear only in some response variants."
1143
+ ],
1144
+ "probe": {
1145
+ "checked": false,
1146
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1147
+ }
1148
+ },
1149
+ {
1150
+ "id": "post_session_mode",
1151
+ "method": "POST",
1152
+ "path": "/api/sessions/{id}/mode",
1153
+ "group": "sessions",
1154
+ "rustHandler": "(none - TypeScript-only, W791)",
1155
+ "docRef": "docs/modes-standard-vs-execution.md#31-api(端点与字段)",
1156
+ "request": {
1157
+ "kind": "json",
1158
+ "fields": [
1159
+ {
1160
+ "name": "mode",
1161
+ "type": "'standard' | 'execution'",
1162
+ "required": true
1163
+ }
1164
+ ]
1165
+ },
1166
+ "response": {
1167
+ "status": 200,
1168
+ "shape": "object",
1169
+ "fields": [
1170
+ {
1171
+ "name": "ok",
1172
+ "type": "boolean",
1173
+ "required": false
1174
+ },
1175
+ {
1176
+ "name": "session",
1177
+ "type": "string",
1178
+ "required": false
1179
+ },
1180
+ {
1181
+ "name": "mode",
1182
+ "type": "string",
1183
+ "required": false
1184
+ },
1185
+ {
1186
+ "name": "effective",
1187
+ "type": "string",
1188
+ "required": false,
1189
+ "note": "constant \"next_turn\": the instance recomposes at the session's next turn boundary"
1190
+ }
1191
+ ]
1192
+ },
1193
+ "errors": [
1194
+ {
1195
+ "status": 400,
1196
+ "error": "invalid mode: {v}"
1197
+ },
1198
+ {
1199
+ "status": 404,
1200
+ "error": "unknown session '{id}'"
1201
+ },
1202
+ {
1203
+ "status": 409,
1204
+ "error": "turn 进行中,无法切换模式"
1205
+ },
1206
+ {
1207
+ "status": 422,
1208
+ "error": "field 'mode' must be a string"
1209
+ }
1210
+ ],
1211
+ "notes": [
1212
+ "P1 (docs/modes-standard-vs-execution.md §3.1/§5.2 #6): rewrites `session.json.mode` through the W729 writer (title/model/prompt are kept), then drops the session's instance so the NEXT turn recomposes it (W516's `invalidateSession` path). The 409 guard is `/compact`'s, in semantics and wording.",
1213
+ "TS-only (U8): the retired backend has no counterpart — see contracts/route-table.snapshot.json `tsOnlyRoutes`. Frontends gate the switch on `GET /api/health.capabilities.session_mode_tools === true`."
1214
+ ],
1215
+ "probe": {
1216
+ "checked": false,
1217
+ "reason": "TypeScript-only endpoint"
1218
+ }
1219
+ },
1220
+ {
1221
+ "id": "put_session_model",
1222
+ "method": "PUT",
1223
+ "path": "/api/sessions/{id}/model",
1224
+ "group": "sessions",
1225
+ "rustHandler": "(none - TypeScript-only, W870)",
1226
+ "docRef": "docs/modes-standard-vs-execution.md#31-api(端点与字段)",
1227
+ "request": {
1228
+ "kind": "json",
1229
+ "fields": [
1230
+ {
1231
+ "name": "model",
1232
+ "type": "string",
1233
+ "required": true,
1234
+ "note": "A model id (same alphabet as POST /api/config). The EMPTY string CLEARS the session override, so the session falls back to the global default model."
1235
+ }
1236
+ ]
1237
+ },
1238
+ "response": {
1239
+ "status": 200,
1240
+ "shape": "object",
1241
+ "fields": [
1242
+ {
1243
+ "name": "ok",
1244
+ "type": "boolean",
1245
+ "required": false
1246
+ },
1247
+ {
1248
+ "name": "session",
1249
+ "type": "string",
1250
+ "required": false
1251
+ },
1252
+ {
1253
+ "name": "model",
1254
+ "type": "string",
1255
+ "required": false,
1256
+ "note": "the model this session will run on its NEXT turn: the session override when one is in force, else the global default"
1257
+ },
1258
+ {
1259
+ "name": "covered",
1260
+ "type": "boolean",
1261
+ "required": false,
1262
+ "note": "true = the session has its OWN session.json.model (so it does NOT follow the global default); false = the global default is in force"
1263
+ },
1264
+ {
1265
+ "name": "effective",
1266
+ "type": "object{model:string,base_model:string,source:'session'|'global',next_turn:true}",
1267
+ "required": false,
1268
+ "note": "base_model is the global default at answer time; source names where model came from; next_turn says when the switch takes effect."
1269
+ }
1270
+ ]
1271
+ },
1272
+ "errors": [
1273
+ {
1274
+ "status": 400,
1275
+ "error": "invalid model name '{v}': ..."
1276
+ },
1277
+ {
1278
+ "status": 404,
1279
+ "error": "unknown session '{id}'"
1280
+ },
1281
+ {
1282
+ "status": 409,
1283
+ "error": "turn 进行中,无法切换模型"
1284
+ },
1285
+ {
1286
+ "status": 422,
1287
+ "error": "field 'model' must be a string"
1288
+ },
1289
+ {
1290
+ "status": 500,
1291
+ "error": "meta write failed: {e}"
1292
+ }
1293
+ ],
1294
+ "notes": [
1295
+ "W870: rewrites session.json.model through the W729 writer (title / mode / prompt are kept), then drops THIS session's instance so its NEXT turn recomposes on the new model (W516 invalidateSession, the same hook PUT /api/sessions/{id}/tools and PUT /api/sessions/{id}/permission use). model:\"\" DELETES the key (K8: absent = no override).",
1296
+ "W870 product semantic: the statusline's model badge shows GET /api/status?session=, whose model is the SESSION instance's profile (global base + session.json.model, see runtime/session-compose.ts profileFor). The picker therefore targets THIS endpoint; POST /api/config still means the GLOBAL default and is what the settings page 通用配置 writes. Before W870 the picker wrote only the global default, so a session carrying an override bounced back to it on the next 2s status poll.",
1297
+ "The 409 guard is /compact's and POST /api/sessions/{id}/mode's, in semantics and wording: a switch inside a running turn would silently half-apply (this turn on the old model, the next on the new one).",
1298
+ "TS-only: the retired backend has no counterpart — see contracts/route-table.snapshot.json tsOnlyRoutes."
1299
+ ],
1300
+ "probe": {
1301
+ "checked": false,
1302
+ "reason": "TypeScript-only endpoint"
1303
+ }
1304
+ },
1305
+ {
1306
+ "id": "post_session_archive",
1307
+ "method": "POST",
1308
+ "path": "/api/sessions/{id}/archive",
1309
+ "group": "sessions",
1310
+ "rustHandler": "workspaces::post_session_archive",
1311
+ "docRef": "contracts/endpoints.json#post_session_archive",
1312
+ "request": {
1313
+ "kind": "none",
1314
+ "fields": []
1315
+ },
1316
+ "response": {
1317
+ "status": 200,
1318
+ "shape": "object",
1319
+ "fields": [
1320
+ {
1321
+ "name": "ok",
1322
+ "type": "boolean",
1323
+ "required": false
1324
+ }
1325
+ ]
1326
+ },
1327
+ "errors": [
1328
+ {
1329
+ "status": 404,
1330
+ "error": "unknown session '{id}'"
1331
+ },
1332
+ {
1333
+ "status": 409,
1334
+ "error": "session '{id}' is already archived"
1335
+ },
1336
+ {
1337
+ "status": 500,
1338
+ "error": "mkdir failed: {e}"
1339
+ },
1340
+ {
1341
+ "status": 500,
1342
+ "error": "move failed: {e}"
1343
+ }
1344
+ ],
1345
+ "notes": [
1346
+ "Moves to the canonical <CELESTEA_HOME>/workspaces/<ws>/archive/<name> keeping the original name (id stable, unarchivable; legacy <ws>/.celestea-archived/<name> still readable).",
1347
+ "W794: the ACTIVE session is archived like any other (no 400) — the engine half runs first (the session's in-flight model response is aborted and its instance released, see POST /api/sessions/batch-delete), and `active_session` is set to null so the marker can never name a session that left the default listing."
1348
+ ],
1349
+ "probe": {
1350
+ "checked": false,
1351
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1352
+ }
1353
+ },
1354
+ {
1355
+ "id": "post_session_unarchive",
1356
+ "method": "POST",
1357
+ "path": "/api/sessions/{id}/unarchive",
1358
+ "group": "sessions",
1359
+ "rustHandler": "workspaces::post_session_unarchive",
1360
+ "docRef": "contracts/endpoints.json#post_session_unarchive",
1361
+ "request": {
1362
+ "kind": "none",
1363
+ "fields": []
1364
+ },
1365
+ "response": {
1366
+ "status": 200,
1367
+ "shape": "object",
1368
+ "fields": [
1369
+ {
1370
+ "name": "ok",
1371
+ "type": "boolean",
1372
+ "required": false
1373
+ }
1374
+ ]
1375
+ },
1376
+ "errors": [
1377
+ {
1378
+ "status": 404,
1379
+ "error": "session '{id}' is not archived"
1380
+ },
1381
+ {
1382
+ "status": 409,
1383
+ "error": "a live session already exists at '{id}'"
1384
+ },
1385
+ {
1386
+ "status": 500,
1387
+ "error": "move failed: {e}"
1388
+ }
1389
+ ],
1390
+ "probe": {
1391
+ "checked": false,
1392
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1393
+ }
1394
+ },
1395
+ {
1396
+ "id": "post_sessions_batch_archive",
1397
+ "method": "POST",
1398
+ "path": "/api/sessions/batch-archive",
1399
+ "group": "sessions",
1400
+ "rustHandler": "workspaces::post_sessions_batch_archive",
1401
+ "docRef": "contracts/endpoints.json#post_sessions_batch_archive",
1402
+ "request": {
1403
+ "kind": "json",
1404
+ "fields": [
1405
+ {
1406
+ "name": "ids",
1407
+ "type": "array<string>",
1408
+ "required": true
1409
+ }
1410
+ ]
1411
+ },
1412
+ "response": {
1413
+ "status": 200,
1414
+ "shape": "object",
1415
+ "fields": [
1416
+ {
1417
+ "name": "ok",
1418
+ "type": "boolean",
1419
+ "required": false
1420
+ },
1421
+ {
1422
+ "name": "archived",
1423
+ "type": "integer",
1424
+ "required": false
1425
+ },
1426
+ {
1427
+ "name": "failed",
1428
+ "type": "array<{id:string,error:string}>",
1429
+ "required": false,
1430
+ "optional": true
1431
+ }
1432
+ ]
1433
+ },
1434
+ "errors": [],
1435
+ "notes": [
1436
+ "Always 200; per-item failures go into `failed`.",
1437
+ "Fields marked optional:true appear only in some response variants."
1438
+ ],
1439
+ "probe": {
1440
+ "checked": false,
1441
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1442
+ }
1443
+ },
1444
+ {
1445
+ "id": "post_sessions_batch_delete",
1446
+ "method": "POST",
1447
+ "path": "/api/sessions/batch-delete",
1448
+ "group": "sessions",
1449
+ "rustHandler": "workspaces::post_sessions_batch_delete",
1450
+ "docRef": "contracts/endpoints.json#post_sessions_batch_delete",
1451
+ "request": {
1452
+ "kind": "json",
1453
+ "fields": [
1454
+ {
1455
+ "name": "ids",
1456
+ "type": "array<string>",
1457
+ "required": true
1458
+ }
1459
+ ]
1460
+ },
1461
+ "response": {
1462
+ "status": 200,
1463
+ "shape": "object",
1464
+ "fields": [
1465
+ {
1466
+ "name": "ok",
1467
+ "type": "boolean",
1468
+ "required": false
1469
+ },
1470
+ {
1471
+ "name": "deleted",
1472
+ "type": "integer",
1473
+ "required": false
1474
+ },
1475
+ {
1476
+ "name": "failed",
1477
+ "type": "array<{id:string,error:string}>",
1478
+ "required": false,
1479
+ "optional": true
1480
+ }
1481
+ ]
1482
+ },
1483
+ "errors": [],
1484
+ "notes": [
1485
+ "Moves to the canonical <CELESTEA_HOME>/workspaces/<ws>/trash/<name>-<ts> (recoverable but no longer addressable by id; legacy <ws>/.celestea-trash/ still readable).",
1486
+ "W794: the ACTIVE session IS deletable (the active marker is a state label, not a protection). Before the directory moves, the session's in-flight model response is aborted through the cooperative cancel path and THAT session's engine instance is released; `active_session` then becomes null (persisted), so it can never keep pointing at the deleted id. Response shape unchanged: one 200 with per-id `failed[]`.",
1487
+ "Fields marked optional:true appear only in some response variants."
1488
+ ],
1489
+ "probe": {
1490
+ "checked": false,
1491
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1492
+ }
1493
+ },
1494
+ {
1495
+ "id": "get_workspaces",
1496
+ "method": "GET",
1497
+ "path": "/api/workspaces",
1498
+ "group": "workspaces",
1499
+ "rustHandler": "workspaces::get_workspaces",
1500
+ "docRef": "contracts/endpoints.json#get_workspaces",
1501
+ "request": {
1502
+ "kind": "none",
1503
+ "fields": []
1504
+ },
1505
+ "response": {
1506
+ "status": 200,
1507
+ "shape": "object",
1508
+ "fields": [
1509
+ {
1510
+ "name": "workspaces",
1511
+ "type": "array<{name:string,path:string,sessions:integer}>",
1512
+ "required": false,
1513
+ "note": "sessions counts LIVE session dirs only"
1514
+ },
1515
+ {
1516
+ "name": "active_session",
1517
+ "type": "string|null",
1518
+ "required": false
1519
+ }
1520
+ ]
1521
+ },
1522
+ "errors": [],
1523
+ "notes": [
1524
+ "No `ok` field. Always 200."
1525
+ ],
1526
+ "probe": {
1527
+ "checked": true,
1528
+ "mode": "read-only GET",
1529
+ "server": "http://127.0.0.1:3777"
1530
+ }
1531
+ },
1532
+ {
1533
+ "id": "post_workspaces",
1534
+ "method": "POST",
1535
+ "path": "/api/workspaces",
1536
+ "group": "workspaces",
1537
+ "rustHandler": "workspaces::post_workspace_create",
1538
+ "docRef": "contracts/endpoints.json#post_workspaces",
1539
+ "request": {
1540
+ "kind": "json",
1541
+ "fields": [
1542
+ {
1543
+ "name": "path",
1544
+ "type": "string",
1545
+ "required": true,
1546
+ "note": "absolute existing directory; legacy v1 `name` tolerated and ignored"
1547
+ }
1548
+ ]
1549
+ },
1550
+ "response": {
1551
+ "status": 200,
1552
+ "shape": "object",
1553
+ "fields": [
1554
+ {
1555
+ "name": "ok",
1556
+ "type": "boolean",
1557
+ "required": false
1558
+ },
1559
+ {
1560
+ "name": "name",
1561
+ "type": "string",
1562
+ "required": false,
1563
+ "note": "folder basename"
1564
+ }
1565
+ ]
1566
+ },
1567
+ "errors": [
1568
+ {
1569
+ "status": 400,
1570
+ "error": "path must not be empty"
1571
+ },
1572
+ {
1573
+ "status": 400,
1574
+ "error": "path '{path}' must be absolute"
1575
+ },
1576
+ {
1577
+ "status": 400,
1578
+ "error": "path '{path}' is not an existing directory"
1579
+ },
1580
+ {
1581
+ "status": 409,
1582
+ "error": "path '{path}' is already registered as workspace '{base}'"
1583
+ },
1584
+ {
1585
+ "status": 400,
1586
+ "error": "path '{path}' has no folder name"
1587
+ },
1588
+ {
1589
+ "status": 409,
1590
+ "error": "workspace '{base}' already exists (folder '{path}' and '{other}' share the same folder name; rename one folder first)"
1591
+ },
1592
+ {
1593
+ "status": 500,
1594
+ "error": "<persist error>"
1595
+ }
1596
+ ],
1597
+ "notes": [
1598
+ "Registers only; never creates/modifies the directory."
1599
+ ],
1600
+ "probe": {
1601
+ "checked": false,
1602
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1603
+ }
1604
+ },
1605
+ {
1606
+ "id": "post_workspace_rename",
1607
+ "method": "POST",
1608
+ "path": "/api/workspaces/{name}/rename",
1609
+ "group": "workspaces",
1610
+ "rustHandler": "workspaces::post_workspace_rename",
1611
+ "docRef": "contracts/endpoints.json#post_workspace_rename",
1612
+ "request": {
1613
+ "kind": "json",
1614
+ "fields": [
1615
+ {
1616
+ "name": "new_name",
1617
+ "type": "string",
1618
+ "required": true
1619
+ }
1620
+ ]
1621
+ },
1622
+ "response": {
1623
+ "status": 200,
1624
+ "shape": "object",
1625
+ "fields": [
1626
+ {
1627
+ "name": "workspaces",
1628
+ "type": "array<{name,path,sessions}>",
1629
+ "required": false
1630
+ },
1631
+ {
1632
+ "name": "active_session",
1633
+ "type": "string|null",
1634
+ "required": false
1635
+ }
1636
+ ]
1637
+ },
1638
+ "errors": [
1639
+ {
1640
+ "status": 409,
1641
+ "error": "turn in progress; rename applies between turns"
1642
+ },
1643
+ {
1644
+ "status": 400,
1645
+ "error": "workspace name must not be empty"
1646
+ },
1647
+ {
1648
+ "status": 400,
1649
+ "error": "invalid workspace name '{name}'"
1650
+ },
1651
+ {
1652
+ "status": 404,
1653
+ "error": "unknown workspace '{old}'"
1654
+ },
1655
+ {
1656
+ "status": 409,
1657
+ "error": "workspace '{new_name}' already exists"
1658
+ },
1659
+ {
1660
+ "status": 409,
1661
+ "error": "target '{new_path}' already exists; rename the folder first"
1662
+ },
1663
+ {
1664
+ "status": 500,
1665
+ "error": "move failed: {e}"
1666
+ },
1667
+ {
1668
+ "status": 500,
1669
+ "error": "cannot derive the moved session dir"
1670
+ },
1671
+ {
1672
+ "status": 500,
1673
+ "error": "compose failed: {e}"
1674
+ },
1675
+ {
1676
+ "status": 500,
1677
+ "error": "cannot persist active session: {e}"
1678
+ }
1679
+ ],
1680
+ "notes": [
1681
+ "Really fs::rename()s the user folder. Returns the full registry view."
1682
+ ],
1683
+ "probe": {
1684
+ "checked": false,
1685
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1686
+ }
1687
+ },
1688
+ {
1689
+ "id": "post_workspace_delete",
1690
+ "method": "POST",
1691
+ "path": "/api/workspaces/{name}/delete",
1692
+ "group": "workspaces",
1693
+ "rustHandler": "workspaces::post_workspace_delete",
1694
+ "docRef": "contracts/endpoints.json#post_workspace_delete",
1695
+ "request": {
1696
+ "kind": "none",
1697
+ "fields": []
1698
+ },
1699
+ "response": {
1700
+ "status": 200,
1701
+ "shape": "object",
1702
+ "fields": [
1703
+ {
1704
+ "name": "ok",
1705
+ "type": "boolean",
1706
+ "required": false
1707
+ }
1708
+ ]
1709
+ },
1710
+ "errors": [
1711
+ {
1712
+ "status": 404,
1713
+ "error": "unknown workspace '{name}'"
1714
+ },
1715
+ {
1716
+ "status": 500,
1717
+ "error": "<persist error>"
1718
+ }
1719
+ ],
1720
+ "notes": [
1721
+ "Unregisters only; never touches the user folder. Clears active_session if it belonged to this ws."
1722
+ ],
1723
+ "probe": {
1724
+ "checked": false,
1725
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1726
+ }
1727
+ },
1728
+ {
1729
+ "id": "post_workspaces_batch_delete",
1730
+ "method": "POST",
1731
+ "path": "/api/workspaces/batch-delete",
1732
+ "group": "workspaces",
1733
+ "rustHandler": "workspaces::post_workspaces_batch_delete",
1734
+ "docRef": "contracts/endpoints.json#post_workspaces_batch_delete",
1735
+ "request": {
1736
+ "kind": "json",
1737
+ "fields": [
1738
+ {
1739
+ "name": "names",
1740
+ "type": "array<string>",
1741
+ "required": true
1742
+ }
1743
+ ]
1744
+ },
1745
+ "response": {
1746
+ "status": 200,
1747
+ "shape": "object",
1748
+ "fields": [
1749
+ {
1750
+ "name": "ok",
1751
+ "type": "boolean",
1752
+ "required": false
1753
+ },
1754
+ {
1755
+ "name": "deleted",
1756
+ "type": "integer",
1757
+ "required": false
1758
+ },
1759
+ {
1760
+ "name": "failed",
1761
+ "type": "array<{name:string,error:string}>",
1762
+ "required": false,
1763
+ "optional": true
1764
+ }
1765
+ ]
1766
+ },
1767
+ "errors": [],
1768
+ "notes": [
1769
+ "Always 200.",
1770
+ "Fields marked optional:true appear only in some response variants."
1771
+ ],
1772
+ "probe": {
1773
+ "checked": false,
1774
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
1775
+ }
1776
+ },
1777
+ {
1778
+ "id": "get_fs_browse",
1779
+ "method": "GET",
1780
+ "path": "/api/fs/browse",
1781
+ "group": "fs",
1782
+ "rustHandler": "workspaces::get_fs_browse",
1783
+ "docRef": "contracts/endpoints.json#get_fs_browse",
1784
+ "request": {
1785
+ "kind": "query",
1786
+ "fields": [
1787
+ {
1788
+ "name": "path",
1789
+ "type": "string",
1790
+ "required": false,
1791
+ "note": "absolute path; omitted/blank lists '/'"
1792
+ }
1793
+ ]
1794
+ },
1795
+ "response": {
1796
+ "status": 200,
1797
+ "shape": "object",
1798
+ "fields": [
1799
+ {
1800
+ "name": "path",
1801
+ "type": "string",
1802
+ "required": false
1803
+ },
1804
+ {
1805
+ "name": "parent",
1806
+ "type": "string|null",
1807
+ "required": false
1808
+ },
1809
+ {
1810
+ "name": "dirs",
1811
+ "type": "array<string>",
1812
+ "required": false
1813
+ },
1814
+ {
1815
+ "name": "roots",
1816
+ "type": "array<string>",
1817
+ "required": false,
1818
+ "note": "hard-coded [\"/src\",\"/tmp\",\"/srv\",\"/home\"]; informational only"
1819
+ }
1820
+ ]
1821
+ },
1822
+ "errors": [
1823
+ {
1824
+ "status": 400,
1825
+ "error": "path '{p}' must be absolute"
1826
+ },
1827
+ {
1828
+ "status": 400,
1829
+ "error": "path '{p}' is not an existing directory"
1830
+ },
1831
+ {
1832
+ "status": 400,
1833
+ "error": "cannot read '{p}': {e}"
1834
+ }
1835
+ ],
1836
+ "notes": [
1837
+ "Error responses keep the SAME shape plus `error` (not the {ok:false} envelope). Directories only, dot-dirs skipped, symlinks not followed, sorted then truncated at MAX_DIR_ENTRIES=200. No auth; loopback-bound only."
1838
+ ],
1839
+ "probe": {
1840
+ "checked": true,
1841
+ "mode": "read-only error-branch probe",
1842
+ "server": "http://127.0.0.1:3777"
1843
+ }
1844
+ },
1845
+ {
1846
+ "id": "get_fs_list",
1847
+ "method": "GET",
1848
+ "path": "/api/fs/list",
1849
+ "group": "fs",
1850
+ "rustHandler": "(none - TypeScript-only, G5)",
1851
+ "docRef": "contracts/endpoints.json#get_fs_list",
1852
+ "request": {
1853
+ "kind": "query",
1854
+ "fields": [
1855
+ {
1856
+ "name": "path",
1857
+ "type": "string",
1858
+ "required": false,
1859
+ "note": "absolute path; omitted/blank lists '/'"
1860
+ }
1861
+ ]
1862
+ },
1863
+ "response": {
1864
+ "status": 200,
1865
+ "shape": "object",
1866
+ "fields": [
1867
+ {
1868
+ "name": "path",
1869
+ "type": "string",
1870
+ "required": false
1871
+ },
1872
+ {
1873
+ "name": "parent",
1874
+ "type": "string|null",
1875
+ "required": false
1876
+ },
1877
+ {
1878
+ "name": "entries",
1879
+ "type": "array<{name:string,type:string,size:number|null,mtime:string|null}>",
1880
+ "required": false,
1881
+ "note": "directories first then files, each by name; type is 'dir'|'file' (symlinks not followed); mtime is ISO-8601"
1882
+ },
1883
+ {
1884
+ "name": "roots",
1885
+ "type": "array<string>",
1886
+ "required": false,
1887
+ "note": "hard-coded [/src,/tmp,/srv,/home]; informational only"
1888
+ },
1889
+ {
1890
+ "name": "truncated",
1891
+ "type": "boolean",
1892
+ "required": false,
1893
+ "note": "true when entries were cut at MAX_DIR_ENTRIES=200"
1894
+ }
1895
+ ]
1896
+ },
1897
+ "errors": [
1898
+ {
1899
+ "status": 400,
1900
+ "error": "path '{p}' must be absolute"
1901
+ },
1902
+ {
1903
+ "status": 400,
1904
+ "error": "path '{p}' is not an existing directory"
1905
+ },
1906
+ {
1907
+ "status": 400,
1908
+ "error": "cannot read '{p}': {e}"
1909
+ }
1910
+ ],
1911
+ "notes": [
1912
+ "G5 (docs/iteration-g-workbench.md §0.1): directories AND files with name/type/size/mtime. Same discipline as get_fs_browse: absolute path, dot-names hidden, symlinks not followed, sorted (dirs first), capped at MAX_DIR_ENTRIES. Additive 'roots'/'truncated' fields. No auth; loopback-bound only."
1913
+ ],
1914
+ "probe": {
1915
+ "checked": false,
1916
+ "reason": "TypeScript-only endpoint"
1917
+ }
1918
+ },
1919
+ {
1920
+ "id": "get_fs_read",
1921
+ "method": "GET",
1922
+ "path": "/api/fs/read",
1923
+ "group": "fs",
1924
+ "rustHandler": "(none - TypeScript-only, G5)",
1925
+ "docRef": "contracts/endpoints.json#get_fs_read",
1926
+ "request": {
1927
+ "kind": "query",
1928
+ "fields": [
1929
+ {
1930
+ "name": "path",
1931
+ "type": "string",
1932
+ "required": true,
1933
+ "note": "absolute path of the file to read"
1934
+ },
1935
+ {
1936
+ "name": "offset",
1937
+ "type": "integer",
1938
+ "required": false,
1939
+ "note": "1-based first line (default 1)"
1940
+ },
1941
+ {
1942
+ "name": "limit",
1943
+ "type": "integer",
1944
+ "required": false,
1945
+ "note": "line budget (default 2000)"
1946
+ }
1947
+ ]
1948
+ },
1949
+ "response": {
1950
+ "status": 200,
1951
+ "shape": "object",
1952
+ "fields": [
1953
+ {
1954
+ "name": "path",
1955
+ "type": "string",
1956
+ "required": false
1957
+ },
1958
+ {
1959
+ "name": "size",
1960
+ "type": "integer",
1961
+ "required": false
1962
+ },
1963
+ {
1964
+ "name": "kind",
1965
+ "type": "string",
1966
+ "required": false,
1967
+ "note": "text | binary; binary returns no text"
1968
+ },
1969
+ {
1970
+ "name": "text",
1971
+ "type": "string",
1972
+ "required": false,
1973
+ "note": "the line window; empty for binary"
1974
+ },
1975
+ {
1976
+ "name": "offset",
1977
+ "type": "integer",
1978
+ "required": false,
1979
+ "note": "1-based first line of the window"
1980
+ },
1981
+ {
1982
+ "name": "limit",
1983
+ "type": "integer",
1984
+ "required": false
1985
+ },
1986
+ {
1987
+ "name": "totalLines",
1988
+ "type": "integer",
1989
+ "required": false,
1990
+ "note": "0 for binary"
1991
+ },
1992
+ {
1993
+ "name": "truncated",
1994
+ "type": "boolean",
1995
+ "required": false,
1996
+ "note": "true when the response is a window (more lines and/or the 256 KiB budget)"
1997
+ }
1998
+ ]
1999
+ },
2000
+ "errors": [
2001
+ {
2002
+ "status": 400,
2003
+ "error": "query path is required"
2004
+ },
2005
+ {
2006
+ "status": 400,
2007
+ "error": "path must be absolute"
2008
+ },
2009
+ {
2010
+ "status": 400,
2011
+ "error": "path does not exist"
2012
+ },
2013
+ {
2014
+ "status": 400,
2015
+ "error": "path is a directory, not a file"
2016
+ },
2017
+ {
2018
+ "status": 400,
2019
+ "error": "path is a symbolic link; links are not followed"
2020
+ },
2021
+ {
2022
+ "status": 400,
2023
+ "error": "query offset/limit must be a positive integer"
2024
+ }
2025
+ ],
2026
+ "notes": [
2027
+ "G5 follow-up (the file manager viewer): read-only, absolute path, a directory is an error, symlinks are not followed. Binary detection and the 256 KiB byte budget / offset+limit line pagination follow the read_file TOOL (BINARY_SNIFF_BYTES, MAX_READ_BYTES, DEFAULT_READ_LIMIT); kind=binary returns no text; truncated is explicit. Same trust boundary as get_fs_list: no auth of its own, loopback by default, a non-loopback bind is refused unless a token is configured."
2028
+ ],
2029
+ "probe": {
2030
+ "checked": false,
2031
+ "reason": "TypeScript-only endpoint"
2032
+ }
2033
+ },
2034
+ {
2035
+ "id": "get_providers",
2036
+ "method": "GET",
2037
+ "path": "/api/providers",
2038
+ "group": "providers",
2039
+ "rustHandler": "providers::get_providers",
2040
+ "docRef": "contracts/endpoints.json#get_providers",
2041
+ "request": {
2042
+ "kind": "none",
2043
+ "fields": []
2044
+ },
2045
+ "response": {
2046
+ "status": 200,
2047
+ "shape": "object",
2048
+ "fields": [
2049
+ {
2050
+ "name": "providers",
2051
+ "type": "array<provider public_view>",
2052
+ "required": false
2053
+ },
2054
+ {
2055
+ "name": "default_model",
2056
+ "type": "string|null",
2057
+ "required": false
2058
+ }
2059
+ ],
2060
+ "publicView": {
2061
+ "excluded": [
2062
+ "api_key"
2063
+ ],
2064
+ "note": "the api_key KEY IS ABSENT (not null); has_key=api_key non-empty (untrimmed)"
2065
+ }
2066
+ },
2067
+ "errors": [],
2068
+ "notes": [
2069
+ "Always 200."
2070
+ ],
2071
+ "probe": {
2072
+ "checked": true,
2073
+ "mode": "read-only GET",
2074
+ "server": "http://127.0.0.1:3777"
2075
+ }
2076
+ },
2077
+ {
2078
+ "id": "post_providers",
2079
+ "method": "POST",
2080
+ "path": "/api/providers",
2081
+ "group": "providers",
2082
+ "rustHandler": "providers::post_providers",
2083
+ "docRef": "contracts/endpoints.json#post_providers",
2084
+ "request": {
2085
+ "kind": "json",
2086
+ "fields": [
2087
+ {
2088
+ "name": "id",
2089
+ "type": "string",
2090
+ "required": true,
2091
+ "note": "identity; same id = overwrite"
2092
+ },
2093
+ {
2094
+ "name": "name",
2095
+ "type": "string",
2096
+ "required": false,
2097
+ "note": "defaults to id"
2098
+ },
2099
+ {
2100
+ "name": "note",
2101
+ "type": "string",
2102
+ "required": false,
2103
+ "note": "defaults to '' (cleared)"
2104
+ },
2105
+ {
2106
+ "name": "base_url",
2107
+ "type": "string",
2108
+ "required": true
2109
+ },
2110
+ {
2111
+ "name": "request_format",
2112
+ "type": "string",
2113
+ "required": false,
2114
+ "note": "chat_completions|responses|anthropic_messages; defaults to chat_completions (silently resets a stored anthropic_messages)"
2115
+ },
2116
+ {
2117
+ "name": "api_key",
2118
+ "type": "string",
2119
+ "required": false,
2120
+ "note": "absent/null/blank = KEEP the stored key (the only keep-on-default field)"
2121
+ },
2122
+ {
2123
+ "name": "models",
2124
+ "type": "array<{id:string,name?:string,reasoning_efforts?:string[],context_window?:number,max_output_tokens?:number}>",
2125
+ "required": false,
2126
+ "note": "absent = CLEAR the list"
2127
+ }
2128
+ ]
2129
+ },
2130
+ "response": {
2131
+ "status": 200,
2132
+ "shape": "object",
2133
+ "fields": [
2134
+ {
2135
+ "name": "(provider public_view)",
2136
+ "type": "object",
2137
+ "required": false
2138
+ }
2139
+ ]
2140
+ },
2141
+ "errors": [
2142
+ {
2143
+ "status": 400,
2144
+ "error": "provider id must not be empty"
2145
+ },
2146
+ {
2147
+ "status": 400,
2148
+ "error": "base_url is required"
2149
+ },
2150
+ {
2151
+ "status": 400,
2152
+ "error": "base_url must be an http:// or https:// URL"
2153
+ },
2154
+ {
2155
+ "status": 400,
2156
+ "error": "invalid request_format '{t}': expected chat_completions | responses | anthropic_messages"
2157
+ },
2158
+ {
2159
+ "status": 400,
2160
+ "error": "each model needs a non-empty id"
2161
+ },
2162
+ {
2163
+ "status": 500,
2164
+ "error": "providers serialize failed: {e}"
2165
+ }
2166
+ ],
2167
+ "notes": [
2168
+ "Response is the whole public_view, not {ok:true}. An empty model row fails the whole request."
2169
+ ],
2170
+ "probe": {
2171
+ "checked": false,
2172
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2173
+ }
2174
+ },
2175
+ {
2176
+ "id": "post_provider_delete",
2177
+ "method": "POST",
2178
+ "path": "/api/providers/{id}/delete",
2179
+ "group": "providers",
2180
+ "rustHandler": "providers::post_provider_delete",
2181
+ "docRef": "contracts/endpoints.json#post_provider_delete",
2182
+ "request": {
2183
+ "kind": "none",
2184
+ "fields": []
2185
+ },
2186
+ "response": {
2187
+ "status": 200,
2188
+ "shape": "object",
2189
+ "fields": [
2190
+ {
2191
+ "name": "ok",
2192
+ "type": "boolean",
2193
+ "required": false
2194
+ }
2195
+ ]
2196
+ },
2197
+ "errors": [
2198
+ {
2199
+ "status": 404,
2200
+ "error": "unknown provider '{id}'"
2201
+ },
2202
+ {
2203
+ "status": 500,
2204
+ "error": "<persist error>"
2205
+ }
2206
+ ],
2207
+ "notes": [
2208
+ "Uses the UNTRIMMED raw path value. Clears default_model when it is no longer listed by any live provider."
2209
+ ],
2210
+ "probe": {
2211
+ "checked": false,
2212
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2213
+ }
2214
+ },
2215
+ {
2216
+ "id": "post_provider_test",
2217
+ "method": "POST",
2218
+ "path": "/api/providers/test",
2219
+ "group": "providers",
2220
+ "rustHandler": "providers::post_provider_test",
2221
+ "docRef": "contracts/endpoints.json#post_provider_test",
2222
+ "request": {
2223
+ "kind": "json",
2224
+ "fields": [
2225
+ {
2226
+ "name": "id",
2227
+ "type": "string",
2228
+ "required": false
2229
+ },
2230
+ {
2231
+ "name": "name",
2232
+ "type": "string",
2233
+ "required": false
2234
+ },
2235
+ {
2236
+ "name": "note",
2237
+ "type": "string",
2238
+ "required": false
2239
+ },
2240
+ {
2241
+ "name": "base_url",
2242
+ "type": "string",
2243
+ "required": false
2244
+ },
2245
+ {
2246
+ "name": "request_format",
2247
+ "type": "string",
2248
+ "required": false
2249
+ },
2250
+ {
2251
+ "name": "api_key",
2252
+ "type": "string",
2253
+ "required": false
2254
+ },
2255
+ {
2256
+ "name": "models",
2257
+ "type": "array<object>",
2258
+ "required": false
2259
+ }
2260
+ ],
2261
+ "note": "all optional; inline overlay, NEVER persisted; missing fields resolved from the store by id; id absent and no store record -> label '__inline__'"
2262
+ },
2263
+ "response": {
2264
+ "status": 200,
2265
+ "shape": "object",
2266
+ "fields": [
2267
+ {
2268
+ "name": "ok",
2269
+ "type": "boolean",
2270
+ "required": false
2271
+ },
2272
+ {
2273
+ "name": "latency_ms",
2274
+ "type": "integer",
2275
+ "required": false,
2276
+ "optional": true
2277
+ },
2278
+ {
2279
+ "name": "model_count",
2280
+ "type": "integer",
2281
+ "required": false,
2282
+ "optional": true
2283
+ },
2284
+ {
2285
+ "name": "error",
2286
+ "type": "string",
2287
+ "required": false,
2288
+ "optional": true
2289
+ }
2290
+ ]
2291
+ },
2292
+ "errors": [
2293
+ {
2294
+ "status": 400,
2295
+ "error": "<candidate error text>"
2296
+ },
2297
+ {
2298
+ "status": 200,
2299
+ "error": "该请求格式暂不支持自动测试"
2300
+ },
2301
+ {
2302
+ "status": 200,
2303
+ "error": "该提供商未配置 api_key"
2304
+ },
2305
+ {
2306
+ "status": 200,
2307
+ "error": "HTTP 401: {...}"
2308
+ }
2309
+ ],
2310
+ "notes": [
2311
+ "Never persists.",
2312
+ "Fields marked optional:true appear only in some response variants."
2313
+ ],
2314
+ "probe": {
2315
+ "checked": false,
2316
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2317
+ }
2318
+ },
2319
+ {
2320
+ "id": "post_provider_models_fetch",
2321
+ "method": "POST",
2322
+ "path": "/api/providers/{id}/models/fetch",
2323
+ "group": "providers",
2324
+ "rustHandler": "providers::post_models_fetch",
2325
+ "docRef": "contracts/endpoints.json#post_provider_models_fetch",
2326
+ "request": {
2327
+ "kind": "none",
2328
+ "fields": []
2329
+ },
2330
+ "response": {
2331
+ "status": 200,
2332
+ "shape": "object",
2333
+ "fields": [
2334
+ {
2335
+ "name": "ok",
2336
+ "type": "boolean",
2337
+ "required": false
2338
+ },
2339
+ {
2340
+ "name": "models",
2341
+ "type": "array<{id:string}>",
2342
+ "required": false,
2343
+ "optional": true
2344
+ },
2345
+ {
2346
+ "name": "error",
2347
+ "type": "string",
2348
+ "required": false,
2349
+ "optional": true
2350
+ }
2351
+ ]
2352
+ },
2353
+ "errors": [
2354
+ {
2355
+ "status": 404,
2356
+ "error": "unknown provider '{id}'"
2357
+ },
2358
+ {
2359
+ "status": 200,
2360
+ "error": "该请求格式暂不支持自动测试"
2361
+ },
2362
+ {
2363
+ "status": 200,
2364
+ "error": "该提供商未配置 api_key"
2365
+ },
2366
+ {
2367
+ "status": 200,
2368
+ "error": "<probe error text>"
2369
+ }
2370
+ ],
2371
+ "notes": [
2372
+ "Keyless same-origin borrow: when the provider has no key and its normalized base_url equals the CURRENT generation's base_url, the engine's own key is borrowed for this request only (never persisted/logged/echoed). Response has no latency_ms.",
2373
+ "Fields marked optional:true appear only in some response variants."
2374
+ ],
2375
+ "probe": {
2376
+ "checked": false,
2377
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2378
+ }
2379
+ },
2380
+ {
2381
+ "id": "post_provider_default",
2382
+ "method": "POST",
2383
+ "path": "/api/providers/default",
2384
+ "group": "providers",
2385
+ "rustHandler": "providers::post_provider_default",
2386
+ "docRef": "contracts/endpoints.json#post_provider_default",
2387
+ "request": {
2388
+ "kind": "json",
2389
+ "fields": [
2390
+ {
2391
+ "name": "model",
2392
+ "type": "string",
2393
+ "required": true
2394
+ },
2395
+ {
2396
+ "name": "provider_id",
2397
+ "type": "string",
2398
+ "required": false,
2399
+ "note": "W750: optional provider disambiguator; when given, `model` must be one of that provider's models"
2400
+ }
2401
+ ],
2402
+ "note": "W750: `provider_id` selects WHICH provider serves the model — model ids are not unique across providers, so without it the first provider listing the id wins (unchanged legacy behaviour)."
2403
+ },
2404
+ "response": {
2405
+ "status": 200,
2406
+ "shape": "object",
2407
+ "fields": [
2408
+ {
2409
+ "name": "(provider public_view)",
2410
+ "type": "object",
2411
+ "required": false
2412
+ }
2413
+ ]
2414
+ },
2415
+ "errors": [
2416
+ {
2417
+ "status": 400,
2418
+ "error": "model must not be empty"
2419
+ },
2420
+ {
2421
+ "status": 400,
2422
+ "error": "provider '{provider_id}' does not list model '{model}'"
2423
+ },
2424
+ {
2425
+ "status": 404,
2426
+ "error": "unknown provider '{provider_id}'"
2427
+ },
2428
+ {
2429
+ "status": 409,
2430
+ "error": "a turn is running; provider default applies between turns"
2431
+ },
2432
+ {
2433
+ "status": 500,
2434
+ "error": "<compose error text>"
2435
+ },
2436
+ {
2437
+ "status": 500,
2438
+ "error": "<set_default_model persist error>"
2439
+ }
2440
+ ],
2441
+ "notes": [
2442
+ "compose failure never persists; persist failure never swaps. Unknown model ids are applied and stored as-is.",
2443
+ "W750: a rejected provider_id (unknown provider / model not listed by it) is refused BEFORE compose, so nothing is applied and nothing is persisted."
2444
+ ],
2445
+ "probe": {
2446
+ "checked": false,
2447
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2448
+ }
2449
+ },
2450
+ {
2451
+ "id": "get_prompts",
2452
+ "method": "GET",
2453
+ "path": "/api/prompts",
2454
+ "group": "prompts",
2455
+ "rustHandler": "prompts::get_prompts",
2456
+ "docRef": "contracts/endpoints.json#get_prompts",
2457
+ "request": {
2458
+ "kind": "query",
2459
+ "fields": [
2460
+ {
2461
+ "name": "workspace",
2462
+ "type": "string",
2463
+ "required": false,
2464
+ "note": "omitted/blank = global scope"
2465
+ }
2466
+ ]
2467
+ },
2468
+ "response": {
2469
+ "status": 200,
2470
+ "shape": "object",
2471
+ "fields": [
2472
+ {
2473
+ "name": "ok",
2474
+ "type": "boolean",
2475
+ "required": false
2476
+ },
2477
+ {
2478
+ "name": "scope",
2479
+ "type": "'global'|'workspace'",
2480
+ "required": false
2481
+ },
2482
+ {
2483
+ "name": "workspace",
2484
+ "type": "string|null",
2485
+ "required": false
2486
+ },
2487
+ {
2488
+ "name": "global_file",
2489
+ "type": "string",
2490
+ "required": false
2491
+ },
2492
+ {
2493
+ "name": "sections",
2494
+ "type": "array<{id,name,template,order,source:'builtin'|'global'|'workspace'}>",
2495
+ "required": false
2496
+ },
2497
+ {
2498
+ "name": "prompts",
2499
+ "type": "array<{id,name,section_overrides,is_default,scope,shadowed}>",
2500
+ "required": false
2501
+ },
2502
+ {
2503
+ "name": "default_prompt",
2504
+ "type": "object{id,scope}|null",
2505
+ "required": false
2506
+ },
2507
+ {
2508
+ "name": "active_prompt",
2509
+ "type": "string|null",
2510
+ "required": false
2511
+ }
2512
+ ]
2513
+ },
2514
+ "errors": [
2515
+ {
2516
+ "status": 404,
2517
+ "error": "unknown workspace '{name}'"
2518
+ }
2519
+ ],
2520
+ "notes": [
2521
+ "sections sorted by (order,id); workspace prompt with the same name sets the global entry's shadowed:true."
2522
+ ],
2523
+ "probe": {
2524
+ "checked": true,
2525
+ "mode": "read-only GET",
2526
+ "server": "http://127.0.0.1:3777"
2527
+ }
2528
+ },
2529
+ {
2530
+ "id": "post_prompts",
2531
+ "method": "POST",
2532
+ "path": "/api/prompts",
2533
+ "group": "prompts",
2534
+ "rustHandler": "prompts::post_prompts_upsert",
2535
+ "docRef": "contracts/endpoints.json#post_prompts",
2536
+ "request": {
2537
+ "kind": "json",
2538
+ "fields": [
2539
+ {
2540
+ "name": "workspace",
2541
+ "type": "string",
2542
+ "required": false,
2543
+ "note": "omitted = global"
2544
+ },
2545
+ {
2546
+ "name": "id",
2547
+ "type": "string",
2548
+ "required": true,
2549
+ "note": "1-128 chars of [A-Za-z0-9._-]"
2550
+ },
2551
+ {
2552
+ "name": "name",
2553
+ "type": "string",
2554
+ "required": true
2555
+ },
2556
+ {
2557
+ "name": "section_overrides",
2558
+ "type": "object<string,string>",
2559
+ "required": false
2560
+ },
2561
+ {
2562
+ "name": "is_default",
2563
+ "type": "boolean",
2564
+ "required": false,
2565
+ "note": "absent keeps the old value (false when new)"
2566
+ }
2567
+ ]
2568
+ },
2569
+ "response": {
2570
+ "status": 200,
2571
+ "shape": "object",
2572
+ "fields": [
2573
+ {
2574
+ "name": "ok",
2575
+ "type": "boolean",
2576
+ "required": false
2577
+ },
2578
+ {
2579
+ "name": "id",
2580
+ "type": "string",
2581
+ "required": false
2582
+ },
2583
+ {
2584
+ "name": "scope",
2585
+ "type": "'global'|'workspace'",
2586
+ "required": false
2587
+ },
2588
+ {
2589
+ "name": "hot_applied",
2590
+ "type": "boolean",
2591
+ "required": false
2592
+ }
2593
+ ]
2594
+ },
2595
+ "errors": [
2596
+ {
2597
+ "status": 400,
2598
+ "error": "prompt id must be 1-128 chars of [A-Za-z0-9._-]"
2599
+ },
2600
+ {
2601
+ "status": 400,
2602
+ "error": "section '{section_id}': template exceeds the 8192 byte cap ({n} bytes)"
2603
+ },
2604
+ {
2605
+ "status": 400,
2606
+ "error": "section '{section_id}': undefined prompt variable '{{name}}'"
2607
+ },
2608
+ {
2609
+ "status": 400,
2610
+ "error": "section '{section_id}': unclosed '{{' in template"
2611
+ },
2612
+ {
2613
+ "status": 404,
2614
+ "error": "unknown workspace '{name}'"
2615
+ },
2616
+ {
2617
+ "status": 409,
2618
+ "error": "turn in progress; prompt applies between turns"
2619
+ },
2620
+ {
2621
+ "status": 500,
2622
+ "error": "<persist error text>"
2623
+ },
2624
+ {
2625
+ "status": 500,
2626
+ "error": "compose failed: {e}"
2627
+ }
2628
+ ],
2629
+ "notes": [
2630
+ "Order: 409 check (no write) -> persist -> compose_and_swap -> on compose failure write the old file back."
2631
+ ],
2632
+ "probe": {
2633
+ "checked": false,
2634
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2635
+ }
2636
+ },
2637
+ {
2638
+ "id": "post_prompts_delete",
2639
+ "method": "POST",
2640
+ "path": "/api/prompts/{id}/delete",
2641
+ "group": "prompts",
2642
+ "rustHandler": "prompts::post_prompts_delete",
2643
+ "docRef": "contracts/endpoints.json#post_prompts_delete",
2644
+ "request": {
2645
+ "kind": "json",
2646
+ "fields": [
2647
+ {
2648
+ "name": "workspace",
2649
+ "type": "string",
2650
+ "required": false,
2651
+ "note": "all fields optional; body extractor required"
2652
+ }
2653
+ ]
2654
+ },
2655
+ "response": {
2656
+ "status": 200,
2657
+ "shape": "object",
2658
+ "fields": [
2659
+ {
2660
+ "name": "ok",
2661
+ "type": "boolean",
2662
+ "required": false
2663
+ },
2664
+ {
2665
+ "name": "scope",
2666
+ "type": "'global'|'workspace'",
2667
+ "required": false
2668
+ },
2669
+ {
2670
+ "name": "hot_applied",
2671
+ "type": "boolean",
2672
+ "required": false
2673
+ }
2674
+ ]
2675
+ },
2676
+ "errors": [
2677
+ {
2678
+ "status": 404,
2679
+ "error": "unknown prompt '{id}'"
2680
+ },
2681
+ {
2682
+ "status": 404,
2683
+ "error": "unknown workspace '{name}'"
2684
+ },
2685
+ {
2686
+ "status": 409,
2687
+ "error": "turn in progress; prompt applies between turns"
2688
+ },
2689
+ {
2690
+ "status": 500,
2691
+ "error": "<persist/compose error text>"
2692
+ }
2693
+ ],
2694
+ "notes": [
2695
+ "Uses the UNTRIMMED raw path value. Clears default_prompt when it pointed here."
2696
+ ],
2697
+ "probe": {
2698
+ "checked": false,
2699
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2700
+ }
2701
+ },
2702
+ {
2703
+ "id": "post_prompts_default",
2704
+ "method": "POST",
2705
+ "path": "/api/prompts/{id}/default",
2706
+ "group": "prompts",
2707
+ "rustHandler": "prompts::post_prompts_default",
2708
+ "docRef": "contracts/endpoints.json#post_prompts_default",
2709
+ "request": {
2710
+ "kind": "json",
2711
+ "fields": [
2712
+ {
2713
+ "name": "workspace",
2714
+ "type": "string",
2715
+ "required": false
2716
+ }
2717
+ ]
2718
+ },
2719
+ "response": {
2720
+ "status": 200,
2721
+ "shape": "object",
2722
+ "fields": [
2723
+ {
2724
+ "name": "ok",
2725
+ "type": "boolean",
2726
+ "required": false
2727
+ },
2728
+ {
2729
+ "name": "default_prompt",
2730
+ "type": "string",
2731
+ "required": false
2732
+ },
2733
+ {
2734
+ "name": "scope",
2735
+ "type": "'global'|'workspace'",
2736
+ "required": false
2737
+ },
2738
+ {
2739
+ "name": "hot_applied",
2740
+ "type": "boolean",
2741
+ "required": false
2742
+ }
2743
+ ]
2744
+ },
2745
+ "errors": [
2746
+ {
2747
+ "status": 404,
2748
+ "error": "unknown prompt '{id}'"
2749
+ },
2750
+ {
2751
+ "status": 404,
2752
+ "error": "unknown workspace '{name}'"
2753
+ },
2754
+ {
2755
+ "status": 409,
2756
+ "error": "turn in progress; prompt applies between turns"
2757
+ },
2758
+ {
2759
+ "status": 500,
2760
+ "error": "<persist/compose error text>"
2761
+ }
2762
+ ],
2763
+ "probe": {
2764
+ "checked": false,
2765
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2766
+ }
2767
+ },
2768
+ {
2769
+ "id": "post_worker_spawn",
2770
+ "method": "POST",
2771
+ "path": "/api/worker/spawn",
2772
+ "group": "workers",
2773
+ "rustHandler": "api::post_worker_spawn",
2774
+ "docRef": "contracts/endpoints.json#post_worker_spawn",
2775
+ "request": {
2776
+ "kind": "json",
2777
+ "fields": [
2778
+ {
2779
+ "name": "wid",
2780
+ "type": "string",
2781
+ "required": true
2782
+ },
2783
+ {
2784
+ "name": "brief",
2785
+ "type": "string",
2786
+ "required": true
2787
+ },
2788
+ {
2789
+ "name": "title",
2790
+ "type": "string",
2791
+ "required": false
2792
+ },
2793
+ {
2794
+ "name": "model",
2795
+ "type": "string",
2796
+ "required": false
2797
+ },
2798
+ {
2799
+ "name": "report_to",
2800
+ "type": "string",
2801
+ "required": false
2802
+ },
2803
+ {
2804
+ "name": "session",
2805
+ "type": "string",
2806
+ "required": false
2807
+ }
2808
+ ]
2809
+ },
2810
+ "response": {
2811
+ "status": 200,
2812
+ "shape": "object",
2813
+ "fields": [
2814
+ {
2815
+ "name": "ok",
2816
+ "type": "boolean",
2817
+ "required": false
2818
+ },
2819
+ {
2820
+ "name": "sessionId",
2821
+ "type": "string",
2822
+ "required": false
2823
+ },
2824
+ {
2825
+ "name": "title",
2826
+ "type": "string",
2827
+ "required": false
2828
+ },
2829
+ {
2830
+ "name": "wid",
2831
+ "type": "string",
2832
+ "required": false
2833
+ }
2834
+ ]
2835
+ },
2836
+ "errors": [
2837
+ {
2838
+ "status": 502,
2839
+ "error": "<hard dispatch failure; {ok:false,error,...}>"
2840
+ },
2841
+ {
2842
+ "status": 500,
2843
+ "error": "tool returned no value"
2844
+ },
2845
+ {
2846
+ "status": 404,
2847
+ "error": "unknown session '<id>'"
2848
+ }
2849
+ ],
2850
+ "notes": [
2851
+ "Tool-contract failures ({ok:false}) still return 200; only hard failures are 5xx. report_to=cli-main wakes the host autowake loop. W833: an explicit session must resolve or the spawn is refused with 404 (no ghost instance is composed)."
2852
+ ],
2853
+ "probe": {
2854
+ "checked": false,
2855
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2856
+ }
2857
+ },
2858
+ {
2859
+ "id": "post_worker_send",
2860
+ "method": "POST",
2861
+ "path": "/api/worker/send",
2862
+ "group": "workers",
2863
+ "rustHandler": "api::post_worker_send",
2864
+ "docRef": "contracts/endpoints.json#post_worker_send",
2865
+ "request": {
2866
+ "kind": "json",
2867
+ "fields": [
2868
+ {
2869
+ "name": "target",
2870
+ "type": "string",
2871
+ "required": true,
2872
+ "note": "session id or name"
2873
+ },
2874
+ {
2875
+ "name": "content",
2876
+ "type": "string",
2877
+ "required": true
2878
+ }
2879
+ ]
2880
+ },
2881
+ "response": {
2882
+ "status": 200,
2883
+ "shape": "object",
2884
+ "fields": [
2885
+ {
2886
+ "name": "(worker tool {ok,delivered,...})",
2887
+ "type": "object",
2888
+ "required": false
2889
+ }
2890
+ ]
2891
+ },
2892
+ "errors": [],
2893
+ "notes": [
2894
+ "Same shape as the worker tool result."
2895
+ ],
2896
+ "probe": {
2897
+ "checked": false,
2898
+ "reason": "write endpoint: P0 forbids mutating probes (providers.json/workspaces.json/prompts.json/cli-main.jsonl are read-only)"
2899
+ }
2900
+ },
2901
+ {
2902
+ "id": "get_worker_status",
2903
+ "method": "GET",
2904
+ "path": "/api/worker/status",
2905
+ "group": "workers",
2906
+ "rustHandler": "api::get_worker_status",
2907
+ "docRef": "contracts/endpoints.json#get_worker_status",
2908
+ "request": {
2909
+ "kind": "query",
2910
+ "fields": [
2911
+ {
2912
+ "name": "wid",
2913
+ "type": "string",
2914
+ "required": false
2915
+ }
2916
+ ]
2917
+ },
2918
+ "response": {
2919
+ "status": 200,
2920
+ "shape": "object",
2921
+ "fields": [
2922
+ {
2923
+ "name": "ok",
2924
+ "type": "boolean",
2925
+ "required": false
2926
+ },
2927
+ {
2928
+ "name": "total",
2929
+ "type": "integer",
2930
+ "required": false
2931
+ },
2932
+ {
2933
+ "name": "by_status",
2934
+ "type": "object{DONE,RUNNING,FAILED}",
2935
+ "required": false
2936
+ },
2937
+ {
2938
+ "name": "by_state",
2939
+ "type": "object{idle,in-turn,running}",
2940
+ "required": false,
2941
+ "note": "LIVE-ONLY additive field, not in api-contract.md"
2942
+ },
2943
+ {
2944
+ "name": "workers",
2945
+ "type": "array<object>",
2946
+ "required": false,
2947
+ "note": "W787: each row carries `attempt` (which try, first = 0 — the §5.2 cross-capability convention the usage ledger also uses), `host_session` (the dispatching conversation, `host=`) and `last_receipt` (the delivered receipt's idempotency key `wid:attempt`) as PURE ADDITIONS."
2948
+ },
2949
+ {
2950
+ "name": "stale",
2951
+ "type": "array<object{wid,status,attempt,host_session,sess,reason,lease_pid,lease_at,retries,artifact,action}>",
2952
+ "required": false,
2953
+ "note": "E §2.3 P0 (W787): RUNNING rows of the PERSISTED table whose owning process is gone (`lease=<pid>@<unix>`, falling back to `proc=<pid>`). OBSERVATION ONLY — the studio never re-dispatches at boot; `action` records what P2 would do. PURE ADDITION."
2954
+ },
2955
+ {
2956
+ "name": "orphans",
2957
+ "type": "array<object{wid,status,attempt,host_session,sess,reason,lease_pid,lease_at,retries,artifact,action}>",
2958
+ "required": false,
2959
+ "note": "E §2.3 P0 (W787): RUNNING rows whose `host=` session no longer exists. Never auto re-dispatched (P2 territory). PURE ADDITION."
2960
+ },
2961
+ {
2962
+ "name": "wid",
2963
+ "type": "string",
2964
+ "required": false,
2965
+ "optional": true
2966
+ },
2967
+ {
2968
+ "name": "error",
2969
+ "type": "string",
2970
+ "required": false,
2971
+ "optional": true
2972
+ }
2973
+ ]
2974
+ },
2975
+ "errors": [
2976
+ {
2977
+ "status": 200,
2978
+ "error": "no worker W264 in registry",
2979
+ "note": "still HTTP 200 with ok:false"
2980
+ }
2981
+ ],
2982
+ "notes": [
2983
+ "Always 200, including the not-found case.",
2984
+ "Fields marked optional:true appear only in some response variants.",
2985
+ "W787 (E §2.3 P0 ③): `stale` / `orphans` come from the boot observer's judgement of the studio's OWN table (`<data dir>/worker-registry.tsv`, env `CELESTEA_WORKER_REGISTRY`), never from the DSH-side plugin's table; the boot sweep writes one audit line per finding plus a summary into `<data dir>/recovery-audit.jsonl`."
2986
+ ],
2987
+ "probe": {
2988
+ "checked": true,
2989
+ "mode": "read-only GET",
2990
+ "server": "http://127.0.0.1:3777"
2991
+ }
2992
+ },
2993
+ {
2994
+ "id": "get_session_grants",
2995
+ "method": "GET",
2996
+ "path": "/api/sessions/{id}/grants",
2997
+ "group": "grants",
2998
+ "rustHandler": "(none - TypeScript-only, W516)",
2999
+ "docRef": "docs/feature-session-grants.md#61-get-apisessionsidgrants",
3000
+ "request": {
3001
+ "kind": "none",
3002
+ "fields": []
3003
+ },
3004
+ "response": {
3005
+ "status": 200,
3006
+ "shape": "object",
3007
+ "fields": [
3008
+ {
3009
+ "name": "ok",
3010
+ "type": "boolean",
3011
+ "required": true
3012
+ },
3013
+ {
3014
+ "name": "session",
3015
+ "type": "string",
3016
+ "required": true
3017
+ },
3018
+ {
3019
+ "name": "grants",
3020
+ "type": "array<{id:string,cap:string,scope:object,granted_at:integer,granted_by:string,expires_at:integer|null,uses_left:integer|null,note:string,expired:boolean}>",
3021
+ "required": true,
3022
+ "note": "expired is evaluated at read time; invalid/expired entries stay listed but are absent from effective"
3023
+ },
3024
+ {
3025
+ "name": "effective",
3026
+ "type": "object{network:bool,read_roots:string[],write_roots:string[],net_hosts:string[],tool_extra:string[],unsandboxed:bool}",
3027
+ "required": true,
3028
+ "note": "widen-only snapshot; the UI shows it verbatim before confirming"
3029
+ },
3030
+ {
3031
+ "name": "max_ttl_sec",
3032
+ "type": "object{<cap>:integer}",
3033
+ "required": true
3034
+ },
3035
+ {
3036
+ "name": "unsandboxed_available",
3037
+ "type": "boolean",
3038
+ "required": true,
3039
+ "note": "CELESTEA_GRANTS_ALLOW_UNSANDBOXED=1"
3040
+ },
3041
+ {
3042
+ "name": "net_hosts_effective",
3043
+ "type": "boolean",
3044
+ "required": true,
3045
+ "note": "W757 additive: false = the session holds net_hosts entries this deployment ignores entirely (neither CELESTEA_HTTP_ALLOW nor CELESTEA_HTTP_DENY set, so the http_request target policy stays inactive and the union-widening changes nothing); true when there are no net_hosts entries to drop. Same verdict as the engine mount path (HttpTargetPolicy.netHostsIneffective)"
3046
+ },
3047
+ {
3048
+ "name": "warnings",
3049
+ "type": "string[]",
3050
+ "required": false,
3051
+ "note": "W516 additive: present only when the file was unreadable or entries were ignored; W757 appends the readable net_hosts_ineffective entry when net_hosts_effective is false"
3052
+ }
3053
+ ]
3054
+ },
3055
+ "errors": [
3056
+ {
3057
+ "status": 404,
3058
+ "error": "unknown session '<id>'"
3059
+ }
3060
+ ],
3061
+ "notes": [
3062
+ "No grants.json -> 200 with grants:[] and effective defaults (never 404).",
3063
+ "Grants live at <session dir>/grants.json; worker:<sid> sessions have no directory and are rejected by session resolution."
3064
+ ],
3065
+ "probe": {
3066
+ "checked": false,
3067
+ "reason": "TypeScript-only endpoint: the retired backend (127.0.0.1:3777) has no /api/sessions/{id}/grants route"
3068
+ }
3069
+ },
3070
+ {
3071
+ "id": "post_session_grants",
3072
+ "method": "POST",
3073
+ "path": "/api/sessions/{id}/grants",
3074
+ "group": "grants",
3075
+ "rustHandler": "(none - TypeScript-only, W516)",
3076
+ "docRef": "docs/feature-session-grants.md#62-post-apisessionsidgrants",
3077
+ "request": {
3078
+ "kind": "json",
3079
+ "fields": [
3080
+ {
3081
+ "name": "cap",
3082
+ "type": "string",
3083
+ "required": true,
3084
+ "note": "network|read_roots|write_roots|net_hosts|unsandboxed (W819-8: tool_extra is a RESERVED cap - still read back and revocable, no longer offered)"
3085
+ },
3086
+ {
3087
+ "name": "scope",
3088
+ "type": "object",
3089
+ "required": false,
3090
+ "note": "roots[]|hosts[]|tools[] depending on cap"
3091
+ },
3092
+ {
3093
+ "name": "ttl_sec",
3094
+ "type": "integer",
3095
+ "required": false,
3096
+ "note": "default 1800, 0 = no expiry, capped per cap (network 3600, roots/hosts/tools 86400, unsandboxed 900)"
3097
+ },
3098
+ {
3099
+ "name": "uses_left",
3100
+ "type": "integer|null",
3101
+ "required": false,
3102
+ "note": "null = unlimited, 1 = one-shot; unsandboxed is forced to 1"
3103
+ },
3104
+ {
3105
+ "name": "note",
3106
+ "type": "string",
3107
+ "required": false,
3108
+ "note": "redacted before it is written"
3109
+ }
3110
+ ],
3111
+ "note": "header X-Celestea-Grant-Confirm: <one-shot token> is required; it comes from GET .../grants/confirm-token only"
3112
+ },
3113
+ "response": {
3114
+ "status": 200,
3115
+ "shape": "object",
3116
+ "fields": [
3117
+ {
3118
+ "name": "ok",
3119
+ "type": "boolean",
3120
+ "required": true
3121
+ },
3122
+ {
3123
+ "name": "grant",
3124
+ "type": "object{same shape as the GET entries}",
3125
+ "required": true
3126
+ },
3127
+ {
3128
+ "name": "effective",
3129
+ "type": "object{same shape as GET effective}",
3130
+ "required": true
3131
+ }
3132
+ ]
3133
+ },
3134
+ "errors": [
3135
+ {
3136
+ "status": 400,
3137
+ "error": "invalid cap '<x>'"
3138
+ },
3139
+ {
3140
+ "status": 400,
3141
+ "error": "invalid scope for cap '<x>': <why>"
3142
+ },
3143
+ {
3144
+ "status": 400,
3145
+ "error": "ttl_sec exceeds the maximum for cap '<x>' (<n>)"
3146
+ },
3147
+ {
3148
+ "status": 400,
3149
+ "error": "value looks like a credential",
3150
+ "note": "the rejected value is never echoed in the response, the audit or the UI"
3151
+ },
3152
+ {
3153
+ "status": 403,
3154
+ "error": "grant confirmation required",
3155
+ "note": "missing / expired / mismatched token"
3156
+ },
3157
+ {
3158
+ "status": 404,
3159
+ "error": "unknown session '<id>'"
3160
+ },
3161
+ {
3162
+ "status": 409,
3163
+ "error": "confirmation token already used"
3164
+ },
3165
+ {
3166
+ "status": 409,
3167
+ "error": "a grant request was just denied; retry in <n>s"
3168
+ },
3169
+ {
3170
+ "status": 429,
3171
+ "error": "too many grant requests; retry in <n>s",
3172
+ "note": "3 per session per minute"
3173
+ },
3174
+ {
3175
+ "status": 500,
3176
+ "error": "cannot persist grants: {e}"
3177
+ }
3178
+ ],
3179
+ "notes": [
3180
+ "One cap holds exactly ONE live entry: the same cap replaces (never stacks).",
3181
+ "A grant is written atomically (mode 0600) and bumps ONLY that session instance: the change lands at its next turn boundary."
3182
+ ],
3183
+ "probe": {
3184
+ "checked": false,
3185
+ "reason": "TypeScript-only endpoint: the retired backend has no /api/sessions/{id}/grants route"
3186
+ }
3187
+ },
3188
+ {
3189
+ "id": "delete_session_grants",
3190
+ "method": "DELETE",
3191
+ "path": "/api/sessions/{id}/grants",
3192
+ "group": "grants",
3193
+ "rustHandler": "(none - TypeScript-only, W516)",
3194
+ "docRef": "docs/feature-session-grants.md#63-delete-apisessionsidgrants",
3195
+ "request": {
3196
+ "kind": "json",
3197
+ "fields": [
3198
+ {
3199
+ "name": "cap",
3200
+ "type": "string",
3201
+ "required": false,
3202
+ "note": "revoke every entry of this cap"
3203
+ },
3204
+ {
3205
+ "name": "grant_id",
3206
+ "type": "string",
3207
+ "required": false,
3208
+ "note": "revoke this entry"
3209
+ }
3210
+ ],
3211
+ "note": "both omitted = revoke everything; NO confirm token: revoking only ever narrows"
3212
+ },
3213
+ "response": {
3214
+ "status": 200,
3215
+ "shape": "object",
3216
+ "fields": [
3217
+ {
3218
+ "name": "ok",
3219
+ "type": "boolean",
3220
+ "required": true
3221
+ },
3222
+ {
3223
+ "name": "revoked",
3224
+ "type": "string[]",
3225
+ "required": true,
3226
+ "note": "grant ids; [] when nothing matched (idempotent)"
3227
+ },
3228
+ {
3229
+ "name": "effective",
3230
+ "type": "object{same shape as GET effective}",
3231
+ "required": true
3232
+ }
3233
+ ]
3234
+ },
3235
+ "errors": [
3236
+ {
3237
+ "status": 404,
3238
+ "error": "unknown session '<id>'"
3239
+ },
3240
+ {
3241
+ "status": 500,
3242
+ "error": "cannot persist grants: {e}"
3243
+ }
3244
+ ],
3245
+ "notes": [
3246
+ "Idempotent: revoking what is not granted answers {ok:true, revoked:[]}."
3247
+ ],
3248
+ "probe": {
3249
+ "checked": false,
3250
+ "reason": "TypeScript-only endpoint"
3251
+ }
3252
+ },
3253
+ {
3254
+ "id": "get_session_grants_confirm_token",
3255
+ "method": "GET",
3256
+ "path": "/api/sessions/{id}/grants/confirm-token",
3257
+ "group": "grants",
3258
+ "rustHandler": "(none - TypeScript-only, W516)",
3259
+ "docRef": "docs/feature-session-grants.md#64-get-apisessionsidgrantsconfirm-token",
3260
+ "request": {
3261
+ "kind": "query",
3262
+ "fields": [
3263
+ {
3264
+ "name": "cap",
3265
+ "type": "string",
3266
+ "required": true
3267
+ },
3268
+ {
3269
+ "name": "scope_hash",
3270
+ "type": "string",
3271
+ "required": true,
3272
+ "note": "sha256 hex of JSON.stringify({cap, scope}) with sorted keys and sorted list values"
3273
+ }
3274
+ ],
3275
+ "note": "requires Sec-Fetch-Site: same-origin (or Sec-Fetch-Mode: cors with a matching/absent Origin)"
3276
+ },
3277
+ "response": {
3278
+ "status": 200,
3279
+ "shape": "object",
3280
+ "fields": [
3281
+ {
3282
+ "name": "ok",
3283
+ "type": "boolean",
3284
+ "required": true
3285
+ },
3286
+ {
3287
+ "name": "token",
3288
+ "type": "string",
3289
+ "required": true,
3290
+ "note": "opaque, 60s TTL, one-shot, bound to (session, cap, scope_hash)"
3291
+ },
3292
+ {
3293
+ "name": "expires_at",
3294
+ "type": "integer",
3295
+ "required": true,
3296
+ "note": "unix seconds"
3297
+ }
3298
+ ]
3299
+ },
3300
+ "errors": [
3301
+ {
3302
+ "status": 400,
3303
+ "error": "invalid cap '<x>'"
3304
+ },
3305
+ {
3306
+ "status": 400,
3307
+ "error": "scope_hash must be a 64-char sha256 hex string"
3308
+ },
3309
+ {
3310
+ "status": 403,
3311
+ "error": "grant confirmation is not available over this transport"
3312
+ },
3313
+ {
3314
+ "status": 404,
3315
+ "error": "unknown session '<id>'"
3316
+ }
3317
+ ],
3318
+ "notes": [
3319
+ "The token is only checked when GRANTING; revoking never needs one.",
3320
+ "A session tool cannot mint one: the endpoint refuses any request without browser same-origin evidence."
3321
+ ],
3322
+ "probe": {
3323
+ "checked": false,
3324
+ "reason": "TypeScript-only endpoint"
3325
+ }
3326
+ },
3327
+ {
3328
+ "id": "get_session_context",
3329
+ "method": "GET",
3330
+ "path": "/api/sessions/{id}/context",
3331
+ "group": "sessions",
3332
+ "rustHandler": "(none - TypeScript-only, W725)",
3333
+ "docRef": "docs/feature-session-context.md#1-端点",
3334
+ "request": {
3335
+ "kind": "none",
3336
+ "fields": []
3337
+ },
3338
+ "response": {
3339
+ "status": 200,
3340
+ "shape": "object",
3341
+ "fields": [
3342
+ {
3343
+ "name": "ok",
3344
+ "type": "boolean",
3345
+ "required": true
3346
+ },
3347
+ {
3348
+ "name": "session",
3349
+ "type": "string",
3350
+ "required": true,
3351
+ "note": "canonical '<workspace>/<session>'"
3352
+ },
3353
+ {
3354
+ "name": "model",
3355
+ "type": "string",
3356
+ "required": true
3357
+ },
3358
+ {
3359
+ "name": "system",
3360
+ "type": "string",
3361
+ "required": true,
3362
+ "note": "the config.system_prompt the loop sends, verbatim"
3363
+ },
3364
+ {
3365
+ "name": "tools",
3366
+ "type": "array<{name:string,description:string,parameters:object}>",
3367
+ "required": true,
3368
+ "note": "registry.schemas() verbatim, in engine order"
3369
+ },
3370
+ {
3371
+ "name": "messages",
3372
+ "type": "array<{role:string,content:string,tool_name?:string,tool_call_id?:string,truncated?:true}>",
3373
+ "required": true,
3374
+ "note": "the post-trim history the next step would send; see docs/feature-session-context.md §2"
3375
+ },
3376
+ {
3377
+ "name": "counts",
3378
+ "type": "object{system_chars:int,tool_count:int,message_count:int}",
3379
+ "required": true,
3380
+ "note": "describes the payload AS SENT (post-truncation)"
3381
+ },
3382
+ {
3383
+ "name": "context",
3384
+ "type": "object{used:int,window:int,ratio:number,estimated:bool}",
3385
+ "required": true,
3386
+ "note": "the statusline's context_usage口径 minus its `method`/`projected`/`window_source` discriminators: used/window/ratio/estimated as defined by get_status (W755: real prompt + measured growth, else the token estimate of THIS very assembly, else used:0; window:0 + ratio:0 when the profile declares no capacity)"
3387
+ },
3388
+ {
3389
+ "name": "truncated",
3390
+ "type": "boolean",
3391
+ "required": true,
3392
+ "note": "true when any entry hit the 20000-char cap (system included)"
3393
+ }
3394
+ ]
3395
+ },
3396
+ "errors": [
3397
+ {
3398
+ "status": 404,
3399
+ "error": "unknown session '<id>'"
3400
+ },
3401
+ {
3402
+ "status": 503,
3403
+ "error": "too many live sessions (limit N)"
3404
+ }
3405
+ ],
3406
+ "notes": [
3407
+ "Read-only: it composes the session's instance when none is live and never drives a turn.",
3408
+ "The body is the ENGINE's assembly (agent-loop buildRequest: system + trimContext(deriveMessages()) + registry.schemas()), never a host-side re-derivation.",
3409
+ "W755: `context` is the same口径 as get_status.context_usage, computed from THIS assembly — which is exactly what the fallback estimate measures, so the panel and the meter can no longer disagree.",
3410
+ "Per-entry cap: a message or the system text longer than 20000 chars is cut to 20000 and marked (message: `truncated:true`; system: top-level flag only).",
3411
+ "`role` is the verbatim engine role, so a trimmed-history marker message can appear as `system` (user|assistant|tool otherwise).",
3412
+ "`worker:<sid>` sessions have no directory and are rejected by session resolution (404)."
3413
+ ],
3414
+ "probe": {
3415
+ "checked": false,
3416
+ "reason": "TypeScript-only endpoint: the retired backend (127.0.0.1:3777) has no /api/sessions/{id}/context route"
3417
+ }
3418
+ },
3419
+ {
3420
+ "id": "get_login",
3421
+ "method": "GET",
3422
+ "path": "/login",
3423
+ "group": "auth",
3424
+ "rustHandler": "(none - TypeScript-only, W767)",
3425
+ "docRef": "docs/feature-studio-auth.md#1-三个端点",
3426
+ "request": {
3427
+ "kind": "none",
3428
+ "fields": []
3429
+ },
3430
+ "response": {
3431
+ "status": 200,
3432
+ "shape": "html",
3433
+ "contentType": "text/html; charset=utf-8",
3434
+ "fields": [
3435
+ {
3436
+ "name": "body",
3437
+ "type": "string",
3438
+ "required": true,
3439
+ "note": "Studio's OWN self-contained login page (inline CSS, no Vite build dependency); Cache-Control: no-store"
3440
+ }
3441
+ ]
3442
+ },
3443
+ "errors": [],
3444
+ "notes": [
3445
+ "W767: exempt from the cookie gate in nginx (`location = /login`); the only unauthenticated HTML page Studio serves.",
3446
+ "Rendered by the backend, so it keeps working while the SPA build is being rebuilt."
3447
+ ]
3448
+ },
3449
+ {
3450
+ "id": "post_auth_login",
3451
+ "method": "POST",
3452
+ "path": "/auth/login",
3453
+ "group": "auth",
3454
+ "rustHandler": "(none - TypeScript-only, W767)",
3455
+ "docRef": "docs/feature-studio-auth.md#1-三个端点",
3456
+ "request": {
3457
+ "kind": "json",
3458
+ "fields": [
3459
+ {
3460
+ "name": "username",
3461
+ "type": "string",
3462
+ "required": true,
3463
+ "note": "the name is checked against Studio's own read-only password file (`htpasswd -vbi`, argv-safe charset)"
3464
+ },
3465
+ {
3466
+ "name": "password",
3467
+ "type": "string",
3468
+ "required": true,
3469
+ "note": "verified through `htpasswd -vbi` on STDIN — never in argv, never logged"
3470
+ }
3471
+ ],
3472
+ "note": "application/x-www-form-urlencoded (browser form post) or application/json (API client); a browser gets a page back, a JSON client gets JSON"
3473
+ },
3474
+ "response": {
3475
+ "status": 200,
3476
+ "shape": "object|html",
3477
+ "contentType": "application/json; charset=utf-8 (JSON client) | text/html; charset=utf-8 (form post)",
3478
+ "fields": [
3479
+ {
3480
+ "name": "ok",
3481
+ "type": "boolean",
3482
+ "required": false,
3483
+ "note": "true — JSON form only"
3484
+ },
3485
+ {
3486
+ "name": "user",
3487
+ "type": "string",
3488
+ "required": false,
3489
+ "note": "the verified username — JSON form only"
3490
+ }
3491
+ ]
3492
+ },
3493
+ "errors": [
3494
+ {
3495
+ "status": 401,
3496
+ "error": "invalid username or password",
3497
+ "note": "a wrong password and an unknown user are indistinguishable (the helper is run either way)"
3498
+ },
3499
+ {
3500
+ "status": 429,
3501
+ "error": "too many failed login attempts",
3502
+ "note": "5 failures per 60 s window, counted per username AND per client IP"
3503
+ },
3504
+ {
3505
+ "status": 500,
3506
+ "error": "credential store unavailable",
3507
+ "note": "the helper or the password file is unusable — never reported as a wrong password"
3508
+ }
3509
+ ],
3510
+ "notes": [
3511
+ "W767: success = 200 + `Set-Cookie: studio_auth=<token>; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax` AND the navigation (a page with `location.replace('/')`, or `{ok:true,user}` for JSON clients) in the SAME response.",
3512
+ "Deliberately NOT a 302: a redirect response is allowed to drop Set-Cookie on some mobile clients.",
3513
+ "Token = `<b64url(user)>.<b64url(rand16)>.<expUnix>.<b64url(HMAC-SHA256(secret, first three parts))>`; the secret is `<data dir>/studio-auth.secret` (0600, created on first use)."
3514
+ ]
3515
+ },
3516
+ {
3517
+ "id": "get_auth_check",
3518
+ "method": "GET",
3519
+ "path": "/auth/check",
3520
+ "group": "auth",
3521
+ "rustHandler": "(none - TypeScript-only, W767)",
3522
+ "docRef": "docs/feature-studio-auth.md#1-三个端点",
3523
+ "request": {
3524
+ "kind": "none",
3525
+ "fields": [],
3526
+ "note": "the cookie arrives in the `Cookie:` header (nginx proxies `$http_cookie` into the auth_request subrequest)"
3527
+ },
3528
+ "response": {
3529
+ "status": 200,
3530
+ "shape": "object",
3531
+ "contentType": "application/json; charset=utf-8",
3532
+ "fields": [
3533
+ {
3534
+ "name": "ok",
3535
+ "type": "boolean",
3536
+ "required": true
3537
+ },
3538
+ {
3539
+ "name": "user",
3540
+ "type": "string",
3541
+ "required": false,
3542
+ "note": "the username carried by a valid cookie"
3543
+ }
3544
+ ]
3545
+ },
3546
+ "errors": [
3547
+ {
3548
+ "status": 401,
3549
+ "error": "unauthorized",
3550
+ "note": "missing, tampered or expired cookie — nginx turns this into a redirect to /login"
3551
+ }
3552
+ ],
3553
+ "notes": [
3554
+ "W767: consumed by nginx `auth_request` only (proxied as the internal location `/__auth_check`); it is not part of the UI surface.",
3555
+ "Checks shape, HMAC signature (constant-time) and expiry; the secret is never sent to a client."
3556
+ ]
3557
+ },
3558
+ {
3559
+ "id": "get_questions",
3560
+ "method": "GET",
3561
+ "path": "/api/questions",
3562
+ "group": "questions",
3563
+ "rustHandler": "(none - TypeScript-only, W783)",
3564
+ "docRef": "docs/feature-ask-user.md#7-断线恢复",
3565
+ "request": {
3566
+ "kind": "query",
3567
+ "fields": [
3568
+ {
3569
+ "name": "session",
3570
+ "type": "string",
3571
+ "required": false,
3572
+ "note": "Only this session's pending questions; absent = every session."
3573
+ },
3574
+ {
3575
+ "name": "pending",
3576
+ "type": "string",
3577
+ "required": false,
3578
+ "note": "`1` = only questions that are still answerable. `0`/absent = the same list (the table never holds settled questions), so the flag documents intent rather than filtering."
3579
+ }
3580
+ ]
3581
+ },
3582
+ "response": {
3583
+ "status": 200,
3584
+ "shape": "object",
3585
+ "fields": [
3586
+ {
3587
+ "name": "ok",
3588
+ "type": "boolean",
3589
+ "required": true
3590
+ },
3591
+ {
3592
+ "name": "questions",
3593
+ "type": "array<{id:string,session:string|null,questions:array,expires_at:integer,timeout_ms:integer,remaining_ms:integer,expired:boolean}>",
3594
+ "required": true,
3595
+ "note": "`expired` is evaluated at READ time (`now >= expires_at`, the grants isExpired pattern) and `remaining_ms` is what the UI counts down, so a client that reconnects rebuilds the card without trusting its own clock."
3596
+ }
3597
+ ]
3598
+ },
3599
+ "errors": [],
3600
+ "notes": [
3601
+ "Reconnect recovery (design 7): the SSE frame can be missed; this list is the authoritative rebuild source.",
3602
+ "After a process restart the pending table is empty, so a question the model asked before the restart is NOT answerable — the client must render the `user_question` row as unanswered/expired instead of blocking (design 7.2 rule 4)."
3603
+ ],
3604
+ "probe": {
3605
+ "checked": false,
3606
+ "reason": "TypeScript-only endpoint"
3607
+ }
3608
+ },
3609
+ {
3610
+ "id": "post_question_answer",
3611
+ "method": "POST",
3612
+ "path": "/api/questions/{id}/answer",
3613
+ "group": "questions",
3614
+ "rustHandler": "(none - TypeScript-only, W783)",
3615
+ "docRef": "docs/feature-ask-user.md#42-数据流关键答案直接-resolve",
3616
+ "request": {
3617
+ "kind": "json",
3618
+ "fields": [
3619
+ {
3620
+ "name": "answers",
3621
+ "type": "array<{id:string,selected:string[],custom?:string}>",
3622
+ "required": true,
3623
+ "note": "One entry per question in the batch. `selected` holds option LABELS (never indices, design 3.2 rule 1); `custom` is the optional free-text answer. A question may be answered with `selected:[]` plus `custom`, or with both."
3624
+ },
3625
+ {
3626
+ "name": "session",
3627
+ "type": "string",
3628
+ "required": false,
3629
+ "note": "Optional guard: when present it must match the session that asked, so a stale UI cannot answer another session's question by guessing an id."
3630
+ }
3631
+ ]
3632
+ },
3633
+ "response": {
3634
+ "status": 200,
3635
+ "shape": "object",
3636
+ "fields": [
3637
+ {
3638
+ "name": "ok",
3639
+ "type": "boolean",
3640
+ "required": true
3641
+ },
3642
+ {
3643
+ "name": "id",
3644
+ "type": "string",
3645
+ "required": true,
3646
+ "note": "the request id that was answered"
3647
+ },
3648
+ {
3649
+ "name": "session",
3650
+ "type": "string|null",
3651
+ "required": true
3652
+ },
3653
+ {
3654
+ "name": "timed_out",
3655
+ "type": "boolean",
3656
+ "required": true,
3657
+ "note": "always false on this path: a real answer arrived (design 6.2: whoever arrives first wins)"
3658
+ }
3659
+ ]
3660
+ },
3661
+ "errors": [
3662
+ {
3663
+ "status": 404,
3664
+ "error": "unknown or already settled question '<id>'",
3665
+ "note": "a question that expired, was cancelled, or belonged to a previous process reads exactly like an unknown one — the pending table is in-memory (design 7.2 rule 4)"
3666
+ },
3667
+ {
3668
+ "status": 409,
3669
+ "error": "question '<id>' already settled",
3670
+ "note": "the race of design 6.2: the answer arrived after the deadline (or after a cancel). `timed_out` in the body says which side won"
3671
+ },
3672
+ {
3673
+ "status": 422,
3674
+ "error": "field 'answers' must be an array of {id,selected[],custom?} objects"
3675
+ }
3676
+ ],
3677
+ "notes": [
3678
+ "This is the endpoint that makes the answer reach a PARKED tool call: it resolves the pending promise the answerer waterfall is awaiting, so the answer NEVER travels as a message (design 4.2).",
3679
+ "`selected` carrying labels is what lets a UI answer without knowing the option order.",
3680
+ "The answer is recorded as a `user_answer` session row (design 7) before the tool resumes."
3681
+ ],
3682
+ "probe": {
3683
+ "checked": false,
3684
+ "reason": "TypeScript-only endpoint: the retired backend (127.0.0.1:3777) has no /api/questions route"
3685
+ }
3686
+ },
3687
+ {
3688
+ "id": "get_usage_ledger",
3689
+ "method": "GET",
3690
+ "path": "/api/usage/ledger",
3691
+ "group": "usage",
3692
+ "rustHandler": "(none - TypeScript-only, W785)",
3693
+ "docRef": "docs/iteration-e-capabilities.md#324-聚合视图",
3694
+ "request": {
3695
+ "kind": "query",
3696
+ "fields": [
3697
+ {
3698
+ "name": "session",
3699
+ "type": "string",
3700
+ "required": false,
3701
+ "note": "Only this session's rows. The value is the ledger's own session label (`<workspace>/<session>`), i.e. exactly the `session` field of a row; absent/empty = every session."
3702
+ },
3703
+ {
3704
+ "name": "since",
3705
+ "type": "integer",
3706
+ "required": false,
3707
+ "note": "Epoch SECONDS, inclusive lower bound on the row's `ts` (the ledger stores second resolution, never ISO strings)."
3708
+ },
3709
+ {
3710
+ "name": "until",
3711
+ "type": "integer",
3712
+ "required": false,
3713
+ "note": "Epoch SECONDS, inclusive upper bound on the row's `ts`."
3714
+ },
3715
+ {
3716
+ "name": "group_by",
3717
+ "type": "\"session\"|\"turn\"|\"model\"|\"day\"",
3718
+ "required": false,
3719
+ "note": "Fold dimension; absent/empty = `session`. `turn` keys are `<session>|<turn_id>` (a row whose turn_id is null uses `-`), `model` keys use `(unknown model)` when the provider reported none, `day` keys are the UTC date `YYYY-MM-DD` of `ts`."
3720
+ }
3721
+ ]
3722
+ },
3723
+ "response": {
3724
+ "status": 200,
3725
+ "shape": "object",
3726
+ "fields": [
3727
+ {
3728
+ "name": "ok",
3729
+ "type": "boolean",
3730
+ "required": true,
3731
+ "note": "`true` = the aggregate below. `false` (with `error`) = there is no ledger here: `usage ledger unavailable` (adapter without the capability) or `usage ledger disabled` (`CELESTEA_USAGE_LEDGER=off`)."
3732
+ },
3733
+ {
3734
+ "name": "currency",
3735
+ "type": "string",
3736
+ "required": false
3737
+ },
3738
+ {
3739
+ "name": "group_by",
3740
+ "type": "string",
3741
+ "required": false
3742
+ },
3743
+ {
3744
+ "name": "rows",
3745
+ "type": "array<object>",
3746
+ "required": false,
3747
+ "note": "One row per group key, key-ascending: `{key:string, tokens:{prompt_tokens,completion_tokens,total_tokens,cache_read,reasoning_tokens}, cost:{in,out,cache,total}|null, records:integer, unpriced_records:integer}`. `turn_total` rows are NEVER counted (they restate their steps, §3.2.1)."
3748
+ },
3749
+ {
3750
+ "name": "totals",
3751
+ "type": "object",
3752
+ "required": false,
3753
+ "note": "The P0 `LedgerTotals` over the SAME filtered rows: `{currency, price_version, records, tokens, cost|null, cost_complete, unpriced_records, billed_unknown_records, unpriced_models}`."
3754
+ },
3755
+ {
3756
+ "name": "unpriced_models",
3757
+ "type": "array<string>",
3758
+ "required": false,
3759
+ "note": "Models the price table could not cover; their rows carry `cost: null` — UNKNOWN, never 0 (§3.2.2 rule 3)."
3760
+ },
3761
+ {
3762
+ "name": "price_version",
3763
+ "type": "string|null",
3764
+ "required": false,
3765
+ "note": "`version` of the last contributing row's price snapshot; null = nothing was priced."
3766
+ },
3767
+ {
3768
+ "name": "error",
3769
+ "type": "string",
3770
+ "required": false,
3771
+ "note": "Present only with `ok:false` (HTTP 200: the request was understood, there is just no ledger to read)."
3772
+ }
3773
+ ]
3774
+ },
3775
+ "errors": [
3776
+ {
3777
+ "status": 422,
3778
+ "error": "field 'since' must be an integer",
3779
+ "note": "also `until`; both are epoch seconds"
3780
+ },
3781
+ {
3782
+ "status": 422,
3783
+ "error": "field 'group_by' must be one of session, turn, model, day"
3784
+ },
3785
+ {
3786
+ "status": 500,
3787
+ "error": "<engine message>",
3788
+ "note": "an unreadable ledger file: reported, never silently served as an empty ledger"
3789
+ }
3790
+ ],
3791
+ "notes": [
3792
+ "The engine-side ESTIMATE of iteration E §3.2.4, never the platform's bill: the price snapshot comes from `<data dir>/pricing.json` and an unknown model is reported as `unpriced` instead of being charged 0 (LTS `biz/newapi.md` I1-I3).",
3793
+ "One process shares ONE append-only ledger file (`<data dir>/usage-ledger.jsonl`), so this endpoint reads every session's rows and the `session` filter is a query, not a boundary.",
3794
+ "`session` matches the row's own label (`<workspace>/<session>` for a named session, `sessionId ?? \"cli-main\"` for the detached generation); a trailing whitespace-only value is treated as absent."
3795
+ ],
3796
+ "probe": {
3797
+ "checked": false,
3798
+ "reason": "TypeScript-only endpoint"
3799
+ }
3800
+ },
3801
+ {
3802
+ "id": "get_permissions_presets",
3803
+ "method": "GET",
3804
+ "path": "/api/permissions/presets",
3805
+ "group": "permissions",
3806
+ "rustHandler": "(none - TypeScript-only, W9)",
3807
+ "docRef": "docs/feature-session-permissions.md#w9",
3808
+ "request": {
3809
+ "kind": "none",
3810
+ "fields": []
3811
+ },
3812
+ "response": {
3813
+ "status": 200,
3814
+ "shape": "object",
3815
+ "fields": [
3816
+ {
3817
+ "name": "ok",
3818
+ "type": "boolean",
3819
+ "required": true
3820
+ },
3821
+ {
3822
+ "name": "builtin",
3823
+ "type": "array<object{id:string,label:string,network:boolean,workspaceWritable:boolean,toolRootsWritable:boolean,writeRoots:string[],allPaths:boolean,unsandboxed:boolean,toolDeny:string[]}>",
3824
+ "required": true
3825
+ },
3826
+ {
3827
+ "name": "custom",
3828
+ "type": "array<object{id:string,label:string,network:boolean,workspaceWritable:boolean,toolRootsWritable:boolean,writeRoots:string[],allPaths:boolean,unsandboxed:boolean,toolDeny:string[]}>",
3829
+ "required": true
3830
+ },
3831
+ {
3832
+ "name": "max",
3833
+ "type": "string",
3834
+ "required": true,
3835
+ "note": "CELESTEA_PERMISSION_MAX (the clamp ceiling)"
3836
+ }
3837
+ ]
3838
+ },
3839
+ "errors": [],
3840
+ "notes": [
3841
+ "Built-ins are code constants (read-only/write-read/full-access); custom presets live at <data dir>/permissions.json.",
3842
+ "W864: allPaths is the one PATH-only capability. full-access sets it, so a session on that baseline reads and writes the whole filesystem (the effective read/write roots become [/]); CELESTEA_PERMISSION_MAX clamps it like every other boolean and a non-allPaths max narrows it away."
3843
+ ],
3844
+ "probe": {
3845
+ "checked": false,
3846
+ "reason": "TypeScript-only endpoint"
3847
+ }
3848
+ },
3849
+ {
3850
+ "id": "post_permissions_presets",
3851
+ "method": "POST",
3852
+ "path": "/api/permissions/presets",
3853
+ "group": "permissions",
3854
+ "rustHandler": "(none - TypeScript-only, W9)",
3855
+ "docRef": "docs/feature-session-permissions.md#w9",
3856
+ "request": {
3857
+ "kind": "json",
3858
+ "fields": [
3859
+ {
3860
+ "name": "preset",
3861
+ "type": "object{id:string,label:string,network:boolean,workspaceWritable:boolean,toolRootsWritable:boolean,writeRoots:string[],allPaths:boolean,unsandboxed:boolean,toolDeny:string[]}",
3862
+ "required": true
3863
+ }
3864
+ ]
3865
+ },
3866
+ "response": {
3867
+ "status": 200,
3868
+ "shape": "object",
3869
+ "fields": [
3870
+ {
3871
+ "name": "ok",
3872
+ "type": "boolean",
3873
+ "required": true
3874
+ },
3875
+ {
3876
+ "name": "preset",
3877
+ "type": "object{id:string,label:string,network:boolean,workspaceWritable:boolean,toolRootsWritable:boolean,writeRoots:string[],allPaths:boolean,unsandboxed:boolean,toolDeny:string[]}",
3878
+ "required": true
3879
+ }
3880
+ ]
3881
+ },
3882
+ "errors": [
3883
+ {
3884
+ "status": 409,
3885
+ "error": "preset '{id}' already exists"
3886
+ },
3887
+ {
3888
+ "status": 422,
3889
+ "error": "invalid preset: {reason}"
3890
+ }
3891
+ ],
3892
+ "notes": [
3893
+ "Atomic 0600 write to <data dir>/permissions.json; a built-in id is rejected."
3894
+ ],
3895
+ "probe": {
3896
+ "checked": false,
3897
+ "reason": "TypeScript-only endpoint"
3898
+ }
3899
+ },
3900
+ {
3901
+ "id": "put_permissions_preset",
3902
+ "method": "PUT",
3903
+ "path": "/api/permissions/presets/{id}",
3904
+ "group": "permissions",
3905
+ "rustHandler": "(none - TypeScript-only, W9)",
3906
+ "docRef": "docs/feature-session-permissions.md#w9",
3907
+ "request": {
3908
+ "kind": "json",
3909
+ "fields": [
3910
+ {
3911
+ "name": "preset",
3912
+ "type": "object{id:string,label:string,network:boolean,workspaceWritable:boolean,toolRootsWritable:boolean,writeRoots:string[],allPaths:boolean,unsandboxed:boolean,toolDeny:string[]}",
3913
+ "required": true
3914
+ }
3915
+ ]
3916
+ },
3917
+ "response": {
3918
+ "status": 200,
3919
+ "shape": "object",
3920
+ "fields": [
3921
+ {
3922
+ "name": "ok",
3923
+ "type": "boolean",
3924
+ "required": true
3925
+ },
3926
+ {
3927
+ "name": "preset",
3928
+ "type": "object{id:string,label:string,network:boolean,workspaceWritable:boolean,toolRootsWritable:boolean,writeRoots:string[],allPaths:boolean,unsandboxed:boolean,toolDeny:string[]}",
3929
+ "required": true
3930
+ }
3931
+ ]
3932
+ },
3933
+ "errors": [
3934
+ {
3935
+ "status": 404,
3936
+ "error": "no custom preset '{id}'"
3937
+ },
3938
+ {
3939
+ "status": 409,
3940
+ "error": "'{id}' is a built-in preset"
3941
+ },
3942
+ {
3943
+ "status": 422,
3944
+ "error": "invalid preset: {reason}"
3945
+ }
3946
+ ],
3947
+ "notes": [
3948
+ "Built-in presets are read-only."
3949
+ ],
3950
+ "probe": {
3951
+ "checked": false,
3952
+ "reason": "TypeScript-only endpoint"
3953
+ }
3954
+ },
3955
+ {
3956
+ "id": "delete_permissions_preset",
3957
+ "method": "DELETE",
3958
+ "path": "/api/permissions/presets/{id}",
3959
+ "group": "permissions",
3960
+ "rustHandler": "(none - TypeScript-only, W9)",
3961
+ "docRef": "docs/feature-session-permissions.md#w9",
3962
+ "request": {
3963
+ "kind": "none",
3964
+ "fields": []
3965
+ },
3966
+ "response": {
3967
+ "status": 200,
3968
+ "shape": "object",
3969
+ "fields": [
3970
+ {
3971
+ "name": "ok",
3972
+ "type": "boolean",
3973
+ "required": true
3974
+ },
3975
+ {
3976
+ "name": "deleted",
3977
+ "type": "string",
3978
+ "required": true
3979
+ }
3980
+ ]
3981
+ },
3982
+ "errors": [
3983
+ {
3984
+ "status": 404,
3985
+ "error": "no custom preset '{id}'"
3986
+ },
3987
+ {
3988
+ "status": 409,
3989
+ "error": "'{id}' is a built-in preset"
3990
+ }
3991
+ ],
3992
+ "notes": [],
3993
+ "probe": {
3994
+ "checked": false,
3995
+ "reason": "TypeScript-only endpoint"
3996
+ }
3997
+ },
3998
+ {
3999
+ "id": "get_session_permission",
4000
+ "method": "GET",
4001
+ "path": "/api/sessions/{id}/permission",
4002
+ "group": "permissions",
4003
+ "rustHandler": "(none - TypeScript-only, W9)",
4004
+ "docRef": "docs/feature-session-permissions.md#w9",
4005
+ "request": {
4006
+ "kind": "none",
4007
+ "fields": []
4008
+ },
4009
+ "response": {
4010
+ "status": 200,
4011
+ "shape": "object",
4012
+ "fields": [
4013
+ {
4014
+ "name": "ok",
4015
+ "type": "boolean",
4016
+ "required": true
4017
+ },
4018
+ {
4019
+ "name": "session",
4020
+ "type": "string",
4021
+ "required": true
4022
+ },
4023
+ {
4024
+ "name": "preset",
4025
+ "type": "string",
4026
+ "required": true,
4027
+ "note": "the effective preset id (after the MAX clamp)"
4028
+ },
4029
+ {
4030
+ "name": "effective",
4031
+ "type": "object{network:boolean,workspaceWritable:boolean,toolRootsWritable:boolean,writeRoots:string[],allPaths:boolean,unsandboxed:boolean,toolDeny:string[]}",
4032
+ "required": true
4033
+ },
4034
+ {
4035
+ "name": "warnings",
4036
+ "type": "string[]",
4037
+ "required": false
4038
+ }
4039
+ ]
4040
+ },
4041
+ "errors": [
4042
+ {
4043
+ "status": 404,
4044
+ "error": "unknown session '{id}'"
4045
+ }
4046
+ ],
4047
+ "notes": [
4048
+ "No permission.json -> the effective preset is CELESTEA_PERMISSION_DEFAULT (full-access).",
4049
+ "worker:<sid> has no directory and is rejected by session resolution."
4050
+ ],
4051
+ "probe": {
4052
+ "checked": false,
4053
+ "reason": "TypeScript-only endpoint"
4054
+ }
4055
+ },
4056
+ {
4057
+ "id": "put_session_permission",
4058
+ "method": "PUT",
4059
+ "path": "/api/sessions/{id}/permission",
4060
+ "group": "permissions",
4061
+ "rustHandler": "(none - TypeScript-only, W9)",
4062
+ "docRef": "docs/feature-session-permissions.md#w9",
4063
+ "request": {
4064
+ "kind": "json",
4065
+ "fields": [
4066
+ {
4067
+ "name": "preset",
4068
+ "type": "string",
4069
+ "required": true
4070
+ }
4071
+ ]
4072
+ },
4073
+ "response": {
4074
+ "status": 200,
4075
+ "shape": "object",
4076
+ "fields": [
4077
+ {
4078
+ "name": "ok",
4079
+ "type": "boolean",
4080
+ "required": true
4081
+ },
4082
+ {
4083
+ "name": "preset",
4084
+ "type": "string",
4085
+ "required": true
4086
+ },
4087
+ {
4088
+ "name": "effective",
4089
+ "type": "object",
4090
+ "required": true
4091
+ }
4092
+ ]
4093
+ },
4094
+ "errors": [
4095
+ {
4096
+ "status": 404,
4097
+ "error": "unknown session '{id}'"
4098
+ },
4099
+ {
4100
+ "status": 422,
4101
+ "error": "unknown preset '{preset}'"
4102
+ }
4103
+ ],
4104
+ "notes": [
4105
+ "Atomic 0600 write to <session dir>/permission.json (parallel to grants.json)."
4106
+ ],
4107
+ "probe": {
4108
+ "checked": false,
4109
+ "reason": "TypeScript-only endpoint"
4110
+ }
4111
+ },
4112
+ {
4113
+ "id": "get_session_tools",
4114
+ "method": "GET",
4115
+ "path": "/api/sessions/{id}/tools",
4116
+ "group": "permissions",
4117
+ "rustHandler": "(none - TypeScript-only, W860)",
4118
+ "docRef": "docs/feature-session-permissions.md#w860",
4119
+ "request": {
4120
+ "kind": "none",
4121
+ "fields": []
4122
+ },
4123
+ "response": {
4124
+ "status": 200,
4125
+ "shape": "object",
4126
+ "fields": [
4127
+ {
4128
+ "name": "ok",
4129
+ "type": "boolean",
4130
+ "required": true
4131
+ },
4132
+ {
4133
+ "name": "session",
4134
+ "type": "string",
4135
+ "required": true
4136
+ },
4137
+ {
4138
+ "name": "disabled",
4139
+ "type": "string[]",
4140
+ "required": true,
4141
+ "note": "W860: the session's OWN disabled tool names, normalized (trimmed, blank-free, deduped, first-occurrence order); [] when no tools.json exists"
4142
+ },
4143
+ {
4144
+ "name": "effective",
4145
+ "type": "object{toolDeny:string[]}",
4146
+ "required": true,
4147
+ "note": "W860: the permission preset's toolDeny UNIONED with disabled (preset first, session second, deduped) — read back through the engine's one grant reader, so it is exactly what the next turn's disclosure policy blocks"
4148
+ },
4149
+ {
4150
+ "name": "warnings",
4151
+ "type": "string[]",
4152
+ "required": false
4153
+ }
4154
+ ]
4155
+ },
4156
+ "errors": [
4157
+ {
4158
+ "status": 404,
4159
+ "error": "unknown session '{id}'"
4160
+ }
4161
+ ],
4162
+ "notes": [
4163
+ "W860: pure SUBTRACTION — a disabled name can only remove a tool from the session face; it can never add one back (the execution-mode fold stays folded).",
4164
+ "Fail-closed read: a void <session dir>/tools.json (bad JSON / wrong version / another session / disabled not a string[]) is ignored with ONE warning and the session runs with no tools disabled.",
4165
+ "effective.toolDeny is the union that engine-grants.ts hands to the composed instance, so GET /api/tools?session= and adapter.sessionContext() report the same face."
4166
+ ],
4167
+ "probe": {
4168
+ "checked": false,
4169
+ "reason": "TypeScript-only endpoint"
4170
+ }
4171
+ },
4172
+ {
4173
+ "id": "put_session_tools",
4174
+ "method": "PUT",
4175
+ "path": "/api/sessions/{id}/tools",
4176
+ "group": "permissions",
4177
+ "rustHandler": "(none - TypeScript-only, W860)",
4178
+ "docRef": "docs/feature-session-permissions.md#w860",
4179
+ "request": {
4180
+ "kind": "json",
4181
+ "fields": [
4182
+ {
4183
+ "name": "disabled",
4184
+ "type": "string[]",
4185
+ "required": true,
4186
+ "note": "tool names to disable for THIS session; every element must be a non-empty string (422 otherwise), then trimmed + deduped before the write"
4187
+ }
4188
+ ]
4189
+ },
4190
+ "response": {
4191
+ "status": 200,
4192
+ "shape": "object",
4193
+ "fields": [
4194
+ {
4195
+ "name": "ok",
4196
+ "type": "boolean",
4197
+ "required": true
4198
+ },
4199
+ {
4200
+ "name": "session",
4201
+ "type": "string",
4202
+ "required": true
4203
+ },
4204
+ {
4205
+ "name": "disabled",
4206
+ "type": "string[]",
4207
+ "required": true,
4208
+ "note": "the normalized list that was persisted"
4209
+ },
4210
+ {
4211
+ "name": "effective",
4212
+ "type": "object{toolDeny:string[]}",
4213
+ "required": true,
4214
+ "note": "the preset deny unioned with the new disabled list"
4215
+ },
4216
+ {
4217
+ "name": "warnings",
4218
+ "type": "string[]",
4219
+ "required": false
4220
+ }
4221
+ ]
4222
+ },
4223
+ "errors": [
4224
+ {
4225
+ "status": 404,
4226
+ "error": "unknown session '{id}'"
4227
+ },
4228
+ {
4229
+ "status": 422,
4230
+ "error": "field 'disabled' must be an array of strings"
4231
+ },
4232
+ {
4233
+ "status": 422,
4234
+ "error": "field 'disabled' must not contain an empty tool name"
4235
+ }
4236
+ ],
4237
+ "notes": [
4238
+ "Atomic 0600 write to <session dir>/tools.json (parallel to permission.json and grants.json).",
4239
+ "Calls RuntimeAdapter.invalidateSession(id): the denials take effect at the next turn boundary, exactly like PUT /api/sessions/{id}/permission."
4240
+ ],
4241
+ "probe": {
4242
+ "checked": false,
4243
+ "reason": "TypeScript-only endpoint"
4244
+ }
4245
+ },
4246
+ {
4247
+ "id": "get_plugins",
4248
+ "method": "GET",
4249
+ "path": "/api/plugins",
4250
+ "group": "plugins",
4251
+ "rustHandler": "(none - TypeScript-only, W860)",
4252
+ "docRef": "docs/feature-session-permissions.md#w860",
4253
+ "request": {
4254
+ "kind": "none",
4255
+ "fields": []
4256
+ },
4257
+ "response": {
4258
+ "status": 200,
4259
+ "shape": "object",
4260
+ "fields": [
4261
+ {
4262
+ "name": "ok",
4263
+ "type": "boolean",
4264
+ "required": true
4265
+ },
4266
+ {
4267
+ "name": "plugins",
4268
+ "type": "array<{name:string,layer:string,hot:boolean}>",
4269
+ "required": true,
4270
+ "note": "W860: the plugins THIS process mounted at startup, in mount order; layer is always \"host\" and hot always false"
4271
+ }
4272
+ ]
4273
+ },
4274
+ "errors": [],
4275
+ "notes": [
4276
+ "W860: the names come from composeStudio recording pluginNames(storePlugins(...)) + pluginNames(hostPlugins(...)) at mount time — never a hand-maintained constant list.",
4277
+ "BOUNDARY: host STARTUP plugins only (the five store plugins + the three host singletons studio/bus, studio/runtime, studio/settings). Plugins the ENGINE mounts while composing a session are NOT part of this inventory."
4278
+ ],
4279
+ "probe": {
4280
+ "checked": false,
4281
+ "reason": "TypeScript-only endpoint"
4282
+ }
4283
+ },
4284
+ {
4285
+ "id": "post_exec",
4286
+ "method": "POST",
4287
+ "path": "/api/exec",
4288
+ "group": "exec",
4289
+ "rustHandler": "(none - TypeScript-only, G2)",
4290
+ "docRef": "contracts/endpoints.json#post_exec",
4291
+ "request": {
4292
+ "kind": "json",
4293
+ "fields": [
4294
+ {
4295
+ "name": "command",
4296
+ "type": "string",
4297
+ "required": true,
4298
+ "note": "shell command line; runs immediately, never through the model"
4299
+ },
4300
+ {
4301
+ "name": "session",
4302
+ "type": "string",
4303
+ "required": false,
4304
+ "note": "session whose permission preset + sandbox scope apply; omitted = the detached scope"
4305
+ },
4306
+ {
4307
+ "name": "workdir",
4308
+ "type": "string",
4309
+ "required": false,
4310
+ "note": "optional cwd inside the sandbox root"
4311
+ },
4312
+ {
4313
+ "name": "timeout_ms",
4314
+ "type": "integer",
4315
+ "required": false,
4316
+ "note": "per-call deadline, bounded by the sandbox max"
4317
+ }
4318
+ ]
4319
+ },
4320
+ "response": {
4321
+ "status": 200,
4322
+ "shape": "object",
4323
+ "fields": [
4324
+ {
4325
+ "name": "ok",
4326
+ "type": "boolean",
4327
+ "required": true
4328
+ },
4329
+ {
4330
+ "name": "exit_code",
4331
+ "type": "integer|null",
4332
+ "required": true
4333
+ },
4334
+ {
4335
+ "name": "signal",
4336
+ "type": "string|null",
4337
+ "required": true
4338
+ },
4339
+ {
4340
+ "name": "stdout",
4341
+ "type": "string",
4342
+ "required": true,
4343
+ "note": "capped at the sandbox output limit"
4344
+ },
4345
+ {
4346
+ "name": "stderr",
4347
+ "type": "string",
4348
+ "required": true
4349
+ },
4350
+ {
4351
+ "name": "duration_ms",
4352
+ "type": "integer",
4353
+ "required": true
4354
+ },
4355
+ {
4356
+ "name": "sandbox",
4357
+ "type": "object",
4358
+ "required": true,
4359
+ "note": "contract fields only: provider, net_isolated, tmp_private, seccomp, optional cpu_sec"
4360
+ }
4361
+ ]
4362
+ },
4363
+ "errors": [
4364
+ {
4365
+ "status": 403,
4366
+ "error": "permission preset denies run_shell (code=shell_denied)"
4367
+ },
4368
+ {
4369
+ "status": 400,
4370
+ "error": "structured sandbox refusal (policy fail, bad workdir, timeout arg, output cap)"
4371
+ },
4372
+ {
4373
+ "status": 404,
4374
+ "error": "unknown session"
4375
+ },
4376
+ {
4377
+ "status": 422,
4378
+ "error": "field command must be a non-empty string"
4379
+ }
4380
+ ],
4381
+ "notes": [
4382
+ "G2 (docs/iteration-g-workbench.md section 1): /run and ! execute immediately and never touch /api/turn or the model. Reuses the run_shell execution path (selectSandboxDetailed + sessionSandboxConfig + sanitized env + output cap); a preset that denies run_shell, or a sandbox policy that refuses, is a structured refusal. NOT a tool: absent from contracts/tools.json and the model-visible face."
4383
+ ],
4384
+ "probe": {
4385
+ "checked": false,
4386
+ "reason": "TypeScript-only endpoint"
4387
+ }
4388
+ }
4389
+ ]
4390
+ }