samagotchi 0.73.0 → 0.75.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 +46 -1
- data/docs/desktop.md +2 -1
- data/docs/guardrails.md +2 -1
- data/docs/internals/background-tasks.md +2 -1
- data/docs/internals/bundles.md +2 -1
- data/docs/memory.md +14 -1
- data/docs/plugins.md +10 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +4 -4
- 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/memory_bundle/installer.rb +18 -4
- data/lib/samagotchi/memory_bundle/memory_files.rb +47 -0
- data/lib/samagotchi/paths.rb +8 -6
- data/lib/samagotchi/session.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -8
- 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/task_runtime.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/chains.js +42 -21
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/info_bar.js +4 -4
- data/lib/samagotchi/web/public/info_bar_model.js +16 -7
- data/lib/samagotchi/web/public/lists_view.js +2 -2
- data/lib/samagotchi/web/public/plain_line.js +55 -0
- data/lib/samagotchi/web/public/question_cards.js +4 -2
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/stage_model.js +3 -33
- data/lib/samagotchi/web/public/turn_html.js +3 -2
- data/lib/samagotchi/web/public/turn_model.js +7 -4
- 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: 583a10afc0042a87287fe62bb6e0e73aae26f69de5668c7926178d951c4fdc04
|
|
4
|
+
data.tar.gz: 9f2e0d659bf688b83b85fdff65a7f55e39bef32051af15a00abab893bb3d767e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cefe884285bab628351d25729aa036d076d002830e904e8362a335a50e47d83f8ba878b8ca0127b31943ca14715e8c962937566d22849a9af1658e94dab87ff
|
|
7
|
+
data.tar.gz: da32e58a43000c308c7fd88addfb66655baadd2f521a915d836cb4e60219a54117f603fed100166529fe0dbfab21801e6f0feae4b6ef8d026060f4747d6440b1
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,49 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.75.0] - 2026-10-17
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Web: a session chain's links are numbered `#N` instead of `day N` (the chip `↩ #3`, `#2 of 3`; its popover's rows),
|
|
16
|
+
since a chain may be continued several times a day. The info bar's ← / → show the neighbour's time (`← 09:12`) when
|
|
17
|
+
it started on the open link's day, else its date, with its full start in the tooltip; a popover row adds its time
|
|
18
|
+
when another link of the chain started the same day. The carry note names the previous link's start with its time
|
|
19
|
+
(`This session continues 3fa2c1d0 (2026-10-17 09:12), …`).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `chi bundle install` / `upgrade` no longer make a bundle repo's README, CHANGELOG, LICENSE, CONTRIBUTING,
|
|
24
|
+
CODE_OF_CONDUCT or SECURITY file a memory, and a manifest whose `files:` lists memories installs only those (an
|
|
25
|
+
unshipped name warns). An upgrade moves such a file an older install made a memory to the trash. Older chi still
|
|
26
|
+
installs every top-level `*.md` (`requires_chi` doesn't gate memories): a bundle for it too names its readme
|
|
27
|
+
`README.markdown`. `rake bundles:check` fails a shipped bundle's `*.md` that its `files:` doesn't list.
|
|
28
|
+
|
|
29
|
+
Update with `chi update` (the system bundle 0.75.0), then restart `chi web`.
|
|
30
|
+
|
|
31
|
+
## [0.74.0] - 2026-10-17
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Web: a closed step's title and an answered question card's two-line copy of the reply that asked it show that
|
|
36
|
+
reply's text without its markdown syntax ("## Decision needed", "**A**", `` `port` ``, `[text](url)` read as plain
|
|
37
|
+
words); the step's body and the card's open copy stay as written.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- mcp bundle 0.7.1: an MCP server that ignores its stdin's EOF no longer outlives a chi killed with `kill -9` (or
|
|
42
|
+
crashing past Ruby): a watchdog in the server's process group stops the group when chi's end of its pipe closes.
|
|
43
|
+
- A relative `XDG_STATE_HOME` or `XDG_CONFIG_HOME` is taken from the current directory: with a relative state dir,
|
|
44
|
+
an `execute` still running at its limit was killed instead of handed over as a task.
|
|
45
|
+
- A worker started in a session's folder (by `chi`, `chi web` or `chi send`) gets the starter's `XDG_STATE_HOME` and
|
|
46
|
+
`XDG_CONFIG_HOME` made absolute: a relative one no longer points it at another state or config dir.
|
|
47
|
+
- `chi desktop install` / `upgrade` bake a relative `XDG_STATE_HOME` or `XDG_CONFIG_HOME` into the helper's launch
|
|
48
|
+
file made absolute: launchd starts the helper in `/`, where it pointed at `/<dir>`.
|
|
49
|
+
- `execute` and `task_create` pass a relative `XDG_STATE_HOME` or `XDG_CONFIG_HOME` on to their commands made
|
|
50
|
+
absolute: a `chi send` run in another `cwd` no longer reads another state or config dir.
|
|
51
|
+
|
|
52
|
+
Update with `chi update` (the system bundle 0.74.0, mcp 0.7.1), then restart `chi web`.
|
|
53
|
+
|
|
11
54
|
## [0.73.0] - 2026-10-16
|
|
12
55
|
|
|
13
56
|
### Added
|
|
@@ -3133,7 +3176,9 @@ and long-lived sessions.
|
|
|
3133
3176
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3134
3177
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3135
3178
|
|
|
3136
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3179
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.75.0...HEAD
|
|
3180
|
+
[0.75.0]: https://github.com/dm1try/samagotchi/compare/v0.74.0...v0.75.0
|
|
3181
|
+
[0.74.0]: https://github.com/dm1try/samagotchi/compare/v0.73.0...v0.74.0
|
|
3137
3182
|
[0.73.0]: https://github.com/dm1try/samagotchi/compare/v0.72.0...v0.73.0
|
|
3138
3183
|
[0.72.0]: https://github.com/dm1try/samagotchi/compare/v0.71.0...v0.72.0
|
|
3139
3184
|
[0.71.0]: https://github.com/dm1try/samagotchi/compare/v0.70.0...v0.71.0
|
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
|
|
@@ -22,7 +22,7 @@ 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.
|
|
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
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
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.
|
|
@@ -32,5 +32,6 @@ Behavior:
|
|
|
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/internals/bundles.md
CHANGED
|
@@ -49,7 +49,8 @@ The order matters: warnings print in it, and a plugin/script the manifest names
|
|
|
49
49
|
|
|
50
50
|
1. `load_source`: normalize, read `manifest.yml` (strict: a missing/invalid one raises; non-strict: a warning).
|
|
51
51
|
2. `resolve_target`: scope = CLI > manifest > `system`.
|
|
52
|
-
3. `install_memories`, per
|
|
52
|
+
3. `install_memories`, per memory (`MemoryFiles.select`: a non-empty `files:` list, else every top-level `*.md` but
|
|
53
|
+
README, CHANGELOG, LICENSE and the like; a listed name not shipped warns): an upgrade 3-way merges the files the bundle owns (`merge_memory`), a dry run only
|
|
53
54
|
classifies (`preview_memory`), a plain install writes new files and skips existing ones (`skip_existing`). A
|
|
54
55
|
same-name file the bundle didn't write is the user's: skipped and never recorded (`@owned`). Model overlays
|
|
55
56
|
(`<name>.<key>.md`) get no index line (`note_overlay`).
|
data/docs/memory.md
CHANGED
|
@@ -199,7 +199,7 @@ step instead of fixing the skill.
|
|
|
199
199
|
## What a bundle owns
|
|
200
200
|
|
|
201
201
|
A bundle owns the memory files it wrote, nothing else. `chi bundle install`
|
|
202
|
-
copies each of its
|
|
202
|
+
copies each of its memories into the scope dir; a file of the same name
|
|
203
203
|
that is already there is left as it is and reported `Skipped` (`--force`
|
|
204
204
|
overwrites it, and then the bundle owns it). A skipped file stays yours: the
|
|
205
205
|
bundle doesn't record it, so `chi bundle upgrade` never merges into it and
|
|
@@ -207,6 +207,19 @@ bundle doesn't record it, so `chi bundle upgrade` never merges into it and
|
|
|
207
207
|
for `chi bundle build` then installing the result on the same machine, and
|
|
208
208
|
for a file another bundle installed.
|
|
209
209
|
|
|
210
|
+
- **Which files are memories.** A manifest whose `files:` lists any: exactly
|
|
211
|
+
those (a listed name the bundle doesn't ship gets a warning). Without a
|
|
212
|
+
list (no `files:`, or `files: {}`): every top-level `*.md` except a repo's
|
|
213
|
+
meta files: README, CHANGELOG, LICENSE (or LICENCE), CONTRIBUTING,
|
|
214
|
+
CODE_OF_CONDUCT and SECURITY, in any case. So a bundle kept in its own git
|
|
215
|
+
repo can have a `README.md` without it becoming a memory. A listed file is
|
|
216
|
+
installed whatever its name (`chi bundle build` of your own `security.md`
|
|
217
|
+
lists it). Since chi 0.75.0. Older chi installs every top-level `*.md`
|
|
218
|
+
whatever `requires_chi` says (it gates hooks, rules and the plugin, not
|
|
219
|
+
memories), so a bundle for older chi too names its readme
|
|
220
|
+
`README.markdown` (or puts docs in a subdirectory).
|
|
221
|
+
`chi bundle upgrade` moves a README an older install made a memory to the
|
|
222
|
+
trash (unless you edited it, like any file a bundle stops shipping).
|
|
210
223
|
- **The index says where a memory came from.** The `index.md` line of a file
|
|
211
224
|
a bundle installed ends in `· from <bundle>`, before the description:
|
|
212
225
|
`- **memory_guide** · system · 2026-10-03 · 5120 · from samagotchi-system —
|
data/docs/plugins.md
CHANGED
|
@@ -15,6 +15,7 @@ my-bundle/
|
|
|
15
15
|
manifest.yml
|
|
16
16
|
plugin.rb
|
|
17
17
|
identity.md # optional, like any bundle's memories, hooks/ and guardrails/
|
|
18
|
+
README.md # never a memory (nor CHANGELOG, LICENSE, ...): see memory.md#what-a-bundle-owns
|
|
18
19
|
```
|
|
19
20
|
|
|
20
21
|
```yaml
|
|
@@ -508,7 +509,8 @@ end
|
|
|
508
509
|
- A plugin whose load fails after it started services has them stopped.
|
|
509
510
|
- `kill -9` runs nothing: a child process is orphaned then. Most stdio
|
|
510
511
|
servers leave when their stdin closes, which it does as chi's process
|
|
511
|
-
ends.
|
|
512
|
+
ends. The mcp bundle also stops a server that doesn't (see
|
|
513
|
+
[The mcp bundle](#the-mcp-bundle), Stop).
|
|
512
514
|
|
|
513
515
|
### `chi.init(label, provides_tools: false, quiet: false, timeout: nil, failed: nil) { |ctx| … }`
|
|
514
516
|
|
|
@@ -1150,7 +1152,13 @@ bundles:
|
|
|
1150
1152
|
the notice says so, and its calls fail at once until chi restarts (a new
|
|
1151
1153
|
session, or the worker's next start).
|
|
1152
1154
|
- **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
|
|
1153
|
-
closed, then TERM and KILL go to the server's process group.
|
|
1155
|
+
closed, then TERM and KILL go to the server's process group. A chi
|
|
1156
|
+
killed with `kill -9` (or crashing past Ruby) stops nothing itself; a
|
|
1157
|
+
server that ignores its stdin's EOF would live on then. So each server's
|
|
1158
|
+
group also holds a watchdog, a `/bin/sh` waiting on a pipe only chi
|
|
1159
|
+
holds: when that pipe closes without chi letting it go, it gives the
|
|
1160
|
+
server a second, then TERMs and KILLs its group (the server's helpers
|
|
1161
|
+
too). A server that leaves its group (`setsid`) is out of its reach.
|
|
1154
1162
|
- **`/mcp`** (anytime) shows a card with the servers, their state (cached
|
|
1155
1163
|
(not started), running with its pid, failed with its count, stopped) and
|
|
1156
1164
|
their tools as `<server>/<tool>`, the name `call_tool` takes.
|
data/docs/releasing.md
CHANGED
|
@@ -55,7 +55,7 @@ as the GitHub release body.
|
|
|
55
55
|
| Task | What it does |
|
|
56
56
|
| --- | --- |
|
|
57
57
|
| `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `hooks:`, `plugin:`). |
|
|
58
|
-
| `rake bundles:check` | Sha lines match; a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: mark it `requires_chi_next` if the change uses anything new. Notes a bundle marked `requires_chi_next`. The release workflow and `release:check` run it in full. |
|
|
58
|
+
| `rake bundles:check` | Sha lines match; every top-level `*.md` of a bundle is listed in its `files:` (an install takes only the listed ones); a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: mark it `requires_chi_next` if the change uses anything new. Notes a bundle marked `requires_chi_next`. The release workflow and `release:check` run it in full. |
|
|
59
59
|
| `rake 'bundles:check[ci]'` | The CI workflow's (every push and PR): the same, except the version check leaves out the system bundle, which is changed and unbumped between releases (`release:bump` moves its version with VERSION). A task argument, not `CI=1`: GitHub sets `CI` in the release workflow too. |
|
|
60
60
|
| `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
|
|
61
61
|
| `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped). No commit. |
|
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
|
|
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 `#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:**
|
|
@@ -204,19 +204,19 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
204
204
|
|
|
205
205
|
## Session chains
|
|
206
206
|
|
|
207
|
-
A session can **continue** an earlier one: "the next
|
|
207
|
+
A session can **continue** an earlier one: "the next run of the same routine" (a daily coordinator, say, continued once a day or several times). The new session's `continues` field names the previous link; the chain is derived from it, never stored: follow `continues` back for the earlier links, forward for the later ones. It is not a parent: archiving one link leaves the next alone, and lists fold delegates, not links. A link is continued at most once, so a chain has no forks and its latest link is the one nobody continues.
|
|
208
208
|
|
|
209
209
|
`SessionManager.continue_session` makes the next link:
|
|
210
210
|
|
|
211
211
|
- **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message.
|
|
212
|
-
- **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
|
|
212
|
+
- **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
|
|
213
213
|
- **The recap first:** a previous link with a live worker is asked for a recap (the Bridge's `POST /recap`) and given up to 30 s to write it; one with none wrote its recap when its worker left.
|
|
214
214
|
- **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a plain REPL's session is refused. Its finished delegates are archived with it, as any archive does.
|
|
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
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
|
|
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 id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). 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 `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, 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 `#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
|
|
@@ -11,6 +11,7 @@ require_relative "index_updater"
|
|
|
11
11
|
require_relative "merger"
|
|
12
12
|
require_relative "trash"
|
|
13
13
|
require_relative "asset_installer"
|
|
14
|
+
require_relative "memory_files"
|
|
14
15
|
require_relative "../bundle_needs"
|
|
15
16
|
require_relative "../model_overlay"
|
|
16
17
|
|
|
@@ -167,7 +168,8 @@ module Samagotchi
|
|
|
167
168
|
[scope, dir]
|
|
168
169
|
end
|
|
169
170
|
|
|
170
|
-
# Copies the bundle's
|
|
171
|
+
# Copies the bundle's memories (MemoryFiles: the files: list, else
|
|
172
|
+
# every *.md but README and the like) into the target dir and refreshes
|
|
171
173
|
# their index lines: an upgrade merges (merge_memory), a dry run only
|
|
172
174
|
# classifies (preview_memory), a plain install writes the new ones.
|
|
173
175
|
# Sets @bundle_md (every file the bundle ships) and @owned: the bundle
|
|
@@ -178,8 +180,9 @@ module Samagotchi
|
|
|
178
180
|
owned_before = @existing ? @existing.files.keys : []
|
|
179
181
|
@bundle_md = []
|
|
180
182
|
@owned = []
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
warn_unshipped_files
|
|
184
|
+
memory_files.each do |file_key|
|
|
185
|
+
file_path = File.join(@bundle_dir, file_key)
|
|
183
186
|
@bundle_md << file_key
|
|
184
187
|
note_overlay(file_key, @target_dir)
|
|
185
188
|
target_path = File.join(@target_dir, file_key)
|
|
@@ -206,6 +209,17 @@ module Samagotchi
|
|
|
206
209
|
end
|
|
207
210
|
end
|
|
208
211
|
|
|
212
|
+
# The bundle's memories' basenames (MemoryFiles.select), once.
|
|
213
|
+
def memory_files
|
|
214
|
+
@memory_files ||= MemoryFiles.select(@bundle_dir, @manifest)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A files: entry with no such *.md in the bundle installs nothing.
|
|
218
|
+
def warn_unshipped_files
|
|
219
|
+
missing = MemoryFiles.unshipped(@bundle_dir, @manifest)
|
|
220
|
+
@warnings << "files: names #{missing.join(", ")}, which the bundle doesn't ship (not installed)" unless missing.empty?
|
|
221
|
+
end
|
|
222
|
+
|
|
209
223
|
# An upgrade's 3-way merge of one file (Merger.classify against the
|
|
210
224
|
# base the last install recorded); a conflict leaves the local file
|
|
211
225
|
# and is recorded in @conflicts for the agent step.
|
|
@@ -431,7 +445,7 @@ module Samagotchi
|
|
|
431
445
|
# An overlay-looking name with no base anywhere is an overlay too,
|
|
432
446
|
# with a warning (a dry run warns as well).
|
|
433
447
|
def note_overlay(file_key, target_dir)
|
|
434
|
-
@bundle_files ||=
|
|
448
|
+
@bundle_files ||= memory_files
|
|
435
449
|
return unless ModelOverlay.bundle_overlay?(file_key, bundle_files: @bundle_files, target_dir: target_dir)
|
|
436
450
|
|
|
437
451
|
@overlays << file_key
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module MemoryBundle
|
|
5
|
+
# Which of a bundle dir's top-level *.md files are its memories (the
|
|
6
|
+
# ones an install copies into the scope dir, docs/memory.md):
|
|
7
|
+
# - a manifest whose files: lists any: exactly those (that exist);
|
|
8
|
+
# - else (no manifest, no files: key, or files: {}): every *.md but a
|
|
9
|
+
# repo's meta files (README, CHANGELOG, LICENSE, ...), so a bundle
|
|
10
|
+
# kept in its own git repo doesn't install its README as a memory.
|
|
11
|
+
# A listed file is installed whatever its name: `chi bundle build` of
|
|
12
|
+
# a user's own security.md lists it, and it is a memory.
|
|
13
|
+
# files: {} means "no list" rather than "no memories": shipped bundles
|
|
14
|
+
# with nothing to install write it (mcp), and an external bundle that
|
|
15
|
+
# left the map empty installed its *.md before this rule.
|
|
16
|
+
module MemoryFiles
|
|
17
|
+
# A repo's meta files, by name without extension (case-insensitive).
|
|
18
|
+
REPO_META = %w[readme changelog license licence contributing code_of_conduct security].freeze
|
|
19
|
+
META_EXTENSIONS = %w[.md .markdown .txt].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] the memories' basenames, sorted
|
|
24
|
+
def select(bundle_dir, manifest)
|
|
25
|
+
present = Dir.glob(File.join(bundle_dir, "*.md")).map { |f| File.basename(f) }.sort
|
|
26
|
+
listed = manifest&.files&.keys
|
|
27
|
+
return present & listed if listed && !listed.empty?
|
|
28
|
+
|
|
29
|
+
present.reject { |name| repo_meta?(name) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The files: entries that name no top-level *.md of the bundle.
|
|
33
|
+
def unshipped(bundle_dir, manifest)
|
|
34
|
+
listed = manifest&.files&.keys || []
|
|
35
|
+
listed.reject { |name| name.end_with?(".md") && File.file?(File.join(bundle_dir, name)) && !name.include?("/") }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether +name+ is a repo's meta file: README, README.md,
|
|
39
|
+
# readme.markdown, LICENSE.txt, ...
|
|
40
|
+
def repo_meta?(name)
|
|
41
|
+
base = name.downcase
|
|
42
|
+
ext = META_EXTENSIONS.find { |e| base.end_with?(e) }
|
|
43
|
+
REPO_META.include?(ext ? base.delete_suffix(ext) : base)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
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
|
@@ -91,7 +91,7 @@ module Samagotchi
|
|
|
91
91
|
# one): see #delegate?.
|
|
92
92
|
attr_writer :delegate
|
|
93
93
|
# The session this one continues (SessionManager.continue_session): the
|
|
94
|
-
# previous link of a chain, "the next
|
|
94
|
+
# previous link of a chain, "the next run of the same routine", else
|
|
95
95
|
# nil. Not a parent: archiving one link leaves the next alone. The chain
|
|
96
96
|
# itself is derived (SessionChain), never stored.
|
|
97
97
|
attr_accessor :continues
|