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