samagotchi 0.72.0 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +9 -3
- data/docs/desktop.md +2 -1
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +4 -2
- data/docs/internals/background-tasks.md +4 -3
- data/docs/plugins.md +9 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +41 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -0
- data/lib/samagotchi/paths.rb +8 -6
- data/lib/samagotchi/session.rb +5 -0
- data/lib/samagotchi/session_chain.rb +24 -0
- data/lib/samagotchi/session_continue.rb +3 -1
- data/lib/samagotchi/session_manager.rb +7 -0
- data/lib/samagotchi/tool_output_cut.rb +2 -1
- data/lib/samagotchi/tools/builtins.rb +17 -4
- data/lib/samagotchi/tools/execute.rb +1 -15
- data/lib/samagotchi/tools/task_runtime.rb +75 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -3
- data/lib/samagotchi/web/public/app.js +33 -10
- data/lib/samagotchi/web/public/bubbles.js +4 -2
- data/lib/samagotchi/web/public/chains.js +21 -1
- data/lib/samagotchi/web/public/data.js +9 -0
- data/lib/samagotchi/web/public/index.html +39 -2
- data/lib/samagotchi/web/public/notice_links.js +55 -0
- data/lib/samagotchi/web/public/plain_line.js +55 -0
- data/lib/samagotchi/web/public/question_cards.js +37 -3
- data/lib/samagotchi/web/public/stage_model.js +3 -33
- data/lib/samagotchi/web/public/stage_slots.js +35 -3
- data/lib/samagotchi/web/public/stage_view.js +9 -4
- data/lib/samagotchi/web/public/turn_html.js +3 -2
- data/lib/samagotchi/web/public/turn_model.js +7 -4
- data/lib/samagotchi/web/public/turn_view.js +3 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baa839500eb96519376830a675e787eab121aab4c8de33fa72ffa3f007469643
|
|
4
|
+
data.tar.gz: 17e32518eaf7348ec4a08514a302a14139d71369904602815662186d9dfa584d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f52356f77fe80e1cb56f3d28c4c7844c08ca4291ee3090a89f18a13261b5ee8db5d2b77835da6b756a118e3a30000b5fe2160f3de255f90ee30294728b0edd9
|
|
7
|
+
data.tar.gz: 88d10148534c0e9daaa6b5f97356b9b4f55311a25fa591ca48f70422f5c3cd128d9204b9bf7ea3f65e71c5fd26b7554de1571ddc650311ed1ab0af67eb4073fe
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,53 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.74.0] - 2026-10-17
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Web: a closed step's title and an answered question card's two-line copy of the reply that asked it show that
|
|
16
|
+
reply's text without its markdown syntax ("## Decision needed", "**A**", `` `port` ``, `[text](url)` read as plain
|
|
17
|
+
words); the step's body and the card's open copy stay as written.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- mcp bundle 0.7.1: an MCP server that ignores its stdin's EOF no longer outlives a chi killed with `kill -9` (or
|
|
22
|
+
crashing past Ruby): a watchdog in the server's process group stops the group when chi's end of its pipe closes.
|
|
23
|
+
- A relative `XDG_STATE_HOME` or `XDG_CONFIG_HOME` is taken from the current directory: with a relative state dir,
|
|
24
|
+
an `execute` still running at its limit was killed instead of handed over as a task.
|
|
25
|
+
- A worker started in a session's folder (by `chi`, `chi web` or `chi send`) gets the starter's `XDG_STATE_HOME` and
|
|
26
|
+
`XDG_CONFIG_HOME` made absolute: a relative one no longer points it at another state or config dir.
|
|
27
|
+
- `chi desktop install` / `upgrade` bake a relative `XDG_STATE_HOME` or `XDG_CONFIG_HOME` into the helper's launch
|
|
28
|
+
file made absolute: launchd starts the helper in `/`, where it pointed at `/<dir>`.
|
|
29
|
+
- `execute` and `task_create` pass a relative `XDG_STATE_HOME` or `XDG_CONFIG_HOME` on to their commands made
|
|
30
|
+
absolute: a `chi send` run in another `cwd` no longer reads another state or config dir.
|
|
31
|
+
|
|
32
|
+
Update with `chi update` (the system bundle 0.74.0, mcp 0.7.1), then restart `chi web`.
|
|
33
|
+
|
|
34
|
+
## [0.73.0] - 2026-10-16
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- Web: the stage's asking text (a question's or approval's reply) renders as markdown with clickable links when
|
|
39
|
+
`web.markdown` is on, and so does a resolved question card's copy of it once the card is opened; their
|
|
40
|
+
`path:line` refs are links with markdown off too.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- Web: `continue →` fills the new link's composer with the previous link's own first message when that link was a
|
|
45
|
+
continuation itself (else `Continue where we left off.`), so a chain's opener comes back on every link; a chain
|
|
46
|
+
link's top bar and tab show its card's title whatever opener it was sent.
|
|
47
|
+
- Web: http(s) URLs in a hook's notice line (source-links' `sources:` line with markdown off) are links; they open
|
|
48
|
+
in a new tab.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- A `task_create` task given a `cwd`, or running `cd`, recorded `failed` / `process_ended_without_exit_code` even
|
|
53
|
+
when it succeeded (its exit code was written relative to the shell's directory); a task killed by a signal now
|
|
54
|
+
records 128 + the signal.
|
|
55
|
+
|
|
56
|
+
Update with `chi update` (the system bundle 0.73.0), then restart `chi web`.
|
|
57
|
+
|
|
11
58
|
## [0.72.0] - 2026-10-16
|
|
12
59
|
|
|
13
60
|
### Added
|
|
@@ -3109,7 +3156,9 @@ and long-lived sessions.
|
|
|
3109
3156
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3110
3157
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3111
3158
|
|
|
3112
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3159
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.74.0...HEAD
|
|
3160
|
+
[0.74.0]: https://github.com/dm1try/samagotchi/compare/v0.73.0...v0.74.0
|
|
3161
|
+
[0.73.0]: https://github.com/dm1try/samagotchi/compare/v0.72.0...v0.73.0
|
|
3113
3162
|
[0.72.0]: https://github.com/dm1try/samagotchi/compare/v0.71.0...v0.72.0
|
|
3114
3163
|
[0.71.0]: https://github.com/dm1try/samagotchi/compare/v0.70.0...v0.71.0
|
|
3115
3164
|
[0.70.0]: https://github.com/dm1try/samagotchi/compare/v0.69.0...v0.70.0
|
data/docs/architecture.md
CHANGED
|
@@ -148,7 +148,7 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
|
|
|
148
148
|
| UI | `Samagotchi::TerminalUI` | Interactive REPL (Reline), rendering (ANSI, spinner, status line), REPL commands. Delegates all core work to an `Engine`. |
|
|
149
149
|
| Model loops and adapters | `KernelLoop`, `LLM::ChatLoop`, `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | The model↔tool loops and the HTTP adapters they talk through (see "Model loops and adapters"). |
|
|
150
150
|
| Bridge (SSE/HTTP) | `Samagotchi::Bridge`, `SessionManager` | The **single live client transport**: an SSE read stream + HTTP POST turn/cancel/answer surface that attaches to a worker's existing `Engine` via `Engine#subscribe`. Every session worker starts it (bound `127.0.0.1`, no auth, localhost-only). |
|
|
151
|
-
| Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. `bin/chi web` entrypoint. |
|
|
151
|
+
| Web (Rack) | `Samagotchi::Web::App`, `SessionManager` | Single-port `127.0.0.1:4567` control plane via `rack`+`webrick` (serve `index.html` + `/api/*`). With `web.host: lan` (or one of this machine's IPv4 addresses) the server also listens on that address, where every request but this machine's needs the access token (`App#token_gate`: the `chi_token` cookie or `Authorization: Bearer`; [CLI](cli.md#chi-web-on-your-phone)). It talks to each worker's Bridge (`App#relay`, `bridge_get`; `/stream` proxies it) and reads the session files; no `Engine` of its own. Markdown (`web.markdown`) is rendered here only (`Web::MarkdownRenderer`): the answers it serves, card bodies, and `POST /api/render` for a text the page already shows. `bin/chi web` entrypoint. |
|
|
152
152
|
| Sessions | `Samagotchi::Session`, `SessionManager` | File-based `~/.local/state/samagotchi/sessions/<uuid>.json` + sidecar `input/`/`output/`; retention (14d/500) + ordering (`updated_at desc`). |
|
|
153
153
|
|
|
154
154
|
- `bin/chi` in REPL mode (see `LaunchMode` above) builds `TerminalUI`. `TerminalUI#run` is the single
|
data/docs/cli.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
- A session id can be shortened to any unique prefix (like git): `chi --attach 2ea8`. `--resume`, `--attach`, `sessions stop`, `sessions archive` and `sessions delete` take one; an ambiguous prefix lists the sessions it matches.
|
|
14
14
|
- `chi web [--port 4567] [--open] [--scope=all]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
|
|
15
15
|
- `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
|
|
16
|
-
- `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages
|
|
16
|
+
- `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages, the stage's asking text and a resolved question card's reply
|
|
17
17
|
- `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
|
|
18
18
|
- `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean|stats` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). `stats ID` prints a session's cost, tokens and progress (a live worker's `GET stats`, else its saved `analytics.json`; `--format json` one object) without running a model turn. A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`, a `--days`, `--keep` or `--limit` that isn't a whole number) exits 2; an unknown or refused session exits 1
|
|
19
19
|
- `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
|
|
@@ -698,8 +698,14 @@ web:
|
|
|
698
698
|
```
|
|
699
699
|
|
|
700
700
|
Only finalized assistant messages are rendered; user messages and live streaming
|
|
701
|
-
chunks remain escaped text.
|
|
702
|
-
|
|
701
|
+
chunks remain escaped text. The stage's asking text (the whole reply that asked
|
|
702
|
+
a waiting question or approval) shows raw at once, then as markdown, its links
|
|
703
|
+
opening in a new tab; a resolved question card's copy of that reply renders when
|
|
704
|
+
the card is first opened (its summary line stays plain). A text the page already shows and renders later goes
|
|
705
|
+
through `POST /api/render` (`{text}` → `{html}`, up to 64 KB; `html` is null
|
|
706
|
+
with markdown off), the same renderer and sanitizer. Generated HTML is
|
|
707
|
+
sanitized, raw HTML in model output is not trusted, and unsafe links are
|
|
708
|
+
removed. If Markdown is enabled without
|
|
703
709
|
commonmarker installed, Chi Web keeps the normal escaped-text display and shows a
|
|
704
710
|
warning explaining how to install the optional gem.
|
|
705
711
|
|
data/docs/desktop.md
CHANGED
|
@@ -239,7 +239,8 @@ exports. So `install` writes `~/Library/Application Support/Chi Helper/launch.js
|
|
|
239
239
|
- the absolute path of the Ruby running chi and of chi itself (the gem's wrapper, or a checkout's `bin/chi`);
|
|
240
240
|
- `LANG=en_US.UTF-8`;
|
|
241
241
|
- only these variables, and only when they are set: `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `GEM_HOME`, `GEM_PATH`,
|
|
242
|
-
`RUBYLIB
|
|
242
|
+
`RUBYLIB` (a relative `XDG_*` dir made absolute from the install's current folder: the helper starts in `/`).
|
|
243
|
+
No tokens and no `SAMAGOTCHI_*` settings go in;
|
|
243
244
|
- the settings the panel needs from config.yml, as chi read them then: the `kitty:` section (when
|
|
244
245
|
`kitty.listen_on` is set), the `tmux:` section (when `tmux.enabled`: the tmux binary and the socket dir resolved
|
|
245
246
|
then, and the agents) and `broadcast_timeout` (`broadcast.triage_deadline` + 10 s).
|
data/docs/guardrails.md
CHANGED
|
@@ -155,7 +155,8 @@ chi starts unsets every `SAMAGOTCHI_GUARDRAILS_*` it would inherit. What a
|
|
|
155
155
|
parent's environment does choose is the config dir: `XDG_CONFIG_HOME` picks
|
|
156
156
|
which config.yml chi reads, and a worker the parent starts or wakes (`chi
|
|
157
157
|
send` to a stopped session) reads the one the parent's `XDG_CONFIG_HOME`
|
|
158
|
-
points at
|
|
158
|
+
points at (a relative one made absolute from the parent's directory). chi
|
|
159
|
+
doesn't record the config dir per session.
|
|
159
160
|
|
|
160
161
|
The worker checks it too. `chi answer` marks its answers as a parent's
|
|
161
162
|
(`client_id: "cli:answer"`), and the worker's Bridge refuses such an allow
|
data/docs/hooks.md
CHANGED
|
@@ -124,7 +124,8 @@ links), for a UI that doesn't render that display. The hook never names a UI;
|
|
|
124
124
|
each decides by what it renders. The web with markdown on (`web.markdown` and
|
|
125
125
|
the commonmarker gem) leaves such a line out, live and after a reload; with
|
|
126
126
|
markdown off it shows, as it does in the REPL, the attached TUI and
|
|
127
|
-
`chi -p`, which print the answer as text
|
|
127
|
+
`chi -p`, which print the answer as text (in the web its http(s) URLs are
|
|
128
|
+
links, as in any hook's notice line). The log keeps the line with
|
|
128
129
|
`fallback_for=display`. `:editor` marks a line that names a file the viewer
|
|
129
130
|
could open from a ref in its editor: the web leaves it out for a viewer on
|
|
130
131
|
this machine with `web.editor` set, and every other UI and viewer (a phone,
|
|
@@ -660,7 +661,8 @@ keeps the web links. With markdown on, the web leaves the line out when the
|
|
|
660
661
|
answer links every URL it names, since it would only repeat them as plain
|
|
661
662
|
text ([`fallback_for: :display`](#what-a-hook-can-do-the-runtime)); it shows
|
|
662
663
|
when it names one the answer doesn't link (a ref only in code, two sources on
|
|
663
|
-
one ref) and whenever markdown is off
|
|
664
|
+
one ref) and whenever markdown is off (its URLs are links in the web). Bundle
|
|
665
|
+
0.4.0 needs chi 0.35.0 for that.
|
|
664
666
|
|
|
665
667
|
Each ref the answer links is also a [ref on the
|
|
666
668
|
answer](#refs-on-the-answer-display-only): kind `issue` (or the source's
|
|
@@ -22,15 +22,16 @@ Behavior:
|
|
|
22
22
|
- A task record keeps its process's pid and start time (`pid_started`). Before `task_stop` signals, the pid must still lead its own process group and must not have started at another time; otherwise the pid was reused by another process, nothing is signalled and the task ends as failed. A start time that can't be read at that moment (a failed `ps`) falls back to the group check, as does a record without `pid_started` (from an older chi). `task_get`, `task_list`, `task_wait`'s polls and the running-task lists (a cancelled turn's note, a worker's idle exit) make the same check, so a task whose pid was reused reads as exited (failed) at once, not as running until a `task_stop`; on macOS that is one `ps` per running task per check (~5 ms).
|
|
23
23
|
- A stop (`task_stop`, the user's Stop of an `execute`, the execute limit in kill mode) TERMs the task's process group, then KILLs it after a grace. The groups its processes started are stopped too: a GNU `timeout N cmd` (without `--foreground`) moves itself and its command to a group of its own. `ProcessGroup.stop` finds them with one `ps -A -o pid=,ppid=,pgid=` (~40 ms on macOS) before the TERM and again before the KILL, walking down from the group's members. A process whose parent already exited (reparented to init, e.g. a setsid'd `cmd &`, or a backgrounded `timeout N cmd &` after its shell exited) is not found and runs on.
|
|
24
24
|
- Task listing is workspace-scoped (current project only).
|
|
25
|
-
- `execute` runs its command with stdout and stderr in two logs under `$XDG_STATE_HOME/samagotchi/execute/<id>/` (outside the workspace: `git status` and `rm -rf tmp` never see them), stdin `/dev/null` and the same `exit_code` wrapper as a task. Its own copies of the logs hold a shared `flock` across the spawn: while a fresh exclusive `flock` fails, some process the command started still has them open. When the shell exits, a background child still holding them (`server &`) gets 1.5 s, then its group is stopped; one holding none (`nohup server >log 2>&1 &`) is left alone. A holder that left the group (setsid) is out of the stop's reach: it runs on, the result ends with the "left running" hint, and its dir stays for it to write to (the sweep removes it once no one holds the logs). Otherwise a finished command's dir is removed. Known gap on Linux: a command that writes through `/dev/stdout` or `/dev/stderr` (`echo b >/dev/stdout`) reopens the log file itself with `O_TRUNC`, so the output before it is lost (macOS dups the descriptor; the fix would be a relay pipe). The check needs the state dir on a local file system: over NFS, `flock` is emulated with `fcntl` locks, which children don't inherit.
|
|
26
|
-
- The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. A command that sets its own EXIT trap replaces it: `execute` still reports the shell's status, and a handed-over
|
|
25
|
+
- `execute` runs its command with stdout and stderr in two logs under `$XDG_STATE_HOME/samagotchi/execute/<id>/` (always an absolute path: a relative `XDG_STATE_HOME` is taken from the directory chi started in; outside the workspace: `git status` and `rm -rf tmp` never see them), stdin `/dev/null` and the same `exit_code` wrapper as a task. Its own copies of the logs hold a shared `flock` across the spawn: while a fresh exclusive `flock` fails, some process the command started still has them open. When the shell exits, a background child still holding them (`server &`) gets 1.5 s, then its group is stopped; one holding none (`nohup server >log 2>&1 &`) is left alone. A holder that left the group (setsid) is out of the stop's reach: it runs on, the result ends with the "left running" hint, and its dir stays for it to write to (the sweep removes it once no one holds the logs). Otherwise a finished command's dir is removed. Known gap on Linux: a command that writes through `/dev/stdout` or `/dev/stderr` (`echo b >/dev/stdout`) reopens the log file itself with `O_TRUNC`, so the output before it is lost (macOS dups the descriptor; the fix would be a relay pipe). The check needs the state dir on a local file system: over NFS, `flock` is emulated with `fcntl` locks, which children don't inherit.
|
|
26
|
+
- The exit code: the command runs after an EXIT trap that writes the shell's status to the `exit_code` file (`TaskRuntime.wrapped_shell_command`, shared with `task_create`), so `exit N`, a `set -e` failure and a trailing `\` or `|` in the command all record what the shell exited with. The trap runs wherever the shell is when it exits (a `task_create` `cwd`, or a `cd` in the command), so `task_create`'s `exit_code_path` is absolute; its `output_path` stays relative (`tmp/tasks/<id>/output.log`, opened by chi in the worker's cwd before the spawn). A command that sets its own EXIT trap replaces it, and a shell killed by a signal skips it (zsh's sh mode, on TERM, INT and KILL): `execute` still reports the shell's status, and a `task_create` task or a handed-over execute gets its code from chi's waiter while the chi that started it runs (`TaskRuntime.record_exit_status`): the exit status, or `128 + the signal` (TERM → 143), with no new `stop_reason`. A record a refresh already finished without a code in between (`failed`, `process_ended_without_exit_code` or the not-chi's-process reason) is corrected, its `finished_at` kept; a stopped task (its stop flag on disk) is never touched. `process_ended_without_exit_code` is left only when the shell wrote nothing and the chi that started it was gone too.
|
|
27
27
|
- A stop of an `execute` (the user's Stop, the limit in kill mode) is done once the shell is reaped and nothing holds the logs: a child that ignores TERM after its shell died gets the KILL.
|
|
28
|
-
- A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path
|
|
28
|
+
- A command still running at `execute.timeout_sec` is handed over as a task (`execute.on_timeout: task`, the default; `kill` stops it instead, and the `!cmd` bang always kills): the result starts `Still running at the 120s limit: moved to a background task, not killed.`, then `task_id: …` and the tails of both logs so far. A timeout during the 1.5 s background grace is a kill (the shell is gone, nothing to hand over). If the record can't be written, the command is killed. `TaskRuntime.adopt` writes a record for the process execute already started, with `handed_over_by: "execute"`. Its logs stay where execute wrote them, so the record's paths are absolute: `output_path` (stdout), `stderr_path` (stderr, a log of its own) and `exit_code_path` (a `task_create` record's `exit_code_path` is absolute too; its `output_path` is relative). `started_at` (and `created_at`) is the spawn time, not the hand-over's; `pid_started` is read at the hand-over, while execute's detach waiter is still running. From there it is an ordinary task: `task_wait`, `task_get`, `task_list` and `task_stop` treat it as any other. Its dir gets a `task` back-pointer file holding the record's absolute path. Once per process, the first `execute` sweeps `…/execute/`: a dir whose logs no process holds is removed when it has no back-pointer and is a day old (chi died mid-command), when its record is gone, or when it is 14 days old. The records live in each project's `tmp/tasks`, which the state dir can't list: hence the back-pointer.
|
|
29
29
|
- `task_wait` and `task_get` show `stderr_path` when the record has one; `task_wait`'s output tail is followed by a `stderr_tail:` of at most 10 lines when that log isn't empty. A `task_create` task has one log (stdout and stderr together) and no `stderr_path`. A `task_wait` result longer than `max_tool_output_chars` keeps its header and the tail's end, with a gap line naming `output_path`, where the whole log already is (and `stderr_path`, when the record has one) (`ToolOutputCut`'s `:head_tail_ref`; nothing is copied; see [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars)).
|
|
30
30
|
- Which tasks are a session's own (a cancelled turn's note, the turn-end note, a worker's idle exit, the web's stop-task button): the ids its `task_create` results and its handed-over executes name (`TaskRuntime.created_ids_in`, `created_id`). An execute's id is read only after its first line (`Execute::HANDED_OVER`, through `TaskRuntime.handed_over_id`; the web has a JS twin in `task_stop.js`), not from a `task_id:` line inside a run, which the command can print. (A command that prints the `---` separator chi joins a turn's results with, then a hand-over line, still fakes one: a known gap.) Either result may start with the `ran as: …` line of a gate-corrected call.
|
|
31
31
|
- `task_get` returns metadata and `output_path`; use `read` for output contents.
|
|
32
32
|
- `task_wait` accepts `timeout`, `tail_lines` (maximum 100), and `done_pattern` (a regular expression string).
|
|
33
33
|
- `task_create` accepts `env` as a JSON object string for deterministic overrides such as `PATH`; use an absolute interpreter path when that is simpler. Ruby/Bundler isolation variables remain protected.
|
|
34
34
|
- `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `TaskRuntime::MARKER_ENV_KEYS` (and `SessionManager::SESSION_MARKER_ENV`, which a spawned worker unsets). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
|
|
35
|
+
- They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
|
|
35
36
|
|
|
36
37
|
Work that needs a model, not a shell command, goes to a child chi session instead: `delegate` / `delegate_result` have the same shape (start, then wait) and return only the child's final reply; see [Sessions: Delegating](../sessions.md#delegating).
|
data/docs/plugins.md
CHANGED
|
@@ -508,7 +508,8 @@ end
|
|
|
508
508
|
- A plugin whose load fails after it started services has them stopped.
|
|
509
509
|
- `kill -9` runs nothing: a child process is orphaned then. Most stdio
|
|
510
510
|
servers leave when their stdin closes, which it does as chi's process
|
|
511
|
-
ends.
|
|
511
|
+
ends. The mcp bundle also stops a server that doesn't (see
|
|
512
|
+
[The mcp bundle](#the-mcp-bundle), Stop).
|
|
512
513
|
|
|
513
514
|
### `chi.init(label, provides_tools: false, quiet: false, timeout: nil, failed: nil) { |ctx| … }`
|
|
514
515
|
|
|
@@ -1150,7 +1151,13 @@ bundles:
|
|
|
1150
1151
|
the notice says so, and its calls fail at once until chi restarts (a new
|
|
1151
1152
|
session, or the worker's next start).
|
|
1152
1153
|
- **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
|
|
1153
|
-
closed, then TERM and KILL go to the server's process group.
|
|
1154
|
+
closed, then TERM and KILL go to the server's process group. A chi
|
|
1155
|
+
killed with `kill -9` (or crashing past Ruby) stops nothing itself; a
|
|
1156
|
+
server that ignores its stdin's EOF would live on then. So each server's
|
|
1157
|
+
group also holds a watchdog, a `/bin/sh` waiting on a pipe only chi
|
|
1158
|
+
holds: when that pipe closes without chi letting it go, it gives the
|
|
1159
|
+
server a second, then TERMs and KILLs its group (the server's helpers
|
|
1160
|
+
too). A server that leaves its group (`setsid`) is out of its reach.
|
|
1154
1161
|
- **`/mcp`** (anytime) shows a card with the servers, their state (cached
|
|
1155
1162
|
(not started), running with its pid, failed with its count, stopped) and
|
|
1156
1163
|
their tools as `<server>/<tool>`, the name `call_tool` takes.
|
data/docs/sessions.md
CHANGED
|
@@ -76,7 +76,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
76
76
|
- `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.
|
|
77
77
|
- 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; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; 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). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). 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). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); 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 (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); 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, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; 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). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). 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 model picked; 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).
|
|
78
78
|
- 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).
|
|
79
|
-
- 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; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `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), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
79
|
+
- 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; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `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), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
|
|
80
80
|
- 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).
|
|
81
81
|
|
|
82
82
|
**Test-session hygiene:**
|
|
@@ -215,8 +215,8 @@ A session can **continue** an earlier one: "the next day of the same routine" (a
|
|
|
215
215
|
- **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`SessionContinue::Refused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that is open in a chi REPL (close it there). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
|
|
216
216
|
- **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
|
|
217
217
|
- **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
|
|
218
|
-
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
|
|
219
|
-
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with `Continue where we left off
|
|
218
|
+
- **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
|
|
219
|
+
- **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its preview and id in the tooltip. A link made by `continue →` inherits the previous link's title when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ day N` chip (`day N of M` when later links aren't listed) opens a popover of the other links, newest first (`day N`, its date, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `day N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
|
|
220
220
|
- **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
|
|
221
221
|
- **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
|
|
222
222
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.7.
|
|
3
|
+
version: 0.7.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; their tools go into chi's tool index: the model searches them with find_tools and calls one with call_tool (<server>/<tool>); /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:67b1ed9337236deb366459aaaed4e49f0d3122a0b38f3ff9328babcd332c0182
|
|
11
11
|
# 0.67.0 is the release with chi.tool_source (the core tool index the bundle publishes into).
|
|
12
12
|
requires_chi: ">= 0.67.0"
|
|
@@ -76,6 +76,15 @@ class Plugin
|
|
|
76
76
|
# How long close waits for the server after closing its stdin, after
|
|
77
77
|
# TERM, and after KILL.
|
|
78
78
|
STOP_GRACE_SECONDS = 1
|
|
79
|
+
# The server's watchdog (#start_watchdog), a shell in its process group
|
|
80
|
+
# whose stdin is a pipe only chi holds. A line on it (#close) lets it
|
|
81
|
+
# go; EOF means chi ended without closing the server (kill -9, a crash
|
|
82
|
+
# past Ruby), whose stdin then hit EOF too: a grace for the server to
|
|
83
|
+
# leave by itself, then TERM and KILL to the group, the way #close stops
|
|
84
|
+
# it. `kill 0` signals the shell's own group: while it runs, that group
|
|
85
|
+
# (and so its id) can't be anyone else's.
|
|
86
|
+
WATCHDOG = "trap '' TERM; read -r _ && exit 0; sleep #{STOP_GRACE_SECONDS}; " \
|
|
87
|
+
"kill -TERM 0; sleep #{STOP_GRACE_SECONDS}; kill -KILL 0".freeze
|
|
79
88
|
|
|
80
89
|
attr_reader :pid
|
|
81
90
|
|
|
@@ -101,6 +110,7 @@ class Plugin
|
|
|
101
110
|
@stdin, @stdout, @stderr, @wait = Open3.popen3(env.to_h { |k, v| [k.to_s, v.to_s] }, *command,
|
|
102
111
|
chdir: cwd, pgroup: true)
|
|
103
112
|
@pid = @wait.pid
|
|
113
|
+
start_watchdog
|
|
104
114
|
@reader = Thread.new { read_loop }
|
|
105
115
|
@err_reader = Thread.new { stderr_loop }
|
|
106
116
|
rescue SystemCallError => e
|
|
@@ -154,7 +164,7 @@ class Plugin
|
|
|
154
164
|
|
|
155
165
|
# End the process: stdin closed first (most servers leave then), then
|
|
156
166
|
# TERM and KILL to its process group (chi's ProcessGroup), a second's
|
|
157
|
-
# grace each.
|
|
167
|
+
# grace each. Then the watchdog is let go.
|
|
158
168
|
def close
|
|
159
169
|
@mutex.synchronize { @closing = true }
|
|
160
170
|
[@stdin].each { |io| io.close unless io.closed? }
|
|
@@ -163,6 +173,7 @@ class Plugin
|
|
|
163
173
|
stopped: -> { !@wait.alive? }, wait: ->(seconds) { @wait.join(seconds) })
|
|
164
174
|
@wait.join(STOP_GRACE_SECONDS)
|
|
165
175
|
end
|
|
176
|
+
release_watchdog
|
|
166
177
|
[@stdout, @stderr].each { |io| io.close unless io.closed? }
|
|
167
178
|
[@reader, @err_reader].each { |t| t.join(1) }
|
|
168
179
|
nil
|
|
@@ -172,6 +183,35 @@ class Plugin
|
|
|
172
183
|
|
|
173
184
|
private
|
|
174
185
|
|
|
186
|
+
# A server outlives a chi killed without #close when it ignores its
|
|
187
|
+
# stdin's EOF (a Node server with another live handle does): the
|
|
188
|
+
# watchdog (WATCHDOG) stops its group then. One that can't start (the
|
|
189
|
+
# server already left its group) is logged and skipped.
|
|
190
|
+
def start_watchdog
|
|
191
|
+
reader, @watchdog_pipe = IO.pipe
|
|
192
|
+
watchdog = Process.spawn("/bin/sh", "-c", WATCHDOG, in: reader, out: File::NULL, err: File::NULL, pgroup: @pid)
|
|
193
|
+
@watchdog = Process.detach(watchdog)
|
|
194
|
+
rescue SystemCallError => e
|
|
195
|
+
@log.call("watchdog_failed", error: e.class.name)
|
|
196
|
+
@watchdog_pipe&.close
|
|
197
|
+
@watchdog_pipe = nil
|
|
198
|
+
ensure
|
|
199
|
+
reader&.close
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A line tells the watchdog chi stopped the server itself.
|
|
203
|
+
def release_watchdog
|
|
204
|
+
return unless @watchdog_pipe
|
|
205
|
+
|
|
206
|
+
begin
|
|
207
|
+
@watchdog_pipe.write("\n")
|
|
208
|
+
rescue IOError, SystemCallError # gone already: the group's KILL reached it
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
@watchdog_pipe.close unless @watchdog_pipe.closed?
|
|
212
|
+
@watchdog&.join(STOP_GRACE_SECONDS)
|
|
213
|
+
end
|
|
214
|
+
|
|
175
215
|
def write(message)
|
|
176
216
|
line = JSON.generate(message)
|
|
177
217
|
@write_mutex.synchronize do
|
|
@@ -13,6 +13,7 @@ require_relative "../installed_gem"
|
|
|
13
13
|
require_relative "../config"
|
|
14
14
|
require_relative "../host_keys"
|
|
15
15
|
require_relative "../redacted_inspect"
|
|
16
|
+
require_relative "../paths"
|
|
16
17
|
|
|
17
18
|
module Samagotchi
|
|
18
19
|
module Desktop
|
|
@@ -146,9 +147,13 @@ module Samagotchi
|
|
|
146
147
|
# RubyGems wrapper (it activates the gem and outlives upgrades and
|
|
147
148
|
# `gem cleanup`), else this checkout's bin/chi. A section per terminal
|
|
148
149
|
# switched on (TERMINALS order); the broadcast timeout when given.
|
|
150
|
+
# The XDG dirs go in absolute (Paths): launchd starts the app in /,
|
|
151
|
+
# where a relative one would mean /<dir>.
|
|
149
152
|
def launch_config
|
|
150
153
|
env = { "LANG" => LANG }
|
|
151
154
|
ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
155
|
+
env["XDG_CONFIG_HOME"] = Paths.config_home(env: @env) if env.key?("XDG_CONFIG_HOME")
|
|
156
|
+
env["XDG_STATE_HOME"] = Paths.state_home(env: @env) if env.key?("XDG_STATE_HOME")
|
|
152
157
|
config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
153
158
|
TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
|
|
154
159
|
config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
|
data/lib/samagotchi/paths.rb
CHANGED
|
@@ -5,17 +5,19 @@ module Samagotchi
|
|
|
5
5
|
# every call (specs and smoke runs point XDG_* elsewhere after load):
|
|
6
6
|
# state: $XDG_STATE_HOME or ~/.local/state (sessions, logs, history, plugin data)
|
|
7
7
|
# config: $XDG_CONFIG_HOME or ~/.config (config.yml, memories, hooks)
|
|
8
|
-
# A blank variable counts as unset.
|
|
9
|
-
#
|
|
8
|
+
# A blank variable counts as unset. A relative one is made absolute from
|
|
9
|
+
# the current directory (File.absolute_path: no ~ expansion), so a command
|
|
10
|
+
# run in another cwd (execute's logs and exit_code file) still finds it.
|
|
11
|
+
# The samagotchi folder inside each is #state_dir / ConfigFile.config_dir.
|
|
10
12
|
module Paths
|
|
11
13
|
APP_DIR = "samagotchi"
|
|
12
14
|
|
|
13
15
|
module_function
|
|
14
16
|
|
|
15
|
-
# @return [String] $XDG_STATE_HOME, else ~/.local/state
|
|
17
|
+
# @return [String] $XDG_STATE_HOME (absolute), else ~/.local/state
|
|
16
18
|
def state_home(env: ENV)
|
|
17
19
|
xdg = env.fetch("XDG_STATE_HOME", "").to_s.strip
|
|
18
|
-
xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
|
|
20
|
+
xdg.empty? ? File.join(Dir.home, ".local", "state") : File.absolute_path(xdg)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
# @return [String] <state home>/samagotchi
|
|
@@ -23,10 +25,10 @@ module Samagotchi
|
|
|
23
25
|
File.join(state_home(env: env), APP_DIR)
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
# @return [String] $XDG_CONFIG_HOME, else ~/.config
|
|
28
|
+
# @return [String] $XDG_CONFIG_HOME (absolute), else ~/.config
|
|
27
29
|
def config_home(env: ENV)
|
|
28
30
|
xdg = env.fetch("XDG_CONFIG_HOME", "").to_s.strip
|
|
29
|
-
xdg.empty? ? File.expand_path("~/.config") : xdg
|
|
31
|
+
xdg.empty? ? File.expand_path("~/.config") : File.absolute_path(xdg)
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# +path+ as chi shows it: ~ for the home folder (~/a for one in it),
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -220,6 +220,11 @@ module Samagotchi
|
|
|
220
220
|
# SessionManager.continue_session sets it); not saved.
|
|
221
221
|
attr_accessor :moved_children
|
|
222
222
|
|
|
223
|
+
# The text web `continue →` prefills in this new link
|
|
224
|
+
# (SessionChain.opener_for; SessionManager.continue_session sets it); not
|
|
225
|
+
# saved.
|
|
226
|
+
attr_accessor :opener
|
|
227
|
+
|
|
223
228
|
def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
|
|
224
229
|
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
225
230
|
first_preview: "", test_run: false, pending_question: nil,
|
|
@@ -78,6 +78,30 @@ module Samagotchi
|
|
|
78
78
|
"#{head} Its recap#{", as of #{as_of}" if as_of}:\n#{text}"
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# What web `continue →` prefills in +previous+'s next link: +previous+'s
|
|
82
|
+
# own first typed message when +previous+ is itself a continuation (so a
|
|
83
|
+
# chain's opener comes back on every link, edited once for all later
|
|
84
|
+
# ones), else nil (a chain's first link's first message is its task; the
|
|
85
|
+
# page falls back to its constant). Typed means a user message with text
|
|
86
|
+
# and no kind: Engine#turn_messages' (turn_id), or a checkpointed prompt
|
|
87
|
+
# (none); a stored `!shell` line ("!(cmd)\n<output>", no turn_id) is not.
|
|
88
|
+
# @param previous [Session]
|
|
89
|
+
# @return [String, nil]
|
|
90
|
+
def opener_for(previous)
|
|
91
|
+
return nil unless previous.continues
|
|
92
|
+
|
|
93
|
+
previous.messages.each do |msg|
|
|
94
|
+
next unless msg[:role] == "user" && msg[:kind].nil?
|
|
95
|
+
|
|
96
|
+
text = msg[:content]
|
|
97
|
+
next unless text.is_a?(String) && !text.strip.empty?
|
|
98
|
+
next if msg[:turn_id].nil? && text.start_with?("!(")
|
|
99
|
+
|
|
100
|
+
return text.strip
|
|
101
|
+
end
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
81
105
|
def day(iso)
|
|
82
106
|
Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
|
|
83
107
|
rescue ArgumentError
|
|
@@ -86,7 +86,8 @@ module Samagotchi
|
|
|
86
86
|
# @param title [String, nil] what the lists show before the first turn;
|
|
87
87
|
# by default the prompt, else the previous link's preview
|
|
88
88
|
# @return [Session] the new link (#model_warning as spawn_session's,
|
|
89
|
-
# #moved_children: the delegates it took over
|
|
89
|
+
# #moved_children: the delegates it took over, #opener:
|
|
90
|
+
# SessionChain.opener_for the previous link)
|
|
90
91
|
# @raise [ArgumentError] no such session (Session::AmbiguousId for a prefix of several)
|
|
91
92
|
# @raise [Refused] continued already, delegates open that can't
|
|
92
93
|
# move, or its folder is gone
|
|
@@ -208,6 +209,7 @@ module Samagotchi
|
|
|
208
209
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
209
210
|
)
|
|
210
211
|
link.moved_children = move
|
|
212
|
+
link.opener = SessionChain.opener_for(previous)
|
|
211
213
|
# The fallback's warning first: spawn_session set its own.
|
|
212
214
|
warnings = [choice.warning, link.model_warning].compact
|
|
213
215
|
link.model_warning = warnings.join("; ") unless warnings.empty?
|
|
@@ -25,6 +25,7 @@ require_relative "worker_sidecar"
|
|
|
25
25
|
require_relative "worker_signals"
|
|
26
26
|
require_relative "log"
|
|
27
27
|
require_relative "log_path"
|
|
28
|
+
require_relative "paths"
|
|
28
29
|
require_relative "model_profile"
|
|
29
30
|
require_relative "installed_gem"
|
|
30
31
|
require_relative "installed_versions"
|
|
@@ -213,6 +214,8 @@ module Samagotchi
|
|
|
213
214
|
# SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
|
|
214
215
|
# XDG_CONFIG_HOME passes: whoever starts or
|
|
215
216
|
# wakes the worker picks which config.yml it reads (docs/guardrails.md).
|
|
217
|
+
# XDG_STATE_HOME and XDG_CONFIG_HOME, when set, pass made absolute
|
|
218
|
+
# (Paths): the worker starts in the session's directory.
|
|
216
219
|
private_class_method def self.spawn_options(session)
|
|
217
220
|
# Own process group: workers outlive `chi web`, and a Ctrl-C in its
|
|
218
221
|
# terminal must not reach them (the worker then leaves the process
|
|
@@ -239,6 +242,10 @@ module Samagotchi
|
|
|
239
242
|
else
|
|
240
243
|
child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
|
|
241
244
|
end
|
|
245
|
+
# And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
|
|
246
|
+
# XDG_CONFIG_HOME would otherwise be read from the session's directory.
|
|
247
|
+
child_env["XDG_STATE_HOME"] = Paths.state_home unless ENV.fetch("XDG_STATE_HOME", "").to_s.strip.empty?
|
|
248
|
+
child_env["XDG_CONFIG_HOME"] = Paths.config_home unless ENV.fetch("XDG_CONFIG_HOME", "").to_s.strip.empty?
|
|
242
249
|
# The spawner's environment must not reach the worker's guardrails (a
|
|
243
250
|
# parent agent starting or waking a session): Process.spawn unsets a
|
|
244
251
|
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
@@ -64,7 +64,8 @@ module Samagotchi
|
|
|
64
64
|
# command has none: its output isn't all there is. A command a signal
|
|
65
65
|
# ended (a segfault, an OOM kill) has an exit line with no number
|
|
66
66
|
# ("exit: "): it ended, so its output is all there is. The JS twin is
|
|
67
|
-
# turn_html.js's EXIT_LINE.
|
|
67
|
+
# turn_html.js's EXIT_LINE; spec/fixtures/exit_lines.json holds the
|
|
68
|
+
# cases both must answer alike (both specs run it).
|
|
68
69
|
STDOUT_LABEL = "\nstdout:\n"
|
|
69
70
|
STDERR_LABEL = "\nstderr:\n"
|
|
70
71
|
EXIT_LINE = /\nexit: (?:-?\d+)?[^\n]*(?:\n\([^\n]*\))?\z/
|