mandala-computer 0.2.0__tar.gz → 0.4.0__tar.gz

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 (59) hide show
  1. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/.github/workflows/ci.yml +17 -0
  2. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/.gitignore +3 -0
  3. mandala_computer-0.4.0/CHANGELOG.md +78 -0
  4. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/PKG-INFO +290 -64
  5. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/README.md +289 -63
  6. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/pyproject.toml +1 -1
  7. mandala_computer-0.4.0/scripts/check_internals.py +215 -0
  8. mandala_computer-0.4.0/scripts/check_surface.py +546 -0
  9. mandala_computer-0.4.0/scripts/internal-names.sha256 +2550 -0
  10. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/__init__.py +1 -1
  11. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_agent.py +26 -8
  12. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_api.py +72 -51
  13. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_async_computer.py +87 -25
  14. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_async_resources.py +19 -1
  15. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_cli.py +2 -3
  16. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_client.py +48 -21
  17. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_computer.py +227 -40
  18. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_events.py +3 -4
  19. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_exceptions.py +41 -8
  20. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_models.py +21 -15
  21. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_resources.py +24 -6
  22. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_sse.py +22 -8
  23. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_webhooks.py +25 -17
  24. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/surface_tables.py +2 -1
  25. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_adversarial_values.py +38 -0
  26. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_agent.py +111 -3
  27. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_async_client.py +75 -6
  28. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_building.py +27 -0
  29. mandala_computer-0.4.0/tests/test_check_internals.py +144 -0
  30. mandala_computer-0.4.0/tests/test_check_surface.py +581 -0
  31. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_cli.py +2 -2
  32. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_client.py +158 -32
  33. mandala_computer-0.4.0/tests/test_guest_deadline.py +194 -0
  34. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_sse.py +64 -0
  35. mandala_computer-0.4.0/tests/test_sse_transport_chunks.py +117 -0
  36. mandala_computer-0.4.0/tests/test_start.py +214 -0
  37. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_surface.py +17 -13
  38. mandala_computer-0.4.0/tests/test_template_transfer.py +270 -0
  39. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_templates.py +9 -8
  40. mandala_computer-0.2.0/scripts/check_surface.py +0 -653
  41. mandala_computer-0.2.0/scripts/surface_text.py +0 -232
  42. mandala_computer-0.2.0/tests/test_check_surface.py +0 -345
  43. mandala_computer-0.2.0/tests/test_surface_parser.py +0 -258
  44. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/.github/workflows/release.yml +0 -0
  45. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/LICENSE +0 -0
  46. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/SECURITY.md +0 -0
  47. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/scripts/smoke_events.py +0 -0
  48. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/py.typed +0 -0
  49. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/surface_inventory.py +0 -0
  50. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_api_contract.py +0 -0
  51. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_async_building.py +0 -0
  52. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_events.py +0 -0
  53. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_moves.py +0 -0
  54. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_parity.py +0 -0
  55. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_retention.py +0 -0
  56. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_smoke_events.py +0 -0
  57. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_surface_inventory.py +0 -0
  58. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_usage.py +0 -0
  59. {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_webhooks.py +0 -0
@@ -19,6 +19,10 @@ jobs:
19
19
  runs-on: ubuntu-latest
20
20
  steps:
21
21
  - uses: actions/checkout@v4
22
+ with:
23
+ # The commit-message half of check_internals needs the base branch to
24
+ # diff against; the default shallow clone has no origin/main.
25
+ fetch-depth: 0
22
26
  - uses: actions/setup-python@v5
23
27
  with:
24
28
  python-version: "3.12"
@@ -27,6 +31,19 @@ jobs:
27
31
  - run: ruff check .
28
32
  - run: ruff format --check .
29
33
  - run: mypy
34
+ # This repo is public and the platform it speaks to is not. Every batch of
35
+ # client fixes is written by reading that platform, and the reading leaves
36
+ # fingerprints — file names, internal function names — in comments that
37
+ # SHIP. OPL-4613 and OPL-4617 scrubbed two of these repos by hand and left
38
+ # nothing behind to catch the next one; this is that (OPL-4635).
39
+ #
40
+ # Both halves. A commit message is public the moment it is pushed, and
41
+ # three of the five references found in the batch that prompted this were
42
+ # in messages and PR descriptions rather than in files — which the file
43
+ # scan cannot see. Needs the full history, hence fetch-depth above.
44
+ - run: python3 scripts/check_internals.py
45
+ - run: python3 scripts/check_internals.py --messages origin/${{ github.event.pull_request.base.ref || 'main' }}..HEAD
46
+ if: github.event_name == 'pull_request'
30
47
 
31
48
  test:
32
49
  runs-on: ubuntu-latest
@@ -21,3 +21,6 @@ htmlcov/
21
21
 
22
22
  # Reports written by the /grok-bug-hunt skill.
23
23
  .grok-bug-hunt/
24
+
25
+ # Adversarial review notes written by the local fix workflow.
26
+ .fix-lanes/
@@ -0,0 +1,78 @@
1
+ # Changelog
2
+
3
+ Notable changes to `mandala-computer`. Dates are release dates; the format
4
+ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
5
+ project is pre-1.0, so a minor version may carry a behaviour change.
6
+
7
+ The reasoning behind a change lives in its commit message rather than here.
8
+ This is the summary you read to decide whether to upgrade.
9
+
10
+ ## [0.4.0] — 2026-09-14
11
+
12
+ ### Added
13
+
14
+ - **`Computer.read_text_file()` and `AsyncComputer.read_text_file()`** — the
15
+ same request `read_file()` makes, decoded as UTF-8. `read_file()` stays the
16
+ primitive and stays `bytes`, because a guest file is not promised to be text
17
+ and decoding one that is not would put replacement characters at the SDK
18
+ boundary for every caller. The decode is `errors="replace"`, on the same
19
+ terms as `ExecResult.stdout_text`: the caller asked for text, and the bytes
20
+ are one call away.
21
+
22
+ ### Changed
23
+
24
+ - **An empty clipboard write now clears the selection** instead of being
25
+ refused locally. `set_clipboard("")` is a thing the platform accepts and a
26
+ thing callers want; refusing it here was this SDK inventing a rule.
27
+ - **`reason: "revoked"` is classified as permanent.** The platform added a
28
+ fifth refusal word for the case where the authority a request arrived with no
29
+ longer holds — suspended, demoted, removed, or a retired session — and it is
30
+ the first of these words about the *caller* rather than about a computer.
31
+ Nothing was broken before this: a 401 or 403 is none of the four transient
32
+ classes, so an unrecognised word already answered `False` from
33
+ `is_transient()`. What changes is that it is answered on purpose, so a future
34
+ status for this refusal cannot quietly make a permission failure look
35
+ replayable. The status still says what to do: 401 means present a credential
36
+ again, 403 means the role changed and signing in again will not help.
37
+
38
+ ### Fixed
39
+
40
+ - **A mid-run refusal no longer discards the partial agent run.** Authorization
41
+ is rechecked before each further piece of work, so a non-streaming
42
+ `agent()` can be stopped after steps have already run on the desktop and
43
+ already cost model tokens — and the refusal body is the only place either is
44
+ ever reported. Those steps and that usage now survive the error.
45
+ - **The documented install works on a Homebrew or distribution Python.** The
46
+ README opened with `pip install mandala-computer`, which answers
47
+ `externally-managed-environment` and installs nothing on the ordinary
48
+ developer Mac (PEP 668). The install guidance now leads with a virtual
49
+ environment rather than with a command that fails.
50
+
51
+ ### Documentation
52
+
53
+ - **A run's steps are not its model calls.** Three places said they were and
54
+ offered `max_steps` as a way to size Anthropic spend on that basis. The
55
+ platform counts a step per *tool call* and encourages a model to ask for
56
+ several actions in one reply, so one model call can spend several steps; a
57
+ paused turn is resubmitted for tokens and no step; and a bash call or a
58
+ cursor read takes no screenshot. A caller budgeting from the old sentence was
59
+ wrong whichever way their run went. The README also now states the default,
60
+ which nothing here did: omit `max_steps` and you get 20, and the ceiling
61
+ is 100.
62
+ - Webhook idempotency and API usage guidance clarified, and the template
63
+ section restored the two refusals on the custom-build path that never clear.
64
+
65
+ ### Internal
66
+
67
+ No effect on the published surface, listed because it is most of the window.
68
+
69
+ - The drift check reads the platform's published **surface manifest** instead
70
+ of scanning its TypeScript and Go as text. Eleven hundred lines of
71
+ hand-written reader are gone, along with the class of defect they kept
72
+ producing: a false all-clear, reporting the mirror in step because the scan
73
+ had silently read nothing. The new reader fails closed on a manifest that is
74
+ missing, unparseable, of an unknown version, or that names a key twice.
75
+ - Surface inventory parsing hardened in the same direction, before the scanner
76
+ was retired.
77
+
78
+ [0.4.0]: https://github.com/mandalacomputer/python-sdk/compare/v0.3.0...v0.4.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mandala-computer
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Python SDK for Mandala Computer — cloud desktops for AI agents
5
5
  Project-URL: Homepage, https://mandala.computer
6
6
  Project-URL: Source, https://github.com/mandalacomputer/python-sdk
@@ -61,6 +61,28 @@ pip install mandala-computer
61
61
  Python 3.10 or newer. The install also puts a `mandala` command on your PATH —
62
62
  see [The `mandala` CLI](#the-mandala-cli).
63
63
 
64
+ If that command answers `error: externally-managed-environment`, nothing is
65
+ wrong: the Python you are asking is managed by something else — Homebrew on a
66
+ Mac, the distribution's package manager on Linux — and refuses to install into
67
+ itself ([PEP 668](https://peps.python.org/pep-0668/)). Install into a virtual
68
+ environment instead:
69
+
70
+ ```sh
71
+ python3 -m venv .venv
72
+ source .venv/bin/activate # fish: source .venv/bin/activate.fish
73
+ pip install mandala-computer
74
+ ```
75
+
76
+ For the `mandala` command on your PATH rather than the library in a project,
77
+ let a tool manager own the environment:
78
+
79
+ ```sh
80
+ pipx install mandala-computer # or: uv tool install mandala-computer
81
+ ```
82
+
83
+ Do not reach for `--break-system-packages`. It does what it says to the Python
84
+ the rest of your system is depending on.
85
+
64
86
  ## Use
65
87
 
66
88
  Authentication is an API key from the dashboard (Settings → API keys), read from
@@ -165,6 +187,76 @@ t = client.templates.publish(doc)
165
187
  c = client.computers.create(template=t.ref)
166
188
  ```
167
189
 
190
+ When `create()` returns a 409 with `body["code"] == "template_image_preparing"`,
191
+ inspect `body["preparation"]`: it retains the server's `state` and `error`,
192
+ including failed preparation. A failure needs diagnosis before a deliberate
193
+ retry. `APIError.retry_after` preserves the `Retry-After` header as seconds,
194
+ including HTTP dates; absent or malformed metadata is `None`.
195
+
196
+ For an explicit continuation, wait the returned delay, repeat **all original
197
+ create arguments, including the same template**, and add the returned
198
+ `template_transfer` token. The token and template must be nonempty strings;
199
+ `size` cannot be combined with a token. An opaque token is sent unchanged,
200
+ including surrounding whitespace. A missing or different template can produce
201
+ `NotFoundError`. The token preserves the selected build, **not create
202
+ idempotency**: stop after success and never blindly retry an ambiguous or lost
203
+ response. `is_transient()` returns `False` for this preparation refusal,
204
+ including preparation still in progress.
205
+
206
+ This helper accepts only a preparation response with usable continuation
207
+ metadata. It surfaces failed preparation for diagnosis:
208
+
209
+ ```python
210
+ def preparation_retry(error):
211
+ body = error.body
212
+ if not isinstance(body, dict) or body.get("code") != "template_image_preparing":
213
+ raise error
214
+ preparation = body.get("preparation")
215
+ token = body.get("template_transfer")
216
+ if (
217
+ not isinstance(preparation, dict)
218
+ or preparation.get("state") not in ("preparing", "copying", "ready")
219
+ or not isinstance(token, str)
220
+ or not token.strip()
221
+ or error.retry_after is None
222
+ ):
223
+ raise error
224
+ return error.retry_after, token
225
+ ```
226
+
227
+ A synchronous caller can make one deliberate continuation attempt:
228
+
229
+ ```python
230
+ import time
231
+ from mandala_computer import ConflictError
232
+
233
+ original = {"template": t.ref, "name": "dev", "cpu": 4, "ram_mb": 8192}
234
+ try:
235
+ c = client.computers.create(**original)
236
+ except ConflictError as error:
237
+ delay, token = preparation_retry(error)
238
+ time.sleep(delay)
239
+ c = client.computers.create(**original, template_transfer=token)
240
+ ```
241
+
242
+ The async equivalent uses the same original arguments and validation:
243
+
244
+ ```python
245
+ import asyncio
246
+ from mandala_computer import ConflictError
247
+
248
+ original = {"template": t.ref, "name": "dev", "cpu": 4, "ram_mb": 8192}
249
+ try:
250
+ c = await client.computers.create(**original)
251
+ except ConflictError as error:
252
+ delay, token = preparation_retry(error)
253
+ await asyncio.sleep(delay)
254
+ c = await client.computers.create(**original, template_transfer=token)
255
+ ```
256
+
257
+ A second refusal is surfaced in both examples so the caller can inspect the
258
+ new state and decide whether another bounded continuation is appropriate.
259
+
168
260
  **The namespace is your account.** `metadata.namespace` has to be your account
169
261
  id — anything else is a `PermissionDeniedError`, `system` included — and this SDK
170
262
  does not rewrite it, because publishing a ref that is not the one in your file
@@ -295,15 +387,35 @@ normally share an image, which is what makes a repeated build cheap;
295
387
  `start(doc, no_reuse=True)` builds again even when an image already carries this
296
388
  document's build digest.
297
389
 
298
- **A build that declares its own family is not launchable yet.** The fleet does
299
- not advertise a family it built rather than shipped, so a create naming such a
300
- ref is refused with a `400` — a bare `APIError`, and a permanent answer: the
301
- message says in words that retrying the create changes nothing and that what
302
- would change it is publishing a new version. Deliberately not a `503`, which
303
- arrives as `UnavailableError` and reads to a retry loop as an answer worth
304
- waiting for. A `503` on this path still means the case that does come good: a
305
- shipped family whose only holder is unreachable. Publishing the document is
306
- worth doing anyway — it claims the ref, and it is what `builds.start()` takes.
390
+ After a successful build, launch the published template with
391
+ `client.computers.create(template=t.ref)`. A pinned template version selects
392
+ the latest successful image built from that exact document; an unpinned ref
393
+ first selects the newest published template version. Existing computers keep
394
+ their original image when a later build becomes available.
395
+
396
+ A create may need to prepare that image before it can launch. Follow the
397
+ [template preparation guidance](#your-own-templates) for a
398
+ `template_image_preparing` response: inspect its state and error, then make a
399
+ deliberate continuation with the returned delay and token and all original
400
+ create arguments. For other image refusals, inspect the response before deciding
401
+ whether to rebuild or retry.
402
+
403
+ Two of those refusals never clear, and both arrive as a `400` — a bare
404
+ `APIError` rather than the `UnavailableError` a `503` becomes, because a retry
405
+ loop reads a `503` as an answer worth waiting for and these are not. A document
406
+ that builds into a family this account may not build into is one: no hypervisor
407
+ will launch it, and what changes the answer is publishing a new version, not
408
+ sending the create again. A document that builds nothing and names an account
409
+ family is the other: a create is served only from the families that ship with
410
+ the product.
411
+
412
+ The refusals beside them mean something else. A `409` — `ConflictError` — says
413
+ no verified image for this exact document is available yet: finish the build,
414
+ or restore an image that has gone missing or is still being verified. A `503`
415
+ — `UnavailableError` — says no hypervisor has an image for that template *right
416
+ now*, which is the one to retry as it stands. A create can answer `503` for
417
+ capacity as well, and that one is worth a smaller size rather than only a
418
+ wait.
307
419
 
308
420
  Everything here has an async twin: `await client.templates.publish(doc)`,
309
421
  `await client.builds.wait(build.id)`, and `async for p in client.builds.events(...)`.
@@ -322,6 +434,22 @@ c.start() # resumes it; ~1s, not a boot
322
434
  c.stop() # discards the session instead
323
435
  ```
324
436
 
437
+ `start(resume_only=True)` resumes a saved session without cold-booting a stopped
438
+ computer. If no saved session remains, the request succeeds and the computer
439
+ stays stopped. The method refreshes and returns the computer; check its state
440
+ when you need to know whether it is running. The async client accepts the same
441
+ keyword. Omit it (or pass `False`) for the ordinary start behavior.
442
+
443
+ After a stopped no-op, do not chain `wait_until_running()` or `wait_for_guest()`.
444
+ Both reject a stopped computer the platform reports it is holding nothing for,
445
+ which is the ordinary case here; where it reports nothing at all they wait
446
+ instead, and spend the whole timeout. Call ordinary `start()` if you intend to
447
+ boot the computer.
448
+
449
+ This option requires platform support for `resume_only`. Older servers may
450
+ ignore the parameter and cold-boot a stopped computer; the SDK cannot infer
451
+ support from a successful response.
452
+
325
453
  `stop()` asks the guest to shut down and gives it time to. `stop(force=True)`
326
454
  skips the asking and pulls the power — the equivalent of holding the button in.
327
455
  It is what to reach for when a guest will not come down on its own, and it loses
@@ -340,8 +468,12 @@ next: `screenshot()` keeps answering, while `clipboard()` starts raising
340
468
  `ConflictError`, because a suspended computer has no clipboard to read. Drive
341
469
  the desktop, or accept the resume.
342
470
 
343
- `wait_until_running()` raises rather than spinning if it finds a suspended
344
- computer, because that state does not resolve on its own — `start()` is the fix.
471
+ `wait_until_running()` raises rather than spinning if it finds a suspended or
472
+ stopped computer **that nobody is starting**, because that state does not
473
+ resolve on its own — `start()` is the fix. One whose start the platform has
474
+ already admitted is waited for: a boot reads as stopped and a resume as
475
+ suspended until the guest process exists, so those two statuses do not say on
476
+ their own whether anything is coming.
345
477
 
346
478
  ### Showing somebody the desktop
347
479
 
@@ -843,8 +975,8 @@ on_clipboard = c.clipboard() # "" is an empty clipboard
843
975
  `set_clipboard()` takes at most 64 KiB of UTF-8; `clipboard()` returns at most
844
976
  128 KiB. They are different bounds on different channels, and the read is
845
977
  **refused rather than truncated** past its own — half a password is not less of
846
- an answer, it is a wrong one that looks completely normal. Empty text and a NUL
847
- are refused here, before the request goes out.
978
+ an answer, it is a wrong one that looks completely normal. Empty text clears the
979
+ clipboard; a NUL is refused here, before the request goes out.
848
980
 
849
981
  The platform confirms the write by reading the selection back before it
850
982
  answers, so `set_clipboard()` returning means the desktop is *holding* the text
@@ -854,10 +986,12 @@ rather than that a command ran.
854
986
  an `APIError.reason`: `contention` and `starting` clear on their own, while
855
987
  `unavailable` and `unsupported` require a different action. `is_transient()`
856
988
  therefore answers `True` for the first pair and `False` for the second. A
857
- stopped or suspended computer is `unavailable`; start it instead of retrying
858
- the clipboard request. If an older platform response has no recognised reason,
859
- the SDK preserves the historical `ConflictError` fallback of `True`, so code
860
- that must support unclassified responses should verify the computer state and
989
+ response classified as `unavailable` requires starting the computer or otherwise
990
+ restoring access, rather than retrying unchanged. Read the returned reason:
991
+ being suspended alone does not determine the classification. If an older
992
+ platform response has no recognised reason, the SDK preserves the historical
993
+ `ConflictError` fallback of `True`, so code that must support unclassified
994
+ responses should verify the computer state and
861
995
  keep its retry loop bounded.
862
996
 
863
997
  Two others worth knowing. A **400** never clears: a computer built from a golden
@@ -916,10 +1050,17 @@ if not result.finished:
916
1050
  **It runs on your own Anthropic key**, passed as `model_key` and sent on that
917
1051
  one request as `X-Model-Key`. The platform never stores one, never bills you for
918
1052
  it, and will not fall back to anything — so the key is a per-call argument
919
- rather than something the client holds. Every step is a model call plus a
920
- screenshot on that key, which is why `max_steps` bounds spending as much as it
921
- bounds the loop. It is a whole number from 1 to 100 — the platform's ceiling,
922
- which the SDK refuses past rather than spending a round trip to be told.
1053
+ rather than something the client holds.
1054
+
1055
+ `max_steps` bounds the loop, and bounds your Anthropic spend only loosely. A
1056
+ step is one **action on the desktop**, not one exchange with the model, and the
1057
+ two do not line up in either direction: one model reply may ask for several
1058
+ actions and spends a step on each, while a reply that asks for none — or a
1059
+ paused turn, resubmitted — costs tokens and no step at all. Nor does every step
1060
+ take a screenshot; a `bash` call or a cursor read does not. Size the run from
1061
+ this; size the bill from your own key. It defaults to 20 and is a whole number
1062
+ up to 100 — the platform's ceiling, which the SDK refuses past rather than
1063
+ spending a round trip to be told.
923
1064
 
924
1065
  **The computer must already be running.** This route will not start one for you:
925
1066
  starting is billable, and it is not a decision to make on your behalf because
@@ -962,6 +1103,15 @@ eight stays an account of eight steps rather than only a message — the spend i
962
1103
  on a key the platform never meters, and the clicks are still on the desktop.
963
1104
  `agent_stream()` hands the same record over as an `AgentFailed` event.
964
1105
 
1106
+ `agent_once()` can be stopped the same way, and it arrives differently. The API
1107
+ rechecks authorization before each further model call and before each tool, so a
1108
+ key revoked, a role dropped, an account suspended or a plan downgraded mid-run
1109
+ ends that one request with a 401, a 403 or a 402 rather than with a body — after
1110
+ steps that already ran on the desktop and already cost model tokens. Where the
1111
+ refusal says how far the run got, `e.agent` carries it there too. What it is not
1112
+ is a reason to send the request again: the credential, the role or the plan has
1113
+ to change first, and `is_transient()` answers `False` for all three.
1114
+
965
1115
  ```python
966
1116
  import mandala_computer as mc
967
1117
 
@@ -1094,15 +1244,35 @@ times out and a nominated tree never armed, the error says so.
1094
1244
 
1095
1245
  **Match on what `hello` gives back, not on what you sent.** The host normalises
1096
1246
  a nomination — a trailing slash and a `.` segment are cleaned away — and the
1097
- cleaned form is what every event carries in `ev.watch`. `stream.watching` is
1098
- where the answer is, and it carries the half a client gets wrong:
1247
+ cleaned form is what every event carries in `ev.watch`. The cleaned nomination
1248
+ is where the answer is, and it carries the half a client gets wrong — through
1249
+ `on_connect`, which is handed the state as the connection opens, or from
1250
+ `stream.watching` once it has:
1099
1251
 
1100
1252
  ```python
1101
- with closing(c.events(watch=["/home/user/out", "/srv/build"])) as stream:
1102
- for tree in stream.watching or []:
1253
+ from contextlib import closing
1254
+ from mandala_computer import Hello
1255
+
1256
+
1257
+ def show_watches(hello: Hello) -> None:
1258
+ for tree in hello.watching or []:
1103
1259
  print(tree.path, tree.armed)
1260
+
1261
+
1262
+ with closing(c.events(watch=["/home/user/out/", "/srv/build"], on_connect=show_watches)) as stream:
1263
+ for ev in stream:
1264
+ if ev.type == "file.changed" and ev.path:
1265
+ print(ev.watch, ev.kind, ev.path)
1104
1266
  ```
1105
1267
 
1268
+ Iteration opens the connection and calls `show_watches` with the initial,
1269
+ normalised watch state before yielding events. The loop then receives every
1270
+ event, including the first; interrupt it when finished, and `closing` releases
1271
+ the connection. The callback runs again if the stream reconnects. `on_connect`
1272
+ rather than reading `stream.watching` before the loop, because nothing has
1273
+ connected yet at that point: read from the stream first and `watching` is
1274
+ `None`, which is not the same statement as "nothing armed".
1275
+
1106
1276
  `armed` is whether the tree is *already being watched*. A watch is not live the
1107
1277
  moment the nomination is accepted — the guest has to be asked, and on a computer
1108
1278
  nobody has opened a terminal on the host installs the watcher first — and inotify
@@ -1247,21 +1417,31 @@ def handle(headers: dict[str, str], raw: bytes) -> int:
1247
1417
  if not verify(secret, headers, raw):
1248
1418
  return 401
1249
1419
  event = json.loads(raw)
1250
- ...
1420
+ ... # atomically persist the delivery id and enqueue new work durably
1251
1421
  return 200
1252
1422
  ```
1253
1423
 
1254
1424
  `verify` checks the signature against the raw bytes and refuses a
1255
- `webhook-timestamp` more than 300 seconds from your clock. What it cannot do
1256
- for you: remember each `webhook-id` you accept for at least that long and
1257
- refuse a repeat. Retries carry the same id and a fresh signature, so that is
1258
- what makes a delivery processed once. A secret of the wrong shape, or a body
1259
- handed over as text, is a `ValueError` rather than a quiet `False` — neither is
1260
- something a forged request can cause.
1261
-
1262
- Acknowledge with a 2xx *before* doing the work. An attempt is cut at ten
1263
- seconds and counted as a failure; anything else is retried, eight attempts over
1264
- about fourteen hours, and then the delivery is `exhausted` — visible, never
1425
+ `webhook-timestamp` more than 300 seconds from your clock. This checks freshness,
1426
+ not whether you already handled the delivery. A captured attempt can remain
1427
+ valid for up to twice the tolerance after its earliest acceptance, because the
1428
+ check accepts timestamps on either side of your clock. Retries also carry the
1429
+ same `webhook-id` with a fresh timestamp and signature.
1430
+
1431
+ Keep delivery ids in durable storage for the full redelivery horizon your
1432
+ receiver supports, with a margin, and cover that captured-attempt validity
1433
+ window too. Atomically record the id with the durable enqueue or business
1434
+ operation so concurrent requests and crashes cannot admit duplicate work.
1435
+ Acknowledge an already recorded delivery without enqueueing it again. A
1436
+ retention period alone does not guarantee exactly-once processing; downstream
1437
+ work must also handle retries idempotently. A secret of the wrong shape, or a
1438
+ body handed over as text, is a `ValueError` rather than a quiet `False` — neither
1439
+ is something a forged request can cause.
1440
+
1441
+ Acknowledge with a 2xx after that durable acceptance, before doing slow work.
1442
+ An attempt is cut at ten seconds and counted as a failure; anything else is
1443
+ retried, eight attempts over about fourteen hours, and then the delivery is
1444
+ `exhausted` — visible, never
1265
1445
  silently dropped:
1266
1446
 
1267
1447
  ```python
@@ -1291,12 +1471,23 @@ public address — a private, loopback or link-local one is a 400 at create.
1291
1471
  - `wait_until_built()` — a cloned computer's disk has been copied. Only clones
1292
1472
  need this; it returns at once for anything else.
1293
1473
  - `wait_until_running()` — the VM is up. The guest OS is still booting. Raises
1294
- rather than waiting out the timeout on a failed build or a suspended session,
1295
- neither of which becomes "running" on its own.
1474
+ rather than waiting out the timeout on a failed build, or on a suspended,
1475
+ stopped or still-building computer the platform is holding nothing for — none
1476
+ of which becomes "running" on its own. A start it HAS admitted is waited for,
1477
+ since a boot reads as stopped and a resume as suspended until the guest
1478
+ process exists; so is a memory-snapshot fork, which reserves its RAM at the
1479
+ start of its disk copy and resumes itself at the end of it while reporting
1480
+ `building` throughout.
1296
1481
  - `wait_for_guest()` — the guest agent answers. Linux and Windows both; the probe
1297
- is `exit 0`, which bash and cmd.exe both have as a builtin. A failed start or
1298
- stopped computer is reported immediately. A suspended computer is different:
1299
- the probe is an `exec()`, so it resumes the session as use normally does.
1482
+ is `exit 0`, which bash and cmd.exe both have as a builtin. A failed start, or
1483
+ a stopped computer nobody is starting, is reported immediately. A suspended
1484
+ computer is different: the probe is an `exec()`, so it resumes the session as
1485
+ use normally does.
1486
+
1487
+ Both waits read `running_ram_mb` to tell those apart, and treat its ABSENCE as
1488
+ unknown rather than as zero: a host too old to report it, or one that could not
1489
+ be reached, has not said nothing is coming. Waiting there costs a timeout;
1490
+ refusing would cost a machine.
1300
1491
 
1301
1492
  The last of those is about the agent, not the desktop, and the agent answers
1302
1493
  first — on Windows by a wide margin, since it runs in session 0 and replies
@@ -1765,6 +1956,20 @@ c.write_file("/home/user/app/.env", "API_TOKEN=hunter2\n")
1765
1956
  report = c.read_file("/home/user/report.csv")
1766
1957
  ```
1767
1958
 
1959
+ `read_file()` answers **bytes**, because a guest file is not promised to be
1960
+ text and decoding one that is not would put replacement characters at the SDK
1961
+ boundary for every caller, including the one reading a tarball. When you know
1962
+ it is text, `read_text_file()` is the same request with the decode done:
1963
+
1964
+ ```python
1965
+ hostname = c.read_text_file("/etc/hostname")
1966
+ ```
1967
+
1968
+ That decode is `errors="replace"`, so it never raises on a file that turns out
1969
+ not to be text — the same bargain `ExecResult.stdout_text` makes, and
1970
+ acceptable for the same reason: the caller asked for text, and the bytes are
1971
+ one call away.
1972
+
1768
1973
  Guest paths are absolute; a relative path is refused before the request is
1769
1974
  made, because nothing about a transfer runs in a shell with a working
1770
1975
  directory. A transfer resumes a suspended computer, like any other use.
@@ -1863,6 +2068,20 @@ this SDK refuses before it sends anything does not — see [below](#refused-befo
1863
2068
  | `ConnectionInterruptedError` | the request was dispatched and the answer was lost; do not replay a create |
1864
2069
  | `TimeoutError` | a `wait_*` helper gave up, or a request outran its budget |
1865
2070
 
2071
+ **401, 403 and 402 can arrive at the end of a wait, not only at the start.**
2072
+ Authorization is not settled once per request: the API rechecks it after the
2073
+ waits inside a call and before each further piece of work, so a key revoked, a
2074
+ membership removed, a role dropped, an account suspended or a plan downgraded
2075
+ while a call was in flight comes back as one of those three even though the same
2076
+ call was authorized when it was sent. Two consequences worth writing code for.
2077
+ None of the three is a transport failure — `is_transient()` answers `False`, and
2078
+ repeating the request unchanged gets the same answer. And the refusal is about
2079
+ the credential rather than the resource: a 401 here does not mean the computer is
2080
+ gone. Where the work is a durable write the API refuses before writing, so a
2081
+ `403` on a create, a move, a webhook or a template publish leaves nothing behind;
2082
+ the exception is the agent loop, whose completed steps stand (see
2083
+ [Letting the platform drive](#letting-the-platform-drive)).
2084
+
1866
2085
  <a name="refused-before-it-was-sent"></a>
1867
2086
  **An argument refused before it was sent is not a `MandalaError`.** Nothing left
1868
2087
  the process when you get one, and there is no status behind it to name: text
@@ -1906,10 +2125,12 @@ length in `size`, which is the whole point of the status: you asked about a file
1906
2125
  whose length you did not know, and the number comes back with the refusal
1907
2126
  instead of behind another request. See [Files](#files).
1908
2127
 
1909
- `RateLimitError` is the only refusal that says how long to wait:
1910
- `retry_after` carries the `Retry-After` header in seconds. Every route on this
1911
- surface is metered, including ones that go on to answer 404 — the meter runs
1912
- before the routing does — so a burst of anything counts against the same budget.
2128
+ `APIError.retry_after` carries a valid `Retry-After` header in seconds, including
2129
+ on `RateLimitError` and template preparation refusals. It is `None` when that
2130
+ metadata is absent or malformed; a delay alone does not make an operation safe
2131
+ to retry. Every route on this surface is metered, including ones that go on to
2132
+ answer 404 — the meter runs before the routing does — so a burst of anything
2133
+ counts against the same budget.
1913
2134
  That budget is generous, in the low thousands of requests a minute even at the
1914
2135
  bottom of the range, so hitting it usually means a poll loop with no sleep in it
1915
2136
  rather than real load.
@@ -1962,10 +2183,10 @@ that creates deserves a look first.
1962
2183
 
1963
2184
  `is_transient(err)` is that rule as a function, and it answers for the riskiest
1964
2185
  caller — code wrapping an arbitrary call, possibly a `create`. It says yes to
1965
- `ConflictError` (minus `MoveRequiredError`), `RateLimitError`, `UnavailableError`
1966
- and `ConnectionError` (minus `ConnectionInterruptedError`), and no to everything
1967
- above whose outcome is unknown, 502 and 504 included. The same four classes, and
1968
- only those, answer yes in the TypeScript and MCP SDKs.
2186
+ `ConflictError` (except `MoveRequiredError` and `template_image_preparing`),
2187
+ `RateLimitError`, `UnavailableError` and `ConnectionError` (except
2188
+ `ConnectionInterruptedError`), and no to everything above whose outcome is
2189
+ unknown, 502 and 504 included.
1969
2190
 
1970
2191
  The `wait_*` helpers do not ask it. They replay idempotent reads under a
1971
2192
  deadline you set, so they ride out every 5xx — a hypervisor briefly away during
@@ -1985,15 +2206,18 @@ clears itself: something is in flight that the operation cannot run alongside
1985
2206
  a disk still being copied, a snapshot being taken, a delete already under way, a
1986
2207
  guest agent that has not finished coming up, or a suspend committed to the
1987
2208
  computer a moment before your call. Waiting and retrying is the fix; changing
1988
- the request is not.
1989
-
1990
- Two do not clear. `MoveRequiredError` is one, and it has a class you can catch.
1991
- The other is a clipboard refusal on a stopped or suspended computer: waiting
1992
- will not start it, so `start()` is the fix. Current platform responses classify
1993
- that refusal with `APIError.reason == "unavailable"`, and `is_transient()`
1994
- answers `False`; `contention` and `starting` answer `True`. An absent or unknown
1995
- reason is deliberately treated as unclassified and falls back to the exception
1996
- type, so a legacy `ConflictError` still answers `True`. If you support such
2209
+ the request is not. Template preparation is an exception: inspect its state
2210
+ and error, then deliberately continue with the returned token and the original
2211
+ create arguments when appropriate, as shown above.
2212
+
2213
+ Some require a different action. `MoveRequiredError` is one, and it has a class
2214
+ you can catch. For clipboard refusals, read `APIError.reason`:
2215
+ `unavailable` and `unsupported` make `is_transient()` answer `False`, while
2216
+ `contention` and `starting` answer `True`. A suspended computer's read is refused,
2217
+ but its status alone does not determine the reason; follow the returned
2218
+ classification. An absent or unknown reason is deliberately treated as
2219
+ unclassified and falls back to the exception type, so a legacy `ConflictError`
2220
+ still answers `True`. If you support such
1997
2221
  responses, check the computer state and keep the retry loop bounded.
1998
2222
 
1999
2223
  A retry loop on it terminates because it has a deadline, not because of any
@@ -2093,11 +2317,13 @@ matter how far behind the client falls, so closing a gap means deleting a line
2093
2317
  from `UNIMPLEMENTED` rather than nobody noticing.
2094
2318
 
2095
2319
  A mirror nobody compares is a comment, though. `scripts/check_surface.py` does
2096
- the comparison against the platform's real route table whenever a checkout of
2097
- the platform is available to it (next door, or wherever `MANDALA_PLATFORM_REPO`
2098
- points — a maintainer's setup, not something a contributor needs), and says so
2099
- and exits 0 when it is not. The suite runs it too: `pytest` skips it where the
2100
- platform is not checked out and fails on drift where it is.
2320
+ the comparison against the platform's published surface manifest — a file the
2321
+ platform generates from its own tables and commits like a lockfile — whenever a
2322
+ checkout of the platform is available to it (next door, or wherever
2323
+ `MANDALA_PLATFORM_REPO` points — a maintainer's setup, not something a
2324
+ contributor needs), and says so and exits 0 when it is not. A manifest it cannot
2325
+ read is a failure, never a comparison of nothing. The suite runs it too: `pytest`
2326
+ skips it where the platform is not checked out and fails on drift where it is.
2101
2327
 
2102
2328
  Response objects keep the raw payload in `.raw`, so a server that starts
2103
2329
  returning more fields does not break older clients. `.raw` is evidence rather