samagotchi 0.101.0 → 0.103.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -1
- data/bin/chi +31 -196
- data/docs/architecture.md +62 -6
- data/docs/cli.md +154 -7
- data/docs/configuration.md +32 -5
- data/docs/guardrails.md +2 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +25 -1
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +174 -15
- data/docs/plugins.md +32 -5
- data/docs/sessions.md +38 -14
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +7 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +33 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +138 -15
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +24 -8
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +186 -118
- data/lib/samagotchi/session.rb +48 -16
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +53 -6
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +15 -10
- data/lib/samagotchi/sessions_command.rb +42 -16
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +387 -79
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +6 -4
- data/lib/samagotchi/web/public/index.html +37 -3
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +7 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +247 -24
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +198 -25
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +132 -20
- data/lib/samagotchi/working_set_command.rb +222 -0
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +414 -0
- metadata +23 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
|
@@ -0,0 +1,303 @@
|
|
|
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. The
|
|
5
|
+
sessions, workers, memories and code stay on that machine; this one only sends requests and prints the answers.
|
|
6
|
+
User-facing behaviour is in [cli.md](../cli.md#remote-sessions). This page is the code's shape, for changing it.
|
|
7
|
+
|
|
8
|
+
**The one rule:** the remote side is that machine's **chi web**, never a worker's Bridge. Bridges stay on loopback.
|
|
9
|
+
chi web answers each request by calling the same `SessionAccess::Local` operation the CLI would call there.
|
|
10
|
+
|
|
11
|
+
## The seam: `SessionAccess`
|
|
12
|
+
|
|
13
|
+
Each session command takes an `access:` backend (default `SessionAccess::Local`) and sends every session read or write
|
|
14
|
+
through it. Both backends have the same methods and raise the same errors with the same words.
|
|
15
|
+
|
|
16
|
+
| Piece | File | Role |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `SessionAccess` | `lib/samagotchi/session_access.rb` | The module: value objects and error classes (below) |
|
|
19
|
+
| `Local` | `session_access/local.rb` | This machine: the `Session`, `SessionManager`, `ReplyWait`, `NoteDelivery`, `ContextSources` and `BridgeClient` calls the commands used to make, moved here |
|
|
20
|
+
| `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 |
|
|
21
|
+
| `HTTPTransport` | `session_access/http_transport.rb` | `Net::HTTP`, one connection per call, `Authorization: Bearer <token>`; returns `HTTPTransport::Reply` (`status`, `body`, `json`, `#error`) |
|
|
22
|
+
| `Dispatch` | `session_access/dispatch.rb` | bin/chi's front for `--remote` / `CHI_REMOTE` |
|
|
23
|
+
| `Contract` | `session_access/contract.rb` | Which commands, subcommands and flags are `:supported` remotely and which are `:local_only` |
|
|
24
|
+
| `Remotes` | `lib/samagotchi/remotes.rb` | `remotes.yml`: `Remotes::Entry` (`name`, `url`, `token`, `#label`) |
|
|
25
|
+
| `RemoteCommand` | `lib/samagotchi/remote_command.rb` | `chi remote add / ls / rm` |
|
|
26
|
+
| `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`) |
|
|
27
|
+
|
|
28
|
+
The value objects (`Data.define`, no hashes across files) are `NewSession` (what `send --new` asks for), `SetupWords`
|
|
29
|
+
(the `--new` setup flags as typed, for the server to check), `SessionView` (`Remote#show`; `Local#show` returns the
|
|
30
|
+
`Session` itself, which has the same fields but `owner`: both backends answer `#owner(id)`), `Created` (`Remote#create`; Local returns the `Session`), `WaitStart` (the
|
|
31
|
+
pre-send state for a wait), `ContextEntry` and `ContextPush`. `Remote` also returns the existing callee types
|
|
32
|
+
(`SessionManager::DeliverResult`, `NoteDelivery::Result`, `ReplyWait::Result`, `BridgeClient::Response`,
|
|
33
|
+
`SessionManager::Restarted`) so that the commands can't tell the backends apart. A command checks `@access.remote?` where the backends must
|
|
34
|
+
differ.
|
|
35
|
+
|
|
36
|
+
### Dispatch: choosing the backend
|
|
37
|
+
|
|
38
|
+
`bin/chi` calls `Dispatch.from(ARGV)` **before** its `SUBCOMMANDS` lookup, because `--remote` comes before the
|
|
39
|
+
subcommand:
|
|
40
|
+
|
|
41
|
+
- It strips any leading `--remote NAME` / `--remote=NAME` words (if there are several, the last one wins). A missing,
|
|
42
|
+
blank or dash-led name raises `Dispatch::Usage` (exit 2). Then it reads `CHI_REMOTE` (trimmed). The flag beats the
|
|
43
|
+
env. There is no default remote.
|
|
44
|
+
- `here?`: `chi remote …` (`ALWAYS_HERE`), and a bare `--version` / `-h` / `--help`, run locally as usual.
|
|
45
|
+
- `#run`: an unknown command exits 1. `#refusal` is the single local-only message, `chi <words> runs on this machine
|
|
46
|
+
only: run it on <remote>` (exit 2). `<words>` comes from `Contract`: a `MACHINE` command, an unlisted command, a
|
|
47
|
+
`:local_only` command, subcommand or long flag, or a run flag (`RUN_FLAGS`: `--attach`, `--resume`, `-p`,
|
|
48
|
+
`--shared`, `--no-shared`; with no command at all it reads "(a session in this terminal)").
|
|
49
|
+
- Otherwise it calls `Remotes.find`, then `Samagotchi.const_get(klass).new(argv, access: Remote.new(entry)).run`.
|
|
50
|
+
`Remotes::Invalid` (including `NotFound`) exits 2. A `RemoteFailed` that escapes the command prints
|
|
51
|
+
`chi <cmd>: <message>` and exits 1.
|
|
52
|
+
|
|
53
|
+
### Contract
|
|
54
|
+
|
|
55
|
+
`Contract::TABLE` maps `"command"`, `"command sub"` and either one followed by a long flag (`"context add --cmd"`) to
|
|
56
|
+
`SUPPORTED` or `LOCAL_ONLY`. A `LOCAL_ONLY` command or subcommand lists no flags. `MACHINE` holds bin/chi's
|
|
57
|
+
non-session commands (`bootstrap update models desktop memory bundle remote`). `spec/session_access_contract_spec.rb`
|
|
58
|
+
reads bin/chi's `SUBCOMMANDS` and each command's `FLAGS`. It fails when a subcommand, a subcommand of a supported
|
|
59
|
+
command, or a flag of a supported command is missing from both lists, or when the table lists something that doesn't
|
|
60
|
+
exist. **A new session command or flag fails that spec until you choose one.**
|
|
61
|
+
|
|
62
|
+
## One request, end to end
|
|
63
|
+
|
|
64
|
+
`chi --remote studio send ab12 -m "fix it" --wait`:
|
|
65
|
+
|
|
66
|
+
1. **bin/chi → Dispatch.** The flag is removed. `send` and `send --message` / `--wait` are `SUPPORTED`. `Remotes.find("studio")`
|
|
67
|
+
reads `$XDG_CONFIG_HOME/samagotchi/remotes.yml`. Then `SendCommand.new(argv, access: Remote.new(entry)).run`.
|
|
68
|
+
2. **SendCommand.** The client-side checks (UI-only slash commands, image ingest) run. The machine-local ones are
|
|
69
|
+
skipped (next section). `run_wait` → `ParentWait#resolve` → `Remote#resolve`.
|
|
70
|
+
3. **Remote → HTTPTransport.** The first request on a `Remote` is preceded by `check_server!`, which sends
|
|
71
|
+
`GET /api/info` and needs `app: "chi-web"` plus the `session_commands` feature. Next come
|
|
72
|
+
`GET /api/sessions/resolve?id=ab12` and `POST …/wait/prepare {busy: true, queue:}` (`Remote#busy`), then
|
|
73
|
+
`POST …/wait/prepare {turn, created}` for the `WaitStart`. `deliver` calls `Remote#show` and `#owner` (each
|
|
74
|
+
`GET /api/sessions/:id?brief=1`), then
|
|
75
|
+
`POST …/turn {prompt, client_id: "cli:send", history: false}`. A 202 ack becomes a `DeliverResult` with `via`
|
|
76
|
+
(`bridge` or `file`).
|
|
77
|
+
4. **chi web.** `Web::App#route` runs the Host check (`local_host_header?`), then `cross_site?`, then in LAN mode for a
|
|
78
|
+
non-loopback peer `token_gate`. Then comes the `ROUTES` match. An id that isn't `Session.valid_id?` gets a 404
|
|
79
|
+
before any handler runs. A `:session` route turns an `ArgumentError` into its unknown-session 404. The handlers in
|
|
80
|
+
`SessionAccessRoutes` call `session_access`, a `SessionAccess::Local` on chi web's own state dir. The page's older
|
|
81
|
+
routes (`/turn`, `/answer`, `/stop`, …) call `SessionManager` and the Bridge directly, as before.
|
|
82
|
+
5. **The wait.** `ParentWait#wait_for_reply` → `Remote#wait_reply` loops on `POST …/wait` with
|
|
83
|
+
`timeout: min(time left, WAIT_SLICE)` (30 s). The server runs `ReplyWait.call` for at most `WAIT_MAX_SECONDS` (30 s)
|
|
84
|
+
and returns the `ReplyWait::Result` fields plus `parent_strategy`. On a `timeout` status the client asks again until
|
|
85
|
+
its own `--timeout` runs out, at which point it builds the `timeout` result itself. A 503 `server_stopping` (chi web
|
|
86
|
+
shutting down mid-wait: `ReplyWait`'s `cancelled:` returns `:canceled`) is retried after `RETRY_AFTER` (1 s).
|
|
87
|
+
`HTTPTransport::READ_TIMEOUT` (75 s) is set above the longest answer, a wait slice or a create's wait for its worker.
|
|
88
|
+
6. **The report.** `ParentReport.report` prints it as it does locally. It uses `Remote#parent_strategy`, which
|
|
89
|
+
is the last wait's `parent_strategy` (the server's config.yml tightened by the session's own), or `HARD` if no wait
|
|
90
|
+
has returned one. The exit codes match the local ones (3 for a question, 130 for Ctrl-C). The commands it prints
|
|
91
|
+
are spelled by `via:`, the backend's `invocation` (a `ChiInvocation`): `chi --remote NAME answer …` remotely, and
|
|
92
|
+
what runs here only (`--attach`, `--resume`) gives way to "chi web on NAME". The commands pass it to each text that
|
|
93
|
+
prints a command (`ParentReport`, `SendCommand::Busy#message`, `sessions stop`, `AnswerCommand`).
|
|
94
|
+
|
|
95
|
+
The routes, their bodies and their refusals are tabled in [architecture.md](../architecture.md), *chi web's routes for
|
|
96
|
+
the session commands*. Keep that table in step with `SessionAccessRoutes`.
|
|
97
|
+
|
|
98
|
+
## Errors: one condition, one message
|
|
99
|
+
|
|
100
|
+
- **Refusals.** `SessionAccessRoutes#refusal_response` (and the page routes that raise) send
|
|
101
|
+
`{error, detail, message, …fields}`: `detail` is the page's wording, `message` is the exception's own text, and the
|
|
102
|
+
fields are those the class is built from (`session_id`, `reason`, `ids`, `busy_id`, `version`).
|
|
103
|
+
`Remote#raise_refusal` rebuilds the class that Local's callee raises: `ArgumentError`, `Session::AmbiguousId`,
|
|
104
|
+
`SessionManager::OwnedByTUI` / `DeleteRefused` / `ArchiveRefused` / `RestartRefused`, `SessionContinue::Refused`,
|
|
105
|
+
and `ModelProfile::MissingModel` for `invalid_model` (no model there, or `--model`'s host unknown there: "could not
|
|
106
|
+
start a session: …", exit 1, as here).
|
|
107
|
+
Checks the client would make locally come back as `SessionAccess::Refused` (`parent_answers_refused`,
|
|
108
|
+
`parent_continue_refused`, `no_vision`, exit 1) or `BadRequest` (the other `invalid_*`, `missing_fields`, `bad_images`,
|
|
109
|
+
exit 2, the usage-error path). That is for the routes `raise_refusal` reads (create, uploads, …). `POST …/turn` is
|
|
110
|
+
mapped in `Remote#deliver_turn`: any 4xx but 404 and `owned_by_tui` becomes a refused `DeliverResult` with the
|
|
111
|
+
server's `ack`, which `SendCommand` prints as `<id> refused: <detail>` (`refused_word`, `REMOTE_REFUSALS`), exit 1.
|
|
112
|
+
A per-method mapping covers the rest (`Remote#deliver_turn`, `#note`, `#context_push`, `#context_remove`,
|
|
113
|
+
`#bridge_reply` for answer/dismiss: chi web's 503 `not_live` or 404 `not_found` → nil, i.e. no worker; 504 → the
|
|
114
|
+
Bridge's 408; 501 → its 404). Anything unmapped becomes `RemoteFailed` "remote NAME (URL) answered STATUS code:
|
|
115
|
+
message".
|
|
116
|
+
- **Transport.** `HTTPTransport#call` turns socket errors into `RemoteFailed` "remote studio (http://…): connection
|
|
117
|
+
refused" (also: no answer within 5 s, a read timeout, can't find host, host unreachable, TLS, a broken connection).
|
|
118
|
+
`ParentWait#run` catches `RemoteFailed` so that `--format json` still prints its error object. Dispatch catches it for
|
|
119
|
+
the other commands.
|
|
120
|
+
- **Auth.** `Remote.auth_failure`: a 401 → "the token was refused (… chi remote add NAME URL --token-file FILE)". A 403
|
|
121
|
+
`forbidden` (the Host check) → "chi web answers its LAN IP only: use the LAN IP <ip>, not a hostname", with the IP
|
|
122
|
+
taken from the 403's detail. `chi remote add` uses the same words through `Remote.info_problem`.
|
|
123
|
+
- **Version skew.** The check is by feature, not by version: `/api/info`'s `features` must include `session_commands`
|
|
124
|
+
(`SessionAccessRoutes::FEATURE`, mirrored as `Remote::FEATURE` so chi web needn't be loaded). If it's missing,
|
|
125
|
+
`RemoteTooOld` says "remote NAME runs chi X, too old for this command through --remote: update chi there (chi
|
|
126
|
+
update)": no release number, which would be a guess before the release. A 404 counts as a missing route
|
|
127
|
+
only when its detail starts with `not found: /` (the 404 from `serve_static`, for a path no route takes). A route's
|
|
128
|
+
own 404 (no such session or source) stays a refusal. **Keep that rule:** a new route's 404 must never use the
|
|
129
|
+
`not found: /…` wording.
|
|
130
|
+
|
|
131
|
+
## Which checks run where
|
|
132
|
+
|
|
133
|
+
Some checks read this machine's config or files, which would be wrong against a remote. Most are marked in the code
|
|
134
|
+
with a "Machine-local" comment; the rest branch on `@access.remote?` (`SendCommand#run_wait`'s busy check,
|
|
135
|
+
`#remote_new_options`, `#run_new`'s `capped_note`, `ContextCommand#add_url`).
|
|
136
|
+
|
|
137
|
+
| Check | Local (in the command) | Under `--remote` |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| 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` |
|
|
140
|
+
| 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 |
|
|
141
|
+
| `--parent-answers` looser than config.yml (`Guardrails::ParentPolicy.capped_note`) | client | server: the create's `parent_answers_warning` |
|
|
142
|
+
| 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 |
|
|
143
|
+
| What a parent may pick, for the report (`ParentReport.strategy_for`) | client | server: the wait route's `parent_strategy` |
|
|
144
|
+
| `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:)`) |
|
|
145
|
+
| `--continues`' previous link and model (`SendCommand#continued_model`) | client | server: `handle_continue` |
|
|
146
|
+
| `--toolkit`, `--tool`, `--set` (`Toolkit.from_flags`, `SessionConfig.from_flags`) | client | server: sent as `SetupWords` → 400 `invalid_*` → exit 2 |
|
|
147
|
+
| `--dir` (default: the cwd) | client, `File.directory?` | required and absolute on the client (`remote_new_options`); chi web's `scope_dir` checks it exists |
|
|
148
|
+
| `sessions list`'s default project (`ProjectScope.root_for(Dir.pwd)`) | client | every project's; `--scope=project` needs `--cwd` (a folder there) |
|
|
149
|
+
| `context`'s default session (`SAMAGOTCHI_PARENT_SESSION`, `ContextCommand#inside_session`) | client | no default: ids are required (`added_by` still reads it) |
|
|
150
|
+
| A context URL's provider (`ContextProviders.resolve`) | client's bundles | server's bundles (`Remote#context_add_url`) |
|
|
151
|
+
| 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) |
|
|
152
|
+
|
|
153
|
+
## Trust model
|
|
154
|
+
|
|
155
|
+
- **The token is the owner.** In LAN mode (`web.host: lan`) every non-loopback request needs the `Web::Token` access
|
|
156
|
+
token, as the `chi_token` cookie or `Authorization: Bearer` (`App#token_gate`). Passing the gate grants everything,
|
|
157
|
+
because no route checks roles (`local_viewer?` only hides `editor_url`). This runs over plain http, which is fine on
|
|
158
|
+
a home LAN, the same as the phone page. A loopback chi web without LAN mode needs no token
|
|
159
|
+
(`Remotes::Entry#token` is nil, and no header is sent).
|
|
160
|
+
- **The token at rest.** `remotes.yml` is written through `AtomicFile.write(perm: 0o600)`. `Entry#inspect` / `#to_s`
|
|
161
|
+
redact the token. `chi remote ls` never prints it (JSON shows `token: true|false`). `HTTPTransport#inspect` and
|
|
162
|
+
`Remote#inspect` show only the label.
|
|
163
|
+
- **The Host check.** `local_host_header?` accepts only the loopback names and the exact LAN IP. `http://studio.local`
|
|
164
|
+
gets a 403 `forbidden`, which the client words as "use the LAN IP". The check guards against DNS rebinding.
|
|
165
|
+
`cross_site?` lets CLI clients through because they send no `Origin` or `Sec-Fetch-Site`.
|
|
166
|
+
- **`client_id` only tightens.** `handle_question_answer` forwards `client_id` only when it is `ClientId::CLI_ANSWER`
|
|
167
|
+
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
|
|
168
|
+
create and turn, a `cli:send` / `cli:answer` id adds checks (`CLI_SEND_IDS`) and never removes any.
|
|
169
|
+
- **Context push is an inbound path.** A remote `context push` writes text that can wake a session. It carries the same
|
|
170
|
+
trust as a local `chi context push` on that machine, the owner's token ([guardrails.md](../guardrails.md)). A remote
|
|
171
|
+
`context add --cmd` runs its command on the session's machine.
|
|
172
|
+
- **Guardrails see through `--remote`.** The `chi-context-cmd` and `chi-broadcast` regexes in
|
|
173
|
+
`bundles/guardrails/guardrails/rules.yml` allow `--remote NAME` / `--remote=NAME` (quoted, continued) between `chi`
|
|
174
|
+
and the subcommand. `chi-answer-piped` already allowed any words there, and `chi-answer-http` matches any
|
|
175
|
+
curl/wget/http/xh line naming `/answer`, chi web's route included. `CHI_REMOTE=…` leaves the command words unchanged. Specs:
|
|
176
|
+
`spec/guardrails/shipped_bundle_spec.rb`.
|
|
177
|
+
|
|
178
|
+
## Testing
|
|
179
|
+
|
|
180
|
+
- `spec/support/remote_backend.rb`: `RemoteBackend.both(self) { … }` runs a group's examples once per backend
|
|
181
|
+
(`backend` is `:local` or `:remote`). The command gets `access: backend_access(state_dir)`. On the remote side,
|
|
182
|
+
`RackTransport` calls `Web::App#call` in-process on the same state dir, in LAN mode, from another peer, with the LAN
|
|
183
|
+
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
|
|
184
|
+
stubs belong to the server too. Used in the send, send `--new`, answer, note, context, sessions list/archive and
|
|
185
|
+
delete specs.
|
|
186
|
+
- An example tagged `local_only: "why"` is skipped on the remote side, with the reason. Use this tag only when the
|
|
187
|
+
words or the check are genuinely this machine's (`PARENT_CHECK`, `--project (Contract)`, image ingest, …), never to
|
|
188
|
+
hide a divergence.
|
|
189
|
+
- `spec/session_access_contract_spec.rb` (the table), `spec/session_access_dispatch_spec.rb`,
|
|
190
|
+
`spec/session_access_remote_spec.rb`, `spec/session_access/http_transport_spec.rb`, `spec/remotes_spec.rb`,
|
|
191
|
+
`spec/remote_command_spec.rb`, `spec/web/session_access_routes_spec.rb`, and the pair-script specs in
|
|
192
|
+
`spec/samagotchi/bundles/{claude,codex}_peer_pair_spec.rb`.
|
|
193
|
+
- A remote attach: `spec/session_access/web_bridge_client_spec.rb` (the mapper's rows and `Unreachable`, through
|
|
194
|
+
`RackTransport`; the stream's request bytes, a chunked reply), `spec/bridge_client/event_stream_options_spec.rb`
|
|
195
|
+
(the idle timeout, which a local follow never sets; epoch_watch; retries; reasons), and one end-to-end spec,
|
|
196
|
+
`spec/remote_attach_stream_spec.rb`: a real `Bridge` behind `Web::App` on WEBrick at 127.0.0.1:0
|
|
197
|
+
(`spec/support/web_server.rb`) followed through a `WebBridgeClient` (join, a turn, a restart, a stop). It runs
|
|
198
|
+
over loopback, so chi web's token gate is skipped there; `spec/web/lan_auth_spec.rb` covers the gate on the
|
|
199
|
+
stream, wake and worker routes.
|
|
200
|
+
- By hand: the smoke-run skill (`.claude/skills/smoke-run`, kept in the checkout, not in git) covers chi web.
|
|
201
|
+
It has no remote recipe yet. The real lane is laptop → Studio over the LAN: `chi remote add`, `send --new --wait`,
|
|
202
|
+
an exit-3 `answer`, `context push`, `note`, then `CHI_REMOTE=studio sh scripts/pair <id>` from the laptop's Claude.
|
|
203
|
+
|
|
204
|
+
## Remote attach (the plumbing)
|
|
205
|
+
|
|
206
|
+
What a remote attached TUI (`chi --remote NAME --attach ID`, not wired yet) will run on. The TUI still talks to a
|
|
207
|
+
worker's Bridge interface; chi web relays each request to that Bridge on loopback.
|
|
208
|
+
|
|
209
|
+
**chi web's routes** (tabled in [architecture.md](../architecture.md)): `POST …/wake` (`Local#wake`: the live worker
|
|
210
|
+
as is, else `SessionManager.resume_session` and `BridgeClient.wait_for`, at most `WAKE_MAX_WAIT` 10 s, the attach's
|
|
211
|
+
`BRIDGE_WAIT` here; a leftover scratch session is refused in `LaunchMode::SCRATCH_LEFTOVER`'s words, a late Bridge
|
|
212
|
+
in `LaunchMode::BRIDGE_LATE`'s) and `GET …/worker` (`Local#worker`: live, the stop marker, the owner; it never starts
|
|
213
|
+
one), both answering the worker as `{epoch, started_at, version, features}`; `POST …/turn` takes `no_interrupt`
|
|
214
|
+
(`SessionManager.deliver_turn(no_interrupt:)`, to the Bridge or into the input file); `POST …/command`'s 501 for a
|
|
215
|
+
builtin the worker lacks names it (`command`). `/api/info`'s feature `remote_attach`
|
|
216
|
+
(`SessionAccessRoutes::ATTACH_FEATURE`, mirrored as `Remote::ATTACH_FEATURE`) says a chi web has them.
|
|
217
|
+
|
|
218
|
+
**One worker identity.** The Bridge's epoch (`Bridge#epoch`, the `<seq>-<epoch>` in every event id) is in its sidecar
|
|
219
|
+
(`WorkerSidecar#epoch`; an older worker's reads nil, and the client keys it by `started_at` instead). A local
|
|
220
|
+
`BridgeClient` is keyed by its port; a remote one stays one object and its `worker_key` changes.
|
|
221
|
+
|
|
222
|
+
**`SessionAccess::WebBridgeClient`** (`session_access/web_bridge_client.rb`, built by `Remote#attach_client(id,
|
|
223
|
+
worker:)` from a wake's or worker read's `worker`): `post_turn` (`no_interrupt`, `history: false`), `post_command`,
|
|
224
|
+
`answer`, `dismiss_question`, `dequeue`, `cancel`, `follow`, `connect_stream`, `session_id`, `worker_key`, `port`
|
|
225
|
+
(nil). One-shots go through `Remote#route_request` (the server check, the auth and missing-route mapping), a new
|
|
226
|
+
connection each, with a 45 s read timeout (chi web's 30 s Bridge read plus its wait for a waking Bridge; 75 s for
|
|
227
|
+
answer and dismiss, which chi web sends twice on a 408). Every reply goes through **one status mapper**,
|
|
228
|
+
`WebBridgeClient::Mapping.response(route, reply, …)`:
|
|
229
|
+
|
|
230
|
+
| chi web's answer | What the loop gets |
|
|
231
|
+
|---|---|
|
|
232
|
+
| 503 `not_live`, 404 `not_found` | raises `Unreachable` (< `IOError`): "no live worker for session ID on NAME" (`NO_WORKER`) |
|
|
233
|
+
| 504 (any) | `Response(408, {error: deadline_passed})`: the loop's late line |
|
|
234
|
+
| 501 with `command` (`/command`) | `Response(400, {error: unknown_command, command})`: the loop names the command |
|
|
235
|
+
| any other 501 | `Response(404, {error: not_found})`: the stale-worker line |
|
|
236
|
+
| anything else | unchanged: 202 acks, 409s, a route's own 404 (`unknown_enqueued_id`), 403 `parent_answer_refused`, 502 |
|
|
237
|
+
|
|
238
|
+
Every `RemoteFailed` (a transport failure, `RemoteTooOld`, a 401 or a 403 `forbidden`) becomes `Unreachable` with its
|
|
239
|
+
message, so a loop that rescues `IOError` around Bridge calls never crashes on one mid-session.
|
|
240
|
+
|
|
241
|
+
**The stream.** `connect_stream` writes `GET /api/sessions/:id/stream…` through `BridgeClient::StreamRequest` (the
|
|
242
|
+
GET a local stream writes, with `Authorization: Bearer` and `Last-Event-ID`; `Host` is the entry's host and port).
|
|
243
|
+
chi web proxies the Bridge's bytes unchanged (under WEBrick a hijacked body is raw, `Connection: close`). No chunked
|
|
244
|
+
decoding in v1: a `Transfer-Encoding: chunked` reply (a proxy in between) and an https entry are logged
|
|
245
|
+
(`stream_refused`, `chunked_unsupported` / `tls_unsupported`) and read as unreachable; decoding and TLS plug into
|
|
246
|
+
`StreamRequest`. `follow` starts `EventStream` with three options a local stream never sets (`BridgeClient#follow`
|
|
247
|
+
passes none):
|
|
248
|
+
- `idle_timeout:` 40 s (`STREAM_IDLE_TIMEOUT`: two of the Bridge's 15 s pings plus margin): no byte for that long is a
|
|
249
|
+
half-open connection (Wi-Fi gone, a sleeping laptop), reconnected with the cursor.
|
|
250
|
+
- `epoch_watch:` a frame whose id carries another epoch than the cursor's is a new worker answering the old cursor:
|
|
251
|
+
`worker_changed {"port" => nil, "epoch" => new}` comes before it (the `reset` the new Bridge sends), and the
|
|
252
|
+
client's `worker_key` is updated in place. A restart reaches the stream this way because `Bridge#stop` removes its
|
|
253
|
+
sidecar before it ends the open streams: the reconnect finds no sidecar, chi web waits for the next worker's
|
|
254
|
+
(`await_bridge_port`) and proxies the cursor to it. A sidecar still naming the dead port would get an empty 200
|
|
255
|
+
(the proxy's connects refused), and the stream would move by rediscovery (`worker_changed`, then a `snapshot`).
|
|
256
|
+
- `announce_retries:` `stream_retrying` once a reconnect fails, `stream_resumed` at the next frame.
|
|
257
|
+
|
|
258
|
+
`EventStream` compares a rediscovered client's key with the key it last followed, not with the client object's, so a
|
|
259
|
+
rediscovery that updates the same client in place is a move. Rediscovery may give a reason with its answer
|
|
260
|
+
(`[nil, reason]` while it retries, `[:gone, reason]` to stop, `AttachLink#live_worker`'s shape), and `stream_closed`
|
|
261
|
+
carries the last one (`unreachable` without). `LocalLink#live_worker` gives `stopped` for a stopped session, but
|
|
262
|
+
`AttachedLoop#live_worker` passes only the client on, so the local closing line is unchanged.
|
|
263
|
+
|
|
264
|
+
## Extension points (not built yet)
|
|
265
|
+
|
|
266
|
+
- **Remote attach.** `--attach`, `-p` and `--resume` are refused today (`Dispatch::RUN_FLAGS`). The seams it plugs into
|
|
267
|
+
are in place:
|
|
268
|
+
- `TerminalUI::AttachLink` (terminal_ui/attach_link.rb): what `AttachedLoop` knows about *where* the session is,
|
|
269
|
+
beside its client: `live_worker` (`[client, nil]`, `[nil, nil]`, `[:gone, reason]`) and `switched(event)` for
|
|
270
|
+
the stream's rediscovery and a restart; `queues?` / `unqueues?`; `stats_metrics`; `ingest_images`; the saved
|
|
271
|
+
records and `children_counts`; `delete_session`, `archive_session`; `installed_version`, `default_model_name`,
|
|
272
|
+
`parent_strategy`; `completions` (Tab's `@path` and `#memory`); `reconnect_delays`; and `via`, the
|
|
273
|
+
`ChiInvocation` every printed command is spelled with. `LocalLink` is this machine's; `AttachedLoop.new` makes
|
|
274
|
+
one of `client:` and its keywords, and takes `link:` instead.
|
|
275
|
+
- The connector: `AttachLauncher.run(connector:)` asks its `#connect` for the `AttachLink` instead of `.connect`
|
|
276
|
+
(which a local run still calls, returning a `BridgeClient`); a connector whose `#connect` answers nil is
|
|
277
|
+
`AttachLauncher::Error` (`NOTHING_CONNECTED`), never an attach here. Its `#client_id`, when it has one, names the
|
|
278
|
+
TUI (`tui:PID` otherwise). `report_unanswered` takes the link's `strategy:` and `via:`.
|
|
279
|
+
- The client the loop and `BridgeClient::EventStream` use: `follow`, `post_turn`, `post_command`, `answer`,
|
|
280
|
+
`dismiss_question`, `dequeue`, `cancel`, `request_exit`, `request_recap`, `session_id`, and for the stream
|
|
281
|
+
`connect_stream(query:, last_event_id:, timeout:)` and `worker_key` (a new worker is a client with another key;
|
|
282
|
+
a Bridge's is its port). `BridgeClient::StreamRequest` writes the stream's GET (host, port, path, extra headers)
|
|
283
|
+
and reads its headers, for a stream through chi web too.
|
|
284
|
+
- `RunOptions.parse(argv)` is bin/chi's one run-flag parser (`RunOptions.switches` lists its flags), so a remote
|
|
285
|
+
branch can take the parsed options before bin/chi's local checks; `LaunchMode.attached_conflict` and
|
|
286
|
+
`LaunchMode::SCRATCH_LEFTOVER` / `BRIDGE_LATE` are the words both would print.
|
|
287
|
+
|
|
288
|
+
The plumbing below (*Remote attach*) is built; `RemoteLink`, the connector and bin/chi's branch are not. chi web
|
|
289
|
+
still has no route for exit, recap, stats or a turn's saved records.
|
|
290
|
+
- **Shared sessions (guests, roles).** The only access point is `App#token_gate`: one token, the owner's. Roles would
|
|
291
|
+
attach there and to the routes. The claude-peer / codex-peer inbox leg (chi → this machine's Claude or Codex) would
|
|
292
|
+
need a relay through chi web.
|
|
293
|
+
- **Invariants to keep:** Bridges stay loopback, and remote access goes through chi web. A forwarded `client_id` can
|
|
294
|
+
only tighten. One condition has one message on both backends: the route sends `message` plus the class's fields, and
|
|
295
|
+
`Remote` raises the same class. A route's own 404 never looks like a missing route.
|
|
296
|
+
|
|
297
|
+
## Known gaps
|
|
298
|
+
|
|
299
|
+
- Through a remote the pair scripts (`bundles/{claude,codex}-peer/scripts/pair`) detect `CHI_REMOTE` or `--remote` in
|
|
300
|
+
`CHI` the way Dispatch does, and attach only `*-outbox`: chi there can't reach this machine's Claude socket or Codex
|
|
301
|
+
thread, so chi's replies reach Claude only when relayed.
|
|
302
|
+
- The peer skills (`skill_claude_peer`, `skill_codex_peer`) have no remote recipe. The `/claude` card says "not paired" for
|
|
303
|
+
an outbox-only (remote) pair, because it reads `claude-inbox`.
|
|
@@ -31,10 +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
|
-
key). Each `WorkingSet::Item` is `kind` (`file`, `memory`, `url`), `key`, `state
|
|
37
|
-
`
|
|
36
|
+
key). Each `WorkingSet::Item` is `kind` (`file`, `memory`, `url`), `key`, `state` (`live`, `stale`, `forgotten`,
|
|
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.
|
|
38
42
|
|
|
39
43
|
**Runs.** It pairs the runs twice with `LLMContextStale.paired_runs`: once on the stored conversation, once on what
|
|
40
44
|
`LLMContextView.new(strategy:).messages(conversation)` sends (stubs keep their `[name]` lead, so they still pair).
|
|
@@ -71,32 +75,138 @@ strategy. A content output is still sent when:
|
|
|
71
75
|
A stale stub of an old read that a newer read superseded never strikes the file: the newer read is still sent. A file
|
|
72
76
|
only edited or written is never forgotten.
|
|
73
77
|
|
|
78
|
+
**Dropped** is a forgotten item whose content outputs all hold the user's applied `:drop` (the user's layer, sent
|
|
79
|
+
under every strategy: [llm-context-forget.md](llm-context-forget.md)), so it is dropped under any strategy and after
|
|
80
|
+
a switch. An item with one output dropped and another forgotten by the model is forgotten. Under none with no drop
|
|
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.
|
|
84
|
+
|
|
74
85
|
**Stale** is a file item whose latest run left a stamp, and whose file now differs from that stamp in mtime or size,
|
|
75
86
|
or is gone. No stamp, live. A forgotten item stays forgotten whatever its file did.
|
|
76
87
|
|
|
88
|
+
**An item's outputs.** `WorkingSet.outputs_of(conversation, kind:, key:, strategy:, cwd:)` answers an item's
|
|
89
|
+
content outputs (`WorkingSet::Output`: `id`, the tool_response entry's `index`, `derived` for a legacy entry's id,
|
|
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
|
|
91
|
+
`all_touches`), so what acts on an item's outputs and the projection never disagree: an item is forgotten exactly
|
|
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
|
|
93
|
+
`memory_read` of several names is an output of each name.
|
|
94
|
+
|
|
77
95
|
**Cost.** Two `paired_runs` walks, one view build and one stat per file item. A 500-message session builds in about
|
|
78
96
|
10 ms on an M5 Ultra and 75 ms on a CI runner; `spec/working_set_spec.rb` only guards against a blow-up (< 400 ms).
|
|
79
97
|
|
|
80
98
|
## Delivery
|
|
81
99
|
|
|
82
|
-
**The event.** The Engine emits `{type: :working_set_updated, items:, at:}` (`
|
|
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`):
|
|
83
102
|
- after each `:generation_started`, in `build_stream_event_handler`, from the running turn's conversation
|
|
84
103
|
(`Engine#live_conversation`, read on the turn's thread) under the turn's strategy
|
|
85
104
|
(`@kernel.turn_settings.llm_context`). Both loops start a generation after `apply_llm_context!`, so the set is
|
|
86
105
|
what that request sends;
|
|
87
106
|
- once in `Engine#end_turn`, after `replace_session_messages` and before the end event, from the kept messages (the
|
|
88
|
-
turn-end apply has run by then)
|
|
107
|
+
turn-end apply has run by then);
|
|
108
|
+
- between turns, from `Engine#announce_working_set` (the session's messages, under the event lock; after a
|
|
109
|
+
`/working-set` op, and after an `/llm-context` change: the states are the next turn's strategy's): announced
|
|
110
|
+
(`working_set_updated` is in `ANNOUNCEABLE_EVENTS`; held after a command's `command_ran` inside
|
|
111
|
+
`holding_announcements`), under the *next* turn's strategy (`SessionModelSettings#turn_llm_context`, as the
|
|
112
|
+
turn-end warm-up reads it; `TurnSettings` keeps the last turn's, stale after `/llm-context` or `/model`). While a
|
|
113
|
+
turn runs, the strategy is always the turn's.
|
|
89
114
|
|
|
90
|
-
It is emitted only when the set differs from the last one emitted for the session (kept per
|
|
91
|
-
while the set is empty, so a tool-free turn on a fresh session emits nothing.
|
|
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
|
|
117
|
+
items and the set is empty now (a rollback removed the tool calls), one empty event goes out, so the page drops the
|
|
118
|
+
stale items; it is remembered, so the next empty request sends none. It goes out whatever the web's flag;
|
|
92
119
|
the Bridge (`ClientEvent.project`) passes it on unchanged, and the attached TUI ignores it.
|
|
93
120
|
|
|
94
|
-
**The route.** `GET /api/sessions/:id/working_set` (`Web::App#handle_working_set`) answers the snapshot's `to_h
|
|
95
|
-
With a live worker it builds from the worker's snapshot messages (as `handle_show` reads them, keys
|
|
96
|
-
the worker's llm ctx chip. Without one it builds from the session file and its saved chip
|
|
97
|
-
(`SessionSetting::LLM_CONTEXT`). The folder is the session's `working_directory`.
|
|
98
|
-
|
|
99
|
-
|
|
121
|
+
**The route.** `GET /api/sessions/:id/working_set` (`Web::App#handle_working_set`) answers the snapshot's `to_h`
|
|
122
|
+
and `running`. With a live worker it builds from the worker's snapshot messages (as `handle_show` reads them, keys
|
|
123
|
+
symbolized) and the worker's llm ctx chip. Without one it builds from the session file and its saved chip
|
|
124
|
+
(`SessionSetting::LLM_CONTEXT`). The folder is the session's `working_directory`. `running` is true when the
|
|
125
|
+
snapshot has a turn in progress (`current_turn`).
|
|
126
|
+
|
|
127
|
+
**The GET while a turn runs.** A worker's snapshot messages are the session's, not the running turn's, so they lag
|
|
128
|
+
its events. The snapshot also carries `working_set`: the running turn's last `:working_set_updated` (`{items, at}`),
|
|
129
|
+
which the Bridge's `TurnAccumulator#working_set` folds from the event log. It is cleared at `:turn_started` and at
|
|
130
|
+
the turn's end, so it is nil between turns and before the turn emitted one. While a turn runs and it is set, the GET
|
|
131
|
+
answers it as is (no build, no stat): a page opened mid-turn gets the running turn's set at once, and its `at` is the
|
|
132
|
+
event's, so the page takes it (below). Before the turn's first event the turn's set is still the session's (the
|
|
133
|
+
Engine emits only a set that differs from the last one), and the GET builds from the messages as between turns; so
|
|
134
|
+
does one from an older worker without the field, its answer then behind the turn's events, which the page keeps.
|
|
135
|
+
|
|
136
|
+
## Steering: drop and undrop
|
|
137
|
+
|
|
138
|
+
`/working-set drop KEY…` and `undrop KEY…` (`WorkingSetCommand`, `SessionCommands#working_set`; the web's list posts
|
|
139
|
+
the same lines on `POST /api/sessions/:id/command`) act on the session's messages between turns: the line is
|
|
140
|
+
`mid_turn: :queue`, so one sent while a turn runs waits for its end (bare `/working-set`, the listing, is
|
|
141
|
+
`:anytime`). `WorkingSetSteer` does the work on a copy (`Engine#clone_messages`, then `LLMContextEdit.store`'s new
|
|
142
|
+
edits Hashes), which `Engine#replace_messages` puts in place; the session is saved, the rollback window closes
|
|
143
|
+
(`TurnFlow#note_conversation_changed`, as `!cmd`), the result says `changed: [:messages]` (the web reads the
|
|
144
|
+
conversation again) and `Engine#announce_working_set` re-emits the set, held behind the command's `command_ran`.
|
|
145
|
+
|
|
146
|
+
- **The outputs** are `WorkingSet.outputs_of` the item under the strategy the set is built under
|
|
147
|
+
(`Engine#working_set_strategy`): the ones still `sent` get an applied `:drop` (`by: "user"`), the edit each held
|
|
148
|
+
before kept in its `was:`. A drop's view is the user's layer ([llm-context-forget.md](llm-context-forget.md)).
|
|
149
|
+
The reply weighs the batch (`LLMContextApply.weigh`: what it frees, the tail the server reads again).
|
|
150
|
+
- **undrop** takes the item's drops off and puts each `was:` back. Free when the drop never reached a request (its
|
|
151
|
+
op is still in the pending note), else the reply says the tail.
|
|
152
|
+
- **Refusals** live on one exception, `WorkingSetSteer::Refused` (its `reason` and words): not in the working set;
|
|
153
|
+
nothing to drop (only edited or written; only a legacy entry's derived ids; none of it sent now); already
|
|
154
|
+
dropped; not dropped. `forget_outputs` uses the same words for a dropped output ("dropped by the user"): the
|
|
155
|
+
model can neither forget nor restore it. A `memory_read` of several names is one output of each: dropping one
|
|
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.
|
|
158
|
+
- **The note**: the ops since the last turn are one tail system message, `kind: working_set` (in
|
|
159
|
+
`Prompt::ESCAPED_KINDS`), its ops in `working_set_ops`. It is pending while only system messages follow it
|
|
160
|
+
(`WorkingSetSteer.pending_note_index`); a later op joins it, and an inverse op (an undrop of a pending drop) takes
|
|
161
|
+
both off, the note too when nothing is left. A turn that sends it makes it history; the next op starts a new one.
|
|
162
|
+
|
|
163
|
+
The projection shows a dropped item as `dropped` (below), the GET and the event alike.
|
|
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.
|
|
100
210
|
|
|
101
211
|
## The page's overlay
|
|
102
212
|
|
|
@@ -111,8 +221,19 @@ An item the server doesn't know yet (a running call) stays live. Known mismatch:
|
|
|
111
221
|
worker's `Dir.pwd` (a real path), the server against `working_directory`, so a session under `/tmp` can miss
|
|
112
222
|
(`/private/tmp`).
|
|
113
223
|
|
|
114
|
-
`working_set_view.js` keeps one server snapshot per session
|
|
115
|
-
|
|
224
|
+
`working_set_view.js` keeps one server snapshot per session; `working_set.js newerSnapshot` picks which. `at` can't
|
|
225
|
+
order them: a rollback (a canceled turn's `end_turn`, a failed turn's `TurnFlow` restore) shortens the conversation,
|
|
226
|
+
so the right set can have the smaller `at`. The page orders by what it saw instead:
|
|
227
|
+
- an event is always taken. The stream delivers them in the order the Engine built them, so the newest event is the
|
|
228
|
+
server's latest set, a rollback's smaller (or empty) one too;
|
|
229
|
+
- a GET's answer is dropped when an event came while it was on its way (the view counts the events it took and
|
|
230
|
+
compares the count at the ask with the count at the answer): the event may be newer;
|
|
231
|
+
- else it is taken, unless it is `running` and its `at` is smaller than the kept one's (it is behind the running
|
|
232
|
+
turn's events: a GET built from the messages). A `running` GET answered from the turn's last event has that
|
|
233
|
+
event's `at`, so it is taken. An idle GET after a rollback the page missed (a resync) or after a file changed wins at any `at`.
|
|
234
|
+
|
|
235
|
+
No server sequence: the GET is built in `chi web` from the worker's snapshot or the file, the events in the worker,
|
|
236
|
+
and a worker's event count starts over when it respawns; the page's own order needs neither. The page asks for it when it seeds a
|
|
116
237
|
session's set (load, resync) and when the list opens (a file changed while the session sat idle). The stream handler
|
|
117
238
|
passes `working_set_updated` to it, and does nothing when the flag is off. A new snapshot redraws:
|
|
118
239
|
- the session bar's chip and the open list;
|
|
@@ -123,6 +244,44 @@ passes `working_set_updated` to it, and does nothing when the flag is off. A new
|
|
|
123
244
|
A state is the item's state now, not when its turn ran. A state change never refolds and never moves a line: the
|
|
124
245
|
rail's line key carries the label's state and its forgotten count, which are patched in place.
|
|
125
246
|
|
|
247
|
+
**Steering from the page.** The list's `drop`/`undrop` (and `undo`, `undo all`) post `/working-set` lines on the
|
|
248
|
+
command route (`createWorkingSet`'s `sendLine`). Pending ops are the queued lines (`command_queued` with `waits:
|
|
249
|
+
"turn_end"`, any client's, and the snapshot's `queued_commands` replay after a reload), parsed and netted by
|
|
250
|
+
`working_set.js pendingSteers` until each one's `command_ran`; a seed clears them (the replay brings them back).
|
|
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`
|
|
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.
|
|
284
|
+
|
|
126
285
|
**Looks** (design spec §3, §5): a stale line is faded (`opacity: .55`); a forgotten one is struck in `--text-faint`
|
|
127
286
|
at `opacity: .5`. A group shows `✂n` for its forgotten members. It is struck only when all its members are
|
|
128
287
|
forgotten, and faded when all are stale or forgotten (`groupState`). The list's ops text says `stale` or `forgotten`
|
data/docs/plugins.md
CHANGED
|
@@ -780,15 +780,18 @@ The Engine itself is never handed to a plugin.
|
|
|
780
780
|
|
|
781
781
|
A bundle's settings are config.yml's, for every session. A setting a session may have of its own (a child's cap,
|
|
782
782
|
say) is one the manifest lists in `session_settings:`; `chi --set BUNDLE.KEY=VALUE` and `chi send --new --set …`
|
|
783
|
-
|
|
783
|
+
(which also take a few core keys, `--set recap.enabled=false`: see
|
|
784
|
+
[Per-session settings](configuration.md#per-session-settings)) then give it to a new session, saved with it (a respawn, a fork and a continued link keep it; a delegate child
|
|
784
785
|
doesn't). chi refuses a bundle that isn't installed (or whose plugin doesn't load) and a key the manifest doesn't
|
|
785
786
|
list, so a typo isn't saved. A manifest that types a key (`session_settings: {cap: number}`: `number`, which
|
|
786
787
|
takes off too, `boolean`, `string`, or `any`) gets only a value of that type: `--set my-bundle.cap=five` is refused,
|
|
787
788
|
exit 2, as a key it doesn't list is (a mapping needs a chi newer than 0.91.0). Values are typed simply: an integer (`3`, `-1`) or a decimal (`2.5`, `.5`, `1e1`)
|
|
788
789
|
is a number, `true`, `yes` and `on` are true, `false`, `no` and `off` are false (any case, as config.yml's YAML reads
|
|
789
790
|
them), and anything else stays the text as given (`010`, `0x10`, `3:30`, `y`, `null`; YAML would read the first
|
|
790
|
-
three as numbers and `null` as nothing); a key typed `string` keeps its text as given (`yes`, `5`, `1.10`); `POST /api/sessions` `
|
|
791
|
-
keeps JSON numbers and booleans.
|
|
791
|
+
three as numbers and `null` as nothing); a key typed `string` keeps its text as given (`yes`, `5`, `1.10`); `POST /api/sessions` `settings` (`{"my-bundle.cap": 3}`) types its strings the same way and
|
|
792
|
+
keeps JSON numbers and booleans. A name that is a core session setting is always chi's: a manifest whose
|
|
793
|
+
`session_settings:` key `K` makes `NAME.K` one (a bundle `recap` offering `enabled`) is refused at install, and
|
|
794
|
+
`chi bundle check` reports it as an `error clash`, in the same words. `ctx.settings` is the config's section with the session's values laid over it (one level: a session's
|
|
792
795
|
`cap` replaces the config's `cap`). Read such a key from `ctx.settings` where it is used, not in
|
|
793
796
|
`initialize(settings)`: that runs once at load, before any session, and gets the config only.
|
|
794
797
|
|
|
@@ -2191,6 +2194,18 @@ Claude pairs; without one the bundle costs one tool declaration.
|
|
|
2191
2194
|
it only within your task, and stop and tell you when Claude agrees or after
|
|
2192
2195
|
the last turn. Claude's skill ends each answer's summary with its verdict
|
|
2193
2196
|
and never answers with `chi send` (which counts as you typing).
|
|
2197
|
+
- **chi on another machine.** With the chi session on the Studio and Claude on
|
|
2198
|
+
the laptop, `CHI_REMOTE=studio sh <bundle>/scripts/pair <id>` pairs through
|
|
2199
|
+
the Studio's chi web ([Remote sessions](cli.md#remote-sessions)), and
|
|
2200
|
+
Claude's answers (its `chi context push claude-outbox`, with `CHI_REMOTE`
|
|
2201
|
+
set) reach chi: the outbox leg works. The inbox leg doesn't: `claude_send`
|
|
2202
|
+
writes to Claude's socket on the machine chi runs on, and the laptop's isn't
|
|
2203
|
+
there. So through a remote (`CHI_REMOTE`, or `--remote NAME` in `CHI`)
|
|
2204
|
+
`pair` attaches only `claude-outbox` and prints one line saying chi there
|
|
2205
|
+
can't send to this Claude: chi's replies reach Claude only when you relay
|
|
2206
|
+
them (or ask it to run `chi --remote studio send --wait <id>`). A session
|
|
2207
|
+
that already has a `claude-inbox` it can't reach:
|
|
2208
|
+
`chi --remote studio context rm claude-inbox <id>`.
|
|
2194
2209
|
- **Asking first.** The tool has no `targets:`, so guardrails match it by name
|
|
2195
2210
|
only: to be asked before each send, add a rule ([Rules in
|
|
2196
2211
|
config.yml](guardrails.md#rules-in-configyml)):
|
|
@@ -2268,8 +2283,9 @@ skill install card) works the same.
|
|
|
2268
2283
|
(absolute: config.toml doesn't expand `~`), and the pair script names it
|
|
2269
2284
|
when a write is refused ("Operation not permitted"). chi never edits
|
|
2270
2285
|
Codex's config. Reading a payload file needs nothing: the default sandbox
|
|
2271
|
-
reads anywhere.
|
|
2272
|
-
on them.
|
|
2286
|
+
reads anywhere. With chi on this machine no network is needed: a push
|
|
2287
|
+
writes files, and chi wakes on them. Through a remote it is the other way
|
|
2288
|
+
round (HTTP, not files): see **chi on another machine** below.
|
|
2273
2289
|
- **Pairing** is Codex's, as with Claude: `/codex install-skill` copies the
|
|
2274
2290
|
bundle's `scripts/chi-peer-SKILL.md` to
|
|
2275
2291
|
`$CODEX_HOME/skills/chi-peer/SKILL.md` (else `~/.codex/…`), with the
|
|
@@ -2290,6 +2306,17 @@ skill install card) works the same.
|
|
|
2290
2306
|
rule Codex follows, not something enforced: anything shaped like that
|
|
2291
2307
|
header is treated this way, and nothing stops a message from asking for
|
|
2292
2308
|
more.
|
|
2309
|
+
- **chi on another machine.** As with Claude, `CHI_REMOTE=studio sh
|
|
2310
|
+
<bundle>/scripts/pair <id>` (or `--remote NAME` in `CHI`) pairs through
|
|
2311
|
+
the Studio's chi web, and only the outbox leg works: `codex_send` runs
|
|
2312
|
+
`codex queue` on the machine chi runs on, where this Codex's thread isn't.
|
|
2313
|
+
So `pair` attaches only `codex-outbox` and prints one line saying chi there
|
|
2314
|
+
can't send to this Codex. Through a remote chi talks HTTP, not files, so
|
|
2315
|
+
`writable_roots` doesn't matter; Codex's sandbox may block the network
|
|
2316
|
+
instead, which pairing and every reply then need (workspace-write has none
|
|
2317
|
+
by default): when chi can't reach the
|
|
2318
|
+
remote, the pair script names `network_access = true` under
|
|
2319
|
+
`[sandbox_workspace_write]` as the likely fix.
|
|
2293
2320
|
- In the web, selecting text offers **Confirm with Codex**, which fills
|
|
2294
2321
|
`/codex validate` and the quote, as claude-peer's pill does.
|
|
2295
2322
|
- **The skill.** `skill_codex_peer` (system scope) is claude-peer's with
|