samagotchi 0.103.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -24
  5. data/docs/cli.md +86 -12
  6. data/docs/guardrails.md +2 -1
  7. data/docs/internals/context-telemetry.md +6 -2
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/participants.md +137 -0
  10. data/docs/internals/remote-sessions.md +153 -27
  11. data/docs/plugins.md +7 -1
  12. data/docs/sessions.md +4 -2
  13. data/docs/sub-agent.md +8 -0
  14. data/docs/testing.md +68 -4
  15. data/lib/samagotchi/bridge.rb +7 -3
  16. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  17. data/lib/samagotchi/bridge_client.rb +4 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_invocation.rb +13 -16
  20. data/lib/samagotchi/context_status.rb +15 -2
  21. data/lib/samagotchi/engine.rb +16 -6
  22. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_scheduler.rb +34 -9
  25. data/lib/samagotchi/kernel_loop.rb +8 -1
  26. data/lib/samagotchi/launch_mode.rb +4 -0
  27. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  28. data/lib/samagotchi/owner_lock.rb +65 -5
  29. data/lib/samagotchi/participants.rb +122 -0
  30. data/lib/samagotchi/remote_attach_command.rb +119 -0
  31. data/lib/samagotchi/run_options.rb +14 -2
  32. data/lib/samagotchi/send_command.rb +1 -1
  33. data/lib/samagotchi/session.rb +8 -4
  34. data/lib/samagotchi/session_access/contract.rb +42 -4
  35. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  36. data/lib/samagotchi/session_access/local.rb +52 -1
  37. data/lib/samagotchi/session_access/remote.rb +112 -16
  38. data/lib/samagotchi/session_access/web_bridge_client.rb +56 -1
  39. data/lib/samagotchi/session_access.rb +85 -3
  40. data/lib/samagotchi/session_continue.rb +1 -1
  41. data/lib/samagotchi/session_inbox.rb +18 -9
  42. data/lib/samagotchi/session_manager.rb +21 -5
  43. data/lib/samagotchi/sessions_command.rb +4 -4
  44. data/lib/samagotchi/share_command.rb +174 -0
  45. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  46. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +83 -20
  48. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  49. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  50. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  51. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  52. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  53. data/lib/samagotchi/terminal_ui.rb +4 -1
  54. data/lib/samagotchi/testing/waiting.rb +92 -9
  55. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +187 -74
  58. data/lib/samagotchi/web/grants.rb +302 -0
  59. data/lib/samagotchi/web/participant_access.rb +286 -0
  60. data/lib/samagotchi/web/principal.rb +29 -0
  61. data/lib/samagotchi/web/server.rb +3 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  63. data/lib/samagotchi/worker.rb +26 -5
  64. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c4a19357a9f5c94b3c76052abc018a034968ad8baf3520d50ace0ab581943b6
4
- data.tar.gz: ec637848fd8dc33b292d836618753699bc8159962c6051d7b9b669beb31eb50f
3
+ metadata.gz: f4a794e9e201d233ca72705b62746997d6ef702804e18ad8a5bb1f85851ed454
4
+ data.tar.gz: 341e3b0cfcbc4367faed3782d600b2d934395eef7623499619f018b1984a06db
5
5
  SHA512:
6
- metadata.gz: 5ca1a63b9c68e9b18b21fb2f2bf6503eb3cec3368e10410c4efce78dc5a853e842fc8ddd74bb5387d21f6b0d38c69f4abdb58a5320bf5c9e8272232ebf56234c
7
- data.tar.gz: 7c86172b9193fc68c5c639989573c17f99af9621d118624684ace1483628a01cde8e609c2438368c297e534043fb86a165e94511e34dfd880171d77f2a75eb57
6
+ metadata.gz: 20bdcd9c915786fe5698401079a36e676ace823d4e42625c5b75fd2472d9990b342f1cc9c23e2a609bffee3b57df88c4fb9b6c927cdf0e182c1f7966fc5fde37
7
+ data.tar.gz: 64d05e8f627bb8c628e3510681b7acf1de4c37995175333614094d00333f66b1de28d91a6b2819f005e4d1e5150c8ffd189f6c9e2379044b89268188086231e6
data/CHANGELOG.md CHANGED
@@ -8,6 +8,58 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.104.0] - 2026-10-23
12
+
13
+ ### Added
14
+
15
+ - Remote attach: `chi --remote NAME --attach ID` (and `--resume ID`, `-p TEXT`, or `--dir DIR` for a new session
16
+ there, that machine's absolute path) runs the attached TUI on another machine's session, through its chi web:
17
+ the same lines and exit codes, every printed command spelled with `--remote NAME`, `@path` images from this
18
+ machine, a "(reconnecting…)" / "(reconnected)" line for about a minute of reconnects, and the version line naming
19
+ both machines. `--non-interactive`, `--verbose`, `--no-shared`, `--memory`, `--mute` and
20
+ `--append-system-prompt` stay this machine's (exit 2). `--dir` without `--remote` exits 2. `/exit`,
21
+ `/exit --delete`, `/archive`, `/stats` and `/recap` work through a remote as here, the status row counts the
22
+ session's delegates there, and a join shows the steps' durations and a failed turn's line from that machine's
23
+ saved records. See [docs/cli.md](docs/cli.md#remote-sessions).
24
+ - Participants (experimental: identity only, the review itself comes in a later release): `chi share ID --name NAME
25
+ [--agent] [--expires 3d]` invites someone, or an agent, to review a session through chi web on the LAN with a link
26
+ of their own, printed once with a QR code (chi keeps only a digest of its token). `chi share ls [ID]` lists
27
+ participants and `chi share revoke ID [P] | --all` ends links; all three work through `--remote`. A link works for 7
28
+ days (1 hour to 30 days), until it is revoked, or until the session is archived or deleted. For now it opens a page
29
+ with the session's title and status; every other route answers it 403, and another session 404. chi web refuses to
30
+ start when another user can read its grants store. Through `--remote`, a revoked, expired or participant's token
31
+ gets its own message instead of "the token was refused". See [docs/cli.md](docs/cli.md#participants).
32
+ - The bundle test kit (experimental) waits with `wait_for(what) { … }`, which raises
33
+ `Samagotchi::Testing::Waiting::TimedOut` naming what it waited for (and, with `seen:`, what it last saw), plus
34
+ `join!` and `pop!` with the same message. Every helper deadline is multiplied by `SPEC_TIME_SCALE` (default 1).
35
+ See [docs/plugins.md](docs/plugins.md#testing-a-bundle-experimental).
36
+
37
+ ### Changed
38
+
39
+ - The bundle test kit's `wait_until!` is renamed `wait_for`, with no alias: a bundle spec that calls
40
+ `wait_until!` changes the name (same arguments).
41
+
42
+ ### Fixed
43
+
44
+ - `chi --attach ID` (and `--resume ID`, and `chi --remote NAME --attach ID`) right after `/exit`, while the leaving
45
+ worker still writes its recap, waits for it with `(waiting for the session to finish its recap…)` (up to
46
+ `recap.timeout` plus 5 s) and then attaches, instead of failing "did not start its Bridge in time". A Ctrl-C while
47
+ an attach connects exits 130 without a backtrace.
48
+ - `/queue TEXT` (or `/cut TEXT`) with no turn running says `(nothing is running: sent now)` in the REPL and the
49
+ attached terminal, instead of sending without a word.
50
+ - The ctx % no longer dips at the start of each turn on a chat host (`api: openai` / `anthropic`): the turn's first
51
+ estimate, made before the host counts anything, left out the tool schemas; it now uses the last count's chars per
52
+ token.
53
+ - Attached terminal: a command typed right after the text `/unqueue` put back in the input (`/unqueue` again,
54
+ `/queue TEXT`) runs as the command, with the text kept in the input, instead of going out joined to the text as
55
+ one prompt.
56
+ - `chi --remote NAME sessions stop` waits up to 10 s there for the worker to let go, as `chi sessions stop` does
57
+ here (chi web waited 2 s, so a slow worker read "still shutting down" more often); the page's Stop still waits 2 s.
58
+ - The terminal's status line, cut to the terminal's width, ends at a word with `…` instead of mid-word (in a pipe
59
+ too).
60
+
61
+ Update with `chi update`.
62
+
11
63
  ## [0.103.0] - 2026-10-23
12
64
 
13
65
  ### Added
@@ -3892,7 +3944,8 @@ and long-lived sessions.
3892
3944
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3893
3945
  hotkey panel that send selected text or the clipboard to your sessions.
3894
3946
 
3895
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.103.0...HEAD
3947
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.104.0...HEAD
3948
+ [0.104.0]: https://github.com/dm1try/samagotchi/compare/v0.103.0...v0.104.0
3896
3949
  [0.103.0]: https://github.com/dm1try/samagotchi/compare/v0.102.0...v0.103.0
3897
3950
  [0.102.0]: https://github.com/dm1try/samagotchi/compare/v0.101.0...v0.102.0
3898
3951
  [0.101.0]: https://github.com/dm1try/samagotchi/compare/v0.100.1...v0.101.0
data/bin/chi CHANGED
@@ -52,6 +52,8 @@ SUBCOMMANDS = {
52
52
  "send" => ["samagotchi/send_command", "SendCommand"],
53
53
  # answer the question a session waits on, then wait (--question, --option)
54
54
  "answer" => ["samagotchi/answer_command", "AnswerCommand"],
55
+ # invite participants to review a session, list them, revoke them
56
+ "share" => ["samagotchi/share_command", "ShareCommand"],
55
57
  # find a model server and write config.yml (--model, --name)
56
58
  "bootstrap" => ["samagotchi/bootstrap_command", "BootstrapCommand"],
57
59
  # bring an installed chi up to date
@@ -69,7 +71,10 @@ SUBCOMMANDS = {
69
71
  }.freeze
70
72
  # chi --remote NAME <command> (or CHI_REMOTE=NAME): the session commands
71
73
  # against another machine's chi web; what runs here only is refused (exit 2).
72
- # chi remote, --version and --help run here as always.
74
+ # chi remote, --version and --help run here as always. Run flags under a
75
+ # remote (--attach, --resume, -p, --dir: a session there in this terminal)
76
+ # are parsed below and go to RemoteAttachCommand before this machine's
77
+ # checks; the local-only ones are refused here.
73
78
  require "samagotchi/session_access/dispatch"
74
79
  begin
75
80
  remote_run = Samagotchi::SessionAccess::Dispatch.from(ARGV)
@@ -78,7 +83,12 @@ rescue Samagotchi::SessionAccess::Dispatch::Usage => e
78
83
  exit 2
79
84
  end
80
85
  ARGV.replace(remote_run.argv)
81
- exit remote_run.run(SUBCOMMANDS) if remote_run.remote? && !remote_run.here?
86
+ remote_attach = remote_run.remote? && !remote_run.here? && remote_run.run_flags?
87
+ if remote_attach && (refusal = remote_run.refusal)
88
+ warn "Error: #{refusal}"
89
+ exit 2
90
+ end
91
+ exit remote_run.run(SUBCOMMANDS) if remote_run.remote? && !remote_run.here? && !remote_attach
82
92
 
83
93
  if (path, klass = SUBCOMMANDS[ARGV[0]])
84
94
  require path
@@ -152,6 +162,29 @@ Samagotchi::Config.reload!(cli_overrides: cli_overrides) unless cli_overrides.em
152
162
  require "samagotchi/log"
153
163
  Samagotchi::Log.configure(level: options[:verbose] ? :debug : nil, mirror: options[:verbose])
154
164
 
165
+ # chi --remote NAME --attach ID (--resume ID, --dir DIR, -p TEXT): the
166
+ # attached TUI on that machine's session. Nothing below runs: it checks
167
+ # this machine's sessions, memories and files.
168
+ if remote_attach
169
+ unless ARGV.empty?
170
+ warn "Error: unknown command #{ARGV.first} (see chi --help)"
171
+ exit 1
172
+ end
173
+ require "samagotchi/remote_attach_command"
174
+ begin
175
+ entry = Samagotchi::Remotes.find(Samagotchi::Remotes.check_name!(remote_run.remote))
176
+ rescue Samagotchi::Remotes::Invalid => e
177
+ warn "Error: #{e.message}"
178
+ exit 2
179
+ end
180
+ exit Samagotchi::RemoteAttachCommand.new(options, access: Samagotchi::SessionAccess::Remote.new(entry)).run
181
+ end
182
+ # --dir names a folder on a remote; here the session starts in this one.
183
+ if options[:dir]
184
+ warn "Error: --dir goes with --remote; cd there instead"
185
+ exit 2
186
+ end
187
+
155
188
  # `chi web`: its flags (--port, --open, --web-view…) went through the parser above.
156
189
  options[:web] = true if web
157
190
 
@@ -340,6 +373,10 @@ if launch == :attached
340
373
  rescue Samagotchi::TerminalUI::AttachLauncher::Error => e
341
374
  warn "Error: #{e.message}"
342
375
  exit 1
376
+ rescue Interrupt
377
+ # Ctrl-C while it connects (a wait for a leaving worker, a late Bridge):
378
+ # no backtrace.
379
+ exit Samagotchi::CLI::Exit::INTERRUPTED
343
380
  end
344
381
  exit Samagotchi::TerminalUI::AttachLauncher.exit_status(ended)
345
382
  end
data/docs/architecture.md CHANGED
@@ -469,7 +469,8 @@ Every route goes through `Bridge#dispatch`: an id other than the bridge's own se
469
469
  The per-session port is OS-assigned (bound to `0`) and published to a `bridge.json` sidecar
470
470
  for client discovery. `chi web`'s `GET /api/sessions/:id/stream` proxies this bridge
471
471
  (503 `not_live` when the worker is not running; full history of any session is served by
472
- `GET /api/sessions/:id/output`). Resume/ring-buffer state is **in-memory**, per worker: a cursor from
472
+ `GET /api/sessions/:id/output`), as a byte pipe for the owner: `App#proxy_stream_body` gives `ProxyStreamBody` the
473
+ request principal's frame filter (`App#stream_filter`, nil for the owner, the only principal today). Resume/ring-buffer state is **in-memory**, per worker: a cursor from
473
474
  an earlier worker gets the `reset` snapshot, not a replay.
474
475
 
475
476
  **Session hub.** The cross-session layer (which sessions exist, who owns them, what changed)
@@ -510,35 +511,46 @@ chi web (the remote-sessions plan; `SessionAccess::Contract` says which commands
510
511
  cover most of it; `Web::SessionAccessRoutes` (`web/session_access_routes.rb`, in `App`'s `ROUTES`) adds the rest, each
511
512
  calling the `SessionAccess::Local` operation of the same name, so a remote backend maps 1:1 onto them. Every one is
512
513
  token-gated like the rest (LAN mode: `Authorization: Bearer`), and one that takes an id is a `:session` route (an id
513
- that isn't one, or no such session: 404 `not_found`). How a request flows end to end, which checks run where and the trust
514
+ that isn't one, or no such session: 404 `not_found`). Every `ROUTES` row also names who may use it, checked against
515
+ the request's `Web::Principal` (`App#principal_for`): `owner`, or `participant` for a route a participant may use too.
516
+ A participant (a `Web::Grants` token for one session) may use three: `GET /`, `GET /api/info` and `GET /api/sessions/:id`,
517
+ each answered as theirs; every other route is `owner` (Permission below, and the participants routes in
518
+ [internals/participants.md](internals/participants.md)); `spec/web/principal_spec.rb` fails on a row without one. How a request flows end to end, which checks run where and the trust
514
519
  model: [internals/remote-sessions.md](internals/remote-sessions.md).
515
520
 
516
- | Route | Body / params | Answer | Refusals |
517
- |---|---|---|---|
518
- | `GET /api/sessions/resolve?id=` | an id or a unique prefix | 200 `{id}` | 404 `not_found`; 400 `ambiguous_id` with `ids` |
519
- | `GET /api/sessions/summaries` | `dir` (a folder: its project; none: every project), `cwd`, `live=1`, `archived=1`, `tests=0`, `sort`, `order`, `limit` | 200: the rows `chi sessions list` prints (`SessionManager.session_summaries`: `status`, `live`, `busy`, `owner`, `recap`, `ctx_pct`, `waiting`, `waiting_id`, `title`, `preview`, …) | 400 `invalid_dir`, `invalid_cwd` |
520
- | `POST /api/sessions` (more fields) | `toolkit`, `tools`, `no_tools`, `parent_answers`; `client_id` (`cli:send`/`cli:answer`: the first line is checked as chi send checks it); `check_vision: true` (images follow) | 201 adds `model_warning` and `parent_answers_warning` (a level looser than config.yml's, capped) | 400 `invalid_toolkit`, `invalid_parent_answers`, `invalid_continues` (beside `continues`); 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
521
- | `POST /api/sessions/:id/turn` (more fields) | `client_id` as above; `check_vision: true` with `images` | as before | 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
522
- | `POST /api/sessions/:id/wait/prepare` | `question_id` (absent: the pending one; `null`: none), `turn`, `created`, `busy` (+ `queue`) | 200 `{session_id, cursor, baseline: {messages, question_id, last_turn}, own_turn, known_turn, running, live, settled, last_turn_id}` (`WaitStart`) | 409 `busy`, `waiting` (`SendCommand::Busy`'s words, and its fields: `question`, `parent_strategy`) |
523
- | `POST /api/sessions/:id/wait` | `cursor`, `baseline`, `turn`, `queued`, `ended_before`, `owner_grace`, `timeout` (capped at 30 s) | 200: `ReplyWait::Result`'s fields (`status`: `done`, `waiting_for_answer`, `no_reply`, `error`, `stopped`, `worker_gone`, or `timeout`: ask again; `text`, `file`, `question` as `PendingQuestion#to_file`, `outcome`, `limit`, `error_kind`, `retryable`, `cancel_reason`, `stopped_by`, `kept_steps`, `turn_id`, `origin`; never the session) and `parent_strategy` `{name, allowed, origin}` (`ParentReport.strategy_for`, this server's config.yml) | 400 `invalid_wait`; 503 `server_stopping` |
524
- | `POST /api/sessions/:id/notes` | `text`, `source` (default `cli`, never `broadcast`) | 200 `{status: queued \| waits, session_id, queued}` (`NoteDelivery`) | 409 `owned_by_tui` (`status: refused`); 400 `note_rejected`, `missing_fields`, `invalid_source` |
525
- | `POST /api/sessions/:id/context` (more bodies) | `{name, push: true, why, hint}` or `{name, cmd, every, why, hint}` beside the page's `{url, why, hint}`; `added_by` `cli`/`agent` (else `web`) | 201 `{status: attached, name}` | 400 `invalid_source`; 409 `exists` |
526
- | `POST /api/sessions/:id/context/:name/push` | `text`, `summary`, `wake`, `hint` (a source's JSON output, checked the same way) | 200 `{status: pushed, name, before, revision, changed}` | 404 `not_found`; 409 `not_push`; 400 `invalid_text` |
527
- | `DELETE /api/sessions/:id/context/:name?own=1` | | 200 `{status: detached}`: the session's own only | 409 `project_source` (chi context rm's words); 404 |
528
- | `POST /api/sessions/:id/stop` | | 200 `{status: stopped, session_id, released}`: `released`, whether the worker let go within `STOP_WAIT_SECONDS` (2 s) | 409 `owned_by_tui` |
529
- | `DELETE /api/sessions/:id?force=` | `force=true` stops a live worker first (the page sends it) | as before | 409 `worker_running` without it |
530
- | `GET /api/sessions/:id?brief=1` | | 200 `{brief, session: {id, status (the file's), title, display_title, project_root, working_directory, model_name, model_typed, parent_answers, owner}, pending_question}` (the file's; no messages): `Remote#show` | 404 |
531
- | `POST /api/sessions/:id/turn` (its 202) | | the ack with `via`: `bridge` or `file` (the input file: a remote `chi send --wait` follows that turn) | |
532
- | `GET /api/sessions/:id/context?full=1`, `…/context/:name?full=1` | | each row with `source` (`Source#to_h`, its command too; the page's rows never carry one), `snapshot` (`Snapshot#to_h`, no text) and `shadowed`, shadowed project sources included | |
533
- | `POST /api/sessions/:id/wake` | `wait` (seconds, default and most 10: `WAKE_MAX_WAIT`) | 200 `{session_id, woke, worker: {epoch, started_at, version, features}, model_name, default_model, parent_strategy, installed}`: the live worker as is (`woke: false`), else resumed and its Bridge waited for (`Local#wake`); `default_model` and `installed` are this machine's | 409 `owned_by_tui`, `scratch` (`LaunchMode::SCRATCH_LEFTOVER`); 504 `worker_timeout` (`LaunchMode::BRIDGE_LATE`); 400 `invalid_wait`; 404 |
534
- | `GET /api/sessions/:id/worker` | | 200 `{session_id, live, stopped, owner: worker \| tui \| null, worker: {epoch, started_at, version, features} \| null, parent_strategy}` (`Local#worker`; never starts one) | 404 |
535
- | `POST /api/sessions/:id/turn` (`no_interrupt`) | `no_interrupt: true`: the turn runs with the raised iteration limit (`SessionManager.deliver_turn(no_interrupt:)`, to the Bridge or the input file) | as before | |
536
- | `POST /api/sessions/:id/command` (its 501) | | | 501 `not_supported` for a builtin the worker lacks adds `command` (`"/title"`) and `message` |
521
+ | Route | Permission | Body / params | Answer | Refusals |
522
+ |---|---|---|---|---|
523
+ | `GET /api/sessions/resolve?id=` | owner | an id or a unique prefix | 200 `{id}` | 404 `not_found`; 400 `ambiguous_id` with `ids` |
524
+ | `GET /api/sessions/summaries` | owner | `dir` (a folder: its project; none: every project), `cwd`, `live=1`, `archived=1`, `tests=0`, `sort`, `order`, `limit` | 200: the rows `chi sessions list` prints (`SessionManager.session_summaries`: `status`, `live`, `busy`, `owner`, `recap`, `ctx_pct`, `waiting`, `waiting_id`, `title`, `preview`, …) | 400 `invalid_dir`, `invalid_cwd` |
525
+ | `POST /api/sessions` (more fields) | owner | `toolkit`, `tools`, `no_tools`, `parent_answers`; `client_id` (`cli:send`/`cli:answer`: the first line is checked as chi send checks it); `check_vision: true` (images follow) | 201 adds `model_warning` and `parent_answers_warning` (a level looser than config.yml's, capped) | 400 `invalid_toolkit`, `invalid_parent_answers`, `invalid_continues` (beside `continues`); 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
526
+ | `POST /api/sessions/:id/turn` (more fields) | owner | `client_id` as above; `check_vision: true` with `images` | as before | 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
527
+ | `POST /api/sessions/:id/wait/prepare` | owner | `question_id` (absent: the pending one; `null`: none), `turn`, `created`, `busy` (+ `queue`) | 200 `{session_id, cursor, baseline: {messages, question_id, last_turn}, own_turn, known_turn, running, live, settled, last_turn_id}` (`WaitStart`) | 409 `busy`, `waiting` (`SendCommand::Busy`'s words, and its fields: `question`, `parent_strategy`) |
528
+ | `POST /api/sessions/:id/wait` | owner | `cursor`, `baseline`, `turn`, `queued`, `ended_before`, `owner_grace`, `timeout` (capped at 30 s) | 200: `ReplyWait::Result`'s fields (`status`: `done`, `waiting_for_answer`, `no_reply`, `error`, `stopped`, `worker_gone`, or `timeout`: ask again; `text`, `file`, `question` as `PendingQuestion#to_file`, `outcome`, `limit`, `error_kind`, `retryable`, `cancel_reason`, `stopped_by`, `kept_steps`, `turn_id`, `origin`; never the session) and `parent_strategy` `{name, allowed, origin}` (`ParentReport.strategy_for`, this server's config.yml) | 400 `invalid_wait`; 503 `server_stopping` |
529
+ | `POST /api/sessions/:id/notes` | owner | `text`, `source` (default `cli`, never `broadcast`) | 200 `{status: queued \| waits, session_id, queued}` (`NoteDelivery`) | 409 `owned_by_tui` (`status: refused`); 400 `note_rejected`, `missing_fields`, `invalid_source` |
530
+ | `POST /api/sessions/:id/context` (more bodies) | owner | `{name, push: true, why, hint}` or `{name, cmd, every, why, hint}` beside the page's `{url, why, hint}`; `added_by` `cli`/`agent` (else `web`) | 201 `{status: attached, name}` | 400 `invalid_source`; 409 `exists` |
531
+ | `POST /api/sessions/:id/context/:name/push` | owner | `text`, `summary`, `wake`, `hint` (a source's JSON output, checked the same way) | 200 `{status: pushed, name, before, revision, changed}` | 404 `not_found`; 409 `not_push`; 400 `invalid_text` |
532
+ | `DELETE /api/sessions/:id/context/:name?own=1` | owner | | 200 `{status: detached}`: the session's own only | 409 `project_source` (chi context rm's words); 404 |
533
+ | `POST /api/sessions/:id/stop` | owner | `wait?` (seconds, capped at `SessionManager::STOP_WAIT`, 10: `chi --remote NAME sessions stop` sends it) | 200 `{status: stopped, session_id, released}`: `released`, whether the worker let go within the wait (without one `STOP_WAIT_SECONDS`, 2 s: the page's Stop) | 409 `owned_by_tui` |
534
+ | `DELETE /api/sessions/:id?force=` | owner | `force=true` stops a live worker first (the page sends it) | as before | 409 `worker_running` without it |
535
+ | `GET /api/sessions/:id?brief=1` | participant (theirs: title, status, participants) | | 200 `{brief, session: {id, status (the file's), title, display_title, project_root, working_directory, model_name, model_typed, parent_answers, owner}, pending_question}` (the file's; no messages): `Remote#show` | 404 |
536
+ | `POST /api/sessions/:id/turn` (its 202) | owner | | the ack with `via`: `bridge` or `file` (the input file: a remote `chi send --wait` follows that turn) | |
537
+ | `GET /api/sessions/:id/context?full=1`, `…/context/:name?full=1` | owner | | each row with `source` (`Source#to_h`, its command too; the page's rows never carry one), `snapshot` (`Snapshot#to_h`, no text) and `shadowed`, shadowed project sources included | |
538
+ | `POST /api/sessions/:id/wake` | owner | `wait` (seconds, default and most 10: `WAKE_MAX_WAIT`) | 200 `{session_id, woke, worker: {epoch, started_at, version, features}, model_name, default_model, parent_strategy, installed}`: the live worker as is (`woke: false`), else resumed and its Bridge waited for (`Local#wake`); `default_model` and `installed` are this machine's | 409 `owned_by_tui`, `scratch` (`LaunchMode::SCRATCH_LEFTOVER`); 504 `worker_timeout` (`LaunchMode::BRIDGE_LATE`); 400 `invalid_wait`; 404 |
539
+ | `GET /api/sessions/:id/worker` | owner | | 200 `{session_id, live, stopped, owner: worker \| tui \| null, worker: {epoch, started_at, version, features} \| null, default_model, parent_strategy}` (`Local#worker`; never starts one; `default_model` is this machine's) | 404 |
540
+ | `POST /api/sessions/:id/exit` | owner | `client_id` (passed on only when it is an attached TUI's, `tui:…`; any other is dropped), `delete` | 200 the worker's `{status: exiting, session_id, discard}` (`BridgeClient#request_exit`; never wakes one) | 409 `held` with the worker's `{status, reason, tasks, hint}`; 503 `not_live`; 501 `not_supported`; 504 `worker_timeout` |
541
+ | `POST /api/sessions/:id/recap` | owner | | 200 the worker's `{enabled, saved, request, min_user_turns, session_off}` (`BridgeClient#request_recap`) | 503, 501, 504 as every relay |
542
+ | `GET /api/sessions/:id/stats` | owner | | 200 `{session_id, metrics}`: the worker's stats, its state's metrics for a worker from before the stats route | 503 `not_live`; 504 `worker_timeout`; 502 `bridge_error` |
543
+ | `GET /api/sessions/:id/children/counts` | owner | | 200 `{running, waiting, unreported}` (`ChildrenStatus.counts`, the delegates' files here) | 404 |
544
+ | `GET /api/sessions/:id/turns/:turn_id/records` | owner | | 200 `{tool_records: […], turn_record: {…} \| null}` (`SessionMetrics.saved_tool_records` / `saved_turn_record`, as saved) | 400 `invalid_turn_id`; 404 |
545
+ | `POST /api/sessions/:id/turn` (`no_interrupt`) | owner | `no_interrupt: true`: the turn runs with the raised iteration limit (`SessionManager.deliver_turn(no_interrupt:)`, to the Bridge or the input file) | as before | |
546
+ | `POST /api/sessions/:id/command` (its 501) | owner | | | 501 `not_supported` for a builtin the worker lacks adds `command` (`"/title"`) and `message` |
547
+ | `GET`/`POST`/`DELETE /api/sessions/:id/participants`, `GET`/`DELETE /api/participants` | owner | `chi share`'s (`name`, `kind`, `ttl`; `?id=p_…`) | the grants, never a token but POST's own once ([internals/participants.md](internals/participants.md)) | 400 `invalid_participant`; 404 |
537
548
 
538
549
  `GET /api/info` names the feature `session_commands` (`SessionAccessRoutes::FEATURE`): `SessionAccess::Remote` asks it
539
550
  once before its first request, and a chi web without it (or `serve_static`'s 404 for a path no route takes) is "remote NAME runs chi X, too
540
551
  old for this command through --remote: update chi there (chi update)". The feature `remote_attach`
541
- (`SessionAccessRoutes::ATTACH_FEATURE`) names the wake and worker routes a remote attached TUI needs.
552
+ (`SessionAccessRoutes::ATTACH_FEATURE`) names the routes a remote attached TUI needs: wake, worker, exit, recap, stats,
553
+ children counts and a turn's records. Their refusals carry `{error, detail, message}`.
542
554
 
543
555
  `POST …/answer` passes on `client_id` only when it is chi answer's (`cli:answer`), so the worker holds the answer to
544
556
  its `guardrails.parent_answers`; any other is dropped, so a forwarded id can only tighten. The worker's 403
data/docs/cli.md CHANGED
@@ -22,7 +22,9 @@
22
22
  - `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending — to an idle session whose last turn already ended, that ended turn's reply or end comes back; `--wait --turn T ID` with no message waits for exactly turn T, whether running, queued or last ended; `--format json` includes its `turn_id` when known, for waiting again after exit 3 or 4; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
23
23
  - `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). What a parent may pick follows `guardrails.parent_answers`, or the session's own `parent_answers` when stricter (`soft`, the default: a deny, a plain answer, Continue and "Allow once"; `standard`: no allow; `hard`: no Continue either), never a wider scope or an allow on chi's own config; a refused pick exits 1 with what to answer instead (a convention, not a security boundary; see [Guardrails](guardrails.md#answers-from-a-parent-agent))
24
24
  - `chi remote add NAME URL [--token-file F | --token-stdin]`, `chi remote ls`, `chi remote rm NAME` — the other machines whose chi web the session commands reach with `--remote NAME` (see [Remote sessions](#remote-sessions))
25
- - `chi --remote NAME send|answer|note|context|sessions …` (or `CHI_REMOTE=NAME`) — those commands on that machine's sessions, through its chi web (see [Remote sessions](#remote-sessions))
25
+ - `chi share ID --name NAME [--agent] [--expires 3d]`, `chi share ls [ID]`, `chi share revoke ID [P] | --all` — invite a participant to review a session through chi web on the LAN (a link of their own, 7 days), list them, revoke them (see [Participants](#participants))
26
+ - `chi --remote NAME send|answer|note|context|sessions|share …` (or `CHI_REMOTE=NAME`) — those commands on that machine's sessions, through its chi web (see [Remote sessions](#remote-sessions))
27
+ - `chi --remote NAME --attach ID` (`--resume ID`, `--dir DIR [-p TEXT]`) — the attached TUI on a session of that machine (see [Remote sessions](#remote-sessions))
26
28
  - `chi memory ls [--scope project|system] [--format text|json]` — the memories of both scopes (this folder's project first), one row each: scope, name, size, date, `from <bundle>`, the index description and its model overlays; `chi memory rm NAME [--scope project|system]` removes one as `memory_write` with `remove: true` does: its file and model overlays go to the bundle trash (the line names the dir; `chi bundle trash` lists and empties it) and its index line is dropped. NAME in both scopes without `--scope` exits 2 naming both; a missing NAME or a bundle's memory (`chi bundle uninstall` removes it) exits 1 (see [Memory: Listing and removing from the shell](memory.md#listing-and-removing-from-the-shell))
27
29
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
28
30
  - `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
@@ -280,7 +282,8 @@ controls exit behavior (`--non-interactive`); `--resume` composes with both.
280
282
  | `--shared` | Run the session (new, or `--resume`'s) in a background worker and attach to it: the default, and the way to get it when `session.shared` is off. See [Sharing a session](#sharing-a-session). |
281
283
  | `--no-shared` | Run the plain in-process REPL for this run. |
282
284
  | `--attach SESSION_ID` | Attach to a session's worker, waking one if it has exited. |
283
- | `--remote NAME` | Before a session command (`chi --remote studio send …`): run it against that remote's sessions, through its chi web; beats `CHI_REMOTE`. Commands and flags that run on this machine only exit 2. See [Remote sessions](#remote-sessions). |
285
+ | `--remote NAME` | Before a session command (`chi --remote studio send …`) or the run flags (`chi --remote studio --attach ID`, `--resume ID`, `--dir DIR -p TEXT`): run it against that remote's sessions, through its chi web; beats `CHI_REMOTE`. Commands and flags that run on this machine only exit 2. See [Remote sessions](#remote-sessions). |
286
+ | `--dir DIR` | With `--remote`: a new session's folder on that machine, its absolute path (required for a new session there). Without `--remote` it exits 2: cd there instead. |
284
287
  | `--model NAME` | Use this model for the run (overrides the configured default and a resumed session's model). |
285
288
  | `--thinking LEVEL` | This session's own thinking level: `off`, `low`, `medium` or `high`, saved in the session and first in the order (`default` unsets it). On `--resume` or `--attach` it is set at once, through a `/thinking` on an attached worker. With `chi web` it is instead the default for the sessions its workers run, below a session's own (as env `SAMAGOTCHI_THINKING_LEVEL` is). See "Thinking" in configuration.md. |
286
289
  | `--toolkit NAME`, `--tool NAME`, `--no-tool NAME` | A new session's toolkit, the tools its prompts declare: `full` (every built-in), `lean`, `child`, one of config's `toolkit.kits` or a bundle's (without `--toolkit`: config's `toolkit.default`), with tools declared on top of it (`--tool`) or left out (`--no-tool`); both repeatable, a comma list too. Saved in the session as given; a respawned worker, a fork and a continue keep it. Ignored with `--attach`/`--resume` (one line, as `--memory`); `/toolkit` changes it. Not on `chi web` (config's `toolkit.default` covers its sessions). See [Toolkits](configuration.md#toolkits). |
@@ -750,12 +753,14 @@ REPL alike:
750
753
  with images works the same way: it waits for the turn's end, and the lines
751
754
  sent after it still go in at the next step.
752
755
  At the open prompt (no turn running) `/cut TEXT` and `/queue TEXT` run TEXT
753
- as a normal prompt.
756
+ as a normal prompt, and the terminals say so: `(nothing is running: sent now)`.
754
757
  - `/unqueue` (attached terminal) takes back the newest line this terminal
755
758
  queued that hasn't started: `removed: <its first 60 characters>`, and its text
756
759
  goes back into the input to change and send again (not over typed text: then
757
760
  it is in the input history, ↑; not with input from a pipe; its images don't
758
- come back), then the one before it on the next `/unqueue`; `(nothing queued: …)` when none waits,
761
+ come back), then the one before it on the next `/unqueue`. A command typed
762
+ after that text without clearing it (`fix it/unqueue`, `fix it/queue more`)
763
+ runs as the command, and the text stays in the input. `(nothing queued: …)` when none waits,
759
764
  `(not removed: it already started)` when its turn won the race, `(not removed:
760
765
  it is already gone)` when another client removed it. Other terminals that
761
766
  showed it say `(a queued message was removed)`; the web drops its bubble. A
@@ -1189,10 +1194,9 @@ CHI_REMOTE=studio chi context push claude-outbox ab12 < answer.json
1189
1194
  list|stop|restart|archive|unarchive|delete`. The rest runs on this machine
1190
1195
  only and exits 2 with `chi <command> runs on this machine only: run it on
1191
1196
  <remote>`: `context refresh|mute|unmute` and `--project`, `sessions
1192
- stats|prune|clean`, `broadcast`, the machine's own commands (`bundle`,
1193
- `memory`, `update`, …) and a session in this terminal (`--attach`, `-p`,
1194
- `--resume`: remote attach comes later). `chi remote`, `--version` and
1195
- `--help` always run here.
1197
+ stats|prune|clean`, `broadcast` and the machine's own commands (`bundle`,
1198
+ `memory`, `update`, …). `chi remote`, `--version` and `--help` always run
1199
+ here.
1196
1200
  - `send --new` needs `--dir`, that machine's absolute path (no folder of this
1197
1201
  one is guessed); `--toolkit`, `--set` and `--parent-answers` are checked
1198
1202
  against that machine's config and bundles. `sessions list` lists every
@@ -1208,18 +1212,88 @@ CHI_REMOTE=studio chi context push claude-outbox ab12 < answer.json
1208
1212
  `--timeout`; exit codes are the same. The commands chi prints for what to
1209
1213
  do next work as pasted: they carry `--remote NAME` (`chi --remote studio
1210
1214
  answer ab12 --question q1 --option N`, `chi --remote studio send --wait …`),
1211
- under `CHI_REMOTE` too. What runs on this machine only isn't offered:
1212
- where chi says `chi --attach ID` here it names that machine's chi web
1213
- (`chi web on studio shows it`), and `sessions stop` names no `chi
1214
- --resume`.
1215
+ under `CHI_REMOTE` too, an attach as well (`chi --remote studio --attach
1216
+ ab12 shows it`, `sessions stop`'s `chi --remote studio --resume ab12`).
1215
1217
  - A remote that can't be reached names itself and why (`chi send: remote
1216
1218
  studio (http://192.168.1.29:4567): connection refused`, exit 1; with
1217
1219
  `--format json` an `error` object).
1218
1220
  - Plain http, the same as the phone's: use it on your home network.
1219
1221
 
1222
+ A session there in this terminal (the attached TUI, through that machine's
1223
+ chi web; its worker stays there):
1224
+
1225
+ ```sh
1226
+ chi --remote studio --attach ab12 # attach (wakes its worker if it exited)
1227
+ chi --remote studio --resume ab12 -p "go on" # resume there, run one turn, stay attached
1228
+ chi --remote studio --dir /Users/me/app -p "hi" # a new session there (--dir required), one turn, stay
1229
+ ```
1230
+
1231
+ - The same TUI and exit codes (0 detached, 3 a question left waiting with
1232
+ input from a pipe, 1 otherwise), every printed command with `--remote
1233
+ studio`. Prompts, session commands, `/model`, `!cmd` (a shell command **on
1234
+ that machine**, under its guardrails), `/queue`, `/cut`, `/unqueue`,
1235
+ `/detach`, `/exit`, `/exit --delete`, `/archive`, `/stats` and `/recap` work
1236
+ as here, on that machine's session (`/exit --delete` deletes it there).
1237
+ The status row's children segment counts its delegates there, and a join
1238
+ shows the steps' durations and a failed turn's line from its saved records
1239
+ there. A chi web too old for one of them says so in one line ("remote
1240
+ studio runs chi X, too old for this command through --remote: update chi
1241
+ there").
1242
+ - A new session needs `--dir`, that machine's absolute path; `--model`,
1243
+ `--thinking`, `--llm-context*`, `--toolkit`, `--tool`, `--no-tool`, `--set`
1244
+ and `--parent-answers` are checked there (exit 2 for a bad one). With
1245
+ `--attach`/`--resume` they work as here (`--model`, `--thinking`,
1246
+ `--llm-context*` change the session; the others are named as ignored).
1247
+ `--non-interactive` (use `chi --remote studio send --new --dir D -m TEXT
1248
+ --wait`), `--verbose`, `--no-shared`, `--memory`, `--mute` and
1249
+ `--append-system-prompt` run on this machine only (exit 2).
1250
+ - `@path` images are this machine's files (read and downscaled here, then
1251
+ uploaded); Tab completes `/` commands and `@path`, not memory names. The
1252
+ prompt history (↑) and `no_interrupt` / `no_default_input` are this
1253
+ terminal's.
1254
+ - When chi web can't be reached the TUI says "(lost the connection to
1255
+ studio; reconnecting…)" and tries for about a minute, then "(reconnected)";
1256
+ what it missed is replayed or resynced. A prompt typed meanwhile is usually
1257
+ accepted there and runs when a worker reads it. A worker that went away
1258
+ (not stopped) is woken again. The end is worded by why: "The session was
1259
+ stopped on studio. Resume it with: chi --remote studio --resume ab12", the
1260
+ token refused, chi web unreachable, the worker gone, the session deleted.
1261
+ - The version line names both machines: the worker older than what studio
1262
+ has installed (`chi --remote studio sessions restart ab12`), this terminal
1263
+ older than studio (`chi update` here).
1264
+
1220
1265
  How it works inside (`SessionAccess`, chi web's routes, where each check runs):
1221
1266
  [internals/remote-sessions.md](internals/remote-sessions.md).
1222
1267
 
1268
+ ### Participants
1269
+
1270
+ A session's owner can invite **participants**, people or agents, to review it through chi web on the LAN
1271
+ (`web.host: lan`), each with a link of their own that reaches that one session:
1272
+
1273
+ ```sh
1274
+ chi share 3f9a1c2e --name Alex
1275
+ # p_7c01d2aa Alex (human, reviewer) · session 3f9a1c2e · expires 2026-10-30 14:02
1276
+ # http://192.168.1.29:4567/?token=chp_…#s=3f9a1c2e-…
1277
+ # (shown once: chi keeps only its digest; chi share revoke 3f9a1c2e p_7c01d2aa ends it)
1278
+ chi share ls # every session's participants (--format json; never a token)
1279
+ chi share revoke 3f9a1c2e # every link to that session; add a participant id for one
1280
+ chi share revoke --all # every link to every session
1281
+ ```
1282
+
1283
+ - The link is printed once (with a QR code at a terminal): chi keeps only a digest of its token. Send it to the
1284
+ participant; opening it sets their browser's cookie, as the owner's LAN link does. `--agent` marks an agent
1285
+ participant, which uses the token as `Authorization: Bearer` (e.g. `chi --remote` with that token).
1286
+ - It works for 7 days (`--expires Nh` or `Nd`, 1 hour to 30 days), until it is revoked, or until the session is
1287
+ archived or deleted. A revoked or expired link shows "This review link has expired or was revoked."
1288
+ - For now a participant sees a page with the session's title and status; comments and the review itself come in
1289
+ a later chi. They can't reach anything else: every other route answers them 403, another session 404.
1290
+ - `chi --remote studio share …` invites on studio: the link names studio's LAN address. Locally, the link names
1291
+ this machine's chi web; without one on the LAN, chi says the link opens on this machine only.
1292
+ - The owner's own token and the `chi web --new-token` rotation are separate: rotating it ends no participant's link.
1293
+
1294
+ How it works inside (grants, the gate, the routes, the audit trail):
1295
+ [internals/participants.md](internals/participants.md).
1296
+
1223
1297
  ## Runtime Model Switch (Assist Mode)
1224
1298
 
1225
1299
  In interactive assist mode, you can switch the request model without restarting:
data/docs/guardrails.md CHANGED
@@ -595,7 +595,8 @@ only by the user: these change a model's system prompt for later sessions (see
595
595
  [Model notes](memory.md#model-notes); a shell command reaches them only under
596
596
  `shell-touches-chi`'s text heuristic); and answering chi's
597
597
  questions around `chi answer`: `chi --attach` or `chi -p` with stdin from a
598
- pipe, a here-string or a file, not `/dev/null` (`chi-answer-piped`), and `curl`/`wget` to a
598
+ pipe, a here-string or a file, not `/dev/null` (`chi-answer-piped`, which also sees
599
+ `chi --remote NAME --attach|--resume|-p` and `CHI_REMOTE=NAME chi …`), and `curl`/`wget` to a
599
600
  session's `/answer` route (`chi-answer-http`), both once or for the session, and only by the
600
601
  user (a parent agent allowing one would answer as the user).
601
602
  `chi answer` itself isn't asked about. It denies writes into `.git/hooks`.
@@ -12,8 +12,12 @@ resumed session's line already names):
12
12
 
13
13
  Both loops do this, before each request (`ContextStatus`, one per turn). The
14
14
  chat loop's estimate counts the conversation's text and tool calls, not the
15
- tool schemas it sends, until the host's first count (its `usage`). The event
16
- carries a `status` string using this prefix:
15
+ tool schemas it sends, until the host's first count (its `usage`). So that a
16
+ turn's first request doesn't dip below the last turn's count (the schemas are
17
+ thousands of tokens), the loop keeps the last counted request's chars per token
18
+ (`ContextStatus#counted_chars_per_token`, on `KernelLoop` / `ChatLoop`) and the
19
+ next turn's estimate before its first count uses it instead of
20
+ `context.chars_per_token`. The event carries a `status` string using this prefix:
17
21
 
18
22
  `CONTEXT_STATUS ...`
19
23
 
@@ -518,7 +518,10 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
518
518
  | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
519
519
 
520
520
  The boundary events (`:turn_started`, `:turn_completed`, `:turn_canceled`, `:turn_failed`) also carry `origin`
521
- when the turn was queued with one.
521
+ when the turn was queued with one: `{client_id:, enqueued_id:}`, plus `author:` when someone other than the session's
522
+ owner wrote it (its input file's `author`, stamped by chi web from the request's `Web::Principal`). An owner's turn
523
+ has no `author` anywhere: not in its input file, its origin or the session. `GuardrailWiring#begin_turn` gets the
524
+ origin, and `#turn_author` reads its author for the gate's checks.
522
525
 
523
526
  ### Hook points (not events)
524
527
 
@@ -0,0 +1,137 @@
1
+ # Participants: identity and routes
2
+
3
+ A session's owner can invite **participants** to review it: people, or agents, who reach that one session through
4
+ chi web with a link of their own. This page is the identity layer (the shared-sessions plan's P2): grants, the gate,
5
+ the routes a participant reaches, and what is written where. A participant can't send a turn yet (P3), and their page
6
+ shows only the session's title and status until the full participant UI lands (P4). The code and docs say
7
+ "participants" and "review", never "shared": `session.shared` already means "attached by default".
8
+
9
+ Until `web.public_url` lands, participants are a **LAN** feature (`web.host: lan`): a loopback peer is always the
10
+ owner, and a reverse proxy on the same machine would make every visitor loopback.
11
+
12
+ ## Grants
13
+
14
+ A grant (`Web::Grants`, `lib/samagotchi/web/grants.rb`) binds **one random token** to **one session**, **one
15
+ participant** and **one role**, until it expires or is revoked.
16
+
17
+ - **Token.** `chp_` + 43 URL-safe characters (32 random bytes, as the owner's `Web::Token`). The prefix lets the gate
18
+ look up only participant tokens, and a leaked one is recognisable. It is shown **once**, in the link chi share or
19
+ `POST …/participants` prints; chi keeps only its **SHA-256 digest**. Lookup digests the presented token and
20
+ compares it with every grant's digest (`Rack::Utils.secure_compare`).
21
+ - **Participant.** The grant's `id`, `p_<8 hex>`, names the participant: a turn they send will be authored
22
+ `"p:<id>"` (`Grant#author`, `Principal#author`; P3). `kind` is `human` or `agent` (D11: an agent uses the same
23
+ token, as a Bearer header; there is no agent UI). `role` is `reviewer`, the only one in v1. `name` is cleaned to
24
+ one line of at most 64 characters with no brackets (`Grants.clean_name`), so it can't forge P3's
25
+ `[Review contribution from …]` header.
26
+ - **Lifetime (D8).** 7 days by default (`chi share --expires Nh|Nd`, 1 hour to 30 days). Revoked by the owner
27
+ (`chi share revoke`, `DELETE …/participants`), and by an **archive** (the session and its delegates) or a
28
+ **delete** (`SessionManager.archive_session` / `delete_session` → `Participants.end_sessions`). A session pruned by
29
+ retention, or archived or deleted by a process that didn't revoke, is caught by the gate: a live grant whose session
30
+ file is gone or archived is revoked on sight (`revoked_by: gone | archive`). Unarchiving doesn't restore a grant.
31
+ An ended grant stays in the store for 30 days, so its token answers `token_revoked` / `token_expired` rather than a
32
+ bare 401; then it goes.
33
+ - **Store.** `$XDG_STATE_HOME/samagotchi/web-grants/grants.json`, next to `web-token`: the file 0600, in its own 0700
34
+ folder, written whole (`AtomicFile`) under a lock file (`grants.lock`) since chi web and a local `chi share` both
35
+ write it. Readers follow its mtime (a `chi share revoke` in another process takes effect at chi web's next request).
36
+ `last_used_at` is written at most once a minute per grant. **chi web refuses to start** when that folder or file can
37
+ be read by another user, or isn't this user's (`Grants.check_permissions!`, one message naming the `chmod`). Only
38
+ those two are checked: the state dir above is often 0755, and that's fine. Never point `XDG_STATE_HOME` at a
39
+ shared folder.
40
+ - Rotating the owner token (`chi web --new-token`) never touches grants; `chi share revoke --all` ends them all.
41
+
42
+ ## The gate
43
+
44
+ `App#principal_for` resolves every request to a `Web::Principal`:
45
+
46
+ 1. A loopback peer, or a chi web not on the LAN: the **owner** (unchanged).
47
+ 2. The owner's token (Bearer, then the cookie; `?token=` on the page's trade): the **owner**. Checked first, so the
48
+ owner's requests never touch the grants store (measured: no change to an owner route's time).
49
+ 3. A `chp_` token whose grant is active: a **participant** (`Principal.new(kind: :participant, grant:)`).
50
+ 4. Anything else: nil. `#token_gate` answers an ended grant's token **401 `token_revoked`** or **401
51
+ `token_expired`** (the page: "This review link has expired or was revoked. Ask the session's owner for a new
52
+ one."), any other **401 `unauthorized`** as before.
53
+
54
+ The `?token=` trade sets the `chi_token` cookie to the token that was presented, the owner's or the participant's
55
+ (one at a time per browser: a participant link opened in the owner's browser replaces the owner's cookie there).
56
+
57
+ Then `#route`, for each matching `ROUTES` row:
58
+
59
+ 1. A participant on a `/api/sessions/:id…` route whose id isn't their grant's session gets **404 `not_found`**, the
60
+ same answer as a session that doesn't exist, before the permission check: they can't learn which sessions exist.
61
+ 2. A route whose permission the principal lacks (`Principal#permits?`) gets **403 `not_permitted`**, worded for the
62
+ role: "a participant can't do that: only the session's owner can".
63
+
64
+ `spec/web/participant_gate_spec.rb` walks `ROUTES`: every `:owner` row answers a participant 403, and every session
65
+ row answers another session's id 404, so a new route is covered without a new example.
66
+
67
+ ## What a participant reaches (P2)
68
+
69
+ | Route | Permission | A participant gets |
70
+ |---|---|---|
71
+ | `GET /` | participant | the participant page (server-rendered, no script): "Alex's review", the session's title and status, "Comments open soon", the participants' names. Never the owner's page, where every route would refuse them. |
72
+ | `GET /api/info` | participant | `{app, version, features}` (what `SessionAccess::Remote`'s check needs); not `pid`, `cwd`, `lan`, `installed` |
73
+ | `GET /api/sessions/:id` | participant | their session only, whatever the query (`brief`, `cards`, `tail`, `timing`): `{session: {id, title, status, participants: [{id, name, kind, role}]}}` (the active ones). `title` is the session's own title, or "an untitled session": never `display_title`'s fallback, the first prompt's opening words |
74
+
75
+ Everything else is `:owner`, `POST …/turn` included (P3 opens it). Static assets (`/app.js`, …) are served to any
76
+ principal past the gate: they are the public frontend.
77
+
78
+ **Streams.** No stream route is a participant's yet (`App#stream_filter` raises for one, until P4's filter). The
79
+ seam for ending them is in place: `App#stream_running(principal)` is the proxy's running check (asked every
80
+ `BridgeClient::STREAM_POLL`), and for a participant it is false once the grant is revoked or expired, so P4's
81
+ participant streams close within a poll of a revoke, including one made by `chi share revoke` in another process.
82
+
83
+ ## The owner's routes
84
+
85
+ | Route | Body / params | Answer | Refusals |
86
+ |---|---|---|---|
87
+ | `GET /api/sessions/:id/participants` | | 200 `{participants: [grant]}`, every state | |
88
+ | `GET /api/participants` | | 200 `{participants: [grant]}`, every session's | |
89
+ | `POST /api/sessions/:id/participants` | `name`, `kind` (`human`/`agent`), `ttl` (seconds) | 201 `{participant: grant, token, link, lan}`: the token and the link once; `link` names this chi web's LAN address (`http://IP:PORT/?token=…#s=<id>`), `lan` false when it has none | 400 `invalid_participant` (name, kind, ttl, an archived session); 404 |
90
+ | `DELETE /api/sessions/:id/participants[?id=p_…]` | | 200 `{revoked: [grant]}`: one participant, or the session's | 400 `invalid_participant` |
91
+ | `DELETE /api/participants` | | 200 `{revoked: [grant]}`: every session's | |
92
+
93
+ A grant here is `Grant#public_h`: `id, session_id, kind, name, role, created_at, expires_at, revoked_at, revoked_by,
94
+ last_used_at, state` (`active | revoked | expired`). Never the token or its digest.
95
+
96
+ `chi share` (docs/cli.md) uses them through `--remote`, and `SessionAccess::Local`'s same-named operations here
97
+ (`participants`, `invite`, `revoke_participants`; `ParticipantView`, `Invitation`). A local invite's link names
98
+ the local chi web's LAN address when one runs (its `GET /api/info`), else `127.0.0.1` with a line saying it opens on
99
+ this machine only.
100
+
101
+ ## Files in the session dir
102
+
103
+ Neither is in the session JSON (chi web never saves that, and a worker would overwrite a field it added), and
104
+ neither is copied to a continue, a fork or a delegate (a continue starts with no participants). A session nobody was
105
+ invited to has neither: its files stay byte for byte as they were.
106
+
107
+ - **`participants.json`** (0600, `Participants.write`): `{participants: [{id, kind, name, role, added_at,
108
+ expires_at, removed_at}]}`, rewritten by whoever changes a grant (chi web, `chi share`). Never a token or a digest.
109
+ The worker reads it with `Participants.read` (P3a's system-prompt line, `ctx`).
110
+ - **`participants.jsonl`** (0600, append-only, `Participants.log`): one JSON line per event, with `at`: `issued`
111
+ (participant, name, kind, role, expires_at), `revoked` (participant, by), `expired` (the first refused use after the
112
+ expiry, once per chi web), `peer` (the first use and each new peer address since chi web started: ip, user agent),
113
+ `refused` (method, path, reason; at most once a minute per route). It goes with the session (retention, delete).
114
+
115
+ ## Errors through `--remote`
116
+
117
+ Each condition has one message, owned by its `SessionAccess` class and built from the remote's label
118
+ (`Remote.access_error`, keyed by chi web's error code, for any route):
119
+
120
+ | Code | Class | Message |
121
+ |---|---|---|
122
+ | 401 `token_revoked` | `TokenRevoked` (an `AuthRefused`) | `this review link was revoked by the session's owner` |
123
+ | 401 `token_expired` | `TokenExpired` (an `AuthRefused`) | `this review link expired: ask the session's owner for a new one` |
124
+ | 403 `not_permitted` | `NotPermitted` | `this token is a participant's: only the session's owner can do that` |
125
+ | 429 `too_many_turns` | `TooManyTurns` (P3b) | `this participant has too many turns waiting: wait for one to finish` |
126
+ | 429 `cap_reached` | `CapReached` (P3b) | `this participant's spending cap is reached: ask the session's owner to raise it` |
127
+
128
+ A 401 `unauthorized` stays `AuthRefused`'s "the token was refused".
129
+
130
+ ## Invariants
131
+
132
+ - The owner is a loopback peer or the owner's token; nothing else. A participant token never widens to the owner.
133
+ - A participant reaches one session, through the `:participant` rows only; another session is a 404.
134
+ - No token is written anywhere: the grants store has digests, the sidecar and the audit trail have neither.
135
+ - Owner sessions are unchanged: no sidecar, no audit file, no key in the session JSON until a grant exists.
136
+ - P2 lets no participant reach `POST …/turn`, `/command`, `/answer` or a stream. P3 adds the turn, with the author
137
+ stamped from the principal (never the body), and the worker's review boundary.