samagotchi 0.16.0 → 0.18.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. metadata +10 -1
data/docs/sessions.md CHANGED
@@ -13,7 +13,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
13
13
 
14
14
  A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
15
15
 
16
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`.
16
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`.
17
17
 
18
18
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session was empty, so it is discarded.` The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
19
19
 
@@ -23,7 +23,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
23
23
 
24
24
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
25
25
 
26
- <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered; a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
26
+ <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
27
27
 
28
28
  **Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
29
29
 
@@ -40,7 +40,7 @@ chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...]
40
40
  chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
41
41
  ```
42
42
 
43
- A session whose worker waits for an answer (a question, a hook's question or a guardrail approval) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
43
+ A session whose worker waits for an answer (a question, a hook's question, a guardrail approval, or the step-limit question of a turn that ran out of steps, which waits between turns) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"|"continue"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
44
44
 
45
45
  Examples:
46
46
 
@@ -63,7 +63,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
63
63
  **Ordering:**
64
64
 
65
65
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
66
- - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
66
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
67
67
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
68
68
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
69
69
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
@@ -149,7 +149,8 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
149
149
  - A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
150
150
  - Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
151
151
  - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
152
- - One line per session: `sent`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
152
+ - A session command runs as the command, as typed in a terminal: `chi send -m "/model qwen" 3fa2` switches the model (`sent as a session command`), and the model never sees the line. What counts is what the session's worker knows (the built-ins, `!cmd`, its bundles' commands); an unknown `/word` (`/usr/bin/env is missing`) goes in as a message. With `--new` a line starting with `/` or `!` starts the session idle and goes to its worker once it is up, which decides. With `--wait` a command has no reply to wait for: exit 0 at once, `<id> sent as a session command: no reply to wait for …` on stderr (`{"status":"command",…,"detail":…}` with `--format json`); its output shows where the session is open.
153
+ - One line per session: `sent`, `sent as a session command`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
153
154
 
154
155
  The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
155
156
 
@@ -168,7 +169,7 @@ chi send --wait 3fa2 # sends nothing: waits fo
168
169
  - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one; the name isn't checked up front, a wrong one fails the worker's first turn).
169
170
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
170
171
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
171
- - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question or a guardrail approval), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
172
+ - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
172
173
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1). A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
173
174
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
174
175
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
@@ -181,6 +182,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
181
182
  - `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
182
183
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one.
183
184
  - What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
185
+ - **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `turn.parent_continue` (see [chi as a sub-agent](sub-agent.md#the-step-limit)).
184
186
  - **A child's approvals go to the parent's user** (the approval relay). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
185
187
  - The child's own card stays answerable (the first answer wins: answered there, the parent's card closes as "answered in ab12"). While relayed it says `Waiting for approval in parent ab12: answering here works too`; `chi sessions list` shows the child as `waiting (in parent ab12)` (`--format json`: `relayed_to`), and the web's bell rings for the parent's card, not the child's.
186
188
  - How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_approvals` by the parent and again by the child.
data/docs/sub-agent.md CHANGED
@@ -33,9 +33,11 @@ Exit codes, both commands:
33
33
  | Exit | `status` | Meaning |
34
34
  |---|---|---|
35
35
  | 0 | `answered` | `text` is chi's reply |
36
+ | 0 | `command` | (`chi send`) the message was a session command (`/model x`): it ran as one, no reply comes |
37
+ | 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
36
38
  | 3 | `question` | chi waits for an answer: `question`, `answer_with` |
37
39
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
38
- | 1 | `failed`, `canceled`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened |
40
+ | 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it) |
39
41
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
40
42
  | 130 | `running` | Ctrl-C; the turn goes on |
41
43
 
@@ -48,8 +50,33 @@ With `--format json` stdout is one JSON object (see
48
50
  "allow_freeform":false},"answer_with":"chi answer 297da360-… --question 3f1c… --option N"}
49
51
  ```
50
52
 
51
- `kind` is `question` (the model's), `hook`, or `approval`, which adds the tool,
52
- command, folder, rule and reason.
53
+ `kind` is `question` (the model's), `hook`, `approval`, which adds the tool,
54
+ command, folder, rule and reason, or `continue`, the step-limit question below.
55
+
56
+ ## The step limit
57
+
58
+ A turn that runs out of steps (`turn.max_iterations`, default 100) before it
59
+ answers waits as a question of kind `continue` (`"limit": 100`, options
60
+ `Continue` and `Stop`). `answer_with` continues it:
61
+ `chi answer ID --question QID --option Continue`, and the continued turn's
62
+ reply (or its next question) comes back on that same wait. To stop it:
63
+ `--option Stop`, with `--text "why"` for the model (Continue takes no text);
64
+ that wait ends with `status: not_continued`, exit 0, and the turn's work so far
65
+ stays in the session. A message instead (`chi send ID -m "…"`) drops the
66
+ question and starts a new turn. It can't be dismissed.
67
+
68
+ A parent may Continue by default: Continue grants no permission (every tool
69
+ call still meets the guardrails), it only spends time and tokens. With
70
+ `turn.parent_continue: false` in chi's config.yml (config.yml only, no
71
+ environment variable) a parent may only Stop: `chi answer` and the worker
72
+ refuse a Continue (exit 1, `a parent agent may only stop this turn here`), and
73
+ the user continues it from the web or the terminal.
74
+
75
+ A chi parent's `delegate_result` gets a child's step-limit question back as a
76
+ `question` with these commands, for its model to decide: continue it (the
77
+ `chi answer` command, through `execute`), send a narrower follow-up with
78
+ `delegate session:` (which drops the question), or report back. It isn't
79
+ relayed to the parent's user the way approvals are.
53
80
 
54
81
  ## Approvals
55
82
 
@@ -74,6 +101,10 @@ variable), "Allow once" goes through; the wider scopes never do,
74
101
  and neither does any allow on chi's own config, hooks or guardrail rules. See
75
102
  [Guardrails](guardrails.md#approvals-from-a-parent-agent).
76
103
 
104
+ Start a child in the folder it should change (`chi send --new --dir PATH`):
105
+ writes and mutating git outside its own repo ask (`write-outside-repo`,
106
+ `git-outside-repo`), which a parent can only deny.
107
+
77
108
  An answer typed into `chi --attach` or the REPL counts as a parent's too when
78
109
  stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
79
110
  `CODEX_THREAD_ID`, `SAMAGOTCHI_PARENT_SESSION`) is set.
@@ -95,7 +126,11 @@ answers from any local process.
95
126
  `chi answer` then exits 1 and says to send the task again
96
127
  (`chi send --wait -m "…" ID`); a wait ends with `worker_gone` (exit 1).
97
128
  - A follow-up (`chi send --wait -m "…" ID`) to a session waiting for an answer
98
- isn't sent: it exits 1 with the `chi answer` command for the question.
129
+ isn't sent: it exits 1 with the `chi answer` command for the question. The
130
+ step-limit question is the exception: it waits between turns, so a message
131
+ goes in, drops it and starts a new turn.
132
+ - `chi send --wait ID` with no message waits past a question already reported
133
+ (the step-limit question too), until someone answers it.
99
134
  - An answer to a question that is no longer open (you answered it in the web
100
135
  first) isn't sent; `chi answer` says so and waits for what comes next.
101
136
  - `chi send --wait` and `chi answer` don't read stdin unless it's a pipe or a
@@ -129,6 +164,10 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
129
164
  `chi answer`, a piped `chi --attach` or chi's web API.
130
165
  - `status: running` (exit 4): still working. Wait again:
131
166
  `chi send --wait --format json --timeout 500 <session_id>`.
167
+ - `question.kind: continue`: chi ran out of steps before it answered. If it is
168
+ getting somewhere, continue it (`answer_with`, `--option Continue`); if not,
169
+ stop it (`--option Stop --text "why"`, exit 0, status `not_continued`) or send
170
+ a narrower follow-up instead. Ask your user when unsure.
132
171
  - Anything else (exit 1): report `detail` to your user. `chi --attach <session_id>`
133
172
  shows the session.
134
173
 
data/docs/testing.md ADDED
@@ -0,0 +1,47 @@
1
+ # Testing
2
+
3
+ ```sh
4
+ bundle exec parallel_rspec -n 8 # Ruby specs, in parallel (or plain: bundle exec rspec)
5
+ npm test # web frontend specs
6
+ npm run e2e # web UI happy paths in Chromium, fake model (once: npx playwright install chromium)
7
+ ```
8
+
9
+ No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
10
+ clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
11
+
12
+ ## Integration specs
13
+
14
+ Examples tagged `:integration` (`spec/integration/`, the "with LLM access" part of `spec/hooks/integration_spec.rb`)
15
+ run turns against a live model server. They are skipped unless `SAMAGOTCHI_INTEGRATION=1` is set, and they need the
16
+ server and the model it serves:
17
+
18
+ | Variable | Meaning | Default |
19
+ | --- | --- | --- |
20
+ | `SAMAGOTCHI_INTEGRATION` | `1` runs them | off |
21
+ | `SAMAGOTCHI_INTEGRATION_HOST` | model server host | `localhost` |
22
+ | `SAMAGOTCHI_INTEGRATION_PORT` | model server port | `8080` |
23
+ | `SAMAGOTCHI_INTEGRATION_MODEL` | the served model id | none: the examples skip |
24
+ | `SAMAGOTCHI_INTEGRATION_API` | `openai` for the chat loop (`hosts.<name>.api`) | none: the native loop |
25
+ | `SAMAGOTCHI_INTEGRATION_TRANSPORT` | `llama_cpp`, `mlx` or `omlx` (`server.transport`) | none: `llama_cpp` |
26
+
27
+ They run under a config built from these variables alone (`default.model`, `server.host`/`port`, and one
28
+ `hosts.integration` entry; `spec/support/integration_server.rb`), never your `~/.config/samagotchi/config.yml`: no
29
+ aliases, memories, hooks, guardrails or other hosts of yours. Sessions and logs go to the suite's temp state folder.
30
+
31
+ Run them one at a time (plain `rspec`, not `parallel_rspec`): each example is a real model turn, and the server may be
32
+ shared.
33
+
34
+ ```sh
35
+ SAMAGOTCHI_INTEGRATION=1 \
36
+ SAMAGOTCHI_INTEGRATION_HOST=192.0.2.10 SAMAGOTCHI_INTEGRATION_PORT=8081 \
37
+ SAMAGOTCHI_INTEGRATION_MODEL=org/Model-GGUF:Q4_K_M \
38
+ bundle exec rspec --tag integration spec/integration spec/hooks/integration_spec.rb spec/integration_config_spec.rb
39
+ ```
40
+
41
+ A run takes one to four minutes against a 35B MoE model on llama.cpp (25 examples). `ECONNREFUSED` means the server is down. The
42
+ examples assert what the model did (the tool it called, the file it edited), not its wording, but they still depend on
43
+ a model that follows tool instructions: a small model may fail some of them.
44
+
45
+ `spec/integration/omlx_spec.rb` tests oMLX's model-id resolution and runs only with
46
+ `SAMAGOTCHI_INTEGRATION_TRANSPORT=omlx`, against an oMLX server (a short model selector in
47
+ `SAMAGOTCHI_INTEGRATION_MODEL`).
@@ -6,6 +6,7 @@ require_relative "bridge_client"
6
6
  require_relative "reply_wait"
7
7
  require_relative "parent_report"
8
8
  require_relative "guardrails/parent_approvals"
9
+ require_relative "guardrails/parent_continue"
9
10
  require_relative "cli/command"
10
11
  require_relative "cli/flags"
11
12
  require_relative "cli/parent_wait"
@@ -46,6 +47,9 @@ module Samagotchi
46
47
  An approval (a guardrail's ask) can be denied here: deny it, and
47
48
  tell your user; allowing it is the user's. With
48
49
  guardrails.parent_approvals: once, "Allow once" is let through.
50
+ A step-limit question (kind continue): --option Continue, or
51
+ --option Stop [--text WHY] (exit 0, status not_continued); it
52
+ can't be dismissed.
49
53
  Exit: 0 answered, 1 failed, refused or the worker is gone, 2
50
54
  usage or an option the question doesn't offer, 3 a question waits,
51
55
  4 still running (--timeout), 130 Ctrl-C.
@@ -105,8 +109,10 @@ module Samagotchi
105
109
  baseline = ReplyWait.baseline_of(session, question_id: qid)
106
110
 
107
111
  if pending && pending[:id].to_s == qid
112
+ return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
113
+
108
114
  selected = selection(pending, options) or return @selection_status
109
- refusal = approval_refusal(pending, selected, id)
115
+ refusal = approval_refusal(pending, selected, id) || continue_refusal(pending, selected)
110
116
  return failure(refusal) if refusal
111
117
 
112
118
  posted = post(id, qid, selected, options)
@@ -154,6 +160,18 @@ module Samagotchi
154
160
  reason && Guardrails::ParentApprovals.message(reason)
155
161
  end
156
162
 
163
+ # Why a parent may not answer Continue (turn.parent_continue: false in
164
+ # this process's config.yml), or nil; the worker checks it again.
165
+ def continue_refusal(pending, selected)
166
+ Guardrails::ParentContinue.refusal(pending, selected) && Guardrails::ParentContinue.message
167
+ end
168
+
169
+ # A step-limit question can't be left unanswered: the turn waits on it.
170
+ def not_dismissable(pending)
171
+ "the step-limit question can't be dismissed: answer #{Array(pending[:options]).join(' or ')} " \
172
+ "(--option Stop --text WHY stops it)"
173
+ end
174
+
157
175
  # Post the answer (or the dismissal) to the live worker's Bridge.
158
176
  # @return [Integer, nil] an exit status when it didn't go in; nil to
159
177
  # wait (it went in, or the question was no longer open: 409)
@@ -174,7 +192,10 @@ module Samagotchi
174
192
 
175
193
  case reply.status
176
194
  when 200 then nil
177
- when 409 then not_open(qid)
195
+ when 409
196
+ return failure(reply.json&.dig("detail").to_s) if reply.json&.dig("error") == "not_dismissable"
197
+
198
+ not_open(qid)
178
199
  when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
179
200
  # The worker's own guardrails.parent_approvals refused the allow.
180
201
  when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context_note"
4
+ require_relative "output_formatter"
5
+ require_relative "steer"
6
+ require_relative "turn_note"
7
+
8
+ module Samagotchi
9
+ # "The last answer a UI shows": the newest message the web's history
10
+ # shows as an assistant message. One rule for the worker's Bridge (`/tail`,
11
+ # Engine#last_answer_message) and the web's disk path, so the two can't
12
+ # drift. Any change to what the web shows as an answer goes through here.
13
+ module AnswerTail
14
+ module_function
15
+
16
+ # @param messages [Array<Hash>] a session's messages (symbol keys from
17
+ # disk or the Engine, string keys from a Bridge snapshot)
18
+ # @param turn_id [String, nil] that turn's answer: the last one between
19
+ # the prompt carrying this turn id and the next prompt (a queued turn's
20
+ # page re-reads after the next turn ran). An unknown or missing id:
21
+ # the newest answer.
22
+ # @return [Hash, nil] the raw message (not a copy), or nil
23
+ def find(messages, turn_id: nil)
24
+ list = Array(messages)
25
+ list = turn_slice(list, turn_id.to_s) || list unless turn_id.to_s.empty?
26
+ list.reverse_each { |m| return m if answer?(m) }
27
+ nil
28
+ end
29
+
30
+ # The messages of the turn whose prompt carries +turn_id+, from its
31
+ # prompt up to the next prompt (merged input and steers stay in it), or
32
+ # nil when no prompt carries it.
33
+ def turn_slice(list, turn_id)
34
+ start = list.rindex { |m| Steer.turn_prompt?(m) && (m[:turn_id] || m["turn_id"]).to_s == turn_id }
35
+ return nil unless start
36
+
37
+ stop = list.each_index.find { |i| i > start && Steer.turn_prompt?(list[i]) } || list.size
38
+ list[start...stop]
39
+ end
40
+
41
+ # Shown as an assistant message: role assistant or model, not a context
42
+ # note, a steer or a no-answer turn's marker, and some text left once the
43
+ # wire-format markup is stripped (a tool-call-only step has none).
44
+ def answer?(message)
45
+ return false unless message.is_a?(Hash)
46
+ return false if ContextNote.note?(message) || Steer.steer?(message) || TurnNote.empty_answer(message)
47
+ return false unless %w[assistant model].include?((message[:role] || message["role"]).to_s)
48
+
49
+ !OutputFormatter.strip_markup((message[:content] || message["content"]).to_s).empty?
50
+ end
51
+ end
52
+ end
@@ -41,11 +41,23 @@ module Samagotchi
41
41
  # continue, ordered parts (thinking / text / tool / input / steer /
42
42
  # reminder / notice), pending_question and the last event_seq folded
43
43
  # in. A finished tool part keeps the live row's action and duration_ms
44
- # too. A notice part holds the event of one of the turn's rows
45
- # (TurnNotice: a hook's notice, an empty-answer retry, a question and
46
- # its answer), which a UI replays through its live handler.
44
+ # too, a running one the time it has run so far (elapsed_ms), so a
45
+ # join times its row from its start. A notice part holds the event
46
+ # of one of the turn's rows (TurnNotice: a hook's notice, an
47
+ # empty-answer retry, a question and its answer), which a UI replays
48
+ # through its live handler.
47
49
  def current_turn
48
- @mutex.synchronize { @turn && Marshal.load(Marshal.dump(@turn)) }
50
+ @mutex.synchronize do
51
+ next nil unless @turn
52
+
53
+ turn = Marshal.load(Marshal.dump(@turn))
54
+ now = @clock.call
55
+ Array(turn[:parts]).each do |part|
56
+ started_at = part[:kind] == "tool" && part[:status] == "running" && @tool_started_at[[part[:iteration], part[:call_index]]]
57
+ part[:elapsed_ms] = ((now - started_at) * 1000).round if started_at
58
+ end
59
+ turn
60
+ end
49
61
  end
50
62
 
51
63
  # The turn in progress as conversation messages, for a plugin's
@@ -142,7 +154,10 @@ module Samagotchi
142
154
  call[:label] = part[:label] if part[:label]
143
155
  title = part[:title] ? { title: part[:title] } : {}
144
156
  events = [{ type: :tool_call_started, **call, params: part[:params], **title }]
145
- return events if part[:status] == "running"
157
+ if part[:status] == "running"
158
+ events.first[:elapsed_ms] = part[:elapsed_ms] unless part[:elapsed_ms].nil?
159
+ return events
160
+ end
146
161
 
147
162
  activity = { tool: part[:tool], status: part[:status], params: part[:params], **title }
148
163
  activity[:action] = part[:action] if part[:action]
@@ -209,8 +209,11 @@ module Samagotchi
209
209
  # last cards and between-turns notices (CardStore#list), the session's commands (Commands::Registry#listing:
210
210
  # what an attached TUI routes and completes, the web's autocomplete), and the event_seq it all covers.
211
211
  # Taken with the log held, so no event is half-applied.
212
- # @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, guardrail_warning:,
213
- # plugin_warning:, init_tasks:, cards:, commands:, event_seq:, event_id:}
212
+ # The pending question at the top level too: one asked between turns
213
+ # (the step-limit question) belongs to no turn, so the accumulator
214
+ # doesn't keep it.
215
+ # @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, pending_question:,
216
+ # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, event_seq:, event_id:}
214
217
  def snapshot
215
218
  @engine.synchronize_events do
216
219
  seq = @engine.event_count
@@ -221,6 +224,7 @@ module Samagotchi
221
224
  recap: @accumulator.recap,
222
225
  saved_recap: @engine.saved_recap,
223
226
  continue_offer: @accumulator.continue_offer,
227
+ pending_question: @engine.pending_question,
224
228
  guardrail_warning: @engine.guardrail_warning,
225
229
  plugin_warning: @engine.plugin_warning,
226
230
  init_tasks: @engine.init_tasks,
@@ -244,6 +248,34 @@ module Samagotchi
244
248
  end
245
249
  end
246
250
 
251
+ # What the page re-reads at a turn's end and when a card arrives: the
252
+ # session state, the last answer (Engine#last_answer_message: one
253
+ # message, not the conversation) and the cards, as one step of the event
254
+ # log (one event_seq). Light whatever the session's length: GET tail.
255
+ # @return [Hash] {session_id:, session_state_snapshot:, answer:, cards:, event_seq:, event_id:}
256
+ # +turn_id+: that turn's answer, not the newest (Engine#last_answer_message).
257
+ def tail_frame(turn_id: nil)
258
+ @engine.synchronize_events do
259
+ seq = @engine.event_count
260
+ state = @engine.session_state_snapshot.merge(event_seq: seq, event_id: event_id(seq))
261
+ { session_id: @session_id, session_state_snapshot: state, answer: @engine.last_answer_message(turn_id: turn_id),
262
+ cards: @cards.list, event_seq: seq, event_id: event_id(seq) }
263
+ end
264
+ end
265
+
266
+ # The worker's own way in for a session command that came as a message
267
+ # (its first prompt, an input file): queued and announced as a POST
268
+ # /command's is (#handle_command).
269
+ # @return [String] its command_id
270
+ def queue_command(line, client_id: nil, card: false)
271
+ command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
272
+ # A card's action (the step-limit question's answer): the UIs leave
273
+ # its line out.
274
+ command[:card] = true if card
275
+ @engine.synchronize_events { queue_command_locked(command) }
276
+ command[:command_id]
277
+ end
278
+
247
279
  private
248
280
 
249
281
  # The Engine's commands: the built-ins and its plugins'.
@@ -337,7 +369,7 @@ module Samagotchi
337
369
  client_id: stream_client_id(request[:query]))
338
370
  break # SSE owns the connection until the client disconnects.
339
371
  else
340
- payload, status, body = dispatch(route.first, route.last, request[:body])
372
+ payload, status, body = dispatch(route.first, route.last, request[:body], request[:query])
341
373
  write_json(io, status, payload, body)
342
374
  end
343
375
 
@@ -436,7 +468,8 @@ module Samagotchi
436
468
  %w[POST relay/status] => :handle_relay_status,
437
469
  %w[GET state] => :handle_state,
438
470
  %w[GET stats] => :handle_stats,
439
- %w[GET snapshot] => :handle_snapshot
471
+ %w[GET snapshot] => :handle_snapshot,
472
+ %w[GET tail] => :handle_tail
440
473
  }.freeze
441
474
  ROUTE_PATH = %r|\A/session/([^/]+)/(.+)\z|u
442
475
 
@@ -452,8 +485,10 @@ module Samagotchi
452
485
  # #own_session?), and a handler that raises answers 500 bridge_error,
453
486
  # logged (the connection thread doesn't report).
454
487
  # @return [Array(Hash, Integer, Hash)] headers, status, body
455
- def dispatch(handler, session_id, body)
488
+ # Only GET tail takes the request's +query+.
489
+ def dispatch(handler, session_id, body, query = nil)
456
490
  return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
491
+ return handle_tail(session_id, body, query) if handler == :handle_tail
457
492
 
458
493
  send(handler, session_id, body)
459
494
  rescue StandardError => e
@@ -523,7 +558,7 @@ module Samagotchi
523
558
  [{}, 409, { error: "question_not_pending", detail: e.message }]
524
559
  rescue QuestionDesk::Refused => e
525
560
  Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
526
- [{}, 403, { error: "parent_approval_refused",
561
+ [{}, 403, { error: e.reason == :stop_only ? "parent_continue_refused" : "parent_approval_refused",
527
562
  detail: Guardrails::ParentApprovals.message(e.reason) }]
528
563
  rescue ArgumentError => e
529
564
  [{}, 400, { error: "invalid_answer", detail: e.message }]
@@ -579,7 +614,12 @@ module Samagotchi
579
614
  return BAD_DEADLINE unless deadline_valid?(deadline)
580
615
  return deadline_passed("dismissal") if expired?("dismiss_expired", deadline, sid: session_id, id: qid)
581
616
 
582
- dismissed = @engine.cancel_question("dismissed", id: qid)
617
+ begin
618
+ dismissed = @engine.cancel_question("dismissed", id: qid)
619
+ rescue QuestionDesk::NotDismissable => e
620
+ # A step-limit question: its answer is Continue or Stop.
621
+ return [{}, 409, { error: "not_dismissable", detail: e.message }]
622
+ end
583
623
  return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless dismissed
584
624
 
585
625
  [{}, 200, { status: "dismissed", id: qid, session_id: @session_id }]
@@ -612,17 +652,21 @@ module Samagotchi
612
652
  deadline = fetched(parsed, "deadline")
613
653
  return BAD_DEADLINE unless deadline_valid?(deadline)
614
654
 
615
- command = { command_id: SecureRandom.uuid, client_id: fetched(parsed, "client_id"), line: line }
655
+ queue_command_request(session_id, line, client_id: fetched(parsed, "client_id"), deadline: deadline,
656
+ card: fetched(parsed, "card") == true)
657
+ end
658
+
659
+ # Queues +line+ (a session command) for the worker and announces its
660
+ # :command_queued, unless +deadline+ has passed. Returns [headers,
661
+ # status, body]: 202 with its command_id, or 408.
662
+ def queue_command_request(session_id, line, client_id:, deadline: nil, card: false)
663
+ command = { command_id: SecureRandom.uuid, client_id: client_id, line: line }
616
664
  # A card's action: the UIs leave its line out (the card is the echo).
617
- command[:card] = true if fetched(parsed, "card") == true
665
+ command[:card] = true if card
618
666
  queued = @engine.synchronize_events do
619
667
  next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
620
668
 
621
- @on_command.call(command)
622
- # An anytime command runs now, beside a turn (D8): the UIs show its
623
- # line here, so the cards it shows come after it.
624
- anytime = command_registry.lookup(line)&.anytime ? { anytime: true } : {}
625
- @engine.announce(type: :command_queued, **command, **anytime)
669
+ queue_command_locked(command)
626
670
  true
627
671
  end
628
672
  return deadline_passed("command") unless queued
@@ -630,6 +674,15 @@ module Samagotchi
630
674
  [{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
631
675
  end
632
676
 
677
+ # With the event log held.
678
+ def queue_command_locked(command)
679
+ @on_command.call(command)
680
+ # An anytime command runs now, beside a turn (D8): the UIs show its
681
+ # line here, so the cards it shows come after it.
682
+ anytime = command_registry.lookup(command[:line])&.anytime ? { anytime: true } : {}
683
+ @engine.announce(type: :command_queued, **command, **anytime)
684
+ end
685
+
633
686
  # A client asks the worker to exit now (`/exit` in the attached TUI). The
634
687
  # worker decides with the event log held, so no POST /turn lands between
635
688
  # its check and its answer; it leaves from its loop after this reply.
@@ -688,6 +741,14 @@ module Samagotchi
688
741
  deadline = fetched(parsed, "deadline")
689
742
  return BAD_DEADLINE unless deadline_valid?(deadline)
690
743
 
744
+ # A session command sent as a message (chi send -m "/model x", chi -p,
745
+ # a UI whose command list lags) runs as the command, as typed in a
746
+ # TUI; an unknown /word stays a prompt. Not with images: those are
747
+ # for the model.
748
+ if images.empty? && @on_command && command_registry.command?(prompt.to_s)
749
+ return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline)
750
+ end
751
+
691
752
  enqueued_id = SecureRandom.uuid
692
753
  # Write and announce with the event log held: the worker can't emit
693
754
  # this turn's :turn_started (or merge it mid-turn) before
@@ -733,6 +794,17 @@ module Samagotchi
733
794
  [{}, 200, snapshot_frame]
734
795
  end
735
796
 
797
+ # The page's light re-read (#tail_frame): no message list. `?turn_id=`:
798
+ # that turn's answer (a queued turn's page re-reads late). The one
799
+ # handler that gets the query (#dispatch); an older worker's route
800
+ # ignores it and sends the newest answer.
801
+ def handle_tail(session_id, _body = nil, query = nil)
802
+ turn_id = query && URI.decode_www_form(query).to_h["turn_id"].to_s
803
+ [{}, 200, tail_frame(turn_id: turn_id.to_s.empty? ? nil : turn_id)]
804
+ rescue ArgumentError
805
+ [{}, 200, tail_frame]
806
+ end
807
+
736
808
  # The stream cursor for +seq+ in this worker.
737
809
  def event_id(seq)
738
810
  EventId.format(seq.to_i, @epoch)
@@ -210,17 +210,32 @@ module Samagotchi
210
210
  # GET /session/:id/<path> as JSON.
211
211
  # @return [Hash, nil] the parsed body, or nil unless the Bridge answered 200
212
212
  def get_json(path)
213
+ status, body = get(path)
214
+ status == 200 ? body : nil
215
+ end
216
+
217
+ # GET /session/:id/<path>, telling a refusal apart from no answer: an
218
+ # older worker's 404 for a route it lacks from a refused connect, a
219
+ # timeout or a 500.
220
+ # @return [Array(Integer, Object)] the status and the parsed JSON body
221
+ # (nil when it isn't JSON); [nil, nil] when no reply came
222
+ def get(path)
213
223
  sock = TCPSocket.new(@host, @port)
214
224
  sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nConnection: close\r\n\r\n")
215
225
  response = read_reply(sock, path)
216
226
  sock.close rescue nil
217
227
  # A 500 whose text has "200" in it is not a 200.
218
- return nil unless response && response[STATUS_LINE, 1] == "200"
228
+ status = response && response[STATUS_LINE, 1]
229
+ return [nil, nil] unless status
219
230
 
220
231
  body = response.split("\r\n\r\n", 2)[1] || ""
221
- JSON.parse(body)
232
+ [status.to_i, begin
233
+ JSON.parse(body)
234
+ rescue JSON::ParserError
235
+ nil
236
+ end]
222
237
  rescue StandardError
223
- nil
238
+ [nil, nil]
224
239
  end
225
240
 
226
241
  # Monotonic SSE cursor of the live Engine, from GET /session/:id/state.