samagotchi 0.121.0 → 0.122.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -1
  3. data/bin/chi +7 -3
  4. data/docs/architecture.md +37 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +11 -4
  7. data/docs/configuration.md +36 -7
  8. data/docs/guardrails.md +63 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/engine.md +2 -0
  12. data/docs/internals/participants.md +16 -8
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/memory.md +33 -0
  16. data/docs/plugins.md +8 -3
  17. data/docs/sessions.md +21 -0
  18. data/docs/testing.md +41 -1
  19. data/lib/samagotchi/answer_command.rb +1 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  21. data/lib/samagotchi/bridge.rb +12 -11
  22. data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
  23. data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
  24. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  25. data/lib/samagotchi/bridge_client.rb +52 -65
  26. data/lib/samagotchi/bridge_secret.rb +9 -7
  27. data/lib/samagotchi/bundle_command/check.rb +27 -2
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  32. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/config.rb +5 -0
  39. data/lib/samagotchi/engine.rb +107 -1
  40. data/lib/samagotchi/event_fanout.rb +0 -1
  41. data/lib/samagotchi/guardrail_wiring.rb +24 -6
  42. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  43. data/lib/samagotchi/guardrails_command.rb +41 -6
  44. data/lib/samagotchi/image_store.rb +6 -3
  45. data/lib/samagotchi/interrupted_turn.rb +6 -3
  46. data/lib/samagotchi/live_versions.rb +6 -7
  47. data/lib/samagotchi/log_line.rb +1 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  50. data/lib/samagotchi/memory_orphans.rb +11 -0
  51. data/lib/samagotchi/participant_mode.rb +11 -0
  52. data/lib/samagotchi/plugin/api.rb +14 -8
  53. data/lib/samagotchi/plugin/sessions.rb +13 -4
  54. data/lib/samagotchi/process_group.rb +13 -0
  55. data/lib/samagotchi/push_command.rb +1 -1
  56. data/lib/samagotchi/relay_verifier.rb +2 -3
  57. data/lib/samagotchi/relay_watcher.rb +2 -2
  58. data/lib/samagotchi/remote_attach_command.rb +1 -1
  59. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  60. data/lib/samagotchi/reply_wait.rb +4 -3
  61. data/lib/samagotchi/review_command.rb +1 -1
  62. data/lib/samagotchi/review_start.rb +6 -2
  63. data/lib/samagotchi/run_options.rb +2 -0
  64. data/lib/samagotchi/session.rb +18 -1
  65. data/lib/samagotchi/session_access/contract.rb +1 -1
  66. data/lib/samagotchi/session_access/local.rb +18 -8
  67. data/lib/samagotchi/session_access/remote.rb +37 -12
  68. data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
  69. data/lib/samagotchi/session_access.rb +21 -9
  70. data/lib/samagotchi/session_continue.rb +2 -3
  71. data/lib/samagotchi/session_manager.rb +133 -29
  72. data/lib/samagotchi/session_setup.rb +8 -4
  73. data/lib/samagotchi/sessions_command.rb +10 -5
  74. data/lib/samagotchi/steer.rb +7 -6
  75. data/lib/samagotchi/system_one/client.rb +12 -6
  76. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  77. data/lib/samagotchi/system_one/log.rb +44 -9
  78. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  79. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  80. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  81. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  82. data/lib/samagotchi/terminal_ui.rb +2 -1
  83. data/lib/samagotchi/tool_runner.rb +2 -1
  84. data/lib/samagotchi/toolkit.rb +95 -10
  85. data/lib/samagotchi/toolkit_binding.rb +115 -7
  86. data/lib/samagotchi/toolkit_command.rb +15 -3
  87. data/lib/samagotchi/toolkit_report_command.rb +318 -0
  88. data/lib/samagotchi/toolkit_suggest.rb +211 -0
  89. data/lib/samagotchi/tools/delegate.rb +1 -1
  90. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  91. data/lib/samagotchi/tools/memory.rb +1 -1
  92. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +127 -69
  95. data/lib/samagotchi/web/participant_access.rb +30 -8
  96. data/lib/samagotchi/web/participant_review.rb +8 -5
  97. data/lib/samagotchi/web/participant_view.rb +7 -3
  98. data/lib/samagotchi/web/participants_admin.rb +13 -0
  99. data/lib/samagotchi/web/principal.rb +3 -4
  100. data/lib/samagotchi/web/public/app.js +1 -1
  101. data/lib/samagotchi/web/public/bubbles.js +3 -2
  102. data/lib/samagotchi/web/public/notifications.js +21 -1
  103. data/lib/samagotchi/web/public/participant.html +3 -2
  104. data/lib/samagotchi/web/public/participant.js +25 -10
  105. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  106. data/lib/samagotchi/web/public/push.js +4 -3
  107. data/lib/samagotchi/web/public/push_devices.js +17 -3
  108. data/lib/samagotchi/web/public/review_history.js +14 -2
  109. data/lib/samagotchi/web/public/review_start.js +1 -3
  110. data/lib/samagotchi/web/public/session_actions.js +14 -2
  111. data/lib/samagotchi/web/public/storage.js +16 -0
  112. data/lib/samagotchi/web/public/turn_events.js +2 -1
  113. data/lib/samagotchi/web/public/turn_html.js +1 -1
  114. data/lib/samagotchi/web/push/client.rb +5 -0
  115. data/lib/samagotchi/web/push/store.rb +16 -3
  116. data/lib/samagotchi/web/push_routes.rb +3 -2
  117. data/lib/samagotchi/web/server.rb +2 -2
  118. data/lib/samagotchi/web/session_access_routes.rb +28 -20
  119. data/lib/samagotchi/web/session_control.rb +4 -2
  120. data/lib/samagotchi/web/session_create.rb +4 -4
  121. data/lib/samagotchi/web/session_hub.rb +6 -4
  122. data/lib/samagotchi/web/session_summary.rb +8 -16
  123. data/lib/samagotchi/web/start_dir.rb +10 -5
  124. data/lib/samagotchi/worker.rb +17 -3
  125. data/lib/samagotchi/worker_link.rb +214 -0
  126. data/lib/samagotchi/worker_sidecar.rb +15 -37
  127. metadata +6 -1
@@ -31,7 +31,7 @@ Behavior:
31
31
  - `task_get` returns metadata and `output_path`; use `read` for output contents.
32
32
  - `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
33
33
  - `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
34
- - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `TaskRuntime::MARKER_ENV_KEYS` (and `SessionManager::SESSION_MARKER_ENV`, which a spawned worker unsets). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
34
+ - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `ChildEnv::SESSION_MARKERS` (`TaskRuntime::MARKER_ENV_KEYS`), which a spawned worker unsets (`ChildEnv.unset`, merged into its spawn env). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
35
35
  - They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
36
36
 
37
37
  ## Finish wake
@@ -35,6 +35,8 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
35
35
  │ # the user message's and the reminders' stamp too), continue: true (only on a continue),
36
36
  │ # resumed: true (only on a step-limit Continue: resumed: given),
37
37
  │ # images (only when there are any), cut_after (the model's cut wait, s)}
38
+ │ # starts ToolkitSuggest's audit asynchronously from a copied snapshot; it never blocks the turn or applies
39
+ │ # a suggestion to the session. See configuration.md (Toolkits) and guardrails.md (System 1).
38
40
  │ # refresh_profile! (may ask the server's /props once)
39
41
  │ # turn.settings = LLM::TurnSettings (vision, sampling, thinking; model_name, loads_tools … are set in
40
42
  │ # generate, after the hooks, from the final model),
@@ -157,7 +157,7 @@ row answers another session's id 404, so a new route is covered without a new ex
157
157
  | `POST /api/sessions/:id/turn` | participant | a queued review turn of their own ([Turns](#turns)) |
158
158
  | `POST /api/sessions/:id/turn/:eid/dequeue` | participant | their own queued turn back; another's (the owner's included) is 403 `not_permitted` |
159
159
  | `GET /api/sessions/resolve?id=` | participant | their session for its id or a prefix of it; anything else 404 as no session (never an ambiguity listing others) |
160
- | `POST /api/sessions/:id/wait/prepare`, `…/wait` | participant | only for a turn of theirs (`turn`: the enqueued id `POST …/turn` answered; else 403); `wait/prepare` answers only what that wait needs, `{session_id, baseline: {last_turn}, known_turn, live, last_turn_id}` (`last_turn_id` only when it is their turn), never the owner's cursor, message count, question or running turn; the answer trimmed to `{status, text, turn_id, cancel_reason}`, the reply's `text` through `ParticipantView.reply` (no thinking or tool call, redacted) and any other text (an error's exception, a failed turn's summary) `ParticipantMessages::TURN_FAILED`; a question or approval their turn raised, or their turn held by the owner's step-limit Continue offer ([Turns](#turns)), is `{status: "waiting_for_owner", detail: "waiting for the session's owner"}` with nothing of it; a revoke or the expiry ends a `wait` already running (401 `token_revoked` / `token_expired`), as it ends their stream |
160
+ | `POST /api/sessions/:id/wait/prepare`, `…/wait` | participant | only for a turn of theirs (`turn`: the enqueued id `POST …/turn` answered; else 403); `wait/prepare` answers only what that wait needs, `{session_id, baseline: {last_turn}, known_turn, live, last_turn_id}` (`last_turn_id` only when it is their turn), never the owner's cursor, message count, question or running turn; the answer trimmed to `{status, text, turn_id, outcome, cancel_reason}`, the reply's `text` through `ParticipantView.reply` (no thinking or tool call, redacted) and any other text (an error's exception, a failed turn's summary) `ParticipantMessages::TURN_FAILED`; a question or approval their turn raised, or their turn held by the owner's step-limit Continue offer ([Turns](#turns)), is `{status: "waiting_for_owner", detail: "waiting for the session's owner"}` with nothing of it; a revoke or the expiry ends a `wait` already running (401 `token_revoked` / `token_expired`), as it ends their stream |
161
161
  | `GET /` | participant | the participant page's shell (`participant.html`, its body's `data-session` and `data-name` the grant's; `participant.js` reads the rest through the two routes below). Never the owner's page, where every route would refuse them. |
162
162
  | `GET /api/sessions/:id/review` | participant | the page's data, filtered ([What a participant sees](#what-a-participant-sees)): `{viewer, session: {id, title, status}, subjects, participants, messages, current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}`; a participant's `viewer` has their `usage` |
163
163
  | `GET /api/sessions/:id/stream` | participant | the session's stream through `ParticipantStreamFilter` (only `from_seq` and `snapshot` reach the Bridge); it ends within a poll of a revoke or the expiry |
@@ -169,6 +169,9 @@ Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt
169
169
 
170
170
  ## Turns
171
171
 
172
+ `ParticipantMode.of(session)` is the single resolver for a participant turn's rules. It returns `:review` for every
173
+ session in P0; the existing review boundary therefore remains in force.
174
+
172
175
  A participant's `POST /api/sessions/:id/turn` (`ParticipantAccess#handle_participant_turn`) takes only the body's
173
176
  `prompt`. Three layers keep it a review turn.
174
177
 
@@ -187,7 +190,9 @@ answers 429 `too_many_turns` past it; with no worker, their waiting input files
187
190
  written, under the session dir's `participants.lock` (`SessionManager.deliver_turn`), so two at once can't both
188
191
  pass. The ack is 202 with
189
192
  `delivery: "queue"`; chi web keeps enqueued id → grant (the last 1000), which `dequeue` and `wait` check, falling
190
- back to a waiting file's author after a restart.
193
+ back after a restart to the author the session's files keep (`ParticipantAccess#turn_author`): the waiting or claimed
194
+ input file's, then `last_turn`'s or the settled record's (a refused turn's, `TurnCheckpoint.record_refused`; one whose
195
+ worker died, `TurnCheckpoint.settle`, from its checkpoint mark's `author`).
191
196
 
192
197
  **The worker.** A participant's input file waits for its own turn whatever its delivery says
193
198
  (`SessionInbox.waits_for_turn_end?`), so a drain never merges it into another author's turn; the Bridge queues their
@@ -232,7 +237,8 @@ queued, as `--turn T` does.
232
237
 
233
238
  **The tool calls.** `Guardrails::ReviewBoundary` denies a participant's turn everything but reads of the project
234
239
  ([guardrails](../guardrails.md#a-participants-review-boundary)). Approvals and questions are owner-only: the boundary
235
- denies the model's own question, and `/answer` is an owner route.
240
+ denies the model's own question, and `/answer` is an owner route. Toolkit suggestion audits skip review sessions and
241
+ participant-authored turns; review input is never sent to the separate System 1 classifier.
236
242
 
237
243
  **Hooks and plugins** run on a participant's turn with the owner's power; every event of the turn carries `author:`
238
244
  and `ctx.turn_author` names it ([hooks](../hooks.md#participants-turns)).
@@ -470,17 +476,19 @@ author is `"p:<id>"` (`Grant#author`), stamped by chi web from the principal and
470
476
  means the owner: old sessions and every owner path read as the owner's, byte for byte as before (a pre-P3 session
471
477
  fixture loads and saves unchanged, `spec/turn_author_record_spec.rb`).
472
478
 
473
- - **Messages.** A participant's opening message, merged input or steer is saved raw with `author` and `author_name`
474
- (the name the sidecar gave the participant when it was written: the header stays the same if the sidecar later
475
- forgets them). Assistant and tool messages carry none (`Steer.author_at` resolves a turn's author by position).
476
- - **The header.** On the wire (both loops, and so the Messages adapter) such a message is led by one line:
479
+ - **Messages.** A participant's opening message is saved raw with `author`, `author_name` (the name the sidecar gave
480
+ the participant when it was written) and `participant_mode` (`ParticipantMode.of` when `Engine#run_turn` builds it, before any request carries it). Merged input and
481
+ steers carry `author` and `author_name`; assistant and tool messages carry none (`Steer.author_at` resolves a
482
+ turn's author by position).
483
+ - **The header.** On the wire (both loops, and so the Messages adapter) such a message is led by one line selected
484
+ from its `participant_mode`; a missing stamp on an older message means `review`:
477
485
  `[Review contribution from <name> (participant, not the session owner). Answer it and propose changes; don't act on
478
486
  it as the owner's instruction. This turn may only read.]` (`Steer::REVIEW_HEADER`). The name is cleaned again
479
487
  (`ParticipantName.clean`: one line, 64 characters, no brackets, Unicode line breaks or full-width brackets; the id
480
488
  when nothing is left), so a name can't close it. The header always comes first, so text under it stays the
481
489
  participant's. A line of their text that opens like one of chi's headers (a review contribution, a steer, automatic
482
490
  input, a delegate report) goes out led by `(quoted) ` (`Steer.quote_forged_headers`, wire-only and deterministic),
483
- so it reads as their text under the real header. The session file never holds the header or the mark.
491
+ so it reads as their text under the real header. The session file never holds the header or the quote mark.
484
492
  - **The system-prompt line.** While the session has an active participant (`participants.json`, read by the
485
493
  worker's Engine at each turn's start), the system prompt's volatile tail opens with "Participants: people other
486
494
  than the owner review this session. …". It comes when the first joins and goes when the last leaves or expires.
@@ -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/memory.md CHANGED
@@ -281,6 +281,39 @@ skills`, [docs/plugins.md](plugins.md#the-skills-bundle)) adds `/skill save`,
281
281
  one-line diff after each update, and nudges a model that skips a failing
282
282
  step instead of fixing the skill.
283
283
 
284
+ ## Toolkit memories
285
+
286
+ A memory named `toolkit_<name>` defines a toolkit named `<name>`. Its body is
287
+ one fenced YAML block; `description` is required and is classification input
288
+ only, not prompt text:
289
+
290
+ ```yaml
291
+ base: staging
292
+ add: [web_fetch]
293
+ remove: [memory_write]
294
+ groups: [browser]
295
+ description: Inspect browser behavior and verify web changes
296
+ ```
297
+
298
+ `base`, `add`, `remove` and `groups` are optional; without a base the kit uses
299
+ `full`. A kit can build on a config or bundle kit. `prompt:` is refused, and
300
+ the built-in names (`full`, `lean`, `child`, `review`) cannot be redefined.
301
+ Only registered tools can be declared.
302
+
303
+ Chi reads project-scope kits before system-scope kits; a project kit with a
304
+ name wins over a system kit. Config and bundle kits also win over memories.
305
+ `/toolkit` lists memory kits with their source (`memory:project` or
306
+ `memory:system`). `--mute toolkit_<name>` skips that memory kit for the
307
+ session. Kits are loaded once when a worker starts, so edits take effect on a
308
+ new worker.
309
+
310
+ Write one when the user asks for a kit, when they choose the same tools with
311
+ `/toolkit add` for the same kind of work more than once, or when
312
+ `chi toolkit report` shows a kit repeatedly short of a tool for one kind of
313
+ prompt. To adjust a config or bundle kit, create a memory kit on that base
314
+ (for example, `base: staging`); do not edit `config.yml` or the bundle. Tell
315
+ the user when you create or update a toolkit memory.
316
+
284
317
  ## What a bundle owns
285
318
 
286
319
  A bundle owns the memory files it wrote, nothing else. `chi bundle install`
data/docs/plugins.md CHANGED
@@ -579,7 +579,7 @@ 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, groups: nil)`
582
+ ### `chi.toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil, description: 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
@@ -587,7 +587,9 @@ config or bundle toolkit) with `add` declared and `remove` left out, and
587
587
  inherits the base's, `""` clears it; at most 2000 characters, and one that
588
588
  isn't a String or is longer is a load error). `groups` selects the source
589
589
  groups the toolkit reaches (names or globs, sorted and deduplicated; nil
590
- inherits the base's, `[]` reaches none, and `:all` reaches every group).
590
+ inherits the base's, `[]` reaches none, and `:all` reaches every group; anything
591
+ else, a map say, is a load error). `description` is optional classifier text
592
+ (up to 300 characters); it is not included in the session prompt.
591
593
  Groups match against the tool index's load-time names. Users pick
592
594
  it like chi's own (`chi --toolkit NAME`, `toolkit.default`, `/toolkit NAME`),
593
595
  and the plugin with `ctx.use_toolkit` (below). Names are `a-z`, `0-9`, `_` and
@@ -597,7 +599,7 @@ tool (full) until it comes back, with a warning at its first turn. See
597
599
  [Toolkits](configuration.md#toolkits). `prompt:` needs chi 0.72.0 (an older
598
600
  chi refuses the keyword, a load error: a bundle that passes it sets
599
601
  `requires_chi: ">= 0.72.0"`). `groups:` needs chi 0.121.0: an older chi refuses that keyword
600
- too, so a bundle passing it sets `requires_chi: ">= 0.121.0"`.
602
+ too, so a bundle passing it sets `requires_chi: ">= 0.121.0"` (`chi bundle check` warns on a lower one).
601
603
 
602
604
  ```ruby
603
605
  chi.toolkit "review", base: "lean", add: %w[delegate delegate_result]
@@ -1014,6 +1016,9 @@ ctx.sessions.review(subjects: ["docs/plan.md"], seed: handoff) # => a review se
1014
1016
  `/title` of its own (see [Session titles](sessions.md#session-titles)).
1015
1017
  - An image a message names is copied into the child. One whose file is
1016
1018
  gone is dropped, with `[image x.png was not copied]` in its message.
1019
+ - A child chi won't start raises `Error` with chi's reason: the session's
1020
+ model is gone from the config, or its toolkit is a config kit with an
1021
+ invalid `groups:` (`toolkit.kits`, `Toolkit.check_new!`).
1017
1022
  - `review(subjects: [], seed: nil, recap_wait: 0)` starts a review session of
1018
1023
  this one, as `chi review --from` does (see [Review sessions](sessions.md#review-sessions)):
1019
1024
  a new session in this session's folder, on its model and setup, seeded with
data/docs/sessions.md CHANGED
@@ -309,6 +309,10 @@ declares every tool (full). The file stores only the name: a kit's tools and pro
309
309
  `/toolkit` changes it, saved at once, from the next turn's start. See
310
310
  [Toolkits](configuration.md#toolkits).
311
311
 
312
+ `toolkit_origin` records why the session is on that kit: `default` for the configured kit, `chosen` for a
313
+ user-selected kit (including `/toolkit` and `ctx.use_toolkit`), `inherited` for a continue or fork, or `suggested`
314
+ for a future toolkit suggestion. A session file from before the field reads as `chosen`.
315
+
312
316
  A `find_tools` result on a chat host saves the tools its hit loads as `loaded: ["list_sessions", "mcp_fs_read"]` on
313
317
  its `tool_response` entry (wire names: a built-in's, an MCP tool's `mcp_<server>_<tool>`), whatever the host: a
314
318
  later turn on `api: anthropic` (after a `/model` switch, a resume) sends the load as references, any other host
@@ -377,3 +381,20 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
377
381
  - **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
382
  - 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
383
  - 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.
384
+ ## Restart fallback
385
+
386
+ If `chi sessions restart ID` cannot ask its worker (the Bridge is unreachable
387
+ or refuses its secret, the worker speaks a newer protocol, or its start is
388
+ stuck), chi sends TERM without writing the stop marker, waits up to
389
+ `SessionManager::FALLBACK_STOP_WAIT` (40 s: the worker writes its recap on
390
+ leave) for the owner lock, and resumes the session; a worker that doesn't let
391
+ go in time is reported as "didn't take the restart" and nothing is resumed. A
392
+ worker that took the lock meanwhile (the old one handed over after all) is
393
+ not touched. It refuses first if disk shows a running turn, pending
394
+ question or approval, queued input, or a running background task started by
395
+ the session; the message directs the user to `chi sessions stop ID`, then any prompt.
396
+ Reminders, a running `/btw`, and other in-memory worker state cannot be
397
+ checked and may be lost in this fallback. A stopped worker (SIGSTOP or
398
+ Ctrl-Z: `ps` state `T`) answers neither the ask nor a TERM, so a restart
399
+ refuses it at once, before asking, with its pid and `kill -CONT PID`.
400
+ The web restart route behaves the 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`,
@@ -357,3 +392,8 @@ kept with the build id `/status` shows (`identity.cache.build_id`); when that ch
357
392
  the version and record it again. This step goes once a Splash release carries its version in `/status`.
358
393
 
359
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
@@ -45,7 +45,7 @@ module Samagotchi
45
45
  nil # never break the running turn
46
46
  end
47
47
 
48
- # @return [Hash, nil] a copy of the turn in progress: prompt, origin,
48
+ # @return [Hash, nil] a copy of the turn in progress: prompt, turn_id, origin,
49
49
  # continue, at (its wall time), ordered parts (generation / thinking / text / tool /
50
50
  # input / steer / reminder / notice), pending_question and the last event_seq folded
51
51
  # in. A finished tool part keeps the live row's action and duration_ms
@@ -136,6 +136,7 @@ module Samagotchi
136
136
  return [] unless turn
137
137
 
138
138
  started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
139
+ started[:turn_id] = turn[:turn_id] if turn[:turn_id]
139
140
  started[:resumed] = true if turn[:resumed]
140
141
  started[:started_at] = started_at if started_at
141
142
  started.merge!(author_of(turn))
@@ -275,6 +276,7 @@ module Samagotchi
275
276
  dequeue([event[:origin]])
276
277
  @turn = { prompt: event[:prompt], origin: event[:origin], continue: !!event[:continue],
277
278
  parts: [], pending_question: nil }
279
+ @turn[:turn_id] = event[:turn_id] if event[:turn_id]
278
280
  @turn[:resumed] = true if event[:resumed]
279
281
  # Its wall time (MessageTime): a joining page's gap divider.
280
282
  @turn[:at] = event[:at] if event[:at]
@@ -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
@@ -818,8 +816,8 @@ module Samagotchi
818
816
  # What a parent's relay may say about this session's approval
819
817
  # (BridgeClient#relay): opened and closed only change how the question
820
818
  # shows (QuestionDesk#annotate); answered makes this worker ask the
821
- # parent for the answer (RelayVerifier). No auth: a poke at opened or
822
- # closed can at most show a wrong banner.
819
+ # parent for the answer (RelayVerifier). Like every Bridge route, this
820
+ # request requires the Bridge's secret.
823
821
  RELAY_ACTIONS = %w[opened closed answered].freeze
824
822
  # Why a relay closed, as a parent says it; anything else is "closed".
825
823
  RELAY_CLOSE_REASONS = %w[stopped closed child_gone answered_on_child].freeze
@@ -1448,7 +1446,8 @@ module Samagotchi
1448
1446
 
1449
1447
  # POST /session/:id/relay/status {relay_id}: what this (the parent's)
1450
1448
  # worker's relay holds, for the child that verifies an answer
1451
- # (RelayVerifier). Read-only and no secret; 404 for an unknown id.
1449
+ # (RelayVerifier). Read-only; like every Bridge route, it requires the
1450
+ # Bridge's secret. 404 for an unknown id.
1452
1451
  def handle_relay_status(_session_id, body)
1453
1452
  parsed = parse_json(body)
1454
1453
  return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
@@ -1500,17 +1499,19 @@ module Samagotchi
1500
1499
  # secret: what every request must name (#secret_ok?).
1501
1500
  WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
1502
1501
  version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES,
1503
- epoch: @epoch, secret: @secret).write(session_dir)
1502
+ epoch: @epoch, secret: @secret, pid: Process.pid,
1503
+ protocol: WorkerLink::PROTOCOL).write(session_dir)
1504
1504
  rescue StandardError => e
1505
1505
  Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
1506
1506
  end
1507
1507
 
1508
- # Only while it still names this bridge: a racing worker for the same
1509
- # session may have written its own since.
1508
+ # Only while it still carries this bridge's epoch: a racing worker for the
1509
+ # same session may have written its own since (a file without an epoch
1510
+ # is only ever an older worker's).
1510
1511
  def remove_sidecar
1511
1512
  return unless @server
1512
1513
 
1513
- File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.port == @port
1514
+ File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.epoch == @epoch.to_s
1514
1515
  rescue StandardError
1515
1516
  nil
1516
1517
  end
@@ -158,16 +158,22 @@ 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
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)
165
+ end
166
+ return :closed if closed?
163
167
 
164
- unless attach(sock)
165
- sock.close rescue nil
166
- return :closed
168
+ if outcome.is_a?(StreamOpen::Unreachable)
169
+ @reason = outcome.detail if outcome.detail
170
+ return :unreachable
167
171
  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
172
+ return :unknown_session if outcome.is_a?(StreamOpen::Refused) && outcome.reason == :unknown_session
173
+ return :secret_refused if outcome.is_a?(StreamOpen::Refused) && outcome.reason == :bridge_secret
174
+ return :unreachable unless outcome.is_a?(StreamOpen::Opened)
175
+
176
+ sock = outcome.sock
171
177
 
172
178
  # Back as soon as the stream answers: a replay may bring nothing
173
179
  # (the session was quiet), and the next frame could be minutes away.
@@ -194,24 +200,6 @@ module Samagotchi
194
200
  detach(sock)
195
201
  end
196
202
 
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
203
  # Publish the socket for #close; false when already closed.
216
204
  def attach(sock)
217
205
  @mutex.synchronize do