samagotchi 0.120.0 → 0.121.1

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +34 -11
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +6 -2
  12. data/docs/internals/prompt-caching.md +7 -6
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/plugins.md +28 -8
  16. data/docs/sessions.md +15 -0
  17. data/docs/testing.md +44 -2
  18. data/lib/samagotchi/answer_command.rb +1 -1
  19. data/lib/samagotchi/bridge.rb +8 -8
  20. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  21. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  22. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  23. data/lib/samagotchi/bridge_client.rb +52 -65
  24. data/lib/samagotchi/bridge_secret.rb +9 -7
  25. data/lib/samagotchi/bundle_command/check.rb +27 -2
  26. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/claude-peer/scripts/pair +21 -7
  28. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  30. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +5 -1
  35. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/engine.rb +1 -0
  39. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  40. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  41. data/lib/samagotchi/guardrails_command.rb +41 -6
  42. data/lib/samagotchi/image_store.rb +6 -3
  43. data/lib/samagotchi/interrupted_turn.rb +6 -3
  44. data/lib/samagotchi/kernel_loop.rb +5 -0
  45. data/lib/samagotchi/live_versions.rb +6 -7
  46. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  47. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  48. data/lib/samagotchi/plugin/api.rb +14 -8
  49. data/lib/samagotchi/plugin/sessions.rb +13 -4
  50. data/lib/samagotchi/push_command.rb +1 -1
  51. data/lib/samagotchi/relay_verifier.rb +2 -3
  52. data/lib/samagotchi/relay_watcher.rb +2 -2
  53. data/lib/samagotchi/remote_attach_command.rb +1 -1
  54. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  55. data/lib/samagotchi/reply_wait.rb +4 -3
  56. data/lib/samagotchi/review_command.rb +1 -1
  57. data/lib/samagotchi/run_options.rb +1 -0
  58. data/lib/samagotchi/session.rb +4 -0
  59. data/lib/samagotchi/session_access/local.rb +18 -8
  60. data/lib/samagotchi/session_access/remote.rb +5 -0
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  62. data/lib/samagotchi/session_access.rb +21 -9
  63. data/lib/samagotchi/session_continue.rb +2 -3
  64. data/lib/samagotchi/session_manager.rb +118 -28
  65. data/lib/samagotchi/sessions_command.rb +10 -5
  66. data/lib/samagotchi/system_one/client.rb +6 -1
  67. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  68. data/lib/samagotchi/system_one/log.rb +21 -6
  69. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  70. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  71. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  72. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  73. data/lib/samagotchi/terminal_ui/question_slot.rb +19 -8
  74. data/lib/samagotchi/tool_declarations.rb +1 -1
  75. data/lib/samagotchi/tool_runner.rb +37 -1
  76. data/lib/samagotchi/toolkit.rb +117 -27
  77. data/lib/samagotchi/toolkit_binding.rb +32 -0
  78. data/lib/samagotchi/toolkit_command.rb +23 -0
  79. data/lib/samagotchi/tools/deferred_tools.rb +10 -2
  80. data/lib/samagotchi/tools/delegate.rb +1 -1
  81. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  82. data/lib/samagotchi/tools/registry.rb +20 -6
  83. data/lib/samagotchi/tools/tool_index.rb +103 -20
  84. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +123 -65
  87. data/lib/samagotchi/web/participant_access.rb +26 -7
  88. data/lib/samagotchi/web/participant_review.rb +11 -6
  89. data/lib/samagotchi/web/participant_view.rb +7 -3
  90. data/lib/samagotchi/web/participants_admin.rb +13 -0
  91. data/lib/samagotchi/web/public/app.js +1 -1
  92. data/lib/samagotchi/web/public/forms.css +12 -0
  93. data/lib/samagotchi/web/public/index.html +7 -5
  94. data/lib/samagotchi/web/public/level_chip.js +2 -2
  95. data/lib/samagotchi/web/public/llm_context_chip.js +4 -4
  96. data/lib/samagotchi/web/public/notifications.js +21 -1
  97. data/lib/samagotchi/web/public/participant.html +4 -2
  98. data/lib/samagotchi/web/public/participant.js +34 -11
  99. data/lib/samagotchi/web/public/participants_panel.js +49 -14
  100. data/lib/samagotchi/web/public/push.js +4 -3
  101. data/lib/samagotchi/web/public/push_devices.js +17 -3
  102. data/lib/samagotchi/web/public/question_card.js +15 -1
  103. data/lib/samagotchi/web/public/question_cards.js +11 -2
  104. data/lib/samagotchi/web/public/review_history.js +24 -4
  105. data/lib/samagotchi/web/public/review_start.js +1 -3
  106. data/lib/samagotchi/web/public/session_actions.js +14 -2
  107. data/lib/samagotchi/web/public/storage.js +16 -0
  108. data/lib/samagotchi/web/public/toolkit_chip.js +3 -0
  109. data/lib/samagotchi/web/push/client.rb +5 -0
  110. data/lib/samagotchi/web/push/store.rb +16 -3
  111. data/lib/samagotchi/web/push_routes.rb +3 -2
  112. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  113. data/lib/samagotchi/web/session_create.rb +4 -4
  114. data/lib/samagotchi/web/session_hub.rb +6 -4
  115. data/lib/samagotchi/web/session_summary.rb +8 -16
  116. data/lib/samagotchi/web/tunnel.rb +10 -1
  117. data/lib/samagotchi/worker.rb +1 -1
  118. data/lib/samagotchi/worker_link.rb +213 -0
  119. data/lib/samagotchi/worker_sidecar.rb +15 -37
  120. metadata +4 -1
@@ -79,11 +79,13 @@ right-hand column happens.
79
79
  `Toolkit::Resolved` the Registry holds, so a switch that changes only the text is a toolkit change
80
80
  (`apply_toolkit!` → `reset!`). `full` and kits without one send the bytes they sent before.
81
81
  - find_tools' description is a function of the toolkit and the source groups **at load**: `ToolIndex#declare`
82
- snapshots the MCP groups' lines (name, summary, callable count) and `ToolIndex#refresh!` (from
83
- `ToolkitBinding#apply!`) rebuilds only the `chi` line, the built-ins the toolkit reaches. An MCP server that
84
- finishes booting between load and the first turn changes search answers only, so two sessions on the same
85
- toolkit with the same MCP cache send the same bytes. Whether find_tools/call_tool are declared at all reads the
86
- same two things (`Registry#index_needed?`), never live groups.
82
+ snapshots source group names and description lines (name, summary, callable count). `ToolIndex#refresh!` (from
83
+ `ToolkitBinding#apply!`) filters that snapshot by the new toolkit and rebuilds the `chi` line from the built-ins
84
+ it reaches; it never consults live group names. The same group filter drives searches, deferred MCP tools and
85
+ whether `find_tools`/`call_tool` are declared (`Registry#index_needed?`). An MCP server that finishes booting
86
+ between load and the first turn changes search answers only, so two sessions on the same toolkit with the same
87
+ MCP cache send the same bytes. Kits that omit `groups:` keep their previous request bytes; switching only
88
+ groups changes the prompt key and asks `/toolkit` to confirm the cache cost once the session has history.
87
89
  - A worker's `execute` and `task_create` descriptions carry the finish wake's sentence ("if you end your turn before
88
90
  it finishes, chi tells you when it does"; `Engine.new(task_finish_notes:)`, set once as the worker builds its
89
91
  Engine); the `--no-shared` REPL's, and a worker's under `session.task_wakes: off`, don't. A session that moves
@@ -240,4 +242,3 @@ is prefilled once. The native and the chat loop use the same plan.
240
242
  `cache_prompt: true` on every native request.
241
243
  - **OpenRouter / Anthropic:** the default cache TTL is 5 minutes (`cache.ttl: 1h` for an hour, see above).
242
244
  Provider routing (which upstream serves a request) also decides whether a cache is there to hit.
243
-
@@ -289,11 +289,11 @@ bin/chi ── RunOptions.parse ── a remote, run flags ──> RemoteAttachC
289
289
  ### The plumbing
290
290
 
291
291
  **chi web's routes** (tabled in [architecture.md](../architecture.md)): `POST …/wake` (`Local#wake`: the live worker
292
- as is, else `SessionManager.resume_session` and `BridgeClient.wait_for`, at most `WAKE_MAX_WAIT` 10 s, the attach's
292
+ as is, else `SessionManager.resume_session` and `WorkerLink.await`, at most `WAKE_MAX_WAIT` 10 s, the attach's
293
293
  `BRIDGE_WAIT` here, after waiting for a worker still leaving to let go of its lock, `OwnerLock.await_release`: its
294
294
  recap on leave's timeout plus 5 s; a leftover scratch session is refused in `LaunchMode::SCRATCH_LEFTOVER`'s words, a late Bridge
295
- in `LaunchMode::BRIDGE_LATE`'s) and `GET …/worker` (`Local#worker`: live, the stop marker, the owner, `leaving` and `leave_seconds` when its worker
296
- is leaving, `WorkerState#leaving?`; it never starts one; it also names that machine's `default_model`), both answering the worker as `{epoch, started_at, version,
295
+ in `LaunchMode::BRIDGE_LATE`'s; a worker on a newer sidecar protocol is refused, 409 `worker_newer`, never resumed over) and `GET …/worker` (`Local#worker`: live, the stop marker, the owner, `leaving` and `leave_seconds` when its worker
296
+ is leaving, `WorkerState#leaving?`, `stuck` for a worker that never published its Bridge, `skew` for a newer protocol; it never starts one; it also names that machine's `default_model`), both answering the worker as `{epoch, started_at, version,
297
297
  features}`; `POST …/exit` (the worker's exit, `client_id` passed on only when it is a `tui:` one), `POST …/recap`,
298
298
  `GET …/stats` (the stats, else an older worker's state metrics), `GET …/children/counts` (`ChildrenStatus.counts`)
299
299
  and `GET …/turns/:turn_id/records` (`SessionMetrics`' saved tool records and turn record), every refusal with
@@ -309,7 +309,7 @@ builtin the worker lacks names it (`command`). `/api/info`'s feature `remote_att
309
309
  **`SessionAccess::WebBridgeClient`** (`session_access/web_bridge_client.rb`, built by `Remote#attach_client(id,
310
310
  worker:)` from a wake's or worker read's `worker`): `post_turn` (`no_interrupt`, `history: false`), `post_command`,
311
311
  `answer`, `dismiss_question`, `dequeue`, `cancel`, `request_exit`, `request_recap`, `stats`, `children_counts`,
312
- `turn_records`, `follow`, `connect_stream`, `session_id`, `worker_key`, `port` (nil). One-shots go through
312
+ `turn_records`, `follow`, `open_stream`, `session_id`, `worker_key`, `port` (nil). One-shots go through
313
313
  `Remote#route_request` (the server check, the auth and missing-route mapping), a new connection each, with a 45 s
314
314
  read timeout (chi web's 30 s Bridge read plus its wait for a waking Bridge; 75 s for answer and dismiss, which chi web
315
315
  sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the children counts and a turn's records, L9). Every reply goes through **one status mapper**,
@@ -318,6 +318,7 @@ sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the childr
318
318
  | chi web's answer | What the loop gets |
319
319
  |---|---|
320
320
  | 503 `not_live`, 404 `not_found` | raises `Unreachable` (< `IOError`): "no live worker for session ID on NAME" (`NO_WORKER`) |
321
+ | 502 `bridge_secret` (stream) | `Refused(:bridge_secret)`; `EventStream` ends with `stream_closed bridge_secret` |
321
322
  | 504 (any) | `Response(408, {error: deadline_passed})`: the loop's late line |
322
323
  | 501 with `command` (`/command`) | `Response(400, {error: unknown_command, command})`: the loop names the command |
323
324
  | any other 501 | `Response(404, {error: not_found})`: the stale-worker line |
@@ -326,7 +327,7 @@ sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the childr
326
327
  Every `RemoteFailed` (a transport failure, `RemoteTooOld`, a 401 or a 403 `forbidden`) becomes `Unreachable` with its
327
328
  message, so a loop that rescues `IOError` around Bridge calls never crashes on one mid-session.
328
329
 
329
- **The stream.** `connect_stream` writes `GET /api/sessions/:id/stream…` through `BridgeClient::StreamRequest` (the
330
+ **The stream.** `open_stream` writes `GET /api/sessions/:id/stream…` through `BridgeClient::StreamRequest` (the
330
331
  GET a local stream writes, with `Authorization: Bearer` and `Last-Event-ID`; `Host` is the entry's host and port).
331
332
  An IPv6 entry (`http://[::1]:4567`) connects on the bare address (`URI#hostname`, as `HTTPTransport` does) and keeps
332
333
  the brackets in `Host`.
@@ -335,7 +336,9 @@ SYN isn't waited on for the OS's minutes on every reconnect.
335
336
  chi web proxies the Bridge's bytes unchanged (under WEBrick a hijacked body is raw, `Connection: close`). No chunked
336
337
  decoding in v1: a `Transfer-Encoding: chunked` reply (a proxy in between) and an https entry are logged
337
338
  (`stream_refused`, `chunked_unsupported` / `tls_unsupported`) and read as unreachable; decoding and TLS plug into
338
- `StreamRequest`. `follow` starts `EventStream` with three options a local stream never sets (`BridgeClient#follow`
339
+ `StreamRequest`. `WebBridgeClient#open_stream` returns typed outcomes: chi web's 502 `bridge_secret` is a refusal
340
+ (`stream_closed bridge_secret`), 503 is unreachable and retried with rediscovery, and 404 is an unknown-session
341
+ refusal. `follow` starts `EventStream` with three options a local stream never sets (`BridgeClient#follow`
339
342
  passes none):
340
343
  - `idle_timeout:` 40 s (`STREAM_IDLE_TIMEOUT`: two of the Bridge's 15 s pings plus margin): no byte for that long is a
341
344
  half-open connection (Wi-Fi gone, a sleeping laptop), reconnected with the cursor.
@@ -343,8 +346,9 @@ passes none):
343
346
  `worker_changed {"port" => nil, "epoch" => new}` comes before it (the `reset` the new Bridge sends), and the
344
347
  client's `worker_key` is updated in place. A restart reaches the stream this way because `Bridge#stop` removes its
345
348
  sidecar before it ends the open streams: the reconnect finds no sidecar, chi web waits for the next worker's
346
- (`await_bridge_port`) and proxies the cursor to it. A sidecar still naming the dead port would get an empty 200
347
- (the proxy's connects refused), and the stream would move by rediscovery (`worker_changed`, then a `snapshot`).
349
+ (`await_worker_link`) and proxies the cursor to it. A sidecar still naming a dead port now gets 503 `not_live`;
350
+ a secret refusal gets 502 `bridge_secret`, and no refusal is sent as SSE frames. The stream moves by rediscovery
351
+ (`worker_changed`, then a `snapshot`).
348
352
  - `announce_retries:` `stream_retrying` once a reconnect fails and rediscovery neither moved the stream nor ended it
349
353
  (a stop prints its stopped line alone), `stream_resumed` once a stream answers again (a replay may bring no frame).
350
354
 
@@ -421,9 +425,9 @@ the closing line reads `stopped` as `unreachable`, the words it always had.
421
425
  TUI (`tui:PID` otherwise). `report_unanswered` takes the link's `strategy:` and `via:`.
422
426
  - The client the loop and `BridgeClient::EventStream` use: `follow`, `post_turn`, `post_command`, `answer`,
423
427
  `dismiss_question`, `dequeue`, `cancel`, `request_exit`, `request_recap`, `session_id`, and for the stream
424
- `connect_stream(query:, last_event_id:, timeout:)` and `worker_key` (a new worker is a client with another key;
425
- a Bridge's is its port). `BridgeClient::StreamRequest` writes the stream's GET (host, port, path, extra headers)
426
- and reads its headers, for a stream through chi web too.
428
+ `open_stream(query:, last_event_id:, header_timeout:, attempts:)` and `worker_key` (a new worker is a client with
429
+ another key; a Bridge's is its port). `EventStream` reads the opened socket; `BridgeClient::StreamRequest`
430
+ writes the stream's GET (host, port, path, extra headers) and reads its headers, for a stream through chi web too.
427
431
  - `RunOptions.parse(argv)` is bin/chi's one run-flag parser (`RunOptions.switches` lists its flags), so a remote
428
432
  branch can take the parsed options before bin/chi's local checks; `LaunchMode.attached_conflict` and
429
433
  `LaunchMode::SCRATCH_LEFTOVER` / `BRIDGE_LATE` are the words both would print.
@@ -66,10 +66,11 @@ files, one per machine, never in config.yml:
66
66
  `Store#add` checks what `pushManager.subscribe` gave, one message per refusal (the route answers it as 400
67
67
  `invalid_subscription`): an endpoint and both keys; https on port 443 without userinfo, at most 2048 bytes; a host in
68
68
  `Store::PUSH_SERVICES` (Apple, Google's FCM, Mozilla, `*.notify.windows.com`; a `services:` seam for specs, not
69
- config): chi web POSTs to that URL, so it never takes an arbitrary host; `p256dh` a 65-byte uncompressed point,
70
- `auth` 16 bytes. The same endpoint again replaces the old entry. `replaces:` (the device's id from its last
71
- subscribe, which the page keeps) drops that entry on this origin too, before the `MAX_SUBS` cap is counted, and
72
- yields it (the route logs `push_replaced`); its own endpoint is never dropped that way, and an unknown id is ignored.
69
+ config): chi web POSTs to that URL, so it never takes an arbitrary host; `p256dh` a 65-byte uncompressed point
70
+ on P-256 (built as `Crypto.ec_public` builds it), `auth` 16 bytes. The same endpoint again replaces the old entry.
71
+ `replaces:` (the device's id from its last subscribe, which the page keeps) drops that entry on this origin too,
72
+ before the `MAX_SUBS` cap is counted, and yields it (the route logs `push_replaced`); its own endpoint is never
73
+ dropped that way, and an unknown id is ignored.
73
74
  That is how a subscription iOS revoked silently (Apple keeps answering 201, so `:gone` never comes) leaves the store:
74
75
  the user turns the bell off and on, the page has no live subscription to delete, and the new one replaces it. Every write is a read-change-write under
75
76
  `store.lock` (chi web's request threads and the sender thread both write). A `subscriptions.json` that doesn't read
@@ -85,7 +86,9 @@ tailnet leaves the old ones stale (never sent to: a tap would open an origin tha
85
86
  `Client#deliver(sub, payload, ttl:, urgency:)` encrypts the payload's JSON, POSTs it over TLS (open 5 s, read 10 s,
86
87
  no redirects, no retry of its own) with `Content-Encoding: aes128gcm`, `TTL`, `Urgency` and the VAPID header, and
87
88
  answers a `Client::Result`: `:ok` (200/201/202), `:gone` (404/410: the device dropped it), `:bad_jwt` (403 with
88
- `BadJwtToken`), `:failed` (anything else; a connection error as its class name or `timeout`, never its message).
89
+ `BadJwtToken`), `:failed` (anything else; a connection error as its class name or `timeout`, never its message;
90
+ any other error too, so a key off the curve on disk fails that device alone, never the Notifier's flush or the test
91
+ push).
89
92
  A 429 or 5xx also carries `retry_after`: its `Retry-After` delta-seconds up to `RETRY_MAX` 60, else (missing, an
90
93
  HTTP date) `RETRY_DEFAULT` 10; nil for anything else, a connection error too (it may have been delivered). The
91
94
  transport is injectable: `spec/web/push/client_spec.rb` runs a fake push service (WEBrick, plain http through the
@@ -206,7 +209,8 @@ public URL, or `web.push.enabled: false`):
206
209
  - `POST /api/push/subscriptions {subscription: {endpoint, keys}, replaces?: id}` → 201 `{id, label}`; the label is
207
210
  chi's own ("iPhone · <date>", from the User-Agent's device class: iPhone, iPad, Mac, else Browser); `replaces`
208
211
  drops the device's earlier entry (`Store#add`).
209
- - `DELETE /api/push/subscriptions {endpoint}` → 204: this device's bell turned off.
212
+ - `DELETE /api/push/subscriptions {endpoint}` → 204: this device's bell turned off (`Store#remove(endpoint, origin:)`:
213
+ only a device on this origin goes, as with the other device routes).
210
214
  - `DELETE /api/push/subscriptions/ID` → 204, 404 `unknown_device` (another origin's, or not a short id); logs
211
215
  `push_removed`. The devices sheet's Remove for another device; this device's own row never uses it (its switch is
212
216
  the bell, so the page's state and the store stay one).
@@ -245,9 +249,13 @@ Screen app the bell (`notifications.js`) registers `/sw.js`, asks `GET /api/push
245
249
  and is "push to this phone" when chi web pushes: on while chi web has this device. A tap asks permission from the tap
246
250
  (iOS asks only from a user gesture), subscribes, posts it (with `replaces`: the id chi web answered last time,
247
251
  kept in localStorage as `chi_push_device`, `PUSH_DEVICE_KEY`; a storage that throws only loses the replace) and sends
248
- a test push; a tap when on unsubscribes and deletes, and forgets the id, unless there was no subscription left to
249
- delete (revoked meanwhile): then the id stays for the next subscribe to replace. The tab's own OS notification is left to the push there; the title count stays. In a browser tab, or with
250
- chi web not pushing, the bell is the in-tab one, unchanged (no duplicate desktop pushes).
252
+ a test push; a tap when on deletes, then unsubscribes (a DELETE that fails changes nothing local: the bell stays on
253
+ and says the error), and forgets the id, unless there was no subscription left to delete (revoked meanwhile): then
254
+ the id stays for the next subscribe to replace. The tab's own OS notification is left to the push there; the title
255
+ count stays. A `GET /api/push` (or `register()`) that fails at launch (chi web restarting, a phone just woken) leaves
256
+ the bell the in-tab one until the page next comes back in front (`backInFront` → `retryPush`, one at a time, no
257
+ timer); chi web answering that it doesn't push is not asked again. In a browser tab, or with chi web not pushing,
258
+ the bell is the in-tab one, unchanged (no duplicate desktop pushes).
251
259
 
252
260
  **The devices sheet** (`public/push_devices.js`, `createPushDevices` on `chip_popover.js`'s shell: a bottom sheet
253
261
  under 600 px, else a popover at the bell; made once chi web says it pushes, so a tab never gets one): in push mode
@@ -273,8 +281,11 @@ page's `byId` (`push.js openPushKeys`: `Attention.open_keys`'s rule), and sets o
273
281
  badge (`navigator.setAppBadge` / `clearAppBadge`); the bell turned off clears it. It runs once push is known, when
274
282
  the page comes back in front, and after each hub frame while visible (so answering on the Mac with the app open
275
283
  clears it at once), one at a time with one rerun. Never before the first snapshot (an empty `byId` would close
276
- everything) and never on a project's page (`createNotifications({scoped})`, app.js passes `!!scope.dir`: its
277
- `/api/events?dir=` leaves out the other projects' sessions).
284
+ everything). The device's notifications are every project's, so on a project's page (`?dir=`, or the bare page on
285
+ chi web's start project: `createNotifications({scoped})`, app.js passes `!!scope.dir`) the tidy-up goes by a stream
286
+ of every session of its own (`watchAll`, through `allEvents`: data.js `openEvents` without a dir), not the page's
287
+ `/api/events?dir=` frames, which leave out the other projects' sessions. That stream is open only with the bell on
288
+ (turning it off closes it), one at a time, and one that ended is opened again when the page next comes back in front.
278
289
 
279
290
  Accepted staleness: an approval answered elsewhere keeps its banner until the app opens (the worker's cleanup on the next push doesn't work on iOS yet: see iOS facts)
280
291
  (or a swipe), and the icon badge may be too high until then; a show already handed to Apple just before its answer
data/docs/plugins.md CHANGED
@@ -579,13 +579,17 @@ end
579
579
  - Each task runs once per session start. A `-p … --non-interactive` run
580
580
  starts them with its turn and shows nothing but the answer.
581
581
 
582
- ### `chi.toolkit(name, base:, add: [], remove: [], prompt: nil)`
582
+ ### `chi.toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)`
583
583
 
584
584
  A toolkit sessions can be put on: `base`'s tools (`full`, `lean`, `child`, a
585
585
  config or bundle toolkit) with `add` declared and `remove` left out, and
586
586
  `prompt`: text the sessions on it get in their system prompt, verbatim (nil
587
587
  inherits the base's, `""` clears it; at most 2000 characters, and one that
588
- isn't a String or is longer is a load error). Users pick
588
+ isn't a String or is longer is a load error). `groups` selects the source
589
+ groups the toolkit reaches (names or globs, sorted and deduplicated; nil
590
+ inherits the base's, `[]` reaches none, and `:all` reaches every group; anything
591
+ else, a map say, is a load error).
592
+ Groups match against the tool index's load-time names. Users pick
589
593
  it like chi's own (`chi --toolkit NAME`, `toolkit.default`, `/toolkit NAME`),
590
594
  and the plugin with `ctx.use_toolkit` (below). Names are `a-z`, `0-9`, `_` and
591
595
  `-`; one chi has (`full`, `lean`, `child`) or another bundle declared is a
@@ -593,11 +597,13 @@ load error. A session saved on a toolkit whose bundle is gone declares every
593
597
  tool (full) until it comes back, with a warning at its first turn. See
594
598
  [Toolkits](configuration.md#toolkits). `prompt:` needs chi 0.72.0 (an older
595
599
  chi refuses the keyword, a load error: a bundle that passes it sets
596
- `requires_chi: ">= 0.72.0"`).
600
+ `requires_chi: ">= 0.72.0"`). `groups:` needs chi 0.121.0: an older chi refuses that keyword
601
+ too, so a bundle passing it sets `requires_chi: ">= 0.121.0"` (`chi bundle check` warns on a lower one).
597
602
 
598
603
  ```ruby
599
604
  chi.toolkit "review", base: "lean", add: %w[delegate delegate_result]
600
605
  chi.toolkit "coding", base: "child", prompt: "- Be concise in your responses"
606
+ chi.toolkit "web", base: "lean", groups: %w[chrome db*]
601
607
  ```
602
608
 
603
609
  ### `chi.tool_source(call:, acts_as:, label:, wait:, prefix:)`
@@ -611,12 +617,19 @@ first source's `label`). The index has one group of its own, **`chi`**: the
611
617
  built-ins the session's toolkit leaves out but doesn't remove
612
618
  ([toolkits](configuration.md#toolkits)), each with its registered schema.
613
619
  The two tools are registered once, after the plugins load, and declared per
614
- toolkit: when a plugin registered a source then, or when the toolkit has a
615
- reachable built-in (`full` with no source declares neither). Nothing a
620
+ toolkit when it reaches a source group or has a reachable built-in (`full`
621
+ with no source declares neither). Nothing a
616
622
  source publishes later changes the request's tools, only what a search
617
623
  answers, so the prompt cache keeps: the search tool's description lists the
618
624
  source groups as they were at load, and a `chi` line first, per toolkit.
619
- The mcp bundle is the only source so far (a group per server). A source's
625
+ The mcp bundle is the only source so far (a group per server). `groups:`
626
+ filters the groups find_tools searches, the source descriptions in the
627
+ request, and the MCP tools `call_tool` can reach. Its group snapshot is
628
+ filtered again at each toolkit change; new groups published after load do
629
+ not alter that prompt description. An unreached MCP tool call is refused
630
+ before the guardrails without starting the server; a deferred stub says to
631
+ switch to a toolkit that reaches its group. `/mcp` marks servers outside the
632
+ session's toolkit. A source's
620
633
  group named `chi`, or named like an earlier group, is left out of search,
621
634
  lookup and the description, with one warning per name.
622
635
 
@@ -1002,6 +1015,9 @@ ctx.sessions.review(subjects: ["docs/plan.md"], seed: handoff) # => a review se
1002
1015
  `/title` of its own (see [Session titles](sessions.md#session-titles)).
1003
1016
  - An image a message names is copied into the child. One whose file is
1004
1017
  gone is dropped, with `[image x.png was not copied]` in its message.
1018
+ - A child chi won't start raises `Error` with chi's reason: the session's
1019
+ model is gone from the config, or its toolkit is a config kit with an
1020
+ invalid `groups:` (`toolkit.kits`, `Toolkit.check_new!`).
1005
1021
  - `review(subjects: [], seed: nil, recap_wait: 0)` starts a review session of
1006
1022
  this one, as `chi review --from` does (see [Review sessions](sessions.md#review-sessions)):
1007
1023
  a new session in this session's folder, on its model and setup, seeded with
@@ -1377,8 +1393,12 @@ bundles:
1377
1393
  its tools as `(failed: …)`. The saved list stays: the next session tries
1378
1394
  again.
1379
1395
  - **`start: eager`** on a server starts it with every session (in an init
1380
- task, after the Bridge is up), for a server whose start does something
1381
- you want at once.
1396
+ task, after the Bridge is up), even when that session's toolkit does not
1397
+ reach its group. A first-run init task still blocks the first turn until
1398
+ it finishes, even for an unreached group. A stale-cache refresh likewise
1399
+ starts its short-lived server regardless of the toolkit. Switching away
1400
+ from a group refuses its calls without starting a lazy cached server, and
1401
+ does not stop a server already running; switching back reuses it.
1382
1402
  - **`npx -y …@latest` checks the npm registry on every start** (~3.4 s for
1383
1403
  `chrome-devtools-mcp`, against ~0.9 s for the installed binary). The cache
1384
1404
  hides it from new sessions, but not from the first call. For a faster first
data/docs/sessions.md CHANGED
@@ -377,3 +377,18 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
377
377
  - **A child asks before it changes anything outside its folder** (the work tree top of the folder it started in): a write or edit there, mutating git in another checkout, a command that isn't read-only run from outside it or naming the main checkout or a sibling worktree. It's a core guardrail, on in every mode and without the guardrails bundle, and answered once or for the child's session ([Guardrails](guardrails.md#a-delegate-childs-boundary)); the approval relays to the parent's user as any other.
378
378
  - Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
379
379
  - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it (and `delegate: true`, which a fork's `parent_id` doesn't come with; a child saved before that field is known by its `system/delegated` memory); `GET /api/sessions`, `/api/sessions/:id` and the web hub's `session` frames carry it and `delegate` (a fork is `false`; the web parent's children chip counts delegates only); `SessionManager.children_of` lists a parent's children.
380
+ ## Restart fallback
381
+
382
+ If `chi sessions restart ID` cannot ask its worker (the Bridge is unreachable
383
+ or refuses its secret, the worker speaks a newer protocol, or its start is
384
+ stuck), chi sends TERM without writing the stop marker, waits up to
385
+ `SessionManager::FALLBACK_STOP_WAIT` (40 s: the worker writes its recap on
386
+ leave) for the owner lock, and resumes the session; a worker that doesn't let
387
+ go in time is reported as "didn't take the restart" and nothing is resumed. A
388
+ worker that took the lock meanwhile (the old one handed over after all) is
389
+ not touched. It refuses first if disk shows a running turn, pending
390
+ question or approval, queued input, or a running background task started by
391
+ the session; the message directs the user to `chi sessions stop ID`, then any prompt.
392
+ Reminders, a running `/btw`, and other in-memory worker state cannot be
393
+ checked and may be lost in this fallback. The web restart route behaves the
394
+ same way.
data/docs/testing.md CHANGED
@@ -163,9 +163,44 @@ A session's Bridge answers only a request that names its secret (`X-Chi-Bridge`,
163
163
  see [architecture.md](architecture.md)). A spec that writes its own request to a real Bridge (`Net::HTTP`, a raw
164
164
  socket) names it too: `include BridgeAuth` (spec/support/bridge_auth.rb, required by the spec that needs it) gives
165
165
  `bridge_auth(session_dir_or_sidecar)`, the header as a Hash, and `bridge_auth_line(...)` for a raw request; or read
166
- `port` and `secret` from the sidecar in one go, as `BridgeClient.discover` does. A client made with `BridgeClient.new`
166
+ `port` and `secret` from the sidecar in one go, as `WorkerLink.client` does. A client made with `BridgeClient.new`
167
167
  takes `secret:` (nil for a fake Bridge that doesn't check it).
168
168
 
169
+ ### Support files
170
+
171
+ `spec/spec_helper.rb` loads the suite-wide helpers (marked *helper*: every example group gets them); a spec that needs
172
+ any other file requires it, so a spec file run alone still finds its requires. Each file's header says more.
173
+
174
+ | File | What it gives |
175
+ |---|---|
176
+ | `bounded_capture.rb` | `BoundedCapture.capture3`: `Open3.capture3` with a deadline, in its own process group killed on expiry |
177
+ | `bridge_auth.rb` | `BridgeAuth`: the Bridge secret's header for a hand-written request (above) |
178
+ | `chi_cli.rb` | *helper*: `run_chi` (bin/chi in a child process), `fork_chi` (in a fork: no Ruby boot), `isolated_chi_env` |
179
+ | `effective_model_configs.rb` | `EffectiveModelConfigs`: one config.yml holding every host the effective-model characterization pins, and its goldens |
180
+ | `env.rb` | *helper*: `with_env` sets ENV for a block and puts it back (nil unsets) |
181
+ | `failing_saves.rb` | shared context "failing session saves": `Session#save` raising ENOSPC while `saves_fail`, and the worker's `save_failed` records |
182
+ | `fake_chat_adapter.rb` | `FakeChatAdapter`: an `LLM::OpenAIChat` stand-in answering `#chat` with queued steps, recording each request |
183
+ | `fake_executables.rb` | `FakeExecutables`: fake commands (`gh`, `git`) for a spec's PATH, symlinks to one launcher (macOS's first-exec check is paid once) |
184
+ | `fake_provider_server.rb` | `FakeProviderServer`: an in-process HTTP provider replaying spec/fixtures/providers, recording requests |
185
+ | `fake_tailscale.rb` | A fake `tailscale` for `Web::Tunnel::Tailscale` (status, serve, lock from JSON files; argv.log) |
186
+ | `git_repo.rb` | *helper*: `git_repo`, a temp folder with `git init`, removed after the example |
187
+ | `integration_server.rb` | *helper*: the live model server `:integration` examples use, from `SAMAGOTCHI_INTEGRATION_*` ([Integration specs](#integration-specs)) |
188
+ | `mcp_bundle.rb` | The shipped mcp bundle's path and the fake MCP server its specs start (`MCP_FAKE_COMMAND`, no RubyGems) |
189
+ | `no_console.rb` | Loaded first (`ruby -r`) by spawned bin/chi children: `IO.console` is nil, so output never wraps at a terminal's width |
190
+ | `recording_surface.rb` | `RecordingSurface`: a `TerminalUI::Surface` that records commits and slots instead of drawing |
191
+ | `remote_backend.rb` | `RemoteBackend`: `SessionAccess::Remote` against chi web's Rack app in this process (no socket, LAN token gate) |
192
+ | `reply_wait_looks.rb` | `ReplyWaitLooks`: counts a `ReplyWait`'s polls, so a step waits for the looks instead of a delay |
193
+ | `stamps.rb` | *helper*: `next_stamp`, distinct ordered times for the lib's `now:` seams; the pinned history clock |
194
+ | `task_dirs.rb` | `with_task_dir`: a tasks' folder of its own, every task stopped and its recorders joined after |
195
+ | `test_kernel.rb` | `TestKernel`: `test_kernel` (a real `KernelLoop` to stub `#run` on) and `test_client` for Engine specs |
196
+ | `thinking_off.rb` | shared context "thinking off": `SAMAGOTCHI_THINKING_LEVEL=off` for the example |
197
+ | `virtual_terminal.rb` | `VirtualTerminal`: just enough VT100 to check what a Screen leaves on the terminal |
198
+ | `waiting.rb` | *helper*: `SpecWaiting` (`wait_for`, `wait_until`, `join!`, `pop!`, …), the test kit's helpers ([Waiting and time](#waiting-and-time)) |
199
+ | `web_push_receiver.rb` | `WebPushReceiver`: a device's key pair and auth secret, decrypting what chi web pushed (RFC 8291) |
200
+ | `web_server.rb` | `WebServer`: chi web's `Web::App` served for real on a free port in a thread, for specs a Rack mock can't serve |
201
+ | `worker_links.rb` | `WorkerLinks`: `live_link(sidecar)` / `none_link`, the `WorkerLink` outcomes a stub of `WorkerLink.resolve` / `.await` (or chi web's `#worker_link`) returns |
202
+ | `worker_loop_rounds.rb` | `WorkerLoopRounds`: counts a worker's idle loop passes, for "nothing happens" checks |
203
+
169
204
  ## Bundle specs: the test kit
170
205
 
171
206
  A shipped bundle's plugin and hook files are tested through the bundle test kit (`samagotchi/testing`,
@@ -230,7 +265,9 @@ A builder runs these while working, not the full suite: the specs the change tou
230
265
  files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
231
266
  docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
232
267
  file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`; `turn_flow.rb` and `turn_note.rb`, whose
233
- words the REPL prints, pick it by a rule), then the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
268
+ words the REPL prints, pick it by a rule). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
269
+ effective-model, request, prompt-snapshot and full system-prompt goldens because they hash the request's prompt and
270
+ tools. Then come the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
234
271
  `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
235
272
  `spec/guardrails/context_spec.rb`), then the specs that name a method the change touches (the innermost `def` around a
236
273
  changed line, read with Prism; one `git grep` for up to 12 names; names under 4 characters, `initialize`, `to_s`,
@@ -355,3 +392,8 @@ kept with the build id `/status` shows (`identity.cache.build_id`); when that ch
355
392
  the version and record it again. This step goes once a Splash release carries its version in `/status`.
356
393
 
357
394
  The code is `script/deps/deps_check.rb`; its spec stubs every request and command (`spec/deps_check_spec.rb`).
395
+
396
+ `script/dead-code` (a tech-debt audit round runs it too) lists the methods defined under lib/ that nothing in the repo
397
+ names (Prism; `be_foo`/`have_foo` count for `foo?`/`has_foo?`), as `path:line name [notes]` with a count; a note says
398
+ what may explain a row (a doc, a name built at run time, a framework hook). Advisory: it always exits 0, CI doesn't
399
+ run it. Its spec is `spec/script/dead_code_spec.rb`.
@@ -196,7 +196,7 @@ module Samagotchi
196
196
  end
197
197
  return failure(worker_gone(id)) unless reply
198
198
 
199
- return failure(BridgeSecret::REFUSED) if reply.secret_refused?
199
+ return failure(BridgeSecret.refused(id, via: @access.invocation)) if reply.secret_refused?
200
200
 
201
201
  case reply.status
202
202
  when 200 then nil
@@ -32,6 +32,7 @@ require_relative "log"
32
32
  require_relative "proxy_trace"
33
33
  require_relative "version"
34
34
  require_relative "worker_sidecar"
35
+ require_relative "worker_link"
35
36
  require_relative "relay_verifier"
36
37
  require_relative "relay_watcher"
37
38
  require_relative "reply_wait"
@@ -75,9 +76,6 @@ module Samagotchi
75
76
  SECRET_HEADER = BridgeSecret::HEADER.downcase
76
77
  # Bytes of randomness in a Bridge's secret (hex in bridge.json: twice as many characters).
77
78
  SECRET_BYTES = 32
78
- # What a request without this Bridge's secret, or with another one, is told (403 bridge_secret).
79
- SECRET_REFUSED = BridgeSecret::REFUSED
80
-
81
79
  # @param engine [Samagotchi::Engine] the owning engine (must already live
82
80
  # in this process)
83
81
  # @param state_dir [String] the session state directory root
@@ -484,7 +482,7 @@ module Samagotchi
484
482
  break
485
483
  elsif !secret_ok?(headers)
486
484
  note_refused(:secret, "secret_refused", request[:path])
487
- write_json(io, 403, nil, { error: BridgeSecret::ERROR, detail: SECRET_REFUSED })
485
+ write_json(io, 403, nil, { error: BridgeSecret::ERROR, detail: BridgeSecret.refused(@session_id) })
488
486
  break
489
487
  # From here on a client of this worker (it named the secret): only its
490
488
  # requests keep the worker from its idle exit, never a refused one's
@@ -1500,17 +1498,19 @@ module Samagotchi
1500
1498
  # secret: what every request must name (#secret_ok?).
1501
1499
  WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
1502
1500
  version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES,
1503
- epoch: @epoch, secret: @secret).write(session_dir)
1501
+ epoch: @epoch, secret: @secret, pid: Process.pid,
1502
+ protocol: WorkerLink::PROTOCOL).write(session_dir)
1504
1503
  rescue StandardError => e
1505
1504
  Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
1506
1505
  end
1507
1506
 
1508
- # Only while it still names this bridge: a racing worker for the same
1509
- # session may have written its own since.
1507
+ # Only while it still carries this bridge's epoch: a racing worker for the
1508
+ # same session may have written its own since (a file without an epoch
1509
+ # is only ever an older worker's).
1510
1510
  def remove_sidecar
1511
1511
  return unless @server
1512
1512
 
1513
- File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.port == @port
1513
+ File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.epoch == @epoch.to_s
1514
1514
  rescue StandardError
1515
1515
  nil
1516
1516
  end
@@ -158,16 +158,18 @@ module Samagotchi
158
158
 
159
159
  # One connection: :events when it yielded any, else why it failed.
160
160
  def read_connection
161
- sock, status, headers = @client.connect_stream(query: @query, last_event_id: @last_event_id, timeout: HEADER_TIMEOUT)
162
- return :unreachable unless sock
163
-
164
- unless attach(sock)
165
- sock.close rescue nil
166
- return :closed
161
+ sock = nil
162
+ outcome = @client.open_stream(query: @query, last_event_id: @last_event_id, header_timeout: HEADER_TIMEOUT,
163
+ attempts: 1) do |opened_sock|
164
+ sock = opened_sock if attach(opened_sock)
167
165
  end
168
- return :unknown_session if status == 404
169
- return :secret_refused if status == 403 && BridgeSecret.refused?(status, refusal_body(sock, headers))
170
- return :unreachable unless status == 200
166
+ return :closed if closed?
167
+ return :unreachable if outcome.is_a?(StreamOpen::Unreachable)
168
+ return :unknown_session if outcome.is_a?(StreamOpen::Refused) && outcome.reason == :unknown_session
169
+ return :secret_refused if outcome.is_a?(StreamOpen::Refused) && outcome.reason == :bridge_secret
170
+ return :unreachable unless outcome.is_a?(StreamOpen::Opened)
171
+
172
+ sock = outcome.sock
171
173
 
172
174
  # Back as soon as the stream answers: a replay may bring nothing
173
175
  # (the session was quiet), and the next frame could be minutes away.
@@ -194,24 +196,6 @@ module Samagotchi
194
196
  detach(sock)
195
197
  end
196
198
 
197
- # A refusal's JSON body: its Content-Length's worth, else up to the
198
- # Bridge's close, within HEADER_TIMEOUT.
199
- def refusal_body(sock, headers)
200
- body = +""
201
- length = headers.to_h["content-length"]&.to_i
202
- limit = length&.between?(0, 16_384) ? length : 16_384
203
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HEADER_TIMEOUT
204
- while body.bytesize < limit
205
- left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
- break unless left.positive? && sock.wait_readable(left)
207
-
208
- body << sock.readpartial(limit - body.bytesize)
209
- end
210
- body
211
- rescue IOError, SystemCallError
212
- body
213
- end
214
-
215
199
  # Publish the socket for #close; false when already closed.
216
200
  def attach(sock)
217
201
  @mutex.synchronize do
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../bridge_secret"
5
+
6
+ module Samagotchi
7
+ class BridgeClient
8
+ # The result of opening an SSE connection. Only Opened owns a socket the
9
+ # caller may read; refused sockets are consumed for their detail and closed.
10
+ module StreamOpen
11
+ Opened = Data.define(:sock, :status, :headers)
12
+ Refused = Data.define(:reason, :detail, :status)
13
+ Unreachable = Data.define(:attempts)
14
+
15
+ BODY_LIMIT = 16_384
16
+ BODY_TIMEOUT = 5.0
17
+
18
+ module_function
19
+
20
+ # The one place a stream's response status is interpreted. Through chi
21
+ # web, its mapped bridge-secret and not-live responses have their own
22
+ # status codes (502 and 503).
23
+ def classify(sock, status, headers)
24
+ return Unreachable.new(attempts: 0) unless sock
25
+ return Opened.new(sock: sock, status: 200, headers: headers) if status.to_i == 200
26
+ return Unreachable.new(attempts: 1) if status.to_i == 503
27
+
28
+ body = refusal_body(sock, headers)
29
+ json = parse(body)
30
+ detail = json&.[]("detail") || (body unless body.empty?)
31
+ code = status.to_i
32
+ error = json&.[]("error")
33
+ outcome = if code == 404
34
+ Refused.new(reason: :unknown_session, detail: detail, status: code)
35
+ elsif BridgeSecret.refused?(code, json || body)
36
+ Refused.new(reason: :bridge_secret, detail: detail, status: code)
37
+ elsif code == 403 && error == "proxied"
38
+ Refused.new(reason: :proxied, detail: detail, status: code)
39
+ elsif code == 403 && error == "cross_origin"
40
+ Refused.new(reason: :cross_origin, detail: detail, status: code)
41
+ elsif code == 502 && error == BridgeSecret::ERROR
42
+ Refused.new(reason: :bridge_secret, detail: detail, status: code)
43
+ else
44
+ Refused.new(reason: :other, detail: detail, status: code)
45
+ end
46
+ outcome
47
+ ensure
48
+ sock&.close unless (status || 0).to_i == 200
49
+ end
50
+
51
+ # A refusal body is bounded and interruptible by closing its socket.
52
+ # Content-Length replies stop at their declared size; older Bridges
53
+ # without it are read through EOF.
54
+ def refusal_body(sock, headers)
55
+ length = headers.to_h["content-length"]&.to_i
56
+ limit = length&.between?(0, BODY_LIMIT) ? length : BODY_LIMIT
57
+ body = +""
58
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BODY_TIMEOUT
59
+ while body.bytesize < limit
60
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ break unless left.positive? && sock.wait_readable(left)
62
+
63
+ body << sock.readpartial(limit - body.bytesize)
64
+ end
65
+ body.force_encoding(Encoding::UTF_8).scrub
66
+ rescue IOError, SystemCallError
67
+ body.force_encoding(Encoding::UTF_8).scrub
68
+ end
69
+
70
+ def parse(body)
71
+ json = JSON.parse(body)
72
+ json if json.is_a?(Hash)
73
+ rescue JSON::ParserError
74
+ nil
75
+ end
76
+ private_class_method :parse
77
+ end
78
+ end
79
+ end
@@ -32,8 +32,14 @@ module Samagotchi
32
32
  # @return [Array(TCPSocket, Integer, Hash), nil] the socket positioned
33
33
  # at the body, the HTTP status and the response headers (names in
34
34
  # lower case), or nil when the server can't be reached
35
+ # @raise [Errno::ETIMEDOUT] a connected server didn't finish its headers
35
36
  def open(host:, port:, path:, last_event_id: nil, headers: {}, timeout: nil)
36
- sock = TCPSocket.new(host, port, connect_timeout: timeout || CONNECT_TIMEOUT)
37
+ begin
38
+ sock = TCPSocket.new(host, port, connect_timeout: timeout || CONNECT_TIMEOUT)
39
+ rescue SystemCallError, SocketError, IOError
40
+ return nil
41
+ end
42
+
37
43
  sock.write(request(host: host, port: port, path: path, last_event_id: last_event_id, headers: headers))
38
44
  deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
39
45
  status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
@@ -47,7 +53,7 @@ module Samagotchi
47
53
  [sock, status, received]
48
54
  rescue SystemCallError, SocketError, IOError
49
55
  sock&.close rescue nil
50
- nil
56
+ raise
51
57
  end
52
58
 
53
59
  # The request's bytes.