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,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../session_access"
5
+ require_relative "../reply_wait"
6
+ require_relative "../bridge_client"
7
+ require_relative "../owner_lock"
8
+ require_relative "../parent_report"
9
+ require_relative "../note_delivery"
10
+ require_relative "../image_store"
11
+ require_relative "../context_sources"
12
+ require_relative "../send_command/busy"
13
+ require_relative "../chi_invocation"
14
+ require_relative "../worker_sidecar"
15
+ require_relative "../launch_mode"
16
+ require_relative "../participants"
17
+ require_relative "../config"
18
+ require_relative "../web/grants"
19
+ require_relative "../web/info_probe"
20
+
21
+ module Samagotchi
22
+ # Loaded on first use: SessionManager and its ~80 files only when a
23
+ # command reaches it (chi context never does).
24
+ autoload :SessionManager, File.expand_path("../session_manager", __dir__)
25
+
26
+ module SessionAccess
27
+ # This machine's sessions: the calls the session commands made before
28
+ # the seam, moved here unchanged. The errors are the callees' own
29
+ # (ArgumentError and Session::AmbiguousId for an id, SessionManager's
30
+ # refusals), which the commands word.
31
+ class Local
32
+ # @param state_dir [String, nil] the sessions' folder; nil: the
33
+ # callees' own default (Session.default_state_dir), and no
34
+ # state_dir: is passed on (chi sessions stop/restart/list)
35
+ def initialize(state_dir: nil)
36
+ @state_dir = state_dir
37
+ end
38
+
39
+ def remote? = false
40
+
41
+ # The printed commands' spelling: this machine's chi.
42
+ # @return [ChiInvocation]
43
+ def invocation = ChiInvocation::HERE
44
+
45
+ # --- ids and sessions
46
+
47
+ # The full id of +given+ (an id or a unique prefix); the session
48
+ # need not exist (Session.resolve_id).
49
+ # @raise [Session::AmbiguousId]
50
+ def resolve_id(given) = Session.resolve_id(given, **dir)
51
+
52
+ # The full id of +given+, of a session that exists.
53
+ # @raise [ArgumentError] no such session (Session::AmbiguousId: a
54
+ # prefix of several)
55
+ def resolve(given)
56
+ id = resolve_id(given)
57
+ show(id)
58
+ id
59
+ end
60
+
61
+ # @return [Session]
62
+ # @raise [ArgumentError] no such session
63
+ def show(id) = Session.load(id, **dir)
64
+
65
+ # @return [OwnerLock::Owner, nil] the worker or chi REPL that owns it
66
+ def owner(id) = SessionManager.session_owner(id, **dir)
67
+
68
+ # SessionManager.list_sessions: Session objects, for chi sessions list.
69
+ def list(**filters) = SessionManager.list_sessions(**filters, **dir)
70
+
71
+ # SessionManager.session_summaries: the summary hashes (chi sessions
72
+ # list --live/--format, chi note --all).
73
+ def summaries(**filters) = SessionManager.session_summaries(**filters, **dir)
74
+
75
+ # The session's worker for an attach: the live one as is, else
76
+ # resumed and waited for up to +wait+ seconds (chi --attach on this
77
+ # machine; chi web's POST …/wake for a remote one). A worker still
78
+ # leaving (its Bridge closed, its recap on leave being written) is
79
+ # waited for first (OwnerLock.await_release: its recap's timeout and
80
+ # a margin), so the resume finds the session free.
81
+ # @return [Woken] its sidecar nil when the Bridge didn't come in time
82
+ # @raise [ArgumentError] no such session
83
+ # @raise [Refused] code scratch: a leftover chi scratch session
84
+ # @raise [SessionManager::OwnedByTUI] a chi REPL has it
85
+ def wake(id, wait:)
86
+ session = show(id)
87
+ raise Refused.new(LaunchMode::SCRATCH_LEFTOVER, code: "scratch") if session.scratch
88
+
89
+ live = WorkerSidecar.live(session_dir(id), unlink: true)
90
+ return Woken.new(woke: false, sidecar: live) if live
91
+
92
+ OwnerLock.await_release(session_dir(id))
93
+ SessionManager.resume_session(id, **dir)
94
+ found = BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: wait)
95
+ Woken.new(woke: true, sidecar: found && WorkerSidecar.read(session_dir(id)))
96
+ end
97
+
98
+ # The session's worker now, never started (chi web's GET …/worker).
99
+ # @return [WorkerState]
100
+ # @raise [ArgumentError] no such session
101
+ def worker(id)
102
+ show(id)
103
+ WorkerState.new(sidecar: WorkerSidecar.live(session_dir(id), unlink: true),
104
+ stopped: Session.stopped_marker?(id, state_dir: state_dir!), owner: owner(id))
105
+ end
106
+
107
+ # --- create and send
108
+
109
+ # @param spec [NewSession]
110
+ # @return [Session] the session created (its id, model_warning,
111
+ # continues, moved_children)
112
+ # @raise [SessionContinue::Refused, SessionManager::ArchiveRefused,
113
+ # SessionManager::OwnedByTUI] refused; StandardError: could not start
114
+ def create(spec)
115
+ start = spec.preview ? { prompt: spec.prompt, preview: spec.preview } : { prompt: spec.prompt }
116
+ if spec.continues
117
+ return SessionManager.continue_session(spec.continues, **start, model: spec.model_name, **dir)
118
+ end
119
+
120
+ SessionManager.spawn_session(**start, working_directory: spec.working_directory, model_name: spec.model_name,
121
+ setup: spec.setup, **dir)
122
+ end
123
+
124
+ # Whether a just-created session's worker came up (its Bridge
125
+ # answers) within +timeout+ seconds.
126
+ def await_worker(id, timeout:)
127
+ BridgeClient.wait_for(id, session_dir: session_dir(id), timeout: timeout)
128
+ end
129
+
130
+ # Copies of +refs+ (ImageStore refs stored in +from+) in the
131
+ # session's folder, as the refs a turn takes.
132
+ # @return [Array<Hash>] {file:, name:} each
133
+ def upload_images(id, refs, from:)
134
+ to = session_dir(id)
135
+ refs.map do |ref|
136
+ ImageStore.copy_file(ref, from: from, to: to)
137
+ { file: ref[:file], name: ref[:name] }
138
+ end
139
+ end
140
+
141
+ # @return [SessionManager::DeliverResult]
142
+ # @raise [SessionManager::OwnedByTUI] a chi REPL owns it
143
+ def deliver_turn(id, prompt:, client_id:, images:, delivery: nil)
144
+ SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id, images: images, delivery: delivery, **dir)
145
+ end
146
+
147
+ # @return [NoteDelivery::Result]
148
+ def note(id, text:, source:)
149
+ NoteDelivery.deliver(id, text: text, source: source, state_dir: state_dir!)
150
+ end
151
+
152
+ # --- waiting for the reply
153
+
154
+ # What a wait needs from before anything is sent (WaitStart).
155
+ # @param session [Session, nil] the session as the caller just read
156
+ # it (its checks and the baseline then see the same state); nil
157
+ # reads it here
158
+ # @param question_id [String, nil, :pending] the question the
159
+ # baseline counts as already reported (ReplyWait.baseline_of);
160
+ # :pending, the one pending now
161
+ # @param turn [String, nil] a turn asked for (--turn): known_turn
162
+ # says whether it can be waited for
163
+ # @param created [Boolean] +session+ was just created: no reply, no
164
+ # question and no ended turn yet, nothing else read (read later, as
165
+ # chi web's prepare route does, its first turn may have ended: that
166
+ # end is the answer, not the baseline's)
167
+ # @return [WaitStart]
168
+ def prepare_wait(id, session: nil, question_id: :pending, turn: nil, created: false)
169
+ session ||= show(id)
170
+ if created
171
+ return WaitStart.new(cursor: nil, baseline: ReplyWait::Baseline.new(messages: session.messages.size),
172
+ own_turn: nil, known_turn: nil, running: false, live: false, settled: false,
173
+ last_turn_id: nil)
174
+ end
175
+
176
+ cursor = ReplyWait.newest_reply(id, state_dir: state_dir!)
177
+ baseline = question_id == :pending ? ReplyWait.baseline_of(session) : ReplyWait.baseline_of(session, question_id: question_id)
178
+ session_dir = session_dir(id)
179
+ running = session.status == Session::STATUS_RUNNING
180
+ WaitStart.new(
181
+ cursor: cursor, baseline: baseline, own_turn: ReplyWait.own_turn(session, session_dir: session_dir),
182
+ known_turn: (ReplyWait.known_turn?(session, session_dir: session_dir, turn: turn) if turn),
183
+ running: running, live: running || !owner(id).nil?,
184
+ settled: session.status == Session::STATUS_IDLE && !session.last_turn.nil? && session.pending_question.nil?,
185
+ last_turn_id: session.last_turn&.enqueued_id
186
+ )
187
+ end
188
+
189
+ # Why chi send --wait may not send into the session now (a turn runs,
190
+ # or a question waits on it with --queue), or nil.
191
+ # @param session [Session, nil] the session as the caller just read
192
+ # it; nil reads it here
193
+ # @return [SendCommand::Busy, nil]
194
+ def busy(id, queue:, session: nil)
195
+ session ||= show(id)
196
+ owner = owner(id) if session.status == Session::STATUS_RUNNING
197
+ SendCommand::Busy.for(session, owner: owner, queue: queue)
198
+ end
199
+
200
+ # ReplyWait.call: blocks until the reply, a question, the turn's end
201
+ # or +timeout+.
202
+ # @return [ReplyWait::Result]
203
+ # @raise [ArgumentError] the session is gone
204
+ def wait_reply(id, **)
205
+ ReplyWait.call(id, state_dir: state_dir!, **)
206
+ end
207
+
208
+ # What a parent may pick on the session's questions: this machine's
209
+ # guardrails.parent_answers tightened by the session's own
210
+ # (ParentReport.strategy_for).
211
+ def parent_strategy(id) = ParentReport.strategy_for(id, state_dir: state_dir!)
212
+
213
+ # --- questions
214
+
215
+ # The answer posted to the session's worker (its Bridge), once more
216
+ # after a 408.
217
+ # @return [BridgeClient::Response, nil] the Bridge's reply; nil when
218
+ # no worker runs it
219
+ # @raise [SystemCallError, IOError, SocketError] the worker went away
220
+ def answer(id, question_id:, selected:, freeform:, client_id:)
221
+ on_worker(id) { |client| client.answer(id: question_id, selected: selected, freeform: freeform, client_id: client_id) }
222
+ end
223
+
224
+ # The question dismissed on the session's worker; as #answer.
225
+ def dismiss(id, question_id:)
226
+ on_worker(id) { |client| client.dismiss_question(id: question_id) }
227
+ end
228
+
229
+ # --- sessions
230
+
231
+ # @return [Boolean] whether the worker released the session
232
+ def stop(id, wait:) = SessionManager.stop_session(id, wait: wait, **dir)
233
+
234
+ # @return [SessionManager::RestartResult]
235
+ def restart(id) = SessionManager.restart_session(id, **dir)
236
+
237
+ # @return [Hash] SessionManager.archive_session's
238
+ def archive(id) = SessionManager.archive_session(id, **dir)
239
+
240
+ # @return [Hash] SessionManager.unarchive_session's
241
+ def unarchive(id) = SessionManager.unarchive_session(id, **dir)
242
+
243
+ # @param force [Boolean] stop a live worker first (else refused)
244
+ # @return [Hash] SessionManager.delete_session's
245
+ def delete(id, force:) = SessionManager.delete_session(id, stop: force, **dir)
246
+
247
+ # --- context (a session's sources, or a project's: local only)
248
+ #
249
+ # +target+ is chi context's Target (session_id, project_root; a
250
+ # project's when session_id is nil).
251
+
252
+ # Attaches +source+ to the target's own sources.
253
+ def context_add(target, source)
254
+ location = context_location(target)
255
+ location.add(source.with(scope: location.scope))
256
+ nil
257
+ end
258
+
259
+ # New text for the target's --push source +name+.
260
+ # @param fetched [ContextSources::Fetched]
261
+ # @return [ContextPush]
262
+ def context_push(target, name, fetched)
263
+ attached = context_attached(target, name) or return ContextPush.new(status: :missing)
264
+ return ContextPush.new(status: :not_push) unless attached.source.push?
265
+
266
+ before, written = attached.location.locked(name) do
267
+ [attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
268
+ end
269
+ ContextPush.new(status: :pushed, before: before, revision: written.revision)
270
+ end
271
+
272
+ # The target's sources: a session's own, then its project's (shadowed
273
+ # ones too, marked), muted and unread; a project's own.
274
+ # @return [Array<ContextEntry>]
275
+ def context_entries(target)
276
+ if target.project?
277
+ location = context_location(target)
278
+ return location.sources.map { |source| ContextEntry.new(source: source, snapshot: location.snapshot(source.name)) }
279
+ end
280
+
281
+ own = context_location(target)
282
+ subs = own.subscriptions
283
+ attached_to(target, shadowed: true).map do |a|
284
+ snapshot = a.snapshot
285
+ read = subs[a.name]&.read
286
+ ContextEntry.new(source: a.source, snapshot: snapshot, muted: own.muted?(a.name), shadowed: a.shadowed,
287
+ unread: snapshot.text? && read != snapshot.revision)
288
+ end
289
+ end
290
+
291
+ # The source +name+ as the target sees it, with its snapshot.
292
+ # @return [ContextEntry, nil]
293
+ def context_entry(target, name)
294
+ attached = context_attached(target, name) or return nil
295
+ ContextEntry.new(source: attached.source, snapshot: attached.snapshot)
296
+ end
297
+
298
+ # Detaches the target's own source +name+.
299
+ # @return [Symbol] :removed, :project (a session's project has it:
300
+ # rm --project, or mute it) or :missing
301
+ def context_remove(target, name)
302
+ ContextSources.check_name!(name)
303
+ return :removed if context_location(target).remove(name)
304
+
305
+ project = !target.project? && ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
306
+ project&.source(name) ? :project : :missing
307
+ end
308
+
309
+ # Local only (chi context refresh, mute, unmute, which work on the
310
+ # files here; Contract: local_only): the source +name+ as the target
311
+ # sees it, with the folder it lives in.
312
+ # @return [ContextSources::Attached, nil]
313
+ def context_attached(target, name)
314
+ ContextSources.check_name!(name)
315
+ if target.project?
316
+ location = context_location(target)
317
+ source = location.source(name)
318
+ return source && ContextSources::Attached.new(source: source, location: location, shadowed: false)
319
+ end
320
+
321
+ attached_to(target).find { |a| a.name == name }
322
+ end
323
+
324
+ # Local only, as #context_attached: the folder a target's own sources
325
+ # live in.
326
+ def context_location(target)
327
+ if target.project?
328
+ ContextSources.project_location_for(target.project_root, state_dir: @state_dir)
329
+ else
330
+ ContextSources.session_location(target.session_id, state_dir: @state_dir)
331
+ end
332
+ end
333
+
334
+ # --- participants (chi share)
335
+
336
+ # Remote's: through this machine's grants store, as chi web would.
337
+ # @return [Array<ParticipantView>]
338
+ def participants(id = nil)
339
+ grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
340
+ end
341
+
342
+ # The link names this machine's chi web: its LAN address when one
343
+ # runs on the LAN (web.port), else 127.0.0.1 (+lan+ false).
344
+ # @return [Invitation]
345
+ # @raise [ArgumentError] no such session
346
+ # @raise [BadRequest] a bad name, kind or ttl, an archived session
347
+ def invite(id, name:, kind:, ttl: nil)
348
+ issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, state_dir: state_dir!)
349
+ ip, port = local_web
350
+ Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
351
+ link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?)
352
+ rescue Web::Grants::Invalid => e
353
+ raise BadRequest.new(e.message, code: "invalid_participant")
354
+ end
355
+
356
+ # @return [Array<ParticipantView>] the ones it revoked
357
+ def revoke_participants(id = nil, participant: nil)
358
+ Participants.revoke(grants, session_id: id, id: participant, state_dir: state_dir!)
359
+ .map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
360
+ end
361
+
362
+ private
363
+
364
+ def grants = @grants ||= Web::Grants.new(Web::Grants.path(state_dir: state_dir!))
365
+
366
+ # Web::Server::DEFAULT_PORT, without loading chi web.
367
+ WEB_PORT = 4567
368
+ LOCAL_PROBE_TIMEOUT = 1.0
369
+
370
+ # [LAN ip or nil, port] of this machine's chi web (GET /api/info).
371
+ def local_web
372
+ port = Config.get("web.port").to_s.to_i
373
+ port = WEB_PORT unless port.positive?
374
+ info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
375
+ [info.is_a?(Hash) ? info["lan"] : nil, port]
376
+ end
377
+
378
+ def dir = @state_dir ? { state_dir: @state_dir } : {}
379
+ def state_dir! = @state_dir || Session.default_state_dir
380
+ def session_dir(id) = Session.session_dir(id, state_dir: state_dir!)
381
+
382
+ def attached_to(target, shadowed: false)
383
+ ContextSources.attached(target.session_id, project_root: target.project_root, state_dir: @state_dir,
384
+ shadowed: shadowed)
385
+ end
386
+
387
+ # The block's post to the session's live worker, again on a 408 (the
388
+ # worker didn't take it in time).
389
+ def on_worker(id)
390
+ client = BridgeClient.discover(id, session_dir: session_dir(id)) or return nil
391
+ reply = nil
392
+ 2.times do
393
+ reply = yield client
394
+ break unless reply.status == 408
395
+ end
396
+ reply
397
+ end
398
+ end
399
+ end
400
+ end