samagotchi 0.102.0 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. metadata +28 -1
data/docs/plugins.md CHANGED
@@ -1207,7 +1207,13 @@ bundle.shutdown
1207
1207
  - **More:** `bundle.plugin` (the instance `register` ran on, for its own methods), `bundle.declarations` (the
1208
1208
  `chi.*` calls `register` made, in order, as made), `bundle.namespace`, `bundle.hook_names(event)`,
1209
1209
  `bundle.run_inits`, `sandbox.write_memory(name, text, scope:)` / `memory_dir`, `sandbox.fake_executable(name,
1210
- body)` (first on `PATH`), `wait_until { }`.
1210
+ body)` (first on `PATH`).
1211
+ - **Waiting:** `wait_for(what, timeout: 5, seen: nil) { … }` polls the block until it is truthy and returns that
1212
+ value, or raises `Samagotchi::Testing::Waiting::TimedOut` ("timed out after 5 s waiting for <what>", plus
1213
+ `; last saw: <value>` from the `seen:` callable). `join!(thread, what)` and `pop!(queue, what)` are
1214
+ `Thread#join` and `Queue#pop` with that deadline and message; `wait_until { }` returns the block's last value
1215
+ instead of raising, for a wait whose timeout is fine; `process_gone?(pid)`. Every deadline is multiplied by
1216
+ `SPEC_TIME_SCALE` (default 1, at least 1; chi's CI sets 2), so a slow runner gets headroom.
1211
1217
  - **Model notes:** `sandbox.model_notes_for(model_id, small: nil, cwd: nil)` lists the model notes chi would load
1212
1218
  into the prompt for a bare model id (`deepseek/deepseek-v4.1-flash`, no host prefix), from the sandbox's
1213
1219
  memories (an installed bundle's included), matched as chi matches them: each `models:` glob against the id and
@@ -2194,6 +2200,18 @@ Claude pairs; without one the bundle costs one tool declaration.
2194
2200
  it only within your task, and stop and tell you when Claude agrees or after
2195
2201
  the last turn. Claude's skill ends each answer's summary with its verdict
2196
2202
  and never answers with `chi send` (which counts as you typing).
2203
+ - **chi on another machine.** With the chi session on the Studio and Claude on
2204
+ the laptop, `CHI_REMOTE=studio sh <bundle>/scripts/pair <id>` pairs through
2205
+ the Studio's chi web ([Remote sessions](cli.md#remote-sessions)), and
2206
+ Claude's answers (its `chi context push claude-outbox`, with `CHI_REMOTE`
2207
+ set) reach chi: the outbox leg works. The inbox leg doesn't: `claude_send`
2208
+ writes to Claude's socket on the machine chi runs on, and the laptop's isn't
2209
+ there. So through a remote (`CHI_REMOTE`, or `--remote NAME` in `CHI`)
2210
+ `pair` attaches only `claude-outbox` and prints one line saying chi there
2211
+ can't send to this Claude: chi's replies reach Claude only when you relay
2212
+ them (or ask it to run `chi --remote studio send --wait <id>`). A session
2213
+ that already has a `claude-inbox` it can't reach:
2214
+ `chi --remote studio context rm claude-inbox <id>`.
2197
2215
  - **Asking first.** The tool has no `targets:`, so guardrails match it by name
2198
2216
  only: to be asked before each send, add a rule ([Rules in
2199
2217
  config.yml](guardrails.md#rules-in-configyml)):
@@ -2271,8 +2289,9 @@ skill install card) works the same.
2271
2289
  (absolute: config.toml doesn't expand `~`), and the pair script names it
2272
2290
  when a write is refused ("Operation not permitted"). chi never edits
2273
2291
  Codex's config. Reading a payload file needs nothing: the default sandbox
2274
- reads anywhere. No network is needed: a push writes files, and chi wakes
2275
- on them.
2292
+ reads anywhere. With chi on this machine no network is needed: a push
2293
+ writes files, and chi wakes on them. Through a remote it is the other way
2294
+ round (HTTP, not files): see **chi on another machine** below.
2276
2295
  - **Pairing** is Codex's, as with Claude: `/codex install-skill` copies the
2277
2296
  bundle's `scripts/chi-peer-SKILL.md` to
2278
2297
  `$CODEX_HOME/skills/chi-peer/SKILL.md` (else `~/.codex/…`), with the
@@ -2293,6 +2312,17 @@ skill install card) works the same.
2293
2312
  rule Codex follows, not something enforced: anything shaped like that
2294
2313
  header is treated this way, and nothing stops a message from asking for
2295
2314
  more.
2315
+ - **chi on another machine.** As with Claude, `CHI_REMOTE=studio sh
2316
+ <bundle>/scripts/pair <id>` (or `--remote NAME` in `CHI`) pairs through
2317
+ the Studio's chi web, and only the outbox leg works: `codex_send` runs
2318
+ `codex queue` on the machine chi runs on, where this Codex's thread isn't.
2319
+ So `pair` attaches only `codex-outbox` and prints one line saying chi there
2320
+ can't send to this Codex. Through a remote chi talks HTTP, not files, so
2321
+ `writable_roots` doesn't matter; Codex's sandbox may block the network
2322
+ instead, which pairing and every reply then need (workspace-write has none
2323
+ by default): when chi can't reach the
2324
+ remote, the pair script names `network_access = true` under
2325
+ `[sandbox_workspace_write]` as the likely fix.
2296
2326
  - In the web, selecting text offers **Confirm with Codex**, which fills
2297
2327
  `/codex validate` and the quote, as claude-peer's pill does.
2298
2328
  - **The skill.** `skill_codex_peer` (system scope) is claude-peer's with
data/docs/sessions.md CHANGED
@@ -17,10 +17,12 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
17
17
 
18
18
  A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
19
19
 
20
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
20
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
21
21
 
22
22
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
23
23
 
24
+ **Participants:** a session its owner invited participants to review (`chi share`, see [CLI: Participants](cli.md#participants)) has two more files in its dir, neither in the session JSON: `participants.json` (0600), who is or was invited (`{participants: [{id, kind, name, role, added_at, expires_at, removed_at}]}`, never a token), which chi web and `chi share` write and the worker reads (`Participants.read`); and `participants.jsonl` (0600, append-only), the audit trail (grants issued, revoked and expired, each new peer, refused routes). A session nobody was invited to has neither. A continue, a fork or a delegate starts with no participants. Archiving or deleting the session revokes its links. See [internals/participants.md](internals/participants.md).
25
+
24
26
  **Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
25
27
 
26
28
  **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`.
@@ -29,9 +31,9 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
29
31
 
30
32
  **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
31
33
 
32
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
34
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
33
35
 
34
- **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
36
+ **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web page always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id?force=true` (the page sends `force`; without it a live worker's session is 409 `worker_running`, as the CLI without `--force`; 200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
35
37
 
36
38
  <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. Typed while a turn runs, `/archive` waits for the turn's end (an attached terminal for the commands queued behind it too) and then archives. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `continue → · archive · stop · delete` (`unarchive` on an archived one; no `continue →` on a link continued already, see *Session chains*); archiving the open session stays on it, with an Undo toast.
37
39
 
@@ -152,11 +154,18 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
152
154
  the provider (cyber). …]` when the provider refused it.
153
155
 
154
156
  After you drop or undrop an item of the working set between turns (`/working-set drop KEY`, the web list's
155
- `drop`; [cli.md](cli.md#the-working-set)), the next turn starts with one line about it (`kind: working_set`,
157
+ `drop`; [cli.md](cli.md#the-working-set)), or pin or unpin one under a strategy with the forget layer, the next turn
158
+ starts with one line about it (`kind: working_set`,
156
159
  escaped like a context note; the UIs don't show it): `[WORKING SET: since your last turn the user dropped
157
160
  lib/cart.rb from your context: you are sent a stub instead of its outputs; read it again only if the task needs
158
161
  it.]`. Every op since the last turn joins that one line, and an undrop before the next turn takes its drop off it.
159
- The drop itself is saved on the entries (`edits`, kind `drop`), so `--resume`, rollbacks and forks keep it.
162
+ An add (`/working-set add KEY`) asks the model in that line to load the item first (`the user added lib/tax.rb to
163
+ your context: before anything else, load it with read "/abs/lib/tax.rb"`); an unadd before the next turn takes it
164
+ off.
165
+ The drop itself is saved on the entries (`edits`, kind `drop`), so `--resume`, rollbacks and forks keep it. A pin is
166
+ the session file's `working_set_pins` (`[{"kind": "file", "key": "/abs/path"}]`, `[]` for none; kinds `file`,
167
+ `memory`, `url`), not an edit: a respawned worker reads it, a continue and a fork (`ctx.sessions.fork`) copy it as
168
+ they copy the toolkit, and a delegate child starts without. A rollback leaves it (it is not in the conversation).
160
169
 
161
170
  When the context window fills past 40% (then 60%, 80%), the native loop leaves a similar line (`kind: context`):
162
171
  `[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted
@@ -187,7 +196,7 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
187
196
 
188
197
  - With both stdin and `-m`, stdin is context: each line becomes a `> ` quote (as web annotations quote a selection), then a blank line, then the message. With only one, it goes in as is. A terminal on stdin is ignored. Both empty is a usage error; over 16 KiB is refused, never cut.
189
198
  - It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then, and cuts nothing. `--cut` asks for a cut, as `/cut TEXT` in a terminal does: a generation that has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`) is cut (now, or once its thinking passes that), and the step starts again with the message. To an idle session `--cut` sends normally. `--cut --wait` into a running turn is refused like any `--wait` there (`busy: a turn is running`). `--queue` (not with `--cut`, a usage error) keeps the message for after the running turn: it runs as a turn of its own then (as `/queue TEXT` does), and a plain message sent after it still goes in at the running turn's next step. To an idle session it runs at once (it still goes as a queue, so a turn another client starts first never takes it as a steer). A worker older than queueing would steer it instead, so it is refused (to an idle session too) (`refused: this session's worker predates queueing, …; restart it`, exit 1) and nothing is sent; `chi sessions restart ID` and send again.
190
- - A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
199
+ - A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Bridges listen on 127.0.0.1: another machine's sessions are reached through its chi web, `chi --remote NAME send …` ([Remote sessions](cli.md#remote-sessions)).
191
200
  - Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
192
201
  - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
193
202
  - A session command runs as the command, as typed in a terminal: `chi send -m "/model qwen" 3fa2` switches the model (`sent as a session command`), and the model never sees the line. What counts is what the session's worker knows (the built-ins, `!cmd`, its bundles' commands); an unknown `/word` (`/usr/bin/env is missing`) goes in as a message. A UI-only command (`/stats`, `/exit`, `/recap`, …) is refused instead: the worker doesn't run it, so the line would reach the model as a prompt (`<id> refused: /stats is a terminal and web command; use chi sessions stats <id8>`; exit 1, and nothing is sent). With `--new` a line starting with `/` or `!` starts the session idle and goes to its worker once it is up, which decides. With `--wait` a command has no reply to wait for: exit 0 at once, `<id> sent as a session command: no reply to wait for …` on stderr (`{"status":"command",…,"detail":…}` with `--format json`); its output shows where the session is open.
@@ -243,7 +252,7 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
243
252
  - **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`SessionContinue::Refused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that is open in a chi REPL (close it there). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
244
253
  - **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
245
254
  - **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
246
- - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
255
+ - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context`, `thinking`, `settings`, `toolkit`, `tools`, `no_tools` or `parent_answers` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
247
256
  - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
248
257
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
249
258
  - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
data/docs/sub-agent.md CHANGED
@@ -164,6 +164,22 @@ An answer typed into `chi --attach` or the REPL counts as a parent's too when
164
164
  stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
165
165
  `CODEX_THREAD_ID`, `SAMAGOTCHI_PARENT_SESSION`) is set.
166
166
 
167
+ The same holds for another machine's session in this terminal (`chi --remote
168
+ NAME --attach ID`, `--resume ID`, `--dir DIR -p TEXT` from a pipe): the answers
169
+ go as chi answer's and are held to that machine's `guardrails.parent_answers`;
170
+ a question left waiting at the pipe's end is exit 3, its report read with that
171
+ machine's parent strategy (asked again then) and its commands spelled `chi
172
+ --remote NAME answer …`. For one turn and no terminal, use `chi --remote NAME
173
+ send --new --dir D -m TEXT --wait` (`--non-interactive` is this machine's only).
174
+
175
+ Through `chi web` (`POST /api/sessions/:id/answer`, what chi answer uses against
176
+ another machine's sessions) the answer keeps chi answer's mark: the route passes
177
+ on `client_id: "cli:answer"` and drops any other id, so a forwarded answer can
178
+ only be held to the parent rules, never pass for the user's own. The worker's
179
+ refusal (403 `parent_answer_refused`) comes back as it said it. A parent's
180
+ `/continue yes` and a loosening `/parent-answers` sent through `chi web`'s create
181
+ and turn routes are refused there too, held to that machine's config.yml.
182
+
167
183
  The worker checks it again with its own config.yml. The guardrails settings
168
184
  have no environment variables, and a worker drops any `SAMAGOTCHI_GUARDRAILS_*`
169
185
  it inherits; but a parent that sets `XDG_CONFIG_HOME` picks which config.yml a
data/docs/testing.md CHANGED
@@ -22,6 +22,9 @@ with two rules only, `no-undef` (a name used but never imported or declared: a d
22
22
  style pass: add a rule only for a bug class it would have caught. It runs in about 2 s, in CI's `npm test` job and in
23
23
  `rake release:check`; `rake lint` stays RuboCop alone (CI's rubocop job has no node). A name that is also a browser
24
24
  global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
25
+ It also checks `spec/e2e/` for one thing, the Playwright twin of `Samagotchi/SpecSleep`: no `page.waitForTimeout`.
26
+ Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); a pause that
27
+ is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`.
25
28
 
26
29
  CI's e2e job keeps the tests, chi and the fake model on the runner but runs Chromium in Playwright's image
27
30
  (`mcr.microsoft.com/playwright:v<version>-noble`, `playwright run-server` on the host network); the tests connect
@@ -66,11 +69,68 @@ either router adds its row there; a row where a UI differs on purpose (or a foun
66
69
  `repl` override and a `note`. The Ruby side runs each row with every core command (a new one fails its
67
70
  `CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
68
71
 
69
- A spec that waits for something uses `wait_until(timeout:)`, or `wait_until!(what, timeout:)` where no check
70
- follows (stubs, helper threads: it raises "timed out after N s waiting for <what>") — never a loop with no deadline.
72
+ ## Waiting and time
73
+
74
+ A spec waits *for* something and never sleeps to wait: a fixed pause is too short on a loaded runner (a parallel
75
+ run, CI) and too long on a fast one. The contract a new spec follows:
76
+
77
+ 1. **Never `sleep` to wait.** Wait for the condition: `wait_for("what") { condition }`, or
78
+ `eventually { expect(…) }` for a value. A `sleep` that *is* the behaviour (slowness, holding a connection,
79
+ separating mtimes) carries `# sleep-ok: why` on its line or the line above (for a call over several lines, its
80
+ first line). A bounded best-effort `wait_until`
81
+ whose timeout is fine ("at most 0.5 s, fine to cut short") carries `# wait-ok: why`; it must not become
82
+ `wait_for`, which would raise where the code moves on. A `Timeout.timeout` is a wait too (use `pop!`, `join!` or
83
+ `wait_for`); one that only guards a call that returns by itself against a hang carries `# timeout-ok: why`.
84
+ 2. **Time logic takes seams.** Intervals, graces and deadlines in lib take `clock:` / `sleeper:` / `interval:`
85
+ constructor arguments, and the unit spec moves the clock itself (`SessionAccess::Remote` is the model;
86
+ `IdleScheduler.new(interval:)`). Real time is used only in one smoke example per class. Two sessions or inbox
87
+ files that must sort apart get distinct times, not a pause: `Session.new_session(now:)`, `Session#save(now:)` and
88
+ `SessionInbox.write_input` / `write_note` / `write_output(now:)` take `now: next_stamp` (spec/support/stamps.rb:
89
+ a millisecond later on each call, starting a second in the past).
90
+ 3. **What a spec starts, it stops and waits for** before teardown: `join!(thread, what)`, `with_task_dir { … }`
91
+ (spec/support/task_dirs.rb: a tasks' folder of its own, its tasks stopped and their exit-status recorders joined
92
+ with `TaskRuntime.quiesce!`), a `stop` that joins (`IdleScheduler#stop`). Never only `Thread#kill`.
93
+ 4. **"Nothing happens" is shown by counting passes** (the worker's loop rounds, spec/support/worker_loop_rounds.rb;
94
+ a class's ticks), not by a pause.
95
+ 5. **A wall-clock bound** only as "not the full N s", with at least 5× headroom measured on the slow (x86) laptop.
96
+
97
+ The helpers are the bundle test kit's (`Samagotchi::Testing::Waiting`, [plugins.md](plugins.md#testing-a-bundle-experimental)),
98
+ so core and bundle specs share one copy; `SpecWaiting` (spec/support/waiting.rb) includes them in every example
99
+ group and also answers them as module calls (`SpecWaiting.pop!`, for a stubbed lib thread):
100
+
101
+ - `wait_for(what, timeout: 5, interval: 0.01, seen: nil) { … }` returns the block's first truthy value, or raises
102
+ `Waiting::TimedOut`. `seen:` (a callable) shows what the condition last saw, since a falsy block value tells
103
+ nothing.
104
+ - `join!(thread, what, timeout: 5)` and `pop!(queue, what, timeout: 5)`: `Thread#join` and `Queue#pop` with the
105
+ same deadline and message. A gate a stubbed lib thread waits on takes `timeout: 30`: it only ends a hang.
106
+ - `wait_until(timeout:) { … }` returns the block's last value (falsy at the deadline): for a value the example
107
+ checks after, or a `wait-ok` wait.
108
+ - `eventually(timeout: 5) { expect(…) }` (spec only) retries until the expectations hold, then re-raises the last
109
+ failure prefixed `still failing after 5 s:`, so the matcher's own expected/got is in the message.
110
+
111
+ One formatter builds the failure text of `wait_for`, `join!` (which adds "to finish") and `pop!`:
112
+
113
+ ```
114
+ timed out after 5 s waiting for task 20261023-0a1b to end
115
+ timed out after 10.0 s (5 s × SPEC_TIME_SCALE 2) waiting for task 20261023-0a1b to end; last saw: "running"
116
+ ```
117
+
118
+ **`SPEC_TIME_SCALE`** (default 1, at least 1; CI's `rspec`, `rspec-macos` and `bundles` jobs set 2) multiplies
119
+ every helper deadline, explicit `timeout:` values included: a positive wait only gets more headroom, and a passing
120
+ run is no slower. Set it locally to reproduce a CI timing (`SPEC_TIME_SCALE=2 bundle exec rspec FILE`). It does not
121
+ scale the lib's own timeouts a spec passes in (`wait: 0.05`, `grace: 0.1`): those are the code's deadlines, and a
122
+ flaky one gets a `clock:` seam instead. A helper's own spec pins the scale (stub `Waiting.time_scale`). Keep
123
+ explicit helper timeouts at 30 or below: 60 s on CI, under the per-example limit.
124
+
71
125
  Every example also runs under a 120 s limit (`spec_helper`'s `around`; `integration`, `macos_build`, `warmup` and
72
126
  `recap` examples are exempt), so a hang fails its example instead of the run. `SPEC_EXAMPLE_TIMEOUT=N` changes the
73
- limit, `0` turns it off (a debugger session).
127
+ limit, `0` turns it off (a debugger session). The limit is not scaled.
128
+
129
+ **The cops.** `Samagotchi/SpecSleep` (an untagged `sleep` in a spec) and `Samagotchi/SilentWait` (a `wait_until`
130
+ whose value is dropped, an example's last line included, `expect(wait_until { … }).to be(true)`, or an untagged
131
+ `Timeout.timeout`) live in `rubocop/` and run in `rake lint`, so CI fails on them. RSpec counts `wait_for`,
132
+ `eventually`, `join!` and `pop!` as an example's expectation (`RSpec/NoExpectationExample`), so a wait can be an
133
+ example's last line. What the cops can't see, `script/affected-specs` hints at (`timing:`, below).
74
134
 
75
135
  ## Bundle specs: the test kit
76
136
 
@@ -140,7 +200,11 @@ words the REPL prints, pick it by a rule), then the other name matches (a spec n
140
200
  `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
141
201
  `spec/guardrails/context_spec.rb`), then the specs that name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
142
202
  The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" (with `npm run lint` too when a frontend module changed) and a
143
- hint for a hub class (named in too many specs to list) go to stderr. Nothing printed means nothing matched.
203
+ hint for a hub class (named in too many specs to list) go to stderr, and so do the `timing:` hints, from the lines a
204
+ changed spec adds (`git diff -U0` against the base; an untracked spec, all its lines): a `Thread.new`, `.start`,
205
+ `spawn` or `fork` with no `join!`, `wait_for`, `with_task_dir` or `Process.wait` added in the same file, and a
206
+ sub-second `be < 0.x` bound ([Waiting and time](#waiting-and-time), rules 3 and 5). Nothing printed means nothing
207
+ matched.
144
208
 
145
209
  It finds a change's own specs, not the regressions it causes elsewhere. So before committing, run the full suite
146
210
  (`bundle exec parallel_rspec -n 8 spec lib/samagotchi/bundles/*/spec`), unless your task says the coordinator runs it; the coordinator runs it at merge
@@ -10,6 +10,7 @@ require_relative "guardrails/parent_policy"
10
10
  require_relative "cli/command"
11
11
  require_relative "cli/flags"
12
12
  require_relative "cli/parent_wait"
13
+ require_relative "session_access/local"
13
14
 
14
15
  module Samagotchi
15
16
  # `chi answer`: answer the question a session's worker waits on (exit 3
@@ -68,11 +69,14 @@ module Samagotchi
68
69
  f.value "--format"
69
70
  end
70
71
 
71
- def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
72
+ # @param access [SessionAccess::Local, nil] where the sessions are;
73
+ # default this machine's, in +state_dir+
74
+ def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
72
75
  @argv = argv.dup
73
76
  @stdout = stdout
74
77
  @stderr = stderr
75
78
  @state_dir = state_dir || Session.default_state_dir
79
+ @access = access || SessionAccess::Local.new(state_dir: @state_dir)
76
80
  end
77
81
 
78
82
  private
@@ -106,23 +110,23 @@ module Samagotchi
106
110
  def run_parsed(options)
107
111
  id = resolve(options[:id]) or return 1
108
112
  qid = options[:question].strip
109
- session = Session.load(id, state_dir: @state_dir)
113
+ session = @access.show(id)
110
114
  pending = session.pending_question
111
115
  # Taken before the answer goes in, with the question answered as the
112
116
  # one already reported: the file keeps it until the turn thread
113
- # wakes, and it must not come back as exit 3.
114
- cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
115
- baseline = ReplyWait.baseline_of(session, question_id: qid)
116
- # The turn the answer is about: the one asking (running), or the one
117
+ # wakes, and it must not come back as exit 3. Its own_turn is the
118
+ # turn the answer is about: the one asking (running), or the one
117
119
  # whose continue offer stands. Its own reply is the answer's, not a
118
120
  # turn queued behind it; nil (an older worker): the next reply.
119
- turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
121
+ start = @access.prepare_wait(id, session: session, question_id: qid)
120
122
 
121
123
  if pending && pending.id.to_s == qid
122
124
  return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
123
125
 
124
126
  selected = selection(pending, options) or return @selection_status
125
- refusal = parent_refusal(pending, selected, session)
127
+ # Remotely the worker's check alone (chi web passes chi answer's
128
+ # client_id on, and its 403 says why).
129
+ refusal = parent_refusal(pending, selected, session) unless @access.remote?
126
130
  return failure(refusal) if refusal
127
131
 
128
132
  posted = post(id, qid, selected, options)
@@ -134,7 +138,7 @@ module Samagotchi
134
138
  end
135
139
  # Answered here, or no longer open (answered elsewhere, or a newer
136
140
  # question): what comes next either way.
137
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn)
141
+ wait_for_reply(id, cursor: start.cursor, baseline: start.baseline, timeout: options[:timeout], turn: start.own_turn)
138
142
  end
139
143
 
140
144
  # The labels to send. A number is an option's place (1-based); an
@@ -163,6 +167,8 @@ module Samagotchi
163
167
  # with this process's config.yml tightened by the session file's own
164
168
  # parent_answers, and the question's own tags). A deny, text alone or a
165
169
  # dismissal always passes. The worker checks it again with its own.
170
+ # Machine-local (this machine's config.yml): remotely skipped here; the
171
+ # server forwards CLI_ANSWER and the worker's check refuses (P2).
166
172
  def parent_refusal(pending, selected, session)
167
173
  offered = Array(pending.options).map(&:to_s)
168
174
  indices = selected.map { |label| offered.index(label) }
@@ -177,23 +183,18 @@ module Samagotchi
177
183
  "(--option Stop --text WHY stops it)"
178
184
  end
179
185
 
180
- # Post the answer (or the dismissal) to the live worker's Bridge.
186
+ # Post the answer (or the dismissal) to the live worker (its Bridge,
187
+ # through SessionAccess), marked as chi answer's.
181
188
  # @return [Integer, nil] an exit status when it didn't go in; nil to
182
189
  # wait (it went in, or the question was no longer open: 409)
183
190
  def post(id, qid, selected, options)
184
- client = BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir))
185
- return failure(worker_gone(id)) unless client
186
-
187
- reply = nil
188
- 2.times do
189
- reply = if options[:dismiss]
190
- client.dismiss_question(id: qid)
191
- else
192
- client.answer(id: qid, selected: selected, freeform: options[:text],
193
- client_id: ClientId::CLI_ANSWER)
194
- end
195
- break unless reply.status == 408
196
- end
191
+ reply = if options[:dismiss]
192
+ @access.dismiss(id, question_id: qid)
193
+ else
194
+ @access.answer(id, question_id: qid, selected: selected, freeform: options[:text],
195
+ client_id: ClientId::CLI_ANSWER)
196
+ end
197
+ return failure(worker_gone(id)) unless reply
197
198
 
198
199
  case reply.status
199
200
  when 200 then nil
@@ -209,7 +210,7 @@ module Samagotchi
209
210
  return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"
210
211
 
211
212
  cant = options[:dismiss] ? "dismiss a question" : "take an answer"
212
- failure(BridgeClient.stale_worker_message(id, cant: cant))
213
+ failure(BridgeClient.stale_worker_message(id, cant: cant, via: @access.invocation))
213
214
  else failure("the worker answered #{reply.status}: #{reply.json&.dig("detail") || reply.body}")
214
215
  end
215
216
  rescue Errno::ETIMEDOUT
@@ -229,7 +230,8 @@ module Samagotchi
229
230
  end
230
231
 
231
232
  def worker_gone(id)
232
- "the worker is gone and the question with it; send the task again: chi send --wait -m \"…\" #{id}"
233
+ "the worker is gone and the question with it; send the task again: " \
234
+ "#{@access.invocation.command("send --wait -m \"…\" #{id}")}"
233
235
  end
234
236
 
235
237
  # @return [Integer] 1, after the line
@@ -172,6 +172,9 @@ module Samagotchi
172
172
  # One a worker that died left is not open.
173
173
  @pending_card.clear
174
174
  @pending_card_handle = @engine.subscribe(observer: @pending_card)
175
+ # #stop and #close_intake wake the acceptor through it: closing the
176
+ # listen socket doesn't cut a select on it short on Linux.
177
+ @wake_reader, @wake_writer = IO.pipe
175
178
  @accept_thread = Thread.new { accept_loop }
176
179
  @accept_thread.report_on_exception = false
177
180
  write_sidecar
@@ -198,12 +201,17 @@ module Samagotchi
198
201
  rescue StandardError
199
202
  nil
200
203
  end
204
+ wake_acceptor
201
205
  end
202
206
 
203
207
  # Stop the acceptor, release the socket and remove the sidecar, so clients
204
208
  # don't have to find it stale. Does not stop the owning turn. Joins the
205
209
  # acceptor thread so the process can exit cleanly (Ruby waits for a thread
206
210
  # blocked in IO.select at VM shutdown). Safe to call again.
211
+ #
212
+ # The sidecar goes before the open streams close: a client that
213
+ # reconnects as its stream ends (chi web's proxy, an attached TUI) then
214
+ # finds no worker, or the next one, never this one's dead port.
207
215
  def stop
208
216
  @mutex.synchronize { @stopped = true }
209
217
  begin
@@ -211,6 +219,7 @@ module Samagotchi
211
219
  rescue StandardError
212
220
  nil
213
221
  end
222
+ wake_acceptor
214
223
  @capture_handle&.unsubscribe
215
224
  @accumulator_handle&.unsubscribe
216
225
  @engine.running_turn_messages = nil
@@ -220,13 +229,14 @@ module Samagotchi
220
229
  # A turn post killed between its enqueue and its reply looks failed to
221
230
  # the web, which then queues the prompt again from the input file.
222
231
  await_answers(REQUEST_GRACE_SECONDS)
232
+ remove_sidecar
223
233
  @connection_threads.each { |t| t.kill rescue nil }
224
234
  @connection_threads.clear
225
235
  # Its relay watchers (RelayWatcher) end with it.
226
236
  watchers = @mutex.synchronize { (@relay_watchers || []).tap { @relay_watchers = [] } }
227
237
  watchers.each { |thread| thread.kill.join(RELAY_WATCHER_JOIN_SECONDS) }
228
238
  @accept_thread&.join(2)
229
- remove_sidecar
239
+ [@wake_reader, @wake_writer].each { |io| io&.close rescue nil }
230
240
  nil
231
241
  end
232
242
 
@@ -360,17 +370,25 @@ module Samagotchi
360
370
  end
361
371
  end
362
372
 
373
+ # #stop or #close_intake: the acceptor's select returns now.
374
+ def wake_acceptor
375
+ @wake_writer&.write_nonblock(".", exception: false)
376
+ rescue IOError
377
+ nil # closed by an earlier #stop
378
+ end
379
+
363
380
  def accept_loop
364
381
  loop do
365
382
  break if stopped? || @server.closed?
366
383
 
367
384
  begin
368
- rs, = IO.select([@server], nil, nil, 0.5)
385
+ rs, = IO.select([@server, @wake_reader], nil, nil, 0.5)
369
386
  rescue IOError
370
387
  break
371
388
  end
372
389
 
373
390
  next unless rs
391
+ break if rs.include?(@wake_reader)
374
392
 
375
393
  client =
376
394
  begin
@@ -874,6 +892,10 @@ module Samagotchi
874
892
  client_id = fetched(parsed, "client_id")
875
893
  # --no-interrupt: the turn runs with the raised iteration limit.
876
894
  no_interrupt = fetched(parsed, "no_interrupt") == true
895
+ # Who wrote it when it isn't the owner (chi web stamps it from the
896
+ # request's principal; the Bridge is loopback and trusts its caller).
897
+ author = fetched(parsed, "author")
898
+ author = nil unless author.is_a?(String) && !author.empty?
877
899
  if sid.to_s.strip.empty? || prompt.to_s.strip.empty?
878
900
  return [{ "Allow" => "POST" }, 400,
879
901
  { error: "missing_fields", detail: "session_id and prompt are required" }]
@@ -917,7 +939,7 @@ module Samagotchi
917
939
 
918
940
  queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
919
941
  enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
920
- no_interrupt: no_interrupt, images: images, delivery: delivery).tap do |ok|
942
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author).tap do |ok|
921
943
  next unless ok
922
944
 
923
945
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
@@ -1064,9 +1086,9 @@ module Samagotchi
1064
1086
 
1065
1087
  # Write a turn into this session's input dir, reusing the file IPC the
1066
1088
  # worker polls. Never calls run_turn across the boundary.
1067
- def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil)
1089
+ def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil)
1068
1090
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1069
- no_interrupt: no_interrupt, images: images, delivery: delivery)
1091
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author)
1070
1092
  end
1071
1093
 
1072
1094
  # ── HTTP plumbing ────────────────────────────────────────────────────────
@@ -1264,9 +1286,11 @@ module Samagotchi
1264
1286
 
1265
1287
  def write_sidecar
1266
1288
  # version: the chi this worker runs (chi update and the web's badge
1267
- # report older ones); features: what a client may ask of it.
1289
+ # report older ones); features: what a client may ask of it; epoch:
1290
+ # this worker's, as its event ids carry it.
1268
1291
  WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
1269
- version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES).write(session_dir)
1292
+ version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES,
1293
+ epoch: @epoch).write(session_dir)
1270
1294
  rescue StandardError => e
1271
1295
  Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
1272
1296
  end