pinesandbox 0.0.1 → 0.3.18

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/README.md +668 -4
  4. data/Rakefile +5 -0
  5. data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
  6. data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
  7. data/lib/pinesandbox/agent_events.rb +213 -0
  8. data/lib/pinesandbox/agent_mode.rb +252 -0
  9. data/lib/pinesandbox/agent_turn.rb +75 -0
  10. data/lib/pinesandbox/attach_credentials_source.rb +392 -0
  11. data/lib/pinesandbox/author_terminal_translator.rb +96 -0
  12. data/lib/pinesandbox/bind_hpke.rb +54 -0
  13. data/lib/pinesandbox/capture_keypair.rb +43 -0
  14. data/lib/pinesandbox/client.rb +446 -0
  15. data/lib/pinesandbox/clock.rb +51 -0
  16. data/lib/pinesandbox/computer.rb +1075 -0
  17. data/lib/pinesandbox/computer_binding.rb +47 -0
  18. data/lib/pinesandbox/computer_credentials.rb +175 -0
  19. data/lib/pinesandbox/connection.rb +26 -0
  20. data/lib/pinesandbox/control_events.rb +156 -0
  21. data/lib/pinesandbox/control_plane.rb +341 -0
  22. data/lib/pinesandbox/control_state.rb +31 -0
  23. data/lib/pinesandbox/control_token_source.rb +232 -0
  24. data/lib/pinesandbox/delegated_connection.rb +46 -0
  25. data/lib/pinesandbox/diagnostics.rb +83 -0
  26. data/lib/pinesandbox/drive_mode.rb +67 -0
  27. data/lib/pinesandbox/error_taxonomy.rb +104 -0
  28. data/lib/pinesandbox/errors.rb +525 -0
  29. data/lib/pinesandbox/exec_stream_parser.rb +90 -0
  30. data/lib/pinesandbox/models/artifact.rb +51 -0
  31. data/lib/pinesandbox/models/computer_session.rb +57 -0
  32. data/lib/pinesandbox/models/computer_tab.rb +30 -0
  33. data/lib/pinesandbox/models/file_entry.rb +38 -0
  34. data/lib/pinesandbox/operations.rb +134 -0
  35. data/lib/pinesandbox/response_handler.rb +88 -0
  36. data/lib/pinesandbox/runtime_details.rb +25 -0
  37. data/lib/pinesandbox/runtime_handle.rb +252 -0
  38. data/lib/pinesandbox/session.rb +627 -0
  39. data/lib/pinesandbox/skills.rb +142 -0
  40. data/lib/pinesandbox/spec_version_middleware.rb +34 -0
  41. data/lib/pinesandbox/sse_frame_parser.rb +95 -0
  42. data/lib/pinesandbox/state_hpke.rb +57 -0
  43. data/lib/pinesandbox/version.rb +3 -0
  44. data/lib/pinesandbox/zone.rb +176 -0
  45. data/lib/pinesandbox.rb +40 -2
  46. data/pinesandbox.gemspec +47 -0
  47. metadata +155 -10
data/README.md CHANGED
@@ -1,6 +1,670 @@
1
- # pinesandbox
1
+ # PineSandbox Computer SDK (Ruby)
2
2
 
3
- This gem name is reserved by Pine AI for the official Pine Computer (PineSandbox) Ruby SDK,
4
- to prevent impersonation. This placeholder contains no SDK code.
3
+ `pinesandbox` is the Ruby server SDK for Pine Computers. Create and reconnect
4
+ Computers, run agent tasks, manage files, and authorize desktop access from
5
+ your backend. Stop/Start restores saved cookies, learned skills, and localStorage.
6
+ Pause/Resume retains live tabs, sessions, and memory when enabled for your project.
5
7
 
6
- Get the SDK from https://console.pinecomputer.io/docs.
8
+ See the [integration guide](https://pinesandbox.io/docs/integration) for setup,
9
+ authentication, and examples. This README covers the Ruby API.
10
+
11
+ ## Install
12
+
13
+ Admitted users download the versioned Ruby SDK bundle from
14
+ <https://pinesandbox.io/sdks/ruby>. Extract it under `vendor/`, then reference
15
+ the bundle from your `Gemfile`:
16
+
17
+ ```ruby
18
+ gem "pinesandbox", path: "vendor/pinesandbox-ruby-sdk-<version>"
19
+ ```
20
+
21
+ Use the directory name of the bundle you extracted; the download page shows the
22
+ exact line for the latest release.
23
+
24
+ No GitHub account or package token is required. See the
25
+ [integration guide §Install](https://pinesandbox.io/docs/integration#1-install)
26
+ for the download and extraction commands.
27
+
28
+ The current beta SDK is on the `0.3` compatibility line.
29
+
30
+ ## Create a Computer
31
+
32
+ A **Computer** is the identity and saved browser state you keep. A **runtime** is
33
+ one running allocation of it. A **session** is work inside that runtime.
34
+
35
+ The examples use `store` for your application's encrypted database; it is not an
36
+ SDK object. Save credentials **before** Create, then save the returned binding.
37
+
38
+ ```ruby
39
+ require "pinesandbox"
40
+
41
+ client = PineSandbox::Client.new(
42
+ api_key: ENV.fetch("PINE_SANDBOX_API_KEY"),
43
+ endpoint: ENV.fetch("PINE_SANDBOX_ENDPOINT")
44
+ )
45
+
46
+ credentials = PineSandbox::Computer.generate_credentials
47
+ store.save_credentials(credentials.id, credentials.to_json)
48
+
49
+ computer = client.create_computer(credentials)
50
+ store.save_binding(computer.id, computer.binding.to_json)
51
+
52
+ session = computer.create_session(name: "main", browser: true)
53
+ session.create_tab(url: "https://example.com")
54
+ ```
55
+
56
+ The two records have different lifetimes:
57
+
58
+ | Save | When | Used for |
59
+ |---|---|---|
60
+ | `credentials.to_json` | Before Create; again if you change keys | Starting this Computer again and decrypting its saved state |
61
+ | `computer.binding.to_json` | After each successful Create or Start | Reconnecting to that exact runtime from any backend |
62
+
63
+ Both contain secrets. Keep them encrypted on your backend, never in a browser.
64
+ For a one-off script, `client.with_computer { |computer| … }` creates and stops the
65
+ Computer for you, raising if Stop cannot be confirmed.
66
+
67
+ ## Reuse it from another backend
68
+
69
+ ```ruby
70
+ credentials = PineSandbox::Computer::Credentials.from_json(store.credentials(computer_id))
71
+ binding = PineSandbox::Computer::Binding.from_json(store.binding(computer_id))
72
+ computer = client.reconnect_computer(credentials, binding: binding)
73
+
74
+ state = computer.status # e.g. :running, :paused, :resuming
75
+ ```
76
+
77
+ Reconnect reconstructs a handle locally. It makes no request, creates nothing,
78
+ and does not replay work. Its next call targets the saved runtime, even if another
79
+ backend has since replaced it. Store the binding revision alongside its JSON in
80
+ your existing Computer record. Save a replacement only if its revision is newer
81
+ (or it is the identical binding); a delayed reply must not overwrite a newer one.
82
+ After confirmed Stop, clear the binding only if the stored revision and runtime
83
+ still match the one stopped. Keep that revision and the durable credentials.
84
+ Session records belong to a binding revision and cannot be reused after Start.
85
+
86
+ To continue a session, also save its name and token when you create it, then use
87
+ `computer.adopt_session(saved_name, saved_token)`. Session lookup does not return
88
+ the token. Save agent turn IDs and event cursors alongside that session so another
89
+ worker can wait for existing work rather than submit it again. See
90
+ [Activity](#activity) for reconnecting event streams.
91
+
92
+ ## Stop, Start, Pause or Resume
93
+
94
+ | Intent | Call | Result |
95
+ |---|---|---|
96
+ | Finish using compute | `computer.stop` | Saves supported state, releases the runtime and ends its sessions |
97
+ | Start a stopped Computer | `client.start_computer(credentials)` | Restores saved state into a new runtime; create new sessions |
98
+ | Keep live memory for later | `computer.pause` | Retains the runtime and sessions; requires project opt-in |
99
+ | Continue paused work | `computer.resume` | Restores the retained runtime and sessions |
100
+ | Observe after a connection failure | `computer.status` | Reads state without changing or renewing it |
101
+
102
+ ```ruby
103
+ raise PineSandbox::TimeoutError, "Stop is unconfirmed" unless computer.stop
104
+
105
+ # Later, at a safe boundary chosen by your application:
106
+ computer = client.start_computer(credentials)
107
+ store.save_binding(computer.id, computer.binding.to_json)
108
+ ```
109
+
110
+ Stop/Start is also how an application replaces an old runtime. An SDK upgrade or
111
+ Pine image rollout does not replace a running Computer. Start can receive a
112
+ compatible older warm revision during rollout; it does not guarantee a build.
113
+
114
+ `attach_computer` remains an alias for Start. **Use Reconnect for an existing
115
+ runtime.** A failed Resume does not fall back to Start. Stop/Start restores saved
116
+ browser state, not live tabs, session files or page memory. Export files first.
117
+ [Persistence and teardown](#persistence--teardown) covers completion, recovery and
118
+ permanent deletion.
119
+
120
+ ## Idle timeout and maximum age
121
+
122
+ `timeout:` is the inactivity window (default five minutes), not an HTTP request
123
+ timeout. Work renews it; reading status, watching events and other observation do
124
+ not. It starts at allocation, so a one-minute window can expire during slow setup.
125
+
126
+ Projects can opt into a lifetime policy. The standard allowance is one minute to
127
+ 24 hours of idle time and seven days of maximum runtime age; approved project
128
+ limits can be higher. Existing allocations retain their admitted terms.
129
+ Enrollment applies to new allocations from Ruby 0.3.13 / Go 0.3.15 and later,
130
+ including integrations that do not use the new policy methods. A requested
131
+ timeout above the allowance, including the SDK default, is rejected. Before
132
+ enrolling a project, check its integrations' requested timeouts and handling of
133
+ maximum age. Deployment alone does not enroll projects or alter existing runtimes.
134
+
135
+ ```ruby
136
+ details = computer.runtime_details
137
+ if details.lifecycle
138
+ updated = computer.set_idle_timeout(seconds: 3600)
139
+ puts updated.lifecycle.idle_timeout_seconds
140
+ puts updated.expires_at # effective expiry; maximum age is unchanged
141
+ end
142
+ ```
143
+
144
+ For an enrolled runtime, changing idle timeout takes effect now while running,
145
+ or on the next Resume while paused. It cannot extend maximum age or snapshot
146
+ retention. Legacy runtimes keep their previous expiry clock while paused; enrolled
147
+ runtimes use snapshot retention and maximum age. An identical value does nothing.
148
+ After a conflict or lost response,
149
+ read `runtime_details` before deciding whether to edit again.
150
+
151
+ Maximum age includes paused time. Graceful Stop begins twelve minutes before the
152
+ hard deadline; expiry is not proof that a final save succeeded. Plan Stop/Start
153
+ at a safe point before that deadline when you need uninterrupted work.
154
+
155
+ `runtime_details` describes the current allocation, including optional lifecycle
156
+ terms and its opaque `runtime_revision`. Missing lifecycle means the runtime
157
+ has no admitted project lifetime policy. `client.project_policy` optionally reads
158
+ allowances for future allocations; ordinary operations do not require that call.
159
+
160
+ `ready_timeout:` bounds allocation/readiness and `bind_ready_timeout:` bounds the
161
+ following handshake. They do not change runtime expiry. For example,
162
+ `timeout: 28_800, ready_timeout: 120` requests eight hours of idle time with a
163
+ two-minute readiness wait. These waits are separate from transport retries and
164
+ cleanup; see the [integration guide](https://pinesandbox.io/docs/integration).
165
+
166
+ ## Handle failures without replacing live work
167
+
168
+ - A network error or `alive? == false` does not prove the runtime is gone.
169
+ Read `status`; its `NotFoundError` confirms runtime-record absence. A
170
+ data-plane `RuntimeGoneError` alone does not: paused/restoring Computers can
171
+ temporarily have no route.
172
+ - `RuntimeRetainedError` (a `BindError`) means Start found an existing runtime.
173
+ Match its `runtime_id` to your saved binding, or reload the latest binding from
174
+ your database. Reconnect, Resume or finish Stop as appropriate; an ID alone
175
+ cannot reconstruct a missing token.
176
+ - A Stop timeout means completion is unconfirmed, not canceled. Keep the binding
177
+ and observe or retry Stop. Older servers report `StopSaveError` for a failed or
178
+ unconfirmed save; current servers still release the runtime.
179
+ - Serialize Create/Start per Computer in your application. On
180
+ `BindingRevisionConflictError`, reload the winner's saved binding instead of
181
+ retrying with a higher revision.
182
+ - If a registered Computer's Start reply is lost before saving its binding,
183
+ serialize recovery, call `client.stop_computer(id)`, then Start separately with
184
+ the saved credentials. This releases the runtime whose token was lost.
185
+ - An interrupted first Create can fail before registering the Computer. Stop-by-ID
186
+ then returns true, but Start raises `UnknownComputerError`. For that initial
187
+ creation, retry Create with your saved credentials after confirming the project
188
+ and ID are correct and the identity was not deleted. Do not treat every unknown
189
+ ID as permission to create. An allocation abandoned before registration may
190
+ remain until expiry; Stop-by-ID cannot discover it.
191
+ - On `AttachAuthorizationCommittedError`, registration has succeeded. Retain its
192
+ non-nil credentials and reconcile the registered Computer before Start. Private
193
+ keys cannot be recovered from an ID.
194
+
195
+ Allocation retries within one call reuse its create ID. A fresh call is a new
196
+ attempt. Failed attach attempts clean up known allocations best-effort; process
197
+ crashes rely on expiry. Longer requested timeouts can leave abandoned compute
198
+ running longer. See [Errors](#errors) for the error types.
199
+
200
+ ## Stored credentials and older integrations
201
+
202
+ The project API key stays on your backend. Computer credentials contain the state
203
+ key, current and retained capture keys, prior state keys and ephemeral mode.
204
+ Bindings contain the runtime ID, Computer token and binding revision. The SDK
205
+ handles short-lived project and attach authorization internally.
206
+
207
+ `computer.credentials` returns an independent copy. Credentials and bindings use
208
+ versioned JSON shared with the Go SDK. Normal `inspect`/`to_s` output redacts
209
+ secrets; their explicit JSON is for encrypted storage and **does contain secrets**.
210
+
211
+ For integrations using older storage or field-based methods:
212
+
213
+ - Use `to_json` / `from_json` for durable records. `to_h` contains raw Ruby values.
214
+ If upgrading from a credential struct with fewer members, convert native
215
+ `Marshal` dumps with that older SDK before upgrading.
216
+ - `adopt_existing` remains supported, but its field-based handle lacks capture
217
+ keypairs. Retain the original complete credentials; prefer `reconnect_computer`
218
+ when both saved records are available.
219
+ - Starting from a credentials value requires custom credential providers to
220
+ implement `current_binding` and `credentials`. Bare callables still support
221
+ Create and the field-based Attach form with an explicit binding revision.
222
+ - `control_plane` and sandbox handles are SDK internals. Integrations use the
223
+ Computer methods above.
224
+
225
+ ## Sessions
226
+
227
+ A session is the unit of work — a browser context + bash terminal. Create one, then
228
+ drive it (next two sections). A Computer runs up to 3 sessions at once by default;
229
+ `computer.capabilities` reports the limit (`sessions.concurrent`).
230
+
231
+ ```ruby
232
+ session = computer.create_session(name: "researcher-1", browser: true)
233
+ computer.session("researcher-1") # metadata by name; token is redacted
234
+ computer.sessions # list all on this Computer
235
+ computer.destroy_session("researcher-1") # tear down (keeps on-disk dir; clean: true to GC)
236
+ ```
237
+
238
+ ## Drive — Pine's agent (hand off the loop)
239
+
240
+ For an attached Computer, `computer.require_agent_protocol!` checks public agent
241
+ support before you enable agent UI. It returns the Computer or raises
242
+ `PineSandbox::AgentProtocolUnsupported`; older Computers need Stop, then Start
243
+ to update. The check never restarts or rebinds a Computer. Any agent call that
244
+ reaches a Computer without public agent support raises the same local error,
245
+ which has no HTTP status. Authorization, connection and other HTTP failures
246
+ keep their own classes, status and problem type.
247
+
248
+ `session.agent` delegates a **goal**; Pine's agent perceives the page and acts.
249
+ Work is addressed by **turn**: a turn starts when a run or an answer is accepted
250
+ and ends when the agent finishes, fails, is canceled or asks a question. Every
251
+ ending is the turn's `result`, with its `usage`; a question is the result's
252
+ summary (`turn.question`), and the answer starts the next turn, which continues
253
+ the same goal. A session has
254
+ **one persistent agent**, whose memory persists across runs; `reset` starts
255
+ fresh. Mutations (`run`/`steer`/`answer`/`cancel`/`reset`) use `ct_`; reads
256
+ (`wait`/`status`/`turns`/`events`) use `ps_` — the SDK picks the right one.
257
+
258
+ Keep the turn ID with the record it belongs to and wait for that turn. The SDK
259
+ does not require the submitting process to stay alive. Any process with the saved
260
+ session and turn ID can continue:
261
+
262
+ ```ruby
263
+ agent = session.agent
264
+ # saved_message_id is your durable request identity. A run with the same key
265
+ # returns the turn it admitted, so a lost response never starts a second run.
266
+ turn = agent.run("Find the cheapest direct SFO→NRT flight next month and report the price",
267
+ idempotency_key: saved_message_id)
268
+ record.update!(turn_id: turn.turn_id)
269
+
270
+ # Any worker, later:
271
+ turn = agent.wait(turn_id: record.turn_id, timeout: 30)
272
+ case turn.state
273
+ when "running", "canceling" then schedule_another_wait(record)
274
+ when "needs_input" then present_question(record, turn.turn_id, turn.question)
275
+ when "answered" then record.update!(turn_id: turn.next_turn_id) # another worker answered
276
+ else save_outcome(record, turn.state, turn.result) # completed, failed or canceled; result carries usage
277
+ end
278
+
279
+ # The process that receives the reply answers the turn that asked:
280
+ turn = agent.answer(turn_id: reply.turn_id, answer: "Use the corporate Amex ending 4242")
281
+ record.update!(turn_id: turn.turn_id)
282
+ ```
283
+
284
+ `wait` holds while the turn runs and returns it when it ends or the timeout
285
+ passes (still `running`). It never cancels the turn and never follows
286
+ `next_turn_id` for you. A read whose response was lost, or whose problem is
287
+ marked retryable, is read again within the timeout. Only the session's latest
288
+ turn can be answered, so starting new work leaves an open question unanswered.
289
+ Sending the same answer again returns the same new turn without delivering its
290
+ secrets again; a different answer to an answered turn raises a `409`
291
+ (`/errors/stale-turn`).
292
+ `run`, `answer` and `cancel` resend a lost response themselves for up to about
293
+ 30 seconds, honoring `Retry-After` (`run` uses a key of its own when you pass
294
+ none). A lost response is a network fault, a `408`, or a gateway `502`, `503` or
295
+ `504`, which cannot say whether the Computer acted. If none arrives, `run`'s
296
+ error carries `#idempotency_key`: save it and send the run again with it. The
297
+ error is also a `PineSandbox::RunUnconfirmedError`, so you can rescue that name
298
+ or the error's own class. Pass a
299
+ key of your own, such as your message ID, so a process that restarts can do the
300
+ same.
301
+
302
+ - `agent.steer(turn_id:, text:)` — add guidance while that turn runs (best-effort;
303
+ never replay it automatically).
304
+ - `agent.cancel(turn_id:)` — cancel that turn while it runs, or close its
305
+ waiting question (the turn ends `canceled`); any other ended turn is returned
306
+ unchanged, so retrying is safe. An answered turn raises a `409`: cancel the
307
+ turn its answer opened.
308
+ - `agent.reset(expected_task_id: agent.status.task_id)` — forget the thread so the
309
+ next `run` is fresh memory (idle-only).
310
+ - `agent.status` — the `AgentTask` and its latest `turn`, for discovery;
311
+ `agent.turns` — the retained turns, oldest first.
312
+
313
+ `run` raises `409` if a turn is already running, or a person or your own agent
314
+ holds the drive slot. Routes: `/v2/sessions/{name}/agent/*` in the
315
+ [Computer API reference](https://pinesandbox.io/spec/computer).
316
+
317
+ ### Activity
318
+
319
+ `events` streams what the agent does as typed, Hash-compatible `AgentEvent`
320
+ values; each carries the `turn_id` it belongs to. Use it to show progress, not
321
+ to detect how a turn ended: `wait` does that. The stream is continuous: a
322
+ dropped connection reopens from the last event it delivered, and after five
323
+ reconnects in a row that delivered nothing it raises `StreamLostError` (a
324
+ `ConnectionError`). Break out of the block to stop reading.
325
+
326
+ ```ruby
327
+ task_id = agent.status.task_id
328
+ # A saved event cursor is valid only for the Task it was stored with.
329
+ cursor = session_progress.last_agent_event_id if session_progress.agent_task_id == task_id
330
+ agent.events(last_event_id: cursor, expected_task_id: task_id) do |e|
331
+ apply_event_and_commit_cursor!(e)
332
+ if (change = e.control_change)
333
+ update_driver_banner(change.controller, change.mode_epoch)
334
+ elsif (progress = e.progress) # the agent explains what it is doing
335
+ narrate(progress.text)
336
+ elsif (step = e.activity) # one row per step, upserted by step.id
337
+ # step.title is the Computer's own description, such as "Click “Log in”";
338
+ # step.kind and step.facts add detail. Every step ends with one outcome,
339
+ # "interrupted" included.
340
+ upsert_step(step.id, step.state, step.title)
341
+ end
342
+ end
343
+ ```
344
+
345
+ A captured screen carries its matching step when known: `frame.activity`
346
+ is a typed activity snapshot, so a frame read from partial history still has
347
+ its action title and outcome. Attach it by activity ID without counting another
348
+ outcome or changing completion time. An absent activity means an independent
349
+ observation; do not guess from nearby events. A text-only update leaves the last
350
+ captured screen and its original attribution visible. Keep historical selection
351
+ separate from following new frames; the event ID identifies each image for replay.
352
+
353
+ This SDK requires a Computer runtime with the public V2 agent contract. There is
354
+ no fallback to native trace. Activity includes progress, questions and results;
355
+ native commands, reasoning and provider metadata are internal-only. Upgrade
356
+ agent consumers before moving them to the new runtime: its legacy agent routes
357
+ reject ordinary bindings.
358
+
359
+ Paged history uses the same typed events as streaming. Scope every saved cursor
360
+ to its Task; after a session replacement, discover the new Task before replaying
361
+ history. Reset clears agent memory while preserving the Task and its history.
362
+
363
+ ```ruby
364
+ page = agent.events_page(after: saved_cursor, expected_task_id: saved_task_id)
365
+ page.fetch("events").each { |event| apply_event!(event) }
366
+ # Persist saved_task_id with page.fetch("next_after") only after applying.
367
+ # A stale-task refusal requires rediscovery with a fresh cursor, never a retry
368
+ # of saved_cursor against the replacement Task.
369
+ ```
370
+
371
+ ## Drive — your own agent (you drive)
372
+
373
+ `session.drive` is the "bring your own agent" surface: you perceive and act. The
374
+ vision-friendly primitive is the **observe → act loop**:
375
+
376
+ ```ruby
377
+ drive = session.drive
378
+ loop do
379
+ snap = drive.observe # { screenshot, viewport, device_scale_factor, scroll, ... }
380
+ action = my_policy.next(snap) # your model/policy decides
381
+ break if action.done?
382
+ drive.computer_use(action.kind, **action.fields) # e.g. :left_click, coordinate: [x, y]; :type, text: "hi"
383
+ end
384
+
385
+ drive.upload_file("input[type=file]", "resume.pdf") # set a file input (deposit the file first)
386
+ ```
387
+
388
+ `observe` is read-only (allowed even while Pine's agent runs — you can watch);
389
+ `computer_use` / `upload_file` take the drive slot (`409` if Pine's agent or a person
390
+ holds it). Pine's resident agent can use private DOM/browser tools through
391
+ `session.delegate`; direct customer shell execution runs public programs and
392
+ works with session files. It cannot invoke Pine's private browser implementation.
393
+ Raw CDP is an image-owned runtime transport and is intentionally absent from
394
+ the SDK surface. Tab helpers
395
+ (`create_tab`/`tabs`/`patch_tab`/`close_tab`), `focus!`, and `epoch` round out the manual
396
+ surface. Public routes are defined by the
397
+ [Computer API reference](https://pinesandbox.io/spec/computer).
398
+
399
+ ## Files & artifacts
400
+
401
+ A session has a **workdir** (where `exec` runs and the agent works), an inbound
402
+ **attachments** root (files you hand the agent), and an **artifact registry** (the
403
+ session's managed outputs).
404
+
405
+ ```ruby
406
+ # Read the workdir (what the agent produced / your shell wrote):
407
+ session.list_files(pattern: "*.csv") # => [FileEntry, ...]
408
+ session.read_file("out/report.csv") # => bytes (binary encoding)
409
+
410
+ # Deposit a material the agent should use. It lands in attachments/ and surfaces
411
+ # to the agent via the materials preamble — a sibling of the workdir, so it is NOT
412
+ # read_file-able (read_file serves the workdir).
413
+ session.write_file("brief.md", File.read("brief.md"))
414
+
415
+ # Artifacts — the session's managed outputs, each with metadata + a download.
416
+ art = session.upload_artifact("logo.png", File.binread("logo.png")) # => Models::Artifact
417
+ session.list_artifacts # all, newest-first
418
+ session.list_artifacts(turn_id: "turn_…") # just one agent turn's outputs
419
+ session.read_artifact(art.id) # => bytes
420
+ session.zip_artifacts # => a .zip of all artifacts (bytes)
421
+ session.open_artifact(art.id) { |chunk| io.write(chunk) } # stream it without buffering
422
+ ```
423
+
424
+ `created_by` names the producer: `upload` (you), `download` (a browser download,
425
+ auto-indexed), `agent` (a turn's outputs, durably captured at the turn's result). Only
426
+ `agent` artifacts carry a `turn_id`, so the `turn_id` filter scopes to one turn's
427
+ outputs; uploads/downloads always appear unfiltered. Uploads are immutable +
428
+ id-addressed (a repeated filename yields a distinct artifact). Routes: `/files`,
429
+ `/artifacts[/{zip,artifact_id}]` in the
430
+ [Computer API reference](https://pinesandbox.io/spec/computer).
431
+
432
+ ## Skills — `learn` / `teach` (and when each blocks)
433
+
434
+ `session.learn` and `session.teach` author a learned-skill **draft** from
435
+ either the agent's last finished turn (`#learn`) or the demonstration a person
436
+ just finished and released (`#teach`). The draft lands pending in the
437
+ Computer's `skills.drafts` queue, and you activate it before any future agent
438
+ sees it. Both start a separate authoring model run that typically takes
439
+ **30–180 s** of wall-clock.
440
+
441
+ There are **two flavors** of each, and choosing the right one matters under
442
+ realistic networking:
443
+
444
+ | Method | Returns when | Use when |
445
+ |---|---|---|
446
+ | `session.learn(scaffold:)` / `session.teach(goal:)` | The authoring run ends (blocks on its event stream) | CLI tools, backend workers / Sidekiq jobs, tests, smoke scripts — anything that owns its own request budget |
447
+ | `session.start_learn(scaffold:)` / `session.start_teach(goal:)` | Immediately, with the raw 202 envelope `{author_session, task_id[, handoff_id]}` | A web handler behind a reverse proxy (Cloudflare, ALB, GFE, Heroku) where the request timeout is 30–100 s — the proxy will sever the sync call before the terminal event |
448
+
449
+ The blocking variant wraps the non-blocking one: it starts the run, follows
450
+ its events to the terminal event, and translates that into a `LearnResult` /
451
+ `TeachResult`. The only difference is whether **you** follow the events with
452
+ `session.author_events` or the SDK does on your behalf.
453
+
454
+ ```ruby
455
+ # Blocking — fine from a Sidekiq job
456
+ result = session.learn(scaffold: "---\nname: book-united\ndescription: …\n---\n")
457
+ result.learned? # => true / false
458
+ result.draft # => { "name" => "book-united", "version" => 1, … }
459
+
460
+ # Non-blocking — what you want from a Rails controller behind CF
461
+ accepted = session.start_learn(scaffold: scaffold)
462
+ # accepted["author_session"] => an opaque ID; pass it back unchanged
463
+ # Follow the run on your own time (pass last_event_id: to resume):
464
+ session.author_events(author_id: accepted["author_session"]) do |evt|
465
+ break if evt.result # then process evt.result["terminal_reason"]
466
+ end
467
+ ```
468
+
469
+ `session.cancel_author(author_id:)` cancels a run that is still in flight.
470
+
471
+ Activation, drafts review, and the rest of the lifecycle live on the
472
+ **Computer**, not the session — `computer.skills.{list, drafts, get(name),
473
+ activate(name, version:), deactivate(name)}`. Draft review is the safety
474
+ gate: nothing learned reaches another agent until you activate it.
475
+
476
+ ## Persistence & teardown
477
+
478
+ ```ruby
479
+ computer.checkpoint # synchronous state save (content-hash dedup) — before a risky step
480
+ computer.stop # save cookies, learned skills and localStorage, then delete the sandbox
481
+ computer.kill # delete without an acknowledged save
482
+ client.delete_computer(computer) # PERMANENT: kill if attached, then delete id + saved state
483
+ ```
484
+
485
+ `stop` and `kill` keep the Computer's identity and saved state; `delete_computer`
486
+ (a `Computer` or its id) ends both. The id is refused for every later attach, saved
487
+ state is purged after 24 hours, and the id is never reusable. Deleting an unknown,
488
+ already-deleted, or other project's id also returns `true`. There is no undelete.
489
+
490
+ `stop` submits graceful Stop to the runtime controller, then waits up to 390s
491
+ (`wait_timeout:`) for compute release. The controller tries to save supported state
492
+ before deletion and continues if the SDK process disappears. Saving is best effort:
493
+ a save that fails or is not confirmed by the deadline still releases the runtime,
494
+ and `stop` returns true; the next Start restores the last saved state. Reconstruct
495
+ the saved handle with `reconnect_computer` to observe or join the same operation from
496
+ another backend. Only an older server keeps the source and raises
497
+ `PineSandbox::StopSaveError`; its `retryable` flag distinguishes a retryable save
498
+ failure from an unconfirmed outcome after the Computer's services restart.
499
+ A `false` return means completion is unconfirmed. Keep the saved binding for retry.
500
+ After Stop returns true, clear the stored binding only if its revision and
501
+ runtime still match the handle stopped; a concurrent Start may have replaced it.
502
+ Keep the revision fence, identity, state key and capture keypairs for future Start. Ephemeral
503
+ bindings skip persistence; the runtime's binding record decides this.
504
+
505
+ If a Start response was lost before saving the runtime token, use
506
+ `client.stop_computer(id)` to stop the currently bound runtime with project
507
+ authorization alone, then call Start separately. This lookup selects one binding;
508
+ it never follows a concurrent newer binding. Private keys remain necessary for
509
+ Start and cannot be recovered from the ID.
510
+
511
+ A true result proves compute release or authoritative runtime absence. It does
512
+ not prove a final save by a runtime that disappeared, and provider snapshot cleanup
513
+ can still be running. Start restores only the latest committed state. Cookies,
514
+ learned skills and compatible localStorage survive supported saved-state recovery;
515
+ tabs, session files, page memory and IndexedDB do not.
516
+
517
+ `start_computer` reads the current binding revision once, checks that the old
518
+ runtime is gone or released, waiting for an already accepted deletion if needed,
519
+ and creates a new binding. A conflict stays a conflict;
520
+ it never adopts a newer revision and displaces another worker's winner. For a
521
+ recorded failed runtime, explicitly call
522
+ `client.recover_computer(id, key: key, capture_keypair: keypair)`.
523
+ Recovery accepts possible unsaved-state loss, releases the observed failed runtime
524
+ with a version precondition, waits up to 90s for compute release, then Starts. It
525
+ refuses healthy running/paused runtimes and an unresolved capture timeout. If
526
+ release times out, retry later; no replacement has been allocated. `kill` remains
527
+ an unconditional destructive operation.
528
+
529
+ This SDK's retryable allocation and server-owned Stop require the updated backend
530
+ and a compatible Computer runtime; neither requires Pause opt-in. Existing
531
+ Computer create, capture/Finish and delete calls remain supported for applications
532
+ keeping their prior SDK. Complete the backend rollout before upgrading, including
533
+ the conditional-release support used by recovery. A pool image update does not
534
+ upgrade an already-running Computer: stop old-image bindings with the prior SDK
535
+ before upgrading their caller. Integrations using the removed generic
536
+ public sandbox operations must migrate to the Computer contract; the private Ruby
537
+ `ControlPlane#create` adapter is removed as well.
538
+
539
+ For projects opted in to Pause/Resume on snapshot-capable runtimes:
540
+
541
+ ```ruby
542
+ computer.pause(timeout: 360) # waits for capture and confirmed source stop
543
+ computer.resume(timeout: 360) # waits for restored routing and runtime recovery
544
+ ```
545
+
546
+ Pause is disabled by default. Pine enables it for a project on request;
547
+ refresh the project token after it is enabled. Disabling the
548
+ feature prevents new pauses but preserves Resume and Stop for retained memory.
549
+ Ordinary Stop/Start remains available without the opt-in.
550
+
551
+ The same sandbox handle, session capabilities and retained process memory remain.
552
+ A pool image update does not change the captured runtime. Calls include acceptance
553
+ and completion in one bounded timeout. A timeout leaves the outcome unconfirmed;
554
+ retry against the retained handle. The controller restores the same paused
555
+ runtime before Stop can save it. Snapshot retention and admitted maximum age still apply; legacy expiry is unchanged.
556
+
557
+ Do not rescue a failed `resume` by automatically calling `start_computer`.
558
+ Retrying Resume keeps the existing memory-recovery intent; Start explicitly
559
+ accepts recovery from the last saved state with new sessions and possible loss
560
+ of unsaved work. An unavailable status or timeout alone does not prove that
561
+ memory is lost. The [Computer API reference](https://pinesandbox.io/spec/computer)
562
+ defines the observed states and recovery choices: retry Resume, refresh the
563
+ status, or Start from saved state.
564
+
565
+ ## Errors
566
+
567
+ Three typed families, one per credential exchange (see `lib/pinesandbox/errors.rb`):
568
+
569
+ Data-plane runtime loss raises `PineSandbox::RuntimeGoneError`; it preserves the
570
+ wire diagnostics and original `ApiError` cause. Errors about a resource on a
571
+ live Computer remain distinct, including `PineSandbox::SessionNotFound` and
572
+ `PineSandbox::ControlNotHeld`.
573
+
574
+ - **`BindError`** — `Computer#attach` outcomes: `BindAuthError`,
575
+ `ComputerAlreadyAttachedError`, `RuntimeUnusableError`, `SavedStateUnavailableError`,
576
+ `BindTimeoutError`; plus `TokenRejectedError` on a post-bind 401 for the
577
+ Computer token (`ct_` not recognized — on a live runtime it is
578
+ `binding_auth_lost`; re-attach only when the runtime is confirmed gone). A 401
579
+ for a session token is an ordinary `UnauthorizedError`. If `create_computer` raises
580
+ `AttachAuthorizationCommittedError`, retain its non-nil `#credentials` and inspect `#runtime_id` before deciding
581
+ whether to stop or recover the failed runtime. Credential inspection redacts private material.
582
+ - **`AttachCredentialsError`** — the portal attach-credentials flow:
583
+ `UnknownComputerError` (404 — deleted or cross-project),
584
+ `ProjectAccessDenied` (403), and `BindingRevisionConflictError` (412 — another start changed the binding; `#current_binding_revision` /
585
+ `#current_runtime_id` identify the winner. Do not retry automatically). A bad `pk_` (401),
586
+ rate limit (429), and server errors are the base `AttachCredentialsError`.
587
+ Inspect the stable problem `#problem_type` and optional safe `#reason`; raw
588
+ internal exceptions are never part of the API contract.
589
+ - **`ControlTokenError`** — the `pk_` → project-token mint: `InvalidClientKey`
590
+ (401), `InsufficientScope` (403), `RateLimited` (429, carries `#retry_after`).
591
+ A `Retry-After` longer than the mint's 10-second backoff cap ends the mint at
592
+ once with that error instead of retrying early.
593
+
594
+ The old names `SandboxGoneError`, `SandboxFailedError`, `PodPoisonedError`,
595
+ `BrokerUnreachableError`, `SandboxHandle` and the `sandbox_id` readers keep
596
+ working as deprecated aliases; they warn when Ruby's deprecation warnings are on.
597
+
598
+ ## Troubleshooting — reporting an issue
599
+
600
+ The SDK supports saved Computer diagnostics. They require the diagnostics backend; older deployments do not serve them. The request needs an existing Computer ID, not its private keys or a live handle:
601
+
602
+ ```ruby
603
+ # Persist one key per logical request if you need to retry after a lost response.
604
+ report = client.create_diagnostic(computer_id: saved_id, session: "main",
605
+ idempotency_key: diagnostic_request_key)
606
+ support_reference = report.fetch("id")
607
+ # Read later. Only poll queued/running reports, with a bounded deadline.
608
+ report = client.get_diagnostic(support_reference)
609
+ puts report.dig("assessment", "message") if report["assessment"]
610
+ ```
611
+
612
+ Supply a session name to investigate its recorded agent work; omit it for a lifecycle check. The investigation does not operate the application or repeat its work. It produces a private report for Pine support and a separate customer-safe assessment. Optional descriptions provide private context. An unavailable diagnostic does not justify repeating customer work. Requesting a report does not open a human support ticket; share its reference separately when asking Pine for help.
613
+
614
+ Clients declaring `diagnostic_response_metadata?` expose `report.retry_after` and
615
+ `PortalError#retry_after` as seconds, including HTTP-date hints. Honor the hint
616
+ before polling or retrying, even when it exceeds your normal backoff cap. Stop
617
+ at your application deadline instead of retrying early. The response remains
618
+ Hash-compatible, and the metadata is not added to its JSON payload. Earlier
619
+ packages do not expose these hints; see the changelog for release availability.
620
+
621
+ Errors are **self-describing at the resource level**: every error the SDK raises off a
622
+ gateway-fronted response folds the failing Computer + operation into `e.message` — resource
623
+ first — so a generic handler that logs only `e.message` shows WHICH Computer and WHICH call
624
+ failed with no extra plumbing.
625
+
626
+ ```ruby
627
+ begin
628
+ session.agent.run("book the 9am slot")
629
+ rescue PineSandbox::ApiError => e
630
+ logger.error(e.message)
631
+ # => "… (runtime=sbx_abc123, op=POST /v2/sessions/main/agent/run, request_id=req_abc123)"
632
+ Report.open(computer_id: computer.id, session: session.name,
633
+ at: Time.now.utc, request_id: e.request_id, op: e.op)
634
+ end
635
+ ```
636
+
637
+ Each error class exposes exactly its own typed context (the message always carries the same
638
+ data): Computer API **`ApiError`** → `#runtime_id` / `#op` / `#request_id`; control-plane errors
639
+ → `#op` / `#request_id`; portal **`ControlTokenError`** / **`AttachCredentialsError`** → `#op`
640
+ / `#request_id`. The suffix grammar is uniform `key=value` across the Go and Ruby SDKs; Ruby
641
+ Computer API errors use **`runtime=`** because the Ruby SDK holds the runtime id, where the Go SDK
642
+ uses **`host=`** because its client holds the Computer host (same Computer, different handle
643
+ each SDK already has).
644
+
645
+ When you file a report, **lead with the resource spine** — the durable key Pine support uses
646
+ to find the failure:
647
+
648
+ 1. **`computer_id`** (`computer.id`) — WHICH Computer. Add the **session name** (`session.name`)
649
+ and, for an agent-lane issue, the **`task_id`**.
650
+ 2. A **UTC time window**.
651
+ 3. **`request_id`** *when you have it* — the precision handle for a single failed call
652
+ (`e.request_id`, also in the message). A read or stream that fails *without* a response —
653
+ a `PineSandbox::TimeoutError` / `ConnectionError`, which carry no id — is still actionable
654
+ off the resource spine alone (the op is still in their message).
655
+
656
+ ## Known limitations
657
+
658
+ - Pause/Resume requires a qualified snapshot-capable Computer runtime.
659
+ Other runtimes reject it as unsupported. Snapshot loss, expiry or
660
+ failed recovery surfaces an error; it never silently falls back to a fresh
661
+ runtime. Enrolled runtimes use snapshot retention and maximum age while paused; legacy expiry is unchanged.
662
+
663
+ ## Develop
664
+
665
+ ```bash
666
+ make install # bundle install
667
+ make lint # ruby -c syntax over lib + spec
668
+ make test # rspec (unit; .rspec scopes to spec/pinesandbox)
669
+ make build # gem build pinesandbox.gemspec
670
+ ```