samagotchi 0.102.0 → 0.103.0

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/bin/chi +23 -191
  4. data/docs/architecture.md +54 -0
  5. data/docs/cli.md +114 -7
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +11 -1
  10. data/docs/internals/remote-sessions.md +303 -0
  11. data/docs/internals/working-set.md +95 -11
  12. data/docs/plugins.md +26 -2
  13. data/docs/sessions.md +12 -5
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/answer_command.rb +27 -25
  16. data/lib/samagotchi/bridge.rb +24 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  18. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  19. data/lib/samagotchi/bridge_client.rb +22 -38
  20. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  22. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  24. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  25. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/chi_invocation.rb +43 -0
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/flags.rb +5 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  31. data/lib/samagotchi/context_command.rb +80 -73
  32. data/lib/samagotchi/engine.rb +12 -8
  33. data/lib/samagotchi/idle_recap.rb +7 -1
  34. data/lib/samagotchi/kernel_loop.rb +13 -1
  35. data/lib/samagotchi/launch_mode.rb +22 -0
  36. data/lib/samagotchi/llm_context_forget.rb +13 -6
  37. data/lib/samagotchi/note_command.rb +8 -7
  38. data/lib/samagotchi/parent_report.rb +55 -43
  39. data/lib/samagotchi/remote_command.rb +170 -0
  40. data/lib/samagotchi/remotes.rb +130 -0
  41. data/lib/samagotchi/run_options.rb +233 -0
  42. data/lib/samagotchi/send_command/busy.rb +58 -0
  43. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  44. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  45. data/lib/samagotchi/send_command.rb +173 -108
  46. data/lib/samagotchi/session.rb +30 -5
  47. data/lib/samagotchi/session_access/contract.rb +129 -0
  48. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  49. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  50. data/lib/samagotchi/session_access/local.rb +349 -0
  51. data/lib/samagotchi/session_access/remote.rb +638 -0
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  53. data/lib/samagotchi/session_access.rb +141 -0
  54. data/lib/samagotchi/session_archive_command.rb +9 -5
  55. data/lib/samagotchi/session_commands.rb +20 -15
  56. data/lib/samagotchi/session_delete_command.rb +8 -4
  57. data/lib/samagotchi/session_manager.rb +7 -2
  58. data/lib/samagotchi/session_setup.rb +11 -6
  59. data/lib/samagotchi/sessions_command.rb +36 -14
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
  61. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
  63. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  64. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  65. data/lib/samagotchi/terminal_ui.rb +2 -2
  66. data/lib/samagotchi/title_command.rb +9 -11
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +360 -69
  69. data/lib/samagotchi/web/public/data.js +3 -2
  70. data/lib/samagotchi/web/public/index.html +20 -4
  71. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  72. data/lib/samagotchi/web/public/stage_view.js +3 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  74. data/lib/samagotchi/web/public/working_set.js +159 -28
  75. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  76. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  77. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  78. data/lib/samagotchi/worker_sidecar.rb +12 -5
  79. data/lib/samagotchi/working_set.rb +88 -14
  80. data/lib/samagotchi/working_set_command.rb +133 -30
  81. data/lib/samagotchi/working_set_pin.rb +34 -0
  82. data/lib/samagotchi/working_set_steer.rb +191 -27
  83. metadata +19 -1
@@ -0,0 +1,638 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../session_access"
5
+ require_relative "../remotes"
6
+ require_relative "../reply_wait"
7
+ require_relative "../pending_question"
8
+ require_relative "../note_delivery"
9
+ require_relative "../owner_lock"
10
+ require_relative "../context_sources"
11
+ require_relative "../bridge_client"
12
+ require_relative "../session_continue"
13
+ require_relative "../guardrails/parent_policy"
14
+ require_relative "../model_profile"
15
+ require_relative "http_transport"
16
+ require_relative "../chi_invocation"
17
+
18
+ module Samagotchi
19
+ autoload :SessionManager, File.expand_path("../session_manager", __dir__)
20
+
21
+ module SessionAccess
22
+ # Another machine's sessions, through its chi web (the remote-sessions
23
+ # plan): SessionAccess::Local's methods, each a request to the route of
24
+ # the same name (Web::SessionAccessRoutes and the page's own), with the
25
+ # remote's token as Authorization: Bearer. Results are Local's value
26
+ # objects (SessionView and Created where Local hands out a Session), and
27
+ # a refusal raises the exception Local's callee raises, built from the
28
+ # fields chi web sends (its message is the same). A request that can't
29
+ # be made, or a chi web without these routes, raises RemoteFailed naming
30
+ # the remote. Waits and prefixes run on the server (plan D2); a wait
31
+ # here asks again until it ends or the caller's own timeout passes.
32
+ class Remote
33
+ # The longest a wait route waits (Web::SessionAccessRoutes::
34
+ # WAIT_MAX_SECONDS): each request asks for at most this.
35
+ WAIT_SLICE = 30.0
36
+ # chi web stopping mid-wait (503 server_stopping): asked again after
37
+ # this, until the caller's own timeout.
38
+ RETRY_AFTER = 1.0
39
+
40
+ attr_reader :entry
41
+
42
+ # @param entry [Remotes::Entry]
43
+ # @param transport [#call] HTTPTransport by default; specs pass one
44
+ # that calls the Rack app in this process
45
+ # @param clock [#call] the monotonic seconds a wait's deadline is
46
+ # counted on (specs pass their own)
47
+ def initialize(entry, transport: nil, sleeper: ->(seconds) { sleep(seconds) },
48
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
49
+ @entry = entry
50
+ @transport = transport || HTTPTransport.new(entry)
51
+ @sleeper = sleeper
52
+ @clock = clock
53
+ @strategies = {}
54
+ end
55
+
56
+ def inspect = "#<#{self.class.name} #{@entry.label}>"
57
+
58
+ def remote? = true
59
+ def name = @entry.name
60
+
61
+ # The printed commands' spelling: chi --remote NAME.
62
+ # @return [ChiInvocation]
63
+ def invocation = ChiInvocation.new(remote: name)
64
+
65
+ # Web::SessionAccessRoutes::FEATURE (GET /api/info's), without
66
+ # loading chi web here.
67
+ FEATURE = "session_commands"
68
+ # Web::SessionAccessRoutes::ATTACH_FEATURE: the routes a remote
69
+ # attached TUI needs (wake, worker).
70
+ ATTACH_FEATURE = "remote_attach"
71
+
72
+ # What GET /api/info's +reply+ says is wrong with +entry+'s chi web for
73
+ # chi --remote, as the error to raise; nil when it will do (chi remote
74
+ # add checks the same).
75
+ # @return [RemoteFailed, nil]
76
+ def self.info_problem(entry, reply)
77
+ failure = auth_failure(entry, reply)
78
+ return RemoteFailed.new(failure) if failure
79
+ unless reply.status == 200 && reply.json.is_a?(Hash) && reply.json["app"] == "chi-web"
80
+ return RemoteFailed.new("#{entry.label}: not chi web (GET /api/info answered #{reply.status})")
81
+ end
82
+ return nil if Array(reply.json["features"]).include?(FEATURE)
83
+
84
+ RemoteTooOld.new(too_old_message(entry, reply.json["version"]))
85
+ end
86
+
87
+ # By feature, not by a release number (that is the release's to
88
+ # name): a chi without the routes, or without the one a command asks.
89
+ def self.too_old_message(entry, version)
90
+ "remote #{entry.name} runs chi #{version || "(unknown)"}, too old for this command through --remote: " \
91
+ "update chi there (chi update)"
92
+ end
93
+
94
+ # The words for a 401 or a 403 forbidden from +entry+'s chi web, nil
95
+ # for any other answer (chi remote add says the same).
96
+ def self.auth_failure(entry, reply)
97
+ case reply.status
98
+ when 401 then "#{entry.label}: the token was refused (chi web's LAN link carries the current one: " \
99
+ "chi remote add #{entry.name} #{entry.url} --token-file FILE)"
100
+ when 403
101
+ return nil unless reply.error == "forbidden"
102
+
103
+ ip = reply.json["detail"].to_s[/ and (\S+) are answered/, 1]
104
+ "#{entry.label}: chi web answers its LAN IP only: use the LAN IP#{" #{ip}" if ip}, not a hostname"
105
+ end
106
+ end
107
+
108
+ # --- ids and sessions
109
+
110
+ # @raise [Session::AmbiguousId]
111
+ def resolve_id(given)
112
+ reply = request(:get, "/api/sessions/resolve", query: { id: given.to_s })
113
+ case reply.status
114
+ when 200 then reply.json.fetch("id")
115
+ when 404 then given.to_s
116
+ else raise_refusal(reply)
117
+ end
118
+ end
119
+
120
+ # @raise [ArgumentError] no such session (Session::AmbiguousId: a
121
+ # prefix of several)
122
+ def resolve(given)
123
+ id = resolve_id(given)
124
+ raise ArgumentError, "Session not found: #{given}" if id == given.to_s && !exists?(id)
125
+
126
+ id
127
+ end
128
+
129
+ # @return [SessionView]
130
+ # @raise [ArgumentError] no such session
131
+ def show(id)
132
+ reply = request(:get, session_path(id), query: { brief: 1 })
133
+ raise_refusal(reply) unless reply.status == 200
134
+
135
+ view_of(reply.json["session"] || {}, reply.json["pending_question"])
136
+ end
137
+
138
+ # @return [OwnerLock::Owner, nil]
139
+ def owner(id) = show(id).owner
140
+
141
+ # The rows chi sessions list prints, as SessionManager.session_summaries
142
+ # gives them (symbol keys).
143
+ def summaries(live: false, cwd: nil, limit: nil, include_tests: true, project_root: nil, include_archived: false,
144
+ sort: nil, order: nil)
145
+ query = { live: (1 if live), cwd: cwd, limit: limit, tests: (0 unless include_tests), dir: project_root,
146
+ archived: (1 if include_archived), sort: sort, order: order }
147
+ reply = request(:get, "/api/sessions/summaries", query: query)
148
+ raise_refusal(reply) unless reply.status == 200
149
+
150
+ Array(reply.json).map { |row| symbolize(row) }
151
+ end
152
+
153
+ # --- create and send
154
+
155
+ # @param spec [NewSession] its setup a SetupWords
156
+ # @return [Created]
157
+ def create(spec)
158
+ reply = request(:post, "/api/sessions", body: create_body(spec))
159
+ raise_refusal(reply) unless reply.status == 201
160
+
161
+ json = reply.json
162
+ Created.new(id: json.fetch("id"), model_warning: json["model_warning"], continues: json["continues"],
163
+ moved_children: json["moved"] || [], parent_answers_warning: json["parent_answers_warning"])
164
+ end
165
+
166
+ # Whether a just-created session's worker came up within +timeout+
167
+ # seconds: its owner lock, as the brief read names it (chi web's
168
+ # create waited for its Bridge a bounded time already).
169
+ def await_worker(id, timeout:)
170
+ deadline = monotonic + timeout
171
+ loop do
172
+ return true if owner(id)&.worker?
173
+ return false if monotonic >= deadline
174
+
175
+ sleep(WORKER_POLL)
176
+ end
177
+ end
178
+
179
+ WORKER_POLL = 0.1
180
+
181
+ # Each image (an ImageStore ref stored in +from+) uploaded into the
182
+ # session (POST …/images), as the refs a turn takes.
183
+ def upload_images(id, refs, from:)
184
+ refs.map do |ref|
185
+ file = (ref[:file] || ref["file"]).to_s
186
+ name = (ref[:name] || ref["name"]).to_s
187
+ reply = request(:post, "#{session_path(id)}/images", query: { name: name },
188
+ raw: File.binread(File.join(from.to_s, file)))
189
+ raise_refusal(reply) unless reply.status == 201
190
+
191
+ { file: reply.json.fetch("file"), name: reply.json["name"] || name }
192
+ end
193
+ end
194
+
195
+ # @return [SessionManager::DeliverResult]
196
+ # @raise [SessionManager::OwnedByTUI]
197
+ def deliver_turn(id, prompt:, client_id:, images:, delivery: nil)
198
+ body = { prompt: prompt, client_id: client_id, history: false }
199
+ body[:images] = images.map { |ref| { file: ref[:file], name: ref[:name] } } unless images.empty?
200
+ body[:check_vision] = true unless images.empty?
201
+ body[:delivery] = delivery if delivery
202
+ reply = request(:post, "#{session_path(id)}/turn", body: body)
203
+ ack = reply.json.is_a?(Hash) ? reply.json : {}
204
+ case reply.status
205
+ when 202
206
+ via = ack["via"] == "file" ? :file : :bridge
207
+ SessionManager::DeliverResult.new(status: :accepted, via: via, ack: ack.except("via"))
208
+ when 504 then SessionManager::DeliverResult.new(status: :timeout, ack: { "detail" => ack["detail"] })
209
+ when 409
210
+ raise SessionManager::OwnedByTUI, ack["session_id"] || id if ack["error"] == "owned_by_tui"
211
+
212
+ SessionManager::DeliverResult.new(status: :refused, code: 409, ack: ack)
213
+ when 404 then raise_refusal(reply)
214
+ else
215
+ return SessionManager::DeliverResult.new(status: :refused, code: reply.status, ack: ack) if (400..499).cover?(reply.status)
216
+
217
+ SessionManager::DeliverResult.new(status: :failed, ack: ack)
218
+ end
219
+ end
220
+
221
+ # @return [NoteDelivery::Result]
222
+ # @raise [SessionInbox::NoteRejected]
223
+ def note(id, text:, source:)
224
+ reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
225
+ json = reply.json.is_a?(Hash) ? reply.json : {}
226
+ case reply.status
227
+ when 200 then NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
228
+ when 409
229
+ return NoteDelivery::Result.new(id: id, status: :refused, queued: 0) if json["error"] == "owned_by_tui"
230
+
231
+ raise_refusal(reply)
232
+ when 400
233
+ raise SessionInbox::NoteRejected, json["detail"].to_s if json["error"] == "note_rejected"
234
+
235
+ raise_refusal(reply)
236
+ else raise_refusal(reply)
237
+ end
238
+ end
239
+
240
+ # --- waiting for the reply
241
+
242
+ # The pre-send state, read on the server (POST …/wait/prepare).
243
+ # +session+ is the caller's read, unused here: the server reads its
244
+ # own.
245
+ # @return [WaitStart]
246
+ def prepare_wait(id, session: nil, question_id: :pending, turn: nil, created: false)
247
+ _ = session
248
+ body = { turn: turn, created: created }
249
+ body[:question_id] = question_id unless question_id == :pending
250
+ reply = request(:post, "#{session_path(id)}/wait/prepare", body: body)
251
+ raise_refusal(reply) unless reply.status == 200
252
+
253
+ wait_start_of(reply.json)
254
+ end
255
+
256
+ # chi send --wait's busy check, on the server (the wait prepare
257
+ # route's busy: true).
258
+ # @return [SendCommand::Busy, nil]
259
+ def busy(id, queue:, session: nil)
260
+ _ = session
261
+ reply = request(:post, "#{session_path(id)}/wait/prepare", body: { busy: true, queue: queue })
262
+ return nil if reply.status == 200
263
+
264
+ raise_refusal(reply) unless reply.status == 409 && %w[busy waiting].include?(reply.error)
265
+
266
+ require_relative "../send_command/busy"
267
+ SendCommand::Busy.new(error: reply.error, session_id: reply.json["session_id"] || id,
268
+ question: PendingQuestion.from_file(reply.json["question"]),
269
+ strategy: strategy_of(reply.json["parent_strategy"]))
270
+ end
271
+
272
+ # ReplyWait on the server, asked again each WAIT_SLICE until it ends
273
+ # or +timeout+ passes.
274
+ # @return [ReplyWait::Result]
275
+ # @raise [ArgumentError] the session is gone
276
+ def wait_reply(id, cursor:, timeout:, baseline:, owner_grace: nil, turn: nil, queued: false, ended_before: false,
277
+ poll_interval: nil)
278
+ _ = poll_interval
279
+ deadline = timeout && (monotonic + timeout)
280
+ body = { cursor: cursor, baseline: baseline&.to_h, turn: turn, queued: queued, ended_before: ended_before,
281
+ owner_grace: owner_grace }
282
+ loop do
283
+ left = deadline && (deadline - monotonic)
284
+ return ReplyWait::Result.new(status: :timeout) if left && left <= 0
285
+
286
+ reply = request(:post, "#{session_path(id)}/wait", body: body.merge(timeout: [left || WAIT_SLICE, WAIT_SLICE].min))
287
+ if reply.status == 503 && reply.error == "server_stopping"
288
+ @sleeper.call(RETRY_AFTER)
289
+ next
290
+ end
291
+ raise_refusal(reply) unless reply.status == 200
292
+
293
+ @strategies[id] = strategy_of(reply.json["parent_strategy"])
294
+ result = result_of(reply.json)
295
+ return result unless result.status == :timeout
296
+ end
297
+ end
298
+
299
+ # What a parent may pick on the session's questions: the server's
300
+ # (its config.yml tightened by the session's own), as its last wait
301
+ # said, else the strictest.
302
+ def parent_strategy(id) = @strategies[id] || Guardrails::ParentPolicy.strategy(Guardrails::ParentPolicy::HARD)
303
+
304
+ # --- questions
305
+
306
+ # The answer, through chi web to the session's worker. chi web's
307
+ # answers mapped back to the Bridge's own (AnswerCommand reads those).
308
+ # @return [BridgeClient::Response, nil] nil when no worker runs it
309
+ def answer(id, question_id:, selected:, freeform:, client_id:)
310
+ bridge_reply(request(:post, "#{session_path(id)}/answer",
311
+ body: { id: question_id, selected: selected, freeform: freeform, client_id: client_id }))
312
+ end
313
+
314
+ def dismiss(id, question_id:)
315
+ bridge_reply(request(:post, "#{session_path(id)}/question/dismiss", body: { id: question_id }))
316
+ end
317
+
318
+ # --- sessions
319
+
320
+ # chi web stops it and waits its own bounded time (Web::App::
321
+ # STOP_WAIT_SECONDS, not +wait+).
322
+ # @return [Boolean] whether the worker had released it by then, as
323
+ # chi web's released says (absent: true)
324
+ def stop(id, wait:)
325
+ _ = wait
326
+ reply = request(:post, "#{session_path(id)}/stop")
327
+ raise_refusal(reply) unless reply.status == 200
328
+
329
+ reply.json["released"] != false
330
+ end
331
+
332
+ # @return [SessionManager::Restarted]
333
+ def restart(id)
334
+ reply = request(:post, "#{session_path(id)}/restart")
335
+ raise_refusal(reply) unless reply.status == 200
336
+
337
+ SessionManager::Restarted.new(session_id: reply.json["session_id"] || id, from_version: reply.json["from_version"],
338
+ version: reply.json["version"])
339
+ end
340
+
341
+ # @return [Hash] {id:, archived:, stopped:, discarded:}
342
+ def archive(id)
343
+ reply = request(:post, "#{session_path(id)}/archive")
344
+ raise_refusal(reply) unless reply.status == 200
345
+
346
+ { id: reply.json["session_id"] || id, archived: Array(reply.json["archived"]), stopped: Array(reply.json["stopped"]),
347
+ discarded: Array(reply.json["discarded"]) }
348
+ end
349
+
350
+ # @return [Hash] {id:, unarchived:}
351
+ def unarchive(id)
352
+ reply = request(:post, "#{session_path(id)}/unarchive")
353
+ raise_refusal(reply) unless reply.status == 200
354
+
355
+ { id: reply.json["session_id"] || id, unarchived: Array(reply.json["unarchived"]) }
356
+ end
357
+
358
+ # @return [Hash] {id:, stopped:}
359
+ def delete(id, force:)
360
+ reply = request(:delete, session_path(id), query: { force: (true if force) })
361
+ raise_refusal(reply) unless reply.status == 200
362
+
363
+ { id: reply.json["session_id"] || id, stopped: reply.json["stopped"] == true }
364
+ end
365
+
366
+ # --- context (a session's own sources: a project's are local only)
367
+
368
+ # @raise [ContextSources::Invalid] refused (a name taken, a bad one)
369
+ def context_add(target, source)
370
+ body = { name: source.name, why: source.why, hint: source.hint, added_by: source.added_by }
371
+ source.push? ? body[:push] = true : body.merge!(cmd: source.cmd, every: source.every_seconds)
372
+ context_added(request(:post, "#{session_path(target.session_id)}/context", body: body.compact))
373
+ nil
374
+ end
375
+
376
+ # A URL through the remote's installed bundles' providers (chi web's
377
+ # {url} body: the page's own).
378
+ # @return [String] the source's name
379
+ # @raise [ContextProviders::Invalid] no bundle there resolves it
380
+ def context_add_url(target, url, why:, hint:, added_by:)
381
+ reply = request(:post, "#{session_path(target.session_id)}/context",
382
+ body: { url: url, why: why, hint: hint, added_by: added_by }.compact)
383
+ if reply.status == 422
384
+ require_relative "../context_providers"
385
+ detail = if reply.error == "no_provider" && reply.json["detail"].to_s.start_with?("no installed bundle resolves")
386
+ "no installed bundle on #{name} resolves #{url} (chi bundle install github-pr there for GitHub PRs)"
387
+ else
388
+ "#{reply.json["detail"]} (on #{name})"
389
+ end
390
+ raise ContextProviders::Invalid, detail
391
+ end
392
+ context_added(reply)
393
+ reply.json.fetch("name")
394
+ end
395
+
396
+ # @param fetched [ContextSources::Fetched]
397
+ # @return [ContextPush]
398
+ def context_push(target, name, fetched)
399
+ reply = request(:post, "#{session_path(target.session_id)}/context/#{name}/push",
400
+ body: { text: fetched.text, summary: fetched.summary, wake: fetched.wake, hint: fetched.hint })
401
+ case reply.status
402
+ when 200 then ContextPush.new(status: :pushed, before: reply.json["before"], revision: reply.json["revision"])
403
+ when 404
404
+ return ContextPush.new(status: :missing) if reply.error == "not_found"
405
+
406
+ raise_refusal(reply)
407
+ when 409 then ContextPush.new(status: :not_push)
408
+ when 400 then raise ContextSources::Invalid, reply.json["detail"].to_s
409
+ else raise_refusal(reply)
410
+ end
411
+ end
412
+
413
+ # @return [Array<ContextEntry>]
414
+ def context_entries(target)
415
+ reply = request(:get, "#{session_path(target.session_id)}/context", query: { full: 1 })
416
+ raise_refusal(reply) unless reply.status == 200
417
+
418
+ Array(reply.json["sources"]).map { |row| context_entry_of(row) }
419
+ end
420
+
421
+ # @return [ContextEntry, nil]
422
+ def context_entry(target, name)
423
+ ContextSources.check_name!(name)
424
+ reply = request(:get, "#{session_path(target.session_id)}/context/#{name}", query: { full: 1 })
425
+ return nil if reply.status == 404 && reply.error == "not_found" && reply.json["detail"].to_s.start_with?("no source")
426
+
427
+ raise_refusal(reply) unless reply.status == 200
428
+
429
+ context_entry_of(reply.json, text: reply.json["text"])
430
+ end
431
+
432
+ # @return [Symbol] :removed, :project or :missing
433
+ def context_remove(target, name)
434
+ ContextSources.check_name!(name)
435
+ reply = request(:delete, "#{session_path(target.session_id)}/context/#{name}", query: { own: 1 })
436
+ case reply.status
437
+ when 200 then :removed
438
+ when 409 then reply.error == "project_source" ? :project : raise_refusal(reply)
439
+ when 404 then reply.json["detail"].to_s.start_with?("no source") ? :missing : raise_refusal(reply)
440
+ else raise_refusal(reply)
441
+ end
442
+ end
443
+
444
+ # --- a remote attach
445
+
446
+ # The client a remote attached TUI talks to the session's worker
447
+ # with, through this chi web.
448
+ # @param worker [Hash] the live worker as POST …/wake or GET …/worker
449
+ # gave it: its epoch is its key (started_at for a worker from before
450
+ # the epoch in its sidecar)
451
+ # @return [WebBridgeClient]
452
+ def attach_client(id, worker:)
453
+ require_relative "web_bridge_client"
454
+ WebBridgeClient.new(remote: self, session_id: id, worker_key: worker["epoch"] || worker["started_at"])
455
+ end
456
+
457
+ # One request to a route, as the session commands make them (the
458
+ # server check first; a refused token or a missing route raise
459
+ # RemoteFailed): WebBridgeClient's, which maps the reply itself.
460
+ # @return [HTTPTransport::Reply]
461
+ # @raise [RemoteFailed]
462
+ def route_request(method, path, **) = request(method, path, **)
463
+
464
+ private
465
+
466
+ def session_path(id) = "/api/sessions/#{id}"
467
+ def monotonic = @clock.call
468
+
469
+ def exists?(id)
470
+ request(:get, session_path(id), query: { brief: 1 }).status == 200
471
+ end
472
+
473
+ # One request, after the first one's check that this chi web has the
474
+ # routes (GET /api/info). A 401, a 403 forbidden, and a 404 for a
475
+ # route it doesn't have (an older chi web) raise RemoteFailed here.
476
+ def request(method, path, **)
477
+ check_server!
478
+ reply = @transport.call(method, path, **)
479
+ raise RemoteFailed, Remote.auth_failure(@entry, reply) if Remote.auth_failure(@entry, reply)
480
+ raise RemoteTooOld, too_old(@version) if missing_route?(reply)
481
+
482
+ reply
483
+ end
484
+
485
+ def check_server!
486
+ return if @checked
487
+
488
+ reply = @transport.call(:get, "/api/info")
489
+ @version = reply.json["version"] if reply.json.is_a?(Hash)
490
+ problem = Remote.info_problem(@entry, reply)
491
+ raise problem if problem
492
+
493
+ @checked = true
494
+ end
495
+
496
+ def too_old(version) = Remote.too_old_message(@entry, version)
497
+
498
+ # chi web's 404 for a path no route takes (#serve_static's), not a
499
+ # route's own "no such session".
500
+ def missing_route?(reply)
501
+ reply.status == 404 && reply.error == "not_found" && reply.json["detail"].to_s.start_with?("not found: /")
502
+ end
503
+
504
+ # Raises what Local's callee raises for this refusal (chi web sends
505
+ # the fields each class is built from; see docs/architecture.md, chi
506
+ # web's routes for the session commands), else RemoteFailed.
507
+ def raise_refusal(reply)
508
+ json = reply.json.is_a?(Hash) ? reply.json : {}
509
+ error = json["error"].to_s
510
+ message = json["message"] || json["detail"]
511
+ sid = json["session_id"]
512
+ reason = json["reason"]&.to_sym
513
+ case error
514
+ when "not_found" then raise ArgumentError, message.to_s
515
+ when "ambiguous_id" then raise Session::AmbiguousId.new(message, ids: Array(json["ids"]))
516
+ when "owned_by_tui" then raise SessionManager::OwnedByTUI, sid
517
+ when "worker_running", "still_stopping" then raise SessionManager::DeleteRefused.new(sid, reason)
518
+ when "busy", "scratch"
519
+ raise SessionManager::ArchiveRefused.new(sid, reason, busy_id: json["busy_id"]) if reason
520
+ when "continued", "open_children", "folder_gone"
521
+ raise SessionContinue::Refused.new(sid, reason, ids: Array(json["ids"]))
522
+ when "held", "not_running", "unsupported", "failed"
523
+ raise SessionManager::RestartRefused.new(sid, reason, version: json["version"]) if reason
524
+ when "parent_answers_refused", "parent_continue_refused", "no_vision"
525
+ raise Refused.new(message.to_s, code: error)
526
+ # No model configured there, or --model's host unknown there: what
527
+ # spawn_session raises here (chi send: could not start a session).
528
+ when "invalid_model" then raise ModelProfile::MissingModel, message.to_s
529
+ when /\Ainvalid_/, "missing_fields", "bad_images"
530
+ raise BadRequest.new(message.to_s, code: error)
531
+ end
532
+ raise RemoteFailed, "#{@entry.label} answered #{reply.status}#{" #{error}" unless error.empty?}" \
533
+ "#{": #{message}" if message}"
534
+ end
535
+
536
+ def create_body(spec)
537
+ body = { model: spec.model_name, client_id: spec.client_id, history: false }
538
+ if spec.preview
539
+ body.merge!(idle: true, preview: spec.preview)
540
+ else
541
+ body[:prompt] = spec.prompt
542
+ end
543
+ body[:check_vision] = true if spec.images
544
+ if spec.continues
545
+ body[:continues] = spec.continues
546
+ else
547
+ body[:dir] = spec.working_directory
548
+ body.merge!(setup_body(spec.setup)) if spec.setup
549
+ end
550
+ body.compact
551
+ end
552
+
553
+ def setup_body(words)
554
+ llm = words.llm_context&.to_h { |field, word| [LLM_CONTEXT_KEYS.fetch(field), word] }
555
+ { llm_context: llm, thinking: words.thinking, toolkit: words.toolkit,
556
+ tools: (words.tools unless Array(words.tools).empty?), no_tools: (words.no_tools unless Array(words.no_tools).empty?),
557
+ settings: words.settings, parent_answers: words.parent_answers }.compact
558
+ end
559
+
560
+ # SetupWords' llm_context fields as the create route names them
561
+ # (LLMContextOverride::COMMAND_WORDS).
562
+ LLM_CONTEXT_KEYS = { strategy: "strategy", apply: "apply", budget_tokens: "budget" }.freeze
563
+ private_constant :LLM_CONTEXT_KEYS
564
+
565
+ def view_of(json, pending)
566
+ owner = json["owner"] && OwnerLock::Owner.new(pid: nil, kind: json["owner"].to_s, started_at: nil)
567
+ SessionView.new(id: json["id"], status: json["status"], title: json["title"],
568
+ display_title: json["display_title"] || json["title"] || json["first_preview"].to_s,
569
+ project_root: json["project_root"], working_directory: json["working_directory"],
570
+ model_name: json["model_name"], model_typed: json["model_typed"],
571
+ parent_answers: json["parent_answers"], owner: owner,
572
+ pending_question: PendingQuestion.from_file(pending))
573
+ end
574
+
575
+ def wait_start_of(json)
576
+ baseline = ReplyWait::Baseline.new(**(json["baseline"] || {}).transform_keys(&:to_sym))
577
+ WaitStart.new(cursor: json["cursor"], baseline: baseline, own_turn: json["own_turn"], known_turn: json["known_turn"],
578
+ running: json["running"] == true, live: json["live"] == true, settled: json["settled"] == true,
579
+ last_turn_id: json["last_turn_id"])
580
+ end
581
+
582
+ RESULT_FIELDS = %i[text file outcome limit error_kind retryable cancel_reason stopped_by kept_steps turn_id origin].freeze
583
+ private_constant :RESULT_FIELDS
584
+
585
+ def result_of(json)
586
+ fields = RESULT_FIELDS.to_h { |key| [key, json[key.to_s]] }
587
+ ReplyWait::Result.new(status: json["status"].to_s.to_sym, question: PendingQuestion.from_file(json["question"]),
588
+ **fields)
589
+ end
590
+
591
+ def strategy_of(json)
592
+ return nil unless json.is_a?(Hash) && json["name"]
593
+
594
+ Guardrails::ParentPolicy::Strategy.new(name: json["name"].to_s, allowed: Array(json["allowed"]).map(&:to_s).freeze,
595
+ origin: (json["origin"] || "config").to_sym)
596
+ end
597
+
598
+ # chi web's answer/dismiss reply as the worker's Bridge would have
599
+ # given it: 503 not_live is no worker (nil), 504 the Bridge's 408, 501
600
+ # not_supported its 404 for a route it lacks, 409 question_not_pending
601
+ # its 409; the rest as it came.
602
+ def bridge_reply(reply)
603
+ json = reply.json.is_a?(Hash) ? reply.json : {}
604
+ case [reply.status, json["error"]]
605
+ in [503, "not_live"] then nil
606
+ in [404, "not_found"] then nil
607
+ in [504, _] then BridgeClient::Response.new(status: 408, body: reply.body)
608
+ in [501, "not_supported"] then BridgeClient::Response.new(status: 404, body: JSON.generate(json.merge("error" => "not_found")))
609
+ else BridgeClient::Response.new(status: reply.status, body: reply.body)
610
+ end
611
+ end
612
+
613
+ def context_added(reply)
614
+ return if reply.status == 201
615
+
616
+ case reply.error
617
+ when "exists", "invalid_source", "invalid_json" then raise ContextSources::Invalid, reply.json["detail"].to_s
618
+ else raise_refusal(reply)
619
+ end
620
+ end
621
+
622
+ def context_entry_of(row, text: nil)
623
+ source = ContextSources::Source.from_h(row["source"] || {})
624
+ snapshot = ContextSources::Snapshot.from_h((row["snapshot"] || {}).merge("text" => text))
625
+ ContextEntry.new(source: source, snapshot: snapshot, muted: row["muted"], shadowed: row["shadowed"],
626
+ unread: row["unread"])
627
+ end
628
+
629
+ def symbolize(value)
630
+ case value
631
+ when Hash then value.to_h { |key, item| [key.to_sym, symbolize(item)] }
632
+ when Array then value.map { |item| symbolize(item) }
633
+ else value
634
+ end
635
+ end
636
+ end
637
+ end
638
+ end