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
@@ -0,0 +1,647 @@
1
+ require "securerandom"
2
+ require_relative "delegated_connection"
3
+ require_relative "agent_mode"
4
+ require_relative "drive_mode"
5
+
6
+ module PineSandbox
7
+ # Public names for what Session#exec yields and returns. These are aliases,
8
+ # not new classes: `is_a?` checks against the defining classes keep working.
9
+ ExecEvent = ExecStreamParser::Event
10
+ ExecResult = Adapters::CoordinatorAdapter::ExecResult
11
+
12
+ # One session on a Computer: a browser window and a shell.
13
+ #
14
+ # Returned by {Computer#create_session} and friends; `parent` is the
15
+ # Computer. Holds the session-scoped `ps_*` token, never the parent's `ct_*`.
16
+ # Operations that accept either token use the session's own, or the
17
+ # Computer token for a handle read without one ({Computer#session} returns
18
+ # the session's metadata without its token). Session-only operations (exec,
19
+ # tabs, focus, epoch, drive actions, passkey intent and decisions) need the
20
+ # session token.
21
+ class Session
22
+ attr_reader :info, :parent
23
+
24
+ def initialize(parent:, payload:)
25
+ @parent = parent
26
+ @info = Models::Session.from_payload(payload)
27
+ raise PineSandbox::ApiError.new(status: 0, error_message: "empty session payload") if @info.nil?
28
+ end
29
+
30
+ def name
31
+ info.name
32
+ end
33
+
34
+ # Optional human-meaningful label set at create (distinct from the addressing
35
+ # name). nil when none was given.
36
+ def label
37
+ info.label
38
+ end
39
+
40
+ # The session token (ps_). A session read with Computer#session or
41
+ # Computer#sessions has none: only Computer#create_session returns it, so
42
+ # save it then.
43
+ def token
44
+ info.token
45
+ end
46
+
47
+ def browser
48
+ info.browser
49
+ end
50
+
51
+ def session_dir
52
+ info.session_dir
53
+ end
54
+
55
+ # session.agent.* — hand-off-the-loop driving (run/steer/answer/cancel/reset/
56
+ # status/result/events). The agent drives; you delegate goals. See {AgentMode}.
57
+ def agent
58
+ @agent ||= AgentMode.new(self)
59
+ end
60
+
61
+ # session.drive.* — drive the session with your own agent
62
+ # (observe/computer_use/upload_file). See {DriveMode}.
63
+ def drive
64
+ @drive ||= DriveMode.new(self)
65
+ end
66
+
67
+ # Attribute the next passkey enrollment for this RP to the current agent
68
+ # session. This grants no consent; use the session-scoped decision methods
69
+ # after the site starts WebAuthn.
70
+ def declare_passkey_intent(rp_id)
71
+ raise ArgumentError, "rp_id is required" if rp_id.to_s.empty?
72
+
73
+ coordinator.declare_passkey_intent(token: token, name: name, rp_id: rp_id)
74
+ end
75
+
76
+ # Non-secret first-enrollment decisions attributed to this agent session.
77
+ def pending_passkey_ceremonies
78
+ coordinator.list_session_passkey_ceremonies(token: token, name: name)
79
+ end
80
+
81
+ def approve_passkey_ceremony(ceremony_id)
82
+ decide_session_passkey_ceremony(ceremony_id, "approve_custodial")
83
+ end
84
+
85
+ def decline_passkey_ceremony(ceremony_id)
86
+ decide_session_passkey_ceremony(ceremony_id, "decline")
87
+ end
88
+
89
+ # session.learn — author a learned-skill DRAFT from THIS session's latest completed run.
90
+ # Authoring is a separate model run, not a task tool. Returns a {LearnResult}: a
91
+ # registered draft, or a not-reusable run. (An ambiguous run is simply not authored —
92
+ # the clarification round lives on #teach, where a person can answer.) Lifecycle/serving is on
93
+ # the Computer — see {Computer#skills}. Uses the Computer token (ct_).
94
+ #
95
+ # **BLOCKS until the authoring run ends — typically 30–180 s.** Use this from
96
+ # CLI tools, backend workers, Sidekiq-class jobs, tests, or any caller that owns its own
97
+ # request budget. **If you're calling from a web handler behind a reverse proxy** (Cloudflare,
98
+ # ALB, GFE, Heroku — all have request timeouts in the 30–100 s range), use {#start_learn}
99
+ # and follow {#author_events} yourself — a sync call WILL hit the proxy's idle / total cap and
100
+ # the connection will be severed before the terminal event arrives, even though the Computer
101
+ # finishes the run.
102
+ #
103
+ # `scaffold:` is an OPTIONAL seed of the SKILL.md the authoring run should
104
+ # continue from — frontmatter, section headers, explicit parameter names, the "## Gotchas"
105
+ # you already know. The run preserves its structure and fills in the body from the
106
+ # navigation. Capped at 32 KiB. Nil/empty authors from scratch.
107
+ def learn(scaffold: nil)
108
+ kwargs = { token: @parent.computer_token, name: name }
109
+ kwargs[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
110
+ # /learn returns 202 + {author_session, task_id} (streaming). Subscribe
111
+ # to the author event stream, await the terminal Result, and translate to
112
+ # the LearnResult shape existing callers expect.
113
+ accepted = ct_call { coordinator.learn_skill(**kwargs) }
114
+ LearnResult.new(self, AuthorTerminalTranslator.to_learn_result(await_author(accepted["author_session"])))
115
+ end
116
+
117
+ # session.start_learn(scaffold:) — non-blocking variant of #learn. Returns the raw
118
+ # 202 envelope (`{author_session, task_id, ...}`) WITHOUT awaiting the run's end.
119
+ # Follow {#author_events} with the envelope's `author_session` for progress and the
120
+ # terminal event.
121
+ #
122
+ # Use this when the wall-clock of the run could exceed the caller's request budget
123
+ # (e.g. a web handler behind a proxy with a ~100 s ceiling, while authoring runs can
124
+ # take minutes). Mirrors {AgentMode#run}'s 202 + events pattern. Uses the Computer
125
+ # token (ct_).
126
+ def start_learn(scaffold: nil)
127
+ kwargs = { token: @parent.computer_token, name: name }
128
+ kwargs[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
129
+ ct_call { coordinator.learn_skill(**kwargs) }
130
+ end
131
+
132
+ # session.teach(goal:) — author a draft from the human demonstration just finalized on this
133
+ # session (take control → demonstrate → release finalizes a handoff; then teach reads it).
134
+ # Returns a clarify-able {TeachResult}; the draft is tainted (a demo may touch a login/MFA
135
+ # field), so you still review and activate it. Uses the Computer token (ct_).
136
+ #
137
+ # **BLOCKS until the authoring run ends — typically 30–180 s.** Same reverse-
138
+ # proxy caveat as {#learn}: if you're calling from a web handler behind a CDN / load
139
+ # balancer with a 30–100 s timeout, use {#start_teach} and follow {#author_events}
140
+ # yourself; the proxy will sever the sync call before the terminal event.
141
+ #
142
+ # `scaffold:` mirrors #learn — an optional seed the authoring run continues from.
143
+ # {TeachResult#clarify} forwards it to the clarification round so your skeleton
144
+ # is preserved across the round-trip.
145
+ def teach(goal:, handoff_id: nil, clarifications: nil, scaffold: nil)
146
+ kwargs = {
147
+ token: @parent.computer_token, name: name, goal: goal,
148
+ handoff_id: handoff_id, clarifications: clarifications
149
+ }
150
+ kwargs[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
151
+ accepted = ct_call { coordinator.teach_skill(**kwargs) }
152
+ term = await_author(accepted["author_session"])
153
+ TeachResult.new(self, goal, scaffold,
154
+ AuthorTerminalTranslator.to_teach_result(term, handoff_id: accepted["handoff_id"]))
155
+ end
156
+
157
+ # session.start_teach(goal:, ...) — non-blocking variant of #teach. Same intent as
158
+ # {#start_learn}: starts teaching (or its clarification round when handoff_id +
159
+ # clarifications are provided) and returns the raw 202 envelope. Follow
160
+ # {#author_events} for the terminal event. Uses the Computer token (ct_).
161
+ def start_teach(goal:, handoff_id: nil, clarifications: nil, scaffold: nil)
162
+ kwargs = {
163
+ token: @parent.computer_token, name: name, goal: goal,
164
+ handoff_id: handoff_id, clarifications: clarifications
165
+ }
166
+ kwargs[:scaffold] = scaffold unless scaffold.nil? || scaffold.empty?
167
+ ct_call { coordinator.teach_skill(**kwargs) }
168
+ end
169
+
170
+ # session.start_refine(skill:, version:, guidance:) — non-blocking REFINE trigger.
171
+ # Revises an already-authored skill with a plain-language instruction. The named
172
+ # version's SKILL.md is the starting point, the instruction is the guidance, and —
173
+ # when this session holds a trustworthy completed turn — that turn's navigation is
174
+ # the evidence, so the session you refine FROM matters. The run registers a new
175
+ # draft version. Returns the raw 202 envelope (`{author_session, task_id, ...}`);
176
+ # follow {#author_events} for the terminal event. Guidance is capped at 64 KiB.
177
+ # Uses the Computer token (ct_).
178
+ def start_refine(skill:, version:, guidance:)
179
+ ct_call do
180
+ coordinator.refine_skill(
181
+ token: @parent.computer_token, name: name, skill: skill, version: version, guidance: guidance
182
+ )
183
+ end
184
+ end
185
+
186
+ # session.refine(skill:, version:, guidance:) — revise a skill version with a
187
+ # plain-language instruction and wait for the new draft. Returns a
188
+ # {LearnResult}. **BLOCKS until the authoring run ends**, like {#learn};
189
+ # behind a proxy with a short request timeout use {#start_refine} and
190
+ # follow {#author_events}. Uses the Computer token (ct_).
191
+ def refine(skill:, version:, guidance:)
192
+ accepted = ct_call do
193
+ coordinator.refine_skill(
194
+ token: @parent.computer_token, name: name, skill: skill, version: version, guidance: guidance
195
+ )
196
+ end
197
+ LearnResult.new(self, AuthorTerminalTranslator.to_learn_result(await_author(accepted["author_session"])))
198
+ end
199
+
200
+ # session.author_events(author_id:) — follow an authoring run started by {#start_learn},
201
+ # {#start_teach} or {#start_refine}. `author_id` is the `author_session` from the start
202
+ # call's 202 envelope. Yields each event as a typed, Hash-compatible {AgentEvent}; the
203
+ # run's last event is its `result` (`event.result`), whose
204
+ # `"terminal_reason"` says how the run ended. A dropped connection reopens
205
+ # from the last event seen; after five reconnects in a row that delivered
206
+ # nothing it raises StreamLostError. Pass `last_event_id:` (an event's
207
+ # `event_id`) to resume in another process. Returns the last seen event id. Without a
208
+ # block, returns an Enumerator that opens the stream only when iterated. Uses the
209
+ # session token, or the Computer token for a handle without one.
210
+ #
211
+ # @param author_id [String] the `author_session` from the start call.
212
+ # @param last_event_id [String, Integer, nil] resume after this event.
213
+ # @yieldparam event [PineSandbox::AgentEvent]
214
+ # @return [String, nil] the last seen event id.
215
+ def author_events(author_id:, last_event_id: nil, &block)
216
+ raise ArgumentError, "author_id is required" if author_id.to_s.empty?
217
+ return enum_for(:author_events, author_id: author_id, last_event_id: last_event_id) unless block
218
+
219
+ read_call do |read|
220
+ coordinator.follow_author_events(
221
+ token: read, name: name, author_id: author_id, last_event_id: last_event_id
222
+ ) { |event| block.call(AgentEvent.wrap(event)) }
223
+ end
224
+ end
225
+
226
+ # session.cancel_author(author_id:) — cancel an in-flight authoring run started by
227
+ # {#start_learn} / {#start_teach} / {#start_refine}. `author_id` is the `author_session`
228
+ # from the start call's 202 envelope (an opaque ID). The run ends with
229
+ # `terminal_reason: "canceled"` on {#author_events}. Returns the parsed 202
230
+ # body — `canceled: false` means the run had already finished (feedback, not an error).
231
+ # Uses the Computer token (ct_).
232
+ def cancel_author(author_id:)
233
+ ct_call { coordinator.cancel_author(token: @parent.computer_token, name: name, author_id: author_id) }
234
+ end
235
+
236
+ # session.author_skill — register a learned-skill draft DIRECTLY (your own agent
237
+ # wrote the SKILL.md, rather than an authoring run). `name:` is the skill's
238
+ # kebab-case name. Returns the draft meta. Uses the Computer token (ct_).
239
+ def author_skill(name:, skill_md:, reason:, origin: nil, tainted: nil)
240
+ ct_call do
241
+ coordinator.author_skill_draft(
242
+ token: @parent.computer_token, name: self.name, skill_name: name,
243
+ skill_md: skill_md, reason: reason, origin: origin, tainted: tainted
244
+ )
245
+ end
246
+ end
247
+
248
+ # Reads the session's latest /epoch payload. Use it to detect a browser
249
+ # or service restart and to read the current session_generation.
250
+ def epoch
251
+ coordinator.epoch(token: token, name: name)
252
+ end
253
+
254
+ def focus!
255
+ coordinator.focus(token: token, name: name)
256
+ self
257
+ end
258
+
259
+ # Streams an exec; yields each parsed PineSandbox::ExecEvent (type,
260
+ # text, data, ...). Returns a PineSandbox::ExecResult (stdout, stderr,
261
+ # exit_code, error_message). Block is optional.
262
+ def exec(command, cwd: nil, timeout_ms: nil, &block)
263
+ coordinator.exec(token: token, name: name, command: command, cwd: cwd, timeout_ms: timeout_ms, &block)
264
+ end
265
+
266
+ def tabs
267
+ payload = coordinator.list_tabs(token: token, name: name)
268
+ payload[:tabs].map { |t| Models::Tab.from_payload(t) }
269
+ end
270
+
271
+ # Lists files and directories under the session workdir.
272
+ #
273
+ # @param path [String, nil] workdir-relative directory to list; nil lists
274
+ # the workdir root.
275
+ # @param pattern [String, nil] optional base-name glob filter, e.g. "*.log".
276
+ # @return [Array<PineSandbox::Models::FileEntry>]
277
+ def list_files(path: nil, pattern: nil)
278
+ read_call { |read| coordinator.list_files(token: read, name: name, path: path, pattern: pattern) }
279
+ end
280
+
281
+ # Reads one file from the session workdir. With a block, yields the file
282
+ # in binary chunks as they arrive instead of returning it whole, so a large
283
+ # file never sits in memory; an error answer raises before any chunk.
284
+ #
285
+ # @param path [String] workdir-relative file path.
286
+ # @yieldparam chunk [String] the next bytes, binary encoding.
287
+ # @return [String, nil] raw file bytes with binary encoding; nil with a block.
288
+ def read_file(path, &block)
289
+ read_call { |read| coordinator.read_file(token: read, name: name, path: path, &block) }
290
+ end
291
+
292
+ # A workdir file's bytes as a stream, for copying a large file onward
293
+ # without buffering it, such as a file a turn's result names: yields binary
294
+ # chunks as they arrive. Without a block, returns an Enumerator of the
295
+ # chunks that sends the request only when iterated
296
+ # (`session.open_file(path).each { |chunk| io.write(chunk) }`).
297
+ # An error answer raises before any chunk.
298
+ #
299
+ # @param path [String] workdir-relative file path.
300
+ # @yieldparam chunk [String] the next bytes, binary encoding.
301
+ # @return [nil, Enumerator]
302
+ def open_file(path, &block)
303
+ raise ArgumentError, "file path is required" if path.to_s.empty?
304
+ return enum_for(:open_file, path) unless block
305
+
306
+ read_call { |read| coordinator.read_file(token: read, name: name, path: path, &block) }
307
+ end
308
+
309
+ # Deposits a material (an agent INPUT file) into the session's attachments/
310
+ # root. `filename` is a single name — path separators ("/", "..") are
311
+ # rejected. The attachments root is a SIBLING of the workdir that
312
+ # {#read_file}/{#list_files} serve, so the agent is told about a deposited
313
+ # material rather than finding it through {#read_file}.
314
+ #
315
+ # @param filename [String] single filename to deposit (no path separators).
316
+ # @param content [String] file bytes.
317
+ # @return [Hash] the response, e.g.
318
+ # { "root" => "attachments", "relative_path" => "in.txt", "size" => 5 }.
319
+ def write_file(filename, content)
320
+ read_call { |read| coordinator.write_file(token: read, name: name, path: filename, content: content) }
321
+ end
322
+
323
+ # Lists the session's artifacts — client uploads, browser downloads,
324
+ # and every completed turn's agent outputs (durably captured at each turn's
325
+ # result). Newest-first.
326
+ #
327
+ # @param turn_id [String, nil] filter to one turn's artifacts.
328
+ # @return [Array<PineSandbox::Models::Artifact>]
329
+ def list_artifacts(turn_id: nil)
330
+ read_call { |read| coordinator.list_artifacts(token: read, name: name, turn_id: turn_id) }
331
+ end
332
+
333
+ # Reads one artifact's bytes by id. With a block, yields them in binary
334
+ # chunks as they arrive (see {#read_file}).
335
+ #
336
+ # @param id [String] the artifact id (from {#list_artifacts}).
337
+ # @yieldparam chunk [String] the next bytes, binary encoding.
338
+ # @return [String, nil] raw artifact bytes with binary encoding; nil with a block.
339
+ def read_artifact(id, &block)
340
+ read_call { |read| coordinator.read_artifact(token: read, name: name, id: id, &block) }
341
+ end
342
+
343
+ # An artifact's bytes as a stream, for copying onward without buffering
344
+ # the file: yields binary chunks as they arrive. Without a block, returns
345
+ # an Enumerator of the chunks that sends the request only when iterated
346
+ # (`session.open_artifact(id).each { |chunk| io.write(chunk) }`). An error
347
+ # answer raises before any chunk.
348
+ #
349
+ # @param id [String] the artifact id (from {#list_artifacts}).
350
+ # @yieldparam chunk [String] the next bytes, binary encoding.
351
+ # @return [nil, Enumerator]
352
+ def open_artifact(id, &block)
353
+ raise ArgumentError, "artifact id is required" if id.to_s.empty?
354
+ return enum_for(:open_artifact, id) unless block
355
+
356
+ read_call { |read| coordinator.read_artifact(token: read, name: name, id: id, &block) }
357
+ end
358
+
359
+ # Downloads the session's artifacts as a zip (optionally one turn's). With
360
+ # a block, yields the zip in binary chunks as they arrive (see {#read_file}).
361
+ #
362
+ # @param turn_id [String, nil] filter to one turn's artifacts.
363
+ # @yieldparam chunk [String] the next bytes, binary encoding.
364
+ # @return [String, nil] raw zip bytes with binary encoding; nil with a block.
365
+ def zip_artifacts(turn_id: nil, &block)
366
+ read_call { |read| coordinator.zip_artifacts(token: read, name: name, turn_id: turn_id, &block) }
367
+ end
368
+
369
+ # Deprecated: use #zip_artifacts.
370
+ def download_artifacts_zip(turn_id: nil, &block)
371
+ PineSandbox.deprecated_name("Session#download_artifacts_zip", "#zip_artifacts")
372
+ zip_artifacts(turn_id: turn_id, &block)
373
+ end
374
+
375
+ # Uploads bytes as a new artifact (created_by:upload). Storage is id-addressed
376
+ # and immutable — a repeated filename yields a distinct artifact.
377
+ #
378
+ # @param filename [String] a single filename (no path separators).
379
+ # @param content [String] the bytes.
380
+ # @return [PineSandbox::Models::Artifact]
381
+ def upload_artifact(filename, content)
382
+ read_call { |read| coordinator.upload_artifact(token: read, name: name, filename: filename, content: content) }
383
+ end
384
+
385
+ def session_generation
386
+ coordinator.list_tabs(token: token, name: name)[:session_generation]
387
+ end
388
+
389
+ def create_tab(url:, label: nil)
390
+ Models::Tab.from_payload(
391
+ coordinator.create_tab(token: token, name: name, url: url, label: label)
392
+ )
393
+ end
394
+
395
+ def patch_tab(target_id, active: nil, label: nil)
396
+ Models::Tab.from_payload(
397
+ coordinator.patch_tab(token: token, name: name, target_id: target_id, active: active, label: label)
398
+ )
399
+ end
400
+
401
+ def close_tab(target_id)
402
+ coordinator.close_tab(token: token, name: name, target_id: target_id)
403
+ end
404
+
405
+ # Destroy the session. Default leaves the session's files on disk; pass
406
+ # clean: true to delete them too. Deprecated alias of
407
+ # Computer#destroy_session, so it uses the Computer token as that does.
408
+ def destroy(clean: false)
409
+ PineSandbox.deprecated_name("Session#destroy", "Computer#destroy_session")
410
+ ct_call { coordinator.destroy_session(token: parent.computer_token, name: name, clean: clean) }
411
+ end
412
+
413
+ # Rebuild the shell after `Session#exec` returned 409 `terminal_lost`
414
+ # (the shell service restarted; the browser survived). After this, `exec`
415
+ # works again with the original PINE_* env + cwd. A second call against an
416
+ # already-active terminal returns 409 — treat that as success-equivalent
417
+ # (already rebuilt by another caller).
418
+ def recreate_terminal!
419
+ coordinator.recreate_terminal(token: token, name: name)
420
+ end
421
+
422
+ # Control surface.
423
+ #
424
+ # All methods below talk to `/v1/sessions/{name}/control[/*]` and
425
+ # `/v1/sessions/{name}/handoffs[/*]`. Mutating PATCH requires `ct_`
426
+ # (the Computer token, held by your backend) — `Session#token`
427
+ # is `ps_`-scoped, so the helpers use `parent.computer_token`. Read
428
+ # routes accept either; they pass `ps_` (Session#token).
429
+
430
+ # GET /v1/sessions/{name}/control. Returns a {ControlState} — the Hash
431
+ # { state:, etag: } — so callers can use the ETag as `If-Match:` on the
432
+ # next PATCH.
433
+ def control_state
434
+ read_call { |read| coordinator.get_control(token: read, name: name) }
435
+ end
436
+
437
+ # Raw PATCH /v1/sessions/{name}/control. Callers can supply an
438
+ # explicit `idempotency_key:` for cross-process retry orchestration.
439
+ #
440
+ # `actor_type:` is REQUIRED; the Computer returns 400
441
+ # /errors/actor-type-required when missing.
442
+ def update_control(controller: nil, idle_paused: nil, idle_deadline: nil,
443
+ actor_type:, if_match:, force: false, idempotency_key: nil)
444
+ body = { actor_type: actor_type }
445
+ body[:controller] = controller unless controller.nil?
446
+ body[:idle_paused] = idle_paused unless idle_paused.nil?
447
+ body[:idle_deadline] = idle_deadline unless idle_deadline.nil?
448
+ ct_call do
449
+ coordinator.patch_control(
450
+ token: parent.computer_token, name: name, body: body,
451
+ if_match: if_match, force: force, idempotency_key: idempotency_key
452
+ )
453
+ end
454
+ end
455
+
456
+ # Hand the session to a person: GET /control for the ETag, then PATCH
457
+ # with it. A concurrent change (412) re-reads the state and retries once
458
+ # with a fresh Idempotency-Key. `force: true` (or actor_type
459
+ # "admin_override") records an admin override, which skips If-Match and
460
+ # is not retried. Returns the new {ControlState}.
461
+ def take_control(actor_type: "user_click", force: false)
462
+ set_controller("human", actor_type: actor_type, force: force)
463
+ end
464
+
465
+ # Hand the session back to the agent. Same handshake as #take_control.
466
+ def release_control(actor_type: "user_click", force: false)
467
+ set_controller("agent", actor_type: actor_type, force: force)
468
+ end
469
+
470
+ # Deprecated: use #take_control.
471
+ def take_human!(actor_type:)
472
+ PineSandbox.deprecated_name("Session#take_human!", "#take_control")
473
+ take_control(actor_type: actor_type)
474
+ end
475
+
476
+ # Deprecated: use #release_control.
477
+ def release!(actor_type:)
478
+ PineSandbox.deprecated_name("Session#release!", "#release_control")
479
+ release_control(actor_type: actor_type)
480
+ end
481
+
482
+ # POST /v1/sessions/{name}/control/notify, with the current ETag read
483
+ # first. Accepts `ct_` OR the session's `ps_`; this uses the session's
484
+ # own token when the handle has one. Returns true.
485
+ def notify_human(reason:, detail: nil)
486
+ etag = control_state.fetch(:etag)
487
+ read_call do |read|
488
+ coordinator.post_control_notify(
489
+ token: read, name: name, reason: reason, detail: detail,
490
+ if_match: etag
491
+ )
492
+ end
493
+ end
494
+
495
+ # POST /v1/sessions/{name}/desktop-token — ct_-only (uses
496
+ # parent.computer_token). Mints a short-TTL browser-safe desktop-stream
497
+ # connection token (dt_…). Your server hands the returned token to the
498
+ # browser, which presents it when opening /vnc/connect; ct_/ps_ never
499
+ # reach the browser. Driving needs control of the session, not this
500
+ # token, so the same token serves a viewer and a driver. Re-mint before
501
+ # each (re)connect. Returns { token:, expires_at: }. 409
502
+ # (NoDesktopStream) when the session is blind; 503
503
+ # (DesktopUnavailable) when streaming is off.
504
+ def desktop_token
505
+ ct_call { coordinator.mint_desktop_token(token: parent.computer_token, name: name) }
506
+ end
507
+
508
+ # Build the browser-safe {DelegatedConnection} for this session: the
509
+ # data-plane host, this session's name, the pinned spec major, and a
510
+ # freshly-minted short-TTL `dt_`. This is the ONE call a server makes to hand a
511
+ # browser everything it needs to connect the desktop — and NOTHING it
512
+ # shouldn't (no `ct_`/`ps_`/JWS). Re-call (server-side) to refresh `dt_`
513
+ # before each (re)connect; the web SDK drives that via a refresh callback
514
+ # to your server.
515
+ def delegate
516
+ dt = desktop_token
517
+ DelegatedConnection.new(
518
+ computer_host: coordinator.base_url,
519
+ session_name: name,
520
+ desktop_token: dt.fetch(:token),
521
+ desktop_token_expires_at: dt[:expires_at]
522
+ )
523
+ end
524
+
525
+ # GET /v1/sessions/{name}/handoffs?limit=&before=. Returns the response body
526
+ # { "handoffs" => [...], "next_before" => <cursor> } — pass next_before back as
527
+ # `before` to page older (absent = no more pages).
528
+ def handoffs(limit: nil, before: nil)
529
+ read_call { |read| coordinator.list_handoffs(token: read, name: name, limit: limit, before: before) }
530
+ end
531
+
532
+ # GET /v1/sessions/{name}/handoffs/{handoff_id}. Raises
533
+ # `PineSandbox::HandoffNotFound` (a subclass of NotFoundError) on
534
+ # 404, `HandoffExpired` (subclass of GoneError) on 410. The
535
+ # exception's `#handoff_id` accessor surfaces the same id the
536
+ # caller passed so error handlers can correlate without parsing
537
+ # the URL again.
538
+ def handoff(handoff_id)
539
+ read_call { |read| coordinator.get_handoff(token: read, name: name, handoff_id: handoff_id) }
540
+ end
541
+
542
+ # control_events streams the v1 SSE event feed and yields a typed
543
+ # struct (ControllerChanged / IdleWarning / HandoffCompleted /
544
+ # HandoffFailed) per emission. The block can raise to abort.
545
+ #
546
+ # Auto-resume: when the underlying HTTP stream closes (slow subscriber
547
+ # drop, server restart, network blip), it reconnects with the last seen
548
+ # event id as Last-Event-ID, waiting 0.5 s and doubling up to 5 s. After
549
+ # five reconnects in a row that delivered no event it raises
550
+ # StreamLostError; an error answer (401/403/404/…) raises at once.
551
+ # `max_reconnects:` additionally caps the total reconnects: once they are
552
+ # used up the stream returns (or raises its transport error).
553
+ # `reconnect_delay:` is accepted for compatibility and no longer used.
554
+ #
555
+ # Returns the most recently seen event id once the stream ends (block
556
+ # broke, max_reconnects exhausted, or `stop_when` returned true).
557
+ def control_events(last_event_id: nil, max_reconnects: nil, reconnect_delay: nil, stop_when: nil, &block)
558
+ raise ArgumentError, "block required" unless block
559
+
560
+ unless reconnect_delay.nil?
561
+ PineSandbox.deprecated_name("Session#control_events(reconnect_delay:)", "the shared reconnect backoff")
562
+ end
563
+ read_call do |read|
564
+ coordinator.follow_control_events(
565
+ token: read, name: name, last_event_id: last_event_id,
566
+ stop_when: stop_when, max_reconnects: max_reconnects, &block
567
+ )
568
+ end
569
+ end
570
+
571
+ private
572
+
573
+ def decide_session_passkey_ceremony(ceremony_id, decision)
574
+ raise ArgumentError, "ceremony_id is required" if ceremony_id.to_s.empty?
575
+
576
+ coordinator.decide_session_passkey_ceremony(
577
+ token: token, name: name, ceremony_id: ceremony_id, decision: decision
578
+ )
579
+ end
580
+
581
+ # Routes a parent-ct_ call through Computer#rebind_on_unauthorized so a
582
+ # rejected ct_ surfaces as the ONE documented typed signal
583
+ # (TokenRejectedError — a report, not an attach instruction) from Session
584
+ # methods too — never a raw 401. The ps_-scoped methods are deliberately
585
+ # NOT wrapped: a session token dies with its session, and re-attach
586
+ # cannot revive it.
587
+ def ct_call(&blk)
588
+ parent.rebind_on_unauthorized(&blk)
589
+ end
590
+
591
+ # The token for operations that accept either: the session's own, or the
592
+ # Computer token for a handle read without one (GET /sessions/{name}
593
+ # returns no ps_).
594
+ def read_token
595
+ token.to_s.empty? ? parent.computer_token : token
596
+ end
597
+
598
+ # Runs an either-token operation with #read_token. On the Computer token
599
+ # it gets the same rejected-ct_ signal as the ct_-tier methods; a session
600
+ # token's 401 stays an UnauthorizedError.
601
+ def read_call
602
+ return yield(token) unless token.to_s.empty?
603
+
604
+ ct_call { yield(parent.computer_token) }
605
+ end
606
+
607
+ # Waits out an authoring run started on this session.
608
+ def await_author(author_id)
609
+ read_call { |read| coordinator.await_author_terminal(token: read, name: name, author_id: author_id) }
610
+ end
611
+
612
+ def set_controller(controller, actor_type:, force:)
613
+ actor_type = "admin_override" if force
614
+ force = actor_type == "admin_override"
615
+ with_etag_retry(retry_precondition: !force) do |etag, key|
616
+ update_control(controller: controller, actor_type: actor_type, if_match: etag,
617
+ force: force, idempotency_key: key)
618
+ end
619
+ end
620
+
621
+ # with_etag_retry GETs the current state, calls the block with the
622
+ # ETag + an Idempotency-Key, retries ONCE on 412, then raises. The retry
623
+ # gets a FRESH key — see below.
624
+ def with_etag_retry(retry_precondition: true)
625
+ # The mutation is ct_-only (PATCH /control); GET /control accepts either
626
+ # token, so read with the session's own, or the ct_ for an adopted handle.
627
+ fetch = -> { coordinator.get_control(token: read_token, name: name) }
628
+ state = fetch.call
629
+ begin
630
+ yield state.fetch(:etag), SecureRandom.uuid
631
+ rescue PineSandbox::ApiError => e
632
+ raise unless retry_precondition && e.status == 412
633
+ # 412 → re-GET, retry with a NEW Idempotency-Key. A 412 leaves the
634
+ # first attempt's key reserved but never resolved, so reusing it would
635
+ # wait out the reservation and then fail with 409 "duplicate
636
+ # Idempotency-Key did not resolve within timeout". A fresh key is a
637
+ # fresh side-effect — exactly what the retry intends.
638
+ state = fetch.call
639
+ yield state.fetch(:etag), SecureRandom.uuid
640
+ end
641
+ end
642
+
643
+ def coordinator
644
+ parent.coordinator
645
+ end
646
+ end
647
+ end