samagotchi 0.102.0 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. metadata +28 -1
@@ -0,0 +1,429 @@
1
+ # Remote sessions
2
+
3
+ `chi --remote NAME <command>` (or `CHI_REMOTE=NAME`) runs chi's session commands (`send`, `answer`, `note`, a
4
+ session's `context`, `sessions list/stop/restart/archive/unarchive/delete`) against another machine's sessions, and
5
+ `chi --remote NAME --attach ID` (`--resume ID`, `--dir DIR`, `-p TEXT`) runs the attached TUI on one of them. The
6
+ sessions, workers, memories and code stay on that machine; this one only sends requests and prints the answers.
7
+ User-facing behaviour is in [cli.md](../cli.md#remote-sessions). This page is the code's shape, for changing it.
8
+
9
+ **The one rule:** the remote side is that machine's **chi web**, never a worker's Bridge. Bridges stay on loopback.
10
+ chi web answers each request by calling the same `SessionAccess::Local` operation the CLI would call there.
11
+
12
+ ## The seam: `SessionAccess`
13
+
14
+ Each session command takes an `access:` backend (default `SessionAccess::Local`) and sends every session read or write
15
+ through it. Both backends have the same methods and raise the same errors with the same words.
16
+
17
+ | Piece | File | Role |
18
+ |---|---|---|
19
+ | `SessionAccess` | `lib/samagotchi/session_access.rb` | The module: value objects and error classes (below) |
20
+ | `Local` | `session_access/local.rb` | This machine: the `Session`, `SessionManager`, `ReplyWait`, `NoteDelivery`, `ContextSources` and `BridgeClient` calls the commands used to make, moved here |
21
+ | `Remote` | `session_access/remote.rb` | Another machine: each method is one request (or a loop of them) to chi web's route of the same name |
22
+ | `HTTPTransport` | `session_access/http_transport.rb` | `Net::HTTP`, one connection per call, `Authorization: Bearer <token>`; returns `HTTPTransport::Reply` (`status`, `body`, `json`, `#error`) |
23
+ | `Dispatch` | `session_access/dispatch.rb` | bin/chi's front for `--remote` / `CHI_REMOTE` |
24
+ | `Contract` | `session_access/contract.rb` | Which commands, subcommands and flags are `:supported` remotely and which are `:local_only` |
25
+ | `Remotes` | `lib/samagotchi/remotes.rb` | `remotes.yml`: `Remotes::Entry` (`name`, `url`, `token`, `#label`) |
26
+ | `RemoteCommand` | `lib/samagotchi/remote_command.rb` | `chi remote add / ls / rm` |
27
+ | `Web::SessionAccessRoutes` | `lib/samagotchi/web/session_access_routes.rb` | The chi web routes the page didn't already have, each calling `Local` (included in `Web::App`) |
28
+
29
+ The value objects (`Data.define`, no hashes across files) are `NewSession` (what `send --new` asks for), `SetupWords`
30
+ (the `--new` setup flags as typed, for the server to check), `SessionView` (`Remote#show`; `Local#show` returns the
31
+ `Session` itself, which has the same fields but `owner`: both backends answer `#owner(id)`), `Created` (`Remote#create`; Local returns the `Session`), `WaitStart` (the
32
+ pre-send state for a wait), `ContextEntry` and `ContextPush`. `Remote` also returns the existing callee types
33
+ (`SessionManager::DeliverResult`, `NoteDelivery::Result`, `ReplyWait::Result`, `BridgeClient::Response`,
34
+ `SessionManager::Restarted`) so that the commands can't tell the backends apart. A command checks `@access.remote?` where the backends must
35
+ differ.
36
+
37
+ ### Dispatch: choosing the backend
38
+
39
+ `bin/chi` calls `Dispatch.from(ARGV)` **before** its `SUBCOMMANDS` lookup, because `--remote` comes before the
40
+ subcommand:
41
+
42
+ - It strips any leading `--remote NAME` / `--remote=NAME` words (if there are several, the last one wins). A missing,
43
+ blank or dash-led name raises `Dispatch::Usage` (exit 2). Then it reads `CHI_REMOTE` (trimmed). The flag beats the
44
+ env. There is no default remote.
45
+ - `here?`: `chi remote …` (`ALWAYS_HERE`), and a bare `--version` / `-h` / `--help`, run locally as usual.
46
+ - `#run`: an unknown command exits 1. `#refusal` is the single local-only message, `chi <words> runs on this machine
47
+ only: run it on <remote>` (exit 2). `<words>` comes from `Contract`: a `MACHINE` command, an unlisted command, a
48
+ `:local_only` command, subcommand or long flag, or a run flag `Contract::RUN` lists local-only (`chi --verbose`;
49
+ `--non-interactive` adds "(chi --remote NAME send --new --dir D -m TEXT --wait runs one turn there)"). A flag's
50
+ value (`-p TEXT`, `--model NAME`: `RunOptions.value_switches`) is never read as a flag.
51
+ - **Run flags** (`#run_flags?`: no subcommand, the first word a flag or nothing) don't go to `#run`: bin/chi refuses
52
+ the local-only ones, parses the rest with `RunOptions.parse` (the one run-flag parser) and, before any of its own
53
+ checks (`Session.resolve_id`, the scratch check, `--memory`, `--append-system-prompt`, `Toolkit.from_flags`, …: all
54
+ this machine's), runs `RemoteAttachCommand.new(options, access: Remote.new(entry)).run`. A word left over exits 1.
55
+ Without a remote, `--dir` exits 2 ("--dir goes with --remote; cd there instead").
56
+ - Otherwise it calls `Remotes.find`, then `Samagotchi.const_get(klass).new(argv, access: Remote.new(entry)).run`.
57
+ `Remotes::Invalid` (including `NotFound`) exits 2. A `RemoteFailed` that escapes the command prints
58
+ `chi <cmd>: <message>` and exits 1.
59
+
60
+ ### Contract
61
+
62
+ `Contract::TABLE` maps `"command"`, `"command sub"` and either one followed by a long flag (`"context add --cmd"`) to
63
+ `SUPPORTED` or `LOCAL_ONLY`. A `LOCAL_ONLY` command or subcommand lists no flags. `MACHINE` holds bin/chi's
64
+ non-session commands (`bootstrap update models desktop memory bundle remote`). `spec/session_access_contract_spec.rb`
65
+ reads bin/chi's `SUBCOMMANDS` and each command's `FLAGS`. It fails when a subcommand, a subcommand of a supported
66
+ command, or a flag of a supported command is missing from both lists, or when the table lists something that doesn't
67
+ exist. **A new session command or flag fails that spec until you choose one.**
68
+
69
+ `Contract::RUN` does the same for bin/chi's run flags (`RunOptions.switches`): `SUPPORTED` (`--attach`, `--resume`,
70
+ `-p`, `--shared`, `--model`, `--thinking`, `--llm-context*`, `--toolkit`, `--tool`, `--no-tool`, `--set`,
71
+ `--parent-answers`, `--no-interrupt`, `--no-default-input`, `--dir`, `--help`, `--version`) or `LOCAL_ONLY`
72
+ (`--no-shared`, `--non-interactive` and `--verbose`: the in-process REPL, P4-D4; `--memory`, `--mute`,
73
+ `--append-system-prompt`: this machine's memories and files, P4-D2/D3; `chi web`'s flags; `--profile`). A config
74
+ registry flag (`--recap-base-url` …) is this machine's config: `LOCAL_ONLY` without being listed
75
+ (`Contract.run_status`). The contract spec fails on a run flag in neither.
76
+
77
+ ## One request, end to end
78
+
79
+ `chi --remote studio send ab12 -m "fix it" --wait`:
80
+
81
+ 1. **bin/chi → Dispatch.** The flag is removed. `send` and `send --message` / `--wait` are `SUPPORTED`. `Remotes.find("studio")`
82
+ reads `$XDG_CONFIG_HOME/samagotchi/remotes.yml`. Then `SendCommand.new(argv, access: Remote.new(entry)).run`.
83
+ 2. **SendCommand.** The client-side checks (UI-only slash commands, image ingest) run. The machine-local ones are
84
+ skipped (next section). `run_wait` → `ParentWait#resolve` → `Remote#resolve`.
85
+ 3. **Remote → HTTPTransport.** The first request on a `Remote` is preceded by `check_server!`, which sends
86
+ `GET /api/info` and needs `app: "chi-web"` plus the `session_commands` feature. Next come
87
+ `GET /api/sessions/resolve?id=ab12` and `POST …/wait/prepare {busy: true, queue:}` (`Remote#busy`), then
88
+ `POST …/wait/prepare {turn, created}` for the `WaitStart`. `deliver` calls `Remote#show` and `#owner` (each
89
+ `GET /api/sessions/:id?brief=1`), then
90
+ `POST …/turn {prompt, client_id: "cli:send", history: false}`. A 202 ack becomes a `DeliverResult` with `via`
91
+ (`bridge` or `file`).
92
+ 4. **chi web.** `Web::App#route` runs the Host check (`local_host_header?`), then `cross_site?`, then in LAN mode for a
93
+ non-loopback peer `token_gate`. Then comes the `ROUTES` match. An id that isn't `Session.valid_id?` gets a 404
94
+ before any handler runs. A `:session` route turns an `ArgumentError` into its unknown-session 404. The handlers in
95
+ `SessionAccessRoutes` call `session_access`, a `SessionAccess::Local` on chi web's own state dir. The page's older
96
+ routes (`/turn`, `/answer`, `/stop`, …) call `SessionManager` and the Bridge directly, as before.
97
+ 5. **The wait.** `ParentWait#wait_for_reply` → `Remote#wait_reply` loops on `POST …/wait` with
98
+ `timeout: min(time left, WAIT_SLICE)` (30 s). The server runs `ReplyWait.call` for at most `WAIT_MAX_SECONDS` (30 s)
99
+ and returns the `ReplyWait::Result` fields plus `parent_strategy`. On a `timeout` status the client asks again until
100
+ its own `--timeout` runs out, at which point it builds the `timeout` result itself. A 503 `server_stopping` (chi web
101
+ shutting down mid-wait: `ReplyWait`'s `cancelled:` returns `:canceled`) is retried after `RETRY_AFTER` (1 s).
102
+ `HTTPTransport::READ_TIMEOUT` (75 s) is set above the longest answer, a wait slice or a create's wait for its worker.
103
+ 6. **The report.** `ParentReport.report` prints it as it does locally. It uses `Remote#parent_strategy`, which
104
+ is the last wait's `parent_strategy` (the server's config.yml tightened by the session's own), or `HARD` if no wait
105
+ has returned one. The exit codes match the local ones (3 for a question, 130 for Ctrl-C). The commands it prints
106
+ are spelled by `via:`, the backend's `invocation` (a `ChiInvocation`): `chi --remote NAME answer …` remotely, and
107
+ what runs here only (`--attach`, `--resume`) gives way to "chi web on NAME". The commands pass it to each text that
108
+ prints a command (`ParentReport`, `SendCommand::Busy#message`, `sessions stop`, `AnswerCommand`).
109
+
110
+ The routes, their bodies and their refusals are tabled in [architecture.md](../architecture.md), *chi web's routes for
111
+ the session commands*. Keep that table in step with `SessionAccessRoutes`.
112
+
113
+ ## Errors: one condition, one message
114
+
115
+ - **Refusals.** `SessionAccessRoutes#refusal_response` (and the page routes that raise) send
116
+ `{error, detail, message, …fields}`: `detail` is the page's wording, `message` is the exception's own text, and the
117
+ fields are those the class is built from (`session_id`, `reason`, `ids`, `busy_id`, `version`).
118
+ `Remote#raise_refusal` rebuilds the class that Local's callee raises: `ArgumentError`, `Session::AmbiguousId`,
119
+ `SessionManager::OwnedByTUI` / `DeleteRefused` / `ArchiveRefused` / `RestartRefused`, `SessionContinue::Refused`,
120
+ and `ModelProfile::MissingModel` for `invalid_model` (no model there, or `--model`'s host unknown there: "could not
121
+ start a session: …", exit 1, as here).
122
+ Checks the client would make locally come back as `SessionAccess::Refused` (`parent_answers_refused`,
123
+ `parent_continue_refused`, `no_vision`, exit 1) or `BadRequest` (the other `invalid_*`, `missing_fields`, `bad_images`,
124
+ exit 2, the usage-error path). That is for the routes `raise_refusal` reads (create, uploads, …). `POST …/turn` is
125
+ mapped in `Remote#deliver_turn`: any 4xx but 404 and `owned_by_tui` becomes a refused `DeliverResult` with the
126
+ server's `ack`, which `SendCommand` prints as `<id> refused: <detail>` (`refused_word`, `REMOTE_REFUSALS`), exit 1.
127
+ A per-method mapping covers the rest (`Remote#deliver_turn`, `#note`, `#context_push`, `#context_remove`,
128
+ `#bridge_reply` for answer/dismiss: chi web's 503 `not_live` or 404 `not_found` → nil, i.e. no worker; 504 → the
129
+ Bridge's 408; 501 → its 404). Anything unmapped becomes `RemoteFailed` "remote NAME (URL) answered STATUS code:
130
+ message".
131
+ - **Transport.** `HTTPTransport#call` turns socket errors into `RemoteFailed` "remote studio (http://…): connection
132
+ refused" (also: no answer within 5 s, a read timeout, can't find host, host unreachable, TLS, a broken connection).
133
+ `ParentWait#run` catches `RemoteFailed` so that `--format json` still prints its error object. Dispatch catches it for
134
+ the other commands.
135
+ - **Auth.** `Remote.access_error`, keyed by chi web's error code on any route: a 401 `unauthorized` → "the token was
136
+ refused (… chi remote add NAME URL --token-file FILE)"; a participant's token's own conditions (401 `token_revoked`,
137
+ `token_expired`, 403 `not_permitted`, P3b's 429 `too_many_turns`, `cap_reached`) → one class and message each
138
+ ([participants.md](participants.md#errors-through---remote)). A 403
139
+ `forbidden` (the Host check) → "chi web answers its LAN IP only: use the LAN IP <ip>, not a hostname", with the IP
140
+ taken from the 403's detail. `chi remote add` uses the same words through `Remote.info_problem`.
141
+ - **A remote attach.** Before the loop runs (resolve, create, wake, the feature check) every failure is
142
+ `AttachLauncher::Error` or the command's own line, exit 1 (`BadRequest`: exit 2): the wake's 409 `owned_by_tui`
143
+ is `OwnedByTUI` (its message is the one `chi --attach` prints here too: `AttachLauncher.connect` now uses it), its
144
+ 409 `scratch` and 504 `worker_timeout` are `Refused` with `LaunchMode::SCRATCH_LEFTOVER` / `BRIDGE_LATE`'s words
145
+ (`Remote#attach_wake`). Once the loop runs, `WebBridgeClient` turns every `RemoteFailed` into `Unreachable`
146
+ (an `IOError`), so the loop's rescues catch it and `AttachedLoop#worker_down` prints its message as is ("could not
147
+ send the prompt (remote studio (http://…): the token was refused …)"); the stream's end is worded by its reason
148
+ (*Remote attach*, below). A refused token is `AuthRefused < RemoteFailed`, so rediscovery can tell it apart.
149
+ - **Version skew.** The check is by feature, not by version: `/api/info`'s `features` must include `session_commands`
150
+ (`SessionAccessRoutes::FEATURE`, mirrored as `Remote::FEATURE` so chi web needn't be loaded). If it's missing,
151
+ `RemoteTooOld` says "remote NAME runs chi X, too old for this command through --remote: update chi there (chi
152
+ update)": no release number, which would be a guess before the release. A 404 counts as a missing route
153
+ only when its detail starts with `not found: /` (the 404 from `serve_static`, for a path no route takes). A route's
154
+ own 404 (no such session or source) stays a refusal. **Keep that rule:** a new route's 404 must never use the
155
+ `not found: /…` wording.
156
+
157
+ ## Which checks run where
158
+
159
+ Some checks read this machine's config or files, which would be wrong against a remote. Most are marked in the code
160
+ with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendCommand#run_wait`'s busy check,
161
+ `#remote_new_options`, `#run_new`'s `capped_note`, `ContextCommand#add_url`).
162
+
163
+ | Check | Local (in the command) | Under `--remote` |
164
+ |---|---|---|
165
+ | Images to a model that can't see them (`SendCommand#vision_refusal`) | client | server: `check_vision: true` on the create and turn routes → 422 `no_vision` |
166
+ | A parent's `/continue yes`, a loosening `/parent-answers` (`SendCommand::ParentGate`) | client, before any target | server: `App#parent_gate` on create/turn for a `cli:send`/`cli:answer` `client_id` → 403; each target refuses its own |
167
+ | `--parent-answers` looser than config.yml (`Guardrails::ParentPolicy.capped_note`) | client | server: the create's `parent_answers_warning` |
168
+ | A parent's option on `chi answer` (`AnswerCommand#parent_refusal`) | client, then the worker | skipped on the client; the worker checks (the answer route forwards `cli:answer`), and its 403 `parent_answer_refused` passes through |
169
+ | What a parent may pick, for the report (`ParentReport.strategy_for`) | client | server: the wait route's `parent_strategy` |
170
+ | `send --wait` busy check (`SendCommand::Busy`) | client | server: `wait/prepare` with `busy: true` → 409 `busy`/`waiting` with the `Busy` fields (`question`, `parent_strategy`); the client words it (`Busy#message(via:)`) |
171
+ | `--continues`' previous link and model (`SendCommand#continued_model`) | client | server: `handle_continue` |
172
+ | `--toolkit`, `--tool`, `--set` (`Toolkit.from_flags`, `SessionConfig.from_flags`) | client | server: sent as `SetupWords` → 400 `invalid_*` → exit 2 |
173
+ | `--dir` (default: the cwd) | client, `File.directory?` | required and absolute on the client (`remote_new_options`); chi web's `scope_dir` checks it exists |
174
+ | `sessions list`'s default project (`ProjectScope.root_for(Dir.pwd)`) | client | every project's; `--scope=project` needs `--cwd` (a folder there) |
175
+ | `context`'s default session (`SAMAGOTCHI_PARENT_SESSION`, `ContextCommand#inside_session`) | client | no default: ids are required (`added_by` still reads it) |
176
+ | A context URL's provider (`ContextProviders.resolve`) | client's bundles | server's bundles (`Remote#context_add_url`) |
177
+ | UI-only slash commands (`SessionCommands.ui_only_entry`), `--image` ingest, note text (`SessionInbox.checked_text`), push text (`ContextSources.parse_output`) | client | client too (the server checks note and push text again) |
178
+ | A remote attach's `--attach`/`--resume` id (`Session.resolve_id`), the leftover scratch check, the "open in a chi REPL" refusal | client (bin/chi, `AttachLauncher.connect`) | server: `Remote#resolve`, then `POST …/wake`'s 409s |
179
+ | A new attached session's `--toolkit`, `--set`, `--thinking`, `--llm-context*`, `--parent-answers`, `--model` | client (bin/chi) | server: `POST /api/sessions` with `idle: true` and `SetupWords`; its warnings printed as `send --new` prints them |
180
+ | The status row's default model, the newest chi for the version line, what a parent may pick (exit 3) | client | server: the wake's `default_model`, `installed`, `parent_strategy` (the last read again from `GET …/worker` at report time) |
181
+ | `@path` images (ingest, convert, downscale) | client, into the session's `images/` | client, into a scratch dir, then uploaded (`POST …/images`) |
182
+ | Prompt history (↑), the `no_interrupt` / `no_default_input` baselines, Tab's `@path` | client | client (this terminal's; `history: false` on every POST); no memory-name completion remotely (P4-D11) |
183
+
184
+ ## Trust model
185
+
186
+ - **The token is the owner.** In LAN mode (`web.host: lan`) every non-loopback request needs the `Web::Token` access
187
+ token, as the `chi_token` cookie or `Authorization: Bearer` (`App#token_gate`). This runs over plain http, which is
188
+ fine on a home LAN, the same as the phone page. A loopback chi web without LAN mode needs no token
189
+ (`Remotes::Entry#token` is nil, and no header is sent).
190
+ - **Every request has a principal.** `App#principal_for` resolves who a request is, as a `Web::Principal`
191
+ (`web/principal.rb`): `Principal::OWNER` for a peer the gate doesn't ask (no LAN mode, or a loopback peer) and for
192
+ the access token; a participant for a live grant's `chp_` token; nil (a 401) for anyone else (`token_revoked` /
193
+ `token_expired` for an ended grant's). `#token_gate` uses it, and `#route` keeps it in the request's env
194
+ (`App#principal(req)`), so a handler reads the principal and never trusts the body for it. Each `ROUTES` row names
195
+ who may use it (its permission column, `:owner` or `:participant`; `Principal#permits?`; any other answers 403
196
+ `not_permitted`). The owner passes every route (`local_viewer?` only hides `editor_url`). A participant, a
197
+ `Web::Grants` token for one session, reaches only the `:participant` rows, and another session's id is a 404
198
+ ([participants.md](participants.md)). A turn's author comes from the principal
199
+ (`Principal#author`, nil for the owner) and rides with the turn (`SessionManager.deliver_turn(author:)` →
200
+ `BridgeClient#post_turn` → `Bridge#handle_post_turn` → the input file, the fallback's too → the turn's `origin`);
201
+ an owner's turn carries none, so its input file and session are byte for byte as before.
202
+ - **The token at rest.** `remotes.yml` is written through `AtomicFile.write(perm: 0o600)`. `Entry#inspect` / `#to_s`
203
+ redact the token. `chi remote ls` never prints it (JSON shows `token: true|false`). `HTTPTransport#inspect` and
204
+ `Remote#inspect` show only the label.
205
+ - **The Host check.** `local_host_header?` accepts only the loopback names and the exact LAN IP. `http://studio.local`
206
+ gets a 403 `forbidden`, which the client words as "use the LAN IP". The check guards against DNS rebinding.
207
+ `cross_site?` lets CLI clients through because they send no `Origin` or `Sec-Fetch-Site`.
208
+ - **`client_id` only tightens.** `handle_question_answer` forwards `client_id` only when it is `ClientId::CLI_ANSWER`
209
+ and drops any other value, so a remote answer is held to the parent rules and can't pass for the user's own. On
210
+ create and turn, a `cli:send` / `cli:answer` id adds checks (`CLI_SEND_IDS`) and never removes any.
211
+ - **A remote attach's `client_id`** (`tui:<pid>@<host>`, P4-D6) is forwarded as given on `/turn` and `/command`, as
212
+ the page's is: the token is the owner. Only the cli ids (`cli:send`, `cli:answer`) add checks there; a parent
213
+ agent driving the TUI from a pipe answers as `cli:answer`, held to that machine's `guardrails.parent_answers`.
214
+ `POST …/exit` passes on only a `tui:` id: any other is dropped, so the worker counts every stream as a UI, which
215
+ only keeps it up. `POST …/wake` spawns a worker, which `/turn` already did. `!cmd` typed in a remote TUI runs on that machine, under
216
+ its guardrails.
217
+ - **Context push is an inbound path.** A remote `context push` writes text that can wake a session. It carries the same
218
+ trust as a local `chi context push` on that machine, the owner's token ([guardrails.md](../guardrails.md)). A remote
219
+ `context add --cmd` runs its command on the session's machine.
220
+ - **Guardrails see through `--remote`.** The `chi-context-cmd` and `chi-broadcast` regexes in
221
+ `bundles/guardrails/guardrails/rules.yml` allow `--remote NAME` / `--remote=NAME` (quoted, continued) between `chi`
222
+ and the subcommand. `chi-answer-piped` already allowed any words there, and `chi-answer-http` matches any
223
+ curl/wget/http/xh line naming `/answer`, chi web's route included. `CHI_REMOTE=…` leaves the command words unchanged. Specs:
224
+ `spec/guardrails/shipped_bundle_spec.rb`.
225
+
226
+ ## Testing
227
+
228
+ - `spec/support/remote_backend.rb`: `RemoteBackend.both(self) { … }` runs a group's examples once per backend
229
+ (`backend` is `:local` or `:remote`). The command gets `access: backend_access(state_dir)`. On the remote side,
230
+ `RackTransport` calls `Web::App#call` in-process on the same state dir, in LAN mode, from another peer, with the LAN
231
+ IP as Host and a Bearer token, so the token gate and the Host check run on every request, and the spec's sessions and
232
+ stubs belong to the server too. Used in the send, send `--new`, answer, note, context, sessions list/archive and
233
+ delete specs.
234
+ - An example tagged `local_only: "why"` is skipped on the remote side, with the reason. Use this tag only when the
235
+ words or the check are genuinely this machine's (`PARENT_CHECK`, `--project (Contract)`, image ingest, …), never to
236
+ hide a divergence.
237
+ - `spec/session_access_contract_spec.rb` (the table), `spec/session_access_dispatch_spec.rb`,
238
+ `spec/session_access_remote_spec.rb`, `spec/session_access/http_transport_spec.rb`, `spec/remotes_spec.rb`,
239
+ `spec/remote_command_spec.rb`, `spec/web/session_access_routes_spec.rb`, and the pair-script specs in
240
+ `spec/samagotchi/bundles/{claude,codex}_peer_pair_spec.rb`.
241
+ - A remote attach: `spec/session_access/web_bridge_client_spec.rb` (the mapper's rows and `Unreachable`, through
242
+ `RackTransport`; the stream's request bytes, a chunked reply), `spec/bridge_client/event_stream_options_spec.rb`
243
+ (the idle timeout, which a local follow never sets; epoch_watch; retries; reasons), and one end-to-end spec,
244
+ `spec/remote_attach_stream_spec.rb`: a real `Bridge` behind `Web::App` on WEBrick at 127.0.0.1:0
245
+ (`spec/support/web_server.rb`) followed through a `WebBridgeClient` (join, a turn, a restart, a stop). It runs
246
+ over loopback, so chi web's token gate is skipped there; `spec/web/lan_auth_spec.rb` covers the gate on the
247
+ stream, wake and worker routes.
248
+ - The remote TUI: `spec/terminal_ui/remote_link_spec.rb` (rediscovery reasons, the wake on a reconnect, images, the
249
+ join's reads, `HANDLED_COMMANDS` covering every attached terminal command), `spec/terminal_ui/attached_loop_remote_spec.rb`
250
+ (the loop's `--remote` lines, `/exit`, `/exit --delete`, `/archive`, `/stats`, `/recap`, a join with that machine's
251
+ saved records, `Unreachable` mid-session, the reasons' and reconnect lines),
252
+ `spec/terminal_ui/remote_connector_spec.rb`, `spec/remote_attach_command_spec.rb`,
253
+ `spec/session_access/remote_attach_spec.rb` (wake, worker, feature check through `RackTransport`), and bin/chi's
254
+ branch in `spec/session_access_dispatch_spec.rb`.
255
+ - By hand: the smoke-run skill (`.claude/skills/smoke-run`, kept in the checkout, not in git) covers chi web; its
256
+ cli-tui recipe has remote attach on one machine (two isolated envs, `chi remote add self http://<LAN IP>:PORT`, the
257
+ terminal's commands through chi web). The real lane is laptop → Studio over the LAN: `chi remote add`, `send --new --wait`,
258
+ an exit-3 `answer`, `context push`, `note`, then `CHI_REMOTE=studio sh scripts/pair <id>` from the laptop's Claude.
259
+
260
+ ## Remote attach
261
+
262
+ `chi --remote NAME --attach ID`: the attached TUI (`AttachedLoop`) on a session of another machine. The TUI still
263
+ talks to a worker's Bridge interface; chi web relays each request to that Bridge on loopback.
264
+
265
+ ```
266
+ bin/chi ── RunOptions.parse ── a remote, run flags ──> RemoteAttachCommand ── AttachLauncher.run(connector: RemoteConnector)
267
+ │ POST …/wake
268
+ AttachedLoop.new(link: RemoteLink) ── link.client: WebBridgeClient
269
+ │ one-shots and the stream
270
+ chi web (that machine) ── the worker's Bridge
271
+ ```
272
+
273
+ ### The plumbing
274
+
275
+ **chi web's routes** (tabled in [architecture.md](../architecture.md)): `POST …/wake` (`Local#wake`: the live worker
276
+ as is, else `SessionManager.resume_session` and `BridgeClient.wait_for`, at most `WAKE_MAX_WAIT` 10 s, the attach's
277
+ `BRIDGE_WAIT` here, after waiting for a worker still leaving to let go of its lock, `OwnerLock.await_release`: its
278
+ recap on leave's timeout plus 5 s; a leftover scratch session is refused in `LaunchMode::SCRATCH_LEFTOVER`'s words, a late Bridge
279
+ in `LaunchMode::BRIDGE_LATE`'s) and `GET …/worker` (`Local#worker`: live, the stop marker, the owner, `leaving` and `leave_seconds` when its worker
280
+ 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,
281
+ features}`; `POST …/exit` (the worker's exit, `client_id` passed on only when it is a `tui:` one), `POST …/recap`,
282
+ `GET …/stats` (the stats, else an older worker's state metrics), `GET …/children/counts` (`ChildrenStatus.counts`)
283
+ and `GET …/turns/:turn_id/records` (`SessionMetrics`' saved tool records and turn record), every refusal with
284
+ `{error, detail, message}`; `POST …/turn` takes `no_interrupt`
285
+ (`SessionManager.deliver_turn(no_interrupt:)`, to the Bridge or into the input file); `POST …/command`'s 501 for a
286
+ builtin the worker lacks names it (`command`). `/api/info`'s feature `remote_attach`
287
+ (`SessionAccessRoutes::ATTACH_FEATURE`, mirrored as `Remote::ATTACH_FEATURE`) says a chi web has them.
288
+
289
+ **One worker identity.** The Bridge's epoch (`Bridge#epoch`, the `<seq>-<epoch>` in every event id) is in its sidecar
290
+ (`WorkerSidecar#epoch`; an older worker's reads nil, and the client keys it by `started_at` instead). A local
291
+ `BridgeClient` is keyed by its port; a remote one stays one object and its `worker_key` changes.
292
+
293
+ **`SessionAccess::WebBridgeClient`** (`session_access/web_bridge_client.rb`, built by `Remote#attach_client(id,
294
+ worker:)` from a wake's or worker read's `worker`): `post_turn` (`no_interrupt`, `history: false`), `post_command`,
295
+ `answer`, `dismiss_question`, `dequeue`, `cancel`, `request_exit`, `request_recap`, `stats`, `children_counts`,
296
+ `turn_records`, `follow`, `connect_stream`, `session_id`, `worker_key`, `port` (nil). One-shots go through
297
+ `Remote#route_request` (the server check, the auth and missing-route mapping), a new connection each, with a 45 s
298
+ 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
299
+ 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**,
300
+ `WebBridgeClient::Mapping.response(route, reply, …)`:
301
+
302
+ | chi web's answer | What the loop gets |
303
+ |---|---|
304
+ | 503 `not_live`, 404 `not_found` | raises `Unreachable` (< `IOError`): "no live worker for session ID on NAME" (`NO_WORKER`) |
305
+ | 504 (any) | `Response(408, {error: deadline_passed})`: the loop's late line |
306
+ | 501 with `command` (`/command`) | `Response(400, {error: unknown_command, command})`: the loop names the command |
307
+ | any other 501 | `Response(404, {error: not_found})`: the stale-worker line |
308
+ | anything else | unchanged: 202 acks, 409s, a route's own 404 (`unknown_enqueued_id`), 403 `parent_answer_refused`, 502 |
309
+
310
+ Every `RemoteFailed` (a transport failure, `RemoteTooOld`, a 401 or a 403 `forbidden`) becomes `Unreachable` with its
311
+ message, so a loop that rescues `IOError` around Bridge calls never crashes on one mid-session.
312
+
313
+ **The stream.** `connect_stream` writes `GET /api/sessions/:id/stream…` through `BridgeClient::StreamRequest` (the
314
+ GET a local stream writes, with `Authorization: Bearer` and `Last-Event-ID`; `Host` is the entry's host and port).
315
+ chi web proxies the Bridge's bytes unchanged (under WEBrick a hijacked body is raw, `Connection: close`). No chunked
316
+ decoding in v1: a `Transfer-Encoding: chunked` reply (a proxy in between) and an https entry are logged
317
+ (`stream_refused`, `chunked_unsupported` / `tls_unsupported`) and read as unreachable; decoding and TLS plug into
318
+ `StreamRequest`. `follow` starts `EventStream` with three options a local stream never sets (`BridgeClient#follow`
319
+ passes none):
320
+ - `idle_timeout:` 40 s (`STREAM_IDLE_TIMEOUT`: two of the Bridge's 15 s pings plus margin): no byte for that long is a
321
+ half-open connection (Wi-Fi gone, a sleeping laptop), reconnected with the cursor.
322
+ - `epoch_watch:` a frame whose id carries another epoch than the cursor's is a new worker answering the old cursor:
323
+ `worker_changed {"port" => nil, "epoch" => new}` comes before it (the `reset` the new Bridge sends), and the
324
+ client's `worker_key` is updated in place. A restart reaches the stream this way because `Bridge#stop` removes its
325
+ sidecar before it ends the open streams: the reconnect finds no sidecar, chi web waits for the next worker's
326
+ (`await_bridge_port`) and proxies the cursor to it. A sidecar still naming the dead port would get an empty 200
327
+ (the proxy's connects refused), and the stream would move by rediscovery (`worker_changed`, then a `snapshot`).
328
+ - `announce_retries:` `stream_retrying` once a reconnect fails and rediscovery neither moved the stream nor ended it
329
+ (a stop prints its stopped line alone), `stream_resumed` once a stream answers again (a replay may bring no frame).
330
+
331
+ `EventStream` compares a rediscovered client's key with the key it last followed, not with the client object's, so a
332
+ rediscovery that updates the same client in place is a move. Rediscovery may give a reason with its answer
333
+ (`[nil, reason]` while it retries, `[:gone, reason]` to stop, `AttachLink#live_worker`'s shape), and `stream_closed`
334
+ carries the last one (`unreachable` without). `AttachedLoop#live_worker` passes the link's pair on; on this machine
335
+ the closing line reads `stopped` as `unreachable`, the words it always had.
336
+
337
+ ### The remote TUI
338
+
339
+ - **`RemoteAttachCommand`** (`remote_attach_command.rb`) takes RunOptions' parsed options: `LaunchMode.attached_conflict`
340
+ (exit 1, the same words); `--attach`/`--resume` resolved there (`Remote#resolve`); without them a new session,
341
+ which needs `--dir`, that machine's absolute path (exit 2, before anything is asked): `Remote#create` with
342
+ `NewSession(idle: true)` and the setup flags as typed (`SetupWords`), its warnings printed as `send --new` prints
343
+ them, its `-p` sent once attached. An existing session prints the ignored-flags line (`--dir` among them) and gets
344
+ `--model`/`--llm-context*`/`--thinking` as first commands, as here. `Remote#require_feature!("remote_attach")` comes
345
+ first (without it, `Remote.too_old_message`). Then `AttachLauncher.run(connector: RemoteConnector)` and
346
+ `AttachLauncher.exit_status`.
347
+ - **`RemoteConnector`** (`terminal_ui/remote_connector.rb`): `#connect` is `Remote#attach_worker` (a worker there
348
+ still leaving prints `LaunchMode::RECAP_WAIT`, as a local attach does, and the wake's read timeout grows by its
349
+ `leave_seconds`), then `Remote#attach_wake` (`POST …/wake`, `WakeReply`) and the `RemoteLink`; `#client_id` is `tui:<pid>@<short hostname>` (P4-D6).
350
+ - **`RemoteLink`** (`terminal_ui/remote_link.rb`), the `AttachLink`:
351
+ - `client`: one `WebBridgeClient` for the attach; `switched(event)` keeps it (its key the event's epoch) and reads
352
+ the new worker's features (`GET …/worker`).
353
+ - `live_worker`: `Remote#attach_worker` (`WorkerReply`) with a reason (M5): `stopped` and `unknown_session` (gone),
354
+ `auth_failed` (`AuthRefused`) and `too_old` (`RemoteTooOld`) end at once; `unreachable` (a transport
355
+ `RemoteFailed`) and `worker_gone` (chi web answers, no live worker, not stopped) retry through
356
+ `RECONNECT_DELAYS` (about a minute, P4-D5). A worker gone for `WAKE_AFTER` (3 s, so a restart's new worker isn't
357
+ raced) is woken once per outage with `POST …/wake` (P4-D9). `failure` keeps the last message for the closing line.
358
+ - `queues?`/`unqueues?`: the worker's `features`. `ingest_images`: `ImageStore.ingest` into a scratch dir here, then
359
+ `Remote#upload_images`; `close` (`AttachLauncher.run`'s `ensure`) removes the dir.
360
+ - `installed_version`: the wake's (that machine's). `default_model_name`: the wake's, then the one `GET …/worker`
361
+ names after a worker change (the loop forgets its copy on `worker_changed`). `parent_strategy`: `GET …/worker`'s,
362
+ read at report time (L4), the wake's when that fails.
363
+ - `completions`: `@path` (this machine's files: images are ingested here), no memory names (P4-D11).
364
+ - `via`: `Remote#invocation`, so every printed command reads `chi --remote NAME …` (`ChiInvocation#attach` and
365
+ `#resume` have their remote forms; `shows`/`open_it` name the attach too).
366
+ - The terminal's own commands through chi web: `/exit` is `WebBridgeClient#request_exit` (`POST …/exit`, this
367
+ TUI's `tui:` id); `/exit --delete` adds `Remote#delete(id, force: true)` and `/archive` `Remote#archive` once the
368
+ worker answered (their refusals the classes `SessionManager` raises here, a transport failure `Unreachable`);
369
+ `/stats` is `GET …/stats` and `/recap` `POST …/recap`. A failure the loop shows in its own words through a
370
+ remote: "(no metrics: …)", "(no recap: …)", the exit lines' "could not ask the worker to stop: …". A chi web
371
+ with `remote_attach` but without one of these routes (`serve_static`'s 404) reads as `RemoteTooOld`, so the line
372
+ carries `Remote.too_old_message`'s words.
373
+ - The join's reads swallow every failure (a debug log line): `saved_tool_records` / `saved_turn_record` are one
374
+ `GET …/turns/:turn_id/records` per join (the second call takes what the first read) and give `[]` / nil;
375
+ `children_counts` gives `Counts.none`.
376
+ - `HANDLED_COMMANDS` must list every `local: true` command shown in the attached TUI (`remote_link_spec`), so a new
377
+ terminal command can't silently fall through remotely.
378
+ - **The loop's lines.** `stream_retrying` → "(lost the connection to NAME; reconnecting…)", `stream_resumed` →
379
+ "(reconnected)". `stream_closed`, by reason: "The session was stopped on NAME. Resume it with: chi --remote NAME
380
+ --resume ID"; "Lost the session: <the auth or too-old words>"; "Lost the connection to NAME (connection refused).
381
+ Re-attach with: chi --remote NAME --attach ID"; "The session's worker on NAME is gone. Resume it with: …"; "The
382
+ session is gone from NAME (deleted)." A restart is one "chi> the session's worker restarted on chi X" (the epoch's
383
+ `worker_changed`, then the new Bridge's `reset`). The version line across machines (P4-D10,
384
+ `VersionLines.across_machines`): "chi> the session's worker runs chi W; NAME has I installed: chi --remote NAME
385
+ sessions restart ID" and "chi> this terminal runs chi T (NAME has I): chi update here".
386
+
387
+ ## Extension points (not built yet)
388
+
389
+ - **Remote attach** is built (*Remote attach*, above); its seams, for another kind of link:
390
+ - `TerminalUI::AttachLink` (terminal_ui/attach_link.rb): what `AttachedLoop` knows about *where* the session is,
391
+ beside its client: `live_worker` (`[client, nil]`, `[nil, nil]`, `[:gone, reason]`) and `switched(event)` for
392
+ the stream's rediscovery and a restart; `queues?` / `unqueues?`; `stats_metrics` (may raise `IOError`: the loop
393
+ shows a remote's message); `ingest_images`; the saved
394
+ records and `children_counts`; `delete_session`, `archive_session`; `installed_version`, `default_model_name`,
395
+ `parent_strategy`; `completions` (Tab's `@path` and `#memory`); `reconnect_delays`; and `via`, the
396
+ `ChiInvocation` every printed command is spelled with. `LocalLink` is this machine's; `AttachedLoop.new` makes
397
+ one of `client:` and its keywords, and takes `link:` instead.
398
+ - The connector: `AttachLauncher.run(connector:)` asks its `#connect` for the `AttachLink` instead of `.connect`
399
+ (which a local run still calls, returning a `BridgeClient`); a connector whose `#connect` answers nil is
400
+ `AttachLauncher::Error` (`NOTHING_CONNECTED`), never an attach here. Its `#client_id`, when it has one, names the
401
+ TUI (`tui:PID` otherwise). `report_unanswered` takes the link's `strategy:` and `via:`.
402
+ - The client the loop and `BridgeClient::EventStream` use: `follow`, `post_turn`, `post_command`, `answer`,
403
+ `dismiss_question`, `dequeue`, `cancel`, `request_exit`, `request_recap`, `session_id`, and for the stream
404
+ `connect_stream(query:, last_event_id:, timeout:)` and `worker_key` (a new worker is a client with another key;
405
+ a Bridge's is its port). `BridgeClient::StreamRequest` writes the stream's GET (host, port, path, extra headers)
406
+ and reads its headers, for a stream through chi web too.
407
+ - `RunOptions.parse(argv)` is bin/chi's one run-flag parser (`RunOptions.switches` lists its flags), so a remote
408
+ branch can take the parsed options before bin/chi's local checks; `LaunchMode.attached_conflict` and
409
+ `LaunchMode::SCRATCH_LEFTOVER` / `BRIDGE_LATE` are the words both would print.
410
+
411
+ An https chi web (a pod behind a proxy) needs TLS and chunked decoding in `StreamRequest`.
412
+ - **Shared sessions (guests, roles).** The only access point is `App#token_gate`: one token, the owner's. Roles would
413
+ attach there and to the routes. The claude-peer / codex-peer inbox leg (chi → this machine's Claude or Codex) would
414
+ need a relay through chi web.
415
+ - **Invariants to keep:** Bridges stay loopback, and remote access goes through chi web. A forwarded `client_id` can
416
+ only tighten. One condition has one message on both backends: the route sends `message` plus the class's fields, and
417
+ `Remote` raises the same class. A route's own 404 never looks like a missing route.
418
+
419
+ ## Known gaps
420
+
421
+ - A prompt typed while a remote stream reconnects is usually accepted (chi web's `/turn` resumes the worker, else
422
+ writes an input file) where a local one fails "could not send the prompt". A POST whose reply is lost reads as
423
+ "could not send", yet ran: sending it again runs it twice (no idempotency key yet).
424
+
425
+ - Through a remote the pair scripts (`bundles/{claude,codex}-peer/scripts/pair`) detect `CHI_REMOTE` or `--remote` in
426
+ `CHI` the way Dispatch does, and attach only `*-outbox`: chi there can't reach this machine's Claude socket or Codex
427
+ thread, so chi's replies reach Claude only when relayed.
428
+ - The peer skills (`skill_claude_peer`, `skill_codex_peer`) have no remote recipe. The `/claude` card says "not paired" for
429
+ an outbox-only (remote) pair, because it reads `claude-inbox`.
@@ -31,11 +31,14 @@ same-size edit in the same second goes unseen (APFS keeps nanoseconds).
31
31
 
32
32
  ## The projection: `WorkingSet.build`
33
33
 
34
- `Samagotchi::WorkingSet.build(conversation, cwd:, strategy:, stat: File.method(:stat))` (`lib/samagotchi/working_set.rb`)
34
+ `Samagotchi::WorkingSet.build(conversation, cwd:, strategy:, stat: File.method(:stat), pins: [])` (`lib/samagotchi/working_set.rb`)
35
35
  is pure and returns a `WorkingSet::Snapshot` (`items`, `at`: the conversation's size in entries, the web's ordering
36
36
  key). Each `WorkingSet::Item` is `kind` (`file`, `memory`, `url`), `key`, `state` (`live`, `stale`, `forgotten`,
37
- `dropped`), `tokens`, `ops`, `count` and
38
- `first_id` (the first run's ToolIds id).
37
+ `dropped`), `tokens`, `ops`, `count`,
38
+ `first_id` (the first run's ToolIds id), `pinned` (the session's `working_set_pins` name it, whatever the
39
+ strategy: the page shows the mark only under forget, so a switch shows it at once) and `has_drop` (any of its content
40
+ outputs holds the user's applied drop, whatever the state: an undrop has something to take back). The Engine's event and the GET
41
+ both pass the session's pins.
39
42
 
40
43
  **Runs.** It pairs the runs twice with `LLMContextStale.paired_runs`: once on the stored conversation, once on what
41
44
  `LLMContextView.new(strategy:).messages(conversation)` sends (stubs keep their `[name]` lead, so they still pair).
@@ -75,14 +78,16 @@ only edited or written is never forgotten.
75
78
  **Dropped** is a forgotten item whose content outputs all hold the user's applied `:drop` (the user's layer, sent
76
79
  under every strategy: [llm-context-forget.md](llm-context-forget.md)), so it is dropped under any strategy and after
77
80
  a switch. An item with one output dropped and another forgotten by the model is forgotten. Under none with no drop
78
- saved the build doesn't pair the sent conversation at all (the view returns the stored Array).
81
+ saved the build doesn't pair the sent conversation at all (the view returns the stored Array). Such a mixed item
82
+ (one output dropped, another stubbed by the stale layer, or still sent) has `has_drop`: the listing says `part
83
+ dropped` and the page offers its undrop.
79
84
 
80
85
  **Stale** is a file item whose latest run left a stamp, and whose file now differs from that stamp in mtime or size,
81
86
  or is gone. No stamp, live. A forgotten item stays forgotten whatever its file did.
82
87
 
83
88
  **An item's outputs.** `WorkingSet.outputs_of(conversation, kind:, key:, strategy:, cwd:)` answers an item's
84
89
  content outputs (`WorkingSet::Output`: `id`, the tool_response entry's `index`, `derived` for a legacy entry's id,
85
- and `sent`, the rule above) in conversation order. It walks the runs exactly as `build` does (one shared
90
+ `sent`, the rule above, and `shared_with`, the other names a `memory_read` of several holds there) in conversation order. It walks the runs exactly as `build` does (one shared
86
91
  `all_touches`), so what acts on an item's outputs and the projection never disagree: an item is forgotten exactly
87
92
  when it has outputs and none is `sent`. An edit-only item, a failed run's file or an unknown key has none; a
88
93
  `memory_read` of several names is an output of each name.
@@ -92,7 +97,8 @@ when it has outputs and none is `sent`. An edit-only item, a failed run's file o
92
97
 
93
98
  ## Delivery
94
99
 
95
- **The event.** The Engine emits `{type: :working_set_updated, items:, at:}` (`Engine#emit_working_set`):
100
+ **The event.** The Engine emits `{type: :working_set_updated, items:, at:}` (and `adds:` while the user's adds are
101
+ pending: [Steering: add and unadd](#steering-add-and-unadd)) (`Engine#emit_working_set`):
96
102
  - after each `:generation_started`, in `build_stream_event_handler`, from the running turn's conversation
97
103
  (`Engine#live_conversation`, read on the turn's thread) under the turn's strategy
98
104
  (`@kernel.turn_settings.llm_context`). Both loops start a generation after `apply_llm_context!`, so the set is
@@ -100,14 +106,14 @@ when it has outputs and none is `sent`. An edit-only item, a failed run's file o
100
106
  - once in `Engine#end_turn`, after `replace_session_messages` and before the end event, from the kept messages (the
101
107
  turn-end apply has run by then);
102
108
  - between turns, from `Engine#announce_working_set` (the session's messages, under the event lock; after a
103
- `/working-set` op): announced
109
+ `/working-set` op, and after an `/llm-context` change: the states are the next turn's strategy's): announced
104
110
  (`working_set_updated` is in `ANNOUNCEABLE_EVENTS`; held after a command's `command_ran` inside
105
111
  `holding_announcements`), under the *next* turn's strategy (`SessionModelSettings#turn_llm_context`, as the
106
112
  turn-end warm-up reads it; `TurnSettings` keeps the last turn's, stale after `/llm-context` or `/model`). While a
107
113
  turn runs, the strategy is always the turn's.
108
114
 
109
- It is emitted only when the set differs from the last one emitted for the session (kept per session id), and never
110
- while the set is empty, so a tool-free turn on a fresh session emits nothing. One exception: when the last one had
115
+ It is emitted only when the set (its items and adds) differs from the last one emitted for the session (kept per
116
+ session id), and never while the set is empty (no items, no adds), so a tool-free turn on a fresh session emits nothing. One exception: when the last one had
111
117
  items and the set is empty now (a rollback removed the tool calls), one empty event goes out, so the page drops the
112
118
  stale items; it is remembered, so the next empty request sends none. It goes out whatever the web's flag;
113
119
  the Bridge (`ClientEvent.project`) passes it on unchanged, and the attached TUI ignores it.
@@ -147,7 +153,8 @@ conversation again) and `Engine#announce_working_set` re-emits the set, held beh
147
153
  nothing to drop (only edited or written; only a legacy entry's derived ids; none of it sent now); already
148
154
  dropped; not dropped. `forget_outputs` uses the same words for a dropped output ("dropped by the user"): the
149
155
  model can neither forget nor restore it. A `memory_read` of several names is one output of each: dropping one
150
- name drops that read for the others too.
156
+ name drops that read for the others too (an op acts on the output whole). `WorkingSet::Output#shared_with` names the
157
+ others an output holds and `Done#shared_with` the ones an op took along; the reply says them.
151
158
  - **The note**: the ops since the last turn are one tail system message, `kind: working_set` (in
152
159
  `Prompt::ESCAPED_KINDS`), its ops in `working_set_ops`. It is pending while only system messages follow it
153
160
  (`WorkingSetSteer.pending_note_index`); a later op joins it, and an inverse op (an undrop of a pending drop) takes
@@ -155,6 +162,52 @@ conversation again) and `Engine#announce_working_set` re-emits the set, held beh
155
162
 
156
163
  The projection shows a dropped item as `dropped` (below), the GET and the event alike.
157
164
 
165
+ ## Steering: pin and unpin
166
+
167
+ `/working-set pin KEY…` and `unpin KEY…` run the same way (`mid_turn: :queue`, `WorkingSetCommand`), but a pin is
168
+ the session's, not an edit: `Session#working_set_pins` (a `WorkingSetPin` per item, `{kind, key}` as the projection
169
+ names it), a `SessionSetup` field, so a respawn (the file), a continue (`SessionSetup.of`) and a plugin's fork
170
+ (`host.setup`) keep it; a delegate child starts without. It changes nothing sent, so no cache break.
171
+
172
+ - **What it guards**: the model's `forget_outputs` (`LLMContextForget`, the `pinned:` callable `KernelLoop` hands it:
173
+ `WorkingSet.pinned_ids` of the pins, the projection's pairing, so later reads of the item are covered). See
174
+ [llm-context-forget.md](llm-context-forget.md). Not stale's stubs: a superseded read's newer read is sent.
175
+ - **`WorkingSetSteer.pin`** refuses an item not in the set and one already pinned (an edit-only item may be pinned:
176
+ its later reads are covered). Pin and drop exclude each other: `drop` (given the pins) refuses a pinned item
177
+ (`unpin_first`); `pin` takes a pending drop off first (an `undrop`, free, in `Done#undone`) and refuses a sent one
178
+ (`undrop_first`). **`unpin`** asks only the pins (a rollback may have taken the item out of the set). The command
179
+ puts the new list in place (`WorkingSetSteer.pinning`) and saves the session.
180
+ - **The note**: a pin or an unpin joins the turn-start note only when the strategy the set is built under has the
181
+ forget layer (the model has no `forget_outputs` otherwise), or to cancel the inverse op the note lists. The reply's
182
+ `changed` is `[:working_set_pins]`, plus `:messages` when the note (or a pending drop) changed; the rollback window
183
+ closes only for `:messages`. Either way `Engine#announce_working_set` re-emits the set.
184
+ - **The projection**: `WorkingSet.build(…, pins:)` sets each item's `pinned`, whatever the strategy; the page shows it
185
+ only under forget (below).
186
+
187
+ ## Steering: add and unadd
188
+
189
+ `/working-set add KEY…` and `unadd KEY…` run the same way (`mid_turn: :queue`, `WorkingSetCommand`), but an add
190
+ changes nothing sent: it is a request in the turn-start note (D1 (a) of the plan), so the model runs the real
191
+ `read` / `memory_read` / `web_fetch` and the output is an ordinary tool run (stamps, ids, forget, stale, the
192
+ projection), on both loops. It costs one model step; a model may skip it (the note asks, chi doesn't run the tool).
193
+
194
+ - **`WorkingSetSteer.add`** refuses an item already added (pending), a URL not http(s) (`web_fetch` fetches only
195
+ those), what the caller's `found` look says can't be read (`WorkingSetCommand#missing`: a file not there or a
196
+ directory; a memory name `memory_read` rejects or that neither scope holds), an item holding the user's drop
197
+ (`undrop_first`: undrop brings it back without a step), and one the model has whole (`already_sent`: live, a read
198
+ of it sent). A stale or forgotten item may be added: that is the web row's `refresh`. **`unadd`** takes a pending
199
+ add back (free); without one it is refused (`not_added`).
200
+ - **The note**: an add always joins it (`the user added lib/tax.rb, memory style to your context: before anything
201
+ else, load them with read "/abs/lib/tax.rb", memory_read "style"`); a file is named by its absolute path in the
202
+ call, since a `read` resolves a relative path against the process's folder. An unadd cancels its add there.
203
+ - **The projection**: the pending adds are not items (nothing of them is sent), but the snapshot carries them:
204
+ `WorkingSet.pending_adds` reads the pending note (`WorkingSet.pending_note_index` / `pending_ops`, which
205
+ `WorkingSetSteer` delegates to) into `Snapshot#adds` (`WorkingSet::Add`: `kind`, `key`, `label`), and `to_h` adds
206
+ `adds:` while any is pending. `Engine#emit_working_set` compares the items and the adds, so an add announces a new
207
+ set and the next turn's first request (the note sent, no adds) emits again, which clears the page's pending add.
208
+ A running turn's set has none (the note was sent when it started), so the Bridge snapshot's `working_set` and the
209
+ GET's `running` answer leave it out.
210
+
158
211
  ## The page's overlay
159
212
 
160
213
  The page keeps its row-derived items and their `at` ordinals (the step tags' jump path), and overlays the server's
@@ -195,8 +248,39 @@ rail's line key carries the label's state and its forgotten count, which are pat
195
248
  command route (`createWorkingSet`'s `sendLine`). Pending ops are the queued lines (`command_queued` with `waits:
196
249
  "turn_end"`, any client's, and the snapshot's `queued_commands` replay after a reload), parsed and netted by
197
250
  `working_set.js pendingSteers` until each one's `command_ran`; a seed clears them (the replay brings them back).
198
- A steer line names a file by its absolute path, a memory as `memory:NAME`, a URL by the server's full key. `dropped`
251
+ A steer line names a file by its path relative to the session's folder (`state().dir`, the folder the worker resolves
252
+ it against) when inside it, else by its absolute path, so the command's bubble reads as the list does; the page keys
253
+ items by absolute path and reads a relative word of any client's line against the folder. A memory is `memory:NAME`, a URL by the server's full key; a key
254
+ with whitespace is quoted (`quoteWord`), and `steerWords` reads a line as the worker's `WorkingSetSteer.words` does
255
+ (`"…"` or `'…'` keeps its spaces, a backslash escapes the quote or itself). `dropped`
199
256
  is in `STATES`: struck like forgotten, and a group is `dropped` when all its members are.
257
+ `overlay` carries the server's `pinned` and `has_drop` (as `hasDrop`; `undroppable`): a forgotten item holding a
258
+ drop offers only `undrop`, a live one `drop` and `undrop`, and neither offers `pin` (the worker asks for the undrop
259
+ first). A row offers `pin` only when the strategy the session's `llm ctx` chip names
260
+ has forget (`working_set.js forgetOn`, `pinnable`: not a muted, dropped or preloaded-only item). A `command_ran` that carries
261
+ `llm_context` redraws the open list (`strategyChanged`), so its pins come and go with the strategy at once. A pinned row shows
262
+ `📌` and `unpin` under any strategy (its drop is greyed, `dropRefusal`: "pinned by the user: unpin it first", the
263
+ exception's words, so it must keep a way out). A pending row hides its tokens and step tag (its pill and undo take their room) and its ops text gives way before
264
+ the name. A pending pin or unpin is dashed with `applies next turn`, and
265
+ `pendingSteers` nets it with its inverse; a pin queued after a drop stands for both (the worker's pin takes the
266
+ pending drop off). A drop queued after a pin, or of a pinned item, is one the worker refuses (`unpin_first`), so it
267
+ is never pending: the row keeps the pin's pill and undo (a pinned row its 📌 and a greyed drop with the refusal's
268
+ words).
269
+ The rail stays read-only but marks a line with a pending op: dashed, its title adding `drops next turn` (or `pinned
270
+ next turn`…; `working_set_rail.js pendingText`, a group's from its members). The view runs its `onServer` listeners
271
+ on a change of the pending ops too, so the stage redraws the rail with `pending()`.
272
+
273
+ **+ add from the page.** The list's foot is a form (`.ws-add`, the sheet's last row on a phone): one KEY as typed,
274
+ posted as `working_set.js addLine` (quoted when it has spaces). A stale row, or a forgotten one without the user's
275
+ drop (`refreshable`; undrop brings that back without a step), offers `refresh`: `/working-set add` of it. An add is
276
+ pending from two sources: the server's snapshot `adds` (an add made between turns runs at once, so no line is
277
+ queued; `serverAddIds`, passed to `pendingSteers` as the ops before any queued line) and a queued line mid-turn. An
278
+ `unadd` nets either; one with no add pending is never pending (the worker refuses it). An added item the session's
279
+ set doesn't hold is a row of its own under `adding · N` (`addedItems`: `+` glyph, `.added`, the server's label, the
280
+ same pill/undo and phone strip as any pending row); one it holds (a refresh) gets the `reads next turn` pill on its
281
+ row. The next turn's first request emits a set without `adds`, which clears them; a model that skips the read
282
+ leaves nothing behind. The rail adds a dashed `+ name · next turn` line per pending add its turn's set doesn't hold
283
+ (`working_set_rail.js addLabels`, inside the rail's budget), and `read again next turn` on a held line's title.
200
284
 
201
285
  **Looks** (design spec §3, §5): a stale line is faded (`opacity: .55`); a forgotten one is struck in `--text-faint`
202
286
  at `opacity: .5`. A group shows `✂n` for its forgotten members. It is struck only when all its members are