pinesandbox 0.0.1 → 0.3.19

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